lnurlcash-conformance 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -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 +258 -48
- package/vectors/lifecycle.json +1 -1
- package/vectors/note-url.json +23 -1
- package/vectors/part2.json +42 -6
- package/vectors/responses.json +41 -5
- package/vectors/retried-mutation.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,59 @@ Semantic versioning. While the LUD-25 draft is unmerged, `0.x` minor bumps
|
|
|
4
4
|
may add or tighten checks that a previously-passing mint now fails; pin an
|
|
5
5
|
exact version if you gate CI on the grade.
|
|
6
6
|
|
|
7
|
+
## 0.11.0 - 2026-09-15
|
|
8
|
+
|
|
9
|
+
**Amount-bearing certificates and internal-transfer discovery.** Part 2 now
|
|
10
|
+
follows the current `lnurl-wallet` and `lnurl-mint` reference implementations;
|
|
11
|
+
the draft LUD-25 prose may lag this release.
|
|
12
|
+
|
|
13
|
+
- `cs1` carries `amount_msat` in its human-readable part using BOLT-11's
|
|
14
|
+
amount suffix rules. The vectors reject the legacy fixed `cs` HRP, and
|
|
15
|
+
the live grader requires the encoded amount to equal the note's
|
|
16
|
+
authoritative `maxWithdrawable` before verifying the certificate.
|
|
17
|
+
- `--address` now requires a valid `text/xpub` metadata entry carrying the
|
|
18
|
+
registered `cx1` plus its next-index hint. The mock address publishes a
|
|
19
|
+
real branch and advances outputs with the LUD-25 public derivation.
|
|
20
|
+
- Legacy hash mutation outputs are signed by the current references. The
|
|
21
|
+
grader still accepts an omitted signature because the reference mint may
|
|
22
|
+
run without an available signer, but verifies every signature it receives.
|
|
23
|
+
- The compliant mock enables secret-free hash lookup by default, matching
|
|
24
|
+
the current reference wallet and mint. `hashLookup: false` remains an
|
|
25
|
+
explicit older-SERVICE fixture.
|
|
26
|
+
|
|
27
|
+
## 0.10.0 - 2026-09-11
|
|
28
|
+
|
|
29
|
+
**Signatures belong to cp1 notes.** The Part 2 rewrite of LUD-25 signs a
|
|
30
|
+
`cp1` note only: a plain hash has nothing to attest to without disclosing
|
|
31
|
+
the secret, so a plain note is unsigned by design. The grader follows it.
|
|
32
|
+
|
|
33
|
+
- `signs the notes it issues` is gone. In its place, `a plain note carries
|
|
34
|
+
no signature, or one that verifies`: a hash rotate answered with a bare
|
|
35
|
+
`{"status":"OK"}` passes, and a mint still issuing the old Part 1
|
|
36
|
+
signature over the hash passes as long as the signature is a true one.
|
|
37
|
+
`mintPubkey` is no longer demanded of a mint that issues no signature.
|
|
38
|
+
- New, last in the note run: `certifies a cp1 note it issues (Part 2)`.
|
|
39
|
+
The grader rotates the note into a fresh `cp1` key, requires a `cs1` in
|
|
40
|
+
`sig` that recovers to `mintPubkey` (or a published previous key) over
|
|
41
|
+
the key and amount, requires the same certificate again on the
|
|
42
|
+
informational GET by `ck1`, then rotates home to a plain secret by that
|
|
43
|
+
`ck1`. A mint that refuses the `cp1` output warns, never fails: Part 2 is
|
|
44
|
+
optional. The mock mint has no Part 2 yet, so it warns here.
|
|
45
|
+
- `vectors/responses.json` follows: a bare `{"status":"OK"}` to a hash
|
|
46
|
+
output is now `ok` (it was `unverifiable`), and so is a split that signs
|
|
47
|
+
only its first hash output. Three cases are new, and carry `output` or
|
|
48
|
+
`change: "cp1"` to say which kind of note the call mints: a `cp1` output
|
|
49
|
+
confirmed without a certificate is `unverifiable`, one certified with a
|
|
50
|
+
`cs1` is `ok`, and a `cp1` change left uncertified is `unverifiable`. A
|
|
51
|
+
consumer driving these cases picks the output by that field; a hash
|
|
52
|
+
where it is absent. Other vector prose that called the hash-note
|
|
53
|
+
signature mandatory now says which output is owed one.
|
|
54
|
+
- CI runs on Node 24.
|
|
55
|
+
|
|
56
|
+
The withdraw-info vector still rejects a `withdrawRequest` with no
|
|
57
|
+
`mintPubkey`; relaxing that for Part 1-only mints is a wallet-side change
|
|
58
|
+
across every kit and is not in this release.
|
|
59
|
+
|
|
7
60
|
## 0.9.0 - 2026-09-11
|
|
8
61
|
|
|
9
62
|
**Part 2 vectors.** `vectors/part2.json` covers LUD-25 Part 2, notes keyed by
|
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ for (const c of cases) {
|
|
|
48
48
|
| `signature.json` | offline verification, both recovery-id orderings, malformed input |
|
|
49
49
|
| `derivation.json` | deterministic note secrets from a BIP39 seed |
|
|
50
50
|
| `cash-derivation.json` | LUD-25's seed-recoverable note secrets under `m/139'`, with BIP-32's own vector 1 |
|
|
51
|
-
| `part2.json` | Part 2: `cp1`/`ck1
|
|
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` |
|
|
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
|
@@ -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,65 @@ const verifySignature = (k1, amountMsat, signatureHex, pubkeyHex) => {
|
|
|
43
37
|
return false
|
|
44
38
|
}
|
|
45
39
|
|
|
40
|
+
// The legacy raw Part 1 signature over a note's hash. Current reference mints
|
|
41
|
+
// issue one when a signer is available, and every one received must be true.
|
|
42
|
+
const verifySignature = (k1, amountMsat, signatureHex, pubkeyHex) => {
|
|
43
|
+
let sig
|
|
44
|
+
try {
|
|
45
|
+
sig = hexToBytes(signatureHex)
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
return recoversTo(signedMessageDigest(`LNURLcash:${amountMsat}:${noteId(k1)}`), sig, pubkeyHex)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A Part 2 certificate: cs1 over the note's raw x-only public key.
|
|
53
|
+
const verifyCertificate = (pubkeyHex32, amountMsat, sig, mintPubkeyHex) =>
|
|
54
|
+
recoversTo(signedMessageDigest(`LNURLcash:${amountMsat}:${pubkeyHex32}`), sig, mintPubkeyHex)
|
|
55
|
+
|
|
56
|
+
// Part 2's bech32m strings: cp1 (32-byte key), ck1 and cs1 (65-byte
|
|
57
|
+
// signature). cs1 has a variable HRP: "cs" plus the amount under BOLT-11's
|
|
58
|
+
// amount rules. Longer than BIP-173's 90 characters by design.
|
|
59
|
+
const BECH32M_LIMIT = 200
|
|
60
|
+
const encodeCash = (hrp, bytes) => bech32m.encode(hrp, bech32m.toWords(bytes), BECH32M_LIMIT)
|
|
61
|
+
const decodeCash = (hrp, value, length) => {
|
|
62
|
+
if (typeof value !== 'string') return null
|
|
63
|
+
try {
|
|
64
|
+
const {prefix, words} = bech32m.decode(value, BECH32M_LIMIT)
|
|
65
|
+
if (prefix !== hrp) return null
|
|
66
|
+
const bytes = bech32m.fromWords(words)
|
|
67
|
+
return bytes.length === length ? bytes : null
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const AMOUNT_MSAT_PER_UNIT = {'': 1e11, m: 1e8, u: 1e5, n: 100, p: 0.1}
|
|
74
|
+
const amountSuffixMsat = suffix => {
|
|
75
|
+
const match = suffix.match(/^(\d+)([munp])?$/)
|
|
76
|
+
if (!match) return null
|
|
77
|
+
const amount = Number(match[1]) * AMOUNT_MSAT_PER_UNIT[match[2] ?? '']
|
|
78
|
+
return Number.isSafeInteger(amount) ? amount : null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const decodeCertificate = value => {
|
|
82
|
+
if (typeof value !== 'string') return null
|
|
83
|
+
const lower = value.trim().toLowerCase()
|
|
84
|
+
const sep = lower.lastIndexOf('1')
|
|
85
|
+
if (sep < 3 || !lower.startsWith('cs')) return null
|
|
86
|
+
const amountMsat = amountSuffixMsat(lower.slice(2, sep))
|
|
87
|
+
if (amountMsat === null) return null
|
|
88
|
+
const signature = decodeCash(lower.slice(0, sep), lower, 65)
|
|
89
|
+
return signature ? {amountMsat, signature} : null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The bearer secret of a cp1 note: the key's signature over the fixed
|
|
93
|
+
// message "LNURLcash", r || s || recovery-id, the same value every time.
|
|
94
|
+
const ownershipProof = secretKey => {
|
|
95
|
+
const lead = secp256k1.sign(signedMessageDigest('LNURLcash'), secretKey, {format: 'recovered', prehash: false})
|
|
96
|
+
return encodeCash('ck', new Uint8Array([...lead.subarray(1), lead[0]]))
|
|
97
|
+
}
|
|
98
|
+
|
|
46
99
|
// LUD-17: lnurlw://host/path is https://host/path, or http:// when the host
|
|
47
100
|
// is an onion service (the spec) or loopback (development). A plain
|
|
48
101
|
// https:// or http:// URL passes through untouched, so a caller can hand
|
|
@@ -202,6 +255,31 @@ export const parseAdvertisedMintFee = metadata => {
|
|
|
202
255
|
return null
|
|
203
256
|
}
|
|
204
257
|
|
|
258
|
+
// A registered Part 2 address advertises the safe-to-share branch and its
|
|
259
|
+
// best-known next index as ["text/xpub", "cx1...:<i>"]. Parsing it here
|
|
260
|
+
// makes --address assert the actual discovery signal rather than merely
|
|
261
|
+
// trusting the operator's flag.
|
|
262
|
+
export const parseInternalTransferHint = metadata => {
|
|
263
|
+
let entries
|
|
264
|
+
try {
|
|
265
|
+
entries = JSON.parse(metadata)
|
|
266
|
+
} catch {
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
if (!Array.isArray(entries)) return null
|
|
270
|
+
for (const entry of entries) {
|
|
271
|
+
if (!Array.isArray(entry) || entry[0] !== 'text/xpub' || typeof entry[1] !== 'string') continue
|
|
272
|
+
const sep = entry[1].lastIndexOf(':')
|
|
273
|
+
if (sep < 0) continue
|
|
274
|
+
const cx1 = entry[1].slice(0, sep)
|
|
275
|
+
const index = Number(entry[1].slice(sep + 1))
|
|
276
|
+
const branch = decodeCash('cx', cx1.toLowerCase(), 64)
|
|
277
|
+
if (!branch || !Number.isInteger(index) || index < 0 || index > 0xffffffff) continue
|
|
278
|
+
return {cx1, index, pubkeyXOnly: branch.slice(0, 32), chainCode: branch.slice(32)}
|
|
279
|
+
}
|
|
280
|
+
return null
|
|
281
|
+
}
|
|
282
|
+
|
|
205
283
|
// Resolves what the user typed - a Lightning Address, a bare domain, or a
|
|
206
284
|
// URL - to the payRequest URL to grade.
|
|
207
285
|
export const resolveMint = input => {
|
|
@@ -220,7 +298,32 @@ export const resolveMint = input => {
|
|
|
220
298
|
|
|
221
299
|
// ---- read-only checks -----------------------------------------------------
|
|
222
300
|
|
|
223
|
-
|
|
301
|
+
// `registeredAddress` grades the target as a LUD-25 Part 2 Lightning
|
|
302
|
+
// Address rather than a mint payLink. The two are the same document to a
|
|
303
|
+
// wallet - both advertise commentAllowed and a withdrawLink, and both
|
|
304
|
+
// mint on payment - but the draft's comment rules are written for the
|
|
305
|
+
// payLink, which has no key to mint under but the one the comment names.
|
|
306
|
+
// A cx1-registered address always has one, the next unused key on its
|
|
307
|
+
// branch, so there a comment naming no output is the ordinary free text
|
|
308
|
+
// LUD-12 invites and is ignored rather than refused (lnurl-mint #46,
|
|
309
|
+
// after every Wallet of Satoshi and Primal payment to such an address
|
|
310
|
+
// failed on a typed message).
|
|
311
|
+
//
|
|
312
|
+
// Deliberately a flag and not a probe. "Returned an invoice for a comment
|
|
313
|
+
// naming no output" is also exactly what a mint falling back to a
|
|
314
|
+
// preimage-keyed note does, and that mint is dangerous - the preimage
|
|
315
|
+
// race the draft's Security considerations describes. Nothing on the wire
|
|
316
|
+
// tells the two apart before settlement: they differ only in what key the
|
|
317
|
+
// note lands under. Guessing would wave the dangerous one through, so the
|
|
318
|
+
// strict payLink rules stay the default and an address is declared.
|
|
319
|
+
//
|
|
320
|
+
// The cost of that is real and worth stating: this flag takes the
|
|
321
|
+
// operator's word, and a preimage-keyed fallback passes under it. It
|
|
322
|
+
// relaxes the comment rules and nothing else, and the draft gives an
|
|
323
|
+
// address no way to say what it is on the wire. A signal it could
|
|
324
|
+
// advertise - so this became detectable rather than declared - is worth
|
|
325
|
+
// raising against LUD-25 Part 2.
|
|
326
|
+
export const gradeMint = async (payUrl, report, {registeredAddress = false} = {}) => {
|
|
224
327
|
let pay
|
|
225
328
|
let mintAddress
|
|
226
329
|
await report.check('payRequest resolves and is well-formed', async () => {
|
|
@@ -274,6 +377,14 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
274
377
|
return `${match[1]} msat + ${match[2]} ppm`
|
|
275
378
|
})
|
|
276
379
|
|
|
380
|
+
if (registeredAddress) {
|
|
381
|
+
await report.check('advertises its cx1 and next index for internal transfers', async () => {
|
|
382
|
+
const hint = parseInternalTransferHint(pay.metadata)
|
|
383
|
+
assert(hint, 'no valid ["text/xpub", "cx1...:<i>"] metadata entry')
|
|
384
|
+
return `index ${hint.index}`
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
277
388
|
let verifyUrl
|
|
278
389
|
await report.check('issues an invoice for the amount requested', async () => {
|
|
279
390
|
const amount = Math.max(pay.minSendable, 1000)
|
|
@@ -546,6 +657,19 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
546
657
|
}
|
|
547
658
|
const spelt = spelling => (spelling === 'comment' ? 'a LUD-12 comment' : 'an h parameter')
|
|
548
659
|
|
|
660
|
+
// The behaviour #46 fixed, asserted rather than assumed. Asked once,
|
|
661
|
+
// not once per malformed value: on an address a quote claims the next
|
|
662
|
+
// branch index as it is issued, so every probe costs the holder a key
|
|
663
|
+
// whether or not anyone pays. `gm` is short enough that no mint could
|
|
664
|
+
// read it as a commitment of any spelling.
|
|
665
|
+
if (registeredAddress) {
|
|
666
|
+
const freeText = await quoteAt('comment', 'gm')
|
|
667
|
+
assert(
|
|
668
|
+
freeText.status !== 'ERROR' && typeof freeText.pr === 'string',
|
|
669
|
+
`refused a comment naming no output: ${freeText.reason} - this address mints on its own branch, so an ordinary LUD-12 message must not fail the payment`
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
|
|
549
673
|
const advertised = spellingsOf(pay)
|
|
550
674
|
const corroborated = spellingsOf(mintAddress)
|
|
551
675
|
const claimed = [...new Set([...advertised, ...corroborated])]
|
|
@@ -622,6 +746,11 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
622
746
|
|
|
623
747
|
for (const spelling of probed) {
|
|
624
748
|
for (const [what, value] of malformed) {
|
|
749
|
+
// Every one of these names no output, which on an address is free
|
|
750
|
+
// text, already covered above. `h` stays probed either way: it is
|
|
751
|
+
// a parameter invented for this one purpose, so a malformed one is
|
|
752
|
+
// a wallet error wherever it is sent.
|
|
753
|
+
if (spelling === 'comment' && registeredAddress) continue
|
|
625
754
|
const body = await quoteAt(spelling, value)
|
|
626
755
|
if (spelling === 'h') {
|
|
627
756
|
if (!hClaimed) continue
|
|
@@ -646,10 +775,20 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
646
775
|
const bare = new URL(pay.callback)
|
|
647
776
|
bare.searchParams.set('amount', String(amount))
|
|
648
777
|
const unnamed = await get(bare)
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
778
|
+
if (registeredAddress) {
|
|
779
|
+
// The draft is explicit that a registered address needs no comment
|
|
780
|
+
// at all: it derives the next key from its own cx1. Refusing here
|
|
781
|
+
// would break every plain Lightning payment to the address.
|
|
782
|
+
assert(
|
|
783
|
+
unnamed.status !== 'ERROR' && typeof unnamed.pr === 'string',
|
|
784
|
+
`refused a quote carrying no comment: ${unnamed.reason} - a registered address mints on its own branch, so a payment to it must not need one`
|
|
785
|
+
)
|
|
786
|
+
} else {
|
|
787
|
+
assert(
|
|
788
|
+
unnamed.status === 'ERROR' && !unnamed.pr,
|
|
789
|
+
'issued an invoice for a quote carrying no comment - current LUD-25 requires rejection before invoicing'
|
|
790
|
+
)
|
|
791
|
+
}
|
|
653
792
|
|
|
654
793
|
if (hClaimed) {
|
|
655
794
|
const mismatch = new URL(pay.callback)
|
|
@@ -706,6 +845,9 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
706
845
|
}
|
|
707
846
|
}
|
|
708
847
|
if (problems.length > 0) throw soft(problems.join('; '))
|
|
848
|
+
if (registeredAddress) {
|
|
849
|
+
return `registered Lightning Address, minting on its own branch: named by ${probed.join(' and ')}; claimed by ${claimedBy.join(', ')}; bound a quote to a hash of the runner's own secret, and honoured one carrying free text and one carrying no comment at all`
|
|
850
|
+
}
|
|
709
851
|
return `named by ${probed.join(' and ')}; claimed by ${claimedBy.join(', ')}; bound a quote to a hash of the runner's own secret and handled four malformed ones as the draft requires`
|
|
710
852
|
})
|
|
711
853
|
|
|
@@ -1046,28 +1188,35 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1046
1188
|
return 'burned the old secret, minted the new'
|
|
1047
1189
|
})
|
|
1048
1190
|
|
|
1049
|
-
|
|
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
|
|
1191
|
+
// A mint that has rotated its signing key may publish the old ones as
|
|
1192
|
+
// previousPubkeys, so notes it issued before the rotation still verify.
|
|
1193
|
+
// Any key it currently stands behind is an acceptable signer for grading
|
|
1194
|
+
// purposes. That is a narrower claim than it looks: it says the
|
|
1195
|
+
// signature is genuine, not that a wallet should accept the new key -
|
|
1196
|
+
// LUD-25 puts that decision with the holder.
|
|
1197
|
+
const signerOf = verifies => [info.mintPubkey, ...previousPubkeys].find(key => verifies(key))
|
|
1198
|
+
const describeSigner = signedBy =>
|
|
1199
|
+
signedBy === info.mintPubkey
|
|
1069
1200
|
? 'verified offline'
|
|
1070
1201
|
: `verified offline against a previous signing key (${signedBy.slice(0, 16)}...)`
|
|
1202
|
+
const unverified = () =>
|
|
1203
|
+
previousPubkeys.length > 0
|
|
1204
|
+
? 'the signature verifies against neither the advertised mintPubkey nor any published previous key'
|
|
1205
|
+
: 'the signature does not verify against the advertised mintPubkey and amount'
|
|
1206
|
+
|
|
1207
|
+
await report.check('a legacy hash mutation signature verifies when present', async () => {
|
|
1208
|
+
// The reference mint can operate without a signing backend and then has
|
|
1209
|
+
// no signature to return. That mode remains usable by tolerant clients,
|
|
1210
|
+
// but the committed reference wallet requires a signature after a
|
|
1211
|
+
// successful mutation, so omission is an interoperability warning.
|
|
1212
|
+
if (currentSig === null) {
|
|
1213
|
+
throw soft('unsigned legacy output: accepted as no-signer mode, but strict reference-wallet clients may refuse it')
|
|
1214
|
+
}
|
|
1215
|
+
assert(info.mintPubkey, 'a sig with no mintPubkey advertised verifies against nothing')
|
|
1216
|
+
assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
|
|
1217
|
+
const signedBy = signerOf(key => verifySignature(current, info.maxWithdrawable, currentSig, key))
|
|
1218
|
+
assert(signedBy, unverified())
|
|
1219
|
+
return `legacy Part 1 signature ${describeSigner(signedBy)}`
|
|
1071
1220
|
})
|
|
1072
1221
|
|
|
1073
1222
|
await report.check('reports a spent hash distinguishably from an unknown hash', async () => {
|
|
@@ -1095,12 +1244,10 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1095
1244
|
})
|
|
1096
1245
|
|
|
1097
1246
|
await report.check('keeps signatures off the informational endpoint', async () => {
|
|
1098
|
-
//
|
|
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.
|
|
1247
|
+
// A plain note has no certificate, so its informational GET carries no
|
|
1248
|
+
// sig. Part 2 does hand out a cs1 here for a cp1 note, and the Part 2
|
|
1249
|
+
// check below expects it; this probe is by hex k1, where one would
|
|
1250
|
+
// invite a wallet to treat an online answer as an offline proof.
|
|
1104
1251
|
const here = new URL(url)
|
|
1105
1252
|
here.searchParams.set('k1', current)
|
|
1106
1253
|
const body = await get(here)
|
|
@@ -1418,6 +1565,69 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1418
1565
|
return body.reason
|
|
1419
1566
|
})
|
|
1420
1567
|
|
|
1568
|
+
// LUD-25 Part 2. The spec's one MUST for signatures lives here: a cp1
|
|
1569
|
+
// note is a public key, and the SERVICE certifies every one it issues
|
|
1570
|
+
// with a cs1 over (key, amount) that recovers to mintPubkey. Part 2 is
|
|
1571
|
+
// optional, so a SERVICE that refuses the cp1 output warns rather than
|
|
1572
|
+
// fails. Last, because the note comes back as a plain secret only if
|
|
1573
|
+
// the rotate home succeeds.
|
|
1574
|
+
await report.check('certifies a cp1 note it issues (Part 2)', async () => {
|
|
1575
|
+
const secretKey = secp256k1.utils.randomSecretKey()
|
|
1576
|
+
const pubkey = schnorr.getPublicKey(secretKey)
|
|
1577
|
+
const cp1 = encodeCash('cp', pubkey)
|
|
1578
|
+
const out = new URL(info.callback)
|
|
1579
|
+
out.searchParams.append('k1', current)
|
|
1580
|
+
out.searchParams.append('p1', cp1)
|
|
1581
|
+
const body = await get(out)
|
|
1582
|
+
if (body.status === 'ERROR') throw soft(`Part 2 not offered: a cp1 output was refused (${body.reason})`)
|
|
1583
|
+
// The plain secret is burned either way; from here the bearer secret
|
|
1584
|
+
// is the key's ownership proof, until the rotate home below.
|
|
1585
|
+
const ck1 = ownershipProof(secretKey)
|
|
1586
|
+
current = ck1
|
|
1587
|
+
assert(info.mintPubkey, 'issued a cp1 note with no mintPubkey advertised - nothing to verify its certificate against')
|
|
1588
|
+
assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
|
|
1589
|
+
const pubkeyHex = bytesToHex(pubkey)
|
|
1590
|
+
const certificate = decodeCertificate(body.sig)
|
|
1591
|
+
assert(certificate, `the rotate to a cp1 output returned no amount-bearing cs1 certificate in sig (got ${JSON.stringify(body.sig)})`)
|
|
1592
|
+
assert(
|
|
1593
|
+
certificate.amountMsat === info.maxWithdrawable,
|
|
1594
|
+
`the cs1 says ${certificate.amountMsat} msat but the note is worth ${info.maxWithdrawable} msat`
|
|
1595
|
+
)
|
|
1596
|
+
const signedBy = signerOf(key => verifyCertificate(pubkeyHex, certificate.amountMsat, certificate.signature, key))
|
|
1597
|
+
assert(signedBy, unverified())
|
|
1598
|
+
|
|
1599
|
+
// The informational GET by ck1 delivers the certificate again, so a
|
|
1600
|
+
// holder need not rotate just to obtain one.
|
|
1601
|
+
const byKey = new URL(url)
|
|
1602
|
+
byKey.searchParams.set('k1', ck1)
|
|
1603
|
+
const lookup = await get(byKey)
|
|
1604
|
+
assert(lookup.status !== 'ERROR', `the cp1 note is not spendable by its ck1: ${lookup.reason}`)
|
|
1605
|
+
assert(
|
|
1606
|
+
lookup.maxWithdrawable === info.maxWithdrawable,
|
|
1607
|
+
`value changed across a rotate to cp1: ${info.maxWithdrawable} -> ${lookup.maxWithdrawable}`
|
|
1608
|
+
)
|
|
1609
|
+
const again = decodeCertificate(lookup.sig)
|
|
1610
|
+
assert(again, 'the informational GET by ck1 returned no amount-bearing cs1 certificate')
|
|
1611
|
+
assert(
|
|
1612
|
+
again.amountMsat === lookup.maxWithdrawable,
|
|
1613
|
+
`the informational cs1 says ${again.amountMsat} msat but the note is worth ${lookup.maxWithdrawable} msat`
|
|
1614
|
+
)
|
|
1615
|
+
assert(
|
|
1616
|
+
signerOf(key => verifyCertificate(pubkeyHex, again.amountMsat, again.signature, key)),
|
|
1617
|
+
'the certificate on the informational GET does not verify'
|
|
1618
|
+
)
|
|
1619
|
+
|
|
1620
|
+
// Home: back to a plain secret, spent by the ck1.
|
|
1621
|
+
const fresh = bytesToHex(randomBytes(32))
|
|
1622
|
+
const home = new URL(info.callback)
|
|
1623
|
+
home.searchParams.append('k1', ck1)
|
|
1624
|
+
home.searchParams.append('p1', noteId(fresh))
|
|
1625
|
+
const back = await get(home)
|
|
1626
|
+
assert(back.status === 'OK', `the cp1 note could not be rotated back to a plain secret by its ck1: ${back.reason}`)
|
|
1627
|
+
current = fresh
|
|
1628
|
+
return `${describeSigner(signedBy)}; the plain note it rotated home to is ${back.sig === undefined ? 'unsigned' : 'still signed the Part 1 way'}`
|
|
1629
|
+
})
|
|
1630
|
+
|
|
1421
1631
|
return {finalSecret: current, noteUrl: (() => {
|
|
1422
1632
|
const u = new URL(url)
|
|
1423
1633
|
u.searchParams.set('k1', current)
|
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",
|
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. 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
|
|
7
|
+
"unverifiable": "the mutation is confirmed but a cp1 output came back without its required cs1 certificate, or a legacy hash output came back without the raw signature a strict caller required. The note EXISTS at the output the WALLET disclosed, so keep its secret even while reporting the missing proof",
|
|
8
8
|
"pending": "this k1 has another operation in flight (a melt); retry shortly",
|
|
9
9
|
"spent": "the SERVICE is authoritative that the note is already burned; a holder may lock it as spent",
|
|
10
10
|
"unknown": "the SERVICE does not recognise this note; surface it, do not silently lock it",
|
|
@@ -19,8 +19,43 @@
|
|
|
19
19
|
"body": {
|
|
20
20
|
"status": "OK"
|
|
21
21
|
},
|
|
22
|
+
"expect": "ok",
|
|
23
|
+
"why": "a bare OK is the conforming answer for a plain hash output since the Part 2 rewrite: the note is real, the WALLET keeps its secret, and nobody the holder hands it to can check it offline, which is what a plain note is. Only a cp1 output is owed a certificate"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "cp1 output confirmed without a certificate",
|
|
27
|
+
"op": "mutation",
|
|
28
|
+
"output": "cp1",
|
|
29
|
+
"http": 200,
|
|
30
|
+
"body": {
|
|
31
|
+
"status": "OK"
|
|
32
|
+
},
|
|
33
|
+
"expect": "unverifiable",
|
|
34
|
+
"why": "a cp1 output is owed a cs1 certificate in sig; without one the note it names cannot be verified offline, which is the whole reason to hold a cp1 note. The note exists at the key the WALLET disclosed"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "cp1 output certified",
|
|
38
|
+
"op": "mutation",
|
|
39
|
+
"output": "cp1",
|
|
40
|
+
"http": 200,
|
|
41
|
+
"body": {
|
|
42
|
+
"status": "OK",
|
|
43
|
+
"sig": "cs14w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2atdv6a53"
|
|
44
|
+
},
|
|
45
|
+
"expect": "ok",
|
|
46
|
+
"signature": "cs14w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2atdv6a53"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "a split to a cp1 change that certifies only its first output",
|
|
50
|
+
"op": "split",
|
|
51
|
+
"change": "cp1",
|
|
52
|
+
"http": 200,
|
|
53
|
+
"body": {
|
|
54
|
+
"status": "OK",
|
|
55
|
+
"sig": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab"
|
|
56
|
+
},
|
|
22
57
|
"expect": "unverifiable",
|
|
23
|
-
"why": "
|
|
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": "
|
|
58
|
-
"
|
|
92
|
+
"expect": "ok",
|
|
93
|
+
"signature": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
|
|
94
|
+
"why": "both outputs are legacy hash notes; tolerant callers can retain a landed output without proof, while a strict caller may require both raw Part 1 signatures"
|
|
59
95
|
},
|
|
60
96
|
{
|
|
61
97
|
"name": "melt success with a LUD-21 style proof",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
],
|
|
11
11
|
"provenance": "Recorded, never inferred. A SERVICE links the burned inputs to the outputs they minted and matches against that. Matching on \"a note exists at h\" alone would let anyone holding a burned k1 and any outstanding note id pull a success out of the SERVICE.",
|
|
12
12
|
"outcomes": {
|
|
13
|
-
"replay": "the original success, byte for byte: the same status, the same sig and sig2, and no balance moved",
|
|
13
|
+
"replay": "the original success, byte for byte: the same status, the same sig and sig2 where the outputs had any, and no balance moved",
|
|
14
14
|
"double-spend": "refused exactly as any other attempt to spend a burned secret, with the reason string unchanged"
|
|
15
15
|
},
|
|
16
16
|
"cases": [
|