Skip to content

feat(payperbyte): add standalone PayPerByte action provider - #1461

Open
0rkz wants to merge 2 commits into
coinbase:mainfrom
0rkz:feat/payperbyte-action-provider
Open

feat(payperbyte): add standalone PayPerByte action provider#1461
0rkz wants to merge 2 commits into
coinbase:mainfrom
0rkz:feat/payperbyte-action-provider

Conversation

@0rkz

@0rkz 0rkz commented Aug 21, 2026

Copy link
Copy Markdown

Description

Disclosure: this PR is submitted by BYTEDev Inc, the operator of PayPerByte (the x402 data feeds
this provider buys from). It follows the precedent of the vendor-specific dtelecom provider.

Adds a payperbyte action provider modeled on the existing dtelecom provider: a self-contained
provider that wires its own x402 payment client directly, rather than depending on or needing to
be registered with the built-in x402ActionProvider.

Three actions:

  • payperbyte_list_feeds — free, unauthenticated GET of the feed catalog (ids, descriptions,
    USDC prices). No payment made.

  • payperbyte_query_feed — pays for one feed via x402 (USDC on Base). The spend cap
    (default maxPaymentUsdc: 1.0, matching the built-in x402 provider's default) is enforced at
    two layers: (1) a cheap pre-check against the catalog's advertised price, before any
    payment logic runs at all; (2) a protocol-level x402Client.registerPolicy() that filters the
    server's actual 402 challenge down to USDC-on-Base options within the cap — this is the layer
    that actually matters, since the catalog is not the authoritative price and a bare client would
    otherwise pay whatever a given 402 response happens to quote. If the policy filters out every
    offered option, no payment can be created; that specific @x402/core error is caught and
    returned as a clean {error: true, noPaymentMade: true, ...} rather than a raw throw. Returns
    the response body and its X-BYTE-Attestation header verbatim (not re-serialized, so the hash
    still matches). 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 —
    because 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 (still
    fail-closed, but for the wrong reason, and it would incorrectly reject legitimate v1 quotes).
    The cap comparison itself explicitly checks Number.isFinite(usdc) && 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.

  • payperbyte_verify_attestation — offline verification of the X-BYTE-Attestation receipt.
    Recomputes keccak256(utf8(body)), checks it against the attested payloadHash and
    payloadLength, recovers the EIP-712 signer, and checks the attestation has not expired. Fails
    closed
    : any mismatch, malformed input, or expired deadline returns {verified: false, reason: "..."}; it never throws and never passes on ambiguous input.

    Two things worth being explicit about, since they're the security-relevant part of this action:

    • The EIP-712 domain is pinned to all four fields (name "BYTE Library", version "1",
      chainId 421614, verifyingContract), never taken from the attestation's own claimed domain
      object, and checked before recovery ever runs. Recovering against an attacker-supplied
      domain 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. A PayperbyteConfig
      attestationDomain override exists only for a future coordinated migration of
      chainId/verifyingContract; the domain name and version are never overridable.
    • A valid result does not by itself mean the signer is a legitimate PayPerByte publisher
      only that some key correctly signed the exact bytes under the real domain. The result always
      includes recoveredSigner so callers can apply their own policy. An optional
      PayperbyteConfig.trustedPublishers: string[] allowlist can be configured to additionally gate
      verified on the recovered signer being on that list; the result then also carries
      publisherTrusted: true/false (or null when no allowlist is configured, with a one-line note
      that policy is the caller's to make).

Honesty about scope (stated in the README, worth restating here)

Verification is evidence toward authenticity and tamper-evidence of the exact bytes served
that a key signed exactly what you received, under the real BYTE Library domain. It does not
attest to, and makes no claim about, whether the underlying data is correct, and (absent a configured
trustedPublishers list) it is not by itself a claim about who signed it beyond the recovered
address. The README also explains that the attestation domain is anchored to a fixed chain
(chainId 421614) independent of the payment settlement network (Base) — those are two different,
deliberately decoupled things, both correct as written.

No new dependency

Verification uses viem's keccak256 and recoverTypedDataAddressviem is already an
AgentKit dependency (used throughout wallet-providers and other action providers). No SDK
package was added for this.

Design note: standalone, not registered with x402ActionProvider

dtelecomActionProvider doesn't use x402ActionProvider's service-registry/allowlist machinery —
it has its own independent x402 client wiring for its own paid endpoints. This provider follows
the same pattern: payperbyte_query_feed constructs its own x402Client, registers the exact-EVM
scheme with the wallet's signer restricted to ["eip155:8453", "eip155:84532"] (rather than the
library's default eip155:* wildcard), registers a payment policy enforcing the USDC+cap check,
and calls wrapFetchWithPayment directly — the same primitives x402ActionProvider itself uses
internally, just not routed through its registration/allowlist logic (this provider enforces its
own network restriction and spend cap instead, at both the scheme-registration and policy layers).

USDC contract addresses come from AgentKit's existing TOKEN_ADDRESSES_BY_SYMBOLS (imported from
../erc20/constants, the same registry x402ActionProvider's own isUsdcAsset helper reads) —
not hardcoded independently. Only the CAIP-2-network-id-to-AgentKit-network-id translation
("eip155:8453" -> "base-mainnet") is provider-local, since the policy only sees the raw x402
protocol network string.

Tests

37 test cases, typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.test.ts.
All against mocked fetch and a mocked wallet — no network calls, no wallet keys in the test
suite itself.

  • supportsNetwork: base-mainnet / base-sepolia true, other networks false.

  • payperbyte_list_feeds: lists feeds with USDC prices correctly computed from the catalog's
    priceAtomic; a failed catalog fetch returns an error JSON rather than throwing.

  • payperbyte_query_feed: happy path (body + attestation passed through verbatim); refuses
    without paying when the CATALOG price exceeds maxPaymentUsdc (asserts the payment mock was
    never called); unknown feed id; non-EVM wallet; unsupported network — each of the four refusal
    cases asserts no payment was attempted; confirms the exact-EVM scheme is registered with
    networks: ["eip155:8453", "eip155:84532"] rather than the library default; confirms a raw
    "All payment requirements were filtered out by policies..." throw from @x402/core gets
    classified into a clean {error: true, noPaymentMade: true} response, not a raw throw.

  • payment cap policy (protocol-level, enforced against the server's real 402 quote) — the
    test suite mocks x402Client.registerPolicy() to capture whatever policy this provider
    registers, then invokes that captured policy directly against synthetic PaymentRequirements
    (a "402 quote") and asserts the filter result. This is the layer that actually protects the
    spend cap, independent of the catalog pre-check: (a) filters out a quote priced above the cap;
    (b) keeps a quote priced within the cap; keeps a quote priced at exactly the cap (boundary
    case); filters out a quote with a negative amount ("-1" is <= cap under a naive comparison,
    so this is checked explicitly with Number.isFinite(usdc) && usdc >= 0, not left to rely on
    NaN <= cap alone); (c) filters out a quote for a non-USDC asset; (d) filters out a quote on a
    non-Base network; a v1-shaped quote (maxAmountRequired, no amount field) within cap
    correctly survives the filter instead of being dropped as NaN, and a v1-shaped quote over cap
    is still correctly filtered out (confirms the fallback doesn't accidentally start accepting
    everything); plus a mixed-list case confirming only the single
    valid Base/USDC/within-cap option survives when several invalid ones are offered alongside it.

  • payperbyte_verify_attestation: this is the part I want to flag as genuinely tested, not just
    schema-checked — every case exercises a real cryptographic signing and recovery flow against
    a freshly-generated, ephemeral, never-persisted viem key:

    • POSITIVE: signs a sample body correctly; verifies with recoveredSigner set and
      publisherTrusted: null (no allowlist configured on the default test provider).
    • NEGATIVE (tampered body): same attestation, modified body → hash mismatch, verified: false.
    • NEGATIVE (wrong signer): the ephemeral key signs, but the attestation claims a different
      address as publisher → recovered signer doesn't match the claimed field, verified: false.
    • NEGATIVE (expired deadline): valid hash and signature, deadline in the past → verified: false, with hashMatch: true / signerMatch: true / expired: true surfaced separately so
      the two legs (cryptographic validity vs. freshness) aren't conflated.
    • NEGATIVE (wrong domain name): an attestation whose domain.name isn't "BYTE Library" is
      rejected before any hashing happens.
    • Forged-domain regression (the case the pinned-domain fix directly targets): an attestation
      that is internally self-consistent — real signature, publisher set correctly to the signer's
      own address — but signed and claimed under a different chainId/verifyingContract than the
      real BYTE Library domain. Confirms it is rejected as a domain mismatch, never recovers as
      verified, proving the fix actually closes the self-referential-forgery path rather than just
      checking the domain name.
    • attestationDomain config override: a provider configured with a migrated chainId/contract
      verifies an attestation signed under that domain, while the same attestation is correctly
      rejected by a default (unmigrated) provider instance — confirms the override is real and scoped
      to the instance it's configured on.
    • trustedPublishers allowlist: an allowlisted signer verifies with publisherTrusted: true; a
      signer not on the list fails verification (verified: false, publisherTrusted: false) even
      though its signature is entirely valid.
    • Malformed input (non-string payloadHash, bypassing TypeScript's static types via a runtime
      cast): confirms the action fails closed with an "invalid input" reason instead of throwing —
      the schema is safeParse'd explicitly inside the action (not just relied on the MCP adapter's
      zod layer), and the whole action body is wrapped in try/catch as a backstop.
  • Real production fixture (payperbyte_verify_attestation — real production fixture (2026-08-21 capture, sanctions-screen), 6 cases, realFixture.test-data.ts): one real
    X-BYTE-Attestation receipt captured 2026-08-21 from the live PayPerByte gateway (a clean,
    ~$0.10 paid sanctions-screen feed call, verdict ALLOW, scanned for degraded-response
    markers with zero matches; the body's documented broadcast disabled (SANCTIONS_SCREEN_BROADCAST=0)
    disabled-state note is whitelisted as an intentional flag in a healthy response, not a degraded-response marker), run through the exact same verification the ephemeral-key cases
    above exercise: POSITIVE (hash, length, and EIP-712 signer recovery all check out against the
    real gateway publisher, 0xB48CCc9e3ab67041e3b5D09700138E45cda6AeA8); trustedPublishers
    allowlisting that real publisher verifies true, a different address on the allowlist
    correctly rejects it; flipping one byte of the real body, and separately tampering the real
    signature, each fail closed without throwing; and pinning the provider to a different
    chainId correctly rejects the real fixture's genuine (unmigrated) domain. The cryptographic
    cross-check on this receipt was independently re-verified in Python via eth_utils.keccak — a
    different implementation than the viem path under test — before the fixture file was
    generated.

Ran via pnpm --filter @coinbase/agentkit test (whole package, not just this file): 934/934
passed
on a clean branch off main (i.e., without the separate x402-v2-description-fix PR
applied), including all 37 new cases (31 ephemeral-key + 6 real-fixture). pnpm --filter @coinbase/agentkit lint and tsc --noEmit both clean.

The one live network call behind this PR — a single, read-only curl against
https://x402.payperbyte.io/feeds — happened outside the test suite, purely to capture the real
catalog response shape for the mock fixture in the tests (field names, nesting, priceAtomic
format). No paid endpoint was ever called, no wallet or key was ever used to make a real request.

Checklist

  • Added a changeset (typescript/.changeset/add-payperbyte-action-provider.md)
  • Added a README.md for the new provider directory
    (typescript/agentkit/src/action-providers/payperbyte/README.md)

@0rkz
0rkz requested a review from murrlincoln as a code owner August 21, 2026 02:39
@cb-heimdall

cb-heimdall commented Aug 21, 2026

Copy link
Copy Markdown

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

@github-actions github-actions Bot added documentation Improvements or additions to documentation action provider New action provider typescript labels Aug 21, 2026
@0rkz
0rkz force-pushed the feat/payperbyte-action-provider branch from b066de1 to fa5617c Compare August 21, 2026 03:53
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 <paperm2m@gmail.com>
@0rkz
0rkz force-pushed the feat/payperbyte-action-provider branch from fa5617c to a377a68 Compare August 21, 2026 03:54
…-21 capture)

Signed-off-by: 0rkz <paperm2m@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action provider New action provider documentation Improvements or additions to documentation typescript

Development

Successfully merging this pull request may close these issues.

2 participants