lnurlcash-conformance 0.2.3 → 0.4.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 +59 -0
- package/README.md +5 -0
- package/llms.txt +10 -0
- package/mock-mint/index.d.ts +8 -0
- package/mock-mint/index.mjs +106 -9
- package/package.json +1 -1
- package/runner/index.mjs +160 -70
- package/vectors/mint-to-hash.json +104 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,65 @@ 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.4.0 - 2026-08-26
|
|
8
|
+
|
|
9
|
+
**The mint-output naming check now reads the spelling LUD-25 actually
|
|
10
|
+
specifies.** The draft names the output with a LUD-12 `comment = hex(h)`,
|
|
11
|
+
advertised as a `commentAllowed` of at least 64; `mintToHash` is the
|
|
12
|
+
parameter form one mint shipped before the comment form was written. The
|
|
13
|
+
suite knew only the latter, so a mint naming notes exactly as the draft
|
|
14
|
+
describes graded as "not offered - a minted note's k1 is the invoice
|
|
15
|
+
preimage". That is the worst kind of wrong answer a conformance suite can
|
|
16
|
+
give: it tells a wallet author the safe mint is the unsafe one.
|
|
17
|
+
|
|
18
|
+
Both spellings are now read from the payRequest and the mint address
|
|
19
|
+
document, probed with a fresh hash each (a mint that binds output ids
|
|
20
|
+
uniquely would rightly refuse the second spelling for naming an output the
|
|
21
|
+
first just took), and reported as `named by comment` / `named by h`.
|
|
22
|
+
|
|
23
|
+
The two are deliberately not probed identically, because the draft's rules
|
|
24
|
+
for them are opposites:
|
|
25
|
+
|
|
26
|
+
- `h` is a parameter invented for naming, so a malformed one MUST be
|
|
27
|
+
refused before an invoice exists. Unchanged.
|
|
28
|
+
- `comment` is plain LUD-12 free text any wallet may send for unrelated
|
|
29
|
+
reasons, so a comment that is not a bare hash MUST fall back to crediting
|
|
30
|
+
`k1=P` rather than be refused, and the mint MUST NOT serve LUD-21 `verify`
|
|
31
|
+
on that fallback - there the preimage is not proof of payment, it is the
|
|
32
|
+
note. Both are now checked, the second as a failure.
|
|
33
|
+
|
|
34
|
+
Only `mintToHash` is expected in both documents. `commentAllowed` is a
|
|
35
|
+
payRequest field; the mint address document is a withdrawRequest, where a
|
|
36
|
+
LUD-12 comment has nowhere to go, so its absence there is no longer
|
|
37
|
+
reported as a disagreement.
|
|
38
|
+
|
|
39
|
+
**A rate-limited grade no longer accuses the mint.** Every mint quote
|
|
40
|
+
issues a real invoice on a real node, so a grader firing a dozen looks like
|
|
41
|
+
the abuse a limiter exists to stop - and probing two spellings nearly
|
|
42
|
+
doubled the count. HTTP 429 was indistinguishable from a spec refusal, so
|
|
43
|
+
the mint was reported as violating whatever check happened to be running
|
|
44
|
+
when the bucket ran dry. 429 is now waited out (honouring `Retry-After`)
|
|
45
|
+
and retried, and reported as an incomplete grade rather than a verdict if
|
|
46
|
+
it persists.
|
|
47
|
+
|
|
48
|
+
The mock mint gains `commentAllowed`, `commentRefusesMalformed` and
|
|
49
|
+
`verifyOnUnnamedMint` to cover all of this.
|
|
50
|
+
|
|
51
|
+
## 0.3.0 - 2026-08-24
|
|
52
|
+
|
|
53
|
+
- Add an optional bound-mint settlement receipt for sealed signers. A
|
|
54
|
+
receipt-capable quote commits to `mint: {h, amount}` before payment; its
|
|
55
|
+
settled LUD-21 response repeats the invoice, output and exact net value and
|
|
56
|
+
adds the ordinary LUD-25 note signature. Absence remains compatible with
|
|
57
|
+
the current preimage-and-rotate flow.
|
|
58
|
+
- `vectors/mint-to-hash.json` now carries the valid quote, unsettled and
|
|
59
|
+
settled shapes plus wrong-output, wrong-amount, premature-signature and
|
|
60
|
+
invalid-signature cases. `docs/BOUND-MINT-RECEIPTS.md` contains candidate
|
|
61
|
+
normative LUD-25 text and the compatibility matrix.
|
|
62
|
+
- The mock mint gains `mintReceipt`; when enabled it publishes the
|
|
63
|
+
verification key before payment, commits the quote and signs only after
|
|
64
|
+
settlement. Self-check and self-grade verify the complete lifecycle.
|
|
65
|
+
|
|
7
66
|
## 0.2.3 - 2026-08-22
|
|
8
67
|
|
|
9
68
|
- **New check: `keeps signatures off the informational endpoint`.** LUD-25
|
package/README.md
CHANGED
|
@@ -60,6 +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` | wallet-chosen mint outputs and optional bound LUD-21 receipts |
|
|
63
64
|
| `lifecycle.json` | behavioural requirements, as scenarios to drive |
|
|
64
65
|
| `threat-suite.json` | the transport/exposure scorecard — candidate spec options against fixed attacks (non-normative) |
|
|
65
66
|
|
|
@@ -67,6 +68,9 @@ Regenerate with `npm run generate`; check them with `npm test`, which
|
|
|
67
68
|
verifies every digest recomputes, every declared signature really does
|
|
68
69
|
verify, and every fee expectation follows from the formula.
|
|
69
70
|
|
|
71
|
+
The upstreamable wire text and compatibility matrix for the optional receipt
|
|
72
|
+
are in [`docs/BOUND-MINT-RECEIPTS.md`](docs/BOUND-MINT-RECEIPTS.md).
|
|
73
|
+
|
|
70
74
|
## The mock mint
|
|
71
75
|
|
|
72
76
|
```bash
|
|
@@ -123,6 +127,7 @@ answered:
|
|
|
123
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 |
|
|
124
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 |
|
|
125
129
|
| `--mintToHash` | takes an optional `h` on the pay callback and credits the minted note there, so the payment preimage is not the money. Off by default, and then `h` is not read at all |
|
|
130
|
+
| `--mintReceipt` | with `--mintToHash`, adds the optional quote commitment and signed LUD-21 settlement receipt |
|
|
126
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 |
|
|
127
132
|
|
|
128
133
|
As a library, for your own test suite:
|
package/llms.txt
CHANGED
|
@@ -54,6 +54,8 @@ payRequest, the same on the mint address document, and the same echoed on
|
|
|
54
54
|
the pay callback's own response when THAT quote was bound.
|
|
55
55
|
mintToHashAdvertisedOn=payRequest,mintAddress,quote narrows which of the
|
|
56
56
|
three claim it (all three by default; it changes only what is claimed).
|
|
57
|
+
mintReceipt=true adds mint:{h,amount} to an honestly bound quote and adds
|
|
58
|
+
sig only to its settled LUD-21 response; it requires mintToHash.
|
|
57
59
|
Misbehaviours, each needing mintToHash as well: mintToHashAcceptsMalformedH,
|
|
58
60
|
mintToHashAcceptsUsedH, mintToHashIgnoresH.
|
|
59
61
|
|
|
@@ -133,6 +135,14 @@ withdraw callback gives, so no oracle appears. A WALLET MUST persist its
|
|
|
133
135
|
secret BEFORE asking for the invoice. Purely additive: without h, nothing
|
|
134
136
|
changes. Table in mint-to-hash.json.
|
|
135
137
|
|
|
138
|
+
For a sealed signer, mint-to-hash may add a bound settlement receipt. The
|
|
139
|
+
quote carries mint:{h,amount}, where amount is exact net msat and no sig is
|
|
140
|
+
allowed. Settled LUD-21 repeats the same h/amount and adds the ordinary
|
|
141
|
+
LUD-25 signature; the wallet matches pr/h/amount and verifies the signature
|
|
142
|
+
before PENDING -> CONFIRMED. Absence of mint is compatible and falls back to
|
|
143
|
+
the legacy preimage/import/rotate flow. Full proposed wire text and matrix:
|
|
144
|
+
docs/BOUND-MINT-RECEIPTS.md.
|
|
145
|
+
|
|
136
146
|
## The retried mutation
|
|
137
147
|
|
|
138
148
|
Every mutation is a GET, and HTTP stacks retry a GET on a dropped
|
package/mock-mint/index.d.ts
CHANGED
|
@@ -157,6 +157,12 @@ export interface MockMintOptions {
|
|
|
157
157
|
* two. A wallet still sends lowercase.
|
|
158
158
|
*/
|
|
159
159
|
mintToHash?: boolean
|
|
160
|
+
/**
|
|
161
|
+
* Emit the optional mint:{h,amount} quote commitment and add the normal
|
|
162
|
+
* note signature on settled LUD-21 verification. Requires mintToHash,
|
|
163
|
+
* verify and signatures. Off by default for baseline compatibility.
|
|
164
|
+
*/
|
|
165
|
+
mintReceipt?: boolean
|
|
160
166
|
/**
|
|
161
167
|
* non-compliant, and only reachable with mintToHash on: issue an
|
|
162
168
|
* invoice for an `h` that is not 64 lowercase hex, so a wallet pays for
|
|
@@ -220,6 +226,8 @@ export interface MockMintState {
|
|
|
220
226
|
settled: boolean
|
|
221
227
|
/** the output id this quote was bound to, when the wallet named one */
|
|
222
228
|
boundTo?: string
|
|
229
|
+
/** the exact invoice returned on the quote, for LUD-21 binding */
|
|
230
|
+
pr?: string
|
|
223
231
|
}
|
|
224
232
|
>
|
|
225
233
|
pubkey: string
|
package/mock-mint/index.mjs
CHANGED
|
@@ -187,6 +187,10 @@ const DEFAULTS = {
|
|
|
187
187
|
// which is the one that matters at the moment money moves: the other
|
|
188
188
|
// two can be cached or stale.
|
|
189
189
|
mintToHash: false,
|
|
190
|
+
// Add the optional bound LUD-21 receipt to an honestly bound quote and
|
|
191
|
+
// its verify response. Requires mintToHash, verify and signatures; off
|
|
192
|
+
// by default so the baseline mock remains the current LUD-25 wire.
|
|
193
|
+
mintReceipt: false,
|
|
190
194
|
// non-compliant, and only reachable with mintToHash on: issue an
|
|
191
195
|
// invoice for an `h` that is not 64 lowercase hex, so a wallet pays for
|
|
192
196
|
// a quote this mint was always going to refuse
|
|
@@ -207,7 +211,30 @@ const DEFAULTS = {
|
|
|
207
211
|
// 'payRequest,mintAddress' is the one that binds without confirming at
|
|
208
212
|
// the moment money moves. Read only when mintToHash is on. Accepts an
|
|
209
213
|
// array or a comma-separated string, so the CLI can pass one.
|
|
210
|
-
mintToHashAdvertisedOn: undefined
|
|
214
|
+
mintToHashAdvertisedOn: undefined,
|
|
215
|
+
|
|
216
|
+
// The SAME capability in the spelling LUD-25 actually specifies:
|
|
217
|
+
// `comment = hex(sha256(secret))` (LUD-12), advertised as a
|
|
218
|
+
// `commentAllowed` of at least 64. A number advertises that many
|
|
219
|
+
// characters and reads `comment` as the output name; false says
|
|
220
|
+
// nothing and reads nothing. Independent of mintToHash - a mint may
|
|
221
|
+
// ship either, both, or neither.
|
|
222
|
+
//
|
|
223
|
+
// The two spellings are not symmetric, and that is the whole reason
|
|
224
|
+
// both exist here. `h` is a parameter invented for naming, so a
|
|
225
|
+
// malformed one MUST be refused before an invoice exists. `comment` is
|
|
226
|
+
// plain LUD-12 free text any wallet may send for unrelated reasons, so
|
|
227
|
+
// LUD-25 requires the opposite: fall back to crediting k1=P, never
|
|
228
|
+
// refuse - and MUST NOT serve verify on that fallback, where P is not
|
|
229
|
+
// proof of payment but the note itself.
|
|
230
|
+
commentAllowed: false,
|
|
231
|
+
// non-compliant, commentAllowed on: refuse a comment that is not a
|
|
232
|
+
// bare 32-byte hex hash instead of falling back, so an ordinary LUD-12
|
|
233
|
+
// wallet cannot pay this mint at all
|
|
234
|
+
commentRefusesMalformed: false,
|
|
235
|
+
// non-compliant, commentAllowed on: serve LUD-21 verify even on the
|
|
236
|
+
// no-comment fallback, where the preimage it hands out IS the note
|
|
237
|
+
verifyOnUnnamedMint: false
|
|
211
238
|
}
|
|
212
239
|
|
|
213
240
|
export const createMockMint = async (options = {}) => {
|
|
@@ -461,7 +488,19 @@ export const createMockMint = async (options = {}) => {
|
|
|
461
488
|
// one a wallet should decide from. Spread in last and only when
|
|
462
489
|
// the option is on, so a mock started with no options answers
|
|
463
490
|
// exactly what it always answered.
|
|
464
|
-
...(mintToHashPlaces.has('payRequest') ? {mintToHash: true} : {})
|
|
491
|
+
...(mintToHashPlaces.has('payRequest') ? {mintToHash: true} : {}),
|
|
492
|
+
// LUD-25: "A mint payLink intending to support this SHOULD
|
|
493
|
+
// advertise a commentAllowed of at least 64". A payRequest field
|
|
494
|
+
// only - the mint address document is a withdrawRequest, where a
|
|
495
|
+
// LUD-12 comment has nowhere to go.
|
|
496
|
+
...(opts.commentAllowed ? {commentAllowed: opts.commentAllowed} : {}),
|
|
497
|
+
// A receipt verifier needs the signing key before payment. The
|
|
498
|
+
// baseline mock remains byte-for-byte unchanged when receipts are
|
|
499
|
+
// off; a real node-key signer can alternatively be recovered from
|
|
500
|
+
// the BOLT-11 invoice itself.
|
|
501
|
+
...(opts.mintReceipt && opts.verify && opts.signatures
|
|
502
|
+
? {mintPubkey: pubkey}
|
|
503
|
+
: {})
|
|
465
504
|
})
|
|
466
505
|
}
|
|
467
506
|
|
|
@@ -626,20 +665,67 @@ export const createMockMint = async (options = {}) => {
|
|
|
626
665
|
}
|
|
627
666
|
}
|
|
628
667
|
|
|
668
|
+
// The LUD-25 spelling. Read after `h` so a mock offering both lets
|
|
669
|
+
// an explicit comment name the output, and deliberately NOT an
|
|
670
|
+
// else-branch: a mint may ship either spelling or both.
|
|
671
|
+
let namedByComment = false
|
|
672
|
+
if (opts.commentAllowed) {
|
|
673
|
+
const sent = q.get('comment')
|
|
674
|
+
if (sent !== null && sent !== '') {
|
|
675
|
+
const wellFormed = /^[0-9a-f]{64}$/i.test(sent)
|
|
676
|
+
if (wellFormed) {
|
|
677
|
+
const h = sent.toLowerCase()
|
|
678
|
+
// Same collision rule the `h` spelling gets: an id already
|
|
679
|
+
// spoken for must never be minted over, whichever parameter
|
|
680
|
+
// named it.
|
|
681
|
+
if (outputIdInUse(h) && !opts.mintToHashAcceptsUsedH) {
|
|
682
|
+
return fail('Invalid or already spent k1.')
|
|
683
|
+
}
|
|
684
|
+
if (!opts.mintToHashIgnoresH) {
|
|
685
|
+
boundTo = h
|
|
686
|
+
namedByComment = true
|
|
687
|
+
}
|
|
688
|
+
} else if (opts.commentRefusesMalformed) {
|
|
689
|
+
// The non-compliant branch. LUD-25 says a comment that is not
|
|
690
|
+
// a bare hash MUST fall back, because plain LUD-12 comments
|
|
691
|
+
// are free text: refusing one turns an ordinary "thanks!"
|
|
692
|
+
// into a mint that cannot be paid.
|
|
693
|
+
return fail('Invalid comment.')
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
629
698
|
const preimage = bytesToHex(randomBytes(32))
|
|
630
699
|
const paymentHash = noteId(preimage)
|
|
631
|
-
const
|
|
700
|
+
const pr = fakeInvoice(amount, preimage)
|
|
701
|
+
const invoice = {amountMsat: net, preimage, settled: false, pr}
|
|
632
702
|
if (boundTo) {
|
|
633
703
|
invoice.boundTo = boundTo
|
|
634
704
|
boundOutputs.set(boundTo, paymentHash)
|
|
635
705
|
}
|
|
636
706
|
invoices.set(paymentHash, invoice)
|
|
637
|
-
const body = {pr
|
|
638
|
-
|
|
707
|
+
const body = {pr, disposable: false}
|
|
708
|
+
// LUD-25 gates verify on whether the note was named: in the
|
|
709
|
+
// no-comment fallback the preimage verify would hand out IS the
|
|
710
|
+
// note's entire bearer secret, so a mint offering the comment
|
|
711
|
+
// spelling must withhold verify from quotes that used it. A mock
|
|
712
|
+
// that never heard of the comment spelling keeps answering exactly
|
|
713
|
+
// as it always did.
|
|
714
|
+
const verifySafe = !opts.commentAllowed || namedByComment || opts.verifyOnUnnamedMint
|
|
715
|
+
if (opts.verify && verifySafe) body.verify = `${origin}/verify/${paymentHash}`
|
|
639
716
|
// Appended last, and only when the quote really was bound, so a
|
|
640
717
|
// mock that was never told about any of this answers byte for byte
|
|
641
718
|
// what it always answered.
|
|
642
719
|
if (echoBound) body.mintToHash = true
|
|
720
|
+
if (
|
|
721
|
+
echoBound &&
|
|
722
|
+
boundTo &&
|
|
723
|
+
opts.mintReceipt &&
|
|
724
|
+
opts.verify &&
|
|
725
|
+
opts.signatures
|
|
726
|
+
) {
|
|
727
|
+
body.mint = {h: boundTo, amount: net}
|
|
728
|
+
}
|
|
643
729
|
return send(body)
|
|
644
730
|
}
|
|
645
731
|
|
|
@@ -652,7 +738,7 @@ export const createMockMint = async (options = {}) => {
|
|
|
652
738
|
}
|
|
653
739
|
const invoice = invoices.get(verifyMatch[1].toLowerCase())
|
|
654
740
|
if (!invoice) return fail('Unknown payment hash.')
|
|
655
|
-
|
|
741
|
+
const body = {
|
|
656
742
|
status: 'OK',
|
|
657
743
|
settled: invoice.settled,
|
|
658
744
|
// the preimage IS the bearer secret here - a real SERVICE should
|
|
@@ -661,8 +747,18 @@ export const createMockMint = async (options = {}) => {
|
|
|
661
747
|
// bound with its own h: that note is credited elsewhere, so the
|
|
662
748
|
// preimage is an ordinary payment proof and leaks nothing.
|
|
663
749
|
preimage: invoice.settled || opts.verifyLeaksEarly ? invoice.preimage : null,
|
|
664
|
-
pr: fakeInvoice(invoice.amountMsat, invoice.preimage)
|
|
665
|
-
}
|
|
750
|
+
pr: invoice.pr ?? fakeInvoice(invoice.amountMsat, invoice.preimage)
|
|
751
|
+
}
|
|
752
|
+
if (invoice.boundTo && opts.mintReceipt && opts.signatures) {
|
|
753
|
+
body.mint = {
|
|
754
|
+
h: invoice.boundTo,
|
|
755
|
+
amount: invoice.amountMsat,
|
|
756
|
+
...(invoice.settled
|
|
757
|
+
? {sig: sign(invoice.boundTo, invoice.amountMsat)}
|
|
758
|
+
: {})
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return send(body)
|
|
666
762
|
}
|
|
667
763
|
|
|
668
764
|
// ---- LUD-03 informational GET ----
|
|
@@ -776,7 +872,8 @@ export const createMockMint = async (options = {}) => {
|
|
|
776
872
|
invoices.set(paymentHash, {
|
|
777
873
|
amountMsat: note.amountMsat,
|
|
778
874
|
preimage: meltPreimage,
|
|
779
|
-
settled: false
|
|
875
|
+
settled: false,
|
|
876
|
+
pr
|
|
780
877
|
})
|
|
781
878
|
}
|
|
782
879
|
if (!opts.meltNeverSettles) {
|
package/package.json
CHANGED
package/runner/index.mjs
CHANGED
|
@@ -89,7 +89,17 @@ const isNpub = value => {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
const
|
|
92
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
93
|
+
|
|
94
|
+
// A mint may rate limit, and the good ones do: every mint quote issues a
|
|
95
|
+
// real invoice on a real node, so a grader firing a dozen of them looks
|
|
96
|
+
// exactly like the abuse the limiter exists to stop. An HTTP 429 is the
|
|
97
|
+
// grade failing to complete, NOT a verdict on the mint, so it is waited
|
|
98
|
+
// out (honouring Retry-After) and retried. Without this the mint is
|
|
99
|
+
// accused of whatever the check happened to be probing when the bucket
|
|
100
|
+
// ran dry - a conforming mint graded as broken, which is worse than no
|
|
101
|
+
// grade at all.
|
|
102
|
+
const get = async (url, timeoutMs = 15_000, retriesLeft = 2) => {
|
|
93
103
|
const res = await fetch(url.toString(), {signal: AbortSignal.timeout(timeoutMs)})
|
|
94
104
|
const text = await res.text()
|
|
95
105
|
let body
|
|
@@ -98,6 +108,17 @@ const get = async (url, timeoutMs = 15_000) => {
|
|
|
98
108
|
} catch {
|
|
99
109
|
throw new Error(`response was not JSON: ${text.slice(0, 120)}`)
|
|
100
110
|
}
|
|
111
|
+
if (res.status === 429) {
|
|
112
|
+
if (retriesLeft > 0) {
|
|
113
|
+
const after = Number(res.headers.get('retry-after'))
|
|
114
|
+
const waitMs = Math.min(Number.isFinite(after) && after > 0 ? after * 1000 : 10_000, 30_000)
|
|
115
|
+
await sleep(waitMs + 250)
|
|
116
|
+
return get(url, timeoutMs, retriesLeft - 1)
|
|
117
|
+
}
|
|
118
|
+
throw soft(
|
|
119
|
+
`the mint rate limited this grade (HTTP 429${body?.reason ? `: ${body.reason}` : ''}) - space the run out and grade again; nothing here is a verdict on the mint`
|
|
120
|
+
)
|
|
121
|
+
}
|
|
101
122
|
return body
|
|
102
123
|
}
|
|
103
124
|
|
|
@@ -449,119 +470,186 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
449
470
|
return detail
|
|
450
471
|
})
|
|
451
472
|
|
|
452
|
-
// Naming the note you are buying.
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
473
|
+
// Naming the note you are buying. No longer outside LUD-25: the draft
|
|
474
|
+
// spells it `comment = hex(sha256(secret))` (Protecting a freshly minted
|
|
475
|
+
// note from a preimage race), advertised as a `commentAllowed` of at
|
|
476
|
+
// least 64 - enough for a hex-encoded 32-byte hash. `mintToHash` is the
|
|
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
|
|
456
486
|
// capability, because a wallet then stops rotating on sight and trusts
|
|
457
487
|
// the mint to bind the note.
|
|
458
488
|
//
|
|
459
|
-
// The claim lives in three places
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
|
|
489
|
+
// The claim lives in three places. The payRequest is the one to decide
|
|
490
|
+
// from, since every mint publishes one while the mint address document
|
|
491
|
+
// is experimental; the mint address repeats it as corroboration; and the
|
|
492
|
+
// pay callback's own response echoes `mintToHash` when THAT quote was
|
|
493
|
+
// bound. LUD-25 defines no echo for the comment spelling, so a missing
|
|
494
|
+
// echo is only held against a mint claiming `mintToHash`.
|
|
495
|
+
//
|
|
496
|
+
// The two spellings are NOT symmetric, and that is why this cannot be
|
|
497
|
+
// one probe with two parameter names:
|
|
498
|
+
//
|
|
499
|
+
// `h` is a parameter invented for exactly this purpose, so a malformed
|
|
500
|
+
// one is a wallet error and MUST be refused before an invoice exists.
|
|
501
|
+
//
|
|
502
|
+
// `comment` is plain LUD-12 free text that any wallet may send for
|
|
503
|
+
// unrelated reasons, so LUD-25 requires the opposite (line 80 of the
|
|
504
|
+
// draft): a comment that is not a bare hex-encoded 32-byte hash MUST
|
|
505
|
+
// fall back to crediting the note as k1=P, never be refused - and on
|
|
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 () => {
|
|
468
509
|
const amount = Math.max(pay.minSendable, 1000)
|
|
469
|
-
|
|
510
|
+
|
|
511
|
+
// LUD-25: "A mint payLink intending to support this SHOULD advertise a
|
|
512
|
+
// commentAllowed of at least 64". Anything shorter cannot carry the
|
|
513
|
+
// hash at all, so it is not this capability whatever else it is.
|
|
514
|
+
const spellingsOf = source => {
|
|
515
|
+
const found = []
|
|
516
|
+
if (source?.mintToHash === true) found.push('h')
|
|
517
|
+
if (Number.isFinite(source?.commentAllowed) && source.commentAllowed >= 64) found.push('comment')
|
|
518
|
+
return found
|
|
519
|
+
}
|
|
520
|
+
const quoteAt = async (spelling, value, msat = amount) => {
|
|
470
521
|
const url = new URL(pay.callback)
|
|
471
522
|
url.searchParams.set('amount', String(msat))
|
|
472
|
-
url.searchParams.set(
|
|
523
|
+
url.searchParams.set(spelling, value)
|
|
473
524
|
return get(url)
|
|
474
525
|
}
|
|
526
|
+
const spelt = spelling => (spelling === 'comment' ? 'a LUD-12 comment' : 'an h parameter')
|
|
527
|
+
|
|
528
|
+
const advertised = spellingsOf(pay)
|
|
529
|
+
const corroborated = spellingsOf(mintAddress)
|
|
530
|
+
const claimed = [...new Set([...advertised, ...corroborated])]
|
|
475
531
|
|
|
476
532
|
// Asked of every mint, advertisement or not: a mint that echoes the
|
|
477
533
|
// capability without publishing it is still claiming it, and a wallet
|
|
478
534
|
// reading the echo would believe it. Nothing pays the invoice that
|
|
479
535
|
// comes back, so this is read-only - an unpaid quote costs a mint an
|
|
480
536
|
// invoice and nothing else.
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
537
|
+
// A fresh secret per spelling, never one shared between them: a mint
|
|
538
|
+
// that accepts both and binds an output id uniquely - as it should,
|
|
539
|
+
// and as the repeat probe below asserts - would rightly refuse the
|
|
540
|
+
// second spelling for naming an output the first just took.
|
|
541
|
+
const probed = claimed.length > 0 ? claimed : ['h']
|
|
542
|
+
const named = Object.fromEntries(
|
|
543
|
+
probed.map(spelling => {
|
|
544
|
+
const secret = bytesToHex(randomBytes(32))
|
|
545
|
+
return [spelling, {secret, h: noteId(secret)}]
|
|
546
|
+
})
|
|
547
|
+
)
|
|
548
|
+
const bound = {}
|
|
549
|
+
for (const spelling of probed) bound[spelling] = await quoteAt(spelling, named[spelling].h)
|
|
550
|
+
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')
|
|
489
554
|
throw soft(
|
|
490
|
-
|
|
491
|
-
? `not offered, and
|
|
555
|
+
refused
|
|
556
|
+
? `not offered, and a named output was refused outright: ${refused.reason}`
|
|
492
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'
|
|
493
558
|
)
|
|
494
559
|
}
|
|
495
560
|
|
|
496
561
|
const problems = []
|
|
497
562
|
const claimedBy = [
|
|
498
|
-
advertised &&
|
|
499
|
-
corroborated &&
|
|
500
|
-
|
|
563
|
+
advertised.length > 0 && `the payRequest (${advertised.join(', ')})`,
|
|
564
|
+
corroborated.length > 0 && `the mint address (${corroborated.join(', ')})`,
|
|
565
|
+
echoedIn.length > 0 && 'the quote itself'
|
|
501
566
|
].filter(Boolean)
|
|
502
567
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
568
|
+
for (const spelling of probed) {
|
|
569
|
+
const body = bound[spelling]
|
|
570
|
+
assert(
|
|
571
|
+
body.status !== 'ERROR',
|
|
572
|
+
`claims this capability (${claimedBy.join(', ')}) and refused a well-formed hash sent as ${spelt(spelling)}: ${body.reason}`
|
|
573
|
+
)
|
|
574
|
+
assert(typeof body.pr === 'string', `no pr in the response to ${spelt(spelling)}`)
|
|
575
|
+
const invoiced = invoiceAmountMsat(body.pr)
|
|
576
|
+
if (invoiced !== null) {
|
|
577
|
+
assert(invoiced === amount, `asked for ${amount} msat, invoiced ${invoiced} msat`)
|
|
578
|
+
}
|
|
511
579
|
}
|
|
512
580
|
|
|
513
581
|
// A quote is not a note. Crediting one before its invoice settles
|
|
514
582
|
// would hand out money for nothing.
|
|
515
583
|
const withdrawUrl = pay.withdrawLink ? fromLud17(pay.withdrawLink) : null
|
|
516
584
|
if (withdrawUrl) {
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
585
|
+
for (const spelling of probed) {
|
|
586
|
+
const probe = new URL(withdrawUrl)
|
|
587
|
+
probe.searchParams.set('k1', named[spelling].secret)
|
|
588
|
+
const early = await get(probe)
|
|
589
|
+
assert(
|
|
590
|
+
early.status === 'ERROR',
|
|
591
|
+
'the note exists before anything paid for it - a quote is not a note'
|
|
592
|
+
)
|
|
593
|
+
}
|
|
524
594
|
}
|
|
525
595
|
|
|
526
|
-
// A malformed h must be refused BEFORE an invoice exists. A wallet
|
|
527
|
-
// that pays a quote the mint was always going to reject has bought
|
|
528
|
-
// nothing, and the mint keeps the sats.
|
|
529
|
-
//
|
|
530
596
|
// Malformed means not 32 bytes of hex, in any casing. Upper case is a
|
|
531
597
|
// spelling, not a defect, and it is deliberately not probed: a WALLET
|
|
532
598
|
// MUST send lowercase and every client here does, a SERVICE SHOULD
|
|
533
599
|
// normalise before comparing, and one that refuses upper case outright
|
|
534
600
|
// is strict rather than wrong - the wallet learns before it pays.
|
|
535
|
-
|
|
601
|
+
const malformed = [
|
|
536
602
|
['not hex', 'z'.repeat(64)],
|
|
537
603
|
['a character short', '0'.repeat(63)],
|
|
538
604
|
['a character long', '0'.repeat(65)],
|
|
539
605
|
['empty', '']
|
|
540
|
-
]
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
606
|
+
]
|
|
607
|
+
|
|
608
|
+
for (const spelling of probed) {
|
|
609
|
+
for (const [what, value] of malformed) {
|
|
610
|
+
const body = await quoteAt(spelling, value)
|
|
611
|
+
if (spelling === 'h') {
|
|
612
|
+
// A wallet that pays a quote the mint was always going to reject
|
|
613
|
+
// has bought nothing, and the mint keeps the sats.
|
|
614
|
+
assert(
|
|
615
|
+
body.status === 'ERROR' && !body.pr,
|
|
616
|
+
`issued an invoice for an h that is ${what} - a wallet would pay for a quote this mint cannot honour`
|
|
617
|
+
)
|
|
618
|
+
continue
|
|
619
|
+
}
|
|
620
|
+
// The comment spelling, where the draft says the opposite.
|
|
621
|
+
assert(
|
|
622
|
+
body.status !== 'ERROR',
|
|
623
|
+
`refused a comment that is ${what} (${body.reason}) - LUD-25 requires the no-name fallback here, because plain LUD-12 comments are free text and any wallet may send one`
|
|
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`
|
|
628
|
+
)
|
|
629
|
+
}
|
|
546
630
|
}
|
|
547
631
|
|
|
548
|
-
// The
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
if (
|
|
632
|
+
// The claims must agree. None of these disagreements loses anyone
|
|
633
|
+
// money on its own - a wallet reading a missing field as false falls
|
|
634
|
+
// back to the preimage flow, which is safe - so each is named rather
|
|
635
|
+
// than failed. Whether the mint really binds is the one thing this
|
|
636
|
+
// check cannot see, because that needs a settlement: it is graded
|
|
637
|
+
// separately, and failed rather than warned.
|
|
638
|
+
if (advertised.includes('h') && echoedIn.length === 0) {
|
|
555
639
|
problems.push(
|
|
556
640
|
'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'
|
|
557
641
|
)
|
|
558
642
|
}
|
|
559
|
-
if (
|
|
643
|
+
if (echoedIn.length > 0 && advertised.length === 0) {
|
|
560
644
|
problems.push(
|
|
561
645
|
'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'
|
|
562
646
|
)
|
|
563
647
|
}
|
|
564
|
-
|
|
648
|
+
// Only the `mintToHash` spelling belongs in both documents. LUD-25 asks
|
|
649
|
+
// a mint *payLink* to advertise `commentAllowed`, and the mint address
|
|
650
|
+
// document is a withdrawRequest, where a LUD-12 comment has nowhere to
|
|
651
|
+
// go - its absence there is correct, not a disagreement.
|
|
652
|
+
if (advertised.includes('h') && mintAddress && !corroborated.includes('h')) {
|
|
565
653
|
problems.push('the payRequest advertises mintToHash and the mint address document does not')
|
|
566
654
|
}
|
|
567
655
|
|
|
@@ -573,15 +661,17 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
573
661
|
// rule the withdraw callback already enforces.
|
|
574
662
|
const otherAmount = Math.min(pay.maxSendable, amount * 2)
|
|
575
663
|
if (otherAmount !== amount) {
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
664
|
+
for (const spelling of probed) {
|
|
665
|
+
const twice = await quoteAt(spelling, named[spelling].h, otherAmount)
|
|
666
|
+
if (twice.status !== 'ERROR') {
|
|
667
|
+
problems.push(
|
|
668
|
+
`issued a second quote against an output already named by ${spelt(spelling)} - whichever payment settles first takes the id, and the other payer has bought nothing`
|
|
669
|
+
)
|
|
670
|
+
}
|
|
581
671
|
}
|
|
582
672
|
}
|
|
583
673
|
if (problems.length > 0) throw soft(problems.join('; '))
|
|
584
|
-
return `claimed by ${claimedBy.join(', ')}; bound a quote to a hash of the runner's own secret and
|
|
674
|
+
return `named by ${probed.join(' and ')}; claimed by ${claimedBy.join(', ')}; bound a quote to a hash of the runner's own secret and handled four malformed ones as the draft requires`
|
|
585
675
|
})
|
|
586
676
|
|
|
587
677
|
// The payRequest, with the mint address the checks above fetched hung
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"noteId": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
199
199
|
"k1": "0505050505050505050505050505050505050505050505050505050505050505",
|
|
200
200
|
"preimageIsAValidK1": false,
|
|
201
|
-
"note": "
|
|
201
|
+
"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
202
|
},
|
|
203
203
|
"unbound": {
|
|
204
204
|
"noteId": "e802086ad6a1e16b78352ad7296d2aabd835b1b16dbe951e1135b97c68e29d81",
|
|
@@ -207,6 +207,108 @@
|
|
|
207
207
|
"note": "the wallet must poll verify for the preimage, and rotate the instant it has it"
|
|
208
208
|
}
|
|
209
209
|
},
|
|
210
|
+
"receipt": {
|
|
211
|
+
"description": "An optional LUD-21 settlement receipt for a bound quote. This is needed by a sealed signer that generated k1 but will not export it merely so its companion app can probe the withdraw endpoint. The quote commits to the exact output id and net amount before payment. Once settled, verify repeats that commitment and adds the ordinary LUD-25 note signature. No field changes the legacy LUD-21 meaning of preimage: it remains payment proof and still does not open the bound note.",
|
|
212
|
+
"optional": true,
|
|
213
|
+
"field": "mint",
|
|
214
|
+
"keyEstablishment": {
|
|
215
|
+
"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
|
+
"payRequest": {
|
|
217
|
+
"mintToHash": true,
|
|
218
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
"commitment": {
|
|
222
|
+
"h": "the normalised output id committed by this quote; 32 bytes as 64 lowercase hex",
|
|
223
|
+
"amount": "the exact net note value in millisatoshis after fees",
|
|
224
|
+
"sig": "absent before settlement; after settlement, the ordinary recoverable LUD-25 signature over LNURLcash:<amount>:<h>"
|
|
225
|
+
},
|
|
226
|
+
"quote": {
|
|
227
|
+
"pr": "lnbc210n1pjqrstuvwxyz",
|
|
228
|
+
"verify": "https://mint.example/verify/e802086ad6a1e16b78352ad7296d2aabd835b1b16dbe951e1135b97c68e29d81",
|
|
229
|
+
"mintToHash": true,
|
|
230
|
+
"mint": {
|
|
231
|
+
"h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
232
|
+
"amount": 21000
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
"unsettled": {
|
|
236
|
+
"status": "OK",
|
|
237
|
+
"settled": false,
|
|
238
|
+
"preimage": null,
|
|
239
|
+
"pr": "lnbc210n1pjqrstuvwxyz",
|
|
240
|
+
"mint": {
|
|
241
|
+
"h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
242
|
+
"amount": 21000
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
"settled": {
|
|
246
|
+
"status": "OK",
|
|
247
|
+
"settled": true,
|
|
248
|
+
"preimage": "0606060606060606060606060606060606060606060606060606060606060606",
|
|
249
|
+
"pr": "lnbc210n1pjqrstuvwxyz",
|
|
250
|
+
"mint": {
|
|
251
|
+
"h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
252
|
+
"amount": 21000,
|
|
253
|
+
"sig": "5559e4ad39ea32a9f8dba641cc69e2874e962fd569cd661b5278ccd13f647d6e74bce7105178f3817f1938cee81765cc3ab9005a5821c5d2228c2003418542ea01"
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
"walletRules": [
|
|
257
|
+
"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
|
+
"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
|
+
"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 can still use mintToHash and claim with its own k1; a sealed signer falls back before payment to the legacy preimage-and-rotate flow."
|
|
261
|
+
],
|
|
262
|
+
"invalid": [
|
|
263
|
+
{
|
|
264
|
+
"name": "quote commits a different h",
|
|
265
|
+
"quote": {
|
|
266
|
+
"mintToHash": true,
|
|
267
|
+
"mint": {
|
|
268
|
+
"h": "e0e77a507412b120f6ede61f62295b1a7b2ff19d3dcc8f7253e51663470c888e",
|
|
269
|
+
"amount": 21000
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
"reason": "the invoice is not demonstrably buying the output the wallet named"
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"name": "verify changes the net amount",
|
|
276
|
+
"verify": {
|
|
277
|
+
"settled": true,
|
|
278
|
+
"mint": {
|
|
279
|
+
"h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
280
|
+
"amount": 20999,
|
|
281
|
+
"sig": "5559e4ad39ea32a9f8dba641cc69e2874e962fd569cd661b5278ccd13f647d6e74bce7105178f3817f1938cee81765cc3ab9005a5821c5d2228c2003418542ea01"
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
"reason": "the settled receipt is not the commitment shown before payment"
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
"name": "signature appears before settlement",
|
|
288
|
+
"verify": {
|
|
289
|
+
"settled": false,
|
|
290
|
+
"mint": {
|
|
291
|
+
"h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
292
|
+
"amount": 21000,
|
|
293
|
+
"sig": "5559e4ad39ea32a9f8dba641cc69e2874e962fd569cd661b5278ccd13f647d6e74bce7105178f3817f1938cee81765cc3ab9005a5821c5d2228c2003418542ea01"
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
"reason": "a note signature is evidence of minted value and cannot be issued speculatively"
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
"name": "settled response has the wrong signature",
|
|
300
|
+
"verify": {
|
|
301
|
+
"settled": true,
|
|
302
|
+
"mint": {
|
|
303
|
+
"h": "f849d67325facf04177bc663b2dc544051831c589ef581d412f2eba44834e77c",
|
|
304
|
+
"amount": 21000,
|
|
305
|
+
"sig": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
"reason": "the signer cannot confirm a note the mint has not authenticated"
|
|
309
|
+
}
|
|
310
|
+
]
|
|
311
|
+
},
|
|
210
312
|
"normalisation": {
|
|
211
313
|
"description": "One worked pair, so an implementation can check that the two spellings name one output rather than two.",
|
|
212
314
|
"sent": "F849D67325FACF04177BC663B2DC544051831C589EF581D412F2EBA44834E77C",
|
|
@@ -441,7 +543,7 @@
|
|
|
441
543
|
"A WALLET MUST send `h` as 64 lowercase hex. A SERVICE SHOULD normalise case before comparing and MUST NOT read the two spellings as two outputs, but a wallet that leans on that will meet a SERVICE that refuses upper case outright.",
|
|
442
544
|
"A WALLET MUST decide from the payRequest, which every SERVICE publishes, rather than from the experimental mint address document, which many do not. A SERVICE that does not advertise `mintToHash` ignores `h`, and the note it mints is the preimage's.",
|
|
443
545
|
"A WALLET MUST check the pay callback's own `mintToHash` before it pays. The other two advertisements can be cached or stale; that one is this quote, now. Absent means not bound, so claim the preimage way and rotate on sight.",
|
|
444
|
-
"Against a SERVICE that
|
|
546
|
+
"Against a SERVICE that advertises mintToHash, a software WALLET needs no verify poll to claim: it knows its own secret, so it asks the withdraw endpoint for it directly. A sealed signer that will not export k1 MAY require the optional bound receipt and use verify only as authenticated settlement evidence.",
|
|
445
547
|
"A note minted at a WALLET-chosen hash is the WALLET's from birth. A wallet deriving its secrets from a seed can restore that note from the seed alone, which a preimage-secret note can never be."
|
|
446
548
|
]
|
|
447
549
|
}
|