From a377a68f33335ccd9ba67168b6ebbfc861812910 Mon Sep 17 00:00:00 2001 From: Mark Zhurbin Date: Thu, 20 Aug 2026 17:03:26 -0400 Subject: [PATCH 1/2] feat: add standalone PayPerByte action provider Adds payperbyte_list_feeds (free catalog GET), payperbyte_query_feed (x402-paid GET on Base, USDC), and payperbyte_verify_attestation (offline, fail-closed verification of the BYTE Library EIP-712 PayloadAttestation receipt each response carries). Standalone: does not depend on or need to be registered with the built-in x402ActionProvider -- wires its own x402 payment client directly, the same pattern DtelecomActionProvider uses. Only base-mainnet and base-sepolia are supported. Spend cap is enforced at TWO layers, not one: 1. A cheap pre-check against the catalog's advertised price, before any payment logic runs. 2. A protocol-level x402Client.registerPolicy() that filters the server's ACTUAL 402 challenge down to USDC-on-Base options within maxPaymentUsdc. This is the layer that actually matters -- the catalog price is not authoritative, and a bare client would otherwise pay whatever a given 402 response happens to quote, regardless of what the catalog advertised. The exact-EVM scheme is also registered restricted to ['eip155:8453', 'eip155:84532'] rather than the library's default eip155:* wildcard. If the policy filters out every offered option, @x402/core throws a specific, identifiable error, caught and returned as a clean {error:true, noPaymentMade:true, ...} rather than a raw throw. USDC contract addresses are read from AgentKit's existing TOKEN_ADDRESSES_BY_SYMBOLS (erc20/constants), not hardcoded independently. The policy reads a quote's amount as maxAmountRequired ?? amount ?? price (the same fallback order the built-in x402 provider's validatePaymentLimit call site uses) -- the declared PaymentRequirements type is v2-only (amount), but a v1-shaped 402 quote carries the price as maxAmountRequired instead; reading amount alone would silently treat every v1 quote's price as NaN and filter it out regardless of its actual price. The cap comparison itself explicitly checks Number.isFinite(usdc) and usdc >= 0 before the <= maxPaymentUsdc check -- a naive <= cap alone would let a negative amount (e.g. "-1") through, since a negative number is always <= a positive cap. Attestation verification pins ALL FOUR EIP-712 domain fields (name, version, chainId, verifyingContract) to trusted constants and rejects any mismatch BEFORE recovery ever runs -- never taken from the attestation's own claimed domain object. Letting the signed data supply its own domain would let a self-consistent forged attestation (signed and claimed under any domain of an attacker's own choosing, with publisher set to their own address) pass a naive "recovered === publisher" check without ever touching the real domain -- EIP-712 domain separation is the entire security mechanism of a typed-data signature. A PayperbyteConfig.attestationDomain override exists only for a future coordinated migration of chainId/verifyingContract; domain name and version are never overridable. An optional PayperbyteConfig.trustedPublishers allowlist additionally gates the verified result on the recovered signer being on that list; the result always carries recoveredSigner and publisherTrusted (true/false when configured, null -- with a note that policy is the caller's -- when not). Input is safeParse'd against the schema explicitly inside the action (not only relied on the caller's own validation), and the whole action body is wrapped in try/catch as a backstop, so malformed input that bypasses schema validation fails closed instead of throwing. Verification uses viem (already a dependency) inline -- no new dependency added. Recomputes keccak256(utf8(body)), checks it against the attested payloadHash/payloadLength, recovers the EIP-712 signer via recoverTypedDataAddress under the pinned domain, and checks the deadline has not passed. Tests: 31 cases, all against mocked fetch/wallet (no network, no keys). The paid-query and catalog paths mock @x402/fetch and global fetch; the mocked x402Client's registerPolicy() captures the real policy this provider registers, so 9 tests invoke it directly against synthetic PaymentRequirements to prove the cap-enforcement logic itself (over-cap, within-cap, exactly-at-cap boundary, negative amount, non-USDC, non-Base-network, a v1-shaped quote within cap that must survive the maxAmountRequired fallback, a v1-shaped quote over cap that must still be correctly filtered out, and a mixed list). A separate test confirms the exact-EVM scheme is registered network-restricted, and another confirms a raw @x402/core policy-rejection throw is classified into a clean error response. The verify-attestation path uses a real cryptographic flow against a freshly-generated, ephemeral, never-persisted viem key: positive, tampered-body, wrong-signer, expired-deadline, and wrong-domain-name cases, PLUS a forged-domain regression test (a self-consistent attestation signed and claimed under a different chainId/contract, confirming it is rejected rather than passing on a naive signer-equals-publisher check), an attestationDomain config-override test, two trustedPublishers allowlist tests, and a malformed-input (non-string field) test proving the action fails closed rather than throwing. README documents scope plainly: authenticity + tamper-evidence of the exact bytes served, not a certification, not a claim the underlying data is correct, and (absent trustedPublishers) not by itself a claim about who signed it. Also documents that the attestation domain (anchored on chainId 421614) and the payment settlement network (Base) are deliberately decoupled, and how the attestationDomain migration override works. Added a changeset. Signed-off-by: 0rkz --- .../add-payperbyte-action-provider.md | 5 + .../agentkit/src/action-providers/index.ts | 1 + .../src/action-providers/payperbyte/README.md | 122 +++ .../action-providers/payperbyte/constants.ts | 67 ++ .../src/action-providers/payperbyte/index.ts | 2 + .../payperbyteActionProvider.test.ts | 611 +++++++++++++++ .../payperbyte/payperbyteActionProvider.ts | 713 ++++++++++++++++++ .../action-providers/payperbyte/schemas.ts | 113 +++ 8 files changed, 1634 insertions(+) create mode 100644 typescript/.changeset/add-payperbyte-action-provider.md create mode 100644 typescript/agentkit/src/action-providers/payperbyte/README.md create mode 100644 typescript/agentkit/src/action-providers/payperbyte/constants.ts create mode 100644 typescript/agentkit/src/action-providers/payperbyte/index.ts create mode 100644 typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.ts create mode 100644 typescript/agentkit/src/action-providers/payperbyte/schemas.ts 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..794adcd39 --- /dev/null +++ b/typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.test.ts @@ -0,0 +1,611 @@ +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 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"); + }); + }); +}); 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/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.", + ); From 049fa2b6511cac5ad9694840edf236d4c66bba03 Mon Sep 17 00:00:00 2001 From: 0rkz Date: Fri, 21 Aug 2026 00:30:09 -0400 Subject: [PATCH 2/2] test(payperbyte): add real-receipt fixture (sanctions-screen, 2026-08-21 capture) Signed-off-by: 0rkz --- .../payperbyteActionProvider.test.ts | 122 ++++++++++++++++++ .../payperbyte/realFixture.test-data.ts | 31 +++++ 2 files changed, 153 insertions(+) create mode 100644 typescript/agentkit/src/action-providers/payperbyte/realFixture.test-data.ts diff --git a/typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.test.ts b/typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.test.ts index 794adcd39..78e7f914a 100644 --- a/typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.test.ts @@ -8,6 +8,7 @@ import { 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 @@ -608,4 +609,125 @@ describe("PayperbyteActionProvider", () => { 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/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", +};