SDK audit — v0.3.0

Pre-mainnet audit of the AgentPaywall SDK verify path

Five findings — four critical (C1–C4), one discovered during remediation (CONFIG-NETWORK-MISMATCH). All fixed before v0.3.0 was published to npm. This page documents what was wrong, why it mattered, and exactly what changed.

Last updated: April 24, 2026

Important: These policy pages are a strong operational baseline, but they are not legal advice. Review them with a qualified lawyer in your jurisdiction before going live.

Summary

Because the SDK is installed and run by strangers on servers that touch real USDC, any bug in the verify path becomes exploitable at scale the moment we point it at mainnet. Before enabling mainnet in the 402 response, we commissioned a senior Solana-developer review of packages/sdk/src/verify.ts and the three middlewares that wrap it (Express, Next.js App Router, Fastify).

The review surfaced four critical issues. A fifth issue — a mismatch between the 402 response and the verifier’s default RPC URL — was surfaced during remediation and fixed in the same release.

  • C1 — Replay set deleted consumed signatures on verify failure, enabling double-acceptance of one valid payment under concurrent retries plus RPC flakiness.
  • C2 — Replay state was an in-process Set with no shared-store option, so on Vercel or any multi-instance deploy the same signature could be accepted once per instance.
  • C3 — Verification compared the SPL-Token instruction amount field. For any Token-2022 mint with transfer fees, gross ≠ net, so an attacker could underpay the recipient while the check passed.
  • C4 — Raw RPC error strings were returned to the unpaid caller in the 402 response body. web3.js errors include the full RPC URL; authenticated RPCs (Helius, QuickNode, Alchemy) put API keys in the query string. An attacker could force errors and harvest developer credentials.
  • CONFIG-NETWORK-MISMATCH — Middlewares hard-coded the devnet RPC URL when rpcUrl was unset, ignoring network: 'mainnet-beta'. A mainnet developer would have seen every real payment rejected while the 402 response correctly advertised mainnet.

All five are fixed in @agentpaywall/sdk@0.3.0. Every item below links to the specific lines that changed.

C1 — Replay set delete-on-failure

Severity: critical —  double-acceptance of a valid payment under normal agent retry behaviour with flaky RPC.

Previous behaviour: when a signature failed verification (including transient causes like an RPC timeout), the middleware removed it from the replay set so the caller could retry. In concurrent retries, a valid signature could be consumed by request A, released on a timeout, and then accepted by request C — approving two API responses for one on-chain payment.

Fix: the signature is consumed atomically on first sight and never released. On a genuine verify failure the client must submit a new signature. Lines removed from express.ts, nextjs.ts, and fastify.ts.

C2 — In-memory replay on serverless

Severity: critical — replay protection was effectively absent on any serverless or multi-pod deployment within the 5-minute freshness window.

Previous behaviour: the replay set lived in a module-level Set. On Vercel, every warm lambda instance has its own memory, so the same signature could be accepted once per instance. A 20-lambda deployment turned one $0.001 payment into 20 free calls.

Fix, in three parts:

  • A ReplayStore interface is now part of the public API. Its contract is a single atomic seen(signature) => Promise<boolean> — true iff the signature was already recorded.
  • createInMemoryReplayStore() is still the default but is clearly labelled as single-process only.
  • At middleware construction, if process.env.VERCEL, AWS_LAMBDA_FUNCTION_NAME, or CF_PAGES is detected and no explicit replayStore was provided, the SDK emits a one-time console.warn naming the footgun. The warning does not throw — dev envs still work — but it will show up in the deploy’s log the first time the middleware is constructed.

We recommend supplying a Redis / Upstash / Durable Object-backed ReplayStore for anything hitting mainnet. A reference Upstash adapter is planned for the docs in the next release.

C3 — Token-2022 transfer-fee bypass

Severity: critical — latent today (mainnet USDC is standard SPL-Token) but activates the moment any developer configures a Token-2022 mint.

Previous behaviour: verification parsed SPL-Token transfer and transferChecked instructions and compared the instruction amount field against the expected amount. For Token-2022 mints with the transfer-fee extension, the instruction amount is the gross amount debited from the sender; the recipient receives gross − fee. An attacker could submit a technically-valid transfer with a gross of expected, have it pass the check, and underpay the recipient.

Fix: verification now reads meta.preTokenBalances and meta.postTokenBalances, sums them per owner for the USDC mint, and compares the net balance delta of the expected recipient. The balance delta is the authoritative amount that actually settled on chain — it is after fees, after any future extensions, and immune to whatever structure an attacker can fit into an instruction list.

Secondary wins from the same rewrite: (a) senderWallet is now derived from the largest net debit rather than the instruction authority field, which could have been a delegate; (b) the verify path no longer iterates unbounded lists of transfer instructions, so an earlier amplification-DoS concern is closed as a side-effect.

C4 — RPC API-key leak in 402 response

Severity: critical — developer liability issue. The SDK was leaking its developers’ paid RPC API keys to any attacker willing to force an error.

Previous behaviour: on a verify failure the middleware returned the raw error string in the 402 response body under verificationError. @solana/web3.js errors for fetch failures typically include the full RPC URL. Helius / QuickNode / Alchemy authenticate with ?api-key=... query params. A malicious consumer could send invalid signatures or hammer the endpoint to trip timeouts and harvest the dev’s RPC key from the 402 JSON.

Fix, three parts:

  • PaymentVerificationResult now carries a stable errorCode from a closed enum (eleven codes including TX_FAILED, TX_TOO_OLD, INSUFFICIENT_AMOUNT, RPC_UNAVAILABLE, etc.).
  • error is now sourced from a static lookup table of public-safe strings. Nothing dynamic — no URLs, no user-supplied strings, no stack frames — ever reaches the client-facing error field.
  • Developers who want the raw error server-side (for their own logger) pass an onError(scope, error) callback in config. The callback is invoked with the full error; the client still sees only the sanitized code and message.

The 402 response body now contains verificationErrorCode alongside verificationError, so agent clients can branch on a stable machine-readable value.

CONFIG-NETWORK-MISMATCH (found during remediation)

Severity: high — would have made the SDK appear broken on mainnet on day one for developers who used the network config shortcut.

Previous behaviour: every middleware hard-coded DEVNET_RPC_URL as the fallback when rpcUrl was unset, while build402Response correctly honoured config.network. A developer setting { network: 'mainnet-beta' } without rpcUrl would have seen the 402 response tell clients “mainnet” while the verifier silently hit devnet — every real mainnet payment rejected.

Fix: resolveRpcUrl(config) and resolveUsdcMintAddress(config) are now exported helpers used by both the middlewares and build402Response. They respect config.rpcUrl / config.usdcMintAddress first, then config.network, then default to devnet. Regression test at packages/sdk/test/payment-response.test.ts asserts the 402 response and the verifier see the same values.

Breaking changes in v0.3.0

The shape of PaymentVerificationResult is additive (new errorCode field) but some internal behaviours changed:

  • result.error strings are now drawn from a fixed set — if you were matching on the previous free-form messages, switch to matching on result.errorCode.
  • RPC fetch / parse errors no longer surface as "Verification failed: <raw error>". They are RPC_UNAVAILABLE to the client and reach your onError callback with the original error object.
  • inferUsdcMint now exact-hostname-matches api.mainnet-beta.solana.com / api.devnet.solana.com, not a substring. Custom RPC URLs were already required to pass usdcMintAddress explicitly; that is now also true for anything with “mainnet” or “devnet” in the path but a different host.
  • Replay-set retry behaviour no longer releases a signature on verify failure. A client that retries with the same signature will get REPLAY_DETECTED — submit a new signature instead.

Timeline and acknowledgements

  • Review scope agreed: April 24, 2026.
  • Findings delivered: April 24, 2026.
  • Fixes landed and v0.3.0 published to npm: April 24, 2026 (same-day turnaround before any mainnet exposure).

Thanks to the reviewer for pushing back on the delete-on-failure race — it looked innocuous until the concurrent-retry case was spelled out.

How to upgrade

npm install @agentpaywall/sdk@0.3.0

Two actions recommended for mainnet deployments:

  • Pass a shared replayStore (Redis / Upstash / Durable Object) in your AgentPaywallConfig. The default in-memory store is safe for single-process deployments only.
  • Pass an onError(scope, error) callback so you can log raw RPC failures server-side. The public 402 response will never leak those details, but you still want visibility.

Questions, pushback on any of these findings, or new findings of your own: security@agentpaywall.com.