@forgesworn/moneyer 0.6.0 → 0.6.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 +16 -0
- package/dist/live-check.d.ts +48 -0
- package/dist/live-check.js +378 -0
- package/package.json +4 -2
- package/scripts/live-bound-mint-check.mjs +98 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.1] - 2026-08-24
|
|
4
|
+
|
|
5
|
+
- The bundled web wallet now accepts a bound mint quote anywhere inside
|
|
6
|
+
the mint's advertised fee band, then uses the committed amount for the
|
|
7
|
+
signed receipt and note. A mint that rounds its fee up to a whole sat -
|
|
8
|
+
including moneyer's production default - no longer makes the page
|
|
9
|
+
silently abandon the sealed-signer receipt path for a legacy invoice.
|
|
10
|
+
Browser coverage uses the production `5000 msat + 1000 ppm`, sat-rounded
|
|
11
|
+
policy and proves the staged quote survives through settlement.
|
|
12
|
+
- A resumable real-node bound-mint release check persists its bearer secret
|
|
13
|
+
with mode `0600` before requesting a quote, treats payer command output as
|
|
14
|
+
opaque, validates the settlement preimage and signed receipt, then melts
|
|
15
|
+
the whole test note to a fresh amountless refund invoice. Interrupted runs
|
|
16
|
+
resume from the same state file; successful runs scrub the secret and leave
|
|
17
|
+
no test-note liability behind.
|
|
18
|
+
|
|
3
19
|
## [0.6.0] - 2026-08-24
|
|
4
20
|
|
|
5
21
|
- **Bound mint settlement receipts.** A pay callback asked to mint at a
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type InvoiceResult } from 'lnurlcash-kit';
|
|
2
|
+
export type LiveCheckStage = 'prepared' | 'quoted' | 'settled' | 'claimed' | 'retiring' | 'retired';
|
|
3
|
+
export type LiveCheckState = {
|
|
4
|
+
version: 1;
|
|
5
|
+
stage: LiveCheckStage;
|
|
6
|
+
payUrl: string;
|
|
7
|
+
grossMsat: number;
|
|
8
|
+
h: string;
|
|
9
|
+
secret?: string;
|
|
10
|
+
netMsat?: number;
|
|
11
|
+
mintPubkey?: string;
|
|
12
|
+
payCallback?: string;
|
|
13
|
+
withdrawLink?: string;
|
|
14
|
+
quote?: InvoiceResult;
|
|
15
|
+
noteCallback?: string;
|
|
16
|
+
refundPr?: string;
|
|
17
|
+
refundPaymentHash?: string;
|
|
18
|
+
meltVerify?: string;
|
|
19
|
+
paymentPreimageValidated?: true;
|
|
20
|
+
receiptSignatureValidated?: true;
|
|
21
|
+
refundSettled?: true;
|
|
22
|
+
completedAt?: string;
|
|
23
|
+
};
|
|
24
|
+
export type LiveBoundMintCheckOptions = {
|
|
25
|
+
payUrl: string;
|
|
26
|
+
grossMsat: number;
|
|
27
|
+
statePath: string;
|
|
28
|
+
payInvoice: (pr: string) => Promise<void>;
|
|
29
|
+
createRefundInvoice: () => Promise<string>;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
pollMs?: number;
|
|
32
|
+
log?: (message: string) => void;
|
|
33
|
+
};
|
|
34
|
+
export type LiveBoundMintCheckResult = {
|
|
35
|
+
stage: 'retired';
|
|
36
|
+
payUrl: string;
|
|
37
|
+
grossMsat: number;
|
|
38
|
+
netMsat: number;
|
|
39
|
+
h: string;
|
|
40
|
+
mintPubkey: string;
|
|
41
|
+
paymentPreimageValidated: true;
|
|
42
|
+
receiptSignatureValidated: true;
|
|
43
|
+
refundSettled: true;
|
|
44
|
+
completedAt: string;
|
|
45
|
+
};
|
|
46
|
+
export declare const readLiveCheckState: (path: string) => Promise<LiveCheckState>;
|
|
47
|
+
export declare const extractBolt11: (output: string) => string | null;
|
|
48
|
+
export declare const runLiveBoundMintCheck: (options: LiveBoundMintCheckOptions) => Promise<LiveBoundMintCheckResult>;
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { lstat, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { decodeBolt11 } from 'farrier-kit/bolt11';
|
|
4
|
+
import { NoteSpentError, PendingNoteError, buildNoteUrl, claimMintedNote, decodeBolt11AmountMsat, fetchInvoiceVerification, fetchPayRequest, hashK1, isBolt11Invoice, isPreimage, meltNote, probeBurnedNote, requestInvoice, requireBoundMintQuote, validateBoundMintReceipt, withinMintFeeBand } from 'lnurlcash-kit';
|
|
5
|
+
const stages = new Set(['prepared', 'quoted', 'settled', 'claimed', 'retiring', 'retired']);
|
|
6
|
+
const errno = (error) => error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
|
|
7
|
+
const serialise = (state) => `${JSON.stringify(state, null, 2)}\n`;
|
|
8
|
+
const writeNewState = async (path, state) => {
|
|
9
|
+
const handle = await open(path, 'wx', 0o600);
|
|
10
|
+
try {
|
|
11
|
+
await handle.writeFile(serialise(state), 'utf8');
|
|
12
|
+
await handle.sync();
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
await handle.close();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const assertSecureStateFile = async (path) => {
|
|
19
|
+
const stat = await lstat(path);
|
|
20
|
+
if (!stat.isFile())
|
|
21
|
+
throw new Error(`Live-check state is not a regular file: ${path}`);
|
|
22
|
+
if (typeof process.getuid === 'function') {
|
|
23
|
+
if (stat.uid !== process.getuid())
|
|
24
|
+
throw new Error(`Live-check state is not owned by this user: ${path}`);
|
|
25
|
+
if ((stat.mode & 0o077) !== 0)
|
|
26
|
+
throw new Error(`Live-check state must have mode 0600: ${path}`);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const replaceState = async (path, state) => {
|
|
30
|
+
await assertSecureStateFile(path);
|
|
31
|
+
const temporary = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
32
|
+
try {
|
|
33
|
+
await writeNewState(temporary, state);
|
|
34
|
+
await rename(temporary, path);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
await unlink(temporary).catch(() => { });
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const validateState = (value) => {
|
|
42
|
+
if (!value || typeof value !== 'object')
|
|
43
|
+
throw new Error('Live-check state is not an object.');
|
|
44
|
+
const state = value;
|
|
45
|
+
if (state.version !== 1 || !state.stage || !stages.has(state.stage))
|
|
46
|
+
throw new Error('Unsupported live-check state.');
|
|
47
|
+
if (typeof state.payUrl !== 'string' ||
|
|
48
|
+
typeof state.grossMsat !== 'number' ||
|
|
49
|
+
!Number.isSafeInteger(state.grossMsat) ||
|
|
50
|
+
state.grossMsat <= 0 ||
|
|
51
|
+
typeof state.h !== 'string' ||
|
|
52
|
+
!/^[0-9a-f]{64}$/.test(state.h)) {
|
|
53
|
+
throw new Error('Live-check state is incomplete.');
|
|
54
|
+
}
|
|
55
|
+
if (state.stage !== 'retired') {
|
|
56
|
+
if (typeof state.secret !== 'string' || !isPreimage(state.secret) || hashK1(state.secret) !== state.h) {
|
|
57
|
+
throw new Error('Live-check state does not contain the secret committed by h.');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return state;
|
|
61
|
+
};
|
|
62
|
+
export const readLiveCheckState = async (path) => {
|
|
63
|
+
await assertSecureStateFile(path);
|
|
64
|
+
return validateState(JSON.parse(await readFile(path, 'utf8')));
|
|
65
|
+
};
|
|
66
|
+
const loadOrCreateState = async (options) => {
|
|
67
|
+
let state;
|
|
68
|
+
try {
|
|
69
|
+
state = await readLiveCheckState(options.statePath);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (errno(error) !== 'ENOENT')
|
|
73
|
+
throw error;
|
|
74
|
+
const secret = randomBytes(32).toString('hex');
|
|
75
|
+
state = {
|
|
76
|
+
version: 1,
|
|
77
|
+
stage: 'prepared',
|
|
78
|
+
payUrl: options.payUrl,
|
|
79
|
+
grossMsat: options.grossMsat,
|
|
80
|
+
secret,
|
|
81
|
+
h: hashK1(secret)
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
// This fsync completes before a quote exists. A crash from here on
|
|
85
|
+
// can lose an index or an unpaid invoice, never the bearer secret.
|
|
86
|
+
await writeNewState(options.statePath, state);
|
|
87
|
+
}
|
|
88
|
+
catch (writeError) {
|
|
89
|
+
if (errno(writeError) !== 'EEXIST')
|
|
90
|
+
throw writeError;
|
|
91
|
+
state = await readLiveCheckState(options.statePath);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (state.payUrl !== options.payUrl || state.grossMsat !== options.grossMsat) {
|
|
95
|
+
throw new Error('Existing live-check state belongs to a different mint or amount.');
|
|
96
|
+
}
|
|
97
|
+
return state;
|
|
98
|
+
};
|
|
99
|
+
const stringsIn = (value) => {
|
|
100
|
+
if (typeof value === 'string')
|
|
101
|
+
return [value];
|
|
102
|
+
if (Array.isArray(value))
|
|
103
|
+
return value.flatMap(stringsIn);
|
|
104
|
+
if (value && typeof value === 'object')
|
|
105
|
+
return Object.values(value).flatMap(stringsIn);
|
|
106
|
+
return [];
|
|
107
|
+
};
|
|
108
|
+
// `lncli` has emitted JSON in some versions and a display table in others.
|
|
109
|
+
// The release check only needs an invoice from the refund command; it does
|
|
110
|
+
// not treat either presentation as an API contract.
|
|
111
|
+
export const extractBolt11 = (output) => {
|
|
112
|
+
const trimmed = output.trim();
|
|
113
|
+
if (isBolt11Invoice(trimmed))
|
|
114
|
+
return trimmed;
|
|
115
|
+
try {
|
|
116
|
+
for (const candidate of stringsIn(JSON.parse(trimmed))) {
|
|
117
|
+
if (isBolt11Invoice(candidate))
|
|
118
|
+
return candidate.trim();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Human-readable output is handled below.
|
|
123
|
+
}
|
|
124
|
+
for (const match of output.matchAll(/ln(?:bc|tb|bcrt|tbs|sb)[0-9]*[munp]?1[a-z0-9]+/gi)) {
|
|
125
|
+
if (isBolt11Invoice(match[0]))
|
|
126
|
+
return match[0].trim();
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
};
|
|
130
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
131
|
+
const waitForSettled = async (verifyUrl, timeoutMs, pollMs) => {
|
|
132
|
+
const deadline = Date.now() + timeoutMs;
|
|
133
|
+
let lastError;
|
|
134
|
+
while (Date.now() <= deadline) {
|
|
135
|
+
try {
|
|
136
|
+
const verification = await fetchInvoiceVerification(verifyUrl);
|
|
137
|
+
if (verification.settled)
|
|
138
|
+
return verification;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
lastError = error;
|
|
142
|
+
}
|
|
143
|
+
await sleep(pollMs);
|
|
144
|
+
}
|
|
145
|
+
const detail = lastError instanceof Error ? ` Last response: ${lastError.message}` : '';
|
|
146
|
+
throw new Error(`Timed out waiting for settlement.${detail}`);
|
|
147
|
+
};
|
|
148
|
+
const existingVerification = async (verifyUrl) => {
|
|
149
|
+
try {
|
|
150
|
+
return await fetchInvoiceVerification(verifyUrl);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const requireQuotedState = (state) => {
|
|
157
|
+
if (!state.secret ||
|
|
158
|
+
state.netMsat === undefined ||
|
|
159
|
+
!state.mintPubkey ||
|
|
160
|
+
!state.payCallback ||
|
|
161
|
+
!state.withdrawLink ||
|
|
162
|
+
!state.quote?.verify) {
|
|
163
|
+
throw new Error('Quoted live-check state is incomplete.');
|
|
164
|
+
}
|
|
165
|
+
const commitment = requireBoundMintQuote(state.quote, state.h, state.netMsat);
|
|
166
|
+
if (commitment.signature !== undefined)
|
|
167
|
+
throw new Error('The pre-payment commitment unexpectedly carries a signature.');
|
|
168
|
+
return {
|
|
169
|
+
secret: state.secret,
|
|
170
|
+
netMsat: state.netMsat,
|
|
171
|
+
mintPubkey: state.mintPubkey,
|
|
172
|
+
payCallback: state.payCallback,
|
|
173
|
+
withdrawLink: state.withdrawLink,
|
|
174
|
+
quote: state.quote
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
const retiredResult = (state) => {
|
|
178
|
+
if (state.stage !== 'retired' ||
|
|
179
|
+
state.netMsat === undefined ||
|
|
180
|
+
!state.mintPubkey ||
|
|
181
|
+
!state.completedAt ||
|
|
182
|
+
state.paymentPreimageValidated !== true ||
|
|
183
|
+
state.receiptSignatureValidated !== true ||
|
|
184
|
+
state.refundSettled !== true) {
|
|
185
|
+
throw new Error('Retired live-check state is incomplete.');
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
stage: 'retired',
|
|
189
|
+
payUrl: state.payUrl,
|
|
190
|
+
grossMsat: state.grossMsat,
|
|
191
|
+
netMsat: state.netMsat,
|
|
192
|
+
h: state.h,
|
|
193
|
+
mintPubkey: state.mintPubkey,
|
|
194
|
+
paymentPreimageValidated: true,
|
|
195
|
+
receiptSignatureValidated: true,
|
|
196
|
+
refundSettled: true,
|
|
197
|
+
completedAt: state.completedAt
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
export const runLiveBoundMintCheck = async (options) => {
|
|
201
|
+
if (!Number.isSafeInteger(options.grossMsat) || options.grossMsat <= 0)
|
|
202
|
+
throw new Error('grossMsat must be a positive integer.');
|
|
203
|
+
new URL(options.payUrl);
|
|
204
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
205
|
+
const pollMs = options.pollMs ?? 500;
|
|
206
|
+
const log = options.log ?? (() => { });
|
|
207
|
+
let state = await loadOrCreateState(options);
|
|
208
|
+
if (state.stage === 'retired')
|
|
209
|
+
return retiredResult(state);
|
|
210
|
+
if (state.stage === 'prepared') {
|
|
211
|
+
const pay = await fetchPayRequest(state.payUrl);
|
|
212
|
+
if (!pay.mintToHash || !pay.mintPubkey || !pay.withdrawLink) {
|
|
213
|
+
throw new Error('Mint does not advertise the bound-mint receipt capabilities required by this check.');
|
|
214
|
+
}
|
|
215
|
+
if (state.grossMsat < pay.minSendable || state.grossMsat > pay.maxSendable) {
|
|
216
|
+
throw new Error(`Test amount is outside the mint range ${pay.minSendable}-${pay.maxSendable} msat.`);
|
|
217
|
+
}
|
|
218
|
+
const quote = await requestInvoice(pay.callback, state.grossMsat, { h: state.h });
|
|
219
|
+
if (!quote.verify || !quote.mint)
|
|
220
|
+
throw new Error('Mint did not bind this quote to h and a verification URL.');
|
|
221
|
+
const netMsat = quote.mint.amountMsat;
|
|
222
|
+
if (!Number.isSafeInteger(netMsat) || netMsat <= 0)
|
|
223
|
+
throw new Error('Mint committed an invalid net note amount.');
|
|
224
|
+
const feeAccepted = pay.mintFee
|
|
225
|
+
? withinMintFeeBand(state.grossMsat, netMsat, pay.mintFee)
|
|
226
|
+
: netMsat === state.grossMsat;
|
|
227
|
+
if (!feeAccepted)
|
|
228
|
+
throw new Error('Mint committed a net amount outside its advertised fee band.');
|
|
229
|
+
const commitment = requireBoundMintQuote(quote, state.h, netMsat);
|
|
230
|
+
if (commitment.signature !== undefined)
|
|
231
|
+
throw new Error('The pre-payment commitment unexpectedly carries a signature.');
|
|
232
|
+
state = {
|
|
233
|
+
...state,
|
|
234
|
+
stage: 'quoted',
|
|
235
|
+
netMsat,
|
|
236
|
+
mintPubkey: pay.mintPubkey,
|
|
237
|
+
payCallback: pay.callback,
|
|
238
|
+
withdrawLink: pay.withdrawLink,
|
|
239
|
+
quote
|
|
240
|
+
};
|
|
241
|
+
await replaceState(options.statePath, state);
|
|
242
|
+
log(`quote committed ${netMsat} msat at the staged note hash`);
|
|
243
|
+
}
|
|
244
|
+
const quoted = requireQuotedState(state);
|
|
245
|
+
if (state.stage === 'quoted') {
|
|
246
|
+
let verification = await fetchInvoiceVerification(quoted.quote.verify);
|
|
247
|
+
if (!verification.settled) {
|
|
248
|
+
let payerError;
|
|
249
|
+
try {
|
|
250
|
+
// Stdout is deliberately outside this interface. Exit status says
|
|
251
|
+
// whether the command believes it paid; /verify supplies the proof.
|
|
252
|
+
await options.payInvoice(quoted.quote.pr);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
payerError = error;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
verification = await waitForSettled(quoted.quote.verify, timeoutMs, pollMs);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
if (payerError instanceof Error) {
|
|
262
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} Payer command: ${payerError.message}`);
|
|
263
|
+
}
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const receipt = validateBoundMintReceipt(quoted.quote, verification, state.h, quoted.netMsat, quoted.mintPubkey);
|
|
268
|
+
const paymentHash = decodeBolt11(quoted.quote.pr).paymentHashHex;
|
|
269
|
+
if (!verification.preimage || hashK1(verification.preimage) !== paymentHash) {
|
|
270
|
+
throw new Error('The settlement preimage does not prove the quoted invoice.');
|
|
271
|
+
}
|
|
272
|
+
if (!receipt.signature)
|
|
273
|
+
throw new Error('The settled receipt has no signature.');
|
|
274
|
+
state = { ...state, stage: 'settled' };
|
|
275
|
+
await replaceState(options.statePath, state);
|
|
276
|
+
log('settlement preimage and bound receipt signature validated');
|
|
277
|
+
}
|
|
278
|
+
if (state.stage === 'settled') {
|
|
279
|
+
const claim = await claimMintedNote(quoted.withdrawLink, quoted.secret);
|
|
280
|
+
if (claim.state !== 'minted' || claim.amountMsat !== quoted.netMsat || !claim.callback) {
|
|
281
|
+
throw new Error('The staged secret did not claim the committed note.');
|
|
282
|
+
}
|
|
283
|
+
state = { ...state, stage: 'claimed', noteCallback: claim.callback };
|
|
284
|
+
await replaceState(options.statePath, state);
|
|
285
|
+
log('the staged secret claimed the committed note');
|
|
286
|
+
}
|
|
287
|
+
if (state.stage === 'claimed') {
|
|
288
|
+
if (!state.noteCallback)
|
|
289
|
+
throw new Error('Claimed live-check state has no note callback.');
|
|
290
|
+
const refundOutput = await options.createRefundInvoice();
|
|
291
|
+
const refundPr = extractBolt11(refundOutput);
|
|
292
|
+
if (!refundPr)
|
|
293
|
+
throw new Error('Refund command did not emit a BOLT11 invoice.');
|
|
294
|
+
if (decodeBolt11AmountMsat(refundPr) !== null) {
|
|
295
|
+
throw new Error('Refund invoice must be amountless so the mint retires the entire test note.');
|
|
296
|
+
}
|
|
297
|
+
const refundPaymentHash = decodeBolt11(refundPr).paymentHashHex;
|
|
298
|
+
const meltVerify = new URL(`/verify/${refundPaymentHash}`, quoted.withdrawLink).toString();
|
|
299
|
+
// Persist the exact refund invoice before asking the mint to pay it.
|
|
300
|
+
// A crash after the callback can therefore resume without inventing a
|
|
301
|
+
// second payment target or losing the note secret.
|
|
302
|
+
state = { ...state, stage: 'retiring', refundPr, refundPaymentHash, meltVerify };
|
|
303
|
+
await replaceState(options.statePath, state);
|
|
304
|
+
}
|
|
305
|
+
if (state.stage === 'retiring') {
|
|
306
|
+
if (!state.noteCallback || !state.refundPr || !state.meltVerify) {
|
|
307
|
+
throw new Error('Retiring live-check state is incomplete.');
|
|
308
|
+
}
|
|
309
|
+
let verification = await existingVerification(state.meltVerify);
|
|
310
|
+
if (verification === null) {
|
|
311
|
+
try {
|
|
312
|
+
const result = await meltNote(state.noteCallback, quoted.secret, state.refundPr);
|
|
313
|
+
if (result.verify && result.verify !== state.meltVerify) {
|
|
314
|
+
throw new Error('Mint returned a different verification URL for the refund melt.');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
if (!(error instanceof PendingNoteError) && !(error instanceof NoteSpentError))
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
verification = await existingVerification(state.meltVerify);
|
|
322
|
+
}
|
|
323
|
+
if (!verification?.settled) {
|
|
324
|
+
try {
|
|
325
|
+
verification = await waitForSettled(state.meltVerify, timeoutMs, pollMs);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
// A cleanly failed melt restores the note. Clear the used refund
|
|
329
|
+
// invoice but retain the secret, so the same command can retry with
|
|
330
|
+
// a fresh amountless invoice rather than stranding value.
|
|
331
|
+
const claim = await claimMintedNote(quoted.withdrawLink, quoted.secret).catch(() => null);
|
|
332
|
+
if (claim?.state === 'minted' && claim.callback) {
|
|
333
|
+
state = {
|
|
334
|
+
version: 1,
|
|
335
|
+
stage: 'claimed',
|
|
336
|
+
payUrl: state.payUrl,
|
|
337
|
+
grossMsat: state.grossMsat,
|
|
338
|
+
h: state.h,
|
|
339
|
+
secret: quoted.secret,
|
|
340
|
+
netMsat: quoted.netMsat,
|
|
341
|
+
mintPubkey: quoted.mintPubkey,
|
|
342
|
+
payCallback: quoted.payCallback,
|
|
343
|
+
withdrawLink: quoted.withdrawLink,
|
|
344
|
+
quote: quoted.quote,
|
|
345
|
+
noteCallback: claim.callback
|
|
346
|
+
};
|
|
347
|
+
await replaceState(options.statePath, state);
|
|
348
|
+
throw new Error(`Refund melt failed cleanly and the note was restored; rerun to use a fresh invoice. ${error instanceof Error ? error.message : ''}`);
|
|
349
|
+
}
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const noteUrl = buildNoteUrl(quoted.withdrawLink, quoted.secret, quoted.netMsat);
|
|
354
|
+
const deadline = Date.now() + timeoutMs;
|
|
355
|
+
while ((await probeBurnedNote(noteUrl)) !== 'gone') {
|
|
356
|
+
if (Date.now() > deadline)
|
|
357
|
+
throw new Error('Refund settled but the test note is not yet recorded as burned.');
|
|
358
|
+
await sleep(pollMs);
|
|
359
|
+
}
|
|
360
|
+
const completedAt = new Date().toISOString();
|
|
361
|
+
state = {
|
|
362
|
+
version: 1,
|
|
363
|
+
stage: 'retired',
|
|
364
|
+
payUrl: state.payUrl,
|
|
365
|
+
grossMsat: state.grossMsat,
|
|
366
|
+
netMsat: quoted.netMsat,
|
|
367
|
+
h: state.h,
|
|
368
|
+
mintPubkey: quoted.mintPubkey,
|
|
369
|
+
paymentPreimageValidated: true,
|
|
370
|
+
receiptSignatureValidated: true,
|
|
371
|
+
refundSettled: true,
|
|
372
|
+
completedAt
|
|
373
|
+
};
|
|
374
|
+
await replaceState(options.statePath, state);
|
|
375
|
+
log('refund settled and the test note was burned; bearer secret scrubbed from state');
|
|
376
|
+
}
|
|
377
|
+
return retiredResult(state);
|
|
378
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/moneyer",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
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",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"files": [
|
|
33
33
|
"dist",
|
|
34
34
|
"web/dist",
|
|
35
|
+
"scripts/live-bound-mint-check.mjs",
|
|
35
36
|
"LICENSE",
|
|
36
37
|
"README.md",
|
|
37
38
|
"CHANGELOG.md",
|
|
@@ -51,7 +52,8 @@
|
|
|
51
52
|
"web:dev": "vite web",
|
|
52
53
|
"web:build": "vite build web",
|
|
53
54
|
"web:preview": "vite preview web",
|
|
54
|
-
"typecheck:web": "tsc -p web"
|
|
55
|
+
"typecheck:web": "tsc -p web",
|
|
56
|
+
"live:bound-mint": "npm run build && node scripts/live-bound-mint-check.mjs"
|
|
55
57
|
},
|
|
56
58
|
"dependencies": {
|
|
57
59
|
"@noble/curves": "^2.3.0",
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A resumable, real-sats bound-mint release check.
|
|
3
|
+
//
|
|
4
|
+
// The payer command receives the mint invoice as its final argument. Its
|
|
5
|
+
// stdout is intentionally ignored: lnd has emitted JSON and tables across
|
|
6
|
+
// versions, while Moneyer's /verify response is the settlement proof that
|
|
7
|
+
// matters. The refund command receives no added arguments and must print an
|
|
8
|
+
// amountless BOLT11 invoice; Moneyer melts the whole test note back to it.
|
|
9
|
+
import {spawnSync} from 'node:child_process'
|
|
10
|
+
import {resolve} from 'node:path'
|
|
11
|
+
import {runLiveBoundMintCheck} from '../dist/live-check.js'
|
|
12
|
+
|
|
13
|
+
const usage = `usage:
|
|
14
|
+
npm run live:bound-mint -- \\
|
|
15
|
+
--pay-url https://mint.example/.well-known/lnurlp/mint \\
|
|
16
|
+
--amount-sat 56 \\
|
|
17
|
+
--state /secure/path/moneyer-live-check.json \\
|
|
18
|
+
--payer <command...> \\
|
|
19
|
+
--refund <command...>
|
|
20
|
+
|
|
21
|
+
The payer command gets the BOLT11 invoice as its final argument. The refund
|
|
22
|
+
command must emit a fresh amountless BOLT11 invoice on stdout. State is mode
|
|
23
|
+
0600 and resumable; rerun the exact command after any interruption.`
|
|
24
|
+
|
|
25
|
+
const failUsage = message => {
|
|
26
|
+
if (message) console.error(message)
|
|
27
|
+
console.error(usage)
|
|
28
|
+
process.exit(2)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const args = process.argv.slice(2)
|
|
32
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
33
|
+
console.log(usage)
|
|
34
|
+
process.exit(0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const payerAt = args.indexOf('--payer')
|
|
38
|
+
const refundAt = args.indexOf('--refund')
|
|
39
|
+
if (payerAt < 0 || refundAt < 0 || refundAt <= payerAt) failUsage('Both --payer and --refund commands are required.')
|
|
40
|
+
|
|
41
|
+
const optionArgs = args.slice(0, payerAt)
|
|
42
|
+
const payerArgv = args.slice(payerAt + 1, refundAt)
|
|
43
|
+
const refundArgv = args.slice(refundAt + 1)
|
|
44
|
+
if (payerArgv.length === 0 || refundArgv.length === 0) failUsage('Command markers may not be empty.')
|
|
45
|
+
|
|
46
|
+
const values = new Map()
|
|
47
|
+
for (let index = 0; index < optionArgs.length; index += 2) {
|
|
48
|
+
const name = optionArgs[index]
|
|
49
|
+
const value = optionArgs[index + 1]
|
|
50
|
+
if (!name?.startsWith('--') || value === undefined) failUsage(`Invalid option near ${name ?? '(end)'}.`)
|
|
51
|
+
if (!['--pay-url', '--amount-sat', '--state', '--timeout-seconds'].includes(name)) failUsage(`Unknown option ${name}.`)
|
|
52
|
+
values.set(name, value)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const payUrl = values.get('--pay-url')
|
|
56
|
+
const amountSat = Number(values.get('--amount-sat'))
|
|
57
|
+
const stateValue = values.get('--state')
|
|
58
|
+
const timeoutSeconds = values.has('--timeout-seconds') ? Number(values.get('--timeout-seconds')) : 60
|
|
59
|
+
if (!payUrl || !stateValue) failUsage('--pay-url, --amount-sat and --state are required.')
|
|
60
|
+
if (!Number.isSafeInteger(amountSat) || amountSat <= 0) failUsage('--amount-sat must be a positive whole number.')
|
|
61
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) failUsage('--timeout-seconds must be positive.')
|
|
62
|
+
|
|
63
|
+
const runCommand = (argv, appended = []) => {
|
|
64
|
+
const [program, ...commandArgs] = argv
|
|
65
|
+
const result = spawnSync(program, [...commandArgs, ...appended], {
|
|
66
|
+
encoding: 'utf8',
|
|
67
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
68
|
+
timeout: timeoutSeconds * 1000
|
|
69
|
+
})
|
|
70
|
+
if (result.error) throw result.error
|
|
71
|
+
if (result.status !== 0) {
|
|
72
|
+
const detail = result.stderr.trim().slice(0, 500)
|
|
73
|
+
throw new Error(`command exited ${result.status}${detail ? `: ${detail}` : ''}`)
|
|
74
|
+
}
|
|
75
|
+
return result.stdout
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const statePath = resolve(stateValue)
|
|
79
|
+
try {
|
|
80
|
+
const result = await runLiveBoundMintCheck({
|
|
81
|
+
payUrl,
|
|
82
|
+
grossMsat: amountSat * 1000,
|
|
83
|
+
statePath,
|
|
84
|
+
timeoutMs: timeoutSeconds * 1000,
|
|
85
|
+
payInvoice: async pr => {
|
|
86
|
+
// Deliberately do not parse or print this output. Settlement is proved
|
|
87
|
+
// independently by the invoice preimage and signed mint receipt.
|
|
88
|
+
runCommand(payerArgv, [pr])
|
|
89
|
+
},
|
|
90
|
+
createRefundInvoice: async () => runCommand(refundArgv),
|
|
91
|
+
log: message => console.error(`[live-check] ${message}`)
|
|
92
|
+
})
|
|
93
|
+
console.log(JSON.stringify(result, null, 2))
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error(`[live-check] ${error instanceof Error ? error.message : String(error)}`)
|
|
96
|
+
console.error(`[live-check] state retained at ${statePath}; rerun the exact command to resume`)
|
|
97
|
+
process.exitCode = 1
|
|
98
|
+
}
|