ACK audit: what an agent payment receipt actually proves
Every agent payment protocol eventually has to answer one question: when the client comes back and says "I paid", what does the server check? We audited the Agent Commerce Kit to see what its receipt actually binds.
The Agent Commerce Kit (ACK) is the open-source half of Catena Labs, the company Circle co-founder Sean Neville started to build a bank for AI agents. Catena raised $18M led by a16z crypto and released ACK under MIT alongside the announcement. It is not a chain, not a facilitator and not a wallet. It is a pair of patterns: ACK-ID for agent identity, ACK-Pay for payment requests and receipts, both expressed as W3C Verifiable Credentials over Decentralized Identifiers.
That framing is unusual enough to be worth measuring. Most of the payment stack we have audited this year moves value and then hands you a transaction hash. ACK does the opposite: it stays rail-agnostic and moves the proof into a signature. This audit is about what that signature covers.
The census
Numbers below were measured on 2026-08-21 against a fresh clone and the published packages.
The repository was created on 2025-05-19 and its first commit lands the same day. It carries 94 commits total, 153 stars, 127 forks and 46 open items. HEAD is 0b8fdaa, tagged [email protected] on 2026-08-04. The workspace holds eight packages — ack-id, ack-pay, vc, did, jwt, keys, caip and an agentcommercekit meta-package — plus five demos and three examples.
Commit activity is lumpy. Forty-seven commits in the first five months of 2025, two in September, two in October, then nothing until February 2026. The 2026 shape is 10 commits in February, 2 in March, 26 in June, 5 in July, 4 in August. That June spike is almost entirely toolchain, tests and security, not protocol.
The specification lives as documentation, not as an RFC or a schema registry: 6,081 words under docs/ack-pay, 3,283 under docs/ack-id, 2,869 in the overview. There is no versioned wire spec separate from the SDK. The package version is the protocol version.
Adoption is small and honest about it. agentcommercekit served 442 npm downloads in the 30 days ending 2026-08-19; @agentcommercekit/ack-pay served 427 in the same window. Twenty-one published versions since 2025-05-16.
ACK-Pay on one screen
The server-initiated flow is the familiar one. The docs say the idiomatic HTTP binding is a 402 Payment Required with the Payment Request in the body, and they are explicit that the payload is transport-agnostic — it can travel over A2A messages or WebSockets just as well. That is the first divergence from x402, which lives in headers and is HTTP-shaped by design.
The body carries two things: a paymentRequest object and a paymentRequestToken, a JWT signed by the server over that object. The object is a list of paymentOptions, each one a way to pay:
{
"id": "usdc-base",
"amount": 10000,
"decimals": 6,
"currency": "USDC",
"network": "eip155:8453",
"recipient": "eip155:8453:0x…",
"paymentService": "https://payments.example.com/base",
"receiptService": "https://receipts.example.com/base"
}
The design intent is clear and, in one respect, better than the alternatives: a single 402 can offer USDC on Base, a card charge through Stripe and USDC on Solana side by side, and the client picks. x402 gets multi-offer behaviour too, but every offer is a chain offer. ACK-Pay treats fiat rails as first-class from the schema up.
The cost of that generality shows in the field types. In packages/ack-pay/src/schemas/zod.ts, network is optional and a bare string. The docs example puts "stripe" and "eip155:8453" in the same field with no registry to say which vocabulary applies. recipient is a bare string too, and the docs concede the "format may vary by network" — the example uses a CAIP-10 account id for Base and a raw address for Solana. The documentation table declares amount to be an integer; the schema accepts z.union([z.number().int().positive(), z.string()]). There is a caip package in the same workspace that parses CAIP-2 and CAIP-19, and ack-pay does not import it.
Those are the seams where a payment protocol becomes a per-integration agreement instead of a wire contract. We flagged the same class of gap in the x402 extensions layer, where a chainId was hardcoded in one spec and left untyped in another.
The receipt is an attestation, not a settlement proof
Here is the whole receipt claim, from create-payment-receipt.ts:
const attestation: Record<string, unknown> = {
paymentRequestToken,
paymentOptionId
}
// metadata is added only if the caller passes it
return createCredential({
type: "PaymentReceiptCredential",
issuer, subject: payerDid, expirationDate, attestation
})
Two fields. The signed request it answers, and which option was used. No amount. No network. No transaction hash. No timestamp of settlement. A Receipt Service signs a Verifiable Credential that says "this payer satisfied this request under option X", and the entire evidentiary weight rests on the verifier's willingness to trust that issuer's DID.
The documentation is candid about the rest. credentialSubject.metadata is "an extension point for verifier-specific payment evidence" and its fields are "non-normative". The example metadata includes settlementNetwork: "eip155:8453" and settlementReference: "0xabc123" — the transaction hash exists, as an optional key in a free-form map that the library never inspects.
Compare with what the rest of the stack does. x402's settle response carries the transaction hash and the network as protocol fields. The offer-receipt extension signs offers and receipts with EIP-712 or JWS and specifies the shape. ERC-8004 has a proofOfPayment hole in its registration file that the ecosystem has been arguing about for months. ACK-Pay resolves that tension by declaring it out of scope: verification is issuer trust, settlement is somebody else's problem.
That is a defensible architecture. It is the same architecture as a card network's authorization message. But it means an ACK deployment is only as good as its Receipt Service list, and that list is a configuration array, not a protocol.
What we ran
We installed [email protected] from npm and drove the full loop with fresh Ed25519 keys and did:key identifiers: server issues a request, receipt service issues a receipt, server verifies it. Four results are worth reporting.
An expired payment request verifies. We built a request with expiresAt: "2025-01-01T00:00:00Z" — nineteen months in the past — and called verifyPaymentRequestToken(token, { resolver, verifyExpiry: true }). It returned the parsed request in 12 ms. The reason is visible in the code: createPaymentRequestToken spreads the request into the JWT payload and adds only iat and sub. It never derives a JWT exp claim from expiresAt, and verifyExpiry only governs the JWT claim. The schema field is decorative unless the application reads it. This is a known gap: PR #158, "fix(ack-pay): enforce payment request expiresAt", has been open since 2026-08-14.
A receipt for a payment that never happened verifies in 22 ms, entirely offline. We never touched a chain, a facilitator or a payment service. We generated a receipt service key, signed a PaymentReceiptCredential, and verifyPaymentReceipt accepted it. That is not a bug — it is the design, stated plainly by running it. The only thing verification proves is that a DID we chose to trust signed a statement.
The receipt does not bind to the request being served. We issued a second request, req-002, and presented the receipt for req-001 against it. Verification passed and returned req-001. That is correct behaviour — the function has no parameter for "the request I am currently serving" — but it means replay prevention across requests is entirely on the application. The verify options are exactly four: resolver, trustedReceiptIssuers, paymentRequestIssuer, verifyPaymentRequestTokenJwt. None of them is an expected request id.
Nothing checks the receipt issuer against the option that named it. Our payment option declared receiptService: "did:web:receipts.example.com". The receipt was issued by an unrelated did:key. Verification passed, because we had put that did:key in trustedReceiptIssuers. The link between "the service I told you to pay through" and "the service that signed your proof" is never enforced by the library.
Three security fixes, all in 2026
The credential layer had a serious flaw and fixed it in the open, which is worth more than a clean history.
Commit 81c68bf, merged 2026-06-20, is titled "bind credential verification to the verified proof (credential forgery)". The message describes it as critical: verifyParsedCredential verified the JWT in proof.jwt but then made every trust decision — expiry, revocation, trusted issuer, claim verification — from the caller-supplied outer object, which is not bound to that proof. An attacker could take any legitimately signed credential, mutate issuer or credentialSubject on the object while keeping the valid proof, and pass verification. The verification file dates to the repository's first commit on 2025-05-19, so the object-input path carried that behaviour for roughly thirteen months.
We reproduced the post-fix behaviour on 0.11.0: mutating credentialSubject.paymentOptionId to stripe-usd and the issuer to did:web:attacker.example.com on a parsed credential, then verifying, returns the credential decoded from the proof. The forged fields are ignored. The fix works.
Two more landed on 2026-08-04 and shipped in 0.11.0. One binds a credential's issuer to the DID that actually signed it (labelled CWE-290). The other makes revocation fail closed (CWE-299): isRevoked had treated every failure — DNS error, timeout, 4xx, 5xx, non-JSON body — as "not revoked", so anyone able to disrupt reachability of a status list, or simply present a credential while the status endpoint was down, could use a revoked credential indefinitely. The changeset also notes that the fetched status list was trusted on shape alone, with its proof never verified.
Read those three together and the picture is consistent: the identity and payment patterns are sound, and the verification plumbing underneath them was doing less than its callers assumed. If you pinned any version below 0.11.0, you are running the old plumbing.
ACK-ID is the stronger half
The identity side holds up better under reading. A ControllerCredential asserts that an agent DID is controlled by a human or organizational DID. The verifier does not take that on faith: verifyAgentControllerClaim resolves the agent's DID document, reads its controller, and rejects the credential unless it equals the claimed controller. The claim is bidirectional — the credential says "this is my agent" and the DID document has to agree.
Anchor that in did:web and the trust root becomes domain control, which is exactly the anchor Web Bot Auth uses from the other direction. The repo counts 173 did:web occurrences against 102 did:pkh and 35 did:key in code and docs. Catena also registered its own method, did:jwks, which turns an existing OAuth2/OIDC JWKS endpoint into a DID; it appears as registered among the 268 method files in the W3C DID Extensions registry, pointing at catena-labs/did-jwks.
There is a working A2A module too — createSignedA2AMessage signs the message minus its metadata into a JWT and puts the signature back into metadata.sig, with a handshake that carries a credential. And there is a skyfire-kya demo that converts Skyfire KYA tokens into W3C credentials, which is the correct instinct: a KYA token is already a JWT with identity claims, so the conversion is mostly a shape change.
Governance signals
The strongest signal is not in the code. ack-lab.com returns a single page: "The ACK-Lab developer preview has ended." The catena-labs/ack-lab-sdk repository is archived, last pushed 2025-09-22. Catena's own site now sells a governance and banking platform for agents — verifiable identity, deterministic policies, audit trails — not a protocol.
The protocol repo, meanwhile, has 35 open pull requests from 22 distinct authors, 28 of them opened in August 2026, against a last merge on 2026-08-04. Open issues number 11. Several of those PRs are exactly the fixes an auditor would write — enforce expiresAt, validate string amounts, reject invalid JWK fields, emit a real Multikey for publicKeyMultibase — and none of them are merged. The JSON-LD context the receipt example carries, agentcommercekit.org/contexts/payment/v1, returns 404; the docs label it "Example ACK context", which is honest, but it means the canonical receipt sample is not resolvable.
Also worth noting: x402 appears eight times in the entire documentation set and zero times in the code. The roadmap lists "full support for and incorporation of Coinbase's x402 framework" under planned research. Two protocols that both answer HTTP 402, with no implemented bridge between them.
What it means for LLM4Agents
ACK-Pay is not a competitor to the rail we settle on. It is a receipt format, and receipt formats are where our gateway is weakest.
When an agent pays us through x402 and EIP-3009, the artifact it walks away with is a transaction hash and whatever our API returns. That is cryptographically strong and organizationally useless: an accounting system cannot reconcile a hash against a service description, and an auditor cannot tell from a chain explorer which model call it bought. A PaymentReceiptCredential — signed by us, naming the request, carrying amount, model and settlement reference in metadata — is the missing artifact. We can issue it without adopting anything else from ACK.
The threat is narrower than it looks and worth naming anyway. If enterprise buyers standardize on VC-shaped receipts and a trusted-issuer list, then being absent from that list is a procurement blocker, no matter how good the settlement is. Being an issuer costs us one signing key and one did:web document.
The caution is what this audit measured. Do not treat a third-party ACK receipt as proof of payment for anything we deliver. It proves that some DID signed a statement. If we ever accept receipts as payment evidence — for credits, refunds or reseller flows — the checks that matter are the four the library leaves to the application: request id match, amount and currency match against the request we issued, freshness against our own clock, and issuer equal to the receipt service we named. And the receipt says nothing about our own delivery, which is the other half of the ledger.
Staying on the frontier
Concrete, in order.
One. Issue receipts. Add a did:web:llm4agents.com document with one Ed25519 signing key, and make every settled x402 payment emit a PaymentReceiptCredential as JWT, available from the billing API and optionally in a response header. Put the transaction hash, network as CAIP-2, amount in atomic units, model id and request id in metadata. Cost: a key and a route.
Two. Verify defensively, if at all. Any code path that accepts an inbound ACK receipt gets a wrapper that requires the request id to match the request we issued, the amount and currency to match, the receipt to be newer than our expiry window, and the issuer to equal the receiptService we named. Pin @agentcommercekit/vc at 0.11.0 or later, never below. Treat expiresAt as ours to enforce.
Three. Adopt the controller pattern for agent identity, not the payment pattern. The bidirectional ControllerCredential check — credential claims a controller, DID document confirms it — is the cheapest human-to-agent binding we have seen this year, and it composes with ERC-8004 registration and with KYA-style tokens rather than replacing them.
Four. Publish the seam nobody has built. An x402 facilitator that also issues an ACK receipt on settle is roughly a hundred lines: take the settle response, sign a credential, return both. The ACK roadmap has wanted it since 2025 and the code does not exist. Building it makes our gateway the reference implementation of a bridge two ecosystems keep pointing at.
Five. Watch the merge queue, not the stars. Thirty-five open PRs against a last merge on 2026-08-04 is the number that predicts whether ACK stays a living spec or becomes a well-written document that a company moved on from. Re-measure in ninety days before betting anything structural on it.
Pay per call, keep the receipt
An OpenAI-compatible gateway where agents settle in stablecoins and every call is accounted for.
Register your agent