@forgesworn/moneyer 0.16.2 → 0.17.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/dist/server.js CHANGED
@@ -1,10 +1,12 @@
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, decodeCp1, encodeCs1WithAmount, grossUpForMintFee, hashK1, recoverNoteOwnershipPubkey } from '@lnurlcash/kit';
4
+ import { applyMintFee, encodeCs1WithAmount, grossUpForMintFee, hashK1 } from '@lnurlcash/kit';
5
5
  import { tryDecodeBolt11 } from 'farrier-kit/bolt11';
6
6
  import { NotePendingError, NoteStore, NoteUnavailableError, OutputCollisionError, swapFingerprint } from "./store.js";
7
7
  import { createNoteSigner } from "./signing.js";
8
+ import { bearerNoteId, decodeNote, decodeSpend, spendDomainOf, verifySpend } from "./spend.js";
9
+ import { createKernelVerifier } from "./kernel.js";
8
10
  import { describeFee } from "./fee-words.js";
9
11
  import { createFakeBackend } from "./backends/fake.js";
10
12
  import { createClnBackend } from "./backends/cln.js";
@@ -19,22 +21,13 @@ import { ANNOUNCE_D_TAG, ANNOUNCE_KIND, announcementContent } from "./announce.j
19
21
  import { isRefusal, registerName, validateNip98 } from "./names.js";
20
22
  import { CONFIG_TOKEN, loadWebAssets } from "./web-assets.js";
21
23
  const HEX32 = /^[0-9a-f]{64}$/;
22
- // An output or a lookup: a 64-hex hash, or a cp1 key.
23
24
  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 owner = recoverNoteOwnershipPubkey(k1);
36
- return owner ? { id: bytesToHex(owner.pubkeyXOnly), cp1: true } : null;
25
+ const q = decodeNote(value);
26
+ return q ? { q, named: HEX32.test(value) ? value : q } : null;
37
27
  };
28
+ const INVALID_K1 = 'Invalid or already spent k1.';
29
+ // LUD-25's own reason for an output that names a note already credited.
30
+ const IN_USE = 'already in use';
38
31
  // LUD-25 renamed `h` to `p` on the lookup and `h`/`h2` to `p1`/`p2` on the
39
32
  // callback. The old names stay accepted; sending both spellings must agree.
40
33
  const renamedRef = (q, name, legacy) => {
@@ -45,7 +38,7 @@ const renamedRef = (q, name, legacy) => {
45
38
  return {
46
39
  present: current !== undefined || old !== undefined,
47
40
  ref: (fromCurrent !== undefined ? fromCurrent : fromOld) ?? null,
48
- conflict: fromCurrent !== undefined && fromOld !== undefined && fromCurrent?.id !== fromOld?.id
41
+ conflict: fromCurrent !== undefined && fromOld !== undefined && fromCurrent?.q !== fromOld?.q
49
42
  };
50
43
  };
51
44
  // The one request body this mint reads. Capped hard: nothing here needs
@@ -90,18 +83,25 @@ export const createMoneyer = async (config, deps = {}) => {
90
83
  const store = deps.store ?? new NoteStore(config.dbPath);
91
84
  const backend = deps.backend ?? backendFor(config);
92
85
  const signer = createNoteSigner(config.signingKey);
93
- // LUD-25 Part 2 signs cp1 notes only, as cs1. A hash output is a plain
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
- };
101
- const certified = (field, ref, amountMsat) => {
102
- const sig = certify(ref, amountMsat);
103
- return sig ? { [field]: sig } : {};
86
+ // Leaves other than a bearer hashlock go to Bitcoin Core's interpreter, in
87
+ // a child process, when the operator configured one (see src/kernel.ts).
88
+ const kernel = deps.scriptVerifier === undefined && config.scriptVerifier ? createKernelVerifier({ command: config.scriptVerifier, log }) : null;
89
+ const scriptVerifier = deps.scriptVerifier ?? kernel ?? undefined;
90
+ // LUD-25 offline verification: a cs1 over hex(Q) and the amount, for
91
+ // every note, bearer notes included.
92
+ const certify = (q, amountMsat) => encodeCs1WithAmount(amountMsat, hexToBytes(signer.sign(q, amountMsat)));
93
+ // LUD-25 names the certificate `c` (and `c2` for split change). The old
94
+ // `sig`/`sig2` go out alongside it until the wallets that read only those
95
+ // have moved on.
96
+ const certified = (field, q, amountMsat) => {
97
+ const cs1 = certify(q, amountMsat);
98
+ return field === 'c' ? { c: cs1, sig: cs1 } : { c2: cs1, sig2: cs1 };
104
99
  };
100
+ // Every host this mint answers on. A spend's signature is bound to one
101
+ // domain, and a note travels as a URL naming one host, so a signature
102
+ // bound to any of this mint's own hosts is this mint's. Without a
103
+ // configured origin (development) the host a request reached is used.
104
+ const configuredDomains = [...new Set([config.publicOrigin, config.onionUrl].filter(Boolean).map(origin => spendDomainOf(origin)))];
105
105
  const webAssets = deps.webAssets === undefined ? loadWebAssets() : deps.webAssets;
106
106
  // Node identity for the discovery endpoint, fetched once, best-effort: a
107
107
  // funding source that cannot answer does not stop the mint serving.
@@ -173,7 +173,7 @@ export const createMoneyer = async (config, deps = {}) => {
173
173
  mintFeeLine,
174
174
  feeInWords,
175
175
  verify: config.verify,
176
- certify: (noteId, amountMsat) => certify({ id: noteId, cp1: true }, amountMsat),
176
+ certify,
177
177
  // configFromEnv guarantees this when zap is set; a caller
178
178
  // building the config by hand gets the same rule.
179
179
  origin: config.publicOrigin ?? (() => {
@@ -182,29 +182,52 @@ export const createMoneyer = async (config, deps = {}) => {
182
182
  log
183
183
  })
184
184
  : null;
185
- // A note whose id we do not know yet may be a settled mint invoice whose
185
+ // A note whose Q we do not know yet may be a settled mint invoice whose
186
186
  // claim simply has not been observed: settle it lazily against the
187
187
  // funding source, which is what makes paying an invoice mint the note.
188
- const resolveNoteId = async (id) => {
189
- const note = store.noteById(id);
188
+ const resolveNote = async (q) => {
189
+ const note = store.noteByQ(q);
190
190
  if (note)
191
191
  return note;
192
- // Either the invoice whose payment hash is this id - the older
193
- // arrangement, where the payment preimage is the spend secret - or the
194
- // invoice a payer bound to this id by naming it with `h`. In the bound
195
- // case the wallet needs nothing but its own secret to claim: no
196
- // /verify poll, no preimage, and so no window in which knowing the
197
- // invoice is knowing the money.
198
- const invoice = store.mintInvoiceByHash(id) ?? store.mintInvoiceByOutputId(id);
192
+ // The invoice a payer bound to this note by naming it. The wallet then
193
+ // needs nothing but its own secret to claim: no /verify poll, no
194
+ // preimage, and so no window in which knowing the invoice is knowing
195
+ // the money. A note from before Q ids may instead be waiting on an
196
+ // invoice under its old id: bound to it, or - the oldest arrangement,
197
+ // where the payment preimage is the spend secret - paid to it.
198
+ const legacy = store.legacyIdOf(q);
199
+ const invoice = store.mintInvoiceByOutputId(q) ??
200
+ (legacy ? (store.mintInvoiceByOutputId(legacy) ?? store.mintInvoiceByHash(legacy)) : null);
199
201
  if (invoice && !invoice.settled && (await backend.isInvoiceSettled(invoice.paymentHash))) {
200
202
  store.settleMintInvoice(invoice.paymentHash);
201
- // The note lands at the id its payer named. Looking it up by this id
202
- // finds it when that id IS the name, and finds nothing when this is
203
- // a bound invoice's payment hash - which is the point of binding.
204
- return store.noteById(id);
203
+ return store.noteByQ(q);
205
204
  }
206
205
  return null;
207
206
  };
207
+ // The note a k1 opens, in whatever state, once its spend is verified
208
+ // against it: a ck1's signature, a cw1's leaf and witness, or a bearer
209
+ // preimage. A refusal otherwise. A key-path failure and an unknown note
210
+ // get the same reason, so the answer is no oracle; a script path's own
211
+ // reason is passed on, since its cw1 already discloses everything.
212
+ const openNote = async (k1, domains) => {
213
+ const spend = decodeSpend(k1);
214
+ if (!spend)
215
+ return { reason: INVALID_K1, specific: false };
216
+ const q = bytesToHex(spend.outputKey);
217
+ const note = await resolveNote(q);
218
+ if (!note)
219
+ return { reason: INVALID_K1, specific: false };
220
+ const verdict = await verifySpend(spend, {
221
+ outputKey: spend.outputKey,
222
+ domains,
223
+ now: Math.floor(Date.now() / 1000),
224
+ lockedAt: Math.floor(note.createdAt / 1000),
225
+ ...(scriptVerifier ? { scriptVerifier } : {})
226
+ });
227
+ if (!verdict.ok)
228
+ return verdict.specific ? { reason: verdict.reason, specific: true } : { reason: INVALID_K1, specific: false };
229
+ return { q, note };
230
+ };
208
231
  // ---- transparency: what the mint owes, and what the node holds ----
209
232
  //
210
233
  // Cached for 30 seconds so a public endpoint cannot be turned into a
@@ -374,6 +397,7 @@ export const createMoneyer = async (config, deps = {}) => {
374
397
  // them instead of the holder finding out it cannot.
375
398
  const mirrors = [config.publicOrigin, config.onionUrl].filter((candidate) => Boolean(candidate) && candidate !== origin);
376
399
  const host = new URL(origin).host;
400
+ const spendDomains = configuredDomains.length ? configuredDomains : [spendDomainOf(origin)];
377
401
  const send = (body, status = 200) => {
378
402
  res.writeHead(status, {
379
403
  'content-type': 'application/json',
@@ -477,8 +501,12 @@ export const createMoneyer = async (config, deps = {}) => {
477
501
  catch {
478
502
  return fail('Body must be JSON.', 400);
479
503
  }
480
- const result = registerName({
504
+ const result = await registerName({
481
505
  store,
506
+ openNote: async (k1) => {
507
+ const opened = await openNote(k1, spendDomains);
508
+ return 'reason' in opened ? null : opened.note;
509
+ },
482
510
  // The NIP-98 signer owns the name. No other identity is accepted:
483
511
  // a pubkey in the body would let anyone register a name to
484
512
  // somebody else's key.
@@ -486,7 +514,8 @@ export const createMoneyer = async (config, deps = {}) => {
486
514
  body: parsed,
487
515
  priceMsat: config.namePriceMsat,
488
516
  reserved: [config.username],
489
- host
517
+ host,
518
+ domains: spendDomains
490
519
  });
491
520
  if (isRefusal(result))
492
521
  return fail(result.reason, result.status);
@@ -680,28 +709,25 @@ export const createMoneyer = async (config, deps = {}) => {
680
709
  // funding source for an invoice. `h` remains an additive compatibility
681
710
  // field, but it never substitutes for comment.
682
711
  //
683
- // LUD-25 Part 2: the comment may instead be `cp1<pk>`, and the note is
684
- // keyed by that key, spent later with a ck1 rather than a secret.
712
+ // The comment is a cp1<Q>, or a bearer note's hex h, and the note is
713
+ // credited at that Q either way.
685
714
  const askedComment = q.get('comment')?.trim().toLowerCase() ?? null;
686
715
  const commentRef = askedComment === null ? null : namedRef(askedComment);
687
716
  if (commentRef === null) {
688
- return fail('a mint quote must name its output with a LUD-12 comment carrying hex(sha256(secret)) or a cp1 key');
717
+ return fail('a mint quote must name its output with a LUD-12 comment carrying a cp1 key or hex(sha256(secret))');
689
718
  }
690
- const commentOutputId = commentRef.id;
691
719
  const askedOutputId = q.get('h')?.trim().toLowerCase() ?? null;
692
720
  if (askedOutputId !== null && !HEX32.test(askedOutputId))
693
721
  return fail('missing h');
694
- // A wallet sending both should send the same hash in both; ours does.
722
+ // A wallet sending both should name the same note in both; ours does.
695
723
  // Disagreement is a bug in the caller, and picking a winner would
696
- // mint a note under a hash one half of it is not watching for.
697
- if (askedOutputId !== null &&
698
- commentOutputId !== askedOutputId) {
724
+ // mint a note under a key one half of it is not watching for.
725
+ if (askedOutputId !== null && decodeNote(askedOutputId) !== commentRef.q) {
699
726
  return fail('comment and h name different outputs');
700
727
  }
701
- const outputId = commentOutputId;
702
- if (store.outputIdInUse(outputId)) {
703
- return fail('Invalid or already spent k1.');
704
- }
728
+ const outputId = commentRef.q;
729
+ if (store.outputIdInUse(outputId))
730
+ return fail(IN_USE);
705
731
  // The preimage is payment proof only; comment names the future note.
706
732
  //
707
733
  // Where the funding source lets Moneyer choose it, choosing means the
@@ -712,11 +738,22 @@ export const createMoneyer = async (config, deps = {}) => {
712
738
  // the store keeps hashes only - and either way the bearer note is keyed
713
739
  // by the wallet's comment commitment, which is why a node that will not
714
740
  // take a preimage can still back a mint.
741
+ // A payment preimage is also a bearer note's spend: the note whose h
742
+ // is the payment hash. So a payment hash must not be that for this
743
+ // quote's own note or any other, or everyone who learns the preimage
744
+ // - the funding source, the route, /verify - could open it.
745
+ const opensANote = (paymentHash) => {
746
+ const asBearer = bearerNoteId(paymentHash);
747
+ return (paymentHash === outputId ||
748
+ asBearer === outputId ||
749
+ store.outputIdInUse(paymentHash) ||
750
+ store.outputIdInUse(asBearer));
751
+ };
715
752
  let chosen = null;
716
753
  if (backend.acceptsInvoicePreimage) {
717
754
  let preimageHex = bytesToHex(randomBytes(32));
718
755
  let paymentHash = hashK1(preimageHex);
719
- while (store.outputIdInUse(paymentHash) || paymentHash === outputId) {
756
+ while (opensANote(paymentHash)) {
720
757
  preimageHex = bytesToHex(randomBytes(32));
721
758
  paymentHash = hashK1(preimageHex);
722
759
  }
@@ -756,10 +793,7 @@ export const createMoneyer = async (config, deps = {}) => {
756
793
  // mint has already seen, or an invoice the node settled before handing
757
794
  // it over, would credit a note against money that never moved on this
758
795
  // quote's behalf.
759
- if (paymentHash === outputId ||
760
- store.outputIdInUse(paymentHash) ||
761
- store.mintInvoiceByHash(paymentHash) ||
762
- store.meltByHash(paymentHash)) {
796
+ if (opensANote(paymentHash) || store.mintInvoiceByHash(paymentHash) || store.meltByHash(paymentHash)) {
763
797
  log('funding source returned an invoice whose payment hash is already in use');
764
798
  return fail('Temporarily unable to issue an invoice.');
765
799
  }
@@ -779,7 +813,7 @@ export const createMoneyer = async (config, deps = {}) => {
779
813
  }
780
814
  }
781
815
  try {
782
- store.recordMintInvoice(paymentHash, pr, amount, net, outputId);
816
+ store.recordMintInvoice(paymentHash, pr, amount, net, outputId, commentRef.named);
783
817
  }
784
818
  catch (err) {
785
819
  // The read above already refused the collisions it could see; this
@@ -787,7 +821,7 @@ export const createMoneyer = async (config, deps = {}) => {
787
821
  // The invoice exists at the funding source but has been shown to
788
822
  // nobody, so nothing can be paid against it.
789
823
  if (err instanceof OutputCollisionError)
790
- return fail('Invalid or already spent k1.');
824
+ return fail(IN_USE);
791
825
  throw err;
792
826
  }
793
827
  return send({
@@ -802,7 +836,7 @@ export const createMoneyer = async (config, deps = {}) => {
802
836
  // value this invoice will mint. It is only offered when /verify can
803
837
  // later authenticate settlement with this mint's signing key.
804
838
  ...(config.verify && signer
805
- ? { mint: { h: outputId, amount: net } }
839
+ ? { mint: { h: commentRef.named, amount: net } }
806
840
  : {}),
807
841
  // Every current-draft mint quote is comment-bound, so the LUD-21
808
842
  // preimage is ordinary settlement proof and safe to disclose after
@@ -843,16 +877,21 @@ export const createMoneyer = async (config, deps = {}) => {
843
877
  pr: invoice.pr,
844
878
  // Repeating h and amount binds this response to the quote. The
845
879
  // ordinary note signature appears only once value exists at h.
880
+ // The receipt signs exactly the `h` it repeats - what the wallet
881
+ // named, a bearer note's h or a cp1's Q - since that is what a
882
+ // receipt validator checks it over. The note's own certificate,
883
+ // the cs1 over its Q, comes from the withdraw endpoint.
846
884
  ...(currentInvoice.outputId !== null && signer
847
- ? {
848
- mint: {
849
- h: currentInvoice.outputId,
850
- amount: currentInvoice.netMsat,
851
- ...(settled
852
- ? { sig: signer.sign(currentInvoice.outputId, currentInvoice.netMsat) }
853
- : {})
854
- }
855
- }
885
+ ? (() => {
886
+ const named = currentInvoice.outputNamed ?? currentInvoice.outputId;
887
+ return {
888
+ mint: {
889
+ h: named,
890
+ amount: currentInvoice.netMsat,
891
+ ...(settled ? { sig: signer.sign(named, currentInvoice.netMsat) } : {})
892
+ }
893
+ };
894
+ })()
856
895
  : {})
857
896
  });
858
897
  }
@@ -876,22 +915,36 @@ export const createMoneyer = async (config, deps = {}) => {
876
915
  // ---- LUD-03 informational GET ----
877
916
  if (requestUrl.pathname === '/w') {
878
917
  const hasK1 = q.has('k1');
879
- const k1 = q.get('k1')?.toLowerCase();
918
+ const k1 = q.get('k1')?.trim();
880
919
  const lookup = renamedRef(q, 'p', 'h');
881
920
  if (lookup.conflict)
882
921
  return fail('p and h name different notes');
883
922
  if (hasK1 === lookup.present)
884
923
  return fail('Unknown note.');
885
- // LUD-25's hash-only check lets a wallet prove that the note it just
886
- // bought exists without sending the bearer secret to the service a
887
- // second time. `p` (or `h`) is accepted in place of `k1`, never
888
- // alongside it, and names a Part 2 note by its cp1 key.
889
- const ref = hasK1 ? (k1 ? spentRef(k1) : null) : lookup.ref;
890
- if (!ref)
891
- return fail('Unknown note.');
892
- const note = await resolveNoteId(ref.id);
893
- if (!note)
894
- return fail('Unknown note.');
924
+ // With a k1, the spend is verified against its note in full, as
925
+ // LUD-25 requires: a wallet checking a note it was handed learns
926
+ // whether that spend really opens it, and a script path's own reason
927
+ // (a timelock not yet due) is passed on. `p` (or `h`) is accepted in
928
+ // place of k1, never alongside it, so a wallet can check a note
929
+ // without sending its spend: a cp1, or a bearer note's h.
930
+ let noteQ;
931
+ let note;
932
+ if (hasK1) {
933
+ const opened = k1 ? await openNote(k1, spendDomains) : null;
934
+ if (!opened || 'reason' in opened)
935
+ return fail(opened?.specific ? opened.reason : 'Unknown note.');
936
+ noteQ = opened.q;
937
+ note = opened.note;
938
+ }
939
+ else {
940
+ if (!lookup.ref)
941
+ return fail('Unknown note.');
942
+ const found = await resolveNote(lookup.ref.q);
943
+ if (!found)
944
+ return fail('Unknown note.');
945
+ noteQ = lookup.ref.q;
946
+ note = found;
947
+ }
895
948
  // Hash lookups protect the secret, not the note's spent state.
896
949
  // Retained burned rows let a wallet reconcile without revealing k1.
897
950
  if (note.state === 'burned')
@@ -914,10 +967,10 @@ export const createMoneyer = async (config, deps = {}) => {
914
967
  return send({
915
968
  tag: 'withdrawRequest',
916
969
  callback: `${origin}/w/cb`,
917
- // A hash-only query deliberately does not echo or invent k1. The
918
- // wallet already has the secret; this endpoint only confirms the
919
- // mint's record at sha256(k1).
920
- ...(k1 ? { k1 } : {}),
970
+ // Echoed verbatim, so a wallet can copy it into a note URL or the
971
+ // callback. A lookup by p deliberately does not echo or invent one:
972
+ // the wallet already has its spend.
973
+ ...(hasK1 && k1 ? { k1 } : {}),
921
974
  minWithdrawable: wholeSatFloor(note.amountMsat),
922
975
  maxWithdrawable: note.amountMsat,
923
976
  defaultDescription: config.description,
@@ -932,15 +985,15 @@ export const createMoneyer = async (config, deps = {}) => {
932
985
  // exists.
933
986
  ...(mirrors.length ? { mirrors } : {}),
934
987
  ...(signer ? { mintPubkey: signer.pubkey } : {}),
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) : {})
988
+ // Every note gets its certificate here, so a wallet need not rotate
989
+ // just to obtain one, and a recovery scan probing ?p= gets one free.
990
+ // A certificate is no spend authorisation, so p needs no proof.
991
+ ...certified('c', noteQ, note.amountMsat)
939
992
  });
940
993
  }
941
994
  // ---- the mutating callback: melt / rotate / split / merge ----
942
995
  if (requestUrl.pathname === '/w/cb') {
943
- const k1s = q.getAll('k1').map(value => value.toLowerCase());
996
+ const k1s = q.getAll('k1').map(value => value.trim());
944
997
  const pr = q.get('pr');
945
998
  const amountRaw = q.get('amount');
946
999
  // One ref per output from here on, so the replay match below treats
@@ -967,22 +1020,28 @@ export const createMoneyer = async (config, deps = {}) => {
967
1020
  return fail('missing p1');
968
1021
  if (amountRaw !== null && !o2)
969
1022
  return fail('missing p2');
970
- if (o2 && o2.id === o1.id)
1023
+ if (o2 && o2.q === o1.q)
971
1024
  return fail('p1 and p2 must differ.');
972
1025
  }
973
1026
  if (config.sunset && amountRaw !== null) {
974
1027
  return fail('This mint is sunsetting - splitting is disabled.');
975
1028
  }
1029
+ // Every k1 is verified against its note first, spent or not, so a
1030
+ // retry of a completed mutation can still be recognised below. One
1031
+ // failure fails the whole request: nothing burns, nothing mints.
1032
+ const opened = [];
1033
+ for (const k1 of k1s) {
1034
+ const result = await openNote(k1, spendDomains);
1035
+ if ('reason' in result)
1036
+ return fail(result.reason);
1037
+ opened.push(result);
1038
+ }
976
1039
  // A note named twice would count its value twice on the way into a
977
- // merge or split. Compared by the note each k1 spends, since two
978
- // different ck1 strings can spend the same one. Atomic refusal, same
979
- // as an invalid k1.
980
- const spent = k1s.map(spentRef);
981
- if (spent.some(ref => ref === null))
982
- return fail('Invalid or already spent k1.');
983
- const spentIds = spent.map(ref => ref.id);
984
- if (new Set(spentIds).size !== spentIds.length)
985
- return fail('Invalid or already spent k1.');
1040
+ // merge or split. Compared by Q, since one note can be opened by more
1041
+ // than one valid spend. Atomic refusal, same as an invalid k1.
1042
+ const spentQs = opened.map(input => input.q);
1043
+ if (new Set(spentQs).size !== spentQs.length)
1044
+ return fail(INVALID_K1);
986
1045
  // ---- the retried mutation ----
987
1046
  //
988
1047
  // A rotate, split or merge is a GET, and transports retry GETs.
@@ -996,40 +1055,43 @@ export const createMoneyer = async (config, deps = {}) => {
996
1055
  // So a request that already minted its outputs is answered with the
997
1056
  // same reply. This branch burns nothing, mints nothing and moves no
998
1057
  // balance: it is a read. The signature is deterministic over the
999
- // output id and its amount, so it is recomputed rather than stored.
1058
+ // output and its amount, so it is recomputed rather than stored.
1000
1059
  // Anything else naming a burned input is a double-spend attempt and
1001
1060
  // still gets today's reason string, unchanged. The fingerprint is
1002
- // over note ids, so a hex request fingerprints exactly as it did
1003
- // before Part 2 and replays from before an upgrade still match.
1061
+ // over the notes' Qs, never the k1 strings, so any valid spend of the
1062
+ // same notes is the same request. A mutation completed before notes
1063
+ // were keyed by Q was fingerprinted over the ids it was stored and
1064
+ // named by, so that is tried as well.
1065
+ const amountPart = amountRaw !== null ? { amountMsat: Number(amountRaw) } : {};
1004
1066
  const fingerprint = pr !== null
1005
1067
  ? null
1006
- : swapFingerprint({
1007
- inputIds: spentIds,
1008
- h: o1.id,
1009
- h2: o2?.id,
1010
- ...(amountRaw !== null ? { amountMsat: Number(amountRaw) } : {})
1011
- });
1012
- const alreadyMinted = fingerprint === null ? null : store.swapByFingerprint(fingerprint);
1068
+ : swapFingerprint({ inputIds: spentQs, h: o1.q, h2: o2?.q, ...amountPart });
1069
+ const earlierFingerprint = pr !== null
1070
+ ? null
1071
+ : swapFingerprint({ inputIds: opened.map(input => input.note.id), h: o1.named, h2: o2?.named, ...amountPart });
1072
+ const alreadyMinted = fingerprint === null
1073
+ ? null
1074
+ : (store.swapByFingerprint(fingerprint) ??
1075
+ (earlierFingerprint !== fingerprint ? store.swapByFingerprint(earlierFingerprint) : null));
1013
1076
  if (alreadyMinted) {
1014
- const first = alreadyMinted.find(output => output.id === o1.id);
1015
- const second = o2 ? alreadyMinted.find(output => output.id === o2.id) : undefined;
1077
+ const recorded = (ref) => alreadyMinted.find(output => output.id === ref.q || output.id === ref.named);
1078
+ const first = recorded(o1);
1079
+ const second = o2 ? recorded(o2) : undefined;
1016
1080
  if (first) {
1017
1081
  return send({
1018
1082
  status: 'OK',
1019
- ...certified('sig', o1, first.amountMsat),
1020
- ...(second ? certified('sig2', o2, second.amountMsat) : {})
1083
+ ...certified('c', o1.q, first.amountMsat),
1084
+ ...(second ? certified('c2', o2.q, second.amountMsat) : {})
1021
1085
  });
1022
1086
  }
1023
1087
  }
1024
- const found = [];
1025
- for (const id of spentIds) {
1026
- const note = await resolveNoteId(id);
1027
- if (!note || note.state === 'burned')
1028
- return fail('Invalid or already spent k1.');
1088
+ for (const { note } of opened) {
1089
+ if (note.state === 'burned')
1090
+ return fail(INVALID_K1);
1029
1091
  if (note.state === 'pending')
1030
1092
  return fail('pending');
1031
- found.push(note);
1032
1093
  }
1094
+ const found = opened.map(input => input.note);
1033
1095
  const totalMsat = found.reduce((sum, note) => sum + note.amountMsat, 0);
1034
1096
  const inputIds = found.map(note => note.id);
1035
1097
  // melt
@@ -1095,7 +1157,7 @@ export const createMoneyer = async (config, deps = {}) => {
1095
1157
  if (err instanceof NotePendingError)
1096
1158
  return fail('pending');
1097
1159
  if (err instanceof NoteUnavailableError)
1098
- return fail('Invalid or already spent k1.');
1160
+ return fail(INVALID_K1);
1099
1161
  return fail('Invoice already used by an earlier melt - use a fresh one.');
1100
1162
  }
1101
1163
  inFlight.add(paymentHash);
@@ -1138,23 +1200,23 @@ export const createMoneyer = async (config, deps = {}) => {
1138
1200
  return fail('insufficient value');
1139
1201
  try {
1140
1202
  store.swap(inputIds, [
1141
- { id: o1.id, amountMsat: amount },
1142
- { id: o2.id, amountMsat: changeMsat }
1203
+ { id: o1.q, amountMsat: amount },
1204
+ { id: o2.q, amountMsat: changeMsat }
1143
1205
  ], fingerprint ?? undefined);
1144
1206
  }
1145
1207
  catch (err) {
1146
1208
  if (err instanceof NotePendingError)
1147
1209
  return fail('pending');
1148
- if (err instanceof OutputCollisionError && (o1.cp1 || o2.cp1))
1149
- return fail('Output already in use.');
1150
- // OutputCollisionError shares the dead-k1 reason on purpose:
1151
- // which table the id collided with is an oracle nobody is owed.
1152
- return fail('Invalid or already spent k1.');
1210
+ // LUD-25 names this one: an output already credited, outstanding
1211
+ // or burned. A wallet deriving keys retries at the next index.
1212
+ if (err instanceof OutputCollisionError)
1213
+ return fail(IN_USE);
1214
+ return fail(INVALID_K1);
1153
1215
  }
1154
1216
  return send({
1155
1217
  status: 'OK',
1156
- ...certified('sig', o1, amount),
1157
- ...certified('sig2', o2, changeMsat)
1218
+ ...certified('c', o1.q, amount),
1219
+ ...certified('c2', o2.q, changeMsat)
1158
1220
  });
1159
1221
  }
1160
1222
  // rotate (one k1) or merge (several) - the same swap either way.
@@ -1163,17 +1225,16 @@ export const createMoneyer = async (config, deps = {}) => {
1163
1225
  // of one - the refund is exactly 0.
1164
1226
  const mergedMsat = totalMsat + (inputIds.length - 1) * baseFeeMsat;
1165
1227
  try {
1166
- store.swap(inputIds, [{ id: o1.id, amountMsat: mergedMsat }], fingerprint ?? undefined);
1228
+ store.swap(inputIds, [{ id: o1.q, amountMsat: mergedMsat }], fingerprint ?? undefined);
1167
1229
  }
1168
1230
  catch (err) {
1169
1231
  if (err instanceof NotePendingError)
1170
1232
  return fail('pending');
1171
- if (err instanceof OutputCollisionError && o1.cp1)
1172
- return fail('Output already in use.');
1173
- // same oracle-free reason for a collision as for a dead k1
1174
- return fail('Invalid or already spent k1.');
1233
+ if (err instanceof OutputCollisionError)
1234
+ return fail(IN_USE);
1235
+ return fail(INVALID_K1);
1175
1236
  }
1176
- return send({ status: 'OK', ...certified('sig', o1, mergedMsat) });
1237
+ return send({ status: 'OK', ...certified('c', o1.q, mergedMsat) });
1177
1238
  }
1178
1239
  return fail('Not found.', 404);
1179
1240
  };
@@ -1269,6 +1330,7 @@ export const createMoneyer = async (config, deps = {}) => {
1269
1330
  if (statsTimer)
1270
1331
  clearInterval(statsTimer);
1271
1332
  nostr?.close();
1333
+ kernel?.close();
1272
1334
  await new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve())));
1273
1335
  await backend.close?.();
1274
1336
  store.close();