@forgesworn/moneyer 0.11.1 → 0.13.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,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0 - 2026-09-11
4
+
5
+ - **Names that pay to the holder's own keys.** `POST /names` takes a `cx1`,
6
+ a watch-only branch, and the key that owns a name can set or clear one
7
+ later. A zap to such a name is credited to the holder's next key on the
8
+ branch, and the gift wrap carries only where to look and the index:
9
+ `/w?p=<cp1>&amount=&sig=<cs1>&i=`, plus an `i` tag. The mint never holds
10
+ a secret for it.
11
+ - The index is taken when the zap settles, never when the invoice is made,
12
+ so an unpaid invoice leaves no gap in the holder's scan. A key that
13
+ already holds a note is skipped.
14
+ - Names gain two columns, `cx1` and `next_index`, added in place on first
15
+ start.
16
+
17
+ ## 0.12.0 - 2026-09-11
18
+
19
+ - LUD-25's renamed parameters are accepted: `p` for the informational
20
+ GET's hash lookup, `p1`/`p2` for the callback's outputs. The old names
21
+ `h` and `h`/`h2` keep working, and a wallet may mix them in one request.
22
+ Sending both spellings of one value with different contents is refused.
23
+ A retry is matched on the outputs, not the spelling, so a split first
24
+ sent as `h`/`h2` and retried as `p1`/`p2` is answered as a replay.
25
+ - The callback's missing-output reasons are now `missing p1` and
26
+ `missing p2` (were `missing h`/`missing h2`), and `p1 and p2 must
27
+ differ.` The pay callback's `missing h` is unchanged.
28
+ - **LUD-25 Part 2 notes.** A note can be keyed by a public key and spent
29
+ with a recoverable signature, so the mint never holds anything that could
30
+ spend it. `comment=cp1…` mints one; `k1=ck1…` looks it up or spends it;
31
+ `p`, `p1` and `p2` take `cp1` keys; and a Part 2 note's certificate comes
32
+ back as `cs1`, on the lookup and on every rotate, split and merge. The two
33
+ kinds mix freely in one merge.
34
+ - A note named twice in one request is now caught by the note each `k1`
35
+ spends rather than by the `k1` string, since one Part 2 note has many
36
+ valid `ck1` strings. The store refuses a repeated input as well.
37
+ - Takes lnurlcash-kit 0.10.0.
38
+
3
39
  ## 0.11.1 - 2026-09-07
4
40
 
5
41
  - Hash lookups report `Note already spent.` for retained burned notes,
@@ -9,6 +45,9 @@
9
45
  burned rows are retained already; no database migration is required.
10
46
  - Test tooling uses an immutable conformance commit matching this response
11
47
  contract. It is a development dependency, not a runtime dependency.
48
+ Since 2026-09-09 that contract is released as `lnurlcash-conformance`
49
+ 0.8.0 and the pin is the version rather than the commit; the graded bytes
50
+ are the same ones 0.11.1 shipped against.
12
51
 
13
52
  ## 0.11.0 - 2026-09-06
14
53
 
package/README.md CHANGED
@@ -344,6 +344,21 @@ preimage then buys nothing: it is an ordinary payment proof, which is why
344
344
  there is no race left to run, and no window in which holding the invoice
345
345
  is nearly holding the money.
346
346
 
347
+ ### Or name it by a key (LUD-25 Part 2)
348
+
349
+ The comment can instead be `cp1<pk>`, a public key. The note is keyed by
350
+ that key and spent with `ck1`, a recoverable signature by its private key,
351
+ so the mint never sees anything that could spend it. Everywhere a note is
352
+ named or spent, the two kinds mix freely:
353
+
354
+ - `GET /w?k1=<ck1>` or `GET /w?p=<cp1>` looks a Part 2 note up, and the
355
+ answer carries `sig`, the mint's certificate for it as `cs1`.
356
+ - `p1`/`p2` on the callback may be `cp1` keys, and the matching `sig`/`sig2`
357
+ come back as `cs1`. A hash output still gets a plain hex signature.
358
+ - A merge may combine Part 1 secrets and Part 2 `ck1`s in one request.
359
+
360
+ A recipient checks a Part 2 note offline from its `ck1` and `cs1` alone.
361
+
347
362
  Moneyer's earlier `mintToHash` extension remains additive. A compatible
348
363
  wallet repeats the same commitment as `h`; it does not replace `comment`:
349
364
 
@@ -416,9 +431,10 @@ signatures, nothing burned, nothing minted, no balance moved. A retry is
416
431
  a read.
417
432
 
418
433
  A request is the same request when it names the same input notes and asks
419
- for the same outputs: the same `h`, the same `h2`, the same `amount`.
434
+ for the same outputs: the same `p1`, the same `p2`, the same `amount`.
420
435
  Input order does not matter, because a reordered retry is the same
421
- operation. Anything else naming a burned note is a double-spend attempt
436
+ operation, and nor does spelling: `h`/`h2`, the older names for
437
+ `p1`/`p2`, name the same outputs. Anything else naming a burned note is a double-spend attempt
422
438
  and still gets `Invalid or already spent k1.`, unchanged. Provenance is
423
439
  recorded rather than inferred for that reason: matching on "a note exists
424
440
  at `h`" alone would let anyone holding a burned `k1` and any outstanding
@@ -526,6 +542,28 @@ A registered name resolves on both rails at once:
526
542
  The discovery endpoint advertises `namePriceMsat` while registration is
527
543
  open, so a wallet can offer the flow without asking.
528
544
 
545
+ ### A name that pays to your own keys (LUD-25 Part 2)
546
+
547
+ Add `"cx1": "cx1..."` to the registration body, or send `{"name", "cx1"}`
548
+ again later from the key that owns the name, and payments to it stop being
549
+ custodial. A `cx1` is a watch-only branch: from it the mint can work out each
550
+ of the holder's note keys in turn, but never spend one.
551
+
552
+ When a zap to the name settles, the mint takes the next index on the branch
553
+ and credits the note to that key. It skips any key already holding a note,
554
+ and an invoice nobody pays takes no index at all, so a wallet scanning its
555
+ branch never meets a gap it did not make. The gift wrap still goes to the
556
+ owner's npub, but it carries no secret: a lookup URL,
557
+ `https://mint.example/w?p=<cp1>&amount=<msat>&sig=<cs1>&i=<index>`, plus an
558
+ `i` tag. The wallet derives the key at that index, checks the certificate,
559
+ and spends with its own `ck1`. A wallet that only knows note URLs sees no
560
+ `k1` and passes the wrap by, and the note waits at the mint for a scan of the
561
+ branch.
562
+
563
+ `"cx1": null` clears the branch and puts the name back on the custodial path.
564
+ Sending a new branch starts it at index 0; sending the same one again keeps
565
+ its place. Zap receipts are unchanged.
566
+
529
567
  ## Reaching the mint over Tor
530
568
 
531
569
  Set `MONEYER_ONION_URL` to this mint's hidden service address and a request
@@ -586,11 +624,11 @@ clearnet.
586
624
  | `/.well-known/lnurlp/<user>` | LUD-16 payRequest; paying mints a note |
587
625
  | `/.well-known/lnurlp/<zap name>` | NIP-57 payRequest; paying mints a note *to the name's pubkey* |
588
626
  | `/.well-known/lnurlw/<user>` | LUD-25 mint address discovery (experimental) |
589
- | `/p/cb` | LUD-06 pay callback; issues the mint invoice, and takes an optional `h` naming the note |
627
+ | `/p/cb` | LUD-06 pay callback; issues the mint invoice for the note the `comment` names, a hash or a `cp1` key (`h` is an older spelling for a hash) |
590
628
  | `/z/cb/<zap name>` | the zap callback; validates the kind 9734 and issues the invoice |
591
629
  | `/verify/<hash>` | LUD-21 verify, for mint invoices and melt payments |
592
- | `/w` | LUD-03 informational GET; accepts `k1` or the non-disclosing LUD-25 `h=sha256(k1)` check; a live note also carries `payLink` |
593
- | `/w/cb` | the mutating callback: melt, rotate, split, merge |
630
+ | `/w` | LUD-03 informational GET; accepts `k1` (a secret or a `ck1`) or the non-disclosing LUD-25 `p` check (`sha256(k1)` or a `cp1` key; `h` is its older name, still accepted); a live note also carries `payLink`, and a Part 2 note its `cs1` as `sig` |
631
+ | `/w/cb` | the mutating callback: melt, rotate, split, merge; inputs as secrets or `ck1`s, outputs as `p1`/`p2` hashes or `cp1` keys, or their older names `h`/`h2` |
594
632
  | `POST /names` | claim a lightning address, authenticated by NIP-98 |
595
633
  | `/.well-known/nostr.json` | NIP-05 for the names this mint serves |
596
634
  | `/stats` | what the mint owes, what the node holds, and the coverage between them |
package/THREAT-MODEL.md CHANGED
@@ -23,7 +23,12 @@ request carrying a callback's query string cannot mutate anything.
23
23
 
24
24
  **A holder inflating a merge or split with a repeated k1.** Duplicated k1
25
25
  parameters in one request are refused outright; they would otherwise count
26
- one note's value twice into the output.
26
+ one note's value twice into the output. The check is on the note each k1
27
+ spends, not on the k1 string: a Part 2 note's `ck1` is a signature, and one
28
+ note has many valid ones (anyone holding a `ck1` can flip it to `(r, n-s)`,
29
+ and the key's owner can sign again). The store refuses a note named twice
30
+ among a swap's inputs as well, so the rule does not rest on the handler
31
+ alone.
27
32
 
28
33
  **A holder claiming an output id that already exists.** `h`/`h2` may not
29
34
  collide with any existing note, any mint invoice's payment hash, settled
@@ -85,6 +90,14 @@ only the buyer ever held, the preimage is bearer material for nothing, and
85
90
  what `/verify` serves is an ordinary payment proof. Wallets should prefer
86
91
  that path wherever a mint advertises `mintToHash`.
87
92
 
93
+ **What a `cx1` on a name gives away.** A name with a watch-only branch is
94
+ paid to the holder's keys, so the mint never holds a secret for those notes.
95
+ But the mint can work out every key on the branch, so it can link every
96
+ payment to that name, and see when each note is spent. That is no more than
97
+ a custodial name already reveals. A wallet should keep the branch for
98
+ receiving and rotate what arrives onto keys the mint cannot enumerate, which
99
+ is what notecase and lnurl-wallet do.
100
+
88
101
  ## Out of scope
89
102
 
90
103
  - Compromise of the host or the funding source credentials: at that point
package/dist/names.d.ts CHANGED
@@ -24,6 +24,8 @@ export type NameGranted = {
24
24
  name: string;
25
25
  pubkey: string;
26
26
  paidMsat: number;
27
+ cx1: string | null;
28
+ updated: boolean;
27
29
  };
28
30
  export declare const isRefusal: (result: NameGranted | NameRefusal) => result is NameRefusal;
29
31
  export declare const registerName: (args: {
@@ -32,6 +34,7 @@ export declare const registerName: (args: {
32
34
  body: {
33
35
  name?: unknown;
34
36
  note?: unknown;
37
+ cx1?: unknown;
35
38
  };
36
39
  priceMsat: number | undefined;
37
40
  reserved: string[];
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 { 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
  //
@@ -103,8 +103,27 @@ export const registerName = (args) => {
103
103
  if ([...ALWAYS_RESERVED, ...args.reserved.map(name => name.toLowerCase())].includes(raw)) {
104
104
  return refuse('That name is reserved.', 403);
105
105
  }
106
- if (store.zapName(raw))
106
+ // LUD-25 Part 2: a watch-only branch, so payments to the name are minted
107
+ // to the holder's own keys. Absent leaves it as it is; null clears it.
108
+ let cx1;
109
+ if (args.body.cx1 === null) {
110
+ cx1 = null;
111
+ }
112
+ else if (args.body.cx1 !== undefined) {
113
+ const given = typeof args.body.cx1 === 'string' ? args.body.cx1.trim().toLowerCase() : '';
114
+ if (!decodeCx1(given))
115
+ return refuse('That is not a cx1.', 400);
116
+ cx1 = given;
117
+ }
118
+ const existing = store.zapName(raw);
119
+ if (existing) {
120
+ // Its owner may set or clear the branch; nobody else may touch it.
121
+ if (existing.pubkey === pubkey && cx1 !== undefined) {
122
+ store.setZapNameCx1(raw, cx1);
123
+ return { name: raw, pubkey, paidMsat: 0, cx1, updated: true };
124
+ }
107
125
  return refuse('That name is taken.', 409);
126
+ }
108
127
  let paidMsat = 0;
109
128
  let noteId;
110
129
  if (priceMsat === 0) {
@@ -157,7 +176,7 @@ export const registerName = (args) => {
157
176
  paidMsat = note.amountMsat;
158
177
  }
159
178
  try {
160
- store.buyZapName({ name: raw, pubkey, ...(noteId ? { noteId } : {}), paidMsat });
179
+ store.buyZapName({ name: raw, pubkey, ...(noteId ? { noteId } : {}), paidMsat, cx1: cx1 ?? null });
161
180
  }
162
181
  catch (err) {
163
182
  if (err instanceof NotePendingError)
@@ -168,5 +187,5 @@ export const registerName = (args) => {
168
187
  // the name and the note together or not at all.
169
188
  return refuse('That name is taken.', 409);
170
189
  }
171
- return { name: raw, pubkey, paidMsat };
190
+ return { name: raw, pubkey, paidMsat, cx1: cx1 ?? null, updated: false };
172
191
  };
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, grossUpForMintFee, hashK1 } from 'lnurlcash-kit';
4
+ import { applyMintFee, decodeCk1, decodeCp1, encodeCs1, 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";
@@ -19,6 +19,36 @@ import { ANNOUNCE_D_TAG, ANNOUNCE_KIND, announcementContent } from "./announce.j
19
19
  import { isRefusal, registerName, validateNip98 } from "./names.js";
20
20
  import { CONFIG_TOKEN, loadWebAssets } from "./web-assets.js";
21
21
  const HEX32 = /^[0-9a-f]{64}$/;
22
+ // An output or a lookup: a 64-hex hash, or a cp1 key.
23
+ const namedRef = (value) => {
24
+ if (HEX32.test(value))
25
+ return { id: value, cp1: false };
26
+ const pubkey = decodeCp1(value);
27
+ return pubkey ? { id: bytesToHex(pubkey), cp1: true } : null;
28
+ };
29
+ // What a k1 spends: sha256(k1) for a Part 1 secret, and for a Part 2 ck1 the
30
+ // key its ownership signature recovers to. Different ck1 strings can recover
31
+ // to the same note, so inputs are compared by ref, never by k1 string.
32
+ const spentRef = (k1) => {
33
+ if (HEX32.test(k1))
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;
38
+ };
39
+ // LUD-25 renamed `h` to `p` on the lookup and `h`/`h2` to `p1`/`p2` on the
40
+ // callback. The old names stay accepted; sending both spellings must agree.
41
+ const renamedRef = (q, name, legacy) => {
42
+ const current = q.get(name)?.toLowerCase();
43
+ const old = q.get(legacy)?.toLowerCase();
44
+ const fromCurrent = current === undefined ? undefined : namedRef(current);
45
+ const fromOld = old === undefined ? undefined : namedRef(old);
46
+ return {
47
+ present: current !== undefined || old !== undefined,
48
+ ref: (fromCurrent !== undefined ? fromCurrent : fromOld) ?? null,
49
+ conflict: fromCurrent !== undefined && fromOld !== undefined && fromCurrent?.id !== fromOld?.id
50
+ };
51
+ };
22
52
  // The one request body this mint reads. Capped hard: nothing here needs
23
53
  // more than a name and a note, and an unbounded read on a public endpoint
24
54
  // is a way to run a mint out of memory.
@@ -61,6 +91,11 @@ export const createMoneyer = async (config, deps = {}) => {
61
91
  const store = deps.store ?? new NoteStore(config.dbPath);
62
92
  const backend = deps.backend ?? backendFor(config);
63
93
  const signer = createNoteSigner(config.signingKey);
94
+ // A cp1 note's certificate is the same signature, carried as cs1.
95
+ const certify = (ref, amountMsat) => {
96
+ const sig = signer.sign(ref.id, amountMsat);
97
+ return ref.cp1 ? encodeCs1(hexToBytes(sig)) : sig;
98
+ };
64
99
  const webAssets = deps.webAssets === undefined ? loadWebAssets() : deps.webAssets;
65
100
  // Node identity for the discovery endpoint, fetched once, best-effort: a
66
101
  // funding source that cannot answer does not stop the mint serving.
@@ -132,6 +167,7 @@ export const createMoneyer = async (config, deps = {}) => {
132
167
  mintFeeLine,
133
168
  feeInWords,
134
169
  verify: config.verify,
170
+ certify: (noteId, amountMsat) => certify({ id: noteId, cp1: true }, amountMsat),
135
171
  // configFromEnv guarantees this when zap is set; a caller
136
172
  // building the config by hand gets the same rule.
137
173
  origin: config.publicOrigin ?? (() => {
@@ -163,7 +199,6 @@ export const createMoneyer = async (config, deps = {}) => {
163
199
  }
164
200
  return null;
165
201
  };
166
- const resolveNote = async (k1) => resolveNoteId(hashK1(k1));
167
202
  // ---- transparency: what the mint owes, and what the node holds ----
168
203
  //
169
204
  // Cached for 30 seconds so a public endpoint cannot be turned into a
@@ -446,14 +481,19 @@ export const createMoneyer = async (config, deps = {}) => {
446
481
  });
447
482
  if (isRefusal(result))
448
483
  return fail(result.reason, result.status);
449
- log(`name ${result.name} registered to ${result.pubkey.slice(0, 8)} for ${result.paidMsat} msat`);
484
+ log(result.updated
485
+ ? `name ${result.name} ${result.cx1 ? 'now pays to a branch' : 'pays out custodially again'}`
486
+ : `name ${result.name} registered to ${result.pubkey.slice(0, 8)} for ${result.paidMsat} msat`);
450
487
  return send({
451
488
  status: 'OK',
452
489
  name: result.name,
453
490
  pubkey: result.pubkey,
454
491
  address: `${result.name}@${host}`,
455
492
  priceMsat: config.namePriceMsat,
456
- paidMsat: result.paidMsat
493
+ paidMsat: result.paidMsat,
494
+ // With a cx1 the name pays to the holder's own keys; without, as a
495
+ // note gift-wrapped to them.
496
+ cx1: result.cx1
457
497
  });
458
498
  }
459
499
  // Every LNURL endpoint below is a GET. The method matters: /w/cb
@@ -630,11 +670,15 @@ export const createMoneyer = async (config, deps = {}) => {
630
670
  // commitment. Missing or malformed input is refused before asking the
631
671
  // funding source for an invoice. `h` remains an additive compatibility
632
672
  // field, but it never substitutes for comment.
673
+ //
674
+ // LUD-25 Part 2: the comment may instead be `cp1<pk>`, and the note is
675
+ // keyed by that key, spent later with a ck1 rather than a secret.
633
676
  const askedComment = q.get('comment')?.trim().toLowerCase() ?? null;
634
- if (askedComment === null || !HEX32.test(askedComment)) {
635
- return fail('a mint quote must name its output with a LUD-12 comment carrying hex(sha256(secret))');
677
+ const commentRef = askedComment === null ? null : namedRef(askedComment);
678
+ if (commentRef === null) {
679
+ return fail('a mint quote must name its output with a LUD-12 comment carrying hex(sha256(secret)) or a cp1 key');
636
680
  }
637
- const commentOutputId = askedComment;
681
+ const commentOutputId = commentRef.id;
638
682
  const askedOutputId = q.get('h')?.trim().toLowerCase() ?? null;
639
683
  if (askedOutputId !== null && !HEX32.test(askedOutputId))
640
684
  return fail('missing h');
@@ -823,18 +867,20 @@ export const createMoneyer = async (config, deps = {}) => {
823
867
  // ---- LUD-03 informational GET ----
824
868
  if (requestUrl.pathname === '/w') {
825
869
  const hasK1 = q.has('k1');
826
- const hasH = q.has('h');
827
870
  const k1 = q.get('k1')?.toLowerCase();
828
- const h = q.get('h')?.toLowerCase();
829
- if (hasK1 === hasH ||
830
- (hasK1 && (!k1 || !HEX32.test(k1))) ||
831
- (hasH && (!h || !HEX32.test(h)))) {
871
+ const lookup = renamedRef(q, 'p', 'h');
872
+ if (lookup.conflict)
873
+ return fail('p and h name different notes');
874
+ if (hasK1 === lookup.present)
832
875
  return fail('Unknown note.');
833
- }
834
876
  // LUD-25's hash-only check lets a wallet prove that the note it just
835
877
  // bought exists without sending the bearer secret to the service a
836
- // second time. `h` is accepted in place of `k1`, never alongside it.
837
- const note = h ? await resolveNoteId(h) : await resolveNote(k1);
878
+ // second time. `p` (or `h`) is accepted in place of `k1`, never
879
+ // alongside it, and names a Part 2 note by its cp1 key.
880
+ const ref = hasK1 ? (k1 ? spentRef(k1) : null) : lookup.ref;
881
+ if (!ref)
882
+ return fail('Unknown note.');
883
+ const note = await resolveNoteId(ref.id);
838
884
  if (!note)
839
885
  return fail('Unknown note.');
840
886
  // Hash lookups protect the secret, not the note's spent state.
@@ -876,7 +922,10 @@ export const createMoneyer = async (config, deps = {}) => {
876
922
  // but a note learns from the note itself that the other door
877
923
  // exists.
878
924
  ...(mirrors.length ? { mirrors } : {}),
879
- ...(signer ? { mintPubkey: signer.pubkey } : {})
925
+ ...(signer ? { mintPubkey: signer.pubkey } : {}),
926
+ // LUD-25 Part 2: a note named by cp1 or ck1 gets its certificate
927
+ // here, so a wallet need not rotate just to obtain one.
928
+ ...(ref.cp1 ? { sig: certify(ref, note.amountMsat) } : {})
880
929
  });
881
930
  }
882
931
  // ---- the mutating callback: melt / rotate / split / merge ----
@@ -884,8 +933,12 @@ export const createMoneyer = async (config, deps = {}) => {
884
933
  const k1s = q.getAll('k1').map(value => value.toLowerCase());
885
934
  const pr = q.get('pr');
886
935
  const amountRaw = q.get('amount');
887
- const h = q.get('h')?.toLowerCase();
888
- const h2 = q.get('h2')?.toLowerCase();
936
+ // One ref per output from here on, so the replay match below treats
937
+ // `p1=X` and `h=X` as the same request.
938
+ const out1 = renamedRef(q, 'p1', 'h');
939
+ const out2 = renamedRef(q, 'p2', 'h2');
940
+ const o1 = out1.ref;
941
+ const o2 = out2.ref;
889
942
  if (k1s.length === 0)
890
943
  return fail('Missing k1.');
891
944
  if (k1s.length > config.maxK1s)
@@ -896,19 +949,29 @@ export const createMoneyer = async (config, deps = {}) => {
896
949
  // Hash parameters are checked before any note resolves, so an
897
950
  // invalid or missing hash can never burn anything.
898
951
  if (!pr) {
899
- if (!h || !HEX32.test(h))
900
- return fail('missing h');
901
- if (amountRaw !== null && (!h2 || !HEX32.test(h2)))
902
- return fail('missing h2');
903
- if (h2 !== undefined && h2 === h)
904
- return fail('h and h2 must differ.');
952
+ if (out1.conflict)
953
+ return fail('p1 and h name different outputs');
954
+ if (out2.conflict)
955
+ return fail('p2 and h2 name different outputs');
956
+ if (!o1)
957
+ return fail('missing p1');
958
+ if (amountRaw !== null && !o2)
959
+ return fail('missing p2');
960
+ if (o2 && o2.id === o1.id)
961
+ return fail('p1 and p2 must differ.');
905
962
  }
906
963
  if (config.sunset && amountRaw !== null) {
907
964
  return fail('This mint is sunsetting - splitting is disabled.');
908
965
  }
909
- // A repeated k1 would count one note's value twice on the way into a
910
- // merge or split. Atomic refusal, same as an invalid one.
911
- if (new Set(k1s).size !== k1s.length)
966
+ // A note named twice would count its value twice on the way into a
967
+ // merge or split. Compared by the note each k1 spends, since two
968
+ // different ck1 strings can spend the same one. Atomic refusal, same
969
+ // as an invalid k1.
970
+ const spent = k1s.map(spentRef);
971
+ if (spent.some(ref => ref === null))
972
+ return fail('Invalid or already spent k1.');
973
+ const spentIds = spent.map(ref => ref.id);
974
+ if (new Set(spentIds).size !== spentIds.length)
912
975
  return fail('Invalid or already spent k1.');
913
976
  // ---- the retried mutation ----
914
977
  //
@@ -925,39 +988,36 @@ export const createMoneyer = async (config, deps = {}) => {
925
988
  // balance: it is a read. The signature is deterministic over the
926
989
  // output id and its amount, so it is recomputed rather than stored.
927
990
  // Anything else naming a burned input is a double-spend attempt and
928
- // still gets today's reason string, unchanged.
929
- // Every k1 has to be hex before it can be hashed into a
930
- // fingerprint; a malformed one falls through to the loop below and
931
- // gets the same refusal it always did.
932
- const fingerprint = pr !== null || !k1s.every(k1 => HEX32.test(k1))
991
+ // still gets today's reason string, unchanged. The fingerprint is
992
+ // over note ids, so a hex request fingerprints exactly as it did
993
+ // before Part 2 and replays from before an upgrade still match.
994
+ const fingerprint = pr !== null
933
995
  ? null
934
996
  : swapFingerprint({
935
- inputIds: k1s.map(hashK1),
936
- h: h,
937
- h2,
997
+ inputIds: spentIds,
998
+ h: o1.id,
999
+ h2: o2?.id,
938
1000
  ...(amountRaw !== null ? { amountMsat: Number(amountRaw) } : {})
939
1001
  });
940
1002
  const alreadyMinted = fingerprint === null ? null : store.swapByFingerprint(fingerprint);
941
1003
  if (alreadyMinted) {
942
- const first = alreadyMinted.find(output => output.id === h);
943
- const second = h2 === undefined ? undefined : alreadyMinted.find(output => output.id === h2);
1004
+ const first = alreadyMinted.find(output => output.id === o1.id);
1005
+ const second = o2 ? alreadyMinted.find(output => output.id === o2.id) : undefined;
944
1006
  if (first) {
945
1007
  return send({
946
1008
  status: 'OK',
947
1009
  ...(signer
948
1010
  ? {
949
- sig: signer.sign(first.id, first.amountMsat),
950
- ...(second ? { sig2: signer.sign(second.id, second.amountMsat) } : {})
1011
+ sig: certify(o1, first.amountMsat),
1012
+ ...(second ? { sig2: certify(o2, second.amountMsat) } : {})
951
1013
  }
952
1014
  : {})
953
1015
  });
954
1016
  }
955
1017
  }
956
1018
  const found = [];
957
- for (const k1 of k1s) {
958
- if (!HEX32.test(k1))
959
- return fail('Invalid or already spent k1.');
960
- const note = await resolveNote(k1);
1019
+ for (const id of spentIds) {
1020
+ const note = await resolveNoteId(id);
961
1021
  if (!note || note.state === 'burned')
962
1022
  return fail('Invalid or already spent k1.');
963
1023
  if (note.state === 'pending')
@@ -1072,8 +1132,8 @@ export const createMoneyer = async (config, deps = {}) => {
1072
1132
  return fail('insufficient value');
1073
1133
  try {
1074
1134
  store.swap(inputIds, [
1075
- { id: h, amountMsat: amount },
1076
- { id: h2, amountMsat: changeMsat }
1135
+ { id: o1.id, amountMsat: amount },
1136
+ { id: o2.id, amountMsat: changeMsat }
1077
1137
  ], fingerprint ?? undefined);
1078
1138
  }
1079
1139
  catch (err) {
@@ -1085,7 +1145,7 @@ export const createMoneyer = async (config, deps = {}) => {
1085
1145
  }
1086
1146
  return send({
1087
1147
  status: 'OK',
1088
- ...(signer ? { sig: signer.sign(h, amount), sig2: signer.sign(h2, changeMsat) } : {})
1148
+ ...(signer ? { sig: certify(o1, amount), sig2: certify(o2, changeMsat) } : {})
1089
1149
  });
1090
1150
  }
1091
1151
  // rotate (one k1) or merge (several) - the same swap either way.
@@ -1094,7 +1154,7 @@ export const createMoneyer = async (config, deps = {}) => {
1094
1154
  // of one - the refund is exactly 0.
1095
1155
  const mergedMsat = totalMsat + (inputIds.length - 1) * baseFeeMsat;
1096
1156
  try {
1097
- store.swap(inputIds, [{ id: h, amountMsat: mergedMsat }], fingerprint ?? undefined);
1157
+ store.swap(inputIds, [{ id: o1.id, amountMsat: mergedMsat }], fingerprint ?? undefined);
1098
1158
  }
1099
1159
  catch (err) {
1100
1160
  if (err instanceof NotePendingError)
@@ -1102,7 +1162,7 @@ export const createMoneyer = async (config, deps = {}) => {
1102
1162
  // same oracle-free reason for a collision as for a dead k1
1103
1163
  return fail('Invalid or already spent k1.');
1104
1164
  }
1105
- return send({ status: 'OK', ...(signer ? { sig: signer.sign(h, mergedMsat) } : {}) });
1165
+ return send({ status: 'OK', ...(signer ? { sig: certify(o1, mergedMsat) } : {}) });
1106
1166
  }
1107
1167
  return fail('Not found.', 404);
1108
1168
  };
package/dist/store.d.ts CHANGED
@@ -53,6 +53,8 @@ export type ZapNameRow = {
53
53
  createdAt: number;
54
54
  paidMsat: number;
55
55
  source: 'env' | 'self';
56
+ cx1: string | null;
57
+ nextIndex: number;
56
58
  };
57
59
  export type NoteListRow = NoteRow & {
58
60
  createdAt: number;
@@ -126,16 +128,27 @@ export declare class NoteStore {
126
128
  unsettledZapInvoices(): ZapInvoiceRow[];
127
129
  deleteUnsettledZapInvoice(paymentHash: string): void;
128
130
  settleZapInvoice(paymentHash: string, noteId: string, wrapJson: string, receiptJson: string | null): boolean;
131
+ settleZapInvoiceToKey(paymentHash: string, keyAt: (index: number) => string | null, build: (noteId: string, index: number) => {
132
+ wrapJson: string;
133
+ receiptJson: string | null;
134
+ }): {
135
+ noteId: string;
136
+ index: number;
137
+ } | null;
129
138
  unpublishedZaps(): ZapInvoiceRow[];
130
139
  markZapPublished(paymentHash: string): void;
140
+ private static readonly NAME_COLUMNS;
141
+ private nameRow;
131
142
  zapName(name: string): ZapNameRow | null;
132
143
  zapNames(): ZapNameRow[];
144
+ setZapNameCx1(name: string, cx1: string | null): void;
133
145
  putOperatorZapName(name: string, pubkey: string): void;
134
146
  buyZapName(args: {
135
147
  name: string;
136
148
  pubkey: string;
137
149
  noteId?: string;
138
150
  paidMsat: number;
151
+ cx1?: string | null;
139
152
  }): void;
140
153
  removeZapName(name: string): boolean;
141
154
  zapNameCountFor(pubkey: string): number;
package/dist/store.js CHANGED
@@ -117,6 +117,12 @@ export class NoteStore {
117
117
  // Two invoices may not name the same note. SQLite counts NULLs as
118
118
  // distinct, so every unbound invoice still fits.
119
119
  this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS mint_invoices_output_id ON mint_invoices (output_id)');
120
+ const nameColumns = this.db.prepare('PRAGMA table_info(zap_names)').all().map(column => column.name);
121
+ if (!nameColumns.includes('cx1'))
122
+ this.db.exec('ALTER TABLE zap_names ADD COLUMN cx1 TEXT');
123
+ if (!nameColumns.includes('next_index')) {
124
+ this.db.exec('ALTER TABLE zap_names ADD COLUMN next_index INTEGER NOT NULL DEFAULT 0');
125
+ }
120
126
  this.db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);');
121
127
  // The moment this mint stopped publishing preimages for notes nobody
122
128
  // named. Written once, the first time a build carrying this code opens
@@ -214,6 +220,11 @@ export class NoteStore {
214
220
  // a secret the mint really did mint a note against.
215
221
  swap(inputIds, outputs, fingerprint) {
216
222
  this.tx(() => {
223
+ // Every input is checked before any is burned, so a note named twice
224
+ // would pass both checks and be counted twice by the caller.
225
+ if (new Set(inputIds).size !== inputIds.length) {
226
+ throw new NoteUnavailableError('a note is named twice among the inputs');
227
+ }
217
228
  for (const id of inputIds)
218
229
  this.assertOutstanding(id);
219
230
  for (const output of outputs)
@@ -435,6 +446,39 @@ export class NoteStore {
435
446
  });
436
447
  }
437
448
  // Settled zaps whose wrap has not yet reached a relay.
449
+ // A zap to a name with a cx1, minted to the next unused key on its
450
+ // branch. The index is taken here, at settlement, never when the invoice
451
+ // is made, so an invoice nobody pays leaves no gap in the holder's scan.
452
+ // A key that already names a note is skipped, as LUD-25 requires.
453
+ // `keyAt` answers null for an index that yields no usable key.
454
+ settleZapInvoiceToKey(paymentHash, keyAt, build) {
455
+ return this.tx(() => {
456
+ const row = this.zapInvoiceByHash(paymentHash);
457
+ if (!row || row.settled)
458
+ return null;
459
+ const name = this.zapName(row.name);
460
+ if (!name?.cx1)
461
+ return null;
462
+ let index = name.nextIndex;
463
+ let noteId = null;
464
+ for (let tries = 0; tries < 1000 && noteId === null; tries++) {
465
+ const candidate = keyAt(index);
466
+ if (candidate !== null && !this.outputIdInUse(candidate))
467
+ noteId = candidate;
468
+ else
469
+ index += 1;
470
+ }
471
+ if (noteId === null)
472
+ throw new Error(`no free key on the branch of ${row.name}`);
473
+ this.insertNote(noteId, row.netMsat);
474
+ this.db.prepare('UPDATE zap_names SET next_index = ? WHERE name = ?').run(index + 1, row.name);
475
+ const { wrapJson, receiptJson } = build(noteId, index);
476
+ this.db
477
+ .prepare('UPDATE zap_invoices SET settled = 1, note_id = ?, wrap_json = ?, receipt_json = ?, settled_at = ? WHERE payment_hash = ?')
478
+ .run(noteId, wrapJson, receiptJson, Date.now(), paymentHash);
479
+ return { noteId, index };
480
+ });
481
+ }
438
482
  unpublishedZaps() {
439
483
  const rows = this.db
440
484
  .prepare(`SELECT ${NoteStore.ZAP_COLUMNS} FROM zap_invoices WHERE settled = 1 AND published_at IS NULL`)
@@ -453,25 +497,36 @@ export class NoteStore {
453
497
  // One table for both kinds: names the operator set in the environment
454
498
  // and names people registered themselves. The lookup path that serves a
455
499
  // zap does not care which is which, so there is one of it.
500
+ static NAME_COLUMNS = 'name, pubkey, created_at, paid_msat, source, cx1, next_index';
501
+ nameRow(row) {
502
+ return {
503
+ name: row.name,
504
+ pubkey: row.pubkey,
505
+ createdAt: row.created_at,
506
+ paidMsat: row.paid_msat,
507
+ source: row.source,
508
+ cx1: row.cx1 ?? null,
509
+ nextIndex: row.next_index
510
+ };
511
+ }
456
512
  zapName(name) {
457
513
  const row = this.db
458
- .prepare('SELECT name, pubkey, created_at, paid_msat, source FROM zap_names WHERE name = ?')
514
+ .prepare(`SELECT ${NoteStore.NAME_COLUMNS} FROM zap_names WHERE name = ?`)
459
515
  .get(name.toLowerCase());
460
- if (!row)
461
- return null;
462
- return { name: row.name, pubkey: row.pubkey, createdAt: row.created_at, paidMsat: row.paid_msat, source: row.source };
516
+ return row ? this.nameRow(row) : null;
463
517
  }
464
518
  zapNames() {
465
519
  const rows = this.db
466
- .prepare('SELECT name, pubkey, created_at, paid_msat, source FROM zap_names ORDER BY name')
520
+ .prepare(`SELECT ${NoteStore.NAME_COLUMNS} FROM zap_names ORDER BY name`)
467
521
  .all();
468
- return rows.map(row => ({
469
- name: row.name,
470
- pubkey: row.pubkey,
471
- createdAt: row.created_at,
472
- paidMsat: row.paid_msat,
473
- source: row.source
474
- }));
522
+ return rows.map(row => this.nameRow(row));
523
+ }
524
+ // A new branch starts at index 0; the same branch sent again keeps its
525
+ // place, so re-registering cannot walk the holder's keys backwards.
526
+ setZapNameCx1(name, cx1) {
527
+ this.db
528
+ .prepare('UPDATE zap_names SET next_index = CASE WHEN cx1 IS ? THEN next_index ELSE 0 END, cx1 = ? WHERE name = ?')
529
+ .run(cx1, cx1, name.toLowerCase());
475
530
  }
476
531
  // The operator's own names, re-applied at every startup. Idempotent, and
477
532
  // the environment wins: it is the operator's mint, and a name they put
@@ -494,8 +549,8 @@ export class NoteStore {
494
549
  this.setNoteState(args.noteId, 'burned');
495
550
  }
496
551
  this.db
497
- .prepare("INSERT INTO zap_names (name, pubkey, created_at, paid_msat, source) VALUES (?, ?, ?, ?, 'self')")
498
- .run(args.name.toLowerCase(), args.pubkey, Date.now(), args.paidMsat);
552
+ .prepare("INSERT INTO zap_names (name, pubkey, created_at, paid_msat, source, cx1) VALUES (?, ?, ?, ?, 'self', ?)")
553
+ .run(args.name.toLowerCase(), args.pubkey, Date.now(), args.paidMsat, args.cx1 ?? null);
499
554
  });
500
555
  }
501
556
  removeZapName(name) {
package/dist/zap.d.ts CHANGED
@@ -31,6 +31,7 @@ export type ZapBridgeDeps = {
31
31
  feeInWords: string | null;
32
32
  verify: boolean;
33
33
  origin: string;
34
+ certify: (noteIdHex: string, amountMsat: number) => string;
34
35
  log?: (message: string) => void;
35
36
  now?: () => number;
36
37
  };
package/dist/zap.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
2
- import { 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';
@@ -171,8 +171,7 @@ export const createZapBridge = (deps) => {
171
171
  store.recordZapInvoice({ paymentHash, name: lowered, recipient, pr, grossMsat: amountMsat, netMsat: net, zapRequest });
172
172
  return { pr, ...(deps.verify ? { verify: `${origin}/verify/${paymentHash}` } : {}) };
173
173
  };
174
- // The note as the recipient's wallet will paste it, wrapped to them.
175
- const buildWrap = (row, k1) => {
174
+ const wrapTags = (row) => {
176
175
  const tags = [
177
176
  ['p', row.recipient],
178
177
  ['amount', String(row.netMsat)],
@@ -192,15 +191,20 @@ export const createZapBridge = (deps) => {
192
191
  // older one simply does not see this.
193
192
  tags.push(['description', row.zapRequest]);
194
193
  }
195
- const rumor = {
196
- kind: NOTE_KIND,
197
- pubkey,
198
- created_at: Math.floor(now() / 1000),
199
- tags,
200
- content: `${origin}/w?k1=${k1}&amount=${row.netMsat}`
201
- };
194
+ return tags;
195
+ };
196
+ const wrap = (row, tags, content) => {
197
+ const rumor = { kind: NOTE_KIND, pubkey, created_at: Math.floor(now() / 1000), tags, content };
202
198
  return wrapEvent(rumor, secret, row.recipient);
203
199
  };
200
+ // The note as the recipient's wallet will paste it, wrapped to them.
201
+ const buildWrap = (row, k1) => wrap(row, wrapTags(row), `${origin}/w?k1=${k1}&amount=${row.netMsat}`);
202
+ // A zap to a name with a cx1: the note already belongs to the holder's
203
+ // key, so the wrap carries no secret at all, only where to look (`p`),
204
+ // the certificate, and the index `i` the holder derives the key at. A
205
+ // reader that knows only note URLs sees no k1 and passes it by, and the
206
+ // 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}`);
204
208
  // NIP-57 receipt, without the preimage tag: it is optional there, and
205
209
  // here it would only invite someone to mistake it for the note.
206
210
  const buildReceipt = (row) => {
@@ -222,6 +226,27 @@ export const createZapBridge = (deps) => {
222
226
  }
223
227
  if (!paid)
224
228
  continue;
229
+ const branch = decodeCx1(store.zapName(row.name)?.cx1 ?? '');
230
+ if (branch) {
231
+ const receipt = buildReceipt(row);
232
+ const keyAt = (index) => {
233
+ try {
234
+ return bytesToHex(deriveNotePubkey(branch.pubkeyXOnly, branch.chainCode, index));
235
+ }
236
+ catch {
237
+ return null;
238
+ }
239
+ };
240
+ const landed = store.settleZapInvoiceToKey(row.paymentHash, keyAt, (noteId, index) => ({
241
+ wrapJson: JSON.stringify(buildKeyWrap(row, noteId, index)),
242
+ receiptJson: receipt ? JSON.stringify(receipt) : null
243
+ }));
244
+ if (landed) {
245
+ minted += 1;
246
+ log(`zap: ${row.name} received ${row.netMsat} msat; note minted to key ${landed.index}, wrap parked`);
247
+ }
248
+ continue;
249
+ }
225
250
  const k1 = bytesToHex(randomBytes(32));
226
251
  const noteId = hashK1(k1);
227
252
  const wrap = buildWrap(row, k1);
package/llms.txt CHANGED
@@ -25,10 +25,13 @@ Client library to build against a mint: lnurlcash-kit
25
25
  inside the invoice. `h` is an additive alias for the same commitment and
26
26
  never replaces the comment. Because the note is not keyed by the
27
27
  preimage, a funding source whose node chooses its own preimages
28
- (phoenixd, NIP-47 make_invoice) can back a mint.
28
+ (phoenixd, NIP-47 make_invoice) can back a mint. LUD-25 Part 2: the
29
+ comment may instead be `cp1<pk>`, and the note is keyed by that key.
29
30
  2. Mutations are atomic: all named k1 burn and all outputs mint, or
30
- nothing happens. Duplicate k1 in one request is refused.
31
- 3. h/h2 are WALLET-supplied hashes; the mint never generates a
31
+ nothing happens. A note named twice in one request is refused, compared
32
+ by the note each k1 spends: one Part 2 note has many valid ck1 strings.
33
+ 3. p1/p2 (older names h/h2, still accepted; both spellings of one output
34
+ must agree) are WALLET-supplied hashes or cp1 keys; the mint never generates a
32
35
  replacement secret, and refuses output ids that already exist as
33
36
  notes, as mint-invoice payment hashes, or as a note another payer has
34
37
  already bought with `h`. A malformed `h` on the pay callback is
@@ -56,8 +59,8 @@ Client library to build against a mint: lnurlcash-kit
56
59
  part applies at mint time only.
57
60
  7. Reason strings are protocol surface: 'pending',
58
61
  'Invalid or already spent k1.', 'Unknown note.', 'Note already spent.',
59
- 'missing h', 'missing h2', 'insufficient value' - wallets classify on
60
- them.
62
+ 'missing p1', 'missing p2' (withdraw callback), 'missing h' (pay
63
+ callback), 'insufficient value' - wallets classify on them.
61
64
  8. minSendable must survive the mint's own fee (grossUpForMintFee of the
62
65
  dust floor), or the mint advertises an amount it will refuse.
63
66
 
@@ -84,7 +87,13 @@ NoteStore - SQLite store; notes by sha256(k1), never secrets
84
87
 
85
88
  /.well-known/lnurlp/{user} LUD-16 payRequest (mints)
86
89
  /.well-known/lnurlw/{user} LUD-25 mint address (experimental)
87
- /p/cb LUD-06 pay callback (optional h names the note)
90
+ /p/cb LUD-06 pay callback (comment names the note: hash or cp1)
88
91
  /verify/{payment_hash} LUD-21 verify (mint invoices AND melt payments)
89
- /w LUD-03 informational GET by k1 or hash-only h
90
- /w/cb melt / rotate / split / merge
92
+ /w LUD-03 informational GET by k1 (secret or ck1) or p (hash or cp1, older h); a cp1 note carries its cs1 as sig
93
+ /w/cb melt / rotate / split / merge; inputs secrets or ck1, outputs p1/p2 hashes or cp1 (or older h/h2); cp1 outputs certified as cs1
94
+ POST /names claim a name (NIP-98; the signer owns it); body {name, note?, cx1?}.
95
+ The owner may resend {name, cx1} to set a branch, or cx1: null to clear it
96
+ /z/cb/{name} zap invoice for a name. With a cx1 the settled note is credited to the
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
99
+ taken at settlement, never at the invoice, and occupied keys are skipped
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.11.1",
3
+ "version": "0.13.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.7.0",
63
+ "lnurlcash-kit": "^0.10.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": "https://codeload.github.com/lnurlcash/lnurlcash-conformance/tar.gz/6c6aee66cf1b6cba72a03af976ecec113f470c9e",
73
+ "lnurlcash-conformance": "^0.8.0",
74
74
  "playwright": "^1.62.1",
75
75
  "typescript": "^5.7.0",
76
76
  "uqr": "^0.1.2",