Repository navigation
Expand file tree
/
Copy pathllms.txt
More file actions
282 lines (264 loc) · 19.2 KB
/
Copy pathllms.txt
File metadata and controls
282 lines (264 loc) · 19.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# notecase
> LNURLcash (LUD-25) wallet for Node 22+: receive, hold, split, merge, send
> and melt Lightning bearer notes. CLI plus a reusable engine. ESM-only. MIT.
Install: `npm install @forgesworn/notecase`
Companion mint: @forgesworn/moneyer
Protocol client it builds on: @lnurlcash/kit
Draft spec: https://github.com/lnurl/luds/pull/301
## The rules the engine enforces (and any wallet MUST)
1. Persist before disclose: a replacement secret reaches disk BEFORE its
hash goes on the wire, and on a seeded wallet the derivation counter
goes to disk in the SAME write. Use the kit's *WithHash variants, never
the generating ones, if you care about crashes. A wasted index costs
nothing; the other order loses a note.
2. Only a DEFINITIVE rejection discards a staged secret. Timeouts, broken
JSON, dropped connections and unconfirmed 200s all keep it, parked as
ambiguous, until a probe of the mint proves what happened. A mutation
is a GET and HTTP stacks retry a dropped GET, so "already spent" about
a LIVE input is not definitive either: it is what a mint says to a
byte-identical repeat of a request that already went through. Park it.
Refusals that cannot be a landed mutation - malformed hash, dust, fee,
sunsetting - stay definitive.
3. Melt OK means in flight. The note locks; settlement is confirmed via
LUD-21 verify, and a failed melt is recovered by a rotate probe - safe
in every outcome by construction.
4. Rotate immediately on receive and on mint claim: the previous holder
(or anyone who saw the invoice) still knows the old secret.
5. maxWithdrawable from the informational GET is the note's value; the
URL's amount parameter is an unverified claim.
6. Pin mint pubkeys on first use; refuse hard on a change, UNLESS the
mint's own discovery lists the pinned key in `previousPubkeys` - then
retire the old key to a kept history, pin the new one, and report it.
Verification accepts the pin or any retired key. A `sig` that verifies
against none of them is a REFUSAL (BadSignatureError), not a warning -
overridable only per receive. No `sig` at all is a warning.
7. Verify NWC payments by preimage against the invoice's payment hash -
a successful-looking response is not evidence money moved.
8. Never print, log or echo a k1 except on explicit send/export.
9. Offline is a promise, not a guess. Never infer it from connectivity:
an offline send makes no wire call at all, and an offline receive
verifies the mint's signature against a pinned key or refuses. A note
taken that way is stored unrotated and rotated by the next reconcile.
10. Sweep every note against its mint from time to time: a bearer note has
copies, and one burned out of band stays listed as money until asked
about. A mint that does not answer is never marked on.
## CLI
notecase init [--insecure-plaintext] [--restore] | seed | restore | adopt
notecase mints add <address|lnurl> / list / use <host>
notecase mint <sats> [--manual] [--mint <host>]
notecase receive <note> [--force] | send <sats> [--notes <id,..>] | melt <bolt11 [sats] | sats --to <addr> | sats --to-nwc> [--notes <id,..>]
notecase check [--apply] [--resign] [--mint <host>]
notecase ladder [set <sats,...>] [--copies n] | prepare [--apply]
notecase send <sats> --offline [--overpay] [--notes <id,..>] | receive --offline
notecase address [claim <name> --mint <host>] | address keys [<name>] | address custodial [<name>] | address scan
notecase balance | list [--all] | reconcile | verify <note>
notecase nwc set <uri> / status / clear # spend THROUGH another wallet
notecase nwc grant <name> [--methods a,b] [--budget <sats>] [--max <sats>]
notecase nwc grants / revoke <name|id> / refill <name|id> [--budget <sats>] / serve
- the service side: NIP-47 over THIS wallet. allowlist defaults to
get_info,make_invoice,lookup_invoice; pay_invoice and get_balance are
opt-in and pay_invoice REQUIRES a budget. one request answered once
(id persisted before the melt), per-connection service keys, 5 min
request age cap. answers only while `nwc serve` runs.
notecase backup export | shares [--threshold N --count M] | recover-key
notecase sync [on|off|status] # notes themselves on relays, several devices, one seed
notecase heartwood link <bunker://...> | notes | collect [<id>...] | send <id> --to <npub> | trust <npub> | inbox | pair
notecase heartwood address keys|custodial|unregister <name> | address scan [name...] # a name the DEVICE's key owns, paid to the device's keys
notecase heartwood recover [--npub <npub>] [--mint <host>] # a lost heartwood's notes, from its nsec or BIP-39 phrase
Amounts are sats (--msat for msat). PIN via prompt or $NOTECASE_PIN.
NOTECASE_HOME relocates the store; NOTECASE_ALLOW_PRIVATE=1 admits LAN mints;
NOTECASE_PROXY=socks5://host:port routes every call through SOCKS5 (Tor). A
proxy turns DNS pinning OFF on purpose: the hostname is resolved at the far
end, so the local resolver never learns which mint the wallet banks with.
## Secrets
Every note secret is derived, so twelve words plus the mint hosts are
enough to find the money:
(d1, d2, d3, d4) = HMAC-SHA256(key = m/139'/0, msg = "<mint host>")[0..16]
read big-endian as 4 uint32
k1[i] = m/139'/d1/d2/d3/d4/i'
LUD-25's own scheme. d1..d4 are RAW uint32 used exactly as they fall: BIP-32
reads >= 2^31 as hardened, so which levels are hardened depends on the host
name. Never mask the top bit, never harden all four - either derives a
different tree. host is the exact lowercase host[:port]; i is decimal from 0,
one counter per mint. Restore walks i upward and stops after 20 consecutive
unknowns, and walks the pre-spec HMAC scheme (key "lnurlcash-note-v1", msg
"<host>:<i>") alongside it for notes minted before LUD-25 specified one.
The counter is half the backup, not an optimisation: a mint answers a
private lookup for a burned note exactly as for one never issued, and a
rotate burns the index below, so a wallet that rotated more than the gap
scans as empty without it. Not secret; back it up; moves upwards only.
With the note store on (`sync`), the records themselves live on relays as
kind 30078, d = "lnurlcash-note:<note id>", NIP-44 to sha256(seed ||
"lnurlcash-mint-backup") - the same unlinkable key the mint list uses, and
never the wallet's npub. Counters travel as d = "lnurlcash-counters:<device
id>", merged by max. A note in a terminal state travels with an empty k1.
Merge rules: spent is sticky locally and travels globally; otherwise the
newer updatedAt wins; the mint, not the relay, decides what is spendable.
## Notes and spends (LUD-25 as of lnurl/luds 6e865b1)
Every note is a BIP-341 taproot output key Q. NoteRecord.id is hex(Q), what
the mint files, burns and certifies it under; compare notes by id, never k1.
A k1 is a spend of Q:
64 hex bearer preimage: the leaf OP_SHA256 <h> OP_EQUAL (a8 20 <h> 87),
leaf version 0xc0, under BIP-341's NUMS internal key
50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0.
Q follows from h = sha256(k1). Every note this wallet makes is one.
On the wire it goes in forms every mint generation reads: looked
up by its own ?k1=, a rotate/split/merge output named by its 64-hex
h under both p1 and h (p2 and h2), a mint quote named by h as both
comment and h, a melt as k1 and pr. A current mint reads either
name and requires them to agree; one from before LUD-25's renames
(lnurl-mint, moneyer < 0.12) reads k1 and h/h2, never p. A mint that
advertises mintToHash but no commentAllowed >= 64 must confirm
mintToHash on the quote, or the invoice is dropped unshown
ck1<Q||sig> BIP-340 by Q over the key-path TapSighash of the canonical
spend tx whose prevout is tagged_hash("LNURLcash/mint", domain),
domain = the mint's lowercase hostname (no scheme, no port), zero
aux_rand: one key at one mint gives one ck1 (spec vector 3). Only
signed for notes paid to this wallet's own keys. The deprecated
shapes (Schnorr over sha256("LNURLcash") or the raw string, and the
65-byte recoverable ECDSA) are gone from @lnurlcash/kit 0.20 and are
refused on receive, before anything is stored, as is a ck1 bound to
another mint. A 96-byte one already held is still looked up and
spent (the mint judges it); a backup holding either still restores
cw1<...> script-path spend (u32 locktime, u32 sequence, (u16 len, item)*
over script, control block, witness; big-endian). A bearer
hashlock's is judged locally like a preimage and looked up by its cp1;
any other script is looked up by passing the cw1 itself to the
mint, which judges it, and cannot be taken offline
cs1 certificates: recoverable ECDSA over sha256(sha256("Lightning Signed
Message:LNURLcash:" + amount_msat + ":" + hex(Q))), for every note, as
cs1<amount> (the BOLT11 amount suffix in the HRP), read from a note URL's c=
(sig= before it) and a mint's c/c2. A mint from before that still certifies
a bearer note over its h instead, as 65 bytes of hex or the fixed-HRP cs1
(a mutation's sig/sig2): for a 64-hex k1 those verify under that rule
(either recovery-id layout), are kept as sent, and travel as sig= beside
amount=. On a ck1 or cw1, or over anything but this note's h, they verify
nowhere; a cs1<amount> over h verifies nowhere either.
Offline receive/verify also checks the spend opens Q at the note URL's domain.
Address proofs: BIP-340 by the branch's sk_0 over sha256("LNURLcash:register:"
+ domain + ":" + name) (or ":unregister:"), domain = the SERVICE's hostname
(spec vector 2); sent as ?sig= on the reference /p/<name> and as "sig" in
moneyer's POST /names body whenever it sets or clears a cx1.
Stored ids move from sha256(k1) to hex(Q) on read (openWallet, the web store,
importBackup, new Wallet), with replaces, melts[].noteId, requests[].paidBy
and settings.noteSyncPushed. Deterministic and idempotent. The relay store
matches a record filed under an old id to its note, and the next push files
it under Q. The primitives come from @lnurlcash/kit 0.20; src/spend.ts keeps
only wallet policy (note ids, offline spend checks, the pre-purpose ladder).
## Library API
openWallet({pin?, home?}) / initWallet(...) -> {data, save, encrypted, storeKey}
- openWallet throws WrongPinError, NoWalletError or WalletExistsError;
walletHome() resolves the store path (NOTECASE_HOME override included)
new Wallet(data, save, {fetch?, timeoutMs?}) ->
- the engine's own errors: InsufficientFundsError, PinMismatchError,
WalletUsageError, KeyRotationError, BadSignatureError
- DEFAULT_LADDER and DEFAULT_LADDER_COPIES are setLadder's fallback
receive(input, {acceptBadSignature?}) -> ReceiveResult {note, warnings},
send(msat, host?, noteIds?) -> NoteRecord, prepareExact(amountMsat),
melt(pr, target), startMint(grossMsat, host?) -> {pending, fee},
awaitMint(pending, {timeoutMs?, intervalMs?}) -> ReceiveResult | null (null on timeout),
claimMint(pending) -> ReceiveResult
- minting: startMint, pay pending.pr from any Lightning wallet (payWithNwc
below does it over NWC), then awaitMint(pending). The note's secret was
fixed at quote time, so no preimage is needed; claimMint's optional
preimage argument is only for invoices quoted by older releases
reconcile(), checkNotes({apply?, mintHost?}) -> CheckReport,
balanceMsat(), unrotatedMsat(), verifyNoteOffline(input), addMint(input),
ladderFor(host), setLadder(host, sats[], copies), ladderPlan(host) -> LadderPlan,
prepareExactFrom(msat, noteIds) - caller-chosen selection, refused if it cannot work,
prepareOffline(host), planOfflineSend(msat, host?, noteIds?) -> OfflineSelection,
sendOffline(msat, host?, {acceptOverpay?, noteIds?, stripSignature?}) -> OfflineHandover,
receiveOffline(input) -> ReceiveResult,
- OfflineSelection = {mintHost, notes, totalMsat, overpayMsat, capped};
OfflineHandover adds urls: string[], one note URL per note. sendOffline
returns a Promise although it makes no network call
- noteIds offline is the hand-over itself, not a pool: short pays nothing,
over needs acceptOverpay. sendToNostr(t, msat, to, host?, {noteIds?, memo?})
namePriceMsat(host?), registerName({name, mintHost?}) -> {address, paidMsat, toKeys},
lightningAddress(), payNameToKeys(toKeys?, {name?, mintHost?}), unregisterName({name?, mintHost?}), addressCx1(host), scanAddress(host?, {gap?})
- with a seed, registerName sends a cx1 (the spec's m/139'/d1..d4, as lnurl-wallet); a
moneyer >=0.13 then pays the name to the wallet's keys, and the inbox opens the
index-only wrap (/w?p=<cp1>&c=<amount-bearing-cs1>&i=; sig= before c=) by deriving the
key at that index on LUD-25's Lightning Address purpose (2), or the ladder from before
purposes for a note paid under it, and rotating
- moneyer's POST /names carries "sig" whenever it sets or clears a cx1: set is proven
by the branch on file (read off the name's payRequest text/cpub or text/xpub, else the one
recorded here), or by the new branch when none is; clear by the branch on file
- the reference lnurl-mint is detected with a reserved-name probe, then takes signed
POST/DELETE /p/{username} requests. The signature proves control of the branch's
purpose-0 index-0 key, over the mint's own hostname; an update is signed by the branch
currently recorded, then the new public cx1 becomes current locally. An optional npub also makes the name NIP-05. It sends no wrap, so
address scan (also available in the web settings) finds payments
heartwoodNameToKeys(t, name, {toKeys?, mintHost?}), heartwoodUnregisterName(t, name, {mintHost?}),
heartwoodScanAddress(t, host?, {gap?, names?}) -> {claimed, waiting, scanned} (waiting: keys the
firmware cannot derive - purpose 2 before 0.18.0-beta.24, the pre-purpose ladder since;
names: walk the cx1 the mint has on file for each name too, e.g. the superseded branch),
collectFromHeartwood(t, onProgress?, {ids?}), recoverHeartwoodNotes(input, {expectedPubkey, mintHost?, passphrase?, gap?})
heartwoodNotes(t), heartwoodNotesOrLastSeen(t) -> {live:true, notes} | {live:false, notes, at, reason},
heartwoodHeld(), heartwoodInventory() -> {notes, at}
- the locker is a till, not a vault: a note IS its secret, so the device keeps its
notes out of every backup (a restored copy on a second board double-spends), and a
dead board's notes are gone. Every listing records the INVENTORY instead -
settings.heartwood.inventory, per note {id, amountMsat, host, state, label?, index?},
dated by settings.heartwood.held.at. Nothing spendable ever enters it; it backs up
and restores, and an older backup with none restores unchanged. A collected note
leaves it, and a shorter reading replaces the last one rather than merging
- notes and names on the pre-2026-09-16 m/139'/1'/d1..d4 branches (words and Nostr key)
are still received, scanned and proven for; they are never handed out again, and
payNameToKeys moves such a name onto the spec's branch
- a heartwood derives its branch from the identity key that owns the name
(HMAC-SHA256(key, "LNURLcash/nostr-seed") -> m/139'/d1..d4 since firmware
0.18.0-beta.24, m/139'/1'/d1..d4 before; a words-less wallet does the same), hands over the
cx1 (heartwood_note_address), and signs its purpose-0 index-0 proof on a hold (firmware
from before purposes signs with its unpurposed index 0, which Notecase refuses to send)
(heartwood_note_address_proof {host, name, action} -> {ok, host, domain, name, action,
cx1, sig}). Notecase refuses to send a proof whose cx1 is not the branch expected or
whose sig does not verify against that cx1's pk_0 over the domain-bound digest.
The device signs Moneyer's NIP-98 itself; the reference /p/<name> takes the proof alone,
and heartwoodUnregisterName(t, name, {mintHost?}) releases one there. Firmware that does
not know the method gets "update heartwood to register this name". Only the device
can spend what arrives. A scan has the device claim a key (heartwood_note_claim
{host, index, amount_msat, p, purpose, c}; the device finds p on its current or its
superseded branch, and refuses a key it does not hold)
- with no recovery words the wallet's own branch comes from its Nostr key the same way
identityCandidates(input, passphrase?) / identitySecretFor(input, npubHex) (src/identitysecret.ts):
a heartwood master's secret from an nsec (bunker or tree-nsec) or BIP-39 phrase
(tree-mnemonic, m/44'/1237'/727'/0'/0'); typed recovery words are not read yet
hasSeed(), counterFor(host), restoreFromMint(host), restoreAll(),
legacyNotes(), adoptLegacyNotes(),
awaitMeltProof(melt, {timeoutMs?, intervalMs?}) -> MeltRecord | null (LUD-21 proof),
nwcGrants(), nwcGrant(idOrName), grantNwc({name, relays?, methods?, budgetMsat?, maxPaymentMsat?}),
revokeNwc(idOrName), refillNwc(idOrName, budgetMsat?),
noteSyncEnabled(), setNoteSync(on, transport?), deviceId(),
pullNotes(transport) -> NoteSyncResult, pushNotes(transport), syncNotes(transport)
initWallet({pin?, home?, mnemonic?}) -> {..., mnemonic} returned ONCE
newMnemonic() / seedFromMnemonic(words) - BIP39, English, no passphrase
- normaliseMnemonic(words) trims/lowercases/collapses whitespace; both
throw BadMnemonicError on anything that is not twelve valid words
exportBackup(data, passphrase) / importBackup(contents, passphrase) ->
WalletData - a passphrase-encrypted BackupEnvelope; importBackup throws
BackupError on a wrong passphrase or corrupt envelope
emptyWallet() - a fresh, seedless WalletData for tests and cold starts
NwcPaymentUnprovenError - thrown by payWithNwc when the wallet cannot
verify the preimage against the invoice's payment hash
new NwcService({wallet, transport, persist, log?}) - NIP-47 service runtime,
.serve(connection) / .stop(id) / .close(); wallet is an NwcServiceWallet
(alias/balanceMsat/makeInvoice/payInvoice/lookupInvoice), so the runtime
fronts anything that can invoice and pay. walletBridge(wallet, opts) is
the bearer-note one. newConnection(...), connectionUri(c), validateGrant(...)
new VaultClient(transport) - the lnurl-vault command protocol over a wire:
info(), identify() (fresh nonce, signature verified here, TOFU-pinned),
listNotes() (pages; total is the count, one page is not), newSecret(),
newSecretPair(), confirm(), discard(), exportSecret() (gated),
importSecret(), markSpent() (gated). storageAdvice(state) says what to do.
collectFromVault(wallet, client, note) - export -> receive (rotates at the
mint) -> mark_spent. Two device presses; the second failing does not lose
the money, it leaves the device's picture stale (clearedOnDevice: false).
depositToVault(wallet, client, note) - new_secret on the device -> the mint
rotates into that hash -> confirm. The wallet's OWN secret never crosses
the cable, and no press is needed. web/src/vaultserial.ts has the WebSerial
transport for both framings (newline JSON, and heartwood's HW binary frame).
createWalletFetch({allowPrivate?, proxy?}) - DNS-pinned fetch, loopback
passthrough; with a socks5:// proxy it tunnels instead and resolves remotely
payWithNwc(uri, pr) -> {preimageHex, feesPaidMsat} / invoiceFromNwc(uri, amountMsat, desc) / nwcStatus(uri)
All amounts are integer milli-satoshis in the engine; the CLI speaks sats.