@forgesworn/moneyer 0.6.0 → 0.7.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 +90 -0
- package/dist/backends/lnd.js +20 -5
- package/dist/live-check.d.ts +48 -0
- package/dist/live-check.js +378 -0
- package/dist/server.js +50 -8
- package/dist/store.d.ts +2 -0
- package/dist/store.js +29 -4
- package/package.json +4 -2
- package/scripts/live-bound-mint-check.mjs +98 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,95 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.0] - 2026-08-26
|
|
4
|
+
|
|
5
|
+
- **The published node capacity is the announced one.** `nodeCapacity` in
|
|
6
|
+
the discovery document was summed from lnd's `/v1/channels`, which is an
|
|
7
|
+
authenticated view of the node and counts private channels. That figure is
|
|
8
|
+
served to every visitor and goes out in the mint's announcement, so a
|
|
9
|
+
channel the operator chose not to announce was being sized in public
|
|
10
|
+
anyway. It now comes from this node's own entry in the public graph -
|
|
11
|
+
`total_capacity`, the same number any stranger on the network already
|
|
12
|
+
reads, converted from sats. A node with nothing announced reports zero
|
|
13
|
+
rather than omitting the field, because zero is the true answer there;
|
|
14
|
+
only a node that cannot be asked leaves it off. cln never reported
|
|
15
|
+
capacity and is unaffected.
|
|
16
|
+
|
|
17
|
+
Operators should expect the number to fall, and to fall to zero on a mint
|
|
18
|
+
running entirely on private channels. It was never the figure it claimed
|
|
19
|
+
to be.
|
|
20
|
+
|
|
21
|
+
- The web wallet prints a note to a PNG you can send. The plate existed only
|
|
22
|
+
as HTML over the artwork, which is no use in a message; the same portrait
|
|
23
|
+
plate composites onto a canvas and comes back as a file - the share sheet
|
|
24
|
+
where the browser offers one, a download everywhere else. The travelling
|
|
25
|
+
plate carries the bech32 LNURL rather than the claim link, because a note
|
|
26
|
+
that leaves in a message is scanned by whatever the recipient already has,
|
|
27
|
+
and that is usually a Lightning wallet.
|
|
28
|
+
|
|
29
|
+
- The bundled web wallet names its note on the plain path too, not only when
|
|
30
|
+
a signed receipt is on offer. It previously fell back to an unnamed mint
|
|
31
|
+
and then required `verify` to read the preimage - which, with the rule
|
|
32
|
+
above, a signer-less mint no longer answers. Naming needs no receipt, and
|
|
33
|
+
the page then claims the secret it chose rather than one the mint
|
|
34
|
+
publishes.
|
|
35
|
+
|
|
36
|
+
- **No LUD-21 `verify` on a mint payment that named no output.** LUD-25
|
|
37
|
+
forbids it, and the reason is concrete: on that path the note's `k1` IS the
|
|
38
|
+
payment preimage, and `verify` publishes the preimage at a URL anyone who
|
|
39
|
+
has seen the invoice can build from its payment hash. The note was only as
|
|
40
|
+
private as the QR it was paid from.
|
|
41
|
+
|
|
42
|
+
Both halves are needed. New unnamed invoices get no `verify` field, and
|
|
43
|
+
`/verify/<hash>` refuses them outright - not advertising a URL does not
|
|
44
|
+
stop anyone constructing it. A payer on this path still learns the preimage
|
|
45
|
+
the way any Lightning wallet does, by paying.
|
|
46
|
+
|
|
47
|
+
**Invoices quoted before this mint adopted the rule keep their `verify`.**
|
|
48
|
+
A wallet polling one of those did not pay the invoice itself - that is why
|
|
49
|
+
it is polling - so the preimage this mint holds is its only route to a note
|
|
50
|
+
it already owns. Refusing them would not close a hole, it would burn
|
|
51
|
+
somebody's money. The cutover is written to a new `meta` table the first
|
|
52
|
+
time a build carrying this code opens the database, and never moves after;
|
|
53
|
+
persisted rather than taken from process start, or a restart would walk the
|
|
54
|
+
line forward and strand a quote made minutes earlier under the same build.
|
|
55
|
+
Old rows drain and the hole closes for everything new.
|
|
56
|
+
|
|
57
|
+
- The mint accepts a LUD-12 `comment` carrying `hex(h)` as the name of the
|
|
58
|
+
note being bought, and advertises `commentAllowed: 64` on the payRequest.
|
|
59
|
+
This is how LUD-25 specifies it; `h` was this mint's own earlier spelling
|
|
60
|
+
and both are now honoured, so wallets on either keep working. A wallet
|
|
61
|
+
sending both must agree with itself - minting under one when the other is
|
|
62
|
+
being watched for would lose the note.
|
|
63
|
+
|
|
64
|
+
The two are deliberately not validated alike. Per LUD-25, a `comment` that
|
|
65
|
+
is not a bare 32-byte hex hash falls back to keying the note by the payment
|
|
66
|
+
preimage, exactly as no comment does, because a comment is free text in
|
|
67
|
+
LUD-12 and failing on every stray one would break ordinary payers. A
|
|
68
|
+
malformed `h` still fails loudly: that is a wallet that meant to name an
|
|
69
|
+
output and got it wrong.
|
|
70
|
+
|
|
71
|
+
This matters beyond conformance. A note minted with no named output has the
|
|
72
|
+
payment preimage as its spend secret, and until this release the mint
|
|
73
|
+
served that preimage on its LUD-21 `verify` URL, which anyone holding the
|
|
74
|
+
invoice can construct. Naming the output is what makes `verify` safe to
|
|
75
|
+
offer, and the entry above now gates it on exactly that.
|
|
76
|
+
|
|
77
|
+
## [0.6.1] - 2026-08-24
|
|
78
|
+
|
|
79
|
+
- The bundled web wallet now accepts a bound mint quote anywhere inside
|
|
80
|
+
the mint's advertised fee band, then uses the committed amount for the
|
|
81
|
+
signed receipt and note. A mint that rounds its fee up to a whole sat -
|
|
82
|
+
including moneyer's production default - no longer makes the page
|
|
83
|
+
silently abandon the sealed-signer receipt path for a legacy invoice.
|
|
84
|
+
Browser coverage uses the production `5000 msat + 1000 ppm`, sat-rounded
|
|
85
|
+
policy and proves the staged quote survives through settlement.
|
|
86
|
+
- A resumable real-node bound-mint release check persists its bearer secret
|
|
87
|
+
with mode `0600` before requesting a quote, treats payer command output as
|
|
88
|
+
opaque, validates the settlement preimage and signed receipt, then melts
|
|
89
|
+
the whole test note to a fresh amountless refund invoice. Interrupted runs
|
|
90
|
+
resume from the same state file; successful runs scrub the secret and leave
|
|
91
|
+
no test-note liability behind.
|
|
92
|
+
|
|
3
93
|
## [0.6.0] - 2026-08-24
|
|
4
94
|
|
|
5
95
|
- **Bound mint settlement receipts.** A pay callback asked to mint at a
|
package/dist/backends/lnd.js
CHANGED
|
@@ -183,12 +183,27 @@ export const createLndBackend = (config) => {
|
|
|
183
183
|
const color = typeof res.json?.color === 'string' ? `#${res.json.color.replace(/^#/, '')}` : undefined;
|
|
184
184
|
const numChannels = Number(res.json?.num_active_channels);
|
|
185
185
|
const numPeers = Number(res.json?.num_peers);
|
|
186
|
-
//
|
|
187
|
-
//
|
|
186
|
+
// Announced capacity, read from this node's own entry in the public
|
|
187
|
+
// graph rather than from its channel list. `/v1/channels` is an
|
|
188
|
+
// authenticated view and counts private channels too; this figure is
|
|
189
|
+
// published in the discovery document, so summing that would tell the
|
|
190
|
+
// world what only the operator can see. The graph self-lookup returns
|
|
191
|
+
// the same `total_capacity` any stranger on the network already
|
|
192
|
+
// reads, in sats, so it is converted here to keep NodeInfo msat.
|
|
193
|
+
//
|
|
194
|
+
// Best-effort, in two flavours: a 404 is a node with nothing
|
|
195
|
+
// announced, which is a public capacity of zero rather than an
|
|
196
|
+
// unknown one, while any other failure leaves the field off - the
|
|
197
|
+
// macaroon may not carry info:read, and the discovery endpoint works
|
|
198
|
+
// fine without it.
|
|
188
199
|
let capacityMsat;
|
|
189
|
-
const
|
|
190
|
-
if (
|
|
191
|
-
|
|
200
|
+
const pubkey = res.json?.identity_pubkey;
|
|
201
|
+
if (typeof pubkey === 'string' && pubkey) {
|
|
202
|
+
const node = await json(`/v1/graph/node/${pubkey}`);
|
|
203
|
+
if (node.ok)
|
|
204
|
+
capacityMsat = Number(node.json?.total_capacity ?? 0) * 1000;
|
|
205
|
+
else if (node.status === 404)
|
|
206
|
+
capacityMsat = 0;
|
|
192
207
|
}
|
|
193
208
|
// Outbound liquidity, best-effort for the same reason as capacity:
|
|
194
209
|
// the macaroon may not carry offchain:read.
|
|
@@ -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/dist/server.js
CHANGED
|
@@ -551,6 +551,12 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
551
551
|
// lightning address never reads that document, and this has to be
|
|
552
552
|
// known BEFORE paying, not after.
|
|
553
553
|
mintToHash: true,
|
|
554
|
+
// The same capability in the spelling LUD-25 specifies: the output
|
|
555
|
+
// hash rides in a LUD-12 comment, so 64 characters is exactly what
|
|
556
|
+
// a hex-encoded 32-byte hash needs. Advertised alongside
|
|
557
|
+
// `mintToHash` rather than instead of it, so wallets on either
|
|
558
|
+
// spelling can name a note.
|
|
559
|
+
commentAllowed: 64,
|
|
554
560
|
disposable: false
|
|
555
561
|
});
|
|
556
562
|
}
|
|
@@ -596,13 +602,33 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
596
602
|
// collision gets the same reason a colliding output gets on the
|
|
597
603
|
// withdraw callback: which table an id already sits in is an oracle
|
|
598
604
|
// nobody is owed.
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
605
|
+
// Two spellings of one thing. LUD-25 puts the output hash in a LUD-12
|
|
606
|
+
// `comment`; `h` is this mint's own earlier name for it, kept so the
|
|
607
|
+
// wallets that adopted it keep working.
|
|
608
|
+
//
|
|
609
|
+
// They are NOT validated the same way, and that asymmetry is the
|
|
610
|
+
// spec's. A malformed `comment` MUST fall back to keying the note by
|
|
611
|
+
// the preimage, exactly as no comment at all does - a comment is a
|
|
612
|
+
// free-text field in LUD-12 and a mint cannot treat every stray one
|
|
613
|
+
// as a failed mint. A malformed `h` is a wallet that meant to name an
|
|
614
|
+
// output and got it wrong, so it still fails loudly rather than
|
|
615
|
+
// quietly minting a note the wallet is not expecting.
|
|
616
|
+
const askedComment = q.get('comment')?.trim().toLowerCase() ?? null;
|
|
617
|
+
const commentOutputId = askedComment !== null && HEX32.test(askedComment) ? askedComment : null;
|
|
618
|
+
const askedOutputId = q.get('h')?.trim().toLowerCase() ?? null;
|
|
619
|
+
if (askedOutputId !== null && !HEX32.test(askedOutputId))
|
|
620
|
+
return fail('missing h');
|
|
621
|
+
// A wallet sending both should send the same hash in both; ours does.
|
|
622
|
+
// Disagreement is a bug in the caller, and picking a winner would
|
|
623
|
+
// mint a note under a hash one half of it is not watching for.
|
|
624
|
+
if (commentOutputId !== null &&
|
|
625
|
+
askedOutputId !== null &&
|
|
626
|
+
commentOutputId !== askedOutputId) {
|
|
627
|
+
return fail('comment and h name different outputs');
|
|
628
|
+
}
|
|
629
|
+
const outputId = commentOutputId ?? askedOutputId;
|
|
630
|
+
if (outputId !== null && store.outputIdInUse(outputId)) {
|
|
631
|
+
return fail('Invalid or already spent k1.');
|
|
606
632
|
}
|
|
607
633
|
// The preimage is the future note's spend secret unless `h` named
|
|
608
634
|
// one; its hash is the invoice's payment hash either way. Generated
|
|
@@ -660,7 +686,15 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
660
686
|
...(outputId !== null && config.verify && signer
|
|
661
687
|
? { mint: { h: outputId, amount: net } }
|
|
662
688
|
: {}),
|
|
663
|
-
|
|
689
|
+
// LUD-25: a SERVICE MUST NOT offer verify on a mint payment that
|
|
690
|
+
// named no output. There the note's k1 IS the preimage, and verify
|
|
691
|
+
// hands it to whoever holds the URL - which anyone who has seen the
|
|
692
|
+
// invoice can build from its payment hash. A wallet on this path
|
|
693
|
+
// learns the preimage from paying the invoice, the way any Lightning
|
|
694
|
+
// wallet already keeps it.
|
|
695
|
+
...(config.verify && outputId !== null
|
|
696
|
+
? { verify: `${origin}/verify/${paymentHash}` }
|
|
697
|
+
: {})
|
|
664
698
|
});
|
|
665
699
|
}
|
|
666
700
|
// ---- LUD-21 verify: mint invoices and melt payments ----
|
|
@@ -671,6 +705,14 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
671
705
|
const paymentHash = verifyMatch[1].toLowerCase();
|
|
672
706
|
const invoice = store.mintInvoiceByHash(paymentHash);
|
|
673
707
|
if (invoice) {
|
|
708
|
+
// The other half of the rule above, and the half that matters: not
|
|
709
|
+
// advertising the URL does not stop anyone building it. Refused for
|
|
710
|
+
// an unnamed invoice quoted since this mint adopted the rule, and
|
|
711
|
+
// still honoured for one quoted before it, whose payer has no other
|
|
712
|
+
// way to reach a note they already own.
|
|
713
|
+
if (invoice.outputId === null && invoice.createdAt >= store.unnamedVerifyCutover()) {
|
|
714
|
+
return fail('Not found.', 404);
|
|
715
|
+
}
|
|
674
716
|
if (!invoice.settled && (await backend.isInvoiceSettled(paymentHash))) {
|
|
675
717
|
store.settleMintInvoice(paymentHash);
|
|
676
718
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type MintInvoiceRow = {
|
|
|
11
11
|
netMsat: number;
|
|
12
12
|
settled: boolean;
|
|
13
13
|
outputId: string | null;
|
|
14
|
+
createdAt: number;
|
|
14
15
|
};
|
|
15
16
|
export type MeltRow = {
|
|
16
17
|
paymentHash: string;
|
|
@@ -83,6 +84,7 @@ export declare class NoteStore {
|
|
|
83
84
|
constructor(path: string, options?: {
|
|
84
85
|
readOnly?: boolean;
|
|
85
86
|
});
|
|
87
|
+
unnamedVerifyCutover(): number;
|
|
86
88
|
private tx;
|
|
87
89
|
noteById(id: string): NoteRow | null;
|
|
88
90
|
private insertNote;
|
package/dist/store.js
CHANGED
|
@@ -117,6 +117,29 @@ export class NoteStore {
|
|
|
117
117
|
// Two invoices may not name the same note. SQLite counts NULLs as
|
|
118
118
|
// distinct, so every unbound invoice still fits.
|
|
119
119
|
this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS mint_invoices_output_id ON mint_invoices (output_id)');
|
|
120
|
+
this.db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);');
|
|
121
|
+
// The moment this mint stopped publishing preimages for notes nobody
|
|
122
|
+
// named. Written once, the first time a build carrying this code opens
|
|
123
|
+
// the database, and never moved after.
|
|
124
|
+
//
|
|
125
|
+
// It has to be persisted rather than taken from process start, or a
|
|
126
|
+
// restart would walk the line forward and strand a quote made minutes
|
|
127
|
+
// earlier under the same build. Invoices older than it keep their
|
|
128
|
+
// verify: a wallet polling one of those did not pay the invoice itself
|
|
129
|
+
// - that is why it is polling - so the preimage this mint holds is its
|
|
130
|
+
// only route to a note it already owns. Refusing those retroactively
|
|
131
|
+
// would not close a hole, it would burn somebody's money. They drain.
|
|
132
|
+
this.db
|
|
133
|
+
.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES ('unnamed_verify_cutover', ?)")
|
|
134
|
+
.run(String(Date.now()));
|
|
135
|
+
}
|
|
136
|
+
// Invoices quoted at or after this instant get no verify if they named no
|
|
137
|
+
// output. See the migration above for why it is not simply "now".
|
|
138
|
+
unnamedVerifyCutover() {
|
|
139
|
+
const row = this.db
|
|
140
|
+
.prepare("SELECT value FROM meta WHERE key = 'unnamed_verify_cutover'")
|
|
141
|
+
.get();
|
|
142
|
+
return row ? Number(row.value) : 0;
|
|
120
143
|
}
|
|
121
144
|
tx(fn) {
|
|
122
145
|
this.db.exec('BEGIN IMMEDIATE');
|
|
@@ -294,7 +317,7 @@ export class NoteStore {
|
|
|
294
317
|
}
|
|
295
318
|
mintInvoiceByHash(paymentHash) {
|
|
296
319
|
const row = this.db
|
|
297
|
-
.prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id FROM mint_invoices WHERE payment_hash = ?')
|
|
320
|
+
.prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id, created_at FROM mint_invoices WHERE payment_hash = ?')
|
|
298
321
|
.get(paymentHash);
|
|
299
322
|
if (!row)
|
|
300
323
|
return null;
|
|
@@ -304,7 +327,8 @@ export class NoteStore {
|
|
|
304
327
|
grossMsat: row.gross_msat,
|
|
305
328
|
netMsat: row.net_msat,
|
|
306
329
|
settled: row.settled === 1,
|
|
307
|
-
outputId: row.output_id
|
|
330
|
+
outputId: row.output_id,
|
|
331
|
+
createdAt: row.created_at
|
|
308
332
|
};
|
|
309
333
|
}
|
|
310
334
|
// The invoice a payer bound to this note id, if any. The lookup a claim
|
|
@@ -320,7 +344,7 @@ export class NoteStore {
|
|
|
320
344
|
// expired invoices - so it is dead weight, and every /p/cb call adds one.
|
|
321
345
|
unsettledMintInvoices() {
|
|
322
346
|
const rows = this.db
|
|
323
|
-
.prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id FROM mint_invoices WHERE settled = 0')
|
|
347
|
+
.prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id, created_at FROM mint_invoices WHERE settled = 0')
|
|
324
348
|
.all();
|
|
325
349
|
return rows.map(row => ({
|
|
326
350
|
paymentHash: row.payment_hash,
|
|
@@ -328,7 +352,8 @@ export class NoteStore {
|
|
|
328
352
|
grossMsat: row.gross_msat,
|
|
329
353
|
netMsat: row.net_msat,
|
|
330
354
|
settled: false,
|
|
331
|
-
outputId: row.output_id
|
|
355
|
+
outputId: row.output_id,
|
|
356
|
+
createdAt: row.created_at
|
|
332
357
|
}));
|
|
333
358
|
}
|
|
334
359
|
// Conditional on STILL unsettled: a settle landing between the sweep's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/moneyer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -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
|
+
}
|