lnurlcash-conformance 0.5.0 → 0.7.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 +70 -0
- package/README.md +13 -10
- package/mock-mint/index.d.ts +18 -7
- package/mock-mint/index.mjs +42 -19
- package/package.json +1 -1
- package/runner/index.mjs +67 -28
- package/vectors/cash-derivation.json +160 -0
- package/vectors/index.json +1 -0
- package/vectors/lifecycle.json +1 -1
- package/vectors/note-url.json +1 -1
- package/vectors/responses.json +32 -3
- package/vectors/retried-mutation.json +1 -1
- package/vectors/signature.json +1 -1
- package/vectors/withdraw-info.json +59 -23
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,76 @@ exact version if you gate CI on the grade.
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.7.0 - 2026-09-04
|
|
10
|
+
|
|
11
|
+
**`cash-derivation.json`: LUD-25's own seed-recoverable note secrets.** The
|
|
12
|
+
draft specifies a BIP-32 scheme under `m/139'` and the reference wallet
|
|
13
|
+
implements it; `derivation.json` remains, unchanged, as the pre-spec HMAC
|
|
14
|
+
scheme that notes are still outstanding under.
|
|
15
|
+
|
|
16
|
+
Every case carries `cashRoot`, the four `domainIndices`, a `hardened` flag per
|
|
17
|
+
index, the `domainNode` and the resulting `k1`. The flags are the point of the
|
|
18
|
+
file. `d1..d4` are raw uint32 and BIP-32 reads any index `>= 2^31` as hardened,
|
|
19
|
+
so which levels are hardened is decided by the mint's own host name - an
|
|
20
|
+
implementation that masks the top bit, or hardens all four, derives a
|
|
21
|
+
different tree and restores nothing, silently. Both hosts in the vector land
|
|
22
|
+
on a mix, so an implementation that gets it wrong cannot pass by luck.
|
|
23
|
+
|
|
24
|
+
`bip32Vector1` carries BIP-32's own published test vector 1, so a port can
|
|
25
|
+
prove its CKDpriv before blaming the LUD-25 path above it. The generator
|
|
26
|
+
implements BIP-32 from `@noble` primitives rather than importing a BIP-32
|
|
27
|
+
library, for the reason at the top of `tools/generate.mjs`: a vector produced
|
|
28
|
+
by the library under test proves nothing.
|
|
29
|
+
|
|
30
|
+
## 0.6.0 - 2026-09-03
|
|
31
|
+
|
|
32
|
+
**Offline verification and mutation replay now grade the current LUD-25
|
|
33
|
+
MUSTs.** A rotate, split or merge without a valid note signature fails rather
|
|
34
|
+
than warning, as does a byte-identical retry that is answered as already
|
|
35
|
+
spent. The conforming mock now replays mutations by default; `signatures=false`
|
|
36
|
+
and `retriedMutation=refuse` remain explicit adversarial fixtures.
|
|
37
|
+
|
|
38
|
+
The optional hash lookup is also checked end to end once a mint offers it.
|
|
39
|
+
The runner requires exactly one of `k1` and `h`, compares the unknown-h response
|
|
40
|
+
with an unknown-k1 response, then burns the original note and confirms its hash
|
|
41
|
+
still gets that same unknown-note answer. This catches the privacy leak where a
|
|
42
|
+
mint answers a burned `h` as already spent. The mock gains separate
|
|
43
|
+
`revealsSpent` and `acceptsBoth` fixtures proving those checks fail.
|
|
44
|
+
|
|
45
|
+
The signature vectors now describe `mintPubkey` as a stable SERVICE key. It may
|
|
46
|
+
be the funding node identity where compatible signing exists, or a dedicated,
|
|
47
|
+
persistent secp256k1 key where it does not.
|
|
48
|
+
|
|
49
|
+
**The client-facing vectors carry the same MUSTs.** They had been left behind
|
|
50
|
+
by the prose, still describing a signature as one accepted shape among
|
|
51
|
+
several, so a wallet library could pass every vector while ignoring the
|
|
52
|
+
requirement entirely.
|
|
53
|
+
|
|
54
|
+
`withdraw-info.json` now puts `mintPubkey` on every accepted withdrawRequest
|
|
55
|
+
and rejects a response that omits it or publishes something that is not a
|
|
56
|
+
33-byte compressed secp256k1 key. A wallet handed a note by a mint that
|
|
57
|
+
publishes no key has nothing to verify it against, which is the gap offline
|
|
58
|
+
verification exists to close.
|
|
59
|
+
|
|
60
|
+
`responses.json` gains an `unverifiable` outcome for a mutation the SERVICE
|
|
61
|
+
confirms but does not sign. It is deliberately not folded into the existing
|
|
62
|
+
`error` or `ambiguous` outcomes: the mutation definitely landed, so the
|
|
63
|
+
wallet-generated secret is the only key to a real note and must be kept, and
|
|
64
|
+
a consumer that treats this as an ordinary failure loses the money it is
|
|
65
|
+
trying to protect. Every case now also declares the `op` it is driven
|
|
66
|
+
through, because a melt mints nothing and so needs no signature, while a
|
|
67
|
+
rotate returning none is the new outcome.
|
|
68
|
+
|
|
69
|
+
**The mock mint starts when its path reaches it through a symlink.** The
|
|
70
|
+
standalone guard compared `import.meta.url` against `file://` plus
|
|
71
|
+
`process.argv[1]`, and node resolves a module's URL to its real path while
|
|
72
|
+
argv[1] stays exactly as it was typed. A checkout symlinked into a sibling
|
|
73
|
+
workspace - which is how the language ports pick this repo up locally - failed
|
|
74
|
+
that comparison, so the mint exited silently, printing nothing at all. Every
|
|
75
|
+
port's adversarial suite then hung to EOF and reported the mint as never having
|
|
76
|
+
announced itself, which says nothing about the cause. CI, checking the repo out
|
|
77
|
+
for real, was never affected, so this only ever broke the local run.
|
|
78
|
+
|
|
9
79
|
## 0.5.0 - 2026-08-31
|
|
10
80
|
|
|
11
81
|
**Breaking: the current LUD-25 draft's mandatory mint comment is now the
|
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ must survive:
|
|
|
92
92
|
| `--echoWrongK1` | answers the informational GET with a different `k1` |
|
|
93
93
|
| `--lieAboutValue=N` | reports a `maxWithdrawable` it never signed |
|
|
94
94
|
| `--signatureLayout=leading` | emits the recovery id at the other end |
|
|
95
|
-
| `--signatures=false` |
|
|
95
|
+
| `--signatures=false` | deliberately violates LUD-25 by issuing no signatures |
|
|
96
96
|
| `--serverGeneratedSecrets` | hands back a secret it generated — the exposure `h` exists to close |
|
|
97
97
|
| `--meltNeverSettles` | holds every melt in flight, so notes stay `pending` |
|
|
98
98
|
| `--meltAlwaysFails` | fails every payment, restoring the note |
|
|
@@ -101,6 +101,11 @@ must survive:
|
|
|
101
101
|
| `--baseFeeMsat=N --feePpm=N` | advertises and withholds a mint fee |
|
|
102
102
|
| `--roundFeeToSat` | rounds the withheld fee up to a whole sat — the note mints short of the formula |
|
|
103
103
|
| `--verifyLeaksEarly` | serves a preimage before settlement, falsely claiming payment proof before payment happened |
|
|
104
|
+
| `--retriedMutation=refuse` | deliberately answers an identical mutation retry as already spent instead of replaying its original success |
|
|
105
|
+
| `--hashLookup=echoesK1` | offers hash lookup but puts a `k1` back in the response |
|
|
106
|
+
| `--hashLookup=answersUnknown` | offers hash lookup but invents a note for an unknown hash |
|
|
107
|
+
| `--hashLookup=revealsSpent` | offers hash lookup but distinguishes a burned hash from an unknown note |
|
|
108
|
+
| `--hashLookup=acceptsBoth` | offers hash lookup but accepts `k1` and `h` together |
|
|
104
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 |
|
|
105
110
|
| `--mintToHashAcceptsUsedH` | claims it and invoices an `h` that already names a note, an invoice or another quote's output |
|
|
106
111
|
| `--mintToHashIgnoresH` | claims it but accepts `h` and mandatory `comment` naming different outputs |
|
|
@@ -108,16 +113,14 @@ must survive:
|
|
|
108
113
|
The three `mintToHash*` misbehaviours need `--mintToHash` alongside them;
|
|
109
114
|
on their own they do nothing, because a mint that never offered the
|
|
110
115
|
capability cannot misuse it.
|
|
111
|
-
| `--verify=false` | no LUD-21 endpoint at all, not merely unadvertised |
|
|
112
|
-
| `--withdrawLinkForm=lnurlw` | spells `withdrawLink` as `lnurlw://host/w` instead of the plain `https://host/w` the reference mint emits. Both are legal; a client has to take both |
|
|
113
116
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
for them, so a mock started with no options answers exactly what it always
|
|
117
|
-
answered:
|
|
117
|
+
The remaining flags enable optional features, legal wire variants, or make a
|
|
118
|
+
conforming default explicit. Optional fields stay absent unless requested:
|
|
118
119
|
|
|
119
120
|
| Flag | What it does |
|
|
120
121
|
| --- | --- |
|
|
122
|
+
| `--verify=false` | provides no LUD-21 endpoint at all, rather than merely leaving it unadvertised |
|
|
123
|
+
| `--withdrawLinkForm=lnurlw` | spells `withdrawLink` as `lnurlw://host/w` instead of the plain `https://host/w` the reference mint emits. Both are legal; a client has to take both |
|
|
121
124
|
| `--name --description --contact --tosUrl --motd --version` | mint info on the experimental discovery endpoint: who runs this, how to reach them, the terms, and what the operator wants holders to know today |
|
|
122
125
|
| `--baseFeeMsat --feePpm` | also publishes `fees: {baseFeeMsat, feePpm}` on that endpoint, the structured twin of the fee line in the payRequest metadata |
|
|
123
126
|
| `--stats` | serves `GET /stats`: what the mint owes, what is in flight, what the node holds, and the coverage between them |
|
|
@@ -125,7 +128,8 @@ answered:
|
|
|
125
128
|
| `--previousPubkeys=a,b` | keys this mint has signed under before, so notes issued before a rotation still verify |
|
|
126
129
|
| `--previousPrivateKey=<hex>` | an old signing key the mock still holds. Its public half joins `previousPubkeys` on its own |
|
|
127
130
|
| `--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 |
|
|
128
|
-
| `--retriedMutation=replay` | answers a byte-identical repeat of a mutation with the original success
|
|
131
|
+
| `--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 |
|
|
132
|
+
| `--hashLookup=true` | accepts an informational lookup by `h=sha256(k1)` without returning the bearer secret. Off by default because the capability is optional |
|
|
129
133
|
| `--mintToHash` | accepts `h` alongside the mandatory identical comment and enables the additive quote/receipt fields. Off by default; baseline comment-bound minting remains on |
|
|
130
134
|
| `--mintReceipt` | with `--mintToHash`, adds the optional quote commitment and signed LUD-21 settlement receipt |
|
|
131
135
|
| `--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 |
|
|
@@ -223,8 +227,7 @@ advertised `mintPubkey` or any key the mint still publishes as a previous
|
|
|
223
227
|
one, that split and merge conserve value - exactly,
|
|
224
228
|
under LUD-25's fee algebra, when the mint's fee advertisement is known -
|
|
225
229
|
that a byte-identical repeat of a mutation is answered with the original
|
|
226
|
-
success rather than as an already-spent input
|
|
227
|
-
has not implemented it is reported as such rather than failed), and that a
|
|
230
|
+
success rather than as an already-spent input, and that a
|
|
228
231
|
burned secret cannot be replayed. It also probes three adversarial shapes a
|
|
229
232
|
mint must refuse atomically: a duplicated `k1` (which a careless mint counts
|
|
230
233
|
twice, minting money from nothing), an output hash that collides with an
|
package/mock-mint/index.d.ts
CHANGED
|
@@ -10,6 +10,13 @@ export type WithdrawLinkForm = 'lnurlw' | 'plain'
|
|
|
10
10
|
|
|
11
11
|
export type RetriedMutation = 'refuse' | 'replay'
|
|
12
12
|
|
|
13
|
+
export type HashLookup =
|
|
14
|
+
| boolean
|
|
15
|
+
| 'echoesK1'
|
|
16
|
+
| 'answersUnknown'
|
|
17
|
+
| 'revealsSpent'
|
|
18
|
+
| 'acceptsBoth'
|
|
19
|
+
|
|
13
20
|
/** where a mint claims it accepts an `h` on its pay callback */
|
|
14
21
|
export type MintToHashPlace = 'payRequest' | 'mintAddress' | 'quote'
|
|
15
22
|
|
|
@@ -26,7 +33,7 @@ export interface MockMintOptions {
|
|
|
26
33
|
* signmessage output unreordered.
|
|
27
34
|
*/
|
|
28
35
|
signatureLayout?: SignatureLayout
|
|
29
|
-
/** withhold sig/sig2
|
|
36
|
+
/** non-compliant: withhold the mandatory sig/sig2 */
|
|
30
37
|
signatures?: boolean
|
|
31
38
|
/**
|
|
32
39
|
* How the payRequest spells its withdrawLink. 'plain' (default) is the
|
|
@@ -124,14 +131,18 @@ export interface MockMintOptions {
|
|
|
124
131
|
/**
|
|
125
132
|
* What a retried mutation gets. A rotate, split or merge is a GET and
|
|
126
133
|
* HTTP stacks retry a GET on a dropped connection, so a SERVICE sees
|
|
127
|
-
* the byte-identical request twice. '
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* SERVICE really did mint. Identical means the same input k1 set, the
|
|
134
|
+
* the byte-identical request twice. 'replay' (default) returns the
|
|
135
|
+
* original success as LUD-25 requires; 'refuse' is the non-compliant
|
|
136
|
+
* fixture. Identical means the same input k1 set, the
|
|
131
137
|
* same h, the same h2 and the same amount; anything else naming a
|
|
132
138
|
* burned input is refused exactly as before.
|
|
133
139
|
*/
|
|
134
140
|
retriedMutation?: RetriedMutation
|
|
141
|
+
/**
|
|
142
|
+
* Optional informational lookup by sha256(k1). `true` is conforming;
|
|
143
|
+
* string values reproduce distinct non-compliant responses.
|
|
144
|
+
*/
|
|
145
|
+
hashLookup?: HashLookup
|
|
135
146
|
/** serve GET /stats, the liabilities endpoint. Off means 404, as before. */
|
|
136
147
|
stats?: boolean
|
|
137
148
|
/** what the node behind a stats-publishing mock claims to hold */
|
|
@@ -230,8 +241,8 @@ export interface MockMintState {
|
|
|
230
241
|
opts: Required<MockMintOptions>
|
|
231
242
|
/**
|
|
232
243
|
* Fund a note directly, bypassing the minting flow. Returns the note's
|
|
233
|
-
* signature, or undefined
|
|
234
|
-
* false`
|
|
244
|
+
* signature, or undefined only for the deliberately non-compliant
|
|
245
|
+
* `signatures: false` fixture.
|
|
235
246
|
*
|
|
236
247
|
* Pass `{previousKey: true}` to sign it under `previousPrivateKey`
|
|
237
248
|
* instead, which is how a case puts one note under the old signing key
|
package/mock-mint/index.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import {sha256} from '@noble/hashes/sha2.js'
|
|
|
15
15
|
import {secp256k1} from '@noble/curves/secp256k1.js'
|
|
16
16
|
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
|
17
17
|
import {randomBytes} from 'node:crypto'
|
|
18
|
+
import {realpathSync} from 'node:fs'
|
|
19
|
+
import {pathToFileURL} from 'node:url'
|
|
18
20
|
|
|
19
21
|
const LSM_PREFIX = 'Lightning Signed Message:'
|
|
20
22
|
|
|
@@ -148,12 +150,9 @@ const DEFAULTS = {
|
|
|
148
150
|
signWithPreviousKey: false,
|
|
149
151
|
// What a retried mutation gets. A rotate, split or merge is a GET, and
|
|
150
152
|
// HTTP stacks retry a GET when the connection they used is dropped, so
|
|
151
|
-
// a SERVICE sees the byte-identical request twice. '
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
// original success, which is what LUD-25 SHOULD say and what stops a
|
|
155
|
-
// holder discarding a note the SERVICE really did mint.
|
|
156
|
-
retriedMutation: 'refuse',
|
|
153
|
+
// a SERVICE sees the byte-identical request twice. 'replay' is the
|
|
154
|
+
// conforming default. 'refuse' is retained as the adversarial fixture.
|
|
155
|
+
retriedMutation: 'replay',
|
|
157
156
|
// Liabilities. Off means 404, exactly as before; on serves GET /stats.
|
|
158
157
|
stats: false,
|
|
159
158
|
// What the node behind a stats-publishing mock claims to hold. Read
|
|
@@ -191,6 +190,9 @@ const DEFAULTS = {
|
|
|
191
190
|
// on the wire the lookup existed to keep it off
|
|
192
191
|
// 'answersUnknown' - non-compliant: answers for a hash it never
|
|
193
192
|
// registered, instead of the unknown-note refusal
|
|
193
|
+
// 'revealsSpent' - non-compliant: distinguishes a burned h from an
|
|
194
|
+
// unknown note
|
|
195
|
+
// 'acceptsBoth' - non-compliant: accepts k1 and h together
|
|
194
196
|
hashLookup: false,
|
|
195
197
|
// LUD-25 lets a SERVICE refuse an oversized merge outright rather than
|
|
196
198
|
// let the URL be mangled upstream. 0 is no explicit cap; a positive number
|
|
@@ -297,7 +299,7 @@ export const createMockMint = async (options = {}) => {
|
|
|
297
299
|
// minted. Recorded, never inferred - matching on "a note exists at h"
|
|
298
300
|
// alone would let anyone holding a burned k1 and any outstanding note
|
|
299
301
|
// id pull a success out of the mint.
|
|
300
|
-
const swaps = new Map() // identity -> [{id, amountMsat}]
|
|
302
|
+
const swaps = new Map() // identity -> [{id, amountMsat, sig}]
|
|
301
303
|
// Output ids a mint quote has already claimed: h -> the payment hash of
|
|
302
304
|
// the invoice that will credit it. Only ever written when mintToHash is
|
|
303
305
|
// on, so with the option off this is empty and every collision check
|
|
@@ -803,13 +805,16 @@ export const createMockMint = async (options = {}) => {
|
|
|
803
805
|
// `h` is only ever read here, never at the callback, where the same
|
|
804
806
|
// letter means the hash of a NEW note.
|
|
805
807
|
const asked = q.get('h')?.toLowerCase()
|
|
808
|
+
if (k1 && asked && opts.hashLookup !== 'acceptsBoth') return fail('Unknown note.')
|
|
806
809
|
if (!k1 && asked && opts.hashLookup) {
|
|
807
810
|
if (!/^[0-9a-f]{64}$/.test(asked)) return fail('Unknown note.')
|
|
808
811
|
const held = notes.get(asked)
|
|
809
812
|
const invent = opts.hashLookup === 'answersUnknown' && !held
|
|
810
813
|
if (!invent) {
|
|
811
814
|
if (!held) return fail('Unknown note.')
|
|
812
|
-
if (held.state === 'burned')
|
|
815
|
+
if (held.state === 'burned') {
|
|
816
|
+
return fail(opts.hashLookup === 'revealsSpent' ? 'Note already spent.' : 'Unknown note.')
|
|
817
|
+
}
|
|
813
818
|
}
|
|
814
819
|
return send({
|
|
815
820
|
tag: 'withdrawRequest',
|
|
@@ -871,18 +876,16 @@ export const createMockMint = async (options = {}) => {
|
|
|
871
876
|
|
|
872
877
|
// The retry branch, before anything is refused for a burned input.
|
|
873
878
|
// This path is a READ: it burns nothing, mints nothing and moves no
|
|
874
|
-
// balance, so it does not go through finish() either. The
|
|
875
|
-
//
|
|
876
|
-
//
|
|
879
|
+
// balance, so it does not go through finish() either. The exact
|
|
880
|
+
// signatures are part of the recorded success, including which
|
|
881
|
+
// published key produced them during a deliberate key rotation.
|
|
877
882
|
if (opts.retriedMutation === 'replay' && !pr) {
|
|
878
883
|
const outputs = swaps.get(swapIdentity(k1s, h, h2, amountRaw))
|
|
879
884
|
if (outputs) {
|
|
880
885
|
const replay = {status: 'OK'}
|
|
881
|
-
|
|
882
|
-
if (first) replay.sig = first
|
|
886
|
+
if (outputs[0].sig) replay.sig = outputs[0].sig
|
|
883
887
|
if (outputs[1]) {
|
|
884
|
-
|
|
885
|
-
if (second) replay.sig2 = second
|
|
888
|
+
if (outputs[1].sig) replay.sig2 = outputs[1].sig
|
|
886
889
|
}
|
|
887
890
|
if (opts.serverGeneratedSecrets) {
|
|
888
891
|
replay.k1 = 'a'.repeat(64)
|
|
@@ -1004,8 +1007,8 @@ export const createMockMint = async (options = {}) => {
|
|
|
1004
1007
|
const sig = mintNote(h, amount)
|
|
1005
1008
|
const sig2 = mintNote(h2, change)
|
|
1006
1009
|
swaps.set(swapIdentity(k1s, h, h2, amountRaw), [
|
|
1007
|
-
{id: h, amountMsat: amount},
|
|
1008
|
-
{id: h2, amountMsat: change}
|
|
1010
|
+
{id: h, amountMsat: amount, sig},
|
|
1011
|
+
{id: h2, amountMsat: change, sig: sig2}
|
|
1009
1012
|
])
|
|
1010
1013
|
const body = {status: 'OK'}
|
|
1011
1014
|
if (sig) body.sig = sig
|
|
@@ -1025,7 +1028,7 @@ export const createMockMint = async (options = {}) => {
|
|
|
1025
1028
|
for (const {note} of found) note.state = 'burned'
|
|
1026
1029
|
const sig = mintNote(h, total + refund)
|
|
1027
1030
|
swaps.set(swapIdentity(k1s, h, h2, amountRaw), [
|
|
1028
|
-
{id: h, amountMsat: total + refund}
|
|
1031
|
+
{id: h, amountMsat: total + refund, sig}
|
|
1029
1032
|
])
|
|
1030
1033
|
const body = {status: 'OK'}
|
|
1031
1034
|
if (sig) body.sig = sig
|
|
@@ -1048,8 +1051,28 @@ export const createMockMint = async (options = {}) => {
|
|
|
1048
1051
|
}
|
|
1049
1052
|
}
|
|
1050
1053
|
|
|
1054
|
+
// Is this file the entry point?
|
|
1055
|
+
//
|
|
1056
|
+
// The obvious `import.meta.url === \`file://${process.argv[1]}\`` is wrong
|
|
1057
|
+
// wherever the path reaches node through a symlink: node resolves a module's
|
|
1058
|
+
// URL to its real path, while argv[1] stays exactly as it was typed. A
|
|
1059
|
+
// checkout symlinked into a sibling workspace - which is how the language
|
|
1060
|
+
// ports pick this repo up locally - then fails the comparison, and the mint
|
|
1061
|
+
// exits silently, printing nothing at all. Every test that waits for the
|
|
1062
|
+
// "listening on" line hangs to EOF and reports the mint as never having
|
|
1063
|
+
// announced itself, which says nothing about the real cause.
|
|
1064
|
+
const isEntryPoint = () => {
|
|
1065
|
+
const entry = process.argv[1]
|
|
1066
|
+
if (!entry) return false
|
|
1067
|
+
try {
|
|
1068
|
+
return import.meta.url === pathToFileURL(realpathSync(entry)).href
|
|
1069
|
+
} catch {
|
|
1070
|
+
return false
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1051
1074
|
// standalone
|
|
1052
|
-
if (
|
|
1075
|
+
if (isEntryPoint()) {
|
|
1053
1076
|
const flags = {}
|
|
1054
1077
|
// A flag's value becomes a number only when it really is one. A 64-hex
|
|
1055
1078
|
// key made of nothing but digits parses as a Number and loses every
|
package/package.json
CHANGED
package/runner/index.mjs
CHANGED
|
@@ -886,6 +886,7 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
886
886
|
// the same answer an unknown `k1` gets, so a mint that never implemented
|
|
887
887
|
// it is indistinguishable from one asked about a note it does not hold.
|
|
888
888
|
// A live note's own hash is therefore the only probe that separates them.
|
|
889
|
+
let hashLookupOffered = false
|
|
889
890
|
await report.check('answers a note lookup by hash without the secret (optional)', async () => {
|
|
890
891
|
const byHash = new URL(url)
|
|
891
892
|
byHash.searchParams.delete('k1')
|
|
@@ -895,6 +896,7 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
895
896
|
if (body.status === 'ERROR' || body.tag !== 'withdrawRequest') {
|
|
896
897
|
return 'not offered - every informational lookup puts the live secret in a query string, where any proxy that logs full URLs keeps it'
|
|
897
898
|
}
|
|
899
|
+
hashLookupOffered = true
|
|
898
900
|
// The whole point of the lookup. A wallet asking by hash already holds
|
|
899
901
|
// the secret - it could not have computed the hash otherwise - so the
|
|
900
902
|
// field buys it nothing, and filling it in puts the note back on the
|
|
@@ -910,16 +912,37 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
910
912
|
// An h it never registered must get the unknown-note answer. One that
|
|
911
913
|
// answers anyway reports a note where none exists, and a wallet
|
|
912
914
|
// restoring from seed reads that as a note it has lost the secret to.
|
|
915
|
+
const unknownSecret = bytesToHex(randomBytes(32))
|
|
913
916
|
const nobody = new URL(url)
|
|
914
917
|
nobody.searchParams.delete('k1')
|
|
915
918
|
nobody.searchParams.delete('amount')
|
|
916
|
-
nobody.searchParams.set('h', noteId(
|
|
919
|
+
nobody.searchParams.set('h', noteId(unknownSecret))
|
|
917
920
|
const invented = await get(nobody)
|
|
921
|
+
const unknownK1 = new URL(url)
|
|
922
|
+
unknownK1.searchParams.set('k1', unknownSecret)
|
|
923
|
+
unknownK1.searchParams.delete('h')
|
|
924
|
+
unknownK1.searchParams.delete('amount')
|
|
925
|
+
const ordinaryUnknown = await get(unknownK1)
|
|
918
926
|
assert(
|
|
919
927
|
invented.status === 'ERROR' || invented.tag !== 'withdrawRequest',
|
|
920
928
|
'answered for a hash it never registered - an unrecognized h must get the same response an unknown k1 would'
|
|
921
929
|
)
|
|
922
|
-
|
|
930
|
+
assert(
|
|
931
|
+
invented.status === ordinaryUnknown.status && invented.reason === ordinaryUnknown.reason,
|
|
932
|
+
`an unknown h answered ${JSON.stringify(invented)} but an unknown k1 answered ${JSON.stringify(ordinaryUnknown)}`
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
// `h` is accepted in place of `k1`, not alongside it. Accepting both
|
|
936
|
+
// lets an intermediary add a second lookup identity and leaves clients
|
|
937
|
+
// unable to know which note the response describes.
|
|
938
|
+
const both = new URL(url)
|
|
939
|
+
both.searchParams.set('h', noteId(k1))
|
|
940
|
+
const ambiguous = await get(both)
|
|
941
|
+
assert(
|
|
942
|
+
ambiguous.status === 'ERROR',
|
|
943
|
+
'accepted both k1 and h on one informational lookup - exactly one lookup identity is allowed'
|
|
944
|
+
)
|
|
945
|
+
return 'by hash, with no k1 in the reply; unknown and mixed lookups refused'
|
|
923
946
|
})
|
|
924
947
|
|
|
925
948
|
// The merge cap. LUD-25 bounds a merge by URL length rather than by the
|
|
@@ -1011,9 +1034,10 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1011
1034
|
return 'burned the old secret, minted the new'
|
|
1012
1035
|
})
|
|
1013
1036
|
|
|
1014
|
-
await report.check('signs the notes it issues
|
|
1015
|
-
|
|
1016
|
-
|
|
1037
|
+
await report.check('signs the notes it issues', async () => {
|
|
1038
|
+
assert(info.mintPubkey, 'no mintPubkey advertised - offline verification is mandatory')
|
|
1039
|
+
assert(isCompressedPubkey(info.mintPubkey), 'mintPubkey is not a 33-byte compressed secp256k1 key')
|
|
1040
|
+
assert(currentSig, 'the rotate returned no sig - offline verification is mandatory')
|
|
1017
1041
|
// A mint that has rotated its signing key publishes the old ones as
|
|
1018
1042
|
// previousPubkeys, so notes it issued before the rotation still
|
|
1019
1043
|
// verify. Any key it currently stands behind is an acceptable signer.
|
|
@@ -1031,6 +1055,28 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1031
1055
|
: `verified offline against a previous signing key (${signedBy.slice(0, 16)}...)`
|
|
1032
1056
|
})
|
|
1033
1057
|
|
|
1058
|
+
await report.check('does not reveal a burned note through hash lookup', async () => {
|
|
1059
|
+
if (!hashLookupOffered) throw soft('hash lookup is not offered')
|
|
1060
|
+
const burned = new URL(url)
|
|
1061
|
+
burned.searchParams.delete('k1')
|
|
1062
|
+
burned.searchParams.delete('amount')
|
|
1063
|
+
burned.searchParams.set('h', noteId(k1))
|
|
1064
|
+
const burnedResponse = await get(burned)
|
|
1065
|
+
|
|
1066
|
+
const unknown = new URL(url)
|
|
1067
|
+
unknown.searchParams.set('k1', bytesToHex(randomBytes(32)))
|
|
1068
|
+
unknown.searchParams.delete('h')
|
|
1069
|
+
unknown.searchParams.delete('amount')
|
|
1070
|
+
const unknownResponse = await get(unknown)
|
|
1071
|
+
|
|
1072
|
+
assert(
|
|
1073
|
+
burnedResponse.status === unknownResponse.status &&
|
|
1074
|
+
burnedResponse.reason === unknownResponse.reason,
|
|
1075
|
+
`a burned h answered ${JSON.stringify(burnedResponse)} but an unknown k1 answered ${JSON.stringify(unknownResponse)}`
|
|
1076
|
+
)
|
|
1077
|
+
return 'burned h is indistinguishable from an unknown note'
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1034
1080
|
await report.check('keeps signatures off the informational endpoint', async () => {
|
|
1035
1081
|
// LUD-25: "Signatures are only ever delivered in the
|
|
1036
1082
|
// withdrawSuccessResponse of a rotate, split or merge, the
|
|
@@ -1260,11 +1306,9 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1260
1306
|
// discards the only copy of a secret the SERVICE really did mint a note
|
|
1261
1307
|
// against. Nobody is told; the money is simply gone.
|
|
1262
1308
|
//
|
|
1263
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
|
|
1266
|
-
// fails outright whichever answer it gives.
|
|
1267
|
-
await report.check('replays a retried mutation rather than refusing it (optional)', async () => {
|
|
1309
|
+
// This is a MUST. A retry must replay the original success and must not
|
|
1310
|
+
// burn or alter either output.
|
|
1311
|
+
await report.check('replays a retried mutation rather than refusing it', async () => {
|
|
1268
1312
|
const valueAt = async secret => {
|
|
1269
1313
|
const u = new URL(url)
|
|
1270
1314
|
u.searchParams.set('k1', secret)
|
|
@@ -1295,22 +1339,18 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1295
1339
|
`the retried rotate changed the note's value: ${minted} -> ${stillThere}`
|
|
1296
1340
|
)
|
|
1297
1341
|
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
} else if (first.sig && retried.sig !== first.sig) {
|
|
1304
|
-
problems.push('a retried rotate replied OK but with a different sig than the original')
|
|
1305
|
-
}
|
|
1342
|
+
assert(
|
|
1343
|
+
retried.status === 'OK',
|
|
1344
|
+
`a retried rotate is answered "${retried.reason}" while the note it minted is live and worth ${stillThere} msat`
|
|
1345
|
+
)
|
|
1346
|
+
assert(retried.sig === first.sig, 'a retried rotate returned a different sig than the original')
|
|
1306
1347
|
|
|
1307
1348
|
// --- a split, retried ---
|
|
1308
1349
|
// h2 and the change amount are part of what makes a request the same
|
|
1309
1350
|
// request, so a rotate on its own does not cover it.
|
|
1310
1351
|
const half = Math.floor(minted / 2)
|
|
1311
1352
|
if (half < 1 || (knownBaseFee !== null && minted - half < knownBaseFee + 1)) {
|
|
1312
|
-
|
|
1313
|
-
return 'a byte-identical rotate replays; note too small to retry a split'
|
|
1353
|
+
throw soft('a byte-identical rotate replays; note too small to exercise a split retry')
|
|
1314
1354
|
}
|
|
1315
1355
|
const a = bytesToHex(randomBytes(32))
|
|
1316
1356
|
const b = bytesToHex(randomBytes(32))
|
|
@@ -1321,8 +1361,7 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1321
1361
|
split.searchParams.append('h2', noteId(b))
|
|
1322
1362
|
const splitFirst = await get(split)
|
|
1323
1363
|
if (splitFirst.status !== 'OK') {
|
|
1324
|
-
|
|
1325
|
-
throw soft(`a byte-identical rotate replays; the split itself was refused: ${splitFirst.reason}`)
|
|
1364
|
+
throw new Error(`the split itself was refused: ${splitFirst.reason}`)
|
|
1326
1365
|
}
|
|
1327
1366
|
const [va, vb] = [await valueAt(a), await valueAt(b)]
|
|
1328
1367
|
const splitRetried = await get(split)
|
|
@@ -1331,11 +1370,12 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1331
1370
|
va2 === va && vb2 === vb,
|
|
1332
1371
|
`the retried split changed its outputs: ${va}/${vb} -> ${va2}/${vb2}`
|
|
1333
1372
|
)
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1373
|
+
assert(
|
|
1374
|
+
splitRetried.status === 'OK',
|
|
1375
|
+
`a retried split is answered "${splitRetried.reason}" while both its outputs are live`
|
|
1376
|
+
)
|
|
1377
|
+
assert(splitRetried.sig === splitFirst.sig, 'a retried split returned a different sig than the original')
|
|
1378
|
+
assert(splitRetried.sig2 === splitFirst.sig2, 'a retried split returned a different sig2 than the original')
|
|
1339
1379
|
|
|
1340
1380
|
// put the two halves back together, so the runner ends holding one note
|
|
1341
1381
|
const merged = bytesToHex(randomBytes(32))
|
|
@@ -1349,7 +1389,6 @@ export const gradeNote = async (noteUrl, report, options = {}) => {
|
|
|
1349
1389
|
currentSig = mergeBody.sig ?? null
|
|
1350
1390
|
}
|
|
1351
1391
|
|
|
1352
|
-
if (problems.length > 0) throw soft(problems.join('; '))
|
|
1353
1392
|
return 'a byte-identical rotate and split both replay the original success'
|
|
1354
1393
|
})
|
|
1355
1394
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"spec": "LUD-25 draft (lnurl/luds#301)",
|
|
4
|
+
"description": "LUD-25's specified seed-recoverable note secrets, the BIP-32 scheme under m/139'. cashRoot is m/139' as privateKey||chainCode hex (64 bytes, no version/depth/fingerprint framing). domainIndices are the four raw uint32 read big-endian from the first 16 bytes of HMAC-SHA256(key = the private key at m/139'/0, msg = utf8(host)); they are used as BIP-32 child indices exactly as they fall, so `hardened` records which of them land >= 2^31 by magnitude - an implementation that masks the top bit or hardens all four derives a different tree and will restore nothing. domainNode is m/139'/d1/d2/d3/d4. k1 is the private key at the hardened child `index` of that node, 32 bytes lowercase hex, and the mint sees only sha256(k1) as ever. host is the mint host exactly as the wallet stores it, with the port when there is one. seedHex is the 64-byte BIP39 seed with no passphrase. bip32Vector1 is BIP-32's own published test vector 1, so CKDpriv itself can be checked first.",
|
|
5
|
+
"scheme": {
|
|
6
|
+
"purpose": "m/139'",
|
|
7
|
+
"hashingKey": "m/139'/0",
|
|
8
|
+
"domainMsg": "host",
|
|
9
|
+
"secretPath": "m/139'/d1/d2/d3/d4/i'",
|
|
10
|
+
"hardenedByMagnitudeOnly": true
|
|
11
|
+
},
|
|
12
|
+
"bip32Vector1": [
|
|
13
|
+
{
|
|
14
|
+
"index": null,
|
|
15
|
+
"node": "e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"index": 2147483648,
|
|
19
|
+
"hardened": true,
|
|
20
|
+
"node": "edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"index": 1,
|
|
24
|
+
"hardened": false,
|
|
25
|
+
"node": "3c6cb8d0f6a264c91ea8b5030fadaa8e538b020f0a387421a12de9319dc933682a7857631386ba23dacac34180dd1983734e444fdbf774041578e9b6adb37c19"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"index": 2147483650,
|
|
29
|
+
"hardened": true,
|
|
30
|
+
"node": "cbce0d719ecf7431d88e6a89fa1483e02e35092af60c042b1df2ff59fa424dca04466b9cc8e161e966409ca52986c584f07e9dc81f735db683c3ff6ec7b1503f"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"index": 2,
|
|
34
|
+
"hardened": false,
|
|
35
|
+
"node": "0f479245fb19a38a1954c5c7c0ebab2f9bdfd96a17563ef28a6a4b1a2a764ef4cfb71883f01676f587d023cc53a35bc7f88f724b1f8c2892ac1275ac822a3edd"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"index": 1000000000,
|
|
39
|
+
"hardened": false,
|
|
40
|
+
"node": "471b76e389e528d6de6d816857e012c5455051cad6660850e58372a6c3e6e7c8c783e67b921d2beb8f6b389cc646d7263b4145701dadd2161548a8b078e65e9e"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"cases": [
|
|
44
|
+
{
|
|
45
|
+
"name": "standard mnemonic, index 0",
|
|
46
|
+
"mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
|
47
|
+
"seedHex": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
|
|
48
|
+
"host": "mint.example",
|
|
49
|
+
"index": 0,
|
|
50
|
+
"cashRoot": "c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60",
|
|
51
|
+
"domainIndices": [
|
|
52
|
+
2589708612,
|
|
53
|
+
3693348916,
|
|
54
|
+
172082394,
|
|
55
|
+
3793182078
|
|
56
|
+
],
|
|
57
|
+
"hardened": [
|
|
58
|
+
true,
|
|
59
|
+
true,
|
|
60
|
+
false,
|
|
61
|
+
true
|
|
62
|
+
],
|
|
63
|
+
"domainNode": "72056e5cde21458b13689c3950904dfd327415a506d064290e5e5f4296a40543dd5e9504ddb6eefbafa4ad3b00ad421858fafc0ed9ea4abd9cb68793f845cfc1",
|
|
64
|
+
"k1": "de5b81405a12e1297b350d80e2ad85043ed5b9436a0c5592d3302778de330499",
|
|
65
|
+
"noteId": "7db9da2845cd45c1c3c2e302d6135da46823e245f756b830ef59ac324b769e02"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "standard mnemonic, index 1",
|
|
69
|
+
"mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
|
70
|
+
"seedHex": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
|
|
71
|
+
"host": "mint.example",
|
|
72
|
+
"index": 1,
|
|
73
|
+
"cashRoot": "c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60",
|
|
74
|
+
"domainIndices": [
|
|
75
|
+
2589708612,
|
|
76
|
+
3693348916,
|
|
77
|
+
172082394,
|
|
78
|
+
3793182078
|
|
79
|
+
],
|
|
80
|
+
"hardened": [
|
|
81
|
+
true,
|
|
82
|
+
true,
|
|
83
|
+
false,
|
|
84
|
+
true
|
|
85
|
+
],
|
|
86
|
+
"domainNode": "72056e5cde21458b13689c3950904dfd327415a506d064290e5e5f4296a40543dd5e9504ddb6eefbafa4ad3b00ad421858fafc0ed9ea4abd9cb68793f845cfc1",
|
|
87
|
+
"k1": "267570df5ba8098d728e839a698f729c2df6fa7b8b7ae7c9c7ffa7dda3417e1d",
|
|
88
|
+
"noteId": "99b98cb845940e0d6401e176aaca1446365aa315201803861e17f3c530ed1dcf"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"name": "standard mnemonic, index 20 (one past a 20-index gap limit)",
|
|
92
|
+
"mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
|
93
|
+
"seedHex": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
|
|
94
|
+
"host": "mint.example",
|
|
95
|
+
"index": 20,
|
|
96
|
+
"cashRoot": "c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60",
|
|
97
|
+
"domainIndices": [
|
|
98
|
+
2589708612,
|
|
99
|
+
3693348916,
|
|
100
|
+
172082394,
|
|
101
|
+
3793182078
|
|
102
|
+
],
|
|
103
|
+
"hardened": [
|
|
104
|
+
true,
|
|
105
|
+
true,
|
|
106
|
+
false,
|
|
107
|
+
true
|
|
108
|
+
],
|
|
109
|
+
"domainNode": "72056e5cde21458b13689c3950904dfd327415a506d064290e5e5f4296a40543dd5e9504ddb6eefbafa4ad3b00ad421858fafc0ed9ea4abd9cb68793f845cfc1",
|
|
110
|
+
"k1": "1f2b8080d4c9431b1dec780abb4eee3c4aaf7ff03f7dd126a7e005ec7af372e0",
|
|
111
|
+
"noteId": "0daebdd7f9355a215c511cadc96e73f586e07238c352b43b21c080713b79cbfc"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "a second mnemonic, same host and index",
|
|
115
|
+
"mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow",
|
|
116
|
+
"seedHex": "878386efb78845b3355bd15ea4d39ef97d179cb712b77d5c12b6be415fffeffe5f377ba02bf3f8544ab800b955e51fbff09828f682052a20faa6addbbddfb096",
|
|
117
|
+
"host": "mint.example",
|
|
118
|
+
"index": 0,
|
|
119
|
+
"cashRoot": "e58252908e0c10965d15d1e3894cbf206a2102c20eb9519e466e2e0e852075d430afaa22bb69ef2151cdffacc463e51068e854152b0b7c08fa2f93e6b8d16741",
|
|
120
|
+
"domainIndices": [
|
|
121
|
+
3862277158,
|
|
122
|
+
545631060,
|
|
123
|
+
182776155,
|
|
124
|
+
2195829862
|
|
125
|
+
],
|
|
126
|
+
"hardened": [
|
|
127
|
+
true,
|
|
128
|
+
false,
|
|
129
|
+
false,
|
|
130
|
+
true
|
|
131
|
+
],
|
|
132
|
+
"domainNode": "df3c9156afc2e416286df9bbfda49c6ab72305ba3d65ae0b7e2214caf581b111959d96bc0c075185ff7304214496a78f43b22ea511f97a7bf408454a7e3d3a85",
|
|
133
|
+
"k1": "d5376fa38f01b39d55be6dc0ea8f3aa6b91f37c183c5a394bb7e30b624855a78",
|
|
134
|
+
"noteId": "5b4ba51285373e8a8c0414e6b3607ea0a6b70968c94f2b9f73ffdfdedfba1c15"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"name": "a host carrying a port",
|
|
138
|
+
"mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
|
139
|
+
"seedHex": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
|
|
140
|
+
"host": "127.0.0.1:8899",
|
|
141
|
+
"index": 0,
|
|
142
|
+
"cashRoot": "c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60",
|
|
143
|
+
"domainIndices": [
|
|
144
|
+
2087962263,
|
|
145
|
+
3073061246,
|
|
146
|
+
2281736429,
|
|
147
|
+
1205328740
|
|
148
|
+
],
|
|
149
|
+
"hardened": [
|
|
150
|
+
false,
|
|
151
|
+
true,
|
|
152
|
+
true,
|
|
153
|
+
false
|
|
154
|
+
],
|
|
155
|
+
"domainNode": "80bdd2d71f0235bd9e22d5838a4a4f346cf5415309ada559942a409a630e102ce214425b89b36d03d4835fdf6592d2619c74d37b746711b4ed85759ea1d358a6",
|
|
156
|
+
"k1": "d7bcc5c9e7015ca2688ed10e24db3f82163d5b59fc887e5dd346abf2426b1270",
|
|
157
|
+
"noteId": "1a8a732d7ec9811654183ecbb27b1887dec947a42c877a12bd93f952837a3887"
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
}
|
package/vectors/index.json
CHANGED
package/vectors/lifecycle.json
CHANGED
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"the connection drops after the SERVICE applied it",
|
|
59
59
|
"the HTTP stack silently resends the identical request"
|
|
60
60
|
],
|
|
61
|
-
"requirement": "a
|
|
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."
|
|
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. `sig`
|
|
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 the offline-verification signature in `sig` (and `sig2` for the second split output).",
|
|
5
5
|
"parse": [
|
|
6
6
|
{
|
|
7
7
|
"url": "https://mint.example/w?k1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&amount=21000",
|
package/vectors/responses.json
CHANGED
|
@@ -1,9 +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.",
|
|
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 carries no signature. LUD-25 requires one on every rotate, split and merge, so this SERVICE is non-conforming - but the note EXISTS at the hash the WALLET disclosed, and its secret is the only key to that value. Keep the secret; report the mint",
|
|
7
8
|
"pending": "this k1 has another operation in flight (a melt); retry shortly",
|
|
8
9
|
"spent": "the SERVICE is authoritative that the note is already burned; a holder may lock it as spent",
|
|
9
10
|
"unknown": "the SERVICE does not recognise this note; surface it, do not silently lock it",
|
|
@@ -13,14 +14,17 @@
|
|
|
13
14
|
"cases": [
|
|
14
15
|
{
|
|
15
16
|
"name": "plain success",
|
|
17
|
+
"op": "mutation",
|
|
16
18
|
"http": 200,
|
|
17
19
|
"body": {
|
|
18
20
|
"status": "OK"
|
|
19
21
|
},
|
|
20
|
-
"expect": "
|
|
22
|
+
"expect": "unverifiable",
|
|
23
|
+
"why": "a bare OK was a conforming rotate answer while offline verification was optional. It is not one now: the note is real and the WALLET must keep its secret, but nobody the holder hands it to can check it"
|
|
21
24
|
},
|
|
22
25
|
{
|
|
23
26
|
"name": "success with an offline-verification signature",
|
|
27
|
+
"op": "mutation",
|
|
24
28
|
"http": 200,
|
|
25
29
|
"body": {
|
|
26
30
|
"status": "OK",
|
|
@@ -31,6 +35,7 @@
|
|
|
31
35
|
},
|
|
32
36
|
{
|
|
33
37
|
"name": "split success with both signatures",
|
|
38
|
+
"op": "split",
|
|
34
39
|
"http": 200,
|
|
35
40
|
"body": {
|
|
36
41
|
"status": "OK",
|
|
@@ -41,8 +46,20 @@
|
|
|
41
46
|
"signature": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
|
|
42
47
|
"changeSignature": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"
|
|
43
48
|
},
|
|
49
|
+
{
|
|
50
|
+
"name": "a split that signs only its first output",
|
|
51
|
+
"op": "split",
|
|
52
|
+
"http": 200,
|
|
53
|
+
"body": {
|
|
54
|
+
"status": "OK",
|
|
55
|
+
"sig": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab"
|
|
56
|
+
},
|
|
57
|
+
"expect": "unverifiable",
|
|
58
|
+
"why": "both outputs of a split are notes and both need a signature; the change is not a lesser note"
|
|
59
|
+
},
|
|
44
60
|
{
|
|
45
61
|
"name": "melt success with a LUD-21 style proof",
|
|
62
|
+
"op": "melt",
|
|
46
63
|
"http": 200,
|
|
47
64
|
"body": {
|
|
48
65
|
"status": "OK",
|
|
@@ -50,10 +67,11 @@
|
|
|
50
67
|
"verify": "https://mint.example/verify/abc"
|
|
51
68
|
},
|
|
52
69
|
"expect": "ok",
|
|
53
|
-
"why": "OK on a melt means the payment is in flight, NOT that the note is confirmed spent"
|
|
70
|
+
"why": "OK on a melt means the payment is in flight, NOT that the note is confirmed spent. A melt mints nothing, so it has no signature to return and none is required"
|
|
54
71
|
},
|
|
55
72
|
{
|
|
56
73
|
"name": "pending",
|
|
74
|
+
"op": "mutation",
|
|
57
75
|
"http": 200,
|
|
58
76
|
"body": {
|
|
59
77
|
"status": "ERROR",
|
|
@@ -64,6 +82,7 @@
|
|
|
64
82
|
},
|
|
65
83
|
{
|
|
66
84
|
"name": "already spent",
|
|
85
|
+
"op": "mutation",
|
|
67
86
|
"http": 200,
|
|
68
87
|
"body": {
|
|
69
88
|
"status": "ERROR",
|
|
@@ -73,6 +92,7 @@
|
|
|
73
92
|
},
|
|
74
93
|
{
|
|
75
94
|
"name": "unknown note",
|
|
95
|
+
"op": "mutation",
|
|
76
96
|
"http": 200,
|
|
77
97
|
"body": {
|
|
78
98
|
"status": "ERROR",
|
|
@@ -82,6 +102,7 @@
|
|
|
82
102
|
},
|
|
83
103
|
{
|
|
84
104
|
"name": "not found wording",
|
|
105
|
+
"op": "mutation",
|
|
85
106
|
"http": 200,
|
|
86
107
|
"body": {
|
|
87
108
|
"status": "ERROR",
|
|
@@ -91,6 +112,7 @@
|
|
|
91
112
|
},
|
|
92
113
|
{
|
|
93
114
|
"name": "ambiguous callback wording is treated as spent",
|
|
115
|
+
"op": "mutation",
|
|
94
116
|
"http": 200,
|
|
95
117
|
"body": {
|
|
96
118
|
"status": "ERROR",
|
|
@@ -101,6 +123,7 @@
|
|
|
101
123
|
},
|
|
102
124
|
{
|
|
103
125
|
"name": "some other refusal",
|
|
126
|
+
"op": "mutation",
|
|
104
127
|
"http": 200,
|
|
105
128
|
"body": {
|
|
106
129
|
"status": "ERROR",
|
|
@@ -110,6 +133,7 @@
|
|
|
110
133
|
},
|
|
111
134
|
{
|
|
112
135
|
"name": "error with no reason",
|
|
136
|
+
"op": "mutation",
|
|
113
137
|
"http": 200,
|
|
114
138
|
"body": {
|
|
115
139
|
"status": "ERROR"
|
|
@@ -118,6 +142,7 @@
|
|
|
118
142
|
},
|
|
119
143
|
{
|
|
120
144
|
"name": "200 with neither OK nor ERROR",
|
|
145
|
+
"op": "mutation",
|
|
121
146
|
"http": 200,
|
|
122
147
|
"body": {
|
|
123
148
|
"something": "else"
|
|
@@ -127,23 +152,27 @@
|
|
|
127
152
|
},
|
|
128
153
|
{
|
|
129
154
|
"name": "unparseable body",
|
|
155
|
+
"op": "mutation",
|
|
130
156
|
"http": 200,
|
|
131
157
|
"bodyRaw": "not json at all",
|
|
132
158
|
"expect": "ambiguous"
|
|
133
159
|
},
|
|
134
160
|
{
|
|
135
161
|
"name": "server error",
|
|
162
|
+
"op": "mutation",
|
|
136
163
|
"http": 500,
|
|
137
164
|
"bodyRaw": "upstream failure",
|
|
138
165
|
"expect": "ambiguous"
|
|
139
166
|
},
|
|
140
167
|
{
|
|
141
168
|
"name": "transport failure",
|
|
169
|
+
"op": "mutation",
|
|
142
170
|
"transportError": true,
|
|
143
171
|
"expect": "ambiguous"
|
|
144
172
|
},
|
|
145
173
|
{
|
|
146
174
|
"name": "timeout",
|
|
175
|
+
"op": "mutation",
|
|
147
176
|
"timeout": true,
|
|
148
177
|
"expect": "ambiguous"
|
|
149
178
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
3
|
"spec": "LUD-25 draft (lnurl/luds#301)",
|
|
4
|
-
"description": "What counts as a retry of a mutation, and what is still a double-spend attempt. Every mutation in LUD-25 is a GET, and HTTP stacks retry a GET when the connection they used is dropped, so a SERVICE sees the byte-identical request twice and the second one arrives with its inputs already burned. A SERVICE that answers the retry as an already-spent input tells the holder the mutation never happened, and the holder discards the only copy of a secret the SERVICE really did mint a note against. Replaying the original success
|
|
4
|
+
"description": "What counts as a retry of a mutation, and what is still a double-spend attempt. Every mutation in LUD-25 is a GET, and HTTP stacks retry a GET when the connection they used is dropped, so a SERVICE sees the byte-identical request twice and the second one arrives with its inputs already burned. A SERVICE that answers the retry as an already-spent input tells the holder the mutation never happened, and the holder discards the only copy of a secret the SERVICE really did mint a note against. Replaying the original success is therefore a MUST. The replay path is a read: it burns nothing, mints nothing and moves no balance, and returns the same signature or signatures. Anything that is NOT a retry and names a burned input is refused exactly as an ordinary double-spend is, with the same reason string, so no oracle appears for whoever is holding a burned secret.",
|
|
5
5
|
"identity": [
|
|
6
6
|
"the same input k1 set, compared as a SET: a merge naming the same notes in a different order is the same merge",
|
|
7
7
|
"the same h",
|
package/vectors/signature.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
3
|
"spec": "LUD-25 draft (lnurl/luds#301)",
|
|
4
|
-
"description": "Offline verification of a note (LUD-25). A SERVICE signs (note id, amount) with
|
|
4
|
+
"description": "Offline verification of a note (LUD-25). A SERVICE signs (note id, amount) with a stable secp256k1 key it controls; this should be the funding node identity where compatible signing exists, or may be a dedicated persistent SERVICE key otherwise. A holder recovers the pubkey and compares it to the pinned current or previous mintPubkey without contacting the SERVICE.",
|
|
5
5
|
"scheme": {
|
|
6
6
|
"domainTag": "LNURLcash",
|
|
7
7
|
"lightningSignedMessagePrefix": "Lightning Signed Message:",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
3
|
"spec": "LUD-03, LUD-25",
|
|
4
|
-
"description": "The informational GET on a note. Never burns, rotates or alters it. maxWithdrawable is the ONLY authoritative statement of what a note is worth - the URL's own `amount` is a claim and the SERVICE ignores it here. The response's k1 MUST be the bearer secret itself, never a derived or opaque id.",
|
|
4
|
+
"description": "The informational GET on a note. Never burns, rotates or alters it. maxWithdrawable is the ONLY authoritative statement of what a note is worth - the URL's own `amount` is a claim and the SERVICE ignores it here. The response's k1 MUST be the bearer secret itself, never a derived or opaque id. Offline verification is mandatory in the current draft, so the response MUST also carry `mintPubkey`, the stable compressed secp256k1 key the SERVICE's note signatures verify against; without it a holder handed a note has nothing to check it against.",
|
|
5
5
|
"queriedUrl": "https://mint.example/w?k1=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&amount=99999&sig=ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
|
|
6
6
|
"requestMustNotSend": [
|
|
7
7
|
"sig"
|
|
@@ -17,42 +17,45 @@
|
|
|
17
17
|
"callback": "https://mint.example/w/cb",
|
|
18
18
|
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
19
19
|
"minWithdrawable": 0,
|
|
20
|
-
"maxWithdrawable": 21000
|
|
20
|
+
"maxWithdrawable": 21000,
|
|
21
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
21
22
|
},
|
|
22
23
|
"maxWithdrawable": 21000
|
|
23
24
|
},
|
|
24
25
|
{
|
|
25
|
-
"name": "
|
|
26
|
+
"name": "minWithdrawable omitted",
|
|
26
27
|
"body": {
|
|
27
28
|
"tag": "withdrawRequest",
|
|
28
29
|
"callback": "https://mint.example/w/cb",
|
|
29
30
|
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
30
|
-
"minWithdrawable": 0,
|
|
31
31
|
"maxWithdrawable": 21000,
|
|
32
32
|
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
33
33
|
},
|
|
34
34
|
"maxWithdrawable": 21000
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
|
-
"name": "
|
|
37
|
+
"name": "echoed k1 in a different casing",
|
|
38
38
|
"body": {
|
|
39
39
|
"tag": "withdrawRequest",
|
|
40
40
|
"callback": "https://mint.example/w/cb",
|
|
41
|
-
"k1": "
|
|
42
|
-
"maxWithdrawable": 21000
|
|
41
|
+
"k1": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
42
|
+
"maxWithdrawable": 21000,
|
|
43
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
43
44
|
},
|
|
44
|
-
"maxWithdrawable": 21000
|
|
45
|
+
"maxWithdrawable": 21000,
|
|
46
|
+
"why": "k1 is bytes; casing carries no meaning"
|
|
45
47
|
},
|
|
46
48
|
{
|
|
47
|
-
"name": "
|
|
49
|
+
"name": "mintPubkey in a different casing",
|
|
48
50
|
"body": {
|
|
49
51
|
"tag": "withdrawRequest",
|
|
50
52
|
"callback": "https://mint.example/w/cb",
|
|
51
|
-
"k1": "
|
|
52
|
-
"maxWithdrawable": 21000
|
|
53
|
+
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
54
|
+
"maxWithdrawable": 21000,
|
|
55
|
+
"mintPubkey": "034F355BDCB7CC0AF728EF3CCEB9615D90684BB5B2CA5F859AB0F0B704075871AA"
|
|
53
56
|
},
|
|
54
57
|
"maxWithdrawable": 21000,
|
|
55
|
-
"why": "
|
|
58
|
+
"why": "a pubkey is bytes too, and a SERVICE that upper-cases its hex is still naming the same key"
|
|
56
59
|
},
|
|
57
60
|
{
|
|
58
61
|
"name": "zero value note",
|
|
@@ -60,7 +63,8 @@
|
|
|
60
63
|
"tag": "withdrawRequest",
|
|
61
64
|
"callback": "https://mint.example/w/cb",
|
|
62
65
|
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
63
|
-
"maxWithdrawable": 0
|
|
66
|
+
"maxWithdrawable": 0,
|
|
67
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
64
68
|
},
|
|
65
69
|
"maxWithdrawable": 0
|
|
66
70
|
}
|
|
@@ -72,7 +76,8 @@
|
|
|
72
76
|
"tag": "payRequest",
|
|
73
77
|
"callback": "https://mint.example/w/cb",
|
|
74
78
|
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
75
|
-
"maxWithdrawable": 21000
|
|
79
|
+
"maxWithdrawable": 21000,
|
|
80
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
76
81
|
}
|
|
77
82
|
},
|
|
78
83
|
{
|
|
@@ -80,7 +85,8 @@
|
|
|
80
85
|
"body": {
|
|
81
86
|
"tag": "withdrawRequest",
|
|
82
87
|
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
83
|
-
"maxWithdrawable": 21000
|
|
88
|
+
"maxWithdrawable": 21000,
|
|
89
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
84
90
|
}
|
|
85
91
|
},
|
|
86
92
|
{
|
|
@@ -88,7 +94,8 @@
|
|
|
88
94
|
"body": {
|
|
89
95
|
"tag": "withdrawRequest",
|
|
90
96
|
"callback": "https://mint.example/w/cb",
|
|
91
|
-
"maxWithdrawable": 21000
|
|
97
|
+
"maxWithdrawable": 21000,
|
|
98
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
92
99
|
}
|
|
93
100
|
},
|
|
94
101
|
{
|
|
@@ -96,7 +103,8 @@
|
|
|
96
103
|
"body": {
|
|
97
104
|
"tag": "withdrawRequest",
|
|
98
105
|
"callback": "https://mint.example/w/cb",
|
|
99
|
-
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
106
|
+
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
107
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
100
108
|
}
|
|
101
109
|
},
|
|
102
110
|
{
|
|
@@ -105,7 +113,8 @@
|
|
|
105
113
|
"tag": "withdrawRequest",
|
|
106
114
|
"callback": "https://mint.example/w/cb",
|
|
107
115
|
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
108
|
-
"maxWithdrawable": "21000"
|
|
116
|
+
"maxWithdrawable": "21000",
|
|
117
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
109
118
|
}
|
|
110
119
|
},
|
|
111
120
|
{
|
|
@@ -114,7 +123,8 @@
|
|
|
114
123
|
"tag": "withdrawRequest",
|
|
115
124
|
"callback": "https://mint.example/w/cb",
|
|
116
125
|
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
117
|
-
"maxWithdrawable": -1
|
|
126
|
+
"maxWithdrawable": -1,
|
|
127
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
118
128
|
}
|
|
119
129
|
},
|
|
120
130
|
{
|
|
@@ -123,7 +133,8 @@
|
|
|
123
133
|
"tag": "withdrawRequest",
|
|
124
134
|
"callback": "https://mint.example/w/cb",
|
|
125
135
|
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
126
|
-
"maxWithdrawable": 21000.5
|
|
136
|
+
"maxWithdrawable": 21000.5,
|
|
137
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
127
138
|
},
|
|
128
139
|
"why": "msat are integers"
|
|
129
140
|
},
|
|
@@ -133,7 +144,8 @@
|
|
|
133
144
|
"tag": "withdrawRequest",
|
|
134
145
|
"callback": "https://mint.example/w/cb",
|
|
135
146
|
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
136
|
-
"maxWithdrawable": 18446744073709552000
|
|
147
|
+
"maxWithdrawable": 18446744073709552000,
|
|
148
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
137
149
|
},
|
|
138
150
|
"why": "no common integer type holds it and a double rounds it - refuse rather than guess the amount"
|
|
139
151
|
},
|
|
@@ -144,7 +156,8 @@
|
|
|
144
156
|
"callback": "https://mint.example/w/cb",
|
|
145
157
|
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
146
158
|
"minWithdrawable": 30000,
|
|
147
|
-
"maxWithdrawable": 21000
|
|
159
|
+
"maxWithdrawable": 21000,
|
|
160
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
148
161
|
}
|
|
149
162
|
},
|
|
150
163
|
{
|
|
@@ -153,9 +166,32 @@
|
|
|
153
166
|
"tag": "withdrawRequest",
|
|
154
167
|
"callback": "https://mint.example/w/cb",
|
|
155
168
|
"k1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
156
|
-
"maxWithdrawable": 21000
|
|
169
|
+
"maxWithdrawable": 21000,
|
|
170
|
+
"mintPubkey": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
157
171
|
},
|
|
158
172
|
"why": "spec MUST: the response k1 is the bearer secret itself. A SERVICE returning something else is non-compliant, or the note was rotated by someone else"
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"name": "no mintPubkey",
|
|
176
|
+
"body": {
|
|
177
|
+
"tag": "withdrawRequest",
|
|
178
|
+
"callback": "https://mint.example/w/cb",
|
|
179
|
+
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
180
|
+
"minWithdrawable": 0,
|
|
181
|
+
"maxWithdrawable": 21000
|
|
182
|
+
},
|
|
183
|
+
"why": "offline verification is mandatory. Without the key a note signature verifies against nothing, and whoever is handed the note is back to the leap of faith the signature exists to remove"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"name": "mintPubkey that is not a compressed secp256k1 key",
|
|
187
|
+
"body": {
|
|
188
|
+
"tag": "withdrawRequest",
|
|
189
|
+
"callback": "https://mint.example/w/cb",
|
|
190
|
+
"k1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
191
|
+
"maxWithdrawable": 21000,
|
|
192
|
+
"mintPubkey": "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
|
|
193
|
+
},
|
|
194
|
+
"why": "an x-only key, a node URI or a truncated hex string verifies nothing. Refused at the response rather than at the first signature check, where the same fault would look like a forged note"
|
|
159
195
|
}
|
|
160
196
|
]
|
|
161
197
|
}
|