Repository navigation
v0.2.0
·
36 commits
to main
since this release
Immutable
release. Only release title and notes can be modified.
Idempotency past nonce, route patterns by regex, a CLI, and an X402\Testing namespace for adopter integration tests. All additive; one rename (PaymentEnforcer::default() → forTesting()) to remove a footgun. Tests pass on the CI matrix (PHP 8.2 / 8.3 / 8.4 × prefer-stable / prefer-lowest).
What's new
PaymentResponseCache— separate PSR-15 middleware that sits beforePaymentEnforcerin the chain. Caches paid 2xx responses keyed by(network, from, nonce, signature bytes)and replays the cached body on duplicates. Closes the "paid but didn't receive content" gap when a client's connection drops between facilitator settle and response delivery —PaymentEnforcer's nonce guard correctly rejects the retried auth, so without this middleware the user paid and gets 402 on retry. PSR-16-backed; same store asPsr16NonceStorein production.RegexPriceTable— pattern-matchedPriceTablefor hosts that price by URL shape ('#^/api/v\d+/users/\d+$#') rather than by exact resource string. PCRE validated atadd()time; runtime PCRE errors (backtrack-limit exhaustion) fail closed with a thrownRuntimeExceptionso a poorly-written pattern can't bypass payment via fail-open. Layer withStaticPriceTablefor mixed setups (exact first, regex fallback).bin/x402 decode <header>— CLI to decode a base64PAYMENT-SIGNATUREheader into JSON. Useful for wire-shape debugging without writing a script. Resolves autoload whether installed undervendor/or run from the package root.X402\Testingnamespace — adopter integration-test helpers:PaymentRequiredBuilder(USDC-on-Base / Base-Sepolia factories with atomic-unit conversion via pure-string decimal shift, no bcmath),StubFacilitator(settles locally),RecordingFacilitator(assertion).PaymentEnforceradapter contracts —ResourceResolverandEnforcementPolicyinterfaces let adopters slot framework-specific resolvers (Laravel route name, Symfony attribute) without subclassing. BothClosureand instance forms accepted.PaymentEnforcer::forTesting()factory — wiresInMemoryNonceStore+ a singlePsr17Factory. Renamed fromdefault()(see below — the old name read as production-ready).CoinbaseFacilitator::default()factory — single intersection-typedRequestFactoryInterface&StreamFactoryInterfaceslot replaces the three-PSR-factory ctor for the 99% case where onePsr17Factoryplays both roles.composer ci— bundles all five gates (rector --dry-run,pint --test,phpstan,pest,lean-package-validator) into one pre-push / CI command.composer qa(auto-fix variants) stays for local cleanup.- Runnable example under
examples/— single-file PSR-15 server + Guzzle-backed paying client, ~80 LOC each.StubFacilitatorsettles locally so a new adopter sees the full 402 → sign → 200 flow without external services. Export-ignored from the dist tarball.
Bug fixes
PaymentResponseCacheforge guard — keying on(network, from, nonce)alone meant an attacker who observed a paid request on-chain could construct anyPAYMENT-SIGNATUREheader with the public tuple and replay the cached response without paying. Cache key now binds the raw header bytes; same authorization replays cleanly (idempotency preserved), forged tuple-match falls through toPaymentEnforcerand is rejected by the nonce store.PaymentResponseCachepoisoned-snapshot fallthrough —isValidSnapshot()checks for required keys + a sane HTTP status range before rebuilding. Garbage in the cache falls through to the handler instead of replaying an empty 200.RegexPriceTablefail-closed on PCRE runtime errors —preg_match()returnsfalseon backtrack-limit exhaustion. Previous=== 1check correctly rejected, but treated the route as "no match" → empty challenge list → free. Now throws so the host returns 5xx and the operator sees the failure.PaymentRequiredBuilder::for()overflow on 18-decimal fixtures — float+round()lost precision at1.5 * 10^18and overflowed the double mantissa at100.0 * 10^18. Replaced with pure-string decimal shift — exact at any decimal count, truncates fractional digits pastdecimalswithout rounding.docs/kms.mdv-value — documentedv = 27 + chainId * 2 + 35for EIP-155 alongside 27/28. That EIP-155 form is for raw transactions; EIP-712 typed data (which x402 uses) accepts only 27/28. A reader's KMS adapter following the doc would emit signatures the verifier rejects. Struck the EIP-155 mention.- PHP 8.2 ParseError on typed class constants — three sites used
public const string FOO = ...(PHP 8.3+ syntax) under a^8.2package floor; local pest passed because the dev box runs 8.3+, but the run-tests matrix on PHP 8.2 ParseError'd. Dropped the explicit type and addedphpVersion: { min: 80200, max: 80499 }tophpstan.neon.distso 8.3-only syntax is now caught at static-analysis time on the dev box, not after a matrix red.
Notes
- Rename:
PaymentEnforcer::default()→PaymentEnforcer::forTesting(). The factory wiresInMemoryNonceStore(process-local, replay-unsafe across workers); the docblock warned, but the namedefault()signaled "production-ready" to fresh readers. The new name reads as obviously wrong if shipped to production. Pre-1.0, no deprecation period — update call sites. - Internal cleanups (no behavior change):
PaymentSignature::authorization()helper extracts the(from, nonce, validBefore)triple now used by bothPaymentEnforcer::guardReplayandPaymentResponseCache::parseAndKey;PaymentResponseCachestashes the parsedPaymentSignatureon the request via attribute soPaymentEnforcerskips a redundant base64-decode + JSON-parse on the common cache-miss path. - Dist hygiene:
docs/,examples/,testbench.yaml,workbench/,internal/, and root-level project docs (ROADMAP.md,CONTRIBUTING.md,SECURITY.md) are export-ignored — vendor consumers don't pull them intovendor/. Lean-package-validator gates leanness on PR.
Full Changelog: 0.1.0...0.2.0