lnurlcash-kit 0.6.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 +129 -0
- package/README.md +140 -46
- package/dist/index.d.ts +37 -2
- package/dist/index.js +268 -57
- package/llms.txt +53 -13
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,135 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.8.0 - 2026-09-04
|
|
6
|
+
|
|
7
|
+
**LUD-25's own derivation, and it is now the one to mint under.** The draft's
|
|
8
|
+
"Seed-recoverable note secrets" section specifies a BIP-32 scheme under
|
|
9
|
+
`m/139'`, and the reference wallet implements it. This kit had shipped its own
|
|
10
|
+
HMAC scheme four days before that section existed. One convention is the whole
|
|
11
|
+
point of writing either of them down, so the specified one wins.
|
|
12
|
+
|
|
13
|
+
- `deriveCashRoot(seed)`, `deriveCashDomainNode(root, host)`,
|
|
14
|
+
`deriveCashSecret(root, host, index)`, `cashSecretAt(domainNode, index)`,
|
|
15
|
+
`cashSecretSource(root, host, start)`, `cashNodeToHex` / `cashNodeFromHex`
|
|
16
|
+
and `deriveCashChild` in a new `cash.ts`. Additive - nothing existing
|
|
17
|
+
changes shape.
|
|
18
|
+
- The scheme, in full, so this entry alone is enough to reimplement it:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
cashHashingKey = m/139'/0
|
|
22
|
+
(d1, d2, d3, d4) = HMAC-SHA256(key = cashHashingKey, msg = utf8(host))[0..16]
|
|
23
|
+
as 4 big-endian uint32
|
|
24
|
+
k1_i = m/139'/d1/d2/d3/d4/i'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`d1..d4` are used **exactly as they fall**. BIP-32 already reads any index
|
|
28
|
+
`>= 2^31` as hardened, so which of the four levels are hardened is decided
|
|
29
|
+
by the mint's host name and roughly half of them will be. Masking the top
|
|
30
|
+
bit, or hardening all four, derives a different tree and restores nothing,
|
|
31
|
+
silently. Only `i` is always hardened. `host` is what `serverOf` produces -
|
|
32
|
+
lowercase, port included - byte-identical to the reference wallet's.
|
|
33
|
+
|
|
34
|
+
Worked example. The BIP39 mnemonic `abandon abandon abandon abandon abandon
|
|
35
|
+
abandon abandon abandon abandon abandon abandon about` with an empty
|
|
36
|
+
passphrase gives `m/139'` as
|
|
37
|
+
`c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60`
|
|
38
|
+
(privateKey || chainCode). At `mint.example` the four levels are
|
|
39
|
+
`[2589708612, 3693348916, 172082394, 3793182078]`, of which the third is
|
|
40
|
+
the only unhardened one, and index 0 is
|
|
41
|
+
`de5b81405a12e1297b350d80e2ad85043ed5b9436a0c5592d3302778de330499`.
|
|
42
|
+
- **The hardware-signer path.** Every unhardened level sits at or above the
|
|
43
|
+
per-mint node, so a signer provisioned with `deriveCashDomainNode`'s output
|
|
44
|
+
rather than the seed needs no elliptic curve at all: each `i'` beneath it is
|
|
45
|
+
HMAC-SHA512 and one modular addition. Whoever derives that node can derive
|
|
46
|
+
every note secret the wallet will hold at that mint, so it is provisioning
|
|
47
|
+
material - one mint's subtree, not the wallet.
|
|
48
|
+
- BIP-32 is implemented here from its own primitives rather than pulled in as
|
|
49
|
+
a dependency, and tested against BIP-32's published test vector 1. Every
|
|
50
|
+
LUD-25 value above is checked against output from the reference
|
|
51
|
+
implementation's own library.
|
|
52
|
+
|
|
53
|
+
**`restoreFromSeed` walks both schemes.** Notes minted under the old scheme
|
|
54
|
+
are still money and a wallet that walked only the new one would leave them at
|
|
55
|
+
a mint it can no longer name.
|
|
56
|
+
|
|
57
|
+
- `restoreFromSeed(baseUrl, seed, host, {gap?, start?, probeK1?,
|
|
58
|
+
allowSecretDisclosure?}, opts?)`. `RestoredNote.scheme` is `bip32` or
|
|
59
|
+
`hmac`, `next` is `{bip32, hmac}`, and `start` takes one per scheme.
|
|
60
|
+
- `restoreNotes` is unchanged in signature and behaviour, and still walks the
|
|
61
|
+
legacy scheme alone. `RestoredNote` and `UnresolvedIndex` gain a `scheme`
|
|
62
|
+
field.
|
|
63
|
+
- `deriveNoteRoot`, `deriveNoteSecret` and `derivedSecretSource` are not
|
|
64
|
+
deprecated and are not going anywhere. Do not mint under them.
|
|
65
|
+
|
|
66
|
+
**Say plainly what a restore can and cannot do.** LUD-25 requires a hash
|
|
67
|
+
lookup to answer for a burned note exactly as it answers for one that never
|
|
68
|
+
existed, and both reference mints do. So a by-hash walk cannot see a spent
|
|
69
|
+
index at all; and since a rotate burns the *old* index, a wallet's spent
|
|
70
|
+
indices sit below its live ones, and one that has rotated more than `gap`
|
|
71
|
+
times scans as completely empty. The persisted per-host counter is what makes
|
|
72
|
+
recovery work - the scan is the fallback. That counter is not secret, so it
|
|
73
|
+
belongs in an ordinary backup, and a restore should merge counters upwards
|
|
74
|
+
only. Documented on `RestoreOptions.start`, in the README and in `llms.txt`.
|
|
75
|
+
|
|
76
|
+
Graded against `lnurlcash-conformance` 0.7.0, whose `cash-derivation.json`
|
|
77
|
+
cases this suite now runs: the LUD-25 path, the four domain levels per host,
|
|
78
|
+
the hardened-by-magnitude flags, and BIP-32's own published test vector 1.
|
|
79
|
+
|
|
80
|
+
## 0.7.0 - 2026-09-04
|
|
81
|
+
|
|
82
|
+
**Offline verification is mandatory, and this library now insists on it.**
|
|
83
|
+
LUD-25 stopped treating a note signature as optional: a SERVICE MUST
|
|
84
|
+
publish `mintPubkey` and MUST sign every note a rotate, split or merge
|
|
85
|
+
mints. A wallet that quietly accepted unsigned notes was handing its holder
|
|
86
|
+
something nobody downstream could check, which is exactly the gap offline
|
|
87
|
+
verification exists to close.
|
|
88
|
+
|
|
89
|
+
- `fetchNoteInfo` and `fetchNoteInfoByHash` refuse a `withdrawRequest` that
|
|
90
|
+
publishes no `mintPubkey`, or one that is not a 33-byte compressed
|
|
91
|
+
secp256k1 key. `WithdrawRequestInfo.mintPubkey` is typed as present.
|
|
92
|
+
- `rotateNote`, `splitNote`, `mergeNotes` and their `*WithHash` forms throw
|
|
93
|
+
the new `UnverifiableNoteError` when the SERVICE confirms the mutation but
|
|
94
|
+
returns no `sig` (or no `sig2` on a split's change).
|
|
95
|
+
- **That error carries the secrets.** The mutation landed - `status` was OK -
|
|
96
|
+
so the note exists at the hash the wallet disclosed and its secret is the
|
|
97
|
+
only key to that value. `newSecretsOf()` reads them exactly as it reads
|
|
98
|
+
them off an ambiguous mutation. Enforcing conformance must never be the
|
|
99
|
+
thing that destroys the money.
|
|
100
|
+
- `requireSignatures: false` opts out, for a mint that predates the
|
|
101
|
+
requirement. One option, stated once, at the call site that needs it.
|
|
102
|
+
|
|
103
|
+
**A mutation whose answer was lost is now re-sent, and usually completes.**
|
|
104
|
+
LUD-25 gained a "Retrying a mutation" section: a SERVICE MUST answer a
|
|
105
|
+
byte-identical rotate, split or merge with the success it already returned,
|
|
106
|
+
signature and all, rather than with the already-spent refusal its burned
|
|
107
|
+
inputs would otherwise earn.
|
|
108
|
+
|
|
109
|
+
That closes the sharpest edge in the protocol. Every mutation is a GET,
|
|
110
|
+
HTTP treats GET as idempotent, and stacks retry one whose connection
|
|
111
|
+
dropped - browsers on a stale keep-alive, Go's `net/http` on a reused
|
|
112
|
+
connection, the JDK's `HttpClient` with no way to switch it off. The mint
|
|
113
|
+
saw the request twice, answered the second as already spent, and the wallet
|
|
114
|
+
was told a mutation had not happened while a note sat at the hash it had
|
|
115
|
+
disclosed. Now the second answer is the first one.
|
|
116
|
+
|
|
117
|
+
- `mutationRetries` defaults to 1. Set 0 for the previous behaviour.
|
|
118
|
+
- Only rotate, split and merge. A melt is **never** retried: it carries
|
|
119
|
+
`pr`, is paid out asynchronously, and the replay rule does not cover it.
|
|
120
|
+
- Only an ambiguous failure is retried. A definitive refusal is the
|
|
121
|
+
SERVICE's considered answer and asking again cannot improve it.
|
|
122
|
+
- The retry re-sends the identical request rather than rebuilding it. The
|
|
123
|
+
replay is matched on the k1 set, `h`, `h2` and `amount`, so a freshly
|
|
124
|
+
generated secret would make the second attempt a different mutation - and
|
|
125
|
+
a second real burn.
|
|
126
|
+
|
|
127
|
+
Against a SERVICE that has not implemented the replay rule, retrying leaves
|
|
128
|
+
a caller exactly where giving up would have: the same secrets on the same
|
|
129
|
+
error, and the same instruction to go and ask what the note at each hash is
|
|
130
|
+
worth.
|
|
131
|
+
|
|
132
|
+
Requires `lnurlcash-conformance` 0.6.0, whose vectors carry the same MUSTs.
|
|
133
|
+
|
|
5
134
|
## 0.6.0 - 2026-08-31
|
|
6
135
|
|
|
7
136
|
- `namesMintOutput()` now requires `commentAllowed >= 64`; the additive
|
package/README.md
CHANGED
|
@@ -58,14 +58,15 @@ console.log(info.maxWithdrawable, 'msat')
|
|
|
58
58
|
// that GET put the secret on the wire, so rotate it
|
|
59
59
|
const fresh = await rotateNote(info.callback, info.k1)
|
|
60
60
|
|
|
61
|
-
// and check the mint really issued it, without asking anyone
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
// and check the mint really issued it, without asking anyone. Both fields
|
|
62
|
+
// are guaranteed here: LUD-25 requires the mint to publish mintPubkey and
|
|
63
|
+
// to sign what it mints, and this library refuses a mint that does neither.
|
|
64
|
+
verifyNoteSignature(fresh.k1, info.maxWithdrawable, fresh.signature!, info.mintPubkey)
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
Every request function takes options last — `fetch`, `timeoutMs`, `offline`,
|
|
68
|
-
`randomSecret
|
|
68
|
+
`randomSecret`, `requireSignatures`, `mutationRetries`.
|
|
69
|
+
`createClient(options)` binds one set once:
|
|
69
70
|
|
|
70
71
|
```ts
|
|
71
72
|
const client = createClient({timeoutMs: 10_000})
|
|
@@ -107,23 +108,39 @@ try {
|
|
|
107
108
|
|
|
108
109
|
`RequestRefusedError` is the opposite and safe: nothing left the process.
|
|
109
110
|
|
|
110
|
-
**3.
|
|
111
|
-
as idempotent, and an LNURLcash mutation is not —
|
|
112
|
-
input.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
This is not hypothetical: the same hazard broke the
|
|
111
|
+
**3. A retried mutation is now a replay, not a double spend.** Every mutation
|
|
112
|
+
is a GET, HTTP treats GET as idempotent, and an LNURLcash mutation is not —
|
|
113
|
+
the first attempt burns the input. For most of this draft's life that was the
|
|
114
|
+
sharpest edge in the protocol: a stack that resent a dropped GET got "already
|
|
115
|
+
spent" for the second attempt, which reads as a *definitive* rejection, so the
|
|
116
|
+
fresh secret got discarded along with the note the service had just minted.
|
|
117
|
+
Node's `fetch` does not retry on its own, but a browser resends an idempotent
|
|
118
|
+
request that failed on a stale pooled connection, and Go and the JDK do the
|
|
119
|
+
same by their own routes — the hazard broke the
|
|
120
120
|
[Kotlin](https://github.com/TheCryptoDonkey/lnurlcash-kotlin) and
|
|
121
121
|
[Go](https://github.com/TheCryptoDonkey/lnurlcash-go) siblings during
|
|
122
|
-
development, by two different mechanisms
|
|
123
|
-
conformance vectors.
|
|
122
|
+
development, by two different mechanisms.
|
|
124
123
|
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
LUD-25 closed it. A service MUST answer a byte-identical rotate, split or
|
|
125
|
+
merge with the success it already returned, signature and all. So this library
|
|
126
|
+
re-sends one whose answer was lost, and an unstoppable transport retry is now
|
|
127
|
+
simply invisible:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// the connection dropped after the mint applied this. It completes anyway.
|
|
131
|
+
const fresh = await rotateNote(callback, oldK1)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`mutationRetries` sets how many times (default 1; `0` restores the old
|
|
135
|
+
give-up-at-once behaviour). Only rotate, split and merge are re-sent — never a
|
|
136
|
+
melt, which carries `pr`, is paid asynchronously and has no replay guarantee —
|
|
137
|
+
and only an ambiguous failure, never a refusal the service actually
|
|
138
|
+
considered. The re-sent request is byte-identical, because the replay is
|
|
139
|
+
matched on the k1 set, `h`, `h2` and `amount`.
|
|
140
|
+
|
|
141
|
+
A service that has not implemented the rule answers the second attempt as
|
|
142
|
+
already spent, exactly as before. So the old defence stays: a mutation refused
|
|
143
|
+
with the input already spent or unknown carries its outputs anyway:
|
|
127
144
|
|
|
128
145
|
```ts
|
|
129
146
|
try {
|
|
@@ -161,8 +178,9 @@ do not mint. Existing notes still redeem through ordinary LUD-03.
|
|
|
161
178
|
|
|
162
179
|
## Offline verification
|
|
163
180
|
|
|
164
|
-
|
|
165
|
-
|
|
181
|
+
Mandatory, and enforced here. A service MUST publish `mintPubkey` and MUST
|
|
182
|
+
sign every note a rotate, split or merge mints, so a holder can confirm
|
|
183
|
+
issuer and amount with nothing but the note:
|
|
166
184
|
|
|
167
185
|
```
|
|
168
186
|
message = "LNURLcash:" || amount_msat || ":" || hex(sha256(k1))
|
|
@@ -170,6 +188,14 @@ digest = sha256(sha256("Lightning Signed Message:" || message))
|
|
|
170
188
|
sig = 65 bytes, r || s || recovery_id
|
|
171
189
|
```
|
|
172
190
|
|
|
191
|
+
A `withdrawRequest` publishing no `mintPubkey`, or one that is not a 33-byte
|
|
192
|
+
compressed secp256k1 key, is refused with a `ProtocolError`. A mutation the
|
|
193
|
+
service confirms but does not sign raises `UnverifiableNoteError` — which
|
|
194
|
+
**carries the fresh secrets**, because the mutation landed and the note it
|
|
195
|
+
minted is real; read them with `newSecretsOf` and persist them before
|
|
196
|
+
anything else. Pass `requireSignatures: false` to deal with a mint that
|
|
197
|
+
predates the requirement.
|
|
198
|
+
|
|
173
199
|
`verifyNoteSignature` recovers the pubkey and compares it to `mintPubkey`.
|
|
174
200
|
It accepts the recovery id at either end, because lnurl-mint once emitted
|
|
175
201
|
the reverse layout and other implementations may still; trying both is safe,
|
|
@@ -219,36 +245,87 @@ information" and fall back to `fetchPayRequest`.
|
|
|
219
245
|
|
|
220
246
|
## Secrets
|
|
221
247
|
|
|
222
|
-
A note's `k1` is generated by the wallet
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
248
|
+
A note's `k1` is generated by the wallet. Draw it from a CSPRNG and the note
|
|
249
|
+
lives only in your wallet file: the mint holds `sha256(k1)` and cannot tell
|
|
250
|
+
you apart from a stranger, so a lost file is lost money. Derive it from a
|
|
251
|
+
seed instead and the wallet restores from words alone, and the same words
|
|
252
|
+
restore the same notes in a *different* wallet.
|
|
253
|
+
|
|
254
|
+
LUD-25 specifies how, and this is the scheme to mint under:
|
|
227
255
|
|
|
228
256
|
```
|
|
229
|
-
|
|
230
|
-
|
|
257
|
+
cashHashingKey = m/139'/0
|
|
258
|
+
(d1, d2, d3, d4) = HMAC-SHA256(key = cashHashingKey, msg = utf8(host))[0..16] as 4 uint32
|
|
259
|
+
k1_i = m/139'/d1/d2/d3/d4/i'
|
|
231
260
|
```
|
|
232
261
|
|
|
262
|
+
`d1..d4` are used **exactly as they fall**. BIP-32 reads any index `>= 2^31`
|
|
263
|
+
as hardened, so which of the four levels are hardened is decided by the
|
|
264
|
+
mint's own host name, and half of them will be. Do not mask the top bit and
|
|
265
|
+
do not harden all four: either one derives a different tree, and a wallet
|
|
266
|
+
restoring against it finds nothing, silently. Only `i` is always hardened.
|
|
267
|
+
|
|
233
268
|
`seed` is raw bytes. A 64-byte BIP39 seed is what wallets use in practice,
|
|
234
269
|
but nothing here depends on BIP39, so a device with its own entropy store
|
|
235
270
|
derives the same way and no consumer carries a wordlist it does not need.
|
|
236
271
|
`host` is the mint host exactly as `serverOf` spells it, lowercase and with
|
|
237
272
|
the port where there is one, so `127.0.0.1:8899` and `mint.example` never
|
|
238
|
-
collide. `index`
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
273
|
+
collide. `index` counts from 0. The output is 32 bytes of hex, the size of a
|
|
274
|
+
payment preimage, and the mint sees nothing different: it only ever receives
|
|
275
|
+
`sha256(k1)`.
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
import {deriveCashRoot, cashSecretSource, restoreFromSeed} from 'lnurlcash-kit'
|
|
279
|
+
|
|
280
|
+
const root = deriveCashRoot(seed) // seed: Uint8Array, yours to keep safe
|
|
281
|
+
const source = cashSecretSource(root, 'mint.example', counter)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### The hardware-signer path
|
|
285
|
+
|
|
286
|
+
Every unhardened level sits at or above the per-mint node, so a signer given
|
|
287
|
+
`m/139'/d1/d2/d3/d4` rather than the seed needs **no elliptic curve at all**:
|
|
288
|
+
each `i'` beneath it is HMAC-SHA512 and one modular addition. That is the
|
|
289
|
+
difference between a device that can do LUD-25 recovery and one that would
|
|
290
|
+
need secp256k1 added to its firmware.
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
import {deriveCashDomainNode, cashNodeToHex, cashNodeFromHex, cashSecretAt} from 'lnurlcash-kit'
|
|
294
|
+
|
|
295
|
+
const node = deriveCashDomainNode(root, 'mint.example')
|
|
296
|
+
provision(cashNodeToHex(node)) // 64 bytes: privateKey || chainCode
|
|
297
|
+
|
|
298
|
+
// on the device, or anywhere holding only that node
|
|
299
|
+
cashSecretAt(cashNodeFromHex(hex), index)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Whoever derives that node can derive every note secret the wallet will ever
|
|
303
|
+
hold **at that mint**. It is provisioning material: one mint's subtree, not
|
|
304
|
+
the wallet.
|
|
305
|
+
|
|
306
|
+
### The legacy scheme
|
|
307
|
+
|
|
308
|
+
This kit shipped its own derivation in 0.2.0, four days before LUD-25 had a
|
|
309
|
+
section on one:
|
|
310
|
+
|
|
311
|
+
```
|
|
312
|
+
root = HMAC-SHA256(key = utf8("lnurlcash-note-v1"), msg = seed)
|
|
313
|
+
k1_i = HMAC-SHA256(key = root, msg = utf8(host + ":" + index))
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`deriveNoteRoot`, `deriveNoteSecret` and `derivedSecretSource` still
|
|
317
|
+
implement it and are not going anywhere, because notes minted under it are
|
|
318
|
+
still money. Do not mint under it. `restoreFromSeed` walks it alongside the
|
|
319
|
+
specified scheme so none of those notes goes missing.
|
|
320
|
+
|
|
321
|
+
Both schemes ship with
|
|
322
|
+
[conformance vectors](https://github.com/TheCryptoDonkey/lnurlcash-conformance)
|
|
246
323
|
for the ports.
|
|
247
324
|
|
|
248
325
|
```ts
|
|
249
326
|
import {deriveNoteRoot, derivedSecretSource, restoreNotes} from 'lnurlcash-kit'
|
|
250
327
|
|
|
251
|
-
const root = deriveNoteRoot(seed) //
|
|
328
|
+
const root = deriveNoteRoot(seed) // legacy: existing notes only
|
|
252
329
|
const source = derivedSecretSource(root, 'mint.example', counter)
|
|
253
330
|
|
|
254
331
|
// hand it to any mutating call and the fresh secrets come from the seed
|
|
@@ -262,20 +339,37 @@ request wastes an index, which costs nothing. A crash the other way round
|
|
|
262
339
|
re-derives a secret the mint has already seen, and the second note minted at
|
|
263
340
|
it collides with the first. This is the rule wallets get wrong.
|
|
264
341
|
|
|
342
|
+
### Restoring
|
|
343
|
+
|
|
265
344
|
Restoring walks the indices and asks the mint what each derived secret is
|
|
266
|
-
worth:
|
|
345
|
+
worth. From a seed it walks both schemes at once:
|
|
267
346
|
|
|
268
347
|
```ts
|
|
269
|
-
const {found, next} = await
|
|
348
|
+
const {found, next} = await restoreFromSeed('https://mint.example/w', seed, 'mint.example')
|
|
349
|
+
// found[].scheme is 'bip32' or 'hmac'; next is {bip32, hmac}
|
|
270
350
|
```
|
|
271
351
|
|
|
272
|
-
A live note is recorded,
|
|
273
|
-
|
|
274
|
-
stops after 20 consecutive unknowns. `next` is the counter to resume from.
|
|
275
|
-
Restoring puts every `k1` it walks on the wire and a restored note carries no
|
|
352
|
+
A live note is recorded, an unknown index counts towards the gap, and the
|
|
353
|
+
walk stops after 20 consecutive unknowns. A restored note carries no
|
|
276
354
|
signature, so rotate each one straight after: that closes the exposure and
|
|
277
355
|
gets the signature in the same call.
|
|
278
356
|
|
|
357
|
+
**The scan is the fallback, not the backup.** LUD-25 requires a hash lookup
|
|
358
|
+
to answer for a burned note exactly as it answers for one that never existed,
|
|
359
|
+
so a by-hash walk cannot see a spent index at all. A rotate burns the *old*
|
|
360
|
+
index, which means a wallet's spent indices sit below its live ones: rotate
|
|
361
|
+
more than `gap` times and a scan from 0 finds nothing whatever. The counter
|
|
362
|
+
`next` gives you is the thing that makes recovery work, so **persist it and
|
|
363
|
+
back it up**. It is not secret - an index reveals nothing without the root -
|
|
364
|
+
so it belongs in an ordinary backup, and a restore should merge counters
|
|
365
|
+
upwards only, never down.
|
|
366
|
+
|
|
367
|
+
Only a walk that discloses raw secrets (`allowSecretDisclosure`) sees "spent"
|
|
368
|
+
at all, because only a `k1` lookup gets that answer. It costs the whole
|
|
369
|
+
window it walked: every index it touched is burned whether or not a note was
|
|
370
|
+
ever minted there, since the secret is in someone's log now. `next` skips
|
|
371
|
+
them for you.
|
|
372
|
+
|
|
279
373
|
The seed is bearer material for every note the wallet will ever hold. Store
|
|
280
374
|
it the way you store the notes, and never log it.
|
|
281
375
|
|
|
@@ -297,14 +391,14 @@ had it, and the preimage is ordinary payment proof.
|
|
|
297
391
|
```ts
|
|
298
392
|
import {
|
|
299
393
|
fetchPayRequest, requestInvoice, claimMintedNote,
|
|
300
|
-
|
|
394
|
+
deriveCashRoot, deriveCashSecret, hashK1, namesMintOutput
|
|
301
395
|
} from 'lnurlcash-kit'
|
|
302
396
|
|
|
303
397
|
const pay = await fetchPayRequest(payUrl) // a Lightning Address resolves here
|
|
304
398
|
if (!namesMintOutput(pay)) throw new Error('mint lacks commentAllowed: 64')
|
|
305
399
|
|
|
306
|
-
const root =
|
|
307
|
-
const k1 =
|
|
400
|
+
const root = deriveCashRoot(seed)
|
|
401
|
+
const k1 = deriveCashSecret(root, 'mint.example', nextIndex)
|
|
308
402
|
await persist({k1, index: nextIndex}) // BEFORE the invoice. always.
|
|
309
403
|
|
|
310
404
|
const {pr} = await requestInvoice(pay.callback, 21_000, {h: hashK1(k1)})
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ type LnurlcashOptions = {
|
|
|
13
13
|
timeoutMs?: number;
|
|
14
14
|
offline?: boolean;
|
|
15
15
|
randomSecret?: RandomSecret;
|
|
16
|
+
requireSignatures?: boolean;
|
|
17
|
+
mutationRetries?: number;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
type MintFee = {
|
|
@@ -41,7 +43,7 @@ type WithdrawRequestInfo = {
|
|
|
41
43
|
minWithdrawable: number;
|
|
42
44
|
maxWithdrawable: number;
|
|
43
45
|
defaultDescription?: string;
|
|
44
|
-
mintPubkey
|
|
46
|
+
mintPubkey: string;
|
|
45
47
|
payLink?: string;
|
|
46
48
|
};
|
|
47
49
|
declare const fetchNoteInfo: (url: string, options?: LnurlcashOptions) => Promise<WithdrawRequestInfo>;
|
|
@@ -189,9 +191,11 @@ type SettledForValue = {
|
|
|
189
191
|
};
|
|
190
192
|
declare const settleNoteForValue: (noteUrl: string, { mints, minMsat, requireSignature }: SettleForValueOptions, options?: LnurlcashOptions) => Promise<SettledForValue>;
|
|
191
193
|
|
|
194
|
+
type NoteScheme = 'bip32' | 'hmac';
|
|
192
195
|
type RestoredNote = {
|
|
193
196
|
index: number;
|
|
194
197
|
k1: string;
|
|
198
|
+
scheme: NoteScheme;
|
|
195
199
|
amountMsat: number | null;
|
|
196
200
|
state: 'live' | 'pending';
|
|
197
201
|
callback?: string;
|
|
@@ -199,6 +203,7 @@ type RestoredNote = {
|
|
|
199
203
|
type UnresolvedIndex = {
|
|
200
204
|
index: number;
|
|
201
205
|
k1: string;
|
|
206
|
+
scheme: NoteScheme;
|
|
202
207
|
reason: string;
|
|
203
208
|
};
|
|
204
209
|
type RestoreResult = {
|
|
@@ -215,6 +220,15 @@ type RestoreOptions = {
|
|
|
215
220
|
allowSecretDisclosure?: boolean;
|
|
216
221
|
};
|
|
217
222
|
declare const restoreNotes: (baseUrl: string, root: Uint8Array, host: string, { gap, start, probeK1, allowSecretDisclosure }?: RestoreOptions, options?: LnurlcashOptions) => Promise<RestoreResult>;
|
|
223
|
+
type SeedRestoreOptions = Omit<RestoreOptions, 'start'> & {
|
|
224
|
+
start?: {
|
|
225
|
+
[K in NoteScheme]?: number;
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
type SeedRestoreResult = Omit<RestoreResult, 'next'> & {
|
|
229
|
+
next: Record<NoteScheme, number>;
|
|
230
|
+
};
|
|
231
|
+
declare const restoreFromSeed: (baseUrl: string, seed: Uint8Array, host: string, { gap, start, probeK1, allowSecretDisclosure }?: SeedRestoreOptions, options?: LnurlcashOptions) => Promise<SeedRestoreResult>;
|
|
218
232
|
|
|
219
233
|
declare const isBech32Lnurl: (data: string) => boolean;
|
|
220
234
|
declare const toBech32Lnurl: (url: string) => string;
|
|
@@ -240,6 +254,22 @@ declare const buildNoteUrl: (withdrawLink: string, k1: string, amountMsat?: numb
|
|
|
240
254
|
declare const withNewK1: (url: string, k1: string, amountMsat: number, signature?: string) => string;
|
|
241
255
|
declare const withoutK1: (url: string, amountMsat: number, signature?: string) => string;
|
|
242
256
|
|
|
257
|
+
type CashNode = {
|
|
258
|
+
privateKey: Uint8Array;
|
|
259
|
+
chainCode: Uint8Array;
|
|
260
|
+
};
|
|
261
|
+
declare const deriveCashChild: (node: CashNode, index: number) => CashNode;
|
|
262
|
+
declare const deriveCashRoot: (seed: Uint8Array) => CashNode;
|
|
263
|
+
declare const cashDomainIndices: (root: CashNode, host: string) => number[];
|
|
264
|
+
declare const deriveCashDomainNode: (root: CashNode, host: string) => CashNode;
|
|
265
|
+
declare const cashSecretAt: (domainNode: CashNode, index: number) => string;
|
|
266
|
+
declare const deriveCashSecret: (root: CashNode, host: string, index: number) => string;
|
|
267
|
+
declare const cashNodeToHex: (node: CashNode) => string;
|
|
268
|
+
declare const cashNodeFromHex: (hex: string) => CashNode;
|
|
269
|
+
declare const cashSecretSource: (root: CashNode, host: string, start?: number) => RandomSecret & {
|
|
270
|
+
index: () => number;
|
|
271
|
+
};
|
|
272
|
+
|
|
243
273
|
declare const PAYMENT_REQUEST_PREFIX = "lnurlcashreq1";
|
|
244
274
|
type PaymentRequestMethodDetails = {
|
|
245
275
|
mints: string[];
|
|
@@ -308,6 +338,10 @@ declare class HashLookupUnsupportedError extends LnurlcashError {
|
|
|
308
338
|
}
|
|
309
339
|
declare class AmbiguousMintError extends LnurlcashError {
|
|
310
340
|
}
|
|
341
|
+
declare class UnverifiableNoteError extends LnurlcashError {
|
|
342
|
+
newSecrets: string[];
|
|
343
|
+
constructor(message: string, newSecrets?: string[]);
|
|
344
|
+
}
|
|
311
345
|
declare class AmbiguousMutationError extends AmbiguousMintError {
|
|
312
346
|
readonly newSecrets: string[];
|
|
313
347
|
constructor(message: string, newSecrets: string[]);
|
|
@@ -338,8 +372,9 @@ declare const createClient: (options?: LnurlcashOptions) => {
|
|
|
338
372
|
fetchInvoiceVerification: (verifyUrl: string) => Promise<VerifyResult>;
|
|
339
373
|
claimMintedNote: (withdrawLink: string, k1: string) => Promise<MintClaim>;
|
|
340
374
|
restoreNotes: (baseUrl: string, root: Uint8Array, host: string, restoreOptions?: RestoreOptions) => Promise<RestoreResult>;
|
|
375
|
+
restoreFromSeed: (baseUrl: string, seed: Uint8Array, host: string, restoreOptions?: SeedRestoreOptions) => Promise<SeedRestoreResult>;
|
|
341
376
|
settleNoteForValue: (noteUrl: string, terms: SettleForValueOptions) => Promise<SettledForValue>;
|
|
342
377
|
};
|
|
343
378
|
type LnurlcashClient = ReturnType<typeof createClient>;
|
|
344
379
|
|
|
345
|
-
export { AmbiguousMintError, AmbiguousMutationError, type BoundMintCommitment, type DecodeOptions, HashLookupUnsupportedError, type HashedMutationResult, type HashedSplitResult, InsufficientValueError, type InvoiceRequestOptions, type InvoiceResult, type LnurlcashClient, LnurlcashError, type LnurlcashOptions, type MeltResult, type MintAddressInfo, type MintClaim, type MintContact, type MintFee, type MintFeeBand, type NoteInfoByHash, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, type PayRequestInfo, type PaymentRequest, type PaymentRequestMethodDetails, PendingNoteError, ProtocolError, type RandomSecret, RequestRefusedError, type RestoreOptions, type RestoreResult, type RestoredNote, type RotateResult, ServiceRejectedError, type SettleForValueOptions, type SettledForValue, type SettledNote, type SignatureCheck, type SplitResult, type UnresolvedIndex, type ValidatedBoundMintReceipt, type VerifyResult, type WithdrawRequestInfo, type WithdrawSuccessResponse, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
|
|
380
|
+
export { AmbiguousMintError, AmbiguousMutationError, type BoundMintCommitment, type CashNode, type DecodeOptions, HashLookupUnsupportedError, type HashedMutationResult, type HashedSplitResult, InsufficientValueError, type InvoiceRequestOptions, type InvoiceResult, type LnurlcashClient, LnurlcashError, type LnurlcashOptions, type MeltResult, type MintAddressInfo, type MintClaim, type MintContact, type MintFee, type MintFeeBand, type NoteInfoByHash, type NoteScheme, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, type PayRequestInfo, type PaymentRequest, type PaymentRequestMethodDetails, PendingNoteError, ProtocolError, type RandomSecret, RequestRefusedError, type RestoreOptions, type RestoreResult, type RestoredNote, type RotateResult, type SeedRestoreOptions, type SeedRestoreResult, ServiceRejectedError, type SettleForValueOptions, type SettledForValue, type SettledNote, type SignatureCheck, type SplitResult, type UnresolvedIndex, UnverifiableNoteError, type ValidatedBoundMintReceipt, type VerifyResult, type WithdrawRequestInfo, type WithdrawSuccessResponse, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, cashDomainIndices, cashNodeFromHex, cashNodeToHex, cashSecretAt, cashSecretSource, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveCashChild, deriveCashDomainNode, deriveCashRoot, deriveCashSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreFromSeed, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { bech32, base64urlnopad } from '@scure/base';
|
|
2
2
|
import { hmac } from '@noble/hashes/hmac.js';
|
|
3
|
-
import { sha256 } from '@noble/hashes/sha2.js';
|
|
3
|
+
import { sha256, sha512 } from '@noble/hashes/sha2.js';
|
|
4
4
|
import { utf8ToBytes, bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
|
5
5
|
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
6
6
|
|
|
@@ -218,6 +218,89 @@ var withoutK1 = (url, amountMsat, signature) => {
|
|
|
218
218
|
else newUrl.searchParams.delete("sig");
|
|
219
219
|
return newUrl.toString();
|
|
220
220
|
};
|
|
221
|
+
var HARDENED = 2147483648;
|
|
222
|
+
var CURVE_N = secp256k1.Point.Fn.ORDER;
|
|
223
|
+
var MASTER_KEY_DOMAIN = utf8ToBytes("Bitcoin seed");
|
|
224
|
+
var CASH_PURPOSE = 139;
|
|
225
|
+
var numberOf = (bytes) => bytes.length === 0 ? 0n : BigInt(`0x${bytesToHex(bytes)}`);
|
|
226
|
+
var to32Bytes = (value) => hexToBytes(value.toString(16).padStart(64, "0"));
|
|
227
|
+
var readUint32BE = (bytes, offset) => (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
|
|
228
|
+
var deriveCashChild = (node, index) => {
|
|
229
|
+
if (!Number.isSafeInteger(index) || index < 0 || index > 4294967295) {
|
|
230
|
+
throw new RangeError(`A BIP-32 child index must be a uint32, not ${index}.`);
|
|
231
|
+
}
|
|
232
|
+
const data = new Uint8Array(37);
|
|
233
|
+
if (index >= HARDENED) {
|
|
234
|
+
data.set(node.privateKey, 1);
|
|
235
|
+
} else {
|
|
236
|
+
data.set(
|
|
237
|
+
secp256k1.Point.BASE.multiply(numberOf(node.privateKey)).toBytes(true),
|
|
238
|
+
0
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
data[33] = index >>> 24 & 255;
|
|
242
|
+
data[34] = index >>> 16 & 255;
|
|
243
|
+
data[35] = index >>> 8 & 255;
|
|
244
|
+
data[36] = index & 255;
|
|
245
|
+
const material = hmac(sha512, node.chainCode, data);
|
|
246
|
+
const left = numberOf(material.subarray(0, 32));
|
|
247
|
+
const key = (left + numberOf(node.privateKey)) % CURVE_N;
|
|
248
|
+
if (left >= CURVE_N || key === 0n) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`BIP-32 derivation at index ${index} produced an invalid key. Use the next index.`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return { privateKey: to32Bytes(key), chainCode: material.slice(32) };
|
|
254
|
+
};
|
|
255
|
+
var masterFrom = (seed) => {
|
|
256
|
+
if (seed.length < 16 || seed.length > 64) {
|
|
257
|
+
throw new RangeError(
|
|
258
|
+
`A BIP-32 seed must be 16 to 64 bytes, not ${seed.length}.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const material = hmac(sha512, MASTER_KEY_DOMAIN, seed);
|
|
262
|
+
const key = numberOf(material.subarray(0, 32));
|
|
263
|
+
if (key === 0n || key >= CURVE_N) {
|
|
264
|
+
throw new Error("This seed does not produce a valid BIP-32 master key.");
|
|
265
|
+
}
|
|
266
|
+
return { privateKey: material.slice(0, 32), chainCode: material.slice(32) };
|
|
267
|
+
};
|
|
268
|
+
var deriveCashRoot = (seed) => deriveCashChild(masterFrom(seed), CASH_PURPOSE + HARDENED);
|
|
269
|
+
var cashDomainIndices = (root, host) => {
|
|
270
|
+
const hashingKey = deriveCashChild(root, 0).privateKey;
|
|
271
|
+
const material = hmac(sha256, hashingKey, utf8ToBytes(host));
|
|
272
|
+
return [0, 4, 8, 12].map((offset) => readUint32BE(material, offset));
|
|
273
|
+
};
|
|
274
|
+
var deriveCashDomainNode = (root, host) => cashDomainIndices(root, host).reduce(deriveCashChild, root);
|
|
275
|
+
var requireIndex2 = (index) => {
|
|
276
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= HARDENED) {
|
|
277
|
+
throw new RangeError(
|
|
278
|
+
`A note index must be an integer in [0, 2^31), not ${index}.`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return index;
|
|
282
|
+
};
|
|
283
|
+
var cashSecretAt = (domainNode, index) => bytesToHex(
|
|
284
|
+
deriveCashChild(domainNode, requireIndex2(index) + HARDENED).privateKey
|
|
285
|
+
);
|
|
286
|
+
var deriveCashSecret = (root, host, index) => cashSecretAt(deriveCashDomainNode(root, host), index);
|
|
287
|
+
var cashNodeToHex = (node) => bytesToHex(node.privateKey) + bytesToHex(node.chainCode);
|
|
288
|
+
var cashNodeFromHex = (hex) => {
|
|
289
|
+
const bytes = hexToBytes(hex.trim().toLowerCase());
|
|
290
|
+
if (bytes.length !== 64) {
|
|
291
|
+
throw new RangeError(
|
|
292
|
+
`A cash node is 64 bytes - a 32-byte key and a 32-byte chain code - not ${bytes.length}.`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return { privateKey: bytes.slice(0, 32), chainCode: bytes.slice(32) };
|
|
296
|
+
};
|
|
297
|
+
var cashSecretSource = (root, host, start = 0) => {
|
|
298
|
+
const domainNode = deriveCashDomainNode(root, host);
|
|
299
|
+
let next = requireIndex2(start);
|
|
300
|
+
const source = (() => cashSecretAt(domainNode, next++));
|
|
301
|
+
source.index = () => next;
|
|
302
|
+
return source;
|
|
303
|
+
};
|
|
221
304
|
|
|
222
305
|
// src/errors.ts
|
|
223
306
|
var LnurlcashError = class extends Error {
|
|
@@ -264,6 +347,13 @@ var HashLookupUnsupportedError = class extends LnurlcashError {
|
|
|
264
347
|
};
|
|
265
348
|
var AmbiguousMintError = class extends LnurlcashError {
|
|
266
349
|
};
|
|
350
|
+
var UnverifiableNoteError = class extends LnurlcashError {
|
|
351
|
+
newSecrets;
|
|
352
|
+
constructor(message, newSecrets = []) {
|
|
353
|
+
super(message);
|
|
354
|
+
this.newSecrets = newSecrets;
|
|
355
|
+
}
|
|
356
|
+
};
|
|
267
357
|
var AmbiguousMutationError = class extends AmbiguousMintError {
|
|
268
358
|
newSecrets;
|
|
269
359
|
constructor(message, newSecrets) {
|
|
@@ -283,6 +373,7 @@ var InsufficientValueError = class extends ServiceRejectedError {
|
|
|
283
373
|
};
|
|
284
374
|
var newSecretsOf = (err) => {
|
|
285
375
|
if (err instanceof AmbiguousMutationError) return err.newSecrets;
|
|
376
|
+
if (err instanceof UnverifiableNoteError) return err.newSecrets;
|
|
286
377
|
if (err instanceof ServiceRejectedError) return err.newSecrets ?? [];
|
|
287
378
|
return [];
|
|
288
379
|
};
|
|
@@ -465,8 +556,18 @@ var resolveOptions = (options = {}) => ({
|
|
|
465
556
|
fetch: options.fetch ?? ((...args) => globalThis.fetch(...args)),
|
|
466
557
|
timeoutMs: options.timeoutMs ?? 3e4,
|
|
467
558
|
offline: options.offline ?? false,
|
|
468
|
-
randomSecret: options.randomSecret ?? defaultRandomSecret
|
|
559
|
+
randomSecret: options.randomSecret ?? defaultRandomSecret,
|
|
560
|
+
requireSignatures: options.requireSignatures ?? true,
|
|
561
|
+
// A negative or non-finite count is read as none rather than thrown on:
|
|
562
|
+
// this is a resilience knob, and refusing the whole operation over it
|
|
563
|
+
// would be a worse answer than not retrying.
|
|
564
|
+
mutationRetries: normaliseRetries(options.mutationRetries)
|
|
469
565
|
});
|
|
566
|
+
var normaliseRetries = (value) => {
|
|
567
|
+
if (value === void 0) return 1;
|
|
568
|
+
if (!Number.isFinite(value) || value <= 0) return 0;
|
|
569
|
+
return Math.floor(value);
|
|
570
|
+
};
|
|
470
571
|
var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
471
572
|
var MAX_REDIRECTS = 5;
|
|
472
573
|
var MAX_BODY_BYTES = 1048576;
|
|
@@ -699,10 +800,17 @@ var verifyNoteSignature = (k1, amountMsat, signatureHex, mintPubkeys) => verifyN
|
|
|
699
800
|
var verifyNoteSignatureHash = (h, amountMsat, signatureHex, mintPubkeys) => verifyNoteSignatureHashAgainst(h, amountMsat, signatureHex, mintPubkeys).valid;
|
|
700
801
|
|
|
701
802
|
// src/client.ts
|
|
702
|
-
var
|
|
803
|
+
var COMPRESSED_PUBKEY = /^0[23][0-9a-f]{64}$/;
|
|
804
|
+
var isCompressedPubkey = (value) => typeof value === "string" && COMPRESSED_PUBKEY.test(value.trim().toLowerCase());
|
|
805
|
+
var assertWithdrawRequestShape = (body, { requireK1, requireMintPubkey }) => {
|
|
703
806
|
if (body?.tag !== "withdrawRequest" || typeof body.callback !== "string" || requireK1 && typeof body.k1 !== "string" || typeof body.maxWithdrawable !== "number" || !Number.isSafeInteger(body.maxWithdrawable) || body.maxWithdrawable < 0 || body.minWithdrawable !== void 0 && (typeof body.minWithdrawable !== "number" || !Number.isSafeInteger(body.minWithdrawable) || body.minWithdrawable < 0 || body.minWithdrawable > body.maxWithdrawable)) {
|
|
704
807
|
throw new ProtocolError("Not a withdrawRequest (unexpected response).");
|
|
705
808
|
}
|
|
809
|
+
if (requireMintPubkey && !isCompressedPubkey(body.mintPubkey)) {
|
|
810
|
+
throw new ProtocolError(
|
|
811
|
+
body.mintPubkey === void 0 ? "This service publishes no mintPubkey, so its notes cannot be verified offline (LUD-25 requires one)." : "This service published a mintPubkey that is not a 33-byte compressed secp256k1 key."
|
|
812
|
+
);
|
|
813
|
+
}
|
|
706
814
|
};
|
|
707
815
|
var fetchNoteInfo = async (url, options = {}) => {
|
|
708
816
|
const opts = resolveOptions(options);
|
|
@@ -715,7 +823,10 @@ var fetchNoteInfo = async (url, options = {}) => {
|
|
|
715
823
|
if (err instanceof ServiceRejectedError) throw classifyNoteError(err.reason);
|
|
716
824
|
throw err;
|
|
717
825
|
}
|
|
718
|
-
assertWithdrawRequestShape(body, {
|
|
826
|
+
assertWithdrawRequestShape(body, {
|
|
827
|
+
requireK1: true,
|
|
828
|
+
requireMintPubkey: opts.requireSignatures
|
|
829
|
+
});
|
|
719
830
|
const queried = noteK1(url);
|
|
720
831
|
if (queried && body.k1.toLowerCase() !== queried) {
|
|
721
832
|
throw new ProtocolError(
|
|
@@ -738,7 +849,10 @@ var fetchNoteInfoByHash = async (withdrawLink, h, options = {}) => {
|
|
|
738
849
|
if (err instanceof ServiceRejectedError) throw classifyNoteError(err.reason);
|
|
739
850
|
throw err;
|
|
740
851
|
}
|
|
741
|
-
assertWithdrawRequestShape(body, {
|
|
852
|
+
assertWithdrawRequestShape(body, {
|
|
853
|
+
requireK1: false,
|
|
854
|
+
requireMintPubkey: opts.requireSignatures
|
|
855
|
+
});
|
|
742
856
|
const info = body;
|
|
743
857
|
const payLink = sameOriginPayLink(body.payLink, reqUrl);
|
|
744
858
|
if (payLink === void 0) delete info.payLink;
|
|
@@ -857,6 +971,28 @@ var callbackRequest = async (callback, params, options) => {
|
|
|
857
971
|
}
|
|
858
972
|
return body;
|
|
859
973
|
};
|
|
974
|
+
var replayableCallbackRequest = async (callback, params, options) => {
|
|
975
|
+
const { mutationRetries } = resolveOptions(options);
|
|
976
|
+
let lastError;
|
|
977
|
+
for (let attempt = 0; ; attempt++) {
|
|
978
|
+
try {
|
|
979
|
+
return await callbackRequest(callback, params, options);
|
|
980
|
+
} catch (err) {
|
|
981
|
+
if (!(err instanceof AmbiguousMintError) || attempt >= mutationRetries) {
|
|
982
|
+
throw err;
|
|
983
|
+
}
|
|
984
|
+
lastError = err;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
throw lastError;
|
|
988
|
+
};
|
|
989
|
+
var requireSignature = (value, options, what) => {
|
|
990
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
991
|
+
if (!resolveOptions(options).requireSignatures) return void 0;
|
|
992
|
+
throw new UnverifiableNoteError(
|
|
993
|
+
`The service confirmed the ${what} but returned no signature, so the note it just minted cannot be verified offline. The note exists - keep the secret.`
|
|
994
|
+
);
|
|
995
|
+
};
|
|
860
996
|
var meltNote = async (callback, k1, pr, options = {}) => {
|
|
861
997
|
const body = await callbackRequest(
|
|
862
998
|
callback,
|
|
@@ -872,7 +1008,7 @@ var meltNote = async (callback, k1, pr, options = {}) => {
|
|
|
872
1008
|
};
|
|
873
1009
|
};
|
|
874
1010
|
var rotateNoteWithHash = async (callback, k1, h, options = {}) => {
|
|
875
|
-
const body = await
|
|
1011
|
+
const body = await replayableCallbackRequest(
|
|
876
1012
|
callback,
|
|
877
1013
|
[
|
|
878
1014
|
["k1", k1],
|
|
@@ -880,10 +1016,10 @@ var rotateNoteWithHash = async (callback, k1, h, options = {}) => {
|
|
|
880
1016
|
],
|
|
881
1017
|
options
|
|
882
1018
|
);
|
|
883
|
-
return { signature: body.sig };
|
|
1019
|
+
return { signature: requireSignature(body.sig, options, "rotate") };
|
|
884
1020
|
};
|
|
885
1021
|
var splitNoteWithHash = async (callback, k1s, amountMsat, h, h2, options = {}) => {
|
|
886
|
-
const body = await
|
|
1022
|
+
const body = await replayableCallbackRequest(
|
|
887
1023
|
callback,
|
|
888
1024
|
[
|
|
889
1025
|
...k1s.map((k1) => ["k1", k1]),
|
|
@@ -893,20 +1029,26 @@ var splitNoteWithHash = async (callback, k1s, amountMsat, h, h2, options = {}) =
|
|
|
893
1029
|
],
|
|
894
1030
|
options
|
|
895
1031
|
);
|
|
896
|
-
return {
|
|
1032
|
+
return {
|
|
1033
|
+
signature: requireSignature(body.sig, options, "split"),
|
|
1034
|
+
changeSignature: requireSignature(body.sig2, options, "split's change")
|
|
1035
|
+
};
|
|
897
1036
|
};
|
|
898
1037
|
var mergeNotesWithHash = async (callback, k1s, h, options = {}) => {
|
|
899
|
-
const body = await
|
|
1038
|
+
const body = await replayableCallbackRequest(
|
|
900
1039
|
callback,
|
|
901
1040
|
[...k1s.map((k1) => ["k1", k1]), ["h", h]],
|
|
902
1041
|
options
|
|
903
1042
|
);
|
|
904
|
-
return { signature: body.sig };
|
|
1043
|
+
return { signature: requireSignature(body.sig, options, "merge") };
|
|
905
1044
|
};
|
|
906
1045
|
var keepingOutputs = (err, newSecrets) => {
|
|
907
1046
|
if (err instanceof NoteSpentError || err instanceof NoteUnknownError) {
|
|
908
1047
|
err.newSecrets = newSecrets;
|
|
909
1048
|
}
|
|
1049
|
+
if (err instanceof UnverifiableNoteError) {
|
|
1050
|
+
err.newSecrets = newSecrets;
|
|
1051
|
+
}
|
|
910
1052
|
return err;
|
|
911
1053
|
};
|
|
912
1054
|
var rotateNote = async (callback, k1, options = {}) => {
|
|
@@ -1012,6 +1154,10 @@ var foldNotes = async (callback, batches, opts, options) => {
|
|
|
1012
1154
|
err.newSecrets = live;
|
|
1013
1155
|
throw err;
|
|
1014
1156
|
}
|
|
1157
|
+
if (err instanceof UnverifiableNoteError) {
|
|
1158
|
+
err.newSecrets = live;
|
|
1159
|
+
throw err;
|
|
1160
|
+
}
|
|
1015
1161
|
throw new AmbiguousMutationError(
|
|
1016
1162
|
err instanceof Error ? err.message : String(err),
|
|
1017
1163
|
live
|
|
@@ -1196,7 +1342,7 @@ var claimMintedNote = async (withdrawLink, k1, options = {}) => {
|
|
|
1196
1342
|
|
|
1197
1343
|
// src/settle.ts
|
|
1198
1344
|
var normaliseHost = (value) => serverOf(value.trim().replace(/^@/, "")).toLowerCase();
|
|
1199
|
-
var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = false }, options = {}) => {
|
|
1345
|
+
var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature: requireSignature2 = false }, options = {}) => {
|
|
1200
1346
|
const url = resolveNoteInput(noteUrl);
|
|
1201
1347
|
if (!url) {
|
|
1202
1348
|
throw new RequestRefusedError(
|
|
@@ -1212,7 +1358,7 @@ var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = fa
|
|
|
1212
1358
|
}
|
|
1213
1359
|
const k1 = requireNoteK1(url);
|
|
1214
1360
|
const info = await fetchNoteInfo(url, options);
|
|
1215
|
-
if (
|
|
1361
|
+
if (requireSignature2) {
|
|
1216
1362
|
const signature = noteSignature(url);
|
|
1217
1363
|
if (!signature) {
|
|
1218
1364
|
throw new ServiceRejectedError("This note carries no signature.");
|
|
@@ -1242,12 +1388,67 @@ var settleNoteForValue = async (noteUrl, { mints, minMsat, requireSignature = fa
|
|
|
1242
1388
|
};
|
|
1243
1389
|
|
|
1244
1390
|
// src/restore.ts
|
|
1245
|
-
var restoreNotes = async (baseUrl, root, host, {
|
|
1391
|
+
var restoreNotes = async (baseUrl, root, host, {
|
|
1392
|
+
gap = 20,
|
|
1393
|
+
start = 0,
|
|
1394
|
+
probeK1,
|
|
1395
|
+
allowSecretDisclosure = false
|
|
1396
|
+
} = {}, options = {}) => {
|
|
1397
|
+
const walked = await restoreSchemes(
|
|
1398
|
+
baseUrl,
|
|
1399
|
+
[
|
|
1400
|
+
{
|
|
1401
|
+
scheme: "hmac",
|
|
1402
|
+
start,
|
|
1403
|
+
secretAt: (index) => deriveNoteSecret(root, host, index)
|
|
1404
|
+
}
|
|
1405
|
+
],
|
|
1406
|
+
{ gap, probeK1, allowSecretDisclosure },
|
|
1407
|
+
options
|
|
1408
|
+
);
|
|
1409
|
+
return { ...walked, next: walked.next.hmac };
|
|
1410
|
+
};
|
|
1411
|
+
var restoreFromSeed = async (baseUrl, seed, host, {
|
|
1412
|
+
gap = 20,
|
|
1413
|
+
start = {},
|
|
1414
|
+
probeK1,
|
|
1415
|
+
allowSecretDisclosure = false
|
|
1416
|
+
} = {}, options = {}) => {
|
|
1417
|
+
const domainNode = deriveCashDomainNode(deriveCashRoot(seed), host);
|
|
1418
|
+
const legacyRoot = deriveNoteRoot(seed);
|
|
1419
|
+
const walked = await restoreSchemes(
|
|
1420
|
+
baseUrl,
|
|
1421
|
+
[
|
|
1422
|
+
{
|
|
1423
|
+
scheme: "bip32",
|
|
1424
|
+
start: start.bip32 ?? 0,
|
|
1425
|
+
secretAt: (index) => cashSecretAt(domainNode, index)
|
|
1426
|
+
},
|
|
1427
|
+
{
|
|
1428
|
+
scheme: "hmac",
|
|
1429
|
+
start: start.hmac ?? 0,
|
|
1430
|
+
secretAt: (index) => deriveNoteSecret(legacyRoot, host, index)
|
|
1431
|
+
}
|
|
1432
|
+
],
|
|
1433
|
+
{ gap, probeK1, allowSecretDisclosure },
|
|
1434
|
+
options
|
|
1435
|
+
);
|
|
1436
|
+
return { ...walked, next: { bip32: walked.next.bip32, hmac: walked.next.hmac } };
|
|
1437
|
+
};
|
|
1438
|
+
var restoreSchemes = async (baseUrl, schemes, {
|
|
1439
|
+
gap,
|
|
1440
|
+
probeK1,
|
|
1441
|
+
allowSecretDisclosure
|
|
1442
|
+
}, options) => {
|
|
1246
1443
|
if (!Number.isSafeInteger(gap) || gap < 1) {
|
|
1247
1444
|
throw new RangeError(`The gap limit must be a positive integer, not ${gap}.`);
|
|
1248
1445
|
}
|
|
1249
|
-
|
|
1250
|
-
|
|
1446
|
+
for (const { scheme, start } of schemes) {
|
|
1447
|
+
if (!Number.isSafeInteger(start) || start < 0) {
|
|
1448
|
+
throw new RangeError(
|
|
1449
|
+
`The start index for the ${scheme} scheme must be a non-negative integer, not ${start}.`
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1251
1452
|
}
|
|
1252
1453
|
let hashLookupsConfirmed = false;
|
|
1253
1454
|
if (probeK1) {
|
|
@@ -1258,66 +1459,75 @@ var restoreNotes = async (baseUrl, root, host, { gap = 20, start = 0, probeK1, a
|
|
|
1258
1459
|
if (!(err instanceof ServiceRejectedError)) throw err;
|
|
1259
1460
|
}
|
|
1260
1461
|
}
|
|
1261
|
-
const byHash =
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1462
|
+
const byHash = [];
|
|
1463
|
+
for (const scheme of schemes) {
|
|
1464
|
+
const outcome = await walk(
|
|
1465
|
+
scheme,
|
|
1466
|
+
gap,
|
|
1467
|
+
async (k1) => {
|
|
1468
|
+
const info = await fetchNoteInfoByHash(baseUrl, hashK1(k1), options);
|
|
1469
|
+
hashLookupsConfirmed = true;
|
|
1470
|
+
return info;
|
|
1471
|
+
}
|
|
1472
|
+
);
|
|
1473
|
+
if (outcome.found.length > 0 || outcome.unresolved.length > 0) {
|
|
1266
1474
|
hashLookupsConfirmed = true;
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
root,
|
|
1270
|
-
host
|
|
1271
|
-
);
|
|
1272
|
-
if (byHash.found.length > 0 || byHash.unresolved.length > 0) hashLookupsConfirmed = true;
|
|
1273
|
-
if (hashLookupsConfirmed) {
|
|
1274
|
-
return {
|
|
1275
|
-
found: byHash.found,
|
|
1276
|
-
unresolved: byHash.unresolved,
|
|
1277
|
-
next: byHash.lastUsed === null ? start : byHash.lastUsed + 1,
|
|
1278
|
-
hashLookupsConfirmed: true,
|
|
1279
|
-
disclosesSecrets: false
|
|
1280
|
-
};
|
|
1475
|
+
}
|
|
1476
|
+
byHash.push(outcome);
|
|
1281
1477
|
}
|
|
1478
|
+
if (hashLookupsConfirmed) return collate(schemes, byHash, false);
|
|
1282
1479
|
if (!allowSecretDisclosure) {
|
|
1283
1480
|
throw new HashLookupUnsupportedError(
|
|
1284
1481
|
"This service never answered a lookup by hash, so a restore cannot tell an empty wallet from a service that only accepts raw secrets. Pass a probeK1 for a note known to exist here, or allowSecretDisclosure to walk by secret instead."
|
|
1285
1482
|
);
|
|
1286
1483
|
}
|
|
1287
|
-
const bySecret =
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1484
|
+
const bySecret = [];
|
|
1485
|
+
for (const scheme of schemes) {
|
|
1486
|
+
bySecret.push(
|
|
1487
|
+
await walk(
|
|
1488
|
+
scheme,
|
|
1489
|
+
gap,
|
|
1490
|
+
(k1) => fetchNoteInfo(buildNoteUrl(baseUrl, k1), options)
|
|
1491
|
+
)
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
return collate(schemes, bySecret, true);
|
|
1495
|
+
};
|
|
1496
|
+
var collate = (schemes, outcomes, disclosesSecrets) => {
|
|
1497
|
+
const next = {};
|
|
1498
|
+
schemes.forEach(({ scheme, start }, at) => {
|
|
1499
|
+
const outcome = outcomes[at];
|
|
1500
|
+
if (!disclosesSecrets) {
|
|
1501
|
+
next[scheme] = outcome.lastUsed === null ? start : outcome.lastUsed + 1;
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
const used = outcome.lastUsed ?? start - 1;
|
|
1505
|
+
const walkedThrough = outcome.highestWalked ?? start - 1;
|
|
1506
|
+
next[scheme] = Math.max(used, walkedThrough) + 1;
|
|
1507
|
+
});
|
|
1296
1508
|
return {
|
|
1297
|
-
found:
|
|
1298
|
-
unresolved:
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
next: Math.max(used, walkedThrough) + 1,
|
|
1303
|
-
hashLookupsConfirmed: false,
|
|
1304
|
-
disclosesSecrets: true
|
|
1509
|
+
found: outcomes.flatMap((outcome) => outcome.found),
|
|
1510
|
+
unresolved: outcomes.flatMap((outcome) => outcome.unresolved),
|
|
1511
|
+
next,
|
|
1512
|
+
hashLookupsConfirmed: !disclosesSecrets,
|
|
1513
|
+
disclosesSecrets
|
|
1305
1514
|
};
|
|
1306
1515
|
};
|
|
1307
|
-
var walk = async (
|
|
1516
|
+
var walk = async ({ scheme, start, secretAt }, gap, lookup) => {
|
|
1308
1517
|
const found = [];
|
|
1309
1518
|
const unresolved = [];
|
|
1310
1519
|
let lastUsed = null;
|
|
1311
1520
|
let highestWalked = null;
|
|
1312
1521
|
let unknownRun = 0;
|
|
1313
1522
|
for (let index = start; unknownRun < gap; index++) {
|
|
1314
|
-
const k1 =
|
|
1523
|
+
const k1 = secretAt(index);
|
|
1315
1524
|
highestWalked = index;
|
|
1316
1525
|
try {
|
|
1317
1526
|
const info = await lookup(k1);
|
|
1318
1527
|
found.push({
|
|
1319
1528
|
index,
|
|
1320
1529
|
k1,
|
|
1530
|
+
scheme,
|
|
1321
1531
|
amountMsat: info.maxWithdrawable,
|
|
1322
1532
|
state: "live",
|
|
1323
1533
|
callback: info.callback
|
|
@@ -1326,7 +1536,7 @@ var walk = async (start, gap, lookup, root, host) => {
|
|
|
1326
1536
|
unknownRun = 0;
|
|
1327
1537
|
} catch (err) {
|
|
1328
1538
|
if (err instanceof PendingNoteError) {
|
|
1329
|
-
found.push({ index, k1, amountMsat: null, state: "pending" });
|
|
1539
|
+
found.push({ index, k1, scheme, amountMsat: null, state: "pending" });
|
|
1330
1540
|
lastUsed = index;
|
|
1331
1541
|
unknownRun = 0;
|
|
1332
1542
|
} else if (err instanceof NoteSpentError) {
|
|
@@ -1335,7 +1545,7 @@ var walk = async (start, gap, lookup, root, host) => {
|
|
|
1335
1545
|
} else if (err instanceof NoteUnknownError) {
|
|
1336
1546
|
unknownRun++;
|
|
1337
1547
|
} else if (err instanceof ServiceRejectedError) {
|
|
1338
|
-
unresolved.push({ index, k1, reason: err.reason });
|
|
1548
|
+
unresolved.push({ index, k1, scheme, reason: err.reason });
|
|
1339
1549
|
lastUsed = index;
|
|
1340
1550
|
unknownRun = 0;
|
|
1341
1551
|
} else {
|
|
@@ -1368,7 +1578,8 @@ var createClient = (options = {}) => ({
|
|
|
1368
1578
|
fetchInvoiceVerification: (verifyUrl) => fetchInvoiceVerification(verifyUrl, options),
|
|
1369
1579
|
claimMintedNote: (withdrawLink, k1) => claimMintedNote(withdrawLink, k1, options),
|
|
1370
1580
|
restoreNotes: (baseUrl, root, host, restoreOptions = {}) => restoreNotes(baseUrl, root, host, restoreOptions, options),
|
|
1581
|
+
restoreFromSeed: (baseUrl, seed, host, restoreOptions = {}) => restoreFromSeed(baseUrl, seed, host, restoreOptions, options),
|
|
1371
1582
|
settleNoteForValue: (noteUrl, terms) => settleNoteForValue(noteUrl, terms, options)
|
|
1372
1583
|
});
|
|
1373
1584
|
|
|
1374
|
-
export { AmbiguousMintError, AmbiguousMutationError, HashLookupUnsupportedError, InsufficientValueError, LnurlcashError, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, PendingNoteError, ProtocolError, RequestRefusedError, ServiceRejectedError, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
|
|
1585
|
+
export { AmbiguousMintError, AmbiguousMutationError, HashLookupUnsupportedError, InsufficientValueError, LnurlcashError, NoteSpentError, NoteUnknownError, PAYMENT_REQUEST_PREFIX, PendingNoteError, ProtocolError, RequestRefusedError, ServiceRejectedError, UnverifiableNoteError, applyMintFee, buildNoteInfoUrlByHash, buildNoteUrl, cashDomainIndices, cashNodeFromHex, cashNodeToHex, cashSecretAt, cashSecretSource, claimMintedNote, classifyNoteError, createClient, decodeBolt11AmountMsat, decodePaymentRequest, defaultRandomSecret, deriveCashChild, deriveCashDomainNode, deriveCashRoot, deriveCashSecret, deriveNoteRoot, deriveNoteSecret, derivedSecretSource, describeMintFee, encodePaymentRequest, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchNoteInfoByHash, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPaymentRequest, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeBatches, mergeNotes, mergeNotesWithHash, mintAddressUrl, mintFeeBand, namesMintOutput, newSecretsOf, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureDigestForHash, noteSignatureMessage, noteSignatureMessageForHash, parseMintFee, paymentRequestAmountMsat, probeBurnedNote, requestInvoice, requireBoundMintQuote, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, restoreFromSeed, restoreNotes, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, settleNoteForValue, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, validateBoundMintReceipt, verifyNoteSignature, verifyNoteSignatureAgainst, verifyNoteSignatureHash, verifyNoteSignatureHashAgainst, withNewK1, withinMintFeeBand, withoutK1 };
|
package/llms.txt
CHANGED
|
@@ -18,10 +18,10 @@ Reference wallet: https://github.com/dni/lnurl-wallet
|
|
|
18
18
|
## Core API
|
|
19
19
|
|
|
20
20
|
resolveNoteInput(text) -> url | null accepts bech32 LNURL, lnurlw://, https
|
|
21
|
-
fetchNoteInfo(url, opts?) -> {callback, k1, maxWithdrawable, mintPubkey
|
|
22
|
-
rotateNote(callback, k1, opts?) -> {k1, signature
|
|
23
|
-
splitNote(callback, k1s, amountMsat, opts?) -> {k1, change, signature
|
|
24
|
-
mergeNotes(callback, k1s, opts?) -> {k1, signature
|
|
21
|
+
fetchNoteInfo(url, opts?) -> {callback, k1, maxWithdrawable, mintPubkey}
|
|
22
|
+
rotateNote(callback, k1, opts?) -> {k1, signature}
|
|
23
|
+
splitNote(callback, k1s, amountMsat, opts?) -> {k1, change, signature, changeSignature}
|
|
24
|
+
mergeNotes(callback, k1s, opts?) -> {k1, signature}
|
|
25
25
|
meltNote(callback, k1, bolt11, opts?) -> {pr?, verify?}
|
|
26
26
|
settleNote(baseUrl, k1, expectedMsat, sig?, opts?) -> {k1, amountMsat, signature?, callback}
|
|
27
27
|
settleNoteForValue(noteUrl, {mints, minMsat, requireSignature?}, opts?)
|
|
@@ -35,10 +35,31 @@ fetchMintAddress(url, opts?) -> {mintPubkey?, payLink, name?, motd?, contact?,
|
|
|
35
35
|
mintPubkey verifies note signatures. It is NOT the node key in nodeUri.
|
|
36
36
|
nodePubkey is a deprecated alias for mintPubkey, dropped next breaking change.
|
|
37
37
|
mintToHash is the fallback copy of the payRequest's flag - see 13.
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
deriveCashRoot(seedBytes) -> CashNode LUD-25's scheme: BIP-32 m/139'
|
|
39
|
+
deriveCashDomainNode(root, host) -> CashNode m/139'/d1/d2/d3/d4, d1..d4 =
|
|
40
|
+
first 16 bytes of HMAC-SHA256(privkey at m/139'/0, host) as 4 raw uint32.
|
|
41
|
+
RAW: BIP-32 reads >= 2^31 as hardened, so ~half the levels are, by the
|
|
42
|
+
host name alone. Never mask the top bit, never harden all four - either
|
|
43
|
+
derives a different tree and restores nothing, silently.
|
|
44
|
+
deriveCashSecret(root, host, index) -> k1 the hardened child i' of that node
|
|
45
|
+
cashSecretAt(domainNode, index) -> k1 no EC needed below the domain node,
|
|
46
|
+
which is why a hardware signer is provisioned with the node, not the seed
|
|
47
|
+
cashSecretSource(root, host, start) -> RandomSecret & {index()}
|
|
48
|
+
cashNodeToHex/cashNodeFromHex privateKey || chainCode, 64 bytes
|
|
49
|
+
deriveCashChild(node, rawUint32) -> CashNode BIP-32 CKDpriv, for checking
|
|
50
|
+
this library against BIP-32's own vectors
|
|
51
|
+
deriveNoteRoot(seedBytes) -> Uint8Array LEGACY, pre-spec. Do not mint under
|
|
52
|
+
deriveNoteSecret(root, host, index) -> k1 it; still scanned so old notes live.
|
|
40
53
|
derivedSecretSource(root, host, start) -> RandomSecret & {index()}
|
|
54
|
+
restoreFromSeed(baseUrl, seed, host, {gap?, start?, probeK1?,
|
|
55
|
+
allowSecretDisclosure?}, opts?) -> {found[] (.scheme), next: {bip32, hmac}}
|
|
56
|
+
walks BOTH schemes. A by-hash walk CANNOT see a spent index (LUD-25 makes
|
|
57
|
+
spent and never-issued the same answer), and a rotate burns the old index,
|
|
58
|
+
so a wallet that rotated more than `gap` times scans as empty. The
|
|
59
|
+
persisted per-host counter is the real backup; the scan is the fallback.
|
|
60
|
+
The counter is not secret - back it up, and merge it upwards only.
|
|
41
61
|
restoreNotes(baseUrl, root, host, {gap?, start?}, opts?) -> {found[], next}
|
|
62
|
+
legacy scheme only
|
|
42
63
|
fetchPayRequest(url, opts?) -> {callback, minSendable, maxSendable, metadata,
|
|
43
64
|
withdrawLink?, mintFee?, mintToHash?} mintToHash: mint accepts an `h`
|
|
44
65
|
fetchInvoiceVerification(url, opts?)
|
|
@@ -53,7 +74,11 @@ paymentRequestAmountMsat(req) -> msat request amount is in SAT, this is the
|
|
|
53
74
|
parseMintFee(metadata) / applyMintFee(gross, fee) / grossUpForMintFee(net, fee)
|
|
54
75
|
createClient(opts) -> all of the above with opts bound
|
|
55
76
|
|
|
56
|
-
Options (always last): {fetch?, timeoutMs?, offline?, randomSecret
|
|
77
|
+
Options (always last): {fetch?, timeoutMs?, offline?, randomSecret?,
|
|
78
|
+
requireSignatures?, mutationRetries?}
|
|
79
|
+
requireSignatures defaults true: refuse a mint that publishes no
|
|
80
|
+
mintPubkey or returns an unsigned mutation. mutationRetries defaults 1:
|
|
81
|
+
re-send a rotate/split/merge whose answer was lost, never a melt.
|
|
57
82
|
|
|
58
83
|
## Rules an implementation MUST follow
|
|
59
84
|
|
|
@@ -64,12 +89,23 @@ Options (always last): {fetch?, timeoutMs?, offline?, randomSecret?}
|
|
|
64
89
|
3. On ANY error from a mutation call newSecretsOf(err) FIRST, persist what
|
|
65
90
|
it returns, then call probeBurnedNote to learn what happened. Treating a
|
|
66
91
|
failure as a failure destroys money the service may already have minted.
|
|
67
|
-
AmbiguousMutationError always carries secrets; so does
|
|
68
|
-
or NoteUnknownError from a mutation, because
|
|
69
|
-
GET looks like
|
|
92
|
+
AmbiguousMutationError always carries secrets; so does UnverifiableNoteError,
|
|
93
|
+
and so does a NoteSpentError or NoteUnknownError from a mutation, because
|
|
94
|
+
that is also what a retried GET looks like at a service that has not
|
|
95
|
+
implemented the replay rule.
|
|
70
96
|
4. RequestRefusedError means nothing was sent - safe to treat as no-op.
|
|
71
97
|
5. A melt's OK means IN FLIGHT, not spent. PendingNoteError means retry, not
|
|
72
98
|
spent.
|
|
99
|
+
5b. Offline verification is MANDATORY. A conforming service publishes
|
|
100
|
+
mintPubkey on every withdrawRequest and returns sig (and sig2 on a split)
|
|
101
|
+
from every rotate/split/merge. This library refuses a service that does
|
|
102
|
+
not; UnverifiableNoteError means the mutation LANDED unsigned, so keep its
|
|
103
|
+
secrets. Opt out per call with requireSignatures: false.
|
|
104
|
+
5c. A retried rotate/split/merge MUST be answered by the service as a replay
|
|
105
|
+
of the original success, so re-sending one whose answer was lost is safe
|
|
106
|
+
and usually completes it. Re-send the IDENTICAL request: the replay is
|
|
107
|
+
matched on the k1 set, h, h2 and amount, so a fresh secret makes it a
|
|
108
|
+
different mutation and a second real burn. Never re-send a melt.
|
|
73
109
|
6. Rotate immediately after claiming a minted note: the mint generated that
|
|
74
110
|
preimage, and LUD-21 verify exposes it to anyone who saw the invoice.
|
|
75
111
|
Does not apply to a note minted with `h` - see 13.
|
|
@@ -112,14 +148,18 @@ Options (always last): {fetch?, timeoutMs?, offline?, randomSecret?}
|
|
|
112
148
|
RequestRefusedError nothing sent, note untouched
|
|
113
149
|
ServiceRejectedError processed and refused (definitive)
|
|
114
150
|
PendingNoteError a melt is in flight on this k1 - retry
|
|
115
|
-
NoteSpentError authoritative: already burned - but from a MUTATION
|
|
116
|
-
|
|
151
|
+
NoteSpentError authoritative: already burned - but from a MUTATION at
|
|
152
|
+
a service that will not replay a retry, it may be a
|
|
153
|
+
retry whose first attempt landed; read
|
|
117
154
|
newSecretsOf(err) before believing it
|
|
118
155
|
NoteUnknownError service does not recognise it
|
|
119
156
|
AmbiguousMintError outcome UNKNOWN - assume nothing
|
|
120
157
|
AmbiguousMutationError carries .newSecrets - persist them
|
|
158
|
+
UnverifiableNoteError the mutation LANDED and came back unsigned. The note is
|
|
159
|
+
real; carries .newSecrets - persist them
|
|
121
160
|
newSecretsOf(err) -> string[] the secrets any error is carrying, or none
|
|
122
|
-
ProtocolError a non-mutating response did not match the spec
|
|
161
|
+
ProtocolError a non-mutating response did not match the spec, which
|
|
162
|
+
includes a withdrawRequest publishing no mintPubkey
|
|
123
163
|
|
|
124
164
|
## Conformance
|
|
125
165
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lnurlcash-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "LNURLcash (LUD-25) bearer note client for TypeScript - mint, rotate, split, merge, melt, and verify offline",
|
|
5
5
|
"author": "TheCryptoDonkey",
|
|
6
6
|
"license": "MIT",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/node": "^26.2.0",
|
|
63
|
-
"lnurlcash-conformance": "^0.
|
|
63
|
+
"lnurlcash-conformance": "^0.7.0",
|
|
64
64
|
"tsup": "^8.5.0",
|
|
65
65
|
"typescript": "^5.7.0",
|
|
66
66
|
"vitest": "^3.0.0"
|