lnurlcash-conformance 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,177 @@ Semantic versioning. While the LUD-25 draft is unmerged, `0.x` minor bumps
4
4
  may add or tighten checks that a previously-passing mint now fails; pin an
5
5
  exact version if you gate CI on the grade.
6
6
 
7
+ ## 0.14.0 - 2026-09-26
8
+
9
+ **LUD-25's unified taproot model** (luds `6e865b1`, "unified taproot
10
+ verification"). Every note is now a BIP-341 output key `Q`, named `cp1<Q>`,
11
+ and every spend opens it by its key path (`ck1`) or a leaf of its script
12
+ tree (`cw1`), with every signature over the sighash of one canonical,
13
+ never-broadcast transaction whose prevout is bound to the mint's domain. A
14
+ bearer note is the one-leaf hashlock case: its 64-hex preimage and its hex
15
+ `h` are short forms, so the Part 1 wire keeps working unchanged, but the
16
+ note is keyed by `Q` and certified over `hex(Q)`. A mint graded clean by
17
+ 0.13.x will fail several checks here; that is the spec moving, not the
18
+ grader.
19
+
20
+ **Derivation purposes and certificate names** (luds `50d740a`). A note key
21
+ is now `t = tagged_hash("LNURLcash/derive", P || chaincode || ser32(purpose)
22
+ || ser32(i)) mod n`, with purpose 0 for the wallet's own notes (and a
23
+ split's `p1`), 1 for a split's change `p2` and 2 for Lightning Address
24
+ auto-mint and internal transfer, each with its own counter and recovery gap
25
+ limit. The register/unregister proof signs with the purpose-0 index-0 key.
26
+ Certificates travel as `c` and `c2` (JSON) and `&c=` (note URL) rather than
27
+ `sig`, `sig2` and `&sig=`, and the internal-transfer hint is `text/cpub`
28
+ rather than `text/xpub`, its index the purpose-2 counter.
29
+
30
+ Vectors:
31
+
32
+ - `spec-vectors.json` follows 25.md's published vectors exactly. Vector 2's
33
+ address proofs sign `sha256("LNURLcash:<action>:<domain>:<username>")`;
34
+ vector 3 is now the key-path `ck1`, with the prevout, every `SigMsg`
35
+ field, the sighash and the serialised canonical spend transaction;
36
+ vector 5 is new, a bearer note from preimage to `cw1` and its `cs1`, and
37
+ its certified note URL carries `&c=`. Every note in vectors 1 and 2 gains
38
+ `purpose`; vector 1 lists purpose 0 at i = 0, 1, 2 and 5 and index 0 on
39
+ purposes 1 and 2, vector 2 purpose 0 at i = 0, 1 and 2, and vectors 3
40
+ and 4 move to the purpose-0 key, so every derived value, proof, `ck1` and
41
+ `cs1` changes. The selfcheck now holds every value to a literal
42
+ transcribed from 25.md at `50d740a`, and recomputes each independently.
43
+ - New `spends.json`: bearer notes of both parities, one key's `ck1` across
44
+ several domains (and the cross-domain spends that must not verify), a
45
+ three-leaf script tree with each leaf's control block, `cw1` and verdict
46
+ plus a key-path spend of the tweaked key, a `CHECKSIG` leaf's
47
+ script-path sighash, time-claim cases, leaf-policy cases (an `OP_SUCCESS`
48
+ byte inside pushed data is data), malformed `cw1`s, off-curve `cp1`s and
49
+ the short-form equivalences.
50
+ - `part2.json` and `nostr-seed.json`: every `ck1` now signs the key-path
51
+ sighash for its host's domain (lowercase, no port), so each branch or case
52
+ gains `domain`, and each note gains `sighash` and `keyPathSignature` in
53
+ place of `ownershipSignature`. The address proofs gain `domain` and sign
54
+ the domain-bound message. `conventions` drops the fixed ownership message.
55
+ Consumers reading `ownershipSignature` break loudly, which is intended:
56
+ it no longer means what it did.
57
+ - `part2.json` and `nostr-seed.json` derive by purpose. Every note gains
58
+ `purpose` beside `index`. Each `part2.json` branch lists purpose 0 at the
59
+ same indices as before, then indices 0 and 1 on purposes 1 and 2, so
60
+ `notes[0]` is still the purpose-0 index-0 key the address proofs sign
61
+ with; each `nostr-seed.json` case lists purpose 0 at 0, 1 and 7, then
62
+ index 0 on purposes 1 and 2. `conventions` gains `purposes` and
63
+ `purposeUse`. A new `prePurpose` record keeps the superseded tweak
64
+ (no `ser32(purpose)`) for one branch, marked `superseded`, only so a
65
+ wallet can recognise and sweep notes it derived under luds `6e865b1`.
66
+ - `note-url.json`, `responses.json` and `withdraw-info.json` use `c`, `c2`
67
+ and `&c=`. `note-url.json` keeps one parse case reading a certificate from
68
+ an older note URL's `&sig=`, and drops a stale `sig` along with a stale
69
+ `c` when the `k1` changes; `responses.json` adds a mint sending the
70
+ certificate under both names. `withdraw-info.json`'s `requestMustNotSend`
71
+ lists both.
72
+ - `spends.json`'s key-path cases sign with vector 1's purpose-0 `sk_0`, so
73
+ the `mint.example` `ck1` is still test vector 3's.
74
+ - `part2.json`'s `noteTweak` says `t` is reduced mod n, as 25.md requires,
75
+ where it said `t >= n` is unusable. No vector reaches n, so no value
76
+ changes. The mock mint's auto-mint reduces rather than skipping the index.
77
+
78
+ Grader, `gradeNote`:
79
+
80
+ - The note given may be any spend of it: a 64-hex preimage, a `ck1` or a
81
+ `cw1`. Mutations name their outputs as `p1`/`p2` rather than `h`/`h2`,
82
+ and lookups use `?p=`.
83
+ - `answers a note lookup by p without the spend` replaces the optional
84
+ hash lookup, and now fails a mint that refuses it: LUD-25 makes `?p=` a
85
+ MUST. It asks by the note's `cp1` and, for a bearer note, its hex `h`.
86
+ - `certifies a bearer output over hex(Q)` replaces `a legacy hash mutation
87
+ signature verifies when present`. A missing certificate warns (a SHOULD);
88
+ one that does not verify over `hex(Q)` fails, and one over the bearer
89
+ note's `h` is named as the pre-taproot message.
90
+ - `a certificate on the informational GET verifies over hex(Q)` replaces
91
+ `keeps signatures off the informational endpoint`: a `sig` there must be
92
+ a `cs1` for exactly the queried note.
93
+ - `refuses a p1 naming a burned note, as "already in use"` replaces
94
+ `refuses an output hash that already names a note`, asks by `cp1` and by
95
+ hex `h`, and fails any reason but exactly `already in use`.
96
+ - `refuses a duplicated k1` also sends one note as its preimage and its
97
+ full `cw1`; `refuses a split whose p1 equals p2` also names one note as its
98
+ `h` and its `cp1`; `refuses a replayed burn` also tries the full `cw1`.
99
+ - `replays a retried mutation rather than refusing it` also retries a
100
+ completed rotate naming its note by the full `cw1` instead of the
101
+ preimage, and its output by `cp1` instead of `h`. Both must replay with
102
+ the same certificate: LUD-25 matches retries on decoded `Q`s.
103
+ - New, on a three-leaf script tree the grader funds by a bearer note's full
104
+ `cw1` and holds itself (MUST, fail): `a bearer note's full cw1 is the
105
+ same spend as its preimage`, `refuses a leaf version other than 0xc0`,
106
+ `refuses a leaf carrying an OP_SUCCESS opcode`, `refuses a block-height
107
+ locktime`, `refuses a locktime still in the future`, `refuses a
108
+ block-count relative lock`, `refuses a relative lock that has not yet
109
+ run` and `accepts a locktime already past`. Each refusal is tried at the
110
+ informational GET as well as the callback.
111
+ - New, on a key-path note (MUST, fail): `credits a key-path note named by
112
+ its cp1`, `the informational GET refuses a spend that does not verify`,
113
+ `refuses a ck1 bound to another domain` and `spends a key-path note by a
114
+ ck1 bound to its own domain`. They replace `certifies a cp1 note it
115
+ issues (Part 2)`, which warned when a mint took no `cp1`; LUD-25 no
116
+ longer has an optional part, so that now fails.
117
+ - New: `every certificate verifies over hex(Q) and the note value`, over
118
+ every `cs1` the run collected. A wrong one fails; missing ones warn.
119
+ - Certificates are read from `c` and `c2` only, on the informational GET
120
+ and every rotate, split and merge, and a retry must return the same `c`
121
+ and `c2`. A mint sending only the pre-`50d740a` `sig`/`sig2` is graded
122
+ uncertified, a warning that names the old spelling; a mint sending both
123
+ names, as one mid-transition does, is graded on `c` and not faulted for
124
+ the extra `sig`. The note URL a run ends with drops `c` and `sig` both.
125
+ - Value is conserved on every path. A refusal is confirmed to have left the
126
+ value in place, a misbehaving mint's output is adopted rather than lost,
127
+ and the script tree has two ways home (its hashlock leaf, then its key
128
+ path). The grader's `ck1`s sign the domain-bound sighash for the note
129
+ URL's own hostname; the deprecated fixed-message `ck1` is tried only as a
130
+ last rescue of the value, and never graded.
131
+ - `gradeNote` resolves to `{finalSecret, noteUrl}`, as it always did in
132
+ practice; the declarations now say so.
133
+
134
+ Grader, `gradeMint`: under `--address`, the internal-transfer hint is read
135
+ from `text/cpub`; one published only as `text/xpub` fails that check. The
136
+ comment check also requires a `cp1<Q>` comment to
137
+ be invoiced, and a `cp1` whose key is not a curve point to be refused before
138
+ any invoice exists. That rule is probed only here: as a `p1` it would
139
+ destroy the note under grade on a mint that got it wrong.
140
+
141
+ API: `ownershipProof(secretKey)` is gone. `keyPathSpend(secretKey, domain)`
142
+ replaces it, since a `ck1` without a domain no longer means anything.
143
+
144
+ Mock mint: keys notes by `hex(Q)` and verifies every spend in full (a `ck1`
145
+ against the hostname it was reached at, or `--domains`; a `cw1` by the leaf
146
+ and time rules, then the script, of which it evaluates only a bearer
147
+ hashlock). It certifies every note with a `cs1` over `hex(Q)`, answers
148
+ `?p=` (`?h=` still read), reads `p1`/`p2` (and `h`/`h2`), and refuses a
149
+ collision as `already in use`. `creditNote` takes any spend; `creditOutput`
150
+ takes a `cp1` or hex `h`. New misbehaviours, each caught by the self-grade
151
+ with the value brought home: `leafVersionUnchecked`, `opSuccessUnchecked`,
152
+ `ignoresTimeClaims` (all rules or named ones), `refusesLocktimes`,
153
+ `unverifiedCk1`, `infoSkipsVerification`, `replayMatchesStrings`,
154
+ `alreadyInUseReason`, `certificateOverH`, `refusesCp1Outputs`,
155
+ `acceptsOffCurveCp1`; `acceptsMissingP2` is the new name of
156
+ `acceptsMissingH2`. The optional bound-mint receipt keeps signing its `h`.
157
+ It sends certificates as `c` and `c2`, and its registered address auto-mints
158
+ on purpose 2 and publishes `text/cpub`; `certificateNames` (`'sig'` or
159
+ `'both'`) and `addressHintType` reproduce a mint still on the older names.
160
+ It has no register/unregister endpoint, so the purpose-0 proof key is held
161
+ to the vectors only.
162
+
163
+ Not yet brought over: `signature.json`, `callbacks.json`,
164
+ `retried-mutation.json` and the other Part 1 wire vectors still describe the
165
+ `h`/`h2` spelling and the certificate over `h`. The wire they describe still
166
+ works through the short forms, but their certificate cases are the
167
+ pre-taproot message.
168
+
169
+ ## 0.13.1 - 2026-09-16
170
+
171
+ - The grader's own `ck1` (the one the Part 2 certification check spends
172
+ with) now signs `sha256("LNURLcash")`, like the vectors. It still signed
173
+ the raw 9-byte message, so a mint that dropped that older form would have
174
+ failed the check, and a mint that only read the older form would have
175
+ passed it. `ownershipProof` is exported, and the selfcheck holds it to the
176
+ digest.
177
+
7
178
  ## 0.13.0 - 2026-09-16
8
179
 
9
180
  **`ck1` and the LN-address register/unregister proof now sign a sha256
package/README.md CHANGED
@@ -48,7 +48,9 @@ for (const c of cases) {
48
48
  | `signature.json` | offline verification, both recovery-id orderings, malformed input |
49
49
  | `derivation.json` | deterministic note secrets from a BIP39 seed |
50
50
  | `cash-derivation.json` | LUD-25's seed-recoverable note secrets under `m/139'`, with BIP-32's own vector 1 |
51
- | `part2.json` | Part 2: `cp1`, 96-byte-payload Schnorr `ck1`, amount-bearing recoverable-ECDSA `cs1`, `cx1`, the per-note key tweak, ownership proofs and mint certificates, on the reference wallet's `m/139'/1'` address path |
51
+ | `spec-vectors.json` | LUD-25's own published test vectors 1-5, transcribed: derivation, the domain-bound address proof, a key-path `ck1` with every `SigMsg` field, a mint certificate, and a bearer note from `h` to `cw1` |
52
+ | `spends.json` | the unified taproot model: bearer notes, `ck1`s across domains, a three-leaf script tree with its control blocks and verdicts, a `CHECKSIG` leaf's script-path sighash, time claims, leaf policy, malformed `cw1`s and the short forms |
53
+ | `part2.json` | key-path notes: `cp1`, the domain-bound `ck1`, amount-bearing `cs1` certificates over `hex(Q)`, `cx1`, the per-note key tweak and address proofs, on the `m/139'/d1..d4` address path |
52
54
  | `bech32.json` | LUD-01 encoding, round trips, corrupted checksums |
53
55
  | `url-admission.json` | which URLs may be fetched, and why `data:` must never be |
54
56
  | `input-resolution.json` | bech32, LUD-17, Lightning Addresses, bare domains |
@@ -63,13 +65,14 @@ for (const c of cases) {
63
65
  | `settle-for-value.json` | the decision table a server works through to take a note as payment |
64
66
  | `retried-mutation.json` | what makes a repeated mutation a retry rather than a double-spend |
65
67
  | `mint-to-hash.json` | additive `mintToHash` compatibility and optional bound LUD-21 receipts; not baseline LUD-25 |
66
- | `nostr-seed.json` | a Part 2 address branch rooted in a Nostr identity key, for a holder with no BIP39 words (heartwood-esp32, lnurlcash-kit); an extension, not LUD-25 |
68
+ | `nostr-seed.json` | a key-path address branch rooted in a Nostr identity key, for a holder with no BIP39 words (heartwood-esp32, lnurlcash-kit), with domain-bound `ck1`s; an extension, not LUD-25 |
67
69
  | `lifecycle.json` | behavioural requirements, as scenarios to drive |
68
70
  | `threat-suite.json` | the transport/exposure scorecard — candidate spec options against fixed attacks (non-normative) |
69
71
 
70
72
  Regenerate with `npm run generate`; check them with `npm test`, which
71
73
  verifies every digest recomputes, every declared signature really does
72
- verify, and every fee expectation follows from the formula.
74
+ verify, every fee expectation follows from the formula, and every value in
75
+ `spec-vectors.json` is the hex 25.md itself publishes.
73
76
 
74
77
  The upstreamable wire text and compatibility matrix for the optional receipt
75
78
  are in [`docs/BOUND-MINT-RECEIPTS.md`](docs/BOUND-MINT-RECEIPTS.md).
@@ -95,8 +98,9 @@ must survive:
95
98
  | `--echoWrongK1` | answers the informational GET with a different `k1` |
96
99
  | `--lieAboutValue=N` | reports a `maxWithdrawable` it never signed |
97
100
  | `--signatureLayout=leading` | emits the recovery id at the other end |
98
- | `--signatures=false` | issues no optional Part 1 signatures. Unsigned plain-hash outputs are conforming; Part 2 `cp1` outputs still require certificates |
99
- | `--serverGeneratedSecrets` | hands back a secret it generated — the exposure `h` exists to close |
101
+ | `--signatures=false` | certifies nothing. LUD-25 has a mint certify every note with a `cs1` over `hex(Q)` as a SHOULD, so the grader warns |
102
+ | `--certificateOverH` | certifies a bearer note over its `h`, the pre-taproot message, instead of `hex(Q)` |
103
+ | `--serverGeneratedSecrets` | hands back a secret it generated: the exposure `p1`/`p2` exist to close |
100
104
  | `--meltNeverSettles` | holds every melt in flight, so notes stay `pending` |
101
105
  | `--meltAlwaysFails` | fails every payment, restoring the note |
102
106
  | `--slowMs=N` | delays every response |
@@ -105,10 +109,20 @@ must survive:
105
109
  | `--roundFeeToSat` | rounds the withheld fee up to a whole sat — the note mints short of the formula |
106
110
  | `--verifyLeaksEarly` | serves a preimage before settlement, falsely claiming payment proof before payment happened |
107
111
  | `--retriedMutation=refuse` | deliberately answers an identical mutation retry as already spent instead of replaying its original success |
108
- | `--hashLookup=echoesK1` | offers hash lookup but puts a `k1` back in the response |
109
- | `--hashLookup=answersUnknown` | offers hash lookup but invents a note for an unknown hash |
110
- | `--hashLookup=hidesSpent` | incorrectly reports a retained spent hash as unknown |
111
- | `--hashLookup=acceptsBoth` | offers hash lookup but accepts `k1` and `h` together |
112
+ | `--replayMatchesStrings` | matches a retry on the raw `k1`/`p1`/`p2` strings, so the same spend spelt another way is refused as already spent |
113
+ | `--alreadyInUseReason=<text>` | refuses a `p1`/`p2` naming a note already in use with some reason other than LUD-25's exact `already in use` |
114
+ | `--leafVersionUnchecked` | accepts a leaf version other than `0xc0`, as consensus alone would |
115
+ | `--opSuccessUnchecked` | accepts a leaf carrying an `OP_SUCCESSx` opcode |
116
+ | `--ignoresTimeClaims[=rule,...]` | ignores a script path's time claim: every rule, or `blockHeight`, `future`, `blockCount`, `relative` |
117
+ | `--refusesLocktimes` | over-strict: refuses every non-zero locktime, a past Unix time included |
118
+ | `--unverifiedCk1` | accepts any `ck1` whose `Q` is outstanding without checking its signature, so one bound to another domain opens the note |
119
+ | `--infoSkipsVerification` | answers the informational GET for a `k1` from its `Q` alone, never verifying the spend |
120
+ | `--refusesCp1Outputs` | refuses a `cp1` wherever one may go, as a mint without key-path notes does |
121
+ | `--acceptsOffCurveCp1` | accepts a `cp1` whose key is not a curve point |
122
+ | `--hashLookup=echoesK1` | answers a lookup by `p` but puts a `k1` back in the response |
123
+ | `--hashLookup=answersUnknown` | answers a lookup by `p` for a note it never registered |
124
+ | `--hashLookup=hidesSpent` | incorrectly reports a retained spent note as unknown |
125
+ | `--hashLookup=acceptsBoth` | accepts `k1` and `p` together |
112
126
  | `--mintToHashAcceptsMalformedH` | claims `mintToHash` and invoices an `h` that is not 64 lowercase hex, so a wallet pays for a quote the mint will refuse |
113
127
  | `--mintToHashAcceptsUsedH` | claims it and invoices an `h` that already names a note, an invoice or another quote's output |
114
128
  | `--mintToHashIgnoresH` | claims it but accepts `h` and mandatory `comment` naming different outputs |
@@ -117,10 +131,19 @@ The three `mintToHash*` misbehaviours need `--mintToHash` alongside them;
117
131
  on their own they do nothing, because a mint that never offered the
118
132
  capability cannot misuse it.
119
133
 
120
- Hash lookup keeps the spending secret off the wire but reports spent state:
121
- `--hashLookup=true` distinguishes a burned hash from an unknown hash. The older
134
+ The lookup by `p` (a `cp1`, or a bearer note's hex `h`; `?h=` is still read as
135
+ its older name) keeps the spend off the wire but reports spent state:
136
+ `--hashLookup=true` distinguishes a burned note from an unknown one. The older
122
137
  `--hashLookup=revealsSpent` spelling remains an alias for this compliant behaviour.
123
138
 
139
+ The mock keys every note by its taproot output key `Q` and verifies every
140
+ spend it is handed: a `ck1` against the key-path sighash for the hostname it
141
+ was reached at (or `--domains=a,b`), and a `cw1` by the leaf rules, the time
142
+ rules against its own clock, and then the script. It has no script
143
+ interpreter, so of the scripts themselves it judges only a bearer hashlock
144
+ (under any internal key, at any depth) and refuses any other as one it
145
+ cannot verify.
146
+
124
147
  The remaining flags enable optional features, legal wire variants, or make a
125
148
  conforming default explicit. Optional fields stay absent unless requested:
126
149
 
@@ -136,7 +159,7 @@ conforming default explicit. Optional fields stay absent unless requested:
136
159
  | `--previousPrivateKey=<hex>` | an old signing key the mock still holds. Its public half joins `previousPubkeys` on its own |
137
160
  | `--signWithPreviousKey` | issues every note under that old key while still advertising the new one: the mid-rotation state a mint passes through when the advertisement moves before the signer |
138
161
  | `--retriedMutation=replay` | answers a byte-identical repeat of a mutation with the original success. This is the conforming default; use `refuse` only as an adversarial fixture |
139
- | `--hashLookup=false` | models an older SERVICE with no secret-free informational lookup. The current reference mock accepts `h=sha256(k1)` by default |
162
+ | `--hashLookup=false` | models an older SERVICE with no lookup by `p`, which LUD-25 now makes a MUST |
140
163
  | `--mintToHash` | accepts `h` alongside the mandatory identical comment and enables the additive quote/receipt fields. Off by default; baseline comment-bound minting remains on |
141
164
  | `--mintReceipt` | with `--mintToHash`, adds the optional quote commitment and signed LUD-21 settlement receipt |
142
165
  | `--mintToHashAdvertisedOn=quote` | narrows which of the three places claim it (`payRequest`, `mintAddress`, `quote`); all three by default. Changes only what is claimed, never what the mint does |
@@ -152,11 +175,14 @@ mint.state.creditNote(k1, 21000)
152
175
  await mint.close()
153
176
  ```
154
177
 
155
- `mint.state` exposes `creditNote`, `noteState`, `settleMelt`, `failMelt` and
156
- the raw note and invoice maps, so a test can assert what the SERVICE
157
- actually did rather than what it said. `creditNote(k1, amount, {previousKey:
158
- true})` signs that one note under `previousPrivateKey`, which is how a case
159
- puts one note under the old signing key and the rest under the new.
178
+ `mint.state` exposes `creditNote`, `creditOutput`, `noteState`, `settleMelt`,
179
+ `failMelt` and the raw note and invoice maps (notes keyed by `hex(Q)`), so a
180
+ test can assert what the SERVICE actually did rather than what it said.
181
+ `creditNote` takes any spend of the note (a 64-hex preimage, a `ck1` or a
182
+ `cw1`); `creditOutput` takes what names it (a `cp1` or a bearer note's hex
183
+ `h`). `creditNote(k1, amount, {previousKey: true})` certifies that one note
184
+ under `previousPrivateKey`, which is how a case puts one note under the old
185
+ signing key and the rest under the new.
160
186
 
161
187
  ## The grader
162
188
 
@@ -167,14 +193,14 @@ npx lnurlcash-conform mint@example.com
167
193
  Read-only by default: resolves the payRequest, checks the `withdrawLink`
168
194
  (either legal spelling, and the report says which one the mint uses),
169
195
  the fee advertisement, invoice amounts, mandatory `commentAllowed: 64`,
170
- pre-invoice rejection of missing or malformed mint comments, whether an
171
- unknown note is reported distinguishably from a spent one, and the
172
- experimental mint address.
196
+ pre-invoice rejection of missing or malformed mint comments (a `cp1` whose
197
+ key is not on the curve among them), whether an unknown note is reported
198
+ distinguishably from a spent one, and the experimental mint address.
173
199
 
174
- Current LUD-25 minting is always comment-bound. The wallet persists a secret,
175
- sends `comment=hex(sha256(secret))`, and the payment preimage remains ordinary
176
- settlement proof. A mint that cannot accept the 64-character commitment, or
177
- that silently creates a preimage-backed note, fails grading.
200
+ Current LUD-25 minting is always comment-bound. The wallet names the note it
201
+ is buying as `comment=cp1<Q>`, or as a bearer note's hex `h`, and the payment
202
+ preimage remains ordinary settlement proof. A mint that cannot accept either
203
+ spelling, or that silently creates a preimage-backed note, fails grading.
178
204
 
179
205
  `mintToHash` is retained as an additive compatibility field. When advertised,
180
206
  the runner sends `h` alongside the mandatory comment and requires both to name
@@ -226,27 +252,52 @@ Both are still read-only. The full run spends:
226
252
  npx lnurlcash-conform mint@example.com --note='lnurlw://...?k1=...' --spend
227
253
  ```
228
254
 
229
- It burns the note it is given and prints where the value ended up. It
230
- checks that the informational GET is idempotent and echoes the queried
231
- `k1`, that the URL's own `amount` is ignored, that a rotate with no `h` is
232
- refused, that a rotate returns no secret, that signatures verify against the
233
- advertised `mintPubkey` or any key the mint still publishes as a previous
234
- one, that split and merge conserve value - exactly,
235
- under LUD-25's fee algebra, when the mint's fee advertisement is known -
236
- that a byte-identical repeat of a mutation is answered with the original
237
- success rather than as an already-spent input, and that a
238
- burned secret cannot be replayed. It also probes three adversarial shapes a
239
- mint must refuse atomically: a duplicated `k1` (which a careless mint counts
240
- twice, minting money from nothing), an output hash that collides with an
241
- existing note id (minting over it hands the output to whoever already knows
242
- that id's preimage), a split whose `h` equals `h2` (one id cannot carry
243
- two notes), a split naming only one output hash (a mint that accepts it is
244
- generating the change secret itself), and a split leaving change one msat
245
- short of the advertised base fee (which LUD-25 says to refuse with
246
- `insufficient value`, not to serve at a loss). And it replays the callback as a POST and as an OPTIONS
247
- preflight - real HTTP stacks send both on their own initiative, so the
248
- mutating endpoint must answer GET only. After every refusal it confirms the refused note is still
249
- spendable. Use a small note. Exit code is non-zero if anything failed.
255
+ It burns the note it is given and prints where the value ended up. The
256
+ note's `k1` may be any spend of it: a bearer note's 64-hex preimage, a `ck1`
257
+ or a `cw1`. It grades LUD-25 as of the unified taproot model (luds
258
+ `6e865b1`), where every note is a taproot output key `Q`, with the
259
+ derivation purposes and certificate names of luds `50d740a`: certificates
260
+ are read from `c` and `c2`, and a mint that also sends the older `sig` and
261
+ `sig2` is not faulted for it.
262
+
263
+ On the note as given it checks that the informational GET is idempotent,
264
+ echoes the queried `k1` and ignores the URL's own `amount`; that a lookup by
265
+ `p` answers by the note's `cp1` and by its hex `h`, with no `k1` in the reply,
266
+ and tells a spent note from an unknown one; that a rotate with no `p1` and a
267
+ split with no `p2` are refused; that a rotate returns no secret; that split
268
+ and merge conserve value, exactly under LUD-25's fee algebra when the fee is
269
+ known; and that a retried mutation is answered with the original success,
270
+ byte for byte and when the retry spells the same spend (preimage or full
271
+ `cw1`) or the same output (`h` or `cp1`) another way, while a burned note
272
+ cannot be spent again. It probes the shapes a mint must refuse atomically:
273
+ one note named twice in a merge, in the same or two spellings (a careless mint
274
+ counts it twice, minting money from nothing); a `p1` naming the burned note
275
+ it was given, which must be refused as exactly `already in use`; a split
276
+ whose `p1` and `p2` name one note; a split leaving change short of the base
277
+ fee (`insufficient value`); and the callback replayed as a POST and as an
278
+ OPTIONS preflight, since the mutating endpoint must answer GET only.
279
+
280
+ Then it moves the value through two notes of its own. The first is a
281
+ three-leaf script tree under an internal key it holds: a bearer hashlock at
282
+ leaf version `0xc0`, the same shape at `0xc2`, and a hashlock followed by
283
+ `OP_SUCCESS80`. It is funded by the full `cw1` of a bearer note, which must be
284
+ the same spend as its preimage. On it the grader shows that a leaf version
285
+ other than `0xc0` and an `OP_SUCCESS` leaf are refused, and that time claims
286
+ are judged by the mint's own clock: a block-height locktime, a locktime in the
287
+ future, a block-count relative lock and an unelapsed relative time lock are
288
+ refused, and a locktime already past is accepted. The second is a key-path
289
+ note, `cp1<Q>` with `Q` untweaked as a seeded wallet makes them: a `ck1`
290
+ signed for another domain is refused at the callback, the informational GET
291
+ refuses it and a `ck1` signed by another key, and the `ck1` bound to the note
292
+ URL's own hostname spends it.
293
+
294
+ Every refusal is confirmed to have left the value where it was, and every
295
+ path has a way home (the tree's hashlock leaf, then its key path), so a
296
+ compliant run ends holding a bearer note worth what it started with.
297
+ Certificates are a SHOULD: a missing `cs1` warns, but every `cs1` returned,
298
+ on a mutation or on the informational GET, must verify over `hex(Q)` and the
299
+ note's value, and one over a bearer note's `h` (the pre-taproot message)
300
+ fails. Use a small note. Exit code is non-zero if anything failed.
250
301
 
251
302
  **What the grader cannot reach.** It never melts. Melting spends real sats
252
303
  against a real mint, which is not something a grading tool may decide to do,
package/llms.txt CHANGED
@@ -13,9 +13,12 @@ vectors/index.json:
13
13
  signature.json offline verification, both recovery-id orderings
14
14
  derivation.json deterministic note secrets from a BIP39 seed
15
15
  cash-derivation.json LUD-25 m/139' note secrets, plus BIP-32 vector 1
16
- part2.json Part 2: cp1, ck1=pubkey||BIP340 Schnorr sig, cs1=recoverable ECDSA certificate,
17
- cx1, the note-key tweak, ownership proofs and certificates;
18
- address path is the reference wallet's m/139'/1'/d1..d4 (not the text's)
16
+ spec-vectors.json 25.md's own test vectors 1-5, every value checked against the spec text
17
+ spends.json the taproot model: bearer notes, ck1 per domain, a 3-leaf tree and its
18
+ verdicts, a CHECKSIG leaf's sighash, time claims, leaf policy, bad cw1s
19
+ part2.json key-path notes: cp1, ck1=Q||BIP340 sig over the domain-bound sighash,
20
+ cs1 over hex(Q), cx1, the note-key tweak per purpose, address proofs;
21
+ m/139'/d1..d4; prePurpose = the superseded tweak, for sweeping only
19
22
  bech32.json LUD-01 encoding
20
23
  url-admission.json which URLs may be fetched (https, or http to loopback/.onion)
21
24
  input-resolution.json bech32, LUD-17, Lightning Address, bare domain
@@ -30,7 +33,7 @@ payment-request.json lnurlcashreq1: asking another holder for value
30
33
  settle-for-value.json the decision table for taking a note as payment
31
34
  retried-mutation.json what makes a repeat a retry, not a double-spend
32
35
  mint-to-hash.json naming the note you are buying: h on the pay callback
33
- nostr-seed.json EXTENSION: Part 2 branch from a Nostr key, HMAC-SHA256(key, "LNURLcash/nostr-seed")
36
+ nostr-seed.json EXTENSION: key-path branch from a Nostr key, HMAC-SHA256(key, "LNURLcash/nostr-seed")
34
37
  lifecycle.json behavioural scenarios
35
38
  threat-suite.json transport/exposure scorecard, options vs attacks (non-normative)
36
39
 
@@ -38,18 +41,29 @@ threat-suite.json transport/exposure scorecard, options vs attacks (non-norma
38
41
 
39
42
  import {createMockMint} from 'lnurlcash-conformance/mock-mint'
40
43
  const mint = await createMockMint({dropAfterMutation: true})
41
- mint.state.creditNote(k1, 21000) // then drive your client at mint.url
44
+ mint.state.creditNote(k1, 21000) // k1: 64-hex preimage, ck1 or cw1
45
+ mint.state.creditOutput(cp1, 21000) // or name it: a cp1 or a bearer note's hex h
42
46
  await mint.close()
43
47
 
48
+ Notes are keyed by hex(Q), every spend is verified in full (a ck1 against the
49
+ hostname the mock was reached at, or domains=a,b), and every note gets a cs1
50
+ over hex(Q). The mock evaluates only a bearer hashlock leaf, under any
51
+ internal key and depth; any other script is refused as unverifiable.
52
+
44
53
  Misbehaviour flags: dropAfterMutation, unconfirmedMutation, malformedJson,
45
54
  echoWrongK1, lieAboutValue, signatureLayout=leading, signatures=false,
46
- serverGeneratedSecrets, meltNeverSettles, meltAlwaysFails, slowMs, sunset,
47
- baseFeeMsat, feePpm, verify=false, withdrawLinkForm=lnurlw (the lnurlw://
48
- spelling of withdrawLink; default is the plain https://, as lnurl-mint).
49
-
50
- retriedMutation=replay (a byte-identical repeat of a mutation gets the
51
- original success back instead of "already spent"; the default 'refuse' is
52
- what the mock has always done).
55
+ certificateOverH, serverGeneratedSecrets, meltNeverSettles, meltAlwaysFails,
56
+ slowMs, sunset, baseFeeMsat, feePpm, verify=false, withdrawLinkForm=lnurlw
57
+ (the lnurlw:// spelling of withdrawLink; default is the plain https://, as
58
+ lnurl-mint). The taproot rules: leafVersionUnchecked, opSuccessUnchecked,
59
+ ignoresTimeClaims (true, or blockHeight/future/blockCount/relative),
60
+ refusesLocktimes, unverifiedCk1, infoSkipsVerification, replayMatchesStrings,
61
+ alreadyInUseReason=<text>, refusesCp1Outputs, acceptsOffCurveCp1,
62
+ acceptsMissingP2.
63
+
64
+ retriedMutation=replay is the default (a repeat of a mutation, matched on the
65
+ notes it names, gets the original success back instead of "already spent");
66
+ 'refuse' is the non-compliant fixture.
53
67
 
54
68
  mintToHash=true (the pay callback takes an optional h - 64 lowercase hex,
55
69
  sha256 of a WALLET-chosen secret, the same thing h means on the withdraw
@@ -87,13 +101,63 @@ softly: a mint publishing none of them loses nothing, a published field of
87
101
  the wrong shape warns, and a signature under a key the mint publishes in
88
102
  previousPubkeys is accepted as its own.
89
103
 
90
- ## The signature scheme, canonically
91
-
92
- note_id = hex(sha256(k1))
93
- message = "LNURLcash:" + amount_msat + ":" + note_id
104
+ --spend grades LUD-25 as of luds 50d740a. Besides the rotate, split, merge,
105
+ retry and refusal checks it moves the value through a three-leaf script tree
106
+ (a hashlock at leaf version 0xc0, the same at 0xc2, a hashlock with
107
+ OP_SUCCESS80) and a key-path note, and requires: the 0xc2 and OP_SUCCESS
108
+ leaves refused; block-height, future, block-count and unelapsed relative time
109
+ claims refused and a past locktime accepted; a ck1 for another domain refused
110
+ at the callback and the informational GET; the right-domain ck1 accepted; a
111
+ p1 naming a burned note refused as exactly "already in use"; a retry respelt
112
+ (preimage vs full cw1, h vs cp1) replayed. A missing cs1 warns (SHOULD); a
113
+ cs1 not over hex(Q) fails. Certificates are read from c and c2 only; a mint
114
+ sending only the pre-50d740a sig/sig2 is graded uncertified (warns), and one
115
+ sending both names passes. --address reads the internal-transfer hint from
116
+ text/cpub (text/xpub before 50d740a is not read). Value always comes home.
117
+
118
+ ## Notes and spends, canonically (luds 6e865b1, keys per 50d740a)
119
+
120
+ Q 32-byte x-only taproot output key; cp1<Q> (bech32m, hrp "cp"), and
121
+ a mint MUST refuse a Q that is not on the curve
122
+ ck1 Q || BIP-340 sig (96 bytes), zero aux_rand, over the key-path sighash
123
+ cw1 u32 locktime || u32 sequence || (u16 len || item)* over the leaf
124
+ script, the control block, then witness items bottom first; all
125
+ big-endian
126
+ bearer leaf a8 20 <h> 87 (OP_SHA256 <h> OP_EQUAL), version 0xc0, internal key
127
+ BIP-341's NUMS point
128
+ H = 50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0;
129
+ 64 hex in a k1 slot = the preimage, in a cp1 slot (comment, p1, p2,
130
+ ?p=) = h
131
+ sighash tagged_hash("TapSighash", 0x00 || SigMsg), SIGHASH_DEFAULT, input 0 of:
132
+ nVersion 2; prevout (tagged_hash("LNURLcash/mint", domain), 0); empty
133
+ scriptSig; nSequence as claimed (0xffffffff for a key path); one
134
+ output, value 0, empty script; nLockTime as claimed (0 for a key
135
+ path); spent output (OP_1 <Q>, 0)
136
+ domain the mint's lowercase hostname: no scheme, no port
137
+ leaves refuse leaf version != 0xc0 and any OP_SUCCESSx outside pushed data
138
+ derive t = tagged_hash("LNURLcash/derive", P || chaincode || ser32(purpose) ||
139
+ ser32(i)) mod n; pk_i = x(lift_x(P) + t·G). purpose 0 = the wallet's
140
+ own notes (mint, rotate, merge, a split's p1), 1 = a split's change p2,
141
+ 2 = Lightning Address auto-mint and internal transfer. One counter and
142
+ one recovery gap limit per purpose; one cx1 covers all three. The
143
+ register/unregister proof signs with purpose 0, index 0
144
+ time nLockTime 0, or a Unix time (>= 500000000) not in the future;
145
+ nSequence with bit 31 set, or bit 22 set and (low 16 bits * 512 s)
146
+ elapsed since the mint credited the note; heights and block counts
147
+ refused
148
+
149
+ ## Certificates, canonically
150
+
151
+ message = "LNURLcash:" + amount_msat + ":" + hex(Q) // every note, bearer too
94
152
  digest = sha256(sha256("Lightning Signed Message:" + message))
95
153
  sig = 65 bytes, r || s || recovery_id (wire format)
96
- pubkey = 33-byte compressed, hex
154
+ cs1 bech32m, hrp "cs" + BOLT-11 amount (cs10n = 1000 msat), over sig
155
+ pubkey = mintPubkey, 33-byte compressed, hex
156
+ wire JSON "c" (and "c2" for a split's change) on the informational GET and
157
+ every rotate, split or merge; note URL ...?k1=<spend>&c=<cs1>. These
158
+ were sig, sig2 and &sig= before luds 50d740a
159
+ hint payRequest metadata ["text/cpub", "cx1...:<i>"], i the purpose-2
160
+ counter (text/xpub before luds 50d740a)
97
161
 
98
162
  Recover from digest with NO further hashing (prehash off). Verifiers MUST
99
163
  also accept recovery_id || r || s. Library layouts differ:
@@ -120,12 +184,11 @@ the request. Full table in settle-for-value.json.
120
184
 
121
185
  ## Naming the note you are buying
122
186
 
123
- In LUD-25 a minted note's k1 is the payment preimage, so the preimage IS
124
- the money, and two sets of untrusted people learn it: every routing node
125
- on the payment path, and anyone who saw the invoice and polled LUD-21
126
- verify with its payment hash. A WALLET MAY instead send h=<64 lowercase
127
- hex> on the LUD-06 pay callback, the sha256 of a secret it chose; the
128
- SERVICE credits the note there and the preimage opens nothing. Support is
187
+ LUD-25 names the note a payment mints in the mandatory LUD-12 comment:
188
+ cp1<Q>, or a bearer note's hex h. The payment preimage is settlement proof,
189
+ never the money. As an older, additive spelling a WALLET MAY also send
190
+ h=<64 lowercase hex> on the LUD-06 pay callback, repeating the comment's h;
191
+ the SERVICE credits the note there and the preimage opens nothing. Support is
129
192
  advertised as mintToHash: true in three places - the payRequest (every
130
193
  mint has one, so decide from this), the mint address document (the same
131
194
  fact, corroboration), and the pay callback's own response (this quote was
@@ -151,15 +214,16 @@ docs/BOUND-MINT-RECEIPTS.md.
151
214
  ## The retried mutation
152
215
 
153
216
  Every mutation is a GET, and HTTP stacks retry a GET on a dropped
154
- connection, so a SERVICE sees the identical request twice with its inputs
217
+ connection, so a SERVICE sees the same request twice with its inputs
155
218
  burned the second time. Answering "already spent" makes the holder discard
156
- the only copy of a secret the SERVICE really did mint against. Identical =
157
- same input k1 SET + same h + same h2 + same amount, present or absent
158
- alike. Anything else naming a burned input is a double-spend and keeps
159
- today's refusal, so no oracle appears. Provenance is recorded, not
160
- inferred. The replay is a READ: nothing burns, nothing mints, the sig is
161
- recomputed from (output id, amount). SHOULD, not MUST: the grader reports
162
- it as unimplemented rather than failing. Table in retried-mutation.json.
219
+ the only copy of a spend the SERVICE really did mint against. The same
220
+ request = the same SET of notes burned + same p1 + same p2 + same amount,
221
+ compared as the Qs they decode to, never the strings (one note has several
222
+ spellings: preimage or full cw1, h or cp1). Anything else naming a burned
223
+ input is a double-spend and keeps today's refusal, so no oracle appears.
224
+ Provenance is recorded, not inferred. The replay is a READ: nothing burns,
225
+ nothing mints, the same certificates come back. A MUST: the grader fails a
226
+ mint that refuses it. Table in retried-mutation.json.
163
227
 
164
228
  ## Fee arithmetic
165
229