@forgesworn/moneyer 0.9.1 → 0.11.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 +93 -1
- package/README.md +22 -9
- package/dist/admin.js +1 -1
- package/dist/backends/cln.js +14 -2
- package/dist/backends/fake.d.ts +2 -0
- package/dist/backends/fake.js +22 -3
- package/dist/backends/lnd.js +8 -0
- package/dist/backends/types.d.ts +3 -1
- package/dist/backends/types.js +7 -4
- package/dist/cli.js +1 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +24 -0
- package/dist/landing.js +1 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +90 -21
- package/llms.txt +11 -7
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,98 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.11.0 - 2026-09-06
|
|
4
|
+
|
|
5
|
+
**Three fields the reference mint publishes and moneyer did not.** Each
|
|
6
|
+
answers a question a wallet had no way to ask.
|
|
7
|
+
|
|
8
|
+
- `nodeUris` - every address the funding node announces, not just the first.
|
|
9
|
+
A node reachable over Tor as well as clearnet announces both, and `nodeUri`
|
|
10
|
+
alone told a peer about a door it may not be able to open. `nodeUri` is
|
|
11
|
+
unchanged and stays the first of the list. lnd already fetched all of them;
|
|
12
|
+
cln now builds the list the same way instead of taking `address[0]`.
|
|
13
|
+
- `sunsetDate` - `MONEYER_SUNSET_DATE`, an ISO-8601 day, published so a wallet
|
|
14
|
+
can warn its holder while spending is still possible. Deliberately separate
|
|
15
|
+
from `MONEYER_SUNSET`, which stops minting: by the time that flag goes on,
|
|
16
|
+
a holder who was going to be told has already not been told. The value is
|
|
17
|
+
validated at boot, so a typo fails loudly rather than putting a wrong date
|
|
18
|
+
in front of a holder. It also shows on the mint's own landing page.
|
|
19
|
+
- `outstandingNotesMsat` - what the mint owes, in msat. A fact about its own
|
|
20
|
+
database, so it survives an unreachable funding source, but it is the same
|
|
21
|
+
disclosure `/stats` makes and answers to the same two switches:
|
|
22
|
+
`MONEYER_STATS=false` or `MONEYER_STATS_RATIO_ONLY=true` leaves it off.
|
|
23
|
+
|
|
24
|
+
- Graded against `lnurlcash-conformance` 0.7.0. Its one addition is
|
|
25
|
+
`cash-derivation.json`, LUD-25's own seed-recoverable note secrets, which is
|
|
26
|
+
a wallet-side vector - a mint never sees a derivation, only `sha256(k1)`. So
|
|
27
|
+
nothing here changes; the pin moves so the grade names the current suite.
|
|
28
|
+
|
|
29
|
+
## 0.10.0 - 2026-09-04
|
|
30
|
+
|
|
31
|
+
Two changes here are breaking for an existing deployment, and both are
|
|
32
|
+
deliberate.
|
|
33
|
+
|
|
34
|
+
**An operator upgrading must set `MONEYER_SIGNING_KEY` before the mint will
|
|
35
|
+
start.** LUD-25 makes offline-verifiable note signatures mandatory for a
|
|
36
|
+
SERVICE, so issuing unsigned notes is no longer an allowed operating mode and
|
|
37
|
+
failing loudly at boot beats doing it quietly. `moneyer admin keys rotate`
|
|
38
|
+
bootstraps a persistent key; `--dev` still generates an ephemeral one for a
|
|
39
|
+
valueless mint.
|
|
40
|
+
|
|
41
|
+
**A custom `LightningBackend` must declare `acceptsInvoicePreimage`.** The
|
|
42
|
+
bundled cln, lnd and fake backends declare `true` and behave exactly as
|
|
43
|
+
before.
|
|
44
|
+
|
|
45
|
+
**Graded against conformance 0.6.0, and the stale-probe exception is gone.**
|
|
46
|
+
The suite now enforces the LUD-25 MUSTs moneyer already implements: every
|
|
47
|
+
rotate, split and merge must return a signature, a retried mutation must be
|
|
48
|
+
answered as a replay of its original success, and a hash lookup must not
|
|
49
|
+
distinguish a burned note id from an unknown one. Moneyer passes all of them
|
|
50
|
+
unchanged - the work landed in 0.9.1 and this is the first suite that checks
|
|
51
|
+
it.
|
|
52
|
+
|
|
53
|
+
`test/conformance-compat.ts` carried an allowlist for two probes that
|
|
54
|
+
conformance 0.4.0 sent without the then-new mandatory comment, so a correct
|
|
55
|
+
refusal looked like a failure. 0.6.0 sends the comment on both, so the
|
|
56
|
+
allowlist is deleted and the test asserts no failures at all.
|
|
57
|
+
|
|
58
|
+
**`lnurlcash-kit` moves to 0.7.0**, which refuses a mint publishing no
|
|
59
|
+
`mintPubkey`, raises `UnverifiableNoteError` for a mutation it confirms
|
|
60
|
+
without signing, and re-sends a mutation whose answer the transport lost.
|
|
61
|
+
Moneyer is on the SERVICE side of all three, so nothing here changes - but
|
|
62
|
+
the kit is also what its own tests drive the mint with, and they pass against
|
|
63
|
+
the stricter client.
|
|
64
|
+
|
|
65
|
+
**A caller-supplied invoice preimage is no longer required of a funding
|
|
66
|
+
source.** It was, and the README said so: "the capability a LUD-25 mint
|
|
67
|
+
cannot exist without". That was true of the draft that keyed a bearer note by
|
|
68
|
+
the payment preimage, and stopped being true in 0.9.0, when comment-bound
|
|
69
|
+
minting became unconditional. A note is bound to the buyer's commitment,
|
|
70
|
+
which the funding source never sees, so a node that mints its own preimages
|
|
71
|
+
can back a mint.
|
|
72
|
+
|
|
73
|
+
`LightningBackend` gains `acceptsInvoicePreimage`, and `createInvoice`'s
|
|
74
|
+
`preimageHex` is optional. cln, lnd and fake declare `true` and are unchanged:
|
|
75
|
+
knowing the payment hash before the invoice exists lets the mint refuse an
|
|
76
|
+
invoice that does not commit to it, which is still the stronger position. A
|
|
77
|
+
backend declaring `false` gets the hash off the returned invoice and the same
|
|
78
|
+
collision checks after the fact, plus two the pre-chosen path never needed - a
|
|
79
|
+
payment hash this mint has already issued or melted, and an invoice the node
|
|
80
|
+
had already settled, are both refused rather than quoted.
|
|
81
|
+
|
|
82
|
+
No phoenixd or NIP-47 backend ships yet; what changed is that one is now
|
|
83
|
+
possible. An operator writing one should know that phoenixd invoices are
|
|
84
|
+
denominated in whole sats and that `payinvoice` takes no fee limit, so a mint
|
|
85
|
+
on it cannot cap the routing cost of a melt the way cln and lnd can.
|
|
86
|
+
|
|
87
|
+
- Moneyer now refuses to start without `MONEYER_SIGNING_KEY`. LUD-25 makes
|
|
88
|
+
offline-verifiable note signatures mandatory for a SERVICE; silently
|
|
89
|
+
issuing unsigned notes is no longer an allowed operating mode. `--dev`
|
|
90
|
+
still creates an ephemeral key, and `moneyer admin keys rotate` can
|
|
91
|
+
bootstrap a persistent one.
|
|
92
|
+
- A hash-only informational lookup now treats a burned note id exactly like
|
|
93
|
+
an unknown one and refuses requests carrying both `k1` and `h`, matching
|
|
94
|
+
LUD-25's non-disclosing `h` lookup. Secret-bearing `k1` lookups retain the
|
|
95
|
+
useful `already spent` answer.
|
|
4
96
|
|
|
5
97
|
## 0.9.1 - 2026-09-01
|
|
6
98
|
|
package/README.md
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
moneyer is an independent implementation of the LUD-25 draft: paying an
|
|
7
7
|
invoice it issues mints a bearer note, and a note's holder can rotate,
|
|
8
8
|
split, merge and melt it against the withdraw callback. The note's spend
|
|
9
|
-
secret is
|
|
10
|
-
|
|
9
|
+
secret is chosen by the buyer, before the invoice exists, and named to the
|
|
10
|
+
mint only as a hash - so the mint never holds it, and neither does any node
|
|
11
|
+
that forwards the payment. It passes the full
|
|
11
12
|
[lnurlcash-conformance](https://github.com/TheCryptoDonkey/lnurlcash-conformance)
|
|
12
13
|
grader, including the spending checks, and the grader runs in this repo's
|
|
13
14
|
own test suite.
|
|
@@ -21,14 +22,18 @@ lesson, that behaviour is kept deliberately and tested.
|
|
|
21
22
|
|
|
22
23
|
- TypeScript, ESM, Node 24+. `node:http` and `node:sqlite`; no web
|
|
23
24
|
framework, no ORM.
|
|
24
|
-
- Funding sources: **cln** (clnrest) and **lnd** (REST)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
- Funding sources: **cln** (clnrest) and **lnd** (REST). Both accept a
|
|
26
|
+
caller-supplied invoice preimage, so the mint knows the payment hash
|
|
27
|
+
before the invoice exists and can refuse an invoice that does not commit
|
|
28
|
+
to it. That is a nicety, not a requirement: a note is keyed by the buyer's
|
|
29
|
+
comment commitment, which the funding source never sees, so a node that
|
|
30
|
+
mints its own preimages (phoenixd, NIP-47 `make_invoice`) can back a mint
|
|
31
|
+
too - it just gets the after-the-fact checks instead. A backend declares
|
|
32
|
+
which it is with `acceptsInvoicePreimage`. A **fake** backend exists for
|
|
28
33
|
development and tests and refuses to run outside `--dev`.
|
|
29
34
|
- Notes are stored by id, `sha256(k1)` - the store never holds a spend
|
|
30
|
-
secret.
|
|
31
|
-
secret is theirs alone from the start.
|
|
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.
|
|
32
37
|
- Signs every note it mints with its own mint key (secp256k1, the standard
|
|
33
38
|
`Lightning Signed Message` construction) for LUD-25 offline verification.
|
|
34
39
|
- The melt discipline: reply OK when the note is reserved, pay in the
|
|
@@ -97,7 +102,7 @@ default, and a variable set to an empty string counts as unset.
|
|
|
97
102
|
| `MONEYER_BACKEND_URL` | | the funding source's REST endpoint |
|
|
98
103
|
| `MONEYER_BACKEND_RUNE` | | cln authentication |
|
|
99
104
|
| `MONEYER_BACKEND_MACAROON` | | lnd authentication, hex |
|
|
100
|
-
| `MONEYER_SIGNING_KEY` | | 32
|
|
105
|
+
| `MONEYER_SIGNING_KEY` | required | Persistent 32-byte hex note-signing key. The service refuses to start without it; use `moneyer admin keys rotate` to generate one |
|
|
101
106
|
| `MONEYER_PREVIOUS_SIGNING_PUBKEYS` | | compressed pubkeys this mint signed under before, comma separated (see below) |
|
|
102
107
|
| `MONEYER_BASE_FEE_MSAT` | `0` | flat mint fee |
|
|
103
108
|
| `MONEYER_FEE_PPM` | `0` | proportional mint fee, parts per million |
|
|
@@ -109,6 +114,7 @@ default, and a variable set to an empty string counts as unset.
|
|
|
109
114
|
| `MONEYER_VERIFY` | `true` | the LUD-21 `verify` endpoint. Off means 404 |
|
|
110
115
|
| `MONEYER_WALLET_URL` | | a companion web wallet the mint's site links notes into |
|
|
111
116
|
| `MONEYER_SUNSET` | `false` | wind down: refuse anything that grows liabilities, keep every way out open |
|
|
117
|
+
| `MONEYER_SUNSET_DATE` | | the day this mint plans to close, ISO-8601 (`2026-12-31`). Published so a wallet can warn its holder while spending is still possible |
|
|
112
118
|
| `MONEYER_STATS` | `true` | the `/stats` endpoint. Off means 404 |
|
|
113
119
|
| `MONEYER_STATS_RATIO_ONLY` | `false` | publish the coverage ratio alone, without the size of the book |
|
|
114
120
|
| `MONEYER_STATS_PUBLISH` | `false` | publish a signed hourly snapshot of `/stats` to Nostr |
|
|
@@ -234,6 +240,13 @@ endpoint is public by design. `MONEYER_STATS=false` switches it off;
|
|
|
234
240
|
`MONEYER_STATS_RATIO_ONLY=true` publishes the ratio without the size of
|
|
235
241
|
the book.
|
|
236
242
|
|
|
243
|
+
The discovery document carries the same number as `outstandingNotesMsat`,
|
|
244
|
+
so a wallet reading a mint address learns what it owes without a second
|
|
245
|
+
request. Both switches govern it: an operator who has turned `/stats` off,
|
|
246
|
+
or down to the ratio, has already said not to publish the size of the
|
|
247
|
+
book, and a second endpoint doing it anyway would make those switches a
|
|
248
|
+
lie.
|
|
249
|
+
|
|
237
250
|
### Signed snapshots
|
|
238
251
|
|
|
239
252
|
With `MONEYER_STATS_PUBLISH=true`, and the Nostr identity that
|
package/dist/admin.js
CHANGED
|
@@ -173,7 +173,7 @@ export const runAdmin = async (argv, deps = {}) => {
|
|
|
173
173
|
out(` node balance ${localBalanceMsat === undefined ? 'not reported' : sats(localBalanceMsat)}`);
|
|
174
174
|
out(` coverage ${stats.coverage === undefined ? (liabilities.outstandingMsat === 0 ? 'nothing outstanding' : 'unknown') : stats.coverage}`);
|
|
175
175
|
out(` lifetime ${totals.mints} mints, ${totals.melts.paid} melts paid, ${totals.melts.restored} restored, ${totals.zaps} zaps`);
|
|
176
|
-
out(` signing key ${signingPubkey(config) ?? 'none -
|
|
176
|
+
out(` signing key ${signingPubkey(config) ?? 'none - service will refuse to start'}`);
|
|
177
177
|
out(` previous keys ${previous.length ? previous.join(', ') : 'none'}`);
|
|
178
178
|
out(` funding source ${config.backend.kind}`);
|
|
179
179
|
return 0;
|
package/dist/backends/cln.js
CHANGED
|
@@ -36,7 +36,11 @@ export const createClnBackend = (config) => {
|
|
|
36
36
|
};
|
|
37
37
|
return {
|
|
38
38
|
name: 'cln',
|
|
39
|
+
acceptsInvoicePreimage: true,
|
|
39
40
|
async createInvoice({ amountMsat, preimageHex, memo, descriptionForHash }) {
|
|
41
|
+
if (preimageHex === undefined) {
|
|
42
|
+
throw new Error('cln accepts a caller-supplied invoice preimage and was given none.');
|
|
43
|
+
}
|
|
40
44
|
const result = await mustCall('/v1/invoice', {
|
|
41
45
|
amount_msat: amountMsat,
|
|
42
46
|
label: bytesToHex(randomBytes(16)),
|
|
@@ -122,8 +126,15 @@ export const createClnBackend = (config) => {
|
|
|
122
126
|
async nodeInfo() {
|
|
123
127
|
const info = await mustCall('/v1/getinfo', {});
|
|
124
128
|
const id = info?.id;
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
// Every announced address, not just the first: a node reachable over
|
|
130
|
+
// both Tor and clearnet announces both, and a peer that can only use
|
|
131
|
+
// one of them needs to be told about it.
|
|
132
|
+
const uris = id
|
|
133
|
+
? (info?.address ?? [])
|
|
134
|
+
.filter((entry) => entry?.address && entry?.port)
|
|
135
|
+
.map((entry) => `${id}@${entry.address}:${entry.port}`)
|
|
136
|
+
: [];
|
|
137
|
+
const uri = uris[0] ?? id;
|
|
127
138
|
const color = typeof info?.color === 'string' ? `#${info.color.replace(/^#/, '')}` : undefined;
|
|
128
139
|
const numChannels = Number(info?.num_active_channels);
|
|
129
140
|
const numPeers = Number(info?.num_peers);
|
|
@@ -142,6 +153,7 @@ export const createClnBackend = (config) => {
|
|
|
142
153
|
return {
|
|
143
154
|
...(info?.alias ? { alias: info.alias } : {}),
|
|
144
155
|
...(uri ? { uri } : {}),
|
|
156
|
+
...(uris.length ? { uris } : {}),
|
|
145
157
|
...(color && /^#[0-9a-fA-F]{6}$/.test(color) ? { color } : {}),
|
|
146
158
|
...(Number.isSafeInteger(numChannels) ? { numChannels } : {}),
|
|
147
159
|
...(Number.isSafeInteger(numPeers) ? { numPeers } : {}),
|
package/dist/backends/fake.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type FakePayMode = 'succeed' | 'fail-clean' | 'fail-then-paid' | 'ambiguo
|
|
|
3
3
|
export declare const DEFAULT_AUTO_SETTLE_AFTER_MS = 1500;
|
|
4
4
|
export type FakeBackendOptions = {
|
|
5
5
|
autoSettle?: boolean;
|
|
6
|
+
nodeChoosesPreimage?: boolean;
|
|
6
7
|
autoSettleAfterMs?: number;
|
|
7
8
|
};
|
|
8
9
|
export type FakeBackend = LightningBackend & {
|
|
@@ -12,6 +13,7 @@ export type FakeBackend = LightningBackend & {
|
|
|
12
13
|
resolvePayment(paymentHashHex: string, status: 'complete' | 'failed'): void;
|
|
13
14
|
registerPaymentPreimage(paymentHashHex: string, preimageHex: string): void;
|
|
14
15
|
seedForeignPayment(paymentHashHex: string, status?: 'complete' | 'pending'): void;
|
|
16
|
+
reuseNextInvoiceHash(paymentHashHex: string): void;
|
|
15
17
|
invoiceByHash(paymentHashHex: string): {
|
|
16
18
|
preimageHex: string;
|
|
17
19
|
amountMsat: number;
|
package/dist/backends/fake.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
1
|
+
import { bytesToHex, randomBytes } from '@noble/hashes/utils.js';
|
|
2
2
|
import { sha256 } from '@noble/hashes/sha2.js';
|
|
3
3
|
import { hexToBytes } from '@noble/hashes/utils.js';
|
|
4
4
|
import { bolt11PaymentHash } from 'farrier-kit/bolt11';
|
|
@@ -22,6 +22,8 @@ const isSettled = (invoice) => {
|
|
|
22
22
|
export const FAKE_LOCAL_BALANCE_MSAT = 100_000_000_000;
|
|
23
23
|
export const createFakeBackend = (options = {}) => {
|
|
24
24
|
const autoSettle = options.autoSettle === true;
|
|
25
|
+
const nodeChoosesPreimage = options.nodeChoosesPreimage === true;
|
|
26
|
+
let reusedInvoiceHash = null;
|
|
25
27
|
const autoSettleAfterMs = options.autoSettleAfterMs ?? DEFAULT_AUTO_SETTLE_AFTER_MS;
|
|
26
28
|
// `settleAt` is when autoSettle starts calling this invoice paid; null
|
|
27
29
|
// for an invoice only control.settleInvoice can settle, which is every
|
|
@@ -33,11 +35,25 @@ export const createFakeBackend = (options = {}) => {
|
|
|
33
35
|
let payMode = 'succeed';
|
|
34
36
|
return {
|
|
35
37
|
name: 'fake',
|
|
38
|
+
acceptsInvoicePreimage: !nodeChoosesPreimage,
|
|
36
39
|
async createInvoice({ amountMsat, preimageHex, memo }) {
|
|
37
|
-
|
|
40
|
+
// A node that mints its own preimages never sees the caller's.
|
|
41
|
+
const chosen = nodeChoosesPreimage ? bytesToHex(randomBytes(32)) : preimageHex;
|
|
42
|
+
if (chosen === undefined) {
|
|
43
|
+
throw new Error('the fake backend accepts a caller-supplied invoice preimage and was given none.');
|
|
44
|
+
}
|
|
45
|
+
// A funding source handing back an invoice that is not the fresh one
|
|
46
|
+
// it was asked for. Nothing is recorded under the reused hash: the
|
|
47
|
+
// point is precisely that this invoice is somebody else's.
|
|
48
|
+
if (reusedInvoiceHash !== null) {
|
|
49
|
+
const paymentHashHex = reusedInvoiceHash;
|
|
50
|
+
reusedInvoiceHash = null;
|
|
51
|
+
return { pr: fakeBolt11({ amountMsat, paymentHashHex, memo }) };
|
|
52
|
+
}
|
|
53
|
+
const paymentHashHex = bytesToHex(sha256(hexToBytes(chosen)));
|
|
38
54
|
const pr = fakeBolt11({ amountMsat, paymentHashHex, memo });
|
|
39
55
|
invoices.set(paymentHashHex, {
|
|
40
|
-
preimageHex,
|
|
56
|
+
preimageHex: chosen,
|
|
41
57
|
amountMsat,
|
|
42
58
|
settled: false,
|
|
43
59
|
settleAt: autoSettle ? Date.now() + autoSettleAfterMs : null
|
|
@@ -128,6 +144,9 @@ export const createFakeBackend = (options = {}) => {
|
|
|
128
144
|
seedForeignPayment(paymentHashHex, status = 'complete') {
|
|
129
145
|
payments.set(paymentHashHex, { status, preimageHex: null, amountMsat: null });
|
|
130
146
|
},
|
|
147
|
+
reuseNextInvoiceHash(paymentHashHex) {
|
|
148
|
+
reusedInvoiceHash = paymentHashHex;
|
|
149
|
+
},
|
|
131
150
|
sentAmountMsat(paymentHashHex) {
|
|
132
151
|
const payment = payments.get(paymentHashHex);
|
|
133
152
|
return payment ? payment.amountMsat : undefined;
|
package/dist/backends/lnd.js
CHANGED
|
@@ -85,7 +85,11 @@ export const createLndBackend = (config) => {
|
|
|
85
85
|
};
|
|
86
86
|
return {
|
|
87
87
|
name: 'lnd',
|
|
88
|
+
acceptsInvoicePreimage: true,
|
|
88
89
|
async createInvoice({ amountMsat, preimageHex, memo, descriptionForHash }) {
|
|
90
|
+
if (preimageHex === undefined) {
|
|
91
|
+
throw new Error('lnd accepts a caller-supplied invoice preimage and was given none.');
|
|
92
|
+
}
|
|
89
93
|
const res = await json('/v1/invoices', {
|
|
90
94
|
method: 'POST',
|
|
91
95
|
body: {
|
|
@@ -220,6 +224,10 @@ export const createLndBackend = (config) => {
|
|
|
220
224
|
return {
|
|
221
225
|
...(res.json?.alias ? { alias: res.json.alias } : {}),
|
|
222
226
|
...(uris[0] || res.json?.identity_pubkey ? { uri: uris[0] ?? res.json.identity_pubkey } : {}),
|
|
227
|
+
// getinfo already hands back every announced address; `uri` only
|
|
228
|
+
// ever carried the first. A node with nothing announced falls back
|
|
229
|
+
// to the bare pubkey above and has no list to publish.
|
|
230
|
+
...(uris.length ? { uris } : {}),
|
|
223
231
|
...(color && /^#[0-9a-fA-F]{6}$/.test(color) ? { color } : {}),
|
|
224
232
|
// !== undefined narrows for exactOptionalPropertyTypes; the isFinite
|
|
225
233
|
// half keeps a NaN from an unparseable channel capacity out.
|
package/dist/backends/types.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type NodeInfo = {
|
|
2
2
|
alias?: string;
|
|
3
3
|
uri?: string;
|
|
4
|
+
uris?: string[];
|
|
4
5
|
color?: string;
|
|
5
6
|
capacityMsat?: number;
|
|
6
7
|
numChannels?: number;
|
|
@@ -19,9 +20,10 @@ export declare class PaymentAlreadyKnownError extends Error {
|
|
|
19
20
|
}
|
|
20
21
|
export interface LightningBackend {
|
|
21
22
|
readonly name: string;
|
|
23
|
+
readonly acceptsInvoicePreimage: boolean;
|
|
22
24
|
createInvoice(args: {
|
|
23
25
|
amountMsat: number;
|
|
24
|
-
preimageHex
|
|
26
|
+
preimageHex?: string;
|
|
25
27
|
memo: string;
|
|
26
28
|
descriptionForHash?: string;
|
|
27
29
|
}): Promise<{
|
package/dist/backends/types.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// The funding source. Every amount is integer milli-satoshis.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// A backend MAY accept a caller-supplied invoice preimage, and Moneyer uses
|
|
4
|
+
// one where it can: knowing the payment hash before the invoice exists lets it
|
|
5
|
+
// prove the BOLT-11 it got back is the one it asked for. That is a nicety, not
|
|
6
|
+
// a requirement. The bearer note is keyed by the wallet's mandatory comment
|
|
7
|
+
// commitment, never by the payment preimage, so a funding source whose node
|
|
8
|
+
// chooses its own preimages can back a mint perfectly well - see
|
|
9
|
+
// `acceptsInvoicePreimage`.
|
|
7
10
|
// The funding source's immediate answer to the payment attempt was a clean,
|
|
8
11
|
// terminal failure - no route, rejected, expired. Distinct from a dropped
|
|
9
12
|
// connection or timeout, where the payment may still have gone out. Even
|
package/dist/cli.js
CHANGED
|
@@ -67,8 +67,7 @@ const log = (message) => console.error(`[moneyer] ${message}`);
|
|
|
67
67
|
const moneyer = await createMoneyer(config, { log });
|
|
68
68
|
console.log(`moneyer listening on ${moneyer.url}`);
|
|
69
69
|
console.log(` lightning address: ${config.username}@${new URL(config.publicOrigin ?? moneyer.url).host}`);
|
|
70
|
-
|
|
71
|
-
console.log(` mint pubkey: ${moneyer.signer.pubkey}`);
|
|
70
|
+
console.log(` mint pubkey: ${moneyer.signer.pubkey}`);
|
|
72
71
|
console.log(` funding source: ${moneyer.backend.name}`);
|
|
73
72
|
if (values.dev) {
|
|
74
73
|
const k1 = bytesToHex(randomBytes(32));
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -94,6 +94,7 @@ export const configFromEnv = (env = process.env) => {
|
|
|
94
94
|
throw new Error('MONEYER_SIGNING_KEY must be 32 bytes of hex.');
|
|
95
95
|
}
|
|
96
96
|
const previousSigningPubkeys = previousPubkeysFromEnv(env, signingKey);
|
|
97
|
+
const sunsetDate = sunsetDateFromEnv(env);
|
|
97
98
|
const kind = env.MONEYER_BACKEND ?? 'fake';
|
|
98
99
|
let backend;
|
|
99
100
|
if (kind === 'fake') {
|
|
@@ -225,10 +226,33 @@ export const configFromEnv = (env = process.env) => {
|
|
|
225
226
|
...(env.MONEYER_WALLET_URL ? { walletUrl: env.MONEYER_WALLET_URL.replace(/\/+$/, '') } : {}),
|
|
226
227
|
maxK1s: int(env.MONEYER_MAX_K1S, DEFAULTS.maxK1s),
|
|
227
228
|
sunset: flag(env.MONEYER_SUNSET, DEFAULTS.sunset),
|
|
229
|
+
...(sunsetDate ? { sunsetDate } : {}),
|
|
228
230
|
...(zap ? { zap } : {}),
|
|
229
231
|
...(namePriceMsat !== undefined ? { namePriceMsat } : {})
|
|
230
232
|
};
|
|
231
233
|
};
|
|
234
|
+
// MONEYER_SUNSET_DATE="2026-12-31" - the day this mint plans to stop.
|
|
235
|
+
// Validated rather than passed through, because a wallet showing a holder
|
|
236
|
+
// "this mint closes on 31/12/2026" off a string nobody checked is worse
|
|
237
|
+
// than showing nothing: a typo that parses as a date in 2027 buys the
|
|
238
|
+
// holder confidence they have not earned, and one that parses as nothing
|
|
239
|
+
// silently disables the warning the operator thought they had switched on.
|
|
240
|
+
// Whole days only - an hour of notice is not notice.
|
|
241
|
+
const sunsetDateFromEnv = (env) => {
|
|
242
|
+
const raw = env.MONEYER_SUNSET_DATE?.trim();
|
|
243
|
+
if (!raw)
|
|
244
|
+
return undefined;
|
|
245
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
246
|
+
throw new Error('MONEYER_SUNSET_DATE must be an ISO-8601 date, e.g. 2026-12-31.');
|
|
247
|
+
}
|
|
248
|
+
// Round-tripped, so 2026-02-31 is caught: Date accepts it and rolls it
|
|
249
|
+
// forward to 3 March, which is not the day the operator typed.
|
|
250
|
+
const parsed = new Date(`${raw}T00:00:00Z`);
|
|
251
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== raw) {
|
|
252
|
+
throw new Error(`MONEYER_SUNSET_DATE is not a real date: ${JSON.stringify(raw)}.`);
|
|
253
|
+
}
|
|
254
|
+
return raw;
|
|
255
|
+
};
|
|
232
256
|
// MONEYER_PREVIOUS_SIGNING_PUBKEYS="02ab...,03cd..." - the keys this mint
|
|
233
257
|
// signed under before the current one. Every entry must be a point the
|
|
234
258
|
// curve accepts, because a typo here would quietly tell wallets to accept
|
package/dist/landing.js
CHANGED
|
@@ -93,6 +93,7 @@ ${mintPubkey ? `<div class="kv"><span>notes signed by</span><code>${escapeHtml(m
|
|
|
93
93
|
${nodeInfo.uri ? `<div class="kv"><span>node</span><code>${escapeHtml(nodeInfo.uri)}</code></div>` : ''}
|
|
94
94
|
${coverageLine ? `<div class="kv"><span>coverage</span><b>${escapeHtml(coverageLine)}</b></div>` : ''}
|
|
95
95
|
${config.sunset ? '<div class="kv"><span>status</span><b>sunsetting - redeem only</b></div>' : ''}
|
|
96
|
+
${!config.sunset && config.sunsetDate ? `<div class="kv"><span>closing</span><b>${escapeHtml(config.sunsetDate)} - spend or move notes before then</b></div>` : ''}
|
|
96
97
|
${contacts.map(entry => `<div class="kv"><span>${entry.label}</span><code>${escapeHtml(entry.value)}</code></div>`).join('\n')}
|
|
97
98
|
${config.tosUrl ? `<div class="kv"><span>terms</span><a href="${escapeHtml(config.tosUrl)}" rel="noopener noreferrer">${escapeHtml(config.tosUrl)}</a></div>` : ''}
|
|
98
99
|
</div>
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -54,10 +54,13 @@ const backendFor = (config) => {
|
|
|
54
54
|
}
|
|
55
55
|
};
|
|
56
56
|
export const createMoneyer = async (config, deps = {}) => {
|
|
57
|
+
if (!config.signingKey) {
|
|
58
|
+
throw new Error('MONEYER_SIGNING_KEY is required: LUD-25 requires every SERVICE to sign its notes. Run `moneyer admin keys rotate` to generate one.');
|
|
59
|
+
}
|
|
57
60
|
const log = deps.log ?? (() => { });
|
|
58
61
|
const store = deps.store ?? new NoteStore(config.dbPath);
|
|
59
62
|
const backend = deps.backend ?? backendFor(config);
|
|
60
|
-
const signer =
|
|
63
|
+
const signer = createNoteSigner(config.signingKey);
|
|
61
64
|
const webAssets = deps.webAssets === undefined ? loadWebAssets() : deps.webAssets;
|
|
62
65
|
// Node identity for the discovery endpoint, fetched once, best-effort: a
|
|
63
66
|
// funding source that cannot answer does not stop the mint serving.
|
|
@@ -246,7 +249,25 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
246
249
|
? { nodeCapacity: nodeInfo.capacityMsat, nodeCapacityMsat: nodeInfo.capacityMsat }
|
|
247
250
|
: {}),
|
|
248
251
|
...(nodeInfo.numChannels !== undefined ? { nodeNumChannels: nodeInfo.numChannels } : {}),
|
|
249
|
-
...(nodeInfo.numPeers !== undefined ? { nodeNumPeers: nodeInfo.numPeers } : {})
|
|
252
|
+
...(nodeInfo.numPeers !== undefined ? { nodeNumPeers: nodeInfo.numPeers } : {}),
|
|
253
|
+
// Every address the node announces. nodeUri stays, and stays the
|
|
254
|
+
// first of these: a wallet that only reads the singular field must
|
|
255
|
+
// keep working, and the reference mint publishes both for the same
|
|
256
|
+
// reason.
|
|
257
|
+
...(nodeInfo.uris?.length ? { nodeUris: nodeInfo.uris } : {}),
|
|
258
|
+
// Advance warning of a planned shutdown, so a wallet can tell its
|
|
259
|
+
// holder while spending is still possible.
|
|
260
|
+
...(config.sunsetDate ? { sunsetDate: config.sunsetDate } : {}),
|
|
261
|
+
// What this mint owes, in msat: the combined value of every note it
|
|
262
|
+
// has issued and not burned. A fact about its own database, so it
|
|
263
|
+
// survives an unreachable funding source - but it is the same
|
|
264
|
+
// disclosure /stats makes, so it answers to the same two switches. An
|
|
265
|
+
// operator who has turned stats off, or down to the coverage ratio
|
|
266
|
+
// alone, has already said not to put the size of the book on the
|
|
267
|
+
// internet, and this endpoint is not a way around that.
|
|
268
|
+
...(config.stats !== false && config.statsRatioOnly !== true
|
|
269
|
+
? { outstandingNotesMsat: store.liabilities().outstandingMsat }
|
|
270
|
+
: {})
|
|
250
271
|
});
|
|
251
272
|
// An hourly signed snapshot, so the coverage history can be checked
|
|
252
273
|
// after the fact rather than taken on the operator's word for it today.
|
|
@@ -629,19 +650,30 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
629
650
|
return fail('Invalid or already spent k1.');
|
|
630
651
|
}
|
|
631
652
|
// The preimage is payment proof only; comment names the future note.
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
653
|
+
//
|
|
654
|
+
// Where the funding source lets Moneyer choose it, choosing means the
|
|
655
|
+
// payment hash is known before the invoice exists, and the invoice that
|
|
656
|
+
// comes back can be held to it. Where the node mints its own preimages,
|
|
657
|
+
// the hash arrives with the invoice and the same collisions are checked
|
|
658
|
+
// after the fact instead. Either way the preimage is never persisted -
|
|
659
|
+
// the store keeps hashes only - and either way the bearer note is keyed
|
|
660
|
+
// by the wallet's comment commitment, which is why a node that will not
|
|
661
|
+
// take a preimage can still back a mint.
|
|
662
|
+
let chosen = null;
|
|
663
|
+
if (backend.acceptsInvoicePreimage) {
|
|
664
|
+
let preimageHex = bytesToHex(randomBytes(32));
|
|
665
|
+
let paymentHash = hashK1(preimageHex);
|
|
666
|
+
while (store.outputIdInUse(paymentHash) || paymentHash === outputId) {
|
|
667
|
+
preimageHex = bytesToHex(randomBytes(32));
|
|
668
|
+
paymentHash = hashK1(preimageHex);
|
|
669
|
+
}
|
|
670
|
+
chosen = { preimageHex, paymentHash };
|
|
639
671
|
}
|
|
640
672
|
let pr;
|
|
641
673
|
try {
|
|
642
674
|
pr = (await backend.createInvoice({
|
|
643
675
|
amountMsat: amount,
|
|
644
|
-
preimageHex:
|
|
676
|
+
...(chosen === null ? {} : { preimageHex: chosen.preimageHex }),
|
|
645
677
|
memo: `LNURLcash mint at ${host}`
|
|
646
678
|
})).pr;
|
|
647
679
|
}
|
|
@@ -649,14 +681,50 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
649
681
|
log(`create invoice failed: ${err.message}`);
|
|
650
682
|
return fail('Temporarily unable to issue an invoice.');
|
|
651
683
|
}
|
|
652
|
-
// Trust but verify: an invoice
|
|
653
|
-
//
|
|
654
|
-
// claim. Refuse to hand it out.
|
|
684
|
+
// Trust but verify: an invoice for the wrong amount would take the
|
|
685
|
+
// payer's money and mint a note worth something else.
|
|
655
686
|
const decoded = tryDecodeBolt11(pr);
|
|
656
|
-
if (!decoded || decoded.
|
|
657
|
-
log('funding source returned an invoice that does not match the requested
|
|
687
|
+
if (!decoded || decoded.amountMsats !== BigInt(amount)) {
|
|
688
|
+
log('funding source returned an invoice that does not match the requested amount');
|
|
658
689
|
return fail('Temporarily unable to issue an invoice.');
|
|
659
690
|
}
|
|
691
|
+
const paymentHash = decoded.paymentHashHex;
|
|
692
|
+
if (chosen !== null) {
|
|
693
|
+
// An invoice that does not commit to OUR payment hash is not the one
|
|
694
|
+
// that was asked for, and paying it would mint nothing claimable.
|
|
695
|
+
if (paymentHash !== chosen.paymentHash) {
|
|
696
|
+
log('funding source returned an invoice that does not match the requested preimage');
|
|
697
|
+
return fail('Temporarily unable to issue an invoice.');
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
// The node chose the preimage, so every check the pre-chosen path got
|
|
702
|
+
// for free has to be made now - plus one it never needed. A hash this
|
|
703
|
+
// mint has already seen, or an invoice the node settled before handing
|
|
704
|
+
// it over, would credit a note against money that never moved on this
|
|
705
|
+
// quote's behalf.
|
|
706
|
+
if (paymentHash === outputId ||
|
|
707
|
+
store.outputIdInUse(paymentHash) ||
|
|
708
|
+
store.mintInvoiceByHash(paymentHash) ||
|
|
709
|
+
store.meltByHash(paymentHash)) {
|
|
710
|
+
log('funding source returned an invoice whose payment hash is already in use');
|
|
711
|
+
return fail('Temporarily unable to issue an invoice.');
|
|
712
|
+
}
|
|
713
|
+
let alreadySettled;
|
|
714
|
+
try {
|
|
715
|
+
alreadySettled = await backend.isInvoiceSettled(paymentHash);
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
// Unconfirmable is not "fresh". Handing out an invoice that might
|
|
719
|
+
// already be paid is the whole risk this check exists to close.
|
|
720
|
+
log(`could not confirm a fresh invoice is unsettled: ${err.message}`);
|
|
721
|
+
return fail('Temporarily unable to issue an invoice.');
|
|
722
|
+
}
|
|
723
|
+
if (alreadySettled) {
|
|
724
|
+
log('funding source returned an invoice that was already settled');
|
|
725
|
+
return fail('Temporarily unable to issue an invoice.');
|
|
726
|
+
}
|
|
727
|
+
}
|
|
660
728
|
try {
|
|
661
729
|
store.recordMintInvoice(paymentHash, pr, amount, net, outputId);
|
|
662
730
|
}
|
|
@@ -758,22 +826,23 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
758
826
|
const hasH = q.has('h');
|
|
759
827
|
const k1 = q.get('k1')?.toLowerCase();
|
|
760
828
|
const h = q.get('h')?.toLowerCase();
|
|
761
|
-
if (
|
|
829
|
+
if (hasK1 === hasH ||
|
|
762
830
|
(hasK1 && (!k1 || !HEX32.test(k1))) ||
|
|
763
831
|
(hasH && (!h || !HEX32.test(h)))) {
|
|
764
832
|
return fail('Unknown note.');
|
|
765
833
|
}
|
|
766
834
|
// LUD-25's hash-only check lets a wallet prove that the note it just
|
|
767
835
|
// bought exists without sending the bearer secret to the service a
|
|
768
|
-
// second time.
|
|
769
|
-
// note; choosing one would make a malformed URL an ownership oracle.
|
|
770
|
-
if (k1 && h && hashK1(k1) !== h)
|
|
771
|
-
return fail('Unknown note.');
|
|
836
|
+
// second time. `h` is accepted in place of `k1`, never alongside it.
|
|
772
837
|
const note = h ? await resolveNoteId(h) : await resolveNote(k1);
|
|
773
838
|
if (!note)
|
|
774
839
|
return fail('Unknown note.');
|
|
840
|
+
// A hash-only lookup must not disclose whether a note id once existed:
|
|
841
|
+
// LUD-25 gives a never-registered and an already-spent h the same
|
|
842
|
+
// response as an unknown k1. A caller presenting the bearer k1 still
|
|
843
|
+
// receives the useful already-spent distinction.
|
|
775
844
|
if (note.state === 'burned')
|
|
776
|
-
return fail('Note already spent.');
|
|
845
|
+
return fail(h ? 'Unknown note.' : 'Note already spent.');
|
|
777
846
|
// A note reserved by an in-flight melt is not withdrawable, and must
|
|
778
847
|
// not be advertised as though it were. LUD-25 makes this GET the way
|
|
779
848
|
// anyone checks what a note is worth, so answering "live, worth all
|
package/llms.txt
CHANGED
|
@@ -15,13 +15,17 @@ Client library to build against a mint: lnurlcash-kit
|
|
|
15
15
|
|
|
16
16
|
## Rules this SERVICE follows (and any mint MUST)
|
|
17
17
|
|
|
18
|
-
1. A mint
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
and to anyone who polls verify with the payment hash
|
|
18
|
+
1. A mint quote MUST be named. The payer sends `comment=hex(sha256(secret))`
|
|
19
|
+
on the pay callback (LUD-12, and the payRequest advertises
|
|
20
|
+
`commentAllowed: 64`); a missing or malformed comment is refused before
|
|
21
|
+
any invoice is issued. The note is credited at that hash, so the secret
|
|
22
|
+
is the payer's alone. The payment preimage is settlement proof and never
|
|
23
|
+
a bearer credential - it is known to the funding source, to every node on
|
|
24
|
+
the route, and to anyone who polls verify with the payment hash written
|
|
25
|
+
inside the invoice. `h` is an additive alias for the same commitment and
|
|
26
|
+
never replaces the comment. Because the note is not keyed by the
|
|
27
|
+
preimage, a funding source whose node chooses its own preimages
|
|
28
|
+
(phoenixd, NIP-47 make_invoice) can back a mint.
|
|
25
29
|
2. Mutations are atomic: all named k1 burn and all outputs mint, or
|
|
26
30
|
nothing happens. Duplicate k1 in one request is refused.
|
|
27
31
|
3. h/h2 are WALLET-supplied hashes; the mint never generates a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/moneyer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "An LNURLcash (LUD-25) mint - strikes Lightning bearer notes. Independent implementation, cln/lnd funding sources, SQLite, zero HTTP framework.",
|
|
5
5
|
"author": "TheCryptoDonkey",
|
|
6
6
|
"license": "MIT",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"@noble/hashes": "^2.3.0",
|
|
61
61
|
"@scure/base": "^1.2.4",
|
|
62
62
|
"farrier-kit": "^1.1.3",
|
|
63
|
-
"lnurlcash-kit": "^0.
|
|
63
|
+
"lnurlcash-kit": "^0.7.0",
|
|
64
64
|
"nostr-tools": "2.24.1"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@types/node": "^24.0.0",
|
|
71
71
|
"animejs": "^4.0.0",
|
|
72
72
|
"happy-dom": "^20.0.0",
|
|
73
|
-
"lnurlcash-conformance": "^0.
|
|
73
|
+
"lnurlcash-conformance": "^0.7.0",
|
|
74
74
|
"playwright": "^1.62.1",
|
|
75
75
|
"typescript": "^5.7.0",
|
|
76
76
|
"uqr": "^0.1.2",
|