lnurlcash-conformance 0.7.0 → 0.8.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 +26 -1
- package/README.md +7 -3
- package/mock-mint/index.d.ts +2 -0
- package/mock-mint/index.mjs +5 -3
- package/package.json +4 -4
- package/runner/index.mjs +30 -13
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,32 @@ 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
|
-
##
|
|
7
|
+
## 0.8.0 - 2026-09-09
|
|
8
|
+
|
|
9
|
+
**A spent note must say so.** LUD-25 [#307][307] settled the hash lookup's
|
|
10
|
+
status contract the other way round from 0.7.0: a retained burned hash returns
|
|
11
|
+
a spent error, an unregistered hash stays unknown, and the two must be
|
|
12
|
+
distinguishable. A holder who kept only the hash can then learn a note was
|
|
13
|
+
redeemed without putting its bearer secret on the wire, which is the whole
|
|
14
|
+
point of the lookup. Mints passing 0.7.0's privacy check fail this one, and the
|
|
15
|
+
change is deliberate.
|
|
16
|
+
|
|
17
|
+
[307]: https://github.com/lnurl/luds/pull/307
|
|
18
|
+
|
|
19
|
+
- Revise spent-hash grading to match the proposed LUD-25 status contract:
|
|
20
|
+
a retained burned hash must return a spent error, while an unregistered
|
|
21
|
+
hash remains unknown. Add the adversarial `hidesSpent` fixture; retain
|
|
22
|
+
`revealsSpent` as a compliant compatibility alias. This reverses the
|
|
23
|
+
0.7.0 privacy check and requires corresponding mint changes.
|
|
24
|
+
|
|
25
|
+
- Documentation only, no grade changes. `previousPubkeys` is no longer a
|
|
26
|
+
LUD-25 field: the spec carried it briefly and dropped it on 2026-09-04,
|
|
27
|
+
replacing it with "`SERVICE` SHOULD NOT rotate `mintPubkey`" plus a
|
|
28
|
+
`WALLET`-side MUST to pin per origin and require explicit holder approval
|
|
29
|
+
for any replacement. The runner already graded it for shape only and never
|
|
30
|
+
for presence, which is the right posture for a convention; the comments and
|
|
31
|
+
the flag table now say that is what it is, and that a wallet must not read
|
|
32
|
+
it as permission to move a pin by itself.
|
|
8
33
|
|
|
9
34
|
## 0.7.0 - 2026-09-04
|
|
10
35
|
|
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ must survive:
|
|
|
104
104
|
| `--retriedMutation=refuse` | deliberately answers an identical mutation retry as already spent instead of replaying its original success |
|
|
105
105
|
| `--hashLookup=echoesK1` | offers hash lookup but puts a `k1` back in the response |
|
|
106
106
|
| `--hashLookup=answersUnknown` | offers hash lookup but invents a note for an unknown hash |
|
|
107
|
-
| `--hashLookup=
|
|
107
|
+
| `--hashLookup=hidesSpent` | incorrectly reports a retained spent hash as unknown |
|
|
108
108
|
| `--hashLookup=acceptsBoth` | offers hash lookup but accepts `k1` and `h` together |
|
|
109
109
|
| `--mintToHashAcceptsMalformedH` | claims `mintToHash` and invoices an `h` that is not 64 lowercase hex, so a wallet pays for a quote the mint will refuse |
|
|
110
110
|
| `--mintToHashAcceptsUsedH` | claims it and invoices an `h` that already names a note, an invoice or another quote's output |
|
|
@@ -114,6 +114,10 @@ The three `mintToHash*` misbehaviours need `--mintToHash` alongside them;
|
|
|
114
114
|
on their own they do nothing, because a mint that never offered the
|
|
115
115
|
capability cannot misuse it.
|
|
116
116
|
|
|
117
|
+
Hash lookup keeps the spending secret off the wire but reports spent state:
|
|
118
|
+
`--hashLookup=true` distinguishes a burned hash from an unknown hash. The older
|
|
119
|
+
`--hashLookup=revealsSpent` spelling remains an alias for this compliant behaviour.
|
|
120
|
+
|
|
117
121
|
The remaining flags enable optional features, legal wire variants, or make a
|
|
118
122
|
conforming default explicit. Optional fields stay absent unless requested:
|
|
119
123
|
|
|
@@ -125,7 +129,7 @@ conforming default explicit. Optional fields stay absent unless requested:
|
|
|
125
129
|
| `--baseFeeMsat --feePpm` | also publishes `fees: {baseFeeMsat, feePpm}` on that endpoint, the structured twin of the fee line in the payRequest metadata |
|
|
126
130
|
| `--stats` | serves `GET /stats`: what the mint owes, what is in flight, what the node holds, and the coverage between them |
|
|
127
131
|
| `--localBalanceMsat=N` | what the node behind a stats-publishing mock claims to hold, so a mock can be told to look under-covered |
|
|
128
|
-
| `--previousPubkeys=a,b` | keys this mint has signed under before, so notes issued before a rotation still verify |
|
|
132
|
+
| `--previousPubkeys=a,b` | keys this mint has signed under before, so notes issued before a rotation still verify. Not a LUD-25 field: the spec carried it briefly and dropped it on 2026-09-04. Graded for shape where a mint offers it, never asked for |
|
|
129
133
|
| `--previousPrivateKey=<hex>` | an old signing key the mock still holds. Its public half joins `previousPubkeys` on its own |
|
|
130
134
|
| `--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 |
|
|
131
135
|
| `--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 |
|
|
@@ -268,7 +272,7 @@ Spec and reference implementations, all by dni, all MIT:
|
|
|
268
272
|
- [lnurl-wallet](https://github.com/dni/lnurl-wallet) — the reference wallet
|
|
269
273
|
|
|
270
274
|
Implementations to run these vectors against are indexed in
|
|
271
|
-
[awesome-lnurlcash](https://github.com/
|
|
275
|
+
[awesome-lnurlcash](https://github.com/lnurlcash/awesome-lnurlcash).
|
|
272
276
|
|
|
273
277
|
Contributions of vectors are welcome, particularly from implementers who
|
|
274
278
|
found a case these missed. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
package/mock-mint/index.d.ts
CHANGED
package/mock-mint/index.mjs
CHANGED
|
@@ -190,8 +190,8 @@ const DEFAULTS = {
|
|
|
190
190
|
// on the wire the lookup existed to keep it off
|
|
191
191
|
// 'answersUnknown' - non-compliant: answers for a hash it never
|
|
192
192
|
// registered, instead of the unknown-note refusal
|
|
193
|
-
// '
|
|
194
|
-
//
|
|
193
|
+
// 'hidesSpent' - non-compliant: hides a retained burned h as unknown
|
|
194
|
+
// 'revealsSpent' - legacy alias for the now-compliant true behaviour
|
|
195
195
|
// 'acceptsBoth' - non-compliant: accepts k1 and h together
|
|
196
196
|
hashLookup: false,
|
|
197
197
|
// LUD-25 lets a SERVICE refuse an oversized merge outright rather than
|
|
@@ -813,8 +813,9 @@ export const createMockMint = async (options = {}) => {
|
|
|
813
813
|
if (!invent) {
|
|
814
814
|
if (!held) return fail('Unknown note.')
|
|
815
815
|
if (held.state === 'burned') {
|
|
816
|
-
return fail(opts.hashLookup === '
|
|
816
|
+
return fail(opts.hashLookup === 'hidesSpent' ? 'Unknown note.' : 'Note already spent.')
|
|
817
817
|
}
|
|
818
|
+
if (held.state === 'pending') return fail('pending')
|
|
818
819
|
}
|
|
819
820
|
return send({
|
|
820
821
|
tag: 'withdrawRequest',
|
|
@@ -833,6 +834,7 @@ export const createMockMint = async (options = {}) => {
|
|
|
833
834
|
const note = notes.get(noteId(k1))
|
|
834
835
|
if (!note) return fail('Unknown note.')
|
|
835
836
|
if (note.state === 'burned') return fail('Note already spent.')
|
|
837
|
+
if (note.state === 'pending') return fail('pending')
|
|
836
838
|
return send({
|
|
837
839
|
tag: 'withdrawRequest',
|
|
838
840
|
callback: `${origin}/w/cb`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lnurlcash-conformance",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
},
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "git+https://github.com/
|
|
12
|
+
"url": "git+https://github.com/lnurlcash/lnurlcash-conformance.git"
|
|
13
13
|
},
|
|
14
|
-
"homepage": "https://github.com/
|
|
14
|
+
"homepage": "https://github.com/lnurlcash/lnurlcash-conformance#readme",
|
|
15
15
|
"bugs": {
|
|
16
|
-
"url": "https://github.com/
|
|
16
|
+
"url": "https://github.com/lnurlcash/lnurlcash-conformance/issues"
|
|
17
17
|
},
|
|
18
18
|
"type": "module",
|
|
19
19
|
"keywords": [
|
package/runner/index.mjs
CHANGED
|
@@ -364,6 +364,15 @@ export const gradeMint = async (payUrl, report) => {
|
|
|
364
364
|
// absence is never a failure - but a field that IS published and is
|
|
365
365
|
// the wrong shape is worth saying out loud, because a wallet will try
|
|
366
366
|
// to render it. Malformed means a warning, not a fail.
|
|
367
|
+
//
|
|
368
|
+
// previousPubkeys in particular: LUD-25 carried it briefly and dropped
|
|
369
|
+
// it on 2026-09-04, replacing it with "SERVICE SHOULD NOT rotate
|
|
370
|
+
// mintPubkey" plus a WALLET-side MUST to pin per origin and require
|
|
371
|
+
// explicit holder approval for any replacement. So it is now purely a
|
|
372
|
+
// convention between the implementations that already emit and read it,
|
|
373
|
+
// and it is graded here as one: shape only, never presence. Nothing in
|
|
374
|
+
// this suite asks a SERVICE to publish it, and a wallet must not treat
|
|
375
|
+
// it as permission to move a pin by itself.
|
|
367
376
|
const problems = []
|
|
368
377
|
for (const key of ['name', 'description', 'tosUrl', 'motd', 'version']) {
|
|
369
378
|
const value = body[key]
|
|
@@ -833,7 +842,10 @@ export const gradeBoundMint = async (noteUrl, report, {preimage, payCallback = n
|
|
|
833
842
|
// options.previousPubkeys: keys this mint has signed under before, from
|
|
834
843
|
// the discovery endpoint. A note issued before a signing-key rotation
|
|
835
844
|
// still verifies against one of them, and grading that as a bad signature
|
|
836
|
-
// would punish a mint for rotating properly.
|
|
845
|
+
// would punish a mint for rotating properly. Not a LUD-25 field - the spec
|
|
846
|
+
// dropped it on 2026-09-04 and now says a SERVICE SHOULD NOT rotate at all
|
|
847
|
+
// - so this is a convention the grader honours where a mint offers it,
|
|
848
|
+
// never something it asks for.
|
|
837
849
|
export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
838
850
|
const knownBaseFee = 'mintFee' in options ? (options.mintFee?.baseFeeMsat ?? 0) : null
|
|
839
851
|
const previousPubkeys = Array.isArray(options.previousPubkeys)
|
|
@@ -1038,9 +1050,12 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1038
1050
|
assert(info.mintPubkey, 'no mintPubkey advertised - offline verification is mandatory')
|
|
1039
1051
|
assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
|
|
1040
1052
|
assert(currentSig, 'the rotate returned no sig - offline verification is mandatory')
|
|
1041
|
-
// A mint that has rotated its signing key
|
|
1053
|
+
// A mint that has rotated its signing key may publish the old ones as
|
|
1042
1054
|
// previousPubkeys, so notes it issued before the rotation still
|
|
1043
|
-
// verify. Any key it currently stands behind is an acceptable signer
|
|
1055
|
+
// verify. Any key it currently stands behind is an acceptable signer
|
|
1056
|
+
// for grading purposes. That is a narrower claim than it looks: it
|
|
1057
|
+
// says the signature is genuine, not that a wallet should accept the
|
|
1058
|
+
// new key - LUD-25 puts that decision with the holder.
|
|
1044
1059
|
const signedBy = [info.mintPubkey, ...previousPubkeys].find(key =>
|
|
1045
1060
|
verifySignature(current, info.maxWithdrawable, currentSig, key)
|
|
1046
1061
|
)
|
|
@@ -1055,26 +1070,28 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1055
1070
|
: `verified offline against a previous signing key (${signedBy.slice(0, 16)}...)`
|
|
1056
1071
|
})
|
|
1057
1072
|
|
|
1058
|
-
await report.check('
|
|
1073
|
+
await report.check('reports a spent hash distinguishably from an unknown hash', async () => {
|
|
1059
1074
|
if (!hashLookupOffered) throw soft('hash lookup is not offered')
|
|
1060
1075
|
const burned = new URL(url)
|
|
1061
1076
|
burned.searchParams.delete('k1')
|
|
1062
1077
|
burned.searchParams.delete('amount')
|
|
1063
1078
|
burned.searchParams.set('h', noteId(k1))
|
|
1064
1079
|
const burnedResponse = await get(burned)
|
|
1080
|
+
assert(
|
|
1081
|
+
burnedResponse.status === 'ERROR' && typeof burnedResponse.reason === 'string' &&
|
|
1082
|
+
/spent/i.test(burnedResponse.reason) && !/unknown|not found/i.test(burnedResponse.reason),
|
|
1083
|
+
`a spent hash must be identified as spent, got ${JSON.stringify(burnedResponse)}`
|
|
1084
|
+
)
|
|
1065
1085
|
|
|
1066
|
-
const unknown = new URL(
|
|
1067
|
-
unknown.searchParams.set('
|
|
1068
|
-
unknown.searchParams.delete('h')
|
|
1069
|
-
unknown.searchParams.delete('amount')
|
|
1086
|
+
const unknown = new URL(burned)
|
|
1087
|
+
unknown.searchParams.set('h', noteId(bytesToHex(randomBytes(32))))
|
|
1070
1088
|
const unknownResponse = await get(unknown)
|
|
1071
|
-
|
|
1072
1089
|
assert(
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
`
|
|
1090
|
+
unknownResponse.status === 'ERROR' && typeof unknownResponse.reason === 'string' &&
|
|
1091
|
+
/unknown|not found/i.test(unknownResponse.reason) && !/spent/i.test(unknownResponse.reason),
|
|
1092
|
+
`an unregistered hash must be identified as unknown, got ${JSON.stringify(unknownResponse)}`
|
|
1076
1093
|
)
|
|
1077
|
-
return '
|
|
1094
|
+
return 'spent and unknown hashes are distinguishable without disclosing k1'
|
|
1078
1095
|
})
|
|
1079
1096
|
|
|
1080
1097
|
await report.check('keeps signatures off the informational endpoint', async () => {
|