lnurlcash-conformance 0.10.0 → 0.11.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
@@ -4,6 +4,26 @@ 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.11.0 - 2026-09-15
8
+
9
+ **Amount-bearing certificates and internal-transfer discovery.** Part 2 now
10
+ follows the current `lnurl-wallet` and `lnurl-mint` reference implementations;
11
+ the draft LUD-25 prose may lag this release.
12
+
13
+ - `cs1` carries `amount_msat` in its human-readable part using BOLT-11's
14
+ amount suffix rules. The vectors reject the legacy fixed `cs` HRP, and
15
+ the live grader requires the encoded amount to equal the note's
16
+ authoritative `maxWithdrawable` before verifying the certificate.
17
+ - `--address` now requires a valid `text/xpub` metadata entry carrying the
18
+ registered `cx1` plus its next-index hint. The mock address publishes a
19
+ real branch and advances outputs with the LUD-25 public derivation.
20
+ - Legacy hash mutation outputs are signed by the current references. The
21
+ grader still accepts an omitted signature because the reference mint may
22
+ run without an available signer, but verifies every signature it receives.
23
+ - The compliant mock enables secret-free hash lookup by default, matching
24
+ the current reference wallet and mint. `hashLookup: false` remains an
25
+ explicit older-SERVICE fixture.
26
+
7
27
  ## 0.10.0 - 2026-09-11
8
28
 
9
29
  **Signatures belong to cp1 notes.** The Part 2 rewrite of LUD-25 signs a
package/README.md CHANGED
@@ -48,7 +48,7 @@ for (const c of cases) {
48
48
  | `signature.json` | offline verification, both recovery-id orderings, malformed input |
49
49
  | `derivation.json` | deterministic note secrets from a BIP39 seed |
50
50
  | `cash-derivation.json` | LUD-25's seed-recoverable note secrets under `m/139'`, with BIP-32's own vector 1 |
51
- | `part2.json` | Part 2: `cp1`/`ck1`/`cs1`/`cx1`, the per-note key tweak, ownership signatures and mint certificates, on the reference wallet's `m/139'/1'` address path |
51
+ | `part2.json` | Part 2: `cp1`/`ck1`/amount-bearing `cs1`/`cx1`, the per-note key tweak, note ownership and mint certificates, on the reference wallet's `m/139'/1'` address path |
52
52
  | `bech32.json` | LUD-01 encoding, round trips, corrupted checksums |
53
53
  | `url-admission.json` | which URLs may be fetched, and why `data:` must never be |
54
54
  | `input-resolution.json` | bech32, LUD-17, Lightning Addresses, bare domains |
@@ -95,7 +95,7 @@ must survive:
95
95
  | `--echoWrongK1` | answers the informational GET with a different `k1` |
96
96
  | `--lieAboutValue=N` | reports a `maxWithdrawable` it never signed |
97
97
  | `--signatureLayout=leading` | emits the recovery id at the other end |
98
- | `--signatures=false` | issues no Part 1 signatures. Allowed since the Part 2 rewrite: a plain note is unsigned by design, so the grader passes it |
98
+ | `--signatures=false` | issues no Part 1 signatures. Accepted as the reference mint's degraded no-signer mode; the reference wallet will refuse an unsigned successful mutation |
99
99
  | `--serverGeneratedSecrets` | hands back a secret it generated — the exposure `h` exists to close |
100
100
  | `--meltNeverSettles` | holds every melt in flight, so notes stay `pending` |
101
101
  | `--meltAlwaysFails` | fails every payment, restoring the note |
@@ -136,7 +136,7 @@ conforming default explicit. Optional fields stay absent unless requested:
136
136
  | `--previousPrivateKey=<hex>` | an old signing key the mock still holds. Its public half joins `previousPubkeys` on its own |
137
137
  | `--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 |
138
138
  | `--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 |
139
- | `--hashLookup=true` | accepts an informational lookup by `h=sha256(k1)` without returning the bearer secret. Off by default because the capability is optional |
139
+ | `--hashLookup=false` | models an older SERVICE with no secret-free informational lookup. The current reference mock accepts `h=sha256(k1)` by default |
140
140
  | `--mintToHash` | accepts `h` alongside the mandatory identical comment and enables the additive quote/receipt fields. Off by default; baseline comment-bound minting remains on |
141
141
  | `--mintReceipt` | with `--mintToHash`, adds the optional quote commitment and signed LUD-21 settlement receipt |
142
142
  | `--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 |
@@ -11,6 +11,7 @@
11
11
  // Usable as a library (createMockMint) or standalone (npm start).
12
12
 
13
13
  import {createServer} from 'node:http'
14
+ import {bech32m} from '@scure/base'
14
15
  import {sha256} from '@noble/hashes/sha2.js'
15
16
  import {secp256k1} from '@noble/curves/secp256k1.js'
16
17
  import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
@@ -20,6 +21,27 @@ import {pathToFileURL} from 'node:url'
20
21
 
21
22
  const LSM_PREFIX = 'Lightning Signed Message:'
22
23
 
24
+ const concat = (...parts) => {
25
+ const out = new Uint8Array(parts.reduce((size, part) => size + part.length, 0))
26
+ let offset = 0
27
+ for (const part of parts) {
28
+ out.set(part, offset)
29
+ offset += part.length
30
+ }
31
+ return out
32
+ }
33
+
34
+ const taggedHash = (tag, ...parts) => {
35
+ const tagHash = sha256(utf8ToBytes(tag))
36
+ return sha256(concat(tagHash, tagHash, ...parts))
37
+ }
38
+
39
+ const ser32 = value => {
40
+ const out = new Uint8Array(4)
41
+ new DataView(out.buffer).setUint32(0, value, false)
42
+ return out
43
+ }
44
+
23
45
  const noteId = k1 => bytesToHex(sha256(hexToBytes(k1)))
24
46
 
25
47
  const sigDigest = (noteIdHex, amountMsat) =>
@@ -193,7 +215,9 @@ const DEFAULTS = {
193
215
  // 'hidesSpent' - non-compliant: hides a retained burned h as unknown
194
216
  // 'revealsSpent' - legacy alias for the now-compliant true behaviour
195
217
  // 'acceptsBoth' - non-compliant: accepts k1 and h together
196
- hashLookup: false,
218
+ // Current reference wallet and mint use secret-free informational
219
+ // lookups by default. Set false only to model an older SERVICE.
220
+ hashLookup: true,
197
221
  // LUD-25 lets a SERVICE refuse an oversized merge outright rather than
198
222
  // let the URL be mangled upstream. 0 is no explicit cap; a positive number
199
223
  // refuses more than that many k1 with the draft's own reason string.
@@ -244,6 +268,13 @@ const DEFAULTS = {
244
268
  // the draft's line 80 behaviour and is now the defect - see
245
269
  // docs/COMMENT-IS-MANDATORY.md.
246
270
  commentFallsBack: false,
271
+ // LUD-25 Part 2: a registered Lightning Address with a cx1 against it.
272
+ // Not a defect - this mint has a key to mint under whatever the comment
273
+ // says (the next unused one on its branch), so a comment naming no
274
+ // output is the free text LUD-12 invites and is ignored, not refused.
275
+ // The note still lands under a branch key and never under the payment
276
+ // preimage, which is the whole difference from `commentFallsBack`.
277
+ registeredAddress: false,
247
278
  // non-compliant, and narrower: refuse a malformed comment - an empty value
248
279
  // included - but fall back when the key is absent entirely. That is the
249
280
  // distinction the malformed loop cannot reach, and the commonest way to
@@ -306,6 +337,20 @@ export const createMockMint = async (options = {}) => {
306
337
  // below asks exactly what it asked before.
307
338
  const boundOutputs = new Map()
308
339
 
340
+ // A deterministic watch-only branch for the registered-address fixture.
341
+ // It is public test material, not a secret used for value. The mock uses
342
+ // the actual LUD-25 tweak so its text/xpub hint names the same output its
343
+ // next quote reserves.
344
+ const registeredBranchSecret = hexToBytes('44'.repeat(32))
345
+ const registeredBranchPubkey = secp256k1.getPublicKey(registeredBranchSecret, true).slice(1)
346
+ const registeredChainCode = sha256(utf8ToBytes('lnurlcash-conformance registered address'))
347
+ const registeredCx1 = bech32m.encode(
348
+ 'cx',
349
+ bech32m.toWords(concat(registeredBranchPubkey, registeredChainCode)),
350
+ 200
351
+ )
352
+ let registeredIndex = 0
353
+
309
354
  // An id is spoken for if it is a note in any state, the payment hash of
310
355
  // an invoice this mint issued, or the output a bound quote is waiting
311
356
  // to credit. Minting over any of them hands the output to somebody who
@@ -314,6 +359,19 @@ export const createMockMint = async (options = {}) => {
314
359
  const outputIdInUse = id =>
315
360
  notes.has(id) || invoices.has(id) || boundOutputs.has(id)
316
361
 
362
+ const nextRegisteredOutput = () => {
363
+ const branch = secp256k1.Point.fromBytes(concat(Uint8Array.of(0x02), registeredBranchPubkey))
364
+ for (;;) {
365
+ const index = registeredIndex++
366
+ const tweak = BigInt(
367
+ `0x${bytesToHex(taggedHash('LNURLcash/derive', registeredBranchPubkey, registeredChainCode, ser32(index)))}`
368
+ )
369
+ if (tweak >= secp256k1.Point.Fn.ORDER) continue
370
+ const output = bytesToHex(branch.add(secp256k1.Point.BASE.multiply(tweak)).toBytes(true).slice(1))
371
+ if (!outputIdInUse(output)) return output
372
+ }
373
+ }
374
+
317
375
  // What makes a request the same request: the same input k1 set, the
318
376
  // same h, the same h2, the same amount. The inputs are a set rather
319
377
  // than a sequence, because a merge naming the same notes in a different
@@ -496,6 +554,9 @@ export const createMockMint = async (options = {}) => {
496
554
  if (opts.baseFeeMsat > 0 || opts.feePpm > 0) {
497
555
  metadata.push(['text/plain', `Mint fees: ${opts.baseFeeMsat},${opts.feePpm}`])
498
556
  }
557
+ if (opts.registeredAddress) {
558
+ metadata.push(['text/xpub', `${registeredCx1}:${registeredIndex}`])
559
+ }
499
560
  return send({
500
561
  tag: 'payRequest',
501
562
  callback: `${origin}/p/cb`,
@@ -720,6 +781,11 @@ export const createMockMint = async (options = {}) => {
720
781
  // to compare h.
721
782
  boundTo = h
722
783
  namedByComment = true
784
+ } else if (opts.registeredAddress) {
785
+ // The next unused key on the branch. Claimed as the quote is
786
+ // issued, so two free-text quotes never name one output.
787
+ boundTo = nextRegisteredOutput()
788
+ namedByComment = true
723
789
  } else if (
724
790
  !opts.commentFallsBack &&
725
791
  !(opts.commentFallsBackWhenAbsent && sent === null)
@@ -826,6 +892,16 @@ export const createMockMint = async (options = {}) => {
826
892
  minWithdrawable: 0,
827
893
  maxWithdrawable: (held?.amountMsat ?? 21000) + opts.lieAboutValue,
828
894
  defaultDescription: 'an LNURLcash note',
895
+ // Keep the mock's optional way-home extension identical for
896
+ // secret-free and raw-secret lookups. This is test fixture policy,
897
+ // not a requirement imposed on a reference mint.
898
+ ...(opts.noteInfoPayLink
899
+ ? {
900
+ payLink: opts.payLinkOffOrigin
901
+ ? 'https://elsewhere.example/.well-known/lnurlp/mint'
902
+ : `${origin}/.well-known/lnurlp/${opts.username}`
903
+ }
904
+ : {}),
829
905
  mintPubkey: pubkey
830
906
  })
831
907
  }
@@ -842,12 +918,10 @@ export const createMockMint = async (options = {}) => {
842
918
  minWithdrawable: 0,
843
919
  maxWithdrawable: note.amountMsat + opts.lieAboutValue,
844
920
  defaultDescription: 'an LNURLcash note',
845
- // The way home, as the reference mint publishes it. A holder with
846
- // nothing but a note can reach the document carrying this mint's
847
- // terms and its retired signing keys; without it a wallet that only
848
- // ever received notes cannot tell an announced key rotation from a
849
- // substituted key, because the document lives under a username the
850
- // note never mentions.
921
+ // Optional way-home extension used by consumer policy tests. A holder
922
+ // with nothing but a note can then reach the document carrying this
923
+ // mint's terms and retired signing keys. Reference implementations do
924
+ // not make this field mandatory on an informational note response.
851
925
  ...(opts.noteInfoPayLink
852
926
  ? {
853
927
  payLink: opts.payLinkOffOrigin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-conformance",
3
- "version": "0.10.0",
3
+ "version": "0.11.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/cli.mjs CHANGED
@@ -27,10 +27,27 @@ if (positional.length === 0 && !noteArg) {
27
27
  lnurlcash-conform <mint> --note=<url> --pr=<invoice> same, paid amount from the invoice
28
28
  lnurlcash-conform <mint> --note=<url> --preimage=<hex> + the bound-mint checks
29
29
  lnurlcash-conform <mint> --note=<url> --spend + the full mutating checks
30
+ lnurlcash-conform <address> --address grade a Part 2 registered address
30
31
 
31
32
  <mint> may be a Lightning Address (mint@example.com), a bare domain, or a
32
33
  payRequest URL.
33
34
 
35
+ --address says the target is a LUD-25 Part 2 Lightning Address with a cx1
36
+ registered against it, not a mint payLink. Such an address mints on the next
37
+ unused key of its own branch, so a comment naming no output is the ordinary
38
+ free text LUD-12 invites and must not fail the payment; without this flag the
39
+ stricter payLink rules apply, which require every quote to name its output.
40
+ Declared rather than detected, and it cannot be otherwise: a mint that
41
+ unsafely falls back to a preimage-keyed note answers a free-text comment
42
+ exactly the same way, and the two differ only in what key the note lands
43
+ under, which nothing reveals before settlement. So --address takes the
44
+ operator's word for it. Use it on an address you control or trust; without
45
+ it the strict rules catch the unsafe mint, with it they cannot.
46
+
47
+ Note that each quote the checks issue claims the next key on the branch,
48
+ whether or not anyone pays it, so grading an address in use costs it a few
49
+ indices.
50
+
34
51
  --paid/--pr name what the note's mint invoice was paid at, and require the
35
52
  note to be freshly minted and never rotated: the check compares its value
36
53
  against the LUD-25 fee formula. It is read-only.
@@ -52,7 +69,7 @@ let pay
52
69
  if (positional[0]) {
53
70
  const payUrl = resolveMint(positional[0])
54
71
  console.log(`grading ${payUrl}\n`)
55
- pay = await gradeMint(payUrl, report)
72
+ pay = await gradeMint(payUrl, report, {registeredAddress: flags.has('--address')})
56
73
  }
57
74
 
58
75
  const mintFee =
package/runner/index.d.ts CHANGED
@@ -57,8 +57,31 @@ export declare const applyMintFee: (gross: number, fee: MintFee | null) => numbe
57
57
  /** the `Mint fees: base,ppm` line out of a payRequest's metadata array */
58
58
  export declare const parseAdvertisedMintFee: (metadata: string) => MintFee | null
59
59
 
60
+ export interface InternalTransferHint {
61
+ cx1: string
62
+ index: number
63
+ pubkeyXOnly: Uint8Array
64
+ chainCode: Uint8Array
65
+ }
66
+
67
+ /** the `text/xpub` branch and next-index hint out of registered-address metadata */
68
+ export declare const parseInternalTransferHint: (
69
+ metadata: string
70
+ ) => InternalTransferHint | null
71
+
60
72
  /** the read-only mint checks: payRequest, withdrawLink, fees, verify, extensions */
61
- export declare const gradeMint: (payUrl: string, report: Report) => Promise<void>
73
+ export declare const gradeMint: (
74
+ payUrl: string,
75
+ report: Report,
76
+ options?: {
77
+ /** Grade as a LUD-25 Part 2 registered Lightning Address rather than a
78
+ * mint payLink: it mints on the next unused key of its own branch, so a
79
+ * comment naming no output is free text and must not fail the payment.
80
+ * Declared, not detected - an unsafe preimage-keyed fallback looks
81
+ * identical before settlement. */
82
+ registeredAddress?: boolean
83
+ }
84
+ ) => Promise<void>
62
85
 
63
86
  /**
64
87
  * Read-only. Needs a freshly minted, never-rotated note and what its mint
package/runner/index.mjs CHANGED
@@ -37,8 +37,8 @@ const recoversTo = (digest, sig, pubkeyHex) => {
37
37
  return false
38
38
  }
39
39
 
40
- // A Part 1 signature, over the note's hash. The spec no longer asks for
41
- // one, but a mint that still issues them must at least issue true ones.
40
+ // The legacy raw Part 1 signature over a note's hash. Current reference mints
41
+ // issue one when a signer is available, and every one received must be true.
42
42
  const verifySignature = (k1, amountMsat, signatureHex, pubkeyHex) => {
43
43
  let sig
44
44
  try {
@@ -54,7 +54,8 @@ const verifyCertificate = (pubkeyHex32, amountMsat, sig, mintPubkeyHex) =>
54
54
  recoversTo(signedMessageDigest(`LNURLcash:${amountMsat}:${pubkeyHex32}`), sig, mintPubkeyHex)
55
55
 
56
56
  // Part 2's bech32m strings: cp1 (32-byte key), ck1 and cs1 (65-byte
57
- // signature). Longer than BIP-173's 90 characters by design.
57
+ // signature). cs1 has a variable HRP: "cs" plus the amount under BOLT-11's
58
+ // amount rules. Longer than BIP-173's 90 characters by design.
58
59
  const BECH32M_LIMIT = 200
59
60
  const encodeCash = (hrp, bytes) => bech32m.encode(hrp, bech32m.toWords(bytes), BECH32M_LIMIT)
60
61
  const decodeCash = (hrp, value, length) => {
@@ -69,6 +70,25 @@ const decodeCash = (hrp, value, length) => {
69
70
  }
70
71
  }
71
72
 
73
+ const AMOUNT_MSAT_PER_UNIT = {'': 1e11, m: 1e8, u: 1e5, n: 100, p: 0.1}
74
+ const amountSuffixMsat = suffix => {
75
+ const match = suffix.match(/^(\d+)([munp])?$/)
76
+ if (!match) return null
77
+ const amount = Number(match[1]) * AMOUNT_MSAT_PER_UNIT[match[2] ?? '']
78
+ return Number.isSafeInteger(amount) ? amount : null
79
+ }
80
+
81
+ const decodeCertificate = value => {
82
+ if (typeof value !== 'string') return null
83
+ const lower = value.trim().toLowerCase()
84
+ const sep = lower.lastIndexOf('1')
85
+ if (sep < 3 || !lower.startsWith('cs')) return null
86
+ const amountMsat = amountSuffixMsat(lower.slice(2, sep))
87
+ if (amountMsat === null) return null
88
+ const signature = decodeCash(lower.slice(0, sep), lower, 65)
89
+ return signature ? {amountMsat, signature} : null
90
+ }
91
+
72
92
  // The bearer secret of a cp1 note: the key's signature over the fixed
73
93
  // message "LNURLcash", r || s || recovery-id, the same value every time.
74
94
  const ownershipProof = secretKey => {
@@ -235,6 +255,31 @@ export const parseAdvertisedMintFee = metadata => {
235
255
  return null
236
256
  }
237
257
 
258
+ // A registered Part 2 address advertises the safe-to-share branch and its
259
+ // best-known next index as ["text/xpub", "cx1...:<i>"]. Parsing it here
260
+ // makes --address assert the actual discovery signal rather than merely
261
+ // trusting the operator's flag.
262
+ export const parseInternalTransferHint = metadata => {
263
+ let entries
264
+ try {
265
+ entries = JSON.parse(metadata)
266
+ } catch {
267
+ return null
268
+ }
269
+ if (!Array.isArray(entries)) return null
270
+ for (const entry of entries) {
271
+ if (!Array.isArray(entry) || entry[0] !== 'text/xpub' || typeof entry[1] !== 'string') continue
272
+ const sep = entry[1].lastIndexOf(':')
273
+ if (sep < 0) continue
274
+ const cx1 = entry[1].slice(0, sep)
275
+ const index = Number(entry[1].slice(sep + 1))
276
+ const branch = decodeCash('cx', cx1.toLowerCase(), 64)
277
+ if (!branch || !Number.isInteger(index) || index < 0 || index > 0xffffffff) continue
278
+ return {cx1, index, pubkeyXOnly: branch.slice(0, 32), chainCode: branch.slice(32)}
279
+ }
280
+ return null
281
+ }
282
+
238
283
  // Resolves what the user typed - a Lightning Address, a bare domain, or a
239
284
  // URL - to the payRequest URL to grade.
240
285
  export const resolveMint = input => {
@@ -253,7 +298,32 @@ export const resolveMint = input => {
253
298
 
254
299
  // ---- read-only checks -----------------------------------------------------
255
300
 
256
- export const gradeMint = async (payUrl, report) => {
301
+ // `registeredAddress` grades the target as a LUD-25 Part 2 Lightning
302
+ // Address rather than a mint payLink. The two are the same document to a
303
+ // wallet - both advertise commentAllowed and a withdrawLink, and both
304
+ // mint on payment - but the draft's comment rules are written for the
305
+ // payLink, which has no key to mint under but the one the comment names.
306
+ // A cx1-registered address always has one, the next unused key on its
307
+ // branch, so there a comment naming no output is the ordinary free text
308
+ // LUD-12 invites and is ignored rather than refused (lnurl-mint #46,
309
+ // after every Wallet of Satoshi and Primal payment to such an address
310
+ // failed on a typed message).
311
+ //
312
+ // Deliberately a flag and not a probe. "Returned an invoice for a comment
313
+ // naming no output" is also exactly what a mint falling back to a
314
+ // preimage-keyed note does, and that mint is dangerous - the preimage
315
+ // race the draft's Security considerations describes. Nothing on the wire
316
+ // tells the two apart before settlement: they differ only in what key the
317
+ // note lands under. Guessing would wave the dangerous one through, so the
318
+ // strict payLink rules stay the default and an address is declared.
319
+ //
320
+ // The cost of that is real and worth stating: this flag takes the
321
+ // operator's word, and a preimage-keyed fallback passes under it. It
322
+ // relaxes the comment rules and nothing else, and the draft gives an
323
+ // address no way to say what it is on the wire. A signal it could
324
+ // advertise - so this became detectable rather than declared - is worth
325
+ // raising against LUD-25 Part 2.
326
+ export const gradeMint = async (payUrl, report, {registeredAddress = false} = {}) => {
257
327
  let pay
258
328
  let mintAddress
259
329
  await report.check('payRequest resolves and is well-formed', async () => {
@@ -307,6 +377,14 @@ export const gradeMint = async (payUrl, report) => {
307
377
  return `${match[1]} msat + ${match[2]} ppm`
308
378
  })
309
379
 
380
+ if (registeredAddress) {
381
+ await report.check('advertises its cx1 and next index for internal transfers', async () => {
382
+ const hint = parseInternalTransferHint(pay.metadata)
383
+ assert(hint, 'no valid ["text/xpub", "cx1...:<i>"] metadata entry')
384
+ return `index ${hint.index}`
385
+ })
386
+ }
387
+
310
388
  let verifyUrl
311
389
  await report.check('issues an invoice for the amount requested', async () => {
312
390
  const amount = Math.max(pay.minSendable, 1000)
@@ -579,6 +657,19 @@ export const gradeMint = async (payUrl, report) => {
579
657
  }
580
658
  const spelt = spelling => (spelling === 'comment' ? 'a LUD-12 comment' : 'an h parameter')
581
659
 
660
+ // The behaviour #46 fixed, asserted rather than assumed. Asked once,
661
+ // not once per malformed value: on an address a quote claims the next
662
+ // branch index as it is issued, so every probe costs the holder a key
663
+ // whether or not anyone pays. `gm` is short enough that no mint could
664
+ // read it as a commitment of any spelling.
665
+ if (registeredAddress) {
666
+ const freeText = await quoteAt('comment', 'gm')
667
+ assert(
668
+ freeText.status !== 'ERROR' && typeof freeText.pr === 'string',
669
+ `refused a comment naming no output: ${freeText.reason} - this address mints on its own branch, so an ordinary LUD-12 message must not fail the payment`
670
+ )
671
+ }
672
+
582
673
  const advertised = spellingsOf(pay)
583
674
  const corroborated = spellingsOf(mintAddress)
584
675
  const claimed = [...new Set([...advertised, ...corroborated])]
@@ -655,6 +746,11 @@ export const gradeMint = async (payUrl, report) => {
655
746
 
656
747
  for (const spelling of probed) {
657
748
  for (const [what, value] of malformed) {
749
+ // Every one of these names no output, which on an address is free
750
+ // text, already covered above. `h` stays probed either way: it is
751
+ // a parameter invented for this one purpose, so a malformed one is
752
+ // a wallet error wherever it is sent.
753
+ if (spelling === 'comment' && registeredAddress) continue
658
754
  const body = await quoteAt(spelling, value)
659
755
  if (spelling === 'h') {
660
756
  if (!hClaimed) continue
@@ -679,10 +775,20 @@ export const gradeMint = async (payUrl, report) => {
679
775
  const bare = new URL(pay.callback)
680
776
  bare.searchParams.set('amount', String(amount))
681
777
  const unnamed = await get(bare)
682
- assert(
683
- unnamed.status === 'ERROR' && !unnamed.pr,
684
- 'issued an invoice for a quote carrying no comment - current LUD-25 requires rejection before invoicing'
685
- )
778
+ if (registeredAddress) {
779
+ // The draft is explicit that a registered address needs no comment
780
+ // at all: it derives the next key from its own cx1. Refusing here
781
+ // would break every plain Lightning payment to the address.
782
+ assert(
783
+ unnamed.status !== 'ERROR' && typeof unnamed.pr === 'string',
784
+ `refused a quote carrying no comment: ${unnamed.reason} - a registered address mints on its own branch, so a payment to it must not need one`
785
+ )
786
+ } else {
787
+ assert(
788
+ unnamed.status === 'ERROR' && !unnamed.pr,
789
+ 'issued an invoice for a quote carrying no comment - current LUD-25 requires rejection before invoicing'
790
+ )
791
+ }
686
792
 
687
793
  if (hClaimed) {
688
794
  const mismatch = new URL(pay.callback)
@@ -739,6 +845,9 @@ export const gradeMint = async (payUrl, report) => {
739
845
  }
740
846
  }
741
847
  if (problems.length > 0) throw soft(problems.join('; '))
848
+ if (registeredAddress) {
849
+ return `registered Lightning Address, minting on its own branch: named by ${probed.join(' and ')}; claimed by ${claimedBy.join(', ')}; bound a quote to a hash of the runner's own secret, and honoured one carrying free text and one carrying no comment at all`
850
+ }
742
851
  return `named by ${probed.join(' and ')}; claimed by ${claimedBy.join(', ')}; bound a quote to a hash of the runner's own secret and handled four malformed ones as the draft requires`
743
852
  })
744
853
 
@@ -1095,17 +1204,19 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1095
1204
  ? 'the signature verifies against neither the advertised mintPubkey nor any published previous key'
1096
1205
  : 'the signature does not verify against the advertised mintPubkey and amount'
1097
1206
 
1098
- await report.check('a plain note carries no signature, or one that verifies', async () => {
1099
- // LUD-25 Part 2 signs cp1 notes only: a hash has nothing to attest to
1100
- // without disclosing the secret, so a plain note is unsigned by
1101
- // design. A mint that still issues the old Part 1 signature over the
1102
- // hash is harmless, as long as the signature is a true one.
1103
- if (currentSig === null) return 'unsigned, as Part 2 specifies for a plain note'
1207
+ await report.check('a legacy hash mutation signature verifies when present', async () => {
1208
+ // The reference mint can operate without a signing backend and then has
1209
+ // no signature to return. That mode remains usable by tolerant clients,
1210
+ // but the committed reference wallet requires a signature after a
1211
+ // successful mutation, so omission is an interoperability warning.
1212
+ if (currentSig === null) {
1213
+ throw soft('unsigned legacy output: accepted as no-signer mode, but strict reference-wallet clients may refuse it')
1214
+ }
1104
1215
  assert(info.mintPubkey, 'a sig with no mintPubkey advertised verifies against nothing')
1105
1216
  assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
1106
1217
  const signedBy = signerOf(key => verifySignature(current, info.maxWithdrawable, currentSig, key))
1107
1218
  assert(signedBy, unverified())
1108
- return `a legacy Part 1 signature, ${describeSigner(signedBy)}`
1219
+ return `legacy Part 1 signature ${describeSigner(signedBy)}`
1109
1220
  })
1110
1221
 
1111
1222
  await report.check('reports a spent hash distinguishably from an unknown hash', async () => {
@@ -1476,9 +1587,13 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1476
1587
  assert(info.mintPubkey, 'issued a cp1 note with no mintPubkey advertised - nothing to verify its certificate against')
1477
1588
  assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
1478
1589
  const pubkeyHex = bytesToHex(pubkey)
1479
- const certificate = decodeCash('cs', body.sig, 65)
1480
- assert(certificate, `the rotate to a cp1 output returned no cs1 certificate in sig (got ${JSON.stringify(body.sig)})`)
1481
- const signedBy = signerOf(key => verifyCertificate(pubkeyHex, info.maxWithdrawable, certificate, key))
1590
+ const certificate = decodeCertificate(body.sig)
1591
+ assert(certificate, `the rotate to a cp1 output returned no amount-bearing cs1 certificate in sig (got ${JSON.stringify(body.sig)})`)
1592
+ assert(
1593
+ certificate.amountMsat === info.maxWithdrawable,
1594
+ `the cs1 says ${certificate.amountMsat} msat but the note is worth ${info.maxWithdrawable} msat`
1595
+ )
1596
+ const signedBy = signerOf(key => verifyCertificate(pubkeyHex, certificate.amountMsat, certificate.signature, key))
1482
1597
  assert(signedBy, unverified())
1483
1598
 
1484
1599
  // The informational GET by ck1 delivers the certificate again, so a
@@ -1491,10 +1606,14 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1491
1606
  lookup.maxWithdrawable === info.maxWithdrawable,
1492
1607
  `value changed across a rotate to cp1: ${info.maxWithdrawable} -> ${lookup.maxWithdrawable}`
1493
1608
  )
1494
- const again = decodeCash('cs', lookup.sig, 65)
1495
- assert(again, 'the informational GET by ck1 returned no cs1 certificate')
1609
+ const again = decodeCertificate(lookup.sig)
1610
+ assert(again, 'the informational GET by ck1 returned no amount-bearing cs1 certificate')
1611
+ assert(
1612
+ again.amountMsat === lookup.maxWithdrawable,
1613
+ `the informational cs1 says ${again.amountMsat} msat but the note is worth ${lookup.maxWithdrawable} msat`
1614
+ )
1496
1615
  assert(
1497
- signerOf(key => verifyCertificate(pubkeyHex, info.maxWithdrawable, again, key)),
1616
+ signerOf(key => verifyCertificate(pubkeyHex, again.amountMsat, again.signature, key)),
1498
1617
  'the certificate on the informational GET does not verify'
1499
1618
  )
1500
1619
 
@@ -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. A SERVICE returning a rotate, split or merge to a cp1 output MUST include its certificate in `sig` (and `sig2` for the second split output); a plain hash output carries none, and a legacy Part 1 signature on one is harmless where it verifies.",
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. An amount-bearing cs1 carries that same declared amount itself, so the current reference wallet omits the duplicate `amount` query parameter and reads it from `sig`. A SERVICE returning a rotate, split or merge to a cp1 output MUST include that certificate in `sig` (and `sig2` for the second split output). A legacy hash output may instead carry the reference mint's raw Part 1 signature when a signer is available; it never carries cs1.",
5
5
  "parse": [
6
6
  {
7
7
  "url": "https://mint.example/w?k1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&amount=21000",
@@ -22,6 +22,13 @@
22
22
  "declaredAmountMsat": 21000,
23
23
  "signature": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab"
24
24
  },
25
+ {
26
+ "url": "https://mint.example/w?k1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&sig=cs210n1p9s9uau4p08z7fee9zx56z6gw8564rnsp8se3f2uwpkpa7yxzecz842de66weae0x0hqv658k75h5ydpqqc9dm652nxsg42m2y82vmqp9qy0pt",
27
+ "k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
28
+ "declaredAmountMsat": 21000,
29
+ "signature": "cs210n1p9s9uau4p08z7fee9zx56z6gw8564rnsp8se3f2uwpkpa7yxzecz842de66weae0x0hqv658k75h5ydpqqc9dm652nxsg42m2y82vmqp9qy0pt",
30
+ "why": "an amount-bearing cs1 carries the declared amount without a duplicate amount query parameter"
31
+ },
25
32
  {
26
33
  "url": "https://mint.example/w",
27
34
  "k1": null,
@@ -78,6 +85,14 @@
78
85
  "amountMsat": 5000,
79
86
  "signature": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
80
87
  "expect": "https://mint.example/w?k1=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&amount=5000&sig=cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"
88
+ },
89
+ {
90
+ "url": "https://mint.example/w?k1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&amount=21000",
91
+ "k1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
92
+ "amountMsat": 21000,
93
+ "signature": "cs210n1p9s9uau4p08z7fee9zx56z6gw8564rnsp8se3f2uwpkpa7yxzecz842de66weae0x0hqv658k75h5ydpqqc9dm652nxsg42m2y82vmqp9qy0pt",
94
+ "expect": "https://mint.example/w?k1=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&sig=cs210n1p9s9uau4p08z7fee9zx56z6gw8564rnsp8se3f2uwpkpa7yxzecz842de66weae0x0hqv658k75h5ydpqqc9dm652nxsg42m2y82vmqp9qy0pt",
95
+ "why": "the cs1 already carries amount_msat, so the current reference wallet omits the duplicate amount parameter"
81
96
  }
82
97
  ],
83
98
  "withoutK1": [
@@ -87,6 +102,13 @@
87
102
  "signature": null,
88
103
  "expect": "https://mint.example/w?amount=5000",
89
104
  "why": "a device-backed note keeps the URL template but never the secret"
105
+ },
106
+ {
107
+ "url": "https://mint.example/w?k1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&amount=21000",
108
+ "amountMsat": 21000,
109
+ "signature": "cs210n1p9s9uau4p08z7fee9zx56z6gw8564rnsp8se3f2uwpkpa7yxzecz842de66weae0x0hqv658k75h5ydpqqc9dm652nxsg42m2y82vmqp9qy0pt",
110
+ "expect": "https://mint.example/w?sig=cs210n1p9s9uau4p08z7fee9zx56z6gw8564rnsp8se3f2uwpkpa7yxzecz842de66weae0x0hqv658k75h5ydpqqc9dm652nxsg42m2y82vmqp9qy0pt",
111
+ "why": "a secret-free mirror also avoids duplicating the amount already encoded in cs1"
90
112
  }
91
113
  ]
92
114
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
3
  "spec": "LUD-25 draft (lnurl/luds#301)",
4
- "description": "LUD-25 Part 2: notes keyed by a public key and spent by a recoverable signature. Every branch is the reference wallet's address path under a BIP39 seed (no passphrase): cashRoot is m/139', domainIndices are the four raw uint32 read big-endian from HMAC-SHA256(key = the private key at m/139'/1'/0, msg = utf8(host)), and addressNode is m/139'/1'/d1/d2/d3/d4 as privateKey||chainCode hex. branchPubkey is its x-only key (branchParity says whether the full point has even y) and cx1 is bech32m(\"cx\", branchPubkey || chainCode). Each note: t = tagged_hash(\"LNURLcash/derive\", branchPubkey || chainCode || ser32_be(index)); notePubkey = x(lift_x(branchPubkey) + t*G), which a watcher holding only the cx1 computes; noteSecretKey = ((branchParity even ? p : n - p) + t) mod n. ownershipSignature is RFC6979 ECDSA, low-S, over sha256(sha256(\"Lightning Signed Message:\" || \"LNURLcash\")), laid out r || s || recovery id, and ck1 is its bech32m(\"ck\") encoding: the value that spends the note. A certificate is the same signature shape by the mint key over sha256(sha256(\"Lightning Signed Message:\" || \"LNURLcash:<amount_msat>:<hex(notePubkey)>\")), encoded bech32m(\"cs\"). All four strings are bech32m with no length limit; all-uppercase is valid and mixed case is not (BIP-350). Values match vectors generated from lnurl-wallet src/lib and confirmed against lnurl-mint.",
4
+ "description": "LUD-25 Part 2: notes keyed by a public key and spent by a recoverable signature. Every branch is the reference wallet's address path under a BIP39 seed (no passphrase): cashRoot is m/139', domainIndices are the four raw uint32 read big-endian from HMAC-SHA256(key = the private key at m/139'/1'/0, msg = utf8(host)), and addressNode is m/139'/1'/d1/d2/d3/d4 as privateKey||chainCode hex. branchPubkey is its x-only key (branchParity says whether the full point has even y) and cx1 is bech32m(\"cx\", branchPubkey || chainCode). Each note: t = tagged_hash(\"LNURLcash/derive\", branchPubkey || chainCode || ser32_be(index)); notePubkey = x(lift_x(branchPubkey) + t*G), which a watcher holding only the cx1 computes; noteSecretKey = ((branchParity even ? p : n - p) + t) mod n. ownershipSignature is RFC6979 ECDSA, low-S, over sha256(sha256(\"Lightning Signed Message:\" || \"LNURLcash\")), laid out r || s || recovery id, and ck1 is its bech32m(\"ck\") encoding: the value that spends the note. Register/update/unregister proofs use that same signature shape from index zero over \"LNURLcash:<action>:<username>\", separating both action and name. A certificate is the same signature shape by the mint key over sha256(sha256(\"Lightning Signed Message:\" || \"LNURLcash:<amount_msat>:<hex(notePubkey)>\")); its bech32m HRP is \"cs\" plus the amount encoded by BOLT-11 rules. All four strings are bech32m with no length limit; all-uppercase is valid and mixed case is not (BIP-350). Values match vectors generated from lnurl-wallet src/lib and confirmed against lnurl-mint.",
5
5
  "conventions": {
6
6
  "addressBranch": "m/139'/1'/d1/d2/d3/d4",
7
7
  "hashingKey": "m/139'/1'/0",
@@ -11,7 +11,9 @@
11
11
  "ownershipMessage": "LNURLcash",
12
12
  "ownershipDigest": "c92e46d0c00a23e46b68442e4c0e22d8983bc74158da73d6c33b6f0fdc970e6e",
13
13
  "signatureLayout": "r || s || recovery id (0..3), RFC6979, low-S",
14
- "certificateMessage": "LNURLcash:<amount_msat>:<hex(pk)>"
14
+ "addressProofMessage": "LNURLcash:<register|unregister>:<username>",
15
+ "certificateMessage": "LNURLcash:<amount_msat>:<hex(pk)>",
16
+ "certificateHrp": "cs || BOLT11_amount_suffix(amount_msat)"
15
17
  },
16
18
  "mint": {
17
19
  "privateKey": "da6ec5c4342514114ee493a55ddc06d085d61eb78f0b7163e07cdb7f1f66ac05",
@@ -626,7 +628,7 @@
626
628
  "message": "LNURLcash:1000:b52e0b9dcd39edd137cf4b6794d0d2a55f13c9aa968078394d49159651b5a02f",
627
629
  "digest": "3234532c63de08043753a7b7404f4c243a540f378d4865dd16a2f5c71bec1e31",
628
630
  "signature": "b2c850964a82eee8d33ed8ca321fe500c15f9fa42e7e57416542e27596aacfb67c3e8206fff2acf0260c7d27d2b63f0fa9e36a1bfc2b19924d5e9db6c1187b5300",
629
- "cs1": "cs1kty9p9j2sthw35e7mr9ry8l9qrq4l8ay9el9wst9gt38t942e7m8c05zqmll9t8sycx86f7jkclsl20rdgdlc2cejfx4a8dkcyv8k5cqte7psz"
631
+ "cs1": "cs10n1kty9p9j2sthw35e7mr9ry8l9qrq4l8ay9el9wst9gt38t942e7m8c05zqmll9t8sycx86f7jkclsl20rdgdlc2cejfx4a8dkcyv8k5cqaqz8vc"
630
632
  },
631
633
  {
632
634
  "notePubkey": "068a32f78dd15c688a9972f6507e1b9d9ca6f97317095997c3bf1dde53a78db9",
@@ -634,7 +636,7 @@
634
636
  "message": "LNURLcash:21000:068a32f78dd15c688a9972f6507e1b9d9ca6f97317095997c3bf1dde53a78db9",
635
637
  "digest": "4fe334bdb45cecd25336877cba4319a00444bca5ae97bbb2f29874e0c0baaee1",
636
638
  "signature": "09605e77950bce2f2739288d4d0b4871e9aa8e7009e198a55c706c1ef886167023d54dceb4ecf72f33ee066a87b7a97a11a1003056ef5454cd04555b510ea66c01",
637
- "cs1": "cs1p9s9uau4p08z7fee9zx56z6gw8564rnsp8se3f2uwpkpa7yxzecz842de66weae0x0hqv658k75h5ydpqqc9dm652nxsg42m2y82vmqp3yj3cv"
639
+ "cs1": "cs210n1p9s9uau4p08z7fee9zx56z6gw8564rnsp8se3f2uwpkpa7yxzecz842de66weae0x0hqv658k75h5ydpqqc9dm652nxsg42m2y82vmqp9qy0pt"
638
640
  },
639
641
  {
640
642
  "notePubkey": "831613eb24856343694e81cb8e1b0b1b5582f76f0bd3db5443cfcb6d96eb8fb7",
@@ -642,7 +644,7 @@
642
644
  "message": "LNURLcash:99999:831613eb24856343694e81cb8e1b0b1b5582f76f0bd3db5443cfcb6d96eb8fb7",
643
645
  "digest": "95f155dc7805a62e459359668cd4bdb3c95d38fd21276067f9fc33307f28059e",
644
646
  "signature": "a9160631f5f10517161335160b004d7828191731c536431ca07f2109e1e9fb7d157a0d8863b343653aaa16574dac9813db100341fb61c08950a01137a5c9ac9a01",
645
- "cs1": "cs14ytqvv047yz3w9snx5tqkqzd0q5pj9e3c5myx89q0ussnc0fld7327sd3p3mxsm9824pv46d4jvp8kcsqdqlkcwq39g2qyfh5hy6exspa2guz2"
647
+ "cs1": "cs999990p14ytqvv047yz3w9snx5tqkqzd0q5pj9e3c5myx89q0ussnc0fld7327sd3p3mxsm9824pv46d4jvp8kcsqdqlkcwq39g2qyfh5hy6exsp5x36tn"
646
648
  },
647
649
  {
648
650
  "notePubkey": "2d0a844a516b08def2cfb284142542c8775dcffd88768180986bc03a1d489bf4",
@@ -650,7 +652,36 @@
650
652
  "message": "LNURLcash:100000000:2d0a844a516b08def2cfb284142542c8775dcffd88768180986bc03a1d489bf4",
651
653
  "digest": "100eb3e508b8a2d32733d7ac3846ea477a5f9120c93071eb4521057a916dce0e",
652
654
  "signature": "8b2daf986ad84609246c87227c6f92bc2b24b660f72487a7afc5e21f0594aaaf0ab85c79e7a934499e0f9b4e5ae74123946f915c5ba928537e35829d18655cf900",
653
- "cs1": "cs13vk6lxr2mprqjfrvsu38cmujhs4jfdnq7ujg0fa0ch3p7pv542hs4wzu08n6jdzfnc8eknj6uaqj89r0j9w9h2fg2dlrtq5arpj4e7gqmdth9n"
655
+ "cs1": "cs1m13vk6lxr2mprqjfrvsu38cmujhs4jfdnq7ujg0fa0ch3p7pv542hs4wzu08n6jdzfnc8eknj6uaqj89r0j9w9h2fg2dlrtq5arpj4e7gqrmgxlj"
656
+ }
657
+ ],
658
+ "addressProofs": [
659
+ {
660
+ "action": "register",
661
+ "username": "alice",
662
+ "message": "LNURLcash:register:alice",
663
+ "digest": "0344528a8e7629117ae8d369e279614848df2551132ca060d646806529d94977",
664
+ "indexZeroSecretKey": "c809325604f901c494bebab0f02d74d43cb3d58c143b753c2b749d1733288f64",
665
+ "indexZeroPubkey": "b52e0b9dcd39edd137cf4b6794d0d2a55f13c9aa968078394d49159651b5a02f",
666
+ "signature": "18037cd57993de939a5edec704319dd6221650878c5682b98108d7869ae447366210853b212dbf2b48202a22b3371c6e5635444d726edef2c7db472fdd8df3b200"
667
+ },
668
+ {
669
+ "action": "unregister",
670
+ "username": "alice",
671
+ "message": "LNURLcash:unregister:alice",
672
+ "digest": "6a6679a4bf19a30ef180677f873140cad9d3929ec3ee7937f9da00a72251ca73",
673
+ "indexZeroSecretKey": "c809325604f901c494bebab0f02d74d43cb3d58c143b753c2b749d1733288f64",
674
+ "indexZeroPubkey": "b52e0b9dcd39edd137cf4b6794d0d2a55f13c9aa968078394d49159651b5a02f",
675
+ "signature": "351af0eaad099b834cc7019207de9fe87e2b384653c87d406d8dc5e1c88fa95d3b48149403cf5d223136c29756861cb397778aac93a2265e8b28ef0cd934ac5301"
676
+ },
677
+ {
678
+ "action": "register",
679
+ "username": "bob",
680
+ "message": "LNURLcash:register:bob",
681
+ "digest": "3994b99587e99411398be24188193db7a7e8e682b45574448019890c36c4b2c5",
682
+ "indexZeroSecretKey": "c809325604f901c494bebab0f02d74d43cb3d58c143b753c2b749d1733288f64",
683
+ "indexZeroPubkey": "b52e0b9dcd39edd137cf4b6794d0d2a55f13c9aa968078394d49159651b5a02f",
684
+ "signature": "4864ac3e78ecdffded8e764527489e684e4caef30edb0c5cb11106e70022711f4d495f4fa35bcb6dfe3e92e93301ae7f307bf1e94a40514bc4249c20e0ec0d2a00"
654
685
  }
655
686
  ],
656
687
  "valid": [
@@ -702,6 +733,11 @@
702
733
  "value": "ck18pf5gt7jfqyrxkyy5ssk7y4t9lauyknjpzjnaf2wppq4y3a68vnn92xvfama804vp27hjyn6h6dy5qz6j5vwy4st8uhe3tqv6thyhfgq0xh4cd",
703
734
  "why": "a ck1 is not a cs1"
704
735
  },
736
+ {
737
+ "type": "cs1",
738
+ "value": "cs1kty9p9j2sthw35e7mr9ry8l9qrq4l8ay9el9wst9gt38t942e7m8c05zqmll9t8sycx86f7jkclsl20rdgdlc2cejfx4a8dkcyv8k5cqte7psz",
739
+ "why": "legacy fixed cs HRP carries no amount"
740
+ },
705
741
  {
706
742
  "type": "cx1",
707
743
  "value": "cx1k5hqh8wd88kazd70fdnef5xj54038jd2j6q8sw2dfy2ev5d45qhsnvwp55",
@@ -1,10 +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. 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.",
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 missing a signature the output or caller requires is its own outcome: it definitely landed, so the secrets matter more than ever. `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 a cp1 output came back without its cs1 certificate. LUD-25 Part 2 requires one on every cp1 output of a rotate, split or merge, so this SERVICE is non-conforming - but the note EXISTS at the key the WALLET disclosed, and its private key is the only key to that value. Keep the key; report the mint. A plain hash output is unsigned by design and is never this outcome",
7
+ "unverifiable": "the mutation is confirmed but a cp1 output came back without its required cs1 certificate, or a legacy hash output came back without the raw signature a strict caller required. The note EXISTS at the output the WALLET disclosed, so keep its secret even while reporting the missing proof",
8
8
  "pending": "this k1 has another operation in flight (a melt); retry shortly",
9
9
  "spent": "the SERVICE is authoritative that the note is already burned; a holder may lock it as spent",
10
10
  "unknown": "the SERVICE does not recognise this note; surface it, do not silently lock it",
@@ -91,7 +91,7 @@
91
91
  },
92
92
  "expect": "ok",
93
93
  "signature": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
94
- "why": "both outputs are plain hash notes, owed nothing; a mint may still issue the old Part 1 signature over one of them, and that is harmless where it verifies"
94
+ "why": "both outputs are legacy hash notes; tolerant callers can retain a landed output without proof, while a strict caller may require both raw Part 1 signatures"
95
95
  },
96
96
  {
97
97
  "name": "melt success with a LUD-21 style proof",