@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/dist/zap.js ADDED
@@ -0,0 +1,294 @@
1
+ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
2
+ import { hashK1 } from 'lnurlcash-kit';
3
+ import { tryDecodeBolt11 } from 'farrier-kit/bolt11';
4
+ import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
5
+ import { SimplePool } from 'nostr-tools/pool';
6
+ import { makeZapReceipt, validateZapRequest } from 'nostr-tools/nip57';
7
+ import { wrapEvent } from 'nostr-tools/nip59';
8
+ // Zap-to-note: a lightning address on this host that pays out as an
9
+ // LNURLcash note delivered over Nostr.
10
+ //
11
+ // A NIP-57 zap is an ordinary LNURL-pay. Paying the mint's own address
12
+ // would mint a note, but to the PAYER: LUD-25 makes the invoice preimage
13
+ // the secret, and on Lightning the payer always learns the preimage. So a
14
+ // zap name works the other way round. The invoice gets a throwaway
15
+ // preimage; on settlement the mint creates a note with a fresh secret of
16
+ // its own, gift-wraps it (NIP-59, kind 2525 rumor) to the name's pubkey,
17
+ // leaves it on their NIP-17 inbox relays, and publishes the kind 9735
18
+ // receipt that makes the zap show up in clients.
19
+ //
20
+ // Until the recipient rotates the note, the mint knows its secret. That is
21
+ // exactly the position a freshly minted note is in anyway, and it is why
22
+ // wallets rotate on receipt. What is new is that the mint learns who was
23
+ // paid, which a lightning address always did.
24
+ export const NOTE_KIND = 2525;
25
+ export const INBOX_RELAYS_KIND = 10050;
26
+ export const ZAP_REQUEST_KIND = 9734;
27
+ // Where a recipient's kind 10050 is looked for, beyond the mint's own relays.
28
+ export const INDEXER_RELAYS = ['wss://purplepag.es', 'wss://relay.damus.io', 'wss://nos.lol'];
29
+ // How long a settled zap keeps retrying the recipient's inbox relays
30
+ // before it settles for whichever took the wrap. The receiver reads ONE
31
+ // of its inbox relays, so a wrap that reached three others is still
32
+ // undelivered. Seen live: a long-lived pool's dead socket to the device's
33
+ // relay failed silently for hours while the other relays said OK.
34
+ export const INBOX_RETRY_MS = 10 * 60_000;
35
+ export const poolTransport = () => {
36
+ const pool = new SimplePool();
37
+ const used = new Set();
38
+ return {
39
+ async publish(relays, event) {
40
+ for (const r of relays)
41
+ used.add(r);
42
+ // A relay whose socket died reports failure forever on a pool that
43
+ // keeps the stale Relay object; drop it so the next publish dials
44
+ // afresh.
45
+ const results = await Promise.allSettled(pool.publish(relays, event));
46
+ results.forEach((r, i) => {
47
+ if (r.status === 'rejected') {
48
+ try {
49
+ pool.close([relays[i]]);
50
+ }
51
+ catch {
52
+ // nothing to close
53
+ }
54
+ }
55
+ });
56
+ const ok = [];
57
+ const failed = [];
58
+ results.forEach((r, i) => (r.status === 'fulfilled' ? ok : failed).push(relays[i]));
59
+ return { ok, failed };
60
+ },
61
+ async query(relays, filter) {
62
+ for (const r of relays)
63
+ used.add(r);
64
+ try {
65
+ return await pool.querySync(relays, filter, { maxWait: 6_000 });
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ },
71
+ close() {
72
+ pool.close([...used]);
73
+ }
74
+ };
75
+ };
76
+ export const inboxRelays = async (transport, pubkey, lookOn) => {
77
+ const events = await transport.query(lookOn, { kinds: [INBOX_RELAYS_KIND], authors: [pubkey], limit: 3 });
78
+ const latest = events.sort((a, b) => b.created_at - a.created_at)[0];
79
+ if (!latest)
80
+ return [];
81
+ return [...new Set(latest.tags.filter(t => t[0] === 'relay' && t[1]).map(t => t[1]))];
82
+ };
83
+ const metadataFor = (name, host, mintFeeLine, feeInWords = null) => {
84
+ const metadata = [
85
+ ['text/plain', `Zap ${name}@${host}: arrives as a Lightning bearer note${feeInWords ? ` (${feeInWords})` : ''}`],
86
+ ['text/identifier', `${name}@${host}`]
87
+ ];
88
+ if (mintFeeLine)
89
+ metadata.push(['text/plain', mintFeeLine]);
90
+ return JSON.stringify(metadata);
91
+ };
92
+ const tagValues = (event, name) => event.tags.filter(t => t[0] === name && t[1]).map(t => t[1]);
93
+ export const createZapBridge = (deps) => {
94
+ const { config, store, backend, transport } = deps;
95
+ const log = deps.log ?? (() => { });
96
+ const now = deps.now ?? (() => Date.now());
97
+ const secret = hexToBytes(config.nostrKey);
98
+ const pubkey = getPublicKey(secret);
99
+ const origin = deps.origin;
100
+ const host = new URL(origin).host;
101
+ // Names live in the store, not in the configuration: the operator's own
102
+ // are loaded there at startup and self-service registrations land in
103
+ // the same table, so one lookup serves both.
104
+ const isZapName = (name) => store.zapName(name.toLowerCase()) !== null;
105
+ const payRequest = (name) => {
106
+ const lowered = name.toLowerCase();
107
+ if (!isZapName(lowered))
108
+ return null;
109
+ return {
110
+ tag: 'payRequest',
111
+ callback: `${origin}/z/cb/${lowered}`,
112
+ minSendable: deps.minSendableMsat,
113
+ maxSendable: deps.maxSendableMsat,
114
+ metadata: metadataFor(lowered, host, deps.mintFeeLine, deps.feeInWords),
115
+ allowsNostr: true,
116
+ nostrPubkey: pubkey
117
+ // Deliberately no withdrawLink: the preimage of this invoice is NOT
118
+ // a note, and an LNURLcash wallet that paid this must not think it
119
+ // is one.
120
+ };
121
+ };
122
+ const callback = async (name, amountMsat, nostrParam) => {
123
+ const lowered = name.toLowerCase();
124
+ const recipient = store.zapName(lowered)?.pubkey;
125
+ if (!recipient)
126
+ return { reason: 'Unknown user.' };
127
+ if (!Number.isSafeInteger(amountMsat) || amountMsat <= 0)
128
+ return { reason: 'Invalid amount.' };
129
+ if (amountMsat < deps.minSendableMsat || amountMsat > deps.maxSendableMsat)
130
+ return { reason: 'Amount out of range.' };
131
+ const net = deps.netAfterMintFee(amountMsat);
132
+ if (net < deps.minMintMsat)
133
+ return { reason: 'Amount too small to mint a note.' };
134
+ let zapRequest = null;
135
+ if (nostrParam !== null && nostrParam !== '') {
136
+ const problem = validateZapRequest(nostrParam);
137
+ if (problem)
138
+ return { reason: problem };
139
+ const parsed = JSON.parse(nostrParam);
140
+ if (parsed.kind !== ZAP_REQUEST_KIND)
141
+ return { reason: 'Zap request is not kind 9734.' };
142
+ const p = tagValues(parsed, 'p');
143
+ if (p.length !== 1 || p[0] !== recipient)
144
+ return { reason: `Zap request is not for ${lowered}@${host}.` };
145
+ const amountTag = tagValues(parsed, 'amount')[0];
146
+ if (amountTag !== undefined && Number(amountTag) !== amountMsat) {
147
+ return { reason: 'Zap request amount does not match.' };
148
+ }
149
+ zapRequest = nostrParam;
150
+ }
151
+ // A throwaway preimage: the payer will learn it, and it must be worth
152
+ // nothing to them. The note's secret is minted at settlement.
153
+ const preimage = bytesToHex(randomBytes(32));
154
+ const paymentHash = hashK1(preimage);
155
+ let pr;
156
+ try {
157
+ pr = (await backend.createInvoice({
158
+ amountMsat,
159
+ preimageHex: preimage,
160
+ memo: `Zap ${lowered}@${host}`,
161
+ descriptionForHash: zapRequest ?? metadataFor(lowered, host, deps.mintFeeLine, deps.feeInWords)
162
+ })).pr;
163
+ }
164
+ catch (err) {
165
+ log(`zap: create invoice failed: ${err.message}`);
166
+ return { reason: 'Temporarily unable to issue an invoice.' };
167
+ }
168
+ const decoded = tryDecodeBolt11(pr);
169
+ if (!decoded || decoded.paymentHashHex !== paymentHash || decoded.amountMsats !== BigInt(amountMsat)) {
170
+ log('zap: funding source returned an invoice that does not match the requested preimage/amount');
171
+ return { reason: 'Temporarily unable to issue an invoice.' };
172
+ }
173
+ store.recordZapInvoice({ paymentHash, name: lowered, recipient, pr, grossMsat: amountMsat, netMsat: net, zapRequest });
174
+ return { pr, ...(deps.verify ? { verify: `${origin}/verify/${paymentHash}` } : {}) };
175
+ };
176
+ // The note as the recipient's wallet will paste it, wrapped to them.
177
+ const buildWrap = (row, k1) => {
178
+ const tags = [
179
+ ['p', row.recipient],
180
+ ['amount', String(row.netMsat)],
181
+ ['u', `${host}/w`]
182
+ ];
183
+ if (row.zapRequest) {
184
+ const zr = JSON.parse(row.zapRequest);
185
+ // Who zapped, and what they zapped, so a receiver can say so.
186
+ tags.push(['P', zr.pubkey]);
187
+ for (const e of tagValues(zr, 'e'))
188
+ tags.push(['e', e]);
189
+ for (const a of tagValues(zr, 'a'))
190
+ tags.push(['a', a]);
191
+ // The zap request itself, the same content the kind 9735 receipt
192
+ // carries, so a wallet can show who zapped and what they wrote
193
+ // without going to a relay for it. Readers take tags by name, so an
194
+ // older one simply does not see this.
195
+ tags.push(['description', row.zapRequest]);
196
+ }
197
+ const rumor = {
198
+ kind: NOTE_KIND,
199
+ pubkey,
200
+ created_at: Math.floor(now() / 1000),
201
+ tags,
202
+ content: `${origin}/w?k1=${k1}&amount=${row.netMsat}`
203
+ };
204
+ return wrapEvent(rumor, secret, row.recipient);
205
+ };
206
+ // NIP-57 receipt, without the preimage tag: it is optional there, and
207
+ // here it would only invite someone to mistake it for the note.
208
+ const buildReceipt = (row) => {
209
+ if (!row.zapRequest)
210
+ return null;
211
+ const unsigned = makeZapReceipt({ zapRequest: row.zapRequest, bolt11: row.pr, paidAt: new Date(now()) });
212
+ return finalizeEvent(unsigned, secret);
213
+ };
214
+ const settle = async () => {
215
+ let minted = 0;
216
+ for (const row of store.unsettledZapInvoices()) {
217
+ let paid;
218
+ try {
219
+ paid = await backend.isInvoiceSettled(row.paymentHash);
220
+ }
221
+ catch (err) {
222
+ log(`zap: settlement check failed for ${row.paymentHash.slice(0, 8)}: ${err.message}`);
223
+ continue;
224
+ }
225
+ if (!paid)
226
+ continue;
227
+ const k1 = bytesToHex(randomBytes(32));
228
+ const noteId = hashK1(k1);
229
+ const wrap = buildWrap(row, k1);
230
+ const receipt = buildReceipt(row);
231
+ if (store.settleZapInvoice(row.paymentHash, noteId, JSON.stringify(wrap), receipt ? JSON.stringify(receipt) : null)) {
232
+ minted += 1;
233
+ log(`zap: ${row.name} received ${row.netMsat} msat; note ${noteId.slice(0, 8)} minted, wrap parked`);
234
+ }
235
+ }
236
+ return minted;
237
+ };
238
+ const publish = async () => {
239
+ let published = 0;
240
+ for (const row of store.unpublishedZaps()) {
241
+ if (!row.wrapJson) {
242
+ store.markZapPublished(row.paymentHash);
243
+ continue;
244
+ }
245
+ const wrap = JSON.parse(row.wrapJson);
246
+ const lookOn = [...new Set([...config.relays, ...INDEXER_RELAYS])];
247
+ const inbox = await inboxRelays(transport, row.recipient, lookOn);
248
+ const wrapRelays = [...new Set([...inbox, ...config.relays])];
249
+ const result = await transport.publish(wrapRelays, wrap);
250
+ if (!result.ok.length) {
251
+ log(`zap: wrap for ${row.name} reached no relay (${result.failed.join(', ')}); will retry`);
252
+ continue;
253
+ }
254
+ const missedInbox = result.failed.filter(r => inbox.includes(r));
255
+ if (missedInbox.length) {
256
+ const age = now() - (row.settledAt ?? now());
257
+ if (age < INBOX_RETRY_MS) {
258
+ log(`zap: wrap for ${row.name} missed inbox relay(s) ${missedInbox.join(', ')}; parked for another try`);
259
+ continue;
260
+ }
261
+ log(`zap: wrap for ${row.name} never reached ${missedInbox.join(', ')} in ${Math.round(age / 60_000)} min; left on ${result.ok.join(', ')}`);
262
+ }
263
+ if (!inbox.length)
264
+ log(`zap: ${row.name} publishes no inbox list; wrap left on the mint's relays`);
265
+ if (row.receiptJson) {
266
+ const receipt = JSON.parse(row.receiptJson);
267
+ // NIP-57 puts every relay in ONE tag: ["relays", url, url, ...].
268
+ const zr = JSON.parse(row.zapRequest ?? '{"tags":[]}');
269
+ const fromRequest = zr.tags.find(t => t[0] === 'relays')?.slice(1) ?? [];
270
+ const receiptRelays = [...new Set([...fromRequest, ...config.relays])].filter(r => /^wss?:\/\//.test(r));
271
+ const sent = await transport.publish(receiptRelays, receipt);
272
+ if (!sent.ok.length)
273
+ log(`zap: receipt for ${row.name} reached no relay; the note still went out`);
274
+ }
275
+ store.markZapPublished(row.paymentHash);
276
+ published += 1;
277
+ }
278
+ return published;
279
+ };
280
+ const sweep = (nowMs = now()) => {
281
+ const stale = [];
282
+ for (const row of store.unsettledZapInvoices()) {
283
+ const decoded = tryDecodeBolt11(row.pr);
284
+ if (!decoded)
285
+ continue;
286
+ if (nowMs > (decoded.timestamp + decoded.expirySeconds) * 1000 + 3_600_000)
287
+ stale.push(row.paymentHash);
288
+ }
289
+ for (const hash of stale)
290
+ store.deleteUnsettledZapInvoice(hash);
291
+ return stale.length;
292
+ };
293
+ return { pubkey, isZapName, payRequest, callback, settle, publish, sweep };
294
+ };
package/llms.txt CHANGED
@@ -1,8 +1,9 @@
1
1
  # moneyer
2
2
 
3
3
  LNURLcash (LUD-25) mint for Node 24+. Strikes Lightning bearer notes:
4
- paying an invoice it issues mints a note whose spend secret IS the
5
- invoice's payment preimage. ESM-only. MIT.
4
+ paying an invoice it issues mints a note whose spend secret is the
5
+ invoice's payment preimage, or a secret the buyer named with `h` on the
6
+ pay callback. ESM-only. MIT.
6
7
 
7
8
  Install: not yet on npm - build from source (see README)
8
9
  Run: node dist/cli.js --dev (fake funding source, prints a funded note)
@@ -16,17 +17,29 @@ Client library to build against a mint: lnurlcash-kit
16
17
 
17
18
  1. A mint invoice's preimage is the note secret - the funding source must
18
19
  accept a caller-supplied preimage (cln and lnd do; phoenixd and NIP-47
19
- make_invoice do not, and cannot back a mint).
20
+ make_invoice do not, and cannot back a mint). UNLESS the payer sent
21
+ `h` on the pay callback: then the note is credited at `h`, the payer's
22
+ own secret, and the preimage is only a payment proof. Prefer that: a
23
+ preimage is known to the funding source, to every node on the route,
24
+ and to anyone who polls verify with the payment hash in the invoice.
20
25
  2. Mutations are atomic: all named k1 burn and all outputs mint, or
21
26
  nothing happens. Duplicate k1 in one request is refused.
22
27
  3. h/h2 are WALLET-supplied hashes; the mint never generates a
23
- replacement secret, and refuses output ids that already exist as notes
24
- or as mint-invoice payment hashes.
28
+ replacement secret, and refuses output ids that already exist as
29
+ notes, as mint-invoice payment hashes, or as a note another payer has
30
+ already bought with `h`. A malformed `h` on the pay callback is
31
+ refused BEFORE an invoice is issued; a colliding one gets the same
32
+ oracle-free refusal a colliding mutation output gets. The reply
33
+ carries mintToHash: true when the invoice was bound, and the
34
+ payRequest and mint address advertise mintToHash: true.
25
35
  4. Melt OK means IN FLIGHT. The note burns only on confirmed payment,
26
36
  restores only on confirmed non-payment, and stays pending otherwise.
27
37
  A clean failure REPORT is not confirmation (hodl invoices).
28
- 5. A melt invoice must equal the note total exactly, must not be one this
29
- mint issued, and must not repeat an earlier melt's payment hash.
38
+ 5. A melt invoice must equal the note total exactly, or state no amount
39
+ at all, in which case the mint sends the whole-sat floor of the note's
40
+ value and refuses with 'insufficient value' when that is zero. It must
41
+ not be one this mint issued, and must not repeat an earlier melt's
42
+ payment hash.
30
43
  On a SHARED funding source the node's own payment history is the only
31
44
  dedupe that spans every consumer: pre-check it before reserving the
32
45
  note, and treat the node's "payment already exists" refusal as a
@@ -47,6 +60,10 @@ Client library to build against a mint: lnurlcash-kit
47
60
  ## Library API
48
61
 
49
62
  createMoneyer(config, deps?) -> {url, port, store, backend, signer, reconcile, close}
63
+ verifyAnnouncement(content, mintPubkey) -> {valid, document}
64
+ kind 30078 / d=lnurlcash-mint: a mint announcing itself, content = its
65
+ discovery document + sig by the note signing key. MONEYER_ANNOUNCE=true,
66
+ off by default.
50
67
  configFromEnv(env?) -> MoneyerConfig MONEYER_* environment variables
51
68
  createFakeBackend() -> LightningBackend with .control test hooks
52
69
  createClnBackend({url, rune}) / createLndBackend({url, macaroon})
@@ -58,7 +75,7 @@ NoteStore - SQLite store; notes by sha256(k1), never secrets
58
75
 
59
76
  /.well-known/lnurlp/{user} LUD-16 payRequest (mints)
60
77
  /.well-known/lnurlw/{user} LUD-25 mint address (experimental)
61
- /p/cb LUD-06 pay callback
78
+ /p/cb LUD-06 pay callback (optional h names the note)
62
79
  /verify/{payment_hash} LUD-21 verify (mint invoices AND melt payments)
63
80
  /w LUD-03 informational GET
64
81
  /w/cb melt / rotate / split / merge
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.1.2",
3
+ "version": "0.2.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",
@@ -57,7 +57,8 @@
57
57
  "@noble/hashes": "^2.3.0",
58
58
  "@scure/base": "^1.2.4",
59
59
  "farrier-kit": "^1.1.3",
60
- "lnurlcash-kit": "^0.1.0"
60
+ "lnurlcash-kit": "^0.2.0",
61
+ "nostr-tools": "2.24.1"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@fontsource/cinzel": "^5.3.0",
@@ -66,7 +67,7 @@
66
67
  "@types/node": "^24.0.0",
67
68
  "animejs": "^4.0.0",
68
69
  "happy-dom": "^20.0.0",
69
- "lnurlcash-conformance": "^0.1.0",
70
+ "lnurlcash-conformance": "^0.2.1",
70
71
  "playwright": "^1.62.1",
71
72
  "typescript": "^5.7.0",
72
73
  "uqr": "^0.1.2",