feat(payperbyte): add standalone PayPerByte action provider - #1461
Open
0rkz wants to merge 2 commits into
Open
Conversation
🟡 Heimdall Review Status
|
0rkz
force-pushed
the
feat/payperbyte-action-provider
branch
from
August 21, 2026 03:53
b066de1 to
fa5617c
Compare
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
force-pushed
the
feat/payperbyte-action-provider
branch
from
August 21, 2026 03:54
fa5617c to
a377a68
Compare
…-21 capture) Signed-off-by: 0rkz <paperm2m@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
dtelecomprovider.Adds a
payperbyteaction provider modeled on the existingdtelecomprovider: a self-containedprovider 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 attwo 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 theserver'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/coreerror is caught andreturned as a clean
{error: true, noPaymentMade: true, ...}rather than a raw throw. Returnsthe response body and its
X-BYTE-Attestationheader verbatim (not re-serialized, so the hashstill matches). The policy reads a quote's amount as
maxAmountRequired ?? amount ?? price—the same fallback order the built-in x402 provider's
validatePaymentLimitcall site uses —because the declared
PaymentRequirementstype is v2-only (amount), but a v1-shaped 402quote carries the price as
maxAmountRequiredinstead; readingamountalone would silentlytreat every v1 quote's price as
NaNand filter it out regardless of its actual price (stillfail-closed, but for the wrong reason, and it would incorrectly reject legitimate v1 quotes).
The cap comparison itself explicitly checks
Number.isFinite(usdc) && usdc >= 0before the<= maxPaymentUsdccheck — a naive<= capalone would let a negative amount (e.g."-1")through, since a negative number is always
<=a positive cap.payperbyte_verify_attestation— offline verification of theX-BYTE-Attestationreceipt.Recomputes
keccak256(utf8(body)), checks it against the attestedpayloadHashandpayloadLength, recovers the EIP-712 signer, and checks the attestation has not expired. Failsclosed: 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:
"BYTE Library", version"1",chainId
421614, verifyingContract), never taken from the attestation's own claimeddomainobject, 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
publisherset to their own address — pass a naive"recovered === publisher" check without ever touching the real domain. A
PayperbyteConfigattestationDomainoverride exists only for a future coordinated migration ofchainId/verifyingContract; the domain name and version are never overridable.
only that some key correctly signed the exact bytes under the real domain. The result always
includes
recoveredSignerso callers can apply their own policy. An optionalPayperbyteConfig.trustedPublishers: string[]allowlist can be configured to additionally gateverifiedon the recovered signer being on that list; the result then also carriespublisherTrusted: true/false(ornullwhen no allowlist is configured, with a one-line notethat 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
trustedPublisherslist) it is not by itself a claim about who signed it beyond the recoveredaddress. 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'skeccak256andrecoverTypedDataAddress—viemis already anAgentKit dependency (used throughout
wallet-providersand other action providers). No SDKpackage was added for this.
Design note: standalone, not registered with x402ActionProvider
dtelecomActionProviderdoesn't usex402ActionProvider'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_feedconstructs its ownx402Client, registers the exact-EVMscheme with the wallet's signer restricted to
["eip155:8453", "eip155:84532"](rather than thelibrary's default
eip155:*wildcard), registers a payment policy enforcing the USDC+cap check,and calls
wrapFetchWithPaymentdirectly — the same primitivesx402ActionProvideritself usesinternally, 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 registryx402ActionProvider's ownisUsdcAssethelper 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 x402protocol network string.
Tests
37 test cases,
typescript/agentkit/src/action-providers/payperbyte/payperbyteActionProvider.test.ts.All against mocked
fetchand a mocked wallet — no network calls, no wallet keys in the testsuite itself.
supportsNetwork: base-mainnet / base-sepolia true, other networks false.payperbyte_list_feeds: lists feeds with USDC prices correctly computed from the catalog'spriceAtomic; a failed catalog fetch returns an error JSON rather than throwing.payperbyte_query_feed: happy path (body + attestation passed through verbatim); refuseswithout paying when the CATALOG price exceeds
maxPaymentUsdc(asserts the payment mock wasnever 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/coregetsclassified 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)— thetest suite mocks
x402Client.registerPolicy()to capture whatever policy this providerregisters, 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<= capunder a naive comparison,so this is checked explicitly with
Number.isFinite(usdc) && usdc >= 0, not left to rely onNaN <= capalone); (c) filters out a quote for a non-USDC asset; (d) filters out a quote on anon-Base network; a v1-shaped quote (
maxAmountRequired, noamountfield) within capcorrectly survives the filter instead of being dropped as
NaN, and a v1-shaped quote over capis 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 justschema-checked — every case exercises a real cryptographic signing and recovery flow against
a freshly-generated, ephemeral, never-persisted
viemkey:recoveredSignerset andpublisherTrusted: null(no allowlist configured on the default test provider).verified: false.address as
publisher→ recovered signer doesn't match the claimed field,verified: false.verified: false, withhashMatch: true/signerMatch: true/expired: truesurfaced separately sothe two legs (cryptographic validity vs. freshness) aren't conflated.
domain.nameisn't"BYTE Library"isrejected before any hashing happens.
that is internally self-consistent — real signature,
publisherset correctly to the signer'sown 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.
attestationDomainconfig override: a provider configured with a migrated chainId/contractverifies 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.
trustedPublishersallowlist: an allowlisted signer verifies withpublisherTrusted: true; asigner not on the list fails verification (
verified: false,publisherTrusted: false) eventhough its signature is entirely valid.
payloadHash, bypassing TypeScript's static types via a runtimecast): 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'szod 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 realX-BYTE-Attestation receipt captured 2026-08-21 from the live PayPerByte gateway (a clean,
~$0.10 paid
sanctions-screenfeed call, verdictALLOW, scanned for degraded-responsemarkers 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);trustedPublishersallowlisting that real publisher verifies
true, a different address on the allowlistcorrectly 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
chainIdcorrectly rejects the real fixture's genuine (unmigrated) domain. The cryptographiccross-check on this receipt was independently re-verified in Python via
eth_utils.keccak— adifferent implementation than the
viempath under test — before the fixture file wasgenerated.
Ran via
pnpm --filter @coinbase/agentkit test(whole package, not just this file): 934/934passed on a clean branch off
main(i.e., without the separate x402-v2-description-fix PRapplied), including all 37 new cases (31 ephemeral-key + 6 real-fixture).
pnpm --filter @coinbase/agentkit lintandtsc --noEmitboth clean.The one live network call behind this PR — a single, read-only
curlagainsthttps://x402.payperbyte.io/feeds— happened outside the test suite, purely to capture the realcatalog response shape for the mock fixture in the tests (field names, nesting,
priceAtomicformat). No paid endpoint was ever called, no wallet or key was ever used to make a real request.
Checklist
typescript/.changeset/add-payperbyte-action-provider.md)README.mdfor the new provider directory(
typescript/agentkit/src/action-providers/payperbyte/README.md)