@forgesworn/moneyer 0.5.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 CHANGED
@@ -1,5 +1,83 @@
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
+
19
+ ## [0.6.0] - 2026-08-24
20
+
21
+ - **Bound mint settlement receipts.** A pay callback asked to mint at a
22
+ wallet-chosen `h` now commits the quote to `mint: {h, amount}` whenever
23
+ signing and LUD-21 verification are available. Before settlement the
24
+ response is unsigned; after settlement `/verify` repeats the commitment
25
+ and adds the ordinary LUD-25 note `sig`. This lets a sealed signer promote
26
+ a staged output without exporting its secret. No-`h`, unsigned and
27
+ verify-disabled deployments retain their previous response shapes.
28
+ - The payRequest publishes `mintPubkey` when note signing is enabled, so a
29
+ receipt-aware wallet can establish the verification key before paying.
30
+ - The bundled web client stages a wallet-chosen secret before requesting the
31
+ invoice, validates the quote and settled receipt, and falls back before
32
+ payment to the existing preimage-import-and-rotate flow when the optional
33
+ receipt is unavailable.
34
+
35
+ - **A standing proof-of-concept disclaimer.** moneyer is an evaluation
36
+ implementation of a draft spec, its funding backends are beta, and a
37
+ note is bearer value with no recovery - so the site now says so at the
38
+ top, before anyone pays, rather than leaving it to a threat model
39
+ nobody opens. A prominent banner on the home page and the mint flow,
40
+ and on the fallback landing page: "Proof of concept · for developers …
41
+ assume you can lose anything you put in", with a link to the operator's
42
+ terms when `tosUrl` is set. Always on, because it is true of every
43
+ moneyer deployment as the spec stands. The per-step `warn` notes and
44
+ the operator MOTD are unchanged and sit alongside it.
45
+ - **The fee says its own units.** `Mint fees: 5000,1000` is the shape
46
+ LUD-25 wallets parse - lnurlcash-kit matches it anchored and
47
+ digits-only, so it cannot carry units - and testers were reading both
48
+ numbers as satoshis. On a 10k sat mint that reads as a 5000 sat fee,
49
+ which is alarming and false. The prose beside it now spells the pair
50
+ out: `fee 5 sat + 0.1% (5000 msat + 1000 ppm), rounded up to the sat`.
51
+ The wire format is untouched.
52
+ - The fee prose moves to `src/fee-words.ts`, the way `privacy.ts` already
53
+ holds the privacy statement, so the payRequest metadata and the
54
+ landing page cannot drift apart on it. The landing page had grown its
55
+ own second version reading `5000 msat flat + 0.1%`; it now uses the
56
+ shared one. `describeFee` is still exported from `server.ts`.
57
+ - **Operator guidance on the custodial position.** `DEPLOY.md` gains a
58
+ "Before you run this" section: the regulatory perimeter follows the
59
+ operator rather than the code, how to configure an evaluation mint so
60
+ it reads as one, why **not** to zero the fee to make that point (a
61
+ fee-free mint pays the melt routing floor out of its own balance and is
62
+ griefable by mint-and-melt cycling - see `THREAT-MODEL.md`), and the
63
+ no-fiat invariant. moneyer is millisatoshis throughout, and that is
64
+ load-bearing rather than incidental.
65
+ - `TERMS-TEMPLATE.md`, a starting point for `MONEYER_TOS_URL`. It defers
66
+ to the mint's own "what the mint knows" statement rather than restating
67
+ it, for the same no-drift reason.
68
+ - **The regulatory guidance grows its missing teeth.** `DEPLOY.md`'s
69
+ "Before you run this" covered the payments perimeter and stopped there;
70
+ it now walks the UK regimes that actually bear on a custodial mint:
71
+ FCA registration under the Money Laundering Regulations 2017 (custody
72
+ is the live limb, and the travel rule the structural one - an anonymous
73
+ bearer instrument cannot carry originator and beneficiary information),
74
+ the financial promotion rules that gate marketing for an unregistered
75
+ operator (`MONEYER_ANNOUNCE` and the site copy both), sanctions, which
76
+ bind regardless of classification, and the cryptoasset custody regime
77
+ the 2025 draft legislation is bringing. `TERMS-TEMPLATE.md` gains the
78
+ liability floor consumer law insists on, a sanctions prohibited-use
79
+ clause, and a privacy notice in outline.
80
+
3
81
  ## [0.5.0] - 2026-08-23
4
82
 
5
83
  - **Both doors, named.** With `MONEYER_ONION_URL` and
package/README.md CHANGED
@@ -66,6 +66,19 @@ node dist/cli.js
66
66
  The mint is then payable at `mint@mint.example` (and the bare-domain `_`
67
67
  alias).
68
68
 
69
+ **Before you point one at mainnet**, read [Before you run this](DEPLOY.md)
70
+ in the deployment guide. A mint holds other people's bitcoin as bearer
71
+ liabilities, and it is the custody rather than the code that a regulator
72
+ has a view on. In the UK that view is specific: the Money Laundering
73
+ Regulations 2017 gate the custody of cryptoassets, by way of business, on
74
+ FCA registration; the financial promotion rules gate how a mint may be
75
+ marketed; sanctions law binds regardless. DEPLOY.md walks through all
76
+ three. Two things carry most of the weight on the payments side: an
77
+ evaluation mint should be configured as one (a fee kept at or below cost recovery rather
78
+ than zeroed, no priced names, a small cap, a MOTD that says so - or
79
+ better, a test network), and **no mint should ever touch fiat**. moneyer is millisatoshis throughout, deliberately, and that
80
+ boundary is what keeps a mint clear of the payments and e-money regimes.
81
+
69
82
  ## Configuration
70
83
 
71
84
  Environment only. Every variable is `MONEYER_*`; anything unset takes the
@@ -327,7 +340,9 @@ is nearly holding the money.
327
340
 
328
341
  `h` is optional and additive. A wallet that sends none gets exactly the
329
342
  behaviour it always got, so upgrading this mint breaks nothing that works
330
- today, and the LUD-25 draft needs no change to allow it.
343
+ today. The wire fields are an implementation proposal for eventual LUD-25
344
+ adoption; current wallets and dni/reference mints keep using the original
345
+ preimage-and-rotate flow unchanged.
331
346
 
332
347
  The rules:
333
348
 
@@ -348,6 +363,13 @@ The rules:
348
363
  - The payRequest and the discovery document both advertise `mintToHash:
349
364
  true`, so a wallet knows this mint takes the parameter before it asks
350
365
  rather than after it pays.
366
+ - When signing and LUD-21 verification are enabled, the quote also carries
367
+ `mint: {h, amount}`. This is the exact output and net millisatoshi value
368
+ the invoice will mint; it never carries `sig` before settlement.
369
+ - Once settled, `/verify` repeats the same `h` and `amount` and adds `sig`,
370
+ the ordinary LUD-25 signature over `LNURLcash:<amount>:<h>`. A sealed
371
+ signer can verify that receipt against the pre-payment commitment and
372
+ pinned `mintPubkey`, then confirm its staged note without exporting `k1`.
351
373
  - Claiming needs nothing else. `GET /w?k1=<the secret>` brings the note
352
374
  into existence as soon as the invoice has settled, with no `verify` poll
353
375
  and no preimage involved. The poll is still the way to claim from a mint
@@ -360,6 +382,10 @@ A named note is also derived-secret friendly: a wallet whose secrets come
360
382
  from its seed can restore a note it bought but never claimed, which a note
361
383
  whose secret was a preimage could never offer.
362
384
 
385
+ The proposed normative wire text, invalid cases and compatibility matrix are
386
+ kept executable in
387
+ [`lnurlcash-conformance`](https://github.com/TheCryptoDonkey/lnurlcash-conformance/blob/main/docs/BOUND-MINT-RECEIPTS.md).
388
+
363
389
  ## A retried mutation is answered, not refused
364
390
 
365
391
  Rotate, split and merge are GETs, and transports retry GETs. Go's
@@ -0,0 +1,132 @@
1
+ # Terms template
2
+
3
+ A starting point for `MONEYER_TOS_URL`. Adapt it, host it, point the
4
+ variable at it. Angle brackets mark what an operator must fill in; the
5
+ rest is written to be true of any moneyer mint and should only change if
6
+ your deployment makes it untrue.
7
+
8
+ This is a template, not legal advice. If your mint holds value that would
9
+ matter to the person who lost it, have someone qualified in your
10
+ jurisdiction read this before you publish it.
11
+
12
+ ---
13
+
14
+ ## Terms for <mint.example>
15
+
16
+ Last updated: <date>
17
+
18
+ ### What this is
19
+
20
+ <mint.example> is an evaluation mint for the
21
+ [LUD-25 draft](https://github.com/lnurl/luds/pull/301), running
22
+ [moneyer](https://github.com/forgesworn/moneyer). It exists to
23
+ demonstrate the protocol and to give wallet and mint implementers
24
+ something to grade against.
25
+
26
+ It is not a place to keep money. Notes on this mint are for testing the
27
+ protocol, and you should not hold value here that you would mind losing.
28
+
29
+ ### What a note is
30
+
31
+ A note is a bearer instrument. Whoever holds its secret can spend it, and
32
+ the mint cannot tell one holder from another - there are no accounts, no
33
+ passwords and no recovery. In particular:
34
+
35
+ - **Lose the secret and the value is gone.** Nobody can restore it, this
36
+ mint included.
37
+ - **Disclose the secret and whoever sees it can spend the note.** Treat it
38
+ the way you would treat cash.
39
+ - A note is a claim on this mint and nothing else. It is not backed by any
40
+ third party, and it is not a claim on the Bitcoin network.
41
+
42
+ ### No guarantee of redemption
43
+
44
+ This mint will try to honour every note it has struck. It does not
45
+ promise to, and you should not rely on it doing so. Redemption can fail
46
+ for reasons inside the operator's control and outside it: insufficient
47
+ channel liquidity, a funding source that is down, a host that is gone, or
48
+ the operator winding the mint down.
49
+
50
+ The mint publishes what it owes and what it holds at `/stats`, updated
51
+ continuously and signed hourly. Read it before you trust the mint with
52
+ anything. A `coverage` figure below 1 means the mint could not pay every
53
+ note out today.
54
+
55
+ The service is provided as is, without warranty of any kind. To the
56
+ fullest extent the law allows, the operator accepts no liability for any
57
+ loss arising from its use. That sentence has a floor: nothing in these
58
+ terms limits a liability the law does not allow to be limited, and if you
59
+ use this mint as a consumer, the statutory rights your law does not let
60
+ you sign away - in the UK, those the Consumer Rights Act 2015 implies -
61
+ are unaffected.
62
+
63
+ ### Wind-down
64
+
65
+ If this mint is retired, it will be put into sunset mode: it will refuse
66
+ anything that grows what it owes, and keep every way out open, so
67
+ outstanding notes can be melted. <Notice will be given through the mint's
68
+ MOTD and at <contact> at least <n> days beforehand.> Notes not melted by
69
+ then may not be redeemable.
70
+
71
+ ### Limits and fees
72
+
73
+ - Smallest note: <n> sats. Largest note: <n> sats.
74
+ - The mint fee is `<base> msat + <ppm> ppm`, published in the payRequest
75
+ metadata and on the discovery endpoint before you pay.
76
+
77
+ The fee is set to recover the routing cost the mint commits to when it
78
+ pays a note out, and is not priced to make a margin. The melt routing
79
+ budget is `max(0.5% of the amount, 5000 msat, the mint fee)`, so above
80
+ roughly 1,250 sats the fee does not cover it.
81
+
82
+ ### What the mint knows
83
+
84
+ This mint is not blind, and that is a property of LUD-25 rather than a
85
+ choice this deployment made. See
86
+ [What the mint knows](<mint.example>) - published on the mint's own site
87
+ and in its discovery document - for the full statement. In short: it
88
+ knows every note it issued, what each is worth, the links between them,
89
+ and the address each request came from. It does not know who holds a note
90
+ between operations.
91
+
92
+ Do not use this mint for anything where that matters to you.
93
+
94
+ That statement is also the privacy notice in outline. The personal data
95
+ are the network addresses requests arrive from and any zap names bound to
96
+ pubkeys; the operator holds them to run the mint and for nothing else,
97
+ keeps them for <retention period>, and does not sell or share them except
98
+ under compulsion of law. Where your law gives you rights over your data -
99
+ in the UK, the UK GDPR rights of access, correction and deletion -
100
+ contact <contact> to exercise them.
101
+
102
+ ### What this mint is not
103
+
104
+ - **Not a bank, and not a deposit-taker.** A note is not a deposit. There
105
+ is no depositor protection scheme behind it, in any jurisdiction.
106
+ - **Not a payment or e-money service.** This mint accepts and pays out
107
+ bitcoin only. It does not accept, hold, or pay out any national
108
+ currency, and no note is denominated in one.
109
+ - **Not a regulated financial service**, and not offered as one.
110
+ <The operator is not authorised or registered by <regulator>.>
111
+
112
+ ### Use
113
+
114
+ Use this mint lawfully. Do not use it if you are designated under UK, UN,
115
+ EU or US sanctions lists (or those of <jurisdiction>), and do not use it
116
+ to move value to or for anyone who is: a bearer mint cannot screen its
117
+ holders, so that duty sits with you. The operator may refuse or reverse
118
+ service, and may cooperate with lawful requests from authorities in
119
+ <jurisdiction>.
120
+
121
+ ### Operator and contact
122
+
123
+ <Operated by <name>, <jurisdiction>. Reach the operator at <contact>.>
124
+
125
+ ### Changes
126
+
127
+ These terms may change. The current version is always the one at this
128
+ URL, and material changes will be flagged through the mint's MOTD.
129
+
130
+ ### Governing law
131
+
132
+ <These terms are governed by the law of <jurisdiction>.>
@@ -0,0 +1,6 @@
1
+ export type MintFeeLike = {
2
+ baseFeeMsat: number;
3
+ feePpm: number;
4
+ };
5
+ export declare const feeInUnits: (fee: MintFeeLike | null, roundedToSat: boolean) => string;
6
+ export declare const describeFee: (fee: MintFeeLike, roundedToSat: boolean) => string;
@@ -0,0 +1,37 @@
1
+ // The mint fee as a person reads it, in one place so the payRequest
2
+ // metadata, the landing page and the mint's own site cannot drift apart
3
+ // on it - the same reason `privacy.ts` exists.
4
+ //
5
+ // The raw pair is spelled out deliberately. The machine-readable line
6
+ // beside it in the payRequest metadata is "Mint fees: 5000,1000" - the
7
+ // shape lnurlcash-kit parses, anchored and digits-only, so it cannot
8
+ // carry its own units - and a reader who has not parsed LUD-25 reads
9
+ // both numbers as satoshis. A tester who thinks the fee is 5000 sats on
10
+ // a 10k sat mint has been told something alarming and false, so the
11
+ // prose says msat and ppm where the wire cannot.
12
+ // "5 sat + 0.1% (5000 msat + 1000 ppm), rounded up to the sat", or
13
+ // "none". Bare, for a page that supplies its own "mint fee" label.
14
+ export const feeInUnits = (fee, roundedToSat) => {
15
+ if (!fee)
16
+ return 'none';
17
+ const shown = [];
18
+ const raw = [];
19
+ if (fee.baseFeeMsat > 0) {
20
+ shown.push(`${fee.baseFeeMsat % 1000 === 0 ? fee.baseFeeMsat / 1000 : (fee.baseFeeMsat / 1000).toFixed(3)} sat`);
21
+ raw.push(`${fee.baseFeeMsat} msat`);
22
+ }
23
+ if (fee.feePpm > 0) {
24
+ shown.push(`${fee.feePpm / 10_000}%`);
25
+ raw.push(`${fee.feePpm} ppm`);
26
+ }
27
+ if (!shown.length)
28
+ return 'none';
29
+ const body = `${shown.join(' + ')} (${raw.join(' + ')})`;
30
+ return roundedToSat ? `${body}, rounded up to the sat` : body;
31
+ };
32
+ // The same fee prefixed for prose: "fee 5 sat + 0.1% (5000 msat + 1000
33
+ // ppm)" - what a payer sees inside their wallet's description.
34
+ export const describeFee = (fee, roundedToSat) => {
35
+ const words = feeInUnits(fee, roundedToSat);
36
+ return words === 'none' ? 'no fee' : `fee ${words}`;
37
+ };
package/dist/landing.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { MINT_KNOWS, MINT_KNOWS_HEADING } from "./privacy.js";
2
+ import { feeInUnits } from "./fee-words.js";
2
3
  import { applyMintFee } from 'lnurlcash-kit';
3
4
  // The mint's face: one self-contained page at GET /, no build step, no
4
5
  // external assets. It states what a visitor needs before trusting a mint
@@ -11,9 +12,7 @@ export const landingPage = (args) => {
11
12
  const stats = args.stats ?? null;
12
13
  const address = `${config.username}@${host}`;
13
14
  const fee = config.mintFee;
14
- const feeLine = fee
15
- ? `${fee.baseFeeMsat > 0 ? `${fee.baseFeeMsat} msat flat` : ''}${fee.baseFeeMsat > 0 && fee.feePpm > 0 ? ' + ' : ''}${fee.feePpm > 0 ? `${fee.feePpm / 10_000}%` : ''}`
16
- : 'none';
15
+ const feeLine = feeInUnits(fee, config.roundFeeToSat !== false);
17
16
  const maxNet = fee ? applyMintFee(config.maxSendableMsat, fee) : config.maxSendableMsat;
18
17
  const sats = (msat) => `${(msat / 1000).toLocaleString('en-GB')} sat`;
19
18
  const title = config.name ?? nodeInfo.alias ?? 'moneyer';
@@ -72,12 +71,17 @@ p.small{color:var(--dim);font-size:13.5px;line-height:1.65;text-align:center}
72
71
  .knows p{font-size:14px;line-height:1.65}
73
72
  .motd{background:var(--raise);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:14px;padding:14px 18px;font-size:14.5px;line-height:1.6}
74
73
  .motd b{display:block;font-size:12px;letter-spacing:.09em;text-transform:uppercase;color:var(--dim);margin-bottom:4px}
74
+ .disclaimer{background:color-mix(in srgb,var(--warn,#c4a35e) 12%,var(--raise));border:1px solid color-mix(in srgb,var(--warn,#c4a35e) 50%,transparent);border-left:3px solid var(--warn,#c4a35e);border-radius:14px;padding:14px 18px;font-size:14.5px;line-height:1.55}
75
+ .disclaimer b{display:block;font-size:11px;font-weight:700;letter-spacing:.13em;text-transform:uppercase;color:var(--warn,#c4a35e);margin-bottom:5px}
76
+ .disclaimer strong{color:var(--warn,#c4a35e)}
77
+ .disclaimer a{color:var(--ink);text-decoration:underline;text-underline-offset:2px}
75
78
  a{color:var(--accent)}
76
79
  </style>
77
80
  </head>
78
81
  <body>
79
82
  <main>
80
83
  <svg class="mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.6v8.8"/><path d="M15.4 9.4c-.7-1.1-1.9-1.8-3.4-1.8-2 0-3.6 1.1-3.6 2.7 0 3.4 7.2 1.8 7.2 5 0 1.6-1.6 2.7-3.6 2.7-1.5 0-2.7-.7-3.4-1.8"/></svg>
84
+ <div class="disclaimer" role="note"><b>Proof of concept · for developers</b><span>An evaluation mint for the <a href="https://github.com/lnurl/luds/pull/301" rel="noopener noreferrer">draft LNURLcash spec</a>. A note is real bearer value with no protection, no recovery and no guaranteed redemption - <strong>assume you can lose anything you put in.</strong>${config.tosUrl ? ` <a href="${escapeHtml(config.tosUrl)}" rel="noopener noreferrer">Terms</a>` : ''}</span></div>
81
85
  <h1>${escapeHtml(title)}<small>An LNURLcash mint. Pay the address below and the invoice's payment preimage <em>is</em> your bearer note - money as a secret you hold.</small></h1>
82
86
  ${config.motd ? `<div class="motd"><b>notice</b>${escapeHtml(config.motd)}</div>` : ''}
83
87
  <div class="addr"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 4.5 13.5H11L9.5 22 18 10.5h-6.5L13 2z"/></svg>${escapeHtml(address)}</div>
@@ -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.d.ts CHANGED
@@ -29,9 +29,6 @@ export type MoneyerDeps = {
29
29
  zapPollMs?: number;
30
30
  statsPublishMs?: number;
31
31
  };
32
- export declare const describeFee: (fee: {
33
- baseFeeMsat: number;
34
- feePpm: number;
35
- }, roundedToSat: boolean) => string;
32
+ export { describeFee } from './fee-words.ts';
36
33
  export declare const createMoneyer: (config: MoneyerConfig, deps?: MoneyerDeps) => Promise<Moneyer>;
37
34
  export declare const sweepExpiredMintInvoices: (store: NoteStore, nowMs?: number) => number;
package/dist/server.js CHANGED
@@ -5,6 +5,7 @@ import { applyMintFee, grossUpForMintFee, hashK1 } from 'lnurlcash-kit';
5
5
  import { tryDecodeBolt11 } from 'farrier-kit/bolt11';
6
6
  import { NotePendingError, NoteStore, NoteUnavailableError, OutputCollisionError, swapFingerprint } from "./store.js";
7
7
  import { createNoteSigner } from "./signing.js";
8
+ import { describeFee } from "./fee-words.js";
8
9
  import { createFakeBackend } from "./backends/fake.js";
9
10
  import { createClnBackend } from "./backends/cln.js";
10
11
  import { createLndBackend } from "./backends/lnd.js";
@@ -34,16 +35,7 @@ const readBody = async (req, limit = MAX_BODY_BYTES) => {
34
35
  }
35
36
  return Buffer.concat(chunks).toString('utf8');
36
37
  };
37
- // "fee 5 sat + 0.1%" - what a payer sees in their wallet's description.
38
- export const describeFee = (fee, roundedToSat) => {
39
- const parts = [];
40
- if (fee.baseFeeMsat > 0)
41
- parts.push(`${fee.baseFeeMsat % 1000 === 0 ? fee.baseFeeMsat / 1000 : (fee.baseFeeMsat / 1000).toFixed(3)} sat`);
42
- if (fee.feePpm > 0)
43
- parts.push(`${(fee.feePpm / 10_000).toString()}%`);
44
- const base = parts.length ? `fee ${parts.join(' + ')}` : 'no fee';
45
- return roundedToSat && parts.length ? `${base}, rounded up to the sat` : base;
46
- };
38
+ export { describeFee } from "./fee-words.js";
47
39
  // A note's value rounded down to a whole sat; unchanged when already whole.
48
40
  const wholeSatFloor = (msat) => Math.floor(msat / 1000) * 1000;
49
41
  const backendFor = (config) => {
@@ -550,6 +542,9 @@ export const createMoneyer = async (config, deps = {}) => {
550
542
  // LUD-17's lnurlw:// is the scheme a wallet puts on a QR, not a
551
543
  // field in a JSON body; every other URL here is directly fetchable.
552
544
  withdrawLink: `${origin}/w`,
545
+ // Receipt-aware wallets need the note-signing key before they pay,
546
+ // so they can authenticate the later LUD-21 mint receipt.
547
+ ...(signer ? { mintPubkey: signer.pubkey } : {}),
553
548
  // This mint takes `h` on the callback below, so a wallet can name
554
549
  // the note it is buying. Advertised here as well as on the
555
550
  // discovery document because a wallet handed nothing but a
@@ -659,6 +654,12 @@ export const createMoneyer = async (config, deps = {}) => {
659
654
  // answer without it, and a wallet can tell the two apart before
660
655
  // paying rather than by looking for a note afterwards.
661
656
  ...(outputId !== null ? { mintToHash: true } : {}),
657
+ // Optional bound-receipt commitment: the exact output and net note
658
+ // value this invoice will mint. It is only offered when /verify can
659
+ // later authenticate settlement with this mint's signing key.
660
+ ...(outputId !== null && config.verify && signer
661
+ ? { mint: { h: outputId, amount: net } }
662
+ : {}),
662
663
  ...(config.verify ? { verify: `${origin}/verify/${paymentHash}` } : {})
663
664
  });
664
665
  }
@@ -673,11 +674,30 @@ export const createMoneyer = async (config, deps = {}) => {
673
674
  if (!invoice.settled && (await backend.isInvoiceSettled(paymentHash))) {
674
675
  store.settleMintInvoice(paymentHash);
675
676
  }
676
- const settled = store.mintInvoiceByHash(paymentHash).settled;
677
+ const currentInvoice = store.mintInvoiceByHash(paymentHash);
678
+ const settled = currentInvoice.settled;
677
679
  // The preimage IS the bearer secret. Served only once settled, and
678
680
  // fetched live from the funding source - it is never stored here.
679
681
  const preimageHex = settled ? await backend.invoicePreimage(paymentHash) : null;
680
- return send({ status: 'OK', settled, preimage: preimageHex, pr: invoice.pr });
682
+ return send({
683
+ status: 'OK',
684
+ settled,
685
+ preimage: preimageHex,
686
+ pr: invoice.pr,
687
+ // Repeating h and amount binds this response to the quote. The
688
+ // ordinary note signature appears only once value exists at h.
689
+ ...(currentInvoice.outputId !== null && signer
690
+ ? {
691
+ mint: {
692
+ h: currentInvoice.outputId,
693
+ amount: currentInvoice.netMsat,
694
+ ...(settled
695
+ ? { sig: signer.sign(currentInvoice.outputId, currentInvoice.netMsat) }
696
+ : {})
697
+ }
698
+ }
699
+ : {})
700
+ });
681
701
  }
682
702
  const melt = store.meltByHash(paymentHash);
683
703
  if (melt) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.5.0",
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,11 +32,13 @@
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",
38
39
  "SECURITY.md",
39
40
  "THREAT-MODEL.md",
41
+ "TERMS-TEMPLATE.md",
40
42
  "llms.txt"
41
43
  ],
42
44
  "scripts": {
@@ -50,14 +52,15 @@
50
52
  "web:dev": "vite web",
51
53
  "web:build": "vite build web",
52
54
  "web:preview": "vite preview web",
53
- "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"
54
57
  },
55
58
  "dependencies": {
56
59
  "@noble/curves": "^2.3.0",
57
60
  "@noble/hashes": "^2.3.0",
58
61
  "@scure/base": "^1.2.4",
59
62
  "farrier-kit": "^1.1.3",
60
- "lnurlcash-kit": "^0.2.0",
63
+ "lnurlcash-kit": "^0.3.0",
61
64
  "nostr-tools": "2.24.1"
62
65
  },
63
66
  "devDependencies": {
@@ -67,7 +70,7 @@
67
70
  "@types/node": "^24.0.0",
68
71
  "animejs": "^4.0.0",
69
72
  "happy-dom": "^20.0.0",
70
- "lnurlcash-conformance": "^0.2.1",
73
+ "lnurlcash-conformance": "^0.3.0",
71
74
  "playwright": "^1.62.1",
72
75
  "typescript": "^5.7.0",
73
76
  "uqr": "^0.1.2",
@@ -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
+ }