@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 +39 -0
- package/README.md +43 -5
- package/THREAT-MODEL.md +14 -1
- package/dist/names.d.ts +3 -0
- package/dist/names.js +23 -4
- package/dist/server.js +108 -48
- package/dist/store.d.ts +13 -0
- package/dist/store.js +69 -14
- package/dist/zap.d.ts +1 -0
- package/dist/zap.js +35 -10
- package/llms.txt +17 -8
- package/package.json +3 -3
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 `
|
|
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
|
|
@@ -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,
|
|
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 `
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
635
|
-
|
|
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 =
|
|
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
|
|
829
|
-
if (
|
|
830
|
-
(
|
|
831
|
-
|
|
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
|
|
837
|
-
|
|
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
|
-
|
|
888
|
-
|
|
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 (
|
|
900
|
-
return fail('
|
|
901
|
-
if (
|
|
902
|
-
return fail('
|
|
903
|
-
if (
|
|
904
|
-
return fail('
|
|
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
|
|
910
|
-
// merge or split.
|
|
911
|
-
|
|
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
|
-
//
|
|
930
|
-
//
|
|
931
|
-
|
|
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:
|
|
936
|
-
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 ===
|
|
943
|
-
const second =
|
|
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:
|
|
950
|
-
...(second ? { sig2:
|
|
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
|
|
958
|
-
|
|
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:
|
|
1076
|
-
{ id:
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
514
|
+
.prepare(`SELECT ${NoteStore.NAME_COLUMNS} FROM zap_names WHERE name = ?`)
|
|
459
515
|
.get(name.toLowerCase());
|
|
460
|
-
|
|
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(
|
|
520
|
+
.prepare(`SELECT ${NoteStore.NAME_COLUMNS} FROM zap_names ORDER BY name`)
|
|
467
521
|
.all();
|
|
468
|
-
return rows.map(row => (
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
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,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 (
|
|
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
|
|
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.
|
|
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.
|
|
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",
|