Releases: CodeStrux/x402-stellar-kit
Release list
v0.2.0
Ten defects found by a security review of the initial release. 0.1.0 is deprecated: it is live on
npm with payment-authorization defects, and this is the release that retires it.
Breaking
-
ResourceResultno longer has apaidvariant.handle()now returnsverifiedcarrying a
single-use, memoisedsettle(). Settling before the application handler runs charges the payer for
a response they never receive — and on their side a settled-but-failed request becomes a
non-expiring indeterminate debit a human has to reconcile by hand.// before const result = await server.handle(request); if (result.kind === "paid") return respond(200, body, result.headers); // after — serve first, settle only once the handler has produced the thing paid for const result = await server.handle(request); if (result.kind === "verified") { const body = await produceTheResource(); const settled = await result.settle(); if (settled.kind === "rejected") return respond(settled.status, settled.reason); return respond(settled.status, body, settled.headers); }
All three bundled adapters already do this. The Express adapter intercepts
end, because Express
hands the response to the application rather than back to the middleware. -
WindowStoreis asynchronous, andreservenow decides. Every method returns aPromise, and
reservetakes the cap and returns{ accepted }.AGENTS.md has always told production adopters to supply "one durable, shared, atomic store". None
of Postgres, Redis, or anything else with a network in front of it is reachable from a synchronous
signature — the kit mandated something its own interface forbade. Async alone was not enough:
pay()read the window, consulted an approver, and only then reserved. Against a shared store two
replicas both pass that check and both reserve, so the real ceiling becomes cap × replicas.// before interface WindowStore { spentInWindow(now: number): bigint; reserve(id: string, units: bigint, now: number, intentHash: string): void; commit(id: string): void; // … } // after interface WindowStore { spentInWindow(now: number): Promise<bigint>; reserve( id: string, units: bigint, now: number, intentHash: string, capUnits: bigint, ): Promise<{ accepted: true } | { accepted: false; spentUnits: bigint }>; commit(id: string): Promise<void>; // … }
Your implementation must make
reserveatomic — one statement, one transaction, or a lock. A
read followed by a write reopens the race this signature exists to close, and no single-process
test can tell the difference.Payerand the adapters keep their existing signatures; only the
store changes. -
PaymentRequired["accepts"]widens to include offers on schemes this kit cannot pay. Narrow
with the exportedisExactOffer, or useselectPayableRequirement, before building an intent.
Type-level only — no runtime behaviour changes for a single-scheme challenge.
Security
assertInvocationnow binds ScVal types, not only decoded values.scValToNativeflattens
scvString/scvSymbol/scvAddressto one string and eight integer widths to onebigint, so a
transfercarrying anscvString"address" or anscvU128amount matched the approved intent on
every field, was signed, and was transmitted — to be refused on-chain after the payer already
carried a non-expiring debit. A hostile signer could freeze a payer's budget without spending a
stroop.LocalFacilitatorserializes settlements. Two concurrent settlements read the same fee-source
sequence from Horizon and both built the same transaction; the loser gottx_bad_seq— and that
loser's payer had already transmitted their signature. One instance now settles one payment at a
time, so throughput is bounded by ledger close. Two instances sharing asourceKeypairstill
collide: give each its own fee source.- The MCP server reports a transmitted payment instead of hiding it. A payment that had left the
process surfaced as a genericInternalError— the shape that invites a retry, and a retry after
transmission pays twice.x402_paid_fetchnow returnsoutcome: "indeterminate"with
transmitted: trueand theintentHash, and says plainly not to retry. Only that tool can report
it, because only it can transmit. - A mixed-scheme 402 no longer discards a payable offer.
acceptsfailed whole if any entry was
notexact, so a challenge advertising another rail beside a perfectly payable one was rejected as
malformed wire data.POL-SCHEME— documented all along — was unreachable through the real payment
path; it now fires. The encode path stays strict: a resource server built on this kit still cannot
advertise a scheme it could not settle. - The payment timeout has a floor of 10 seconds and must be whole seconds. The check was
> 0, so a hostile server advertising a two-second timeout got a payment signed with time bounds
already close to expired: it could never settle, yet the payer transmitted it and the reservation
became a permanent debit. Repeat that and the rolling budget drains without a payment ever landing.
The floor is enforced in the policy engine and inauthorizationWindowLedgers, becauseSigner
is a seam an adopter can drive directly.
Fixed
- The Express adapter can be told to trust
X-Forwarded-Proto. Behind any TLS terminator the
origin sees plain HTTP, so the reconstructed URL saidhttp://while the configured resource said
https://, and every paid request was refused. PasstrustForwardedProto: true. It is off by
default because the header is set by whoever is talking to the process, and Express already honours
it undertrust proxy. A resource-URL mismatch now names both URLs and the likely cause instead of
saying only "Resource URL mismatch". Hono and Next wer