@forgesworn/moneyer 0.16.1 → 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/CHANGELOG.md +84 -0
- package/README.md +69 -31
- package/TERMS-TEMPLATE.md +14 -0
- package/THREAT-MODEL.md +43 -11
- package/dist/admin.js +24 -14
- package/dist/cli.js +3 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/kernel.d.ts +10 -0
- package/dist/kernel.js +105 -0
- package/dist/names.d.ts +12 -2
- package/dist/names.js +50 -7
- package/dist/server.d.ts +2 -0
- package/dist/server.js +201 -139
- package/dist/spend.d.ts +85 -0
- package/dist/spend.js +454 -0
- package/dist/store.d.ts +6 -1
- package/dist/store.js +91 -11
- package/dist/zap.js +11 -6
- package/llms.txt +25 -11
- package/package.json +3 -2
- package/scripts/kernel-verifier.py +59 -0
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,
|
|
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
|
-
|
|
25
|
-
|
|
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?.
|
|
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
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
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
|
|
189
|
-
const note = store.
|
|
188
|
+
const resolveNote = async (q) => {
|
|
189
|
+
const note = store.noteByQ(q);
|
|
190
190
|
if (note)
|
|
191
191
|
return note;
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
684
|
-
//
|
|
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))
|
|
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
|
|
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
|
|
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 =
|
|
702
|
-
if (store.outputIdInUse(outputId))
|
|
703
|
-
return fail(
|
|
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 (
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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')?.
|
|
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
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
if (
|
|
894
|
-
|
|
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
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
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
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
...
|
|
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.
|
|
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.
|
|
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
|
|
978
|
-
//
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
|
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
|
|
1003
|
-
//
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
|
1015
|
-
const
|
|
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('
|
|
1020
|
-
...(second ? certified('
|
|
1083
|
+
...certified('c', o1.q, first.amountMsat),
|
|
1084
|
+
...(second ? certified('c2', o2.q, second.amountMsat) : {})
|
|
1021
1085
|
});
|
|
1022
1086
|
}
|
|
1023
1087
|
}
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
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(
|
|
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.
|
|
1142
|
-
{ id: o2.
|
|
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
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
return fail(
|
|
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('
|
|
1157
|
-
...certified('
|
|
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.
|
|
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
|
|
1172
|
-
return fail(
|
|
1173
|
-
|
|
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('
|
|
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();
|