lnurlcash-conformance 0.1.1 → 0.1.2
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 +65 -0
- package/README.md +29 -5
- package/llms.txt +3 -1
- package/mock-mint/index.d.ts +16 -0
- package/mock-mint/index.mjs +30 -6
- package/package.json +1 -1
- package/runner/index.mjs +104 -20
- package/vectors/index.json +2 -1
- package/vectors/pay-request.json +28 -0
- package/vectors/threat-suite.json +327 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,71 @@ 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.1.2 - 2026-08-21
|
|
8
|
+
|
|
9
|
+
- The minted-value check took a band instead of a single number, because
|
|
10
|
+
it was failing the reference implementation.
|
|
11
|
+
- LUD-25 states the mint fee as `base_fee_msat` plus a ppm cut and says
|
|
12
|
+
nothing about rounding. dni's lnurl-mint ceilings that fee to a whole
|
|
13
|
+
sat on purpose, so the mint is "never short a sat"; moneyer is
|
|
14
|
+
msat-exact. Every public mint on the awesome list except moneyer runs
|
|
15
|
+
lnurl-mint, so the majority of live services round.
|
|
16
|
+
- The check asserted equality against the msat-exact formula, and even
|
|
17
|
+
named the rounding in its failure message as something "the formula
|
|
18
|
+
does not allow". Measured on real sats: 40,000 msat at
|
|
19
|
+
mint.forgesworn.dev with a 1000 + 1000 ppm fee credits 38,000, not
|
|
20
|
+
38,960. A clean grade was unreachable for the reference.
|
|
21
|
+
- Deciding which reading is right is not this repo's job - "either may
|
|
22
|
+
be wrong, and the LUD-25 PR is where that gets settled". So the
|
|
23
|
+
compliant answer is now the range between the two: the formula is the
|
|
24
|
+
most a holder can be credited, the sat-ceilinged fee the least. The
|
|
25
|
+
report names which one it saw.
|
|
26
|
+
- Both edges are graded, and selfgrade proves it: a msat past the
|
|
27
|
+
ceilinged fee fails, and so does crediting more than the formula. A
|
|
28
|
+
band with no edges would grade nothing. The mock's `roundFeeToSat` is
|
|
29
|
+
reclassified from a misbehaviour to the reference's behaviour, and
|
|
30
|
+
gains `extraFeeMsat` for landing outside the band deliberately.
|
|
31
|
+
|
|
32
|
+
- Two refusals LUD-25 spells out were never graded. Both are reachable
|
|
33
|
+
against a live mint, and both were being stepped around rather than
|
|
34
|
+
tested.
|
|
35
|
+
- `refuses a split with no h2`: a split names two outputs, so a mint
|
|
36
|
+
given only `h` either refuses or invents the change note's secret
|
|
37
|
+
itself. The second is the exact prior-holder exposure wallet-generated
|
|
38
|
+
secrets exist to close, and it was ungraded.
|
|
39
|
+
- `refuses a split whose change cannot cover the base fee`: the grader
|
|
40
|
+
already knew the advertised base fee, and skipped (`note too small to
|
|
41
|
+
split past the advertised base fee`) rather than deliberately leaving
|
|
42
|
+
change one msat short of it. It now does that on purpose and expects
|
|
43
|
+
`insufficient value`. Warns when no fee is advertised, since the rule
|
|
44
|
+
cannot bite.
|
|
45
|
+
- Both confirm the note survives the refusal, and both are self-verified:
|
|
46
|
+
the mock gains `acceptsMissingH2` and `splitIgnoresBaseFee`, and
|
|
47
|
+
selfgrade asserts each is caught by name rather than by failure count.
|
|
48
|
+
- The grader never melts, so `pending`, restore-on-failure and a melt's own
|
|
49
|
+
LUD-21 `verify` cannot be graded against a live service - melting real
|
|
50
|
+
sats is not something a grader may do on its own initiative. The mock
|
|
51
|
+
covers all three for client-side suites. Said so in the README, which
|
|
52
|
+
previously left the gap to be inferred.
|
|
53
|
+
|
|
54
|
+
- `withdrawLink` has two legal spellings in the wild. LUD-25 calls it "a
|
|
55
|
+
raw, non bech32-encoded URL as described in LUD-17", and LUD-17 describes
|
|
56
|
+
both the `lnurlw://` scheme and the plain `https://` URL it stands for.
|
|
57
|
+
lnurl-mint (and the spec's own diagram) emit `https://mint.example/w`;
|
|
58
|
+
moneyer emits `lnurlw://moneyer.dev/w`. The mock mint only ever served
|
|
59
|
+
the second, so a client that broke on the reference mint's form would
|
|
60
|
+
still have passed here.
|
|
61
|
+
- The mock mint now serves the plain `https://` spelling by default,
|
|
62
|
+
matching the reference mint, and takes `withdrawLinkForm: 'lnurlw'`
|
|
63
|
+
for the other. A test asserting the old `lnurlw://` default needs
|
|
64
|
+
updating (lnurlcash-kit's did).
|
|
65
|
+
- `pay-request.json` gains accepted cases for the plain form and for an
|
|
66
|
+
onion host; a parser must pass both through untouched.
|
|
67
|
+
- The grader accepts either spelling, rejects a bech32 `lnurl1...` value,
|
|
68
|
+
and names the form in its report. Its three ad-hoc `lnurlw://` rewrites
|
|
69
|
+
are now one exported `fromLud17`.
|
|
70
|
+
- Selfgrade runs the compliant mock in both forms.
|
|
71
|
+
|
|
7
72
|
## 0.1.1 - 2026-08-20
|
|
8
73
|
|
|
9
74
|
- The mock mint's mint-address response now carries the node stats
|
package/README.md
CHANGED
|
@@ -57,6 +57,7 @@ for (const c of cases) {
|
|
|
57
57
|
| `withdraw-info.json` | the informational GET, and what makes a response invalid |
|
|
58
58
|
| `pay-request.json` | minting, LUD-11 disposable, LUD-21 verify |
|
|
59
59
|
| `lifecycle.json` | behavioural requirements, as scenarios to drive |
|
|
60
|
+
| `threat-suite.json` | the transport/exposure scorecard — candidate spec options against fixed attacks (non-normative) |
|
|
60
61
|
|
|
61
62
|
Regenerate with `npm run generate`; check them with `npm test`, which
|
|
62
63
|
verifies every digest recomputes, every declared signature really does
|
|
@@ -93,6 +94,7 @@ must survive:
|
|
|
93
94
|
| `--roundFeeToSat` | rounds the withheld fee up to a whole sat — the note mints short of the formula |
|
|
94
95
|
| `--verifyLeaksEarly` | serves the preimage from verify before settlement — the bearer secret, to anyone with the hash |
|
|
95
96
|
| `--verify=false` | no LUD-21 endpoint at all, not merely unadvertised |
|
|
97
|
+
| `--withdrawLinkForm=lnurlw` | spells `withdrawLink` as `lnurlw://host/w` instead of the plain `https://host/w` the reference mint emits. Both are legal; a client has to take both |
|
|
96
98
|
|
|
97
99
|
As a library, for your own test suite:
|
|
98
100
|
|
|
@@ -115,7 +117,8 @@ actually did rather than what it said.
|
|
|
115
117
|
npx lnurlcash-conform mint@example.com
|
|
116
118
|
```
|
|
117
119
|
|
|
118
|
-
Read-only by default: resolves the payRequest, checks the `withdrawLink
|
|
120
|
+
Read-only by default: resolves the payRequest, checks the `withdrawLink`
|
|
121
|
+
(either legal spelling, and the report says which one the mint uses),
|
|
119
122
|
the fee advertisement, invoice amounts, that LUD-21 verify serves no
|
|
120
123
|
preimage before settlement (on a mint that value IS the bearer secret, and
|
|
121
124
|
everyone on the payment's route knows the payment hash), whether an
|
|
@@ -124,8 +127,14 @@ experimental mint address.
|
|
|
124
127
|
|
|
125
128
|
One check needs a real payment, which the runner cannot make on its own.
|
|
126
129
|
Given a freshly minted, never-rotated note and what its mint invoice was
|
|
127
|
-
paid at, it compares the note's value against the fee
|
|
128
|
-
|
|
130
|
+
paid at, it compares the note's value against the advertised fee:
|
|
131
|
+
|
|
132
|
+
LUD-25 says nothing about whether that fee rounds, and the two live
|
|
133
|
+
implementations differ. dni's lnurl-mint ceilings it to a whole sat on
|
|
134
|
+
purpose, so the mint is never short a sat; moneyer withholds the
|
|
135
|
+
msat-exact amount. Both pass. The check grades the range between them and
|
|
136
|
+
names which it saw, and a msat outside it either way fails - a mint taking
|
|
137
|
+
more than the ceilinged fee, or crediting more than it advertised.
|
|
129
138
|
|
|
130
139
|
```bash
|
|
131
140
|
npx lnurlcash-conform mint@example.com --note='lnurlw://...?k1=...' --paid=500000
|
|
@@ -148,12 +157,24 @@ and that a burned secret cannot be replayed. It also probes three adversarial sh
|
|
|
148
157
|
mint must refuse atomically: a duplicated `k1` (which a careless mint counts
|
|
149
158
|
twice, minting money from nothing), an output hash that collides with an
|
|
150
159
|
existing note id (minting over it hands the output to whoever already knows
|
|
151
|
-
that id's preimage),
|
|
152
|
-
two notes)
|
|
160
|
+
that id's preimage), a split whose `h` equals `h2` (one id cannot carry
|
|
161
|
+
two notes), a split naming only one output hash (a mint that accepts it is
|
|
162
|
+
generating the change secret itself), and a split leaving change one msat
|
|
163
|
+
short of the advertised base fee (which LUD-25 says to refuse with
|
|
164
|
+
`insufficient value`, not to serve at a loss). And it replays the callback as a POST and as an OPTIONS
|
|
153
165
|
preflight - real HTTP stacks send both on their own initiative, so the
|
|
154
166
|
mutating endpoint must answer GET only. After every refusal it confirms the refused note is still
|
|
155
167
|
spendable. Use a small note. Exit code is non-zero if anything failed.
|
|
156
168
|
|
|
169
|
+
**What the grader cannot reach.** It never melts. Melting spends real sats
|
|
170
|
+
against a real mint, which is not something a grading tool may decide to do,
|
|
171
|
+
so `pending` on a k1 mid-melt, restoring the note when the outgoing payment
|
|
172
|
+
fails, and the melt's own LUD-21 `verify` are all outside what a grade can
|
|
173
|
+
say anything about. They are not unspecified and not untested: the mock mint
|
|
174
|
+
implements every one of them (`meltNeverSettles`, `meltAlwaysFails`), so a
|
|
175
|
+
client suite driving the mock covers the whole melt path. A clean grade
|
|
176
|
+
means the read-only and non-melt mutating surface is compliant, no more.
|
|
177
|
+
|
|
157
178
|
The grader shares no code with any LNURLcash library — it is written against
|
|
158
179
|
`fetch` and `@noble` directly. A grader that shared an implementation with
|
|
159
180
|
the thing it grades would agree with that implementation's mistakes, which
|
|
@@ -171,6 +192,9 @@ Spec and reference implementations, all by dni, all MIT:
|
|
|
171
192
|
- [lnurl-mint](https://github.com/dni/lnurl-mint) — the reference service
|
|
172
193
|
- [lnurl-wallet](https://github.com/dni/lnurl-wallet) — the reference wallet
|
|
173
194
|
|
|
195
|
+
Implementations to run these vectors against are indexed in
|
|
196
|
+
[awesome-lnurlcash](https://github.com/TheCryptoDonkey/awesome-lnurlcash).
|
|
197
|
+
|
|
174
198
|
Contributions of vectors are welcome, particularly from implementers who
|
|
175
199
|
found a case these missed. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
176
200
|
|
package/llms.txt
CHANGED
|
@@ -22,6 +22,7 @@ responses.json classifying replies incl. ambiguous outcomes
|
|
|
22
22
|
withdraw-info.json the informational GET
|
|
23
23
|
pay-request.json minting, LUD-11, LUD-21
|
|
24
24
|
lifecycle.json behavioural scenarios
|
|
25
|
+
threat-suite.json transport/exposure scorecard, options vs attacks (non-normative)
|
|
25
26
|
|
|
26
27
|
## Mock mint
|
|
27
28
|
|
|
@@ -33,7 +34,8 @@ await mint.close()
|
|
|
33
34
|
Misbehaviour flags: dropAfterMutation, unconfirmedMutation, malformedJson,
|
|
34
35
|
echoWrongK1, lieAboutValue, signatureLayout=leading, signatures=false,
|
|
35
36
|
serverGeneratedSecrets, meltNeverSettles, meltAlwaysFails, slowMs, sunset,
|
|
36
|
-
baseFeeMsat, feePpm, verify=false
|
|
37
|
+
baseFeeMsat, feePpm, verify=false, withdrawLinkForm=lnurlw (the lnurlw://
|
|
38
|
+
spelling of withdrawLink; default is the plain https://, as lnurl-mint).
|
|
37
39
|
|
|
38
40
|
CLI: npx lnurlcash-mock-mint --port=8899 (nothing is payable)
|
|
39
41
|
|
package/mock-mint/index.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type SignatureLayout = 'trailing' | 'leading'
|
|
|
6
6
|
|
|
7
7
|
export type NoteState = 'outstanding' | 'pending' | 'burned'
|
|
8
8
|
|
|
9
|
+
export type WithdrawLinkForm = 'lnurlw' | 'plain'
|
|
10
|
+
|
|
9
11
|
export interface MockMintOptions {
|
|
10
12
|
username?: string
|
|
11
13
|
minSendableMsat?: number
|
|
@@ -21,6 +23,12 @@ export interface MockMintOptions {
|
|
|
21
23
|
signatureLayout?: SignatureLayout
|
|
22
24
|
/** withhold sig/sig2 entirely, as a SERVICE with no funding source does */
|
|
23
25
|
signatures?: boolean
|
|
26
|
+
/**
|
|
27
|
+
* How the payRequest spells its withdrawLink. 'plain' (default) is the
|
|
28
|
+
* fetchable https:// URL the reference mint emits and the spec's diagram
|
|
29
|
+
* shows; 'lnurlw' is the LUD-17 scheme form. Both are legal; test against both.
|
|
30
|
+
*/
|
|
31
|
+
withdrawLinkForm?: WithdrawLinkForm
|
|
24
32
|
/** LUD-21 verify endpoint. Off means 404, not merely unadvertised. */
|
|
25
33
|
verify?: boolean
|
|
26
34
|
privateKey?: string
|
|
@@ -42,6 +50,14 @@ export interface MockMintOptions {
|
|
|
42
50
|
meltAlwaysFails?: boolean
|
|
43
51
|
/** non-compliant: generate the replacement secret SERVICE-side and hand it back */
|
|
44
52
|
serverGeneratedSecrets?: boolean
|
|
53
|
+
/** ceiling the mint fee to a whole sat, as dni's lnurl-mint does; compliant */
|
|
54
|
+
roundFeeToSat?: boolean
|
|
55
|
+
/** withhold this many msat on top of the fee, landing outside the compliant band */
|
|
56
|
+
extraFeeMsat?: number
|
|
57
|
+
/** non-compliant: accept a split with no h2, generating the change secret instead of refusing */
|
|
58
|
+
acceptsMissingH2?: boolean
|
|
59
|
+
/** non-compliant: split without taking the base fee out of change, and so without its floor */
|
|
60
|
+
splitIgnoresBaseFee?: boolean
|
|
45
61
|
/** delay before responding, in milliseconds */
|
|
46
62
|
slowMs?: number
|
|
47
63
|
/** reject splits and mints, as a mint winding down does */
|
package/mock-mint/index.mjs
CHANGED
|
@@ -59,6 +59,13 @@ const DEFAULTS = {
|
|
|
59
59
|
signatureLayout: 'trailing',
|
|
60
60
|
// withhold sig/sig2 entirely, as a SERVICE with no funding source does
|
|
61
61
|
signatures: true,
|
|
62
|
+
// how the payRequest spells its withdrawLink. 'plain' is the fetchable
|
|
63
|
+
// https:// URL the reference mint emits and the spec's diagram shows;
|
|
64
|
+
// 'lnurlw' is the LUD-17 scheme form some mints emitted under the
|
|
65
|
+
// draft's looser wording. Both are legal raw, non-bech32 URLs, and a
|
|
66
|
+
// WALLET that handles one but not the other fails against real mints.
|
|
67
|
+
// Run your client against both.
|
|
68
|
+
withdrawLinkForm: 'plain',
|
|
62
69
|
// LUD-21 verify endpoint. Off means 404, not merely unadvertised: the
|
|
63
70
|
// preimage it serves IS a bearer secret, so an operator needs a real
|
|
64
71
|
// off switch.
|
|
@@ -83,9 +90,13 @@ const DEFAULTS = {
|
|
|
83
90
|
// it back, the exposure LUD-25's h/h2 exists to close
|
|
84
91
|
serverGeneratedSecrets: false,
|
|
85
92
|
// non-compliant: round the total mint fee up to a whole sat, as a fee
|
|
86
|
-
//
|
|
87
|
-
//
|
|
93
|
+
// ceiling the mint fee to a whole sat, as dni's lnurl-mint does on
|
|
94
|
+
// purpose. Compliant: LUD-25 does not say whether the fee rounds, so
|
|
95
|
+
// the grader accepts anything between this and the msat-exact formula
|
|
88
96
|
roundFeeToSat: false,
|
|
97
|
+
// beyond the band: withhold this much on top of the ceilinged fee, the
|
|
98
|
+
// one thing the minted-value check must still catch
|
|
99
|
+
extraFeeMsat: 0,
|
|
89
100
|
// non-compliant: serve the preimage from /verify before settlement. On
|
|
90
101
|
// a mint that value IS the bearer secret of the note the payment will
|
|
91
102
|
// create, so this hands the note to anyone holding the payment hash
|
|
@@ -133,6 +144,7 @@ export const createMockMint = async (options = {}) => {
|
|
|
133
144
|
Math.floor(((gross % 1e6) * opts.feePpm) / 1e6)
|
|
134
145
|
let fee = opts.baseFeeMsat + proportional
|
|
135
146
|
if (opts.roundFeeToSat) fee = Math.ceil(fee / 1000) * 1000
|
|
147
|
+
fee += opts.extraFeeMsat
|
|
136
148
|
return Math.max(0, gross - fee)
|
|
137
149
|
}
|
|
138
150
|
|
|
@@ -276,7 +288,8 @@ export const createMockMint = async (options = {}) => {
|
|
|
276
288
|
minSendable: minSendableMsat,
|
|
277
289
|
maxSendable: opts.maxSendableMsat,
|
|
278
290
|
metadata: JSON.stringify(metadata),
|
|
279
|
-
withdrawLink:
|
|
291
|
+
withdrawLink:
|
|
292
|
+
opts.withdrawLinkForm === 'plain' ? `${origin}/w` : `lnurlw://${req.headers.host}/w`,
|
|
280
293
|
disposable: false
|
|
281
294
|
})
|
|
282
295
|
}
|
|
@@ -444,7 +457,10 @@ export const createMockMint = async (options = {}) => {
|
|
|
444
457
|
}
|
|
445
458
|
|
|
446
459
|
if (!h) return fail('missing h')
|
|
447
|
-
|
|
460
|
+
// opts.acceptsMissingH2: the misbehaviour where a SERVICE fills in the
|
|
461
|
+
// change note's secret itself rather than refusing. Whoever runs the
|
|
462
|
+
// mint is then a prior holder of half the split.
|
|
463
|
+
if (amountRaw !== null && !h2 && !opts.acceptsMissingH2) return fail('missing h2')
|
|
448
464
|
if (!/^[0-9a-f]{64}$/.test(h)) return fail('missing h')
|
|
449
465
|
if (h2 && !/^[0-9a-f]{64}$/.test(h2)) return fail('missing h2')
|
|
450
466
|
// One id cannot carry two notes, and an id already in use - as a note
|
|
@@ -476,8 +492,16 @@ export const createMockMint = async (options = {}) => {
|
|
|
476
492
|
// change that cannot cover the fee, or would land at exactly
|
|
477
493
|
// nothing, refuses with the spec's own reason.
|
|
478
494
|
const changeBeforeFee = total - amount
|
|
479
|
-
|
|
480
|
-
|
|
495
|
+
// opts.splitIgnoresBaseFee: the misbehaviour where a SERVICE never
|
|
496
|
+
// learned the split-fee rule at all - no fee out of change, and so
|
|
497
|
+
// no floor to refuse against either.
|
|
498
|
+
let change
|
|
499
|
+
if (opts.splitIgnoresBaseFee) {
|
|
500
|
+
change = changeBeforeFee
|
|
501
|
+
} else {
|
|
502
|
+
if (changeBeforeFee < opts.baseFeeMsat) return fail('insufficient value')
|
|
503
|
+
change = changeBeforeFee - opts.baseFeeMsat
|
|
504
|
+
}
|
|
481
505
|
if (change < 1) return fail('insufficient value')
|
|
482
506
|
for (const {note} of found) note.state = 'burned'
|
|
483
507
|
const sig = mintNote(h, amount)
|
package/package.json
CHANGED
package/runner/index.mjs
CHANGED
|
@@ -42,6 +42,19 @@ const verifySignature = (k1, amountMsat, signatureHex, pubkeyHex) => {
|
|
|
42
42
|
return false
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// LUD-17: lnurlw://host/path is https://host/path, or http:// when the host
|
|
46
|
+
// is an onion service (the spec) or loopback (development). A plain
|
|
47
|
+
// https:// or http:// URL passes through untouched, so a caller can hand
|
|
48
|
+
// this either form a SERVICE emits.
|
|
49
|
+
export const fromLud17 = value => {
|
|
50
|
+
const v = String(value).trim()
|
|
51
|
+
if (!/^lnurl[wpc]:\/\//i.test(v)) return v
|
|
52
|
+
const rest = v.slice(v.indexOf('://') + 3)
|
|
53
|
+
const host = rest.split(/[/?#]/, 1)[0].replace(/:\d+$/, '').toLowerCase()
|
|
54
|
+
const plain = ['localhost', '127.0.0.1', '0.0.0.0'].includes(host) || host.endsWith('.onion')
|
|
55
|
+
return (plain ? 'http://' : 'https://') + rest
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
const isAllowedUrl = value => {
|
|
46
59
|
let url
|
|
47
60
|
try {
|
|
@@ -187,9 +200,21 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
187
200
|
typeof pay.withdrawLink === 'string',
|
|
188
201
|
'no withdrawLink - this is an ordinary payRequest, not an LNURLcash mint'
|
|
189
202
|
)
|
|
190
|
-
const
|
|
191
|
-
assert(
|
|
192
|
-
|
|
203
|
+
const link = pay.withdrawLink.trim()
|
|
204
|
+
assert(
|
|
205
|
+
!/^lnurl1/i.test(link),
|
|
206
|
+
'withdrawLink is bech32-encoded - LUD-25 wants the raw URL, not an LNURL'
|
|
207
|
+
)
|
|
208
|
+
// Both forms are in the wild: lnurl-mint emits the plain https:// URL
|
|
209
|
+
// (as the spec's own diagram does), moneyer the lnurlw:// LUD-17 form.
|
|
210
|
+
// Either is a raw, non-bech32 URL; a WALLET has to take both.
|
|
211
|
+
const form = /^lnurlw:\/\//i.test(link) ? 'lnurlw:// form' : 'plain URL form'
|
|
212
|
+
assert(
|
|
213
|
+
/^(lnurlw|https?):\/\//i.test(link),
|
|
214
|
+
`withdrawLink has an unexpected scheme: ${link}`
|
|
215
|
+
)
|
|
216
|
+
assert(isAllowedUrl(fromLud17(link)), `withdrawLink is not fetchable: ${link}`)
|
|
217
|
+
return `${link} (${form})`
|
|
193
218
|
})
|
|
194
219
|
|
|
195
220
|
await report.check('metadata parses, and any fee advertisement is valid', async () => {
|
|
@@ -250,9 +275,7 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
250
275
|
})
|
|
251
276
|
|
|
252
277
|
await report.check('reports an unknown note distinguishably', async () => {
|
|
253
|
-
const withdrawUrl = (pay.withdrawLink
|
|
254
|
-
/localhost|127\.0\.0\.1|\.onion/.test(pay.withdrawLink) ? 'http://' : 'https://'
|
|
255
|
-
)
|
|
278
|
+
const withdrawUrl = pay.withdrawLink ? fromLud17(pay.withdrawLink) : ''
|
|
256
279
|
if (!withdrawUrl) throw soft('no withdrawLink to probe')
|
|
257
280
|
const url = new URL(withdrawUrl)
|
|
258
281
|
url.searchParams.set('k1', bytesToHex(randomBytes(32)))
|
|
@@ -294,25 +317,40 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
294
317
|
export const gradeMintedValue = async (noteUrl, report, {mintFee = null, paidMsat}) => {
|
|
295
318
|
await report.check('a minted note is worth the amount paid minus the fee', async () => {
|
|
296
319
|
assert(Number.isFinite(paidMsat) && paidMsat > 0, `paidMsat was ${paidMsat}`)
|
|
297
|
-
const url = new URL(noteUrl
|
|
298
|
-
/localhost|127\.0\.0\.1|\.onion/.test(noteUrl) ? 'http://' : 'https://'
|
|
299
|
-
))
|
|
320
|
+
const url = new URL(fromLud17(noteUrl))
|
|
300
321
|
const info = await get(url)
|
|
301
322
|
assert(info.status !== 'ERROR', `refused: ${info.reason}`)
|
|
302
323
|
assert(Number.isFinite(info.maxWithdrawable), 'no maxWithdrawable')
|
|
303
|
-
const
|
|
324
|
+
const exact = applyMintFee(paidMsat, mintFee)
|
|
304
325
|
const feeText = mintFee
|
|
305
326
|
? `${mintFee.baseFeeMsat} msat + ${mintFee.feePpm} ppm`
|
|
306
327
|
: 'no advertised fee'
|
|
307
|
-
|
|
328
|
+
// LUD-25 gives the fee as base plus a ppm cut and says nothing about
|
|
329
|
+
// rounding, and the two live implementations read that differently:
|
|
330
|
+
// dni's lnurl-mint - the reference, and what every public mint but
|
|
331
|
+
// moneyer runs - ceilings the fee to a whole sat on purpose, moneyer
|
|
332
|
+
// is msat-exact. Grading either as a failure would be this repo
|
|
333
|
+
// picking a side the draft has not picked. So the compliant answer is
|
|
334
|
+
// a band: the formula is the most a holder can be credited, the
|
|
335
|
+
// sat-ceilinged fee the least. Anything outside is still wrong, which
|
|
336
|
+
// is what this check is for.
|
|
337
|
+
const exactFee = paidMsat - exact
|
|
338
|
+
const ceilinged = Math.max(0, paidMsat - Math.ceil(exactFee / 1000) * 1000)
|
|
339
|
+
assert(
|
|
340
|
+
info.maxWithdrawable <= exact,
|
|
341
|
+
`paid ${paidMsat} msat against ${feeText}: the note holds ${info.maxWithdrawable} msat, more than the ${exact} the formula allows`
|
|
342
|
+
)
|
|
308
343
|
assert(
|
|
309
|
-
info.maxWithdrawable
|
|
310
|
-
`paid ${paidMsat} msat against ${feeText}: the
|
|
311
|
-
(info.maxWithdrawable === satRounded && satRounded !== expected
|
|
312
|
-
? ' - consistent with the fee being rounded up to a whole sat, which the formula does not allow'
|
|
313
|
-
: '')
|
|
344
|
+
info.maxWithdrawable >= ceilinged,
|
|
345
|
+
`paid ${paidMsat} msat against ${feeText}: the note holds ${info.maxWithdrawable} msat, short of ${ceilinged} - beyond even a fee ceilinged to a whole sat`
|
|
314
346
|
)
|
|
315
|
-
|
|
347
|
+
const how =
|
|
348
|
+
info.maxWithdrawable === exact
|
|
349
|
+
? 'msat-exact'
|
|
350
|
+
: info.maxWithdrawable === ceilinged
|
|
351
|
+
? 'fee ceilinged to a whole sat, as the reference mint does'
|
|
352
|
+
: 'inside the band'
|
|
353
|
+
return `${paidMsat} msat paid -> ${info.maxWithdrawable} msat note (${feeText}, ${how})`
|
|
316
354
|
})
|
|
317
355
|
}
|
|
318
356
|
|
|
@@ -328,9 +366,7 @@ export const gradeMintedValue = async (noteUrl, report, {mintFee = null, paidMsa
|
|
|
328
366
|
// every split's change, and a merge of n notes refunds (n - 1) base fees.
|
|
329
367
|
export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
330
368
|
const knownBaseFee = 'mintFee' in options ? (options.mintFee?.baseFeeMsat ?? 0) : null
|
|
331
|
-
const url = new URL(noteUrl
|
|
332
|
-
/localhost|127\.0\.0\.1|\.onion/.test(noteUrl) ? 'http://' : 'https://'
|
|
333
|
-
))
|
|
369
|
+
const url = new URL(fromLud17(noteUrl))
|
|
334
370
|
const k1 = url.searchParams.get('k1')?.toLowerCase()
|
|
335
371
|
assert(k1 && /^[0-9a-f]{64}$/.test(k1), 'that note carries no 32-byte hex k1')
|
|
336
372
|
|
|
@@ -383,6 +419,23 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
383
419
|
return body.reason
|
|
384
420
|
})
|
|
385
421
|
|
|
422
|
+
await report.check('refuses a split with no h2', async () => {
|
|
423
|
+
const cb = new URL(info.callback)
|
|
424
|
+
cb.searchParams.append('k1', k1)
|
|
425
|
+
cb.searchParams.append('amount', String(Math.max(1, Math.floor(info.maxWithdrawable / 2))))
|
|
426
|
+
cb.searchParams.append('h', noteId(bytesToHex(randomBytes(32))))
|
|
427
|
+
const body = await get(cb)
|
|
428
|
+
assert(
|
|
429
|
+
body.status === 'ERROR',
|
|
430
|
+
'accepted a split with only one output hash - the change note has nowhere to go but a SERVICE-generated secret'
|
|
431
|
+
)
|
|
432
|
+
const after = new URL(url)
|
|
433
|
+
after.searchParams.set('k1', k1)
|
|
434
|
+
const still = await get(after)
|
|
435
|
+
assert(still.status !== 'ERROR', `a refused split burned the note anyway: ${still.reason}`)
|
|
436
|
+
return body.reason
|
|
437
|
+
})
|
|
438
|
+
|
|
386
439
|
let current = k1
|
|
387
440
|
let currentSig = null
|
|
388
441
|
await report.check('rotate mints a note the service never saw the secret of', async () => {
|
|
@@ -537,6 +590,37 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
537
590
|
return 'POST and OPTIONS left the note untouched'
|
|
538
591
|
})
|
|
539
592
|
|
|
593
|
+
await report.check('refuses a split whose change cannot cover the base fee', async () => {
|
|
594
|
+
if (knownBaseFee === null) throw soft('fee unknown - pass --paid to grade the fee rules')
|
|
595
|
+
if (knownBaseFee === 0) throw soft('no base fee advertised, so the rule cannot bite')
|
|
596
|
+
// Leave the change one msat short of the base fee. LUD-25 says fail the
|
|
597
|
+
// whole split rather than hand back a change note worth less than the
|
|
598
|
+
// fee that was meant to come out of it.
|
|
599
|
+
const amount = info.maxWithdrawable - knownBaseFee + 1
|
|
600
|
+
if (amount < 1 || amount >= info.maxWithdrawable) {
|
|
601
|
+
throw soft('note too small to leave change short of the base fee')
|
|
602
|
+
}
|
|
603
|
+
const cb = new URL(info.callback)
|
|
604
|
+
cb.searchParams.append('k1', current)
|
|
605
|
+
cb.searchParams.append('amount', String(amount))
|
|
606
|
+
cb.searchParams.append('h', noteId(bytesToHex(randomBytes(32))))
|
|
607
|
+
cb.searchParams.append('h2', noteId(bytesToHex(randomBytes(32))))
|
|
608
|
+
const body = await get(cb)
|
|
609
|
+
assert(
|
|
610
|
+
body.status === 'ERROR',
|
|
611
|
+
`accepted a split leaving ${info.maxWithdrawable - amount} msat of change against a ${knownBaseFee} msat base fee`
|
|
612
|
+
)
|
|
613
|
+
const after = new URL(url)
|
|
614
|
+
after.searchParams.set('k1', current)
|
|
615
|
+
const still = await get(after)
|
|
616
|
+
assert(still.status !== 'ERROR', `a refused split burned the note anyway: ${still.reason}`)
|
|
617
|
+
assert(
|
|
618
|
+
still.maxWithdrawable === info.maxWithdrawable,
|
|
619
|
+
`a refused split changed the note's value: ${info.maxWithdrawable} -> ${still.maxWithdrawable}`
|
|
620
|
+
)
|
|
621
|
+
return body.reason
|
|
622
|
+
})
|
|
623
|
+
|
|
540
624
|
await report.check('split conserves value', async () => {
|
|
541
625
|
const half = Math.floor(info.maxWithdrawable / 2)
|
|
542
626
|
if (half < 1) throw soft('note too small to split')
|
package/vectors/index.json
CHANGED
package/vectors/pay-request.json
CHANGED
|
@@ -16,6 +16,34 @@
|
|
|
16
16
|
"withdrawLink": "lnurlw://mint.example/w",
|
|
17
17
|
"mintFee": null
|
|
18
18
|
},
|
|
19
|
+
{
|
|
20
|
+
"name": "withdrawLink in plain URL form",
|
|
21
|
+
"body": {
|
|
22
|
+
"tag": "payRequest",
|
|
23
|
+
"callback": "https://mint.example/p/cb",
|
|
24
|
+
"minSendable": 1000,
|
|
25
|
+
"maxSendable": 100000000,
|
|
26
|
+
"metadata": "[[\"text/plain\",\"a mint\"],[\"text/identifier\",\"mint@mint.example\"]]",
|
|
27
|
+
"withdrawLink": "https://mint.example/w"
|
|
28
|
+
},
|
|
29
|
+
"withdrawLink": "https://mint.example/w",
|
|
30
|
+
"mintFee": null,
|
|
31
|
+
"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
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "withdrawLink on an onion service",
|
|
35
|
+
"body": {
|
|
36
|
+
"tag": "payRequest",
|
|
37
|
+
"callback": "http://mintmintmintmintmintmintmintmintmintmintmintmintmintmi.onion/p/cb",
|
|
38
|
+
"minSendable": 1000,
|
|
39
|
+
"maxSendable": 100000000,
|
|
40
|
+
"metadata": "[[\"text/plain\",\"a mint\"]]",
|
|
41
|
+
"withdrawLink": "lnurlw://mintmintmintmintmintmintmintmintmintmintmintmintmintmi.onion/w"
|
|
42
|
+
},
|
|
43
|
+
"withdrawLink": "lnurlw://mintmintmintmintmintmintmintmintmintmintmintmintmintmi.onion/w",
|
|
44
|
+
"mintFee": null,
|
|
45
|
+
"why": "the parser passes the link through; resolution to http:// happens when a note is built from it (see note-url.json build)"
|
|
46
|
+
},
|
|
19
47
|
{
|
|
20
48
|
"name": "with an advertised fee",
|
|
21
49
|
"body": {
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"spec": "LUD-25 draft (lnurl/luds#301)",
|
|
4
|
+
"description": "The transport/exposure scorecard from the LUD-25 design debate: candidate spec options measured against a fixed set of attacks, so proposed changes are argued against the same scenarios instead of in the abstract. NON-NORMATIVE - option A is the current draft and every other option is a proposal, marked as such below; only rows with status \"pins-current\" describe behavior the draft already requires, and rows pinning today's vulnerable behavior exist to be INVERTED by the PR landing the named option. The executable twin of this file is lnurl-mint's tests/test_bearer_threat_suite_poc.py (dni/lnurl-mint#22).",
|
|
5
|
+
"options": {
|
|
6
|
+
"A": {
|
|
7
|
+
"name": "status quo",
|
|
8
|
+
"status": "current-draft",
|
|
9
|
+
"summary": "lnurl/luds#301 as drafted"
|
|
10
|
+
},
|
|
11
|
+
"B": {
|
|
12
|
+
"name": "comment-secret",
|
|
13
|
+
"status": "proposal",
|
|
14
|
+
"summary": "A + a secret the WALLET attaches to the payRequest (LUD-12 comment), encrypted to the mint; the note's k1 becomes \"<secret>:<preimage>\" and the public LUD-21 preimage alone no longer redeems"
|
|
15
|
+
},
|
|
16
|
+
"C": {
|
|
17
|
+
"name": "?p= everywhere",
|
|
18
|
+
"status": "proposal",
|
|
19
|
+
"summary": "every k1 replaced in transport by that k1 encrypted to the mint"
|
|
20
|
+
},
|
|
21
|
+
"D": {
|
|
22
|
+
"name": "hash-keyed informational GET",
|
|
23
|
+
"status": "proposal",
|
|
24
|
+
"summary": "A + poll /w by sha256(k1), never k1"
|
|
25
|
+
},
|
|
26
|
+
"E": {
|
|
27
|
+
"name": "blinded signatures",
|
|
28
|
+
"status": "proposal",
|
|
29
|
+
"summary": "the chaumian model"
|
|
30
|
+
},
|
|
31
|
+
"F": {
|
|
32
|
+
"name": "B + D",
|
|
33
|
+
"status": "proposal",
|
|
34
|
+
"summary": "comment-secret plus the hash-keyed informational GET"
|
|
35
|
+
},
|
|
36
|
+
"G": {
|
|
37
|
+
"name": "locked notes",
|
|
38
|
+
"status": "proposal",
|
|
39
|
+
"summary": "a second asset class, not a bearer variant: redemption requires an LUD-04 signature from the LUD-05/LUD-13 linkingKey registered at mint/rotate time, over the FULL redemption request (k1, h/h2, amount/pr), so logged signatures are not replayable. Scored separately per scenario as lockedNotes, because the trades differ by note type"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"policy": {
|
|
43
|
+
"redGreen": "Scenarios asserting today's vulnerable behavior are pins that must be INVERTED by the PR landing the named option, mirroring the INVERTS WHEN policy in this file's executable companion, lnurl-mint's tests/test_bearer_threat_suite_poc.py (dni/lnurl-mint#22): that PR flips the pin red and forces the assertion to be rewritten against the fixed behavior. Controls (T4, T5) assert behavior that must never change.",
|
|
44
|
+
"coreTheorem": "Re-encrypting a bearer credential to the party that redeems it never shrinks its exposure set: the mint honors the ciphertext, so the ciphertext IS the note. The only encryption that helps is encrypting to the holder, which kills bearer-ness - option G takes that trade deliberately, via signatures rather than ciphertext.",
|
|
45
|
+
"seedRecoverableNotes": "A WALLET deriving note secrets deterministically (BIP85, or an LUD-05-style HMAC path plus a counter) can restore outstanding notes from the seed via hash-keyed lookup - option D doubles as the restore API. Restore covers device loss, NOT theft: anyone who copied a circulating note may have spent it long before the restore runs. One derivation convention must be pinned in the spec, or wallets fragment and restores silently miss notes."
|
|
46
|
+
},
|
|
47
|
+
"scenarios": [
|
|
48
|
+
{
|
|
49
|
+
"id": "T1",
|
|
50
|
+
"name": "verify race",
|
|
51
|
+
"kind": "attack",
|
|
52
|
+
"adversary": "anyone who saw the unpaid mint invoice - the payment hash travels inside it",
|
|
53
|
+
"steps": [
|
|
54
|
+
"observe an unpaid mint invoice and extract its payment hash",
|
|
55
|
+
"poll the LUD-21 verify endpoint until the invoice settles",
|
|
56
|
+
"take the disclosed preimage the moment it settles",
|
|
57
|
+
"rotate the note before the payer does"
|
|
58
|
+
],
|
|
59
|
+
"currentBehavior": "succeeds when verify is on",
|
|
60
|
+
"options": {
|
|
61
|
+
"closedBy": [
|
|
62
|
+
"B",
|
|
63
|
+
"F"
|
|
64
|
+
],
|
|
65
|
+
"notClosedBy": [
|
|
66
|
+
"C",
|
|
67
|
+
"E"
|
|
68
|
+
],
|
|
69
|
+
"lockedNotes": "closed - the note is locked to its linkingKey from birth, so a racer holding only P cannot redeem"
|
|
70
|
+
},
|
|
71
|
+
"status": "inverts-when-option-B",
|
|
72
|
+
"notes": "C does not close it: encrypting to the mint is a public operation (mintPubkey is advertised), so the racer wraps the leaked preimage himself and replays. E does not either: the race becomes \"whoever presents P plus a blinded B first gets the signature\"."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "T2",
|
|
76
|
+
"name": "routing-node race",
|
|
77
|
+
"kind": "attack",
|
|
78
|
+
"adversary": "every routing hop on the mint payment's path - each learns the preimage as the HTLC settles",
|
|
79
|
+
"steps": [
|
|
80
|
+
"sit on the payment path of a mint invoice",
|
|
81
|
+
"learn the preimage as the settling HTLC propagates back",
|
|
82
|
+
"rotate before the payer"
|
|
83
|
+
],
|
|
84
|
+
"currentBehavior": "succeeds even with VERIFY_ENABLED=false - no spec endpoint is involved",
|
|
85
|
+
"options": {
|
|
86
|
+
"closedBy": [
|
|
87
|
+
"B",
|
|
88
|
+
"F"
|
|
89
|
+
],
|
|
90
|
+
"lockedNotes": "closed for locked notes only"
|
|
91
|
+
},
|
|
92
|
+
"status": "inverts-when-option-B",
|
|
93
|
+
"notes": "The same outcome as T1 with no verify at all: the preimage leaks from the payment protocol itself, not from any endpoint."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"id": "T3",
|
|
97
|
+
"name": "poll-log replay",
|
|
98
|
+
"kind": "attack",
|
|
99
|
+
"adversary": "anyone reading whatever retains request URLs - a proxy, an access log, browser history",
|
|
100
|
+
"steps": [
|
|
101
|
+
"the holder polls the informational GET /w?k1=<live note>",
|
|
102
|
+
"the poll burns nothing, so the SPENDABLE k1 is left in the log",
|
|
103
|
+
"the reader replays it into a rotate"
|
|
104
|
+
],
|
|
105
|
+
"currentBehavior": "succeeds - every value poll leaves the SPENDABLE k1 in whatever retains request URLs",
|
|
106
|
+
"options": {
|
|
107
|
+
"closedBy": [
|
|
108
|
+
"D",
|
|
109
|
+
"F"
|
|
110
|
+
],
|
|
111
|
+
"notClosedBy": [
|
|
112
|
+
"C"
|
|
113
|
+
],
|
|
114
|
+
"lockedNotes": "closed - a logged k1 is useless without the key"
|
|
115
|
+
},
|
|
116
|
+
"status": "inverts-when-option-D",
|
|
117
|
+
"notes": "D leaves only a harmless hash in those logs. C does not close it: a logged p redeems exactly like a logged k1 - the mint honors the ciphertext, so the ciphertext IS the note."
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"id": "T4",
|
|
121
|
+
"name": "callback-log replay",
|
|
122
|
+
"kind": "control",
|
|
123
|
+
"adversary": "anyone reading a logged mutating callback URL",
|
|
124
|
+
"steps": [
|
|
125
|
+
"a k1 is captured from a MUTATING callback URL",
|
|
126
|
+
"the request it rode in on already burned it",
|
|
127
|
+
"replay it later"
|
|
128
|
+
],
|
|
129
|
+
"currentBehavior": "fails with \"Invalid or already spent k1.\" - the burn from the original request is the protection",
|
|
130
|
+
"options": {
|
|
131
|
+
"holdsUnder": [
|
|
132
|
+
"A",
|
|
133
|
+
"B",
|
|
134
|
+
"C",
|
|
135
|
+
"D",
|
|
136
|
+
"E",
|
|
137
|
+
"F",
|
|
138
|
+
"G"
|
|
139
|
+
]
|
|
140
|
+
},
|
|
141
|
+
"status": "pins-current",
|
|
142
|
+
"notes": "Must hold under every option. A replay landing in the same millisecond as the original is a plain race, not a logging problem."
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"id": "T5",
|
|
146
|
+
"name": "note at rest",
|
|
147
|
+
"kind": "control",
|
|
148
|
+
"adversary": "whoever finds the note URL - chat history, a screenshot, a printed QR",
|
|
149
|
+
"steps": [
|
|
150
|
+
"find a note URL at rest",
|
|
151
|
+
"spend it"
|
|
152
|
+
],
|
|
153
|
+
"currentBehavior": "the finder spends it - a note URL at rest IS the money",
|
|
154
|
+
"options": {
|
|
155
|
+
"notClosedBy": [
|
|
156
|
+
"A",
|
|
157
|
+
"B",
|
|
158
|
+
"C",
|
|
159
|
+
"D",
|
|
160
|
+
"E",
|
|
161
|
+
"F"
|
|
162
|
+
],
|
|
163
|
+
"lockedNotes": "beaten - the only option that beats the at-rest axiom, precisely because it surrenders bearer-ness"
|
|
164
|
+
},
|
|
165
|
+
"status": "pins-current",
|
|
166
|
+
"notes": "The bearer axiom: every bearer option \"fails\" this by design, and the all-minus row is deliberate. Any future option claiming to fix at-rest exposure has to answer this scenario first."
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"id": "T6",
|
|
170
|
+
"name": "operator correlation",
|
|
171
|
+
"kind": "property",
|
|
172
|
+
"adversary": "the mint operator",
|
|
173
|
+
"steps": [
|
|
174
|
+
"at rotate, the WALLET discloses h = sha256(new_k1)",
|
|
175
|
+
"the mint keys its storage by h",
|
|
176
|
+
"a later spend of new_k1 matches the recorded h"
|
|
177
|
+
],
|
|
178
|
+
"currentBehavior": "issuance links to redemption - a full transaction graph at the operator",
|
|
179
|
+
"options": {
|
|
180
|
+
"closedBy": [
|
|
181
|
+
"E"
|
|
182
|
+
],
|
|
183
|
+
"notClosedBy": [
|
|
184
|
+
"A",
|
|
185
|
+
"B",
|
|
186
|
+
"C",
|
|
187
|
+
"D",
|
|
188
|
+
"F"
|
|
189
|
+
],
|
|
190
|
+
"lockedNotes": "open - the operator knows exactly which key owns which notes"
|
|
191
|
+
},
|
|
192
|
+
"status": "privacy-axis",
|
|
193
|
+
"notes": "h-preimages give log confidentiality (T4) but not unlinkability from the operator; only blinded issuance closes this."
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"id": "T7",
|
|
197
|
+
"name": "legacy LUD-03 melt",
|
|
198
|
+
"kind": "property",
|
|
199
|
+
"adversary": "none - a compatibility property",
|
|
200
|
+
"steps": [
|
|
201
|
+
"a wallet that knows nothing of LNURLcash melts a note to a BOLT-11 pr, as plain LUD-03"
|
|
202
|
+
],
|
|
203
|
+
"currentBehavior": "works - the note melts to a BOLT-11 pr as plain LUD-03",
|
|
204
|
+
"options": {
|
|
205
|
+
"preservedBy": [
|
|
206
|
+
"A",
|
|
207
|
+
"B",
|
|
208
|
+
"D",
|
|
209
|
+
"E",
|
|
210
|
+
"F"
|
|
211
|
+
],
|
|
212
|
+
"brokenBy": [
|
|
213
|
+
"C"
|
|
214
|
+
],
|
|
215
|
+
"lockedNotes": "broken - a plain LUD-03 wallet cannot lnurl-auth, so locked notes have no legacy story"
|
|
216
|
+
},
|
|
217
|
+
"status": "pins-current",
|
|
218
|
+
"notes": "C breaks it because legacy wallets cannot encrypt - unless mixed mode re-admits plaintext k1, which voids C's only claim."
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"id": "T8",
|
|
222
|
+
"name": "first-contact offline verify",
|
|
223
|
+
"kind": "gap",
|
|
224
|
+
"adversary": "an attacker who self-signs a note and supplies their own key",
|
|
225
|
+
"steps": [
|
|
226
|
+
"a recipient who has never interacted with this mint receives a note",
|
|
227
|
+
"it has no mintPubkey on record, so it cannot verify the note's sig offline",
|
|
228
|
+
"embedding the pubkey in the note URL proves nothing - an attacker self-signs and supplies their own key"
|
|
229
|
+
],
|
|
230
|
+
"currentBehavior": "no offline verification on first contact - a spec-level gap, no endpoint to hit",
|
|
231
|
+
"options": {
|
|
232
|
+
"closedBy": []
|
|
233
|
+
},
|
|
234
|
+
"status": "spec-gap",
|
|
235
|
+
"notes": "No option on the scorecard addresses this; it needs a key-distribution story, not a transport change."
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
"id": "T9",
|
|
239
|
+
"name": "comment silently ignored",
|
|
240
|
+
"kind": "gap",
|
|
241
|
+
"adversary": "none - a silent downgrade hazard, not an attacker",
|
|
242
|
+
"steps": [
|
|
243
|
+
"a wallet already sending a LUD-12 comment calls the payRequest callback",
|
|
244
|
+
"the callback takes no comment param and unknown query params are dropped silently",
|
|
245
|
+
"the wallet gets a bare k1=P note with verify advertised anyway"
|
|
246
|
+
],
|
|
247
|
+
"currentBehavior": "a silent downgrade with no signal to the wallet",
|
|
248
|
+
"options": {
|
|
249
|
+
"closedBy": [
|
|
250
|
+
"B",
|
|
251
|
+
"F"
|
|
252
|
+
]
|
|
253
|
+
},
|
|
254
|
+
"status": "inverts-when-option-B",
|
|
255
|
+
"notes": "Option B must define semantics - fail-closed (reject the callback) or fallback (k1=P, no verify for that invoice) - never silent."
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
"id": "T10",
|
|
259
|
+
"name": "merge URL budget",
|
|
260
|
+
"kind": "property",
|
|
261
|
+
"adversary": "none - pure URL arithmetic",
|
|
262
|
+
"steps": [
|
|
263
|
+
"build a merge callback carrying one k1 per input note plus h for the result",
|
|
264
|
+
"compare its length against the ~2000-character practical GET budget LUD-12 itself notes"
|
|
265
|
+
],
|
|
266
|
+
"currentBehavior": "a merge of 25 notes fits in plaintext hex k1s (64 chars each)",
|
|
267
|
+
"options": {
|
|
268
|
+
"preservedBy": [
|
|
269
|
+
"A",
|
|
270
|
+
"B",
|
|
271
|
+
"D",
|
|
272
|
+
"E",
|
|
273
|
+
"F"
|
|
274
|
+
],
|
|
275
|
+
"brokenBy": [
|
|
276
|
+
"C"
|
|
277
|
+
]
|
|
278
|
+
},
|
|
279
|
+
"arithmetic": {
|
|
280
|
+
"budgetChars": 2000,
|
|
281
|
+
"exampleCallback": "https://mint.example/w/cb",
|
|
282
|
+
"plaintextK1Chars": 64,
|
|
283
|
+
"encryptedK1": {
|
|
284
|
+
"layout": "33-byte ephemeral pubkey + 12-byte nonce + 32-byte ciphertext + 16-byte tag",
|
|
285
|
+
"bytes": 93,
|
|
286
|
+
"base64Chars": 124
|
|
287
|
+
},
|
|
288
|
+
"mergeOf25": {
|
|
289
|
+
"plaintextChars": 1792,
|
|
290
|
+
"plaintextFits": true,
|
|
291
|
+
"encryptedChars": 3267,
|
|
292
|
+
"encryptedFits": false
|
|
293
|
+
},
|
|
294
|
+
"mergeCapacity": {
|
|
295
|
+
"plaintext": 28,
|
|
296
|
+
"encrypted": 15,
|
|
297
|
+
"advertisedMaxK1s": 100
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
"status": "arithmetic",
|
|
301
|
+
"notes": "The same merge with every k1 swapped for an encrypted-to-the-mint blob (option C) does not fit. max_k1s=100 is unreachable in BOTH variants under the budget - plaintext caps at 28, blobs at 15 - so C would halve the merge ceiling in exchange for nothing, per T1/T2/T3."
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
"id": "T11",
|
|
305
|
+
"name": "offline handoff",
|
|
306
|
+
"kind": "property",
|
|
307
|
+
"adversary": "none - the bearer property itself",
|
|
308
|
+
"steps": [
|
|
309
|
+
"hand a bearer note to its next holder with no mint contact at handoff time"
|
|
310
|
+
],
|
|
311
|
+
"currentBehavior": "works - the spec's Offline circulation section",
|
|
312
|
+
"options": {
|
|
313
|
+
"preservedBy": [
|
|
314
|
+
"A",
|
|
315
|
+
"B",
|
|
316
|
+
"C",
|
|
317
|
+
"D",
|
|
318
|
+
"E",
|
|
319
|
+
"F"
|
|
320
|
+
],
|
|
321
|
+
"lockedNotes": "lost - transfer requires an online re-lock via the mint"
|
|
322
|
+
},
|
|
323
|
+
"status": "pins-current",
|
|
324
|
+
"notes": "Structural, no endpoint. Locked notes are registered claims, not cash; the bearer core and locked notes are complements, not competitors."
|
|
325
|
+
}
|
|
326
|
+
]
|
|
327
|
+
}
|