@forgesworn/moneyer 0.16.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/README.md +69 -31
- package/TERMS-TEMPLATE.md +14 -0
- package/THREAT-MODEL.md +43 -11
- package/dist/admin.js +24 -14
- package/dist/cli.js +3 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/kernel.d.ts +10 -0
- package/dist/kernel.js +105 -0
- package/dist/names.d.ts +12 -2
- package/dist/names.js +50 -7
- package/dist/server.d.ts +2 -0
- package/dist/server.js +201 -139
- package/dist/spend.d.ts +85 -0
- package/dist/spend.js +454 -0
- package/dist/store.d.ts +6 -1
- package/dist/store.js +91 -11
- package/dist/zap.js +11 -6
- package/llms.txt +22 -10
- package/package.json +3 -2
- package/scripts/kernel-verifier.py +59 -0
package/dist/store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import { sha256 } from '@noble/hashes/sha2.js';
|
|
3
3
|
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
4
|
+
import { bearerNoteId } from "./spend.js";
|
|
4
5
|
// What identifies one mutation request, so a retry of it can be answered
|
|
5
6
|
// with the same reply instead of "already spent". Everything the WALLET
|
|
6
7
|
// chose goes in: the notes it named (by id, never by secret - the store
|
|
@@ -123,6 +124,16 @@ export class NoteStore {
|
|
|
123
124
|
if (!nameColumns.includes('next_index')) {
|
|
124
125
|
this.db.exec('ALTER TABLE zap_names ADD COLUMN next_index INTEGER NOT NULL DEFAULT 0');
|
|
125
126
|
}
|
|
127
|
+
if (!invoiceColumns.includes('output_named')) {
|
|
128
|
+
this.db.exec('ALTER TABLE mint_invoices ADD COLUMN output_named TEXT');
|
|
129
|
+
}
|
|
130
|
+
// The Q of the bearer note the invoice's payment preimage would open.
|
|
131
|
+
// That preimage reaches the funding source, every hop, and /verify, so
|
|
132
|
+
// no note may ever be credited there. See outputIdInUse.
|
|
133
|
+
if (!invoiceColumns.includes('payment_q')) {
|
|
134
|
+
this.db.exec('ALTER TABLE mint_invoices ADD COLUMN payment_q TEXT');
|
|
135
|
+
}
|
|
136
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS mint_invoices_payment_q ON mint_invoices (payment_q)');
|
|
126
137
|
this.db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);');
|
|
127
138
|
// The moment this mint stopped publishing preimages for notes nobody
|
|
128
139
|
// named. Written once, the first time a build carrying this code opens
|
|
@@ -138,6 +149,66 @@ export class NoteStore {
|
|
|
138
149
|
this.db
|
|
139
150
|
.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES ('unnamed_verify_cutover', ?)")
|
|
140
151
|
.run(String(Date.now()));
|
|
152
|
+
this.indexLegacyIds();
|
|
153
|
+
// Lightning Address payments now land on their own purpose of a name's
|
|
154
|
+
// branch, a counter that starts at 0. The indices on file counted the
|
|
155
|
+
// old single ladder, so restart them once rather than open a gap a
|
|
156
|
+
// wallet's scan could stop short of.
|
|
157
|
+
this.tx(() => {
|
|
158
|
+
const moved = this.db
|
|
159
|
+
.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES ('address_purpose_counter', ?)")
|
|
160
|
+
.run(String(Date.now()));
|
|
161
|
+
if (moved.changes > 0)
|
|
162
|
+
this.db.exec('UPDATE zap_names SET next_index = 0');
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
// Once per database: the Q each id written before notes were keyed by Q
|
|
166
|
+
// would have as a bearer note (see the top of this file). Every note row,
|
|
167
|
+
// every note an invoice was told to credit, and every payment hash that
|
|
168
|
+
// is itself a note's id. Rows written after this run are keyed by Q
|
|
169
|
+
// already, apart from notes still to land from invoices indexed here.
|
|
170
|
+
indexLegacyIds() {
|
|
171
|
+
this.db.exec('CREATE TABLE IF NOT EXISTS legacy_ids (q TEXT PRIMARY KEY, id TEXT NOT NULL)');
|
|
172
|
+
const done = this.db.prepare("SELECT 1 FROM meta WHERE key = 'legacy_ids_indexed'").get();
|
|
173
|
+
if (done)
|
|
174
|
+
return;
|
|
175
|
+
this.tx(() => {
|
|
176
|
+
const ids = this.db
|
|
177
|
+
.prepare(`SELECT id FROM notes
|
|
178
|
+
UNION SELECT output_id FROM mint_invoices WHERE output_id IS NOT NULL
|
|
179
|
+
UNION SELECT payment_hash FROM mint_invoices WHERE output_id IS NULL`)
|
|
180
|
+
.all();
|
|
181
|
+
const insert = this.db.prepare('INSERT OR IGNORE INTO legacy_ids (q, id) VALUES (?, ?)');
|
|
182
|
+
for (const { id } of ids) {
|
|
183
|
+
if (/^[0-9a-f]{64}$/.test(id))
|
|
184
|
+
insert.run(bearerNoteId(id), id);
|
|
185
|
+
}
|
|
186
|
+
const unmapped = this.db.prepare('SELECT payment_hash FROM mint_invoices WHERE payment_q IS NULL').all();
|
|
187
|
+
const setQ = this.db.prepare('UPDATE mint_invoices SET payment_q = ? WHERE payment_hash = ?');
|
|
188
|
+
for (const { payment_hash } of unmapped)
|
|
189
|
+
setQ.run(bearerNoteId(payment_hash), payment_hash);
|
|
190
|
+
this.db.prepare("INSERT INTO meta (key, value) VALUES ('legacy_ids_indexed', ?)").run(String(Date.now()));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
// The old id a note named by `q` is stored under, if it predates Q ids.
|
|
194
|
+
// A read-only store opened on a database no upgraded mint has touched
|
|
195
|
+
// has no index yet, and so no old ids to find.
|
|
196
|
+
legacyIdOf(q) {
|
|
197
|
+
try {
|
|
198
|
+
const row = this.db.prepare('SELECT id FROM legacy_ids WHERE q = ?').get(q);
|
|
199
|
+
return row?.id ?? null;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// The note whose output key is `q`, wherever it is stored.
|
|
206
|
+
noteByQ(q) {
|
|
207
|
+
const note = this.noteById(q);
|
|
208
|
+
if (note)
|
|
209
|
+
return note;
|
|
210
|
+
const legacy = this.legacyIdOf(q);
|
|
211
|
+
return legacy ? this.noteById(legacy) : null;
|
|
141
212
|
}
|
|
142
213
|
// Invoices quoted at or after this instant get no verify if they named no
|
|
143
214
|
// output. See the migration above for why it is not simply "now".
|
|
@@ -159,11 +230,12 @@ export class NoteStore {
|
|
|
159
230
|
throw err;
|
|
160
231
|
}
|
|
161
232
|
}
|
|
233
|
+
// By the row's own key. A request names notes by Q: see noteByQ.
|
|
162
234
|
noteById(id) {
|
|
163
235
|
const row = this.db
|
|
164
|
-
.prepare('SELECT id, amount_msat, state FROM notes WHERE id = ?')
|
|
236
|
+
.prepare('SELECT id, amount_msat, state, created_at FROM notes WHERE id = ?')
|
|
165
237
|
.get(id);
|
|
166
|
-
return row ? { id: row.id, amountMsat: row.amount_msat, state: row.state } : null;
|
|
238
|
+
return row ? { id: row.id, amountMsat: row.amount_msat, state: row.state, createdAt: row.created_at } : null;
|
|
167
239
|
}
|
|
168
240
|
insertNote(id, amountMsat) {
|
|
169
241
|
const now = Date.now();
|
|
@@ -187,11 +259,16 @@ export class NoteStore {
|
|
|
187
259
|
// Public because the pay callback has to answer this before it asks the
|
|
188
260
|
// funding source for an invoice: a wallet must never pay for a quote the
|
|
189
261
|
// mint was always going to refuse.
|
|
262
|
+
//
|
|
263
|
+
// A Q that an old id maps to is in use too, burned or not: crediting a
|
|
264
|
+
// new note there would give one secret two notes.
|
|
190
265
|
outputIdInUse(id) {
|
|
191
266
|
const asNote = this.db.prepare('SELECT 1 FROM notes WHERE id = ?').get(id);
|
|
192
267
|
const asInvoice = this.db.prepare('SELECT 1 FROM mint_invoices WHERE payment_hash = ?').get(id);
|
|
193
268
|
const asBoundOutput = this.db.prepare('SELECT 1 FROM mint_invoices WHERE output_id = ?').get(id);
|
|
194
|
-
|
|
269
|
+
const asLegacy = this.db.prepare('SELECT 1 FROM legacy_ids WHERE q = ?').get(id);
|
|
270
|
+
const asPreimageNote = this.db.prepare('SELECT 1 FROM mint_invoices WHERE payment_q = ?').get(id);
|
|
271
|
+
return Boolean(asNote || asInvoice || asBoundOutput || asLegacy || asPreimageNote);
|
|
195
272
|
}
|
|
196
273
|
assertOutputIdFree(id) {
|
|
197
274
|
if (this.outputIdInUse(id))
|
|
@@ -313,22 +390,23 @@ export class NoteStore {
|
|
|
313
390
|
outcome: row.outcome
|
|
314
391
|
}));
|
|
315
392
|
}
|
|
316
|
-
// `outputId` is the note
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
393
|
+
// `outputId` is hex(Q) of the note the payer's wallet named, and
|
|
394
|
+
// `outputNamed` the value it named it by. Both ids are checked in the
|
|
395
|
+
// same transaction that inserts the row, so two requests racing for one
|
|
396
|
+
// id cannot both be told yes.
|
|
397
|
+
recordMintInvoice(paymentHash, pr, grossMsat, netMsat, outputId = null, outputNamed = outputId) {
|
|
320
398
|
this.tx(() => {
|
|
321
399
|
this.assertOutputIdFree(paymentHash);
|
|
322
400
|
if (outputId !== null)
|
|
323
401
|
this.assertOutputIdFree(outputId);
|
|
324
402
|
this.db
|
|
325
|
-
.prepare('INSERT INTO mint_invoices (payment_hash, pr, gross_msat, net_msat, settled, created_at, output_id) VALUES (?, ?, ?, ?, 0, ?, ?)')
|
|
326
|
-
.run(paymentHash, pr, grossMsat, netMsat, Date.now(), outputId);
|
|
403
|
+
.prepare('INSERT INTO mint_invoices (payment_hash, pr, gross_msat, net_msat, settled, created_at, output_id, output_named, payment_q) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?)')
|
|
404
|
+
.run(paymentHash, pr, grossMsat, netMsat, Date.now(), outputId, outputNamed, bearerNoteId(paymentHash));
|
|
327
405
|
});
|
|
328
406
|
}
|
|
329
407
|
mintInvoiceByHash(paymentHash) {
|
|
330
408
|
const row = this.db
|
|
331
|
-
.prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id, created_at FROM mint_invoices WHERE payment_hash = ?')
|
|
409
|
+
.prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id, output_named, created_at FROM mint_invoices WHERE payment_hash = ?')
|
|
332
410
|
.get(paymentHash);
|
|
333
411
|
if (!row)
|
|
334
412
|
return null;
|
|
@@ -339,6 +417,7 @@ export class NoteStore {
|
|
|
339
417
|
netMsat: row.net_msat,
|
|
340
418
|
settled: row.settled === 1,
|
|
341
419
|
outputId: row.output_id,
|
|
420
|
+
outputNamed: row.output_named,
|
|
342
421
|
createdAt: row.created_at
|
|
343
422
|
};
|
|
344
423
|
}
|
|
@@ -355,7 +434,7 @@ export class NoteStore {
|
|
|
355
434
|
// expired invoices - so it is dead weight, and every /p/cb call adds one.
|
|
356
435
|
unsettledMintInvoices() {
|
|
357
436
|
const rows = this.db
|
|
358
|
-
.prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id, created_at FROM mint_invoices WHERE settled = 0')
|
|
437
|
+
.prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id, output_named, created_at FROM mint_invoices WHERE settled = 0')
|
|
359
438
|
.all();
|
|
360
439
|
return rows.map(row => ({
|
|
361
440
|
paymentHash: row.payment_hash,
|
|
@@ -364,6 +443,7 @@ export class NoteStore {
|
|
|
364
443
|
netMsat: row.net_msat,
|
|
365
444
|
settled: false,
|
|
366
445
|
outputId: row.output_id,
|
|
446
|
+
outputNamed: row.output_named,
|
|
367
447
|
createdAt: row.created_at
|
|
368
448
|
}));
|
|
369
449
|
}
|
package/dist/zap.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
|
|
2
|
-
import { decodeCx1,
|
|
2
|
+
import { decodeCx1, encodeCp1, hashK1 } from '@lnurlcash/kit';
|
|
3
|
+
import { NOTE_PURPOSE_LIGHTNING_ADDRESS, bearerNoteIdOfPreimage, deriveNotePubkey } from "./spend.js";
|
|
3
4
|
import { tryDecodeBolt11 } from 'farrier-kit/bolt11';
|
|
4
5
|
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
|
5
6
|
import { SimplePool } from 'nostr-tools/pool';
|
|
@@ -83,8 +84,11 @@ const metadataFor = (name, host, mintFeeLine, feeInWords = null, internalTransfe
|
|
|
83
84
|
['text/plain', `Zap ${name}@${host}: arrives as a Lightning bearer note${feeInWords ? ` (${feeInWords})` : ''}`],
|
|
84
85
|
['text/identifier', `${name}@${host}`]
|
|
85
86
|
];
|
|
87
|
+
// `text/cpub`, never the older `text/xpub` as well: a wallet that reads
|
|
88
|
+
// `text/xpub` derives the index without a purpose, and would transfer to
|
|
89
|
+
// a key the payee never scans. Without it that wallet pays by Lightning.
|
|
86
90
|
if (internalTransfer)
|
|
87
|
-
metadata.push(['text/
|
|
91
|
+
metadata.push(['text/cpub', internalTransfer]);
|
|
88
92
|
if (mintFeeLine)
|
|
89
93
|
metadata.push(['text/plain', mintFeeLine]);
|
|
90
94
|
return JSON.stringify(metadata);
|
|
@@ -113,7 +117,7 @@ export const createZapBridge = (deps) => {
|
|
|
113
117
|
let index = registered.nextIndex;
|
|
114
118
|
for (let tries = 0; tries < 1000; tries++, index++) {
|
|
115
119
|
try {
|
|
116
|
-
const noteId = bytesToHex(deriveNotePubkey(branch.pubkeyXOnly, branch.chainCode, index));
|
|
120
|
+
const noteId = bytesToHex(deriveNotePubkey(branch.pubkeyXOnly, branch.chainCode, NOTE_PURPOSE_LIGHTNING_ADDRESS, index));
|
|
117
121
|
if (!store.outputIdInUse(noteId)) {
|
|
118
122
|
internalTransfer = `${registered.cx1}:${index}`;
|
|
119
123
|
break;
|
|
@@ -224,7 +228,7 @@ export const createZapBridge = (deps) => {
|
|
|
224
228
|
// the certificate, and the index `i` the holder derives the key at. A
|
|
225
229
|
// reader that knows only note URLs sees no k1 and passes it by, and the
|
|
226
230
|
// note waits at the mint for a scan to find it.
|
|
227
|
-
const buildKeyWrap = (row, noteId, index) => wrap(row, [...wrapTags(row), ['i', String(index)]], `${origin}/w?p=${encodeCp1(hexToBytes(noteId))}&
|
|
231
|
+
const buildKeyWrap = (row, noteId, index) => wrap(row, [...wrapTags(row), ['i', String(index)]], `${origin}/w?p=${encodeCp1(hexToBytes(noteId))}&c=${deps.certify(noteId, row.netMsat)}&i=${index}`);
|
|
228
232
|
// NIP-57 receipt, without the preimage tag: it is optional there, and
|
|
229
233
|
// here it would only invite someone to mistake it for the note.
|
|
230
234
|
const buildReceipt = (row) => {
|
|
@@ -251,7 +255,7 @@ export const createZapBridge = (deps) => {
|
|
|
251
255
|
const receipt = buildReceipt(row);
|
|
252
256
|
const keyAt = (index) => {
|
|
253
257
|
try {
|
|
254
|
-
return bytesToHex(deriveNotePubkey(branch.pubkeyXOnly, branch.chainCode, index));
|
|
258
|
+
return bytesToHex(deriveNotePubkey(branch.pubkeyXOnly, branch.chainCode, NOTE_PURPOSE_LIGHTNING_ADDRESS, index));
|
|
255
259
|
}
|
|
256
260
|
catch {
|
|
257
261
|
return null;
|
|
@@ -268,7 +272,8 @@ export const createZapBridge = (deps) => {
|
|
|
268
272
|
continue;
|
|
269
273
|
}
|
|
270
274
|
const k1 = bytesToHex(randomBytes(32));
|
|
271
|
-
|
|
275
|
+
// The bearer note whose preimage is k1, stored under its Q.
|
|
276
|
+
const noteId = bearerNoteIdOfPreimage(k1);
|
|
272
277
|
const wrap = buildWrap(row, k1);
|
|
273
278
|
const receipt = buildReceipt(row);
|
|
274
279
|
if (store.settleZapInvoice(row.paymentHash, noteId, JSON.stringify(wrap), receipt ? JSON.stringify(receipt) : null)) {
|
package/llms.txt
CHANGED
|
@@ -29,11 +29,16 @@ Client library to build against a mint: @lnurlcash/kit
|
|
|
29
29
|
inside the invoice. `h` is an additive alias for the same commitment and
|
|
30
30
|
never replaces the comment. Because the note is not keyed by the
|
|
31
31
|
preimage, a funding source whose node chooses its own preimages
|
|
32
|
-
(phoenixd, NIP-47 make_invoice) can back a mint. LUD-25
|
|
33
|
-
|
|
32
|
+
(phoenixd, NIP-47 make_invoice) can back a mint. Every note is a LUD-25
|
|
33
|
+
taproot output key Q: a 64-hex comment is a bearer note's h (Q follows
|
|
34
|
+
from it), and `cp1<Q>` names any note directly.
|
|
34
35
|
2. Mutations are atomic: all named k1 burn and all outputs mint, or
|
|
35
36
|
nothing happens. A note named twice in one request is refused, compared
|
|
36
|
-
by the
|
|
37
|
+
by the Q each k1 spends: one note has many valid spends (preimage, cw1,
|
|
38
|
+
ck1). Every k1 is verified first: a ck1 is bound to this mint's domains
|
|
39
|
+
(clearnet and onion), a cw1 is checked for leaf version, OP_SUCCESSx and
|
|
40
|
+
Unix-time claims, and only bearer hashlock leaves are evaluated in
|
|
41
|
+
process; other leaves need MoneyerDeps.scriptVerifier.
|
|
37
42
|
3. p1/p2 (older names h/h2, still accepted; both spellings of one output
|
|
38
43
|
must agree) are WALLET-supplied hashes or cp1 keys; the mint never generates a
|
|
39
44
|
replacement secret, and refuses output ids that already exist as
|
|
@@ -84,10 +89,17 @@ configFromEnv(env?) -> MoneyerConfig MONEYER_* environment variables
|
|
|
84
89
|
operator configured - it never builds one.
|
|
85
90
|
createFakeBackend() -> LightningBackend with .control test hooks
|
|
86
91
|
createClnBackend({url, rune}) / createLndBackend({url, macaroon})
|
|
92
|
+
decodeSpend(k1) -> Spend | null a preimage, ck1 or cw1, with the Q it opens
|
|
93
|
+
decodeNote(value) -> hex(Q) | null a cp1, or a bearer note's 64-hex h
|
|
94
|
+
verifySpend(spend, {outputKey, domains, now, lockedAt, scriptVerifier?}) -> Promise<SpendVerdict>
|
|
95
|
+
bearerNoteId(hHex) / bearerNoteIdOfPreimage(k1Hex) -> hex(Q)
|
|
96
|
+
keyPathSighash(Q, domain) / scriptPathSighash(Q, domain, leaf, locktime, sequence) -> 32 bytes
|
|
97
|
+
ScriptVerifier = ({outputKey, domain, spend, now, lockedAt}) => Promise<reason | null>
|
|
98
|
+
pass as MoneyerDeps.scriptVerifier to judge leaves moneyer cannot evaluate itself
|
|
87
99
|
createNoteSigner(privHex) -> {pubkey, sign(noteId, amountMsat)}
|
|
88
100
|
privHex is a secp256k1 private key, as MONEYER_SIGNING_KEY; zero is rejected
|
|
89
101
|
fakeBolt11({amountMsat, paymentHashHex, memo?}) -> decodable, unpayable invoice
|
|
90
|
-
NoteStore - SQLite store; notes by
|
|
102
|
+
NoteStore - SQLite store; notes by hex(Q), never spends; pre-Q rows found via legacy_ids
|
|
91
103
|
runMelt(job, deps) -> Promise<void> / reconcilePendingMelts(store, backend, inFlight, log?)
|
|
92
104
|
the melt discipline: burn only on confirmed payment, restore only on confirmed
|
|
93
105
|
non-payment. The /w/cb handler drives both; outcomes land in the store, not in a
|
|
@@ -108,15 +120,15 @@ signStats(stats, privHex) -> string the signature alone, hex r || s || recover
|
|
|
108
120
|
|
|
109
121
|
/.well-known/lnurlp/{user} LUD-16 payRequest (mints)
|
|
110
122
|
/.well-known/lnurlw/{user} LUD-25 mint address (experimental)
|
|
111
|
-
/p/cb LUD-06 pay callback (comment names the note:
|
|
123
|
+
/p/cb LUD-06 pay callback (comment names the note: a bearer h or a cp1)
|
|
112
124
|
/verify/{payment_hash} LUD-21 verify (mint invoices AND melt payments)
|
|
113
|
-
/w LUD-03 informational GET by k1 (
|
|
114
|
-
/w/cb melt / rotate / split / merge; inputs
|
|
125
|
+
/w LUD-03 informational GET by k1 (preimage, ck1 or cw1, verified in full) or p (a bearer h or a cp1, older h); every note carries its cs1 over Q as sig
|
|
126
|
+
/w/cb melt / rotate / split / merge; inputs preimages, ck1 or cw1, outputs p1/p2 as a bearer h or cp1 (or older h/h2); every output certified as cs1 over Q; a taken output is `already in use`
|
|
115
127
|
POST /names claim a name (NIP-98; the signer owns it); body {name, note?, cx1?}.
|
|
116
128
|
The owner may resend {name, cx1} to set a branch, or cx1: null to clear it
|
|
117
129
|
/z/cb/{name} zap invoice for a name. With a cx1 the settled note is credited to the
|
|
118
|
-
holder's next key, and the kind 2525 gift wrap carries no secret:
|
|
119
|
-
/w?p=<cp1>&
|
|
130
|
+
holder's next key on the branch's Lightning Address purpose (2), and the kind 2525 gift wrap carries no secret:
|
|
131
|
+
/w?p=<cp1>&c=<amount-bearing-cs1>&i=<index> plus an `i` tag. The index is
|
|
120
132
|
taken at settlement, never at the invoice, and occupied keys are skipped
|
|
121
|
-
Its payRequest metadata publishes text/
|
|
133
|
+
Its payRequest metadata publishes text/cpub = cx1:<next-free-purpose-2-index> for
|
|
122
134
|
same-mint rotate/split/merge transfers; a raced key returns "already in use"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/moneyer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"dist",
|
|
51
51
|
"web/dist",
|
|
52
52
|
"scripts/live-bound-mint-check.mjs",
|
|
53
|
+
"scripts/kernel-verifier.py",
|
|
53
54
|
"LICENSE",
|
|
54
55
|
"README.md",
|
|
55
56
|
"CHANGELOG.md",
|
|
@@ -87,7 +88,7 @@
|
|
|
87
88
|
"@types/node": "^24.0.0",
|
|
88
89
|
"animejs": "^4.0.0",
|
|
89
90
|
"happy-dom": "^20.0.0",
|
|
90
|
-
"lnurlcash-conformance": "0.
|
|
91
|
+
"lnurlcash-conformance": "0.15.0",
|
|
91
92
|
"playwright": "^1.62.1",
|
|
92
93
|
"typescript": "^5.7.0",
|
|
93
94
|
"uqr": "^0.1.2",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""moneyer's tapscript verifier: Bitcoin Core's own script interpreter,
|
|
3
|
+
through lnurlcash-kernel (pip install lnurlcash-kernel), the same verifier
|
|
4
|
+
the reference mint uses.
|
|
5
|
+
|
|
6
|
+
moneyer evaluates a bearer note's hashlock leaf itself and hands every other
|
|
7
|
+
script-path spend here. One JSON request per line on stdin, one reply per
|
|
8
|
+
line on stdout, matched by id:
|
|
9
|
+
|
|
10
|
+
-> {"id": 1, "q": "<hex Q>", "domain": "mint.example", "cw1": "cw1...",
|
|
11
|
+
"now": 1790000000, "locked_at": 1789990000}
|
|
12
|
+
<- {"id": 1, "ok": true}
|
|
13
|
+
<- {"id": 1, "reason": "bitcoin core rejected the spend"}
|
|
14
|
+
|
|
15
|
+
`now` and `locked_at` are moneyer's clock, in Unix seconds: the kernel never
|
|
16
|
+
reads one. A reply carrying "error" is a broken install or a bug, never a
|
|
17
|
+
verdict, and moneyer refuses the spend.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
import lnurlcashkernel as kernel
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def answer(request: dict) -> dict:
|
|
27
|
+
spend = kernel.decode_spend(request["cw1"])
|
|
28
|
+
if spend is None or spend.key_path:
|
|
29
|
+
return {"reason": "malformed cw1"}
|
|
30
|
+
try:
|
|
31
|
+
kernel.verify_spend(
|
|
32
|
+
output_key=bytes.fromhex(request["q"]),
|
|
33
|
+
domain=request["domain"],
|
|
34
|
+
spend=spend,
|
|
35
|
+
now=int(request["now"]),
|
|
36
|
+
locked_at=int(request["locked_at"]),
|
|
37
|
+
)
|
|
38
|
+
except kernel.SpendRejected as exc:
|
|
39
|
+
return {"reason": exc.reason}
|
|
40
|
+
return {"ok": True}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main() -> None:
|
|
44
|
+
for line in sys.stdin:
|
|
45
|
+
try:
|
|
46
|
+
request = json.loads(line)
|
|
47
|
+
request_id = request["id"]
|
|
48
|
+
except (ValueError, KeyError, TypeError):
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
reply = answer(request)
|
|
52
|
+
except Exception as exc: # noqa: BLE001 - reported, never a verdict
|
|
53
|
+
reply = {"error": type(exc).__name__}
|
|
54
|
+
sys.stdout.write(json.dumps({"id": request_id, **reply}) + "\n")
|
|
55
|
+
sys.stdout.flush()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
main()
|