@forgesworn/moneyer 0.14.0 → 0.16.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,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 - 2026-09-16
4
+
5
+ - Bump `@lnurlcash/kit` from 0.14.2 to 0.18.1: the previous pin predated the
6
+ entire Schnorr `ck1` migration, so any note produced by a current wallet
7
+ was already unverifiable here. Brings the `ck1`/address-proof sha256
8
+ digest fix (signing `sha256(message)` instead of the raw string, matching
9
+ most conforming Schnorr signers) and a legacy-read fallback for notes
10
+ minted under the intermediate raw-message scheme.
11
+ - `spentRef` follows `recoverNoteOwnershipPubkey`'s new signature: it now
12
+ takes the `ck1` string directly and returns `{pubkeyXOnly, legacy}` rather
13
+ than raw recovered bytes.
14
+ - `claimMintedNote` now classifies a melt-in-flight note by catching the
15
+ kit's own `PendingNoteError`, replacing a `ServiceError{reason:'pending'}`
16
+ check the kit stopped throwing. The bump had silently broken this: an
17
+ unhandled `PendingNoteError` would have surfaced to a bound-mint quote's
18
+ poller as an unreachable-mint error rather than "pending" - untested
19
+ before this bump, now covered.
20
+
21
+ ## 0.15.0 - 2026-09-15
22
+
23
+ - Use `@lnurlcash/kit` 0.14 as the protocol implementation. Removed kit
24
+ convenience APIs that are Moneyer policy remain local rather than becoming
25
+ changes to the reference wallet package.
26
+ - Match the current reference mint's mutation proof formats: legacy hash
27
+ outputs receive the raw 65-byte Part 1 signature when signing is available;
28
+ `cp1` outputs receive an amount-bearing `cs1` certificate. Informational
29
+ lookup still publishes a certificate only for `cp1` notes.
30
+ - Grade against `lnurlcash-conformance` 0.11, including amount-bearing
31
+ certificates, Part 2 address ownership and internal transfers.
32
+ - Publish a key-backed name's `cx1:<next-free-index>` as `text/xpub` metadata
33
+ so a wallet already holding Moneyer notes can pay it by an ordinary
34
+ rotate/split/merge without Lightning. The hint is derived from current
35
+ ledger occupancy and public-key output collisions receive the reference
36
+ retry signal.
37
+
3
38
  ## 0.14.0 - 2026-09-11
4
39
 
5
40
  - **A plain note is unsigned.** LUD-25 Part 2 certifies `cp1` notes only:
package/README.md CHANGED
@@ -9,7 +9,7 @@ split, merge and melt it against the withdraw callback. The note's spend
9
9
  secret is chosen by the buyer, before the invoice exists, and named to the
10
10
  mint only as a hash - so the mint never holds it, and neither does any node
11
11
  that forwards the payment. It passes the full
12
- [lnurlcash-conformance](https://github.com/TheCryptoDonkey/lnurlcash-conformance)
12
+ [lnurlcash-conformance](https://github.com/lnurlcash/lnurlcash-conformance)
13
13
  grader, including the spending checks, and the grader runs in this repo's
14
14
  own test suite.
15
15
 
@@ -43,14 +43,10 @@ lesson, that behaviour is kept deliberately and tested.
43
43
 
44
44
  ## Run
45
45
 
46
- Not on npm yet - build from source, with the sibling repos it links
47
- against until those publish:
46
+ Install the published package and build it:
48
47
 
49
48
  ```bash
50
- git clone https://github.com/TheCryptoDonkey/lnurlcash-kit
51
- git clone https://github.com/TheCryptoDonkey/lnurlcash-conformance
52
49
  git clone https://github.com/forgesworn/moneyer
53
- (cd lnurlcash-kit && npm install && npm run build)
54
50
  cd moneyer && npm install && npm run build && npm run web:build
55
51
  ```
56
52
 
@@ -144,7 +140,7 @@ payRequest metadata's fee prose), `version`, and `previousPubkeys`.
144
140
 
145
141
  `GET /` serves the mint's own site (`web/`, vite + anime.js, built by
146
142
  `npm run web:build` into memory-served static files). It is a wallet-grade
147
- LNURLcash client in its own right, driven entirely by `lnurlcash-kit`
143
+ LNURLcash client in its own right, driven entirely by `@lnurlcash/kit`
148
144
  against the same endpoints every wallet uses:
149
145
 
150
146
  - **Mint a note in the browser**: amount in, fee grossed up and shown
@@ -292,7 +288,7 @@ single mint's implementation.
292
288
  LUD-25 gives the fee as a flat `base_fee_msat` plus a ppm cut and says
293
289
  nothing about rounding. dni's lnurl-mint, the reference, ceilings it to a
294
290
  whole sat on purpose so the mint is never short a sat; moneyer withholds
295
- the msat-exact amount. Neither is out of spec - lnurlcash-kit's
291
+ the msat-exact amount. Neither is out of spec - `@lnurlcash/kit`'s
296
292
  `mintFeeBand` treats anything between the two as the mint keeping its
297
293
  word, and the conformance grader accepts both.
298
294
 
@@ -354,8 +350,8 @@ named or spent, the two kinds mix freely:
354
350
  - `GET /w?k1=<ck1>` or `GET /w?p=<cp1>` looks a Part 2 note up, and the
355
351
  answer carries `sig`, the mint's certificate for it as `cs1`.
356
352
  - `p1`/`p2` on the callback may be `cp1` keys, and the matching `sig`/`sig2`
357
- come back as `cs1`. A hash output gets none: a plain note is unsigned by
358
- design, since there is nothing to attest to without disclosing the secret.
353
+ come back as amount-bearing `cs1`. A legacy hash output instead receives
354
+ the raw 65-byte Part 1 signature used by the reference mint.
359
355
  - A merge may combine Part 1 secrets and Part 2 `ck1`s in one request.
360
356
 
361
357
  A recipient checks a Part 2 note offline from its `ck1` and `cs1` alone.
@@ -411,7 +407,7 @@ whose secret was a preimage could never offer.
411
407
 
412
408
  The proposed normative wire text, invalid cases and compatibility matrix are
413
409
  kept executable in
414
- [`lnurlcash-conformance`](https://github.com/TheCryptoDonkey/lnurlcash-conformance/blob/main/docs/BOUND-MINT-RECEIPTS.md).
410
+ [`lnurlcash-conformance`](https://github.com/lnurlcash/lnurlcash-conformance/blob/main/docs/BOUND-MINT-RECEIPTS.md).
415
411
 
416
412
  ## A retried mutation is answered, not refused
417
413
 
@@ -555,7 +551,7 @@ and credits the note to that key. It skips any key already holding a note,
555
551
  and an invoice nobody pays takes no index at all, so a wallet scanning its
556
552
  branch never meets a gap it did not make. The gift wrap still goes to the
557
553
  owner's npub, but it carries no secret: a lookup URL,
558
- `https://mint.example/w?p=<cp1>&amount=<msat>&sig=<cs1>&i=<index>`, plus an
554
+ `https://mint.example/w?p=<cp1>&sig=<amount-bearing-cs1>&i=<index>`, plus an
559
555
  `i` tag. The wallet derives the key at that index, checks the certificate,
560
556
  and spends with its own `ck1`. A wallet that only knows note URLs sees no
561
557
  `k1` and passes the wrap by, and the note waits at the mint for a scan of the
@@ -568,6 +564,13 @@ in `MONEYER_ZAP_NAMES`.
568
564
  Sending a new branch starts it at index 0; sending the same one again keeps
569
565
  its place. Zap receipts are unchanged.
570
566
 
567
+ The name's payRequest also publishes `['text/xpub', '<cx1>:<index>']`, where
568
+ the index is the next key not already present in this mint's ledger. A payer
569
+ already holding a note here can therefore rotate, split or merge straight to
570
+ that `cp1` without creating or routing a Lightning invoice. The index is a
571
+ hint: if another payment wins the key first, Moneyer returns `Output already
572
+ in use.` so the reference kit advances and retries.
573
+
571
574
  ## Reaching the mint over Tor
572
575
 
573
576
  Set `MONEYER_ONION_URL` to this mint's hidden service address and a request
@@ -707,14 +710,14 @@ against live nodes; treat them as beta until they have.
707
710
  ## Dogfood
708
711
 
709
712
  Part of the ForgeSworn LNURLcash stack: built on
710
- [`lnurlcash-kit`](https://github.com/TheCryptoDonkey/lnurlcash-kit) (fee
713
+ [`@lnurlcash/kit`](https://github.com/lnurlcash/lnurl-wallet/tree/main/src/lib) (fee
711
714
  maths, hashing, signature agreement) and
712
715
  [`farrier-kit`](https://github.com/forgesworn/farrier-kit) (BOLT-11
713
716
  decoding and preimage verification on the melt path). The companion wallet
714
717
  is [`@forgesworn/notecase`](https://github.com/forgesworn/notecase).
715
718
 
716
719
  Other mints, wallets and libraries speaking the same protocol are indexed
717
- in [awesome-lnurlcash](https://github.com/TheCryptoDonkey/awesome-lnurlcash).
720
+ in [awesome-lnurlcash](https://github.com/lnurlcash/awesome-lnurlcash).
718
721
 
719
722
  ## Licence
720
723
 
package/dist/admin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
3
3
  import { secp256k1 } from '@noble/curves/secp256k1.js';
4
- import { hashK1, noteDeclaredAmount, noteK1, noteSignature, verifyNoteSignature } from 'lnurlcash-kit';
4
+ import { hashK1, noteDeclaredAmount, noteK1, noteSignature, verifyNoteSignature } from '@lnurlcash/kit';
5
5
  import { configFromEnv, pubkeyHex } from "./config.js";
6
6
  import { NoteStore } from "./store.js";
7
7
  import { buildStats } from "./stats.js";
@@ -0,0 +1,15 @@
1
+ /** Result of checking the wallet-chosen secret behind a bound mint quote. */
2
+ export type MintClaim = {
3
+ state: 'minted' | 'unminted' | 'pending' | 'spent';
4
+ k1: string;
5
+ amountMsat: number | null;
6
+ callback: string | null;
7
+ };
8
+ /**
9
+ * Inspect the note a wallet named before requesting its invoice.
10
+ *
11
+ * This is Moneyer's operational orchestration, not a wire primitive: it
12
+ * classifies the read-only lookup so the live check can poll an unpaid quote
13
+ * without mistaking an unreachable mint for an unminted note.
14
+ */
15
+ export declare const claimMintedNote: (withdrawLink: string, k1: string) => Promise<MintClaim>;
package/dist/claim.js ADDED
@@ -0,0 +1,32 @@
1
+ import { NoteSpentError, NoteUnknownError, PendingNoteError, buildNoteUrl, fetchNoteInfo, isPreimage } from '@lnurlcash/kit';
2
+ /**
3
+ * Inspect the note a wallet named before requesting its invoice.
4
+ *
5
+ * This is Moneyer's operational orchestration, not a wire primitive: it
6
+ * classifies the read-only lookup so the live check can poll an unpaid quote
7
+ * without mistaking an unreachable mint for an unminted note.
8
+ */
9
+ export const claimMintedNote = async (withdrawLink, k1) => {
10
+ const secret = k1.trim().toLowerCase();
11
+ if (!isPreimage(secret))
12
+ throw new Error('A note secret must be 32 bytes of hex - nothing was sent.');
13
+ const blank = { k1: secret, amountMsat: null, callback: null };
14
+ try {
15
+ const info = await fetchNoteInfo(buildNoteUrl(withdrawLink, secret));
16
+ return {
17
+ state: 'minted',
18
+ k1: secret,
19
+ amountMsat: info.maxWithdrawable,
20
+ callback: info.callback
21
+ };
22
+ }
23
+ catch (error) {
24
+ if (error instanceof PendingNoteError)
25
+ return { ...blank, state: 'pending' };
26
+ if (error instanceof NoteSpentError)
27
+ return { ...blank, state: 'spent' };
28
+ if (error instanceof NoteUnknownError)
29
+ return { ...blank, state: 'unminted' };
30
+ throw error;
31
+ }
32
+ };
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
3
  import { bytesToHex, randomBytes } from '@noble/hashes/utils.js';
4
- import { buildNoteUrl, hashK1 } from 'lnurlcash-kit';
4
+ import { buildNoteUrl, hashK1 } from '@lnurlcash/kit';
5
5
  import { configFromEnv } from "./config.js";
6
6
  import { createMoneyer } from "./server.js";
7
7
  // moneyer - strike LNURLcash bearer notes.
package/dist/config.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { MintFee } from 'lnurlcash-kit';
1
+ import type { MintFee } from '@lnurlcash/kit';
2
2
  export type BackendConfig = {
3
3
  kind: 'fake';
4
4
  autoSettle?: boolean;
package/dist/fee-words.js CHANGED
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // The raw pair is spelled out deliberately. The machine-readable line
6
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
7
+ // shape @lnurlcash/kit parses, anchored and digits-only, so it cannot
8
8
  // carry its own units - and a reader who has not parsed LUD-25 reads
9
9
  // both numbers as satoshis. A tester who thinks the fee is 5000 sats on
10
10
  // a 10k sat mint has been told something alarming and false, so the
package/dist/landing.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { MINT_KNOWS, MINT_KNOWS_HEADING } from "./privacy.js";
2
2
  import { feeInUnits } from "./fee-words.js";
3
- import { applyMintFee } from 'lnurlcash-kit';
3
+ import { applyMintFee } from '@lnurlcash/kit';
4
4
  // The mint's face: one self-contained page at GET /, no build step, no
5
5
  // external assets. It states what a visitor needs before trusting a mint
6
6
  // with sats: who it is, what it charges, the key its notes verify
@@ -101,7 +101,7 @@ ${config.tosUrl ? `<div class="kv"><span>terms</span><a href="${escapeHtml(confi
101
101
  <h2>${escapeHtml(MINT_KNOWS_HEADING)}</h2>
102
102
  ${MINT_KNOWS.map(paragraph => `<p>${escapeHtml(paragraph)}</p>`).join('\n')}
103
103
  </section>
104
- <p class="small">Works with any LUD-25 wallet - <a href="https://github.com/forgesworn/notecase">notecase</a> among them. Verify a note offline against the signing key above.<br/>Independent implementation of the <a href="https://github.com/lnurl/luds/pull/301">LNURLcash draft</a> - graded by <a href="https://github.com/TheCryptoDonkey/lnurlcash-conformance">lnurlcash-conformance</a>.</p>
104
+ <p class="small">Works with any LUD-25 wallet - <a href="https://github.com/forgesworn/notecase">notecase</a> among them. Verify a note offline against the signing key above.<br/>Independent implementation of the <a href="https://github.com/lnurl/luds/pull/301">LNURLcash draft</a> - graded by <a href="https://github.com/lnurlcash/lnurlcash-conformance">lnurlcash-conformance</a>.</p>
105
105
  </main>
106
106
  </body>
107
107
  </html>`;
@@ -1,4 +1,4 @@
1
- import { type InvoiceResult } from 'lnurlcash-kit';
1
+ import { type InvoiceResult } from '@lnurlcash/kit';
2
2
  export type LiveCheckStage = 'prepared' | 'quoted' | 'settled' | 'claimed' | 'retiring' | 'retired';
3
3
  export type LiveCheckState = {
4
4
  version: 1;
@@ -1,7 +1,8 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { lstat, open, readFile, rename, unlink } from 'node:fs/promises';
3
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';
4
+ import { NoteSpentError, PendingNoteError, buildNoteUrl, decodeBolt11AmountMsat, fetchInvoiceVerification, fetchPayRequest, hashK1, isBolt11Invoice, isPreimage, meltNote, probeBurnedNote, requestInvoice, requireBoundMintQuote, validateBoundMintReceipt, withinMintFeeBand } from '@lnurlcash/kit';
5
+ import { claimMintedNote } from "./claim.js";
5
6
  const stages = new Set(['prepared', 'quoted', 'settled', 'claimed', 'retiring', 'retired']);
6
7
  const errno = (error) => error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
7
8
  const serialise = (state) => `${JSON.stringify(state, null, 2)}\n`;
@@ -215,7 +216,7 @@ export const runLiveBoundMintCheck = async (options) => {
215
216
  if (state.grossMsat < pay.minSendable || state.grossMsat > pay.maxSendable) {
216
217
  throw new Error(`Test amount is outside the mint range ${pay.minSendable}-${pay.maxSendable} msat.`);
217
218
  }
218
- const quote = await requestInvoice(pay.callback, state.grossMsat, { h: state.h });
219
+ const quote = await requestInvoice(pay.callback, state.grossMsat, state.h);
219
220
  if (!quote.verify || !quote.mint)
220
221
  throw new Error('Mint did not bind this quote to h and a verification URL.');
221
222
  const netMsat = quote.mint.amountMsat;
package/dist/names.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { sha256 } from '@noble/hashes/sha2.js';
2
2
  import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
3
3
  import { verifyEvent } from 'nostr-tools/pure';
4
- import { decodeCx1, hashK1, noteK1 } from 'lnurlcash-kit';
4
+ import { decodeCx1, hashK1, noteK1 } from '@lnurlcash/kit';
5
5
  import { NotePendingError, NoteStore, NoteUnavailableError } from "./store.js";
6
6
  // Self-service lightning addresses.
7
7
  //
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
3
3
  import { finalizeEvent } from 'nostr-tools/pure';
4
- import { applyMintFee, decodeCk1, decodeCp1, encodeCs1, grossUpForMintFee, hashK1, recoverNoteOwnershipPubkey } from 'lnurlcash-kit';
4
+ import { applyMintFee, decodeCp1, encodeCs1WithAmount, grossUpForMintFee, hashK1, recoverNoteOwnershipPubkey } 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";
@@ -32,9 +32,8 @@ const namedRef = (value) => {
32
32
  const spentRef = (k1) => {
33
33
  if (HEX32.test(k1))
34
34
  return { id: hashK1(k1), cp1: false };
35
- const signature = decodeCk1(k1);
36
- const pubkey = signature ? recoverNoteOwnershipPubkey(signature) : null;
37
- return pubkey ? { id: bytesToHex(pubkey), cp1: true } : null;
35
+ const owner = recoverNoteOwnershipPubkey(k1);
36
+ return owner ? { id: bytesToHex(owner.pubkeyXOnly), cp1: true } : null;
38
37
  };
39
38
  // LUD-25 renamed `h` to `p` on the lookup and `h`/`h2` to `p1`/`p2` on the
40
39
  // callback. The old names stay accepted; sending both spellings must agree.
@@ -92,9 +91,13 @@ export const createMoneyer = async (config, deps = {}) => {
92
91
  const backend = deps.backend ?? backendFor(config);
93
92
  const signer = createNoteSigner(config.signingKey);
94
93
  // LUD-25 Part 2 signs cp1 notes only, as cs1. A hash output is a plain
95
- // Part 1 note: there is nothing to attest to without disclosing the
96
- // secret, so it goes out unsigned.
97
- const certify = (ref, amountMsat) => ref.cp1 ? encodeCs1(hexToBytes(signer.sign(ref.id, amountMsat))) : undefined;
94
+ // Match the reference mint's wire format: legacy hash outputs carry the
95
+ // compact signature as hex, while public-key outputs carry the same
96
+ // signature and amount in a cs1 certificate.
97
+ const certify = (ref, amountMsat) => {
98
+ const signature = signer.sign(ref.id, amountMsat);
99
+ return ref.cp1 ? encodeCs1WithAmount(amountMsat, hexToBytes(signature)) : signature;
100
+ };
98
101
  const certified = (field, ref, amountMsat) => {
99
102
  const sig = certify(ref, amountMsat);
100
103
  return sig ? { [field]: sig } : {};
@@ -123,7 +126,7 @@ export const createMoneyer = async (config, deps = {}) => {
123
126
  if (!config.mintFee)
124
127
  return 0;
125
128
  const exact = grossMsat - applyMintFee(grossMsat, config.mintFee);
126
- // Both readings sit inside lnurlcash-kit's mintFeeBand, so a wallet
129
+ // Both readings sit inside @lnurlcash/kit's mintFeeBand, so a wallet
127
130
  // is not misled either way - it is told the range up front.
128
131
  //
129
132
  // Absent means ON, the same as DEFAULTS and MONEYER_ROUND_FEE_TO_SAT:
@@ -280,7 +283,7 @@ export const createMoneyer = async (config, deps = {}) => {
280
283
  ...(nodeInfo.uri ? { nodeUri: nodeInfo.uri } : {}),
281
284
  ...(nodeInfo.color ? { nodeColor: nodeInfo.color } : {}),
282
285
  // nodeCapacity is the name the reference mint, the conformance
283
- // mock and lnurlcash-kit all use; nodeCapacityMsat was ours alone
286
+ // mock and @lnurlcash/kit all use; nodeCapacityMsat was ours alone
284
287
  // and only survived a round trip through the kit's rest-spread.
285
288
  // Both go out for one release, then the old name goes.
286
289
  ...(nodeInfo.capacityMsat !== undefined
@@ -929,9 +932,10 @@ export const createMoneyer = async (config, deps = {}) => {
929
932
  // exists.
930
933
  ...(mirrors.length ? { mirrors } : {}),
931
934
  ...(signer ? { mintPubkey: signer.pubkey } : {}),
932
- // LUD-25 Part 2: a note named by cp1 or ck1 gets its certificate
933
- // here, so a wallet need not rotate just to obtain one.
934
- ...certified('sig', ref, note.amountMsat)
935
+ // Part 2: a note named by cp1 or ck1 gets its certificate here, so
936
+ // a wallet need not rotate just to obtain one. The reference mint
937
+ // omits the legacy raw signature from this informational response.
938
+ ...(ref.cp1 ? certified('sig', ref, note.amountMsat) : {})
935
939
  });
936
940
  }
937
941
  // ---- the mutating callback: melt / rotate / split / merge ----
@@ -1141,6 +1145,8 @@ export const createMoneyer = async (config, deps = {}) => {
1141
1145
  catch (err) {
1142
1146
  if (err instanceof NotePendingError)
1143
1147
  return fail('pending');
1148
+ if (err instanceof OutputCollisionError && (o1.cp1 || o2.cp1))
1149
+ return fail('Output already in use.');
1144
1150
  // OutputCollisionError shares the dead-k1 reason on purpose:
1145
1151
  // which table the id collided with is an oracle nobody is owed.
1146
1152
  return fail('Invalid or already spent k1.');
@@ -1162,6 +1168,8 @@ export const createMoneyer = async (config, deps = {}) => {
1162
1168
  catch (err) {
1163
1169
  if (err instanceof NotePendingError)
1164
1170
  return fail('pending');
1171
+ if (err instanceof OutputCollisionError && o1.cp1)
1172
+ return fail('Output already in use.');
1165
1173
  // same oracle-free reason for a collision as for a dead k1
1166
1174
  return fail('Invalid or already spent k1.');
1167
1175
  }
package/dist/signing.js CHANGED
@@ -6,7 +6,7 @@ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
6
6
  //
7
7
  // "LNURLcash:" || amount_msat || ":" || note_id
8
8
  //
9
- // where note_id is hex(sha256(k1)). lnurlcash-kit's noteSignatureDigest
9
+ // where note_id is hex(sha256(k1)). @lnurlcash/kit's noteSignatureDigest
10
10
  // takes the SECRET and hashes it itself, which a wallet holds and a mint
11
11
  // never does - so the digest is rebuilt here from the id. The two must
12
12
  // agree byte for byte; the test suite proves they do by verifying every
@@ -21,7 +21,7 @@ export const signDigestRecoverable = (digest, privateKeyHex) => {
21
21
  return bytesToHex(new Uint8Array([...lead.subarray(1), lead[0]]));
22
22
  };
23
23
  // Does this signature recover to that public key? Both byte orders are
24
- // tried for the same reason lnurlcash-kit tries both: recovery-id-first is
24
+ // tried for the same reason @lnurlcash/kit tries both: recovery-id-first is
25
25
  // what noble emits, recovery-id-last is what the wire carries.
26
26
  export const recoversToPubkey = (digest, signatureHex, pubkeyHex) => {
27
27
  let wireSig;
package/dist/stats.js CHANGED
@@ -79,7 +79,7 @@ export const verifyStatsSignature = (stats, signatureHex, mintPubkeyHex) => {
79
79
  return false;
80
80
  }
81
81
  const target = mintPubkeyHex.trim().toLowerCase();
82
- // Both byte orders are tried for the same reason lnurlcash-kit tries
82
+ // Both byte orders are tried for the same reason @lnurlcash/kit tries
83
83
  // both: recovery-id-first is what noble emits, recovery-id-last is what
84
84
  // the wire carries.
85
85
  const recoveryIdFirst = new Uint8Array([wireSig[64], ...wireSig.subarray(0, 64)]);
package/dist/zap.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
2
- import { decodeCx1, deriveNotePubkey, encodeCp1, hashK1 } from 'lnurlcash-kit';
2
+ import { decodeCx1, deriveNotePubkey, encodeCp1, hashK1 } from '@lnurlcash/kit';
3
3
  import { tryDecodeBolt11 } from 'farrier-kit/bolt11';
4
4
  import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
5
5
  import { SimplePool } from 'nostr-tools/pool';
@@ -78,11 +78,13 @@ export const inboxRelays = async (transport, pubkey, lookOn) => {
78
78
  return [];
79
79
  return [...new Set(latest.tags.filter(t => t[0] === 'relay' && t[1]).map(t => t[1]))];
80
80
  };
81
- const metadataFor = (name, host, mintFeeLine, feeInWords = null) => {
81
+ const metadataFor = (name, host, mintFeeLine, feeInWords = null, internalTransfer) => {
82
82
  const metadata = [
83
83
  ['text/plain', `Zap ${name}@${host}: arrives as a Lightning bearer note${feeInWords ? ` (${feeInWords})` : ''}`],
84
84
  ['text/identifier', `${name}@${host}`]
85
85
  ];
86
+ if (internalTransfer)
87
+ metadata.push(['text/xpub', internalTransfer]);
86
88
  if (mintFeeLine)
87
89
  metadata.push(['text/plain', mintFeeLine]);
88
90
  return JSON.stringify(metadata);
@@ -102,14 +104,32 @@ export const createZapBridge = (deps) => {
102
104
  const isZapName = (name) => store.zapName(name.toLowerCase()) !== null;
103
105
  const payRequest = (name) => {
104
106
  const lowered = name.toLowerCase();
105
- if (!isZapName(lowered))
107
+ const registered = store.zapName(lowered);
108
+ if (!registered)
106
109
  return null;
110
+ let internalTransfer;
111
+ const branch = decodeCx1(registered.cx1 ?? '');
112
+ if (branch) {
113
+ let index = registered.nextIndex;
114
+ for (let tries = 0; tries < 1000; tries++, index++) {
115
+ try {
116
+ const noteId = bytesToHex(deriveNotePubkey(branch.pubkeyXOnly, branch.chainCode, index));
117
+ if (!store.outputIdInUse(noteId)) {
118
+ internalTransfer = `${registered.cx1}:${index}`;
119
+ break;
120
+ }
121
+ }
122
+ catch {
123
+ // A vanishingly rare unusable tweak consumes this public index.
124
+ }
125
+ }
126
+ }
107
127
  return {
108
128
  tag: 'payRequest',
109
129
  callback: `${origin}/z/cb/${lowered}`,
110
130
  minSendable: deps.minSendableMsat,
111
131
  maxSendable: deps.maxSendableMsat,
112
- metadata: metadataFor(lowered, host, deps.mintFeeLine, deps.feeInWords),
132
+ metadata: metadataFor(lowered, host, deps.mintFeeLine, deps.feeInWords, internalTransfer),
113
133
  allowsNostr: true,
114
134
  nostrPubkey: pubkey
115
135
  // Deliberately no withdrawLink: the preimage of this invoice is NOT
@@ -204,7 +224,7 @@ export const createZapBridge = (deps) => {
204
224
  // the certificate, and the index `i` the holder derives the key at. A
205
225
  // reader that knows only note URLs sees no k1 and passes it by, and the
206
226
  // note waits at the mint for a scan to find it.
207
- const buildKeyWrap = (row, noteId, index) => wrap(row, [...wrapTags(row), ['i', String(index)]], `${origin}/w?p=${encodeCp1(hexToBytes(noteId))}&amount=${row.netMsat}&sig=${deps.certify(noteId, row.netMsat)}&i=${index}`);
227
+ const buildKeyWrap = (row, noteId, index) => wrap(row, [...wrapTags(row), ['i', String(index)]], `${origin}/w?p=${encodeCp1(hexToBytes(noteId))}&sig=${deps.certify(noteId, row.netMsat)}&i=${index}`);
208
228
  // NIP-57 receipt, without the preimage tag: it is optional there, and
209
229
  // here it would only invite someone to mistake it for the note.
210
230
  const buildReceipt = (row) => {
package/llms.txt CHANGED
@@ -5,13 +5,13 @@ paying an invoice it issues mints a note whose spend secret is the
5
5
  invoice's payment preimage, or a secret the buyer named with `h` on the
6
6
  pay callback. ESM-only. MIT.
7
7
 
8
- Install: not yet on npm - build from source (see README)
8
+ Install: npm install @forgesworn/moneyer
9
9
  Run: node dist/cli.js --dev (fake funding source, prints a funded note)
10
10
 
11
11
  Draft spec: https://github.com/lnurl/luds/pull/301
12
- Conformance: https://github.com/TheCryptoDonkey/lnurlcash-conformance
12
+ Conformance: https://github.com/lnurlcash/lnurlcash-conformance
13
13
  Companion wallet: @forgesworn/notecase
14
- Client library to build against a mint: lnurlcash-kit
14
+ Client library to build against a mint: @lnurlcash/kit
15
15
 
16
16
  ## Rules this SERVICE follows (and any mint MUST)
17
17
 
@@ -95,5 +95,7 @@ POST /names claim a name (NIP-98; the signer owns it); body {nam
95
95
  The owner may resend {name, cx1} to set a branch, or cx1: null to clear it
96
96
  /z/cb/{name} zap invoice for a name. With a cx1 the settled note is credited to the
97
97
  holder's next key, and the kind 2525 gift wrap carries no secret:
98
- /w?p=<cp1>&amount=&sig=<cs1>&i=<index> plus an `i` tag. The index is
98
+ /w?p=<cp1>&sig=<amount-bearing-cs1>&i=<index> plus an `i` tag. The index is
99
99
  taken at settlement, never at the invoice, and occupied keys are skipped
100
+ Its payRequest metadata publishes text/xpub = cx1:<next-free-index> for
101
+ same-mint rotate/split/merge transfers; a raced key returns "already in use"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.14.0",
3
+ "version": "0.16.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",
@@ -56,11 +56,11 @@
56
56
  "live:bound-mint": "npm run build && node scripts/live-bound-mint-check.mjs"
57
57
  },
58
58
  "dependencies": {
59
+ "@lnurlcash/kit": "0.18.1",
59
60
  "@noble/curves": "^2.3.0",
60
61
  "@noble/hashes": "^2.3.0",
61
62
  "@scure/base": "^1.2.4",
62
63
  "farrier-kit": "^1.1.3",
63
- "lnurlcash-kit": "^0.13.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.10.0",
73
+ "lnurlcash-conformance": "0.11.1",
74
74
  "playwright": "^1.62.1",
75
75
  "typescript": "^5.7.0",
76
76
  "uqr": "^0.1.2",