@forgesworn/moneyer 0.4.0 → 0.6.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 CHANGED
@@ -1,5 +1,87 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-08-24
4
+
5
+ - **Bound mint settlement receipts.** A pay callback asked to mint at a
6
+ wallet-chosen `h` now commits the quote to `mint: {h, amount}` whenever
7
+ signing and LUD-21 verification are available. Before settlement the
8
+ response is unsigned; after settlement `/verify` repeats the commitment
9
+ and adds the ordinary LUD-25 note `sig`. This lets a sealed signer promote
10
+ a staged output without exporting its secret. No-`h`, unsigned and
11
+ verify-disabled deployments retain their previous response shapes.
12
+ - The payRequest publishes `mintPubkey` when note signing is enabled, so a
13
+ receipt-aware wallet can establish the verification key before paying.
14
+ - The bundled web client stages a wallet-chosen secret before requesting the
15
+ invoice, validates the quote and settled receipt, and falls back before
16
+ payment to the existing preimage-import-and-rotate flow when the optional
17
+ receipt is unavailable.
18
+
19
+ - **A standing proof-of-concept disclaimer.** moneyer is an evaluation
20
+ implementation of a draft spec, its funding backends are beta, and a
21
+ note is bearer value with no recovery - so the site now says so at the
22
+ top, before anyone pays, rather than leaving it to a threat model
23
+ nobody opens. A prominent banner on the home page and the mint flow,
24
+ and on the fallback landing page: "Proof of concept · for developers …
25
+ assume you can lose anything you put in", with a link to the operator's
26
+ terms when `tosUrl` is set. Always on, because it is true of every
27
+ moneyer deployment as the spec stands. The per-step `warn` notes and
28
+ the operator MOTD are unchanged and sit alongside it.
29
+ - **The fee says its own units.** `Mint fees: 5000,1000` is the shape
30
+ LUD-25 wallets parse - lnurlcash-kit matches it anchored and
31
+ digits-only, so it cannot carry units - and testers were reading both
32
+ numbers as satoshis. On a 10k sat mint that reads as a 5000 sat fee,
33
+ which is alarming and false. The prose beside it now spells the pair
34
+ out: `fee 5 sat + 0.1% (5000 msat + 1000 ppm), rounded up to the sat`.
35
+ The wire format is untouched.
36
+ - The fee prose moves to `src/fee-words.ts`, the way `privacy.ts` already
37
+ holds the privacy statement, so the payRequest metadata and the
38
+ landing page cannot drift apart on it. The landing page had grown its
39
+ own second version reading `5000 msat flat + 0.1%`; it now uses the
40
+ shared one. `describeFee` is still exported from `server.ts`.
41
+ - **Operator guidance on the custodial position.** `DEPLOY.md` gains a
42
+ "Before you run this" section: the regulatory perimeter follows the
43
+ operator rather than the code, how to configure an evaluation mint so
44
+ it reads as one, why **not** to zero the fee to make that point (a
45
+ fee-free mint pays the melt routing floor out of its own balance and is
46
+ griefable by mint-and-melt cycling - see `THREAT-MODEL.md`), and the
47
+ no-fiat invariant. moneyer is millisatoshis throughout, and that is
48
+ load-bearing rather than incidental.
49
+ - `TERMS-TEMPLATE.md`, a starting point for `MONEYER_TOS_URL`. It defers
50
+ to the mint's own "what the mint knows" statement rather than restating
51
+ it, for the same no-drift reason.
52
+ - **The regulatory guidance grows its missing teeth.** `DEPLOY.md`'s
53
+ "Before you run this" covered the payments perimeter and stopped there;
54
+ it now walks the UK regimes that actually bear on a custodial mint:
55
+ FCA registration under the Money Laundering Regulations 2017 (custody
56
+ is the live limb, and the travel rule the structural one - an anonymous
57
+ bearer instrument cannot carry originator and beneficiary information),
58
+ the financial promotion rules that gate marketing for an unregistered
59
+ operator (`MONEYER_ANNOUNCE` and the site copy both), sanctions, which
60
+ bind regardless of classification, and the cryptoasset custody regime
61
+ the 2025 draft legislation is bringing. `TERMS-TEMPLATE.md` gains the
62
+ liability floor consumer law insists on, a sanctions prohibited-use
63
+ clause, and a privacy notice in outline.
64
+
65
+ ## [0.5.0] - 2026-08-23
66
+
67
+ - **Both doors, named.** With `MONEYER_ONION_URL` and
68
+ `MONEYER_PUBLIC_ORIGIN` both set, `/w` and the mint address now carry
69
+ `mirrors`: this mint's other origin. Both serve the same notes - a note
70
+ is keyed by `sha256(k1)` and not by host - but a note travels as a URL,
71
+ and a URL names one host, so an onion note handed to somebody without
72
+ Tor is unspendable to them. Naming the other door is what lets a wallet
73
+ offer the holder a way across.
74
+ - Absent rather than empty when there is only one door, because a wallet
75
+ reading `[]` would think it had been told something. The hourly Nostr
76
+ announcement carries it too.
77
+ - Documented, because it is a real limit rather than a missing feature: a
78
+ wallet must **not** fail over to a mirror on its own. Every request that
79
+ identifies a note carries the k1, and nothing in LUD-25 lets a host
80
+ prove it is the same mint before receiving that secret - a hostile
81
+ discovery document can name any host and claim any `mintPubkey`, since
82
+ claiming is not proving. Closing it needs a signed challenge the spec
83
+ does not have.
84
+
3
85
  ## [0.4.0] - 2026-08-23
4
86
 
5
87
  - **Reaching the mint over Tor.** `MONEYER_ONION_URL` names this mint's
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
@@ -507,6 +533,37 @@ controlled, and that property is unchanged: the worst a forged Host can do
507
533
  is get back the onion URL instead of the clearnet one, and the operator
508
534
  configured both. Anything else falls through to `MONEYER_PUBLIC_ORIGIN`.
509
535
 
536
+ ### Serving both audiences at once
537
+
538
+ Both doors serve the **same notes**. A note is keyed by `sha256(k1)` and
539
+ not by host, so a k1 struck on the onion is valid at the clearnet host and
540
+ the other way round. What is bound to a host is only the URL a note
541
+ travels as - and an onion note handed to somebody without Tor is
542
+ unspendable to them, while a clearnet note redeemed over Tor is private
543
+ only if their wallet proxies.
544
+
545
+ So when both are configured, `/w` and the mint address name the other one
546
+ in `mirrors`:
547
+
548
+ ```json
549
+ {"tag": "withdrawRequest", "callback": "http://<v3>.onion/w/cb",
550
+ "mirrors": ["https://mint.example"], "mintPubkey": "..."}
551
+ ```
552
+
553
+ Absent when there is only one door, rather than an empty array - a wallet
554
+ reading `[]` would think it had been told something. The hourly Nostr
555
+ announcement carries it too, so a wallet that discovers this mint on a
556
+ relay learns both doors before it holds anything.
557
+
558
+ **A wallet must not fail over to a mirror on its own.** Every request that
559
+ identifies a note carries the k1, so switching hosts discloses a bearer
560
+ secret to the new one, and nothing in LUD-25 lets a host prove it is the
561
+ same mint before receiving that secret - a hostile discovery document can
562
+ name any host and claim any `mintPubkey`, because claiming is not proving.
563
+ Show the holder the other door and let them choose it. Closing that
564
+ properly needs a challenge the mint signs, which the spec does not have
565
+ yet.
566
+
510
567
  Running the hidden service itself is not this mint's job. Point a Tor
511
568
  `HiddenServiceDir` (or a proxy that speaks onion) at whatever host and port
512
569
  it already listens on, the same way you would front it with Caddy for
@@ -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>
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) => {
@@ -208,7 +200,7 @@ export const createMoneyer = async (config, deps = {}) => {
208
200
  // LUD-25 discovery, built here rather than inline in its route because
209
201
  // the mint also announces itself with it, and there must be one
210
202
  // description of a mint rather than two that can drift apart.
211
- const mintAddressDocument = (origin, user) => ({
203
+ const mintAddressDocument = (origin, user, mirrors = []) => ({
212
204
  tag: 'withdrawRequest',
213
205
  callback: `${origin}/w`,
214
206
  minWithdrawable: config.minMintMsat,
@@ -217,6 +209,7 @@ export const createMoneyer = async (config, deps = {}) => {
217
209
  : config.maxSendableMsat,
218
210
  defaultDescription: config.description,
219
211
  payLink: `${origin}/.well-known/lnurlp/${user}`,
212
+ ...(mirrors.length ? { mirrors } : {}),
220
213
  ...(signer ? { mintPubkey: signer.pubkey } : {}),
221
214
  // The human layer: who runs this, how to reach them, the terms,
222
215
  // and today's message. Absent unless the operator set it.
@@ -294,7 +287,7 @@ export const createMoneyer = async (config, deps = {}) => {
294
287
  kind: ANNOUNCE_KIND,
295
288
  created_at: Math.floor(Date.now() / 1000),
296
289
  tags: [['d', ANNOUNCE_D_TAG]],
297
- content: announcementContent(mintAddressDocument(config.publicOrigin, config.username), config.signingKey)
290
+ content: announcementContent(mintAddressDocument(config.publicOrigin, config.username, config.onionUrl ? [config.onionUrl] : []), config.signingKey)
298
291
  }, hexToBytes(config.zap.nostrKey));
299
292
  const { ok, failed } = await nostr.publish(config.zap.relays, event);
300
293
  log(`mint announced to ${ok.length} relay${ok.length === 1 ? '' : 's'}${failed.length ? `, ${failed.length} refused` : ''}`);
@@ -311,6 +304,14 @@ export const createMoneyer = async (config, deps = {}) => {
311
304
  const origin = onionHost && askedFor === onionHost
312
305
  ? config.onionUrl
313
306
  : (config.publicOrigin ?? `http://${req.headers.host ?? '127.0.0.1'}`);
307
+ // This mint's OTHER doors. A note is keyed by sha256(k1) and not by
308
+ // host, so every one of these serves the same notes - but a note
309
+ // travels as a URL, and a URL names one host. An onion note handed to
310
+ // somebody without Tor is unspendable to them, and a clearnet note
311
+ // redeemed over Tor is only private if their wallet proxies. Saying
312
+ // which other host answers is what lets a wallet move a note between
313
+ // them instead of the holder finding out it cannot.
314
+ const mirrors = [config.publicOrigin, config.onionUrl].filter((candidate) => Boolean(candidate) && candidate !== origin);
314
315
  const host = new URL(origin).host;
315
316
  const send = (body, status = 200) => {
316
317
  res.writeHead(status, {
@@ -541,6 +542,9 @@ export const createMoneyer = async (config, deps = {}) => {
541
542
  // LUD-17's lnurlw:// is the scheme a wallet puts on a QR, not a
542
543
  // field in a JSON body; every other URL here is directly fetchable.
543
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 } : {}),
544
548
  // This mint takes `h` on the callback below, so a wallet can name
545
549
  // the note it is buying. Advertised here as well as on the
546
550
  // discovery document because a wallet handed nothing but a
@@ -555,7 +559,7 @@ export const createMoneyer = async (config, deps = {}) => {
555
559
  if (lnurlwMatch) {
556
560
  if (!knownUser(lnurlwMatch[1]))
557
561
  return fail('Unknown user.', 404);
558
- return send(mintAddressDocument(origin, lnurlwMatch[1]));
562
+ return send(mintAddressDocument(origin, lnurlwMatch[1], mirrors));
559
563
  }
560
564
  // ---- LUD-06 pay callback: issue a mint invoice ----
561
565
  if (requestUrl.pathname === '/p/cb') {
@@ -650,6 +654,12 @@ export const createMoneyer = async (config, deps = {}) => {
650
654
  // answer without it, and a wallet can tell the two apart before
651
655
  // paying rather than by looking for a note afterwards.
652
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
+ : {}),
653
663
  ...(config.verify ? { verify: `${origin}/verify/${paymentHash}` } : {})
654
664
  });
655
665
  }
@@ -664,11 +674,30 @@ export const createMoneyer = async (config, deps = {}) => {
664
674
  if (!invoice.settled && (await backend.isInvoiceSettled(paymentHash))) {
665
675
  store.settleMintInvoice(paymentHash);
666
676
  }
667
- const settled = store.mintInvoiceByHash(paymentHash).settled;
677
+ const currentInvoice = store.mintInvoiceByHash(paymentHash);
678
+ const settled = currentInvoice.settled;
668
679
  // The preimage IS the bearer secret. Served only once settled, and
669
680
  // fetched live from the funding source - it is never stored here.
670
681
  const preimageHex = settled ? await backend.invoicePreimage(paymentHash) : null;
671
- 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
+ });
672
701
  }
673
702
  const melt = store.meltByHash(paymentHash);
674
703
  if (melt) {
@@ -725,6 +754,10 @@ export const createMoneyer = async (config, deps = {}) => {
725
754
  // signing keys. Without it a wallet that only ever received notes
726
755
  // cannot tell an announced key rotation from a substituted key.
727
756
  payLink: `${origin}/.well-known/lnurlp/${config.username}`,
757
+ // Where else this same note can be redeemed. A holder with nothing
758
+ // but a note learns from the note itself that the other door
759
+ // exists.
760
+ ...(mirrors.length ? { mirrors } : {}),
728
761
  ...(signer ? { mintPubkey: signer.pubkey } : {})
729
762
  });
730
763
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -37,6 +37,7 @@
37
37
  "CHANGELOG.md",
38
38
  "SECURITY.md",
39
39
  "THREAT-MODEL.md",
40
+ "TERMS-TEMPLATE.md",
40
41
  "llms.txt"
41
42
  ],
42
43
  "scripts": {
@@ -57,7 +58,7 @@
57
58
  "@noble/hashes": "^2.3.0",
58
59
  "@scure/base": "^1.2.4",
59
60
  "farrier-kit": "^1.1.3",
60
- "lnurlcash-kit": "^0.2.0",
61
+ "lnurlcash-kit": "^0.3.0",
61
62
  "nostr-tools": "2.24.1"
62
63
  },
63
64
  "devDependencies": {
@@ -67,7 +68,7 @@
67
68
  "@types/node": "^24.0.0",
68
69
  "animejs": "^4.0.0",
69
70
  "happy-dom": "^20.0.0",
70
- "lnurlcash-conformance": "^0.2.1",
71
+ "lnurlcash-conformance": "^0.3.0",
71
72
  "playwright": "^1.62.1",
72
73
  "typescript": "^5.7.0",
73
74
  "uqr": "^0.1.2",