lnurlcash-conformance 0.1.0 → 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 CHANGED
@@ -4,6 +4,80 @@ 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
+
72
+ ## 0.1.1 - 2026-08-20
73
+
74
+ - The mock mint's mint-address response now carries the node stats
75
+ lnurl-mint advertises: `nodeCapacity` (msat), `nodeNumChannels` and
76
+ `nodeNumPeers`. `nodeCapacity` is the field an implementation is most
77
+ likely to rename on its own side and then forget to map - lnurl-wallet
78
+ and lnurlcash-kit both did, and neither test caught it, because nothing
79
+ they tested against ever sent the wire name.
80
+
7
81
  ## 0.1.0 - 2026-08-20
8
82
 
9
83
  First release. Three things, usable independently.
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 formula - msat-exact,
128
- so a fee implementation that quietly rounds up to whole sats is caught:
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), and a split whose `h` equals `h2` (one id cannot carry
152
- two notes). And it replays the callback as a POST and as an OPTIONS
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
 
@@ -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 */
@@ -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
- // implementation that works in sats rather than msat does - the note
87
- // mints short of the formula
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: `lnurlw://${req.headers.host}/w`,
291
+ withdrawLink:
292
+ opts.withdrawLinkForm === 'plain' ? `${origin}/w` : `lnurlw://${req.headers.host}/w`,
280
293
  disposable: false
281
294
  })
282
295
  }
@@ -298,7 +311,14 @@ export const createMockMint = async (options = {}) => {
298
311
  mintPubkey: pubkey,
299
312
  nodeAlias: 'mock-mint',
300
313
  nodeUri: `${pubkey}@127.0.0.1:9735`,
301
- nodeColor: '#ff9900'
314
+ nodeColor: '#ff9900',
315
+ // The node stats lnurl-mint advertises. `nodeCapacity` is msat, like
316
+ // every other amount, and is named without the suffix on the wire -
317
+ // an implementation that renames it on its own side has to map it,
318
+ // and one that spreads the response through will read undefined.
319
+ nodeCapacity: 500_000_000,
320
+ nodeNumChannels: 4,
321
+ nodeNumPeers: 6
302
322
  })
303
323
  }
304
324
 
@@ -437,7 +457,10 @@ export const createMockMint = async (options = {}) => {
437
457
  }
438
458
 
439
459
  if (!h) return fail('missing h')
440
- if (amountRaw !== null && !h2) return fail('missing h2')
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')
441
464
  if (!/^[0-9a-f]{64}$/.test(h)) return fail('missing h')
442
465
  if (h2 && !/^[0-9a-f]{64}$/.test(h2)) return fail('missing h2')
443
466
  // One id cannot carry two notes, and an id already in use - as a note
@@ -469,8 +492,16 @@ export const createMockMint = async (options = {}) => {
469
492
  // change that cannot cover the fee, or would land at exactly
470
493
  // nothing, refuses with the spec's own reason.
471
494
  const changeBeforeFee = total - amount
472
- if (changeBeforeFee < opts.baseFeeMsat) return fail('insufficient value')
473
- const change = changeBeforeFee - opts.baseFeeMsat
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
+ }
474
505
  if (change < 1) return fail('insufficient value')
475
506
  for (const {note} of found) note.state = 'burned'
476
507
  const sig = mintNote(h, amount)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-conformance",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Language-neutral conformance vectors, an adversarial mock mint, and a grader for LNURLcash (LUD-25) implementations",
5
5
  "author": "TheCryptoDonkey",
6
6
  "license": "MIT",
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 resolved = pay.withdrawLink.replace(/^lnurlw:\/\//i, 'https://')
191
- assert(isAllowedUrl(resolved), `withdrawLink is not fetchable: ${pay.withdrawLink}`)
192
- return pay.withdrawLink
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 ?? '').replace(/^lnurlw:\/\//i, m =>
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.replace(/^lnurlw:\/\//i, m =>
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 expected = applyMintFee(paidMsat, mintFee)
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
- const satRounded = expected - (expected % 1000)
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 === expected,
310
- `paid ${paidMsat} msat against ${feeText}: the formula nets ${expected} msat, the note holds ${info.maxWithdrawable}` +
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
- return `${paidMsat} msat paid -> ${info.maxWithdrawable} msat note (${feeText})`
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.replace(/^lnurlw:\/\//i, m =>
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')
@@ -15,6 +15,7 @@
15
15
  "responses.json",
16
16
  "withdraw-info.json",
17
17
  "pay-request.json",
18
- "lifecycle.json"
18
+ "lifecycle.json",
19
+ "threat-suite.json"
19
20
  ]
20
21
  }
@@ -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
+ }