lnurlcash-conformance 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/runner/index.mjs +134 -43
- package/vectors/lifecycle.json +1 -1
- package/vectors/note-url.json +1 -1
- package/vectors/responses.json +40 -4
- package/vectors/retried-mutation.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ Semantic versioning. While the LUD-25 draft is unmerged, `0.x` minor bumps
|
|
|
4
4
|
may add or tighten checks that a previously-passing mint now fails; pin an
|
|
5
5
|
exact version if you gate CI on the grade.
|
|
6
6
|
|
|
7
|
+
## 0.10.0 - 2026-09-11
|
|
8
|
+
|
|
9
|
+
**Signatures belong to cp1 notes.** The Part 2 rewrite of LUD-25 signs a
|
|
10
|
+
`cp1` note only: a plain hash has nothing to attest to without disclosing
|
|
11
|
+
the secret, so a plain note is unsigned by design. The grader follows it.
|
|
12
|
+
|
|
13
|
+
- `signs the notes it issues` is gone. In its place, `a plain note carries
|
|
14
|
+
no signature, or one that verifies`: a hash rotate answered with a bare
|
|
15
|
+
`{"status":"OK"}` passes, and a mint still issuing the old Part 1
|
|
16
|
+
signature over the hash passes as long as the signature is a true one.
|
|
17
|
+
`mintPubkey` is no longer demanded of a mint that issues no signature.
|
|
18
|
+
- New, last in the note run: `certifies a cp1 note it issues (Part 2)`.
|
|
19
|
+
The grader rotates the note into a fresh `cp1` key, requires a `cs1` in
|
|
20
|
+
`sig` that recovers to `mintPubkey` (or a published previous key) over
|
|
21
|
+
the key and amount, requires the same certificate again on the
|
|
22
|
+
informational GET by `ck1`, then rotates home to a plain secret by that
|
|
23
|
+
`ck1`. A mint that refuses the `cp1` output warns, never fails: Part 2 is
|
|
24
|
+
optional. The mock mint has no Part 2 yet, so it warns here.
|
|
25
|
+
- `vectors/responses.json` follows: a bare `{"status":"OK"}` to a hash
|
|
26
|
+
output is now `ok` (it was `unverifiable`), and so is a split that signs
|
|
27
|
+
only its first hash output. Three cases are new, and carry `output` or
|
|
28
|
+
`change: "cp1"` to say which kind of note the call mints: a `cp1` output
|
|
29
|
+
confirmed without a certificate is `unverifiable`, one certified with a
|
|
30
|
+
`cs1` is `ok`, and a `cp1` change left uncertified is `unverifiable`. A
|
|
31
|
+
consumer driving these cases picks the output by that field; a hash
|
|
32
|
+
where it is absent. Other vector prose that called the hash-note
|
|
33
|
+
signature mandatory now says which output is owed one.
|
|
34
|
+
- CI runs on Node 24.
|
|
35
|
+
|
|
36
|
+
The withdraw-info vector still rejects a `withdrawRequest` with no
|
|
37
|
+
`mintPubkey`; relaxing that for Part 1-only mints is a wallet-side change
|
|
38
|
+
across every kit and is not in this release.
|
|
39
|
+
|
|
7
40
|
## 0.9.0 - 2026-09-11
|
|
8
41
|
|
|
9
42
|
**Part 2 vectors.** `vectors/part2.json` covers LUD-25 Part 2, notes keyed by
|
package/README.md
CHANGED
|
@@ -95,7 +95,7 @@ must survive:
|
|
|
95
95
|
| `--echoWrongK1` | answers the informational GET with a different `k1` |
|
|
96
96
|
| `--lieAboutValue=N` | reports a `maxWithdrawable` it never signed |
|
|
97
97
|
| `--signatureLayout=leading` | emits the recovery id at the other end |
|
|
98
|
-
| `--signatures=false` |
|
|
98
|
+
| `--signatures=false` | issues no Part 1 signatures. Allowed since the Part 2 rewrite: a plain note is unsigned by design, so the grader passes it |
|
|
99
99
|
| `--serverGeneratedSecrets` | hands back a secret it generated — the exposure `h` exists to close |
|
|
100
100
|
| `--meltNeverSettles` | holds every melt in flight, so notes stay `pending` |
|
|
101
101
|
| `--meltAlwaysFails` | fails every payment, restoring the note |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lnurlcash-conformance",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Language-neutral conformance vectors, an adversarial mock mint, and a grader for LNURLcash (LUD-25) implementations",
|
|
5
5
|
"author": "TheCryptoDonkey",
|
|
6
6
|
"license": "MIT",
|
package/runner/index.mjs
CHANGED
|
@@ -5,30 +5,24 @@
|
|
|
5
5
|
// with the thing it grades would agree with that implementation's mistakes,
|
|
6
6
|
// which is the one thing it must never do.
|
|
7
7
|
|
|
8
|
-
import {bech32} from '@scure/base'
|
|
8
|
+
import {bech32, bech32m} from '@scure/base'
|
|
9
9
|
import {sha256} from '@noble/hashes/sha2.js'
|
|
10
|
-
import {secp256k1} from '@noble/curves/secp256k1.js'
|
|
10
|
+
import {schnorr, secp256k1} from '@noble/curves/secp256k1.js'
|
|
11
11
|
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
|
12
12
|
import {randomBytes} from 'node:crypto'
|
|
13
13
|
|
|
14
14
|
const noteId = k1 => bytesToHex(sha256(hexToBytes(k1)))
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
// The "Lightning Signed Message" digest every LUD-25 signature is over.
|
|
17
|
+
const signedMessageDigest = message =>
|
|
18
|
+
sha256(sha256(new Uint8Array([...utf8ToBytes('Lightning Signed Message:'), ...utf8ToBytes(message)])))
|
|
19
|
+
|
|
20
|
+
// Does a 65-byte recoverable signature recover to that key? Both byte
|
|
21
|
+
// orderings are tried: the spec wants r || s || recovery-id, a node's
|
|
22
|
+
// signmessage emits the recovery id first, and a mint that forgot to
|
|
23
|
+
// reorder is still signing with its own key.
|
|
24
|
+
const recoversTo = (digest, sig, pubkeyHex) => {
|
|
23
25
|
if (sig.length !== 65) return false
|
|
24
|
-
const digest = sha256(
|
|
25
|
-
sha256(
|
|
26
|
-
new Uint8Array([
|
|
27
|
-
...utf8ToBytes('Lightning Signed Message:'),
|
|
28
|
-
...utf8ToBytes(`LNURLcash:${amountMsat}:${noteId(k1)}`)
|
|
29
|
-
])
|
|
30
|
-
)
|
|
31
|
-
)
|
|
32
26
|
const leading = new Uint8Array([sig[64], ...sig.subarray(0, 64)])
|
|
33
27
|
for (const candidate of [leading, sig]) {
|
|
34
28
|
try {
|
|
@@ -43,6 +37,45 @@ const verifySignature = (k1, amountMsat, signatureHex, pubkeyHex) => {
|
|
|
43
37
|
return false
|
|
44
38
|
}
|
|
45
39
|
|
|
40
|
+
// A Part 1 signature, over the note's hash. The spec no longer asks for
|
|
41
|
+
// one, but a mint that still issues them must at least issue true ones.
|
|
42
|
+
const verifySignature = (k1, amountMsat, signatureHex, pubkeyHex) => {
|
|
43
|
+
let sig
|
|
44
|
+
try {
|
|
45
|
+
sig = hexToBytes(signatureHex)
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
return recoversTo(signedMessageDigest(`LNURLcash:${amountMsat}:${noteId(k1)}`), sig, pubkeyHex)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A Part 2 certificate: cs1 over the note's raw x-only public key.
|
|
53
|
+
const verifyCertificate = (pubkeyHex32, amountMsat, sig, mintPubkeyHex) =>
|
|
54
|
+
recoversTo(signedMessageDigest(`LNURLcash:${amountMsat}:${pubkeyHex32}`), sig, mintPubkeyHex)
|
|
55
|
+
|
|
56
|
+
// Part 2's bech32m strings: cp1 (32-byte key), ck1 and cs1 (65-byte
|
|
57
|
+
// signature). Longer than BIP-173's 90 characters by design.
|
|
58
|
+
const BECH32M_LIMIT = 200
|
|
59
|
+
const encodeCash = (hrp, bytes) => bech32m.encode(hrp, bech32m.toWords(bytes), BECH32M_LIMIT)
|
|
60
|
+
const decodeCash = (hrp, value, length) => {
|
|
61
|
+
if (typeof value !== 'string') return null
|
|
62
|
+
try {
|
|
63
|
+
const {prefix, words} = bech32m.decode(value, BECH32M_LIMIT)
|
|
64
|
+
if (prefix !== hrp) return null
|
|
65
|
+
const bytes = bech32m.fromWords(words)
|
|
66
|
+
return bytes.length === length ? bytes : null
|
|
67
|
+
} catch {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The bearer secret of a cp1 note: the key's signature over the fixed
|
|
73
|
+
// message "LNURLcash", r || s || recovery-id, the same value every time.
|
|
74
|
+
const ownershipProof = secretKey => {
|
|
75
|
+
const lead = secp256k1.sign(signedMessageDigest('LNURLcash'), secretKey, {format: 'recovered', prehash: false})
|
|
76
|
+
return encodeCash('ck', new Uint8Array([...lead.subarray(1), lead[0]]))
|
|
77
|
+
}
|
|
78
|
+
|
|
46
79
|
// LUD-17: lnurlw://host/path is https://host/path, or http:// when the host
|
|
47
80
|
// is an onion service (the spec) or loopback (development). A plain
|
|
48
81
|
// https:// or http:// URL passes through untouched, so a caller can hand
|
|
@@ -1046,28 +1079,33 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1046
1079
|
return 'burned the old secret, minted the new'
|
|
1047
1080
|
})
|
|
1048
1081
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
// new key - LUD-25 puts that decision with the holder.
|
|
1059
|
-
const signedBy = [info.mintPubkey, ...previousPubkeys].find(key =>
|
|
1060
|
-
verifySignature(current, info.maxWithdrawable, currentSig, key)
|
|
1061
|
-
)
|
|
1062
|
-
assert(
|
|
1063
|
-
signedBy,
|
|
1064
|
-
previousPubkeys.length > 0
|
|
1065
|
-
? 'the signature verifies against neither the advertised mintPubkey nor any published previous key'
|
|
1066
|
-
: 'the signature does not verify against the advertised mintPubkey and amount'
|
|
1067
|
-
)
|
|
1068
|
-
return signedBy === info.mintPubkey
|
|
1082
|
+
// A mint that has rotated its signing key may publish the old ones as
|
|
1083
|
+
// previousPubkeys, so notes it issued before the rotation still verify.
|
|
1084
|
+
// Any key it currently stands behind is an acceptable signer for grading
|
|
1085
|
+
// purposes. That is a narrower claim than it looks: it says the
|
|
1086
|
+
// signature is genuine, not that a wallet should accept the new key -
|
|
1087
|
+
// LUD-25 puts that decision with the holder.
|
|
1088
|
+
const signerOf = verifies => [info.mintPubkey, ...previousPubkeys].find(key => verifies(key))
|
|
1089
|
+
const describeSigner = signedBy =>
|
|
1090
|
+
signedBy === info.mintPubkey
|
|
1069
1091
|
? 'verified offline'
|
|
1070
1092
|
: `verified offline against a previous signing key (${signedBy.slice(0, 16)}...)`
|
|
1093
|
+
const unverified = () =>
|
|
1094
|
+
previousPubkeys.length > 0
|
|
1095
|
+
? 'the signature verifies against neither the advertised mintPubkey nor any published previous key'
|
|
1096
|
+
: 'the signature does not verify against the advertised mintPubkey and amount'
|
|
1097
|
+
|
|
1098
|
+
await report.check('a plain note carries no signature, or one that verifies', async () => {
|
|
1099
|
+
// LUD-25 Part 2 signs cp1 notes only: a hash has nothing to attest to
|
|
1100
|
+
// without disclosing the secret, so a plain note is unsigned by
|
|
1101
|
+
// design. A mint that still issues the old Part 1 signature over the
|
|
1102
|
+
// hash is harmless, as long as the signature is a true one.
|
|
1103
|
+
if (currentSig === null) return 'unsigned, as Part 2 specifies for a plain note'
|
|
1104
|
+
assert(info.mintPubkey, 'a sig with no mintPubkey advertised verifies against nothing')
|
|
1105
|
+
assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
|
|
1106
|
+
const signedBy = signerOf(key => verifySignature(current, info.maxWithdrawable, currentSig, key))
|
|
1107
|
+
assert(signedBy, unverified())
|
|
1108
|
+
return `a legacy Part 1 signature, ${describeSigner(signedBy)}`
|
|
1071
1109
|
})
|
|
1072
1110
|
|
|
1073
1111
|
await report.check('reports a spent hash distinguishably from an unknown hash', async () => {
|
|
@@ -1095,12 +1133,10 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1095
1133
|
})
|
|
1096
1134
|
|
|
1097
1135
|
await report.check('keeps signatures off the informational endpoint', async () => {
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
// for it, and invites a wallet to treat the informational answer as an
|
|
1103
|
-
// offline proof when it is an online one.
|
|
1136
|
+
// A plain note has no certificate, so its informational GET carries no
|
|
1137
|
+
// sig. Part 2 does hand out a cs1 here for a cp1 note, and the Part 2
|
|
1138
|
+
// check below expects it; this probe is by hex k1, where one would
|
|
1139
|
+
// invite a wallet to treat an online answer as an offline proof.
|
|
1104
1140
|
const here = new URL(url)
|
|
1105
1141
|
here.searchParams.set('k1', current)
|
|
1106
1142
|
const body = await get(here)
|
|
@@ -1418,6 +1454,61 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1418
1454
|
return body.reason
|
|
1419
1455
|
})
|
|
1420
1456
|
|
|
1457
|
+
// LUD-25 Part 2. The spec's one MUST for signatures lives here: a cp1
|
|
1458
|
+
// note is a public key, and the SERVICE certifies every one it issues
|
|
1459
|
+
// with a cs1 over (key, amount) that recovers to mintPubkey. Part 2 is
|
|
1460
|
+
// optional, so a SERVICE that refuses the cp1 output warns rather than
|
|
1461
|
+
// fails. Last, because the note comes back as a plain secret only if
|
|
1462
|
+
// the rotate home succeeds.
|
|
1463
|
+
await report.check('certifies a cp1 note it issues (Part 2)', async () => {
|
|
1464
|
+
const secretKey = secp256k1.utils.randomSecretKey()
|
|
1465
|
+
const pubkey = schnorr.getPublicKey(secretKey)
|
|
1466
|
+
const cp1 = encodeCash('cp', pubkey)
|
|
1467
|
+
const out = new URL(info.callback)
|
|
1468
|
+
out.searchParams.append('k1', current)
|
|
1469
|
+
out.searchParams.append('p1', cp1)
|
|
1470
|
+
const body = await get(out)
|
|
1471
|
+
if (body.status === 'ERROR') throw soft(`Part 2 not offered: a cp1 output was refused (${body.reason})`)
|
|
1472
|
+
// The plain secret is burned either way; from here the bearer secret
|
|
1473
|
+
// is the key's ownership proof, until the rotate home below.
|
|
1474
|
+
const ck1 = ownershipProof(secretKey)
|
|
1475
|
+
current = ck1
|
|
1476
|
+
assert(info.mintPubkey, 'issued a cp1 note with no mintPubkey advertised - nothing to verify its certificate against')
|
|
1477
|
+
assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
|
|
1478
|
+
const pubkeyHex = bytesToHex(pubkey)
|
|
1479
|
+
const certificate = decodeCash('cs', body.sig, 65)
|
|
1480
|
+
assert(certificate, `the rotate to a cp1 output returned no cs1 certificate in sig (got ${JSON.stringify(body.sig)})`)
|
|
1481
|
+
const signedBy = signerOf(key => verifyCertificate(pubkeyHex, info.maxWithdrawable, certificate, key))
|
|
1482
|
+
assert(signedBy, unverified())
|
|
1483
|
+
|
|
1484
|
+
// The informational GET by ck1 delivers the certificate again, so a
|
|
1485
|
+
// holder need not rotate just to obtain one.
|
|
1486
|
+
const byKey = new URL(url)
|
|
1487
|
+
byKey.searchParams.set('k1', ck1)
|
|
1488
|
+
const lookup = await get(byKey)
|
|
1489
|
+
assert(lookup.status !== 'ERROR', `the cp1 note is not spendable by its ck1: ${lookup.reason}`)
|
|
1490
|
+
assert(
|
|
1491
|
+
lookup.maxWithdrawable === info.maxWithdrawable,
|
|
1492
|
+
`value changed across a rotate to cp1: ${info.maxWithdrawable} -> ${lookup.maxWithdrawable}`
|
|
1493
|
+
)
|
|
1494
|
+
const again = decodeCash('cs', lookup.sig, 65)
|
|
1495
|
+
assert(again, 'the informational GET by ck1 returned no cs1 certificate')
|
|
1496
|
+
assert(
|
|
1497
|
+
signerOf(key => verifyCertificate(pubkeyHex, info.maxWithdrawable, again, key)),
|
|
1498
|
+
'the certificate on the informational GET does not verify'
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
// Home: back to a plain secret, spent by the ck1.
|
|
1502
|
+
const fresh = bytesToHex(randomBytes(32))
|
|
1503
|
+
const home = new URL(info.callback)
|
|
1504
|
+
home.searchParams.append('k1', ck1)
|
|
1505
|
+
home.searchParams.append('p1', noteId(fresh))
|
|
1506
|
+
const back = await get(home)
|
|
1507
|
+
assert(back.status === 'OK', `the cp1 note could not be rotated back to a plain secret by its ck1: ${back.reason}`)
|
|
1508
|
+
current = fresh
|
|
1509
|
+
return `${describeSigner(signedBy)}; the plain note it rotated home to is ${back.sig === undefined ? 'unsigned' : 'still signed the Part 1 way'}`
|
|
1510
|
+
})
|
|
1511
|
+
|
|
1421
1512
|
return {finalSecret: current, noteUrl: (() => {
|
|
1422
1513
|
const u = new URL(url)
|
|
1423
1514
|
u.searchParams.set('k1', current)
|
package/vectors/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. A SERVICE returning a rotate, split or merge to a cp1 output MUST include its certificate in `sig` (and `sig2` for the second split output); a plain hash output carries none, and a legacy Part 1 signature on one is harmless where it verifies.",
|
|
5
5
|
"parse": [
|
|
6
6
|
{
|
|
7
7
|
"url": "https://mint.example/w?k1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&amount=21000",
|
package/vectors/responses.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"description": "Classifying a SERVICE response. The distinction that matters for funds is definitive-rejection versus ambiguous-outcome: a parsed {\"status\":\"ERROR\"} means the request was processed and refused, while a transport failure, an unparseable body, or a 200 that does not confirm means the mutation MAY have landed - and for rotate/split/merge the WALLET-generated secrets are then the only copy of the outputs, so they must ride the error rather than be discarded. A confirmed mutation carrying no signature is its own outcome: it definitely landed, so the secrets matter more than ever, and the SERVICE is non-conforming. `op` says which call each case is driven through - a melt is the one mutation with no signature to return.",
|
|
5
5
|
"outcomes": {
|
|
6
6
|
"ok": "the operation is confirmed",
|
|
7
|
-
"unverifiable": "the mutation is confirmed but
|
|
7
|
+
"unverifiable": "the mutation is confirmed but a cp1 output came back without its cs1 certificate. LUD-25 Part 2 requires one on every cp1 output of a rotate, split or merge, so this SERVICE is non-conforming - but the note EXISTS at the key the WALLET disclosed, and its private key is the only key to that value. Keep the key; report the mint. A plain hash output is unsigned by design and is never this outcome",
|
|
8
8
|
"pending": "this k1 has another operation in flight (a melt); retry shortly",
|
|
9
9
|
"spent": "the SERVICE is authoritative that the note is already burned; a holder may lock it as spent",
|
|
10
10
|
"unknown": "the SERVICE does not recognise this note; surface it, do not silently lock it",
|
|
@@ -19,8 +19,43 @@
|
|
|
19
19
|
"body": {
|
|
20
20
|
"status": "OK"
|
|
21
21
|
},
|
|
22
|
+
"expect": "ok",
|
|
23
|
+
"why": "a bare OK is the conforming answer for a plain hash output since the Part 2 rewrite: the note is real, the WALLET keeps its secret, and nobody the holder hands it to can check it offline, which is what a plain note is. Only a cp1 output is owed a certificate"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "cp1 output confirmed without a certificate",
|
|
27
|
+
"op": "mutation",
|
|
28
|
+
"output": "cp1",
|
|
29
|
+
"http": 200,
|
|
30
|
+
"body": {
|
|
31
|
+
"status": "OK"
|
|
32
|
+
},
|
|
33
|
+
"expect": "unverifiable",
|
|
34
|
+
"why": "a cp1 output is owed a cs1 certificate in sig; without one the note it names cannot be verified offline, which is the whole reason to hold a cp1 note. The note exists at the key the WALLET disclosed"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "cp1 output certified",
|
|
38
|
+
"op": "mutation",
|
|
39
|
+
"output": "cp1",
|
|
40
|
+
"http": 200,
|
|
41
|
+
"body": {
|
|
42
|
+
"status": "OK",
|
|
43
|
+
"sig": "cs14w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2atdv6a53"
|
|
44
|
+
},
|
|
45
|
+
"expect": "ok",
|
|
46
|
+
"signature": "cs14w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2at4w46h2atdv6a53"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "a split to a cp1 change that certifies only its first output",
|
|
50
|
+
"op": "split",
|
|
51
|
+
"change": "cp1",
|
|
52
|
+
"http": 200,
|
|
53
|
+
"body": {
|
|
54
|
+
"status": "OK",
|
|
55
|
+
"sig": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab"
|
|
56
|
+
},
|
|
22
57
|
"expect": "unverifiable",
|
|
23
|
-
"why": "
|
|
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 plain hash notes, owed nothing; a mint may still issue the old Part 1 signature over one of them, and that is harmless where it verifies"
|
|
59
95
|
},
|
|
60
96
|
{
|
|
61
97
|
"name": "melt success with a LUD-21 style proof",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
],
|
|
11
11
|
"provenance": "Recorded, never inferred. A SERVICE links the burned inputs to the outputs they minted and matches against that. Matching on \"a note exists at h\" alone would let anyone holding a burned k1 and any outstanding note id pull a success out of the SERVICE.",
|
|
12
12
|
"outcomes": {
|
|
13
|
-
"replay": "the original success, byte for byte: the same status, the same sig and sig2, and no balance moved",
|
|
13
|
+
"replay": "the original success, byte for byte: the same status, the same sig and sig2 where the outputs had any, and no balance moved",
|
|
14
14
|
"double-spend": "refused exactly as any other attempt to spend a burned secret, with the reason string unchanged"
|
|
15
15
|
},
|
|
16
16
|
"cases": [
|