lnurlcash-conformance 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +3 -3
- package/mock-mint/index.mjs +81 -7
- package/package.json +1 -1
- package/runner/cli.mjs +18 -1
- package/runner/index.d.ts +24 -1
- package/runner/index.mjs +140 -21
- package/vectors/note-url.json +23 -1
- package/vectors/part2.json +42 -6
- package/vectors/responses.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ Semantic versioning. While the LUD-25 draft is unmerged, `0.x` minor bumps
|
|
|
4
4
|
may add or tighten checks that a previously-passing mint now fails; pin an
|
|
5
5
|
exact version if you gate CI on the grade.
|
|
6
6
|
|
|
7
|
+
## 0.11.0 - 2026-09-15
|
|
8
|
+
|
|
9
|
+
**Amount-bearing certificates and internal-transfer discovery.** Part 2 now
|
|
10
|
+
follows the current `lnurl-wallet` and `lnurl-mint` reference implementations;
|
|
11
|
+
the draft LUD-25 prose may lag this release.
|
|
12
|
+
|
|
13
|
+
- `cs1` carries `amount_msat` in its human-readable part using BOLT-11's
|
|
14
|
+
amount suffix rules. The vectors reject the legacy fixed `cs` HRP, and
|
|
15
|
+
the live grader requires the encoded amount to equal the note's
|
|
16
|
+
authoritative `maxWithdrawable` before verifying the certificate.
|
|
17
|
+
- `--address` now requires a valid `text/xpub` metadata entry carrying the
|
|
18
|
+
registered `cx1` plus its next-index hint. The mock address publishes a
|
|
19
|
+
real branch and advances outputs with the LUD-25 public derivation.
|
|
20
|
+
- Legacy hash mutation outputs are signed by the current references. The
|
|
21
|
+
grader still accepts an omitted signature because the reference mint may
|
|
22
|
+
run without an available signer, but verifies every signature it receives.
|
|
23
|
+
- The compliant mock enables secret-free hash lookup by default, matching
|
|
24
|
+
the current reference wallet and mint. `hashLookup: false` remains an
|
|
25
|
+
explicit older-SERVICE fixture.
|
|
26
|
+
|
|
7
27
|
## 0.10.0 - 2026-09-11
|
|
8
28
|
|
|
9
29
|
**Signatures belong to cp1 notes.** The Part 2 rewrite of LUD-25 signs a
|
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ for (const c of cases) {
|
|
|
48
48
|
| `signature.json` | offline verification, both recovery-id orderings, malformed input |
|
|
49
49
|
| `derivation.json` | deterministic note secrets from a BIP39 seed |
|
|
50
50
|
| `cash-derivation.json` | LUD-25's seed-recoverable note secrets under `m/139'`, with BIP-32's own vector 1 |
|
|
51
|
-
| `part2.json` | Part 2: `cp1`/`ck1
|
|
51
|
+
| `part2.json` | Part 2: `cp1`/`ck1`/amount-bearing `cs1`/`cx1`, the per-note key tweak, note ownership and mint certificates, on the reference wallet's `m/139'/1'` address path |
|
|
52
52
|
| `bech32.json` | LUD-01 encoding, round trips, corrupted checksums |
|
|
53
53
|
| `url-admission.json` | which URLs may be fetched, and why `data:` must never be |
|
|
54
54
|
| `input-resolution.json` | bech32, LUD-17, Lightning Addresses, bare domains |
|
|
@@ -95,7 +95,7 @@ must survive:
|
|
|
95
95
|
| `--echoWrongK1` | answers the informational GET with a different `k1` |
|
|
96
96
|
| `--lieAboutValue=N` | reports a `maxWithdrawable` it never signed |
|
|
97
97
|
| `--signatureLayout=leading` | emits the recovery id at the other end |
|
|
98
|
-
| `--signatures=false` | issues no Part 1 signatures.
|
|
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=
|
|
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 |
|
package/mock-mint/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
846
|
-
// nothing but a note can reach the document carrying this
|
|
847
|
-
// terms and
|
|
848
|
-
//
|
|
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.
|
|
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: (
|
|
73
|
+
export declare const gradeMint: (
|
|
74
|
+
payUrl: string,
|
|
75
|
+
report: Report,
|
|
76
|
+
options?: {
|
|
77
|
+
/** Grade as a LUD-25 Part 2 registered Lightning Address rather than a
|
|
78
|
+
* mint payLink: it mints on the next unused key of its own branch, so a
|
|
79
|
+
* comment naming no output is free text and must not fail the payment.
|
|
80
|
+
* Declared, not detected - an unsafe preimage-keyed fallback looks
|
|
81
|
+
* identical before settlement. */
|
|
82
|
+
registeredAddress?: boolean
|
|
83
|
+
}
|
|
84
|
+
) => Promise<void>
|
|
62
85
|
|
|
63
86
|
/**
|
|
64
87
|
* Read-only. Needs a freshly minted, never-rotated note and what its mint
|
package/runner/index.mjs
CHANGED
|
@@ -37,8 +37,8 @@ const recoversTo = (digest, sig, pubkeyHex) => {
|
|
|
37
37
|
return false
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
// one
|
|
40
|
+
// The legacy raw Part 1 signature over a note's hash. Current reference mints
|
|
41
|
+
// issue one when a signer is available, and every one received must be true.
|
|
42
42
|
const verifySignature = (k1, amountMsat, signatureHex, pubkeyHex) => {
|
|
43
43
|
let sig
|
|
44
44
|
try {
|
|
@@ -54,7 +54,8 @@ const verifyCertificate = (pubkeyHex32, amountMsat, sig, mintPubkeyHex) =>
|
|
|
54
54
|
recoversTo(signedMessageDigest(`LNURLcash:${amountMsat}:${pubkeyHex32}`), sig, mintPubkeyHex)
|
|
55
55
|
|
|
56
56
|
// Part 2's bech32m strings: cp1 (32-byte key), ck1 and cs1 (65-byte
|
|
57
|
-
// signature).
|
|
57
|
+
// signature). cs1 has a variable HRP: "cs" plus the amount under BOLT-11's
|
|
58
|
+
// amount rules. Longer than BIP-173's 90 characters by design.
|
|
58
59
|
const BECH32M_LIMIT = 200
|
|
59
60
|
const encodeCash = (hrp, bytes) => bech32m.encode(hrp, bech32m.toWords(bytes), BECH32M_LIMIT)
|
|
60
61
|
const decodeCash = (hrp, value, length) => {
|
|
@@ -69,6 +70,25 @@ const decodeCash = (hrp, value, length) => {
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
const AMOUNT_MSAT_PER_UNIT = {'': 1e11, m: 1e8, u: 1e5, n: 100, p: 0.1}
|
|
74
|
+
const amountSuffixMsat = suffix => {
|
|
75
|
+
const match = suffix.match(/^(\d+)([munp])?$/)
|
|
76
|
+
if (!match) return null
|
|
77
|
+
const amount = Number(match[1]) * AMOUNT_MSAT_PER_UNIT[match[2] ?? '']
|
|
78
|
+
return Number.isSafeInteger(amount) ? amount : null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const decodeCertificate = value => {
|
|
82
|
+
if (typeof value !== 'string') return null
|
|
83
|
+
const lower = value.trim().toLowerCase()
|
|
84
|
+
const sep = lower.lastIndexOf('1')
|
|
85
|
+
if (sep < 3 || !lower.startsWith('cs')) return null
|
|
86
|
+
const amountMsat = amountSuffixMsat(lower.slice(2, sep))
|
|
87
|
+
if (amountMsat === null) return null
|
|
88
|
+
const signature = decodeCash(lower.slice(0, sep), lower, 65)
|
|
89
|
+
return signature ? {amountMsat, signature} : null
|
|
90
|
+
}
|
|
91
|
+
|
|
72
92
|
// The bearer secret of a cp1 note: the key's signature over the fixed
|
|
73
93
|
// message "LNURLcash", r || s || recovery-id, the same value every time.
|
|
74
94
|
const ownershipProof = secretKey => {
|
|
@@ -235,6 +255,31 @@ export const parseAdvertisedMintFee = metadata => {
|
|
|
235
255
|
return null
|
|
236
256
|
}
|
|
237
257
|
|
|
258
|
+
// A registered Part 2 address advertises the safe-to-share branch and its
|
|
259
|
+
// best-known next index as ["text/xpub", "cx1...:<i>"]. Parsing it here
|
|
260
|
+
// makes --address assert the actual discovery signal rather than merely
|
|
261
|
+
// trusting the operator's flag.
|
|
262
|
+
export const parseInternalTransferHint = metadata => {
|
|
263
|
+
let entries
|
|
264
|
+
try {
|
|
265
|
+
entries = JSON.parse(metadata)
|
|
266
|
+
} catch {
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
if (!Array.isArray(entries)) return null
|
|
270
|
+
for (const entry of entries) {
|
|
271
|
+
if (!Array.isArray(entry) || entry[0] !== 'text/xpub' || typeof entry[1] !== 'string') continue
|
|
272
|
+
const sep = entry[1].lastIndexOf(':')
|
|
273
|
+
if (sep < 0) continue
|
|
274
|
+
const cx1 = entry[1].slice(0, sep)
|
|
275
|
+
const index = Number(entry[1].slice(sep + 1))
|
|
276
|
+
const branch = decodeCash('cx', cx1.toLowerCase(), 64)
|
|
277
|
+
if (!branch || !Number.isInteger(index) || index < 0 || index > 0xffffffff) continue
|
|
278
|
+
return {cx1, index, pubkeyXOnly: branch.slice(0, 32), chainCode: branch.slice(32)}
|
|
279
|
+
}
|
|
280
|
+
return null
|
|
281
|
+
}
|
|
282
|
+
|
|
238
283
|
// Resolves what the user typed - a Lightning Address, a bare domain, or a
|
|
239
284
|
// URL - to the payRequest URL to grade.
|
|
240
285
|
export const resolveMint = input => {
|
|
@@ -253,7 +298,32 @@ export const resolveMint = input => {
|
|
|
253
298
|
|
|
254
299
|
// ---- read-only checks -----------------------------------------------------
|
|
255
300
|
|
|
256
|
-
|
|
301
|
+
// `registeredAddress` grades the target as a LUD-25 Part 2 Lightning
|
|
302
|
+
// Address rather than a mint payLink. The two are the same document to a
|
|
303
|
+
// wallet - both advertise commentAllowed and a withdrawLink, and both
|
|
304
|
+
// mint on payment - but the draft's comment rules are written for the
|
|
305
|
+
// payLink, which has no key to mint under but the one the comment names.
|
|
306
|
+
// A cx1-registered address always has one, the next unused key on its
|
|
307
|
+
// branch, so there a comment naming no output is the ordinary free text
|
|
308
|
+
// LUD-12 invites and is ignored rather than refused (lnurl-mint #46,
|
|
309
|
+
// after every Wallet of Satoshi and Primal payment to such an address
|
|
310
|
+
// failed on a typed message).
|
|
311
|
+
//
|
|
312
|
+
// Deliberately a flag and not a probe. "Returned an invoice for a comment
|
|
313
|
+
// naming no output" is also exactly what a mint falling back to a
|
|
314
|
+
// preimage-keyed note does, and that mint is dangerous - the preimage
|
|
315
|
+
// race the draft's Security considerations describes. Nothing on the wire
|
|
316
|
+
// tells the two apart before settlement: they differ only in what key the
|
|
317
|
+
// note lands under. Guessing would wave the dangerous one through, so the
|
|
318
|
+
// strict payLink rules stay the default and an address is declared.
|
|
319
|
+
//
|
|
320
|
+
// The cost of that is real and worth stating: this flag takes the
|
|
321
|
+
// operator's word, and a preimage-keyed fallback passes under it. It
|
|
322
|
+
// relaxes the comment rules and nothing else, and the draft gives an
|
|
323
|
+
// address no way to say what it is on the wire. A signal it could
|
|
324
|
+
// advertise - so this became detectable rather than declared - is worth
|
|
325
|
+
// raising against LUD-25 Part 2.
|
|
326
|
+
export const gradeMint = async (payUrl, report, {registeredAddress = false} = {}) => {
|
|
257
327
|
let pay
|
|
258
328
|
let mintAddress
|
|
259
329
|
await report.check('payRequest resolves and is well-formed', async () => {
|
|
@@ -307,6 +377,14 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
307
377
|
return `${match[1]} msat + ${match[2]} ppm`
|
|
308
378
|
})
|
|
309
379
|
|
|
380
|
+
if (registeredAddress) {
|
|
381
|
+
await report.check('advertises its cx1 and next index for internal transfers', async () => {
|
|
382
|
+
const hint = parseInternalTransferHint(pay.metadata)
|
|
383
|
+
assert(hint, 'no valid ["text/xpub", "cx1...:<i>"] metadata entry')
|
|
384
|
+
return `index ${hint.index}`
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
310
388
|
let verifyUrl
|
|
311
389
|
await report.check('issues an invoice for the amount requested', async () => {
|
|
312
390
|
const amount = Math.max(pay.minSendable, 1000)
|
|
@@ -579,6 +657,19 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
579
657
|
}
|
|
580
658
|
const spelt = spelling => (spelling === 'comment' ? 'a LUD-12 comment' : 'an h parameter')
|
|
581
659
|
|
|
660
|
+
// The behaviour #46 fixed, asserted rather than assumed. Asked once,
|
|
661
|
+
// not once per malformed value: on an address a quote claims the next
|
|
662
|
+
// branch index as it is issued, so every probe costs the holder a key
|
|
663
|
+
// whether or not anyone pays. `gm` is short enough that no mint could
|
|
664
|
+
// read it as a commitment of any spelling.
|
|
665
|
+
if (registeredAddress) {
|
|
666
|
+
const freeText = await quoteAt('comment', 'gm')
|
|
667
|
+
assert(
|
|
668
|
+
freeText.status !== 'ERROR' && typeof freeText.pr === 'string',
|
|
669
|
+
`refused a comment naming no output: ${freeText.reason} - this address mints on its own branch, so an ordinary LUD-12 message must not fail the payment`
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
|
|
582
673
|
const advertised = spellingsOf(pay)
|
|
583
674
|
const corroborated = spellingsOf(mintAddress)
|
|
584
675
|
const claimed = [...new Set([...advertised, ...corroborated])]
|
|
@@ -655,6 +746,11 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
655
746
|
|
|
656
747
|
for (const spelling of probed) {
|
|
657
748
|
for (const [what, value] of malformed) {
|
|
749
|
+
// Every one of these names no output, which on an address is free
|
|
750
|
+
// text, already covered above. `h` stays probed either way: it is
|
|
751
|
+
// a parameter invented for this one purpose, so a malformed one is
|
|
752
|
+
// a wallet error wherever it is sent.
|
|
753
|
+
if (spelling === 'comment' && registeredAddress) continue
|
|
658
754
|
const body = await quoteAt(spelling, value)
|
|
659
755
|
if (spelling === 'h') {
|
|
660
756
|
if (!hClaimed) continue
|
|
@@ -679,10 +775,20 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
679
775
|
const bare = new URL(pay.callback)
|
|
680
776
|
bare.searchParams.set('amount', String(amount))
|
|
681
777
|
const unnamed = await get(bare)
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
778
|
+
if (registeredAddress) {
|
|
779
|
+
// The draft is explicit that a registered address needs no comment
|
|
780
|
+
// at all: it derives the next key from its own cx1. Refusing here
|
|
781
|
+
// would break every plain Lightning payment to the address.
|
|
782
|
+
assert(
|
|
783
|
+
unnamed.status !== 'ERROR' && typeof unnamed.pr === 'string',
|
|
784
|
+
`refused a quote carrying no comment: ${unnamed.reason} - a registered address mints on its own branch, so a payment to it must not need one`
|
|
785
|
+
)
|
|
786
|
+
} else {
|
|
787
|
+
assert(
|
|
788
|
+
unnamed.status === 'ERROR' && !unnamed.pr,
|
|
789
|
+
'issued an invoice for a quote carrying no comment - current LUD-25 requires rejection before invoicing'
|
|
790
|
+
)
|
|
791
|
+
}
|
|
686
792
|
|
|
687
793
|
if (hClaimed) {
|
|
688
794
|
const mismatch = new URL(pay.callback)
|
|
@@ -739,6 +845,9 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
739
845
|
}
|
|
740
846
|
}
|
|
741
847
|
if (problems.length > 0) throw soft(problems.join('; '))
|
|
848
|
+
if (registeredAddress) {
|
|
849
|
+
return `registered Lightning Address, minting on its own branch: named by ${probed.join(' and ')}; claimed by ${claimedBy.join(', ')}; bound a quote to a hash of the runner's own secret, and honoured one carrying free text and one carrying no comment at all`
|
|
850
|
+
}
|
|
742
851
|
return `named by ${probed.join(' and ')}; claimed by ${claimedBy.join(', ')}; bound a quote to a hash of the runner's own secret and handled four malformed ones as the draft requires`
|
|
743
852
|
})
|
|
744
853
|
|
|
@@ -1095,17 +1204,19 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1095
1204
|
? 'the signature verifies against neither the advertised mintPubkey nor any published previous key'
|
|
1096
1205
|
: 'the signature does not verify against the advertised mintPubkey and amount'
|
|
1097
1206
|
|
|
1098
|
-
await report.check('a
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
if (currentSig === null)
|
|
1207
|
+
await report.check('a legacy hash mutation signature verifies when present', async () => {
|
|
1208
|
+
// The reference mint can operate without a signing backend and then has
|
|
1209
|
+
// no signature to return. That mode remains usable by tolerant clients,
|
|
1210
|
+
// but the committed reference wallet requires a signature after a
|
|
1211
|
+
// successful mutation, so omission is an interoperability warning.
|
|
1212
|
+
if (currentSig === null) {
|
|
1213
|
+
throw soft('unsigned legacy output: accepted as no-signer mode, but strict reference-wallet clients may refuse it')
|
|
1214
|
+
}
|
|
1104
1215
|
assert(info.mintPubkey, 'a sig with no mintPubkey advertised verifies against nothing')
|
|
1105
1216
|
assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
|
|
1106
1217
|
const signedBy = signerOf(key => verifySignature(current, info.maxWithdrawable, currentSig, key))
|
|
1107
1218
|
assert(signedBy, unverified())
|
|
1108
|
-
return `
|
|
1219
|
+
return `legacy Part 1 signature ${describeSigner(signedBy)}`
|
|
1109
1220
|
})
|
|
1110
1221
|
|
|
1111
1222
|
await report.check('reports a spent hash distinguishably from an unknown hash', async () => {
|
|
@@ -1476,9 +1587,13 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1476
1587
|
assert(info.mintPubkey, 'issued a cp1 note with no mintPubkey advertised - nothing to verify its certificate against')
|
|
1477
1588
|
assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
|
|
1478
1589
|
const pubkeyHex = bytesToHex(pubkey)
|
|
1479
|
-
const certificate =
|
|
1480
|
-
assert(certificate, `the rotate to a cp1 output returned no cs1 certificate in sig (got ${JSON.stringify(body.sig)})`)
|
|
1481
|
-
|
|
1590
|
+
const certificate = decodeCertificate(body.sig)
|
|
1591
|
+
assert(certificate, `the rotate to a cp1 output returned no amount-bearing cs1 certificate in sig (got ${JSON.stringify(body.sig)})`)
|
|
1592
|
+
assert(
|
|
1593
|
+
certificate.amountMsat === info.maxWithdrawable,
|
|
1594
|
+
`the cs1 says ${certificate.amountMsat} msat but the note is worth ${info.maxWithdrawable} msat`
|
|
1595
|
+
)
|
|
1596
|
+
const signedBy = signerOf(key => verifyCertificate(pubkeyHex, certificate.amountMsat, certificate.signature, key))
|
|
1482
1597
|
assert(signedBy, unverified())
|
|
1483
1598
|
|
|
1484
1599
|
// The informational GET by ck1 delivers the certificate again, so a
|
|
@@ -1491,10 +1606,14 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1491
1606
|
lookup.maxWithdrawable === info.maxWithdrawable,
|
|
1492
1607
|
`value changed across a rotate to cp1: ${info.maxWithdrawable} -> ${lookup.maxWithdrawable}`
|
|
1493
1608
|
)
|
|
1494
|
-
const again =
|
|
1495
|
-
assert(again, 'the informational GET by ck1 returned no cs1 certificate')
|
|
1609
|
+
const again = decodeCertificate(lookup.sig)
|
|
1610
|
+
assert(again, 'the informational GET by ck1 returned no amount-bearing cs1 certificate')
|
|
1611
|
+
assert(
|
|
1612
|
+
again.amountMsat === lookup.maxWithdrawable,
|
|
1613
|
+
`the informational cs1 says ${again.amountMsat} msat but the note is worth ${lookup.maxWithdrawable} msat`
|
|
1614
|
+
)
|
|
1496
1615
|
assert(
|
|
1497
|
-
signerOf(key => verifyCertificate(pubkeyHex,
|
|
1616
|
+
signerOf(key => verifyCertificate(pubkeyHex, again.amountMsat, again.signature, key)),
|
|
1498
1617
|
'the certificate on the informational GET does not verify'
|
|
1499
1618
|
)
|
|
1500
1619
|
|
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 to a cp1 output 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. 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
|
}
|
package/vectors/part2.json
CHANGED
|
@@ -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)>\"))
|
|
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
|
-
"
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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",
|
package/vectors/responses.json
CHANGED
|
@@ -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
|
|
4
|
+
"description": "Classifying a SERVICE response. The distinction that matters for funds is definitive-rejection versus ambiguous-outcome: a parsed {\"status\":\"ERROR\"} means the request was processed and refused, while a transport failure, an unparseable body, or a 200 that does not confirm means the mutation MAY have landed - and for rotate/split/merge the WALLET-generated secrets are then the only copy of the outputs, so they must ride the error rather than be discarded. A confirmed mutation missing a signature the output or caller requires is its own outcome: it definitely landed, so the secrets matter more than ever. `op` says which call each case is driven through - a melt is the one mutation with no signature to return.",
|
|
5
5
|
"outcomes": {
|
|
6
6
|
"ok": "the operation is confirmed",
|
|
7
|
-
"unverifiable": "the mutation is confirmed but a cp1 output came back without its cs1 certificate
|
|
7
|
+
"unverifiable": "the mutation is confirmed but a cp1 output came back without its required cs1 certificate, or a legacy hash output came back without the raw signature a strict caller required. The note EXISTS at the output the WALLET disclosed, so keep its secret even while reporting the missing proof",
|
|
8
8
|
"pending": "this k1 has another operation in flight (a melt); retry shortly",
|
|
9
9
|
"spent": "the SERVICE is authoritative that the note is already burned; a holder may lock it as spent",
|
|
10
10
|
"unknown": "the SERVICE does not recognise this note; surface it, do not silently lock it",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
},
|
|
92
92
|
"expect": "ok",
|
|
93
93
|
"signature": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
|
|
94
|
-
"why": "both outputs are
|
|
94
|
+
"why": "both outputs are legacy hash notes; tolerant callers can retain a landed output without proof, while a strict caller may require both raw Part 1 signatures"
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
"name": "melt success with a LUD-21 style proof",
|