@forgesworn/moneyer 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,175 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-08-22
4
+
5
+ While LUD-25 is a draft, a `0.x` minor bump may be breaking; this one is
6
+ additive on the wire apart from the node-capacity rename below.
7
+
8
+ - The test suite has a 30 second timeout rather than vitest's 5 second
9
+ default. Nearly every test here starts an HTTP server, opens a database
10
+ and does real curve and KDF work; 5 seconds is a unit test's budget, and
11
+ on a loaded machine the slower cases crossed it while passing perfectly
12
+ well. A gate that fails at random teaches people to bypass it.
13
+ - **`moneyer --dev` is a mint a wallet can actually use.** The fake
14
+ funding source settled nothing on its own: settlement was reachable only
15
+ through an in-process handle a test holds, so a wallet pointed at a
16
+ standalone `MONEYER_BACKEND=fake` mint asked for an invoice, nothing ever
17
+ paid it, no note was ever minted, and every flow needing a note - split,
18
+ merge, melt, send - dead-ended behind an empty wallet while the mint
19
+ looked healthy throughout. `--dev` now treats every invoice it issues as
20
+ paid the moment it is issued, so minting, splitting and melting all work
21
+ locally against a wallet. It can only be set on the fake backend, which
22
+ moves no money, and the startup banner says plainly that the notes are
23
+ worth nothing. `MONEYER_FAKE_AUTOSETTLE=true` sets it explicitly for a
24
+ compose file that cannot pass a flag.
25
+ - The mint can announce itself. With `MONEYER_ANNOUNCE=true` and the Nostr
26
+ identity zap-to-note already uses, it publishes its own discovery
27
+ document hourly as a parameterised replaceable event (kind 30078, `d`
28
+ tag `lnurlcash-mint`), alongside the liabilities snapshot. Until now a
29
+ wallet could only learn of a mint by being told its address; this is the
30
+ half that has to exist before finding one means anything. The content is
31
+ the document exactly as the discovery endpoint serves it, so there is one
32
+ description of a mint and not two, plus a `sig` over the canonicalised
33
+ body made with the note signing key, so a holder can check the
34
+ announcement against the same key their notes verify against.
35
+ `verifyAnnouncement` is exported for that. Off unless the operator turns
36
+ it on: a mint that does not want to be listed says nothing. No new event
37
+ kind, and no recommendations or reviews; both are protocol decisions that
38
+ belong with the NIPs.
39
+ - A note melts to an invoice that states no amount. Such an invoice used
40
+ to be refused outright, which is a papercut for anyone paying one: for a
41
+ bearer note the amount was never in question, since the note's value is
42
+ the amount. The mint sends the whole-sat floor of the note's value, the
43
+ same figure it already accepts from a wallet that fills the amount in
44
+ itself, and keeps the sub-sat remainder as the same dust. A note worth
45
+ less than a single satoshi has nothing left once the floor applies and
46
+ is refused with `insufficient value`; it can still be melted by a wallet
47
+ that invoices its exact value. Everything else about the melt path is
48
+ untouched, including the pending and restore discipline.
49
+ - A wallet can name the note it is buying. It chooses the note's spend
50
+ secret itself, keeps it, and sends `h`, the sha256 of that secret, on
51
+ the pay callback; the mint credits the note at `h` when the invoice
52
+ settles. The invoice's payment preimage then buys nothing and is only a
53
+ payment proof, which is what it always should have been: a preimage is
54
+ known to the funding source, to every node that forwarded the payment,
55
+ and to anyone who polls LUD-21 `verify` with the payment hash written
56
+ inside the invoice. Naming the note leaves the buyer as the only party
57
+ who ever held the secret, and replaces "claim and rotate faster than
58
+ anybody else" with nothing to race for. A malformed `h` is refused
59
+ before an invoice is issued, so a wallet never pays for a quote the
60
+ mint would reject; an `h` that already names a note or an invoice is
61
+ refused with `Invalid or already spent k1.`, the same oracle-free
62
+ sentence a colliding output gets on the withdraw callback. The reply
63
+ carries `mintToHash: true` when the binding was made, and the payRequest
64
+ and the discovery document advertise `mintToHash: true` so a wallet
65
+ knows before it asks. Claiming needs no `verify` poll: the wallet asks
66
+ `/w?k1=<its own secret>` directly. Entirely optional and additive: a
67
+ wallet that sends no `h` gets exactly today's behaviour, and no existing
68
+ wallet or service breaks. The draft itself does need one additive
69
+ amendment, because it says a `SERVICE` MUST accept `k1=P` and a bound
70
+ mint deliberately does not; that is proposed separately. Wallets are urged to persist the secret before
71
+ requesting the invoice, which is the one thing that could make this
72
+ worse than what it replaces.
73
+ - A live note's informational GET now carries `payLink`, pointing at this
74
+ mint's payRequest, which is the counterpart of the `withdrawLink` a
75
+ payRequest already advertises. It is the route home for a holder who has
76
+ nothing but a note: from it a wallet reaches the discovery document, and
77
+ so the mint's terms and its retired signing keys. Without it a wallet
78
+ that only ever received notes cannot tell an announced key rotation from
79
+ a substituted key. Optional and additive; unknown and spent notes still
80
+ say nothing about the mint beyond the refusal.
81
+ - Both database connections now wait up to five seconds for a lock instead
82
+ of giving up at once. `node:sqlite` opens with no busy timeout, so a
83
+ second writer met `database is locked` the moment the first one held it.
84
+ The operator CLI is that second writer while the mint is running, and the
85
+ times you reach for `moneyer admin reconcile` are the busy ones. Nothing
86
+ was ever at risk of corruption; the command simply failed when it was
87
+ least convenient.
88
+ - The discovery endpoint carries the human layer: `name`, `description`,
89
+ `contact` (`nostr` as an npub, `email`, `url`), `tosUrl`, `motd`,
90
+ `fees`, `version` and `previousPubkeys`. Every one is optional and an
91
+ unset one is absent rather than empty. New environment variables:
92
+ `MONEYER_NAME`, `MONEYER_CONTACT_NOSTR`, `MONEYER_CONTACT_EMAIL`,
93
+ `MONEYER_CONTACT_URL`, `MONEYER_TOS_URL`, `MONEYER_MOTD`.
94
+ - **Wire-name change**: node capacity now goes out as `nodeCapacity`, the
95
+ name the reference mint, the conformance mock and `lnurlcash-kit` all
96
+ use. `nodeCapacityMsat`, which was moneyer's alone, is emitted alongside
97
+ it for this one release and then removed. Both are milli-satoshis.
98
+ - The mint's own site and the fallback landing page show the message of
99
+ the day, the operator's contact and the terms link, and read capacity
100
+ under either name.
101
+ - `GET /stats` states what the mint owes and what its funding node holds:
102
+ outstanding liabilities and note count, melts in flight and the age of
103
+ the oldest, the node's local balance, and the coverage ratio between
104
+ the two to four decimal places. Cached 30 seconds, never per-note,
105
+ public by design. `MONEYER_STATS=false` switches it off and
106
+ `MONEYER_STATS_RATIO_ONLY=true` publishes the ratio alone. Both the
107
+ mint's site and the fallback landing page carry a coverage row.
108
+ - `MONEYER_STATS_PUBLISH=true` publishes an hourly signed snapshot of
109
+ those figures to Nostr (kind 30078, `d` tag `lnurlcash-liabilities`),
110
+ signed with the note signing key so anyone can check the history
111
+ against the mint's advertised pubkey. `scripts/verify-stats.mjs` does
112
+ exactly that, and `verifyStatsSnapshot` is exported for wallets.
113
+ - The lnd and cln backends report the node's local channel balance;
114
+ the fake one reports a configurable balance, one bitcoin by default.
115
+ - **Self-service lightning addresses.** With `MONEYER_NAME_PRICE_MSAT`
116
+ set, anyone with an npub can claim `name@<host>` by posting to
117
+ `/names` with a NIP-98 Authorization and a note of this mint. The key
118
+ that signs the request owns the name; no other identity is accepted.
119
+ The note is burned and the name recorded in one transaction, so
120
+ nothing is ever paid for a name somebody else got first. Unset means
121
+ registration is closed; `0` means free, three names per pubkey.
122
+ Registered and operator-configured names live in one table and are
123
+ served by one lookup, and `GET /.well-known/nostr.json?name=` resolves
124
+ them over NIP-05 as well, one name at a time. Discovery advertises
125
+ `namePriceMsat` while registration is open.
126
+ - The kind 2525 rumor for a zap-funded note carries the zap request in a
127
+ `description` tag - the same content the kind 9735 receipt carries - so
128
+ a wallet can show who zapped and what they wrote without fetching the
129
+ receipt. Readers take tags by name, so an older one does not notice.
130
+ - `MONEYER_ZAP_NAMES` is now optional: a mint that opens registration can
131
+ start with no names of its own. `MONEYER_NOSTR_KEY` and
132
+ `MONEYER_NOSTR_RELAYS` still go together.
133
+ - `moneyer admin names` gains `add <name> <npub>` and `rm <name>`, and
134
+ `list` shows configured names the running mint has not loaded yet.
135
+ - **A retried rotate, split or merge is answered instead of refused.**
136
+ These are GETs, and transports retry GETs on their own: the retry
137
+ arrives byte-identical after the inputs are burned, and the old answer
138
+ - `Invalid or already spent k1.` - told the wallet to drop the only
139
+ copy of a secret the mint really had minted a note against. The mint
140
+ now records which request minted which outputs, and replays the same
141
+ reply: same signatures, nothing burned, nothing minted, no balance
142
+ moved. A request counts as the same request when it names the same
143
+ input notes and asks for the same `h`, `h2` and `amount`; input order
144
+ does not matter. Anything else naming a burned note is still refused
145
+ with the same reason string, so no oracle appears, and the melt path is
146
+ untouched. moneyer's own behaviour: the draft says nothing about
147
+ retries yet.
148
+ - `moneyer admin <command>`: the operator surface. `status`, `notes`,
149
+ `note`, `melts`, `reconcile`, `sweep`, `snapshot`, `names list`,
150
+ `keys rotate` and `verify-note`, all reading the same `MONEYER_*`
151
+ environment the mint does, and all opening the database read-only
152
+ unless the command mutates. `snapshot` uses `VACUUM INTO`, so it needs
153
+ no sqlite3 binary and is safe against a live mint; `keys rotate`
154
+ generates a key and prints the two environment lines without touching
155
+ anything.
156
+ - `MONEYER_METRICS=true` serves `GET /metrics` in the OpenMetrics text
157
+ format. Off by default and never authenticated by the app: restrict the
158
+ path at the reverse proxy. The README's Operating section names the
159
+ three alerts worth having.
160
+ - A "What the mint knows" section in the README, on the mint's own site
161
+ and on the fallback landing page, in the same words: what a LUD-25 mint
162
+ can see, what it cannot, why the wallet-side mitigations are weak, and
163
+ why the design was chosen anyway. Blindness is the one ecash property
164
+ this cannot offer, and a reader should not have to infer that.
165
+ - `MONEYER_PREVIOUS_SIGNING_PUBKEYS` lists the compressed pubkeys this
166
+ mint signed notes under before, published as `previousPubkeys` on
167
+ discovery. Rotating the signing key no longer invalidates every
168
+ outstanding note's signature or trips a pinned wallet's mismatch
169
+ check. Pubkeys only, validated as points on the curve at startup: the
170
+ old private keys are not needed to verify and should not stay on the
171
+ server. The runbook is in the README.
172
+
3
173
  ## [0.1.2] - 2026-08-21
4
174
 
5
175
  - `MONEYER_ROUND_FEE_TO_SAT` ceilings the mint fee to a whole sat, as
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 whose spend secret is that invoice's
8
- payment preimage, and a note's holder can rotate, split, merge and melt it
9
- against the withdraw callback. It passes the full
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 freshly minted note's preimage lives only with the payer and
30
- the funding source.
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). Configuration is environment-only; see `src/config.ts` for the
67
- full `MONEYER_*` set including fees (`MONEYER_BASE_FEE_MSAT`,
68
- `MONEYER_FEE_PPM`), limits, and `MONEYER_SUNSET` for winding down without
69
- stranding holders.
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