From af471b5b6a1dea8099ab64d4aa08beaeac7a0af4 Mon Sep 17 00:00:00 2001 From: forge-worker Date: Thu, 4 Jun 2026 13:46:37 -0700 Subject: [PATCH 01/17] WIP: add draft-cashu-charge-01 Cashu method Backtick `pop_` placeholder in prose (line ~154) so kramdown-rfc does not emit an illegal raw element that breaks xml2rfc. Author/IANA Contact frontmatter left as TODO pending the human. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/methods/cashu/draft-cashu-charge-01.md | 1131 ++++++++++++++++++ 1 file changed, 1131 insertions(+) create mode 100644 specs/methods/cashu/draft-cashu-charge-01.md diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md new file mode 100644 index 00000000..1e68b4a4 --- /dev/null +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -0,0 +1,1131 @@ +--- +title: Cashu Charge Intent for HTTP Payment Authentication +abbrev: Cashu Charge Intent +docname: draft-cashu-charge-01 +version: 01 +category: info +ipr: noModificationTrust200902 +submissiontype: independent +consensus: false + +author: + - name: TODO + ins: TODO + email: todo@example.com + org: TODO + +normative: + RFC2119: + RFC3339: + RFC4648: + RFC8174: + RFC8259: + RFC8785: + RFC9457: + RFC9530: + I-D.payment-intent-charge: + title: "'charge' Intent for HTTP Payment Authentication" + target: https://datatracker.ietf.org/doc/draft-payment-intent-charge/ + author: + - name: Jake Moxey + - name: Brendan Ryan + - name: Tom Meagher + date: 2026 + I-D.httpauth-payment: + title: "The 'Payment' HTTP Authentication Scheme" + target: https://datatracker.ietf.org/doc/draft-ryan-httpauth-payment/ + author: + - name: Jake Moxey + date: 2026-01 + NUT-00: + title: "NUT-00: Notation, blinding, and tokens" + target: https://github.com/cashubtc/nuts/blob/main/00.md + author: + - org: Cashu + date: 2024 + NUT-02: + title: "NUT-02: Keysets and fees" + target: https://github.com/cashubtc/nuts/blob/main/02.md + author: + - org: Cashu + date: 2024 + NUT-03: + title: "NUT-03: Swap tokens" + target: https://github.com/cashubtc/nuts/blob/main/03.md + author: + - org: Cashu + date: 2024 + NUT-10: + title: "NUT-10: Spending conditions" + target: https://github.com/cashubtc/nuts/blob/main/10.md + author: + - org: Cashu + date: 2024 + NUT-12: + title: "NUT-12: Offline ecash signature validation (DLEQ)" + target: https://github.com/cashubtc/nuts/blob/main/12.md + author: + - org: Cashu + date: 2024 + NUT-18: + title: "NUT-18: Payment requests" + target: https://github.com/cashubtc/nuts/blob/main/18.md + author: + - org: Cashu + date: 2024 + +informative: + NUT-01: + title: "NUT-01: Mint public key exchange" + target: https://github.com/cashubtc/nuts/blob/main/01.md + author: + - org: Cashu + date: 2024 + NUT-07: + title: "NUT-07: Token state check" + target: https://github.com/cashubtc/nuts/blob/main/07.md + author: + - org: Cashu + date: 2024 + NUT-09: + title: "NUT-09: Restore signatures" + target: https://github.com/cashubtc/nuts/blob/main/09.md + author: + - org: Cashu + date: 2024 + NUT-24: + title: "NUT-24: HTTP 402 Payment Required" + target: https://github.com/cashubtc/nuts/blob/main/24.md + author: + - org: Cashu + date: 2025 + NUT-26: + title: "NUT-26: Bech32m payment requests" + target: https://github.com/cashubtc/nuts/blob/main/26.md + author: + - org: Cashu + date: 2025 + POP: + title: "Proof of Power: time-locked Cashu credentials" + target: https://github.com/cashubtc/nuts + author: + - org: Cashu + date: 2026 + W3C-DID: + title: "Decentralized Identifiers (DIDs) v1.0" + target: https://www.w3.org/TR/did-core/ + author: + - org: W3C + date: 2022 +--- + +--- abstract + +This document defines the "charge" intent for the "cashu" payment +method within the Payment HTTP Authentication Scheme +{{I-D.httpauth-payment}}. The server issues a Cashu payment request +{{NUT-18}} as a challenge; the client presents a Cashu token that +redeems to the requested amount as a credential, which the server +verifies and redeems by swapping {{NUT-03}} it at the issuing mint. This method +relocates the challenge-and-token semantics of the existing Cashu +HTTP 402 binding {{NUT-24}} into the standard +`Authorization`/`WWW-Authenticate` framework. + +--- middle + +# Introduction + +HTTP Payment Authentication {{I-D.httpauth-payment}} defines +a challenge-response mechanism that gates access to resources behind +micropayments. This document registers the "charge" intent for the +"cashu" payment method. + +Cashu is a Chaumian ecash protocol in which a mint issues +blind-signed bearer tokens denominated in a unit. A token carries +its own value; it is verified and redeemed by swapping {{NUT-03}} +it at the mint that signed it. The "cashu" method gates a resource +behind presentation of such a token: the server names an amount, +unit, and acceptable mint set in the challenge, and the client +returns a token the server redeems to settle. + +The "cashu" method is independent of what backs the mint's unit. +The unit MAY be a denomination of an external asset (e.g., "sat") +or a purpose-built unit such as the time-locked Proof-of-Power unit +`pop_` {{POP}}. This document defines the generic Cashu charge +exchange; unit-specific backing semantics are defined by the unit's +own specification and are out of scope here. + +The flow proceeds as follows: + +~~~ + Client Server Mint + | | | + | (1) GET /resource | | + |--------------------------> | | + | | | + | (2) 402 Payment Required | | + | (request, mints) | | + |<-------------------------- | | + | | | + | (3) Swap token to exact | | + | amount (local) | | + |---------------------------------------------------------> | + | (4) Exact-amount token | | + |<--------------------------------------------------------- | + | | | + | (5) GET /resource | | + | credential: token | | + |--------------------------> | | + | | (6) Swap presented token | + | |---------------------------> | + | | (7) Fresh proofs | + | |<--------------------------- | + | | | + | (8) 200 OK (resource) | | + |<-------------------------- | | + | | | +~~~ + +## Relationship to NUT-24 {#relationship-nut24} + +NUT-24 {{NUT-24}} is the existing Cashu binding of HTTP 402 +"Payment Required". In NUT-24, a server returns HTTP 402 with an +`X-Cashu` header carrying a NUT-18 {{NUT-18}} `creqA` payment +request restricted to the fields `{a, u, m, nut10}` with an empty +transport; the client retries with a `cashuB` token in the +`X-Cashu` header; a bad mint, unit, or amount yields HTTP 400. + +This document is the standards-aligned sibling of that binding. It +relocates the same `creqA`-challenge and `cashuB`-credential +semantics into the standard `Authorization`/`WWW-Authenticate` +authentication framework {{I-D.httpauth-payment}}, substituting a +402 response carrying a fresh `WWW-Authenticate: Payment` +re-challenge for NUT-24's flat 400. The embedded `creqA` reuses +NUT-24's challenge field subset `{a, u, m, nut10}` (amount, unit, +mints, and spending-condition kind), so a single Cashu code path +can serve both bindings: the wire envelope differs, the payment +request and token do not. + +## Relationship to the Charge Intent + +This document inherits the shared request semantics of the "charge" +intent from {{I-D.payment-intent-charge}}. It defines only the +Cashu-specific `methodDetails`, `payload`, verification, and +settlement procedures for the "cashu" payment method. + +# Requirements Language + +{::boilerplate bcp14-tagged} + +# Terminology + +Cashu Token +: A bearer ecash token (a `cashuB...` string, the NUT-00 + TokenV4 serialization) encoding one or more proofs issued by a + single mint under a single unit, the mint's URL, and that unit. + The authoritative value carried by the credential. + +Payment Request +: A Cashu payment request {{NUT-18}} (a `creqA...` string, or the + `creqb1...` Bech32m form {{NUT-26}}) encoding the amount, unit, + acceptable mints, spending-condition kind, and single-use flag a + payer must satisfy. The Cashu analog of a BOLT11 invoice. It is + self-contained; all payment parameters are derived from it. + +Proof +: A single unit of ecash: an `{amount, id, secret, C}` tuple + {{NUT-00}} signed by a mint keyset. A token is a set of proofs. + +Swap +: The mint operation {{NUT-03}} that exchanges a set of input + proofs for a set of freshly blind-signed output proofs. A + successful swap marks the inputs spent and is the act of + redemption. + +DLEQ Proof +: A discrete-log-equality proof {{NUT-12}} a mint attaches to a + signature, letting the holder verify offline that the signature + was produced by the advertised mint key. + +Swap Fee +: The NUT-03 input fee a swap deducts from the input proofs, + `swap_fee = ceil(sum(input_fee_ppk over input proofs) / 1000)`, + where each keyset's `input_fee_ppk` is published per {{NUT-02}}. + Deterministic from the input proofs' keysets, so it can be + computed offline before a token is presented. + +# Intent Identifier + +The intent identifier for this specification is "charge". It MUST +be lowercase. + +# Intent: "charge" + +The "charge" intent represents a one-time payment gating access to +a resource. The server advertises a Cashu payment request +({{NUT-18}}) naming an exact amount and unit per request. The +client presents a Cashu token whose value, after the swap fee the +mint will deduct, settles to exactly that amount as the credential. +The server verifies the token and redeems it by swapping +({{NUT-03}}) it at the issuing mint; a successful swap both proves +the token unspent and transfers its value to the server. + +The "cashu" charge is exact-amount and non-custodial. The server +makes no change: it accepts only a token that, once swapped, nets +the server the requested amount exactly, redeems the whole token, +and keeps the resulting proofs. Where the token's keyset(s) charge +a NUT-03 input fee, the holder pre-funds that fee in the presented +token (see {{fees}}); for fee-free keysets the presented value +equals the requested amount. A client holding a token larger than +that value MUST split it locally at the mint before presenting (see +{{settlement}}); the remainder is never seen by the server. + +## Fees {#fees} + +A NUT-03 swap deducts an input fee determined by the keyset(s) of +the input proofs. Per {{NUT-02}} and {{NUT-03}}, the fee is + +~~~ +swap_fee = ceil( sum(input_fee_ppk over input proofs) / 1000 ) +~~~ + +where `input_fee_ppk` is the per-keyset fee published in the mint's +keyset list ({{NUT-02}}). The fee is deterministic from the input +proofs alone, so a holder can compute it before presenting. + +Because the server redeems the whole token and must net exactly +`amount`, the holder pre-funds the fee: the presented token's total +value MUST equal `amount + swap_fee`, where `swap_fee` is computed +over the presented proofs' keyset(s). The server swaps the whole +token, the mint deducts `swap_fee`, and the server's output proofs +sum to exactly `amount`. This is the holder-pre-funds model: the +server's exact-value check ({{verification}}, step 12) compares the +presented total against `amount + expected_swap_fee`, where +`expected_swap_fee` is recomputed by the server from the presented +proofs. + +Because `swap_fee` scales with the number of input proofs, a holder +selecting proofs to total `amount + swap_fee` faces a mild fixpoint +(adding a proof to cover the fee can itself raise the fee); standard +fee-aware wallet coin selection resolves it. + +For a zero-fee keyset the formula reduces to `swap_fee = 0`, so the +presented total equals `amount`. The swap fee is always the +keyset's `input_fee_ppk` per {{NUT-02}}; the server reads it from +the mint's published keysets and never assumes a value. The +Proof-of-Power unit `pop_` {{POP}} is served by fee-free +keysets today (`input_fee_ppk = 0`) — the mint operator's choice, +not a guarantee of this method or the unit — so a `pop_` charge +reduces to `presented == amount` for as long as that holds. + +This document does NOT use the term `fee_reserve`; the swap fee +defined here is the in-mint NUT-03 input fee and is unrelated to +any cross-mint melt reserve. + +Where `swap_fee` is large relative to `amount` the charge becomes +uneconomic rather than impossible: the holder still presents +`amount + swap_fee` and the server still nets `amount`, but the +holder pays a fee disproportionate to the value transferred (e.g. +`amount = 1`, `swap_fee = 5` costs the holder 6 to deliver 1). +Servers SHOULD choose an `amount` well above the swap fee of the +mints they accept, and a holder MAY decline a charge it judges +uneconomic. This is a pricing property of the chosen amount and +keyset fee, not an error condition. + +# Encoding Conventions {#encoding} + +All JSON {{RFC8259}} objects carried in auth-params or +HTTP headers in this specification MUST be serialized using the JSON +Canonicalization Scheme (JCS) {{RFC8785}} before encoding. JCS +produces a deterministic byte sequence, which is required for +any digest or signature operations defined by the base spec +{{I-D.httpauth-payment}}. + +The resulting bytes MUST then be encoded using base64url +{{RFC4648}} Section 5 without padding characters +(`=`). Implementations MUST NOT append `=` padding +when encoding, and MUST accept input with or without padding when +decoding. + +This encoding convention applies to: the `request` +auth-param in `WWW-Authenticate`, the credential token in +`Authorization`, and the receipt token in +`Payment-Receipt`. + +The `request` object is a JCS-canonical JSON object. The Cashu +payment request (`methodDetails.request`) and the Cashu token +(`payload.cashu_token`) are each carried as an opaque string value +within that JSON, exactly as a BOLT11 invoice is carried as a +string value in the "lightning" method. The `creqA...` (or +`creqb1...` {{NUT-26}}) and `cashuB...` strings have their own +internal encoding {{NUT-18}} {{NUT-00}}; that encoding is opaque to +the framework and is never canonicalized by it. Conformance to the +JCS requirement is a property of the enclosing JSON object, not of +these embedded strings. + +# Request Schema + +## Shared Fields + +The `request` auth-param of the `WWW-Authenticate: Payment` +header contains a JCS-serialized, base64url-encoded JSON object +(see {{encoding}}). The following shared fields are +included in that object: + +amount +: REQUIRED. The required amount in the base units of the Cashu + unit, encoded as a canonical decimal string of ASCII digits + (e.g., "100"). The value MUST be a positive integer with no + leading zeros, no sign, no whitespace, and no fractional part, + and MUST fit in an unsigned 64-bit integer. Servers MUST emit + `amount` in this canonical form; since it is server-authored and + echoed in the credential, an altered or malformed echoed `amount` + is tampering, rejected as `invalid-challenge` (step 5). This value is the amount the server + nets after the swap; it MUST equal the amount encoded in + `methodDetails.request`. The presented token value is + `amount + swap_fee` (see {{fees}}). + +currency +: REQUIRED. The Cashu unit string {{NUT-00}} the presented token + MUST carry (e.g., "sat", "pop_1782668279"). It MUST equal the + unit encoded in `methodDetails.request`. This is a + method-defined currency identifier per + {{I-D.payment-intent-charge}}. + +description +: OPTIONAL. A human-readable memo describing the resource or + service being paid for. If present, this value SHOULD be set as + the description field of the Cashu payment request + ({{NUT-18}}) and is distinct from any `description` auth-param + that the base {{I-D.httpauth-payment}} scheme may include at + the header level. + +recipient +: OPTIONAL. Payment recipient in method-native format, per + {{I-D.payment-intent-charge}}. Cashu implementations do not use + this field; the recipient is the server, implied by the mint + swap it performs. Servers SHOULD omit it. + +externalId +: OPTIONAL. Merchant's reference (e.g., order ID, invoice number), + per {{I-D.payment-intent-charge}}. May be used for + reconciliation. If present, it is echoed in the receipt (see + {{receipt}}). + +## Method Details + +The following fields are nested under `methodDetails` in the +request JSON. The Cashu payment request (`methodDetails.request`) +is the authoritative source for payment parameters. The +`methodDetails.mints` field is the server-chosen set of mints +whose tokens the server will accept; it is the Cashu analog of the +"lightning" method's `network` field. Clients MUST decode and +verify the payment request independently before presenting, and +MUST reject challenges where `amount` or `currency` do not match +the values encoded in the payment request. + +request +: REQUIRED. The Cashu payment request string ({{NUT-18}}, a + `creqA...` value). Servers and clients SHOULD also accept the + equivalent Bech32m encoding ({{NUT-26}}, a `creqb1...` value); + the two encodings are interchangeable and carry the same payment + parameters. This field is authoritative; all payment parameters + (amount, unit, acceptable mints, spending-condition kind, + single-use flag, optional description) are derived from it. Its + transport set MUST be empty, which {{NUT-18}} defines as in-band: + the credential is returned over the same HTTP channel in the + `Authorization` header rather than over a separate transport. Its + spending-condition kind MUST be absent (`nut10` is `None`); see + {{verification}}. + +mints +: REQUIRED. A JSON array of mint URL strings whose tokens the + server accepts for this challenge. The server, not the client, + chooses these mints. The presented token's mint MUST be a member + of this array (see {{verification}}). This array MUST be + non-empty and MUST be a superset of, or equal to, the mint set + encoded in `methodDetails.request`. Clients MUST reject a + challenge whose `mints` does not include a mint they can obtain + a token from. + +# Credential Schema + +The `Authorization` header carries a single base64url-encoded +JSON token (no auth-params). The decoded object contains two +top-level fields: + +challenge +: REQUIRED. An echo of the challenge auth-params from the + `WWW-Authenticate` header: `id`, `realm`, `method`, `intent`, + `request`, and, if present in the challenge, `digest`, `opaque`, + and `expires`. This binds the credential to the exact challenge + that was issued. A client MUST echo each of these fields + unchanged when the server included it (see {{verification}}). + +source +: OPTIONAL. A payer identifier string, as defined by + {{I-D.httpauth-payment}}. The RECOMMENDED format + is a Decentralized Identifier (DID) per + {{W3C-DID}}. Cashu tokens are bearer instruments and carry no + payer identity; implementations MAY omit this field, and + servers MUST NOT require it. + +payload +: REQUIRED. A JSON object containing the Cashu-specific credential + fields. The single required field is `cashu_token`: the Cashu + token ({{NUT-00}}, a `cashuB...` string) whose value, net of the + swap fee, settles to exactly the requested amount (see + {{fees}}). + +Example (decoded): + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.example.com", + "method": "cashu", + "intent": "charge", + "request": "eyJ...", + "expires": "2026-03-15T12:05:00Z" + }, + "source": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "payload": { + "cashu_token": "cashuBpGF0gaJhaUgA..." + } +} +~~~ + +When the challenge carried `digest` and `opaque`, the credential +echoes them too: + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.example.com", + "method": "cashu", + "intent": "charge", + "request": "eyJ...", + "digest": "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:", + "opaque": "eyJpbnRlbnQiOiJwaV8xMjMifQ", + "expires": "2026-03-15T12:05:00Z" + }, + "payload": { + "cashu_token": "cashuBpGF0gaJhaUgA..." + } +} +~~~ + +# Verification Procedure {#verification} + +Upon receiving a request with a credential, the server MUST: + +1. Decode the base64url credential and parse the JSON. +2. Verify that `payload.cashu_token` is present, is a string, and + decodes as a Cashu token ({{NUT-00}}). The token MUST be a + `cashuB...` (TokenV4) serialization; a `cashuA...` (TokenV3) + string MUST be rejected. Reject a token that does not parse or + carries zero proofs. Servers SHOULD reject a token carrying more + than a configured maximum number of proofs (see {{security-dos}}). +3. Verify that all proofs in the token reference a single mint and a + single unit. Reject any token whose proofs name more than one + mint or more than one unit. +4. Recover and authenticate the challenge parameters. Under + stateless operation (RECOMMENDED), recompute the HMAC-SHA256 + `id` binding of {{I-D.httpauth-payment}} over the echoed + `credential.challenge` parameters with the server key, and + reject the request unless it equals `credential.challenge.id`; + the echoed `request` parameters (amount, unit, accepted mints) + are thereby authenticated. Under stored operation, look up the + challenge by `credential.challenge.id`, reject if none is found, + and take the stored `request` parameters as authoritative. +5. Verify the echoed `credential.challenge` fields (`id`, `realm`, + `method`, `intent`, `request`, and, when issued, `digest`, + `opaque`, `expires`) are consistent with the authenticated + challenge from step 4 — an exact match against the stored + auth-params under stored operation, or covered by the `id`-HMAC + over those same fields under stateless operation. A mismatch is + tampering and MUST be rejected as `invalid-challenge`. +6. If the challenge carried a `digest` auth-param, the server MUST + compute the content digest of the current request body per + {{RFC9530}} and reject the credential if it does not match the + echoed `digest`. +7. If `credential.challenge.expires` is present, the server MUST + reject the credential when that timestamp is in the past. A + Cashu `creqA` carries no expiry of its own, so the `expires` + auth-param is the sole challenge-expiry signal; rejection on a + past `expires` is a `payment-expired` condition (see {{errors}}). +8. Verify the token's unit equals `currency`. +9. Verify the token's mint is a member of `methodDetails.mints`. + Mint membership SHOULD be compared by mint identity key + {{NUT-01}} rather than by URL string. +10. Verify that no proof carries a NUT-10 {{NUT-10}} well-known + (P2PK or HTLC) secret. This intent accepts plain-secret BEARER + proofs only; a proof bound to a spending condition is rejected + as `verification-failed` (see {{spending-conditions}}). +11. Resolve every proof's keyset id against the mint's published + keysets {{NUT-02}}. When a proof uses a short keyset id, the + server MUST resolve it to the full keyset via the mint's fetched + keyset list and MUST reject a short id that is ambiguous or does + not resolve (see {{short-keyset}}). +12. Compute `expected_swap_fee` over the resolved keyset(s) of the + presented proofs ({{fees}}) and verify the token's total value + equals `amount + expected_swap_fee` EXACTLY. A token worth more + OR less MUST be rejected; the server makes no change. +13. Where present, verify the DLEQ proof {{NUT-12}} on each input + proof. A proof whose DLEQ proof is present but invalid MUST be + rejected; absence of an input-proof DLEQ MUST NOT by itself + cause rejection, because input-proof DLEQ is frequently stripped + from `cashuB` tokens (see {{security-dleq}}). +14. Swap ({{NUT-03}}) the whole token at its mint, following the + durability and idempotency requirements of {{settlement}}. A + successful swap is the redemption step. The server MUST verify + the DLEQ proofs {{NUT-12}} on the blind signatures the swap + returns and SHOULD reject a mint that omits them (see + {{security-dleq}}). A swap rejected because a proof is already + spent, or because a DLEQ check on the returned signatures fails, + is a `verification-failed` condition; a swap rejected because + the keyset has retired or its `final_expiry` has passed is a + `payment-expired` condition (see {{settlement}}, {{errors}}). + +Steps 8 through 13 are structural and MUST be performed before the +network swap in step 14, so a structurally invalid token never +produces a mint round trip. The keyset resolution of step 11 MAY +require fetching the mint's keysets {{NUT-02}} before the swap. + +## Spending-Condition-Locked Tokens {#spending-conditions} + +The "cashu" charge accepts plain-secret BEARER proofs only. A +proof whose secret is a NUT-10 {{NUT-10}} well-known secret (for +example a P2PK or HTLC lock) requires a witness the server cannot +produce, so its swap would fail with no diagnosable reason. The +server therefore rejects any locked proof before the swap (step 10) +as `verification-failed`. Consistently, the challenge's embedded +`creqA` MUST set `nut10` to `None` (absent); a `creqA` requesting a +spending-condition kind is not used by this intent. Support for +locked tokens is left to a future intent. + +## Challenge Binding + +To prevent token replay across different resources or challenges, +the server MUST bind the issued `request` parameters to the +challenge `id` and verify, when a credential is presented, that +`credential.challenge` is an exact echo of an issued challenge. +The server SHOULD compute `id` as the HMAC-SHA256 binding defined +by {{I-D.httpauth-payment}} so that binding is stateless; +alternatively the server MAY store issued challenges and verify by +lookup. The challenge MUST be constructed with all framework- +REQUIRED auth-params — `id`, `realm`, `method`, `intent`, and +`request` — and the server SHOULD include `expires`; when the +charge gates a request with a body the server SHOULD include +`digest` and SHOULD include any `opaque` correlation data it needs +echoed. + +The `single_use` flag carried inside the embedded `creqA` +({{NUT-18}}) is informational for this method. Replay protection +does not depend on it: it comes from challenge binding (above) +together with the proof-level single-use property of the redeemed +token (see {{security-replay}}). + +Replay of the underlying token is independently prevented at the +proof level: a token can be swapped at most once, after which its +proofs are spent and the mint refuses any further swap (see +{{security-replay}}). Challenge binding additionally prevents a +token valid for one challenge from being presented against a +different challenge. + +## Short Keyset Identifiers {#short-keyset} + +A proof carries a keyset id identifying the signing key. A v1 +keyset id is a short 8-byte (16 hex character) identifier; a v2 +keyset id is the full 33-byte identifier. When a presented proof +uses a short keyset id, the server MUST resolve it to a full keyset +by fetching the mint's keyset list {{NUT-02}} and matching, and +MUST derive the keyset per {{NUT-02}}. A short id that matches no +published keyset, or that is ambiguous across the mint's keysets, +MUST be rejected as `verification-failed`. Resolution is required +both to compute the swap fee ({{fees}}) and to construct correct +swap outputs. + +# Settlement Procedure {#settlement} + +Settlement is the mint swap ({{NUT-03}}) of the presented token. +The server swaps the whole token for fresh proofs it controls; +holding those proofs is settlement. The mint deducts the swap fee +({{fees}}) from the inputs, so the server's output proofs sum to +exactly `amount`. The server's outputs are blinded against the +mint's currently ACTIVE keyset for the unit ({{NUT-02}}), which MAY +differ from the keyset(s) that signed the input proofs. Cashu +settlement is final once the swap succeeds: the input proofs are +spent and cannot be restored. The server makes no change and +returns no proofs to the client. + +Because the "cashu" charge is exact-amount, a client holding a +token larger than the value it must present MUST split it locally +before presenting. The client swaps ({{NUT-03}}) its token at the +mint into (a) a token worth exactly `amount + swap_fee`, which it +presents, and (b) a remainder it keeps, generating the blinded +outputs for both halves itself. This local split is itself a +fee-bearing swap: to end up holding a presentable token worth +`amount + swap_fee` AND keep a remainder, the holder must spend +inputs worth `amount + swap_fee + split_fee`, where `split_fee` is +the fee of the local split swap computed over its own input proofs +({{fees}}). Neither the mint nor the server learns the remainder's +secrets. This local split is the client's responsibility and +happens before the `Authorization` request; the server never +performs it. + +## Durability, Idempotency, and Crash Recovery {#durability} + +The swap is a money-moving operation; a crash or timeout around it +can lose the server's value or double-charge the holder. Servers +MUST therefore: + +- Persist the swap's output secrets (the blinding factors and + blinded messages) BEFORE sending the swap to the mint, so that a + crash after the mint has spent the inputs does not lose the + ability to reconstruct or restore the resulting proofs. +- Serialize redemption per presented token and per `challenge.id`, + so concurrent requests presenting the same token or hitting the + same challenge cannot issue two swaps. The redemption and the + decision to return HTTP 200 MUST be a single atomic operation + (see {{security-replay}}). +- Accept an `Idempotency-Key` request header per + {{I-D.httpauth-payment}} for non-idempotent target methods and, + on a retry bearing the same key, return the original response + without re-swapping. + +If a swap request times out or returns 5xx with an indeterminate +outcome, the server MUST NOT blindly re-swap. It MUST first query +the proofs' state with NUT-07 `/checkstate` {{NUT-07}}: if the +inputs are already spent, the first swap succeeded and the server +recovers its outputs via NUT-09 `/restore` {{NUT-09}} using the +persisted output secrets; only if the inputs are unspent may the +server re-swap. A swap that cannot be resolved this way MUST be +surfaced as `mint-unavailable` (see {{errors}}) with the token +treated as not consumed. + +## Consume-Once and Resource Delivery + +The server MUST treat the redemption (the swap) and the decision to +return HTTP 200 as a single operation: a challenge whose token has +been redeemed MUST NOT be accepted again, even if resource delivery +subsequently fails. If resource delivery fails after the token is +redeemed, the server MUST return an appropriate HTTP error +(e.g., 500) and MUST NOT reissue the same challenge. The client +MUST treat such a response as a payment loss and MAY retry with a +new token. Cashu settlement is final once the swap succeeds; the +redeemed token cannot be refunded by the server. + +Servers MUST include `Cache-Control: no-store` on all HTTP +402 responses. The challenge contains a single-use payment request; +caching it could cause clients to present a token against a stale +challenge. + +## Receipt Generation {#receipt} + +The server MUST include a `Payment-Receipt` header on the 200 +response per {{I-D.httpauth-payment}}, and MUST NOT include it on +error responses. It carries the following fields: + +method +: REQUIRED. The string "cashu". + +challengeId +: REQUIRED. The challenge identifier (the `id` from the + WWW-Authenticate challenge) for audit and traceability + correlation. + +reference +: REQUIRED. A SHA-256 hash, as a lowercase hex string, of the + exact `cashu_token` credential string received from the client + (the `cashuB...` string as presented, not a re-encoding). Serves + as a stable, shareable settlement identifier. The token string + itself MUST NOT be used here: although a redeemed token is spent + and cannot be replayed, the proof secrets remain sensitive and + MUST NOT be exposed in logs, analytics, or shared receipts. + +status +: REQUIRED. The string "success". + +timestamp +: REQUIRED. The settlement time in {{RFC3339}} format. + +externalId +: OPTIONAL. Echo of the `externalId` from the request, if one was + provided. + +The response carrying a `Payment-Receipt` header MUST include +`Cache-Control: private` per {{I-D.httpauth-payment}}, so that no +shared cache stores the receipt. + +Example (decoded): + +~~~json +{ + "method": "cashu", + "challengeId": "kM9xPqWvT2nJrHsY4aDfEb", + "reference": "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca7", + "status": "success", + "timestamp": "2026-03-10T21:00:00Z", + "externalId": "order_12345" +} +~~~ + +# Error Responses {#errors} + +When rejecting a credential for a payment-verification failure, the +server MUST return HTTP 402 (Payment Required) with a fresh +`WWW-Authenticate: Payment` challenge per {{I-D.httpauth-payment}}. +The server SHOULD include a response body conforming to RFC 9457 +{{RFC9457}} Problem Details, with +`Content-Type: application/problem+json`. + +A malformed REQUEST — as opposed to a failed payment — follows the +framework's status handling rather than returning 402: a credential +naming an unsupported method yields `method-unsupported` (HTTP 400), +and a request bearing more than one `Authorization: Payment` +credential is rejected with HTTP 400 per {{I-D.httpauth-payment}}. +The 402 problem types below are scoped to payment-verification +failures. + +The following problem types are defined for this intent: + +https://paymentauth.org/problems/cashu/malformed-credential +: HTTP 402. The credential token could not be decoded, the JSON + could not be parsed, required fields (`challenge`, `payload`, + `payload.cashu_token`) are absent or have the wrong type, + `cashu_token` does not decode as a Cashu token, or the token is a + `cashuA...` (TokenV3) serialization. A fresh challenge + MUST be included in `WWW-Authenticate`. + +https://paymentauth.org/problems/cashu/invalid-challenge +: HTTP 402. The value of `credential.challenge.id` does not match + any challenge issued by this server (stored operation), or + `credential.challenge` is not an exact echo of an issued + challenge (including a `digest` that does not match the request + body) or fails its `id`-HMAC (stateless operation). Under stored + operation this also covers a challenge already consumed; under + stateless operation a replayed token is instead caught at the + swap as a spent token (`verification-failed`). A fresh challenge + MUST be included in `WWW-Authenticate`. + +https://paymentauth.org/problems/cashu/payment-expired +: HTTP 402. The challenge `expires` auth-param echoed in the + credential is in the past, or the mint rejected the swap because + the token's keyset has retired or its `final_expiry` {{NUT-02}} + has passed. A fresh challenge MUST be included in + `WWW-Authenticate`. + +https://paymentauth.org/problems/cashu/payment-insufficient +: HTTP 402. The token's total value does not equal + `amount + swap_fee` (see {{fees}}). This problem type is used for + both under-funded and over-funded tokens; the server makes no + change. A fresh challenge MUST be included in `WWW-Authenticate`. + +https://paymentauth.org/problems/cashu/verification-failed +: HTTP 402. The token failed a non-amount, non-expiry verification + check: its unit does not equal `currency`, its proofs reference + more than one mint or unit, its mint is not a member of + `methodDetails.mints`, a proof carries a NUT-10 {{NUT-10}} + spending condition, a proof uses an unresolvable or ambiguous + short keyset id, a present DLEQ proof {{NUT-12}} is invalid, the + mint omitted DLEQ proofs on the swap-returned signatures, or the + mint rejected the swap because a proof was already spent. A fresh + challenge MUST be included in `WWW-Authenticate`. + +https://paymentauth.org/problems/cashu/mint-unavailable +: HTTP 503. The mint could not be reached (DNS, TCP, TLS, or + timeout), or a swap outcome could not be resolved (see + {{durability}}), so the token could neither be verified nor + redeemed. This problem type EXTENDS the base scheme's status set + ({{I-D.httpauth-payment}}), which does not otherwise define a + 503 condition; it is an intentional, transient extension. The + token is NOT consumed and the client MAY retry the same token. + The server SHOULD include a `Retry-After` header and MUST NOT + treat the token as consumed. + +A token whose mint is reachable but is not in +`methodDetails.mints`, or whose unit is otherwise disallowed by +server policy, is a `verification-failed` condition (HTTP 402), +not a policy denial of an otherwise-valid payment. Servers that +distinguish a successfully-redeemed payment from a subsequent +policy denial of access MUST use HTTP 403 with no challenge, per +{{I-D.httpauth-payment}}. + +Example error response body: + +~~~json +{ + "type": "https://paymentauth.org/problems/cashu/payment-insufficient", + "title": "Payment Insufficient", + "status": 402, + "detail": "Presented token value does not equal amount plus swap fee" +} +~~~ + +# Security Considerations + +## Token Replay {#security-replay} + +Replay protection for the "cashu" method lives at the proof level. +A presented token is single-use: redeeming it swaps ({{NUT-03}}) +its proofs, after which the mint marks them spent and refuses any +further swap. A second presentation of the same token therefore +fails verification at the swap step. Servers MUST treat swap +success as consume-once: the swap and the decision to return HTTP +200 MUST be atomic, so that concurrent requests presenting the +same token result in exactly one success and one rejection, with +no window in which both are accepted (see {{durability}}). + +## Challenge Binding + +The token's single-use property protects the token, but not the +challenge: absent binding, a token valid for one challenge could +be presented against a different one. Servers MUST bind the +`request` parameters to the challenge `id` (see {{verification}}) +and SHOULD use the HMAC-SHA256 binding of {{I-D.httpauth-payment}} +for stateless verification. A server that neither binds nor stores +its challenges cannot detect a token redirected from another +challenge instance and MUST NOT be considered conformant. + +## DLEQ Verification {#security-dleq} + +The security-relevant DLEQ check {{NUT-12}} is on the blind +signatures the mint RETURNS from the swap, not on the input proofs +the client presents. Servers MUST verify the DLEQ proofs on the +swap-returned signatures and SHOULD reject a mint that omits them: +without that check a malicious mint could make the server report a +successful charge for output proofs it never validly signed, which +the server then cannot spend. + +DLEQ on the presented input proofs is treated more leniently: +input-proof DLEQ is optional and frequently stripped from `cashuB` +tokens, so its absence MUST NOT by itself cause rejection. Where an +input proof does carry a DLEQ proof, the server verifies it and +rejects the token if it is invalid. This placement matches the +Proof-of-Power verifier requirements {{POP}}. + +## Amount and Fee Determinism {#security-fees} + +The "cashu" charge is exact-amount. The server MUST verify that the +token's total value equals `amount + expected_swap_fee` exactly +(see {{fees}}), rejecting both over- and under-funded tokens, and +MUST perform this check before the swap. The swap fee is +deterministic: it is a pure function of the presented proofs' +keysets and the per-keyset `input_fee_ppk` published by the mint +{{NUT-02}}, so the holder computes the same `amount + swap_fee` the +server will check, and the server recomputes the fee from the +proofs it actually received rather than trusting any client- +supplied value. Pre-funding the fee on the holder side keeps the +whole-token redemption and the exact net `amount` mutually +satisfiable, which a naive "present exactly `amount`" rule would +not be at any keyset with `input_fee_ppk > 0`. Where the swap fee +is large relative to `amount` the charge remains satisfiable but +uneconomic; servers SHOULD price `amount` well above the swap fee +of the mints they accept (see {{fees}}). + +## Keyset Rotation and Expiry + +A `final_expiry` boundary {{NUT-02}} can fall between the +last structural check (step 13) and the swap (step 14): a token that +passes verification is not guaranteed to swap, because the mint +enforces keyset retirement and `final_expiry` at swap time. Servers +MUST treat a swap rejected for keyset retirement or passed +`final_expiry` as `payment-expired`, distinct from the +double-spend, disallowed-mint, and bad-DLEQ cases that are +`verification-failed`. Output proofs are blinded against the unit's +ACTIVE keyset (see {{settlement}}), so the server holds spendable +proofs even when the input keyset is on the verge of retiring. + +## Mint Trust + +The server trusts the mints it lists in `methodDetails.mints`: a +listed mint custodies the value the server redeems and could, +in principle, refuse to honor a swap or rotate its keyset early. +Servers MUST choose the mint set and SHOULD identify mints by +mint identity key {{NUT-01}} rather than by URL, so that a +DNS or URL takeover cannot substitute an untrusted mint. Clients +likewise rely on the listed mints to honor the tokens they hold. + +## Privacy + +Cashu tokens are bearer instruments carrying no payer identity, +and the mint's blind signatures {{NUT-00}} prevent the mint from +linking a redeemed token to its issuance. The exact-amount, +non-custodial model preserves this: because the holder splits its +token locally before presenting and keeps the remainder, neither +the server nor the mint observes the remainder or its secrets. The +server sees only a token worth exactly the value it must present. +Implementations MUST NOT log token secrets, and MUST use the token +hash, not the token, as a receipt reference (see {{receipt}}). + +## Denial of Service {#security-dos} + +A token carrying a very large number of proofs inflates both +verification cost and the swap fee. Servers SHOULD bound the number +of proofs they accept in a single token and SHOULD rate-limit +challenge issuance and credential-verification attempts per +{{I-D.httpauth-payment}}. + +## Transport Security + +All communication MUST use TLS per {{I-D.httpauth-payment}}. +A Cashu token is a bearer credential: any party that observes it +in transit before it is redeemed can redeem it themselves. +Credentials MUST only be transmitted over HTTPS, and servers MUST +redeem a presented token promptly to minimize the window in which +an intercepted token could be spent by an attacker. + +# Future Work + +This document defines only the exact-amount "charge" intent, in +which each token is presented once and fully consumed, carries no +spending condition, and is funded so the server nets the requested +amount after the swap fee. A future session-based variant +(analogous to a "session" method) could admit a reusable bearer +credential — for example a hash of a deposited token used to +authorize a series of requests — and could return change to the +holder rather than requiring exact-amount presentation. Support for +spending-condition-locked tokens (NUT-10 {{NUT-10}} P2PK/HTLC) and +for fee-bearing models other than holder-pre-funds would likewise +be defined by their own intents. Such variants would define their +own intent and credential schema and are explicitly out of scope +for the "charge" intent specified here. + +# IANA Considerations + +## Payment Method Registration + +This document requests registration of the following entry in +the "HTTP Payment Methods" registry established by +{{I-D.httpauth-payment}}: + +| Method Identifier | Description | Reference | +|-------------------|-------------|-----------| +| `cashu` | Cashu (Chaumian ecash) bearer token payment | This document | + +Contact: TODO () + +## Payment Intent Registration + +This document requests registration of the following entry in +the "HTTP Payment Intents" registry established by +{{I-D.httpauth-payment}}: + +| Intent | Applicable Methods | Description | Reference | +|--------|-------------------|-------------|-----------| +| `charge` | `cashu` | One-time exact-amount Cashu token payment gating access to a resource | This document | + +--- back + +# Examples + +## Initial Request and 402 Challenge + +~~~http +GET /weather HTTP/1.1 +Host: api.example.com + +HTTP/1.1 402 Payment Required +WWW-Authenticate: Payment id="kM9xPqWvT2nJrHsY4aDfEb", + realm="api.example.com", + method="cashu", + intent="charge", + request="eyJhbW91bnQiOiIxMDAiLCJjdXJyZW5jeSI6InNhdCIsImRlc2NyaXB0aW9uIjoiV2VhdGhlciByZXBvcnQgZm9yIDk0MTA3IiwibWV0aG9kRGV0YWlscyI6eyJtaW50cyI6WyJodHRwczovL21pbnQuZXhhbXBsZS5jb20iXSwicmVxdWVzdCI6ImNyZXFBLi4uIn19", + expires="2026-03-15T12:05:00Z" +Cache-Control: no-store +~~~ + +Decoded `request`: + +~~~json +{ + "amount": "100", + "currency": "sat", + "description": "Weather report for 94107", + "methodDetails": { + "mints": ["https://mint.example.com"], + "request": "creqA..." + } +} +~~~ + +## Retry with Credential + +~~~http +GET /weather HTTP/1.1 +Host: api.example.com +Authorization: Payment eyJjaGFsbGVuZ2UiOnsiaWQiOiJrTTl4UHFXdlQybkpySHNZNGFEZkViIiwicmVhbG0iOiJhcGkuZXhhbXBsZS5jb20iLCJtZXRob2QiOiJjYXNodSIsImludGVudCI6ImNoYXJnZSIsInJlcXVlc3QiOiJleUouLi4iLCJleHBpcmVzIjoiMjAyNi0wMy0xNVQxMjowNTowMFoifSwic291cmNlIjoiZGlkOmtleTp6Nk1raGFYZ0JaRHZvdERrTDUyNTdmYWl6dGlHaUMyUXRLTEdwYm5uRUd0YTJkb0siLCJwYXlsb2FkIjp7ImNhc2h1X3Rva2VuIjoiY2FzaHVCcEdGMGdhSmhhVWdBLi4uIn19 + +HTTP/1.1 200 OK +Payment-Receipt: eyJjaGFsbGVuZ2VJZCI6ImtNOXhQcVd2VDJuSnJIc1k0YURmRWIiLCJleHRlcm5hbElkIjoib3JkZXJfMTIzNDUiLCJtZXRob2QiOiJjYXNodSIsInJlZmVyZW5jZSI6IjliNzFkMjI0YmQ2MmYzNzg1ZDk2ZDQ2YWQzZWEzZDczMzE5YmZiYzI4OTBjYWFkYWUyZGZmNzI1MTk2NzNjYTciLCJzdGF0dXMiOiJzdWNjZXNzIiwidGltZXN0YW1wIjoiMjAyNi0wMy0xMFQyMTowMDowMFoifQ +Cache-Control: private +Content-Type: application/json + +{"temperature": 72, "condition": "sunny"} +~~~ + +Decoded credential: + +~~~json +{ + "challenge": { + "id": "kM9xPqWvT2nJrHsY4aDfEb", + "realm": "api.example.com", + "method": "cashu", + "intent": "charge", + "request": "eyJ...", + "expires": "2026-03-15T12:05:00Z" + }, + "source": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "payload": { + "cashu_token": "cashuBpGF0gaJhaUgA..." + } +} +~~~ + +Decoded receipt: + +~~~json +{ + "challengeId": "kM9xPqWvT2nJrHsY4aDfEb", + "externalId": "order_12345", + "method": "cashu", + "reference": "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca7", + "status": "success", + "timestamp": "2026-03-10T21:00:00Z" +} +~~~ + +## Proof-of-Power Unit + +The same exchange with a Proof-of-Power unit {{POP}}: only the +`currency` and the unit encoded in the payment request differ. The +`pop_` keyset is operationally fee-free today +(`input_fee_ppk = 0`, the operator's choice), so here the presented +token is worth exactly the requested `amount`; against a fee-bearing +keyset it would be `amount + swap_fee` (see {{fees}}). The token is +verified and redeemed identically; the unit's time-locked backing is +enforced by the mint's keyset `final_expiry` {{NUT-02}} at swap time +and is transparent to this method. + +Decoded `request`: + +~~~json +{ + "amount": "1", + "currency": "pop_1782668279", + "methodDetails": { + "mints": ["https://mint.example.com"], + "request": "creqA..." + } +} +~~~ + +# Acknowledgements + +The authors thank the Cashu developer community for the NUT +specifications this method builds on, and Brendan Ryan and the +Tempo Labs team for the Payment HTTP Authentication framework. From df115953b7eaba57a1912ff105b60aedcba993be Mon Sep 17 00:00:00 2001 From: orveth Date: Mon, 8 Jun 2026 15:48:26 -0700 Subject: [PATCH 02/17] spec(cashu): conformance + correctness pass from sibling/intent review - Map the Verification Procedure to the charge intent's five server responsibilities; explicitly discharge amount-match (net-settled) and recipient-match (implicit: the server is the redeeming mint). - Fix keyset id version labels: v1/v2 -> version-00 / version-01 per NUT-02 (byte lengths were already correct). - Add a Client-Side Verification security subsection (sibling parity). - Reword the Abstract for the holder-pre-funds fee model. - Classify a keyset-list fetch failure as mint-unavailable (503), distinct from an unresolvable short keyset id (verification-failed). Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/methods/cashu/draft-cashu-charge-01.md | 49 ++++++++++++++++---- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index 1e68b4a4..7b131632 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -124,8 +124,9 @@ informative: This document defines the "charge" intent for the "cashu" payment method within the Payment HTTP Authentication Scheme {{I-D.httpauth-payment}}. The server issues a Cashu payment request -{{NUT-18}} as a challenge; the client presents a Cashu token that -redeems to the requested amount as a credential, which the server +{{NUT-18}} as a challenge; the client presents a Cashu token whose +value, net of the mint's swap fee, redeems to the requested amount +as a credential, which the server verifies and redeems by swapping {{NUT-03}} it at the issuing mint. This method relocates the challenge-and-token semantics of the existing Cashu HTTP 402 binding {{NUT-24}} into the standard @@ -594,6 +595,19 @@ network swap in step 14, so a structurally invalid token never produces a mint round trip. The keyset resolution of step 11 MAY require fetching the mint's keysets {{NUT-02}} before the swap. +These steps discharge the verification responsibilities the "charge" +intent ({{I-D.payment-intent-charge}}) places on the server: +challenge-match and freshness are steps 4–7; payment-proof +verification is steps 8–14, where the swap itself is the proof of +payment; the amount-match responsibility is step 12, read as the +NET settled amount — the server nets exactly `amount` while the +holder pre-funds the swap fee, so the presented total is +`amount + expected_swap_fee` (see {{fees}}); and the recipient-match +responsibility is satisfied implicitly, because redemption is the +server swapping the presented token to itself at the mint, so there +is no distinct recipient to compare — the `recipient` field is +correspondingly omitted. + ## Spending-Condition-Locked Tokens {#spending-conditions} The "cashu" charge accepts plain-secret BEARER proofs only. A @@ -637,16 +651,21 @@ different challenge. ## Short Keyset Identifiers {#short-keyset} -A proof carries a keyset id identifying the signing key. A v1 -keyset id is a short 8-byte (16 hex character) identifier; a v2 -keyset id is the full 33-byte identifier. When a presented proof +A proof carries a keyset id identifying the signing key. A +version-`00` keyset id is a short 8-byte (16 hex character) +identifier; a version-`01` keyset id is the full 33-byte (66 hex +character) identifier ({{NUT-02}}). When a presented proof uses a short keyset id, the server MUST resolve it to a full keyset by fetching the mint's keyset list {{NUT-02}} and matching, and MUST derive the keyset per {{NUT-02}}. A short id that matches no published keyset, or that is ambiguous across the mint's keysets, -MUST be rejected as `verification-failed`. Resolution is required -both to compute the swap fee ({{fees}}) and to construct correct -swap outputs. +MUST be rejected as `verification-failed`. A failure to FETCH the +keyset list at all (a network error reaching the mint), as distinct +from a short id that resolves but matches no or several keysets, is +a `mint-unavailable` (HTTP 503) condition with the token not +consumed (see {{errors}}), not a `verification-failed`. Resolution +is required both to compute the swap fee ({{fees}}) and to construct +correct swap outputs. # Settlement Procedure {#settlement} @@ -867,6 +886,20 @@ Example error response body: # Security Considerations +## Client-Side Verification {#security-client} + +Before presenting a token, a client MUST verify the challenge +independently rather than trusting the server's `amount` and +`currency` auth-params: it MUST decode `methodDetails.request` +({{NUT-18}}) and confirm the amount and unit it encodes match the +`amount` and `currency` fields, and MUST confirm +`methodDetails.mints` contains a mint it trusts and can obtain a +token from. A client that skips these checks can be induced to pay +a different amount, in a different unit, or against an +attacker-substituted mint set. These checks are stated normatively +in the Request Schema and restated here as a security requirement, +as the sibling method specifications do. + ## Token Replay {#security-replay} Replay protection for the "cashu" method lives at the proof level. From 9e3f973554711337008d7301625e77de2b337606 Mon Sep 17 00:00:00 2001 From: orveth Date: Mon, 8 Jun 2026 16:36:19 -0700 Subject: [PATCH 03/17] spec(cashu): review pass - cleancut, conformance + correctness Per gudnuf's PR review + a sibling/intent/NUT research pass: remove all Proof-of-Power / pop_ts references (unit is now service-chosen, external asset OR service-internal); drop cross-method analogies (BOLT11, lightning) and define artifacts intrinsically; DLEQ - drop input-proof DLEQ entirely (the swap is the authoritative check), keep the swap-output DLEQ MUST; rename problem-type payment-insufficient -> amount-mismatch (covers over+under); trim Fees hard (point to the NUTs, lead with no-change), remove the fee_reserve disclaimer, trim the NUT-24 relationship; remove the Future Work section; clarify currency (the unit string is the currency, sat/msat are distinct), keep the encoded creqA with justification, clarify the DoS proof bound is server-internal; reframe the DLEQ definition + privacy wording to canonical Cashu language (blind sigs unlink redemption from issuance; the mint still sees the redemption); soften the 503 wording. Net -33 lines. Coupled follow-up: the same slug rename in the pops MPP adapter. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/methods/cashu/draft-cashu-charge-01.md | 271 ++++++++----------- 1 file changed, 119 insertions(+), 152 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index 7b131632..87a2d049 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -105,12 +105,6 @@ informative: author: - org: Cashu date: 2025 - POP: - title: "Proof of Power: time-locked Cashu credentials" - target: https://github.com/cashubtc/nuts - author: - - org: Cashu - date: 2026 W3C-DID: title: "Decentralized Identifiers (DIDs) v1.0" target: https://www.w3.org/TR/did-core/ @@ -142,19 +136,24 @@ micropayments. This document registers the "charge" intent for the "cashu" payment method. Cashu is a Chaumian ecash protocol in which a mint issues -blind-signed bearer tokens denominated in a unit. A token carries -its own value; it is verified and redeemed by swapping {{NUT-03}} -it at the mint that signed it. The "cashu" method gates a resource +blind-signed bearer tokens denominated in a unit. The mint's blind +signatures let a token be redeemed without the mint linking the +redemption to the token's issuance. A token carries its own value; +it is verified and redeemed by swapping {{NUT-03}} it at the mint +that signed it. The "cashu" method gates a resource behind presentation of such a token: the server names an amount, unit, and acceptable mint set in the challenge, and the client returns a token the server redeems to settle. -The "cashu" method is independent of what backs the mint's unit. -The unit MAY be a denomination of an external asset (e.g., "sat") -or a purpose-built unit such as the time-locked Proof-of-Power unit -`pop_` {{POP}}. This document defines the generic Cashu charge -exchange; unit-specific backing semantics are defined by the unit's -own specification and are out of scope here. +The "cashu" method is independent of what the mint's unit +denominates. The unit is a label chosen by the service that issues +the token: it MAY be a denomination of an external asset (for +example, "sat") or a unit meaningful only within the issuing +service. This document defines the generic Cashu charge exchange and +treats the unit as an opaque identifier carried in the `currency` +field; what a unit denominates, and any backing or redemption +semantics, are defined by the issuing service and are out of scope +here. The flow proceeds as follows: @@ -230,8 +229,8 @@ Payment Request : A Cashu payment request {{NUT-18}} (a `creqA...` string, or the `creqb1...` Bech32m form {{NUT-26}}) encoding the amount, unit, acceptable mints, spending-condition kind, and single-use flag a - payer must satisfy. The Cashu analog of a BOLT11 invoice. It is - self-contained; all payment parameters are derived from it. + payer must satisfy. It is a self-contained, mint-agnostic request + artifact; all payment parameters are derived from it. Proof : A single unit of ecash: an `{amount, id, secret, C}` tuple @@ -244,9 +243,11 @@ Swap redemption. DLEQ Proof -: A discrete-log-equality proof {{NUT-12}} a mint attaches to a - signature, letting the holder verify offline that the signature - was produced by the advertised mint key. +: A discrete-log-equality proof {{NUT-12}} binding a blind signature + to the mint's advertised public key, proving the mint signed with + the key it published. In this method it lets the server detect a + mint that returns signatures it did not validly produce (see + {{security-dleq}}). Swap Fee : The NUT-03 input fee a swap deducts from the input proofs, @@ -271,8 +272,8 @@ The server verifies the token and redeems it by swapping ({{NUT-03}}) it at the issuing mint; a successful swap both proves the token unspent and transfers its value to the server. -The "cashu" charge is exact-amount and non-custodial. The server -makes no change: it accepts only a token that, once swapped, nets +The "cashu" charge is exact-amount and makes no change: the server +accepts only a token that, once swapped, nets the server the requested amount exactly, redeems the whole token, and keeps the resulting proofs. Where the token's keyset(s) charge a NUT-03 input fee, the holder pre-funds that fee in the presented @@ -283,55 +284,27 @@ that value MUST split it locally at the mint before presenting (see ## Fees {#fees} -A NUT-03 swap deducts an input fee determined by the keyset(s) of -the input proofs. Per {{NUT-02}} and {{NUT-03}}, the fee is - -~~~ -swap_fee = ceil( sum(input_fee_ppk over input proofs) / 1000 ) -~~~ - -where `input_fee_ppk` is the per-keyset fee published in the mint's -keyset list ({{NUT-02}}). The fee is deterministic from the input -proofs alone, so a holder can compute it before presenting. - -Because the server redeems the whole token and must net exactly -`amount`, the holder pre-funds the fee: the presented token's total -value MUST equal `amount + swap_fee`, where `swap_fee` is computed -over the presented proofs' keyset(s). The server swaps the whole -token, the mint deducts `swap_fee`, and the server's output proofs -sum to exactly `amount`. This is the holder-pre-funds model: the -server's exact-value check ({{verification}}, step 12) compares the -presented total against `amount + expected_swap_fee`, where -`expected_swap_fee` is recomputed by the server from the presented -proofs. - -Because `swap_fee` scales with the number of input proofs, a holder -selecting proofs to total `amount + swap_fee` faces a mild fixpoint -(adding a proof to cover the fee can itself raise the fee); standard -fee-aware wallet coin selection resolves it. - -For a zero-fee keyset the formula reduces to `swap_fee = 0`, so the -presented total equals `amount`. The swap fee is always the -keyset's `input_fee_ppk` per {{NUT-02}}; the server reads it from -the mint's published keysets and never assumes a value. The -Proof-of-Power unit `pop_` {{POP}} is served by fee-free -keysets today (`input_fee_ppk = 0`) — the mint operator's choice, -not a guarantee of this method or the unit — so a `pop_` charge -reduces to `presented == amount` for as long as that holds. - -This document does NOT use the term `fee_reserve`; the swap fee -defined here is the in-mint NUT-03 input fee and is unrelated to -any cross-mint melt reserve. - -Where `swap_fee` is large relative to `amount` the charge becomes -uneconomic rather than impossible: the holder still presents -`amount + swap_fee` and the server still nets `amount`, but the -holder pays a fee disproportionate to the value transferred (e.g. -`amount = 1`, `swap_fee = 5` costs the holder 6 to deliver 1). -Servers SHOULD choose an `amount` well above the swap fee of the -mints they accept, and a holder MAY decline a charge it judges -uneconomic. This is a pricing property of the chosen amount and -keyset fee, not an error condition. +A NUT-03 swap deducts an input fee set by the input proofs' +keyset(s), `swap_fee = ceil(sum(input_fee_ppk) / 1000)`, where +`input_fee_ppk` is published per keyset in the mint's keyset list +({{NUT-02}}, {{NUT-03}}). The fee is deterministic from the presented +proofs, so both holder and server compute the same value before the +token is presented. + +The "cashu" charge is exact-amount and the server makes no change, so +the holder pre-funds the fee: the presented token's total value MUST +equal `amount + swap_fee`. The server swaps the whole token, the mint +deducts `swap_fee`, and the server's outputs sum to exactly `amount`. +For a zero-fee keyset this reduces to `presented == amount`. The +server's exact-value check ({{verification}}, step 12) recomputes +`swap_fee` from the presented proofs and never trusts a +client-supplied value. + +Selecting proofs to total `amount + swap_fee` has a mild fixpoint (a +proof added to cover the fee can raise the fee); standard fee-aware +coin selection resolves it. Where `swap_fee` is large relative to +`amount` the charge is uneconomic rather than impossible (see +{{security-fees}}). # Encoding Conventions {#encoding} @@ -356,8 +329,7 @@ auth-param in `WWW-Authenticate`, the credential token in The `request` object is a JCS-canonical JSON object. The Cashu payment request (`methodDetails.request`) and the Cashu token (`payload.cashu_token`) are each carried as an opaque string value -within that JSON, exactly as a BOLT11 invoice is carried as a -string value in the "lightning" method. The `creqA...` (or +within that JSON. The `creqA...` (or `creqb1...` {{NUT-26}}) and `cashuB...` strings have their own internal encoding {{NUT-18}} {{NUT-00}}; that encoding is opaque to the framework and is never canonicalized by it. Conformance to the @@ -388,10 +360,14 @@ amount currency : REQUIRED. The Cashu unit string {{NUT-00}} the presented token - MUST carry (e.g., "sat", "pop_1782668279"). It MUST equal the - unit encoded in `methodDetails.request`. This is a - method-defined currency identifier per - {{I-D.payment-intent-charge}}. + MUST carry (e.g., "sat"). It MUST equal the unit encoded in + `methodDetails.request`. This is a method-defined currency + identifier per {{I-D.payment-intent-charge}}: the Cashu unit + string is itself the `currency` value, and `amount` is an integer + count of that unit. This method does not decompose a unit into a + parent asset and a sub-unit; units that differ in scale are + distinct `currency` values (for example, "sat" and "msat" are two + different units, never the same currency at different precisions). description : OPTIONAL. A human-readable memo describing the resource or @@ -419,9 +395,9 @@ The following fields are nested under `methodDetails` in the request JSON. The Cashu payment request (`methodDetails.request`) is the authoritative source for payment parameters. The `methodDetails.mints` field is the server-chosen set of mints -whose tokens the server will accept; it is the Cashu analog of the -"lightning" method's `network` field. Clients MUST decode and -verify the payment request independently before presenting, and +whose tokens the server will accept for this challenge. Clients MUST +decode and verify the payment request independently before +presenting, and MUST reject challenges where `amount` or `currency` do not match the values encoded in the payment request. @@ -430,7 +406,12 @@ request `creqA...` value). Servers and clients SHOULD also accept the equivalent Bech32m encoding ({{NUT-26}}, a `creqb1...` value); the two encodings are interchangeable and carry the same payment - parameters. This field is authoritative; all payment parameters + parameters. The request is carried in its native encoded form + rather than as the decoded JSON it represents: the encoded string + is the canonical, self-contained artifact existing Cashu wallets + and libraries already produce and parse, and it is byte-identical + to the request used by the NUT-24 {{NUT-24}} binding, so a single + Cashu code path serves both. This field is authoritative; all payment parameters (amount, unit, acceptable mints, spending-condition kind, single-use flag, optional description) are derived from it. Its transport set MUST be empty, which {{NUT-18}} defines as in-band: @@ -574,31 +555,29 @@ Upon receiving a request with a credential, the server MUST: presented proofs ({{fees}}) and verify the token's total value equals `amount + expected_swap_fee` EXACTLY. A token worth more OR less MUST be rejected; the server makes no change. -13. Where present, verify the DLEQ proof {{NUT-12}} on each input - proof. A proof whose DLEQ proof is present but invalid MUST be - rejected; absence of an input-proof DLEQ MUST NOT by itself - cause rejection, because input-proof DLEQ is frequently stripped - from `cashuB` tokens (see {{security-dleq}}). -14. Swap ({{NUT-03}}) the whole token at its mint, following the +13. Swap ({{NUT-03}}) the whole token at its mint, following the durability and idempotency requirements of {{settlement}}. A - successful swap is the redemption step. The server MUST verify - the DLEQ proofs {{NUT-12}} on the blind signatures the swap - returns and SHOULD reject a mint that omits them (see - {{security-dleq}}). A swap rejected because a proof is already + successful swap is the redemption step and is the authoritative + check that the input proofs were genuine, validly signed, and + unspent. The server MUST verify the DLEQ proofs {{NUT-12}} on + the blind signatures the swap returns and SHOULD reject a mint + that omits them (see {{security-dleq}}); it does not verify DLEQ + on the presented input proofs, since the swap already proves + their validity. A swap rejected because a proof is already spent, or because a DLEQ check on the returned signatures fails, is a `verification-failed` condition; a swap rejected because the keyset has retired or its `final_expiry` has passed is a `payment-expired` condition (see {{settlement}}, {{errors}}). -Steps 8 through 13 are structural and MUST be performed before the -network swap in step 14, so a structurally invalid token never +Steps 8 through 12 are structural and MUST be performed before the +network swap in step 13, so a structurally invalid token never produces a mint round trip. The keyset resolution of step 11 MAY require fetching the mint's keysets {{NUT-02}} before the swap. These steps discharge the verification responsibilities the "charge" intent ({{I-D.payment-intent-charge}}) places on the server: challenge-match and freshness are steps 4–7; payment-proof -verification is steps 8–14, where the swap itself is the proof of +verification is steps 8–13, where the swap itself is the proof of payment; the amount-match responsibility is step 12, read as the NET settled amount — the server nets exactly `amount` while the holder pre-funds the swap fee, so the presented total is @@ -618,7 +597,7 @@ server therefore rejects any locked proof before the swap (step 10) as `verification-failed`. Consistently, the challenge's embedded `creqA` MUST set `nut10` to `None` (absent); a `creqA` requesting a spending-condition kind is not used by this intent. Support for -locked tokens is left to a future intent. +spending-condition-locked tokens is out of scope for this intent. ## Challenge Binding @@ -837,11 +816,11 @@ https://paymentauth.org/problems/cashu/payment-expired has passed. A fresh challenge MUST be included in `WWW-Authenticate`. -https://paymentauth.org/problems/cashu/payment-insufficient +https://paymentauth.org/problems/cashu/amount-mismatch : HTTP 402. The token's total value does not equal - `amount + swap_fee` (see {{fees}}). This problem type is used for - both under-funded and over-funded tokens; the server makes no - change. A fresh challenge MUST be included in `WWW-Authenticate`. + `amount + swap_fee` (see {{fees}}) — over- or under-funded; the + server makes no change. A fresh challenge MUST be included in + `WWW-Authenticate`. https://paymentauth.org/problems/cashu/verification-failed : HTTP 402. The token failed a non-amount, non-expiry verification @@ -849,8 +828,8 @@ https://paymentauth.org/problems/cashu/verification-failed more than one mint or unit, its mint is not a member of `methodDetails.mints`, a proof carries a NUT-10 {{NUT-10}} spending condition, a proof uses an unresolvable or ambiguous - short keyset id, a present DLEQ proof {{NUT-12}} is invalid, the - mint omitted DLEQ proofs on the swap-returned signatures, or the + short keyset id, the mint omitted DLEQ proofs on the swap-returned + signatures, or the mint rejected the swap because a proof was already spent. A fresh challenge MUST be included in `WWW-Authenticate`. @@ -858,12 +837,10 @@ https://paymentauth.org/problems/cashu/mint-unavailable : HTTP 503. The mint could not be reached (DNS, TCP, TLS, or timeout), or a swap outcome could not be resolved (see {{durability}}), so the token could neither be verified nor - redeemed. This problem type EXTENDS the base scheme's status set - ({{I-D.httpauth-payment}}), which does not otherwise define a - 503 condition; it is an intentional, transient extension. The - token is NOT consumed and the client MAY retry the same token. - The server SHOULD include a `Retry-After` header and MUST NOT - treat the token as consumed. + redeemed — an infrastructure failure, not a payment-verification + outcome. The token is NOT consumed and the client MAY retry the + same token. The server SHOULD include a `Retry-After` header and + MUST NOT treat the token as consumed. A token whose mint is reachable but is not in `methodDetails.mints`, or whose unit is otherwise disallowed by @@ -877,8 +854,8 @@ Example error response body: ~~~json { - "type": "https://paymentauth.org/problems/cashu/payment-insufficient", - "title": "Payment Insufficient", + "type": "https://paymentauth.org/problems/cashu/amount-mismatch", + "title": "Amount Mismatch", "status": 402, "detail": "Presented token value does not equal amount plus swap fee" } @@ -933,12 +910,13 @@ without that check a malicious mint could make the server report a successful charge for output proofs it never validly signed, which the server then cannot spend. -DLEQ on the presented input proofs is treated more leniently: -input-proof DLEQ is optional and frequently stripped from `cashuB` -tokens, so its absence MUST NOT by itself cause rejection. Where an -input proof does carry a DLEQ proof, the server verifies it and -rejects the token if it is invalid. This placement matches the -Proof-of-Power verifier requirements {{POP}}. +The server does not verify DLEQ on the presented input proofs: +input-proof DLEQ exists to let an offline party verify ecash +without contacting the mint, but here the server swaps the token +immediately and the swap itself proves the inputs were validly +signed and unspent, so an input-proof DLEQ check would add nothing. +It is also frequently stripped from `cashuB` tokens, so requiring it +would needlessly reject valid tokens. ## Amount and Fee Determinism {#security-fees} @@ -962,7 +940,7 @@ of the mints they accept (see {{fees}}). ## Keyset Rotation and Expiry A `final_expiry` boundary {{NUT-02}} can fall between the -last structural check (step 13) and the swap (step 14): a token that +last structural check (step 12) and the swap (step 13): a token that passes verification is not guaranteed to swap, because the mint enforces keyset retirement and `final_expiry` at swap time. Servers MUST treat a swap rejected for keyset retirement or passed @@ -985,20 +963,26 @@ likewise rely on the listed mints to honor the tokens they hold. ## Privacy Cashu tokens are bearer instruments carrying no payer identity, -and the mint's blind signatures {{NUT-00}} prevent the mint from -linking a redeemed token to its issuance. The exact-amount, -non-custodial model preserves this: because the holder splits its -token locally before presenting and keeps the remainder, neither -the server nor the mint observes the remainder or its secrets. The -server sees only a token worth exactly the value it must present. -Implementations MUST NOT log token secrets, and MUST use the token -hash, not the token, as a receipt reference (see {{receipt}}). +and the mint's blind signatures {{NUT-00}} unlink a token's +redemption from its issuance. The exact-amount model adds to this: +because the holder splits its token locally before presenting and +presents only an exact-amount token, neither the server nor the +mint observes the remainder or its secrets, and the server learns +that one charge was paid without learning the size of the holder's +remaining balance. The server still observes that a redemption +occurred — the blind signatures hide the link to issuance, not the +redemption itself. Implementations MUST NOT log token secrets, and +MUST use the token hash, not the token, as a receipt reference (see +{{receipt}}). ## Denial of Service {#security-dos} A token carrying a very large number of proofs inflates both verification cost and the swap fee. Servers SHOULD bound the number -of proofs they accept in a single token and SHOULD rate-limit +of proofs they accept in a single token; this bound is a +server-internal limit and is not advertised in the challenge (which +carries no proof-count field), so a client learns of it only when an +over-large token is rejected. Servers SHOULD also rate-limit challenge issuance and credential-verification attempts per {{I-D.httpauth-payment}}. @@ -1011,22 +995,6 @@ Credentials MUST only be transmitted over HTTPS, and servers MUST redeem a presented token promptly to minimize the window in which an intercepted token could be spent by an attacker. -# Future Work - -This document defines only the exact-amount "charge" intent, in -which each token is presented once and fully consumed, carries no -spending condition, and is funded so the server nets the requested -amount after the swap fee. A future session-based variant -(analogous to a "session" method) could admit a reusable bearer -credential — for example a hash of a deposited token used to -authorize a series of requests — and could return change to the -holder rather than requiring exact-amount presentation. Support for -spending-condition-locked tokens (NUT-10 {{NUT-10}} P2PK/HTLC) and -for fee-bearing models other than holder-pre-funds would likewise -be defined by their own intents. Such variants would define their -own intent and credential schema and are explicitly out of scope -for the "charge" intent specified here. - # IANA Considerations ## Payment Method Registration @@ -1132,24 +1100,23 @@ Decoded receipt: } ~~~ -## Proof-of-Power Unit +## Service-Defined Unit -The same exchange with a Proof-of-Power unit {{POP}}: only the -`currency` and the unit encoded in the payment request differ. The -`pop_` keyset is operationally fee-free today -(`input_fee_ppk = 0`, the operator's choice), so here the presented -token is worth exactly the requested `amount`; against a fee-bearing -keyset it would be `amount + swap_fee` (see {{fees}}). The token is -verified and redeemed identically; the unit's time-locked backing is -enforced by the mint's keyset `final_expiry` {{NUT-02}} at swap time -and is transparent to this method. +The same exchange with a service-defined unit: only the `currency` +and the unit encoded in the payment request differ. Here the mint's +keyset for the unit is fee-free (`input_fee_ppk = 0`, the operator's +choice), so the presented token is worth exactly the requested +`amount`; against a fee-bearing keyset it would be `amount + swap_fee` +(see {{fees}}). The token is verified and redeemed identically; any +backing or expiry the unit carries is enforced by the mint at swap +time and is transparent to this method. Decoded `request`: ~~~json { "amount": "1", - "currency": "pop_1782668279", + "currency": "credits", "methodDetails": { "mints": ["https://mint.example.com"], "request": "creqA..." From 6e8fad0e7fe2ec9d2a3df5f1dd518c3a1b56ccb4 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 9 Jun 2026 09:48:34 -0700 Subject: [PATCH 04/17] editorial: tighten prose for readability in draft-cashu-charge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split several long sentences, un-strand displaced clauses, and drop two redundant restatements. Readability only — no change to any normative requirement, technical claim, number, slug, or reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/methods/cashu/draft-cashu-charge-01.md | 56 +++++++++----------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index 87a2d049..6586b4a1 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -118,10 +118,10 @@ informative: This document defines the "charge" intent for the "cashu" payment method within the Payment HTTP Authentication Scheme {{I-D.httpauth-payment}}. The server issues a Cashu payment request -{{NUT-18}} as a challenge; the client presents a Cashu token whose -value, net of the mint's swap fee, redeems to the requested amount -as a credential, which the server -verifies and redeems by swapping {{NUT-03}} it at the issuing mint. This method +{{NUT-18}} as a challenge; the client presents, as a credential, a +Cashu token whose value, net of the mint's swap fee, settles to the +requested amount. The server verifies the token and redeems it by +swapping {{NUT-03}} it at the issuing mint. This method relocates the challenge-and-token semantics of the existing Cashu HTTP 402 binding {{NUT-24}} into the standard `Authorization`/`WWW-Authenticate` framework. @@ -198,9 +198,9 @@ transport; the client retries with a `cashuB` token in the This document is the standards-aligned sibling of that binding. It relocates the same `creqA`-challenge and `cashuB`-credential semantics into the standard `Authorization`/`WWW-Authenticate` -authentication framework {{I-D.httpauth-payment}}, substituting a -402 response carrying a fresh `WWW-Authenticate: Payment` -re-challenge for NUT-24's flat 400. The embedded `creqA` reuses +authentication framework {{I-D.httpauth-payment}}. In place of +NUT-24's flat 400, it returns a 402 carrying a fresh +`WWW-Authenticate: Payment` re-challenge. The embedded `creqA` reuses NUT-24's challenge field subset `{a, u, m, nut10}` (amount, unit, mints, and spending-condition kind), so a single Cashu code path can serve both bindings: the wire envelope differs, the payment @@ -265,17 +265,17 @@ be lowercase. The "charge" intent represents a one-time payment gating access to a resource. The server advertises a Cashu payment request -({{NUT-18}}) naming an exact amount and unit per request. The -client presents a Cashu token whose value, after the swap fee the -mint will deduct, settles to exactly that amount as the credential. +({{NUT-18}}) naming an exact amount and unit per request. As the +credential, the client presents a Cashu token whose value, after +the swap fee the mint will deduct, settles to exactly that amount. The server verifies the token and redeems it by swapping ({{NUT-03}}) it at the issuing mint; a successful swap both proves the token unspent and transfers its value to the server. The "cashu" charge is exact-amount and makes no change: the server -accepts only a token that, once swapped, nets -the server the requested amount exactly, redeems the whole token, -and keeps the resulting proofs. Where the token's keyset(s) charge +accepts only a token that, once swapped, nets it the requested +amount exactly; it then redeems the whole token and keeps the +resulting proofs. Where the token's keyset(s) charge a NUT-03 input fee, the holder pre-funds that fee in the presented token (see {{fees}}); for fee-free keysets the presented value equals the requested amount. A client holding a token larger than @@ -407,9 +407,9 @@ request equivalent Bech32m encoding ({{NUT-26}}, a `creqb1...` value); the two encodings are interchangeable and carry the same payment parameters. The request is carried in its native encoded form - rather than as the decoded JSON it represents: the encoded string + rather than as the decoded JSON it represents. The encoded string is the canonical, self-contained artifact existing Cashu wallets - and libraries already produce and parse, and it is byte-identical + and libraries already produce and parse. It is also byte-identical to the request used by the NUT-24 {{NUT-24}} binding, so a single Cashu code path serves both. This field is authoritative; all payment parameters (amount, unit, acceptable mints, spending-condition kind, @@ -574,18 +574,17 @@ network swap in step 13, so a structurally invalid token never produces a mint round trip. The keyset resolution of step 11 MAY require fetching the mint's keysets {{NUT-02}} before the swap. -These steps discharge the verification responsibilities the "charge" -intent ({{I-D.payment-intent-charge}}) places on the server: -challenge-match and freshness are steps 4–7; payment-proof +These steps satisfy the verification responsibilities the "charge" +intent ({{I-D.payment-intent-charge}}) places on the server. +Challenge-match and freshness are steps 4–7, and payment-proof verification is steps 8–13, where the swap itself is the proof of -payment; the amount-match responsibility is step 12, read as the -NET settled amount — the server nets exactly `amount` while the +payment. The amount-match responsibility is step 12, read as the +NET settled amount: the server nets exactly `amount` while the holder pre-funds the swap fee, so the presented total is -`amount + expected_swap_fee` (see {{fees}}); and the recipient-match -responsibility is satisfied implicitly, because redemption is the -server swapping the presented token to itself at the mint, so there -is no distinct recipient to compare — the `recipient` field is -correspondingly omitted. +`amount + expected_swap_fee` (see {{fees}}). The recipient-match +responsibility is satisfied implicitly: redemption is the server +swapping the presented token to itself at the mint, so there is no +distinct recipient to compare, and the `recipient` field is omitted. ## Spending-Condition-Locked Tokens {#spending-conditions} @@ -624,9 +623,7 @@ token (see {{security-replay}}). Replay of the underlying token is independently prevented at the proof level: a token can be swapped at most once, after which its proofs are spent and the mint refuses any further swap (see -{{security-replay}}). Challenge binding additionally prevents a -token valid for one challenge from being presented against a -different challenge. +{{security-replay}}). ## Short Keyset Identifiers {#short-keyset} @@ -874,8 +871,7 @@ independently rather than trusting the server's `amount` and token from. A client that skips these checks can be induced to pay a different amount, in a different unit, or against an attacker-substituted mint set. These checks are stated normatively -in the Request Schema and restated here as a security requirement, -as the sibling method specifications do. +in the Request Schema and repeated here as a security requirement. ## Token Replay {#security-replay} From d7b2c09e5c5f6f1321982c9482bffbe9911a9d88 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 9 Jun 2026 10:03:39 -0700 Subject: [PATCH 05/17] editorial: drop the NUT-24-relocation line from the abstract The Relationship to NUT-24 section covers it; the abstract states what the method is. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/methods/cashu/draft-cashu-charge-01.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index 6586b4a1..71611fe4 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -121,10 +121,7 @@ method within the Payment HTTP Authentication Scheme {{NUT-18}} as a challenge; the client presents, as a credential, a Cashu token whose value, net of the mint's swap fee, settles to the requested amount. The server verifies the token and redeems it by -swapping {{NUT-03}} it at the issuing mint. This method -relocates the challenge-and-token semantics of the existing Cashu -HTTP 402 binding {{NUT-24}} into the standard -`Authorization`/`WWW-Authenticate` framework. +swapping {{NUT-03}} it at the issuing mint. --- middle From 974100446b7a7b4795662767884cd004cffb4a12 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 9 Jun 2026 10:25:38 -0700 Subject: [PATCH 06/17] editorial: apply missing-details + granularity review pass Subtract the repeated swap-fee formula (now stated once, in #fees) and two duplicated rationales; reduce the NUT-24 section to a one-liner anchored on NUT-18. Add requirement-level money-safety (byte-for-byte challenge echo for the stateless HMAC, idempotency-key reuse rejected, redemption committed as one durable state transition). Cite the NUT-06 /v1/info pubkey for mint identity (SHOULD, URL fallback) instead of the per-keyset NUT-01 keys, and note the HMAC key is a server secret. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/methods/cashu/draft-cashu-charge-01.md | 110 ++++++++----------- 1 file changed, 48 insertions(+), 62 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index 71611fe4..c5ff6396 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -75,9 +75,9 @@ normative: date: 2024 informative: - NUT-01: - title: "NUT-01: Mint public key exchange" - target: https://github.com/cashubtc/nuts/blob/main/01.md + NUT-06: + title: "NUT-06: Mint information" + target: https://github.com/cashubtc/nuts/blob/main/06.md author: - org: Cashu date: 2024 @@ -185,23 +185,11 @@ The flow proceeds as follows: ## Relationship to NUT-24 {#relationship-nut24} -NUT-24 {{NUT-24}} is the existing Cashu binding of HTTP 402 -"Payment Required". In NUT-24, a server returns HTTP 402 with an -`X-Cashu` header carrying a NUT-18 {{NUT-18}} `creqA` payment -request restricted to the fields `{a, u, m, nut10}` with an empty -transport; the client retries with a `cashuB` token in the -`X-Cashu` header; a bad mint, unit, or amount yields HTTP 400. - -This document is the standards-aligned sibling of that binding. It -relocates the same `creqA`-challenge and `cashuB`-credential -semantics into the standard `Authorization`/`WWW-Authenticate` -authentication framework {{I-D.httpauth-payment}}. In place of -NUT-24's flat 400, it returns a 402 carrying a fresh -`WWW-Authenticate: Payment` re-challenge. The embedded `creqA` reuses -NUT-24's challenge field subset `{a, u, m, nut10}` (amount, unit, -mints, and spending-condition kind), so a single Cashu code path -can serve both bindings: the wire envelope differs, the payment -request and token do not. +NUT-24 {{NUT-24}} binds the same NUT-18 {{NUT-18}} payment request to +HTTP 402 over an `X-Cashu` header; this method binds it over the +standard `Authorization`/`WWW-Authenticate` framework instead. The +embedded `creqA` is byte-identical, so one Cashu code path can serve +both. ## Relationship to the Charge Intent @@ -247,11 +235,9 @@ DLEQ Proof {{security-dleq}}). Swap Fee -: The NUT-03 input fee a swap deducts from the input proofs, - `swap_fee = ceil(sum(input_fee_ppk over input proofs) / 1000)`, - where each keyset's `input_fee_ppk` is published per {{NUT-02}}. - Deterministic from the input proofs' keysets, so it can be - computed offline before a token is presented. +: The NUT-03 input fee a swap deducts from the input proofs (see + {{fees}}). Deterministic from the input proofs' keysets, so it can + be computed offline before a token is presented. # Intent Identifier @@ -272,12 +258,11 @@ the token unspent and transfers its value to the server. The "cashu" charge is exact-amount and makes no change: the server accepts only a token that, once swapped, nets it the requested amount exactly; it then redeems the whole token and keeps the -resulting proofs. Where the token's keyset(s) charge -a NUT-03 input fee, the holder pre-funds that fee in the presented -token (see {{fees}}); for fee-free keysets the presented value -equals the requested amount. A client holding a token larger than -that value MUST split it locally at the mint before presenting (see -{{settlement}}); the remainder is never seen by the server. +resulting proofs. The holder pre-funds the swap fee, so the presented +token's value is `amount + swap_fee` (see {{fees}}). A client holding +a token larger than that value MUST split it locally at the mint +before presenting (see {{settlement}}); the remainder is never seen +by the server. ## Fees {#fees} @@ -299,9 +284,7 @@ client-supplied value. Selecting proofs to total `amount + swap_fee` has a mild fixpoint (a proof added to cover the fee can raise the fee); standard fee-aware -coin selection resolves it. Where `swap_fee` is large relative to -`amount` the charge is uneconomic rather than impossible (see -{{security-fees}}). +coin selection resolves it. # Encoding Conventions {#encoding} @@ -537,8 +520,9 @@ Upon receiving a request with a credential, the server MUST: past `expires` is a `payment-expired` condition (see {{errors}}). 8. Verify the token's unit equals `currency`. 9. Verify the token's mint is a member of `methodDetails.mints`. - Mint membership SHOULD be compared by mint identity key - {{NUT-01}} rather than by URL string. + Mint membership SHOULD be compared by the mint's public key (the + `pubkey` of `GET /v1/info` {{NUT-06}}) when present, falling back + to the canonicalized mint URL when it is absent. 10. Verify that no proof carries a NUT-10 {{NUT-10}} well-known (P2PK or HTLC) secret. This intent accepts plain-secret BEARER proofs only; a proof bound to a spending condition is rejected @@ -558,10 +542,9 @@ Upon receiving a request with a credential, the server MUST: check that the input proofs were genuine, validly signed, and unspent. The server MUST verify the DLEQ proofs {{NUT-12}} on the blind signatures the swap returns and SHOULD reject a mint - that omits them (see {{security-dleq}}); it does not verify DLEQ - on the presented input proofs, since the swap already proves - their validity. A swap rejected because a proof is already - spent, or because a DLEQ check on the returned signatures fails, + that omits them (see {{security-dleq}}). A swap rejected because + a proof is already spent, or because a DLEQ check on the returned + signatures fails, is a `verification-failed` condition; a swap rejected because the keyset has retired or its `final_expiry` has passed is a `payment-expired` condition (see {{settlement}}, {{errors}}). @@ -604,8 +587,12 @@ challenge `id` and verify, when a credential is presented, that The server SHOULD compute `id` as the HMAC-SHA256 binding defined by {{I-D.httpauth-payment}} so that binding is stateless; alternatively the server MAY store issued challenges and verify by -lookup. The challenge MUST be constructed with all framework- -REQUIRED auth-params — `id`, `realm`, `method`, `intent`, and +lookup. Under stateless operation a presented credential MUST echo +each challenge auth-param byte-for-byte as issued — in particular +the `request` string, which the server MUST NOT decode and +re-encode — or the `id` recomputation will not match. The challenge +MUST be constructed with all framework-REQUIRED auth-params — `id`, +`realm`, `method`, `intent`, and `request` — and the server SHOULD include `expires`; when the charge gates a request with a body the server SHOULD include `digest` and SHOULD include any `opaque` correlation data it needs @@ -681,12 +668,13 @@ MUST therefore: - Serialize redemption per presented token and per `challenge.id`, so concurrent requests presenting the same token or hitting the same challenge cannot issue two swaps. The redemption and the - decision to return HTTP 200 MUST be a single atomic operation - (see {{security-replay}}). + decision to return HTTP 200 MUST be committed as a single durable + state transition (see {{security-replay}}). - Accept an `Idempotency-Key` request header per {{I-D.httpauth-payment}} for non-idempotent target methods and, on a retry bearing the same key, return the original response - without re-swapping. + without re-swapping; a key reused with a different token or + challenge MUST be rejected. If a swap request times out or returns 5xx with an indeterminate outcome, the server MUST NOT blindly re-swap. It MUST first query @@ -917,18 +905,11 @@ The "cashu" charge is exact-amount. The server MUST verify that the token's total value equals `amount + expected_swap_fee` exactly (see {{fees}}), rejecting both over- and under-funded tokens, and MUST perform this check before the swap. The swap fee is -deterministic: it is a pure function of the presented proofs' -keysets and the per-keyset `input_fee_ppk` published by the mint -{{NUT-02}}, so the holder computes the same `amount + swap_fee` the -server will check, and the server recomputes the fee from the -proofs it actually received rather than trusting any client- -supplied value. Pre-funding the fee on the holder side keeps the -whole-token redemption and the exact net `amount` mutually -satisfiable, which a naive "present exactly `amount`" rule would -not be at any keyset with `input_fee_ppk > 0`. Where the swap fee -is large relative to `amount` the charge remains satisfiable but -uneconomic; servers SHOULD price `amount` well above the swap fee -of the mints they accept (see {{fees}}). +deterministic (see {{fees}}), so the server recomputes it from the +proofs it actually received rather than trusting any client-supplied +value. Where the swap fee is large relative to `amount` the charge +remains satisfiable but uneconomic; servers SHOULD price `amount` +well above the swap fee of the mints they accept. ## Keyset Rotation and Expiry @@ -948,10 +929,13 @@ proofs even when the input keyset is on the verge of retiring. The server trusts the mints it lists in `methodDetails.mints`: a listed mint custodies the value the server redeems and could, in principle, refuse to honor a swap or rotate its keyset early. -Servers MUST choose the mint set and SHOULD identify mints by -mint identity key {{NUT-01}} rather than by URL, so that a -DNS or URL takeover cannot substitute an untrusted mint. Clients -likewise rely on the listed mints to honor the tokens they hold. +Servers MUST choose the mint set and SHOULD identify a mint by its +public key (the `pubkey` of `GET /v1/info` {{NUT-06}}) when the mint +publishes one, so that a DNS or URL takeover cannot substitute an +untrusted mint; because that field is OPTIONAL, a server SHOULD pin +each accepted mint's identity out of band rather than trust it on +first contact. Clients likewise rely on the listed mints to honor +the tokens they hold. ## Privacy @@ -966,7 +950,9 @@ remaining balance. The server still observes that a redemption occurred — the blind signatures hide the link to issuance, not the redemption itself. Implementations MUST NOT log token secrets, and MUST use the token hash, not the token, as a receipt reference (see -{{receipt}}). +{{receipt}}). The stateless `id`-HMAC key is a server secret and +MUST NOT be logged or shared; its compromise lets an attacker forge +challenges and defeat challenge binding. ## Denial of Service {#security-dos} From d87b2f2fd55684216e8d56fe903008ee8ae38b74 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 9 Jun 2026 10:40:36 -0700 Subject: [PATCH 07/17] =?UTF-8?q?editorial:=20verbosity=20pass=20=E2=80=94?= =?UTF-8?q?=20cut=20cross-section=20repetition;=20add=20Mint=20term?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the exact-amount/pre-fund rule and the challenge-binding and fee-determinism points to one statement each; one-sentence the opaque-embedded-strings note; drop two request-field asides and the redundant proof-level-replay paragraph. Add a Mint terminology entry. All subtractive — no MUST/SHOULD, value, error mapping, or implementation detail removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/methods/cashu/draft-cashu-charge-01.md | 115 +++++++------------ 1 file changed, 43 insertions(+), 72 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index c5ff6396..0ecb71e3 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -143,14 +143,11 @@ unit, and acceptable mint set in the challenge, and the client returns a token the server redeems to settle. The "cashu" method is independent of what the mint's unit -denominates. The unit is a label chosen by the service that issues -the token: it MAY be a denomination of an external asset (for -example, "sat") or a unit meaningful only within the issuing -service. This document defines the generic Cashu charge exchange and -treats the unit as an opaque identifier carried in the `currency` -field; what a unit denominates, and any backing or redemption -semantics, are defined by the issuing service and are out of scope -here. +denominates. The unit is a label chosen by the issuing service — it +MAY denote an external asset (for example, "sat") or a unit +meaningful only within that service. This document treats it as an +opaque `currency` identifier; its backing and redemption semantics +are out of scope here. The flow proceeds as follows: @@ -210,6 +207,11 @@ Cashu Token single mint under a single unit, the mint's URL, and that unit. The authoritative value carried by the credential. +Mint +: The Cashu issuer that blind-signs proofs and swaps {{NUT-03}} them + under its keysets {{NUT-02}}. Each token names the single mint that + issued it. + Payment Request : A Cashu payment request {{NUT-18}} (a `creqA...` string, or the `creqb1...` Bech32m form {{NUT-26}}) encoding the amount, unit, @@ -255,14 +257,11 @@ The server verifies the token and redeems it by swapping ({{NUT-03}}) it at the issuing mint; a successful swap both proves the token unspent and transfers its value to the server. -The "cashu" charge is exact-amount and makes no change: the server -accepts only a token that, once swapped, nets it the requested -amount exactly; it then redeems the whole token and keeps the -resulting proofs. The holder pre-funds the swap fee, so the presented -token's value is `amount + swap_fee` (see {{fees}}). A client holding -a token larger than that value MUST split it locally at the mint -before presenting (see {{settlement}}); the remainder is never seen -by the server. +The "cashu" charge is exact-amount: the server redeems the whole +token and makes no change, so the holder pre-funds the swap fee and +the presented value is `amount + swap_fee` (see {{fees}}). A holder +of a larger token MUST split it locally first (see {{settlement}}); +the remainder is never seen by the server. ## Fees {#fees} @@ -273,12 +272,10 @@ keyset(s), `swap_fee = ceil(sum(input_fee_ppk) / 1000)`, where proofs, so both holder and server compute the same value before the token is presented. -The "cashu" charge is exact-amount and the server makes no change, so -the holder pre-funds the fee: the presented token's total value MUST -equal `amount + swap_fee`. The server swaps the whole token, the mint -deducts `swap_fee`, and the server's outputs sum to exactly `amount`. -For a zero-fee keyset this reduces to `presented == amount`. The -server's exact-value check ({{verification}}, step 12) recomputes +Because the charge is exact-amount with no change, the holder +pre-funds the fee: the presented token's total value MUST equal +`amount + swap_fee` (for a zero-fee keyset, `presented == amount`). +The server's exact-value check ({{verification}}, step 12) recomputes `swap_fee` from the presented proofs and never trusts a client-supplied value. @@ -306,15 +303,11 @@ auth-param in `WWW-Authenticate`, the credential token in `Authorization`, and the receipt token in `Payment-Receipt`. -The `request` object is a JCS-canonical JSON object. The Cashu -payment request (`methodDetails.request`) and the Cashu token -(`payload.cashu_token`) are each carried as an opaque string value -within that JSON. The `creqA...` (or -`creqb1...` {{NUT-26}}) and `cashuB...` strings have their own -internal encoding {{NUT-18}} {{NUT-00}}; that encoding is opaque to -the framework and is never canonicalized by it. Conformance to the -JCS requirement is a property of the enclosing JSON object, not of -these embedded strings. +The Cashu payment request (`methodDetails.request`) and the Cashu +token (`payload.cashu_token`) are opaque string values within the +JCS-canonical `request` object; their own internal encoding +({{NUT-18}}, {{NUT-00}}) is never canonicalized — JCS conformance is +a property of the enclosing object only. # Request Schema @@ -385,13 +378,8 @@ request : REQUIRED. The Cashu payment request string ({{NUT-18}}, a `creqA...` value). Servers and clients SHOULD also accept the equivalent Bech32m encoding ({{NUT-26}}, a `creqb1...` value); - the two encodings are interchangeable and carry the same payment - parameters. The request is carried in its native encoded form - rather than as the decoded JSON it represents. The encoded string - is the canonical, self-contained artifact existing Cashu wallets - and libraries already produce and parse. It is also byte-identical - to the request used by the NUT-24 {{NUT-24}} binding, so a single - Cashu code path serves both. This field is authoritative; all payment parameters + the two encodings are interchangeable. This field is + authoritative; all payment parameters (amount, unit, acceptable mints, spending-condition kind, single-use flag, optional description) are derived from it. Its transport set MUST be empty, which {{NUT-18}} defines as in-band: @@ -554,17 +542,13 @@ network swap in step 13, so a structurally invalid token never produces a mint round trip. The keyset resolution of step 11 MAY require fetching the mint's keysets {{NUT-02}} before the swap. -These steps satisfy the verification responsibilities the "charge" -intent ({{I-D.payment-intent-charge}}) places on the server. -Challenge-match and freshness are steps 4–7, and payment-proof -verification is steps 8–13, where the swap itself is the proof of -payment. The amount-match responsibility is step 12, read as the -NET settled amount: the server nets exactly `amount` while the -holder pre-funds the swap fee, so the presented total is -`amount + expected_swap_fee` (see {{fees}}). The recipient-match -responsibility is satisfied implicitly: redemption is the server -swapping the presented token to itself at the mint, so there is no -distinct recipient to compare, and the `recipient` field is omitted. +These steps satisfy the "charge" intent's verification +responsibilities ({{I-D.payment-intent-charge}}): challenge-match and +freshness in steps 4–7, payment-proof verification (the swap itself) +in steps 8–13, and amount-match in step 12 (read as the NET settled +amount, per {{fees}}). Recipient-match is implicit — redemption swaps +the token to the server itself, so there is no distinct recipient and +`recipient` is omitted. ## Spending-Condition-Locked Tokens {#spending-conditions} @@ -604,11 +588,6 @@ does not depend on it: it comes from challenge binding (above) together with the proof-level single-use property of the redeemed token (see {{security-replay}}). -Replay of the underlying token is independently prevented at the -proof level: a token can be swapped at most once, after which its -proofs are spent and the mint refuses any further swap (see -{{security-replay}}). - ## Short Keyset Identifiers {#short-keyset} A proof carries a keyset id identifying the signing key. A @@ -640,12 +619,10 @@ settlement is final once the swap succeeds: the input proofs are spent and cannot be restored. The server makes no change and returns no proofs to the client. -Because the "cashu" charge is exact-amount, a client holding a -token larger than the value it must present MUST split it locally -before presenting. The client swaps ({{NUT-03}}) its token at the -mint into (a) a token worth exactly `amount + swap_fee`, which it -presents, and (b) a remainder it keeps, generating the blinded -outputs for both halves itself. This local split is itself a +The client swaps ({{NUT-03}}) its larger token at the mint into (a) +a token worth exactly `amount + swap_fee`, which it presents, and +(b) a remainder it keeps, generating the blinded outputs for both +halves itself. This local split is itself a fee-bearing swap: to end up holding a presentable token worth `amount + swap_fee` AND keep a remainder, the holder must spend inputs worth `amount + swap_fee + split_fee`, where `split_fee` is @@ -854,9 +831,7 @@ independently rather than trusting the server's `amount` and `amount` and `currency` fields, and MUST confirm `methodDetails.mints` contains a mint it trusts and can obtain a token from. A client that skips these checks can be induced to pay -a different amount, in a different unit, or against an -attacker-substituted mint set. These checks are stated normatively -in the Request Schema and repeated here as a security requirement. +the wrong amount or unit, or to an attacker-substituted mint. ## Token Replay {#security-replay} @@ -901,15 +876,11 @@ would needlessly reject valid tokens. ## Amount and Fee Determinism {#security-fees} -The "cashu" charge is exact-amount. The server MUST verify that the -token's total value equals `amount + expected_swap_fee` exactly -(see {{fees}}), rejecting both over- and under-funded tokens, and -MUST perform this check before the swap. The swap fee is -deterministic (see {{fees}}), so the server recomputes it from the -proofs it actually received rather than trusting any client-supplied -value. Where the swap fee is large relative to `amount` the charge -remains satisfiable but uneconomic; servers SHOULD price `amount` -well above the swap fee of the mints they accept. +The exact-amount check ({{verification}} step 12, see {{fees}}) is +the server's guard against an over- or under-funded token, performed +before the swap. Where the swap fee is large relative to `amount` the +charge remains satisfiable but uneconomic; servers SHOULD price +`amount` well above the swap fee of the mints they accept. ## Keyset Rotation and Expiry From 0e1025e698ff836e4696117fcf9e29fe7afb542b Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 9 Jun 2026 15:41:18 -0700 Subject: [PATCH 08/17] spec(cashu): determinate failure semantics + review-round-2 fixes Fresh-eyes review (client/server/adversary/editor) + owner walk: crash-recovery invariant restored; post-swap DLEQ = mint-trust incident, not payment failure; indeterminate swaps resolved via NUT-09/NUT-07; lost-response and payment-expired client guidance; swap-rejection else-branch; expires REQUIRED under stateless; problem types reworked to framework generics (cashu/ keeps amount-mismatch, mint-unavailable); resolved-keyset unit check; per-proof fee summation; exhaustive URL canonicalization; JCS-valid worked example; security-section dedup. Adversarially verified; one contradiction caught and fixed pre-commit. Co-Authored-By: Claude Fable 5 --- specs/methods/cashu/draft-cashu-charge-01.md | 641 +++++++++++-------- 1 file changed, 365 insertions(+), 276 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index 0ecb71e3..7c3e40a5 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -17,6 +17,7 @@ author: normative: RFC2119: RFC3339: + RFC3986: RFC4648: RFC8174: RFC8259: @@ -73,6 +74,12 @@ normative: author: - org: Cashu date: 2024 + NUT-26: + title: "NUT-26: Bech32m payment requests" + target: https://github.com/cashubtc/nuts/blob/main/26.md + author: + - org: Cashu + date: 2025 informative: NUT-06: @@ -93,18 +100,18 @@ informative: author: - org: Cashu date: 2024 + NUT-13: + title: "NUT-13: Deterministic secrets" + target: https://github.com/cashubtc/nuts/blob/main/13.md + author: + - org: Cashu + date: 2024 NUT-24: title: "NUT-24: HTTP 402 Payment Required" target: https://github.com/cashubtc/nuts/blob/main/24.md author: - org: Cashu date: 2025 - NUT-26: - title: "NUT-26: Bech32m payment requests" - target: https://github.com/cashubtc/nuts/blob/main/26.md - author: - - org: Cashu - date: 2025 W3C-DID: title: "Decentralized Identifiers (DIDs) v1.0" target: https://www.w3.org/TR/did-core/ @@ -158,7 +165,7 @@ The flow proceeds as follows: |--------------------------> | | | | | | (2) 402 Payment Required | | - | (request, mints) | | + | (paymentRequest) | | |<-------------------------- | | | | | | (3) Swap token to exact | | @@ -243,8 +250,8 @@ Swap Fee # Intent Identifier -The intent identifier for this specification is "charge". It MUST -be lowercase. +The intent identifier for this specification is "charge" +(lowercase, per {{I-D.httpauth-payment}}). # Intent: "charge" @@ -260,22 +267,25 @@ the token unspent and transfers its value to the server. The "cashu" charge is exact-amount: the server redeems the whole token and makes no change, so the holder pre-funds the swap fee and the presented value is `amount + swap_fee` (see {{fees}}). A holder -of a larger token MUST split it locally first (see {{settlement}}); +of a larger token splits it locally first (see {{settlement}}); the remainder is never seen by the server. ## Fees {#fees} A NUT-03 swap deducts an input fee set by the input proofs' -keyset(s), `swap_fee = ceil(sum(input_fee_ppk) / 1000)`, where +keyset(s): one term per input PROOF, each contributing its own +keyset's `input_fee_ppk`, summed and divided once with a ceiling — +`swap_fee = ceil(sum_over_proofs(input_fee_ppk) / 1000)` — where `input_fee_ppk` is published per keyset in the mint's keyset list -({{NUT-02}}, {{NUT-03}}). The fee is deterministic from the presented -proofs, so both holder and server compute the same value before the -token is presented. +({{NUT-02}}, {{NUT-03}}). Twelve proofs at 100 ppk owe 2, not 1: +the sum runs over proofs, not distinct keysets. The fee is +deterministic from the presented proofs, so both holder and server +compute the same value before the token is presented. Because the charge is exact-amount with no change, the holder pre-funds the fee: the presented token's total value MUST equal `amount + swap_fee` (for a zero-fee keyset, `presented == amount`). -The server's exact-value check ({{verification}}, step 12) recomputes +The server's exact-value check ({{verification}}, step 9) recomputes `swap_fee` from the presented proofs and never trusts a client-supplied value. @@ -293,17 +303,20 @@ any digest or signature operations defined by the base spec {{I-D.httpauth-payment}}. The resulting bytes MUST then be encoded using base64url -{{RFC4648}} Section 5 without padding characters -(`=`). Implementations MUST NOT append `=` padding -when encoding, and MUST accept input with or without padding when -decoding. +{{RFC4648}} Section 5 without padding characters (`=`). +Implementations MUST NOT append `=` padding when encoding. A +padded header value is malformed under the framework's grammar +({{I-D.httpauth-payment}}); the cashu artifacts carried inside +JSON strings (`creqA...`, `cashuB...`) MUST be accepted with or +without padding, as their own encodings allow ({{NUT-18}}, +{{NUT-00}}). This encoding convention applies to: the `request` -auth-param in `WWW-Authenticate`, the credential token in -`Authorization`, and the receipt token in +auth-param in `WWW-Authenticate`, the credential in +`Authorization`, and the receipt in `Payment-Receipt`. -The Cashu payment request (`methodDetails.request`) and the Cashu +The Cashu payment request (`methodDetails.paymentRequest`) and the Cashu token (`payload.cashu_token`) are opaque string values within the JCS-canonical `request` object; their own internal encoding ({{NUT-18}}, {{NUT-00}}) is never canonicalized — JCS conformance is @@ -319,22 +332,18 @@ header contains a JCS-serialized, base64url-encoded JSON object included in that object: amount -: REQUIRED. The required amount in the base units of the Cashu - unit, encoded as a canonical decimal string of ASCII digits - (e.g., "100"). The value MUST be a positive integer with no - leading zeros, no sign, no whitespace, and no fractional part, - and MUST fit in an unsigned 64-bit integer. Servers MUST emit - `amount` in this canonical form; since it is server-authored and - echoed in the credential, an altered or malformed echoed `amount` - is tampering, rejected as `invalid-challenge` (step 5). This value is the amount the server - nets after the swap; it MUST equal the amount encoded in - `methodDetails.request`. The presented token value is - `amount + swap_fee` (see {{fees}}). +: REQUIRED. The amount charged, in the base units of the Cashu + unit, as a canonical decimal string (e.g., "100") — a positive + integer per the charge intent ({{I-D.payment-intent-charge}}). + This is the amount the server nets after the swap; it MUST equal + the `a` value encoded in `methodDetails.paymentRequest`, + compared as integers. The + presented token value is `amount + swap_fee` (see {{fees}}). currency : REQUIRED. The Cashu unit string {{NUT-00}} the presented token MUST carry (e.g., "sat"). It MUST equal the unit encoded in - `methodDetails.request`. This is a method-defined currency + `methodDetails.paymentRequest`. This is a method-defined currency identifier per {{I-D.payment-intent-charge}}: the Cashu unit string is itself the `currency` value, and `amount` is an integer count of that unit. This method does not decompose a unit into a @@ -364,39 +373,36 @@ externalId ## Method Details -The following fields are nested under `methodDetails` in the -request JSON. The Cashu payment request (`methodDetails.request`) -is the authoritative source for payment parameters. The -`methodDetails.mints` field is the server-chosen set of mints -whose tokens the server will accept for this challenge. Clients MUST -decode and verify the payment request independently before -presenting, and +The following field is nested under `methodDetails` in the +request JSON. The Cashu payment request +(`methodDetails.paymentRequest`) is the authoritative source for +all payment parameters, including the set of mints whose tokens +the server accepts for this challenge. Clients MUST decode and +verify the payment request independently before presenting, and MUST reject challenges where `amount` or `currency` do not match the values encoded in the payment request. -request +paymentRequest : REQUIRED. The Cashu payment request string ({{NUT-18}}, a `creqA...` value). Servers and clients SHOULD also accept the equivalent Bech32m encoding ({{NUT-26}}, a `creqb1...` value); the two encodings are interchangeable. This field is authoritative; all payment parameters - (amount, unit, acceptable mints, spending-condition kind, - single-use flag, optional description) are derived from it. Its - transport set MUST be empty, which {{NUT-18}} defines as in-band: + (amount, unit, accepted mints, spending-condition kind, + single-use flag, optional description) are derived from it. The + `a` (amount), `u` (unit), and `m` (mints) fields are OPTIONAL in + {{NUT-18}}, but this method REQUIRES all three: a server MUST + encode `a` and `u`, whose values back the `amount` and `currency` + checks above, and MUST populate `m` with a non-empty set of + accepted mint URLs (compared after canonicalization, see + {{mint-trust}}). A client MUST reject a challenge whose payment + request omits any of them. The payment request's transport set + MUST be empty, which {{NUT-18}} defines as in-band: the credential is returned over the same HTTP channel in the `Authorization` header rather than over a separate transport. Its spending-condition kind MUST be absent (`nut10` is `None`); see - {{verification}}. - -mints -: REQUIRED. A JSON array of mint URL strings whose tokens the - server accepts for this challenge. The server, not the client, - chooses these mints. The presented token's mint MUST be a member - of this array (see {{verification}}). This array MUST be - non-empty and MUST be a superset of, or equal to, the mint set - encoded in `methodDetails.request`. Clients MUST reject a - challenge whose `mints` does not include a mint they can obtain - a token from. + {{verification}}. A NUT-18 payment id (`i`), if present, is + ignored: the challenge `id` identifies the payment. # Credential Schema @@ -408,9 +414,10 @@ challenge : REQUIRED. An echo of the challenge auth-params from the `WWW-Authenticate` header: `id`, `realm`, `method`, `intent`, `request`, and, if present in the challenge, `digest`, `opaque`, - and `expires`. This binds the credential to the exact challenge - that was issued. A client MUST echo each of these fields - unchanged when the server included it (see {{verification}}). + `description`, and `expires`. This binds the credential to the + exact challenge that was issued. A client MUST echo each of + these fields unchanged when the server included it (see + {{verification}}). source : OPTIONAL. A payer identifier string, as defined by @@ -478,89 +485,100 @@ Upon receiving a request with a credential, the server MUST: string MUST be rejected. Reject a token that does not parse or carries zero proofs. Servers SHOULD reject a token carrying more than a configured maximum number of proofs (see {{security-dos}}). -3. Verify that all proofs in the token reference a single mint and a - single unit. Reject any token whose proofs name more than one - mint or more than one unit. -4. Recover and authenticate the challenge parameters. Under - stateless operation (RECOMMENDED), recompute the HMAC-SHA256 - `id` binding of {{I-D.httpauth-payment}} over the echoed - `credential.challenge` parameters with the server key, and - reject the request unless it equals `credential.challenge.id`; - the echoed `request` parameters (amount, unit, accepted mints) - are thereby authenticated. Under stored operation, look up the - challenge by `credential.challenge.id`, reject if none is found, - and take the stored `request` parameters as authoritative. -5. Verify the echoed `credential.challenge` fields (`id`, `realm`, - `method`, `intent`, `request`, and, when issued, `digest`, - `opaque`, `expires`) are consistent with the authenticated - challenge from step 4 — an exact match against the stored - auth-params under stored operation, or covered by the `id`-HMAC - over those same fields under stateless operation. A mismatch is - tampering and MUST be rejected as `invalid-challenge`. -6. If the challenge carried a `digest` auth-param, the server MUST - compute the content digest of the current request body per - {{RFC9530}} and reject the credential if it does not match the - echoed `digest`. -7. If `credential.challenge.expires` is present, the server MUST - reject the credential when that timestamp is in the past. A - Cashu `creqA` carries no expiry of its own, so the `expires` - auth-param is the sole challenge-expiry signal; rejection on a - past `expires` is a `payment-expired` condition (see {{errors}}). -8. Verify the token's unit equals `currency`. -9. Verify the token's mint is a member of `methodDetails.mints`. - Mint membership SHOULD be compared by the mint's public key (the - `pubkey` of `GET /v1/info` {{NUT-06}}) when present, falling back - to the canonicalized mint URL when it is absent. -10. Verify that no proof carries a NUT-10 {{NUT-10}} well-known - (P2PK or HTLC) secret. This intent accepts plain-secret BEARER - proofs only; a proof bound to a spending condition is rejected - as `verification-failed` (see {{spending-conditions}}). -11. Resolve every proof's keyset id against the mint's published - keysets {{NUT-02}}. When a proof uses a short keyset id, the - server MUST resolve it to the full keyset via the mint's fetched - keyset list and MUST reject a short id that is ambiguous or does - not resolve (see {{short-keyset}}). -12. Compute `expected_swap_fee` over the resolved keyset(s) of the - presented proofs ({{fees}}) and verify the token's total value - equals `amount + expected_swap_fee` EXACTLY. A token worth more - OR less MUST be rejected; the server makes no change. -13. Swap ({{NUT-03}}) the whole token at its mint, following the - durability and idempotency requirements of {{settlement}}. A +3. Verify the token declares exactly one mint and one unit. + (TokenV4 makes both structural, so this fails only malformed or + hand-built tokens; the unit's semantic checks are steps 5 + and 8.) +4. Authenticate and validate the echoed challenge per + {{I-D.httpauth-payment}}: recover the challenge — under stateless + operation (RECOMMENDED) recompute the `id`-HMAC over the echoed + `credential.challenge` with the server key, or under stored + operation look it up by `credential.challenge.id` — and verify + its field consistency, body `digest` ({{RFC9530}}), and + `expires` freshness. A + tampered or inconsistent challenge MUST be rejected as + `invalid-challenge`; a `credential.challenge.expires` in the past + is a `payment-expired` condition (see {{errors}}). Because a + Cashu `creqA` carries no expiry of its own, the `expires` + auth-param is the sole challenge-expiry signal. +5. Verify the token's unit equals `currency`. +6. Verify the token's mint is a member of the payment request's + mint set (`m`), comparing by canonicalized mint URL (see + {{mint-trust}}). A token whose mint is not a member is rejected + as `verification-failed`. +7. Verify that no proof carries a NUT-10 {{NUT-10}} well-known + (P2PK or HTLC) secret. This intent accepts plain-secret BEARER + proofs only; a proof bound to a spending condition is rejected + as `verification-failed` (see {{spending-conditions}}). +8. Resolve every proof's keyset id against the mint's published + keysets {{NUT-02}}. When a proof uses a short keyset id, the + server MUST resolve it to the full keyset via the mint's fetched + keyset list and MUST reject a short id that is ambiguous or does + not resolve (see {{short-keyset}}). Verify each resolved + keyset's unit equals `currency`: the token's declared unit is + client-supplied data, the keyset is the authority; a proof whose + keyset belongs to a different unit is rejected as + `verification-failed`. +9. Compute `expected_swap_fee` over the resolved keyset(s) of the + presented proofs ({{fees}}) and verify the token's total value + equals `amount + expected_swap_fee` EXACTLY. A token worth more + OR less MUST be rejected; the server makes no change. +10. Swap ({{NUT-03}}) the whole token at the matched mint's + configured URL (the mint-set entry matched in step 6), + never a URL carried in the token (see {{settlement}}). A successful swap is the redemption step and is the authoritative check that the input proofs were genuine, validly signed, and - unspent. The server MUST verify the DLEQ proofs {{NUT-12}} on - the blind signatures the swap returns and SHOULD reject a mint - that omits them (see {{security-dleq}}). A swap rejected because - a proof is already spent, or because a DLEQ check on the returned - signatures fails, - is a `verification-failed` condition; a swap rejected because - the keyset has retired or its `final_expiry` has passed is a - `payment-expired` condition (see {{settlement}}, {{errors}}). - -Steps 8 through 12 are structural and MUST be performed before the -network swap in step 13, so a structurally invalid token never -produces a mint round trip. The keyset resolution of step 11 MAY -require fetching the mint's keysets {{NUT-02}} before the swap. + unspent. The server SHOULD verify the DLEQ proofs {{NUT-12}} + on the blind signatures the swap returns; a failed or missing + DLEQ proof after a successful swap is a mint-trust incident, + not a payment failure (see {{security-dleq}}). A swap rejected + because a proof is already spent is a `verification-failed` + condition; a swap rejected because the keyset has retired or + its `final_expiry` has passed is a `payment-expired` condition; + a swap rejected for any other reason is a `verification-failed` + condition — the token was not redeemed + (see {{settlement}}, {{errors}}). + +Steps 5 through 9 are structural and MUST be performed before the +network swap in step 10, so a structurally invalid token never +reaches the swap. The keyset resolution of step 8 MAY require +fetching the mint's keysets {{NUT-02}} first. The expected values +these steps check — `currency`, the mint set, `amount` — are +derived from the authenticated challenge's embedded payment +request, the authoritative artifact (see Method Details); the +top-level auth-params were already required to match it at +challenge construction. These steps satisfy the "charge" intent's verification responsibilities ({{I-D.payment-intent-charge}}): challenge-match and -freshness in steps 4–7, payment-proof verification (the swap itself) -in steps 8–13, and amount-match in step 12 (read as the NET settled +freshness in step 4, payment-proof verification (the swap itself) +in steps 5–10, and amount-match in step 9 (read as the NET settled amount, per {{fees}}). Recipient-match is implicit — redemption swaps the token to the server itself, so there is no distinct recipient and `recipient` is omitted. ## Spending-Condition-Locked Tokens {#spending-conditions} -The "cashu" charge accepts plain-secret BEARER proofs only. A -proof whose secret is a NUT-10 {{NUT-10}} well-known secret (for -example a P2PK or HTLC lock) requires a witness the server cannot -produce, so its swap would fail with no diagnosable reason. The -server therefore rejects any locked proof before the swap (step 10) -as `verification-failed`. Consistently, the challenge's embedded -`creqA` MUST set `nut10` to `None` (absent); a `creqA` requesting a -spending-condition kind is not used by this intent. Support for -spending-condition-locked tokens is out of scope for this intent. +This profile accepts plain-secret BEARER proofs only. The +challenge's embedded payment request MUST set `nut10` to `None` +(absent), +and a presented proof carrying a NUT-10 {{NUT-10}} well-known +secret (a P2PK or HTLC lock) is rejected before the swap (step 7) +as `verification-failed`: the server cannot produce the witness +such a proof requires, so its swap would otherwise fail +undiagnosably. + +This is a deliberate v1 boundary, not a permanent one. Locking the +token to the server's key (P2PK) is the natural extension — it +would let a server verify a payment offline (valid DLEQ + +locked-to-itself + locally deduplicated, no swap) and make an +intercepted token unspendable by a thief (see +{{security-transport}}). That changes the verification model +(server keys, witness checks, local double-spend tracking), so it +is left to a future profile. Requiring `nut10` absent here is what +makes that addition safe: a bearer-profile client already rejects +any nut10-carrying challenge, so a later P2PK profile degrades +closed rather than silently misbehaving. ## Challenge Binding @@ -573,12 +591,16 @@ by {{I-D.httpauth-payment}} so that binding is stateless; alternatively the server MAY store issued challenges and verify by lookup. Under stateless operation a presented credential MUST echo each challenge auth-param byte-for-byte as issued — in particular -the `request` string, which the server MUST NOT decode and -re-encode — or the `id` recomputation will not match. The challenge -MUST be constructed with all framework-REQUIRED auth-params — `id`, -`realm`, `method`, `intent`, and -`request` — and the server SHOULD include `expires`; when the -charge gates a request with a body the server SHOULD include +the `request` string, used byte-as-issued (a decode/re-encode +cycle changes the bytes and the recomputation fails). The +challenge carries the framework-REQUIRED auth-params (`id`, +`realm`, `method`, `intent`, `request`). A server operating +statelessly MUST include `expires`: a stateless challenge has no +server-side state to expire it, so one issued without `expires` +never lapses, stays presentable indefinitely, and pins stale +pricing. Under stored operation `expires` remains RECOMMENDED. +When the charge gates a request with a body the server SHOULD +include `digest` and SHOULD include any `opaque` correlation data it needs echoed. @@ -590,21 +612,16 @@ token (see {{security-replay}}). ## Short Keyset Identifiers {#short-keyset} -A proof carries a keyset id identifying the signing key. A -version-`00` keyset id is a short 8-byte (16 hex character) -identifier; a version-`01` keyset id is the full 33-byte (66 hex -character) identifier ({{NUT-02}}). When a presented proof -uses a short keyset id, the server MUST resolve it to a full keyset -by fetching the mint's keyset list {{NUT-02}} and matching, and -MUST derive the keyset per {{NUT-02}}. A short id that matches no -published keyset, or that is ambiguous across the mint's keysets, -MUST be rejected as `verification-failed`. A failure to FETCH the -keyset list at all (a network error reaching the mint), as distinct -from a short id that resolves but matches no or several keysets, is -a `mint-unavailable` (HTTP 503) condition with the token not -consumed (see {{errors}}), not a `verification-failed`. Resolution -is required both to compute the swap fee ({{fees}}) and to construct -correct swap outputs. +When a presented proof uses a short (version-`00`) keyset id, the +server MUST resolve it to the full keyset against the mint's +published keyset list ({{NUT-02}}) — resolution is required to +compute the swap fee ({{fees}}) and construct correct swap outputs. +A short id that resolves to no keyset, or is ambiguous across the +mint's keysets, MUST be rejected as `verification-failed`. A +failure to fetch the keyset list at all (a network error reaching +the mint) is distinct: that is a `mint-unavailable` (HTTP 503) +condition with the token NOT consumed (see {{errors}}), not a +`verification-failed`. # Settlement Procedure {#settlement} @@ -619,62 +636,78 @@ settlement is final once the swap succeeds: the input proofs are spent and cannot be restored. The server makes no change and returns no proofs to the client. +A successful swap is destructive: the input proofs are consumed +whether or not the server retains the result. The server MUST be +able to reconstruct its swap outputs if it crashes between sending +the swap and durably storing the returned signatures — either by +persisting the blinded output secrets before sending the swap, or +by deriving them deterministically ({{NUT-13}}) and recovering the +mint's response via restore ({{NUT-09}}, which covers interrupted +swaps). A server that cannot do so destroys the redeemed value on +a crash: the mint has recorded the inputs as spent, and no party +can recover the outputs. + +A swap whose request was transmitted but whose result was not +received leaves consumption unknown. The server MUST resolve such +an outcome before honoring any further presentation of the same +challenge: restore its own swap outputs ({{NUT-09}}) — always +possible under the reconstruction requirement above, and if the +mint returns signatures the original swap succeeded and the +payment is complete — or check the input proofs' spent state +({{NUT-07}}). Until resolved, the server answers +`mint-unavailable` ({{errors}}). + The client swaps ({{NUT-03}}) its larger token at the mint into (a) a token worth exactly `amount + swap_fee`, which it presents, and (b) a remainder it keeps, generating the blinded outputs for both halves itself. This local split is itself a fee-bearing swap: to end up holding a presentable token worth `amount + swap_fee` AND keep a remainder, the holder must spend -inputs worth `amount + swap_fee + split_fee`, where `split_fee` is -the fee of the local split swap computed over its own input proofs -({{fees}}). Neither the mint nor the server learns the remainder's +inputs worth at least `amount + swap_fee + split_fee`, where +`split_fee` is the fee of the local split swap computed over its +own input proofs ({{fees}}). The presented half consists of the +split's outputs, blinded against the mint's currently ACTIVE +keyset — so the `input_fee_ppk` that prices `swap_fee` is the +active keyset's, which MAY differ from that of the proofs the +holder spent. Neither the mint nor the server learns the remainder's secrets. This local split is the client's responsibility and happens before the `Authorization` request; the server never performs it. -## Durability, Idempotency, and Crash Recovery {#durability} - -The swap is a money-moving operation; a crash or timeout around it -can lose the server's value or double-charge the holder. Servers -MUST therefore: - -- Persist the swap's output secrets (the blinding factors and - blinded messages) BEFORE sending the swap to the mint, so that a - crash after the mint has spent the inputs does not lose the - ability to reconstruct or restore the resulting proofs. -- Serialize redemption per presented token and per `challenge.id`, - so concurrent requests presenting the same token or hitting the - same challenge cannot issue two swaps. The redemption and the - decision to return HTTP 200 MUST be committed as a single durable - state transition (see {{security-replay}}). -- Accept an `Idempotency-Key` request header per - {{I-D.httpauth-payment}} for non-idempotent target methods and, - on a retry bearing the same key, return the original response - without re-swapping; a key reused with a different token or - challenge MUST be rejected. - -If a swap request times out or returns 5xx with an indeterminate -outcome, the server MUST NOT blindly re-swap. It MUST first query -the proofs' state with NUT-07 `/checkstate` {{NUT-07}}: if the -inputs are already spent, the first swap succeeded and the server -recovers its outputs via NUT-09 `/restore` {{NUT-09}} using the -persisted output secrets; only if the inputs are unspent may the -server re-swap. A swap that cannot be resolved this way MUST be -surfaced as `mint-unavailable` (see {{errors}}) with the token -treated as not consumed. - ## Consume-Once and Resource Delivery The server MUST treat the redemption (the swap) and the decision to return HTTP 200 as a single operation: a challenge whose token has been redeemed MUST NOT be accepted again, even if resource delivery -subsequently fails. If resource delivery fails after the token is -redeemed, the server MUST return an appropriate HTTP error +subsequently fails. A server using stored challenges records +consumption only upon — and atomically with — swap success; a +challenge whose swap never succeeded remains presentable +(`mint-unavailable`, {{errors}}). Once the swap succeeds the +payment is complete: +the server MUST NOT respond with a payment-failure status (402) or +issue a fresh challenge for a condition detected after a successful +swap (see {{security-dleq}}). If resource delivery fails after the +token is redeemed, the server MUST return an appropriate HTTP error (e.g., 500) and MUST NOT reissue the same challenge. The client MUST treat such a response as a payment loss and MAY retry with a new token. Cashu settlement is final once the swap succeeds; the redeemed token cannot be refunded by the server. +The same loss mode applies when a success response is lost in +transit: a later re-presentation of the same credential is a new +request against an already-consumed challenge and fails — under +stateless operation at the swap, as a spent token +(`verification-failed`); under stored operation at the challenge +lookup (`invalid-challenge`, {{errors}}). The protocol provides +no replay. A client can confirm what happened with a proof-state +check ({{NUT-07}}). + +A server that implements the framework's optional +`Idempotency-Key` ({{I-D.httpauth-payment}}) MUST perform the +idempotency lookup before the verification procedure: a redeemed +token cannot be re-verified, so a verify-then-replay +implementation never replays. + Servers MUST include `Cache-Control: no-store` on all HTTP 402 responses. The challenge contains a single-use payment request; caching it could cause clients to present a token against a stale @@ -747,17 +780,23 @@ credential is rejected with HTTP 400 per {{I-D.httpauth-payment}}. The 402 problem types below are scoped to payment-verification failures. -The following problem types are defined for this intent: +Payment-verification failures surface as the framework's +registered problem types where their semantics match; this method +defines the cashu-specific causes that map to each. Two +genuinely method-specific conditions are defined under the +`cashu/` namespace (`amount-mismatch`, `mint-unavailable`): -https://paymentauth.org/problems/cashu/malformed-credential -: HTTP 402. The credential token could not be decoded, the JSON +https://paymentauth.org/problems/malformed-credential +: HTTP 402. The credential could not be decoded, the JSON could not be parsed, required fields (`challenge`, `payload`, `payload.cashu_token`) are absent or have the wrong type, - `cashu_token` does not decode as a Cashu token, or the token is a - `cashuA...` (TokenV3) serialization. A fresh challenge + `cashu_token` does not decode as a Cashu token, the token is a + `cashuA...` (TokenV3) serialization, the token carries zero + proofs, or it exceeds the server's proof-count bound + ({{security-dos}}). A fresh challenge MUST be included in `WWW-Authenticate`. -https://paymentauth.org/problems/cashu/invalid-challenge +https://paymentauth.org/problems/invalid-challenge : HTTP 402. The value of `credential.challenge.id` does not match any challenge issued by this server (stored operation), or `credential.challenge` is not an exact echo of an issued @@ -768,41 +807,51 @@ https://paymentauth.org/problems/cashu/invalid-challenge swap as a spent token (`verification-failed`). A fresh challenge MUST be included in `WWW-Authenticate`. -https://paymentauth.org/problems/cashu/payment-expired +https://paymentauth.org/problems/payment-expired : HTTP 402. The challenge `expires` auth-param echoed in the credential is in the past, or the mint rejected the swap because the token's keyset has retired or its `final_expiry` {{NUT-02}} has passed. A fresh challenge MUST be included in - `WWW-Authenticate`. + `WWW-Authenticate`. The two causes need no discriminator: the + client SHOULD re-present the SAME token against the fresh + challenge once; a second consecutive `payment-expired` for that + token means its keyset has expired and the token SHOULD be + abandoned. https://paymentauth.org/problems/cashu/amount-mismatch : HTTP 402. The token's total value does not equal `amount + swap_fee` (see {{fees}}) — over- or under-funded; the server makes no change. A fresh challenge MUST be included in - `WWW-Authenticate`. + `WWW-Authenticate`. This method-specific type covers both + directions; the framework's `payment-insufficient` names only + underpayment and is not used by this method. -https://paymentauth.org/problems/cashu/verification-failed +https://paymentauth.org/problems/verification-failed : HTTP 402. The token failed a non-amount, non-expiry verification check: its unit does not equal `currency`, its proofs reference - more than one mint or unit, its mint is not a member of - `methodDetails.mints`, a proof carries a NUT-10 {{NUT-10}} + more than one mint or unit, its mint is not in the payment + request's mint set, a proof carries a NUT-10 {{NUT-10}} spending condition, a proof uses an unresolvable or ambiguous - short keyset id, the mint omitted DLEQ proofs on the swap-returned - signatures, or the - mint rejected the swap because a proof was already spent. A fresh - challenge MUST be included in `WWW-Authenticate`. + short keyset id, a resolved keyset's unit differs from + `currency`, or the mint rejected the swap — because a proof was + already spent, or for any reason other than keyset retirement or + expiry (verification step 10). A fresh challenge MUST be + included in `WWW-Authenticate`. https://paymentauth.org/problems/cashu/mint-unavailable -: HTTP 503. The mint could not be reached (DNS, TCP, TLS, or - timeout), or a swap outcome could not be resolved (see - {{durability}}), so the token could neither be verified nor - redeemed — an infrastructure failure, not a payment-verification - outcome. The token is NOT consumed and the client MAY retry the - same token. The server SHOULD include a `Retry-After` header and - MUST NOT treat the token as consumed. - -A token whose mint is reachable but is not in -`methodDetails.mints`, or whose unit is otherwise disallowed by +: HTTP 503. The mint could not be reached, or the swap was sent + but its outcome is unknown — an infrastructure failure, not a + payment-verification outcome. The server SHOULD include a + `Retry-After` header. If the swap request was never transmitted + (DNS, connect, or TLS failure), the token is NOT consumed and + the client MAY retry the same token. If the swap was transmitted + but its result was not received, consumption is unknown: the + server MUST NOT claim the token unconsumed and MUST resolve the + outcome before acting further on the same challenge (see + {{settlement}}). + +A token whose mint is reachable but is not in the payment +request's mint set, or whose unit is otherwise disallowed by server policy, is a `verification-failed` condition (HTTP 402), not a policy denial of an otherwise-valid payment. Servers that distinguish a successfully-redeemed payment from a subsequent @@ -824,14 +873,21 @@ Example error response body: ## Client-Side Verification {#security-client} -Before presenting a token, a client MUST verify the challenge -independently rather than trusting the server's `amount` and -`currency` auth-params: it MUST decode `methodDetails.request` -({{NUT-18}}) and confirm the amount and unit it encodes match the -`amount` and `currency` fields, and MUST confirm -`methodDetails.mints` contains a mint it trusts and can obtain a -token from. A client that skips these checks can be induced to pay -the wrong amount or unit, or to an attacker-substituted mint. +A client that skips the independent checks required by the +`paymentRequest` definition (Method Details) — decoding the +payment request and confirming its amount, unit, and mint set — +can be induced to pay the wrong amount or unit, or to pay toward +an attacker-substituted mint. + +After an outcome that never resolved — a timeout, connection +loss, or 5xx after a credential was sent — a client can settle +its token's fate with a proof-state check at the mint +({{NUT-07}}): proofs SPENT mean the server redeemed the token +(the payment happened; whether the resource arrived is the loss +mode of {{settlement}}), proofs UNSPENT mean nothing was redeemed +and the same token remains safe to re-present. Clients SHOULD +perform this check before reusing or writing off a token whose +presentation produced no definite answer. ## Token Replay {#security-replay} @@ -843,28 +899,36 @@ fails verification at the swap step. Servers MUST treat swap success as consume-once: the swap and the decision to return HTTP 200 MUST be atomic, so that concurrent requests presenting the same token result in exactly one success and one rejection, with -no window in which both are accepted (see {{durability}}). +no window in which both are accepted. ## Challenge Binding The token's single-use property protects the token, but not the challenge: absent binding, a token valid for one challenge could -be presented against a different one. Servers MUST bind the -`request` parameters to the challenge `id` (see {{verification}}) -and SHOULD use the HMAC-SHA256 binding of {{I-D.httpauth-payment}} -for stateless verification. A server that neither binds nor stores -its challenges cannot detect a token redirected from another -challenge instance and MUST NOT be considered conformant. +be presented against a different one, and a server that neither +binds nor stores its challenges cannot detect the redirection. +The normative binding and echo rules live in {{verification}} +(Challenge Binding). ## DLEQ Verification {#security-dleq} The security-relevant DLEQ check {{NUT-12}} is on the blind signatures the mint RETURNS from the swap, not on the input proofs -the client presents. Servers MUST verify the DLEQ proofs on the -swap-returned signatures and SHOULD reject a mint that omits them: -without that check a malicious mint could make the server report a -successful charge for output proofs it never validly signed, which -the server then cannot spend. +the client presents. The server SHOULD verify the DLEQ proofs on +the swap-returned signatures: without that check a malicious mint +could report a successful charge while returning output proofs it +never validly signed, which the server then cannot spend. + +A failed or missing DLEQ proof on swap-returned signatures is a +mint-trust incident, not a payment failure. The client's inputs +were genuine and were consumed by the successful swap; only the +mint controls the signatures it returns. The server MUST NOT fail +the payment for it ({{settlement}}): it SHOULD serve the resource, +alert the operator, and quarantine the mint pending investigation. +The check is SHOULD rather than MUST because it protects the +server only against a mint its operator already chose to trust; +operators who rely on it SHOULD select mints that support NUT-12 +(discoverable via the mint's info endpoint {{NUT-06}}). The server does not verify DLEQ on the presented input proofs: input-proof DLEQ exists to let an offline party verify ecash @@ -876,37 +940,52 @@ would needlessly reject valid tokens. ## Amount and Fee Determinism {#security-fees} -The exact-amount check ({{verification}} step 12, see {{fees}}) is -the server's guard against an over- or under-funded token, performed -before the swap. Where the swap fee is large relative to `amount` the +Where the swap fee is large relative to `amount` the charge remains satisfiable but uneconomic; servers SHOULD price `amount` well above the swap fee of the mints they accept. ## Keyset Rotation and Expiry -A `final_expiry` boundary {{NUT-02}} can fall between the -last structural check (step 12) and the swap (step 13): a token that +A `final_expiry` boundary {{NUT-02}} can fall between the last +structural check (verification step 9) and the swap (step 10): a +token that passes verification is not guaranteed to swap, because the mint enforces keyset retirement and `final_expiry` at swap time. Servers MUST treat a swap rejected for keyset retirement or passed `final_expiry` as `payment-expired`, distinct from the -double-spend, disallowed-mint, and bad-DLEQ cases that are -`verification-failed`. Output proofs are blinded against the unit's +double-spend and disallowed-mint cases that are +`verification-failed`. A keyset that is merely inactive — no longer +the mint's current signing keyset but not yet retired or past +`final_expiry` — remains valid for redemption; the server MUST NOT +reject a proof for keyset inactivity alone, only for actual +retirement or expiry. Output proofs are blinded against the unit's ACTIVE keyset (see {{settlement}}), so the server holds spendable proofs even when the input keyset is on the verge of retiring. -## Mint Trust - -The server trusts the mints it lists in `methodDetails.mints`: a -listed mint custodies the value the server redeems and could, -in principle, refuse to honor a swap or rotate its keyset early. -Servers MUST choose the mint set and SHOULD identify a mint by its -public key (the `pubkey` of `GET /v1/info` {{NUT-06}}) when the mint -publishes one, so that a DNS or URL takeover cannot substitute an -untrusted mint; because that field is OPTIONAL, a server SHOULD pin -each accepted mint's identity out of band rather than trust it on -first contact. Clients likewise rely on the listed mints to honor -the tokens they hold. +## Mint Trust {#mint-trust} + +The server trusts the mints it lists in the payment request: a +listed mint custodies the value the server redeems and could, in +principle, refuse to honor a swap or rotate its keyset early. +Membership is decided by canonicalized mint URL (verification +step 6). Two mint URLs are equal when these transformations — +exhaustive; no further {{RFC3986}} normalization (such as +percent-encoding case changes) is applied — yield identical +strings: lowercase the scheme and host (comparing an +internationalized host in its punycode A-label form), drop a +default port (443 for `https`, 80 for `http`), and strip all +trailing slashes (as token serialization does, {{NUT-00}}); the +path and query are otherwise case-sensitive and preserved +verbatim. A mint URL containing userinfo (`user@host`) MUST be +rejected outright. URL equality alone does not defend against a +DNS or TLS takeover of a listed mint's domain, because the +`pubkey` of `GET /v1/info` {{NUT-06}} +would be fetched over the same channel an attacker controls and is +public in any case. The durable control is operator diligence: +choose the mint set deliberately, and where takeover resistance +matters, pin each accepted mint's identity (for example its public +key) out of band rather than trusting it on first contact. Clients +likewise rely on the listed mints to honor the tokens they hold. ## Privacy @@ -919,7 +998,11 @@ mint observes the remainder or its secrets, and the server learns that one charge was paid without learning the size of the holder's remaining balance. The server still observes that a redemption occurred — the blind signatures hide the link to issuance, not the -redemption itself. Implementations MUST NOT log token secrets, and +redemption itself. The mint, however, can still correlate the +holder's pre-payment split with the redemption moments later by +amount and timing; clients that need to avoid that SHOULD hold +pre-made exact-value tokens. +Implementations MUST NOT log token secrets, and MUST use the token hash, not the token, as a receipt reference (see {{receipt}}). The stateless `id`-HMAC key is a server secret and MUST NOT be logged or shared; its compromise lets an attacker forge @@ -931,19 +1014,26 @@ A token carrying a very large number of proofs inflates both verification cost and the swap fee. Servers SHOULD bound the number of proofs they accept in a single token; this bound is a server-internal limit and is not advertised in the challenge (which -carries no proof-count field), so a client learns of it only when an -over-large token is rejected. Servers SHOULD also rate-limit -challenge issuance and credential-verification attempts per -{{I-D.httpauth-payment}}. +carries no proof-count field), so a client learns of it only when +an over-large token is rejected as `malformed-credential`. +Servers SHOULD also rate-limit challenge issuance and +credential-verification attempts per {{I-D.httpauth-payment}}. -## Transport Security +## Transport Security {#security-transport} All communication MUST use TLS per {{I-D.httpauth-payment}}. A Cashu token is a bearer credential: any party that observes it -in transit before it is redeemed can redeem it themselves. -Credentials MUST only be transmitted over HTTPS, and servers MUST -redeem a presented token promptly to minimize the window in which -an intercepted token could be spent by an attacker. +in transit before it is redeemed can redeem it itself. Challenge +binding does not prevent this — it binds the credential to a +challenge, not the token to a holder — so an interceptor at any +TLS-terminating hop (a reverse proxy, a compromised gateway) sees +the plaintext token and can redeem it directly. Credentials MUST +only be transmitted over HTTPS, and servers SHOULD redeem a +presented token promptly; TLS and prompt redemption shrink the +exposure window rather than close it. The structural fix is to lock the +token to the server's key (P2PK) so an intercepted token is +unspendable by anyone else; that is this method's forward path (see +{{spending-conditions}}), out of scope for the bearer profile. # IANA Considerations @@ -984,7 +1074,7 @@ WWW-Authenticate: Payment id="kM9xPqWvT2nJrHsY4aDfEb", realm="api.example.com", method="cashu", intent="charge", - request="eyJhbW91bnQiOiIxMDAiLCJjdXJyZW5jeSI6InNhdCIsImRlc2NyaXB0aW9uIjoiV2VhdGhlciByZXBvcnQgZm9yIDk0MTA3IiwibWV0aG9kRGV0YWlscyI6eyJtaW50cyI6WyJodHRwczovL21pbnQuZXhhbXBsZS5jb20iXSwicmVxdWVzdCI6ImNyZXFBLi4uIn19", + request="eyJhbW91bnQiOiIxMDAiLCJjdXJyZW5jeSI6InNhdCIsImRlc2NyaXB0aW9uIjoiV2VhdGhlciByZXBvcnQgZm9yIDk0MTA3IiwibWV0aG9kRGV0YWlscyI6eyJwYXltZW50UmVxdWVzdCI6ImNyZXFBLi4uIn19", expires="2026-03-15T12:05:00Z" Cache-Control: no-store ~~~ @@ -997,8 +1087,7 @@ Decoded `request`: "currency": "sat", "description": "Weather report for 94107", "methodDetails": { - "mints": ["https://mint.example.com"], - "request": "creqA..." + "paymentRequest": "creqA..." } } ~~~ @@ -1008,7 +1097,7 @@ Decoded `request`: ~~~http GET /weather HTTP/1.1 Host: api.example.com -Authorization: Payment eyJjaGFsbGVuZ2UiOnsiaWQiOiJrTTl4UHFXdlQybkpySHNZNGFEZkViIiwicmVhbG0iOiJhcGkuZXhhbXBsZS5jb20iLCJtZXRob2QiOiJjYXNodSIsImludGVudCI6ImNoYXJnZSIsInJlcXVlc3QiOiJleUouLi4iLCJleHBpcmVzIjoiMjAyNi0wMy0xNVQxMjowNTowMFoifSwic291cmNlIjoiZGlkOmtleTp6Nk1raGFYZ0JaRHZvdERrTDUyNTdmYWl6dGlHaUMyUXRLTEdwYm5uRUd0YTJkb0siLCJwYXlsb2FkIjp7ImNhc2h1X3Rva2VuIjoiY2FzaHVCcEdGMGdhSmhhVWdBLi4uIn19 +Authorization: Payment eyJjaGFsbGVuZ2UiOnsiZXhwaXJlcyI6IjIwMjYtMDMtMTVUMTI6MDU6MDBaIiwiaWQiOiJrTTl4UHFXdlQybkpySHNZNGFEZkViIiwiaW50ZW50IjoiY2hhcmdlIiwibWV0aG9kIjoiY2FzaHUiLCJyZWFsbSI6ImFwaS5leGFtcGxlLmNvbSIsInJlcXVlc3QiOiJleUouLi4ifSwicGF5bG9hZCI6eyJjYXNodV90b2tlbiI6ImNhc2h1QnBHRjBnYUpoYVVnQS4uLiJ9LCJzb3VyY2UiOiJkaWQ6a2V5Ono2TWtoYVhnQlpEdm90RGtMNTI1N2ZhaXp0aUdpQzJRdEtMR3Bibm5FR3RhMmRvSyJ9 HTTP/1.1 200 OK Payment-Receipt: eyJjaGFsbGVuZ2VJZCI6ImtNOXhQcVd2VDJuSnJIc1k0YURmRWIiLCJleHRlcm5hbElkIjoib3JkZXJfMTIzNDUiLCJtZXRob2QiOiJjYXNodSIsInJlZmVyZW5jZSI6IjliNzFkMjI0YmQ2MmYzNzg1ZDk2ZDQ2YWQzZWEzZDczMzE5YmZiYzI4OTBjYWFkYWUyZGZmNzI1MTk2NzNjYTciLCJzdGF0dXMiOiJzdWNjZXNzIiwidGltZXN0YW1wIjoiMjAyNi0wMy0xMFQyMTowMDowMFoifQ @@ -1018,22 +1107,23 @@ Content-Type: application/json {"temperature": 72, "condition": "sunny"} ~~~ -Decoded credential: +Decoded credential (keys in JCS order, as the wire bytes carry +them — see {{encoding}}): ~~~json { "challenge": { + "expires": "2026-03-15T12:05:00Z", "id": "kM9xPqWvT2nJrHsY4aDfEb", - "realm": "api.example.com", - "method": "cashu", "intent": "charge", - "request": "eyJ...", - "expires": "2026-03-15T12:05:00Z" + "method": "cashu", + "realm": "api.example.com", + "request": "eyJ..." }, - "source": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", "payload": { "cashu_token": "cashuBpGF0gaJhaUgA..." - } + }, + "source": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" } ~~~ @@ -1068,8 +1158,7 @@ Decoded `request`: "amount": "1", "currency": "credits", "methodDetails": { - "mints": ["https://mint.example.com"], - "request": "creqA..." + "paymentRequest": "creqA..." } } ~~~ From e4819d04204a4291c85db8d39a02db2ef1fbb9c5 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 9 Jun 2026 16:54:29 -0700 Subject: [PATCH 09/17] spec(cashu): apply review round 3; remove em-dashes - abstract: drop fee detail - intro: drop unit-independence paragraph (currency def + example carry it) - drop NUT-26/bech32m acceptance; creqA only - fold step 3 into step 2 (TokenV4 makes it structural); renumber to 9 steps - step 9: swap at configured URL of the matched entry; drop token-URL clause - delete spending-conditions section; witness rationale inlined in step 6 - collapse client-split paragraph to client guidance - delete input-DLEQ paragraph and security-fees section - mint-trust: drop DNS/TLS-takeover and pubkey prose - transport: drop P2PK forward-path sentence - purge all em-dashes Held for owner decision: payload.cashu_token casing; overpayment acceptance + cashu-specific error-type removal. Co-Authored-By: Claude Fable 5 --- specs/methods/cashu/draft-cashu-charge-01.md | 277 +++++++------------ 1 file changed, 102 insertions(+), 175 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index 7c3e40a5..48fa4dc4 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -74,12 +74,6 @@ normative: author: - org: Cashu date: 2024 - NUT-26: - title: "NUT-26: Bech32m payment requests" - target: https://github.com/cashubtc/nuts/blob/main/26.md - author: - - org: Cashu - date: 2025 informative: NUT-06: @@ -125,9 +119,8 @@ informative: This document defines the "charge" intent for the "cashu" payment method within the Payment HTTP Authentication Scheme {{I-D.httpauth-payment}}. The server issues a Cashu payment request -{{NUT-18}} as a challenge; the client presents, as a credential, a -Cashu token whose value, net of the mint's swap fee, settles to the -requested amount. The server verifies the token and redeems it by +{{NUT-18}} as a challenge; the client presents a Cashu token as the +credential. The server verifies the token and redeems it by swapping {{NUT-03}} it at the issuing mint. --- middle @@ -149,13 +142,6 @@ behind presentation of such a token: the server names an amount, unit, and acceptable mint set in the challenge, and the client returns a token the server redeems to settle. -The "cashu" method is independent of what the mint's unit -denominates. The unit is a label chosen by the issuing service — it -MAY denote an external asset (for example, "sat") or a unit -meaningful only within that service. This document treats it as an -opaque `currency` identifier; its backing and redemption semantics -are out of scope here. - The flow proceeds as follows: ~~~ @@ -220,11 +206,11 @@ Mint issued it. Payment Request -: A Cashu payment request {{NUT-18}} (a `creqA...` string, or the - `creqb1...` Bech32m form {{NUT-26}}) encoding the amount, unit, - acceptable mints, spending-condition kind, and single-use flag a - payer must satisfy. It is a self-contained, mint-agnostic request - artifact; all payment parameters are derived from it. +: A Cashu payment request {{NUT-18}} (a `creqA...` string) + encoding the amount, unit, acceptable mints, spending-condition + kind, and single-use flag a payer must satisfy. It is a + self-contained, mint-agnostic request artifact; all payment + parameters are derived from it. Proof : A single unit of ecash: an `{amount, id, secret, C}` tuple @@ -274,8 +260,8 @@ the remainder is never seen by the server. A NUT-03 swap deducts an input fee set by the input proofs' keyset(s): one term per input PROOF, each contributing its own -keyset's `input_fee_ppk`, summed and divided once with a ceiling — -`swap_fee = ceil(sum_over_proofs(input_fee_ppk) / 1000)` — where +keyset's `input_fee_ppk`, summed and divided once with a ceiling: +`swap_fee = ceil(sum_over_proofs(input_fee_ppk) / 1000)`, where `input_fee_ppk` is published per keyset in the mint's keyset list ({{NUT-02}}, {{NUT-03}}). Twelve proofs at 100 ppk owe 2, not 1: the sum runs over proofs, not distinct keysets. The fee is @@ -285,7 +271,7 @@ compute the same value before the token is presented. Because the charge is exact-amount with no change, the holder pre-funds the fee: the presented token's total value MUST equal `amount + swap_fee` (for a zero-fee keyset, `presented == amount`). -The server's exact-value check ({{verification}}, step 9) recomputes +The server's exact-value check ({{verification}}, step 8) recomputes `swap_fee` from the presented proofs and never trusts a client-supplied value. @@ -319,7 +305,7 @@ auth-param in `WWW-Authenticate`, the credential in The Cashu payment request (`methodDetails.paymentRequest`) and the Cashu token (`payload.cashu_token`) are opaque string values within the JCS-canonical `request` object; their own internal encoding -({{NUT-18}}, {{NUT-00}}) is never canonicalized — JCS conformance is +({{NUT-18}}, {{NUT-00}}) is never canonicalized; JCS conformance is a property of the enclosing object only. # Request Schema @@ -333,7 +319,7 @@ included in that object: amount : REQUIRED. The amount charged, in the base units of the Cashu - unit, as a canonical decimal string (e.g., "100") — a positive + unit, as a canonical decimal string (e.g., "100"), a positive integer per the charge intent ({{I-D.payment-intent-charge}}). This is the amount the server nets after the swap; it MUST equal the `a` value encoded in `methodDetails.paymentRequest`, @@ -384,9 +370,7 @@ the values encoded in the payment request. paymentRequest : REQUIRED. The Cashu payment request string ({{NUT-18}}, a - `creqA...` value). Servers and clients SHOULD also accept the - equivalent Bech32m encoding ({{NUT-26}}, a `creqb1...` value); - the two encodings are interchangeable. This field is + `creqA...` value). This field is authoritative; all payment parameters (amount, unit, accepted mints, spending-condition kind, single-use flag, optional description) are derived from it. The @@ -481,36 +465,38 @@ Upon receiving a request with a credential, the server MUST: 1. Decode the base64url credential and parse the JSON. 2. Verify that `payload.cashu_token` is present, is a string, and decodes as a Cashu token ({{NUT-00}}). The token MUST be a - `cashuB...` (TokenV4) serialization; a `cashuA...` (TokenV3) - string MUST be rejected. Reject a token that does not parse or - carries zero proofs. Servers SHOULD reject a token carrying more - than a configured maximum number of proofs (see {{security-dos}}). -3. Verify the token declares exactly one mint and one unit. - (TokenV4 makes both structural, so this fails only malformed or - hand-built tokens; the unit's semantic checks are steps 5 - and 8.) -4. Authenticate and validate the echoed challenge per - {{I-D.httpauth-payment}}: recover the challenge — under stateless - operation (RECOMMENDED) recompute the `id`-HMAC over the echoed - `credential.challenge` with the server key, or under stored - operation look it up by `credential.challenge.id` — and verify - its field consistency, body `digest` ({{RFC9530}}), and + `cashuB...` (TokenV4) serialization carrying at least one + proof; a `cashuA...` (TokenV3) string MUST be rejected, as MUST + a token that does not parse. A valid TokenV4 structurally + carries exactly one mint and one unit. Servers SHOULD reject a + token carrying more than a configured maximum number of proofs + (see {{security-dos}}). +3. Authenticate and validate the echoed challenge per + {{I-D.httpauth-payment}}: recover the challenge (under + stateless operation, RECOMMENDED, recompute the `id`-HMAC over + the echoed `credential.challenge` with the server key; under + stored operation, look it up by `credential.challenge.id`) and + verify its field consistency, body `digest` ({{RFC9530}}), and `expires` freshness. A tampered or inconsistent challenge MUST be rejected as `invalid-challenge`; a `credential.challenge.expires` in the past is a `payment-expired` condition (see {{errors}}). Because a Cashu `creqA` carries no expiry of its own, the `expires` auth-param is the sole challenge-expiry signal. -5. Verify the token's unit equals `currency`. -6. Verify the token's mint is a member of the payment request's +4. Verify the token's unit equals `currency`. +5. Verify the token's mint is a member of the payment request's mint set (`m`), comparing by canonicalized mint URL (see {{mint-trust}}). A token whose mint is not a member is rejected as `verification-failed`. -7. Verify that no proof carries a NUT-10 {{NUT-10}} well-known +6. Verify that no proof carries a NUT-10 {{NUT-10}} well-known (P2PK or HTLC) secret. This intent accepts plain-secret BEARER proofs only; a proof bound to a spending condition is rejected - as `verification-failed` (see {{spending-conditions}}). -8. Resolve every proof's keyset id against the mint's published + as `verification-failed`, because the server cannot produce the + witness such a proof requires and its swap would otherwise fail + undiagnosably. The challenge's embedded payment request promises + this in advance: its `nut10` field is absent (see Method + Details). +7. Resolve every proof's keyset id against the mint's published keysets {{NUT-02}}. When a proof uses a short keyset id, the server MUST resolve it to the full keyset via the mint's fetched keyset list and MUST reject a short id that is ambiguous or does @@ -519,31 +505,30 @@ Upon receiving a request with a credential, the server MUST: client-supplied data, the keyset is the authority; a proof whose keyset belongs to a different unit is rejected as `verification-failed`. -9. Compute `expected_swap_fee` over the resolved keyset(s) of the +8. Compute `expected_swap_fee` over the resolved keyset(s) of the presented proofs ({{fees}}) and verify the token's total value equals `amount + expected_swap_fee` EXACTLY. A token worth more OR less MUST be rejected; the server makes no change. -10. Swap ({{NUT-03}}) the whole token at the matched mint's - configured URL (the mint-set entry matched in step 6), - never a URL carried in the token (see {{settlement}}). A - successful swap is the redemption step and is the authoritative - check that the input proofs were genuine, validly signed, and - unspent. The server SHOULD verify the DLEQ proofs {{NUT-12}} - on the blind signatures the swap returns; a failed or missing - DLEQ proof after a successful swap is a mint-trust incident, - not a payment failure (see {{security-dleq}}). A swap rejected - because a proof is already spent is a `verification-failed` - condition; a swap rejected because the keyset has retired or - its `final_expiry` has passed is a `payment-expired` condition; - a swap rejected for any other reason is a `verification-failed` - condition — the token was not redeemed - (see {{settlement}}, {{errors}}). - -Steps 5 through 9 are structural and MUST be performed before the -network swap in step 10, so a structurally invalid token never -reaches the swap. The keyset resolution of step 8 MAY require +9. Swap ({{NUT-03}}) the whole token at the configured URL of the + mint-set entry matched in step 5 (see {{settlement}}). A + successful swap is the redemption step and is the authoritative + check that the input proofs were genuine, validly signed, and + unspent. The server SHOULD verify the DLEQ proofs {{NUT-12}} + on the blind signatures the swap returns; a failed or missing + DLEQ proof after a successful swap is a mint-trust incident, + not a payment failure (see {{security-dleq}}). A swap rejected + because a proof is already spent is a `verification-failed` + condition; a swap rejected because the keyset has retired or + its `final_expiry` has passed is a `payment-expired` condition; + a swap rejected for any other reason is a `verification-failed` + condition: the token was not redeemed + (see {{settlement}}, {{errors}}). + +Steps 4 through 8 are structural and MUST be performed before the +network swap in step 9, so a structurally invalid token never +reaches the swap. The keyset resolution of step 7 MAY require fetching the mint's keysets {{NUT-02}} first. The expected values -these steps check — `currency`, the mint set, `amount` — are +these steps check (`currency`, the mint set, `amount`) are derived from the authenticated challenge's embedded payment request, the authoritative artifact (see Method Details); the top-level auth-params were already required to match it at @@ -551,35 +536,12 @@ challenge construction. These steps satisfy the "charge" intent's verification responsibilities ({{I-D.payment-intent-charge}}): challenge-match and -freshness in step 4, payment-proof verification (the swap itself) -in steps 5–10, and amount-match in step 9 (read as the NET settled -amount, per {{fees}}). Recipient-match is implicit — redemption swaps +freshness in step 3, payment-proof verification (the swap itself) +in steps 4-9, and amount-match in step 8 (read as the NET settled +amount, per {{fees}}). Recipient-match is implicit: redemption swaps the token to the server itself, so there is no distinct recipient and `recipient` is omitted. -## Spending-Condition-Locked Tokens {#spending-conditions} - -This profile accepts plain-secret BEARER proofs only. The -challenge's embedded payment request MUST set `nut10` to `None` -(absent), -and a presented proof carrying a NUT-10 {{NUT-10}} well-known -secret (a P2PK or HTLC lock) is rejected before the swap (step 7) -as `verification-failed`: the server cannot produce the witness -such a proof requires, so its swap would otherwise fail -undiagnosably. - -This is a deliberate v1 boundary, not a permanent one. Locking the -token to the server's key (P2PK) is the natural extension — it -would let a server verify a payment offline (valid DLEQ + -locked-to-itself + locally deduplicated, no swap) and make an -intercepted token unspendable by a thief (see -{{security-transport}}). That changes the verification model -(server keys, witness checks, local double-spend tracking), so it -is left to a future profile. Requiring `nut10` absent here is what -makes that addition safe: a bearer-profile client already rejects -any nut10-carrying challenge, so a later P2PK profile degrades -closed rather than silently misbehaving. - ## Challenge Binding To prevent token replay across different resources or challenges, @@ -590,7 +552,7 @@ The server SHOULD compute `id` as the HMAC-SHA256 binding defined by {{I-D.httpauth-payment}} so that binding is stateless; alternatively the server MAY store issued challenges and verify by lookup. Under stateless operation a presented credential MUST echo -each challenge auth-param byte-for-byte as issued — in particular +each challenge auth-param byte-for-byte as issued, in particular the `request` string, used byte-as-issued (a decode/re-encode cycle changes the bytes and the recomputation fails). The challenge carries the framework-REQUIRED auth-params (`id`, @@ -614,7 +576,7 @@ token (see {{security-replay}}). When a presented proof uses a short (version-`00`) keyset id, the server MUST resolve it to the full keyset against the mint's -published keyset list ({{NUT-02}}) — resolution is required to +published keyset list ({{NUT-02}}); resolution is required to compute the swap fee ({{fees}}) and construct correct swap outputs. A short id that resolves to no keyset, or is ambiguous across the mint's keysets, MUST be rejected as `verification-failed`. A @@ -639,7 +601,7 @@ returns no proofs to the client. A successful swap is destructive: the input proofs are consumed whether or not the server retains the result. The server MUST be able to reconstruct its swap outputs if it crashes between sending -the swap and durably storing the returned signatures — either by +the swap and durably storing the returned signatures: either by persisting the blinded output secrets before sending the swap, or by deriving them deterministically ({{NUT-13}}) and recovering the mint's response via restore ({{NUT-09}}, which covers interrupted @@ -650,29 +612,19 @@ can recover the outputs. A swap whose request was transmitted but whose result was not received leaves consumption unknown. The server MUST resolve such an outcome before honoring any further presentation of the same -challenge: restore its own swap outputs ({{NUT-09}}) — always -possible under the reconstruction requirement above, and if the -mint returns signatures the original swap succeeded and the -payment is complete — or check the input proofs' spent state +challenge: restore its own swap outputs ({{NUT-09}}), which the +reconstruction requirement above makes always possible (if the +mint returns signatures, the original swap succeeded and the +payment is complete), or check the input proofs' spent state ({{NUT-07}}). Until resolved, the server answers `mint-unavailable` ({{errors}}). -The client swaps ({{NUT-03}}) its larger token at the mint into (a) -a token worth exactly `amount + swap_fee`, which it presents, and -(b) a remainder it keeps, generating the blinded outputs for both -halves itself. This local split is itself a -fee-bearing swap: to end up holding a presentable token worth -`amount + swap_fee` AND keep a remainder, the holder must spend -inputs worth at least `amount + swap_fee + split_fee`, where -`split_fee` is the fee of the local split swap computed over its -own input proofs ({{fees}}). The presented half consists of the -split's outputs, blinded against the mint's currently ACTIVE -keyset — so the `input_fee_ppk` that prices `swap_fee` is the -active keyset's, which MAY differ from that of the proofs the -holder spent. Neither the mint nor the server learns the remainder's -secrets. This local split is the client's responsibility and -happens before the `Authorization` request; the server never -performs it. +The client presents a token worth exactly `amount + swap_fee`. If +it does not already hold matching denominations, it first swaps +({{NUT-03}}) at the mint to produce them; the remainder of that +local split stays with the client, and neither the mint nor the +server learns the remainder's secrets. The split happens before +the `Authorization` request; the server never performs it. ## Consume-Once and Resource Delivery @@ -680,7 +632,7 @@ The server MUST treat the redemption (the swap) and the decision to return HTTP 200 as a single operation: a challenge whose token has been redeemed MUST NOT be accepted again, even if resource delivery subsequently fails. A server using stored challenges records -consumption only upon — and atomically with — swap success; a +consumption only upon, and atomically with, swap success; a challenge whose swap never succeeded remains presentable (`mint-unavailable`, {{errors}}). Once the swap succeeds the payment is complete: @@ -695,7 +647,7 @@ redeemed token cannot be refunded by the server. The same loss mode applies when a success response is lost in transit: a later re-presentation of the same credential is a new -request against an already-consumed challenge and fails — under +request against an already-consumed challenge and fails: under stateless operation at the swap, as a spent token (`verification-failed`); under stored operation at the challenge lookup (`invalid-challenge`, {{errors}}). The protocol provides @@ -772,7 +724,7 @@ The server SHOULD include a response body conforming to RFC 9457 {{RFC9457}} Problem Details, with `Content-Type: application/problem+json`. -A malformed REQUEST — as opposed to a failed payment — follows the +A malformed REQUEST, as opposed to a failed payment, follows the framework's status handling rather than returning 402: a credential naming an unsupported method yields `method-unsupported` (HTTP 400), and a request bearing more than one `Authorization: Payment` @@ -820,27 +772,26 @@ https://paymentauth.org/problems/payment-expired https://paymentauth.org/problems/cashu/amount-mismatch : HTTP 402. The token's total value does not equal - `amount + swap_fee` (see {{fees}}) — over- or under-funded; the - server makes no change. A fresh challenge MUST be included in + `amount + swap_fee` (see {{fees}}), whether over- or + under-funded; the server makes no change. A fresh challenge MUST be included in `WWW-Authenticate`. This method-specific type covers both directions; the framework's `payment-insufficient` names only underpayment and is not used by this method. https://paymentauth.org/problems/verification-failed : HTTP 402. The token failed a non-amount, non-expiry verification - check: its unit does not equal `currency`, its proofs reference - more than one mint or unit, its mint is not in the payment - request's mint set, a proof carries a NUT-10 {{NUT-10}} - spending condition, a proof uses an unresolvable or ambiguous - short keyset id, a resolved keyset's unit differs from - `currency`, or the mint rejected the swap — because a proof was - already spent, or for any reason other than keyset retirement or - expiry (verification step 10). A fresh challenge MUST be - included in `WWW-Authenticate`. + check: its unit does not equal `currency`, its mint is not in + the payment request's mint set, a proof carries a NUT-10 + {{NUT-10}} spending condition, a proof uses an unresolvable or + ambiguous short keyset id, a resolved keyset's unit differs from + `currency`, or the mint rejected the swap, whether because a + proof was already spent or for any reason other than keyset + retirement or expiry (verification step 9). A fresh challenge + MUST be included in `WWW-Authenticate`. https://paymentauth.org/problems/cashu/mint-unavailable : HTTP 503. The mint could not be reached, or the swap was sent - but its outcome is unknown — an infrastructure failure, not a + but its outcome is unknown: an infrastructure failure, not a payment-verification outcome. The server SHOULD include a `Retry-After` header. If the swap request was never transmitted (DNS, connect, or TLS failure), the token is NOT consumed and @@ -874,13 +825,13 @@ Example error response body: ## Client-Side Verification {#security-client} A client that skips the independent checks required by the -`paymentRequest` definition (Method Details) — decoding the -payment request and confirming its amount, unit, and mint set — +`paymentRequest` definition (Method Details), decoding the +payment request and confirming its amount, unit, and mint set, can be induced to pay the wrong amount or unit, or to pay toward an attacker-substituted mint. -After an outcome that never resolved — a timeout, connection -loss, or 5xx after a credential was sent — a client can settle +After an outcome that never resolved (a timeout, connection +loss, or 5xx after a credential was sent), a client can settle its token's fate with a proof-state check at the mint ({{NUT-07}}): proofs SPENT mean the server redeemed the token (the payment happened; whether the resource arrived is the loss @@ -930,33 +881,19 @@ server only against a mint its operator already chose to trust; operators who rely on it SHOULD select mints that support NUT-12 (discoverable via the mint's info endpoint {{NUT-06}}). -The server does not verify DLEQ on the presented input proofs: -input-proof DLEQ exists to let an offline party verify ecash -without contacting the mint, but here the server swaps the token -immediately and the swap itself proves the inputs were validly -signed and unspent, so an input-proof DLEQ check would add nothing. -It is also frequently stripped from `cashuB` tokens, so requiring it -would needlessly reject valid tokens. - -## Amount and Fee Determinism {#security-fees} - -Where the swap fee is large relative to `amount` the -charge remains satisfiable but uneconomic; servers SHOULD price -`amount` well above the swap fee of the mints they accept. - ## Keyset Rotation and Expiry A `final_expiry` boundary {{NUT-02}} can fall between the last -structural check (verification step 9) and the swap (step 10): a +structural check (verification step 8) and the swap (step 9): a token that passes verification is not guaranteed to swap, because the mint enforces keyset retirement and `final_expiry` at swap time. Servers MUST treat a swap rejected for keyset retirement or passed `final_expiry` as `payment-expired`, distinct from the double-spend and disallowed-mint cases that are -`verification-failed`. A keyset that is merely inactive — no longer +`verification-failed`. A keyset that is merely inactive (no longer the mint's current signing keyset but not yet retired or past -`final_expiry` — remains valid for redemption; the server MUST NOT +`final_expiry`) remains valid for redemption; the server MUST NOT reject a proof for keyset inactivity alone, only for actual retirement or expiry. Output proofs are blinded against the unit's ACTIVE keyset (see {{settlement}}), so the server holds spendable @@ -968,24 +905,17 @@ The server trusts the mints it lists in the payment request: a listed mint custodies the value the server redeems and could, in principle, refuse to honor a swap or rotate its keyset early. Membership is decided by canonicalized mint URL (verification -step 6). Two mint URLs are equal when these transformations — -exhaustive; no further {{RFC3986}} normalization (such as -percent-encoding case changes) is applied — yield identical -strings: lowercase the scheme and host (comparing an +step 5). Two mint URLs are equal when these transformations +yield identical strings (the list is exhaustive; no further +{{RFC3986}} normalization, such as percent-encoding case changes, +is applied): lowercase the scheme and host (comparing an internationalized host in its punycode A-label form), drop a default port (443 for `https`, 80 for `http`), and strip all trailing slashes (as token serialization does, {{NUT-00}}); the path and query are otherwise case-sensitive and preserved verbatim. A mint URL containing userinfo (`user@host`) MUST be -rejected outright. URL equality alone does not defend against a -DNS or TLS takeover of a listed mint's domain, because the -`pubkey` of `GET /v1/info` {{NUT-06}} -would be fetched over the same channel an attacker controls and is -public in any case. The durable control is operator diligence: -choose the mint set deliberately, and where takeover resistance -matters, pin each accepted mint's identity (for example its public -key) out of band rather than trusting it on first contact. Clients -likewise rely on the listed mints to honor the tokens they hold. +rejected outright. Clients likewise rely on the listed mints to +honor the tokens they hold. ## Privacy @@ -997,7 +927,7 @@ presents only an exact-amount token, neither the server nor the mint observes the remainder or its secrets, and the server learns that one charge was paid without learning the size of the holder's remaining balance. The server still observes that a redemption -occurred — the blind signatures hide the link to issuance, not the +occurred: the blind signatures hide the link to issuance, not the redemption itself. The mint, however, can still correlate the holder's pre-payment split with the redemption moments later by amount and timing; clients that need to avoid that SHOULD hold @@ -1024,16 +954,13 @@ credential-verification attempts per {{I-D.httpauth-payment}}. All communication MUST use TLS per {{I-D.httpauth-payment}}. A Cashu token is a bearer credential: any party that observes it in transit before it is redeemed can redeem it itself. Challenge -binding does not prevent this — it binds the credential to a -challenge, not the token to a holder — so an interceptor at any +binding does not prevent this (it binds the credential to a +challenge, not the token to a holder), so an interceptor at any TLS-terminating hop (a reverse proxy, a compromised gateway) sees the plaintext token and can redeem it directly. Credentials MUST only be transmitted over HTTPS, and servers SHOULD redeem a presented token promptly; TLS and prompt redemption shrink the -exposure window rather than close it. The structural fix is to lock the -token to the server's key (P2PK) so an intercepted token is -unspendable by anyone else; that is this method's forward path (see -{{spending-conditions}}), out of scope for the bearer profile. +exposure window rather than close it. # IANA Considerations @@ -1108,7 +1035,7 @@ Content-Type: application/json ~~~ Decoded credential (keys in JCS order, as the wire bytes carry -them — see {{encoding}}): +them; see {{encoding}}): ~~~json { From 307dec55108797bb1925d5d6b335e05263f340c2 Mon Sep 17 00:00:00 2001 From: orveth Date: Tue, 9 Jun 2026 20:22:01 -0700 Subject: [PATCH 10/17] spec(cashu): apply fork verdicts; payload.token + accept overpayment - rename payload.cashu_token -> payload.token (all prose, schema, examples; Authorization example blob re-encoded, JCS order kept) - accept token value >= amount + swap_fee; excess retained, server still makes no change; client SHOULD present the exact total - underpayment maps to the framework's payment-insufficient; cashu/amount-mismatch removed - cashu/mint-unavailable type removed; mint unreachability is plain HTTP 503 + Retry-After with the consumed-vs-unknown rules intact - zero method-specific problem types remain - fees: note held-proof coin selection may not land exact and then overpays minimally (cashu-ts behavior) Co-Authored-By: Claude Fable 5 --- specs/methods/cashu/draft-cashu-charge-01.md | 139 ++++++++++--------- 1 file changed, 72 insertions(+), 67 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index 48fa4dc4..963c16d5 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -245,16 +245,16 @@ The "charge" intent represents a one-time payment gating access to a resource. The server advertises a Cashu payment request ({{NUT-18}}) naming an exact amount and unit per request. As the credential, the client presents a Cashu token whose value, after -the swap fee the mint will deduct, settles to exactly that amount. +the swap fee the mint will deduct, covers that amount. The server verifies the token and redeems it by swapping ({{NUT-03}}) it at the issuing mint; a successful swap both proves the token unspent and transfers its value to the server. -The "cashu" charge is exact-amount: the server redeems the whole -token and makes no change, so the holder pre-funds the swap fee and -the presented value is `amount + swap_fee` (see {{fees}}). A holder -of a larger token splits it locally first (see {{settlement}}); -the remainder is never seen by the server. +The server redeems the whole token and makes no change: the holder +pre-funds the swap fee, presenting a value of at least +`amount + swap_fee`, and any excess is retained by the server (see +{{fees}}). A holder of a larger token splits it locally first (see +{{settlement}}); the remainder is never seen by the server. ## Fees {#fees} @@ -268,16 +268,20 @@ the sum runs over proofs, not distinct keysets. The fee is deterministic from the presented proofs, so both holder and server compute the same value before the token is presented. -Because the charge is exact-amount with no change, the holder -pre-funds the fee: the presented token's total value MUST equal -`amount + swap_fee` (for a zero-fee keyset, `presented == amount`). -The server's exact-value check ({{verification}}, step 8) recomputes -`swap_fee` from the presented proofs and never trusts a -client-supplied value. - -Selecting proofs to total `amount + swap_fee` has a mild fixpoint (a -proof added to cover the fee can raise the fee); standard fee-aware -coin selection resolves it. +Because the server makes no change, the holder pre-funds the fee: +the presented token's total value MUST be at least +`amount + swap_fee` (for a zero-fee keyset, at least `amount`). +Value beyond that is accepted and retained by the server, so a +client SHOULD present the exact total. The server's value check +({{verification}}, step 8) recomputes `swap_fee` from the presented +proofs and never trusts a client-supplied value. + +Selecting proofs to total exactly `amount + swap_fee` has a mild +fixpoint (a proof added to cover the fee can raise the fee). +Fee-aware coin selection resolves it when the client swaps for +fresh denominations; a client spending already-held proofs MAY be +unable to land on the exact total, and then overpays by the +smallest margin its proofs allow. # Encoding Conventions {#encoding} @@ -303,7 +307,7 @@ auth-param in `WWW-Authenticate`, the credential in `Payment-Receipt`. The Cashu payment request (`methodDetails.paymentRequest`) and the Cashu -token (`payload.cashu_token`) are opaque string values within the +token (`payload.token`) are opaque string values within the JCS-canonical `request` object; their own internal encoding ({{NUT-18}}, {{NUT-00}}) is never canonicalized; JCS conformance is a property of the enclosing object only. @@ -413,9 +417,9 @@ source payload : REQUIRED. A JSON object containing the Cashu-specific credential - fields. The single required field is `cashu_token`: the Cashu + fields. The single required field is `token`: the Cashu token ({{NUT-00}}, a `cashuB...` string) whose value, net of the - swap fee, settles to exactly the requested amount (see + swap fee, covers the requested amount (see {{fees}}). Example (decoded): @@ -432,7 +436,7 @@ Example (decoded): }, "source": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", "payload": { - "cashu_token": "cashuBpGF0gaJhaUgA..." + "token": "cashuBpGF0gaJhaUgA..." } } ~~~ @@ -453,7 +457,7 @@ echoes them too: "expires": "2026-03-15T12:05:00Z" }, "payload": { - "cashu_token": "cashuBpGF0gaJhaUgA..." + "token": "cashuBpGF0gaJhaUgA..." } } ~~~ @@ -463,7 +467,7 @@ echoes them too: Upon receiving a request with a credential, the server MUST: 1. Decode the base64url credential and parse the JSON. -2. Verify that `payload.cashu_token` is present, is a string, and +2. Verify that `payload.token` is present, is a string, and decodes as a Cashu token ({{NUT-00}}). The token MUST be a `cashuB...` (TokenV4) serialization carrying at least one proof; a `cashuA...` (TokenV3) string MUST be rejected, as MUST @@ -507,8 +511,10 @@ Upon receiving a request with a credential, the server MUST: `verification-failed`. 8. Compute `expected_swap_fee` over the resolved keyset(s) of the presented proofs ({{fees}}) and verify the token's total value - equals `amount + expected_swap_fee` EXACTLY. A token worth more - OR less MUST be rejected; the server makes no change. + is at least `amount + expected_swap_fee`. A token worth less + MUST be rejected as `payment-insufficient` ({{errors}}); value + above the requirement is accepted and retained. The server + makes no change either way. 9. Swap ({{NUT-03}}) the whole token at the configured URL of the mint-set entry matched in step 5 (see {{settlement}}). A successful swap is the redemption step and is the authoritative @@ -581,9 +587,8 @@ compute the swap fee ({{fees}}) and construct correct swap outputs. A short id that resolves to no keyset, or is ambiguous across the mint's keysets, MUST be rejected as `verification-failed`. A failure to fetch the keyset list at all (a network error reaching -the mint) is distinct: that is a `mint-unavailable` (HTTP 503) -condition with the token NOT consumed (see {{errors}}), not a -`verification-failed`. +the mint) is distinct: the server answers HTTP 503 with the token +NOT consumed (see {{errors}}), not `verification-failed`. # Settlement Procedure {#settlement} @@ -591,7 +596,7 @@ Settlement is the mint swap ({{NUT-03}}) of the presented token. The server swaps the whole token for fresh proofs it controls; holding those proofs is settlement. The mint deducts the swap fee ({{fees}}) from the inputs, so the server's output proofs sum to -exactly `amount`. The server's outputs are blinded against the +at least `amount`. The server's outputs are blinded against the mint's currently ACTIVE keyset for the unit ({{NUT-02}}), which MAY differ from the keyset(s) that signed the input proofs. Cashu settlement is final once the swap succeeds: the input proofs are @@ -617,10 +622,12 @@ reconstruction requirement above makes always possible (if the mint returns signatures, the original swap succeeded and the payment is complete), or check the input proofs' spent state ({{NUT-07}}). Until resolved, the server answers -`mint-unavailable` ({{errors}}). +HTTP 503 ({{errors}}). -The client presents a token worth exactly `amount + swap_fee`. If -it does not already hold matching denominations, it first swaps +The client presents a token worth `amount + swap_fee` (value +beyond that is not returned, so there is no reason to present +more). If it does not already hold matching denominations, it +first swaps ({{NUT-03}}) at the mint to produce them; the remainder of that local split stays with the client, and neither the mint nor the server learns the remainder's secrets. The split happens before @@ -634,7 +641,7 @@ been redeemed MUST NOT be accepted again, even if resource delivery subsequently fails. A server using stored challenges records consumption only upon, and atomically with, swap success; a challenge whose swap never succeeded remains presentable -(`mint-unavailable`, {{errors}}). Once the swap succeeds the +(HTTP 503, {{errors}}). Once the swap succeeds the payment is complete: the server MUST NOT respond with a payment-failure status (402) or issue a fresh challenge for a condition detected after a successful @@ -681,7 +688,7 @@ challengeId reference : REQUIRED. A SHA-256 hash, as a lowercase hex string, of the - exact `cashu_token` credential string received from the client + exact `token` credential string received from the client (the `cashuB...` string as presented, not a re-encoding). Serves as a stable, shareable settlement identifier. The token string itself MUST NOT be used here: although a redeemed token is spent @@ -733,16 +740,14 @@ The 402 problem types below are scoped to payment-verification failures. Payment-verification failures surface as the framework's -registered problem types where their semantics match; this method -defines the cashu-specific causes that map to each. Two -genuinely method-specific conditions are defined under the -`cashu/` namespace (`amount-mismatch`, `mint-unavailable`): +registered problem types; this method defines no problem types of +its own, only the cashu-specific causes that map to each: https://paymentauth.org/problems/malformed-credential : HTTP 402. The credential could not be decoded, the JSON could not be parsed, required fields (`challenge`, `payload`, - `payload.cashu_token`) are absent or have the wrong type, - `cashu_token` does not decode as a Cashu token, the token is a + `payload.token`) are absent or have the wrong type, + `token` does not decode as a Cashu token, the token is a `cashuA...` (TokenV3) serialization, the token carries zero proofs, or it exceeds the server's proof-count bound ({{security-dos}}). A fresh challenge @@ -770,13 +775,13 @@ https://paymentauth.org/problems/payment-expired token means its keyset has expired and the token SHOULD be abandoned. -https://paymentauth.org/problems/cashu/amount-mismatch -: HTTP 402. The token's total value does not equal - `amount + swap_fee` (see {{fees}}), whether over- or - under-funded; the server makes no change. A fresh challenge MUST be included in - `WWW-Authenticate`. This method-specific type covers both - directions; the framework's `payment-insufficient` names only - underpayment and is not used by this method. +https://paymentauth.org/problems/payment-insufficient +: HTTP 402. The token's total value is less than + `amount + swap_fee` (see {{fees}}); the server makes no change + and the token is not redeemed. A fresh challenge MUST be + included in `WWW-Authenticate`. There is no over-payment + counterpart: value above the requirement is accepted and + retained ({{fees}}). https://paymentauth.org/problems/verification-failed : HTTP 402. The token failed a non-amount, non-expiry verification @@ -789,17 +794,17 @@ https://paymentauth.org/problems/verification-failed retirement or expiry (verification step 9). A fresh challenge MUST be included in `WWW-Authenticate`. -https://paymentauth.org/problems/cashu/mint-unavailable -: HTTP 503. The mint could not be reached, or the swap was sent - but its outcome is unknown: an infrastructure failure, not a - payment-verification outcome. The server SHOULD include a - `Retry-After` header. If the swap request was never transmitted - (DNS, connect, or TLS failure), the token is NOT consumed and - the client MAY retry the same token. If the swap was transmitted - but its result was not received, consumption is unknown: the - server MUST NOT claim the token unconsumed and MUST resolve the - outcome before acting further on the same challenge (see - {{settlement}}). +Mint unreachability is an infrastructure failure, not a +payment-verification outcome, and carries no problem type: when +the mint cannot be reached, or a swap was sent but its outcome is +unknown, the server MUST answer HTTP 503 (Service Unavailable) +and SHOULD include a `Retry-After` header. If the swap request +was never transmitted (DNS, connect, or TLS failure), the token +is NOT consumed and the client MAY retry the same token. If the +swap was transmitted but its result was not received, consumption +is unknown: the server MUST NOT claim the token unconsumed and +MUST resolve the outcome before acting further on the same +challenge (see {{settlement}}). A token whose mint is reachable but is not in the payment request's mint set, or whose unit is otherwise disallowed by @@ -813,10 +818,10 @@ Example error response body: ~~~json { - "type": "https://paymentauth.org/problems/cashu/amount-mismatch", - "title": "Amount Mismatch", + "type": "https://paymentauth.org/problems/payment-insufficient", + "title": "Payment Insufficient", "status": 402, - "detail": "Presented token value does not equal amount plus swap fee" + "detail": "Presented token value is less than amount plus swap fee" } ~~~ @@ -921,9 +926,9 @@ honor the tokens they hold. Cashu tokens are bearer instruments carrying no payer identity, and the mint's blind signatures {{NUT-00}} unlink a token's -redemption from its issuance. The exact-amount model adds to this: -because the holder splits its token locally before presenting and -presents only an exact-amount token, neither the server nor the +redemption from its issuance. The local-split model adds to this: +because the holder splits its token locally and presents only what +the charge requires, neither the server nor the mint observes the remainder or its secrets, and the server learns that one charge was paid without learning the size of the holder's remaining balance. The server still observes that a redemption @@ -984,7 +989,7 @@ the "HTTP Payment Intents" registry established by | Intent | Applicable Methods | Description | Reference | |--------|-------------------|-------------|-----------| -| `charge` | `cashu` | One-time exact-amount Cashu token payment gating access to a resource | This document | +| `charge` | `cashu` | One-time Cashu token payment gating access to a resource | This document | --- back @@ -1024,7 +1029,7 @@ Decoded `request`: ~~~http GET /weather HTTP/1.1 Host: api.example.com -Authorization: Payment eyJjaGFsbGVuZ2UiOnsiZXhwaXJlcyI6IjIwMjYtMDMtMTVUMTI6MDU6MDBaIiwiaWQiOiJrTTl4UHFXdlQybkpySHNZNGFEZkViIiwiaW50ZW50IjoiY2hhcmdlIiwibWV0aG9kIjoiY2FzaHUiLCJyZWFsbSI6ImFwaS5leGFtcGxlLmNvbSIsInJlcXVlc3QiOiJleUouLi4ifSwicGF5bG9hZCI6eyJjYXNodV90b2tlbiI6ImNhc2h1QnBHRjBnYUpoYVVnQS4uLiJ9LCJzb3VyY2UiOiJkaWQ6a2V5Ono2TWtoYVhnQlpEdm90RGtMNTI1N2ZhaXp0aUdpQzJRdEtMR3Bibm5FR3RhMmRvSyJ9 +Authorization: Payment eyJjaGFsbGVuZ2UiOnsiZXhwaXJlcyI6IjIwMjYtMDMtMTVUMTI6MDU6MDBaIiwiaWQiOiJrTTl4UHFXdlQybkpySHNZNGFEZkViIiwiaW50ZW50IjoiY2hhcmdlIiwibWV0aG9kIjoiY2FzaHUiLCJyZWFsbSI6ImFwaS5leGFtcGxlLmNvbSIsInJlcXVlc3QiOiJleUouLi4ifSwicGF5bG9hZCI6eyJ0b2tlbiI6ImNhc2h1QnBHRjBnYUpoYVVnQS4uLiJ9LCJzb3VyY2UiOiJkaWQ6a2V5Ono2TWtoYVhnQlpEdm90RGtMNTI1N2ZhaXp0aUdpQzJRdEtMR3Bibm5FR3RhMmRvSyJ9 HTTP/1.1 200 OK Payment-Receipt: eyJjaGFsbGVuZ2VJZCI6ImtNOXhQcVd2VDJuSnJIc1k0YURmRWIiLCJleHRlcm5hbElkIjoib3JkZXJfMTIzNDUiLCJtZXRob2QiOiJjYXNodSIsInJlZmVyZW5jZSI6IjliNzFkMjI0YmQ2MmYzNzg1ZDk2ZDQ2YWQzZWEzZDczMzE5YmZiYzI4OTBjYWFkYWUyZGZmNzI1MTk2NzNjYTciLCJzdGF0dXMiOiJzdWNjZXNzIiwidGltZXN0YW1wIjoiMjAyNi0wMy0xMFQyMTowMDowMFoifQ @@ -1048,7 +1053,7 @@ them; see {{encoding}}): "request": "eyJ..." }, "payload": { - "cashu_token": "cashuBpGF0gaJhaUgA..." + "token": "cashuBpGF0gaJhaUgA..." }, "source": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" } @@ -1072,7 +1077,7 @@ Decoded receipt: The same exchange with a service-defined unit: only the `currency` and the unit encoded in the payment request differ. Here the mint's keyset for the unit is fee-free (`input_fee_ppk = 0`, the operator's -choice), so the presented token is worth exactly the requested +choice), so the presented token is worth the requested `amount`; against a fee-bearing keyset it would be `amount + swap_fee` (see {{fees}}). The token is verified and redeemed identically; any backing or expiry the unit carries is enforced by the mint at swap From cb005cae8fe01ea2512f39b4c9d30c41d51d2591 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 10 Jun 2026 00:36:25 -0700 Subject: [PATCH 11/17] spec(cashu): short keyset ids are version-01, not version-00 Per NUT-02 (cashu 0.16 reference): version-00 ids are complete and round-trip locally; only version-01 ids have prefix short forms that require resolution against the mint's keyset list. Co-Authored-By: Claude Fable 5 --- specs/methods/cashu/draft-cashu-charge-01.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index 963c16d5..4a217bb4 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -580,7 +580,7 @@ token (see {{security-replay}}). ## Short Keyset Identifiers {#short-keyset} -When a presented proof uses a short (version-`00`) keyset id, the +When a presented proof uses a short (version-`01`) keyset id, the server MUST resolve it to the full keyset against the mint's published keyset list ({{NUT-02}}); resolution is required to compute the swap fee ({{fees}}) and construct correct swap outputs. From e6cd1ec1d221cb98421243d0e43fdeb735820623 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 10 Jun 2026 09:33:51 -0700 Subject: [PATCH 12/17] editorial: align the mint lane in the flow diagram --- specs/methods/cashu/draft-cashu-charge-01.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index 4a217bb4..f3df74c4 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -156,9 +156,9 @@ The flow proceeds as follows: | | | | (3) Swap token to exact | | | amount (local) | | - |---------------------------------------------------------> | + |---------------------------------------------------------> | | (4) Exact-amount token | | - |<--------------------------------------------------------- | + |<--------------------------------------------------------- | | | | | (5) GET /resource | | | credential: token | | From c938991e4d7a92ca87238e303bfef269353edff1 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 10 Jun 2026 11:58:26 -0700 Subject: [PATCH 13/17] spec(cashu): apply review round 5; allow advertised nut10 conditions, condense - Server MAY advertise a nut10 spending condition it can satisfy; payer locks to it, server supplies the witness at swap. Bearer-only step 6 deleted; safety rides NUT-03 swap atomicity, now stated. - All swap rejections map to verification-failed with the cause in the problem detail; payment-expired is single-cause (stale challenge, re-present the same token). - DLEQ reduced to one SHOULD at the swap step; terminology entry, security-dleq section, and orphaned NUT-06 reference deleted. - Transport security compressed to three sentences. - single_use MUST be true; creqA payment id MUST equal the challenge id; clients reject violations. - Fees condensed; userinfo line and no-own-types sentence deleted; digest+opaque and service-defined-unit examples cut (siblings carry neither). Steps renumbered 1-8. Co-Authored-By: Claude Fable 5 --- specs/methods/cashu/draft-cashu-charge-01.md | 269 ++++++------------- 1 file changed, 89 insertions(+), 180 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index f3df74c4..8f8c94ff 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -76,12 +76,6 @@ normative: date: 2024 informative: - NUT-06: - title: "NUT-06: Mint information" - target: https://github.com/cashubtc/nuts/blob/main/06.md - author: - - org: Cashu - date: 2024 NUT-07: title: "NUT-07: Token state check" target: https://github.com/cashubtc/nuts/blob/main/07.md @@ -207,8 +201,8 @@ Mint Payment Request : A Cashu payment request {{NUT-18}} (a `creqA...` string) - encoding the amount, unit, acceptable mints, spending-condition - kind, and single-use flag a payer must satisfy. It is a + encoding the amount, unit, acceptable mints, any spending + condition, and single-use flag a payer must satisfy. It is a self-contained, mint-agnostic request artifact; all payment parameters are derived from it. @@ -222,13 +216,6 @@ Swap successful swap marks the inputs spent and is the act of redemption. -DLEQ Proof -: A discrete-log-equality proof {{NUT-12}} binding a blind signature - to the mint's advertised public key, proving the mint signed with - the key it published. In this method it lets the server detect a - mint that returns signatures it did not validly produce (see - {{security-dleq}}). - Swap Fee : The NUT-03 input fee a swap deducts from the input proofs (see {{fees}}). Deterministic from the input proofs' keysets, so it can @@ -258,30 +245,20 @@ pre-funds the swap fee, presenting a value of at least ## Fees {#fees} -A NUT-03 swap deducts an input fee set by the input proofs' -keyset(s): one term per input PROOF, each contributing its own -keyset's `input_fee_ppk`, summed and divided once with a ceiling: +A NUT-03 swap deducts a deterministic input fee set by the input +proofs' keyset(s): `swap_fee = ceil(sum_over_proofs(input_fee_ppk) / 1000)`, where `input_fee_ppk` is published per keyset in the mint's keyset list -({{NUT-02}}, {{NUT-03}}). Twelve proofs at 100 ppk owe 2, not 1: -the sum runs over proofs, not distinct keysets. The fee is -deterministic from the presented proofs, so both holder and server -compute the same value before the token is presented. +({{NUT-02}}, {{NUT-03}}). The sum runs over proofs, not distinct +keysets, and is computable from the presented proofs alone, so +holder and server arrive at the same value. Because the server makes no change, the holder pre-funds the fee: the presented token's total value MUST be at least -`amount + swap_fee` (for a zero-fee keyset, at least `amount`). -Value beyond that is accepted and retained by the server, so a -client SHOULD present the exact total. The server's value check -({{verification}}, step 8) recomputes `swap_fee` from the presented -proofs and never trusts a client-supplied value. - -Selecting proofs to total exactly `amount + swap_fee` has a mild -fixpoint (a proof added to cover the fee can raise the fee). -Fee-aware coin selection resolves it when the client swaps for -fresh denominations; a client spending already-held proofs MAY be -unable to land on the exact total, and then overpays by the -smallest margin its proofs allow. +`amount + swap_fee`. The server recomputes `swap_fee` from the +presented proofs ({{verification}}, step 7) and never trusts a +client-supplied value. Value beyond the requirement is accepted +and retained, so a client SHOULD present the exact total. # Encoding Conventions {#encoding} @@ -369,15 +346,16 @@ request JSON. The Cashu payment request all payment parameters, including the set of mints whose tokens the server accepts for this challenge. Clients MUST decode and verify the payment request independently before presenting, and -MUST reject challenges where `amount` or `currency` do not match -the values encoded in the payment request. +MUST reject challenges where `amount`, `currency`, or the +challenge `id` do not match the values encoded in the payment +request, or whose single-use flag is not true. paymentRequest : REQUIRED. The Cashu payment request string ({{NUT-18}}, a `creqA...` value). This field is authoritative; all payment parameters - (amount, unit, accepted mints, spending-condition kind, - single-use flag, optional description) are derived from it. The + (amount, unit, accepted mints, spending conditions, single-use + flag, optional description) are derived from it. The `a` (amount), `u` (unit), and `m` (mints) fields are OPTIONAL in {{NUT-18}}, but this method REQUIRES all three: a server MUST encode `a` and `u`, whose values back the `amount` and `currency` @@ -387,10 +365,15 @@ paymentRequest request omits any of them. The payment request's transport set MUST be empty, which {{NUT-18}} defines as in-band: the credential is returned over the same HTTP channel in the - `Authorization` header rather than over a separate transport. Its - spending-condition kind MUST be absent (`nut10` is `None`); see - {{verification}}. A NUT-18 payment id (`i`), if present, is - ignored: the challenge `id` identifies the payment. + `Authorization` header rather than over a separate transport. The + server MAY set the request's `nut10` field to a spending + condition it can satisfy at redemption ({{NUT-10}}); the payer + then locks the presented proofs to it, and the server supplies + the witness at the swap ({{verification}}, step 8). The + single-use flag MUST be true: a challenge identifies one + payment. The payment id (`i`) MUST be present and equal the + challenge `id`, so one identifier names the payment everywhere + it appears. # Credential Schema @@ -441,27 +424,6 @@ Example (decoded): } ~~~ -When the challenge carried `digest` and `opaque`, the credential -echoes them too: - -~~~json -{ - "challenge": { - "id": "kM9xPqWvT2nJrHsY4aDfEb", - "realm": "api.example.com", - "method": "cashu", - "intent": "charge", - "request": "eyJ...", - "digest": "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:", - "opaque": "eyJpbnRlbnQiOiJwaV8xMjMifQ", - "expires": "2026-03-15T12:05:00Z" - }, - "payload": { - "token": "cashuBpGF0gaJhaUgA..." - } -} -~~~ - # Verification Procedure {#verification} Upon receiving a request with a credential, the server MUST: @@ -492,15 +454,7 @@ Upon receiving a request with a credential, the server MUST: mint set (`m`), comparing by canonicalized mint URL (see {{mint-trust}}). A token whose mint is not a member is rejected as `verification-failed`. -6. Verify that no proof carries a NUT-10 {{NUT-10}} well-known - (P2PK or HTLC) secret. This intent accepts plain-secret BEARER - proofs only; a proof bound to a spending condition is rejected - as `verification-failed`, because the server cannot produce the - witness such a proof requires and its swap would otherwise fail - undiagnosably. The challenge's embedded payment request promises - this in advance: its `nut10` field is absent (see Method - Details). -7. Resolve every proof's keyset id against the mint's published +6. Resolve every proof's keyset id against the mint's published keysets {{NUT-02}}. When a proof uses a short keyset id, the server MUST resolve it to the full keyset via the mint's fetched keyset list and MUST reject a short id that is ambiguous or does @@ -509,30 +463,33 @@ Upon receiving a request with a credential, the server MUST: client-supplied data, the keyset is the authority; a proof whose keyset belongs to a different unit is rejected as `verification-failed`. -8. Compute `expected_swap_fee` over the resolved keyset(s) of the +7. Compute `expected_swap_fee` over the resolved keyset(s) of the presented proofs ({{fees}}) and verify the token's total value is at least `amount + expected_swap_fee`. A token worth less MUST be rejected as `payment-insufficient` ({{errors}}); value above the requirement is accepted and retained. The server makes no change either way. -9. Swap ({{NUT-03}}) the whole token at the configured URL of the - mint-set entry matched in step 5 (see {{settlement}}). A - successful swap is the redemption step and is the authoritative - check that the input proofs were genuine, validly signed, and - unspent. The server SHOULD verify the DLEQ proofs {{NUT-12}} - on the blind signatures the swap returns; a failed or missing - DLEQ proof after a successful swap is a mint-trust incident, - not a payment failure (see {{security-dleq}}). A swap rejected - because a proof is already spent is a `verification-failed` - condition; a swap rejected because the keyset has retired or - its `final_expiry` has passed is a `payment-expired` condition; - a swap rejected for any other reason is a `verification-failed` - condition: the token was not redeemed +8. Swap ({{NUT-03}}) the whole token at the configured URL of the + mint-set entry matched in step 5 (see {{settlement}}). When the + presented proofs carry the spending condition the payment + request advertised, the server supplies the corresponding + witness data in the swap inputs ({{NUT-10}}). A successful swap + is the redemption step and is the authoritative check that the + input proofs were genuine, validly signed, unspent, and that + any spending condition was satisfied; a rejected swap consumes + nothing ({{NUT-03}}), an atomicity this procedure relies on. + The server SHOULD verify the DLEQ proofs {{NUT-12}} on the + blind signatures the swap returns; a failure indicates a + misbehaving mint, not a payment failure, and the payment stands + ({{settlement}}). A rejected swap, whether for a spent proof, + an unsatisfied spending condition, a retired keyset or passed + `final_expiry` {{NUT-02}}, or any other cause, is a + `verification-failed` condition: the token was not redeemed (see {{settlement}}, {{errors}}). -Steps 4 through 8 are structural and MUST be performed before the -network swap in step 9, so a structurally invalid token never -reaches the swap. The keyset resolution of step 7 MAY require +Steps 4 through 7 are structural and MUST be performed before the +network swap in step 8, so a structurally invalid token never +reaches the swap. The keyset resolution of step 6 MAY require fetching the mint's keysets {{NUT-02}} first. The expected values these steps check (`currency`, the mint set, `amount`) are derived from the authenticated challenge's embedded payment @@ -543,7 +500,7 @@ challenge construction. These steps satisfy the "charge" intent's verification responsibilities ({{I-D.payment-intent-charge}}): challenge-match and freshness in step 3, payment-proof verification (the swap itself) -in steps 4-9, and amount-match in step 8 (read as the NET settled +in steps 4-8, and amount-match in step 7 (read as the NET settled amount, per {{fees}}). Recipient-match is implicit: redemption swaps the token to the server itself, so there is no distinct recipient and `recipient` is omitted. @@ -572,11 +529,11 @@ include `digest` and SHOULD include any `opaque` correlation data it needs echoed. -The `single_use` flag carried inside the embedded `creqA` -({{NUT-18}}) is informational for this method. Replay protection -does not depend on it: it comes from challenge binding (above) -together with the proof-level single-use property of the redeemed -token (see {{security-replay}}). +The single-use flag inside the embedded `creqA` ({{NUT-18}}) is +always true for this method (see Method Details), but replay +protection does not depend on it: it comes from challenge binding +(above) together with the proof-level single-use property of the +redeemed token (see {{security-replay}}). ## Short Keyset Identifiers {#short-keyset} @@ -645,7 +602,7 @@ challenge whose swap never succeeded remains presentable payment is complete: the server MUST NOT respond with a payment-failure status (402) or issue a fresh challenge for a condition detected after a successful -swap (see {{security-dleq}}). If resource delivery fails after the +swap. If resource delivery fails after the token is redeemed, the server MUST return an appropriate HTTP error (e.g., 500) and MUST NOT reissue the same challenge. The client MUST treat such a response as a payment loss and MAY retry with a @@ -740,8 +697,8 @@ The 402 problem types below are scoped to payment-verification failures. Payment-verification failures surface as the framework's -registered problem types; this method defines no problem types of -its own, only the cashu-specific causes that map to each: +registered problem types, with the cashu-specific causes that map +to each: https://paymentauth.org/problems/malformed-credential : HTTP 402. The credential could not be decoded, the JSON @@ -766,14 +723,9 @@ https://paymentauth.org/problems/invalid-challenge https://paymentauth.org/problems/payment-expired : HTTP 402. The challenge `expires` auth-param echoed in the - credential is in the past, or the mint rejected the swap because - the token's keyset has retired or its `final_expiry` {{NUT-02}} - has passed. A fresh challenge MUST be included in - `WWW-Authenticate`. The two causes need no discriminator: the - client SHOULD re-present the SAME token against the fresh - challenge once; a second consecutive `payment-expired` for that - token means its keyset has expired and the token SHOULD be - abandoned. + credential is in the past. The challenge is stale, not the + token: the client SHOULD re-present the same token against the + fresh challenge, which MUST be included in `WWW-Authenticate`. https://paymentauth.org/problems/payment-insufficient : HTTP 402. The token's total value is less than @@ -784,15 +736,19 @@ https://paymentauth.org/problems/payment-insufficient retained ({{fees}}). https://paymentauth.org/problems/verification-failed -: HTTP 402. The token failed a non-amount, non-expiry verification - check: its unit does not equal `currency`, its mint is not in - the payment request's mint set, a proof carries a NUT-10 - {{NUT-10}} spending condition, a proof uses an unresolvable or - ambiguous short keyset id, a resolved keyset's unit differs from - `currency`, or the mint rejected the swap, whether because a - proof was already spent or for any reason other than keyset - retirement or expiry (verification step 9). A fresh challenge - MUST be included in `WWW-Authenticate`. +: HTTP 402. The token failed a non-amount verification check: its + unit does not equal `currency`, its mint is not in the payment + request's mint set, a proof uses an unresolvable or ambiguous + short keyset id, a resolved keyset's unit differs from + `currency`, or the mint rejected the swap, whether for a spent + proof, an unsatisfied spending condition, a retired keyset or + passed `final_expiry` {{NUT-02}}, or any other cause + (verification step 8). The server SHOULD name the cause in the + problem `detail`. A client can distinguish an expired keyset + locally, since a wallet knows its proofs' keysets and their + `final_expiry`; such proofs can no longer be swapped, and + payment requires proofs from an active keyset. A fresh + challenge MUST be included in `WWW-Authenticate`. Mint unreachability is an infrastructure failure, not a payment-verification outcome, and carries no problem type: when @@ -866,38 +822,15 @@ binds nor stores its challenges cannot detect the redirection. The normative binding and echo rules live in {{verification}} (Challenge Binding). -## DLEQ Verification {#security-dleq} - -The security-relevant DLEQ check {{NUT-12}} is on the blind -signatures the mint RETURNS from the swap, not on the input proofs -the client presents. The server SHOULD verify the DLEQ proofs on -the swap-returned signatures: without that check a malicious mint -could report a successful charge while returning output proofs it -never validly signed, which the server then cannot spend. - -A failed or missing DLEQ proof on swap-returned signatures is a -mint-trust incident, not a payment failure. The client's inputs -were genuine and were consumed by the successful swap; only the -mint controls the signatures it returns. The server MUST NOT fail -the payment for it ({{settlement}}): it SHOULD serve the resource, -alert the operator, and quarantine the mint pending investigation. -The check is SHOULD rather than MUST because it protects the -server only against a mint its operator already chose to trust; -operators who rely on it SHOULD select mints that support NUT-12 -(discoverable via the mint's info endpoint {{NUT-06}}). - ## Keyset Rotation and Expiry A `final_expiry` boundary {{NUT-02}} can fall between the last -structural check (verification step 8) and the swap (step 9): a -token that -passes verification is not guaranteed to swap, because the mint -enforces keyset retirement and `final_expiry` at swap time. Servers -MUST treat a swap rejected for keyset retirement or passed -`final_expiry` as `payment-expired`, distinct from the -double-spend and disallowed-mint cases that are -`verification-failed`. A keyset that is merely inactive (no longer -the mint's current signing keyset but not yet retired or past +structural check (verification step 7) and the swap (step 8): a +token that passes verification is not guaranteed to swap, because +the mint enforces keyset retirement and `final_expiry` at swap +time; the rejected swap surfaces as `verification-failed` +({{errors}}). A keyset that is merely inactive (no longer the +mint's current signing keyset but not yet retired or past `final_expiry`) remains valid for redemption; the server MUST NOT reject a proof for keyset inactivity alone, only for actual retirement or expiry. Output proofs are blinded against the unit's @@ -918,9 +851,8 @@ internationalized host in its punycode A-label form), drop a default port (443 for `https`, 80 for `http`), and strip all trailing slashes (as token serialization does, {{NUT-00}}); the path and query are otherwise case-sensitive and preserved -verbatim. A mint URL containing userinfo (`user@host`) MUST be -rejected outright. Clients likewise rely on the listed mints to -honor the tokens they hold. +verbatim. Clients likewise rely on the listed mints to honor the +tokens they hold. ## Privacy @@ -956,16 +888,16 @@ credential-verification attempts per {{I-D.httpauth-payment}}. ## Transport Security {#security-transport} -All communication MUST use TLS per {{I-D.httpauth-payment}}. -A Cashu token is a bearer credential: any party that observes it -in transit before it is redeemed can redeem it itself. Challenge -binding does not prevent this (it binds the credential to a -challenge, not the token to a holder), so an interceptor at any -TLS-terminating hop (a reverse proxy, a compromised gateway) sees -the plaintext token and can redeem it directly. Credentials MUST -only be transmitted over HTTPS, and servers SHOULD redeem a -presented token promptly; TLS and prompt redemption shrink the -exposure window rather than close it. +A Cashu token without a spending condition is a bearer +credential: any party that observes it in transit before it is +redeemed can redeem it itself, and challenge binding does not +prevent this (it binds the credential to a challenge, not the +token to a holder). The framework's TLS requirement +({{I-D.httpauth-payment}}) therefore carries the token's full +value, including across TLS-terminating hops such as reverse +proxies. Servers SHOULD redeem a presented token promptly; TLS +and prompt redemption shrink the exposure window rather than +close it. # IANA Considerations @@ -1072,29 +1004,6 @@ Decoded receipt: } ~~~ -## Service-Defined Unit - -The same exchange with a service-defined unit: only the `currency` -and the unit encoded in the payment request differ. Here the mint's -keyset for the unit is fee-free (`input_fee_ppk = 0`, the operator's -choice), so the presented token is worth the requested -`amount`; against a fee-bearing keyset it would be `amount + swap_fee` -(see {{fees}}). The token is verified and redeemed identically; any -backing or expiry the unit carries is enforced by the mint at swap -time and is transparent to this method. - -Decoded `request`: - -~~~json -{ - "amount": "1", - "currency": "credits", - "methodDetails": { - "paymentRequest": "creqA..." - } -} -~~~ - # Acknowledgements The authors thank the Cashu developer community for the NUT From f3183d20a9aa7b4eff6072ff96bea0d3811509ef Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 10 Jun 2026 12:08:01 -0700 Subject: [PATCH 14/17] spec(cashu): omit the creqA payment id; drop unconditional bearer claims The i==id rule was unconstructible under stateless binding: the challenge id is the HMAC over the request bytes, and the creqA carrying i lives inside those bytes, so no embedded value can equal the computed id. Per review: i SHOULD be omitted; the challenge id identifies the payment. The client-side id-match rejection goes with it. Bearer-ness is now conditioned on the absence of a spending condition, so four unconditional bearer claims lose the word; the general protocol description in the introduction keeps it. Co-Authored-By: Claude Fable 5 --- specs/methods/cashu/draft-cashu-charge-01.md | 29 ++++++++++---------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index 8f8c94ff..8c9a5aeb 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -189,7 +189,7 @@ settlement procedures for the "cashu" payment method. # Terminology Cashu Token -: A bearer ecash token (a `cashuB...` string, the NUT-00 +: An ecash token (a `cashuB...` string, the NUT-00 TokenV4 serialization) encoding one or more proofs issued by a single mint under a single unit, the mint's URL, and that unit. The authoritative value carried by the credential. @@ -346,9 +346,9 @@ request JSON. The Cashu payment request all payment parameters, including the set of mints whose tokens the server accepts for this challenge. Clients MUST decode and verify the payment request independently before presenting, and -MUST reject challenges where `amount`, `currency`, or the -challenge `id` do not match the values encoded in the payment -request, or whose single-use flag is not true. +MUST reject challenges where `amount` or `currency` do not match +the values encoded in the payment request, or whose single-use +flag is not true. paymentRequest : REQUIRED. The Cashu payment request string ({{NUT-18}}, a @@ -371,9 +371,10 @@ paymentRequest then locks the presented proofs to it, and the server supplies the witness at the swap ({{verification}}, step 8). The single-use flag MUST be true: a challenge identifies one - payment. The payment id (`i`) MUST be present and equal the - challenge `id`, so one identifier names the payment everywhere - it appears. + payment. The payment id (`i`) SHOULD be + omitted: the challenge `id` identifies the payment, and under + stateless binding the `id` is computed over the `request` bytes, + so no embedded value can equal it. # Credential Schema @@ -394,9 +395,9 @@ source : OPTIONAL. A payer identifier string, as defined by {{I-D.httpauth-payment}}. The RECOMMENDED format is a Decentralized Identifier (DID) per - {{W3C-DID}}. Cashu tokens are bearer instruments and carry no - payer identity; implementations MAY omit this field, and - servers MUST NOT require it. + {{W3C-DID}}. Cashu tokens carry no payer identity; + implementations MAY omit this field, and servers MUST NOT + require it. payload : REQUIRED. A JSON object containing the Cashu-specific credential @@ -856,9 +857,9 @@ tokens they hold. ## Privacy -Cashu tokens are bearer instruments carrying no payer identity, -and the mint's blind signatures {{NUT-00}} unlink a token's -redemption from its issuance. The local-split model adds to this: +Cashu tokens carry no payer identity, and the mint's blind +signatures {{NUT-00}} unlink a token's redemption from its +issuance. The local-split model adds to this: because the holder splits its token locally and presents only what the charge requires, neither the server nor the mint observes the remainder or its secrets, and the server learns @@ -909,7 +910,7 @@ the "HTTP Payment Methods" registry established by | Method Identifier | Description | Reference | |-------------------|-------------|-----------| -| `cashu` | Cashu (Chaumian ecash) bearer token payment | This document | +| `cashu` | Cashu (Chaumian ecash) token payment | This document | Contact: TODO () From 55a8a4d61ab56f14090f67796ebe907d73e032f2 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 10 Jun 2026 18:45:32 -0700 Subject: [PATCH 15/17] spec(cashu): drop the receipt-reference proof-secret restatement The Privacy section already carries the normative rule (MUST NOT log token secrets; MUST use the token hash as the receipt reference); the receipt-field sentence restated it. Reference, do not restate. Co-Authored-By: Claude Fable 5 --- specs/methods/cashu/draft-cashu-charge-01.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index 8c9a5aeb..c6313426 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -648,10 +648,7 @@ reference : REQUIRED. A SHA-256 hash, as a lowercase hex string, of the exact `token` credential string received from the client (the `cashuB...` string as presented, not a re-encoding). Serves - as a stable, shareable settlement identifier. The token string - itself MUST NOT be used here: although a redeemed token is spent - and cannot be replayed, the proof secrets remain sensitive and - MUST NOT be exposed in logs, analytics, or shared receipts. + as a stable, shareable settlement identifier. status : REQUIRED. The string "success". From 982ba2ec8ac967d253bb2b7f417958eb30afd287 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 10 Jun 2026 18:52:08 -0700 Subject: [PATCH 16/17] spec(cashu): cut unenforceable logging hygiene + a DoS aside Drop 'Implementations MUST NOT log token secrets' (unenforceable implementation hygiene, no client/server-protocol bearing; the receipt-reference-is-the-hash rule already lives in Receipt Generation). Reframe the id-HMAC-key line to its protocol consequence (compromise forges challenges, defeats binding) without the MUST-NOT-be-logged directive. Drop the 'carries no proof-count field' parenthetical aside. Co-Authored-By: Claude Fable 5 --- specs/methods/cashu/draft-cashu-charge-01.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index c6313426..e91f1402 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -866,21 +866,18 @@ occurred: the blind signatures hide the link to issuance, not the redemption itself. The mint, however, can still correlate the holder's pre-payment split with the redemption moments later by amount and timing; clients that need to avoid that SHOULD hold -pre-made exact-value tokens. -Implementations MUST NOT log token secrets, and -MUST use the token hash, not the token, as a receipt reference (see -{{receipt}}). The stateless `id`-HMAC key is a server secret and -MUST NOT be logged or shared; its compromise lets an attacker forge -challenges and defeat challenge binding. +pre-made exact-value tokens. The stateless `id`-HMAC key is a +server secret; its compromise lets an attacker forge challenges and +defeat challenge binding. ## Denial of Service {#security-dos} A token carrying a very large number of proofs inflates both verification cost and the swap fee. Servers SHOULD bound the number of proofs they accept in a single token; this bound is a -server-internal limit and is not advertised in the challenge (which -carries no proof-count field), so a client learns of it only when -an over-large token is rejected as `malformed-credential`. +server-internal limit and is not advertised in the challenge, so a +client learns of it only when an over-large token is rejected as +`malformed-credential`. Servers SHOULD also rate-limit challenge issuance and credential-verification attempts per {{I-D.httpauth-payment}}. From c09a60efbee45bbf3d868a0fc632e38fab424ec4 Mon Sep 17 00:00:00 2001 From: orveth Date: Wed, 10 Jun 2026 18:55:32 -0700 Subject: [PATCH 17/17] spec(cashu): restore the no-log-secrets line (lightning house-style) Re-adds 'Implementations MUST NOT log token secrets, and MUST use the token hash as a receipt reference', consistent with the lightning charge spec's equivalent. The id-HMAC-key line stays reframed (no lightning precedent for it). Co-Authored-By: Claude Fable 5 --- specs/methods/cashu/draft-cashu-charge-01.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/specs/methods/cashu/draft-cashu-charge-01.md b/specs/methods/cashu/draft-cashu-charge-01.md index e91f1402..51b632b1 100644 --- a/specs/methods/cashu/draft-cashu-charge-01.md +++ b/specs/methods/cashu/draft-cashu-charge-01.md @@ -866,7 +866,9 @@ occurred: the blind signatures hide the link to issuance, not the redemption itself. The mint, however, can still correlate the holder's pre-payment split with the redemption moments later by amount and timing; clients that need to avoid that SHOULD hold -pre-made exact-value tokens. The stateless `id`-HMAC key is a +pre-made exact-value tokens. Implementations MUST NOT log token +secrets, and MUST use the token hash, not the token, as a receipt +reference (see {{receipt}}). The stateless `id`-HMAC key is a server secret; its compromise lets an attacker forge challenges and defeat challenge binding.