lnurlcash-conformance 0.4.0 → 0.5.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 +47 -0
- package/README.md +18 -38
- package/mock-mint/index.d.ts +7 -13
- package/mock-mint/index.mjs +126 -65
- package/package.json +1 -1
- package/runner/index.mjs +144 -51
- package/vectors/lifecycle.json +6 -5
- package/vectors/mint-to-hash.json +50 -32
- package/vectors/pay-request.json +51 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,53 @@ 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
|
+
## Unreleased
|
|
8
|
+
|
|
9
|
+
## 0.5.0 - 2026-08-31
|
|
10
|
+
|
|
11
|
+
**Breaking: the current LUD-25 draft's mandatory mint comment is now the
|
|
12
|
+
baseline.** Every minting payRequest must advertise `commentAllowed >= 64`,
|
|
13
|
+
and every quote must carry `comment=hex(sha256(secret))`; missing or malformed
|
|
14
|
+
comments are refused before invoice creation. The payment preimage is
|
|
15
|
+
settlement proof and never a bearer credential. `mintToHash`/`h` remains an
|
|
16
|
+
additive ForgeSworn compatibility and receipt extension: when used, `h` must
|
|
17
|
+
repeat the mandatory comment and cannot replace it. The mock, runner,
|
|
18
|
+
lifecycle and pay-request vectors all enforce the same rule, including a
|
|
19
|
+
mismatched `h`/`comment` refusal. `commentFallsBack` remains only as the
|
|
20
|
+
non-compliant historical fixture documented in
|
|
21
|
+
`docs/COMMENT-IS-MANDATORY.md`.
|
|
22
|
+
|
|
23
|
+
**A mint that is not a Lightning Address is no longer failed for having no
|
|
24
|
+
mint address document.** The document is probed by swapping
|
|
25
|
+
`/.well-known/lnurlp/` for `/.well-known/lnurlw/` in the payRequest URL. On a
|
|
26
|
+
mint served from a plain path that swap changes nothing, so the probe
|
|
27
|
+
re-fetched the payRequest, read `tag: "payRequest"`, and failed the mint for
|
|
28
|
+
an optional document it has nowhere to publish. Found by grading
|
|
29
|
+
`bitkarrot/lnurlmint`, which is served at `/lnurlmint/lnurlp/<id>`; it now
|
|
30
|
+
grades 6 passed, 0 failed on the checks that predate the mandate above.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
**Two checks for the draft's 25 August additions, both optional-graded.**
|
|
34
|
+
|
|
35
|
+
`answers a note lookup by hash without the secret` covers "Checking a note
|
|
36
|
+
without exposing it". The capability is detected rather than announced: the
|
|
37
|
+
draft deliberately gives an unrecognized `h` the same answer an unknown
|
|
38
|
+
`k1` gets, so a live note's own hash is the only probe that separates a
|
|
39
|
+
mint which implements the lookup from one which never did. A mint that does
|
|
40
|
+
not offer it is not graded down; one that offers it must omit `k1` from the
|
|
41
|
+
reply - a wallet asking by hash already holds the secret, and filling the
|
|
42
|
+
field in puts the note back on the wire the lookup exists to keep it off -
|
|
43
|
+
must report the same value by hash as by `k1`, and must refuse a hash it
|
|
44
|
+
never registered.
|
|
45
|
+
|
|
46
|
+
`refuses an oversized merge cleanly` covers the merge cap. The cap itself
|
|
47
|
+
is a MAY, so a mint that does not name one is not graded down, only
|
|
48
|
+
reported, since a wallet must then batch by URL length rather than rely on
|
|
49
|
+
a refusal. The one answer that fails is `OK`: every input on that probe is
|
|
50
|
+
fabricated by the runner, so a mint accepting it has minted an output
|
|
51
|
+
against notes it never held, which is what a truncated `k1` list read as a
|
|
52
|
+
shorter merge looks like from outside.
|
|
53
|
+
|
|
7
54
|
## 0.4.0 - 2026-08-26
|
|
8
55
|
|
|
9
56
|
**The mint-output naming check now reads the spelling LUD-25 actually
|
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ for (const c of cases) {
|
|
|
60
60
|
| `payment-request.json` | `lnurlcashreq1`: one holder asking another for value |
|
|
61
61
|
| `settle-for-value.json` | the decision table a server works through to take a note as payment |
|
|
62
62
|
| `retried-mutation.json` | what makes a repeated mutation a retry rather than a double-spend |
|
|
63
|
-
| `mint-to-hash.json` |
|
|
63
|
+
| `mint-to-hash.json` | additive `mintToHash` compatibility and optional bound LUD-21 receipts; not baseline LUD-25 |
|
|
64
64
|
| `lifecycle.json` | behavioural requirements, as scenarios to drive |
|
|
65
65
|
| `threat-suite.json` | the transport/exposure scorecard — candidate spec options against fixed attacks (non-normative) |
|
|
66
66
|
|
|
@@ -100,10 +100,10 @@ must survive:
|
|
|
100
100
|
| `--sunset` | refuses anything that grows its liabilities |
|
|
101
101
|
| `--baseFeeMsat=N --feePpm=N` | advertises and withholds a mint fee |
|
|
102
102
|
| `--roundFeeToSat` | rounds the withheld fee up to a whole sat — the note mints short of the formula |
|
|
103
|
-
| `--verifyLeaksEarly` | serves
|
|
103
|
+
| `--verifyLeaksEarly` | serves a preimage before settlement, falsely claiming payment proof before payment happened |
|
|
104
104
|
| `--mintToHashAcceptsMalformedH` | claims `mintToHash` and invoices an `h` that is not 64 lowercase hex, so a wallet pays for a quote the mint will refuse |
|
|
105
105
|
| `--mintToHashAcceptsUsedH` | claims it and invoices an `h` that already names a note, an invoice or another quote's output |
|
|
106
|
-
| `--mintToHashIgnoresH` | claims it
|
|
106
|
+
| `--mintToHashIgnoresH` | claims it but accepts `h` and mandatory `comment` naming different outputs |
|
|
107
107
|
|
|
108
108
|
The three `mintToHash*` misbehaviours need `--mintToHash` alongside them;
|
|
109
109
|
on their own they do nothing, because a mint that never offered the
|
|
@@ -126,7 +126,7 @@ answered:
|
|
|
126
126
|
| `--previousPrivateKey=<hex>` | an old signing key the mock still holds. Its public half joins `previousPubkeys` on its own |
|
|
127
127
|
| `--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 |
|
|
128
128
|
| `--retriedMutation=replay` | answers a byte-identical repeat of a mutation with the original success instead of `already spent`. The default, `refuse`, is what this mock has always done |
|
|
129
|
-
| `--mintToHash` |
|
|
129
|
+
| `--mintToHash` | accepts `h` alongside the mandatory identical comment and enables the additive quote/receipt fields. Off by default; baseline comment-bound minting remains on |
|
|
130
130
|
| `--mintReceipt` | with `--mintToHash`, adds the optional quote commitment and signed LUD-21 settlement receipt |
|
|
131
131
|
| `--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 |
|
|
132
132
|
|
|
@@ -155,43 +155,23 @@ npx lnurlcash-conform mint@example.com
|
|
|
155
155
|
|
|
156
156
|
Read-only by default: resolves the payRequest, checks the `withdrawLink`
|
|
157
157
|
(either legal spelling, and the report says which one the mint uses),
|
|
158
|
-
the fee advertisement, invoice amounts,
|
|
159
|
-
|
|
160
|
-
everyone on the payment's route knows the payment hash), whether an
|
|
158
|
+
the fee advertisement, invoice amounts, mandatory `commentAllowed: 64`,
|
|
159
|
+
pre-invoice rejection of missing or malformed mint comments, whether an
|
|
161
160
|
unknown note is reported distinguishably from a spent one, and the
|
|
162
161
|
experimental mint address.
|
|
163
162
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
money moves, because the other two can be cached). Anything that is not
|
|
177
|
-
exactly the boolean `true` is no.
|
|
178
|
-
|
|
179
|
-
A mint that says nothing anywhere is reported as not offering it and passes,
|
|
180
|
-
which is every mint today. A mint that claims it is asked to prove the
|
|
181
|
-
refusals: a malformed `h` must get no invoice at all, since a wallet that
|
|
182
|
-
pays for a quote the mint will reject has bought nothing. Malformed means
|
|
183
|
-
not 32 bytes of hex, in any casing. A wallet MUST send `h` as 64 lowercase
|
|
184
|
-
hex and every client here does, but hex is case-insensitive, so a service
|
|
185
|
-
SHOULD normalise before comparing and MUST NOT read `AAAA...` and `aaaa...`
|
|
186
|
-
as two different outputs: keying the string it was handed files the note
|
|
187
|
-
where the wallet will never look for it, and nobody is told. A service that
|
|
188
|
-
refuses upper case outright is being strict rather than wrong, so the grader
|
|
189
|
-
does not probe it either way. Where the three
|
|
190
|
-
claims disagree, the grader names the disagreement rather than failing it:
|
|
191
|
-
none of those loses anyone money on its own. What is failed is a mint that
|
|
192
|
-
claims the capability and does not bind, because a wallet believing the
|
|
193
|
-
claim stops rotating on sight; that one needs a settlement to see, so it
|
|
194
|
-
rides on `--preimage`.
|
|
163
|
+
Current LUD-25 minting is always comment-bound. The wallet persists a secret,
|
|
164
|
+
sends `comment=hex(sha256(secret))`, and the payment preimage remains ordinary
|
|
165
|
+
settlement proof. A mint that cannot accept the 64-character commitment, or
|
|
166
|
+
that silently creates a preimage-backed note, fails grading.
|
|
167
|
+
|
|
168
|
+
`mintToHash` is retained as an additive compatibility field. When advertised,
|
|
169
|
+
the runner sends `h` alongside the mandatory comment and requires both to name
|
|
170
|
+
the same output. A malformed `h` must be rejected before invoice creation.
|
|
171
|
+
The payRequest, experimental mint-address document and quote echo are checked
|
|
172
|
+
separately because each claim has a different lifetime. Disagreement between
|
|
173
|
+
those extension advertisements warns; failure to honour a claimed binding
|
|
174
|
+
fails when the paid `--preimage` check proves where the note actually landed.
|
|
195
175
|
|
|
196
176
|
Three other things a mint may publish are graded softly, because none of them
|
|
197
177
|
is in LUD-25: the mint info on the discovery endpoint, a `/stats` endpoint
|
package/mock-mint/index.d.ts
CHANGED
|
@@ -137,14 +137,10 @@ export interface MockMintOptions {
|
|
|
137
137
|
/** what the node behind a stats-publishing mock claims to hold */
|
|
138
138
|
localBalanceMsat?: number
|
|
139
139
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* thing `h` means on the withdraw callback - and credits the note at
|
|
145
|
-
* that id when the invoice settles. The payment preimage is then not a
|
|
146
|
-
* valid k1 for that note, which matters because every routing node on
|
|
147
|
-
* the payment path learns it, and so does anyone who saw the invoice.
|
|
140
|
+
* Additive compatibility spelling for the mandatory mint comment. Off,
|
|
141
|
+
* `h` is not advertised or read, while comment-bound minting remains the
|
|
142
|
+
* baseline. On, WALLET may repeat the same 64-hex output commitment as
|
|
143
|
+
* `h`, enabling the ForgeSworn/Moneyer quote and receipt vocabulary.
|
|
148
144
|
*
|
|
149
145
|
* The capability is advertised in three places: `mintToHash: true` on
|
|
150
146
|
* the payRequest (every mint has one, so it is what a wallet decides
|
|
@@ -176,11 +172,9 @@ export interface MockMintOptions {
|
|
|
176
172
|
*/
|
|
177
173
|
mintToHashAcceptsUsedH?: boolean
|
|
178
174
|
/**
|
|
179
|
-
* non-compliant, mintToHash on:
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* nothing. The wallet stopped rotating on sight because it was told it
|
|
183
|
-
* did not need to, so this is the worst of both schemes.
|
|
175
|
+
* non-compliant, mintToHash on: accept `h` and mandatory `comment` even
|
|
176
|
+
* when they name different outputs. The normative comment still binds
|
|
177
|
+
* the note, but the extension claim and any h-watching signer disagree.
|
|
184
178
|
*/
|
|
185
179
|
mintToHashIgnoresH?: boolean
|
|
186
180
|
/**
|
package/mock-mint/index.mjs
CHANGED
|
@@ -66,9 +66,8 @@ const DEFAULTS = {
|
|
|
66
66
|
// WALLET that handles one but not the other fails against real mints.
|
|
67
67
|
// Run your client against both.
|
|
68
68
|
withdrawLinkForm: 'plain',
|
|
69
|
-
// LUD-21 verify endpoint. Off means 404, not merely unadvertised
|
|
70
|
-
//
|
|
71
|
-
// off switch.
|
|
69
|
+
// LUD-21 verify endpoint. Off means 404, not merely unadvertised, so the
|
|
70
|
+
// mock can exercise current mints that provide no settlement polling.
|
|
72
71
|
verify: true,
|
|
73
72
|
// Publish `payLink` on a note's informational GET, the way home for a
|
|
74
73
|
// holder who has nothing but the note. On by default because the
|
|
@@ -166,17 +165,14 @@ const DEFAULTS = {
|
|
|
166
165
|
//
|
|
167
166
|
// ---- naming the note you are buying ----
|
|
168
167
|
//
|
|
169
|
-
// Off
|
|
170
|
-
//
|
|
171
|
-
//
|
|
168
|
+
// Off means only the additive `h` spelling is absent: it is not advertised
|
|
169
|
+
// and the pay callback does not read it. Mandatory comment-bound minting
|
|
170
|
+
// remains active independently.
|
|
172
171
|
//
|
|
173
|
-
// On, a WALLET MAY
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
// Which matters because two sets of untrusted people learn a preimage:
|
|
178
|
-
// every routing node on the payment path, and anyone who merely saw the
|
|
179
|
-
// invoice and polled /verify with its payment hash.
|
|
172
|
+
// On, a WALLET MAY repeat the mandatory comment hash as
|
|
173
|
+
// h=<64 lowercase hex> on the LUD-06 pay callback. This enables the
|
|
174
|
+
// earlier ForgeSworn/Moneyer capability and receipt vocabulary without
|
|
175
|
+
// changing which output the normative comment names.
|
|
180
176
|
//
|
|
181
177
|
// The capability is claimed in three places and they say different
|
|
182
178
|
// things. `mintToHash: true` on the payRequest means "I accept an h on
|
|
@@ -187,6 +183,23 @@ const DEFAULTS = {
|
|
|
187
183
|
// which is the one that matters at the moment money moves: the other
|
|
188
184
|
// two can be cached or stale.
|
|
189
185
|
mintToHash: false,
|
|
186
|
+
// LUD-25 "Checking a note without exposing it": answer the informational
|
|
187
|
+
// GET by `?h=sha256(k1)` as well as by `?k1=`, so a wallet can look a note
|
|
188
|
+
// up without putting the live secret in a query string. Optional.
|
|
189
|
+
// true - compliant
|
|
190
|
+
// 'echoesK1' - non-compliant: fills in k1, putting the secret back
|
|
191
|
+
// on the wire the lookup existed to keep it off
|
|
192
|
+
// 'answersUnknown' - non-compliant: answers for a hash it never
|
|
193
|
+
// registered, instead of the unknown-note refusal
|
|
194
|
+
hashLookup: false,
|
|
195
|
+
// LUD-25 lets a SERVICE refuse an oversized merge outright rather than
|
|
196
|
+
// let the URL be mangled upstream. 0 is no explicit cap; a positive number
|
|
197
|
+
// refuses more than that many k1 with the draft's own reason string.
|
|
198
|
+
mergeCap: 0,
|
|
199
|
+
// non-compliant: answer OK to an oversized merge whatever its inputs -
|
|
200
|
+
// what a mint that parses a truncated k1 list and shrugs looks like from
|
|
201
|
+
// outside. It has minted an output against notes it never saw.
|
|
202
|
+
acceptsOversizedMerge: false,
|
|
190
203
|
// Add the optional bound LUD-21 receipt to an honestly bound quote and
|
|
191
204
|
// its verify response. Requires mintToHash, verify and signatures; off
|
|
192
205
|
// by default so the baseline mock remains the current LUD-25 wire.
|
|
@@ -199,10 +212,10 @@ const DEFAULTS = {
|
|
|
199
212
|
// already names a note, an invoice or another quote's output, so two
|
|
200
213
|
// payers' money points at one id
|
|
201
214
|
mintToHashAcceptsUsedH: false,
|
|
202
|
-
// non-compliant, mintToHash on:
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
215
|
+
// non-compliant, mintToHash on: accept h and mandatory comment even when
|
|
216
|
+
// they name different outputs. The comment still binds the note, but the
|
|
217
|
+
// extension claim is false and a receipt-watching signer follows the
|
|
218
|
+
// other commitment.
|
|
206
219
|
mintToHashIgnoresH: false,
|
|
207
220
|
// Which of the three the mock actually says it in. Undefined means all
|
|
208
221
|
// three, which is what an honest mint publishes. Narrowing it changes
|
|
@@ -213,25 +226,27 @@ const DEFAULTS = {
|
|
|
213
226
|
// array or a comma-separated string, so the CLI can pass one.
|
|
214
227
|
mintToHashAdvertisedOn: undefined,
|
|
215
228
|
|
|
216
|
-
// The
|
|
229
|
+
// The mandatory minting commitment in the spelling LUD-25 specifies:
|
|
217
230
|
// `comment = hex(sha256(secret))` (LUD-12), advertised as a
|
|
218
231
|
// `commentAllowed` of at least 64. A number advertises that many
|
|
219
|
-
// characters and reads `comment` as the output name
|
|
220
|
-
//
|
|
221
|
-
//
|
|
232
|
+
// characters and reads `comment` as the output name. The conforming
|
|
233
|
+
// default is 64. Setting it false or below 64 deliberately models a
|
|
234
|
+
// SERVICE that cannot mint under the current draft.
|
|
222
235
|
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
236
|
+
// `h` remains an additive Moneyer compatibility field. When supplied it
|
|
237
|
+
// must be well formed and equal the mandatory comment; it never replaces
|
|
238
|
+
// the comment and cannot make an otherwise unnamed quote valid.
|
|
239
|
+
commentAllowed: 64,
|
|
240
|
+
// non-compliant, commentAllowed on: fall back to a preimage-keyed note
|
|
241
|
+
// when the comment is missing or malformed, instead of refusing. This was
|
|
242
|
+
// the draft's line 80 behaviour and is now the defect - see
|
|
243
|
+
// docs/COMMENT-IS-MANDATORY.md.
|
|
244
|
+
commentFallsBack: false,
|
|
245
|
+
// non-compliant, and narrower: refuse a malformed comment - an empty value
|
|
246
|
+
// included - but fall back when the key is absent entirely. That is the
|
|
247
|
+
// distinction the malformed loop cannot reach, and the commonest way to
|
|
248
|
+
// arrive unnamed, since it is what every ordinary LUD-06 wallet sends.
|
|
249
|
+
commentFallsBackWhenAbsent: false,
|
|
235
250
|
// non-compliant, commentAllowed on: serve LUD-21 verify even on the
|
|
236
251
|
// no-comment fallback, where the preimage it hands out IS the note
|
|
237
252
|
verifyOnUnnamedMint: false
|
|
@@ -401,10 +416,9 @@ export const createMockMint = async (options = {}) => {
|
|
|
401
416
|
const invoice = hash ? invoices.get(hash) : null
|
|
402
417
|
if (!invoice) return fail('unknown payment hash')
|
|
403
418
|
invoice.settled = true
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
// then the note is credited there and the preimage is nobody's key.
|
|
419
|
+
// Paying a mint invoice brings its comment-bound note into existence.
|
|
420
|
+
// The payment-hash target is reachable only under deliberate legacy
|
|
421
|
+
// defect flags that allowed an unnamed quote.
|
|
408
422
|
const target = invoice.boundTo ?? hash
|
|
409
423
|
if (!notes.has(target)) mintNote(target, invoice.amountMsat)
|
|
410
424
|
if (invoice.boundTo) boundOutputs.delete(invoice.boundTo)
|
|
@@ -461,7 +475,13 @@ export const createMockMint = async (options = {}) => {
|
|
|
461
475
|
const origin = `http://${req.headers.host}`
|
|
462
476
|
|
|
463
477
|
// ---- LUD-16 payRequest (minting) ----
|
|
464
|
-
|
|
478
|
+
// Both shapes are in the wild. A Lightning Address mint lives under
|
|
479
|
+
// /.well-known/lnurlp/<name>; an LNbits extension serves the same
|
|
480
|
+
// payRequest at a plain path with no well-known anywhere, which is the
|
|
481
|
+
// case a grader must not mistake for a missing mint address document.
|
|
482
|
+
const lnurlpMatch =
|
|
483
|
+
url.pathname.match(/^\/\.well-known\/lnurlp\/(.+)$/) ??
|
|
484
|
+
url.pathname.match(/^\/lnurlp\/(.+)$/)
|
|
465
485
|
if (lnurlpMatch) {
|
|
466
486
|
const user = lnurlpMatch[1]
|
|
467
487
|
if (user !== opts.username && user !== '_') {
|
|
@@ -627,6 +647,7 @@ export const createMockMint = async (options = {}) => {
|
|
|
627
647
|
// before the option existed.
|
|
628
648
|
let boundTo = null
|
|
629
649
|
let echoBound = false
|
|
650
|
+
let requestedH = null
|
|
630
651
|
if (opts.mintToHash) {
|
|
631
652
|
// absent is not the same as empty: a wallet that sent `h=` meant
|
|
632
653
|
// to bind, and must not be handed an unbound quote in silence
|
|
@@ -655,6 +676,7 @@ export const createMockMint = async (options = {}) => {
|
|
|
655
676
|
return fail('Invalid or already spent k1.')
|
|
656
677
|
}
|
|
657
678
|
if (wellFormed) {
|
|
679
|
+
requestedH = h
|
|
658
680
|
// The echo says "this quote is bound", so an honest mint
|
|
659
681
|
// sets it exactly when it did bind. mintToHashIgnoresH is the
|
|
660
682
|
// mint that says it and does not; leaving 'quote' out of
|
|
@@ -665,14 +687,17 @@ export const createMockMint = async (options = {}) => {
|
|
|
665
687
|
}
|
|
666
688
|
}
|
|
667
689
|
|
|
668
|
-
// The LUD-25 spelling.
|
|
669
|
-
//
|
|
670
|
-
//
|
|
690
|
+
// The LUD-25 spelling. It is mandatory for every mint quote. The
|
|
691
|
+
// additive `h` spelling may corroborate it, but never substitutes for
|
|
692
|
+
// it and must name the same output when both are present.
|
|
671
693
|
let namedByComment = false
|
|
672
|
-
|
|
694
|
+
// >= 64, the same threshold the runner reads the capability at: a
|
|
695
|
+
// commentAllowed too short to carry a 32-byte hash is not this
|
|
696
|
+
// capability, so nothing about it may be required either.
|
|
697
|
+
if (opts.commentAllowed >= 64) {
|
|
673
698
|
const sent = q.get('comment')
|
|
674
|
-
|
|
675
|
-
|
|
699
|
+
const wellFormed = sent !== null && sent !== '' && /^[0-9a-f]{64}$/i.test(sent)
|
|
700
|
+
{
|
|
676
701
|
if (wellFormed) {
|
|
677
702
|
const h = sent.toLowerCase()
|
|
678
703
|
// Same collision rule the `h` spelling gets: an id already
|
|
@@ -681,16 +706,28 @@ export const createMockMint = async (options = {}) => {
|
|
|
681
706
|
if (outputIdInUse(h) && !opts.mintToHashAcceptsUsedH) {
|
|
682
707
|
return fail('Invalid or already spent k1.')
|
|
683
708
|
}
|
|
684
|
-
if (
|
|
685
|
-
|
|
686
|
-
|
|
709
|
+
if (
|
|
710
|
+
requestedH !== null &&
|
|
711
|
+
requestedH !== h &&
|
|
712
|
+
!opts.mintToHashIgnoresH
|
|
713
|
+
) {
|
|
714
|
+
return fail('h and comment must name the same output.')
|
|
687
715
|
}
|
|
688
|
-
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
716
|
+
// The normative comment always binds the quote. The defect flag
|
|
717
|
+
// only models an extension implementation that ignores or fails
|
|
718
|
+
// to compare h.
|
|
719
|
+
boundTo = h
|
|
720
|
+
namedByComment = true
|
|
721
|
+
} else if (
|
|
722
|
+
!opts.commentFallsBack &&
|
|
723
|
+
!(opts.commentFallsBackWhenAbsent && sent === null)
|
|
724
|
+
) {
|
|
725
|
+
// A mint quote without the mandatory comment can only fall back
|
|
726
|
+
// to the payment preimage. The current draft forbids that even
|
|
727
|
+
// when the additive `h` field happened to name an output too.
|
|
728
|
+
return fail(
|
|
729
|
+
'Missing or malformed comment: a hex-encoded 32-byte hashed secret is required to mint.'
|
|
730
|
+
)
|
|
694
731
|
}
|
|
695
732
|
}
|
|
696
733
|
}
|
|
@@ -705,13 +742,12 @@ export const createMockMint = async (options = {}) => {
|
|
|
705
742
|
}
|
|
706
743
|
invoices.set(paymentHash, invoice)
|
|
707
744
|
const body = {pr, disposable: false}
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
const verifySafe = !opts.commentAllowed || namedByComment || opts.verifyOnUnnamedMint
|
|
745
|
+
// Verify is safe for every conforming quote because comment-bound
|
|
746
|
+
// minting makes the payment preimage ordinary settlement proof. The
|
|
747
|
+
// conditional remains only so the mock can reproduce the retired
|
|
748
|
+
// preimage-backed behaviour when commentAllowed is deliberately off.
|
|
749
|
+
const verifySafe =
|
|
750
|
+
!(opts.commentAllowed >= 64) || Boolean(boundTo) || opts.verifyOnUnnamedMint
|
|
715
751
|
if (opts.verify && verifySafe) body.verify = `${origin}/verify/${paymentHash}`
|
|
716
752
|
// Appended last, and only when the quote really was bound, so a
|
|
717
753
|
// mock that was never told about any of this answers byte for byte
|
|
@@ -741,11 +777,9 @@ export const createMockMint = async (options = {}) => {
|
|
|
741
777
|
const body = {
|
|
742
778
|
status: 'OK',
|
|
743
779
|
settled: invoice.settled,
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
// bound with its own h: that note is credited elsewhere, so the
|
|
748
|
-
// preimage is an ordinary payment proof and leaks nothing.
|
|
780
|
+
// For a conforming comment-bound quote this is ordinary payment
|
|
781
|
+
// proof and never the bearer secret. It becomes a bearer secret only
|
|
782
|
+
// in the mock's deliberately non-compliant legacy mode.
|
|
749
783
|
preimage: invoice.settled || opts.verifyLeaksEarly ? invoice.preimage : null,
|
|
750
784
|
pr: invoice.pr ?? fakeInvoice(invoice.amountMsat, invoice.preimage)
|
|
751
785
|
}
|
|
@@ -764,6 +798,31 @@ export const createMockMint = async (options = {}) => {
|
|
|
764
798
|
// ---- LUD-03 informational GET ----
|
|
765
799
|
if (url.pathname === '/w') {
|
|
766
800
|
const k1 = q.get('k1')?.toLowerCase()
|
|
801
|
+
// The hash lookup. Notes are already keyed by sha256(k1) internally,
|
|
802
|
+
// so this is a second way in to a lookup the mint can always do - and
|
|
803
|
+
// `h` is only ever read here, never at the callback, where the same
|
|
804
|
+
// letter means the hash of a NEW note.
|
|
805
|
+
const asked = q.get('h')?.toLowerCase()
|
|
806
|
+
if (!k1 && asked && opts.hashLookup) {
|
|
807
|
+
if (!/^[0-9a-f]{64}$/.test(asked)) return fail('Unknown note.')
|
|
808
|
+
const held = notes.get(asked)
|
|
809
|
+
const invent = opts.hashLookup === 'answersUnknown' && !held
|
|
810
|
+
if (!invent) {
|
|
811
|
+
if (!held) return fail('Unknown note.')
|
|
812
|
+
if (held.state === 'burned') return fail('Note already spent.')
|
|
813
|
+
}
|
|
814
|
+
return send({
|
|
815
|
+
tag: 'withdrawRequest',
|
|
816
|
+
callback: `${origin}/w/cb`,
|
|
817
|
+
// k1 is omitted: a wallet asking by hash already holds the secret,
|
|
818
|
+
// which is the only way it could have computed the hash to send.
|
|
819
|
+
...(opts.hashLookup === 'echoesK1' ? {k1: 'c'.repeat(64)} : {}),
|
|
820
|
+
minWithdrawable: 0,
|
|
821
|
+
maxWithdrawable: (held?.amountMsat ?? 21000) + opts.lieAboutValue,
|
|
822
|
+
defaultDescription: 'an LNURLcash note',
|
|
823
|
+
mintPubkey: pubkey
|
|
824
|
+
})
|
|
825
|
+
}
|
|
767
826
|
if (!k1) return fail('Unknown note.')
|
|
768
827
|
if (!/^[0-9a-f]{64}$/.test(k1)) return fail('Unknown note.')
|
|
769
828
|
const note = notes.get(noteId(k1))
|
|
@@ -802,6 +861,8 @@ export const createMockMint = async (options = {}) => {
|
|
|
802
861
|
const h2 = q.get('h2')
|
|
803
862
|
|
|
804
863
|
if (k1s.length === 0) return fail('Missing k1.')
|
|
864
|
+
if (opts.acceptsOversizedMerge && k1s.length > 20) return send({status: 'OK'})
|
|
865
|
+
if (opts.mergeCap > 0 && k1s.length > opts.mergeCap) return fail('too many k1')
|
|
805
866
|
// a repeated k1 would count one note's value twice into the output -
|
|
806
867
|
// refused atomically, as the reference mint does
|
|
807
868
|
if (new Set(k1s).size !== k1s.length) return fail('Invalid or already spent k1.')
|
package/package.json
CHANGED
package/runner/index.mjs
CHANGED
|
@@ -279,6 +279,14 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
279
279
|
const amount = Math.max(pay.minSendable, 1000)
|
|
280
280
|
const url = new URL(pay.callback)
|
|
281
281
|
url.searchParams.set('amount', String(amount))
|
|
282
|
+
// A mint advertising comment protection requires a named quote (see
|
|
283
|
+
// docs/COMMENT-IS-MANDATORY.md), so the plain LUD-06 request an
|
|
284
|
+
// ordinary wallet would send is refused there by design. Name it the
|
|
285
|
+
// way a LUD-25 wallet does, or this check grades the mandate rather
|
|
286
|
+
// than the invoice.
|
|
287
|
+
if (Number.isFinite(pay.commentAllowed) && pay.commentAllowed >= 64) {
|
|
288
|
+
url.searchParams.set('comment', noteId(bytesToHex(randomBytes(32))))
|
|
289
|
+
}
|
|
282
290
|
const body = await get(url)
|
|
283
291
|
assert(body.status !== 'ERROR', `refused: ${body.reason}`)
|
|
284
292
|
assert(typeof body.pr === 'string', 'no pr in the response')
|
|
@@ -297,10 +305,8 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
297
305
|
})
|
|
298
306
|
|
|
299
307
|
await report.check('verify serves no secret before settlement', async () => {
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
// payment hash. A verify endpoint that answers the hash with the
|
|
303
|
-
// preimage before settlement hands the note to whoever polls first.
|
|
308
|
+
// The payment preimage is safe from bearer-note use once comment-bound,
|
|
309
|
+
// but it is still a settlement proof and must not exist before payment.
|
|
304
310
|
if (!verifyUrl) throw soft('no LUD-21 verify URL to probe')
|
|
305
311
|
const body = await get(verifyUrl)
|
|
306
312
|
assert(body.status !== 'ERROR', `refused its own verify URL: ${body.reason}`)
|
|
@@ -310,7 +316,7 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
310
316
|
)
|
|
311
317
|
assert(
|
|
312
318
|
body.preimage == null,
|
|
313
|
-
'served a preimage before
|
|
319
|
+
'served a payment preimage before the invoice settled'
|
|
314
320
|
)
|
|
315
321
|
return 'settled: false, no preimage'
|
|
316
322
|
})
|
|
@@ -332,6 +338,15 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
332
338
|
|
|
333
339
|
await report.check('publishes a mint address (experimental, optional)', async () => {
|
|
334
340
|
const mirror = payUrl.replace('/.well-known/lnurlp/', '/.well-known/lnurlw/')
|
|
341
|
+
// The document only has a canonical location on a LUD-16 Lightning
|
|
342
|
+
// Address, where it mirrors the payRequest's own well-known path. A
|
|
343
|
+
// mint served from a plain path - an LNbits extension, say - has
|
|
344
|
+
// nowhere to publish it, and the swap above leaves the URL untouched:
|
|
345
|
+
// probing it anyway re-fetches the payRequest and reads its
|
|
346
|
+
// tag as a malformed mint address.
|
|
347
|
+
if (mirror === payUrl) {
|
|
348
|
+
throw soft('not published - this mint is not served from a Lightning Address, so there is no well-known lnurlw path to mirror')
|
|
349
|
+
}
|
|
335
350
|
let body
|
|
336
351
|
try {
|
|
337
352
|
body = await get(mirror)
|
|
@@ -470,21 +485,10 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
470
485
|
return detail
|
|
471
486
|
})
|
|
472
487
|
|
|
473
|
-
// Naming the note
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
// parameter form one mint shipped before the comment form was written.
|
|
478
|
-
// Both are read here: a suite that knows only one spelling reports a
|
|
479
|
-
// mint that names notes perfectly well in the other as not offering it
|
|
480
|
-
// at all, which is worse than silence - it tells a wallet author the
|
|
481
|
-
// safe mint is the unsafe one.
|
|
482
|
-
//
|
|
483
|
-
// Soft in the direction that matters: a mint that says nothing anywhere
|
|
484
|
-
// and names nothing mints exactly what the draft's fallback describes,
|
|
485
|
-
// which is not a defect. What is graded is a mint that CLAIMS the
|
|
486
|
-
// capability, because a wallet then stops rotating on sight and trusts
|
|
487
|
-
// the mint to bind the note.
|
|
488
|
+
// Naming the note being bought is mandatory in the current LUD-25 draft:
|
|
489
|
+
// `comment = hex(sha256(secret))`, with `commentAllowed` large enough for
|
|
490
|
+
// all 64 characters. `mintToHash` is the additive parameter spelling one
|
|
491
|
+
// mint shipped first. It may corroborate the comment, never replace it.
|
|
488
492
|
//
|
|
489
493
|
// The claim lives in three places. The payRequest is the one to decide
|
|
490
494
|
// from, since every mint publishes one while the mint address document
|
|
@@ -493,24 +497,24 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
493
497
|
// bound. LUD-25 defines no echo for the comment spelling, so a missing
|
|
494
498
|
// echo is only held against a mint claiming `mintToHash`.
|
|
495
499
|
//
|
|
496
|
-
// The two spellings are
|
|
497
|
-
// one probe with two parameter names:
|
|
500
|
+
// The two spellings are not symmetric:
|
|
498
501
|
//
|
|
499
502
|
// `h` is a parameter invented for exactly this purpose, so a malformed
|
|
500
503
|
// one is a wallet error and MUST be refused before an invoice exists.
|
|
501
504
|
//
|
|
502
|
-
// `comment` is
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
// that fallback the mint MUST NOT serve LUD-21 verify, because there
|
|
507
|
-
// P is not proof of payment, it is the entire note.
|
|
508
|
-
await report.check('accepts a named output on the mint quote (LUD-25 comment / mintToHash, optional)', async () => {
|
|
505
|
+
// `comment` is the normative commitment and MUST be present and valid
|
|
506
|
+
// before any invoice exists. For an `h` probe the same hash is therefore
|
|
507
|
+
// carried in both fields.
|
|
508
|
+
await report.check('requires comment-bound minting and honours the mintToHash extension', async () => {
|
|
509
509
|
const amount = Math.max(pay.minSendable, 1000)
|
|
510
510
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
511
|
+
assert(
|
|
512
|
+
Number.isFinite(pay.commentAllowed) && pay.commentAllowed >= 64,
|
|
513
|
+
`minting payRequest must advertise commentAllowed: 64, got ${JSON.stringify(pay.commentAllowed)}`
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
// Current LUD-25 minting requires commentAllowed of at least 64.
|
|
517
|
+
// Anything shorter cannot carry the output commitment at all.
|
|
514
518
|
const spellingsOf = source => {
|
|
515
519
|
const found = []
|
|
516
520
|
if (source?.mintToHash === true) found.push('h')
|
|
@@ -521,6 +525,14 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
521
525
|
const url = new URL(pay.callback)
|
|
522
526
|
url.searchParams.set('amount', String(msat))
|
|
523
527
|
url.searchParams.set(spelling, value)
|
|
528
|
+
if (spelling === 'h') {
|
|
529
|
+
// `h` is an additive compatibility field. A conforming quote still
|
|
530
|
+
// carries the mandatory LUD-12 comment, and both name one output.
|
|
531
|
+
url.searchParams.set(
|
|
532
|
+
'comment',
|
|
533
|
+
/^[0-9a-f]{64}$/i.test(value) ? value : noteId(bytesToHex(randomBytes(32)))
|
|
534
|
+
)
|
|
535
|
+
}
|
|
524
536
|
return get(url)
|
|
525
537
|
}
|
|
526
538
|
const spelt = spelling => (spelling === 'comment' ? 'a LUD-12 comment' : 'an h parameter')
|
|
@@ -538,7 +550,9 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
538
550
|
// that accepts both and binds an output id uniquely - as it should,
|
|
539
551
|
// and as the repeat probe below asserts - would rightly refuse the
|
|
540
552
|
// second spelling for naming an output the first just took.
|
|
541
|
-
|
|
553
|
+
// Probe h even when it was not advertised: a quote echoing mintToHash
|
|
554
|
+
// is itself a claim, and is otherwise impossible to discover.
|
|
555
|
+
const probed = [...new Set([...claimed, 'h'])]
|
|
542
556
|
const named = Object.fromEntries(
|
|
543
557
|
probed.map(spelling => {
|
|
544
558
|
const secret = bytesToHex(randomBytes(32))
|
|
@@ -548,15 +562,7 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
548
562
|
const bound = {}
|
|
549
563
|
for (const spelling of probed) bound[spelling] = await quoteAt(spelling, named[spelling].h)
|
|
550
564
|
const echoedIn = probed.filter(spelling => bound[spelling].mintToHash === true)
|
|
551
|
-
|
|
552
|
-
if (claimed.length === 0 && echoedIn.length === 0) {
|
|
553
|
-
const refused = probed.map(spelling => bound[spelling]).find(body => body.status === 'ERROR')
|
|
554
|
-
throw soft(
|
|
555
|
-
refused
|
|
556
|
-
? `not offered, and a named output was refused outright: ${refused.reason}`
|
|
557
|
-
: 'not offered - a minted note\'s k1 is the invoice preimage, which every routing node on the payment path learns, so a wallet must claim and rotate the instant it settles'
|
|
558
|
-
)
|
|
559
|
-
}
|
|
565
|
+
const hClaimed = claimed.includes('h') || echoedIn.includes('h')
|
|
560
566
|
|
|
561
567
|
const problems = []
|
|
562
568
|
const claimedBy = [
|
|
@@ -609,6 +615,7 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
609
615
|
for (const [what, value] of malformed) {
|
|
610
616
|
const body = await quoteAt(spelling, value)
|
|
611
617
|
if (spelling === 'h') {
|
|
618
|
+
if (!hClaimed) continue
|
|
612
619
|
// A wallet that pays a quote the mint was always going to reject
|
|
613
620
|
// has bought nothing, and the mint keeps the sats.
|
|
614
621
|
assert(
|
|
@@ -617,30 +624,48 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
617
624
|
)
|
|
618
625
|
continue
|
|
619
626
|
}
|
|
620
|
-
// The comment spelling
|
|
627
|
+
// The normative comment spelling. A malformed commitment is refused
|
|
628
|
+
// before invoice creation; there is no preimage-backed fallback.
|
|
621
629
|
assert(
|
|
622
|
-
body.status
|
|
623
|
-
`
|
|
624
|
-
)
|
|
625
|
-
assert(
|
|
626
|
-
!body.verify,
|
|
627
|
-
`served a LUD-21 verify URL on a quote whose comment is ${what}, which the draft credits as k1=P - verify then hands the note itself to anyone holding the invoice, not merely proof that it was paid`
|
|
630
|
+
body.status === 'ERROR' && !body.pr,
|
|
631
|
+
`issued an invoice for a comment that is ${what} - current LUD-25 requires a well-formed wallet commitment before invoice creation`
|
|
628
632
|
)
|
|
629
633
|
}
|
|
630
634
|
}
|
|
631
635
|
|
|
636
|
+
// The malformed loop can only probe values; explicitly cover absence.
|
|
637
|
+
const bare = new URL(pay.callback)
|
|
638
|
+
bare.searchParams.set('amount', String(amount))
|
|
639
|
+
const unnamed = await get(bare)
|
|
640
|
+
assert(
|
|
641
|
+
unnamed.status === 'ERROR' && !unnamed.pr,
|
|
642
|
+
'issued an invoice for a quote carrying no comment - current LUD-25 requires rejection before invoicing'
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
if (hClaimed) {
|
|
646
|
+
const mismatch = new URL(pay.callback)
|
|
647
|
+
mismatch.searchParams.set('amount', String(amount))
|
|
648
|
+
mismatch.searchParams.set('comment', noteId(bytesToHex(randomBytes(32))))
|
|
649
|
+
mismatch.searchParams.set('h', noteId(bytesToHex(randomBytes(32))))
|
|
650
|
+
const body = await get(mismatch)
|
|
651
|
+
assert(
|
|
652
|
+
body.status === 'ERROR' && !body.pr,
|
|
653
|
+
'issued an invoice when h and the mandatory comment named different outputs'
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
|
|
632
657
|
// The claims must agree. None of these disagreements loses anyone
|
|
633
658
|
// money on its own - a wallet reading a missing field as false falls
|
|
634
659
|
// back to the preimage flow, which is safe - so each is named rather
|
|
635
660
|
// than failed. Whether the mint really binds is the one thing this
|
|
636
661
|
// check cannot see, because that needs a settlement: it is graded
|
|
637
662
|
// separately, and failed rather than warned.
|
|
638
|
-
if (advertised.includes('h') && echoedIn.
|
|
663
|
+
if (advertised.includes('h') && !echoedIn.includes('h')) {
|
|
639
664
|
problems.push(
|
|
640
665
|
'bound quotes carry no mintToHash in the response, so a wallet cannot confirm at the one moment it is worth confirming, and falls back to racing the preimage'
|
|
641
666
|
)
|
|
642
667
|
}
|
|
643
|
-
if (echoedIn.
|
|
668
|
+
if (echoedIn.includes('h') && !advertised.includes('h')) {
|
|
644
669
|
problems.push(
|
|
645
670
|
'echoes mintToHash on a quote but does not advertise it on the payRequest, which is the endpoint every mint publishes and the one a wallet decides from'
|
|
646
671
|
)
|
|
@@ -662,6 +687,7 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
662
687
|
const otherAmount = Math.min(pay.maxSendable, amount * 2)
|
|
663
688
|
if (otherAmount !== amount) {
|
|
664
689
|
for (const spelling of probed) {
|
|
690
|
+
if (spelling === 'h' && !hClaimed) continue
|
|
665
691
|
const twice = await quoteAt(spelling, named[spelling].h, otherAmount)
|
|
666
692
|
if (twice.status !== 'ERROR') {
|
|
667
693
|
problems.push(
|
|
@@ -779,6 +805,7 @@ export const gradeBoundMint = async (noteUrl, report, {preimage, payCallback = n
|
|
|
779
805
|
if (payCallback) {
|
|
780
806
|
const quote = new URL(payCallback)
|
|
781
807
|
quote.searchParams.set('amount', '1000')
|
|
808
|
+
quote.searchParams.set('comment', noteId(k1))
|
|
782
809
|
quote.searchParams.set('h', noteId(k1))
|
|
783
810
|
const body = await get(quote)
|
|
784
811
|
assert(
|
|
@@ -854,6 +881,72 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
854
881
|
return 'maxWithdrawable is authoritative'
|
|
855
882
|
})
|
|
856
883
|
|
|
884
|
+
// LUD-25 "Checking a note without exposing it". Optional, and detected
|
|
885
|
+
// rather than announced: the draft deliberately gives an unrecognized `h`
|
|
886
|
+
// the same answer an unknown `k1` gets, so a mint that never implemented
|
|
887
|
+
// it is indistinguishable from one asked about a note it does not hold.
|
|
888
|
+
// A live note's own hash is therefore the only probe that separates them.
|
|
889
|
+
await report.check('answers a note lookup by hash without the secret (optional)', async () => {
|
|
890
|
+
const byHash = new URL(url)
|
|
891
|
+
byHash.searchParams.delete('k1')
|
|
892
|
+
byHash.searchParams.delete('amount')
|
|
893
|
+
byHash.searchParams.set('h', noteId(k1))
|
|
894
|
+
const body = await get(byHash)
|
|
895
|
+
if (body.status === 'ERROR' || body.tag !== 'withdrawRequest') {
|
|
896
|
+
return 'not offered - every informational lookup puts the live secret in a query string, where any proxy that logs full URLs keeps it'
|
|
897
|
+
}
|
|
898
|
+
// The whole point of the lookup. A wallet asking by hash already holds
|
|
899
|
+
// the secret - it could not have computed the hash otherwise - so the
|
|
900
|
+
// field buys it nothing, and filling it in puts the note back on the
|
|
901
|
+
// wire this lookup exists to keep it off.
|
|
902
|
+
assert(
|
|
903
|
+
body.k1 === undefined,
|
|
904
|
+
'the response carried a k1 - a lookup by hash must omit it, or it puts the bearer secret back in the reply the wallet asked by hash to avoid'
|
|
905
|
+
)
|
|
906
|
+
assert(
|
|
907
|
+
body.maxWithdrawable === info.maxWithdrawable,
|
|
908
|
+
`the same note is worth ${body.maxWithdrawable} by hash and ${info.maxWithdrawable} by k1`
|
|
909
|
+
)
|
|
910
|
+
// An h it never registered must get the unknown-note answer. One that
|
|
911
|
+
// answers anyway reports a note where none exists, and a wallet
|
|
912
|
+
// restoring from seed reads that as a note it has lost the secret to.
|
|
913
|
+
const nobody = new URL(url)
|
|
914
|
+
nobody.searchParams.delete('k1')
|
|
915
|
+
nobody.searchParams.delete('amount')
|
|
916
|
+
nobody.searchParams.set('h', noteId(bytesToHex(randomBytes(32))))
|
|
917
|
+
const invented = await get(nobody)
|
|
918
|
+
assert(
|
|
919
|
+
invented.status === 'ERROR' || invented.tag !== 'withdrawRequest',
|
|
920
|
+
'answered for a hash it never registered - an unrecognized h must get the same response an unknown k1 would'
|
|
921
|
+
)
|
|
922
|
+
return 'by hash, with no k1 in the reply'
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
// The merge cap. LUD-25 bounds a merge by URL length rather than by the
|
|
926
|
+
// protocol, and a request past that is truncated somewhere upstream into
|
|
927
|
+
// a malformed one. Probed with fabricated inputs: nothing here is a real
|
|
928
|
+
// note, so a compliant SERVICE has two acceptable answers and no third.
|
|
929
|
+
await report.check('refuses an oversized merge cleanly (optional cap)', async () => {
|
|
930
|
+
const cb = new URL(info.callback)
|
|
931
|
+
// Enough repeated k1 to carry the whole URL past the ~2000 characters
|
|
932
|
+
// browsers, servers and proxies commonly stop at.
|
|
933
|
+
const many = Math.ceil((2400 - cb.href.length) / 68)
|
|
934
|
+
for (let i = 0; i < many; i++) {
|
|
935
|
+
cb.searchParams.append('k1', bytesToHex(randomBytes(32)))
|
|
936
|
+
}
|
|
937
|
+
cb.searchParams.append('h', noteId(bytesToHex(randomBytes(32))))
|
|
938
|
+
const body = await get(cb)
|
|
939
|
+
// Whatever else it does, it must not say yes. Every input was invented
|
|
940
|
+
// by this runner and names no note anywhere.
|
|
941
|
+
assert(
|
|
942
|
+
body.status !== 'OK',
|
|
943
|
+
`answered OK to a merge of ${many} notes it has never held - a truncated k1 list read as a shorter merge mints an output against inputs the SERVICE never saw`
|
|
944
|
+
)
|
|
945
|
+
return /too many k1/i.test(body.reason ?? '')
|
|
946
|
+
? `capped: "${body.reason}"`
|
|
947
|
+
: `no explicit cap - refused as "${body.reason}", so an oversized merge is indistinguishable from an invalid one and a wallet must batch by URL length`
|
|
948
|
+
})
|
|
949
|
+
|
|
857
950
|
await report.check('refuses a rotate with no h', async () => {
|
|
858
951
|
const cb = new URL(info.callback)
|
|
859
952
|
cb.searchParams.append('k1', k1)
|
package/vectors/lifecycle.json
CHANGED
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
],
|
|
10
10
|
"scenarios": [
|
|
11
11
|
{
|
|
12
|
-
"name": "mint
|
|
12
|
+
"name": "mint to a wallet-generated secret",
|
|
13
13
|
"steps": [
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
14
|
+
"persist a fresh secret",
|
|
15
|
+
"send its hash as the LUD-12 comment",
|
|
16
|
+
"pay the invoice",
|
|
17
|
+
"claim with the persisted secret"
|
|
17
18
|
],
|
|
18
|
-
"requirement": "a
|
|
19
|
+
"requirement": "a SERVICE MUST reject a missing or malformed comment before invoicing. The payment preimage is only settlement proof; the freshly minted bearer k1 is the WALLET-generated secret whose hash was carried in comment."
|
|
19
20
|
},
|
|
20
21
|
{
|
|
21
22
|
"name": "melt is not settlement",
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"spec": "LUD-06, LUD-21, LUD-25",
|
|
4
|
-
"description": "
|
|
3
|
+
"spec": "LUD-06, LUD-21, LUD-25 plus ForgeSworn mintToHash extension",
|
|
4
|
+
"description": "ForgeSworn compatibility profile layered on current LUD-25 minting. The normative output commitment is always `comment=hex(sha256(secret))`. A WALLET MAY repeat that same 64-hex hash as `h` for services and sealed-signer receipts that shipped before the comment spelling. `h` never replaces comment; if present it must be well formed and identify the same output. With or without the extension, the payment preimage is settlement proof and never a valid bearer k1.",
|
|
5
5
|
"parameter": {
|
|
6
6
|
"name": "h",
|
|
7
|
-
"on": "the LUD-06 pay callback, alongside amount",
|
|
8
|
-
"value": "sha256
|
|
7
|
+
"on": "the LUD-06 pay callback, alongside amount and the mandatory identical comment",
|
|
8
|
+
"value": "the same sha256 commitment already carried in comment, 32 bytes as 64 lowercase hex",
|
|
9
9
|
"optional": true,
|
|
10
|
-
"absent": "
|
|
10
|
+
"absent": "current LUD-25 behaviour: comment alone names the wallet-generated bearer secret"
|
|
11
11
|
},
|
|
12
12
|
"caseRule": {
|
|
13
|
-
"wallet": "A WALLET
|
|
14
|
-
"service": "A SERVICE SHOULD normalise case before comparing,
|
|
13
|
+
"wallet": "A WALLET using this extension MUST send the same commitment in `comment` and `h`, each as 64 lowercase hex.",
|
|
14
|
+
"service": "A SERVICE MUST require the normative comment, SHOULD normalise hex case before comparing, MUST reject a mismatched h, and MUST NOT treat upper- and lower-case spellings as different outputs.",
|
|
15
15
|
"whyItMatters": "A SERVICE that keys the string it was handed files the note under the upper-case spelling, and then cannot find it when the wallet asks the withdraw endpoint for its own lowercase secret. The money is not stolen, it is simply lost, and nobody is told. A SERVICE that instead refuses an upper-case `h` outright is being strict rather than wrong, and loses nobody anything - the wallet learns before it pays.",
|
|
16
16
|
"comparedAs": "Every case below carries `comparedAs`: the value a SERVICE compares and keys by, which is `h` lowercased, or null when `h` is absent or malformed."
|
|
17
17
|
},
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"collision": "Invalid or already spent k1."
|
|
47
47
|
},
|
|
48
48
|
"outcomes": {
|
|
49
|
-
"bound": "an invoice is issued carrying `mintToHash: true`, and
|
|
50
|
-
"
|
|
49
|
+
"extension-bound": "comment and h name the same output; an invoice is issued carrying `mintToHash: true`, and settlement credits that committed hash. The payment preimage names nothing",
|
|
50
|
+
"comment-only": "no h was sent: the mandatory comment still names the output, and the payment preimage names nothing",
|
|
51
51
|
"malformed-h": "not 32 bytes of hex in any casing: refused before any invoice exists, so a wallet never pays for a quote the SERVICE was always going to reject",
|
|
52
52
|
"collision": "refused before any invoice exists, with the same reason a colliding output hash gets on the withdraw callback, so a probe learns nothing about which ids exist"
|
|
53
53
|
},
|
|
@@ -61,21 +61,23 @@
|
|
|
61
61
|
{
|
|
62
62
|
"name": "no h at all",
|
|
63
63
|
"amountMsat": 21000,
|
|
64
|
+
"comment": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
64
65
|
"h": null,
|
|
65
66
|
"comparedAs": null,
|
|
66
|
-
"outcome": "
|
|
67
|
+
"outcome": "comment-only",
|
|
67
68
|
"invoiced": true,
|
|
68
69
|
"echo": false,
|
|
69
|
-
"noteId": "
|
|
70
|
+
"noteId": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
70
71
|
"reason": null,
|
|
71
|
-
"why": "the LUD-25 flow
|
|
72
|
+
"why": "the current LUD-25 flow: the mandatory comment names the output without the extension"
|
|
72
73
|
},
|
|
73
74
|
{
|
|
74
75
|
"name": "a well-formed h",
|
|
75
76
|
"amountMsat": 21000,
|
|
77
|
+
"comment": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
76
78
|
"h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
77
79
|
"comparedAs": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
78
|
-
"outcome": "bound",
|
|
80
|
+
"outcome": "extension-bound",
|
|
79
81
|
"invoiced": true,
|
|
80
82
|
"echo": true,
|
|
81
83
|
"noteId": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
@@ -84,6 +86,7 @@
|
|
|
84
86
|
{
|
|
85
87
|
"name": "an h that is not hex",
|
|
86
88
|
"amountMsat": 21000,
|
|
89
|
+
"comment": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
87
90
|
"h": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
|
88
91
|
"comparedAs": null,
|
|
89
92
|
"outcome": "malformed-h",
|
|
@@ -96,6 +99,7 @@
|
|
|
96
99
|
{
|
|
97
100
|
"name": "an h one character short",
|
|
98
101
|
"amountMsat": 21000,
|
|
102
|
+
"comment": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
99
103
|
"h": "000000000000000000000000000000000000000000000000000000000000000",
|
|
100
104
|
"comparedAs": null,
|
|
101
105
|
"outcome": "malformed-h",
|
|
@@ -107,6 +111,7 @@
|
|
|
107
111
|
{
|
|
108
112
|
"name": "an h one character long",
|
|
109
113
|
"amountMsat": 21000,
|
|
114
|
+
"comment": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
110
115
|
"h": "00000000000000000000000000000000000000000000000000000000000000000",
|
|
111
116
|
"comparedAs": null,
|
|
112
117
|
"outcome": "malformed-h",
|
|
@@ -118,6 +123,7 @@
|
|
|
118
123
|
{
|
|
119
124
|
"name": "an empty h",
|
|
120
125
|
"amountMsat": 21000,
|
|
126
|
+
"comment": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
121
127
|
"h": "",
|
|
122
128
|
"comparedAs": null,
|
|
123
129
|
"outcome": "malformed-h",
|
|
@@ -130,9 +136,10 @@
|
|
|
130
136
|
{
|
|
131
137
|
"name": "an h in upper case hex",
|
|
132
138
|
"amountMsat": 21000,
|
|
139
|
+
"comment": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
133
140
|
"h": "F849D67325FACF04177BC663B2DC544051831C589EF581D412F2EBA44834E77C",
|
|
134
141
|
"comparedAs": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
135
|
-
"outcome": "bound",
|
|
142
|
+
"outcome": "extension-bound",
|
|
136
143
|
"invoiced": true,
|
|
137
144
|
"echo": true,
|
|
138
145
|
"noteId": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
@@ -142,6 +149,7 @@
|
|
|
142
149
|
{
|
|
143
150
|
"name": "an h that already names a note",
|
|
144
151
|
"amountMsat": 21000,
|
|
152
|
+
"comment": "e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e",
|
|
145
153
|
"h": "e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e",
|
|
146
154
|
"comparedAs": "e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e",
|
|
147
155
|
"outcome": "collision",
|
|
@@ -154,6 +162,7 @@
|
|
|
154
162
|
{
|
|
155
163
|
"name": "an h in upper case hex that already names a note",
|
|
156
164
|
"amountMsat": 21000,
|
|
165
|
+
"comment": "e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e",
|
|
157
166
|
"h": "E0E77A507412B120F6EDE61F62295B1A7B2FF19D3DCC8F7253E51663470C888E",
|
|
158
167
|
"comparedAs": "e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e",
|
|
159
168
|
"outcome": "collision",
|
|
@@ -166,6 +175,7 @@
|
|
|
166
175
|
{
|
|
167
176
|
"name": "an h that already names an invoice this SERVICE issued",
|
|
168
177
|
"amountMsat": 21000,
|
|
178
|
+
"comment": "4bb06f8e4e3a7715d201d573d0aa423762e55dabd61a2c02278fa56cc6d294e0",
|
|
169
179
|
"h": "4bb06f8e4e3a7715d201d573d0aa423762e55dabd61a2c02278fa56cc6d294e0",
|
|
170
180
|
"comparedAs": "4bb06f8e4e3a7715d201d573d0aa423762e55dabd61a2c02278fa56cc6d294e0",
|
|
171
181
|
"outcome": "collision",
|
|
@@ -178,6 +188,7 @@
|
|
|
178
188
|
{
|
|
179
189
|
"name": "an h another quote is already waiting to credit",
|
|
180
190
|
"amountMsat": 21000,
|
|
191
|
+
"comment": "2578ccf8645b2d1dc10c465eff843585970f3a7e22296a92cad55d489a272072",
|
|
181
192
|
"h": "2578ccf8645b2d1dc10c465eff843585970f3a7e22296a92cad55d489a272072",
|
|
182
193
|
"comparedAs": "2578ccf8645b2d1dc10c465eff843585970f3a7e22296a92cad55d489a272072",
|
|
183
194
|
"outcome": "collision",
|
|
@@ -191,20 +202,21 @@
|
|
|
191
202
|
"settlement": {
|
|
192
203
|
"description": "One worked settlement, both ways round, so an implementation can check where the note landed rather than only that an invoice came back.",
|
|
193
204
|
"walletSecret": "0505050505050505050505050505050505050505050505050505050505050505",
|
|
205
|
+
"comment": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
194
206
|
"h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
195
207
|
"preimage": "0606060606060606060606060606060606060606060606060606060606060606",
|
|
196
208
|
"paymentHash": "e802086ad6a1e16b78352ad7296d2aabd835b1b16dbe951e1135b97c68e29d81",
|
|
197
|
-
"
|
|
209
|
+
"extensionBound": {
|
|
198
210
|
"noteId": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
199
211
|
"k1": "0505050505050505050505050505050505050505050505050505050505050505",
|
|
200
212
|
"preimageIsAValidK1": false,
|
|
201
213
|
"note": "a software wallet can claim by asking the withdraw endpoint for its own secret directly. A sealed signer that will not export that secret instead uses the optional bound receipt below to confirm the note without revealing k1"
|
|
202
214
|
},
|
|
203
|
-
"
|
|
204
|
-
"noteId": "
|
|
205
|
-
"k1": "
|
|
206
|
-
"preimageIsAValidK1":
|
|
207
|
-
"note": "the
|
|
215
|
+
"commentOnly": {
|
|
216
|
+
"noteId": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
217
|
+
"k1": "0505050505050505050505050505050505050505050505050505050505050505",
|
|
218
|
+
"preimageIsAValidK1": false,
|
|
219
|
+
"note": "without h, the mandatory comment still binds the freshly minted note to the wallet secret"
|
|
208
220
|
}
|
|
209
221
|
},
|
|
210
222
|
"receipt": {
|
|
@@ -214,6 +226,7 @@
|
|
|
214
226
|
"keyEstablishment": {
|
|
215
227
|
"rule": "The WALLET must know the receipt verification key before it pays: recover the signing node identity from the BOLT-11 invoice, or read mintPubkey from the payRequest under the wallet's existing trust/pinning policy.",
|
|
216
228
|
"payRequest": {
|
|
229
|
+
"commentAllowed": 64,
|
|
217
230
|
"mintToHash": true,
|
|
218
231
|
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
219
232
|
}
|
|
@@ -257,7 +270,7 @@
|
|
|
257
270
|
"A WALLET that requires a receipt MUST refuse to show or pay an invoice unless quote.mintToHash is exactly true and quote.mint matches the h it requested and the exact amount it expects to receive.",
|
|
258
271
|
"Before accepting settlement, it MUST match verify.pr to quote.pr, match verify.mint.h and verify.mint.amount to the quote commitment, require settled to be exactly true, and verify mint.sig with the mint public key and its locally held k1.",
|
|
259
272
|
"A SERVICE MUST NOT return mint.sig before settlement. An unsettled response MAY repeat h and amount so a wallet can diagnose a mismatch, but that repetition is not a receipt.",
|
|
260
|
-
"Absence of quote.mint means the optional receipt is not offered. A software wallet
|
|
273
|
+
"Absence of quote.mint means the optional receipt is not offered. A software wallet still claims the comment-bound note with its own k1; a sealed signer must use another authenticated confirmation path or decline before payment."
|
|
261
274
|
],
|
|
262
275
|
"invalid": [
|
|
263
276
|
{
|
|
@@ -328,6 +341,7 @@
|
|
|
328
341
|
"minSendable": 1000,
|
|
329
342
|
"maxSendable": 100000000,
|
|
330
343
|
"metadata": "[[\"text/plain\",\"a mint\"]]",
|
|
344
|
+
"commentAllowed": 64,
|
|
331
345
|
"withdrawLink": "https://mint.example/w",
|
|
332
346
|
"mintToHash": true
|
|
333
347
|
},
|
|
@@ -342,6 +356,7 @@
|
|
|
342
356
|
"minSendable": 1000,
|
|
343
357
|
"maxSendable": 100000000,
|
|
344
358
|
"metadata": "[[\"text/plain\",\"a mint\"]]",
|
|
359
|
+
"commentAllowed": 64,
|
|
345
360
|
"withdrawLink": "https://mint.example/w"
|
|
346
361
|
},
|
|
347
362
|
"offered": false,
|
|
@@ -356,6 +371,7 @@
|
|
|
356
371
|
"minSendable": 1000,
|
|
357
372
|
"maxSendable": 100000000,
|
|
358
373
|
"metadata": "[[\"text/plain\",\"a mint\"]]",
|
|
374
|
+
"commentAllowed": 64,
|
|
359
375
|
"withdrawLink": "https://mint.example/w",
|
|
360
376
|
"mintToHash": false
|
|
361
377
|
},
|
|
@@ -370,6 +386,7 @@
|
|
|
370
386
|
"minSendable": 1000,
|
|
371
387
|
"maxSendable": 100000000,
|
|
372
388
|
"metadata": "[[\"text/plain\",\"a mint\"]]",
|
|
389
|
+
"commentAllowed": 64,
|
|
373
390
|
"withdrawLink": "https://mint.example/w",
|
|
374
391
|
"mintToHash": "true"
|
|
375
392
|
},
|
|
@@ -385,6 +402,7 @@
|
|
|
385
402
|
"minSendable": 1000,
|
|
386
403
|
"maxSendable": 100000000,
|
|
387
404
|
"metadata": "[[\"text/plain\",\"a mint\"]]",
|
|
405
|
+
"commentAllowed": 64,
|
|
388
406
|
"withdrawLink": "https://mint.example/w",
|
|
389
407
|
"mintToHash": 1
|
|
390
408
|
},
|
|
@@ -521,29 +539,29 @@
|
|
|
521
539
|
"contradictions": [
|
|
522
540
|
{
|
|
523
541
|
"name": "claims the capability and does not bind",
|
|
524
|
-
"what": "any of the three says `mintToHash: true`,
|
|
542
|
+
"what": "any of the three says `mintToHash: true`, but the service ignores h or lets it disagree with the mandatory comment",
|
|
525
543
|
"verdict": "broken",
|
|
526
|
-
"why": "the
|
|
544
|
+
"why": "the extension claim is false and any receipt commitment may authenticate a different output from the mandatory comment."
|
|
527
545
|
},
|
|
528
546
|
{
|
|
529
547
|
"name": "binds and says nothing on the quote",
|
|
530
548
|
"what": "the payRequest advertises it, the quote carries `h`, and the response omits `mintToHash`",
|
|
531
549
|
"verdict": "safe but unconfirmable",
|
|
532
|
-
"why": "
|
|
550
|
+
"why": "the comment-bound note remains safe, but the wallet cannot rely on extension-specific receipt semantics for this quote."
|
|
533
551
|
},
|
|
534
552
|
{
|
|
535
553
|
"name": "says nothing anywhere and ignores `h`",
|
|
536
|
-
"what": "no advertisement
|
|
554
|
+
"what": "no extension advertisement or echo; the mandatory comment still names the note",
|
|
537
555
|
"verdict": "not implemented",
|
|
538
|
-
"why": "
|
|
556
|
+
"why": "the additive extension is absent; current LUD-25 comment minting remains fully implemented."
|
|
539
557
|
}
|
|
540
558
|
],
|
|
541
559
|
"walletRules": [
|
|
542
|
-
"A WALLET MUST persist its chosen secret BEFORE asking for the invoice
|
|
543
|
-
"A WALLET MUST
|
|
544
|
-
"A WALLET
|
|
545
|
-
"A WALLET MUST check the pay callback's own
|
|
546
|
-
"
|
|
547
|
-
"A note
|
|
560
|
+
"A WALLET MUST persist its chosen secret BEFORE asking for the invoice, then send its hash in the mandatory comment.",
|
|
561
|
+
"A WALLET using the extension MUST repeat that same hash as 64 lowercase hex in h; it MUST NOT send two different output commitments.",
|
|
562
|
+
"A WALLET decides whether the additive h and receipt fields are supported from mintToHash on the payRequest; absence affects only the extension, never the mandatory comment-bound note.",
|
|
563
|
+
"A WALLET requiring a bound receipt MUST check the pay callback's own mintToHash and mint commitment before paying. If absent, it declines or uses another authenticated confirmation path; it never falls back to a preimage-backed mint.",
|
|
564
|
+
"A software WALLET needs no verify preimage to claim a comment-bound note: it already knows its secret. A sealed signer MAY require the optional receipt and use verify as authenticated settlement evidence.",
|
|
565
|
+
"A comment-bound note belongs to the WALLET from birth. Seed-derived secrets make it recoverable without relying on payment history."
|
|
548
566
|
]
|
|
549
567
|
}
|
package/vectors/pay-request.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
3
|
"spec": "LUD-06, LUD-11, LUD-16, LUD-21, LUD-25",
|
|
4
|
-
"description": "Minting. A LUD-06 payRequest MAY advertise `withdrawLink`, the raw LUD-17 URL of the withdraw endpoint:
|
|
4
|
+
"description": "Minting. A LUD-06 payRequest MAY advertise `withdrawLink`, the raw LUD-17 URL of the withdraw endpoint, and MUST then advertise `commentAllowed: 64`. Before paying, WALLET generates and persists a 32-byte secret and sends `comment=hex(sha256(secret))`; SERVICE rejects a missing or malformed comment before issuing an invoice. The payment preimage is settlement proof, never the bearer k1.",
|
|
5
5
|
"accepted": [
|
|
6
6
|
{
|
|
7
7
|
"name": "a minting payRequest",
|
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
"minSendable": 1000,
|
|
12
12
|
"maxSendable": 100000000,
|
|
13
13
|
"metadata": "[[\"text/plain\",\"a mint\"],[\"text/identifier\",\"mint@mint.example\"]]",
|
|
14
|
+
"commentAllowed": 64,
|
|
14
15
|
"withdrawLink": "lnurlw://mint.example/w"
|
|
15
16
|
},
|
|
16
17
|
"withdrawLink": "lnurlw://mint.example/w",
|
|
18
|
+
"commentAllowed": 64,
|
|
17
19
|
"mintFee": null
|
|
18
20
|
},
|
|
19
21
|
{
|
|
@@ -24,9 +26,11 @@
|
|
|
24
26
|
"minSendable": 1000,
|
|
25
27
|
"maxSendable": 100000000,
|
|
26
28
|
"metadata": "[[\"text/plain\",\"a mint\"],[\"text/identifier\",\"mint@mint.example\"]]",
|
|
29
|
+
"commentAllowed": 64,
|
|
27
30
|
"withdrawLink": "https://mint.example/w"
|
|
28
31
|
},
|
|
29
32
|
"withdrawLink": "https://mint.example/w",
|
|
33
|
+
"commentAllowed": 64,
|
|
30
34
|
"mintFee": null,
|
|
31
35
|
"why": "LUD-25 says a raw, non-bech32 URL \"as described in LUD-17\", and LUD-17 describes both the lnurlw:// scheme and the plain URL it stands for. lnurl-mint, and the spec diagram, use this form; moneyer uses lnurlw://. A WALLET MUST accept either, unchanged, and resolve it through the same LUD-17 rule as any other input"
|
|
32
36
|
},
|
|
@@ -38,9 +42,11 @@
|
|
|
38
42
|
"minSendable": 1000,
|
|
39
43
|
"maxSendable": 100000000,
|
|
40
44
|
"metadata": "[[\"text/plain\",\"a mint\"]]",
|
|
45
|
+
"commentAllowed": 64,
|
|
41
46
|
"withdrawLink": "lnurlw://mintmintmintmintmintmintmintmintmintmintmintmintmintmi.onion/w"
|
|
42
47
|
},
|
|
43
48
|
"withdrawLink": "lnurlw://mintmintmintmintmintmintmintmintmintmintmintmintmintmi.onion/w",
|
|
49
|
+
"commentAllowed": 64,
|
|
44
50
|
"mintFee": null,
|
|
45
51
|
"why": "the parser passes the link through; resolution to http:// happens when a note is built from it (see note-url.json build)"
|
|
46
52
|
},
|
|
@@ -52,9 +58,11 @@
|
|
|
52
58
|
"minSendable": 1000,
|
|
53
59
|
"maxSendable": 100000000,
|
|
54
60
|
"metadata": "[[\"text/plain\",\"a mint\"],[\"text/plain\",\"Mint fees: 1000,2000\"]]",
|
|
61
|
+
"commentAllowed": 64,
|
|
55
62
|
"withdrawLink": "lnurlw://mint.example/w"
|
|
56
63
|
},
|
|
57
64
|
"withdrawLink": "lnurlw://mint.example/w",
|
|
65
|
+
"commentAllowed": 64,
|
|
58
66
|
"mintFee": {
|
|
59
67
|
"baseFeeMsat": 1000,
|
|
60
68
|
"feePpm": 2000
|
|
@@ -88,8 +96,49 @@
|
|
|
88
96
|
"minSendable": 1000,
|
|
89
97
|
"maxSendable": 1000
|
|
90
98
|
}
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"name": "minting payRequest without room for the required hash comment",
|
|
102
|
+
"body": {
|
|
103
|
+
"tag": "payRequest",
|
|
104
|
+
"callback": "https://mint.example/p/cb",
|
|
105
|
+
"minSendable": 1000,
|
|
106
|
+
"maxSendable": 100000000,
|
|
107
|
+
"metadata": "[[\"text/plain\",\"a broken mint\"]]",
|
|
108
|
+
"withdrawLink": "lnurlw://mint.example/w"
|
|
109
|
+
},
|
|
110
|
+
"why": "current LUD-25 draft: minting is unavailable unless commentAllowed can carry exactly the 64-character hash commitment"
|
|
91
111
|
}
|
|
92
112
|
],
|
|
113
|
+
"mintCallback": {
|
|
114
|
+
"accepted": [
|
|
115
|
+
{
|
|
116
|
+
"name": "wallet names the freshly minted note before invoice creation",
|
|
117
|
+
"amountMsat": 21000,
|
|
118
|
+
"comment": "e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e",
|
|
119
|
+
"result": "invoice",
|
|
120
|
+
"noteId": "e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e",
|
|
121
|
+
"bearerK1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
122
|
+
"paymentPreimageIsBearerK1": false
|
|
123
|
+
}
|
|
124
|
+
],
|
|
125
|
+
"rejected": [
|
|
126
|
+
{
|
|
127
|
+
"name": "missing comment",
|
|
128
|
+
"amountMsat": 21000,
|
|
129
|
+
"comment": null,
|
|
130
|
+
"result": "error-before-invoice",
|
|
131
|
+
"why": "an unnamed mint would make the payment preimage the money, which the current draft no longer permits"
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"name": "malformed comment",
|
|
135
|
+
"amountMsat": 21000,
|
|
136
|
+
"comment": "not-a-32-byte-hash",
|
|
137
|
+
"result": "error-before-invoice",
|
|
138
|
+
"why": "comment must be bare hex encoding of a 32-byte SHA-256 output"
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
},
|
|
93
142
|
"invoice": {
|
|
94
143
|
"accepted": [
|
|
95
144
|
{
|
|
@@ -158,7 +207,7 @@
|
|
|
158
207
|
},
|
|
159
208
|
"settled": true,
|
|
160
209
|
"preimage": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
161
|
-
"why": "
|
|
210
|
+
"why": "the payment preimage proves settlement but cannot redeem the comment-bound bearer note"
|
|
162
211
|
},
|
|
163
212
|
{
|
|
164
213
|
"name": "settled, preimage withheld",
|