lnurlcash-conformance 0.9.0 → 0.10.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,39 @@ 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.10.0 - 2026-09-11
8
+
9
+ **Signatures belong to cp1 notes.** The Part 2 rewrite of LUD-25 signs a
10
+ `cp1` note only: a plain hash has nothing to attest to without disclosing
11
+ the secret, so a plain note is unsigned by design. The grader follows it.
12
+
13
+ - `signs the notes it issues` is gone. In its place, `a plain note carries
14
+ no signature, or one that verifies`: a hash rotate answered with a bare
15
+ `{"status":"OK"}` passes, and a mint still issuing the old Part 1
16
+ signature over the hash passes as long as the signature is a true one.
17
+ `mintPubkey` is no longer demanded of a mint that issues no signature.
18
+ - New, last in the note run: `certifies a cp1 note it issues (Part 2)`.
19
+ The grader rotates the note into a fresh `cp1` key, requires a `cs1` in
20
+ `sig` that recovers to `mintPubkey` (or a published previous key) over
21
+ the key and amount, requires the same certificate again on the
22
+ informational GET by `ck1`, then rotates home to a plain secret by that
23
+ `ck1`. A mint that refuses the `cp1` output warns, never fails: Part 2 is
24
+ optional. The mock mint has no Part 2 yet, so it warns here.
25
+ - `vectors/responses.json` follows: a bare `{"status":"OK"}` to a hash
26
+ output is now `ok` (it was `unverifiable`), and so is a split that signs
27
+ only its first hash output. Three cases are new, and carry `output` or
28
+ `change: "cp1"` to say which kind of note the call mints: a `cp1` output
29
+ confirmed without a certificate is `unverifiable`, one certified with a
30
+ `cs1` is `ok`, and a `cp1` change left uncertified is `unverifiable`. A
31
+ consumer driving these cases picks the output by that field; a hash
32
+ where it is absent. Other vector prose that called the hash-note
33
+ signature mandatory now says which output is owed one.
34
+ - CI runs on Node 24.
35
+
36
+ The withdraw-info vector still rejects a `withdrawRequest` with no
37
+ `mintPubkey`; relaxing that for Part 1-only mints is a wallet-side change
38
+ across every kit and is not in this release.
39
+
7
40
  ## 0.9.0 - 2026-09-11
8
41
 
9
42
  **Part 2 vectors.** `vectors/part2.json` covers LUD-25 Part 2, notes keyed by
package/README.md CHANGED
@@ -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. Allowed since the Part 2 rewrite: a plain note is unsigned by design, so the grader passes it |
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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lnurlcash-conformance",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Language-neutral conformance vectors, an adversarial mock mint, and a grader for LNURLcash (LUD-25) implementations",
5
5
  "author": "TheCryptoDonkey",
6
6
  "license": "MIT",
package/runner/index.mjs CHANGED
@@ -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,45 @@ const verifySignature = (k1, amountMsat, signatureHex, pubkeyHex) => {
43
37
  return false
44
38
  }
45
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.
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). Longer than BIP-173's 90 characters by design.
58
+ const BECH32M_LIMIT = 200
59
+ const encodeCash = (hrp, bytes) => bech32m.encode(hrp, bech32m.toWords(bytes), BECH32M_LIMIT)
60
+ const decodeCash = (hrp, value, length) => {
61
+ if (typeof value !== 'string') return null
62
+ try {
63
+ const {prefix, words} = bech32m.decode(value, BECH32M_LIMIT)
64
+ if (prefix !== hrp) return null
65
+ const bytes = bech32m.fromWords(words)
66
+ return bytes.length === length ? bytes : null
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+
72
+ // The bearer secret of a cp1 note: the key's signature over the fixed
73
+ // message "LNURLcash", r || s || recovery-id, the same value every time.
74
+ const ownershipProof = secretKey => {
75
+ const lead = secp256k1.sign(signedMessageDigest('LNURLcash'), secretKey, {format: 'recovered', prehash: false})
76
+ return encodeCash('ck', new Uint8Array([...lead.subarray(1), lead[0]]))
77
+ }
78
+
46
79
  // LUD-17: lnurlw://host/path is https://host/path, or http:// when the host
47
80
  // is an onion service (the spec) or loopback (development). A plain
48
81
  // https:// or http:// URL passes through untouched, so a caller can hand
@@ -1046,28 +1079,33 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1046
1079
  return 'burned the old secret, minted the new'
1047
1080
  })
1048
1081
 
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
1082
+ // A mint that has rotated its signing key may publish the old ones as
1083
+ // previousPubkeys, so notes it issued before the rotation still verify.
1084
+ // Any key it currently stands behind is an acceptable signer for grading
1085
+ // purposes. That is a narrower claim than it looks: it says the
1086
+ // signature is genuine, not that a wallet should accept the new key -
1087
+ // LUD-25 puts that decision with the holder.
1088
+ const signerOf = verifies => [info.mintPubkey, ...previousPubkeys].find(key => verifies(key))
1089
+ const describeSigner = signedBy =>
1090
+ signedBy === info.mintPubkey
1069
1091
  ? 'verified offline'
1070
1092
  : `verified offline against a previous signing key (${signedBy.slice(0, 16)}...)`
1093
+ const unverified = () =>
1094
+ previousPubkeys.length > 0
1095
+ ? 'the signature verifies against neither the advertised mintPubkey nor any published previous key'
1096
+ : 'the signature does not verify against the advertised mintPubkey and amount'
1097
+
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'
1104
+ assert(info.mintPubkey, 'a sig with no mintPubkey advertised verifies against nothing')
1105
+ assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
1106
+ const signedBy = signerOf(key => verifySignature(current, info.maxWithdrawable, currentSig, key))
1107
+ assert(signedBy, unverified())
1108
+ return `a legacy Part 1 signature, ${describeSigner(signedBy)}`
1071
1109
  })
1072
1110
 
1073
1111
  await report.check('reports a spent hash distinguishably from an unknown hash', async () => {
@@ -1095,12 +1133,10 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1095
1133
  })
1096
1134
 
1097
1135
  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.
1136
+ // A plain note has no certificate, so its informational GET carries no
1137
+ // sig. Part 2 does hand out a cs1 here for a cp1 note, and the Part 2
1138
+ // check below expects it; this probe is by hex k1, where one would
1139
+ // invite a wallet to treat an online answer as an offline proof.
1104
1140
  const here = new URL(url)
1105
1141
  here.searchParams.set('k1', current)
1106
1142
  const body = await get(here)
@@ -1418,6 +1454,61 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
1418
1454
  return body.reason
1419
1455
  })
1420
1456
 
1457
+ // LUD-25 Part 2. The spec's one MUST for signatures lives here: a cp1
1458
+ // note is a public key, and the SERVICE certifies every one it issues
1459
+ // with a cs1 over (key, amount) that recovers to mintPubkey. Part 2 is
1460
+ // optional, so a SERVICE that refuses the cp1 output warns rather than
1461
+ // fails. Last, because the note comes back as a plain secret only if
1462
+ // the rotate home succeeds.
1463
+ await report.check('certifies a cp1 note it issues (Part 2)', async () => {
1464
+ const secretKey = secp256k1.utils.randomSecretKey()
1465
+ const pubkey = schnorr.getPublicKey(secretKey)
1466
+ const cp1 = encodeCash('cp', pubkey)
1467
+ const out = new URL(info.callback)
1468
+ out.searchParams.append('k1', current)
1469
+ out.searchParams.append('p1', cp1)
1470
+ const body = await get(out)
1471
+ if (body.status === 'ERROR') throw soft(`Part 2 not offered: a cp1 output was refused (${body.reason})`)
1472
+ // The plain secret is burned either way; from here the bearer secret
1473
+ // is the key's ownership proof, until the rotate home below.
1474
+ const ck1 = ownershipProof(secretKey)
1475
+ current = ck1
1476
+ assert(info.mintPubkey, 'issued a cp1 note with no mintPubkey advertised - nothing to verify its certificate against')
1477
+ assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
1478
+ 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))
1482
+ assert(signedBy, unverified())
1483
+
1484
+ // The informational GET by ck1 delivers the certificate again, so a
1485
+ // holder need not rotate just to obtain one.
1486
+ const byKey = new URL(url)
1487
+ byKey.searchParams.set('k1', ck1)
1488
+ const lookup = await get(byKey)
1489
+ assert(lookup.status !== 'ERROR', `the cp1 note is not spendable by its ck1: ${lookup.reason}`)
1490
+ assert(
1491
+ lookup.maxWithdrawable === info.maxWithdrawable,
1492
+ `value changed across a rotate to cp1: ${info.maxWithdrawable} -> ${lookup.maxWithdrawable}`
1493
+ )
1494
+ const again = decodeCash('cs', lookup.sig, 65)
1495
+ assert(again, 'the informational GET by ck1 returned no cs1 certificate')
1496
+ assert(
1497
+ signerOf(key => verifyCertificate(pubkeyHex, info.maxWithdrawable, again, key)),
1498
+ 'the certificate on the informational GET does not verify'
1499
+ )
1500
+
1501
+ // Home: back to a plain secret, spent by the ck1.
1502
+ const fresh = bytesToHex(randomBytes(32))
1503
+ const home = new URL(info.callback)
1504
+ home.searchParams.append('k1', ck1)
1505
+ home.searchParams.append('p1', noteId(fresh))
1506
+ const back = await get(home)
1507
+ assert(back.status === 'OK', `the cp1 note could not be rotated back to a plain secret by its ck1: ${back.reason}`)
1508
+ current = fresh
1509
+ return `${describeSigner(signedBy)}; the plain note it rotated home to is ${back.sig === undefined ? 'unsigned' : 'still signed the Part 1 way'}`
1510
+ })
1511
+
1421
1512
  return {finalSecret: current, noteUrl: (() => {
1422
1513
  const u = new URL(url)
1423
1514
  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. 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.",
5
5
  "parse": [
6
6
  {
7
7
  "url": "https://mint.example/w?k1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&amount=21000",
@@ -4,7 +4,7 @@
4
4
  "description": "Classifying a SERVICE response. The distinction that matters for funds is definitive-rejection versus ambiguous-outcome: a parsed {\"status\":\"ERROR\"} means the request was processed and refused, while a transport failure, an unparseable body, or a 200 that does not confirm means the mutation MAY have landed - and for rotate/split/merge the WALLET-generated secrets are then the only copy of the outputs, so they must ride the error rather than be discarded. A confirmed mutation carrying no signature is its own outcome: it definitely landed, so the secrets matter more than ever, and the SERVICE is non-conforming. `op` says which call each case is driven through - a melt is the one mutation with no signature to return.",
5
5
  "outcomes": {
6
6
  "ok": "the operation is confirmed",
7
- "unverifiable": "the mutation is confirmed but carries no signature. LUD-25 requires one on every rotate, split and merge, so this SERVICE is non-conforming - but the note EXISTS at the hash the WALLET disclosed, and its secret is the only key to that value. Keep the secret; report the mint",
7
+ "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",
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 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"
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": [