@forgesworn/moneyer 0.11.1 → 0.12.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 +25 -0
- package/README.md +21 -5
- package/THREAT-MODEL.md +6 -1
- package/dist/server.js +100 -46
- package/dist/store.js +5 -0
- package/llms.txt +11 -8
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0 - 2026-09-11
|
|
4
|
+
|
|
5
|
+
- LUD-25's renamed parameters are accepted: `p` for the informational
|
|
6
|
+
GET's hash lookup, `p1`/`p2` for the callback's outputs. The old names
|
|
7
|
+
`h` and `h`/`h2` keep working, and a wallet may mix them in one request.
|
|
8
|
+
Sending both spellings of one value with different contents is refused.
|
|
9
|
+
A retry is matched on the outputs, not the spelling, so a split first
|
|
10
|
+
sent as `h`/`h2` and retried as `p1`/`p2` is answered as a replay.
|
|
11
|
+
- The callback's missing-output reasons are now `missing p1` and
|
|
12
|
+
`missing p2` (were `missing h`/`missing h2`), and `p1 and p2 must
|
|
13
|
+
differ.` The pay callback's `missing h` is unchanged.
|
|
14
|
+
- **LUD-25 Part 2 notes.** A note can be keyed by a public key and spent
|
|
15
|
+
with a recoverable signature, so the mint never holds anything that could
|
|
16
|
+
spend it. `comment=cp1…` mints one; `k1=ck1…` looks it up or spends it;
|
|
17
|
+
`p`, `p1` and `p2` take `cp1` keys; and a Part 2 note's certificate comes
|
|
18
|
+
back as `cs1`, on the lookup and on every rotate, split and merge. The two
|
|
19
|
+
kinds mix freely in one merge.
|
|
20
|
+
- A note named twice in one request is now caught by the note each `k1`
|
|
21
|
+
spends rather than by the `k1` string, since one Part 2 note has many
|
|
22
|
+
valid `ck1` strings. The store refuses a repeated input as well.
|
|
23
|
+
- Takes lnurlcash-kit 0.10.0.
|
|
24
|
+
|
|
3
25
|
## 0.11.1 - 2026-09-07
|
|
4
26
|
|
|
5
27
|
- Hash lookups report `Note already spent.` for retained burned notes,
|
|
@@ -9,6 +31,9 @@
|
|
|
9
31
|
burned rows are retained already; no database migration is required.
|
|
10
32
|
- Test tooling uses an immutable conformance commit matching this response
|
|
11
33
|
contract. It is a development dependency, not a runtime dependency.
|
|
34
|
+
Since 2026-09-09 that contract is released as `lnurlcash-conformance`
|
|
35
|
+
0.8.0 and the pin is the version rather than the commit; the graded bytes
|
|
36
|
+
are the same ones 0.11.1 shipped against.
|
|
12
37
|
|
|
13
38
|
## 0.11.0 - 2026-09-06
|
|
14
39
|
|
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 `
|
|
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
|
|
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
|
|
@@ -586,11 +602,11 @@ clearnet.
|
|
|
586
602
|
| `/.well-known/lnurlp/<user>` | LUD-16 payRequest; paying mints a note |
|
|
587
603
|
| `/.well-known/lnurlp/<zap name>` | NIP-57 payRequest; paying mints a note *to the name's pubkey* |
|
|
588
604
|
| `/.well-known/lnurlw/<user>` | LUD-25 mint address discovery (experimental) |
|
|
589
|
-
| `/p/cb` | LUD-06 pay callback; issues the mint invoice,
|
|
605
|
+
| `/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
606
|
| `/z/cb/<zap name>` | the zap callback; validates the kind 9734 and issues the invoice |
|
|
591
607
|
| `/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 `
|
|
593
|
-
| `/w/cb` | the mutating callback: melt, rotate, split, merge |
|
|
608
|
+
| `/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` |
|
|
609
|
+
| `/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
610
|
| `POST /names` | claim a lightning address, authenticated by NIP-98 |
|
|
595
611
|
| `/.well-known/nostr.json` | NIP-05 for the names this mint serves |
|
|
596
612
|
| `/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
|
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.
|
|
@@ -163,7 +198,6 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
163
198
|
}
|
|
164
199
|
return null;
|
|
165
200
|
};
|
|
166
|
-
const resolveNote = async (k1) => resolveNoteId(hashK1(k1));
|
|
167
201
|
// ---- transparency: what the mint owes, and what the node holds ----
|
|
168
202
|
//
|
|
169
203
|
// Cached for 30 seconds so a public endpoint cannot be turned into a
|
|
@@ -630,11 +664,15 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
630
664
|
// commitment. Missing or malformed input is refused before asking the
|
|
631
665
|
// funding source for an invoice. `h` remains an additive compatibility
|
|
632
666
|
// field, but it never substitutes for comment.
|
|
667
|
+
//
|
|
668
|
+
// LUD-25 Part 2: the comment may instead be `cp1<pk>`, and the note is
|
|
669
|
+
// keyed by that key, spent later with a ck1 rather than a secret.
|
|
633
670
|
const askedComment = q.get('comment')?.trim().toLowerCase() ?? null;
|
|
634
|
-
|
|
635
|
-
|
|
671
|
+
const commentRef = askedComment === null ? null : namedRef(askedComment);
|
|
672
|
+
if (commentRef === null) {
|
|
673
|
+
return fail('a mint quote must name its output with a LUD-12 comment carrying hex(sha256(secret)) or a cp1 key');
|
|
636
674
|
}
|
|
637
|
-
const commentOutputId =
|
|
675
|
+
const commentOutputId = commentRef.id;
|
|
638
676
|
const askedOutputId = q.get('h')?.trim().toLowerCase() ?? null;
|
|
639
677
|
if (askedOutputId !== null && !HEX32.test(askedOutputId))
|
|
640
678
|
return fail('missing h');
|
|
@@ -823,18 +861,20 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
823
861
|
// ---- LUD-03 informational GET ----
|
|
824
862
|
if (requestUrl.pathname === '/w') {
|
|
825
863
|
const hasK1 = q.has('k1');
|
|
826
|
-
const hasH = q.has('h');
|
|
827
864
|
const k1 = q.get('k1')?.toLowerCase();
|
|
828
|
-
const
|
|
829
|
-
if (
|
|
830
|
-
(
|
|
831
|
-
|
|
865
|
+
const lookup = renamedRef(q, 'p', 'h');
|
|
866
|
+
if (lookup.conflict)
|
|
867
|
+
return fail('p and h name different notes');
|
|
868
|
+
if (hasK1 === lookup.present)
|
|
832
869
|
return fail('Unknown note.');
|
|
833
|
-
}
|
|
834
870
|
// LUD-25's hash-only check lets a wallet prove that the note it just
|
|
835
871
|
// bought exists without sending the bearer secret to the service a
|
|
836
|
-
// second time. `h` is accepted in place of `k1`, never
|
|
837
|
-
|
|
872
|
+
// second time. `p` (or `h`) is accepted in place of `k1`, never
|
|
873
|
+
// alongside it, and names a Part 2 note by its cp1 key.
|
|
874
|
+
const ref = hasK1 ? (k1 ? spentRef(k1) : null) : lookup.ref;
|
|
875
|
+
if (!ref)
|
|
876
|
+
return fail('Unknown note.');
|
|
877
|
+
const note = await resolveNoteId(ref.id);
|
|
838
878
|
if (!note)
|
|
839
879
|
return fail('Unknown note.');
|
|
840
880
|
// Hash lookups protect the secret, not the note's spent state.
|
|
@@ -876,7 +916,10 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
876
916
|
// but a note learns from the note itself that the other door
|
|
877
917
|
// exists.
|
|
878
918
|
...(mirrors.length ? { mirrors } : {}),
|
|
879
|
-
...(signer ? { mintPubkey: signer.pubkey } : {})
|
|
919
|
+
...(signer ? { mintPubkey: signer.pubkey } : {}),
|
|
920
|
+
// LUD-25 Part 2: a note named by cp1 or ck1 gets its certificate
|
|
921
|
+
// here, so a wallet need not rotate just to obtain one.
|
|
922
|
+
...(ref.cp1 ? { sig: certify(ref, note.amountMsat) } : {})
|
|
880
923
|
});
|
|
881
924
|
}
|
|
882
925
|
// ---- the mutating callback: melt / rotate / split / merge ----
|
|
@@ -884,8 +927,12 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
884
927
|
const k1s = q.getAll('k1').map(value => value.toLowerCase());
|
|
885
928
|
const pr = q.get('pr');
|
|
886
929
|
const amountRaw = q.get('amount');
|
|
887
|
-
|
|
888
|
-
|
|
930
|
+
// One ref per output from here on, so the replay match below treats
|
|
931
|
+
// `p1=X` and `h=X` as the same request.
|
|
932
|
+
const out1 = renamedRef(q, 'p1', 'h');
|
|
933
|
+
const out2 = renamedRef(q, 'p2', 'h2');
|
|
934
|
+
const o1 = out1.ref;
|
|
935
|
+
const o2 = out2.ref;
|
|
889
936
|
if (k1s.length === 0)
|
|
890
937
|
return fail('Missing k1.');
|
|
891
938
|
if (k1s.length > config.maxK1s)
|
|
@@ -896,19 +943,29 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
896
943
|
// Hash parameters are checked before any note resolves, so an
|
|
897
944
|
// invalid or missing hash can never burn anything.
|
|
898
945
|
if (!pr) {
|
|
899
|
-
if (
|
|
900
|
-
return fail('
|
|
901
|
-
if (
|
|
902
|
-
return fail('
|
|
903
|
-
if (
|
|
904
|
-
return fail('
|
|
946
|
+
if (out1.conflict)
|
|
947
|
+
return fail('p1 and h name different outputs');
|
|
948
|
+
if (out2.conflict)
|
|
949
|
+
return fail('p2 and h2 name different outputs');
|
|
950
|
+
if (!o1)
|
|
951
|
+
return fail('missing p1');
|
|
952
|
+
if (amountRaw !== null && !o2)
|
|
953
|
+
return fail('missing p2');
|
|
954
|
+
if (o2 && o2.id === o1.id)
|
|
955
|
+
return fail('p1 and p2 must differ.');
|
|
905
956
|
}
|
|
906
957
|
if (config.sunset && amountRaw !== null) {
|
|
907
958
|
return fail('This mint is sunsetting - splitting is disabled.');
|
|
908
959
|
}
|
|
909
|
-
// A
|
|
910
|
-
// merge or split.
|
|
911
|
-
|
|
960
|
+
// A note named twice would count its value twice on the way into a
|
|
961
|
+
// merge or split. Compared by the note each k1 spends, since two
|
|
962
|
+
// different ck1 strings can spend the same one. Atomic refusal, same
|
|
963
|
+
// as an invalid k1.
|
|
964
|
+
const spent = k1s.map(spentRef);
|
|
965
|
+
if (spent.some(ref => ref === null))
|
|
966
|
+
return fail('Invalid or already spent k1.');
|
|
967
|
+
const spentIds = spent.map(ref => ref.id);
|
|
968
|
+
if (new Set(spentIds).size !== spentIds.length)
|
|
912
969
|
return fail('Invalid or already spent k1.');
|
|
913
970
|
// ---- the retried mutation ----
|
|
914
971
|
//
|
|
@@ -925,39 +982,36 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
925
982
|
// balance: it is a read. The signature is deterministic over the
|
|
926
983
|
// output id and its amount, so it is recomputed rather than stored.
|
|
927
984
|
// Anything else naming a burned input is a double-spend attempt and
|
|
928
|
-
// still gets today's reason string, unchanged.
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
|
|
932
|
-
const fingerprint = pr !== null || !k1s.every(k1 => HEX32.test(k1))
|
|
985
|
+
// still gets today's reason string, unchanged. The fingerprint is
|
|
986
|
+
// over note ids, so a hex request fingerprints exactly as it did
|
|
987
|
+
// before Part 2 and replays from before an upgrade still match.
|
|
988
|
+
const fingerprint = pr !== null
|
|
933
989
|
? null
|
|
934
990
|
: swapFingerprint({
|
|
935
|
-
inputIds:
|
|
936
|
-
h:
|
|
937
|
-
h2,
|
|
991
|
+
inputIds: spentIds,
|
|
992
|
+
h: o1.id,
|
|
993
|
+
h2: o2?.id,
|
|
938
994
|
...(amountRaw !== null ? { amountMsat: Number(amountRaw) } : {})
|
|
939
995
|
});
|
|
940
996
|
const alreadyMinted = fingerprint === null ? null : store.swapByFingerprint(fingerprint);
|
|
941
997
|
if (alreadyMinted) {
|
|
942
|
-
const first = alreadyMinted.find(output => output.id ===
|
|
943
|
-
const second =
|
|
998
|
+
const first = alreadyMinted.find(output => output.id === o1.id);
|
|
999
|
+
const second = o2 ? alreadyMinted.find(output => output.id === o2.id) : undefined;
|
|
944
1000
|
if (first) {
|
|
945
1001
|
return send({
|
|
946
1002
|
status: 'OK',
|
|
947
1003
|
...(signer
|
|
948
1004
|
? {
|
|
949
|
-
sig:
|
|
950
|
-
...(second ? { sig2:
|
|
1005
|
+
sig: certify(o1, first.amountMsat),
|
|
1006
|
+
...(second ? { sig2: certify(o2, second.amountMsat) } : {})
|
|
951
1007
|
}
|
|
952
1008
|
: {})
|
|
953
1009
|
});
|
|
954
1010
|
}
|
|
955
1011
|
}
|
|
956
1012
|
const found = [];
|
|
957
|
-
for (const
|
|
958
|
-
|
|
959
|
-
return fail('Invalid or already spent k1.');
|
|
960
|
-
const note = await resolveNote(k1);
|
|
1013
|
+
for (const id of spentIds) {
|
|
1014
|
+
const note = await resolveNoteId(id);
|
|
961
1015
|
if (!note || note.state === 'burned')
|
|
962
1016
|
return fail('Invalid or already spent k1.');
|
|
963
1017
|
if (note.state === 'pending')
|
|
@@ -1072,8 +1126,8 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1072
1126
|
return fail('insufficient value');
|
|
1073
1127
|
try {
|
|
1074
1128
|
store.swap(inputIds, [
|
|
1075
|
-
{ id:
|
|
1076
|
-
{ id:
|
|
1129
|
+
{ id: o1.id, amountMsat: amount },
|
|
1130
|
+
{ id: o2.id, amountMsat: changeMsat }
|
|
1077
1131
|
], fingerprint ?? undefined);
|
|
1078
1132
|
}
|
|
1079
1133
|
catch (err) {
|
|
@@ -1085,7 +1139,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1085
1139
|
}
|
|
1086
1140
|
return send({
|
|
1087
1141
|
status: 'OK',
|
|
1088
|
-
...(signer ? { sig:
|
|
1142
|
+
...(signer ? { sig: certify(o1, amount), sig2: certify(o2, changeMsat) } : {})
|
|
1089
1143
|
});
|
|
1090
1144
|
}
|
|
1091
1145
|
// rotate (one k1) or merge (several) - the same swap either way.
|
|
@@ -1094,7 +1148,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1094
1148
|
// of one - the refund is exactly 0.
|
|
1095
1149
|
const mergedMsat = totalMsat + (inputIds.length - 1) * baseFeeMsat;
|
|
1096
1150
|
try {
|
|
1097
|
-
store.swap(inputIds, [{ id:
|
|
1151
|
+
store.swap(inputIds, [{ id: o1.id, amountMsat: mergedMsat }], fingerprint ?? undefined);
|
|
1098
1152
|
}
|
|
1099
1153
|
catch (err) {
|
|
1100
1154
|
if (err instanceof NotePendingError)
|
|
@@ -1102,7 +1156,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1102
1156
|
// same oracle-free reason for a collision as for a dead k1
|
|
1103
1157
|
return fail('Invalid or already spent k1.');
|
|
1104
1158
|
}
|
|
1105
|
-
return send({ status: 'OK', ...(signer ? { sig:
|
|
1159
|
+
return send({ status: 'OK', ...(signer ? { sig: certify(o1, mergedMsat) } : {}) });
|
|
1106
1160
|
}
|
|
1107
1161
|
return fail('Not found.', 404);
|
|
1108
1162
|
};
|
package/dist/store.js
CHANGED
|
@@ -214,6 +214,11 @@ export class NoteStore {
|
|
|
214
214
|
// a secret the mint really did mint a note against.
|
|
215
215
|
swap(inputIds, outputs, fingerprint) {
|
|
216
216
|
this.tx(() => {
|
|
217
|
+
// Every input is checked before any is burned, so a note named twice
|
|
218
|
+
// would pass both checks and be counted twice by the caller.
|
|
219
|
+
if (new Set(inputIds).size !== inputIds.length) {
|
|
220
|
+
throw new NoteUnavailableError('a note is named twice among the inputs');
|
|
221
|
+
}
|
|
217
222
|
for (const id of inputIds)
|
|
218
223
|
this.assertOutstanding(id);
|
|
219
224
|
for (const output of outputs)
|
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.
|
|
31
|
-
|
|
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
|
|
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,7 @@ 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 (
|
|
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
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/moneyer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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.
|
|
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": "
|
|
73
|
+
"lnurlcash-conformance": "^0.8.0",
|
|
74
74
|
"playwright": "^1.62.1",
|
|
75
75
|
"typescript": "^5.7.0",
|
|
76
76
|
"uqr": "^0.1.2",
|