@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 +84 -0
- package/README.md +69 -31
- package/TERMS-TEMPLATE.md +14 -0
- package/THREAT-MODEL.md +43 -11
- package/dist/admin.js +24 -14
- package/dist/cli.js +3 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/kernel.d.ts +10 -0
- package/dist/kernel.js +105 -0
- package/dist/names.d.ts +12 -2
- package/dist/names.js +50 -7
- package/dist/server.d.ts +2 -0
- package/dist/server.js +201 -139
- package/dist/spend.d.ts +85 -0
- package/dist/spend.js +454 -0
- package/dist/store.d.ts +6 -1
- package/dist/store.js +91 -11
- package/dist/zap.js +11 -6
- package/llms.txt +25 -11
- package/package.json +3 -2
- package/scripts/kernel-verifier.py +59 -0
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
|
|
35
|
-
|
|
36
|
-
comment, so the
|
|
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
|
-
###
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
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
|
-
|
|
554
|
-
|
|
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>&
|
|
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/
|
|
572
|
-
the index is the next key not already present in this mint's
|
|
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
|
|
576
|
-
in use
|
|
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
|
|
598
|
-
|
|
599
|
-
|
|
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
|
|
642
|
-
| `/w/cb` | the mutating callback: melt, rotate, split, merge; inputs as
|
|
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:
|
|
28
|
-
note
|
|
29
|
-
|
|
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.**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 {
|
|
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
|
-
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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(
|
|
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
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";
|
package/dist/kernel.d.ts
ADDED
|
@@ -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
|
+
};
|