lnurlcash-conformance 0.9.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,59 @@ 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
+
27
+ ## 0.10.0 - 2026-09-11
28
+
29
+ **Signatures belong to cp1 notes.** The Part 2 rewrite of LUD-25 signs a
30
+ `cp1` note only: a plain hash has nothing to attest to without disclosing
31
+ the secret, so a plain note is unsigned by design. The grader follows it.
32
+
33
+ - `signs the notes it issues` is gone. In its place, `a plain note carries
34
+ no signature, or one that verifies`: a hash rotate answered with a bare
35
+ `{"status":"OK"}` passes, and a mint still issuing the old Part 1
36
+ signature over the hash passes as long as the signature is a true one.
37
+ `mintPubkey` is no longer demanded of a mint that issues no signature.
38
+ - New, last in the note run: `certifies a cp1 note it issues (Part 2)`.
39
+ The grader rotates the note into a fresh `cp1` key, requires a `cs1` in
40
+ `sig` that recovers to `mintPubkey` (or a published previous key) over
41
+ the key and amount, requires the same certificate again on the
42
+ informational GET by `ck1`, then rotates home to a plain secret by that
43
+ `ck1`. A mint that refuses the `cp1` output warns, never fails: Part 2 is
44
+ optional. The mock mint has no Part 2 yet, so it warns here.
45
+ - `vectors/responses.json` follows: a bare `{"status":"OK"}` to a hash
46
+ output is now `ok` (it was `unverifiable`), and so is a split that signs
47
+ only its first hash output. Three cases are new, and carry `output` or
48
+ `change: "cp1"` to say which kind of note the call mints: a `cp1` output
49
+ confirmed without a certificate is `unverifiable`, one certified with a
50
+ `cs1` is `ok`, and a `cp1` change left uncertified is `unverifiable`. A
51
+ consumer driving these cases picks the output by that field; a hash
52
+ where it is absent. Other vector prose that called the hash-note
53
+ signature mandatory now says which output is owed one.
54
+ - CI runs on Node 24.
55
+
56
+ The withdraw-info vector still rejects a `withdrawRequest` with no
57
+ `mintPubkey`; relaxing that for Part 1-only mints is a wallet-side change
58
+ across every kit and is not in this release.
59
+
7
60
  ## 0.9.0 - 2026-09-11
8
61
 
9
62
  **Part 2 vectors.** `vectors/part2.json` covers LUD-25 Part 2, notes keyed by
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` | deliberately violates LUD-25 by issuing no signatures |
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.9.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
@@ -5,30 +5,24 @@
5
5
  // with the thing it grades would agree with that implementation's mistakes,
6
6
  // which is the one thing it must never do.
7
7
 
8
- import {bech32} from '@scure/base'
8
+ import {bech32, bech32m} from '@scure/base'
9
9
  import {sha256} from '@noble/hashes/sha2.js'
10
- import {secp256k1} from '@noble/curves/secp256k1.js'
10
+ import {schnorr, secp256k1} from '@noble/curves/secp256k1.js'
11
11
  import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
12
12
  import {randomBytes} from 'node:crypto'
13
13
 
14
14
  const noteId = k1 => bytesToHex(sha256(hexToBytes(k1)))
15
15
 
16
- const verifySignature = (k1, amountMsat, signatureHex, pubkeyHex) => {
17
- let sig
18
- try {
19
- sig = hexToBytes(signatureHex)
20
- } catch {
21
- return false
22
- }
16
+ // The "Lightning Signed Message" digest every LUD-25 signature is over.
17
+ const signedMessageDigest = message =>
18
+ sha256(sha256(new Uint8Array([...utf8ToBytes('Lightning Signed Message:'), ...utf8ToBytes(message)])))
19
+
20
+ // Does a 65-byte recoverable signature recover to that key? Both byte
21
+ // orderings are tried: the spec wants r || s || recovery-id, a node's
22
+ // signmessage emits the recovery id first, and a mint that forgot to
23
+ // reorder is still signing with its own key.
24
+ const recoversTo = (digest, sig, pubkeyHex) => {
23
25
  if (sig.length !== 65) return false
24
- const digest = sha256(
25
- sha256(
26
- new Uint8Array([
27
- ...utf8ToBytes('Lightning Signed Message:'),
28
- ...utf8ToBytes(`LNURLcash:${amountMsat}:${noteId(k1)}`)
29
- ])
30
- )
31
- )
32
26
  const leading = new Uint8Array([sig[64], ...sig.subarray(0, 64)])
33
27
  for (const candidate of [leading, sig]) {
34
28
  try {
@@ -43,6 +37,65 @@ const verifySignature = (k1, amountMsat, signatureHex, pubkeyHex) => {
43
37
  return false
44
38
  }
45
39
 
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
+ const verifySignature = (k1, amountMsat, signatureHex, pubkeyHex) => {
43
+ let sig
44
+ try {
45
+ sig = hexToBytes(signatureHex)
46
+ } catch {
47
+ return false
48
+ }
49
+ return recoversTo(signedMessageDigest(`LNURLcash:${amountMsat}:${noteId(k1)}`), sig, pubkeyHex)
50
+ }
51
+
52
+ // A Part 2 certificate: cs1 over the note's raw x-only public key.
53
+ const verifyCertificate = (pubkeyHex32, amountMsat, sig, mintPubkeyHex) =>
54
+ recoversTo(signedMessageDigest(`LNURLcash:${amountMsat}:${pubkeyHex32}`), sig, mintPubkeyHex)
55
+
56
+ // Part 2's bech32m strings: cp1 (32-byte key), ck1 and cs1 (65-byte
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.
59
+ const BECH32M_LIMIT = 200
60
+ const encodeCash = (hrp, bytes) => bech32m.encode(hrp, bech32m.toWords(bytes), BECH32M_LIMIT)
61
+ const decodeCash = (hrp, value, length) => {
62
+ if (typeof value !== 'string') return null
63
+ try {
64
+ const {prefix, words} = bech32m.decode(value, BECH32M_LIMIT)
65
+ if (prefix !== hrp) return null
66
+ const bytes = bech32m.fromWords(words)
67
+ return bytes.length === length ? bytes : null
68
+ } catch {
69
+ return null
70
+ }
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
+
92
+ // The bearer secret of a cp1 note: the key's signature over the fixed
93
+ // message "LNURLcash", r || s || recovery-id, the same value every time.
94
+ const ownershipProof = secretKey => {
95
+ const lead = secp256k1.sign(signedMessageDigest('LNURLcash'), secretKey, {format: 'recovered', prehash: false})
96
+ return encodeCash('ck', new Uint8Array([...lead.subarray(1), lead[0]]))
97
+ }
98
+
46
99
  // LUD-17: lnurlw://host/path is https://host/path, or http:// when the host
47
100
  // is an onion service (the spec) or loopback (development). A plain
48
101
  // https:// or http:// URL passes through untouched, so a caller can hand
@@ -202,6 +255,31 @@ export const parseAdvertisedMintFee = metadata => {
202
255
  return null
203
256
  }
204
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
+
205
283
  // Resolves what the user typed - a Lightning Address, a bare domain, or a
206
284
  // URL - to the payRequest URL to grade.
207
285
  export const resolveMint = input => {
@@ -220,7 +298,32 @@ export const resolveMint = input => {
220
298
 
221
299
  // ---- read-only checks -----------------------------------------------------
222
300
 
223
- 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} = {}) => {
224
327
  let pay
225
328
  let mintAddress
226
329
  await report.check('payRequest resolves and is well-formed', async () => {
@@ -274,6 +377,14 @@ export const gradeMint = async (payUrl, report) => {
274
377
  return `${match[1]} msat + ${match[2]} ppm`
275
378
  })
276
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
+
277
388
  let verifyUrl
278
389
  await report.check('issues an invoice for the amount requested', async () => {
279
390
  const amount = Math.max(pay.minSendable, 1000)
@@ -546,6 +657,19 @@ export const gradeMint = async (payUrl, report) => {
546
657
  }
547
658
  const spelt = spelling => (spelling === 'comment' ? 'a LUD-12 comment' : 'an h parameter')
548
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
+
549
673
  const advertised = spellingsOf(pay)
550
674
  const corroborated = spellingsOf(mintAddress)
551
675
  const claimed = [...new Set([...advertised, ...corroborated])]
@@ -622,6 +746,11 @@ export const gradeMint = async (payUrl, report) => {
622
746
 
623
747
  for (const spelling of probed) {
624
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
625
754
  const body = await quoteAt(spelling, value)
626
755
  if (spelling === 'h') {
627
756
  if (!hClaimed) continue
@@ -646,10 +775,20 @@ export const gradeMint = async (payUrl, report) => {
646
775
  const bare = new URL(pay.callback)
647
776
  bare.searchParams.set('amount', String(amount))
648
777
  const unnamed = await get(bare)
649
- assert(
650
- unnamed.status === 'ERROR' && !unnamed.pr,
651
- 'issued an invoice for a quote carrying no comment - current LUD-25 requires rejection before invoicing'
652
- )
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
+ }
653
792
 
654
793
  if (hClaimed) {
655
794
  const mismatch = new URL(pay.callback)
@@ -706,6 +845,9 @@ export const gradeMint = async (payUrl, report) => {
706
845
  }
707
846
  }
708
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
+ }
709
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`
710
852
  })
711
853
 
@@ -1046,28 +1188,35 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1046
1188
  return 'burned the old secret, minted the new'
1047
1189
  })
1048
1190
 
1049
- await report.check('signs the notes it issues', async () => {
1050
- assert(info.mintPubkey, 'no mintPubkey advertised - offline verification is mandatory')
1051
- assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
1052
- assert(currentSig, 'the rotate returned no sig - offline verification is mandatory')
1053
- // A mint that has rotated its signing key may publish the old ones as
1054
- // previousPubkeys, so notes it issued before the rotation still
1055
- // verify. Any key it currently stands behind is an acceptable signer
1056
- // for grading purposes. That is a narrower claim than it looks: it
1057
- // says the signature is genuine, not that a wallet should accept the
1058
- // new key - LUD-25 puts that decision with the holder.
1059
- const signedBy = [info.mintPubkey, ...previousPubkeys].find(key =>
1060
- verifySignature(current, info.maxWithdrawable, currentSig, key)
1061
- )
1062
- assert(
1063
- signedBy,
1064
- previousPubkeys.length > 0
1065
- ? 'the signature verifies against neither the advertised mintPubkey nor any published previous key'
1066
- : 'the signature does not verify against the advertised mintPubkey and amount'
1067
- )
1068
- return signedBy === info.mintPubkey
1191
+ // A mint that has rotated its signing key may publish the old ones as
1192
+ // previousPubkeys, so notes it issued before the rotation still verify.
1193
+ // Any key it currently stands behind is an acceptable signer for grading
1194
+ // purposes. That is a narrower claim than it looks: it says the
1195
+ // signature is genuine, not that a wallet should accept the new key -
1196
+ // LUD-25 puts that decision with the holder.
1197
+ const signerOf = verifies => [info.mintPubkey, ...previousPubkeys].find(key => verifies(key))
1198
+ const describeSigner = signedBy =>
1199
+ signedBy === info.mintPubkey
1069
1200
  ? 'verified offline'
1070
1201
  : `verified offline against a previous signing key (${signedBy.slice(0, 16)}...)`
1202
+ const unverified = () =>
1203
+ previousPubkeys.length > 0
1204
+ ? 'the signature verifies against neither the advertised mintPubkey nor any published previous key'
1205
+ : 'the signature does not verify against the advertised mintPubkey and amount'
1206
+
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
+ }
1215
+ assert(info.mintPubkey, 'a sig with no mintPubkey advertised verifies against nothing')
1216
+ assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
1217
+ const signedBy = signerOf(key => verifySignature(current, info.maxWithdrawable, currentSig, key))
1218
+ assert(signedBy, unverified())
1219
+ return `legacy Part 1 signature ${describeSigner(signedBy)}`
1071
1220
  })
1072
1221
 
1073
1222
  await report.check('reports a spent hash distinguishably from an unknown hash', async () => {
@@ -1095,12 +1244,10 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1095
1244
  })
1096
1245
 
1097
1246
  await report.check('keeps signatures off the informational endpoint', async () => {
1098
- // LUD-25: "Signatures are only ever delivered in the
1099
- // withdrawSuccessResponse of a rotate, split or merge, the
1100
- // informational endpoint never returns one." A mint that hands one out
1101
- // here lets anyone holding only a note's PUBLIC url mint a certificate
1102
- // for it, and invites a wallet to treat the informational answer as an
1103
- // offline proof when it is an online one.
1247
+ // A plain note has no certificate, so its informational GET carries no
1248
+ // sig. Part 2 does hand out a cs1 here for a cp1 note, and the Part 2
1249
+ // check below expects it; this probe is by hex k1, where one would
1250
+ // invite a wallet to treat an online answer as an offline proof.
1104
1251
  const here = new URL(url)
1105
1252
  here.searchParams.set('k1', current)
1106
1253
  const body = await get(here)
@@ -1418,6 +1565,69 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1418
1565
  return body.reason
1419
1566
  })
1420
1567
 
1568
+ // LUD-25 Part 2. The spec's one MUST for signatures lives here: a cp1
1569
+ // note is a public key, and the SERVICE certifies every one it issues
1570
+ // with a cs1 over (key, amount) that recovers to mintPubkey. Part 2 is
1571
+ // optional, so a SERVICE that refuses the cp1 output warns rather than
1572
+ // fails. Last, because the note comes back as a plain secret only if
1573
+ // the rotate home succeeds.
1574
+ await report.check('certifies a cp1 note it issues (Part 2)', async () => {
1575
+ const secretKey = secp256k1.utils.randomSecretKey()
1576
+ const pubkey = schnorr.getPublicKey(secretKey)
1577
+ const cp1 = encodeCash('cp', pubkey)
1578
+ const out = new URL(info.callback)
1579
+ out.searchParams.append('k1', current)
1580
+ out.searchParams.append('p1', cp1)
1581
+ const body = await get(out)
1582
+ if (body.status === 'ERROR') throw soft(`Part 2 not offered: a cp1 output was refused (${body.reason})`)
1583
+ // The plain secret is burned either way; from here the bearer secret
1584
+ // is the key's ownership proof, until the rotate home below.
1585
+ const ck1 = ownershipProof(secretKey)
1586
+ current = ck1
1587
+ assert(info.mintPubkey, 'issued a cp1 note with no mintPubkey advertised - nothing to verify its certificate against')
1588
+ assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
1589
+ const pubkeyHex = bytesToHex(pubkey)
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))
1597
+ assert(signedBy, unverified())
1598
+
1599
+ // The informational GET by ck1 delivers the certificate again, so a
1600
+ // holder need not rotate just to obtain one.
1601
+ const byKey = new URL(url)
1602
+ byKey.searchParams.set('k1', ck1)
1603
+ const lookup = await get(byKey)
1604
+ assert(lookup.status !== 'ERROR', `the cp1 note is not spendable by its ck1: ${lookup.reason}`)
1605
+ assert(
1606
+ lookup.maxWithdrawable === info.maxWithdrawable,
1607
+ `value changed across a rotate to cp1: ${info.maxWithdrawable} -> ${lookup.maxWithdrawable}`
1608
+ )
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
+ )
1615
+ assert(
1616
+ signerOf(key => verifyCertificate(pubkeyHex, again.amountMsat, again.signature, key)),
1617
+ 'the certificate on the informational GET does not verify'
1618
+ )
1619
+
1620
+ // Home: back to a plain secret, spent by the ck1.
1621
+ const fresh = bytesToHex(randomBytes(32))
1622
+ const home = new URL(info.callback)
1623
+ home.searchParams.append('k1', ck1)
1624
+ home.searchParams.append('p1', noteId(fresh))
1625
+ const back = await get(home)
1626
+ assert(back.status === 'OK', `the cp1 note could not be rotated back to a plain secret by its ck1: ${back.reason}`)
1627
+ current = fresh
1628
+ return `${describeSigner(signedBy)}; the plain note it rotated home to is ${back.sig === undefined ? 'unsigned' : 'still signed the Part 1 way'}`
1629
+ })
1630
+
1421
1631
  return {finalSecret: current, noteUrl: (() => {
1422
1632
  const u = new URL(url)
1423
1633
  u.searchParams.set('k1', current)
@@ -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 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."
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 where the outputs had any, 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. A SERVICE returning a rotate, split or merge MUST include the offline-verification signature in `sig` (and `sig2` for the second split output).",
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 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
+ "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",
@@ -19,8 +19,43 @@
19
19
  "body": {
20
20
  "status": "OK"
21
21
  },
22
+ "expect": "ok",
23
+ "why": "a bare OK is the conforming answer for a plain hash output since the Part 2 rewrite: the note is real, the WALLET keeps its secret, and nobody the holder hands it to can check it offline, which is what a plain note is. Only a cp1 output is owed a certificate"
24
+ },
25
+ {
26
+ "name": "cp1 output confirmed without a certificate",
27
+ "op": "mutation",
28
+ "output": "cp1",
29
+ "http": 200,
30
+ "body": {
31
+ "status": "OK"
32
+ },
33
+ "expect": "unverifiable",
34
+ "why": "a cp1 output is owed a cs1 certificate in sig; without one the note it names cannot be verified offline, which is the whole reason to hold a cp1 note. The note exists at the key the WALLET disclosed"
35
+ },
36
+ {
37
+ "name": "cp1 output certified",
38
+ "op": "mutation",
39
+ "output": "cp1",
40
+ "http": 200,
41
+ "body": {
42
+ "status": "OK",
43
+ "sig": "cs14w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2atdv6a53"
44
+ },
45
+ "expect": "ok",
46
+ "signature": "cs14w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2atdv6a53"
47
+ },
48
+ {
49
+ "name": "a split to a cp1 change that certifies only its first output",
50
+ "op": "split",
51
+ "change": "cp1",
52
+ "http": 200,
53
+ "body": {
54
+ "status": "OK",
55
+ "sig": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab"
56
+ },
22
57
  "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"
58
+ "why": "the change is a cp1 note and is owed its certificate in sig2 exactly as the first output would be; the change is not a lesser note"
24
59
  },
25
60
  {
26
61
  "name": "success with an offline-verification signature",
@@ -54,8 +89,9 @@
54
89
  "status": "OK",
55
90
  "sig": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab"
56
91
  },
57
- "expect": "unverifiable",
58
- "why": "both outputs of a split are notes and both need a signature; the change is not a lesser note"
92
+ "expect": "ok",
93
+ "signature": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
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"
59
95
  },
60
96
  {
61
97
  "name": "melt success with a LUD-21 style proof",
@@ -10,7 +10,7 @@
10
10
  ],
11
11
  "provenance": "Recorded, never inferred. A SERVICE links the burned inputs to the outputs they minted and matches against that. Matching on \"a note exists at h\" alone would let anyone holding a burned k1 and any outstanding note id pull a success out of the SERVICE.",
12
12
  "outcomes": {
13
- "replay": "the original success, byte for byte: the same status, the same sig and sig2, and no balance moved",
13
+ "replay": "the original success, byte for byte: the same status, the same sig and sig2 where the outputs had any, and no balance moved",
14
14
  "double-spend": "refused exactly as any other attempt to spend a burned secret, with the reason string unchanged"
15
15
  },
16
16
  "cases": [