@forgesworn/moneyer 0.9.0 → 0.10.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
@@ -2,6 +2,82 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.10.0 - 2026-09-04
6
+
7
+ Two changes here are breaking for an existing deployment, and both are
8
+ deliberate.
9
+
10
+ **An operator upgrading must set `MONEYER_SIGNING_KEY` before the mint will
11
+ start.** LUD-25 makes offline-verifiable note signatures mandatory for a
12
+ SERVICE, so issuing unsigned notes is no longer an allowed operating mode and
13
+ failing loudly at boot beats doing it quietly. `moneyer admin keys rotate`
14
+ bootstraps a persistent key; `--dev` still generates an ephemeral one for a
15
+ valueless mint.
16
+
17
+ **A custom `LightningBackend` must declare `acceptsInvoicePreimage`.** The
18
+ bundled cln, lnd and fake backends declare `true` and behave exactly as
19
+ before.
20
+
21
+ **Graded against conformance 0.6.0, and the stale-probe exception is gone.**
22
+ The suite now enforces the LUD-25 MUSTs moneyer already implements: every
23
+ rotate, split and merge must return a signature, a retried mutation must be
24
+ answered as a replay of its original success, and a hash lookup must not
25
+ distinguish a burned note id from an unknown one. Moneyer passes all of them
26
+ unchanged - the work landed in 0.9.1 and this is the first suite that checks
27
+ it.
28
+
29
+ `test/conformance-compat.ts` carried an allowlist for two probes that
30
+ conformance 0.4.0 sent without the then-new mandatory comment, so a correct
31
+ refusal looked like a failure. 0.6.0 sends the comment on both, so the
32
+ allowlist is deleted and the test asserts no failures at all.
33
+
34
+ **`lnurlcash-kit` moves to 0.7.0**, which refuses a mint publishing no
35
+ `mintPubkey`, raises `UnverifiableNoteError` for a mutation it confirms
36
+ without signing, and re-sends a mutation whose answer the transport lost.
37
+ Moneyer is on the SERVICE side of all three, so nothing here changes - but
38
+ the kit is also what its own tests drive the mint with, and they pass against
39
+ the stricter client.
40
+
41
+ **A caller-supplied invoice preimage is no longer required of a funding
42
+ source.** It was, and the README said so: "the capability a LUD-25 mint
43
+ cannot exist without". That was true of the draft that keyed a bearer note by
44
+ the payment preimage, and stopped being true in 0.9.0, when comment-bound
45
+ minting became unconditional. A note is bound to the buyer's commitment,
46
+ which the funding source never sees, so a node that mints its own preimages
47
+ can back a mint.
48
+
49
+ `LightningBackend` gains `acceptsInvoicePreimage`, and `createInvoice`'s
50
+ `preimageHex` is optional. cln, lnd and fake declare `true` and are unchanged:
51
+ knowing the payment hash before the invoice exists lets the mint refuse an
52
+ invoice that does not commit to it, which is still the stronger position. A
53
+ backend declaring `false` gets the hash off the returned invoice and the same
54
+ collision checks after the fact, plus two the pre-chosen path never needed - a
55
+ payment hash this mint has already issued or melted, and an invoice the node
56
+ had already settled, are both refused rather than quoted.
57
+
58
+ No phoenixd or NIP-47 backend ships yet; what changed is that one is now
59
+ possible. An operator writing one should know that phoenixd invoices are
60
+ denominated in whole sats and that `payinvoice` takes no fee limit, so a mint
61
+ on it cannot cap the routing cost of a melt the way cln and lnd can.
62
+
63
+ - Moneyer now refuses to start without `MONEYER_SIGNING_KEY`. LUD-25 makes
64
+ offline-verifiable note signatures mandatory for a SERVICE; silently
65
+ issuing unsigned notes is no longer an allowed operating mode. `--dev`
66
+ still creates an ephemeral key, and `moneyer admin keys rotate` can
67
+ bootstrap a persistent one.
68
+ - A hash-only informational lookup now treats a burned note id exactly like
69
+ an unknown one and refuses requests carrying both `k1` and `h`, matching
70
+ LUD-25's non-disclosing `h` lookup. Secret-bearing `k1` lookups retain the
71
+ useful `already spent` answer.
72
+
73
+ ## 0.9.1 - 2026-09-01
74
+
75
+ **Fix: the LUD-25 informational GET now accepts `h=sha256(k1)`.** A
76
+ receipt-aware wallet can verify that a bound mint payment created its note
77
+ without disclosing the bearer secret. The hash-only reply deliberately omits
78
+ `k1`; lazy settlement works on this path as it does on the secret-bearing
79
+ path, and conflicting `k1` and `h` parameters are refused.
80
+
5
81
  ## 0.9.0 - 2026-08-31
6
82
 
7
83
  **LUD-25 comment-bound minting is now unconditional.** Every mint quote must
package/README.md CHANGED
@@ -6,8 +6,9 @@
6
6
  moneyer is an independent implementation of the LUD-25 draft: paying an
7
7
  invoice it issues mints a bearer note, and a note's holder can rotate,
8
8
  split, merge and melt it against the withdraw callback. The note's spend
9
- secret is the invoice's payment preimage, or, better, a secret the buyer
10
- chose and named on the way in. It passes the full
9
+ secret is chosen by the buyer, before the invoice exists, and named to the
10
+ mint only as a hash - so the mint never holds it, and neither does any node
11
+ that forwards the payment. It passes the full
11
12
  [lnurlcash-conformance](https://github.com/TheCryptoDonkey/lnurlcash-conformance)
12
13
  grader, including the spending checks, and the grader runs in this repo's
13
14
  own test suite.
@@ -21,14 +22,18 @@ lesson, that behaviour is kept deliberately and tested.
21
22
 
22
23
  - TypeScript, ESM, Node 24+. `node:http` and `node:sqlite`; no web
23
24
  framework, no ORM.
24
- - Funding sources: **cln** (clnrest) and **lnd** (REST), both of which
25
- accept a caller-supplied invoice preimage - the capability a LUD-25 mint
26
- cannot exist without. phoenixd and NIP-47 `make_invoice` do not offer it,
27
- which is why neither can back a mint. A **fake** backend exists for
25
+ - Funding sources: **cln** (clnrest) and **lnd** (REST). Both accept a
26
+ caller-supplied invoice preimage, so the mint knows the payment hash
27
+ before the invoice exists and can refuse an invoice that does not commit
28
+ to it. That is a nicety, not a requirement: a note is keyed by the buyer's
29
+ comment commitment, which the funding source never sees, so a node that
30
+ mints its own preimages (phoenixd, NIP-47 `make_invoice`) can back a mint
31
+ too - it just gets the after-the-fact checks instead. A backend declares
32
+ which it is with `acceptsInvoicePreimage`. A **fake** backend exists for
28
33
  development and tests and refuses to run outside `--dev`.
29
34
  - Notes are stored by id, `sha256(k1)` - the store never holds a spend
30
- secret. A buyer may name the note they are buying, in which case the
31
- secret is theirs alone from the start.
35
+ secret. The buyer names the note they are buying with a mandatory LUD-12
36
+ comment, so the secret is theirs alone from the start.
32
37
  - Signs every note it mints with its own mint key (secp256k1, the standard
33
38
  `Lightning Signed Message` construction) for LUD-25 offline verification.
34
39
  - The melt discipline: reply OK when the note is reserved, pay in the
@@ -97,7 +102,7 @@ default, and a variable set to an empty string counts as unset.
97
102
  | `MONEYER_BACKEND_URL` | | the funding source's REST endpoint |
98
103
  | `MONEYER_BACKEND_RUNE` | | cln authentication |
99
104
  | `MONEYER_BACKEND_MACAROON` | | lnd authentication, hex |
100
- | `MONEYER_SIGNING_KEY` | | 32 bytes of hex. Unset means notes go out unsigned, which holders will notice |
105
+ | `MONEYER_SIGNING_KEY` | required | Persistent 32-byte hex note-signing key. The service refuses to start without it; use `moneyer admin keys rotate` to generate one |
101
106
  | `MONEYER_PREVIOUS_SIGNING_PUBKEYS` | | compressed pubkeys this mint signed under before, comma separated (see below) |
102
107
  | `MONEYER_BASE_FEE_MSAT` | `0` | flat mint fee |
103
108
  | `MONEYER_FEE_PPM` | `0` | proportional mint fee, parts per million |
@@ -576,7 +581,7 @@ clearnet.
576
581
  | `/p/cb` | LUD-06 pay callback; issues the mint invoice, and takes an optional `h` naming the note |
577
582
  | `/z/cb/<zap name>` | the zap callback; validates the kind 9734 and issues the invoice |
578
583
  | `/verify/<hash>` | LUD-21 verify, for mint invoices and melt payments |
579
- | `/w` | LUD-03 informational GET; a live note also carries `payLink`, the route back to this mint's discovery document |
584
+ | `/w` | LUD-03 informational GET; accepts `k1` or the non-disclosing LUD-25 `h=sha256(k1)` check; a live note also carries `payLink` |
580
585
  | `/w/cb` | the mutating callback: melt, rotate, split, merge |
581
586
  | `POST /names` | claim a lightning address, authenticated by NIP-98 |
582
587
  | `/.well-known/nostr.json` | NIP-05 for the names this mint serves |
package/dist/admin.js CHANGED
@@ -173,7 +173,7 @@ export const runAdmin = async (argv, deps = {}) => {
173
173
  out(` node balance ${localBalanceMsat === undefined ? 'not reported' : sats(localBalanceMsat)}`);
174
174
  out(` coverage ${stats.coverage === undefined ? (liabilities.outstandingMsat === 0 ? 'nothing outstanding' : 'unknown') : stats.coverage}`);
175
175
  out(` lifetime ${totals.mints} mints, ${totals.melts.paid} melts paid, ${totals.melts.restored} restored, ${totals.zaps} zaps`);
176
- out(` signing key ${signingPubkey(config) ?? 'none - notes go out unsigned'}`);
176
+ out(` signing key ${signingPubkey(config) ?? 'none - service will refuse to start'}`);
177
177
  out(` previous keys ${previous.length ? previous.join(', ') : 'none'}`);
178
178
  out(` funding source ${config.backend.kind}`);
179
179
  return 0;
@@ -36,7 +36,11 @@ export const createClnBackend = (config) => {
36
36
  };
37
37
  return {
38
38
  name: 'cln',
39
+ acceptsInvoicePreimage: true,
39
40
  async createInvoice({ amountMsat, preimageHex, memo, descriptionForHash }) {
41
+ if (preimageHex === undefined) {
42
+ throw new Error('cln accepts a caller-supplied invoice preimage and was given none.');
43
+ }
40
44
  const result = await mustCall('/v1/invoice', {
41
45
  amount_msat: amountMsat,
42
46
  label: bytesToHex(randomBytes(16)),
@@ -3,6 +3,7 @@ export type FakePayMode = 'succeed' | 'fail-clean' | 'fail-then-paid' | 'ambiguo
3
3
  export declare const DEFAULT_AUTO_SETTLE_AFTER_MS = 1500;
4
4
  export type FakeBackendOptions = {
5
5
  autoSettle?: boolean;
6
+ nodeChoosesPreimage?: boolean;
6
7
  autoSettleAfterMs?: number;
7
8
  };
8
9
  export type FakeBackend = LightningBackend & {
@@ -12,6 +13,7 @@ export type FakeBackend = LightningBackend & {
12
13
  resolvePayment(paymentHashHex: string, status: 'complete' | 'failed'): void;
13
14
  registerPaymentPreimage(paymentHashHex: string, preimageHex: string): void;
14
15
  seedForeignPayment(paymentHashHex: string, status?: 'complete' | 'pending'): void;
16
+ reuseNextInvoiceHash(paymentHashHex: string): void;
15
17
  invoiceByHash(paymentHashHex: string): {
16
18
  preimageHex: string;
17
19
  amountMsat: number;
@@ -1,4 +1,4 @@
1
- import { bytesToHex } from '@noble/hashes/utils.js';
1
+ import { bytesToHex, randomBytes } from '@noble/hashes/utils.js';
2
2
  import { sha256 } from '@noble/hashes/sha2.js';
3
3
  import { hexToBytes } from '@noble/hashes/utils.js';
4
4
  import { bolt11PaymentHash } from 'farrier-kit/bolt11';
@@ -22,6 +22,8 @@ const isSettled = (invoice) => {
22
22
  export const FAKE_LOCAL_BALANCE_MSAT = 100_000_000_000;
23
23
  export const createFakeBackend = (options = {}) => {
24
24
  const autoSettle = options.autoSettle === true;
25
+ const nodeChoosesPreimage = options.nodeChoosesPreimage === true;
26
+ let reusedInvoiceHash = null;
25
27
  const autoSettleAfterMs = options.autoSettleAfterMs ?? DEFAULT_AUTO_SETTLE_AFTER_MS;
26
28
  // `settleAt` is when autoSettle starts calling this invoice paid; null
27
29
  // for an invoice only control.settleInvoice can settle, which is every
@@ -33,11 +35,25 @@ export const createFakeBackend = (options = {}) => {
33
35
  let payMode = 'succeed';
34
36
  return {
35
37
  name: 'fake',
38
+ acceptsInvoicePreimage: !nodeChoosesPreimage,
36
39
  async createInvoice({ amountMsat, preimageHex, memo }) {
37
- const paymentHashHex = bytesToHex(sha256(hexToBytes(preimageHex)));
40
+ // A node that mints its own preimages never sees the caller's.
41
+ const chosen = nodeChoosesPreimage ? bytesToHex(randomBytes(32)) : preimageHex;
42
+ if (chosen === undefined) {
43
+ throw new Error('the fake backend accepts a caller-supplied invoice preimage and was given none.');
44
+ }
45
+ // A funding source handing back an invoice that is not the fresh one
46
+ // it was asked for. Nothing is recorded under the reused hash: the
47
+ // point is precisely that this invoice is somebody else's.
48
+ if (reusedInvoiceHash !== null) {
49
+ const paymentHashHex = reusedInvoiceHash;
50
+ reusedInvoiceHash = null;
51
+ return { pr: fakeBolt11({ amountMsat, paymentHashHex, memo }) };
52
+ }
53
+ const paymentHashHex = bytesToHex(sha256(hexToBytes(chosen)));
38
54
  const pr = fakeBolt11({ amountMsat, paymentHashHex, memo });
39
55
  invoices.set(paymentHashHex, {
40
- preimageHex,
56
+ preimageHex: chosen,
41
57
  amountMsat,
42
58
  settled: false,
43
59
  settleAt: autoSettle ? Date.now() + autoSettleAfterMs : null
@@ -128,6 +144,9 @@ export const createFakeBackend = (options = {}) => {
128
144
  seedForeignPayment(paymentHashHex, status = 'complete') {
129
145
  payments.set(paymentHashHex, { status, preimageHex: null, amountMsat: null });
130
146
  },
147
+ reuseNextInvoiceHash(paymentHashHex) {
148
+ reusedInvoiceHash = paymentHashHex;
149
+ },
131
150
  sentAmountMsat(paymentHashHex) {
132
151
  const payment = payments.get(paymentHashHex);
133
152
  return payment ? payment.amountMsat : undefined;
@@ -85,7 +85,11 @@ export const createLndBackend = (config) => {
85
85
  };
86
86
  return {
87
87
  name: 'lnd',
88
+ acceptsInvoicePreimage: true,
88
89
  async createInvoice({ amountMsat, preimageHex, memo, descriptionForHash }) {
90
+ if (preimageHex === undefined) {
91
+ throw new Error('lnd accepts a caller-supplied invoice preimage and was given none.');
92
+ }
89
93
  const res = await json('/v1/invoices', {
90
94
  method: 'POST',
91
95
  body: {
@@ -19,9 +19,10 @@ export declare class PaymentAlreadyKnownError extends Error {
19
19
  }
20
20
  export interface LightningBackend {
21
21
  readonly name: string;
22
+ readonly acceptsInvoicePreimage: boolean;
22
23
  createInvoice(args: {
23
24
  amountMsat: number;
24
- preimageHex: string;
25
+ preimageHex?: string;
25
26
  memo: string;
26
27
  descriptionForHash?: string;
27
28
  }): Promise<{
@@ -1,9 +1,12 @@
1
1
  // The funding source. Every amount is integer milli-satoshis.
2
2
  //
3
- // createInvoice currently takes a caller-supplied preimage so Moneyer can
4
- // verify that the returned BOLT-11 commits to the invoice it records and can
5
- // later provide ordinary LUD-21 settlement proof. The bearer note itself is
6
- // always keyed by the wallet's mandatory comment commitment.
3
+ // A backend MAY accept a caller-supplied invoice preimage, and Moneyer uses
4
+ // one where it can: knowing the payment hash before the invoice exists lets it
5
+ // prove the BOLT-11 it got back is the one it asked for. That is a nicety, not
6
+ // a requirement. The bearer note is keyed by the wallet's mandatory comment
7
+ // commitment, never by the payment preimage, so a funding source whose node
8
+ // chooses its own preimages can back a mint perfectly well - see
9
+ // `acceptsInvoicePreimage`.
7
10
  // The funding source's immediate answer to the payment attempt was a clean,
8
11
  // terminal failure - no route, rejected, expired. Distinct from a dropped
9
12
  // connection or timeout, where the payment may still have gone out. Even
package/dist/cli.js CHANGED
@@ -67,8 +67,7 @@ const log = (message) => console.error(`[moneyer] ${message}`);
67
67
  const moneyer = await createMoneyer(config, { log });
68
68
  console.log(`moneyer listening on ${moneyer.url}`);
69
69
  console.log(` lightning address: ${config.username}@${new URL(config.publicOrigin ?? moneyer.url).host}`);
70
- if (moneyer.signer)
71
- console.log(` mint pubkey: ${moneyer.signer.pubkey}`);
70
+ console.log(` mint pubkey: ${moneyer.signer.pubkey}`);
72
71
  console.log(` funding source: ${moneyer.backend.name}`);
73
72
  if (values.dev) {
74
73
  const k1 = bytesToHex(randomBytes(32));
package/dist/server.d.ts CHANGED
@@ -11,7 +11,7 @@ export type Moneyer = {
11
11
  config: MoneyerConfig;
12
12
  store: NoteStore;
13
13
  backend: LightningBackend;
14
- signer: NoteSigner | null;
14
+ signer: NoteSigner;
15
15
  zap: ZapBridge | null;
16
16
  reconcile: () => Promise<void>;
17
17
  stats: () => Promise<MintStats>;
package/dist/server.js CHANGED
@@ -54,10 +54,13 @@ const backendFor = (config) => {
54
54
  }
55
55
  };
56
56
  export const createMoneyer = async (config, deps = {}) => {
57
+ if (!config.signingKey) {
58
+ throw new Error('MONEYER_SIGNING_KEY is required: LUD-25 requires every SERVICE to sign its notes. Run `moneyer admin keys rotate` to generate one.');
59
+ }
57
60
  const log = deps.log ?? (() => { });
58
61
  const store = deps.store ?? new NoteStore(config.dbPath);
59
62
  const backend = deps.backend ?? backendFor(config);
60
- const signer = config.signingKey ? createNoteSigner(config.signingKey) : null;
63
+ const signer = createNoteSigner(config.signingKey);
61
64
  const webAssets = deps.webAssets === undefined ? loadWebAssets() : deps.webAssets;
62
65
  // Node identity for the discovery endpoint, fetched once, best-effort: a
63
66
  // funding source that cannot answer does not stop the mint serving.
@@ -140,8 +143,7 @@ export const createMoneyer = async (config, deps = {}) => {
140
143
  // A note whose id we do not know yet may be a settled mint invoice whose
141
144
  // claim simply has not been observed: settle it lazily against the
142
145
  // funding source, which is what makes paying an invoice mint the note.
143
- const resolveNote = async (k1) => {
144
- const id = hashK1(k1);
146
+ const resolveNoteId = async (id) => {
145
147
  const note = store.noteById(id);
146
148
  if (note)
147
149
  return note;
@@ -161,6 +163,7 @@ export const createMoneyer = async (config, deps = {}) => {
161
163
  }
162
164
  return null;
163
165
  };
166
+ const resolveNote = async (k1) => resolveNoteId(hashK1(k1));
164
167
  // ---- transparency: what the mint owes, and what the node holds ----
165
168
  //
166
169
  // Cached for 30 seconds so a public endpoint cannot be turned into a
@@ -629,19 +632,30 @@ export const createMoneyer = async (config, deps = {}) => {
629
632
  return fail('Invalid or already spent k1.');
630
633
  }
631
634
  // The preimage is payment proof only; comment names the future note.
632
- // Generated here, handed to the funding source, never persisted - the store
633
- // keeps hashes only.
634
- let preimage = bytesToHex(randomBytes(32));
635
- let paymentHash = hashK1(preimage);
636
- while (store.outputIdInUse(paymentHash) || paymentHash === outputId) {
637
- preimage = bytesToHex(randomBytes(32));
638
- paymentHash = hashK1(preimage);
635
+ //
636
+ // Where the funding source lets Moneyer choose it, choosing means the
637
+ // payment hash is known before the invoice exists, and the invoice that
638
+ // comes back can be held to it. Where the node mints its own preimages,
639
+ // the hash arrives with the invoice and the same collisions are checked
640
+ // after the fact instead. Either way the preimage is never persisted -
641
+ // the store keeps hashes only - and either way the bearer note is keyed
642
+ // by the wallet's comment commitment, which is why a node that will not
643
+ // take a preimage can still back a mint.
644
+ let chosen = null;
645
+ if (backend.acceptsInvoicePreimage) {
646
+ let preimageHex = bytesToHex(randomBytes(32));
647
+ let paymentHash = hashK1(preimageHex);
648
+ while (store.outputIdInUse(paymentHash) || paymentHash === outputId) {
649
+ preimageHex = bytesToHex(randomBytes(32));
650
+ paymentHash = hashK1(preimageHex);
651
+ }
652
+ chosen = { preimageHex, paymentHash };
639
653
  }
640
654
  let pr;
641
655
  try {
642
656
  pr = (await backend.createInvoice({
643
657
  amountMsat: amount,
644
- preimageHex: preimage,
658
+ ...(chosen === null ? {} : { preimageHex: chosen.preimageHex }),
645
659
  memo: `LNURLcash mint at ${host}`
646
660
  })).pr;
647
661
  }
@@ -649,14 +663,50 @@ export const createMoneyer = async (config, deps = {}) => {
649
663
  log(`create invoice failed: ${err.message}`);
650
664
  return fail('Temporarily unable to issue an invoice.');
651
665
  }
652
- // Trust but verify: an invoice that does not commit to OUR payment
653
- // hash would take the payer's money and mint a note they can never
654
- // claim. Refuse to hand it out.
666
+ // Trust but verify: an invoice for the wrong amount would take the
667
+ // payer's money and mint a note worth something else.
655
668
  const decoded = tryDecodeBolt11(pr);
656
- if (!decoded || decoded.paymentHashHex !== paymentHash || decoded.amountMsats !== BigInt(amount)) {
657
- log('funding source returned an invoice that does not match the requested preimage/amount');
669
+ if (!decoded || decoded.amountMsats !== BigInt(amount)) {
670
+ log('funding source returned an invoice that does not match the requested amount');
658
671
  return fail('Temporarily unable to issue an invoice.');
659
672
  }
673
+ const paymentHash = decoded.paymentHashHex;
674
+ if (chosen !== null) {
675
+ // An invoice that does not commit to OUR payment hash is not the one
676
+ // that was asked for, and paying it would mint nothing claimable.
677
+ if (paymentHash !== chosen.paymentHash) {
678
+ log('funding source returned an invoice that does not match the requested preimage');
679
+ return fail('Temporarily unable to issue an invoice.');
680
+ }
681
+ }
682
+ else {
683
+ // The node chose the preimage, so every check the pre-chosen path got
684
+ // for free has to be made now - plus one it never needed. A hash this
685
+ // mint has already seen, or an invoice the node settled before handing
686
+ // it over, would credit a note against money that never moved on this
687
+ // quote's behalf.
688
+ if (paymentHash === outputId ||
689
+ store.outputIdInUse(paymentHash) ||
690
+ store.mintInvoiceByHash(paymentHash) ||
691
+ store.meltByHash(paymentHash)) {
692
+ log('funding source returned an invoice whose payment hash is already in use');
693
+ return fail('Temporarily unable to issue an invoice.');
694
+ }
695
+ let alreadySettled;
696
+ try {
697
+ alreadySettled = await backend.isInvoiceSettled(paymentHash);
698
+ }
699
+ catch (err) {
700
+ // Unconfirmable is not "fresh". Handing out an invoice that might
701
+ // already be paid is the whole risk this check exists to close.
702
+ log(`could not confirm a fresh invoice is unsettled: ${err.message}`);
703
+ return fail('Temporarily unable to issue an invoice.');
704
+ }
705
+ if (alreadySettled) {
706
+ log('funding source returned an invoice that was already settled');
707
+ return fail('Temporarily unable to issue an invoice.');
708
+ }
709
+ }
660
710
  try {
661
711
  store.recordMintInvoice(paymentHash, pr, amount, net, outputId);
662
712
  }
@@ -754,14 +804,27 @@ export const createMoneyer = async (config, deps = {}) => {
754
804
  }
755
805
  // ---- LUD-03 informational GET ----
756
806
  if (requestUrl.pathname === '/w') {
807
+ const hasK1 = q.has('k1');
808
+ const hasH = q.has('h');
757
809
  const k1 = q.get('k1')?.toLowerCase();
758
- if (!k1 || !HEX32.test(k1))
810
+ const h = q.get('h')?.toLowerCase();
811
+ if (hasK1 === hasH ||
812
+ (hasK1 && (!k1 || !HEX32.test(k1))) ||
813
+ (hasH && (!h || !HEX32.test(h)))) {
759
814
  return fail('Unknown note.');
760
- const note = await resolveNote(k1);
815
+ }
816
+ // LUD-25's hash-only check lets a wallet prove that the note it just
817
+ // bought exists without sending the bearer secret to the service a
818
+ // second time. `h` is accepted in place of `k1`, never alongside it.
819
+ const note = h ? await resolveNoteId(h) : await resolveNote(k1);
761
820
  if (!note)
762
821
  return fail('Unknown note.');
822
+ // A hash-only lookup must not disclose whether a note id once existed:
823
+ // LUD-25 gives a never-registered and an already-spent h the same
824
+ // response as an unknown k1. A caller presenting the bearer k1 still
825
+ // receives the useful already-spent distinction.
763
826
  if (note.state === 'burned')
764
- return fail('Note already spent.');
827
+ return fail(h ? 'Unknown note.' : 'Note already spent.');
765
828
  // A note reserved by an in-flight melt is not withdrawable, and must
766
829
  // not be advertised as though it were. LUD-25 makes this GET the way
767
830
  // anyone checks what a note is worth, so answering "live, worth all
@@ -780,7 +843,10 @@ export const createMoneyer = async (config, deps = {}) => {
780
843
  return send({
781
844
  tag: 'withdrawRequest',
782
845
  callback: `${origin}/w/cb`,
783
- k1,
846
+ // A hash-only query deliberately does not echo or invent k1. The
847
+ // wallet already has the secret; this endpoint only confirms the
848
+ // mint's record at sha256(k1).
849
+ ...(k1 ? { k1 } : {}),
784
850
  minWithdrawable: wholeSatFloor(note.amountMsat),
785
851
  maxWithdrawable: note.amountMsat,
786
852
  defaultDescription: config.description,
package/llms.txt CHANGED
@@ -15,13 +15,17 @@ Client library to build against a mint: lnurlcash-kit
15
15
 
16
16
  ## Rules this SERVICE follows (and any mint MUST)
17
17
 
18
- 1. A mint invoice's preimage is the note secret - the funding source must
19
- accept a caller-supplied preimage (cln and lnd do; phoenixd and NIP-47
20
- make_invoice do not, and cannot back a mint). UNLESS the payer sent
21
- `h` on the pay callback: then the note is credited at `h`, the payer's
22
- own secret, and the preimage is only a payment proof. Prefer that: a
23
- preimage is known to the funding source, to every node on the route,
24
- and to anyone who polls verify with the payment hash in the invoice.
18
+ 1. A mint quote MUST be named. The payer sends `comment=hex(sha256(secret))`
19
+ on the pay callback (LUD-12, and the payRequest advertises
20
+ `commentAllowed: 64`); a missing or malformed comment is refused before
21
+ any invoice is issued. The note is credited at that hash, so the secret
22
+ is the payer's alone. The payment preimage is settlement proof and never
23
+ a bearer credential - it is known to the funding source, to every node on
24
+ the route, and to anyone who polls verify with the payment hash written
25
+ inside the invoice. `h` is an additive alias for the same commitment and
26
+ never replaces the comment. Because the note is not keyed by the
27
+ preimage, a funding source whose node chooses its own preimages
28
+ (phoenixd, NIP-47 make_invoice) can back a mint.
25
29
  2. Mutations are atomic: all named k1 burn and all outputs mint, or
26
30
  nothing happens. Duplicate k1 in one request is refused.
27
31
  3. h/h2 are WALLET-supplied hashes; the mint never generates a
@@ -82,5 +86,5 @@ NoteStore - SQLite store; notes by sha256(k1), never secrets
82
86
  /.well-known/lnurlw/{user} LUD-25 mint address (experimental)
83
87
  /p/cb LUD-06 pay callback (optional h names the note)
84
88
  /verify/{payment_hash} LUD-21 verify (mint invoices AND melt payments)
85
- /w LUD-03 informational GET
89
+ /w LUD-03 informational GET by k1 or hash-only h
86
90
  /w/cb melt / rotate / split / merge
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",
@@ -60,7 +60,7 @@
60
60
  "@noble/hashes": "^2.3.0",
61
61
  "@scure/base": "^1.2.4",
62
62
  "farrier-kit": "^1.1.3",
63
- "lnurlcash-kit": "^0.6.0",
63
+ "lnurlcash-kit": "^0.7.0",
64
64
  "nostr-tools": "2.24.1"
65
65
  },
66
66
  "devDependencies": {
@@ -70,7 +70,7 @@
70
70
  "@types/node": "^24.0.0",
71
71
  "animejs": "^4.0.0",
72
72
  "happy-dom": "^20.0.0",
73
- "lnurlcash-conformance": "^0.5.0",
73
+ "lnurlcash-conformance": "^0.6.0",
74
74
  "playwright": "^1.62.1",
75
75
  "typescript": "^5.7.0",
76
76
  "uqr": "^0.1.2",