lnurlcash-conformance 0.5.0 → 0.7.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 CHANGED
@@ -6,6 +6,76 @@ exact version if you gate CI on the grade.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.7.0 - 2026-09-04
10
+
11
+ **`cash-derivation.json`: LUD-25's own seed-recoverable note secrets.** The
12
+ draft specifies a BIP-32 scheme under `m/139'` and the reference wallet
13
+ implements it; `derivation.json` remains, unchanged, as the pre-spec HMAC
14
+ scheme that notes are still outstanding under.
15
+
16
+ Every case carries `cashRoot`, the four `domainIndices`, a `hardened` flag per
17
+ index, the `domainNode` and the resulting `k1`. The flags are the point of the
18
+ file. `d1..d4` are raw uint32 and BIP-32 reads any index `>= 2^31` as hardened,
19
+ so which levels are hardened is decided by the mint's own host name - an
20
+ implementation that masks the top bit, or hardens all four, derives a
21
+ different tree and restores nothing, silently. Both hosts in the vector land
22
+ on a mix, so an implementation that gets it wrong cannot pass by luck.
23
+
24
+ `bip32Vector1` carries BIP-32's own published test vector 1, so a port can
25
+ prove its CKDpriv before blaming the LUD-25 path above it. The generator
26
+ implements BIP-32 from `@noble` primitives rather than importing a BIP-32
27
+ library, for the reason at the top of `tools/generate.mjs`: a vector produced
28
+ by the library under test proves nothing.
29
+
30
+ ## 0.6.0 - 2026-09-03
31
+
32
+ **Offline verification and mutation replay now grade the current LUD-25
33
+ MUSTs.** A rotate, split or merge without a valid note signature fails rather
34
+ than warning, as does a byte-identical retry that is answered as already
35
+ spent. The conforming mock now replays mutations by default; `signatures=false`
36
+ and `retriedMutation=refuse` remain explicit adversarial fixtures.
37
+
38
+ The optional hash lookup is also checked end to end once a mint offers it.
39
+ The runner requires exactly one of `k1` and `h`, compares the unknown-h response
40
+ with an unknown-k1 response, then burns the original note and confirms its hash
41
+ still gets that same unknown-note answer. This catches the privacy leak where a
42
+ mint answers a burned `h` as already spent. The mock gains separate
43
+ `revealsSpent` and `acceptsBoth` fixtures proving those checks fail.
44
+
45
+ The signature vectors now describe `mintPubkey` as a stable SERVICE key. It may
46
+ be the funding node identity where compatible signing exists, or a dedicated,
47
+ persistent secp256k1 key where it does not.
48
+
49
+ **The client-facing vectors carry the same MUSTs.** They had been left behind
50
+ by the prose, still describing a signature as one accepted shape among
51
+ several, so a wallet library could pass every vector while ignoring the
52
+ requirement entirely.
53
+
54
+ `withdraw-info.json` now puts `mintPubkey` on every accepted withdrawRequest
55
+ and rejects a response that omits it or publishes something that is not a
56
+ 33-byte compressed secp256k1 key. A wallet handed a note by a mint that
57
+ publishes no key has nothing to verify it against, which is the gap offline
58
+ verification exists to close.
59
+
60
+ `responses.json` gains an `unverifiable` outcome for a mutation the SERVICE
61
+ confirms but does not sign. It is deliberately not folded into the existing
62
+ `error` or `ambiguous` outcomes: the mutation definitely landed, so the
63
+ wallet-generated secret is the only key to a real note and must be kept, and
64
+ a consumer that treats this as an ordinary failure loses the money it is
65
+ trying to protect. Every case now also declares the `op` it is driven
66
+ through, because a melt mints nothing and so needs no signature, while a
67
+ rotate returning none is the new outcome.
68
+
69
+ **The mock mint starts when its path reaches it through a symlink.** The
70
+ standalone guard compared `import.meta.url` against `file://` plus
71
+ `process.argv[1]`, and node resolves a module's URL to its real path while
72
+ argv[1] stays exactly as it was typed. A checkout symlinked into a sibling
73
+ workspace - which is how the language ports pick this repo up locally - failed
74
+ that comparison, so the mint exited silently, printing nothing at all. Every
75
+ port's adversarial suite then hung to EOF and reported the mint as never having
76
+ announced itself, which says nothing about the cause. CI, checking the repo out
77
+ for real, was never affected, so this only ever broke the local run.
78
+
9
79
  ## 0.5.0 - 2026-08-31
10
80
 
11
81
  **Breaking: the current LUD-25 draft's mandatory mint comment is now the
package/README.md CHANGED
@@ -92,7 +92,7 @@ must survive:
92
92
  | `--echoWrongK1` | answers the informational GET with a different `k1` |
93
93
  | `--lieAboutValue=N` | reports a `maxWithdrawable` it never signed |
94
94
  | `--signatureLayout=leading` | emits the recovery id at the other end |
95
- | `--signatures=false` | issues no signatures at all |
95
+ | `--signatures=false` | deliberately violates LUD-25 by issuing no signatures |
96
96
  | `--serverGeneratedSecrets` | hands back a secret it generated — the exposure `h` exists to close |
97
97
  | `--meltNeverSettles` | holds every melt in flight, so notes stay `pending` |
98
98
  | `--meltAlwaysFails` | fails every payment, restoring the note |
@@ -101,6 +101,11 @@ must survive:
101
101
  | `--baseFeeMsat=N --feePpm=N` | advertises and withholds a mint fee |
102
102
  | `--roundFeeToSat` | rounds the withheld fee up to a whole sat — the note mints short of the formula |
103
103
  | `--verifyLeaksEarly` | serves a preimage before settlement, falsely claiming payment proof before payment happened |
104
+ | `--retriedMutation=refuse` | deliberately answers an identical mutation retry as already spent instead of replaying its original success |
105
+ | `--hashLookup=echoesK1` | offers hash lookup but puts a `k1` back in the response |
106
+ | `--hashLookup=answersUnknown` | offers hash lookup but invents a note for an unknown hash |
107
+ | `--hashLookup=revealsSpent` | offers hash lookup but distinguishes a burned hash from an unknown note |
108
+ | `--hashLookup=acceptsBoth` | offers hash lookup but accepts `k1` and `h` together |
104
109
  | `--mintToHashAcceptsMalformedH` | claims `mintToHash` and invoices an `h` that is not 64 lowercase hex, so a wallet pays for a quote the mint will refuse |
105
110
  | `--mintToHashAcceptsUsedH` | claims it and invoices an `h` that already names a note, an invoice or another quote's output |
106
111
  | `--mintToHashIgnoresH` | claims it but accepts `h` and mandatory `comment` naming different outputs |
@@ -108,16 +113,14 @@ must survive:
108
113
  The three `mintToHash*` misbehaviours need `--mintToHash` alongside them;
109
114
  on their own they do nothing, because a mint that never offered the
110
115
  capability cannot misuse it.
111
- | `--verify=false` | no LUD-21 endpoint at all, not merely unadvertised |
112
- | `--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 |
113
116
 
114
- Five behaviours are outside LUD-25 and outside that table, because none
115
- of them is misbehaviour. All are absent or off unless you ask
116
- for them, so a mock started with no options answers exactly what it always
117
- answered:
117
+ The remaining flags enable optional features, legal wire variants, or make a
118
+ conforming default explicit. Optional fields stay absent unless requested:
118
119
 
119
120
  | Flag | What it does |
120
121
  | --- | --- |
122
+ | `--verify=false` | provides no LUD-21 endpoint at all, rather than merely leaving it unadvertised |
123
+ | `--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 |
121
124
  | `--name --description --contact --tosUrl --motd --version` | mint info on the experimental discovery endpoint: who runs this, how to reach them, the terms, and what the operator wants holders to know today |
122
125
  | `--baseFeeMsat --feePpm` | also publishes `fees: {baseFeeMsat, feePpm}` on that endpoint, the structured twin of the fee line in the payRequest metadata |
123
126
  | `--stats` | serves `GET /stats`: what the mint owes, what is in flight, what the node holds, and the coverage between them |
@@ -125,7 +128,8 @@ answered:
125
128
  | `--previousPubkeys=a,b` | keys this mint has signed under before, so notes issued before a rotation still verify |
126
129
  | `--previousPrivateKey=<hex>` | an old signing key the mock still holds. Its public half joins `previousPubkeys` on its own |
127
130
  | `--signWithPreviousKey` | issues every note under that old key while still advertising the new one: the mid-rotation state a mint passes through when the advertisement moves before the signer |
128
- | `--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 |
131
+ | `--retriedMutation=replay` | answers a byte-identical repeat of a mutation with the original success. This is the conforming default; use `refuse` only as an adversarial fixture |
132
+ | `--hashLookup=true` | accepts an informational lookup by `h=sha256(k1)` without returning the bearer secret. Off by default because the capability is optional |
129
133
  | `--mintToHash` | accepts `h` alongside the mandatory identical comment and enables the additive quote/receipt fields. Off by default; baseline comment-bound minting remains on |
130
134
  | `--mintReceipt` | with `--mintToHash`, adds the optional quote commitment and signed LUD-21 settlement receipt |
131
135
  | `--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 |
@@ -223,8 +227,7 @@ advertised `mintPubkey` or any key the mint still publishes as a previous
223
227
  one, that split and merge conserve value - exactly,
224
228
  under LUD-25's fee algebra, when the mint's fee advertisement is known -
225
229
  that a byte-identical repeat of a mutation is answered with the original
226
- success rather than as an already-spent input (a SHOULD, so a mint that
227
- has not implemented it is reported as such rather than failed), and that a
230
+ success rather than as an already-spent input, and that a
228
231
  burned secret cannot be replayed. It also probes three adversarial shapes a
229
232
  mint must refuse atomically: a duplicated `k1` (which a careless mint counts
230
233
  twice, minting money from nothing), an output hash that collides with an
@@ -10,6 +10,13 @@ export type WithdrawLinkForm = 'lnurlw' | 'plain'
10
10
 
11
11
  export type RetriedMutation = 'refuse' | 'replay'
12
12
 
13
+ export type HashLookup =
14
+ | boolean
15
+ | 'echoesK1'
16
+ | 'answersUnknown'
17
+ | 'revealsSpent'
18
+ | 'acceptsBoth'
19
+
13
20
  /** where a mint claims it accepts an `h` on its pay callback */
14
21
  export type MintToHashPlace = 'payRequest' | 'mintAddress' | 'quote'
15
22
 
@@ -26,7 +33,7 @@ export interface MockMintOptions {
26
33
  * signmessage output unreordered.
27
34
  */
28
35
  signatureLayout?: SignatureLayout
29
- /** withhold sig/sig2 entirely, as a SERVICE with no funding source does */
36
+ /** non-compliant: withhold the mandatory sig/sig2 */
30
37
  signatures?: boolean
31
38
  /**
32
39
  * How the payRequest spells its withdrawLink. 'plain' (default) is the
@@ -124,14 +131,18 @@ export interface MockMintOptions {
124
131
  /**
125
132
  * What a retried mutation gets. A rotate, split or merge is a GET and
126
133
  * HTTP stacks retry a GET on a dropped connection, so a SERVICE sees
127
- * the byte-identical request twice. 'refuse' (default) answers the
128
- * second one as an already-spent input; 'replay' answers it with the
129
- * original success, which is what stops a holder discarding a note the
130
- * SERVICE really did mint. Identical means the same input k1 set, the
134
+ * the byte-identical request twice. 'replay' (default) returns the
135
+ * original success as LUD-25 requires; 'refuse' is the non-compliant
136
+ * fixture. Identical means the same input k1 set, the
131
137
  * same h, the same h2 and the same amount; anything else naming a
132
138
  * burned input is refused exactly as before.
133
139
  */
134
140
  retriedMutation?: RetriedMutation
141
+ /**
142
+ * Optional informational lookup by sha256(k1). `true` is conforming;
143
+ * string values reproduce distinct non-compliant responses.
144
+ */
145
+ hashLookup?: HashLookup
135
146
  /** serve GET /stats, the liabilities endpoint. Off means 404, as before. */
136
147
  stats?: boolean
137
148
  /** what the node behind a stats-publishing mock claims to hold */
@@ -230,8 +241,8 @@ export interface MockMintState {
230
241
  opts: Required<MockMintOptions>
231
242
  /**
232
243
  * Fund a note directly, bypassing the minting flow. Returns the note's
233
- * signature, or undefined when this mint was started with `signatures:
234
- * false` (as a SERVICE with no funding source behaves).
244
+ * signature, or undefined only for the deliberately non-compliant
245
+ * `signatures: false` fixture.
235
246
  *
236
247
  * Pass `{previousKey: true}` to sign it under `previousPrivateKey`
237
248
  * instead, which is how a case puts one note under the old signing key
@@ -15,6 +15,8 @@ import {sha256} from '@noble/hashes/sha2.js'
15
15
  import {secp256k1} from '@noble/curves/secp256k1.js'
16
16
  import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
17
17
  import {randomBytes} from 'node:crypto'
18
+ import {realpathSync} from 'node:fs'
19
+ import {pathToFileURL} from 'node:url'
18
20
 
19
21
  const LSM_PREFIX = 'Lightning Signed Message:'
20
22
 
@@ -148,12 +150,9 @@ const DEFAULTS = {
148
150
  signWithPreviousKey: false,
149
151
  // What a retried mutation gets. A rotate, split or merge is a GET, and
150
152
  // HTTP stacks retry a GET when the connection they used is dropped, so
151
- // a SERVICE sees the byte-identical request twice. 'refuse' answers the
152
- // second one as an already-spent input, which is what this mock has
153
- // always done and remains the default. 'replay' answers it with the
154
- // original success, which is what LUD-25 SHOULD say and what stops a
155
- // holder discarding a note the SERVICE really did mint.
156
- retriedMutation: 'refuse',
153
+ // a SERVICE sees the byte-identical request twice. 'replay' is the
154
+ // conforming default. 'refuse' is retained as the adversarial fixture.
155
+ retriedMutation: 'replay',
157
156
  // Liabilities. Off means 404, exactly as before; on serves GET /stats.
158
157
  stats: false,
159
158
  // What the node behind a stats-publishing mock claims to hold. Read
@@ -191,6 +190,9 @@ const DEFAULTS = {
191
190
  // on the wire the lookup existed to keep it off
192
191
  // 'answersUnknown' - non-compliant: answers for a hash it never
193
192
  // registered, instead of the unknown-note refusal
193
+ // 'revealsSpent' - non-compliant: distinguishes a burned h from an
194
+ // unknown note
195
+ // 'acceptsBoth' - non-compliant: accepts k1 and h together
194
196
  hashLookup: false,
195
197
  // LUD-25 lets a SERVICE refuse an oversized merge outright rather than
196
198
  // let the URL be mangled upstream. 0 is no explicit cap; a positive number
@@ -297,7 +299,7 @@ export const createMockMint = async (options = {}) => {
297
299
  // minted. Recorded, never inferred - matching on "a note exists at h"
298
300
  // alone would let anyone holding a burned k1 and any outstanding note
299
301
  // id pull a success out of the mint.
300
- const swaps = new Map() // identity -> [{id, amountMsat}]
302
+ const swaps = new Map() // identity -> [{id, amountMsat, sig}]
301
303
  // Output ids a mint quote has already claimed: h -> the payment hash of
302
304
  // the invoice that will credit it. Only ever written when mintToHash is
303
305
  // on, so with the option off this is empty and every collision check
@@ -803,13 +805,16 @@ export const createMockMint = async (options = {}) => {
803
805
  // `h` is only ever read here, never at the callback, where the same
804
806
  // letter means the hash of a NEW note.
805
807
  const asked = q.get('h')?.toLowerCase()
808
+ if (k1 && asked && opts.hashLookup !== 'acceptsBoth') return fail('Unknown note.')
806
809
  if (!k1 && asked && opts.hashLookup) {
807
810
  if (!/^[0-9a-f]{64}$/.test(asked)) return fail('Unknown note.')
808
811
  const held = notes.get(asked)
809
812
  const invent = opts.hashLookup === 'answersUnknown' && !held
810
813
  if (!invent) {
811
814
  if (!held) return fail('Unknown note.')
812
- if (held.state === 'burned') return fail('Note already spent.')
815
+ if (held.state === 'burned') {
816
+ return fail(opts.hashLookup === 'revealsSpent' ? 'Note already spent.' : 'Unknown note.')
817
+ }
813
818
  }
814
819
  return send({
815
820
  tag: 'withdrawRequest',
@@ -871,18 +876,16 @@ export const createMockMint = async (options = {}) => {
871
876
 
872
877
  // The retry branch, before anything is refused for a burned input.
873
878
  // This path is a READ: it burns nothing, mints nothing and moves no
874
- // balance, so it does not go through finish() either. The signature
875
- // is deterministic over (id, amount), so it is recomputed rather
876
- // than stored.
879
+ // balance, so it does not go through finish() either. The exact
880
+ // signatures are part of the recorded success, including which
881
+ // published key produced them during a deliberate key rotation.
877
882
  if (opts.retriedMutation === 'replay' && !pr) {
878
883
  const outputs = swaps.get(swapIdentity(k1s, h, h2, amountRaw))
879
884
  if (outputs) {
880
885
  const replay = {status: 'OK'}
881
- const first = sign(outputs[0].id, outputs[0].amountMsat)
882
- if (first) replay.sig = first
886
+ if (outputs[0].sig) replay.sig = outputs[0].sig
883
887
  if (outputs[1]) {
884
- const second = sign(outputs[1].id, outputs[1].amountMsat)
885
- if (second) replay.sig2 = second
888
+ if (outputs[1].sig) replay.sig2 = outputs[1].sig
886
889
  }
887
890
  if (opts.serverGeneratedSecrets) {
888
891
  replay.k1 = 'a'.repeat(64)
@@ -1004,8 +1007,8 @@ export const createMockMint = async (options = {}) => {
1004
1007
  const sig = mintNote(h, amount)
1005
1008
  const sig2 = mintNote(h2, change)
1006
1009
  swaps.set(swapIdentity(k1s, h, h2, amountRaw), [
1007
- {id: h, amountMsat: amount},
1008
- {id: h2, amountMsat: change}
1010
+ {id: h, amountMsat: amount, sig},
1011
+ {id: h2, amountMsat: change, sig: sig2}
1009
1012
  ])
1010
1013
  const body = {status: 'OK'}
1011
1014
  if (sig) body.sig = sig
@@ -1025,7 +1028,7 @@ export const createMockMint = async (options = {}) => {
1025
1028
  for (const {note} of found) note.state = 'burned'
1026
1029
  const sig = mintNote(h, total + refund)
1027
1030
  swaps.set(swapIdentity(k1s, h, h2, amountRaw), [
1028
- {id: h, amountMsat: total + refund}
1031
+ {id: h, amountMsat: total + refund, sig}
1029
1032
  ])
1030
1033
  const body = {status: 'OK'}
1031
1034
  if (sig) body.sig = sig
@@ -1048,8 +1051,28 @@ export const createMockMint = async (options = {}) => {
1048
1051
  }
1049
1052
  }
1050
1053
 
1054
+ // Is this file the entry point?
1055
+ //
1056
+ // The obvious `import.meta.url === \`file://${process.argv[1]}\`` is wrong
1057
+ // wherever the path reaches node through a symlink: node resolves a module's
1058
+ // URL to its real path, while argv[1] stays exactly as it was typed. A
1059
+ // checkout symlinked into a sibling workspace - which is how the language
1060
+ // ports pick this repo up locally - then fails the comparison, and the mint
1061
+ // exits silently, printing nothing at all. Every test that waits for the
1062
+ // "listening on" line hangs to EOF and reports the mint as never having
1063
+ // announced itself, which says nothing about the real cause.
1064
+ const isEntryPoint = () => {
1065
+ const entry = process.argv[1]
1066
+ if (!entry) return false
1067
+ try {
1068
+ return import.meta.url === pathToFileURL(realpathSync(entry)).href
1069
+ } catch {
1070
+ return false
1071
+ }
1072
+ }
1073
+
1051
1074
  // standalone
1052
- if (import.meta.url === `file://${process.argv[1]}`) {
1075
+ if (isEntryPoint()) {
1053
1076
  const flags = {}
1054
1077
  // A flag's value becomes a number only when it really is one. A 64-hex
1055
1078
  // key made of nothing but digits parses as a Number and loses every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-conformance",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
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
@@ -886,6 +886,7 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
886
886
  // the same answer an unknown `k1` gets, so a mint that never implemented
887
887
  // it is indistinguishable from one asked about a note it does not hold.
888
888
  // A live note's own hash is therefore the only probe that separates them.
889
+ let hashLookupOffered = false
889
890
  await report.check('answers a note lookup by hash without the secret (optional)', async () => {
890
891
  const byHash = new URL(url)
891
892
  byHash.searchParams.delete('k1')
@@ -895,6 +896,7 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
895
896
  if (body.status === 'ERROR' || body.tag !== 'withdrawRequest') {
896
897
  return 'not offered - every informational lookup puts the live secret in a query string, where any proxy that logs full URLs keeps it'
897
898
  }
899
+ hashLookupOffered = true
898
900
  // The whole point of the lookup. A wallet asking by hash already holds
899
901
  // the secret - it could not have computed the hash otherwise - so the
900
902
  // field buys it nothing, and filling it in puts the note back on the
@@ -910,16 +912,37 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
910
912
  // An h it never registered must get the unknown-note answer. One that
911
913
  // answers anyway reports a note where none exists, and a wallet
912
914
  // restoring from seed reads that as a note it has lost the secret to.
915
+ const unknownSecret = bytesToHex(randomBytes(32))
913
916
  const nobody = new URL(url)
914
917
  nobody.searchParams.delete('k1')
915
918
  nobody.searchParams.delete('amount')
916
- nobody.searchParams.set('h', noteId(bytesToHex(randomBytes(32))))
919
+ nobody.searchParams.set('h', noteId(unknownSecret))
917
920
  const invented = await get(nobody)
921
+ const unknownK1 = new URL(url)
922
+ unknownK1.searchParams.set('k1', unknownSecret)
923
+ unknownK1.searchParams.delete('h')
924
+ unknownK1.searchParams.delete('amount')
925
+ const ordinaryUnknown = await get(unknownK1)
918
926
  assert(
919
927
  invented.status === 'ERROR' || invented.tag !== 'withdrawRequest',
920
928
  'answered for a hash it never registered - an unrecognized h must get the same response an unknown k1 would'
921
929
  )
922
- return 'by hash, with no k1 in the reply'
930
+ assert(
931
+ invented.status === ordinaryUnknown.status && invented.reason === ordinaryUnknown.reason,
932
+ `an unknown h answered ${JSON.stringify(invented)} but an unknown k1 answered ${JSON.stringify(ordinaryUnknown)}`
933
+ )
934
+
935
+ // `h` is accepted in place of `k1`, not alongside it. Accepting both
936
+ // lets an intermediary add a second lookup identity and leaves clients
937
+ // unable to know which note the response describes.
938
+ const both = new URL(url)
939
+ both.searchParams.set('h', noteId(k1))
940
+ const ambiguous = await get(both)
941
+ assert(
942
+ ambiguous.status === 'ERROR',
943
+ 'accepted both k1 and h on one informational lookup - exactly one lookup identity is allowed'
944
+ )
945
+ return 'by hash, with no k1 in the reply; unknown and mixed lookups refused'
923
946
  })
924
947
 
925
948
  // The merge cap. LUD-25 bounds a merge by URL length rather than by the
@@ -1011,9 +1034,10 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1011
1034
  return 'burned the old secret, minted the new'
1012
1035
  })
1013
1036
 
1014
- await report.check('signs the notes it issues (optional)', async () => {
1015
- if (!info.mintPubkey) throw soft('no mintPubkey advertised - offline verification unavailable')
1016
- if (!currentSig) throw soft('mintPubkey advertised but no sig returned')
1037
+ await report.check('signs the notes it issues', async () => {
1038
+ assert(info.mintPubkey, 'no mintPubkey advertised - offline verification is mandatory')
1039
+ assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
1040
+ assert(currentSig, 'the rotate returned no sig - offline verification is mandatory')
1017
1041
  // A mint that has rotated its signing key publishes the old ones as
1018
1042
  // previousPubkeys, so notes it issued before the rotation still
1019
1043
  // verify. Any key it currently stands behind is an acceptable signer.
@@ -1031,6 +1055,28 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1031
1055
  : `verified offline against a previous signing key (${signedBy.slice(0, 16)}...)`
1032
1056
  })
1033
1057
 
1058
+ await report.check('does not reveal a burned note through hash lookup', async () => {
1059
+ if (!hashLookupOffered) throw soft('hash lookup is not offered')
1060
+ const burned = new URL(url)
1061
+ burned.searchParams.delete('k1')
1062
+ burned.searchParams.delete('amount')
1063
+ burned.searchParams.set('h', noteId(k1))
1064
+ const burnedResponse = await get(burned)
1065
+
1066
+ const unknown = new URL(url)
1067
+ unknown.searchParams.set('k1', bytesToHex(randomBytes(32)))
1068
+ unknown.searchParams.delete('h')
1069
+ unknown.searchParams.delete('amount')
1070
+ const unknownResponse = await get(unknown)
1071
+
1072
+ assert(
1073
+ burnedResponse.status === unknownResponse.status &&
1074
+ burnedResponse.reason === unknownResponse.reason,
1075
+ `a burned h answered ${JSON.stringify(burnedResponse)} but an unknown k1 answered ${JSON.stringify(unknownResponse)}`
1076
+ )
1077
+ return 'burned h is indistinguishable from an unknown note'
1078
+ })
1079
+
1034
1080
  await report.check('keeps signatures off the informational endpoint', async () => {
1035
1081
  // LUD-25: "Signatures are only ever delivered in the
1036
1082
  // withdrawSuccessResponse of a rotate, split or merge, the
@@ -1260,11 +1306,9 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1260
1306
  // discards the only copy of a secret the SERVICE really did mint a note
1261
1307
  // against. Nobody is told; the money is simply gone.
1262
1308
  //
1263
- // Soft, because this is a SHOULD. A SERVICE that has not implemented it
1264
- // is reported as not having implemented it, not failed. What is NOT
1265
- // soft is damage: a retry that burns the output, or changes its value,
1266
- // fails outright whichever answer it gives.
1267
- await report.check('replays a retried mutation rather than refusing it (optional)', async () => {
1309
+ // This is a MUST. A retry must replay the original success and must not
1310
+ // burn or alter either output.
1311
+ await report.check('replays a retried mutation rather than refusing it', async () => {
1268
1312
  const valueAt = async secret => {
1269
1313
  const u = new URL(url)
1270
1314
  u.searchParams.set('k1', secret)
@@ -1295,22 +1339,18 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1295
1339
  `the retried rotate changed the note's value: ${minted} -> ${stillThere}`
1296
1340
  )
1297
1341
 
1298
- const problems = []
1299
- if (retried.status === 'ERROR') {
1300
- problems.push(
1301
- `a retried rotate is answered "${retried.reason}" while the note it minted is live and worth ${stillThere} msat`
1302
- )
1303
- } else if (first.sig && retried.sig !== first.sig) {
1304
- problems.push('a retried rotate replied OK but with a different sig than the original')
1305
- }
1342
+ assert(
1343
+ retried.status === 'OK',
1344
+ `a retried rotate is answered "${retried.reason}" while the note it minted is live and worth ${stillThere} msat`
1345
+ )
1346
+ assert(retried.sig === first.sig, 'a retried rotate returned a different sig than the original')
1306
1347
 
1307
1348
  // --- a split, retried ---
1308
1349
  // h2 and the change amount are part of what makes a request the same
1309
1350
  // request, so a rotate on its own does not cover it.
1310
1351
  const half = Math.floor(minted / 2)
1311
1352
  if (half < 1 || (knownBaseFee !== null && minted - half < knownBaseFee + 1)) {
1312
- if (problems.length > 0) throw soft(problems.join('; ') + '; note too small to retry a split')
1313
- return 'a byte-identical rotate replays; note too small to retry a split'
1353
+ throw soft('a byte-identical rotate replays; note too small to exercise a split retry')
1314
1354
  }
1315
1355
  const a = bytesToHex(randomBytes(32))
1316
1356
  const b = bytesToHex(randomBytes(32))
@@ -1321,8 +1361,7 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1321
1361
  split.searchParams.append('h2', noteId(b))
1322
1362
  const splitFirst = await get(split)
1323
1363
  if (splitFirst.status !== 'OK') {
1324
- if (problems.length > 0) throw soft(problems.join('; ') + `; the split itself was refused: ${splitFirst.reason}`)
1325
- throw soft(`a byte-identical rotate replays; the split itself was refused: ${splitFirst.reason}`)
1364
+ throw new Error(`the split itself was refused: ${splitFirst.reason}`)
1326
1365
  }
1327
1366
  const [va, vb] = [await valueAt(a), await valueAt(b)]
1328
1367
  const splitRetried = await get(split)
@@ -1331,11 +1370,12 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1331
1370
  va2 === va && vb2 === vb,
1332
1371
  `the retried split changed its outputs: ${va}/${vb} -> ${va2}/${vb2}`
1333
1372
  )
1334
- if (splitRetried.status === 'ERROR') {
1335
- problems.push(`a retried split is answered "${splitRetried.reason}" while both its outputs are live`)
1336
- } else if (splitFirst.sig2 && splitRetried.sig2 !== splitFirst.sig2) {
1337
- problems.push('a retried split replied OK but with a different sig2 than the original')
1338
- }
1373
+ assert(
1374
+ splitRetried.status === 'OK',
1375
+ `a retried split is answered "${splitRetried.reason}" while both its outputs are live`
1376
+ )
1377
+ assert(splitRetried.sig === splitFirst.sig, 'a retried split returned a different sig than the original')
1378
+ assert(splitRetried.sig2 === splitFirst.sig2, 'a retried split returned a different sig2 than the original')
1339
1379
 
1340
1380
  // put the two halves back together, so the runner ends holding one note
1341
1381
  const merged = bytesToHex(randomBytes(32))
@@ -1349,7 +1389,6 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1349
1389
  currentSig = mergeBody.sig ?? null
1350
1390
  }
1351
1391
 
1352
- if (problems.length > 0) throw soft(problems.join('; '))
1353
1392
  return 'a byte-identical rotate and split both replay the original success'
1354
1393
  })
1355
1394
 
@@ -0,0 +1,160 @@
1
+ {
2
+ "version": 1,
3
+ "spec": "LUD-25 draft (lnurl/luds#301)",
4
+ "description": "LUD-25's specified seed-recoverable note secrets, the BIP-32 scheme under m/139'. cashRoot is m/139' as privateKey||chainCode hex (64 bytes, no version/depth/fingerprint framing). domainIndices are the four raw uint32 read big-endian from the first 16 bytes of HMAC-SHA256(key = the private key at m/139'/0, msg = utf8(host)); they are used as BIP-32 child indices exactly as they fall, so `hardened` records which of them land >= 2^31 by magnitude - an implementation that masks the top bit or hardens all four derives a different tree and will restore nothing. domainNode is m/139'/d1/d2/d3/d4. k1 is the private key at the hardened child `index` of that node, 32 bytes lowercase hex, and the mint sees only sha256(k1) as ever. host is the mint host exactly as the wallet stores it, with the port when there is one. seedHex is the 64-byte BIP39 seed with no passphrase. bip32Vector1 is BIP-32's own published test vector 1, so CKDpriv itself can be checked first.",
5
+ "scheme": {
6
+ "purpose": "m/139'",
7
+ "hashingKey": "m/139'/0",
8
+ "domainMsg": "host",
9
+ "secretPath": "m/139'/d1/d2/d3/d4/i'",
10
+ "hardenedByMagnitudeOnly": true
11
+ },
12
+ "bip32Vector1": [
13
+ {
14
+ "index": null,
15
+ "node": "e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508"
16
+ },
17
+ {
18
+ "index": 2147483648,
19
+ "hardened": true,
20
+ "node": "edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141"
21
+ },
22
+ {
23
+ "index": 1,
24
+ "hardened": false,
25
+ "node": "3c6cb8d0f6a264c91ea8b5030fadaa8e538b020f0a387421a12de9319dc933682a7857631386ba23dacac34180dd1983734e444fdbf774041578e9b6adb37c19"
26
+ },
27
+ {
28
+ "index": 2147483650,
29
+ "hardened": true,
30
+ "node": "cbce0d719ecf7431d88e6a89fa1483e02e35092af60c042b1df2ff59fa424dca04466b9cc8e161e966409ca52986c584f07e9dc81f735db683c3ff6ec7b1503f"
31
+ },
32
+ {
33
+ "index": 2,
34
+ "hardened": false,
35
+ "node": "0f479245fb19a38a1954c5c7c0ebab2f9bdfd96a17563ef28a6a4b1a2a764ef4cfb71883f01676f587d023cc53a35bc7f88f724b1f8c2892ac1275ac822a3edd"
36
+ },
37
+ {
38
+ "index": 1000000000,
39
+ "hardened": false,
40
+ "node": "471b76e389e528d6de6d816857e012c5455051cad6660850e58372a6c3e6e7c8c783e67b921d2beb8f6b389cc646d7263b4145701dadd2161548a8b078e65e9e"
41
+ }
42
+ ],
43
+ "cases": [
44
+ {
45
+ "name": "standard mnemonic, index 0",
46
+ "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
47
+ "seedHex": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
48
+ "host": "mint.example",
49
+ "index": 0,
50
+ "cashRoot": "c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60",
51
+ "domainIndices": [
52
+ 2589708612,
53
+ 3693348916,
54
+ 172082394,
55
+ 3793182078
56
+ ],
57
+ "hardened": [
58
+ true,
59
+ true,
60
+ false,
61
+ true
62
+ ],
63
+ "domainNode": "72056e5cde21458b13689c3950904dfd327415a506d064290e5e5f4296a40543dd5e9504ddb6eefbafa4ad3b00ad421858fafc0ed9ea4abd9cb68793f845cfc1",
64
+ "k1": "de5b81405a12e1297b350d80e2ad85043ed5b9436a0c5592d3302778de330499",
65
+ "noteId": "7db9da2845cd45c1c3c2e302d6135da46823e245f756b830ef59ac324b769e02"
66
+ },
67
+ {
68
+ "name": "standard mnemonic, index 1",
69
+ "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
70
+ "seedHex": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
71
+ "host": "mint.example",
72
+ "index": 1,
73
+ "cashRoot": "c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60",
74
+ "domainIndices": [
75
+ 2589708612,
76
+ 3693348916,
77
+ 172082394,
78
+ 3793182078
79
+ ],
80
+ "hardened": [
81
+ true,
82
+ true,
83
+ false,
84
+ true
85
+ ],
86
+ "domainNode": "72056e5cde21458b13689c3950904dfd327415a506d064290e5e5f4296a40543dd5e9504ddb6eefbafa4ad3b00ad421858fafc0ed9ea4abd9cb68793f845cfc1",
87
+ "k1": "267570df5ba8098d728e839a698f729c2df6fa7b8b7ae7c9c7ffa7dda3417e1d",
88
+ "noteId": "99b98cb845940e0d6401e176aaca1446365aa315201803861e17f3c530ed1dcf"
89
+ },
90
+ {
91
+ "name": "standard mnemonic, index 20 (one past a 20-index gap limit)",
92
+ "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
93
+ "seedHex": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
94
+ "host": "mint.example",
95
+ "index": 20,
96
+ "cashRoot": "c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60",
97
+ "domainIndices": [
98
+ 2589708612,
99
+ 3693348916,
100
+ 172082394,
101
+ 3793182078
102
+ ],
103
+ "hardened": [
104
+ true,
105
+ true,
106
+ false,
107
+ true
108
+ ],
109
+ "domainNode": "72056e5cde21458b13689c3950904dfd327415a506d064290e5e5f4296a40543dd5e9504ddb6eefbafa4ad3b00ad421858fafc0ed9ea4abd9cb68793f845cfc1",
110
+ "k1": "1f2b8080d4c9431b1dec780abb4eee3c4aaf7ff03f7dd126a7e005ec7af372e0",
111
+ "noteId": "0daebdd7f9355a215c511cadc96e73f586e07238c352b43b21c080713b79cbfc"
112
+ },
113
+ {
114
+ "name": "a second mnemonic, same host and index",
115
+ "mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow",
116
+ "seedHex": "878386efb78845b3355bd15ea4d39ef97d179cb712b77d5c12b6be415fffeffe5f377ba02bf3f8544ab800b955e51fbff09828f682052a20faa6addbbddfb096",
117
+ "host": "mint.example",
118
+ "index": 0,
119
+ "cashRoot": "e58252908e0c10965d15d1e3894cbf206a2102c20eb9519e466e2e0e852075d430afaa22bb69ef2151cdffacc463e51068e854152b0b7c08fa2f93e6b8d16741",
120
+ "domainIndices": [
121
+ 3862277158,
122
+ 545631060,
123
+ 182776155,
124
+ 2195829862
125
+ ],
126
+ "hardened": [
127
+ true,
128
+ false,
129
+ false,
130
+ true
131
+ ],
132
+ "domainNode": "df3c9156afc2e416286df9bbfda49c6ab72305ba3d65ae0b7e2214caf581b111959d96bc0c075185ff7304214496a78f43b22ea511f97a7bf408454a7e3d3a85",
133
+ "k1": "d5376fa38f01b39d55be6dc0ea8f3aa6b91f37c183c5a394bb7e30b624855a78",
134
+ "noteId": "5b4ba51285373e8a8c0414e6b3607ea0a6b70968c94f2b9f73ffdfdedfba1c15"
135
+ },
136
+ {
137
+ "name": "a host carrying a port",
138
+ "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
139
+ "seedHex": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
140
+ "host": "127.0.0.1:8899",
141
+ "index": 0,
142
+ "cashRoot": "c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60",
143
+ "domainIndices": [
144
+ 2087962263,
145
+ 3073061246,
146
+ 2281736429,
147
+ 1205328740
148
+ ],
149
+ "hardened": [
150
+ false,
151
+ true,
152
+ true,
153
+ false
154
+ ],
155
+ "domainNode": "80bdd2d71f0235bd9e22d5838a4a4f346cf5415309ada559942a409a630e102ce214425b89b36d03d4835fdf6592d2619c74d37b746711b4ed85759ea1d358a6",
156
+ "k1": "d7bcc5c9e7015ca2688ed10e24db3f82163d5b59fc887e5dd346abf2426b1270",
157
+ "noteId": "1a8a732d7ec9811654183ecbb27b1887dec947a42c877a12bd93f952837a3887"
158
+ }
159
+ ]
160
+ }
@@ -6,6 +6,7 @@
6
6
  "files": [
7
7
  "signature.json",
8
8
  "derivation.json",
9
+ "cash-derivation.json",
9
10
  "bech32.json",
10
11
  "url-admission.json",
11
12
  "input-resolution.json",
@@ -58,7 +58,7 @@
58
58
  "the connection drops after the SERVICE applied it",
59
59
  "the HTTP stack silently resends the identical request"
60
60
  ],
61
- "requirement": "a WALLET MUST ensure its HTTP stack does not retry a mutating callback. Every mutation is a GET, HTTP treats GET as idempotent, and an LNURLcash mutation is not: the first attempt burns the input. A retried mutation is answered \"invalid or already spent k1\", which classifies as a DEFINITIVE rejection - so the WALLET concludes nothing happened and discards the fresh secret that was the only copy of the note the SERVICE just minted. This is not hypothetical: it was found in two independent implementations during this suite's own development. Java's java.net.http.HttpClient retries idempotent GETs on a mid-flight connection reset and cannot be configured out of it (use a client that can); Go's net/http retries when the request went over a REUSED connection, and a client with no explicit Transport shares a process-wide pool, so the behaviour depends on what unrelated code did first (disable keep-alives). Browsers retry on a stale pooled connection for the same reason. Test this deliberately: the mock mint's dropAfterMutation mode reproduces it."
61
+ "requirement": "a SERVICE MUST recognize the byte-identical retry from the same k1 set, h, h2 and amount, and return the original success with the same sig and sig2 without moving balance again. A WALLET still persists every fresh output secret before sending the first request and keeps it across an ambiguous transport failure. A request that changes any recorded field is a genuine double-spend attempt and gets the ordinary already-spent refusal."
62
62
  },
63
63
  {
64
64
  "name": "settle a merge or split output",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
3
  "spec": "LUD-25 draft (lnurl/luds#301)",
4
- "description": "A note is an ordinary LUD-03 withdrawRequest URL whose k1 IS the asset. `amount` alongside it is only a claim by whoever encoded the note - the authoritative value is always maxWithdrawable from an informational GET. `sig` is the optional offline-verification signature.",
4
+ "description": "A note is an ordinary LUD-03 withdrawRequest URL whose k1 IS the asset. `amount` alongside it is only a claim by whoever encoded the note - the authoritative value is always maxWithdrawable from an informational GET. A SERVICE returning a rotate, split or merge MUST include the offline-verification signature in `sig` (and `sig2` for the second split output).",
5
5
  "parse": [
6
6
  {
7
7
  "url": "https://mint.example/w?k1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&amount=21000",
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "version": 1,
3
3
  "spec": "LUD-25 draft (lnurl/luds#301)",
4
- "description": "Classifying a SERVICE response. The distinction that matters for funds is definitive-rejection versus ambiguous-outcome: a parsed {\"status\":\"ERROR\"} means the request was processed and refused, while a transport failure, an unparseable body, or a 200 that does not confirm means the mutation MAY have landed - and for rotate/split/merge the WALLET-generated secrets are then the only copy of the outputs, so they must ride the error rather than be discarded.",
4
+ "description": "Classifying a SERVICE response. The distinction that matters for funds is definitive-rejection versus ambiguous-outcome: a parsed {\"status\":\"ERROR\"} means the request was processed and refused, while a transport failure, an unparseable body, or a 200 that does not confirm means the mutation MAY have landed - and for rotate/split/merge the WALLET-generated secrets are then the only copy of the outputs, so they must ride the error rather than be discarded. A confirmed mutation carrying no signature is its own outcome: it definitely landed, so the secrets matter more than ever, and the SERVICE is non-conforming. `op` says which call each case is driven through - a melt is the one mutation with no signature to return.",
5
5
  "outcomes": {
6
6
  "ok": "the operation is confirmed",
7
+ "unverifiable": "the mutation is confirmed but carries no signature. LUD-25 requires one on every rotate, split and merge, so this SERVICE is non-conforming - but the note EXISTS at the hash the WALLET disclosed, and its secret is the only key to that value. Keep the secret; report the mint",
7
8
  "pending": "this k1 has another operation in flight (a melt); retry shortly",
8
9
  "spent": "the SERVICE is authoritative that the note is already burned; a holder may lock it as spent",
9
10
  "unknown": "the SERVICE does not recognise this note; surface it, do not silently lock it",
@@ -13,14 +14,17 @@
13
14
  "cases": [
14
15
  {
15
16
  "name": "plain success",
17
+ "op": "mutation",
16
18
  "http": 200,
17
19
  "body": {
18
20
  "status": "OK"
19
21
  },
20
- "expect": "ok"
22
+ "expect": "unverifiable",
23
+ "why": "a bare OK was a conforming rotate answer while offline verification was optional. It is not one now: the note is real and the WALLET must keep its secret, but nobody the holder hands it to can check it"
21
24
  },
22
25
  {
23
26
  "name": "success with an offline-verification signature",
27
+ "op": "mutation",
24
28
  "http": 200,
25
29
  "body": {
26
30
  "status": "OK",
@@ -31,6 +35,7 @@
31
35
  },
32
36
  {
33
37
  "name": "split success with both signatures",
38
+ "op": "split",
34
39
  "http": 200,
35
40
  "body": {
36
41
  "status": "OK",
@@ -41,8 +46,20 @@
41
46
  "signature": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
42
47
  "changeSignature": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"
43
48
  },
49
+ {
50
+ "name": "a split that signs only its first output",
51
+ "op": "split",
52
+ "http": 200,
53
+ "body": {
54
+ "status": "OK",
55
+ "sig": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab"
56
+ },
57
+ "expect": "unverifiable",
58
+ "why": "both outputs of a split are notes and both need a signature; the change is not a lesser note"
59
+ },
44
60
  {
45
61
  "name": "melt success with a LUD-21 style proof",
62
+ "op": "melt",
46
63
  "http": 200,
47
64
  "body": {
48
65
  "status": "OK",
@@ -50,10 +67,11 @@
50
67
  "verify": "https://mint.example/verify/abc"
51
68
  },
52
69
  "expect": "ok",
53
- "why": "OK on a melt means the payment is in flight, NOT that the note is confirmed spent"
70
+ "why": "OK on a melt means the payment is in flight, NOT that the note is confirmed spent. A melt mints nothing, so it has no signature to return and none is required"
54
71
  },
55
72
  {
56
73
  "name": "pending",
74
+ "op": "mutation",
57
75
  "http": 200,
58
76
  "body": {
59
77
  "status": "ERROR",
@@ -64,6 +82,7 @@
64
82
  },
65
83
  {
66
84
  "name": "already spent",
85
+ "op": "mutation",
67
86
  "http": 200,
68
87
  "body": {
69
88
  "status": "ERROR",
@@ -73,6 +92,7 @@
73
92
  },
74
93
  {
75
94
  "name": "unknown note",
95
+ "op": "mutation",
76
96
  "http": 200,
77
97
  "body": {
78
98
  "status": "ERROR",
@@ -82,6 +102,7 @@
82
102
  },
83
103
  {
84
104
  "name": "not found wording",
105
+ "op": "mutation",
85
106
  "http": 200,
86
107
  "body": {
87
108
  "status": "ERROR",
@@ -91,6 +112,7 @@
91
112
  },
92
113
  {
93
114
  "name": "ambiguous callback wording is treated as spent",
115
+ "op": "mutation",
94
116
  "http": 200,
95
117
  "body": {
96
118
  "status": "ERROR",
@@ -101,6 +123,7 @@
101
123
  },
102
124
  {
103
125
  "name": "some other refusal",
126
+ "op": "mutation",
104
127
  "http": 200,
105
128
  "body": {
106
129
  "status": "ERROR",
@@ -110,6 +133,7 @@
110
133
  },
111
134
  {
112
135
  "name": "error with no reason",
136
+ "op": "mutation",
113
137
  "http": 200,
114
138
  "body": {
115
139
  "status": "ERROR"
@@ -118,6 +142,7 @@
118
142
  },
119
143
  {
120
144
  "name": "200 with neither OK nor ERROR",
145
+ "op": "mutation",
121
146
  "http": 200,
122
147
  "body": {
123
148
  "something": "else"
@@ -127,23 +152,27 @@
127
152
  },
128
153
  {
129
154
  "name": "unparseable body",
155
+ "op": "mutation",
130
156
  "http": 200,
131
157
  "bodyRaw": "not json at all",
132
158
  "expect": "ambiguous"
133
159
  },
134
160
  {
135
161
  "name": "server error",
162
+ "op": "mutation",
136
163
  "http": 500,
137
164
  "bodyRaw": "upstream failure",
138
165
  "expect": "ambiguous"
139
166
  },
140
167
  {
141
168
  "name": "transport failure",
169
+ "op": "mutation",
142
170
  "transportError": true,
143
171
  "expect": "ambiguous"
144
172
  },
145
173
  {
146
174
  "name": "timeout",
175
+ "op": "mutation",
147
176
  "timeout": true,
148
177
  "expect": "ambiguous"
149
178
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
3
  "spec": "LUD-25 draft (lnurl/luds#301)",
4
- "description": "What counts as a retry of a mutation, and what is still a double-spend attempt. Every mutation in LUD-25 is a GET, and HTTP stacks retry a GET when the connection they used is dropped, so a SERVICE sees the byte-identical request twice and the second one arrives with its inputs already burned. A SERVICE that answers the retry as an already-spent input tells the holder the mutation never happened, and the holder discards the only copy of a secret the SERVICE really did mint a note against. Replaying the original success instead is a SHOULD, not a MUST: a SERVICE that has not implemented it is not broken, but a SERVICE that has must draw the line in exactly this place, or two SERVICEs give the same wallet two different answers to the same dropped connection. The replay path is a read - it burns nothing, mints nothing and moves no balance - and the signature is deterministic over the output id and amount, so it is recomputed rather than stored. Anything that is NOT a retry and names a burned input is refused exactly as an ordinary double-spend is, with the same reason string, so no oracle appears for whoever is holding a burned secret.",
4
+ "description": "What counts as a retry of a mutation, and what is still a double-spend attempt. Every mutation in LUD-25 is a GET, and HTTP stacks retry a GET when the connection they used is dropped, so a SERVICE sees the byte-identical request twice and the second one arrives with its inputs already burned. A SERVICE that answers the retry as an already-spent input tells the holder the mutation never happened, and the holder discards the only copy of a secret the SERVICE really did mint a note against. Replaying the original success is therefore a MUST. The replay path is a read: it burns nothing, mints nothing and moves no balance, and returns the same signature or signatures. Anything that is NOT a retry and names a burned input is refused exactly as an ordinary double-spend is, with the same reason string, so no oracle appears for whoever is holding a burned secret.",
5
5
  "identity": [
6
6
  "the same input k1 set, compared as a SET: a merge naming the same notes in a different order is the same merge",
7
7
  "the same h",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
3
  "spec": "LUD-25 draft (lnurl/luds#301)",
4
- "description": "Offline verification of a note (LUD-25). A SERVICE signs (note id, amount) with its Lightning node identity key via the standard signmessage wrapping; a holder recovers the pubkey and compares it to mintPubkey without contacting the SERVICE.",
4
+ "description": "Offline verification of a note (LUD-25). A SERVICE signs (note id, amount) with a stable secp256k1 key it controls; this should be the funding node identity where compatible signing exists, or may be a dedicated persistent SERVICE key otherwise. A holder recovers the pubkey and compares it to the pinned current or previous mintPubkey without contacting the SERVICE.",
5
5
  "scheme": {
6
6
  "domainTag": "LNURLcash",
7
7
  "lightningSignedMessagePrefix": "Lightning Signed Message:",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
3
  "spec": "LUD-03, LUD-25",
4
- "description": "The informational GET on a note. Never burns, rotates or alters it. maxWithdrawable is the ONLY authoritative statement of what a note is worth - the URL's own `amount` is a claim and the SERVICE ignores it here. The response's k1 MUST be the bearer secret itself, never a derived or opaque id.",
4
+ "description": "The informational GET on a note. Never burns, rotates or alters it. maxWithdrawable is the ONLY authoritative statement of what a note is worth - the URL's own `amount` is a claim and the SERVICE ignores it here. The response's k1 MUST be the bearer secret itself, never a derived or opaque id. Offline verification is mandatory in the current draft, so the response MUST also carry `mintPubkey`, the stable compressed secp256k1 key the SERVICE's note signatures verify against; without it a holder handed a note has nothing to check it against.",
5
5
  "queriedUrl": "https://mint.example/w?k1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&amount=99999&sig=ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
6
6
  "requestMustNotSend": [
7
7
  "sig"
@@ -17,42 +17,45 @@
17
17
  "callback": "https://mint.example/w/cb",
18
18
  "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
19
19
  "minWithdrawable": 0,
20
- "maxWithdrawable": 21000
20
+ "maxWithdrawable": 21000,
21
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
21
22
  },
22
23
  "maxWithdrawable": 21000
23
24
  },
24
25
  {
25
- "name": "with a mint pubkey for offline verification",
26
+ "name": "minWithdrawable omitted",
26
27
  "body": {
27
28
  "tag": "withdrawRequest",
28
29
  "callback": "https://mint.example/w/cb",
29
30
  "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
30
- "minWithdrawable": 0,
31
31
  "maxWithdrawable": 21000,
32
32
  "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
33
33
  },
34
34
  "maxWithdrawable": 21000
35
35
  },
36
36
  {
37
- "name": "minWithdrawable omitted",
37
+ "name": "echoed k1 in a different casing",
38
38
  "body": {
39
39
  "tag": "withdrawRequest",
40
40
  "callback": "https://mint.example/w/cb",
41
- "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
42
- "maxWithdrawable": 21000
41
+ "k1": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
42
+ "maxWithdrawable": 21000,
43
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
43
44
  },
44
- "maxWithdrawable": 21000
45
+ "maxWithdrawable": 21000,
46
+ "why": "k1 is bytes; casing carries no meaning"
45
47
  },
46
48
  {
47
- "name": "echoed k1 in a different casing",
49
+ "name": "mintPubkey in a different casing",
48
50
  "body": {
49
51
  "tag": "withdrawRequest",
50
52
  "callback": "https://mint.example/w/cb",
51
- "k1": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
52
- "maxWithdrawable": 21000
53
+ "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
54
+ "maxWithdrawable": 21000,
55
+ "mintPubkey": "034F355BDCB7CC0AF728EF3CCEB9615D90684BB5B2CA5F859AB0F0B704075871AA"
53
56
  },
54
57
  "maxWithdrawable": 21000,
55
- "why": "k1 is bytes; casing carries no meaning"
58
+ "why": "a pubkey is bytes too, and a SERVICE that upper-cases its hex is still naming the same key"
56
59
  },
57
60
  {
58
61
  "name": "zero value note",
@@ -60,7 +63,8 @@
60
63
  "tag": "withdrawRequest",
61
64
  "callback": "https://mint.example/w/cb",
62
65
  "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
63
- "maxWithdrawable": 0
66
+ "maxWithdrawable": 0,
67
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
64
68
  },
65
69
  "maxWithdrawable": 0
66
70
  }
@@ -72,7 +76,8 @@
72
76
  "tag": "payRequest",
73
77
  "callback": "https://mint.example/w/cb",
74
78
  "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
75
- "maxWithdrawable": 21000
79
+ "maxWithdrawable": 21000,
80
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
76
81
  }
77
82
  },
78
83
  {
@@ -80,7 +85,8 @@
80
85
  "body": {
81
86
  "tag": "withdrawRequest",
82
87
  "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
83
- "maxWithdrawable": 21000
88
+ "maxWithdrawable": 21000,
89
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
84
90
  }
85
91
  },
86
92
  {
@@ -88,7 +94,8 @@
88
94
  "body": {
89
95
  "tag": "withdrawRequest",
90
96
  "callback": "https://mint.example/w/cb",
91
- "maxWithdrawable": 21000
97
+ "maxWithdrawable": 21000,
98
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
92
99
  }
93
100
  },
94
101
  {
@@ -96,7 +103,8 @@
96
103
  "body": {
97
104
  "tag": "withdrawRequest",
98
105
  "callback": "https://mint.example/w/cb",
99
- "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
106
+ "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
107
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
100
108
  }
101
109
  },
102
110
  {
@@ -105,7 +113,8 @@
105
113
  "tag": "withdrawRequest",
106
114
  "callback": "https://mint.example/w/cb",
107
115
  "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
108
- "maxWithdrawable": "21000"
116
+ "maxWithdrawable": "21000",
117
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
109
118
  }
110
119
  },
111
120
  {
@@ -114,7 +123,8 @@
114
123
  "tag": "withdrawRequest",
115
124
  "callback": "https://mint.example/w/cb",
116
125
  "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
117
- "maxWithdrawable": -1
126
+ "maxWithdrawable": -1,
127
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
118
128
  }
119
129
  },
120
130
  {
@@ -123,7 +133,8 @@
123
133
  "tag": "withdrawRequest",
124
134
  "callback": "https://mint.example/w/cb",
125
135
  "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
126
- "maxWithdrawable": 21000.5
136
+ "maxWithdrawable": 21000.5,
137
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
127
138
  },
128
139
  "why": "msat are integers"
129
140
  },
@@ -133,7 +144,8 @@
133
144
  "tag": "withdrawRequest",
134
145
  "callback": "https://mint.example/w/cb",
135
146
  "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
136
- "maxWithdrawable": 18446744073709552000
147
+ "maxWithdrawable": 18446744073709552000,
148
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
137
149
  },
138
150
  "why": "no common integer type holds it and a double rounds it - refuse rather than guess the amount"
139
151
  },
@@ -144,7 +156,8 @@
144
156
  "callback": "https://mint.example/w/cb",
145
157
  "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
146
158
  "minWithdrawable": 30000,
147
- "maxWithdrawable": 21000
159
+ "maxWithdrawable": 21000,
160
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
148
161
  }
149
162
  },
150
163
  {
@@ -153,9 +166,32 @@
153
166
  "tag": "withdrawRequest",
154
167
  "callback": "https://mint.example/w/cb",
155
168
  "k1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
156
- "maxWithdrawable": 21000
169
+ "maxWithdrawable": 21000,
170
+ "mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
157
171
  },
158
172
  "why": "spec MUST: the response k1 is the bearer secret itself. A SERVICE returning something else is non-compliant, or the note was rotated by someone else"
173
+ },
174
+ {
175
+ "name": "no mintPubkey",
176
+ "body": {
177
+ "tag": "withdrawRequest",
178
+ "callback": "https://mint.example/w/cb",
179
+ "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
180
+ "minWithdrawable": 0,
181
+ "maxWithdrawable": 21000
182
+ },
183
+ "why": "offline verification is mandatory. Without the key a note signature verifies against nothing, and whoever is handed the note is back to the leap of faith the signature exists to remove"
184
+ },
185
+ {
186
+ "name": "mintPubkey that is not a compressed secp256k1 key",
187
+ "body": {
188
+ "tag": "withdrawRequest",
189
+ "callback": "https://mint.example/w/cb",
190
+ "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
191
+ "maxWithdrawable": 21000,
192
+ "mintPubkey": "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
193
+ },
194
+ "why": "an x-only key, a node URI or a truncated hex string verifies nothing. Refused at the response rather than at the first signature check, where the same fault would look like a forged note"
159
195
  }
160
196
  ]
161
197
  }