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 +171 -0
- package/README.md +96 -45
- package/llms.txt +95 -31
- package/mock-mint/index.d.ts +85 -12
- package/mock-mint/index.mjs +359 -193
- package/mock-mint/spend.mjs +252 -0
- package/package.json +1 -1
- package/runner/cli.mjs +7 -4
- package/runner/index.d.ts +17 -3
- package/runner/index.mjs +746 -432
- package/runner/spend.mjs +314 -0
- package/vectors/bech32.json +1 -1
- package/vectors/index.json +2 -1
- package/vectors/lifecycle.json +1 -1
- package/vectors/nostr-seed.json +169 -49
- package/vectors/note-url.json +26 -11
- package/vectors/part2.json +824 -336
- package/vectors/responses.json +22 -8
- package/vectors/retried-mutation.json +1 -1
- package/vectors/settle-for-value.json +1 -1
- package/vectors/spec-vectors.json +124 -57
- package/vectors/spends.json +609 -0
- package/vectors/threat-suite.json +1 -1
- package/vectors/withdraw-info.json +2 -1
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
|
-
| `
|
|
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
|
|
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,
|
|
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` |
|
|
99
|
-
| `--
|
|
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
|
-
| `--
|
|
109
|
-
| `--
|
|
110
|
-
| `--
|
|
111
|
-
| `--
|
|
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
|
-
|
|
121
|
-
|
|
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
|
|
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`,
|
|
156
|
-
the raw note and invoice maps
|
|
157
|
-
actually did rather than what it said.
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
171
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
settlement proof. A mint that cannot accept
|
|
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.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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:
|
|
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) //
|
|
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,
|
|
47
|
-
baseFeeMsat, feePpm, verify=false, withdrawLinkForm=lnurlw
|
|
48
|
-
spelling of withdrawLink; default is the plain https://, as
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|