@forgesworn/moneyer 0.1.2 → 0.2.1
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 +182 -0
- package/README.md +447 -11
- package/THREAT-MODEL.md +15 -6
- package/dist/admin.d.ts +12 -0
- package/dist/admin.js +410 -0
- package/dist/announce.d.ts +12 -0
- package/dist/announce.js +82 -0
- package/dist/backends/cln.js +30 -8
- package/dist/backends/fake-bolt11.d.ts +1 -1
- package/dist/backends/fake-bolt11.js +3 -1
- package/dist/backends/fake.d.ts +9 -1
- package/dist/backends/fake.js +57 -14
- package/dist/backends/lnd.js +30 -6
- package/dist/backends/types.d.ts +3 -0
- package/dist/cli.js +25 -3
- package/dist/config.d.ts +30 -0
- package/dist/config.js +194 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/landing.d.ts +2 -0
- package/dist/landing.js +42 -2
- package/dist/melt.d.ts +1 -0
- package/dist/melt.js +2 -1
- package/dist/names.d.ts +39 -0
- package/dist/names.js +172 -0
- package/dist/privacy.d.ts +2 -0
- package/dist/privacy.js +14 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +566 -41
- package/dist/signing.d.ts +2 -0
- package/dist/signing.js +34 -0
- package/dist/stats.d.ts +31 -0
- package/dist/stats.js +115 -0
- package/dist/store.d.ts +105 -3
- package/dist/store.js +355 -19
- package/dist/version.d.ts +1 -0
- package/dist/version.js +17 -0
- package/dist/zap.d.ts +52 -0
- package/dist/zap.js +294 -0
- package/llms.txt +25 -8
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
> to strike coins; this one strikes Lightning bearer notes.
|
|
5
5
|
|
|
6
6
|
moneyer is an independent implementation of the LUD-25 draft: paying an
|
|
7
|
-
invoice it issues mints a bearer note
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
invoice it issues mints a bearer note, and a note's holder can rotate,
|
|
8
|
+
split, merge and melt it against the withdraw callback. The note's spend
|
|
9
|
+
secret is the invoice's payment preimage, or, better, a secret the buyer
|
|
10
|
+
chose and named on the way in. It passes the full
|
|
10
11
|
[lnurlcash-conformance](https://github.com/TheCryptoDonkey/lnurlcash-conformance)
|
|
11
12
|
grader, including the spending checks, and the grader runs in this repo's
|
|
12
13
|
own test suite.
|
|
@@ -26,8 +27,8 @@ lesson, that behaviour is kept deliberately and tested.
|
|
|
26
27
|
which is why neither can back a mint. A **fake** backend exists for
|
|
27
28
|
development and tests and refuses to run outside `--dev`.
|
|
28
29
|
- Notes are stored by id, `sha256(k1)` - the store never holds a spend
|
|
29
|
-
secret. A
|
|
30
|
-
the
|
|
30
|
+
secret. A buyer may name the note they are buying, in which case the
|
|
31
|
+
secret is theirs alone from the start.
|
|
31
32
|
- Signs every note it mints with its own mint key (secp256k1, the standard
|
|
32
33
|
`Lightning Signed Message` construction) for LUD-25 offline verification.
|
|
33
34
|
- The melt discipline: reply OK when the note is reserved, pay in the
|
|
@@ -63,10 +64,61 @@ node dist/cli.js
|
|
|
63
64
|
```
|
|
64
65
|
|
|
65
66
|
The mint is then payable at `mint@mint.example` (and the bare-domain `_`
|
|
66
|
-
alias).
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
alias).
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
Environment only. Every variable is `MONEYER_*`; anything unset takes the
|
|
72
|
+
default, and a variable set to an empty string counts as unset.
|
|
73
|
+
|
|
74
|
+
| variable | default | what it does |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| `MONEYER_HOST` | `127.0.0.1` | listen address |
|
|
77
|
+
| `MONEYER_PORT` | `3737` | listen port |
|
|
78
|
+
| `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 |
|
|
79
|
+
| `MONEYER_USERNAME` | `mint` | the local part of the mint's own lightning address |
|
|
80
|
+
| `MONEYER_DESCRIPTION` | `an LNURLcash note` | what a note is called on the wire (`defaultDescription`, and `description` on discovery) |
|
|
81
|
+
| `MONEYER_DB` | `moneyer.sqlite` | SQLite path; `:memory:` allowed |
|
|
82
|
+
| `MONEYER_BACKEND` | `fake` | `cln`, `lnd`, or `fake` (refused outside `--dev`) |
|
|
83
|
+
| `MONEYER_BACKEND_URL` | | the funding source's REST endpoint |
|
|
84
|
+
| `MONEYER_BACKEND_RUNE` | | cln authentication |
|
|
85
|
+
| `MONEYER_BACKEND_MACAROON` | | lnd authentication, hex |
|
|
86
|
+
| `MONEYER_SIGNING_KEY` | | 32 bytes of hex. Unset means notes go out unsigned, which holders will notice |
|
|
87
|
+
| `MONEYER_PREVIOUS_SIGNING_PUBKEYS` | | compressed pubkeys this mint signed under before, comma separated (see below) |
|
|
88
|
+
| `MONEYER_BASE_FEE_MSAT` | `0` | flat mint fee |
|
|
89
|
+
| `MONEYER_FEE_PPM` | `0` | proportional mint fee, parts per million |
|
|
90
|
+
| `MONEYER_ROUND_FEE_TO_SAT` | `false` | ceiling the mint fee to a whole sat (see below) |
|
|
91
|
+
| `MONEYER_MIN_SENDABLE_MSAT` | `1000` | smallest payment the mint advertises |
|
|
92
|
+
| `MONEYER_MAX_SENDABLE_MSAT` | `100000000` | largest payment the mint advertises |
|
|
93
|
+
| `MONEYER_MIN_MINT_MSAT` | `1000` | dust floor: the smallest note the mint will strike |
|
|
94
|
+
| `MONEYER_MAX_K1S` | `21` | most notes one callback may name |
|
|
95
|
+
| `MONEYER_VERIFY` | `true` | the LUD-21 `verify` endpoint. Off means 404 |
|
|
96
|
+
| `MONEYER_WALLET_URL` | | a companion web wallet the mint's site links notes into |
|
|
97
|
+
| `MONEYER_SUNSET` | `false` | wind down: refuse anything that grows liabilities, keep every way out open |
|
|
98
|
+
| `MONEYER_STATS` | `true` | the `/stats` endpoint. Off means 404 |
|
|
99
|
+
| `MONEYER_STATS_RATIO_ONLY` | `false` | publish the coverage ratio alone, without the size of the book |
|
|
100
|
+
| `MONEYER_STATS_PUBLISH` | `false` | publish a signed hourly snapshot of `/stats` to Nostr |
|
|
101
|
+
| `MONEYER_ANNOUNCE` | `false` | announce this mint on Nostr, hourly, so a wallet can find it (see below) |
|
|
102
|
+
| `MONEYER_METRICS` | `false` | the `/metrics` endpoint, in the OpenMetrics text format |
|
|
103
|
+
| `MONEYER_NAME_PRICE_MSAT` | | what a self-service lightning address costs. Unset means registration is closed; `0` means free |
|
|
104
|
+
|
|
105
|
+
### Who runs this mint
|
|
106
|
+
|
|
107
|
+
Optional, all of it, and unset means the field is simply absent from the
|
|
108
|
+
discovery endpoint rather than present and empty. This is the human layer
|
|
109
|
+
a holder wants before trusting a mint with sats.
|
|
110
|
+
|
|
111
|
+
| variable | what it does |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| `MONEYER_NAME` | the mint's name, shown on its own site and published as `name` |
|
|
114
|
+
| `MONEYER_CONTACT_NOSTR` | an npub or hex pubkey to reach the operator on; published as an npub |
|
|
115
|
+
| `MONEYER_CONTACT_EMAIL` | an email address to reach the operator on |
|
|
116
|
+
| `MONEYER_CONTACT_URL` | a contact page |
|
|
117
|
+
| `MONEYER_TOS_URL` | the terms a holder is agreeing to |
|
|
118
|
+
| `MONEYER_MOTD` | a message of the day, at most 280 characters: maintenance, a sunset, a fee change. It is how an operator talks to holders without a mailing list they never signed up to |
|
|
119
|
+
|
|
120
|
+
The discovery endpoint also publishes `fees` (the structured twin of the
|
|
121
|
+
payRequest metadata's fee prose), `version`, and `previousPubkeys`.
|
|
70
122
|
|
|
71
123
|
## The website
|
|
72
124
|
|
|
@@ -97,6 +149,117 @@ import {createMoneyer, configFromEnv} from '@forgesworn/moneyer'
|
|
|
97
149
|
const mint = await createMoneyer(configFromEnv())
|
|
98
150
|
```
|
|
99
151
|
|
|
152
|
+
## Rotating the signing key
|
|
153
|
+
|
|
154
|
+
moneyer signs notes with its own key rather than the funding node's, so
|
|
155
|
+
the node can be swapped without touching a single outstanding note. The
|
|
156
|
+
signing key itself is the harder one: rotate it naively and every note
|
|
157
|
+
already out there stops verifying at once, and every wallet that pinned
|
|
158
|
+
the old key refuses the mint on its next contact.
|
|
159
|
+
|
|
160
|
+
So the old pubkeys stay published. `previousPubkeys` on the discovery
|
|
161
|
+
endpoint lists the keys this mint has signed under before, and a wallet
|
|
162
|
+
that finds its pin in that list treats the change as a rotation rather
|
|
163
|
+
than an impostor. The trust argument is TOFU's own: whoever controls the
|
|
164
|
+
host controls the pin either way, and the history only stops a legitimate
|
|
165
|
+
rotation from looking like an attack.
|
|
166
|
+
|
|
167
|
+
To rotate:
|
|
168
|
+
|
|
169
|
+
1. Generate a new key: `openssl rand -hex 32`.
|
|
170
|
+
2. Set `MONEYER_SIGNING_KEY` to the new key, and add the **old public**
|
|
171
|
+
key to `MONEYER_PREVIOUS_SIGNING_PUBKEYS` (compressed, 33 bytes of
|
|
172
|
+
hex, comma separated, oldest last). The old private key is not wanted
|
|
173
|
+
here and should not stay on the server: verifying an old note needs
|
|
174
|
+
only the pubkey.
|
|
175
|
+
3. Restart. Notes signed under the old key keep verifying against it;
|
|
176
|
+
every note minted or mutated from now on is signed under the new one,
|
|
177
|
+
so a holder who rotates their note re-signs it under the new key for
|
|
178
|
+
free.
|
|
179
|
+
4. Tell holders through `MONEYER_MOTD`.
|
|
180
|
+
|
|
181
|
+
Both variables are validated at startup: an entry that is not a
|
|
182
|
+
compressed point on the curve, or that repeats the current key, stops the
|
|
183
|
+
mint rather than quietly publishing a key that verifies nothing.
|
|
184
|
+
|
|
185
|
+
## Transparency: what the mint owes
|
|
186
|
+
|
|
187
|
+
LNURLcash notes are not blinded, so a mint can state its liabilities
|
|
188
|
+
exactly. No epochs, no blinded sums, no ceremony: the mint knows every
|
|
189
|
+
note it has issued and what each is worth, and it can add them up.
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
curl -s https://mint.example/stats | jq
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"at": 1755800000000,
|
|
198
|
+
"outstandingMsat": 48120000,
|
|
199
|
+
"outstandingNotes": 12,
|
|
200
|
+
"pendingMsat": 0,
|
|
201
|
+
"pendingMelts": 0,
|
|
202
|
+
"oldestPendingMeltAgeSecs": 0,
|
|
203
|
+
"localBalanceMsat": 92400000,
|
|
204
|
+
"coverage": 1.9202,
|
|
205
|
+
"reconciledAt": 1755799800000
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`coverage` is the funding node's outbound balance over the mint's
|
|
210
|
+
outstanding liabilities, to four decimal places. Under 1 means the node
|
|
211
|
+
could not pay every note out today; that is the operator's business to
|
|
212
|
+
show, not the endpoint's to hide. A mint with nothing outstanding has no
|
|
213
|
+
coverage figure at all, because "infinitely covered" is not a claim worth
|
|
214
|
+
making, and a funding source that will not report its balance leaves both
|
|
215
|
+
`localBalanceMsat` and `coverage` out rather than have them guessed.
|
|
216
|
+
|
|
217
|
+
The figures are cached for 30 seconds, are never per-note, and appear as
|
|
218
|
+
one row on the mint's own site and its fallback landing page. The
|
|
219
|
+
endpoint is public by design. `MONEYER_STATS=false` switches it off;
|
|
220
|
+
`MONEYER_STATS_RATIO_ONLY=true` publishes the ratio without the size of
|
|
221
|
+
the book.
|
|
222
|
+
|
|
223
|
+
### Signed snapshots
|
|
224
|
+
|
|
225
|
+
With `MONEYER_STATS_PUBLISH=true`, and the Nostr identity that
|
|
226
|
+
zap-to-note uses, the mint publishes an hourly snapshot as a
|
|
227
|
+
parameterised replaceable event (kind 30078, `d` tag
|
|
228
|
+
`lnurlcash-liabilities`). Its content is the `/stats` JSON plus a `sig`
|
|
229
|
+
over the canonicalised (RFC 8785) body, made with the **note signing
|
|
230
|
+
key** - the key a holder already checks their own notes against, so the
|
|
231
|
+
history adds nothing new to trust.
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
node scripts/verify-stats.mjs <mintPubkey> snapshot.json
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Announcing the mint
|
|
238
|
+
|
|
239
|
+
A wallet only ever learns of a mint by being told its address. There is no
|
|
240
|
+
way to find one, and nothing that might follow from finding one, a list or
|
|
241
|
+
a recommendation, can exist until that first step does.
|
|
242
|
+
|
|
243
|
+
With `MONEYER_ANNOUNCE=true`, and the same Nostr identity zap-to-note
|
|
244
|
+
uses, the mint announces itself on the same hourly pass as the snapshot:
|
|
245
|
+
kind 30078 again, `d` tag `lnurlcash-mint`. The content is this mint's own
|
|
246
|
+
discovery document, exactly as `/.well-known/lnurlw/<user>` serves it, so
|
|
247
|
+
there is one description of a mint rather than two that drift apart. It
|
|
248
|
+
carries a `sig` over the canonicalised (RFC 8785) document made with the
|
|
249
|
+
**note signing key**, so a holder can check that the mint announcing
|
|
250
|
+
itself is the mint their notes verify against, whoever the Nostr identity
|
|
251
|
+
publishing it belongs to. `verifyAnnouncement` in this package does that
|
|
252
|
+
check.
|
|
253
|
+
|
|
254
|
+
Off by default, deliberately. A mint that does not want to be listed says
|
|
255
|
+
nothing, and turning it on is the operator saying otherwise. Announcing
|
|
256
|
+
needs `MONEYER_PUBLIC_ORIGIN`, since an announcement made with a `Host`
|
|
257
|
+
header nobody sent is an address nobody can call back on.
|
|
258
|
+
|
|
259
|
+
There is no recommendation or review here, and no new event kind. Both are
|
|
260
|
+
protocol decisions that belong with the LNURLcash NIPs rather than with a
|
|
261
|
+
single mint's implementation.
|
|
262
|
+
|
|
100
263
|
## The mint fee, and the rounding question
|
|
101
264
|
|
|
102
265
|
LUD-25 gives the fee as a flat `base_fee_msat` plus a ppm cut and says
|
|
@@ -110,16 +273,289 @@ word, and the conformance grader accepts both.
|
|
|
110
273
|
behaviour. Off by default: turning it on raises what the mint withholds,
|
|
111
274
|
and that is not a change to make behind an operator's back on a redeploy.
|
|
112
275
|
|
|
276
|
+
Why you probably want it on anyway: msat-exact fees make notes like 94.9
|
|
277
|
+
sat, and most Lightning wallets can only invoice whole sats, so such a
|
|
278
|
+
note cannot be withdrawn by them. moneyer covers the notes already out
|
|
279
|
+
there by advertising `minWithdrawable` as the note floored to a whole sat
|
|
280
|
+
and accepting a melt for that; the sub-sat remainder is dust the mint
|
|
281
|
+
keeps. Rounding at mint time means it never comes up.
|
|
282
|
+
|
|
283
|
+
## Melting to an invoice with no amount
|
|
284
|
+
|
|
285
|
+
An invoice that states no amount leaves the sending side to decide, and
|
|
286
|
+
for a bearer note there is nothing to decide: the note's value is the
|
|
287
|
+
amount. moneyer melts a note to one for the whole-sat floor of its value,
|
|
288
|
+
the same figure it accepts from a wallet that fills the amount in itself,
|
|
289
|
+
and keeps the sub-sat remainder as the same dust described above.
|
|
290
|
+
|
|
291
|
+
A note worth less than a single satoshi has nothing left once the floor
|
|
292
|
+
applies, so an amountless invoice from one is refused with `insufficient
|
|
293
|
+
value`. It can still be melted the ordinary way by a wallet that invoices
|
|
294
|
+
its exact millisatoshi value.
|
|
295
|
+
|
|
296
|
+
Nothing else about the melt changes. `OK` still means in flight, the note
|
|
297
|
+
still burns only on confirmed payment and restores only on confirmed
|
|
298
|
+
non-payment, and the routing budget is still sized against the note.
|
|
299
|
+
|
|
300
|
+
## Name the note you are buying
|
|
301
|
+
|
|
302
|
+
Paying a mint invoice mints a note, and by default that note's spend
|
|
303
|
+
secret is the invoice's payment preimage. A payment preimage is not a
|
|
304
|
+
private thing. The funding source has it, every node that forwarded the
|
|
305
|
+
payment has it, and LUD-21 `verify` hands it to whoever asks with the
|
|
306
|
+
payment hash, which is written inside the invoice on the payer's screen.
|
|
307
|
+
The draft's answer is for the wallet to claim and rotate the instant the
|
|
308
|
+
invoice settles, which is a foot race the wallet has to keep winning.
|
|
309
|
+
|
|
310
|
+
So a wallet may name the note instead. It chooses the secret first, writes
|
|
311
|
+
it down, and sends `h`, the sha256 of that secret, on the pay callback:
|
|
312
|
+
|
|
313
|
+
```
|
|
314
|
+
GET /p/cb?amount=21000&h=<64 hex>
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
The mint credits the note at `h` when the invoice settles. The payment
|
|
318
|
+
preimage then buys nothing: it is an ordinary payment proof, which is why
|
|
319
|
+
`verify` goes on serving it. Nobody but the buyer ever knew the secret, so
|
|
320
|
+
there is no race left to run, and no window in which holding the invoice
|
|
321
|
+
is nearly holding the money.
|
|
322
|
+
|
|
323
|
+
`h` is optional and additive. A wallet that sends none gets exactly the
|
|
324
|
+
behaviour it always got, so upgrading this mint breaks nothing that works
|
|
325
|
+
today, and the LUD-25 draft needs no change to allow it.
|
|
326
|
+
|
|
327
|
+
The rules:
|
|
328
|
+
|
|
329
|
+
- `h` is 64 hex characters, the sha256 of a 32-byte secret: the same
|
|
330
|
+
meaning `h` carries on the withdraw callback. Upper case is accepted and
|
|
331
|
+
read as lower case, there too.
|
|
332
|
+
- A malformed `h` is refused before the mint asks its funding source for
|
|
333
|
+
anything, so a wallet is never left holding a quote the mint was always
|
|
334
|
+
going to reject.
|
|
335
|
+
- An `h` that already names a note, an invoice, or a note another buyer
|
|
336
|
+
has already bought is refused with `Invalid or already spent k1.`, the
|
|
337
|
+
same sentence a colliding output gets on the withdraw callback. Which
|
|
338
|
+
table an id sits in is an oracle nobody is owed.
|
|
339
|
+
- The callback's reply carries `mintToHash: true` when the invoice really
|
|
340
|
+
was bound to `h`. A mint that did not implement the parameter would
|
|
341
|
+
ignore it and answer without that field, so a wallet can tell the two
|
|
342
|
+
apart before paying rather than by hunting for a note afterwards.
|
|
343
|
+
- The payRequest and the discovery document both advertise `mintToHash:
|
|
344
|
+
true`, so a wallet knows this mint takes the parameter before it asks
|
|
345
|
+
rather than after it pays.
|
|
346
|
+
- Claiming needs nothing else. `GET /w?k1=<the secret>` brings the note
|
|
347
|
+
into existence as soon as the invoice has settled, with no `verify` poll
|
|
348
|
+
and no preimage involved. The poll is still the way to claim from a mint
|
|
349
|
+
that does not advertise `mintToHash`.
|
|
350
|
+
- **Persist the secret before asking for the invoice.** Paying and then
|
|
351
|
+
losing the secret is the one way this is worse than the old arrangement,
|
|
352
|
+
and writing it down first removes it entirely.
|
|
353
|
+
|
|
354
|
+
A named note is also derived-secret friendly: a wallet whose secrets come
|
|
355
|
+
from its seed can restore a note it bought but never claimed, which a note
|
|
356
|
+
whose secret was a preimage could never offer.
|
|
357
|
+
|
|
358
|
+
## A retried mutation is answered, not refused
|
|
359
|
+
|
|
360
|
+
Rotate, split and merge are GETs, and transports retry GETs. Go's
|
|
361
|
+
`net/http` retries one that failed on a reused idle connection; the JDK's
|
|
362
|
+
`HttpClient` retries idempotent methods with no switch to stop it. The
|
|
363
|
+
retry is byte-identical, and it arrives after the mint has already burned
|
|
364
|
+
the inputs.
|
|
365
|
+
|
|
366
|
+
Answering it "already spent" is what destroys money. A wallet that
|
|
367
|
+
believes the refusal - which is exactly what the error taxonomy tells it
|
|
368
|
+
to do - deletes the staged secret, and the mint keeps a note nobody can
|
|
369
|
+
ever spend.
|
|
370
|
+
|
|
371
|
+
So moneyer records which request minted which outputs, and a request that
|
|
372
|
+
has already minted its outputs gets the same reply again: same `OK`, same
|
|
373
|
+
signatures, nothing burned, nothing minted, no balance moved. A retry is
|
|
374
|
+
a read.
|
|
375
|
+
|
|
376
|
+
A request is the same request when it names the same input notes and asks
|
|
377
|
+
for the same outputs: the same `h`, the same `h2`, the same `amount`.
|
|
378
|
+
Input order does not matter, because a reordered retry is the same
|
|
379
|
+
operation. Anything else naming a burned note is a double-spend attempt
|
|
380
|
+
and still gets `Invalid or already spent k1.`, unchanged. Provenance is
|
|
381
|
+
recorded rather than inferred for that reason: matching on "a note exists
|
|
382
|
+
at `h`" alone would let anyone holding a burned `k1` and any outstanding
|
|
383
|
+
note id draw a success out of the mint.
|
|
384
|
+
|
|
385
|
+
The melt path is untouched: melts are deduplicated by payment hash, which
|
|
386
|
+
is a different question with a different answer.
|
|
387
|
+
|
|
388
|
+
This is moneyer's own behaviour. The LUD-25 draft says nothing about
|
|
389
|
+
retries yet; the suggested wording is on lnurl/luds#301 as a SHOULD.
|
|
390
|
+
|
|
391
|
+
## What the mint knows
|
|
392
|
+
|
|
393
|
+
This mint knows every note it has issued and what each is worth. It
|
|
394
|
+
knows every rotate, split and merge, the links between them - which note
|
|
395
|
+
became which, and when - and the network address the request came from.
|
|
396
|
+
It knows the invoice a melt paid.
|
|
397
|
+
|
|
398
|
+
It does not know who holds a note between those operations. A note
|
|
399
|
+
handed to someone else offline leaves no trace here until they rotate
|
|
400
|
+
it, which is one reason a wallet rotates on receipt.
|
|
401
|
+
|
|
402
|
+
The wallet-side mitigations are weak, and worth naming as weak. A Tor or
|
|
403
|
+
SOCKS proxy hides the address, not the links. Rotating at unpredictable
|
|
404
|
+
times blurs the timing, not the graph. Nothing a holder does stops this
|
|
405
|
+
mint seeing the chain of notes it struck.
|
|
406
|
+
|
|
407
|
+
The design was chosen anyway because it needs no new cryptography, any
|
|
408
|
+
LUD-03 wallet can cash a note out, and verifying a note offline needs a
|
|
409
|
+
signature and nothing else. The privacy story is trust the operator, and
|
|
410
|
+
that is worth saying plainly.
|
|
411
|
+
|
|
412
|
+
## Zap-to-note: a lightning address that pays out as a note
|
|
413
|
+
|
|
414
|
+
A Nostr zap is an ordinary LNURL-pay. Paid to the mint's own address it
|
|
415
|
+
would mint a note, but to the payer: the invoice preimage is the secret
|
|
416
|
+
and on Lightning the payer always learns it. So moneyer can also serve
|
|
417
|
+
names that work the other way round. A zap to `alice@<host>` gets an
|
|
418
|
+
invoice with a throwaway preimage; when it settles, the mint creates a
|
|
419
|
+
note with a fresh secret, seals it in a NIP-59 gift wrap (a kind 2525
|
|
420
|
+
rumor, the shape heartwood-esp32 and notecase read) to alice's pubkey,
|
|
421
|
+
leaves it on her NIP-17 inbox relays, and publishes the kind 9735 receipt
|
|
422
|
+
so the zap shows up in her client like any other. A hardware wallet that
|
|
423
|
+
catches up on its inbox when it powers on will find the note waiting.
|
|
424
|
+
|
|
425
|
+
Until alice rotates the note, the mint knows its secret. That is the
|
|
426
|
+
position every freshly minted note is in, and it is why wallets rotate on
|
|
427
|
+
receipt; notecase does it on `heartwood collect`. What is new is that the
|
|
428
|
+
mint learns who was paid, which a lightning address always did.
|
|
429
|
+
|
|
430
|
+
```bash
|
|
431
|
+
MONEYER_PUBLIC_ORIGIN=https://mint.example # required: settlement is on a timer
|
|
432
|
+
MONEYER_NOSTR_KEY=<32 bytes hex> # the mint's own Nostr identity
|
|
433
|
+
MONEYER_NOSTR_RELAYS=wss://relay.example,wss://nos.lol
|
|
434
|
+
MONEYER_ZAP_NAMES="alice=npub1...,bob=<hex pubkey>" # optional, see below
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
The key and the relays go together; the names are optional, because a
|
|
438
|
+
mint can open registration and start with none of its own. A zap name's
|
|
439
|
+
payRequest carries `allowsNostr` and the mint's `nostrPubkey`, and
|
|
440
|
+
deliberately no `withdrawLink`. The receipt carries no preimage tag: it is
|
|
441
|
+
optional in NIP-57, and here it would only invite someone to mistake it
|
|
442
|
+
for the note. A zap name must not be the mint username or `_`. The kind
|
|
443
|
+
2525 rumor carries the zap request in a `description` tag, so a wallet can
|
|
444
|
+
show who zapped and what they wrote without fetching the receipt.
|
|
445
|
+
|
|
446
|
+
### Self-service: anyone with an npub can claim a name
|
|
447
|
+
|
|
448
|
+
Set `MONEYER_NAME_PRICE_MSAT` and the mint takes registrations. Unset
|
|
449
|
+
means registration is closed and `POST /names` 404s; `0` means free,
|
|
450
|
+
capped at three names per pubkey (rate limiting per address is the
|
|
451
|
+
reverse proxy's job).
|
|
452
|
+
|
|
453
|
+
```bash
|
|
454
|
+
curl -X POST https://mint.example/names \
|
|
455
|
+
-H "Authorization: Nostr <base64 kind 27235 event>" \
|
|
456
|
+
-d '{"name":"donkey","note":"https://mint.example/w?k1=...&amount=21000"}'
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
The NIP-98 event must be kind 27235, signed within 60 seconds, with `u`
|
|
460
|
+
set to the full URL, `method` set to `POST`, and `payload` set to the
|
|
461
|
+
SHA-256 of the exact body sent. **The key that signs it owns the name.**
|
|
462
|
+
No pubkey in the body is accepted, and there is no account, no password
|
|
463
|
+
and no recovery.
|
|
464
|
+
|
|
465
|
+
A paid registration hands over a note of this mint, as a URL or as a bare
|
|
466
|
+
secret. The mint burns the whole note - liabilities drop and the sats are
|
|
467
|
+
revenue - so send one worth exactly the price; split first if you need to.
|
|
468
|
+
A note worth less than the price is refused, and so is one from another
|
|
469
|
+
host. Nothing is burned unless the name is granted: the burn and the
|
|
470
|
+
registration are one transaction.
|
|
471
|
+
|
|
472
|
+
Names are `^[a-z0-9][a-z0-9_.-]{2,31}$`. The mint's own username, `_`,
|
|
473
|
+
`admin` and `mint` are reserved, as is any name already taken. Names are
|
|
474
|
+
permanent in this version; removing one is the operator's, through
|
|
475
|
+
`moneyer admin names rm`.
|
|
476
|
+
|
|
477
|
+
A registered name resolves on both rails at once:
|
|
478
|
+
|
|
479
|
+
- `name@mint.example` as a lightning address, paying out as a bearer note
|
|
480
|
+
gift-wrapped to the owner's key.
|
|
481
|
+
- `GET /.well-known/nostr.json?name=<name>` as a NIP-05 address. Only the
|
|
482
|
+
name asked for is answered: the list of everyone here is not something
|
|
483
|
+
to hand out.
|
|
484
|
+
|
|
485
|
+
The discovery endpoint advertises `namePriceMsat` while registration is
|
|
486
|
+
open, so a wallet can offer the flow without asking.
|
|
487
|
+
|
|
113
488
|
## Endpoints
|
|
114
489
|
|
|
115
490
|
| | |
|
|
116
491
|
| --- | --- |
|
|
117
492
|
| `/.well-known/lnurlp/<user>` | LUD-16 payRequest; paying mints a note |
|
|
493
|
+
| `/.well-known/lnurlp/<zap name>` | NIP-57 payRequest; paying mints a note *to the name's pubkey* |
|
|
118
494
|
| `/.well-known/lnurlw/<user>` | LUD-25 mint address discovery (experimental) |
|
|
119
|
-
| `/p/cb` | LUD-06 pay callback; issues the mint invoice |
|
|
495
|
+
| `/p/cb` | LUD-06 pay callback; issues the mint invoice, and takes an optional `h` naming the note |
|
|
496
|
+
| `/z/cb/<zap name>` | the zap callback; validates the kind 9734 and issues the invoice |
|
|
120
497
|
| `/verify/<hash>` | LUD-21 verify, for mint invoices and melt payments |
|
|
121
|
-
| `/w` | LUD-03 informational GET |
|
|
498
|
+
| `/w` | LUD-03 informational GET; a live note also carries `payLink`, the route back to this mint's discovery document |
|
|
122
499
|
| `/w/cb` | the mutating callback: melt, rotate, split, merge |
|
|
500
|
+
| `POST /names` | claim a lightning address, authenticated by NIP-98 |
|
|
501
|
+
| `/.well-known/nostr.json` | NIP-05 for the names this mint serves |
|
|
502
|
+
| `/stats` | what the mint owes, what the node holds, and the coverage between them |
|
|
503
|
+
| `/metrics` | the same figures for a scraper, off by default |
|
|
504
|
+
|
|
505
|
+
## Operating
|
|
506
|
+
|
|
507
|
+
```bash
|
|
508
|
+
set -a; source /etc/moneyer/env; set +a
|
|
509
|
+
moneyer admin status
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
`moneyer admin` reads the same `MONEYER_*` environment the mint does, so
|
|
513
|
+
there is one description of a deployment and not two. It opens the
|
|
514
|
+
database **read-only** unless the command mutates: an operator poking at
|
|
515
|
+
a live mint should not be able to write to it by accident, and a mistyped
|
|
516
|
+
path should not silently create an empty database to answer from.
|
|
517
|
+
|
|
518
|
+
| command | does |
|
|
519
|
+
| --- | --- |
|
|
520
|
+
| `status` | liabilities, melts in flight, unsettled invoices, node balance and coverage, lifetime totals, keys |
|
|
521
|
+
| `notes [--state outstanding\|pending\|burned] [--limit n]` | list notes, newest first |
|
|
522
|
+
| `note <id\|k1>` | one note; 64 hex that names no note is hashed and looked up as the secret |
|
|
523
|
+
| `melts [--pending]` | list melts |
|
|
524
|
+
| `reconcile` | one pending-melt reconcile pass, printing what changed |
|
|
525
|
+
| `sweep` | delete mint invoices whose expiry is provably past |
|
|
526
|
+
| `snapshot <path>` | a consistent copy of the database, taken live, refusing to overwrite |
|
|
527
|
+
| `names list\|add <name> <npub>\|rm <name>` | the lightning addresses this mint pays out as notes |
|
|
528
|
+
| `keys rotate` | generate a signing key and print the two environment lines; writes nothing |
|
|
529
|
+
| `verify-note <url>` | check a note's signature offline, then say what the mint holds at that id |
|
|
530
|
+
|
|
531
|
+
`snapshot` uses SQLite's `VACUUM INTO`, so it needs no `sqlite3` binary on
|
|
532
|
+
the box and is safe to run against a live mint.
|
|
533
|
+
|
|
534
|
+
### Metrics
|
|
535
|
+
|
|
536
|
+
`MONEYER_METRICS=true` serves `GET /metrics` in the OpenMetrics text
|
|
537
|
+
format: `moneyer_outstanding_msat`, `moneyer_outstanding_notes`,
|
|
538
|
+
`moneyer_pending_melts`, `moneyer_oldest_pending_melt_seconds`,
|
|
539
|
+
`moneyer_local_balance_msat`, `moneyer_unsettled_mint_invoices`,
|
|
540
|
+
`moneyer_mints_total`, `moneyer_melts_total{outcome}` and
|
|
541
|
+
`moneyer_zaps_total`.
|
|
542
|
+
|
|
543
|
+
The app does not authenticate it. Restrict the path to your scraper at
|
|
544
|
+
the reverse proxy, the way `/stats` is left public deliberately and this
|
|
545
|
+
is not.
|
|
546
|
+
|
|
547
|
+
### The three alerts worth having
|
|
548
|
+
|
|
549
|
+
- **oldest pending melt over 30 minutes.** A melt that cannot be resolved
|
|
550
|
+
is a note nobody can spend and a payment nobody can account for. The
|
|
551
|
+
reconciler retries every five minutes; half an hour without a terminal
|
|
552
|
+
answer means the funding source needs looking at.
|
|
553
|
+
- **coverage under 1.** The node cannot pay out every note it owes.
|
|
554
|
+
Whether that is a channel imbalance or something worse, it is the one
|
|
555
|
+
number a custodial mint must never be relaxed about.
|
|
556
|
+
- **unsettled invoices growing.** Invoices are issued and never paid all
|
|
557
|
+
day, and the sweep clears the expired ones. A count that climbs through
|
|
558
|
+
a sweep means invoices are being issued that nobody can pay.
|
|
123
559
|
|
|
124
560
|
## Testing
|
|
125
561
|
|
package/THREAT-MODEL.md
CHANGED
|
@@ -26,11 +26,13 @@ parameters in one request are refused outright; they would otherwise count
|
|
|
26
26
|
one note's value twice into the output.
|
|
27
27
|
|
|
28
28
|
**A holder claiming an output id that already exists.** `h`/`h2` may not
|
|
29
|
-
collide with any existing note
|
|
30
|
-
or not
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
future payer's money at a
|
|
29
|
+
collide with any existing note, any mint invoice's payment hash, settled
|
|
30
|
+
or not, or any note a payer has bought by naming it. The invoice cases are
|
|
31
|
+
the subtle ones: `/verify` hands a settled mint invoice's preimage to its
|
|
32
|
+
payer, and that preimage is the k1 of whatever note carries the hash as
|
|
33
|
+
its id - minting "over" such an id would point a future payer's money at a
|
|
34
|
+
stranger's note. A named note is the same hazard one step earlier, since
|
|
35
|
+
it is spoken for before it exists.
|
|
34
36
|
|
|
35
37
|
**A holder melting into the mint's own invoice.** Refused synchronously:
|
|
36
38
|
paying it would route the funding source's money at itself, which real
|
|
@@ -70,12 +72,19 @@ runs at startup and on demand, resolving pending melts by asking the
|
|
|
70
72
|
funding source, never by assumption.
|
|
71
73
|
|
|
72
74
|
**The verify endpoint as an oracle.** `/verify` serves bearer material
|
|
73
|
-
(the preimage)
|
|
75
|
+
(the preimage) whenever the note was minted the old way, at the invoice's
|
|
76
|
+
payment hash. It is only served once the funding source reports
|
|
74
77
|
settlement, is fetched live rather than cached, and has a real off switch
|
|
75
78
|
(`MONEYER_VERIFY=0` makes it a 404). Anyone who saw the unpaid invoice can
|
|
76
79
|
poll it, which is why wallets must rotate immediately on claim - stated in
|
|
77
80
|
the spec and enforced by the companion wallet.
|
|
78
81
|
|
|
82
|
+
The durable answer is for the wallet to name the note it is buying (`h` on
|
|
83
|
+
the pay callback, see the README). The note is then credited at a secret
|
|
84
|
+
only the buyer ever held, the preimage is bearer material for nothing, and
|
|
85
|
+
what `/verify` serves is an ordinary payment proof. Wallets should prefer
|
|
86
|
+
that path wherever a mint advertises `mintToHash`.
|
|
87
|
+
|
|
79
88
|
## Out of scope
|
|
80
89
|
|
|
81
90
|
- Compromise of the host or the funding source credentials: at that point
|
package/dist/admin.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { NoteStore } from './store.ts';
|
|
2
|
+
import type { LightningBackend } from './backends/types.ts';
|
|
3
|
+
export type AdminDeps = {
|
|
4
|
+
env?: NodeJS.ProcessEnv;
|
|
5
|
+
out?: (line: string) => void;
|
|
6
|
+
err?: (line: string) => void;
|
|
7
|
+
store?: NoteStore;
|
|
8
|
+
backend?: LightningBackend;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
};
|
|
11
|
+
export declare const adminHelp: () => string;
|
|
12
|
+
export declare const runAdmin: (argv: string[], deps?: AdminDeps) => Promise<number>;
|