Skip to content

Conform to draft-cashu-charge-01 @ 307dec5#17

Merged
orveth merged 13 commits into
mainfrom
feat/spec-conformance
Jun 10, 2026
Merged

Conform to draft-cashu-charge-01 @ 307dec5#17
orveth merged 13 commits into
mainfrom
feat/spec-conformance

Conversation

@orveth

@orveth orveth commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Brings pops into conformance with draft-cashu-charge-01 @ 307dec5 (orveth/mpp-specs#1), built as five adversarially-verified chunks per plans/build-spec-pops-spec-conformance.md.

What changed

Wire (chunks 1-2). Challenges emit methodDetails.paymentRequest only (the request/mints shape is gone; the embedded creqA must carry a/u/non-empty m, empty transports, no nut10). The credential payload field is token. Gateway, middleware, pop pay, and the wasm surface all speak the ONE codec; the gateway's legacy flat dialect is dead. Every error surface emits from a single problem map with absolute type URIs: framework generics only (malformed-credential, invalid-challenge, verification-failed, payment-expired, payment-insufficient, method-unsupported) — zero cashu-specific problem types remain, enforced by test.

Challenge binding (chunk 3). Challenges carry an HMAC-SHA256 id over the framework's seven slots plus a mandatory expires (default TTL 300s) in both hosts; credentials are verified by byte-exact echo + constant-time id recomputation + freshness, in that order. Key via config/POPS_BINDING_KEY/boot-generation. The gateway's prebuilt header and fixed id are gone.

Money-path behavior (chunk 4, money-gate verified). Per the spec's rulings: value >= amount+fee (excess retained, underpayment → payment-insufficient); post-swap DLEQ failure/omission no longer fails the payment — the value redeems, a WARN names the mint, and dleq_ok rides the result to the operator surface in both hosts; every resolved keyset's unit is asserted against currency pre-swap (closes a unit-binding bypass); mint HTTP calls are bounded (default 10s; 0 is a config error because an unbounded swap call can consume the token).

Close-out (chunk 5). Mint swap rejections are classified by cdk's typed NUT codes (12001/12002 → payment-expired; everything else stays the catch-all) — retired-keyset tokens stop reporting as double-spend. pop pay refuses expired challenges before touching a token (new contract code challenge_expired; the frozen table is now 33 codes with a drift-test). skills/payment-credential.md is rewritten to this wire. The vercel demo builds, runs, and no longer discards redeemed proofs.

Verification

Each chunk was implemented by a fresh agent against the frozen build-spec and then adversarially verified by a separate fresh agent that re-ran all gates itself; chunk 4 additionally passed a money-path gate (range-diff audit of the #16 rebase, byte-equivalence of the swap success path, proof that DLEQ still executes and that no client-constructible token reaches the swap with a foreign-unit keyset). Final state: 466/466 tests, clippy -D warnings clean, wasm target green, demo next build + live smoke reproduced by the verifier.

Known gaps (deliberate, documented)

  • Crash durability: the spec's reconstruct-outputs-after-a-crash MUST is not met (blinding secrets are random and unpersisted) per the owner's simple-v1 ruling — assume no crash. The cheap conformant path later is deterministic secrets (NUT-13) + restore (NUT-09).
  • wasm rejection granularity: the wasm mint client doesn't parse NUT error bodies, so keyset-retirement rejections answer verification-failed there instead of payment-expired (no re-present-once signal). Stated in the module docs and payment-credential.md.
  • Fee-bearing keysets remain rejected (fee-free profile), now with an honest error instead of double-spend.

Breaking changes for existing integrators

Challenge methodDetails shape, credential payload field name, problem-type URIs, and the exact-amount rule all changed with the spec. skills/payment-credential.md documents the new wire end-to-end.

🤖 Generated with Claude Code


Full-pass audit results (pre-merge)

Three blind audits ran against the finished branch: a from-scratch conformance matrix (99 normative rows: 69 conform, 8 conform-untested, 11 gaps, 7 deviations — gaps dominated by the accepted simple-v1 calls and SHOULD-grade items), a cross-chunk money-path re-walk (SHIP: replay/concurrency collapse to exactly one success, no path settles under amount, no consume-then-402, no reachable request-path panics), and a docs-truth integrator test (modes 1+2 shippable from docs alone; every executed claim matched). Their consolidated fix list landed as the final two commits (gateway receipt + cache-override MUSTs, receipt-on-error gate, JCS receipt bytes, short-keyset slug + local ambiguity detection, userinfo rejection, honest rejection details, truthful client loss messaging, docs honesty-scoping), each verified by a final scoped adversarial pass (481/481).

Recorded v2 backlog (not in this PR)

  • Crash durability: reconstructable swap outputs (deterministic secrets NUT-13 + restore NUT-09) and indeterminate-outcome resolution machinery (NUT-07) — owner-accepted simple-v1 exception.
  • Client recovery: NUT-07 checkstate in pop pay, auto re-present-once on payment-expired.
  • Fee math (general input_fee_ppk recomputation) or formal fee-free profiling; the fee gate currently reads the active output keyset.
  • wasm challenge-issuance/binding API (mode 3 is documented as an advisory demo surface).
  • Multi-keyset same-unit tokens (currently fail-closed rejected; spec contemplates them).
  • Rate limiting, Idempotency-Key, binding-key rotation overlap, request-body digest issuance, Accept-Payment negotiation.
  • Upstream: cashu 0.16 Id::from_short_keyset_id can panic on crafted short ids against mixed-version keyset lists (pre-existing, pre-swap, unwind-only; our local gate narrows but does not fully close it) — candidate cashu issue. Spec L583 says short ids are version-00; reality is version-01 — one-word spec fix.

orveth and others added 13 commits June 9, 2026 21:21
…t only

The 402 request object now matches draft-cashu-charge-01 Request Schema:
methodDetails carries exactly one field, paymentRequest (renamed from
request); methodDetails.mints is deleted from emit and parse — the mint
set lives only in the creqA's `m`, the authoritative artifact.

Both sides enforce the spec's creqA requirements: encode refuses a
requirement naming no mints (`m` must be non-empty); decode rejects a
creqA omitting a/u/m, top-level amount/currency disagreeing with the
creqA's a/u (integer compare for amount), a non-empty transport set, or
a nut10 spending condition (bearer-only, degrade-closed).

The gateway builds its challenge via the shared encode_charge_request
codec, killing its flat {"cashu_request": ...} dialect; the legacy
encode/decode_request_envelope surface is deleted. pop pay parses only
the new shape (legacy-shape reject test added).

Encoding stance per spec Encoding §: header values are base64url
no-pad (padded values rejected — tests pin it); creqA/cashuB inside the
JSON are accepted padded or not (cashu decodes with Indifferent mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e URIs

New pops-core-verify::problem module (serde-only, wasm-clean) maps every
ChargeError to { absolute type URI, slug, HTTP status, title } plus a
shared RFC-9457 Problem body. All four hosts consume it: the Payment
middleware, the X-Cashu middleware (hand-rolled match deleted; failure
bodies are now problem+json), the gateway (json_string hand-escaper
deleted; bodies serialize via serde_json; bare 402 carries the
framework's payment-required type), and the wasm rejection object
(which now also exposes status/problem_type/problem_slug; the code
field documented as a finer-grained discriminant). http_status keeps
its public charge_error_status fn as a thin StatusCode adapter.

URIs per spec Errors §: https://paymentauth.org/problems/<slug> for the
framework types; the cashu/ namespace ONLY for amount-mismatch and
mint-unavailable. No relative URIs.

Mapping fixes:
- method-unsupported (400) added for a credential naming a method other
  than "cashu" (was mis-mapped to malformed-credential 402); both
  Payment hosts also 400 a request bearing >1 Payment credential.
- MalformedRequest corrected to 400 + about:blank — it no longer
  borrows the invalid-challenge slug (a 402 type about the echo).
- FeeTooHigh added end-to-end (swap ceremony -> MintClientError ->
  ValidationError -> ChargeError): a fee-bearing keyset is a policy
  reject mapping to verification-failed 402 with an honest detail,
  no longer collapsed into the DoubleSpend path.

Conformance tests: the full map pinned variant-by-variant with the
spec choice documented per case; Payment + X-Cashu surfaces asserted
byte-identical and equal to the map; the gateway asserted against the
same map through its real response path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Spec Credential Schema @ 307dec5: the single required payload field is
`token`. Emit + parse + every host/test/fixture follow; the legacy
`cashu_token` spelling no longer parses (covered by a reject test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tOrUnit -> malformed-credential

Spec step 8 @ 307dec5: presented value must be AT LEAST amount +
expected_swap_fee — under-funded is the framework's payment-insufficient
(402); value above is accepted and the excess retained (no over-payment
counterpart). ChargeError::AmountMismatch is renamed PaymentInsufficient;
in the money file the comparison flips != -> < and the error-arm rename
maps through, nothing else.

Errors §: the method defines no problem types of its own. cashu/
amount-mismatch ceases to exist; mint unreachability is a plain 503 +
Retry-After whose body is about:blank (consumed-vs-unknown behavior
unchanged; all three hosts now carry Retry-After on 503). Multi-mint/
multi-unit is a parse-level fault -> malformed-credential.

Step-number comments updated to the renumbered procedure (membership=5,
value=8, swap=9).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… both hosts

Framework Challenge Binding: the challenge id is base64url(HMAC-SHA256(
server_key, realm|method|intent|request|expires|digest|opaque)) over the
seven fixed positional slots (empty string for an absent optional), in
the new always-compiled pops-core-verify::binding module (hmac+sha2,
wasm-clean). Key from operator config — middleware state builder /
gateway TOML binding_key + POPS_BINDING_KEY env — with a generate-at-boot
fallback; redacted Debug (the key MUST NOT be logged).

Every challenge now carries RFC 3339 expires (now + TTL, default 300 s,
configurable) — MUST under stateless operation. On credential: recompute
the id-HMAC over the echoed params (tampered / inconsistent / unissued /
expires-less echo -> invalid-challenge 402), then freshness (past
expires -> payment-expired 402), all before any swap.

Middleware: the unverified UUID id dies; echoes were previously
unchecked. Gateway: the prebuilt-const header and fixed "pops-gateway"
id die — per-request challenge construction. Client (pop pay) echoes
every issued param verbatim, expires included; parse_payment_params now
captures the optional params and the echo carries description.

Also lands the chunk-1+2 verifier carry-overs: the credential-counting
helper is single-sourced (middleware::count_payment_credentials, gateway
imports it) and the gateway grows the >1-Payment-credential 400 test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt HTTP timeout

Chunk 4 (money-path) of the draft-cashu-charge-01 conformance run, spec @ 307dec5:

- DLEQ serve-and-flag (step 9 + security-dleq + consume-once): a missing or
  invalid NUT-12 DLEQ on the swap-RETURNED signatures no longer fails the
  request. The verification still RUNS in the ceremony; its verdict travels as
  SwapOutcome.dleq_ok -> ValidatedCharge.dleq_ok -> Redeemed.dleq_ok (the
  middleware Extension and the wasm success object), with a WARN naming the
  mint at the point of detection. The old behavior destroyed redeemed value
  (inputs consumed, outputs discarded) and 402'd a settled payment.
  ChargeError::DleqInvalid and the SwapOutputDleqInvalid carriers are DELETED
  (nothing can construct them; decision documented in the problem-map tests).

- Resolved-keyset-unit assertion (step 7): every keyset that resolves against
  the mint's published list must carry the requirement's unit; a mismatch is
  verification-failed BEFORE the swap (zero swap calls), closing the
  sat-keyset-under-pop-token bypass. New ValidationError variant maps to
  ChargeError::WrongUnit.

- Mint HTTP timeout: CdkMintClient bounds every mint call (keysets/keys/swap)
  with a configurable timeout (default 10s; tokio::time::timeout). Elapsed ->
  Unreachable, so the existing 503 + Retry-After path and the
  consumed-vs-unknown contract apply unchanged (swap-POST timeout re-tags
  indeterminate). Plumbed through require_charge_state_with_mint_timeout and
  the gateway's mint_http_timeout_secs TOML knob (0 = config error).

- Carried one-liner: pops-core-verify/Cargo.toml uuid comment no longer claims
  uuid generates challenge ids (HMAC binding replaced it; the dep stays only
  for the wasm feature's uuid/js).

Tests: 450 passing (hung-mint e2e against real listeners at both phases,
WARN capture, keyset-unit pre-swap gate, flag plumbing through both
middlewares + ceremony); clippy -D warnings clean; wasm target builds.
…ent-expired

Spec verification step 9: a swap rejected because the keyset has retired
or its final_expiry has passed is a payment-expired condition; every
other rejection (already-spent included) is the else-branch
verification-failed. cdk's transport parses the mint's NUT error body
and types the two registered keyset codes — 12001 keyset-not-known ->
cdk::Error::UnknownKeySet, 12002 keyset-inactive ->
cdk::Error::InactiveKeyset (KeysetUnknown(Id) is the wallet-local twin)
— so map_cdk_err classifies those three variants into the new
MintClientError::KeysetRetiredOrExpired, which flows through
ValidationError::KeysetRetiredOrExpired to ChargeError::Expired
(payment-expired 402 via the shared problem map). No registered NUT
code names final_expiry itself, so the keyset codes are the entire
honest wire signal; anything else stays in the RejectedSwap ->
DoubleSpend -> verification-failed catch-all. No string matching.

Wire-level tests drive a real axum mint answering the swap POST with
{"code":12002}/{"code":11001} through cdk's full parse path; host-level
test pins the middleware's payment-expired 402 body. Also: a dedicated
ceremony test for the pre-POST 'input keyset unknown at mint' rejection
(definitive RejectedSwap, no swap POST attempted), and a gateway test
asserting the settle log line carries the dleq_ok verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The framework forbids submitting a credential against an expired
challenge: pay now checks the 402's expires (RFC 3339) right after
parsing the auth-params — before the charge decode, before the token is
read, and long before any swap — and refuses with the new structured
challenge_expired code carrying {url, expires}. An unparseable expires
is refused the same way (freshness cannot be established); a challenge
without expires carries no expiry signal and proceeds. The held token
is untouched on this exit; the remedy is re-requesting the resource for
a fresh challenge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…arge-01 shape; sync operator docs

skills/payment-credential.md is rewritten end to end: methodDetails
carries paymentRequest only (creqA REQUIRED to encode a/u/m, empty
transports, no nut10), payload.token, HMAC-bound challenge id +
expires with the byte-exact-echo rule, the >=-value rule with excess
retained, the full outcome table (framework problem slugs, about:blank
400/503 + Retry-After, consumed-vs-unknown and the payment-expired
re-present-once semantics), the dleq_ok operator flag, and the current
wasm/native codec surface.

skills/gate-a-service.md gains the gateway knobs (binding_key /
POPS_BINDING_KEY, challenge_ttl_secs, mint_http_timeout_secs with 0 a
config error), the binding/expiry + DLEQ serve-and-flag must-knows, the
middleware builder chain, the wasm rejection's mapped status/problem
fields, and a pop-pay-first test flow for the bound challenge.

README/AGENTS/pop-wallet: the ghost crate pops-core-types is gone from
AGENTS.md; the error table gains fee_too_high and challenge_expired
rows and the advertised count is the actual 33 (pinned by a new test
that greps the doc table against the contract codes). Gateway README +
config.example.toml document the binding/TTL knobs, the problem+json
wire, and the settle log's dleq_ok field; ts/README names the current
request-object exports. pops-core-verify's uuid dep comment states the
plain retention rationale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h_proofs

route.ts now drives the actual wasm surface: the challenge's request
param is the spec request object (encode_request_object over
amount/currency/description + methodDetails.paymentRequest), and a
verify_and_redeem rejection answers with its mapped status +
problem_type — RFC-9457 application/problem+json on 402 (+ fresh
challenge) / 503 (+ Retry-After) / 400 — so the demo emits the same
wire as the native hosts.

fresh_proofs are no longer discarded: every settlement is kept as one
gateway-shaped JSON line — appended to POPS_PROOFS_SINK when set, else
logged loudly with the warning that the line IS spendable money and a
real deployment must persist it durably. dleq_ok rides the success
(console.warn mint-trust alert + surfaced in the settled body, request
still served). Demo simplifications (static challenge id, no echo
authentication, fixed default creqA) are stated in the header.

Verified live: next build green; a bare GET answers 402 whose request
param decodes to the exact spec object, and a garbage token answers
the malformed-credential problem body with a fresh challenge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…wap/spent/short-id/userinfo rejections, truthful pay loss messaging

C1 gateway paid 200 carries Payment-Receipt (shared middleware builder) +
Cache-Control: private overriding upstream; C2 middleware gates receipt+private
on response success; C3 receipt bytes are JCS-canonical like the other two
artifacts; C4 unresolvable AND ambiguous v2 short keyset ids reject as
ShortKeysetIdUnresolved (local prefix-match count) instead of
malformed-credential; C5 userinfo mint URLs rejected token-side
(verification-failed), requirement-side (400) and at gateway config
(fail-fast); C6 mint-typed TokenAlreadySpent keeps the double-spend detail,
every other non-keyset rejection now answers the neutral SwapRejected detail;
C7 pop pay 5xx-after-send messages claim unknown redemption state instead of
'unspent ecash' (4xx stays determinate); C8 two stale >=amount doc-comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…osts; fix stale operator/wallet docs

D1 gate-a-service + payment-credential scope every challenge-binding /
expires / receipt claim to the two Rust hosts and label mode 3 (serverless
wasm) an advisory demo surface (wasm issuance API = recorded backlog);
D2 receipt section now true post-C1 for both Rust hosts, notes the mode-3
exception, adds the optional externalId field; D3 mints must be NON-empty for
the Payment hosts (challenge emission requires it; empty misconfig answers
500); D4 deposit JSON does expose created_at/created_at_utc; D5 document
POPS_CREQ_A + the self-contradicting-challenge trap and default the demo mint
to http://localhost:3338 (matching re-derived creqA); D6 pop README install
path is crates/pop (virtual root manifest) + quote / recover --target / init
mnemonic flags; D7 the middleware's bare 402 has an empty body (problem body
is SHOULD); D8 document charge.max_proofs (default 64) and
POPS_GATEWAY_CONFIG (default /etc/pops-gateway/config.toml); D9 fee paragraph
states the fee-free profile rejects fee-bearing keysets (swap_fee always 0).
Also aligns the pop-wallet gateway_rejected_payment row with C7's
status-split truth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@orveth orveth merged commit 7e366f0 into main Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant