lnurlcash-conformance 0.8.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 +54 -0
- package/README.md +4 -1
- package/llms.txt +4 -0
- package/package.json +1 -1
- package/runner/index.mjs +134 -43
- package/vectors/index.json +3 -1
- package/vectors/lifecycle.json +1 -1
- package/vectors/nostr-seed.json +133 -0
- package/vectors/note-url.json +1 -1
- package/vectors/part2.json +711 -0
- package/vectors/responses.json +40 -4
- package/vectors/retried-mutation.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,60 @@ 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
|
+
|
|
40
|
+
## 0.9.0 - 2026-09-11
|
|
41
|
+
|
|
42
|
+
**Part 2 vectors.** `vectors/part2.json` covers LUD-25 Part 2, notes keyed by
|
|
43
|
+
a public key and spent by a recoverable signature: the four bech32m strings
|
|
44
|
+
(`cp1`, `ck1`, `cs1`, `cx1`, strict per BIP-350), the per-note key tweak
|
|
45
|
+
(watch-only and held), ownership signatures and mint certificates, over 8
|
|
46
|
+
branches (two mnemonics, four hosts including one with a port, both branch
|
|
47
|
+
parities) and 7 indices up to `u32::MAX`. The generator builds them from the
|
|
48
|
+
primitives like every other file here, and they match, byte for byte,
|
|
49
|
+
vectors generated from lnurl-wallet's `src/lib` and confirmed by lnurl-mint.
|
|
50
|
+
The address branch is the reference wallet's `m/139'/1'/d1..d4`, not the
|
|
51
|
+
draft text's `m/139'/d1..d4`; `conventions` records both. The self-check
|
|
52
|
+
recomputes every value, including recovering each `ck1` to its key and each
|
|
53
|
+
`cs1` to the mint's.
|
|
54
|
+
|
|
55
|
+
`vectors/nostr-seed.json` is an extension, not LUD-25, marked as such: a Part
|
|
56
|
+
2 address branch rooted in a Nostr identity key (`HMAC-SHA256(key,
|
|
57
|
+
"LNURLcash/nostr-seed")`, then the same path), for a holder with no BIP39
|
|
58
|
+
words. heartwood-esp32 derives it on the device and lnurlcash-kit exports
|
|
59
|
+
it; the generated file is identical to the one both already grade against.
|
|
60
|
+
|
|
7
61
|
## 0.8.0 - 2026-09-09
|
|
8
62
|
|
|
9
63
|
**A spent note must say so.** LUD-25 [#307][307] settled the hash lookup's
|
package/README.md
CHANGED
|
@@ -47,6 +47,8 @@ for (const c of cases) {
|
|
|
47
47
|
| --- | --- |
|
|
48
48
|
| `signature.json` | offline verification, both recovery-id orderings, malformed input |
|
|
49
49
|
| `derivation.json` | deterministic note secrets from a BIP39 seed |
|
|
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 |
|
|
50
52
|
| `bech32.json` | LUD-01 encoding, round trips, corrupted checksums |
|
|
51
53
|
| `url-admission.json` | which URLs may be fetched, and why `data:` must never be |
|
|
52
54
|
| `input-resolution.json` | bech32, LUD-17, Lightning Addresses, bare domains |
|
|
@@ -61,6 +63,7 @@ for (const c of cases) {
|
|
|
61
63
|
| `settle-for-value.json` | the decision table a server works through to take a note as payment |
|
|
62
64
|
| `retried-mutation.json` | what makes a repeated mutation a retry rather than a double-spend |
|
|
63
65
|
| `mint-to-hash.json` | additive `mintToHash` compatibility and optional bound LUD-21 receipts; not baseline LUD-25 |
|
|
66
|
+
| `nostr-seed.json` | a Part 2 address branch rooted in a Nostr identity key, for a holder with no BIP39 words (heartwood-esp32, lnurlcash-kit); an extension, not LUD-25 |
|
|
64
67
|
| `lifecycle.json` | behavioural requirements, as scenarios to drive |
|
|
65
68
|
| `threat-suite.json` | the transport/exposure scorecard — candidate spec options against fixed attacks (non-normative) |
|
|
66
69
|
|
|
@@ -92,7 +95,7 @@ must survive:
|
|
|
92
95
|
| `--echoWrongK1` | answers the informational GET with a different `k1` |
|
|
93
96
|
| `--lieAboutValue=N` | reports a `maxWithdrawable` it never signed |
|
|
94
97
|
| `--signatureLayout=leading` | emits the recovery id at the other end |
|
|
95
|
-
| `--signatures=false` |
|
|
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 |
|
|
96
99
|
| `--serverGeneratedSecrets` | hands back a secret it generated — the exposure `h` exists to close |
|
|
97
100
|
| `--meltNeverSettles` | holds every melt in flight, so notes stay `pending` |
|
|
98
101
|
| `--meltAlwaysFails` | fails every payment, restoring the note |
|
package/llms.txt
CHANGED
|
@@ -12,6 +12,9 @@ vectors/index.json:
|
|
|
12
12
|
|
|
13
13
|
signature.json offline verification, both recovery-id orderings
|
|
14
14
|
derivation.json deterministic note secrets from a BIP39 seed
|
|
15
|
+
cash-derivation.json LUD-25 m/139' note secrets, plus BIP-32 vector 1
|
|
16
|
+
part2.json Part 2: cp1/ck1/cs1/cx1, the note-key tweak, ownership sigs, certificates;
|
|
17
|
+
address path is the reference wallet's m/139'/1'/d1..d4 (not the text's)
|
|
15
18
|
bech32.json LUD-01 encoding
|
|
16
19
|
url-admission.json which URLs may be fetched (https, or http to loopback/.onion)
|
|
17
20
|
input-resolution.json bech32, LUD-17, Lightning Address, bare domain
|
|
@@ -26,6 +29,7 @@ payment-request.json lnurlcashreq1: asking another holder for value
|
|
|
26
29
|
settle-for-value.json the decision table for taking a note as payment
|
|
27
30
|
retried-mutation.json what makes a repeat a retry, not a double-spend
|
|
28
31
|
mint-to-hash.json naming the note you are buying: h on the pay callback
|
|
32
|
+
nostr-seed.json EXTENSION: Part 2 branch from a Nostr key, HMAC-SHA256(key, "LNURLcash/nostr-seed")
|
|
29
33
|
lifecycle.json behavioural scenarios
|
|
30
34
|
threat-suite.json transport/exposure scorecard, options vs attacks (non-normative)
|
|
31
35
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lnurlcash-conformance",
|
|
3
|
-
"version": "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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
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)
|
package/vectors/index.json
CHANGED
package/vectors/lifecycle.json
CHANGED
|
@@ -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",
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"spec": "LUD-25 draft (lnurl/luds#301)",
|
|
4
|
+
"extension": true,
|
|
5
|
+
"description": "An extension, not LUD-25: a Part 2 address branch rooted in a Nostr identity key, for a holder with no BIP39 words. seed = HMAC-SHA256(key = identity (the 32-byte secret key), msg = utf8(\"LNURLcash/nostr-seed\")); the branch is then the reference wallet's m/139'/1'/d1..d4 from that seed exactly as in part2.json, and every note field means what it means there. identityPubkey is the identity's x-only public key, the npub a lightning address belongs to. heartwood-esp32 derives this on the device and lnurlcash-kit exports it (deriveNostrAddressNode); a mint sees an ordinary cx1.",
|
|
6
|
+
"label": "LNURLcash/nostr-seed",
|
|
7
|
+
"cases": [
|
|
8
|
+
{
|
|
9
|
+
"identity": "0000000000000000000000000000000000000000000000000000000000000001",
|
|
10
|
+
"identityPubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
|
11
|
+
"host": "moneyer.dev",
|
|
12
|
+
"seed": "3ae0ea260b8066e219db88cf06b3ea9a170104875f3ea70a8434a2bc478ea121",
|
|
13
|
+
"addressNode": "6ec86f66d87919b32347fdc6b93855bdf3649e2641fc57ee977b72ce9d34625f63246e942e8f68ec0b82c7bcc1b39e5643a2a304356bcb3821163805f9d2d700",
|
|
14
|
+
"cx1": "cx1haqpwf60sc5gkzar0282ma4t56fm9fzlm43nr23ztyvxkl0w8j4xxfrwjshg768vpwpv00xpkw09vsaz5vzr267t8qs3vwq9l8fdwqq605q4l",
|
|
15
|
+
"notes": [
|
|
16
|
+
{
|
|
17
|
+
"index": 0,
|
|
18
|
+
"noteSecretKey": "6257b20051d8bf990abef9274b05eec72deed8f6a6adae958e91ceb51e06136a",
|
|
19
|
+
"notePubkey": "5d4fc85381b2b548c802d96a3bb98c2b278fef566e4888545abf1ebb7f3bfc79",
|
|
20
|
+
"cp1": "cp1t48us5upk2653jqzm94rhwvv9vnclm6kdeygs4z6hu0tkleml3uscuyhw6",
|
|
21
|
+
"ck1": "ck1v5ua6x3ldunqyhj5mh89dyxt3g27uy2n8awq3cktj83jtn7hux0q9dgh4g6mly78vgmh4cgf34dq7l8ww7vadx644scv9h8tekq672qqr60q6e"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"index": 1,
|
|
25
|
+
"noteSecretKey": "ad44ab63c2ca7890e2a3a7644597dfcb7ad5aece2722d3506aa1b0a2df4d8461",
|
|
26
|
+
"notePubkey": "d0ca20135641a5e43124476048992fc61dce1fba6e787d47d34e5a32de86675c",
|
|
27
|
+
"cp1": "cp16r9zqy6kgxj7gvfygasy3xf0ccwuu8a6deu8637nfedr9h5xvawqrq3sk4",
|
|
28
|
+
"ck1": "ck1p99ktdrr2vpf8unme3mjd9wvgx22840n05aqh78vw9kma337xntshw7pdldn4dujn3e23hgyx9znr3e60g8293gkv0h0xk9mskgqd9qq0cjpsp"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"index": 7,
|
|
32
|
+
"noteSecretKey": "e9acf5438666e2563e12f77f904298a1c11b6f580ae44adfbaad5897b5f85893",
|
|
33
|
+
"notePubkey": "f2bbf60e6d9697e488cdbfe4449045541e753e3062f14d06ad0934c9e98ebcfe",
|
|
34
|
+
"cp1": "cp172alvrndj6t7fzxdhljyfyz92s08203svtc56p4dpy6vn6vwhnlqv0yvd2",
|
|
35
|
+
"ck1": "ck1r55fx609dq0pz8qg6q73hsa0v4wkdz6ldxqr2rshg72ydvk3aezrv2c77xw8smqckgjrj9h5vp5n4tp04v6hae0yazrzcfcj4p7saespmxl28s"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"identity": "0000000000000000000000000000000000000000000000000000000000000001",
|
|
41
|
+
"identityPubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
|
42
|
+
"host": "mint.example",
|
|
43
|
+
"seed": "3ae0ea260b8066e219db88cf06b3ea9a170104875f3ea70a8434a2bc478ea121",
|
|
44
|
+
"addressNode": "06825d4c6b2d170fa57fa0449dfd5c8576eb166f3c35a8e39cf14db396e2ec9c7c338e8dca8b99b65031b0fbc5740fae7328bb55ff156791f5cd9dc393b4da30",
|
|
45
|
+
"cx1": "cx14tvrmc8yw86su4puf327tv28g5hl2re7p2ygwyttfcsqqg6hn9q8cvuw3h9ghxdk2qcmp779ws86uueghd2l79t8j86um8wrjw6d5vqfhgw4f",
|
|
46
|
+
"notes": [
|
|
47
|
+
{
|
|
48
|
+
"index": 0,
|
|
49
|
+
"noteSecretKey": "3fe7bd30638deca734f56bec3adc015b32b5095ccd373113aae9392ef13aad09",
|
|
50
|
+
"notePubkey": "5d8b9c7229178df9413e818e50535b7c53e8cb9ce519de831d0fd6548beab22e",
|
|
51
|
+
"cp1": "cp1tk9ecu3fz7xljsf7sx89q56m03f73juuu5vaaqcaplt9fzl2kghqsdyjd4",
|
|
52
|
+
"ck1": "ck154300fcxpmnr9308fs7ut66snvc2xhez2uap2ngn32zha2ew3kv9zz8hjnzncewzdrpk8jxgheg4wthclvl4cdnygkpn9c9kda64swgq34camt"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"index": 1,
|
|
56
|
+
"noteSecretKey": "a76597b438f20a6c57995d615ad8fa8f63dadbf2efbcdc8a73e4bdf9c605b203",
|
|
57
|
+
"notePubkey": "66866a6420435fea5c9179e5ea9c31fb5cbd985c7f590a2a509cd5ef09e91b5d",
|
|
58
|
+
"cp1": "cp1v6rx5epqgd075hy308j748p3ldwtmxzu0avs52jsnn277z0frdwslr7t47",
|
|
59
|
+
"ck1": "ck1ea8utp0c6l35xgj6huluu6mypcvuqd9le4mmfm6dlcgl20f60lnpvjxnmtx6afpuxpc70ngdt0uhx07kuzws8amg6fcew2fetanrvyqpljdau4"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"index": 7,
|
|
63
|
+
"noteSecretKey": "8fb88ed836e0fe83728642938d6463cee74781b7d4b0d313717be6de50b6d046",
|
|
64
|
+
"notePubkey": "14c823d28bbdd662c788ab968ac56f6aef6cb88e622afe11bb6ac2134f24fb80",
|
|
65
|
+
"cp1": "cp1znyz855thhtx93ug4wtg43t0dthkewywvg40uydmdtppxneylwqq3nehx2",
|
|
66
|
+
"ck1": "ck1gtw6p9hglznv5xhw0z5elf8u66mc2s38gq7703w9mfgl2pc7ds7q092nl8jz8nqh523qw5640lg8gw8kvj60u97mcu9l5zgfkp9hmnqpstf30d"
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"identity": "7f4c11a9742721d66e40e321ca70b682c27f7422190c84a187525e69e6038369",
|
|
72
|
+
"identityPubkey": "99e98a193119f2a8adbe6c7da81d3cd64a25742cc49f51f4ec0da44b22bf09bf",
|
|
73
|
+
"host": "moneyer.dev",
|
|
74
|
+
"seed": "efd666778ca0baac6648f11f4b3c428655a1a380caa89309a4d18ae1529f0999",
|
|
75
|
+
"addressNode": "05d3aa38d42680e7d812aca8f11bedee79b19bcd0ecbe3ecb0fecf3efd5678f9ddcf32d0170b91e809ed08f3a5c06b47042921b7a230072d772edcf58fcb2b89",
|
|
76
|
+
"cx1": "cx168cuwapgp0hd6h7resq0uh78eze300vg7ef6t4kmywpcmaptdemdmnej6qtshy0gp8ks3ua9cp45wppfyxm6yvq894mjah843l9jhzg4rxu74",
|
|
77
|
+
"notes": [
|
|
78
|
+
{
|
|
79
|
+
"index": 0,
|
|
80
|
+
"noteSecretKey": "efe31724912e78b6a9262deecc2ad25c70022a551ca7dca2a99058fab81f00cf",
|
|
81
|
+
"notePubkey": "5fcdc98dad667ff47d8e0394d9c4fb5664269558de303a4704281c64b01f4ca3",
|
|
82
|
+
"cp1": "cp1tlxunrddvellglvwqw2dn38m2ejzd92cmccr53cy9qwxfvqlfj3s74l9ar",
|
|
83
|
+
"ck1": "ck1yvcxftc6l8pduzetwhxzxmnled7w4xzgkldy7dsxga90rxux8r7kjy5l9wphc0p79k526wg3x4plk0nx54erv3z0gyt4r8082aqypyqpve8twp"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"index": 1,
|
|
87
|
+
"noteSecretKey": "4d2988c9a2dbec87b1c737fed42e37424a4a7cf72f2b3721a0668f40b4f8ad02",
|
|
88
|
+
"notePubkey": "ae1fc4e14097b00e67343e62c3fad53eb836b201bd3774e24185b137fb7a6c7d",
|
|
89
|
+
"cp1": "cp14c0ufc2qj7cquee58e3v87k486urdvsph5mhfcjpskcn07m6d37s6qvndu",
|
|
90
|
+
"ck1": "ck12ggha0c0za5clvnx0cdqasuwexn947uyu6wmady9wvrt9xuzj4j3nsjk8r0uzq6yzyq6jmtnpeqarwxq28c7cz756g03et2frnqjtyqqp927xj"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"index": 7,
|
|
94
|
+
"noteSecretKey": "17bfb4cc3c3768c1cce2b33e8b9600e6231bbd925cd3fcc413009915f0348e4d",
|
|
95
|
+
"notePubkey": "254feeb2016cc8deb154983d3da72dcad2c202736eca9031dcefd9693a9faae6",
|
|
96
|
+
"cp1": "cp1y487avspdnydav25nq7nmfedetfvyqnndm9fqvwualvkjw5l4tnqkypely",
|
|
97
|
+
"ck1": "ck1eesa3wacyrffpnl267vns608cah8cr6n35rlhnydwnq9r94jc2xxfceh0pt40f388u4k995m7f6dw5jhzcpgm97pjwf0dk0h7rj8rhcqt54nxa"
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"identity": "7f4c11a9742721d66e40e321ca70b682c27f7422190c84a187525e69e6038369",
|
|
103
|
+
"identityPubkey": "99e98a193119f2a8adbe6c7da81d3cd64a25742cc49f51f4ec0da44b22bf09bf",
|
|
104
|
+
"host": "127.0.0.1:8899",
|
|
105
|
+
"seed": "efd666778ca0baac6648f11f4b3c428655a1a380caa89309a4d18ae1529f0999",
|
|
106
|
+
"addressNode": "9df193134dacac4561674b0f099f2dfcbfea884f3df655aa53117798cb3e3065a2eb93a6d2be16ca6acee95a43317b870a718bf96b0a2163e1c99c52f96518b7",
|
|
107
|
+
"cx1": "cx10l8vgpvm7nsa6x8pn87ampuxvevw9eyxj2x2dzg72zk7fzrcfg7696un5mftu9k2dt8wjkjrx9acwzn330ukkz3pv0sun8zjl9j33dcymzxac",
|
|
108
|
+
"notes": [
|
|
109
|
+
{
|
|
110
|
+
"index": 0,
|
|
111
|
+
"noteSecretKey": "72242afc3854a1c5d41748b25d2fddc06b75b663b69cd051eb8d102262703696",
|
|
112
|
+
"notePubkey": "c01e87a6ebb3cb6596284a6b0da265df73bdaf885bfd0760929c7e2828cbfd0c",
|
|
113
|
+
"cp1": "cp1cq0g0fhtk09kt93gff4smgn9maemmtugt07swcyjn3lzs2xtl5xqjvp56d",
|
|
114
|
+
"ck1": "ck15kws54u2vk84hzmf2a6wm7yhnzljl907lmnsr5grgeg06evcj464qvccyflnzqdm44aenxg4gl2dmxfy34pvk3unt4k5ptl7yfr3hngqhn3gvc"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"index": 1,
|
|
118
|
+
"noteSecretKey": "cea7c90475f1cc5c19784bccee03b0f4b3972d6a8c3710fb820720cbd74e0d1d",
|
|
119
|
+
"notePubkey": "ba9bcbb4e1b9e5884b40f5c3b15d59352814ca77bd226d51960f289a22e96319",
|
|
120
|
+
"cp1": "cp1h2duhd8ph8jcsj6q7hpmzh2ex55pfjnhh53x65vkpu5f5ghfvvvsa3zqpx",
|
|
121
|
+
"ck1": "ck126c3ppdcs0708ya2r8ffde5ggtnnp5tdu6und5tnygqu804t034rcu7mgefx0feusscnqr3ym78l2mywev5apjjptg9mm2g9d7sse3gp6tm5ss"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"index": 7,
|
|
125
|
+
"noteSecretKey": "4ea4a3488ae292b38074ef02996ffe5cc82be76e6fc509b0793d8e22be0c181f",
|
|
126
|
+
"notePubkey": "49ef82ee7810b6d8099635eb422feca997850663daded48788993ec7d9585965",
|
|
127
|
+
"cp1": "cp1f8hc9mnczzmdszvkxh45ytlv4xtc2pnrmt0dfpugnylv0k2ct9jst4p42x",
|
|
128
|
+
"ck1": "ck16esd3xmpy5gw2elw3d83hpye7ygf5mtzcuh8aygwpevc9fjwjdmnwldmj3kajcu9da2kz92n5uty3ryqeckjxe586p2ncrsj20re9mgq7vv73l"
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
}
|
package/vectors/note-url.json
CHANGED
|
@@ -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
|
|
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",
|