@forgesworn/moneyer 0.16.1 → 0.17.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 CHANGED
@@ -1,5 +1,89 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 - 2026-09-26
4
+
5
+ LUD-25's unified taproot model (lnurl/luds 6e865b1). Every note is now a
6
+ BIP-341 taproot output key Q, and that is what the mint stores, burns and
7
+ certifies.
8
+
9
+ Also LUD-25's derivation purposes and renames (lnurl/luds 1286380..50d740a),
10
+ which change every key a `cx1` derives:
11
+
12
+ - **Lightning Address payments land on purpose 2.** A name's zaps are
13
+ credited at `tagged_hash("LNURLcash/derive", P || chaincode || ser32(2) ||
14
+ ser32(i))`, a counter of their own, and every name's counter restarts at 0
15
+ once on upgrade. The internal-transfer hint counts the same purpose.
16
+ - **The address proof is by the purpose-0 index-0 key.** A `sig` made with
17
+ the old unpurposed index-0 key no longer verifies, so a wallet must be
18
+ upgraded before it can set or clear a name's `cx1`.
19
+ - **`c` and `c2`.** Certificates go out as `c`/`c2`, with the older
20
+ `sig`/`sig2` alongside carrying the same value for now. A key wrap's
21
+ lookup URL carries `&c=`.
22
+ - **`text/cpub`, not `text/xpub`.** The payRequest's internal-transfer hint
23
+ is renamed, and the old name is dropped rather than doubled: a wallet
24
+ reading `text/xpub` derives without a purpose and would transfer to a key
25
+ the payee never scans. It pays by Lightning instead.
26
+ - The older `ck1` shapes are still accepted although the reference mint has
27
+ dropped them: Heartwood firmware still signs the 65-byte one.
28
+
29
+ - **Notes are keyed by Q.** A comment, `p`, `p1` or `p2` of 64 hex is a
30
+ bearer note's h, credited at the Q of its `OP_SHA256 <h> OP_EQUAL` leaf
31
+ under the NUMS key; a `cp1` is Q itself, and is refused unless Q is on the
32
+ curve. A 64-hex value there never names a key note any more: that is how
33
+ the spec reads it now.
34
+ - **`ck1` is bound to this mint.** Its signature is over the key-path
35
+ sighash of the canonical spend transaction, whose prevout commits to the
36
+ domain, so a `ck1` seen by one mint cannot be spent at another. Any of the
37
+ mint's own hosts counts: `MONEYER_PUBLIC_ORIGIN`'s and
38
+ `MONEYER_ONION_URL`'s. The older `ck1`s (Schnorr over `sha256("LNURLcash")`
39
+ or the raw string, and the 65-byte recoverable shape) are still accepted,
40
+ as the reference mint accepts them, so notes already handed out stay
41
+ redeemable.
42
+ - **`cw1` script-path spends.** A bearer note's full `cw1` is the same note
43
+ as its preimage. Leaf versions other than `0xc0` and `OP_SUCCESSx` leaves
44
+ are refused, and time claims are checked against the mint's clock: Unix
45
+ times only, relative locks counted from when the note was credited.
46
+ Bearer hashlock leaves are evaluated here. Any other leaf goes to Bitcoin
47
+ Core's interpreter when `MONEYER_SCRIPT_VERIFIER` runs
48
+ `scripts/kernel-verifier.py` (lnurlcash-kernel, the reference mint's
49
+ verifier) or a `scriptVerifier` is passed to `createMoneyer`; without one
50
+ it is refused with a reason and the note left outstanding. A verifier
51
+ that crashes, errors or stalls refuses, never accepts.
52
+ - **Every note is certified.** The callback's `c`/`c2` and the
53
+ informational GET's `c` are a `cs1` over hex(Q) for bearer notes as well
54
+ as key notes, reversing 0.14.0's "a plain note is unsigned". The LUD-21
55
+ bound receipt still signs exactly the `h` it repeats.
56
+ - **The informational GET verifies the spend in full.** A `ck1` whose
57
+ signature does not open its note gets `Unknown note.`, and a `cw1`'s own
58
+ failure reason is passed on. The `k1` is echoed exactly as sent.
59
+ - **`already in use`.** An output, or a mint comment, naming a note that is
60
+ outstanding, burned, or awaited by an invoice gets LUD-25's exact reason.
61
+ - **Retries match on Q.** A completed rotate, split or merge retried with a
62
+ different spelling of the same spend (a preimage for its `cw1`, say) is
63
+ answered as the replay it is. Mutations completed before the upgrade
64
+ still match under their old fingerprint.
65
+ - **Setting a name's `cx1` needs the address proof.** `POST /names` now
66
+ requires `sig`, a BIP-340 signature by the branch's index-0 key over
67
+ `sha256("LNURLcash:register:<domain>:<name>")`, whenever a `cx1` is set,
68
+ or `:unregister:` whenever one is cleared. The branch on file proves any
69
+ change to it. NIP-98 still decides who owns the name.
70
+ - A payment hash whose preimage would open a note, this quote's own or any
71
+ other, is refused before an invoice is handed out, now that notes are
72
+ keyed by Q rather than by h.
73
+ - **Upgrading.** Rows written before this release keep their old ids.
74
+ On first start the store records, once, the Q each old id would have as a
75
+ bearer note (`legacy_ids`), so an old bearer note is found by its
76
+ preimage, `cw1`, h or `cp1`, and no new note can be credited over it.
77
+ Nothing is rewritten, and key notes were keyed by Q already.
78
+
79
+ ## 0.16.2 - 2026-09-24
80
+
81
+ Documentation only; no code changes.
82
+
83
+ - `llms.txt` notes that `verifyStatsSnapshot` returns `stats: null` when the
84
+ content is not JSON or carries no signature, and returns the parsed stats
85
+ with `valid: false` for a bad signature, so check `valid` first.
86
+
3
87
  ## 0.16.1 - 2026-09-24
4
88
 
5
89
  Documentation only; no code changes.
package/README.md CHANGED
@@ -31,9 +31,10 @@ lesson, that behaviour is kept deliberately and tested.
31
31
  too - it just gets the after-the-fact checks instead. A backend declares
32
32
  which it is with `acceptsInvoicePreimage`. A **fake** backend exists for
33
33
  development and tests and refuses to run outside `--dev`.
34
- - Notes are stored by id, `sha256(k1)` - the store never holds a spend
35
- secret. The buyer names the note they are buying with a mandatory LUD-12
36
- comment, so the secret is theirs alone from the start.
34
+ - Notes are stored by their taproot output key Q, as LUD-25 now defines
35
+ every note - the store never holds a spend. The buyer names the note they
36
+ are buying with a mandatory LUD-12 comment, so the spend is theirs alone
37
+ from the start.
37
38
  - Signs every note it mints with its own mint key (secp256k1, the standard
38
39
  `Lightning Signed Message` construction) for LUD-25 offline verification.
39
40
  - The melt discipline: reply OK when the note is reserved, pay in the
@@ -95,6 +96,7 @@ default, and a variable set to an empty string counts as unset.
95
96
  | `MONEYER_PORT` | `3737` | listen port |
96
97
  | `MONEYER_PUBLIC_ORIGIN` | derived from `Host` | the origin wallets are told to call back on. Required behind a reverse proxy, and required for zap-to-note |
97
98
  | `MONEYER_ONION_URL` | | this mint as a Tor hidden service, e.g. `http://<v3>.onion`. A request arriving on that host gets its URLs built from it (see below) |
99
+ | `MONEYER_SCRIPT_VERIFIER` | | a command that judges script-path spends moneyer cannot evaluate itself, e.g. `python3 /opt/moneyer/kernel-verifier.py` (see DEPLOY.md). Unset, such spends are refused and their notes wait |
98
100
  | `MONEYER_USERNAME` | `mint` | the local part of the mint's own lightning address |
99
101
  | `MONEYER_DESCRIPTION` | `an LNURLcash note` | what a note is called on the wire (`defaultDescription`, and `description` on discovery) |
100
102
  | `MONEYER_DB` | `moneyer.sqlite` | SQLite path; `:memory:` allowed |
@@ -344,21 +346,44 @@ preimage then buys nothing: it is an ordinary payment proof, which is why
344
346
  there is no race left to run, and no window in which holding the invoice
345
347
  is nearly holding the money.
346
348
 
347
- ### Or name it by a key (LUD-25 Part 2)
348
-
349
- The comment can instead be `cp1<pk>`, a public key. The note is keyed by
350
- that key and spent with `ck1`, a recoverable signature by its private key,
351
- so the mint never sees anything that could spend it. Everywhere a note is
352
- named or spent, the two kinds mix freely:
353
-
354
- - `GET /w?k1=<ck1>` or `GET /w?p=<cp1>` looks a Part 2 note up, and the
355
- answer carries `sig`, the mint's certificate for it as `cs1`.
356
- - `p1`/`p2` on the callback may be `cp1` keys, and the matching `sig`/`sig2`
357
- come back as amount-bearing `cs1`. A legacy hash output instead receives
358
- the raw 65-byte Part 1 signature used by the reference mint.
359
- - A merge may combine Part 1 secrets and Part 2 `ck1`s in one request.
360
-
361
- A recipient checks a Part 2 note offline from its `ck1` and `cs1` alone.
349
+ ### Every note is a taproot output key
350
+
351
+ LUD-25 makes every note a BIP-341 output key Q, written `cp1<Q>`, and that
352
+ is what this mint stores, burns and certifies. The 64-hex secret above is
353
+ the short form of the simplest kind, a bearer note: an
354
+ `OP_SHA256 <h> OP_EQUAL` leaf under BIP-341's NUMS key, so its Q follows
355
+ from h alone. Wherever a note is named (a comment, `p`, `p1`, `p2`), 64 hex
356
+ is that h, and `cp1<Q>` names any note directly. Wherever one is spent
357
+ (`k1`), it can be:
358
+
359
+ - the 64-hex preimage of a bearer note;
360
+ - `ck1<Q || sig>`, a key-path spend: a BIP-340 signature by Q over the
361
+ sighash of LUD-25's canonical spend transaction, whose prevout commits to
362
+ this mint's domain, so it cannot be replayed at another mint. Any of the
363
+ mint's own hosts counts, clearnet or onion. The older `ck1` shapes are
364
+ still accepted, as the reference mint accepts them;
365
+ - `cw1<...>`, a script-path spend: a leaf of Q's tree, its control block and
366
+ the witness that satisfies it. A bearer note's full `cw1` is the same note
367
+ as its preimage. Leaf versions other than `0xc0` and `OP_SUCCESSx` leaves
368
+ are refused, and time claims are checked against the mint's own clock:
369
+ Unix times only, relative locks counted from when the note was credited.
370
+ That is the mint asserting its clock, a custodial policy and not a
371
+ consensus proof.
372
+
373
+ Bearer hashlock leaves are evaluated in process. LUD-25 has a mint accept
374
+ any consensus-valid leaf, and moneyer is not a tapscript interpreter, so
375
+ any other leaf goes to Bitcoin Core's own, the one the reference mint uses:
376
+ set `MONEYER_SCRIPT_VERIFIER` to run `scripts/kernel-verifier.py` beside the
377
+ mint (or pass a `scriptVerifier` to `createMoneyer`). Without one such a
378
+ spend is refused with a reason and the note stays outstanding; a verifier
379
+ that crashes, errors or stalls refuses too, and never accepts.
380
+
381
+ Every note gets a certificate, `cs1` over hex(Q) and its amount: `c` on
382
+ the informational GET, `c`/`c2` on every rotate, split and merge. The same
383
+ value also goes out as the older `sig`/`sig2` until wallets reading only
384
+ those have moved on. The
385
+ informational GET verifies a `k1` in full before answering, so a wallet
386
+ checking a note it was handed learns whether its spend really opens it.
362
387
 
363
388
  Moneyer's earlier `mintToHash` extension remains additive. A compatible
364
389
  wallet repeats the same commitment as `h`; it does not replace `comment`:
@@ -543,19 +568,29 @@ A registered name resolves on both rails at once:
543
568
  The discovery endpoint advertises `namePriceMsat` while registration is
544
569
  open, so a wallet can offer the flow without asking.
545
570
 
546
- ### A name that pays to your own keys (LUD-25 Part 2)
571
+ ### A name that pays to your own keys (LUD-25 auto-mint)
547
572
 
548
573
  Add `"cx1": "cx1..."` to the registration body, or send `{"name", "cx1"}`
549
574
  again later from the key that owns the name, and payments to it stop being
550
575
  custodial. A `cx1` is a watch-only branch: from it the mint can work out each
551
576
  of the holder's note keys in turn, but never spend one.
552
577
 
553
- When a zap to the name settles, the mint takes the next index on the branch
554
- and credits the note to that key. It skips any key already holding a note,
578
+ A `cx1` is public, so pointing a name at one has to be proven by the branch
579
+ itself, as LUD-25 requires: the body also carries `"sig"`, a BIP-340
580
+ signature by the branch's purpose-0 index-0 key over
581
+ `sha256("LNURLcash:register:<domain>:<name>")`, `<domain>` being this mint's
582
+ hostname. Changing the branch later is proven by the branch on file, not the
583
+ new one, and clearing it takes the same signature over
584
+ `LNURLcash:unregister:...` instead. The NIP-98 key still decides who owns
585
+ the name; the proof decides where it may pay.
586
+
587
+ When a zap to the name settles, the mint takes the next index on the
588
+ branch's Lightning Address purpose (LUD-25's purpose 2, a counter of its
589
+ own) and credits the note to that key. It skips any key already holding a note,
555
590
  and an invoice nobody pays takes no index at all, so a wallet scanning its
556
591
  branch never meets a gap it did not make. The gift wrap still goes to the
557
592
  owner's npub, but it carries no secret: a lookup URL,
558
- `https://mint.example/w?p=<cp1>&sig=<amount-bearing-cs1>&i=<index>`, plus an
593
+ `https://mint.example/w?p=<cp1>&c=<amount-bearing-cs1>&i=<index>`, plus an
559
594
  `i` tag. The wallet derives the key at that index, checks the certificate,
560
595
  and spends with its own `ck1`. A wallet that only knows note URLs sees no
561
596
  `k1` and passes the wrap by, and the note waits at the mint for a scan of the
@@ -568,12 +603,14 @@ in `MONEYER_ZAP_NAMES`.
568
603
  Sending a new branch starts it at index 0; sending the same one again keeps
569
604
  its place. Zap receipts are unchanged.
570
605
 
571
- The name's payRequest also publishes `['text/xpub', '<cx1>:<index>']`, where
572
- the index is the next key not already present in this mint's ledger. A payer
606
+ The name's payRequest also publishes `['text/cpub', '<cx1>:<index>']`, where
607
+ the index is the next purpose-2 key not already present in this mint's
608
+ ledger. It never publishes the older `text/xpub`: a wallet reading that
609
+ derives without a purpose, so it pays by Lightning instead. A payer
573
610
  already holding a note here can therefore rotate, split or merge straight to
574
611
  that `cp1` without creating or routing a Lightning invoice. The index is a
575
- hint: if another payment wins the key first, Moneyer returns `Output already
576
- in use.` so the reference kit advances and retries.
612
+ hint: if another payment wins the key first, Moneyer returns LUD-25's
613
+ `already in use`, so the reference kit advances and retries.
577
614
 
578
615
  ## Reaching the mint over Tor
579
616
 
@@ -594,9 +631,10 @@ configured both. Anything else falls through to `MONEYER_PUBLIC_ORIGIN`.
594
631
 
595
632
  ### Serving both audiences at once
596
633
 
597
- Both doors serve the **same notes**. A note is keyed by `sha256(k1)` and
598
- not by host, so a k1 struck on the onion is valid at the clearnet host and
599
- the other way round. What is bound to a host is only the URL a note
634
+ Both doors serve the **same notes**. A note is keyed by its Q and not by
635
+ host, so a k1 struck on the onion is valid at the clearnet host and the
636
+ other way round. A `ck1` is bound to a domain, and both of this mint's
637
+ domains are its own, so one signed for either host is accepted at both. What is bound to a host is only the URL a note
600
638
  travels as - and an onion note handed to somebody without Tor is
601
639
  unspendable to them, while a clearnet note redeemed over Tor is private
602
640
  only if their wallet proxies.
@@ -638,8 +676,8 @@ clearnet.
638
676
  | `/p/cb` | LUD-06 pay callback; issues the mint invoice for the note the `comment` names, a hash or a `cp1` key (`h` is an older spelling for a hash) |
639
677
  | `/z/cb/<zap name>` | the zap callback; validates the kind 9734 and issues the invoice |
640
678
  | `/verify/<hash>` | LUD-21 verify, for mint invoices and melt payments |
641
- | `/w` | LUD-03 informational GET; accepts `k1` (a secret or a `ck1`) or the non-disclosing LUD-25 `p` check (`sha256(k1)` or a `cp1` key; `h` is its older name, still accepted); a live note also carries `payLink`, and a Part 2 note its `cs1` as `sig` |
642
- | `/w/cb` | the mutating callback: melt, rotate, split, merge; inputs as secrets or `ck1`s, outputs as `p1`/`p2` hashes or `cp1` keys, or their older names `h`/`h2` |
679
+ | `/w` | LUD-03 informational GET; accepts `k1` (a preimage, `ck1` or `cw1`, verified in full) or the non-disclosing LUD-25 `p` check (a bearer note's h or any `cp1`; `h` is its older name, still accepted); a live note carries `payLink` and its `cs1` as `sig` |
680
+ | `/w/cb` | the mutating callback: melt, rotate, split, merge; inputs as preimages, `ck1`s or `cw1`s, outputs as `p1`/`p2` (a bearer note's h or a `cp1`), or their older names `h`/`h2` |
643
681
  | `POST /names` | claim a lightning address, authenticated by NIP-98 |
644
682
  | `/.well-known/nostr.json` | NIP-05 for the names this mint serves |
645
683
  | `/stats` | what the mint owes, what the node holds, and the coverage between them |
package/TERMS-TEMPLATE.md CHANGED
@@ -109,6 +109,20 @@ contact <contact> to exercise them.
109
109
  - **Not a regulated financial service**, and not offered as one.
110
110
  <The operator is not authorised or registered by <regulator>.>
111
111
 
112
+ ### Scripts and timelocks
113
+
114
+ A note may be locked to a script, and this mint checks every script the
115
+ same way: it verifies the spend it is shown and nothing about why the
116
+ note was locked. It runs no oracle, offers no contracts, and takes no
117
+ part in any arrangement between the people a script names. A timelock is
118
+ judged against this mint's own clock: it is the mint's promise, not a
119
+ guarantee enforced by the Bitcoin network.
120
+
121
+ Do not use this mint to stake bets, run a lottery or offer any other
122
+ gambling, whatever script expresses it. The operator may stop verifying
123
+ script spends at any time; a note that needs one then waits, outstanding,
124
+ until verification returns or the mint winds down.
125
+
112
126
  ### Use
113
127
 
114
128
  Use this mint lawfully. Do not use it if you are designated under UK, UN,
package/THREAT-MODEL.md CHANGED
@@ -24,20 +24,47 @@ request carrying a callback's query string cannot mutate anything.
24
24
  **A holder inflating a merge or split with a repeated k1.** Duplicated k1
25
25
  parameters in one request are refused outright; they would otherwise count
26
26
  one note's value twice into the output. The check is on the note each k1
27
- spends, not on the k1 string: a Part 2 note's `ck1` is a signature, and one
28
- note has many valid ones (anyone holding a `ck1` can flip it to `(r, n-s)`,
29
- and the key's owner can sign again). The store refuses a note named twice
27
+ spends, its Q, not on the k1 string: one note has many valid spends (a
28
+ bearer note's preimage and its full `cw1`, the key owner's fresh `ck1`s,
29
+ the deprecated `ck1` shapes a wallet may still hold). The store refuses a note named twice
30
30
  among a swap's inputs as well, so the rule does not rest on the handler
31
31
  alone.
32
32
 
33
- **A holder claiming an output id that already exists.** `h`/`h2` may not
34
- collide with any existing note, any mint invoice's payment hash, settled
35
- or not, or any note a payer has bought by naming it. The invoice cases are
36
- the subtle ones: `/verify` hands a settled mint invoice's preimage to its
37
- payer, and that preimage is the k1 of whatever note carries the hash as
38
- its id - minting "over" such an id would point a future payer's money at a
39
- stranger's note. A named note is the same hazard one step earlier, since
40
- it is spoken for before it exists.
33
+ **A holder claiming an output id that already exists.** An output (`p1`,
34
+ `p2`, or a mint comment) may not name a Q that is already a note, spent or
35
+ not, or that a payer has bought by naming it, and gets LUD-25's
36
+ `already in use`. Nor may it name the bearer note a mint invoice's payment
37
+ preimage would open: `/verify` hands that preimage out, so minting there
38
+ would point money at a note anyone who saw the invoice can spend. A note
39
+ written before notes were keyed by Q is protected the same way through
40
+ `legacy_ids`, so its preimage never opens two notes.
41
+
42
+ **A payment preimage that opens a note.** Every preimage is also the spend
43
+ of the bearer note whose h is its payment hash, and a preimage reaches the
44
+ funding source, every hop and `/verify`. So a quote is refused, before any
45
+ invoice is shown, if its payment hash is the h of this quote's own note or
46
+ of any note on file, whoever chose the preimage.
47
+
48
+ **A spend replayed at another mint.** A `ck1` signs the sighash of LUD-25's
49
+ canonical spend transaction, whose prevout commits to the mint's domain,
50
+ so a key-path spend one mint has seen cannot be used at another. Moneyer
51
+ accepts a signature bound to any of its own hosts, clearnet or onion, and
52
+ nothing else. The deprecated `ck1` shapes are not bound to any mint; they
53
+ are still accepted, as the reference mint accepts them, so that notes
54
+ already handed out stay redeemable, and they should be retired once none
55
+ are expected to remain.
56
+
57
+ **A leaf that succeeds for anyone.** Tapscript keeps upgrade hooks that
58
+ consensus accepts unconditionally: unknown leaf versions and `OP_SUCCESSx`
59
+ opcodes. A `cw1` using either is refused before anything runs, as LUD-25
60
+ requires; otherwise a note locked to such a leaf would be spendable by
61
+ whoever saw it.
62
+
63
+ **Timelocks.** A `cw1` carries a signed `nLockTime`/`nSequence`, and the
64
+ mint checks them against its own clock: Unix times only, relative locks
65
+ counted from when it credited the note. That is the mint asserting its
66
+ clock, a custodial policy and not a consensus guarantee, and must never be
67
+ described as trustless.
41
68
 
42
69
  **A holder melting into the mint's own invoice.** Refused synchronously:
43
70
  paying it would route the funding source's money at itself, which real
@@ -109,6 +136,11 @@ is what notecase and lnurl-wallet do.
109
136
 
110
137
  ## Known limitations
111
138
 
139
+ - A script-path spend whose leaf is not a bearer hashlock can only be
140
+ judged by a tapscript interpreter, which moneyer does not contain. Without
141
+ a `scriptVerifier` such spends are refused and the note waits; a mint
142
+ cannot tell a script note from a key note when it is credited, since Q
143
+ is opaque, so it cannot refuse one up front either.
112
144
  - The cln and lnd backends are unexercised against live nodes (direct
113
145
  ports of the reference mint's logic). Run `--dev` traffic and the
114
146
  conformance grader against a staging deployment before taking real money.
package/dist/admin.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
3
3
  import { secp256k1 } from '@noble/curves/secp256k1.js';
4
- import { hashK1, noteDeclaredAmount, noteK1, noteSignature, verifyNoteSignature } from '@lnurlcash/kit';
4
+ import { noteDeclaredAmount, noteK1, noteSignature, verifyNoteSignature, verifyNoteSignatureHash } from '@lnurlcash/kit';
5
+ import { decodeNote, decodeSpend } from "./spend.js";
5
6
  import { configFromEnv, pubkeyHex } from "./config.js";
6
7
  import { NoteStore } from "./store.js";
7
8
  import { buildStats } from "./stats.js";
@@ -206,17 +207,18 @@ export const runAdmin = async (argv, deps = {}) => {
206
207
  err('Usage: moneyer admin note <id|k1>');
207
208
  return 2;
208
209
  }
209
- const asked = wanted.toLowerCase();
210
- let note = store.noteById(asked);
211
- let id = asked;
212
- // 64 hex that names no note is very likely the secret itself,
213
- // which an operator has in front of them far more often than an
214
- // id. Hash it and look again rather than say "unknown".
215
- if (!note && /^[0-9a-f]{64}$/.test(asked)) {
216
- id = hashK1(asked);
217
- note = store.noteById(id);
210
+ const asked = wanted.trim().toLowerCase();
211
+ let note = store.noteById(asked) ?? store.noteByQ(asked);
212
+ // Whatever else an operator is likely to have in front of them: a
213
+ // bearer note's h or a cp1, or a spend - a preimage, a ck1, a cw1.
214
+ // Each names a Q; look again by it rather than say "unknown".
215
+ if (!note) {
216
+ const named = decodeNote(asked);
217
+ const spend = decodeSpend(asked);
218
+ const q = named && store.noteByQ(named) ? named : spend ? bytesToHex(spend.outputKey) : null;
219
+ note = q ? store.noteByQ(q) : null;
218
220
  if (note)
219
- out(`(that is a secret; its note id is ${id})`);
221
+ out(`(that ${named === q ? 'names' : 'spends'} the note at Q ${q}${note.id === q ? '' : `, stored under ${note.id}`})`);
220
222
  }
221
223
  if (!note) {
222
224
  const invoice = store.mintInvoiceByHash(asked);
@@ -228,6 +230,7 @@ export const runAdmin = async (argv, deps = {}) => {
228
230
  out(`no note, and no mint invoice, at ${asked}`);
229
231
  return 1;
230
232
  }
233
+ const id = note.id;
231
234
  out(`${id}`);
232
235
  out(` value ${sats(note.amountMsat)}`);
233
236
  out(` state ${note.state}`);
@@ -362,7 +365,12 @@ export const runAdmin = async (argv, deps = {}) => {
362
365
  err('That is not a note URL - no k1 in it.');
363
366
  return 2;
364
367
  }
365
- const id = hashK1(k1);
368
+ const spend = decodeSpend(k1);
369
+ if (!spend) {
370
+ err('That note URL carries no spend this mint could read.');
371
+ return 2;
372
+ }
373
+ const id = bytesToHex(spend.outputKey);
366
374
  const declared = noteDeclaredAmount(url);
367
375
  const signature = noteSignature(url);
368
376
  const current = signingPubkey(config);
@@ -377,12 +385,14 @@ export const runAdmin = async (argv, deps = {}) => {
377
385
  out('signature present, but the URL declares no amount to check it against');
378
386
  }
379
387
  else {
380
- const signedBy = keys.find(pubkey => verifyNoteSignature(k1, declared, signature, pubkey));
388
+ // Over Q, as LUD-25 certifies every note now, or over the note's
389
+ // old id for a certificate issued before that.
390
+ const signedBy = keys.find(pubkey => verifyNoteSignatureHash(id, declared, signature, pubkey) || verifyNoteSignature(k1, declared, signature, pubkey));
381
391
  out(signedBy === undefined
382
392
  ? 'signature DOES NOT verify against this mint'
383
393
  : `signature verifies against ${signedBy === current ? 'the current key' : `a previous key (${signedBy})`}`);
384
394
  }
385
- const note = store.noteById(id);
395
+ const note = store.noteByQ(id);
386
396
  out(`mint ${note ? `holds ${sats(note.amountMsat)}, state ${note.state}` : 'has no note at this id'}`);
387
397
  // A note the mint does not know, or one already burned, is the
388
398
  // answer the operator came for; say it in the exit code too.
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
3
  import { bytesToHex, randomBytes } from '@noble/hashes/utils.js';
4
- import { buildNoteUrl, hashK1 } from '@lnurlcash/kit';
4
+ import { buildNoteUrl } from '@lnurlcash/kit';
5
+ import { bearerNoteIdOfPreimage } from "./spend.js";
5
6
  import { configFromEnv } from "./config.js";
6
7
  import { createMoneyer } from "./server.js";
7
8
  // moneyer - strike LNURLcash bearer notes.
@@ -71,7 +72,7 @@ console.log(` mint pubkey: ${moneyer.signer.pubkey}`);
71
72
  console.log(` funding source: ${moneyer.backend.name}`);
72
73
  if (values.dev) {
73
74
  const k1 = bytesToHex(randomBytes(32));
74
- moneyer.store.creditNote(hashK1(k1), 21_000);
75
+ moneyer.store.creditNote(bearerNoteIdOfPreimage(k1), 21_000);
75
76
  console.log(` a 21 sat note: ${buildNoteUrl(`${moneyer.url}/w`, k1, 21_000)}`);
76
77
  console.log('\nDEV MINT - the fake funding source invents its invoices and treats every one\nof them as paid the moment it is issued, so minting here costs nothing and\nthe notes it hands out are worth nothing. Melts always succeed and send no\nsats anywhere. Never point a wallet holding real money at this.');
77
78
  }
package/dist/config.d.ts CHANGED
@@ -22,6 +22,7 @@ export type MoneyerConfig = {
22
22
  port: number;
23
23
  publicOrigin?: string;
24
24
  onionUrl?: string;
25
+ scriptVerifier?: string[];
25
26
  username: string;
26
27
  description: string;
27
28
  name?: string;
package/dist/config.js CHANGED
@@ -197,11 +197,14 @@ export const configFromEnv = (env = process.env) => {
197
197
  // header from, and the note URL it wraps must be right first time.
198
198
  throw new Error('Zap-to-note needs MONEYER_PUBLIC_ORIGIN.');
199
199
  }
200
+ // Split on whitespace: a path with spaces in it belongs in a wrapper script.
201
+ const scriptVerifier = env.MONEYER_SCRIPT_VERIFIER?.trim().split(/\s+/).filter(Boolean);
200
202
  return {
201
203
  host: env.MONEYER_HOST ?? DEFAULTS.host,
202
204
  port: int(env.MONEYER_PORT, DEFAULTS.port),
203
205
  ...(publicOrigin ? { publicOrigin } : {}),
204
206
  ...(onionUrl ? { onionUrl } : {}),
207
+ ...(scriptVerifier?.length ? { scriptVerifier } : {}),
205
208
  username: env.MONEYER_USERNAME ?? DEFAULTS.username,
206
209
  description: env.MONEYER_DESCRIPTION ?? DEFAULTS.description,
207
210
  ...(name ? { name } : {}),
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export { configFromEnv, DEFAULTS, type MoneyerConfig, type BackendConfig } from
2
2
  export { createMoneyer, type Moneyer, type MoneyerDeps } from './server.ts';
3
3
  export { NoteStore, NotePendingError, NoteUnavailableError, OutputCollisionError, type NoteRow, type NoteState, type MeltRow, type MintInvoiceRow, type Liabilities } from './store.ts';
4
4
  export { createNoteSigner, noteIdSignatureDigest, recoversToPubkey, signDigestRecoverable, type NoteSigner } from './signing.ts';
5
+ export { bearerNoteId, bearerNoteIdOfPreimage, decodeNote, decodeSpend, deriveNotePubkey, keyPathSighash, NOTE_PURPOSE_CHANGE, NOTE_PURPOSE_LIGHTNING_ADDRESS, NOTE_PURPOSE_WALLET, scriptPathSighash, spendDomainOf, verifySpend, type ScriptSpend, type ScriptVerifier, type Spend, type SpendVerdict } from './spend.ts';
6
+ export { createKernelVerifier, type KernelVerifier } from './kernel.ts';
5
7
  export { STATS_D_TAG, STATS_KIND, buildStats, canonicalJson, signStats, statsDigest, statsSnapshotContent, verifyStatsSignature, verifyStatsSnapshot, type MintStats } from './stats.ts';
6
8
  export { ANNOUNCE_D_TAG, ANNOUNCE_KIND, ANNOUNCE_MESSAGE_PREFIX, announcementContent, announcementDigest, canonicalise, signAnnouncement, verifyAnnouncement, type MintAddressDocument } from './announce.ts';
7
9
  export { PaymentAlreadyKnownError, PaymentFailedError, PaymentPendingError, type LightningBackend, type NodeInfo, type PaymentOutcome } from './backends/types.ts';
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ export { configFromEnv, DEFAULTS } from "./config.js";
15
15
  export { createMoneyer } from "./server.js";
16
16
  export { NoteStore, NotePendingError, NoteUnavailableError, OutputCollisionError } from "./store.js";
17
17
  export { createNoteSigner, noteIdSignatureDigest, recoversToPubkey, signDigestRecoverable } from "./signing.js";
18
+ export { bearerNoteId, bearerNoteIdOfPreimage, decodeNote, decodeSpend, deriveNotePubkey, keyPathSighash, NOTE_PURPOSE_CHANGE, NOTE_PURPOSE_LIGHTNING_ADDRESS, NOTE_PURPOSE_WALLET, scriptPathSighash, spendDomainOf, verifySpend } from "./spend.js";
19
+ export { createKernelVerifier } from "./kernel.js";
18
20
  export { STATS_D_TAG, STATS_KIND, buildStats, canonicalJson, signStats, statsDigest, statsSnapshotContent, verifyStatsSignature, verifyStatsSnapshot } from "./stats.js";
19
21
  export { ANNOUNCE_D_TAG, ANNOUNCE_KIND, ANNOUNCE_MESSAGE_PREFIX, announcementContent, announcementDigest, canonicalise, signAnnouncement, verifyAnnouncement } from "./announce.js";
20
22
  export { PaymentAlreadyKnownError, PaymentFailedError, PaymentPendingError } from "./backends/types.js";
@@ -0,0 +1,10 @@
1
+ import { type ScriptVerifier } from './spend.ts';
2
+ export declare const UNAVAILABLE = "script verification is unavailable - try again later";
3
+ export type KernelVerifier = ScriptVerifier & {
4
+ close: () => void;
5
+ };
6
+ export declare const createKernelVerifier: (options: {
7
+ command: string[];
8
+ timeoutMs?: number;
9
+ log?: (message: string) => void;
10
+ }) => KernelVerifier;
package/dist/kernel.js ADDED
@@ -0,0 +1,105 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ import { bytesToHex } from '@noble/hashes/utils.js';
4
+ import { encodeCw1 } from "./spend.js";
5
+ // A ScriptVerifier backed by a long-lived child process speaking one JSON
6
+ // request and one JSON reply per line: scripts/kernel-verifier.py, which
7
+ // runs Bitcoin Core's own interpreter through lnurlcash-kernel.
8
+ //
9
+ // LUD-25 has a mint accept any consensus-valid tapscript, and moneyer is
10
+ // not a tapscript interpreter. Rather than write one, it borrows the one
11
+ // the reference mint uses, so the two mints accept and refuse exactly the
12
+ // same spends.
13
+ //
14
+ // Anything short of a verdict - the process missing, crashing, answering
15
+ // with an error, or too slowly - refuses the spend. The note is untouched
16
+ // and the wallet can try again; accepting on a broken verifier would be
17
+ // the one unrecoverable answer.
18
+ export const UNAVAILABLE = 'script verification is unavailable - try again later';
19
+ export const createKernelVerifier = (options) => {
20
+ const [program, ...args] = options.command;
21
+ if (!program)
22
+ throw new Error('The script verifier needs a command.');
23
+ const timeoutMs = options.timeoutMs ?? 5_000;
24
+ const log = options.log ?? (() => { });
25
+ let child = null;
26
+ let nextId = 1;
27
+ const pending = new Map();
28
+ const settle = (id, reason) => {
29
+ const waiting = pending.get(id);
30
+ if (!waiting)
31
+ return;
32
+ clearTimeout(waiting.timer);
33
+ pending.delete(id);
34
+ waiting.resolve(reason);
35
+ };
36
+ const refuseAll = () => {
37
+ for (const id of [...pending.keys()])
38
+ settle(id, UNAVAILABLE);
39
+ };
40
+ const running = () => {
41
+ if (child)
42
+ return child;
43
+ const started = spawn(program, args, { stdio: ['pipe', 'pipe', 'pipe'] });
44
+ started.on('error', err => {
45
+ log(`script verifier failed to start: ${err.message}`);
46
+ if (child === started)
47
+ child = null;
48
+ refuseAll();
49
+ });
50
+ started.on('exit', code => {
51
+ log(`script verifier exited (${code ?? 'signal'})`);
52
+ if (child === started)
53
+ child = null;
54
+ refuseAll();
55
+ });
56
+ // A write to a process that has just died fails asynchronously; the
57
+ // exit handler above has already refused whatever was waiting.
58
+ started.stdin.on('error', err => log(`script verifier stdin: ${err.message}`));
59
+ started.stderr.on('data', chunk => log(`script verifier: ${String(chunk).trim()}`));
60
+ createInterface({ input: started.stdout }).on('line', line => {
61
+ let reply;
62
+ try {
63
+ reply = JSON.parse(line);
64
+ }
65
+ catch {
66
+ return;
67
+ }
68
+ if (typeof reply.id !== 'number')
69
+ return;
70
+ if (reply.ok === true)
71
+ settle(reply.id, null);
72
+ else if (typeof reply.reason === 'string')
73
+ settle(reply.id, reply.reason);
74
+ else {
75
+ log(`script verifier error: ${String(reply.error)}`);
76
+ settle(reply.id, UNAVAILABLE);
77
+ }
78
+ });
79
+ child = started;
80
+ return started;
81
+ };
82
+ const verify = ({ outputKey, domain, spend, now, lockedAt }) => new Promise(resolve => {
83
+ const id = nextId++;
84
+ const timer = setTimeout(() => {
85
+ log('script verifier timed out');
86
+ settle(id, UNAVAILABLE);
87
+ }, timeoutMs);
88
+ pending.set(id, { resolve, timer });
89
+ const request = { id, q: bytesToHex(outputKey), domain, cw1: encodeCw1(spend), now, locked_at: lockedAt };
90
+ try {
91
+ running().stdin.write(`${JSON.stringify(request)}\n`);
92
+ }
93
+ catch (err) {
94
+ log(`script verifier unreachable: ${err.message}`);
95
+ settle(id, UNAVAILABLE);
96
+ }
97
+ });
98
+ return Object.assign(verify, {
99
+ close: () => {
100
+ refuseAll();
101
+ child?.kill();
102
+ child = null;
103
+ }
104
+ });
105
+ };