lnurlcash-kit 0.1.0-next.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 +59 -0
- package/LICENSE +26 -0
- package/README.md +199 -0
- package/SECURITY.md +39 -0
- package/THREAT-MODEL.md +158 -0
- package/dist/index.d.ts +193 -0
- package/dist/index.js +708 -0
- package/llms.txt +66 -0
- package/package.json +68 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Semantic versioning. While the LUD-25 draft is unmerged, `0.x` minor bumps
|
|
4
|
+
may carry breaking changes; pin an exact version.
|
|
5
|
+
|
|
6
|
+
## 0.1.0 - 2026-08-20
|
|
7
|
+
|
|
8
|
+
First release. The protocol layer of
|
|
9
|
+
[lnurl-wallet](https://github.com/dni/lnurl-wallet) (MIT, dni), extracted as
|
|
10
|
+
a standalone library.
|
|
11
|
+
|
|
12
|
+
### Changes made on extraction
|
|
13
|
+
|
|
14
|
+
**No globals.** The wallet's `offlineMode()` global became an `offline`
|
|
15
|
+
option, and `fetch`, `timeoutMs` and `randomSecret` joined it. Nothing reads
|
|
16
|
+
ambient state, so a caller can be certain what a call will and will not do.
|
|
17
|
+
`createClient(options)` binds one set for callers who would otherwise thread
|
|
18
|
+
the same object everywhere.
|
|
19
|
+
|
|
20
|
+
**No DOM or storage assumptions.** Only `fetch`, `URL` and `crypto` are
|
|
21
|
+
required, all substitutable.
|
|
22
|
+
|
|
23
|
+
### Behavioural fixes
|
|
24
|
+
|
|
25
|
+
Both were found by the conformance vectors, and both exist in the source
|
|
26
|
+
this was extracted from.
|
|
27
|
+
|
|
28
|
+
**A reasonless service error is no longer reported as an unknown note.**
|
|
29
|
+
`{"status":"ERROR"}` with no `reason` had a friendly default substituted
|
|
30
|
+
before classification, and that default — "Unknown service error" — matched
|
|
31
|
+
the rule for "unknown note". A service that said nothing was therefore
|
|
32
|
+
reported as denying the note exists, and through `probeBurnedNote` that
|
|
33
|
+
reads as "the burn landed": a conclusion about somebody's money drawn from a
|
|
34
|
+
blank. The reason is now carried through exactly as sent, empty included.
|
|
35
|
+
|
|
36
|
+
**`grossUpForMintFee` returns the true minimum, and cannot be stalled.** It
|
|
37
|
+
estimated linearly then walked one msat at a time, bounded by a guard. At a
|
|
38
|
+
99.9999% fee the walk is around a million steps, so the guard tripped and
|
|
39
|
+
the answer came back non-minimal — and the fee is chosen by the service, so
|
|
40
|
+
that input is reachable on purpose. It is now a binary search, which is
|
|
41
|
+
exact and bounded for every fee.
|
|
42
|
+
|
|
43
|
+
**The proportional fee term no longer overflows.** `gross * ppm / 1_000_000`
|
|
44
|
+
exceeds 64-bit unsigned at realistic amounts — 21M BTC is 2.1e15 msat, times
|
|
45
|
+
999_999 ppm is about 2.1e21 — and exceeds a double's exact range too. It is
|
|
46
|
+
computed split. This changes nothing in TypeScript at ordinary amounts, and
|
|
47
|
+
matters a great deal to the ports.
|
|
48
|
+
|
|
49
|
+
### Additions
|
|
50
|
+
|
|
51
|
+
**A mutation naming no note is refused** before it reaches the network,
|
|
52
|
+
rather than sent as a callback with no `k1` for a service to interpret
|
|
53
|
+
generously.
|
|
54
|
+
|
|
55
|
+
**`mint@localhost:8000` resolves.** A Lightning Address needs a dot to be a
|
|
56
|
+
domain, so a bare local host was rejected even though the resolution below
|
|
57
|
+
it already handled the port and the cleartext scheme such a host needs. The
|
|
58
|
+
strict `isLightningAddress` is unchanged; only the resolvers are more
|
|
59
|
+
generous, and only for hosts that are already treated as insecure.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dni
|
|
4
|
+
Copyright (c) 2026 TheCryptoDonkey
|
|
5
|
+
|
|
6
|
+
This library's protocol layer was extracted from lnurl-wallet
|
|
7
|
+
(https://github.com/dni/lnurl-wallet), the LNURLcash reference wallet, which
|
|
8
|
+
is MIT licensed and authored by dni. Subsequent changes are TheCryptoDonkey's.
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# lnurlcash-kit
|
|
2
|
+
|
|
3
|
+
LNURLcash ([LUD-25 draft](https://github.com/lnurl/luds/pull/301)) bearer
|
|
4
|
+
notes for TypeScript: mint, rotate, split, merge, melt, and verify a note
|
|
5
|
+
offline.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install lnurlcash-kit
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This is an early `0.x` release tracking a **draft** spec. Pin an exact
|
|
12
|
+
version.
|
|
13
|
+
|
|
14
|
+
## What a bearer note is
|
|
15
|
+
|
|
16
|
+
An ordinary [LUD-03](https://github.com/lnurl/luds/blob/luds/03.md)
|
|
17
|
+
withdrawRequest link whose `k1` **is** the asset:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
lnurlw://mint.example/w?k1=<secret>&amount=<msat>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Whoever knows the `k1` controls the sats behind it, like a banknote. The
|
|
24
|
+
`amount` alongside it is only a claim by whoever encoded the note; the
|
|
25
|
+
authoritative value is always `maxWithdrawable` from an informational GET.
|
|
26
|
+
|
|
27
|
+
No new endpoint and no new encoding, so a wallet that has never heard of
|
|
28
|
+
LNURLcash sees a normal withdraw link and can still cash it out. Every
|
|
29
|
+
mutating operation is a GET on the `callback` from that withdrawRequest:
|
|
30
|
+
|
|
31
|
+
| Request | Result |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `callback?k1=X&pr=<bolt11>` | **melt**: X burned once `pr` settles |
|
|
34
|
+
| `callback?k1=X&h=<sha256(X')>` | **rotate**: X burned, a note keyed by `h` minted |
|
|
35
|
+
| `callback?k1=X&amount=<msat>&h=..&h2=..` | **split**: X burned, notes keyed by `h` and `h2` minted |
|
|
36
|
+
| `callback?k1=X&k1=Y&h=<sha256(Z)>` | **merge**: all burned, one note keyed by `h` minted |
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import {
|
|
42
|
+
resolveNoteInput,
|
|
43
|
+
fetchNoteInfo,
|
|
44
|
+
rotateNote,
|
|
45
|
+
splitNote,
|
|
46
|
+
meltNote,
|
|
47
|
+
verifyNoteSignature
|
|
48
|
+
} from 'lnurlcash-kit'
|
|
49
|
+
|
|
50
|
+
// accepts a bech32 LNURL, an lnurlw:// URL, or a plain https one
|
|
51
|
+
const url = resolveNoteInput(scanned)
|
|
52
|
+
if (!url) throw new Error('not a note')
|
|
53
|
+
|
|
54
|
+
// what is it actually worth? Only the service can say.
|
|
55
|
+
const info = await fetchNoteInfo(url)
|
|
56
|
+
console.log(info.maxWithdrawable, 'msat')
|
|
57
|
+
|
|
58
|
+
// that GET put the secret on the wire, so rotate it
|
|
59
|
+
const fresh = await rotateNote(info.callback, info.k1)
|
|
60
|
+
|
|
61
|
+
// and check the mint really issued it, without asking anyone
|
|
62
|
+
if (info.mintPubkey && fresh.signature) {
|
|
63
|
+
verifyNoteSignature(fresh.k1, info.maxWithdrawable, fresh.signature, info.mintPubkey)
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Every request function takes options last — `fetch`, `timeoutMs`, `offline`,
|
|
68
|
+
`randomSecret`. `createClient(options)` binds one set once:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const client = createClient({timeoutMs: 10_000})
|
|
72
|
+
await client.rotateNote(callback, k1)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## The five things that will cost you money
|
|
76
|
+
|
|
77
|
+
Everything below is a bug class this library exists to close. If you write
|
|
78
|
+
your own client instead, write these first.
|
|
79
|
+
|
|
80
|
+
**1. Never let the service generate a replacement secret.** On rotate, split
|
|
81
|
+
and merge the *wallet* draws a fresh 32 bytes and discloses only
|
|
82
|
+
`sha256(secret)` as `h`. A service-issued replacement has, structurally,
|
|
83
|
+
been seen by that service — so a "rotate" that accepts one closes no
|
|
84
|
+
exposure at all. This library generates them and ignores any `k1` a
|
|
85
|
+
non-compliant service tries to hand back.
|
|
86
|
+
|
|
87
|
+
**2. A failed mutation is not a failure.** If a rotate times out, the
|
|
88
|
+
service may already have burned your input and minted the output. The fresh
|
|
89
|
+
secret in your process is then the only copy of that money in existence.
|
|
90
|
+
Every mutating call raises `AmbiguousMutationError` carrying `newSecrets` —
|
|
91
|
+
**persist them before doing anything else**, then use `probeBurnedNote` to
|
|
92
|
+
find out what happened:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
try {
|
|
96
|
+
const {k1} = await rotateNote(callback, oldK1)
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (err instanceof AmbiguousMutationError) {
|
|
99
|
+
await save(err.newSecrets) // first. always.
|
|
100
|
+
const fate = await probeBurnedNote(noteUrl)
|
|
101
|
+
// 'live' -> nothing landed, the saved secrets are worthless
|
|
102
|
+
// 'gone' -> the burn landed, the saved secrets ARE the note
|
|
103
|
+
// 'unknown' -> keep everything and try again later
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`RequestRefusedError` is the opposite and safe: nothing left the process.
|
|
109
|
+
|
|
110
|
+
**3. Your HTTP stack must not retry.** Every mutation is a GET, HTTP treats GET
|
|
111
|
+
as idempotent, and an LNURLcash mutation is not — the first attempt burns the
|
|
112
|
+
input. A retried mutation is answered "already spent", which reads as a
|
|
113
|
+
*definitive* rejection, so the fresh secret gets discarded along with the note
|
|
114
|
+
the service just minted. Node's `fetch` does not retry on its own, but a
|
|
115
|
+
browser will resend an idempotent request that failed on a stale pooled
|
|
116
|
+
connection, and any retry wrapper, service worker or proxy in front of this
|
|
117
|
+
will do the same. If you pass your own `fetch`, do not make it retry these.
|
|
118
|
+
|
|
119
|
+
This is not hypothetical: the same hazard broke the
|
|
120
|
+
[Kotlin](https://github.com/TheCryptoDonkey/lnurlcash-kotlin) and
|
|
121
|
+
[Go](https://github.com/TheCryptoDonkey/lnurlcash-go) siblings during
|
|
122
|
+
development, by two different mechanisms, and is now a named scenario in the
|
|
123
|
+
conformance vectors.
|
|
124
|
+
|
|
125
|
+
**4. A melt's `OK` means "in flight", not "spent".** The service pays
|
|
126
|
+
asynchronously and only burns the note once the payment settles, restoring
|
|
127
|
+
it if the payment fails. A failed melt is never reported back through the
|
|
128
|
+
callback — it is only observable as the note becoming spendable again. Other
|
|
129
|
+
operations on that `k1` raise `PendingNoteError` meanwhile; retry, never
|
|
130
|
+
read it as spent.
|
|
131
|
+
|
|
132
|
+
**5. Rotate the instant you claim a minted note.** The preimage that mints a
|
|
133
|
+
note is generated by the service, and if it serves
|
|
134
|
+
[LUD-21](https://github.com/lnurl/luds/blob/luds/21.md) `verify`, *anyone*
|
|
135
|
+
who saw the unpaid invoice can poll for it — the payment hash travels inside
|
|
136
|
+
the invoice. First rotater wins. A wallet that rotates on settlement wins by
|
|
137
|
+
construction; a human copying a preimage by hand does not.
|
|
138
|
+
|
|
139
|
+
## Offline verification
|
|
140
|
+
|
|
141
|
+
A service may sign each note with its Lightning node identity key, so a
|
|
142
|
+
holder can confirm issuer and amount with nothing but the note:
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
message = "LNURLcash:" || amount_msat || ":" || hex(sha256(k1))
|
|
146
|
+
digest = sha256(sha256("Lightning Signed Message:" || message))
|
|
147
|
+
sig = 65 bytes, r || s || recovery_id
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`verifyNoteSignature` recovers the pubkey and compares it to `mintPubkey`.
|
|
151
|
+
It accepts the recovery id at either end, because lnurl-mint once emitted
|
|
152
|
+
the reverse layout and other implementations may still; trying both is safe,
|
|
153
|
+
since the wrong ordering recovers an unrelated key that cannot match.
|
|
154
|
+
|
|
155
|
+
The signature commits to the note's *hash*, not its secret — so you can
|
|
156
|
+
prove a mint issued a note, to expose one that will not honour it, without
|
|
157
|
+
handing over what would let anyone spend it.
|
|
158
|
+
|
|
159
|
+
## Scope
|
|
160
|
+
|
|
161
|
+
This library speaks the protocol. It does not store notes, hold keys, manage
|
|
162
|
+
a balance, pay invoices, or decide anything about your UI. Storage and key
|
|
163
|
+
management are yours, and they are where most of the remaining risk lives —
|
|
164
|
+
see [THREAT-MODEL.md](THREAT-MODEL.md).
|
|
165
|
+
|
|
166
|
+
Amounts are integers in **milli-satoshis**, everywhere, with no exceptions.
|
|
167
|
+
|
|
168
|
+
## Provenance
|
|
169
|
+
|
|
170
|
+
The protocol layer was extracted from
|
|
171
|
+
[lnurl-wallet](https://github.com/dni/lnurl-wallet), the LNURLcash reference
|
|
172
|
+
wallet by dni, rather than reimplemented — that code has been exercised
|
|
173
|
+
against a real mint and an adversarial mock, and a fresh rewrite would have
|
|
174
|
+
thrown that away to no one's benefit.
|
|
175
|
+
|
|
176
|
+
The reference implementations, both dni's, both MIT:
|
|
177
|
+
|
|
178
|
+
- [lnurl-mint](https://github.com/dni/lnurl-mint) — the reference service
|
|
179
|
+
- [lnurl-wallet](https://github.com/dni/lnurl-wallet) — the reference wallet
|
|
180
|
+
|
|
181
|
+
Changes made on extraction are listed in [CHANGELOG.md](CHANGELOG.md); two
|
|
182
|
+
are behavioural fixes worth reading if you are porting from that code.
|
|
183
|
+
|
|
184
|
+
## Conformance
|
|
185
|
+
|
|
186
|
+
Tested against [lnurlcash-conformance](https://github.com/TheCryptoDonkey/lnurlcash-conformance):
|
|
187
|
+
language-neutral vectors plus a mock mint that can be told to misbehave —
|
|
188
|
+
drop a connection mid-mutation, sign in the wrong byte order, lie about a
|
|
189
|
+
note's value, never settle a melt. If you are writing an LNURLcash
|
|
190
|
+
implementation in any language, run those vectors before you run real sats
|
|
191
|
+
through it.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
npm test
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT. See [LICENSE](LICENSE) for the attribution.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
`0.x` tracks a draft spec ([LUD-25](https://github.com/lnurl/luds/pull/301)).
|
|
6
|
+
Only the latest `0.x` release is supported. Pin an exact version.
|
|
7
|
+
|
|
8
|
+
## Reporting a vulnerability
|
|
9
|
+
|
|
10
|
+
Report privately through GitHub's advisory form:
|
|
11
|
+
|
|
12
|
+
<https://github.com/TheCryptoDonkey/lnurlcash-kit/security/advisories/new>
|
|
13
|
+
|
|
14
|
+
Please do not open a public issue for anything that could be used to take
|
|
15
|
+
somebody's notes.
|
|
16
|
+
|
|
17
|
+
Include what you can: affected version, a reproduction, and what an attacker
|
|
18
|
+
gets out of it. A rough report today beats a polished one next month.
|
|
19
|
+
|
|
20
|
+
Expect an acknowledgement within a few days. This is maintained by one
|
|
21
|
+
person, so timelines are best-effort rather than contractual.
|
|
22
|
+
|
|
23
|
+
## Scope
|
|
24
|
+
|
|
25
|
+
**In scope**: anything in this library that could lose or leak a note —
|
|
26
|
+
secrets on the wire, a mutation misclassified as definitive, verification
|
|
27
|
+
that accepts a signature it should not, URL admission bypasses, a fee or
|
|
28
|
+
amount calculation that can be steered by a hostile mint.
|
|
29
|
+
|
|
30
|
+
**Out of scope**: how a calling application stores secrets, weak RNG
|
|
31
|
+
substituted through `randomSecret`, the behaviour of any particular mint, and
|
|
32
|
+
the LUD-25 draft itself — spec concerns belong on
|
|
33
|
+
[the PR](https://github.com/lnurl/luds/pull/301), where they help everyone
|
|
34
|
+
rather than just this library.
|
|
35
|
+
|
|
36
|
+
If a finding affects the protocol rather than this implementation, it is
|
|
37
|
+
worth telling the reference implementations too:
|
|
38
|
+
[lnurl-mint](https://github.com/dni/lnurl-mint) and
|
|
39
|
+
[lnurl-wallet](https://github.com/dni/lnurl-wallet).
|
package/THREAT-MODEL.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Threat model
|
|
2
|
+
|
|
3
|
+
What this library defends against, what it cannot, and what it hands to you.
|
|
4
|
+
|
|
5
|
+
## What it is
|
|
6
|
+
|
|
7
|
+
A protocol client for LNURLcash bearer notes. It builds requests, classifies
|
|
8
|
+
responses, generates replacement note secrets, and verifies mint signatures
|
|
9
|
+
offline. It holds no state between calls.
|
|
10
|
+
|
|
11
|
+
## Assets
|
|
12
|
+
|
|
13
|
+
**Note secrets (`k1`).** Bearer instruments. Whoever holds one can spend it,
|
|
14
|
+
with no further authentication, from anywhere. Compromise is theft, and it
|
|
15
|
+
is silent and irreversible: the money is gone before the previous holder has
|
|
16
|
+
any way to notice.
|
|
17
|
+
|
|
18
|
+
**Replacement secrets awaiting confirmation.** After a mutation whose outcome
|
|
19
|
+
is unknown, the secrets generated in-process may be the only copies of notes
|
|
20
|
+
a service has already minted. Losing them destroys the money exactly as
|
|
21
|
+
thoroughly as leaking them gives it away.
|
|
22
|
+
|
|
23
|
+
**Mint pubkeys.** Not secret, but a wrong one makes offline verification
|
|
24
|
+
meaningless.
|
|
25
|
+
|
|
26
|
+
## Trust boundaries
|
|
27
|
+
|
|
28
|
+
| Party | Trusted for |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| the SERVICE (mint) | custody of the sats, and honest accounting. Nothing else. |
|
|
31
|
+
| the caller's storage | confidentiality and durability of secrets. This library provides neither. |
|
|
32
|
+
| the caller's RNG | unpredictability of replacement secrets. Substitutable, and load-bearing. |
|
|
33
|
+
| the network | nothing. |
|
|
34
|
+
|
|
35
|
+
A mint is trusted with custody by construction — it holds the funds. It is
|
|
36
|
+
*not* trusted to describe them accurately, which is why value comes from
|
|
37
|
+
`maxWithdrawable` rather than from a note URL's own `amount`, and why a
|
|
38
|
+
signed note can be checked against a key the mint published earlier.
|
|
39
|
+
|
|
40
|
+
## What this library defends against
|
|
41
|
+
|
|
42
|
+
**A service that keeps a copy of your note.** Rotate, split and merge disclose
|
|
43
|
+
only `sha256(secret)`. The service registers the note under that hash and
|
|
44
|
+
never sees the secret. This is the difference between a bearer note and a
|
|
45
|
+
receipt, and it is why a service-generated replacement is refused even when
|
|
46
|
+
offered (`serverGeneratedSecrets` in the mock mint exercises exactly this).
|
|
47
|
+
|
|
48
|
+
**A mutation whose outcome is unknown.** Timeouts, dropped connections,
|
|
49
|
+
unreadable bodies and unconfirmed 200s are all raised as
|
|
50
|
+
`AmbiguousMutationError` carrying the fresh secrets, never as failure.
|
|
51
|
+
Requests that provably never left — offline mode, a refused URL, an
|
|
52
|
+
unparseable callback — are raised as `RequestRefusedError` instead, which is
|
|
53
|
+
safe to treat as "nothing happened".
|
|
54
|
+
|
|
55
|
+
**A note that answers its own questions.** Every URL fetched, whether scanned
|
|
56
|
+
by a user or supplied by a service in its own response, must be https, or
|
|
57
|
+
http to loopback or `.onion`. A `data:` URL carrying withdrawRequest JSON
|
|
58
|
+
would otherwise mint a self-contained fake note that verifies against
|
|
59
|
+
nothing. Redirects are followed by hand, each hop re-admitted against the
|
|
60
|
+
same rule, so an https endpoint cannot pass a request - and the k1 a
|
|
61
|
+
callback URL carries - off to cleartext or another scheme mid-chain. And
|
|
62
|
+
every response body is read with a size cap, so one hostile answer cannot
|
|
63
|
+
exhaust the caller's memory.
|
|
64
|
+
|
|
65
|
+
**A service that inflates a note.** With offline verification configured, the
|
|
66
|
+
signature commits to the amount. A service reporting more than it signed
|
|
67
|
+
fails verification, without the holder contacting anyone.
|
|
68
|
+
|
|
69
|
+
**A service that swaps your note.** The informational GET checks that the
|
|
70
|
+
echoed `k1` is the one queried. A different one means either a non-compliant
|
|
71
|
+
service or a note redeemed by somebody else.
|
|
72
|
+
|
|
73
|
+
**A secret leaking through a query string.** `sig` is stripped before the
|
|
74
|
+
informational GET, since the service already knows what it signed.
|
|
75
|
+
|
|
76
|
+
**A hostile fee advertisement.** Fees of 100% or more are refused at parse
|
|
77
|
+
time, and the gross-up search is a binary search rather than a walk — so a
|
|
78
|
+
service cannot stall a caller with an extreme fee.
|
|
79
|
+
|
|
80
|
+
**Integer overflow on realistic amounts.** The proportional fee term is
|
|
81
|
+
computed split, because 21M BTC in msat times a high ppm exceeds 64-bit
|
|
82
|
+
unsigned. Ports that multiply naively pass every small test and mangle large
|
|
83
|
+
ones; the conformance vectors include a case that catches it.
|
|
84
|
+
|
|
85
|
+
## What it does not defend against
|
|
86
|
+
|
|
87
|
+
**Storage compromise.** This library never persists anything. If your
|
|
88
|
+
storage is readable — an unencrypted database, a synced folder, a debugger,
|
|
89
|
+
a crash dump — every note in it is spendable by whoever reads it. Encrypt at
|
|
90
|
+
rest, and treat backups as the same exposure.
|
|
91
|
+
|
|
92
|
+
**A weak RNG.** `randomSecret` is replaceable, which means it can be replaced
|
|
93
|
+
badly. A predictable secret is a note anyone can mint themselves. Use the
|
|
94
|
+
platform CSPRNG, or a hardware RNG, and nothing else.
|
|
95
|
+
|
|
96
|
+
**Secrets in logs.** A note URL carries its secret in a query string. A
|
|
97
|
+
request logger, an error reporter, a crash handler or an analytics SDK that
|
|
98
|
+
records URLs records bearer money. This library never logs; what wraps it
|
|
99
|
+
might.
|
|
100
|
+
|
|
101
|
+
**A malicious or compromised mint.** It holds the funds. It can refuse to
|
|
102
|
+
honour a note, vanish, or inflate its liabilities. Offline verification
|
|
103
|
+
proves what it *said*, which is useful for exposing it afterwards, and is
|
|
104
|
+
not custody.
|
|
105
|
+
|
|
106
|
+
**The mint-time preimage race.** A freshly minted note's secret is the
|
|
107
|
+
invoice preimage, so the service has necessarily seen it, and anyone who saw
|
|
108
|
+
the unpaid invoice can poll LUD-21 `verify` for it the moment it settles.
|
|
109
|
+
Rotating immediately wins that race; a slow manual flow does not. Do not
|
|
110
|
+
publish unpaid mint invoices.
|
|
111
|
+
|
|
112
|
+
**Traffic analysis.** Every operation reaches the mint directly. The mint
|
|
113
|
+
learns your IP, your timing, and which notes move together. Notes are bearer
|
|
114
|
+
instruments, not private ones — merging several notes tells the mint they
|
|
115
|
+
had one holder. Route over Tor if that matters.
|
|
116
|
+
|
|
117
|
+
**Anything about the sats themselves.** No custody, no channel management,
|
|
118
|
+
no payment routing.
|
|
119
|
+
|
|
120
|
+
## The retry hazard
|
|
121
|
+
|
|
122
|
+
Every LNURLcash mutation is an HTTP GET, and HTTP treats GET as idempotent, so
|
|
123
|
+
a stack may resend one when a connection fails mid-flight. An LNURLcash
|
|
124
|
+
mutation is not idempotent: the first attempt burns the input.
|
|
125
|
+
|
|
126
|
+
A retried mutation is therefore answered "already spent", which classifies as a
|
|
127
|
+
definitive rejection — so a caller concludes nothing happened and discards the
|
|
128
|
+
fresh secret that was the only copy of the note the service just minted.
|
|
129
|
+
|
|
130
|
+
This library never retries, and the `fetch` it uses by default does not either
|
|
131
|
+
in Node. It cannot control what wraps it: a browser resending an idempotent
|
|
132
|
+
request on a stale pooled connection, a service worker, a retry interceptor, a
|
|
133
|
+
proxy or a mesh with automatic retries would all reintroduce it. A caller
|
|
134
|
+
passing its own `fetch` owns this.
|
|
135
|
+
|
|
136
|
+
The hazard is real rather than theoretical — it broke the Kotlin and Go
|
|
137
|
+
siblings during development, by two different mechanisms — and is a named
|
|
138
|
+
scenario in the conformance vectors.
|
|
139
|
+
|
|
140
|
+
## Deliberate design choices
|
|
141
|
+
|
|
142
|
+
**Both signature recovery-id orderings are accepted.** The wire format is
|
|
143
|
+
`r || s || recovery_id`; lnurl-mint once emitted the reverse. Trying both is
|
|
144
|
+
not a weakening: recovering under the wrong ordering yields an unrelated
|
|
145
|
+
pubkey, which cannot match the expected one.
|
|
146
|
+
|
|
147
|
+
**Errors are typed by whether the request could have been processed**, not by
|
|
148
|
+
transport detail. That distinction is the whole safety model, and message
|
|
149
|
+
text is not a stable interface — never branch on it.
|
|
150
|
+
|
|
151
|
+
**No global state.** Offline mode, fetch, timeout and RNG are parameters. A
|
|
152
|
+
caller that wants certainty nothing reaches the network sets `offline` and
|
|
153
|
+
gets a refusal, rather than trusting that no code path happens to make a
|
|
154
|
+
request.
|
|
155
|
+
|
|
156
|
+
## Reporting
|
|
157
|
+
|
|
158
|
+
See [SECURITY.md](SECURITY.md).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
declare const isBech32Lnurl: (data: string) => boolean;
|
|
2
|
+
declare const toBech32Lnurl: (url: string) => string;
|
|
3
|
+
declare const fromBech32Lnurl: (data: string) => string | null;
|
|
4
|
+
declare const isAllowedServiceUrl: (value: string) => boolean;
|
|
5
|
+
declare const fromLud17: (url: string) => string;
|
|
6
|
+
declare const toLud17w: (url: string) => string;
|
|
7
|
+
declare const isLightningAddress: (value: string) => boolean;
|
|
8
|
+
declare const resolveMintInput: (value: string) => string | null;
|
|
9
|
+
declare const mintAddressUrl: (payUrl: string) => string | null;
|
|
10
|
+
declare const lightningAddressUsername: (payUrl: string) => string | null;
|
|
11
|
+
declare const resolveLnurlInput: (value: string) => string | null;
|
|
12
|
+
declare const serverOf: (url: string) => string;
|
|
13
|
+
|
|
14
|
+
declare const noteK1: (url: string) => string | null;
|
|
15
|
+
declare const requireNoteK1: (url: string) => string;
|
|
16
|
+
declare const noteDeclaredAmount: (url: string) => number | null;
|
|
17
|
+
declare const noteSignature: (url: string) => string | null;
|
|
18
|
+
declare const resolveNoteInput: (value: string) => string | null;
|
|
19
|
+
declare const isValidNoteInput: (value: string) => boolean;
|
|
20
|
+
declare const buildNoteUrl: (withdrawLink: string, k1: string, amountMsat?: number) => string;
|
|
21
|
+
declare const withNewK1: (url: string, k1: string, amountMsat: number, signature?: string) => string;
|
|
22
|
+
declare const withoutK1: (url: string, amountMsat: number, signature?: string) => string;
|
|
23
|
+
|
|
24
|
+
declare const hashK1: (k1: string) => string;
|
|
25
|
+
type RandomSecret = () => string;
|
|
26
|
+
declare const defaultRandomSecret: RandomSecret;
|
|
27
|
+
declare const isPreimage: (value: string) => boolean;
|
|
28
|
+
|
|
29
|
+
declare const noteSignatureMessage: (k1: string, amountMsat: number) => string;
|
|
30
|
+
declare const noteSignatureDigest: (k1: string, amountMsat: number) => Uint8Array;
|
|
31
|
+
declare const verifyNoteSignature: (k1: string, amountMsat: number, signatureHex: string, mintPubkeyHex: string) => boolean;
|
|
32
|
+
|
|
33
|
+
type MintFee = {
|
|
34
|
+
baseFeeMsat: number;
|
|
35
|
+
feePpm: number;
|
|
36
|
+
};
|
|
37
|
+
declare const parseMintFee: (metadata: string) => MintFee | null;
|
|
38
|
+
declare const applyMintFee: (grossMsat: number, fee: MintFee) => number;
|
|
39
|
+
declare const grossUpForMintFee: (netMsat: number, fee: MintFee) => number;
|
|
40
|
+
declare const formatFeePercent: (ppm: number) => string;
|
|
41
|
+
declare const describeMintFee: (fee: MintFee) => string;
|
|
42
|
+
|
|
43
|
+
declare const isBolt11Invoice: (value: string) => boolean;
|
|
44
|
+
declare const sameInvoice: (a: string, b: string) => boolean;
|
|
45
|
+
declare const decodeBolt11AmountMsat: (pr: string) => number | null;
|
|
46
|
+
|
|
47
|
+
declare class LnurlcashError extends Error {
|
|
48
|
+
constructor(message: string);
|
|
49
|
+
}
|
|
50
|
+
declare class RequestRefusedError extends LnurlcashError {
|
|
51
|
+
}
|
|
52
|
+
declare class ProtocolError extends LnurlcashError {
|
|
53
|
+
}
|
|
54
|
+
declare class ServiceRejectedError extends LnurlcashError {
|
|
55
|
+
readonly reason: string;
|
|
56
|
+
constructor(reason: string);
|
|
57
|
+
}
|
|
58
|
+
declare class PendingNoteError extends ServiceRejectedError {
|
|
59
|
+
constructor(reason?: string);
|
|
60
|
+
}
|
|
61
|
+
declare class NoteSpentError extends ServiceRejectedError {
|
|
62
|
+
constructor(reason: string);
|
|
63
|
+
}
|
|
64
|
+
declare class NoteUnknownError extends ServiceRejectedError {
|
|
65
|
+
constructor(reason: string);
|
|
66
|
+
}
|
|
67
|
+
declare class AmbiguousMintError extends LnurlcashError {
|
|
68
|
+
}
|
|
69
|
+
declare class AmbiguousMutationError extends AmbiguousMintError {
|
|
70
|
+
readonly newSecrets: string[];
|
|
71
|
+
constructor(message: string, newSecrets: string[]);
|
|
72
|
+
}
|
|
73
|
+
declare const classifyNoteError: (reason: string) => ServiceRejectedError;
|
|
74
|
+
|
|
75
|
+
type LnurlcashOptions = {
|
|
76
|
+
fetch?: typeof globalThis.fetch;
|
|
77
|
+
timeoutMs?: number;
|
|
78
|
+
offline?: boolean;
|
|
79
|
+
randomSecret?: RandomSecret;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type WithdrawRequestInfo = {
|
|
83
|
+
tag: 'withdrawRequest';
|
|
84
|
+
callback: string;
|
|
85
|
+
k1: string;
|
|
86
|
+
minWithdrawable: number;
|
|
87
|
+
maxWithdrawable: number;
|
|
88
|
+
defaultDescription?: string;
|
|
89
|
+
mintPubkey?: string;
|
|
90
|
+
};
|
|
91
|
+
declare const fetchNoteInfo: (url: string, options?: LnurlcashOptions) => Promise<WithdrawRequestInfo>;
|
|
92
|
+
declare const probeBurnedNote: (url: string, options?: LnurlcashOptions) => Promise<"live" | "gone" | "unknown">;
|
|
93
|
+
type MintAddressInfo = {
|
|
94
|
+
tag: 'withdrawRequest';
|
|
95
|
+
callback: string;
|
|
96
|
+
minWithdrawable: number;
|
|
97
|
+
maxWithdrawable: number;
|
|
98
|
+
defaultDescription?: string;
|
|
99
|
+
nodePubkey?: string;
|
|
100
|
+
payLink: string;
|
|
101
|
+
nodeAlias?: string;
|
|
102
|
+
nodeUri?: string;
|
|
103
|
+
nodeColor?: string;
|
|
104
|
+
nodeCapacityMsat?: number;
|
|
105
|
+
nodeNumChannels?: number;
|
|
106
|
+
nodeNumPeers?: number;
|
|
107
|
+
};
|
|
108
|
+
declare const fetchMintAddress: (url: string, options?: LnurlcashOptions) => Promise<MintAddressInfo>;
|
|
109
|
+
type WithdrawSuccessResponse = {
|
|
110
|
+
status: 'OK';
|
|
111
|
+
sig?: string;
|
|
112
|
+
sig2?: string;
|
|
113
|
+
pr?: string;
|
|
114
|
+
verify?: string;
|
|
115
|
+
};
|
|
116
|
+
type MeltResult = {
|
|
117
|
+
verify?: string;
|
|
118
|
+
pr?: string;
|
|
119
|
+
};
|
|
120
|
+
declare const meltNote: (callback: string, k1: string, pr: string, options?: LnurlcashOptions) => Promise<MeltResult>;
|
|
121
|
+
type HashedMutationResult = {
|
|
122
|
+
signature?: string;
|
|
123
|
+
};
|
|
124
|
+
declare const rotateNoteWithHash: (callback: string, k1: string, h: string, options?: LnurlcashOptions) => Promise<HashedMutationResult>;
|
|
125
|
+
type HashedSplitResult = {
|
|
126
|
+
signature?: string;
|
|
127
|
+
changeSignature?: string;
|
|
128
|
+
};
|
|
129
|
+
declare const splitNoteWithHash: (callback: string, k1s: string[], amountMsat: number, h: string, h2: string, options?: LnurlcashOptions) => Promise<HashedSplitResult>;
|
|
130
|
+
declare const mergeNotesWithHash: (callback: string, k1s: string[], h: string, options?: LnurlcashOptions) => Promise<HashedMutationResult>;
|
|
131
|
+
type RotateResult = {
|
|
132
|
+
k1: string;
|
|
133
|
+
signature?: string;
|
|
134
|
+
};
|
|
135
|
+
declare const rotateNote: (callback: string, k1: string, options?: LnurlcashOptions) => Promise<RotateResult>;
|
|
136
|
+
type SplitResult = {
|
|
137
|
+
k1: string;
|
|
138
|
+
signature?: string;
|
|
139
|
+
change: string;
|
|
140
|
+
changeSignature?: string;
|
|
141
|
+
};
|
|
142
|
+
declare const splitNote: (callback: string, k1s: string[], amountMsat: number, options?: LnurlcashOptions) => Promise<SplitResult>;
|
|
143
|
+
declare const mergeNotes: (callback: string, k1s: string[], options?: LnurlcashOptions) => Promise<RotateResult>;
|
|
144
|
+
type SettledNote = {
|
|
145
|
+
k1: string;
|
|
146
|
+
amountMsat: number;
|
|
147
|
+
signature?: string;
|
|
148
|
+
callback: string;
|
|
149
|
+
};
|
|
150
|
+
declare const settleNote: (baseUrl: string, k1: string, expectedAmountMsat: number, signature: string | undefined, options?: LnurlcashOptions) => Promise<SettledNote>;
|
|
151
|
+
type PayRequestInfo = {
|
|
152
|
+
tag: 'payRequest';
|
|
153
|
+
callback: string;
|
|
154
|
+
minSendable: number;
|
|
155
|
+
maxSendable: number;
|
|
156
|
+
metadata: string;
|
|
157
|
+
withdrawLink?: string;
|
|
158
|
+
mintPubkey?: string;
|
|
159
|
+
mintFee?: MintFee;
|
|
160
|
+
};
|
|
161
|
+
declare const fetchPayRequest: (url: string, options?: LnurlcashOptions) => Promise<PayRequestInfo>;
|
|
162
|
+
type InvoiceResult = {
|
|
163
|
+
pr: string;
|
|
164
|
+
verify?: string;
|
|
165
|
+
disposable: boolean;
|
|
166
|
+
};
|
|
167
|
+
declare const requestInvoice: (payCallback: string, amountMsat: number, options?: LnurlcashOptions) => Promise<InvoiceResult>;
|
|
168
|
+
type VerifyResult = {
|
|
169
|
+
settled: boolean;
|
|
170
|
+
preimage: string | null;
|
|
171
|
+
pr: string;
|
|
172
|
+
};
|
|
173
|
+
declare const fetchInvoiceVerification: (verifyUrl: string, options?: LnurlcashOptions) => Promise<VerifyResult>;
|
|
174
|
+
|
|
175
|
+
declare const createClient: (options?: LnurlcashOptions) => {
|
|
176
|
+
fetchNoteInfo: (url: string) => Promise<WithdrawRequestInfo>;
|
|
177
|
+
probeBurnedNote: (url: string) => Promise<"live" | "gone" | "unknown">;
|
|
178
|
+
fetchMintAddress: (url: string) => Promise<MintAddressInfo>;
|
|
179
|
+
meltNote: (callback: string, k1: string, pr: string) => Promise<MeltResult>;
|
|
180
|
+
rotateNote: (callback: string, k1: string) => Promise<RotateResult>;
|
|
181
|
+
rotateNoteWithHash: (callback: string, k1: string, h: string) => Promise<HashedMutationResult>;
|
|
182
|
+
splitNote: (callback: string, k1s: string[], amountMsat: number) => Promise<SplitResult>;
|
|
183
|
+
splitNoteWithHash: (callback: string, k1s: string[], amountMsat: number, h: string, h2: string) => Promise<HashedSplitResult>;
|
|
184
|
+
mergeNotes: (callback: string, k1s: string[]) => Promise<RotateResult>;
|
|
185
|
+
mergeNotesWithHash: (callback: string, k1s: string[], h: string) => Promise<HashedMutationResult>;
|
|
186
|
+
settleNote: (baseUrl: string, k1: string, expectedAmountMsat: number, signature?: string) => Promise<SettledNote>;
|
|
187
|
+
fetchPayRequest: (url: string) => Promise<PayRequestInfo>;
|
|
188
|
+
requestInvoice: (payCallback: string, amountMsat: number) => Promise<InvoiceResult>;
|
|
189
|
+
fetchInvoiceVerification: (verifyUrl: string) => Promise<VerifyResult>;
|
|
190
|
+
};
|
|
191
|
+
type LnurlcashClient = ReturnType<typeof createClient>;
|
|
192
|
+
|
|
193
|
+
export { AmbiguousMintError, AmbiguousMutationError, type HashedMutationResult, type HashedSplitResult, type InvoiceResult, type LnurlcashClient, LnurlcashError, type LnurlcashOptions, type MeltResult, type MintAddressInfo, type MintFee, NoteSpentError, NoteUnknownError, type PayRequestInfo, PendingNoteError, ProtocolError, type RandomSecret, RequestRefusedError, type RotateResult, ServiceRejectedError, type SettledNote, type SplitResult, type VerifyResult, type WithdrawRequestInfo, type WithdrawSuccessResponse, applyMintFee, buildNoteUrl, classifyNoteError, createClient, decodeBolt11AmountMsat, defaultRandomSecret, describeMintFee, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeNotes, mergeNotesWithHash, mintAddressUrl, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureMessage, parseMintFee, probeBurnedNote, requestInvoice, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, verifyNoteSignature, withNewK1, withoutK1 };
|