diff --git a/typescript/.changeset/add-payperbyte-action-provider.md b/typescript/.changeset/add-payperbyte-action-provider.md new file mode 100644 index 000000000..b41f9ac50 --- /dev/null +++ b/typescript/.changeset/add-payperbyte-action-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added a standalone PayPerByte action provider: `payperbyte_list_feeds` (free catalog listing), `payperbyte_query_feed` (x402-paid feed query on Base, with a spend cap checked before payment), and `payperbyte_verify_attestation` (offline, fail-closed verification of the BYTE Library EIP-712 attestation each response carries). diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..769164fad 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -23,6 +23,7 @@ export * from "./pyth"; export * from "./moonwell"; export * from "./morpho"; export * from "./opensea"; +export * from "./payperbyte"; export * from "./spl"; export * from "./superfluid"; export * from "./sushi"; diff --git a/typescript/agentkit/src/action-providers/payperbyte/README.md b/typescript/agentkit/src/action-providers/payperbyte/README.md new file mode 100644 index 000000000..67d3b06ec --- /dev/null +++ b/typescript/agentkit/src/action-providers/payperbyte/README.md @@ -0,0 +1,122 @@ +# PayPerByte Action Provider + +This provider integrates [PayPerByte](https://x402.payperbyte.io) data feeds into AgentKit, +letting an agent list the feed catalog, pay for a feed with USDC on Base via x402, and +offline-verify the cryptographic receipt each response carries. + +## Overview + +PayPerByte publishes small, single-purpose data feeds (weather, earthquakes, security advisory +digests, and similar) behind the [x402 payment protocol](https://www.x402.org/), paid in USDC on +Base. Every response carries an `X-BYTE-Attestation` header: an EIP-712 signature over the exact +response bytes, under the "BYTE Library" domain. + +**Scope, stated plainly:** verification here proves *authenticity and tamper-evidence* — that the +claimed publisher signed exactly the bytes you received. It is evidence toward authenticity, never +a certification, and it says nothing about whether the underlying data is *correct*. + +This provider is standalone: it does not depend on, or need to be registered with, the built-in +`x402ActionProvider` — it wires its own x402 payment client directly, the same way +`dtelecomActionProvider` does for its own paid endpoints. + +## Setup + +No configuration required — the provider uses the default gateway URL +(`https://x402.payperbyte.io`) and a default USDC spend cap of 1.0 per query, matching the +built-in x402 action provider's default. + +```typescript +import { AgentKit } from "@coinbase/agentkit"; +import { payperbyteActionProvider } from "@coinbase/agentkit"; + +const agentkit = await AgentKit.from({ + walletProvider, + actionProviders: [payperbyteActionProvider()], +}); +``` + +Optional configuration: + +```typescript +payperbyteActionProvider({ + maxPaymentUsdc: 0.5, // refuse to pay more than $0.50 for a single feed query + baseUrl: "https://x402.payperbyte.io", // override for testing + trustedPublishers: ["0x..."], // optional: gate `verified` on the signer being on this list + attestationDomain: { chainId: 1, verifyingContract: "0x..." }, // CONSENSUS-CRITICAL migration override — see below +}); +``` + +## Actions + +| Action | Description | +|--------|-------------| +| `payperbyte_list_feeds` | Free, unauthenticated GET of the feed catalog — ids, descriptions, USDC prices. No payment. | +| `payperbyte_query_feed` | Pays for one feed via x402 (USDC on Base). Checks the catalog price against `maxPaymentUsdc` *before* attempting payment — refuses without paying if it exceeds the cap. Returns the response body and its `X-BYTE-Attestation` header verbatim. | +| `payperbyte_verify_attestation` | Offline verification of an `X-BYTE-Attestation` receipt against the exact body it covers. Makes no network call. | + +## Verification: how it works, and what it does and doesn't prove + +`payperbyte_verify_attestation` takes the exact response body string and the parsed attestation +object (both returned verbatim by `payperbyte_query_feed`) and: + +1. **Pins the EIP-712 domain to all four fields** (name `"BYTE Library"`, version `"1"`, + chainId `421614`, verifyingContract `0x44729bB148F46d8Db509E47b0453edc271e06e95` by default) + and rejects a mismatch on any of them *before* recovery ever runs. The domain is never taken + from the attestation's own claimed `domain` object — doing so would let a self-consistent + forged attestation (signed and claimed under a domain of the attacker's own choosing, with + `publisher` set to their own address) pass a naive "recovered === publisher" check without + ever touching the real domain. +2. Recomputes `keccak256(utf8(body))` and checks it against the attestation's `payloadHash` and + `payloadLength`. +3. Recovers the EIP-712 signer under the pinned domain and checks it matches the claimed + `publisher` field. +4. If `trustedPublishers` is configured, additionally checks the recovered signer is on that + list. If not configured, `verified` does not depend on *who* signed it — only that a key + signed the exact bytes under the real domain. Either way, the result always includes + `recoveredSigner` and a `publisherTrusted` field (`true`/`false` when the list is configured, + `null` — with a note that policy is the caller's — when it is not). +5. Checks the attestation's `deadline` has not passed. + +It **fails closed**: any domain mismatch, hash mismatch, signature-recovery failure, publisher +mismatch, untrusted signer, expired deadline, or malformed input returns `{verified: false, +reason: "..."}` — never a throw, and never a pass on ambiguous input. Input is `safeParse`'d +against the schema explicitly inside the action (not just relied on the caller's own validation), +and the whole action body is wrapped in try/catch as a backstop. + +This proves the exact bytes you received were signed by a key under the real BYTE Library domain +at signing time — and, if you configure `trustedPublishers`, that the key is one you've chosen to +trust. It does **not** prove the data itself is accurate, current, or fit for any purpose — that +is a separate question the attestation makes no claim about. + +### Migrating the attestation domain + +`attestationDomain` exists only for a future, deliberate, coordinated migration of the BYTE +Library domain's `chainId`/`verifyingContract` (for example, moving off a testnet). The domain +**name** (`"BYTE Library"`) and **version** (`"1"`) are never overridable through this config — +only chainId and verifyingContract can change, and only when you specifically intend a migration. +Setting this incorrectly silently changes which signatures verification will accept; leave it +unset unless you know you need it. + +### Attestation domain vs. payment rail + +The `X-BYTE-Attestation` domain is anchored to `chainId 421614` (Arbitrum Sepolia) and +`verifyingContract 0x44729bB148F46d8Db509E47b0453edc271e06e95` — this is fixed and is **not** the +network payment settles on. Payment for PayPerByte feeds settles in USDC on **Base** +(`base-mainnet` / `base-sepolia`, `eip155:8453`). The attestation domain and the payment rail are +deliberately decoupled: the attestation is a standing cryptographic commitment anchored on one +chain, independent of which chain a given purchase happens to settle on. Both this provider's +`supportsNetwork` (Base only) and the attestation domain's chainId (always 421614) are correct +as written — they answer different questions. + +## Network Support + +`payperbyte_query_feed` requires an `EvmWalletProvider` on `base-mainnet` or `base-sepolia`. +`payperbyte_list_feeds` and `payperbyte_verify_attestation` make no payment and work with any +wallet provider (or none, for verification, since it takes its input as plain arguments). + +## Dependencies + +- [`viem`](https://viem.sh) — EIP-712 hashing and typed-data signature recovery (already an + AgentKit dependency; no new dependency added for verification). +- `@x402/fetch`, `@x402/evm` — x402 payment protocol client, same libraries the built-in + `x402ActionProvider` uses. diff --git a/typescript/agentkit/src/action-providers/payperbyte/constants.ts b/typescript/agentkit/src/action-providers/payperbyte/constants.ts new file mode 100644 index 000000000..0625366e6 --- /dev/null +++ b/typescript/agentkit/src/action-providers/payperbyte/constants.ts @@ -0,0 +1,67 @@ +/** + * BYTE Library PayloadAttestation — EIP-712 domain and struct, matching the PayPerByte + * gateway's X-BYTE-Attestation format exactly. + * + * INTEROP CONTRACT: ALL FOUR domain fields (name, version, chainId, verifyingContract) are + * consensus-critical and are pinned to the constants below during verification — never taken + * from the attestation's own claimed `domain` object. EIP-712 domain separation is the entire + * security mechanism of a typed-data signature: if a verifier lets the signed data supply its + * own domain, an attacker can sign under ANY domain with their own key, set `publisher` to + * their own address, and pass a naive "recovered === publisher" check — a self-referential + * forgery that never touches the real BYTE Library domain at all. `PayperbyteConfig.attestationDomain` + * exists ONLY for a future coordinated migration of chainId/verifyingContract; the domain name + * and version are never overridable, by design. + * + * The attestation domain stays anchored on chainId 421614 (Arbitrum Sepolia) regardless of + * which network the underlying x402 payment settles on (Base) — attestation domain and payment + * rail are deliberately decoupled; see the "attestation domain vs. payment rail" note in the + * provider README. + */ +export const BYTE_ATTESTATION_DOMAIN_NAME = "BYTE Library" as const; +export const BYTE_ATTESTATION_DOMAIN_VERSION = "1" as const; +export const BYTE_ATTESTATION_CHAIN_ID = 421614; +export const BYTE_ATTESTATION_VERIFYING_CONTRACT = + "0x44729bB148F46d8Db509E47b0453edc271e06e95" as const; + +/** + * The pinned, trusted attestation domain — used for verification unless + * `PayperbyteConfig.attestationDomain` overrides chainId/verifyingContract (name and version + * are never overridable). See the INTEROP CONTRACT note above for why this must never be built + * from an attestation's own claimed `domain` field. + */ +export const PINNED_ATTESTATION_DOMAIN = { + name: BYTE_ATTESTATION_DOMAIN_NAME, + version: BYTE_ATTESTATION_DOMAIN_VERSION, + chainId: BYTE_ATTESTATION_CHAIN_ID, + verifyingContract: BYTE_ATTESTATION_VERIFYING_CONTRACT, +} as const; + +/** The EIP-712 struct — identical across contract, gateway, MCP server, and SDK. */ +export const PAYLOAD_ATTESTATION_TYPES = { + PayloadAttestation: [ + { name: "publisher", type: "address" }, + { name: "payloadHash", type: "bytes32" }, + { name: "payloadLength", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], +} as const; + +export const DEFAULT_BASE_URL = "https://x402.payperbyte.io"; +export const DEFAULT_MAX_PAYMENT_USDC = 1.0; + +/** Only Base networks are supported — PayPerByte feeds settle in USDC on Base. */ +export const SUPPORTED_NETWORKS = ["base-mainnet", "base-sepolia"] as const; + +/** + * CAIP-2 network id -> canonical USDC contract address, for the x402 client's payment + * policy (which only sees the raw x402 protocol network string, e.g. "eip155:8453", not the + * AgentKit wallet-provider network id "base-mainnet"). CAIP-2 strings match + * `NETWORK_MAPPINGS` in the built-in x402 provider's constants.ts. Addresses are the same + * `TOKEN_ADDRESSES_BY_SYMBOLS[...].USDC` entries the rest of AgentKit uses (imported directly + * in payperbyteActionProvider.ts, not duplicated here) — this map only pins the network-id + * translation. + */ +export const USDC_BY_CAIP2_NETWORK: Record = { + "eip155:8453": "base-mainnet", + "eip155:84532": "base-sepolia", +}; diff --git a/typescript/agentkit/src/action-providers/payperbyte/index.ts b/typescript/agentkit/src/action-providers/payperbyte/index.ts new file mode 100644 index 000000000..3965810d9 --- /dev/null +++ b/typescript/agentkit/src/action-providers/payperbyte/index.ts @@ -0,0 +1,2 @@ +export { PayperbyteActionProvider, payperbyteActionProvider } from "./payperbyteActionProvider"; +export * from "./schemas"; diff --git a/typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.test.ts b/typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.test.ts new file mode 100644 index 000000000..78e7f914a --- /dev/null +++ b/typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.test.ts @@ -0,0 +1,733 @@ +import { payperbyteActionProvider } from "./payperbyteActionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { keccak256 } from "viem"; +import { + BYTE_ATTESTATION_DOMAIN_NAME, + BYTE_ATTESTATION_CHAIN_ID, + BYTE_ATTESTATION_VERIFYING_CONTRACT, + PAYLOAD_ATTESTATION_TYPES, +} from "./constants"; +import { REAL_FIXTURE_BODY, REAL_FIXTURE_ATTESTATION } from "./realFixture.test-data"; +import type { PaymentPolicy, PaymentRequirements } from "@x402/fetch"; + +// Mock @x402/fetch and @x402/evm so no real payment logic runs. The x402Client mock has a real +// registerPolicy() that captures whatever policy this provider registers, so tests can invoke +// that captured policy directly against synthetic PaymentRequirements and assert the filter +// result -- proving the cap is enforced against the server's actual 402 quote, not just the +// catalog's advertised price. +const mockWrapFetchWithPayment = jest.fn(); +let capturedPolicy: PaymentPolicy | null = null; +const mockX402ClientInstance = { + registerPolicy: jest.fn((policy: PaymentPolicy) => { + capturedPolicy = policy; + return mockX402ClientInstance; + }), +}; +jest.mock("@x402/fetch", () => ({ + x402Client: jest.fn().mockImplementation(() => mockX402ClientInstance), + wrapFetchWithPayment: (...args: unknown[]) => mockWrapFetchWithPayment(...args), +})); +const mockRegisterExactEvmScheme = jest.fn(); +jest.mock("@x402/evm/exact/client", () => ({ + registerExactEvmScheme: (...args: unknown[]) => mockRegisterExactEvmScheme(...args), +})); + +// Mock fetch globally to prevent any actual network requests. +global.fetch = jest.fn(); + +// A representative subset of the real catalog shape, captured via one read-only curl against +// https://x402.payperbyte.io/feeds (2026-08-20) — not fabricated field names. +const MOCK_CATALOG = { + protocol: "PayPerByte x402 Gateway", + version: "0.3.0", + networks: ["eip155:8453"], + facilitator: "https://api.cdp.coinbase.com/platform/v2/x402", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + feeds: [ + { + id: "weather", + name: "Weather (US, multi-city)", + description: "NWS weather forecasts for 5 US cities (NYC, LA, Chicago, Houston, Miami)", + updateFrequency: "3600s", + provenance: "eip712-attested", + endpoint: "/feeds/weather", + expectedSizeBytes: 4400, + priceAtomic: "5000", + price: "$0.0050", + publisher: "0xa820763c023a929e83c59e4fd5a623e5a8efe941", + disclaimerCategory: "general", + method: ["GET"], + }, + { + id: "threat-intel", + name: "Security Advisories Digest", + description: "Recent CVE highlights + CISA known-exploited-vulnerability entries", + updateFrequency: "3600s", + provenance: "eip712-attested", + endpoint: "/feeds/threat-intel", + expectedSizeBytes: 5300, + priceAtomic: "50000", + price: "$0.050", + publisher: "0xb90b00f891dc534a5b59c60170661b868f3c26de", + disclaimerCategory: "general", + method: ["GET", "POST"], + }, + ], +}; + +/** Mocks the next global.fetch call to resolve with the representative catalog fixture. */ +function mockCatalogFetch() { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => MOCK_CATALOG, + }); +} + +describe("PayperbyteActionProvider", () => { + const provider = payperbyteActionProvider(); + + const mockWallet = { + getAddress: jest.fn().mockReturnValue("0x1234567890abcdef1234567890abcdef12345678"), + getNetwork: jest.fn().mockReturnValue({ + protocolFamily: "evm", + networkId: "base-mainnet", + chainId: "8453", + }), + getName: jest.fn().mockReturnValue("test-wallet"), + toSigner: jest.fn().mockReturnValue({ + address: "0x1234567890abcdef1234567890abcdef12345678", + signMessage: jest.fn(), + signTypedData: jest.fn(), + }), + } as unknown as EvmWalletProvider; + Object.setPrototypeOf(mockWallet, EvmWalletProvider.prototype); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("supportsNetwork", () => { + it("supports base-mainnet", () => { + expect(provider.supportsNetwork({ networkId: "base-mainnet" } as never)).toBe(true); + }); + it("supports base-sepolia", () => { + expect(provider.supportsNetwork({ networkId: "base-sepolia" } as never)).toBe(true); + }); + it("does not support other networks", () => { + expect(provider.supportsNetwork({ networkId: "solana-mainnet" } as never)).toBe(false); + expect(provider.supportsNetwork({ networkId: "ethereum-mainnet" } as never)).toBe(false); + }); + }); + + describe("payperbyte_list_feeds", () => { + it("lists feeds with USDC prices computed from priceAtomic", async () => { + mockCatalogFetch(); + + const result = await provider.listFeeds(mockWallet, {}); + const parsed = JSON.parse(result); + + expect(parsed.success).toBe(true); + expect(parsed.feeds).toHaveLength(2); + expect(parsed.feeds[0].id).toBe("weather"); + expect(parsed.feeds[0].priceUsdc).toBeCloseTo(0.005, 6); + expect(parsed.feeds[1].priceUsdc).toBeCloseTo(0.05, 6); + expect(global.fetch).toHaveBeenCalledWith( + "https://x402.payperbyte.io/feeds", + expect.objectContaining({ headers: { Accept: "application/json" } }), + ); + // No payment path touched for a free catalog listing. + expect(mockWrapFetchWithPayment).not.toHaveBeenCalled(); + }); + + it("returns an error JSON if the catalog fetch fails, without throwing", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Unavailable", + }); + + const result = await provider.listFeeds(mockWallet, {}); + const parsed = JSON.parse(result); + expect(parsed.error).toBe(true); + }); + }); + + describe("payperbyte_query_feed", () => { + it("pays for and returns a feed, passing body and attestation through verbatim", async () => { + mockCatalogFetch(); + const responseBody = '{"answer":"72F, sunny"}'; + const attestationHeader = JSON.stringify({ + alg: "EIP712-PayloadAttestation", + domain: { + name: "BYTE Library", + version: "1", + chainId: 421614, + verifyingContract: "0x44729bB148F46d8Db509E47b0453edc271e06e95", + }, + publisher: "0xa820763c023a929e83c59e4fd5a623e5a8efe941", + payloadHash: keccak256(new TextEncoder().encode(responseBody)), + payloadLength: responseBody.length, + deadline: Math.floor(Date.now() / 1000) + 300, + signature: "0xdeadbeef", + }); + const mockPaidFetch = jest.fn().mockResolvedValueOnce({ + status: 200, + text: async () => responseBody, + headers: { + get: (name: string) => (name === "x-byte-attestation" ? attestationHeader : null), + }, + }); + mockWrapFetchWithPayment.mockReturnValueOnce(mockPaidFetch); + + const result = await provider.queryFeed(mockWallet, { feedId: "weather", queryParams: null }); + const parsed = JSON.parse(result); + + expect(parsed.success).toBe(true); + expect(parsed.body).toBe(responseBody); + expect(parsed.attestation.publisher).toBe("0xa820763c023a929e83c59e4fd5a623e5a8efe941"); + expect(mockPaidFetch).toHaveBeenCalledWith( + "https://x402.payperbyte.io/feeds/weather", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("refuses without paying when the feed price exceeds maxPaymentUsdc", async () => { + const cappedProvider = payperbyteActionProvider({ maxPaymentUsdc: 0.01 }); + mockCatalogFetch(); + + const result = await cappedProvider.queryFeed(mockWallet, { + feedId: "threat-intel", + queryParams: null, + }); + const parsed = JSON.parse(result); + + expect(parsed.error).toBe(true); + expect(parsed.message).toContain("spend cap"); + expect(mockWrapFetchWithPayment).not.toHaveBeenCalled(); + }); + + it("errors on an unknown feed id, without attempting payment", async () => { + mockCatalogFetch(); + const result = await provider.queryFeed(mockWallet, { + feedId: "does-not-exist", + queryParams: null, + }); + const parsed = JSON.parse(result); + + expect(parsed.error).toBe(true); + expect(parsed.availableFeedIds).toEqual(["weather", "threat-intel"]); + expect(mockWrapFetchWithPayment).not.toHaveBeenCalled(); + }); + + it("errors on a non-EvmWalletProvider without attempting payment", async () => { + const nonEvmWallet = {} as unknown as EvmWalletProvider; // no prototype chain -> not instanceof EvmWalletProvider + const result = await provider.queryFeed(nonEvmWallet, { + feedId: "weather", + queryParams: null, + }); + const parsed = JSON.parse(result); + + expect(parsed.error).toBe(true); + expect(parsed.message).toBe("Unsupported wallet provider"); + expect(mockWrapFetchWithPayment).not.toHaveBeenCalled(); + }); + + it("errors on an unsupported network without attempting payment", async () => { + const wrongNetworkWallet = { + ...mockWallet, + getNetwork: jest + .fn() + .mockReturnValue({ protocolFamily: "evm", networkId: "ethereum-mainnet" }), + } as unknown as EvmWalletProvider; + Object.setPrototypeOf(wrongNetworkWallet, EvmWalletProvider.prototype); + + const result = await provider.queryFeed(wrongNetworkWallet, { + feedId: "weather", + queryParams: null, + }); + const parsed = JSON.parse(result); + + expect(parsed.error).toBe(true); + expect(parsed.message).toBe("Unsupported network"); + expect(mockWrapFetchWithPayment).not.toHaveBeenCalled(); + }); + + it("restricts the payment scheme itself to Base networks, not the eip155:* default", async () => { + mockCatalogFetch(); + const mockPaidFetch = jest.fn().mockResolvedValueOnce({ + status: 200, + text: async () => "{}", + headers: { get: () => null }, + }); + mockWrapFetchWithPayment.mockReturnValueOnce(mockPaidFetch); + + await provider.queryFeed(mockWallet, { feedId: "weather", queryParams: null }); + + expect(mockRegisterExactEvmScheme).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ networks: ["eip155:8453", "eip155:84532"] }), + ); + }); + + it("classifies a policy-rejection throw from @x402/core into a clean error, not a raw throw", async () => { + mockCatalogFetch(); + mockWrapFetchWithPayment.mockReturnValueOnce( + jest + .fn() + .mockRejectedValueOnce( + new Error("All payment requirements were filtered out by policies for x402 version: 2"), + ), + ); + + const result = await provider.queryFeed(mockWallet, { feedId: "weather", queryParams: null }); + const parsed = JSON.parse(result); + + expect(parsed.error).toBe(true); + expect(parsed.noPaymentMade).toBe(true); + expect(parsed.message).toContain("402 quote exceeds cap"); + }); + }); + + describe("payment cap policy (protocol-level, enforced against the server's real 402 quote)", () => { + const USDC_BASE_MAINNET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + + /** + * Builds a synthetic PaymentRequirements object (a "402 quote"), defaulting to a valid + * Base-mainnet USDC option within the default cap. + * + * @param overrides - Fields to override on the default requirement. + * @returns A PaymentRequirements object for testing the cap policy directly. + */ + function makeRequirement(overrides: Partial = {}): PaymentRequirements { + return { + scheme: "exact", + network: "eip155:8453", + asset: USDC_BASE_MAINNET, + amount: "500000", // $0.50 at 6 decimals + payTo: "0x000000000000000000000000000000000000dEaD", + maxTimeoutSeconds: 60, + extra: {}, + ...overrides, + }; + } + + beforeEach(async () => { + // Trigger one successful queryFeed call purely to populate `capturedPolicy` via the + // mocked x402Client.registerPolicy -- the policy itself is then tested directly below + // against synthetic PaymentRequirements, independent of the mocked payment flow. + capturedPolicy = null; + mockCatalogFetch(); + const mockPaidFetch = jest.fn().mockResolvedValueOnce({ + status: 200, + text: async () => "{}", + headers: { get: () => null }, + }); + mockWrapFetchWithPayment.mockReturnValueOnce(mockPaidFetch); + await provider.queryFeed(mockWallet, { feedId: "weather", queryParams: null }); + expect(capturedPolicy).not.toBeNull(); + }); + + it("(a) filters out a 402 quote priced above the cap", () => { + const overCap = makeRequirement({ amount: "2000000" }); // $2.00 > default $1.00 cap + expect(capturedPolicy!(2, [overCap])).toEqual([]); + }); + + it("(b) keeps a 402 quote priced within the cap", () => { + const withinCap = makeRequirement({ amount: "500000" }); // $0.50 <= default $1.00 cap + expect(capturedPolicy!(2, [withinCap])).toEqual([withinCap]); + }); + + it("keeps a 402 quote priced at EXACTLY the cap", () => { + const atCap = makeRequirement({ amount: "1000000" }); // $1.00 == default $1.00 cap + expect(capturedPolicy!(2, [atCap])).toEqual([atCap]); + }); + + it("L-7: filters out a quote with a NEGATIVE amount, instead of keeping it (negative <= cap is true)", () => { + const negativeAmount = makeRequirement({ amount: "-1" }); + expect(capturedPolicy!(2, [negativeAmount])).toEqual([]); + }); + + it("(c) filters out a quote for a non-USDC asset", () => { + const notUsdc = makeRequirement({ asset: "0x1111111111166b7FE7bd91427724B487980aFc69" }); // ZORA, not USDC + expect(capturedPolicy!(2, [notUsdc])).toEqual([]); + }); + + it("(d) filters out a quote on a non-Base network", () => { + const ethereumMainnet = makeRequirement({ network: "eip155:1", asset: USDC_BASE_MAINNET }); + expect(capturedPolicy!(2, [ethereumMainnet])).toEqual([]); + }); + + it( + "M-9: a v1-shaped quote (maxAmountRequired, no amount field) within cap survives the " + + "filter instead of being dropped as NaN", + () => { + // PaymentRequirementsV1 carries the price as `maxAmountRequired`, not `amount` -- + // the declared PaymentRequirements type is v2-only, but real v1 quotes at runtime + // won't have `amount` set at all. + const v1Quote = makeRequirement({ amount: undefined as unknown as string }); + (v1Quote as PaymentRequirements & { maxAmountRequired?: string }).maxAmountRequired = + "500000"; // $0.50, within the default $1.00 cap + expect(capturedPolicy!(2, [v1Quote])).toEqual([v1Quote]); + }, + ); + + it("M-9 regression: without the fallback this would silently drop a v1 quote as NaN <= cap (always false)", () => { + const v1QuoteOverCap = makeRequirement({ amount: undefined as unknown as string }); + (v1QuoteOverCap as PaymentRequirements & { maxAmountRequired?: string }).maxAmountRequired = + "5000000"; // $5.00, OVER the default $1.00 cap -- must still be correctly filtered OUT + expect(capturedPolicy!(2, [v1QuoteOverCap])).toEqual([]); + }); + + it("mixed list: keeps only the within-cap Base/USDC option", () => { + const good = makeRequirement({ amount: "500000" }); + const tooExpensive = makeRequirement({ amount: "5000000" }); + const wrongAsset = makeRequirement({ asset: "0x1111111111166b7FE7bd91427724B487980aFc69" }); + const wrongNetwork = makeRequirement({ network: "eip155:1" }); + expect(capturedPolicy!(2, [tooExpensive, wrongAsset, wrongNetwork, good])).toEqual([good]); + }); + }); + + describe("payperbyte_verify_attestation", () => { + // Ephemeral throwaway key, generated fresh for this test run and never persisted anywhere. + const ephemeralPrivateKey = generatePrivateKey(); + const ephemeralAccount = privateKeyToAccount(ephemeralPrivateKey); + + /** + * Signs a sample body as a PayloadAttestation with the ephemeral test key, under a given + * domain (defaults to the real pinned BYTE Library domain). + * + * @param body - The exact string to hash and sign over. + * @param overrides - Optional publisher/deadline/domain overrides, for building negative test cases. + * @returns The attestation object, matching the X-BYTE-Attestation header shape. + */ + async function signSampleBody( + body: string, + overrides: Partial<{ + publisher: `0x${string}`; + deadline: number; + domainName: string; + domainVersion: string; + chainId: number; + verifyingContract: `0x${string}`; + }> = {}, + ) { + const bodyBytes = new TextEncoder().encode(body); + const payloadHash = keccak256(bodyBytes); + const payloadLength = bodyBytes.length; + const deadline = overrides.deadline ?? Math.floor(Date.now() / 1000) + 300; + const publisher = overrides.publisher ?? ephemeralAccount.address; + const domain = { + name: overrides.domainName ?? BYTE_ATTESTATION_DOMAIN_NAME, + version: overrides.domainVersion ?? "1", + chainId: overrides.chainId ?? BYTE_ATTESTATION_CHAIN_ID, + verifyingContract: overrides.verifyingContract ?? BYTE_ATTESTATION_VERIFYING_CONTRACT, + }; + + const signature = await ephemeralAccount.signTypedData({ + domain, + types: PAYLOAD_ATTESTATION_TYPES, + primaryType: "PayloadAttestation", + message: { + publisher, + payloadHash, + payloadLength: BigInt(payloadLength), + deadline: BigInt(deadline), + }, + }); + + return { + alg: "EIP712-PayloadAttestation", + domain, + publisher, + payloadHash, + payloadLength, + deadline, + signature, + }; + } + + it("POSITIVE: verifies a freshly-signed attestation over the exact body", async () => { + const body = '{"feed":"weather","data":{"tempF":72}}'; + const attestation = await signSampleBody(body); + + const result = await provider.verifyAttestation(mockWallet, { body, attestation }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(true); + expect(parsed.publisher).toBe(ephemeralAccount.address); + expect(parsed.recoveredSigner).toBe(ephemeralAccount.address); + // No trustedPublishers configured on the default `provider` -> policy is left to the caller. + expect(parsed.publisherTrusted).toBeNull(); + }); + + it("NEGATIVE (tampered body): a modified body produces a hash mismatch", async () => { + const originalBody = '{"feed":"weather","data":{"tempF":72}}'; + const attestation = await signSampleBody(originalBody); + const tamperedBody = '{"feed":"weather","data":{"tempF":999}}'; + + const result = await provider.verifyAttestation(mockWallet, { + body: tamperedBody, + attestation, + }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(false); + expect(parsed.reason).toContain("does not match the attested payloadHash"); + }); + + it("NEGATIVE (wrong signer): attestation.publisher claims an address that did not sign it", async () => { + const body = '{"feed":"weather","data":{"tempF":72}}'; + const wrongPublisher = "0x000000000000000000000000000000000000dEaD" as const; + // Sign for real with the ephemeral key, but claim a different publisher in the message — + // the recovered signer will not match the claimed publisher. + const attestation = await signSampleBody(body, { publisher: wrongPublisher }); + + const result = await provider.verifyAttestation(mockWallet, { body, attestation }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(false); + expect(parsed.reason).toContain("does not match the claimed publisher"); + expect(parsed.claimedPublisher).toBe(wrongPublisher); + }); + + it("NEGATIVE (expired deadline): a past deadline fails closed even though hash+signature check out", async () => { + const body = '{"feed":"weather","data":{"tempF":72}}'; + const pastDeadline = Math.floor(Date.now() / 1000) - 3600; // 1h in the past + const attestation = await signSampleBody(body, { deadline: pastDeadline }); + + const result = await provider.verifyAttestation(mockWallet, { body, attestation }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(false); + expect(parsed.hashMatch).toBe(true); + expect(parsed.signerMatch).toBe(true); + expect(parsed.expired).toBe(true); + }); + + it("NEGATIVE (wrong domain name): rejects immediately, never renamed away from 'BYTE Library'", async () => { + const body = '{"feed":"weather","data":{"tempF":72}}'; + const attestation = await signSampleBody(body, { domainName: "Not BYTE Library" }); + + const result = await provider.verifyAttestation(mockWallet, { body, attestation }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(false); + expect(parsed.reason).toContain("domain mismatch"); + expect(parsed.reason).toContain('"Not BYTE Library" != "BYTE Library"'); + }); + + it( + "BLOCKER regression (BL-2, forged domain): an attestation self-consistently signed and " + + "claimed under a DIFFERENT chainId/verifyingContract must NOT verify, even though its " + + "own hash/signature/publisher are all internally consistent", + async () => { + const body = '{"feed":"weather","data":{"tempF":72}}'; + // The attacker signs with THEIR OWN key, under a domain THEY chose (chainId 1, a + // different verifyingContract), and claims to be the publisher of their own signature. + // Every internal check (hash match, signer === claimed publisher) passes on its own + // terms — the only thing that can catch this is pinning the domain independently of + // what the attestation itself claims. + const forged = await signSampleBody(body, { + chainId: 1, + verifyingContract: "0x000000000000000000000000000000000000dEaD", + }); + + const result = await provider.verifyAttestation(mockWallet, { body, attestation: forged }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(false); + expect(parsed.reason).toContain("domain mismatch"); + expect(parsed.reason).toContain("chainId 1 != 421614"); + }, + ); + + it("attestationDomain config override: verification uses the CONFIGURED pinned domain, not the default", async () => { + const migratedProvider = payperbyteActionProvider({ + attestationDomain: { + chainId: 84532, + verifyingContract: "0x1111111111111111111111111111111111111111", + }, + }); + const body = '{"feed":"weather","data":{"tempF":72}}'; + // Signed under the NEW configured domain, not the hardcoded default. + const attestation = await signSampleBody(body, { + chainId: 84532, + verifyingContract: "0x1111111111111111111111111111111111111111", + }); + + const result = await migratedProvider.verifyAttestation(mockWallet, { body, attestation }); + const parsed = JSON.parse(result); + expect(parsed.verified).toBe(true); + + // The SAME attestation against the default (unmigrated) provider must fail domain pinning. + const defaultResult = await provider.verifyAttestation(mockWallet, { body, attestation }); + expect(JSON.parse(defaultResult).verified).toBe(false); + }); + + it("trustedPublishers: an allowlisted signer verifies with publisherTrusted:true", async () => { + const trustingProvider = payperbyteActionProvider({ + trustedPublishers: [ephemeralAccount.address], + }); + const body = '{"feed":"weather","data":{"tempF":72}}'; + const attestation = await signSampleBody(body); + + const result = await trustingProvider.verifyAttestation(mockWallet, { body, attestation }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(true); + expect(parsed.publisherTrusted).toBe(true); + }); + + it("trustedPublishers: a signer NOT on the allowlist fails verification even with a valid signature", async () => { + const trustingProvider = payperbyteActionProvider({ + trustedPublishers: ["0x000000000000000000000000000000000000dEaD"], // not the ephemeral signer + }); + const body = '{"feed":"weather","data":{"tempF":72}}'; + const attestation = await signSampleBody(body); + + const result = await trustingProvider.verifyAttestation(mockWallet, { body, attestation }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(false); + expect(parsed.publisherTrusted).toBe(false); + expect(parsed.reason).toContain("not in the configured trustedPublishers allowlist"); + }); + + it("M-1: malformed input (non-string payloadHash) fails closed instead of throwing", async () => { + const body = '{"feed":"weather","data":{"tempF":72}}'; + const attestation = await signSampleBody(body); + const malformed = { ...attestation, payloadHash: 12345 as unknown as string }; + + // This must not throw, even though TypeScript's static types say it can't happen — the + // point is defending a caller that bypasses the schema layer entirely (only the MCP + // adapter zod-parses; a direct/programmatic caller does not). + const result = await provider.verifyAttestation(mockWallet, { body, attestation: malformed }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(false); + expect(parsed.reason).toContain("invalid input"); + }); + }); + + describe("payperbyte_verify_attestation — real production fixture (2026-08-21 capture, sanctions-screen)", () => { + // REAL_FIXTURE_BODY / REAL_FIXTURE_ATTESTATION are a real X-BYTE-Attestation receipt + // captured from the live PayPerByte gateway, not synthesized — see realFixture.test-data.ts + // for provenance. This complements the ephemeral-key tests above with one genuine + // real-world positive vector, cross-checked against an independent implementation at + // capture time (see the header comment on realFixture.test-data.ts). + it("POSITIVE: verifies the real captured receipt, recovering the real gateway publisher", async () => { + // Sanity-check the fixture itself before trusting the provider's verdict about it. + const bodyBytes = new TextEncoder().encode(REAL_FIXTURE_BODY); + expect(bodyBytes.length).toBe(REAL_FIXTURE_ATTESTATION.payloadLength); + expect(keccak256(bodyBytes).toLowerCase()).toBe( + REAL_FIXTURE_ATTESTATION.payloadHash.toLowerCase(), + ); + + const result = await provider.verifyAttestation(mockWallet, { + body: REAL_FIXTURE_BODY, + attestation: REAL_FIXTURE_ATTESTATION, + }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(true); + expect(parsed.publisher).toBe(REAL_FIXTURE_ATTESTATION.publisher); + expect(parsed.recoveredSigner.toLowerCase()).toBe( + REAL_FIXTURE_ATTESTATION.publisher.toLowerCase(), + ); + expect(parsed.publisherTrusted).toBeNull(); + }); + + it("trustedPublishers: the real fixture's publisher on the allowlist verifies with publisherTrusted:true", async () => { + const trustingProvider = payperbyteActionProvider({ + trustedPublishers: [REAL_FIXTURE_ATTESTATION.publisher], + }); + + const result = await trustingProvider.verifyAttestation(mockWallet, { + body: REAL_FIXTURE_BODY, + attestation: REAL_FIXTURE_ATTESTATION, + }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(true); + expect(parsed.publisherTrusted).toBe(true); + }); + + it("trustedPublishers: a different address on the allowlist rejects the real fixture even though hash+signature check out", async () => { + const trustingProvider = payperbyteActionProvider({ + trustedPublishers: ["0x000000000000000000000000000000000000dEaD"], + }); + + const result = await trustingProvider.verifyAttestation(mockWallet, { + body: REAL_FIXTURE_BODY, + attestation: REAL_FIXTURE_ATTESTATION, + }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(false); + expect(parsed.hashMatch).toBe(true); + expect(parsed.signerMatch).toBe(true); + expect(parsed.publisherTrusted).toBe(false); + expect(parsed.reason).toContain("not in the configured trustedPublishers allowlist"); + }); + + it("NEGATIVE (tampered body): flipping a single byte of the real fixture body fails closed without throwing", async () => { + // Flip one character in the middle of the real body -- still valid-length UTF-8, so this + // exercises the hash-mismatch path specifically, not a length mismatch. + const midpoint = Math.floor(REAL_FIXTURE_BODY.length / 2); + const flippedChar = REAL_FIXTURE_BODY[midpoint] === "a" ? "b" : "a"; + const tamperedBody = + REAL_FIXTURE_BODY.slice(0, midpoint) + flippedChar + REAL_FIXTURE_BODY.slice(midpoint + 1); + expect(tamperedBody).not.toBe(REAL_FIXTURE_BODY); + + const result = await provider.verifyAttestation(mockWallet, { + body: tamperedBody, + attestation: REAL_FIXTURE_ATTESTATION, + }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(false); + expect(parsed.reason).toContain("does not match the attested payloadHash"); + }); + + it("NEGATIVE (tampered signature): corrupting the real fixture's signature fails closed without throwing", async () => { + const originalSig = REAL_FIXTURE_ATTESTATION.signature; + const tamperedSig = `0x${originalSig.slice(2, 4) === "00" ? "01" : "00"}${originalSig.slice(4)}`; + expect(tamperedSig).not.toBe(originalSig); + const tamperedAttestation = { ...REAL_FIXTURE_ATTESTATION, signature: tamperedSig }; + + const result = await provider.verifyAttestation(mockWallet, { + body: REAL_FIXTURE_BODY, + attestation: tamperedAttestation, + }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(false); + // A corrupted signature either fails recovery outright or recovers to a different address + // than the claimed publisher -- both are acceptable fail-closed outcomes here. + expect( + parsed.reason.includes("signature recovery failed") || + parsed.reason.includes("does not match the claimed publisher"), + ).toBe(true); + }); + + it("NEGATIVE (attestationDomain override to another chainId): the real fixture, signed under chainId 421614, is rejected against a provider pinned to a different chainId", async () => { + const migratedProvider = payperbyteActionProvider({ + attestationDomain: { + chainId: 84532, + verifyingContract: REAL_FIXTURE_ATTESTATION.domain.verifyingContract, + }, + }); + + const result = await migratedProvider.verifyAttestation(mockWallet, { + body: REAL_FIXTURE_BODY, + attestation: REAL_FIXTURE_ATTESTATION, + }); + const parsed = JSON.parse(result); + + expect(parsed.verified).toBe(false); + expect(parsed.reason).toContain("domain mismatch"); + expect(parsed.reason).toContain("chainId 421614 != 84532"); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.ts b/typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.ts new file mode 100644 index 000000000..30458bed2 --- /dev/null +++ b/typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.ts @@ -0,0 +1,713 @@ +import { z } from "zod"; +import { keccak256, recoverTypedDataAddress, type Hex } from "viem"; +import { ActionProvider } from "../actionProvider"; +import { Network } from "../../network"; +import { CreateAction } from "../actionDecorator"; +import { EvmWalletProvider, WalletProvider } from "../../wallet-providers"; +import { + x402Client, + wrapFetchWithPayment, + type PaymentPolicy, + type PaymentRequirements, +} from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; +import { TOKEN_ADDRESSES_BY_SYMBOLS } from "../erc20/constants"; +import { + ListFeedsSchema, + QueryFeedSchema, + VerifyAttestationSchema, + PayperbyteConfig, +} from "./schemas"; +import { + PAYLOAD_ATTESTATION_TYPES, + PINNED_ATTESTATION_DOMAIN, + DEFAULT_BASE_URL, + DEFAULT_MAX_PAYMENT_USDC, + SUPPORTED_NETWORKS, + USDC_BY_CAIP2_NETWORK, +} from "./constants"; + +interface AttestationDomain { + name: string; + version: string; + chainId: number; + verifyingContract: string; +} + +const USDC_DECIMALS = 6; + +interface FeedCatalogEntry { + id: string; + name: string; + description: string; + endpoint: string; + priceAtomic: string; + price: string; + publisher: string; + disclaimerCategory: string; + method: string[]; +} + +interface FeedCatalog { + protocol: string; + version: string; + networks: string[]; + facilitator: string; + asset: string; + feeds: FeedCatalogEntry[]; +} + +/** + * PayperbyteActionProvider provides actions for discovering and querying PayPerByte's x402 data + * feeds, and for offline-verifying the EIP-712 BYTE Library attestation each response carries. + * + * This provider is standalone: it does not depend on, or need to be registered with, the + * built-in X402ActionProvider — it wires its own x402 payment client directly, the same way + * DtelecomActionProvider does for its own paid endpoints. + * + * Scope, stated plainly: verification here proves authenticity and tamper-evidence of the exact + * bytes served — that the claimed publisher signed exactly this response body. It is evidence + * toward authenticity, not a certification, and it says nothing about whether the underlying + * data is correct. + */ +export class PayperbyteActionProvider extends ActionProvider { + private readonly baseUrl: string; + private readonly maxPaymentUsdc: number; + private readonly attestationDomain: AttestationDomain; + private readonly trustedPublishers: string[] | null; + + /** + * Creates a new instance of PayperbyteActionProvider. + * + * @param config - Optional configuration: baseUrl, maxPaymentUsdc spend cap, an + * attestationDomain migration override (consensus-critical, do not set casually), and a + * trustedPublishers allowlist for verification. + */ + constructor(config: PayperbyteConfig = {}) { + super("payperbyte", []); + this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; + this.maxPaymentUsdc = config.maxPaymentUsdc ?? DEFAULT_MAX_PAYMENT_USDC; + // Domain name and version are never overridable — only chainId/verifyingContract can be, + // and only for a deliberate, coordinated migration. See constants.ts's INTEROP CONTRACT note. + this.attestationDomain = { + name: PINNED_ATTESTATION_DOMAIN.name, + version: PINNED_ATTESTATION_DOMAIN.version, + chainId: config.attestationDomain?.chainId ?? PINNED_ATTESTATION_DOMAIN.chainId, + verifyingContract: + config.attestationDomain?.verifyingContract ?? PINNED_ATTESTATION_DOMAIN.verifyingContract, + }; + this.trustedPublishers = config.trustedPublishers + ? config.trustedPublishers.map(a => a.toLowerCase()) + : null; + } + + /** + * Lists the PayPerByte feed catalog. Free, unauthenticated — no payment, no wallet needed. + * + * @param _walletProvider - Unused but required by the action interface. + * @param _args - Empty arguments object. + * @returns A JSON string with the feed catalog (id, description, price, publisher per feed). + */ + @CreateAction({ + name: "payperbyte_list_feeds", + description: + "List the PayPerByte feed catalog: feed ids, descriptions, and USDC prices. Free, no " + + "payment made. Use payperbyte_query_feed with a feedId from this list to actually fetch data.", + schema: ListFeedsSchema, + }) + async listFeeds( + _walletProvider: WalletProvider, + _args: z.infer, + ): Promise { + try { + const catalog = await this.fetchCatalog(); + return JSON.stringify( + { + success: true, + protocol: catalog.protocol, + version: catalog.version, + networks: catalog.networks, + feeds: catalog.feeds.map(f => ({ + id: f.id, + name: f.name, + description: f.description, + priceUsdc: this.atomicToUsdc(f.priceAtomic), + publisher: f.publisher, + disclaimerCategory: f.disclaimerCategory, + })), + }, + null, + 2, + ); + } catch (error) { + return this.handleError(error, `${this.baseUrl}/feeds`); + } + } + + /** + * Queries one PayPerByte feed with an x402 payment (USDC on Base). Enforces maxPaymentUsdc: + * fetches the catalog first to check the feed's listed price before ever attempting payment, + * and refuses without paying if the price exceeds the configured cap. + * + * @param walletProvider - The wallet provider used to pay for the feed. + * @param args - feedId (from payperbyte_list_feeds) and optional query params. + * @returns A JSON string with the response body and its X-BYTE-Attestation header, verbatim. + */ + @CreateAction({ + name: "payperbyte_query_feed", + description: + "Query a PayPerByte feed by id, paying via x402 (USDC on Base). Refuses without paying if " + + "the feed's price exceeds the configured cap. Returns the response body and its " + + "X-BYTE-Attestation header verbatim — pass both to payperbyte_verify_attestation before " + + "acting on the data.", + schema: QueryFeedSchema, + }) + async queryFeed( + walletProvider: WalletProvider, + args: z.infer, + ): Promise { + try { + if (!(walletProvider instanceof EvmWalletProvider)) { + return JSON.stringify( + { + error: true, + message: "Unsupported wallet provider", + details: "payperbyte_query_feed requires an EvmWalletProvider on Base or Base Sepolia.", + }, + null, + 2, + ); + } + + const network = walletProvider.getNetwork(); + if (!this.supportsNetwork(network)) { + return JSON.stringify( + { + error: true, + message: "Unsupported network", + details: + `PayPerByte feeds are only available on ${SUPPORTED_NETWORKS.join(" or ")}; ` + + `the current wallet network is ${network.networkId}.`, + }, + null, + 2, + ); + } + + const catalog = await this.fetchCatalog(); + const feed = catalog.feeds.find(f => f.id === args.feedId); + if (!feed) { + return JSON.stringify( + { + error: true, + message: "Unknown feed id", + details: `"${args.feedId}" is not in the catalog. Call payperbyte_list_feeds for valid ids.`, + availableFeedIds: catalog.feeds.map(f => f.id), + }, + null, + 2, + ); + } + + const priceUsdc = this.atomicToUsdc(feed.priceAtomic); + if (priceUsdc > this.maxPaymentUsdc) { + return JSON.stringify( + { + error: true, + message: "Feed price exceeds the configured spend cap", + details: + `Feed "${feed.id}" costs $${priceUsdc.toFixed(4)} USDC, which exceeds the ` + + `configured maxPaymentUsdc cap of $${this.maxPaymentUsdc.toFixed(4)}. No payment was made.`, + feedId: feed.id, + priceUsdc, + maxPaymentUsdc: this.maxPaymentUsdc, + }, + null, + 2, + ); + } + + const client = this.createX402Client(walletProvider); + const fetchWithPayment = wrapFetchWithPayment(fetch, client); + + const url = this.buildFeedUrl(feed.endpoint, args.queryParams); + // The catalog price check above is a cheap first gate, but the CATALOG is not the + // authoritative price — the server's actual 402 challenge is. registerPolicy() (set up + // in createX402Client) enforces the real cap at the protocol level: it filters the + // server's offered PaymentRequirements down to Base-network, USDC, <=maxPaymentUsdc + // options, so a bare client can't be made to pay whatever a 402 response happens to + // quote. If every offered option gets filtered out, @x402/core throws a specific, + // identifiable error — caught and classified below rather than surfaced as a raw throw. + let response: Response; + try { + response = await fetchWithPayment(url, { method: "GET" }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes("filtered out by policies") || + message.includes("No network/scheme registered") + ) { + return JSON.stringify( + { + error: true, + message: "402 quote exceeds cap, or is not USDC on Base", + details: + `The server's 402 payment challenge for "${feed.id}" did not offer any option ` + + `that is USDC on Base (mainnet or Sepolia) within the maxPaymentUsdc cap of ` + + `$${this.maxPaymentUsdc.toFixed(4)}. No payment was made.`, + feedId: feed.id, + maxPaymentUsdc: this.maxPaymentUsdc, + noPaymentMade: true, + }, + null, + 2, + ); + } + throw error; + } + + // Read as text FIRST — the attestation hash is computed over the exact response + // bytes, so we must not JSON.parse-then-restringify (that can change whitespace + // and key order and silently break hash verification). + const body = await response.text(); + const attestationHeader = response.headers.get("x-byte-attestation"); + let attestation: unknown = null; + if (attestationHeader) { + try { + attestation = JSON.parse(attestationHeader); + } catch { + attestation = { raw: attestationHeader, parseError: true }; + } + } + + const paymentResponseHeader = + response.headers.get("payment-response") ?? response.headers.get("x-payment-response"); + let paymentProof: Record | null = null; + if (paymentResponseHeader) { + try { + paymentProof = JSON.parse(atob(paymentResponseHeader)); + } catch { + paymentProof = { raw: paymentResponseHeader }; + } + } + + if (response.status !== 200) { + return JSON.stringify( + { + success: false, + message: `Request failed with status ${response.status}. Payment was not settled.`, + feedId: feed.id, + url, + status: response.status, + body, + }, + null, + 2, + ); + } + + return JSON.stringify( + { + success: true, + feedId: feed.id, + url, + status: response.status, + body, + attestation, + paymentProof, + note: + "Call payperbyte_verify_attestation with this body and attestation before treating " + + "the data as authentic — this response has not been verified yet.", + }, + null, + 2, + ); + } catch (error) { + return this.handleError(error, `${this.baseUrl}/feeds/${args.feedId}`); + } + } + + /** + * Offline verification of a BYTE Library X-BYTE-Attestation receipt. Makes no network call. + * Fails closed: any mismatch, malformed input, or recovery error returns verified:false with a + * reason, never throws — even if called with unvalidated input that bypassed the schema layer + * (safeParse'd here explicitly, then the whole body is wrapped in try/catch as a backstop). + * + * SECURITY NOTE: recovery uses the PINNED attestationDomain (constants.ts / provider config), + * never the attestation's own claimed `domain` object. Recovering against an attacker-supplied + * domain would let a forged attestation "verify" against its own self-consistent-but-wrong + * domain — checked and rejected before recovery ever runs. + * + * @param _walletProvider - Unused but required by the action interface. + * @param args - The exact response body string and the parsed attestation object. + * @returns A JSON string verdict: {verified, reason, recoveredSigner, publisherTrusted, ...}. + */ + @CreateAction({ + name: "payperbyte_verify_attestation", + description: + "Offline-verify a BYTE Library X-BYTE-Attestation receipt against the exact response body " + + "it was computed over. Pins the EIP-712 domain to the real BYTE Library domain (rejects " + + "any attestation claiming a different domain, before recovery). Recomputes keccak256(body), " + + "checks it against the attested payloadHash and payloadLength, recovers the EIP-712 signer, " + + "and checks the attestation has not expired. Fails closed on any mismatch or malformed " + + "input. A valid result proves a key signed the exact bytes under the real domain — it does " + + "NOT by itself prove that key is a legitimate PayPerByte publisher unless trustedPublishers " + + "was configured; evidence toward authenticity and tamper-evidence, never a claim the data " + + "itself is correct.", + schema: VerifyAttestationSchema, + }) + async verifyAttestation(_walletProvider: WalletProvider, args: unknown): Promise { + try { + const parsed = VerifyAttestationSchema.safeParse(args); + if (!parsed.success) { + return JSON.stringify( + { + verified: false, + reason: `invalid input: ${parsed.error.message}`, + }, + null, + 2, + ); + } + const { body, attestation } = parsed.data; + + // Pin ALL FOUR domain fields to the trusted domain and reject any mismatch BEFORE + // recovery. Do not use attestation.domain for anything past this point. + const domain = this.attestationDomain; + const domainMismatches: string[] = []; + if (attestation.domain.name !== domain.name) { + domainMismatches.push(`name "${attestation.domain.name}" != "${domain.name}"`); + } + if (attestation.domain.version !== domain.version) { + domainMismatches.push(`version "${attestation.domain.version}" != "${domain.version}"`); + } + if (attestation.domain.chainId !== domain.chainId) { + domainMismatches.push(`chainId ${attestation.domain.chainId} != ${domain.chainId}`); + } + if ( + attestation.domain.verifyingContract.toLowerCase() !== + domain.verifyingContract.toLowerCase() + ) { + domainMismatches.push( + `verifyingContract "${attestation.domain.verifyingContract}" != "${domain.verifyingContract}"`, + ); + } + if (domainMismatches.length > 0) { + return JSON.stringify( + { + verified: false, + reason: + "domain mismatch — this attestation was not signed under the trusted BYTE Library " + + `domain: ${domainMismatches.join("; ")}`, + }, + null, + 2, + ); + } + + const bodyBytes = new TextEncoder().encode(body); + const recomputedHash = keccak256(bodyBytes); + + if (recomputedHash.toLowerCase() !== attestation.payloadHash.toLowerCase()) { + return JSON.stringify( + { + verified: false, + reason: + "recomputed keccak256(body) does not match the attested payloadHash — the body is " + + "not what was signed (tampered, truncated, or the wrong body was passed)", + recomputedHash, + attestedHash: attestation.payloadHash, + }, + null, + 2, + ); + } + + if (bodyBytes.length !== attestation.payloadLength) { + return JSON.stringify( + { + verified: false, + reason: + `body byte length ${bodyBytes.length} does not match the attested payloadLength ` + + `${attestation.payloadLength}`, + }, + null, + 2, + ); + } + + let recovered: `0x${string}`; + try { + recovered = await recoverTypedDataAddress({ + domain: { + name: domain.name, + version: domain.version, + chainId: domain.chainId, + verifyingContract: domain.verifyingContract as Hex, + }, + types: PAYLOAD_ATTESTATION_TYPES, + primaryType: "PayloadAttestation", + message: { + publisher: attestation.publisher as Hex, + payloadHash: attestation.payloadHash as Hex, + payloadLength: BigInt(attestation.payloadLength), + deadline: BigInt(attestation.deadline), + }, + signature: attestation.signature as Hex, + }); + } catch (error) { + return JSON.stringify( + { + verified: false, + reason: `signature recovery failed — malformed or invalid signature: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + null, + 2, + ); + } + + if (recovered.toLowerCase() !== attestation.publisher.toLowerCase()) { + return JSON.stringify( + { + verified: false, + reason: "the recovered signer does not match the claimed publisher field", + recoveredSigner: recovered, + claimedPublisher: attestation.publisher, + }, + null, + 2, + ); + } + + const publisherTrusted = this.trustedPublishers + ? this.trustedPublishers.includes(recovered.toLowerCase()) + : null; + const publisherTrustNote = + publisherTrusted === null + ? "No trustedPublishers list is configured: this proves a key signed the exact bytes " + + "under the real BYTE Library domain, not that the key belongs to a legitimate " + + "PayPerByte publisher. Check recoveredSigner yourself, or configure " + + "trustedPublishers to enforce an allowlist." + : undefined; + + if (publisherTrusted === false) { + return JSON.stringify( + { + verified: false, + hashMatch: true, + signerMatch: true, + recoveredSigner: recovered, + publisherTrusted: false, + reason: `recovered signer ${recovered} is not in the configured trustedPublishers allowlist`, + }, + null, + 2, + ); + } + + const nowS = Math.floor(Date.now() / 1000); + if (attestation.deadline <= nowS) { + return JSON.stringify( + { + verified: false, + hashMatch: true, + signerMatch: true, + expired: true, + recoveredSigner: recovered, + publisherTrusted, + reason: + `attestation deadline ${attestation.deadline} (unix-s) has passed (now ${nowS}) — ` + + "the hash and signature are valid, but this is a point-in-time record of what the " + + "publisher signed, not a standing claim about the present. Re-fetch before acting on it.", + }, + null, + 2, + ); + } + + return JSON.stringify( + { + verified: true, + publisher: attestation.publisher, + recoveredSigner: recovered, + publisherTrusted, + deadline: attestation.deadline, + reason: + "the recomputed hash matches the attested payloadHash, the EIP-712 signature recovers " + + "to the claimed publisher under the pinned BYTE Library domain, and the attestation " + + "has not expired" + + (publisherTrustNote ? `. ${publisherTrustNote}` : ""), + }, + null, + 2, + ); + } catch (error) { + return JSON.stringify( + { + verified: false, + reason: `unexpected error during verification: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + null, + 2, + ); + } + } + + /** + * Checks if the action provider supports the given network. Only Base mainnet and Base + * Sepolia — PayPerByte feeds settle in USDC on Base. + * + * @param network - The network to check support for. + * @returns True if the network is supported. + */ + supportsNetwork = (network: Network) => + (SUPPORTED_NETWORKS as readonly string[]).includes(network.networkId!); + + /** + * Fetches the free, unauthenticated feed catalog. + * + * @returns The parsed feed catalog. + */ + private async fetchCatalog(): Promise { + const response = await fetch(`${this.baseUrl}/feeds`, { + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch feed catalog: HTTP ${response.status} ${response.statusText}`, + ); + } + return (await response.json()) as FeedCatalog; + } + + /** + * Converts a USDC amount in atomic units (6 decimals) to a whole-unit number. + * + * @param priceAtomic - The price in atomic units, as returned by the catalog (e.g. "5000"). + * @returns The price in whole USDC (e.g. 0.005). + */ + private atomicToUsdc(priceAtomic: string): number { + return Number(priceAtomic) / 10 ** USDC_DECIMALS; + } + + /** + * Builds the full feed URL, appending optional query-string parameters. + * + * @param endpoint - The feed's endpoint path (or full URL) from the catalog. + * @param queryParams - Optional query-string parameters to append. + * @returns The full URL to request. + */ + private buildFeedUrl(endpoint: string, queryParams: Record | null): string { + const base = endpoint.startsWith("http") ? endpoint : `${this.baseUrl}${endpoint}`; + if (!queryParams || Object.keys(queryParams).length === 0) { + return base; + } + const url = new URL(base); + Object.entries(queryParams).forEach(([key, value]) => url.searchParams.append(key, value)); + return url.toString(); + } + + /** + * Creates an x402 client configured with the wallet's signer, for the exact-EVM payment + * scheme, restricted to Base networks with a policy that enforces the USDC + spend-cap check + * against the server's ACTUAL 402 quote (not just the catalog's advertised price — the + * catalog is a cheap pre-check, this is the protocol-level enforcement that can't be + * bypassed by a server quoting something different than the catalog). + * + * @param walletProvider - The EVM wallet provider to pay with. + * @returns A configured x402Client. + */ + private createX402Client(walletProvider: EvmWalletProvider): x402Client { + const client = new x402Client(); + const account = walletProvider.toSigner(); + const signer = { + ...account, + readContract: (args: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args?: readonly unknown[]; + }) => + walletProvider.readContract({ + address: args.address, + abi: args.abi as never, + functionName: args.functionName as never, + args: args.args as never, + }), + }; + // Restrict the scheme itself to Base networks (rather than the default eip155:* wildcard), + // so a non-Base 402 quote fails at scheme resolution instead of relying solely on the policy. + registerExactEvmScheme(client, { + signer, + networks: Object.keys(USDC_BY_CAIP2_NETWORK) as `${string}:${string}`[], + }); + client.registerPolicy(this.buildCapPolicy()); + return client; + } + + /** + * Builds the payment policy that enforces this provider's maxPaymentUsdc cap against the + * x402 client's real, protocol-level payment requirements (server's 402 quote), independent + * of and in addition to the catalog price pre-check in queryFeed. + * + * @returns A PaymentPolicy that filters out any requirement that isn't USDC on a supported + * Base network within the configured cap. + */ + private buildCapPolicy(): PaymentPolicy { + return (_x402Version: number, requirements: PaymentRequirements[]) => + requirements.filter(r => { + const networkId = USDC_BY_CAIP2_NETWORK[r.network]; + if (!networkId) { + return false; // not a supported Base network + } + const usdcAddress = TOKEN_ADDRESSES_BY_SYMBOLS[networkId]?.USDC; + if (!usdcAddress || r.asset.toLowerCase() !== usdcAddress.toLowerCase()) { + return false; // not USDC + } + // The declared PaymentRequirements type is v2-only (`amount`), but a v1-shaped 402 + // quote carries the price as `maxAmountRequired` instead (PaymentRequirementsV1 in + // @x402/core) -- fall back the same way the built-in x402 provider's + // validatePaymentLimit call site does, so v1 quotes aren't always filtered out as NaN. + const rWithV1Fields = r as PaymentRequirements & { + maxAmountRequired?: string; + price?: string; + }; + const amountStr = rWithV1Fields.maxAmountRequired ?? r.amount ?? rWithV1Fields.price; + if (!amountStr) { + return false; // no usable amount field at all + } + const usdc = this.atomicToUsdc(amountStr); + // Explicitly reject NaN/Infinity/negative amounts rather than relying on JS's + // "NaN <= cap is false" behavior alone -- a negative amount (e.g. "-1") would + // otherwise pass "negative <= cap" and be KEPT. + return Number.isFinite(usdc) && usdc >= 0 && usdc <= this.maxPaymentUsdc; + }); + } + + /** + * Formats a caught error into the provider's standard error JSON shape. + * + * @param error - The error to format. + * @param url - The URL that was being accessed when the error occurred. + * @returns A JSON string with the error details. + */ + private handleError(error: unknown, url: string): string { + const message = error instanceof Error ? error.message : String(error); + return JSON.stringify( + { + error: true, + message: `Error making request to ${url}`, + details: message, + }, + null, + 2, + ); + } +} + +export const payperbyteActionProvider = (config?: PayperbyteConfig) => + new PayperbyteActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/payperbyte/realFixture.test-data.ts b/typescript/agentkit/src/action-providers/payperbyte/realFixture.test-data.ts new file mode 100644 index 000000000..182a2af6d --- /dev/null +++ b/typescript/agentkit/src/action-providers/payperbyte/realFixture.test-data.ts @@ -0,0 +1,31 @@ +/** + * Real, valid X-BYTE-Attestation receipt captured from + * the live PayPerByte gateway (2026-08-21T03:55:08.648Z, https://x402.payperbyte.io/feeds/sanctions-screen), publisher + * 0xB48CCc9e3ab67041e3b5D09700138E45cda6AeA8 = the gateway delivery attester. + * + * Re-verified by regen_from_capture.mjs immediately before this file was generated: byte + * length, keccak256(body) == payloadHash, and EIP-712 signer recovery == publisher all + * checked true. Scanned for degraded-response markers (non-null error/timeout/null-field/ + * off-feed patterns) with zero matches. The body's documented + * `broadcast disabled (SANCTIONS_SCREEN_BROADCAST=0)` disabled-state note is whitelisted by the + * scanner as an intentional flag in a healthy response, not a degraded-response marker. + * + */ +export const REAL_FIXTURE_BODY = + '{"answer":{"v":"sanctions-screen/v1","ts":1787284507,"query":{"address":"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913","name":null,"chain":null},"verdict":"ALLOW","score":100,"reasons":["no match on the OFAC SDN list (19249 entries; list published 2026-08-20, fetched 2026-08-20T23:42:38Z, sha256 50213298d936901a\\u2026)","no match on the OFAC Consolidated (non-SDN) list (481 entries; list published 2026-08-20, fetched 2026-08-20T23:42:41Z, sha256 5a629469398539ac\\u2026)"],"signals":{"sdn":{"list_available":true,"address_hit":false,"address_matches":[],"name_exact_hit":false,"name_exact_matches":[],"name_fuzzy_hit":false,"name_fuzzy_matches":[],"list_state":{"source":"OFAC SDN (Specially Designated Nationals and Blocked Persons)","source_url":"https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN.CSV","published_date":"2026-08-20","fetched_at":"2026-08-20T23:42:38Z","content_sha256":"50213298d936901a1aaad7bb19c968dab9e82fa07e8c808aacfae8fcea3d870e","entry_count":19249,"age_days":0,"stale":false},"error":null},"consolidated":{"list_available":true,"address_hit":false,"address_matches":[],"name_exact_hit":false,"name_exact_matches":[],"name_fuzzy_hit":false,"name_fuzzy_matches":[],"list_state":{"source":"OFAC Consolidated (non-SDN) Sanctions List","source_url":"https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/CONS_PRIM.CSV","published_date":"2026-08-20","fetched_at":"2026-08-20T23:42:41Z","content_sha256":"5a629469398539aca2d180a086543e2161d1203fb2a3c9c737b1d682544df5b1","entry_count":481,"age_days":0,"stale":false},"error":null}},"list_state":{"sdn":{"source":"OFAC SDN (Specially Designated Nationals and Blocked Persons)","source_url":"https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN.CSV","published_date":"2026-08-20","fetched_at":"2026-08-20T23:42:38Z","content_sha256":"50213298d936901a1aaad7bb19c968dab9e82fa07e8c808aacfae8fcea3d870e","entry_count":19249,"age_days":0,"stale":false},"consolidated":{"source":"OFAC Consolidated (non-SDN) Sanctions List","source_url":"https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/CONS_PRIM.CSV","published_date":"2026-08-20","fetched_at":"2026-08-20T23:42:41Z","content_sha256":"5a629469398539aca2d180a086543e2161d1203fb2a3c9c737b1d682544df5b1","entry_count":481,"age_days":0,"stale":false}},"retrieved_at":"2026-08-21T03:55:07Z","methodology":"ss-v1","input_hashes":{"sdn":"0x9bcbbaa69c4040ffc3513afab8080366074718c49a18071fc2c9fd865af6f8d4","consolidated":"0xeee09b9059da1fb85d29fdc01bd7e7c6d3b8d76d3712a5cd7937e2bb66d0469b"},"source":"OFAC SDN + OFAC Consolidated (non-SDN) via sanctionslistservice.ofac.treas.gov (official Treasury exports)","error":null},"broadcast":{"ok":false,"tx":null,"delivered":0,"note":"broadcast disabled (SANCTIONS_SCREEN_BROADCAST=0)"},"attestation":{"payloadHash":"0xbe58daa362cf94a4b4d6dc90c8415c306c06d69eedb5f599a69e14e62cc79464","payloadLength":2720,"deadline":2102644507,"signer":"0x344ECaCDe6566294c31397445c98b62a3EEEA456","signature":"0xb63cf806e4d74bc8323de684f502ceda8c04e2e3bbc049dc9d631bd276214dac02dfbb22d624b28deff1359b857e5110247d4c2f7e147232f2fddaca0a084ed21b","domain":{"name":"BYTE Library","version":"1","chainId":421614,"verifyingContract":"0x44729bB148F46d8Db509E47b0453edc271e06e95"}}}\n'; + +export const REAL_FIXTURE_ATTESTATION = { + alg: "EIP712-PayloadAttestation", + domain: { + name: "BYTE Library", + version: "1", + chainId: 421614, + verifyingContract: "0x44729bB148F46d8Db509E47b0453edc271e06e95", + }, + publisher: "0xB48CCc9e3ab67041e3b5D09700138E45cda6AeA8", + payloadHash: "0xb14ef4b30838a2964800ace5f02f592834e14c695be5862b54b6ff8d2e1647d3", + payloadLength: 3312, + deadline: 2102644507, + signature: + "0x575399d1e3f8fdcfc5586c93be797951a83802b718621aa1e1d938dbf56f443434e1ec4cb18bead30bc4ea2582f587ee1797ea8580334bcd42d682ed5eea6cf11c", +}; diff --git a/typescript/agentkit/src/action-providers/payperbyte/schemas.ts b/typescript/agentkit/src/action-providers/payperbyte/schemas.ts new file mode 100644 index 000000000..2a1e8565a --- /dev/null +++ b/typescript/agentkit/src/action-providers/payperbyte/schemas.ts @@ -0,0 +1,113 @@ +import { z } from "zod"; + +// --- Config --- + +/** + * Configuration for PayperbyteActionProvider. + */ +export interface PayperbyteConfig { + /** + * Maximum USDC (whole units) this provider will spend on a single feed query. Defaults to + * 1.0, matching the default cap used by the built-in x402 action provider. + */ + maxPaymentUsdc?: number; + /** + * Base URL for the PayPerByte x402 gateway. Defaults to https://x402.payperbyte.io — override + * only for testing against a different deployment. + */ + baseUrl?: string; + /** + * CONSENSUS-CRITICAL override for the attestation's chainId/verifyingContract, for a future + * coordinated migration of the BYTE Library domain ONLY. Defaults to the pinned constants + * (chainId 421614, verifyingContract 0x44729bB148F46d8Db509E47b0453edc271e06e95). The domain + * NAME ("BYTE Library") and version ("1") are never overridable — do not set this unless you + * are deliberately migrating the domain and know the new values are correct; getting this + * wrong silently changes which signatures verification will accept. + */ + attestationDomain?: { + chainId: number; + verifyingContract: string; + }; + /** + * If set, `payperbyte_verify_attestation`'s `verified` additionally requires the recovered + * signer to be one of these addresses (case-insensitive). If unset, verification only proves + * that SOME key correctly signed the exact bytes under the pinned BYTE Library domain — it + * does not by itself prove that key belongs to a legitimate PayPerByte publisher. The result + * always includes `recoveredSigner` and `publisherTrusted` (true/false when this is set, null + * when it is not) so callers can apply their own publisher policy either way. + */ + trustedPublishers?: string[]; +} + +// --- Catalog (free) --- + +export const ListFeedsSchema = z + .object({}) + .describe( + "List the PayPerByte feed catalog: feed ids, descriptions, and USDC prices. This is a free, " + + "unauthenticated GET — no payment is made and no wallet is required.", + ); + +// --- Paid query --- + +export const QueryFeedSchema = z + .object({ + feedId: z + .string() + .min(1) + .describe( + "The id of the feed to query, from payperbyte_list_feeds (e.g. 'weather', 'earthquakes').", + ), + queryParams: z + .record(z.string(), z.string()) + .nullable() + .describe("Optional query-string parameters to append to the feed's GET request."), + }) + .describe( + "Query a PayPerByte feed. This makes an x402-paid GET request (USDC on Base) and returns the " + + "response body together with its X-BYTE-Attestation header verbatim, so the result can be " + + "checked with payperbyte_verify_attestation before being acted on.", + ); + +// --- Offline verification --- + +const AttestationDomainSchema = z.object({ + name: z.string(), + version: z.string(), + chainId: z.number(), + verifyingContract: z.string(), +}); + +export const AttestationSchema = z + .object({ + alg: z.string().nullable(), + domain: AttestationDomainSchema, + publisher: z.string(), + payloadHash: z.string(), + payloadLength: z.number(), + deadline: z.number(), + signature: z.string(), + }) + .describe( + "The parsed X-BYTE-Attestation header — pass it exactly as returned by payperbyte_query_feed " + + "(or as parsed JSON from that header on any other BYTE Library-attested response).", + ); + +export const VerifyAttestationSchema = z + .object({ + body: z + .string() + .describe( + "The exact response body string the attestation was computed over — verbatim, not " + + "re-serialized (whitespace and key order matter for the hash to match).", + ), + attestation: AttestationSchema, + }) + .describe( + "Offline verification of a BYTE Library X-BYTE-Attestation receipt: recomputes keccak256 of " + + "the exact body bytes, checks it against the attested payloadHash and payloadLength, and " + + "recovers the EIP-712 signer to confirm it matches the claimed publisher and has not expired. " + + "Makes no network call. This proves the exact bytes were signed by the claimed publisher — it " + + "is evidence toward authenticity and tamper-evidence, not a claim that the underlying data is " + + "correct.", + );