Conform to draft-cashu-charge-01 @ 307dec5#17
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings pops into conformance with
draft-cashu-charge-01@307dec5(orveth/mpp-specs#1), built as five adversarially-verified chunks perplans/build-spec-pops-spec-conformance.md.What changed
Wire (chunks 1-2). Challenges emit
methodDetails.paymentRequestonly (therequest/mintsshape is gone; the embedded creqA must carrya/u/non-emptym, empty transports, no nut10). The credential payload field istoken. 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
idover the framework's seven slots plus a mandatoryexpires(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, anddleq_okrides the result to the operator surface in both hosts; every resolved keyset's unit is asserted againstcurrencypre-swap (closes a unit-binding bypass); mint HTTP calls are bounded (default 10s;0is 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 payrefuses expired challenges before touching a token (new contract codechallenge_expired; the frozen table is now 33 codes with a drift-test).skills/payment-credential.mdis 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 warningsclean, wasm target green, demonext build+ live smoke reproduced by the verifier.Known gaps (deliberate, documented)
verification-failedthere instead ofpayment-expired(no re-present-once signal). Stated in the module docs andpayment-credential.md.Breaking changes for existing integrators
Challenge
methodDetailsshape, credential payload field name, problem-type URIs, and the exact-amount rule all changed with the spec.skills/payment-credential.mddocuments 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)
pop pay, auto re-present-once on payment-expired.input_fee_ppkrecomputation) or formal fee-free profiling; the fee gate currently reads the active output keyset.Id::from_short_keyset_idcan 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.