@forgesworn/moneyer 0.1.2 → 0.2.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 +170 -0
- package/README.md +447 -11
- package/THREAT-MODEL.md +15 -6
- package/dist/admin.d.ts +12 -0
- package/dist/admin.js +410 -0
- package/dist/announce.d.ts +12 -0
- package/dist/announce.js +82 -0
- package/dist/backends/cln.js +30 -8
- package/dist/backends/fake-bolt11.d.ts +1 -1
- package/dist/backends/fake-bolt11.js +3 -1
- package/dist/backends/fake.d.ts +7 -1
- package/dist/backends/fake.js +27 -11
- package/dist/backends/lnd.js +30 -6
- package/dist/backends/types.d.ts +3 -0
- package/dist/cli.js +25 -3
- package/dist/config.d.ts +29 -0
- package/dist/config.js +194 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/landing.d.ts +2 -0
- package/dist/landing.js +42 -2
- package/dist/melt.d.ts +1 -0
- package/dist/melt.js +2 -1
- package/dist/names.d.ts +39 -0
- package/dist/names.js +172 -0
- package/dist/privacy.d.ts +2 -0
- package/dist/privacy.js +14 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +561 -41
- package/dist/signing.d.ts +2 -0
- package/dist/signing.js +34 -0
- package/dist/stats.d.ts +31 -0
- package/dist/stats.js +115 -0
- package/dist/store.d.ts +105 -3
- package/dist/store.js +355 -19
- package/dist/version.d.ts +1 -0
- package/dist/version.js +17 -0
- package/dist/zap.d.ts +52 -0
- package/dist/zap.js +294 -0
- package/llms.txt +25 -8
- package/package.json +4 -3
package/dist/signing.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare const noteIdSignatureDigest: (noteId: string, amountMsat: number) => Uint8Array;
|
|
2
|
+
export declare const signDigestRecoverable: (digest: Uint8Array, privateKeyHex: string) => string;
|
|
3
|
+
export declare const recoversToPubkey: (digest: Uint8Array, signatureHex: string, pubkeyHex: string) => boolean;
|
|
2
4
|
export type NoteSigner = {
|
|
3
5
|
pubkey: string;
|
|
4
6
|
sign: (noteId: string, amountMsat: number) => string;
|
package/dist/signing.js
CHANGED
|
@@ -12,6 +12,40 @@ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
|
12
12
|
// agree byte for byte; the test suite proves they do by verifying every
|
|
13
13
|
// signature this module makes with the kit's verifyNoteSignature.
|
|
14
14
|
export const noteIdSignatureDigest = (noteId, amountMsat) => sha256(sha256(utf8ToBytes(`Lightning Signed Message:LNURLcash:${amountMsat}:${noteId}`)));
|
|
15
|
+
// A recoverable signature in the LUD-25 wire layout, r || s || recovery_id.
|
|
16
|
+
// noble v2 emits recovery_id || r || s, so the leading byte moves to the
|
|
17
|
+
// back. Anything this mint signs for a third party to check goes through
|
|
18
|
+
// here.
|
|
19
|
+
export const signDigestRecoverable = (digest, privateKeyHex) => {
|
|
20
|
+
const lead = secp256k1.sign(digest, hexToBytes(privateKeyHex), { format: 'recovered', prehash: false });
|
|
21
|
+
return bytesToHex(new Uint8Array([...lead.subarray(1), lead[0]]));
|
|
22
|
+
};
|
|
23
|
+
// Does this signature recover to that public key? Both byte orders are
|
|
24
|
+
// tried for the same reason lnurlcash-kit tries both: recovery-id-first is
|
|
25
|
+
// what noble emits, recovery-id-last is what the wire carries.
|
|
26
|
+
export const recoversToPubkey = (digest, signatureHex, pubkeyHex) => {
|
|
27
|
+
let wireSig;
|
|
28
|
+
try {
|
|
29
|
+
wireSig = hexToBytes(signatureHex);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (wireSig.length !== 65)
|
|
35
|
+
return false;
|
|
36
|
+
const target = pubkeyHex.trim().toLowerCase();
|
|
37
|
+
const recoveryIdFirst = new Uint8Array([wireSig[64], ...wireSig.subarray(0, 64)]);
|
|
38
|
+
for (const candidate of [recoveryIdFirst, wireSig]) {
|
|
39
|
+
try {
|
|
40
|
+
if (bytesToHex(secp256k1.recoverPublicKey(candidate, digest, { prehash: false })) === target)
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// wrong recovery id for this order - try the other
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
};
|
|
15
49
|
// Signs with the mint's own dedicated key, in process. The reference mint
|
|
16
50
|
// signs via its node's signmessage RPC instead; either is valid, since the
|
|
17
51
|
// only key a wallet ever checks against is the mintPubkey the SERVICE
|
package/dist/stats.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Liabilities } from './store.ts';
|
|
2
|
+
export declare const STATS_KIND = 30078;
|
|
3
|
+
export declare const STATS_D_TAG = "lnurlcash-liabilities";
|
|
4
|
+
export declare const STATS_MESSAGE_PREFIX = "LNURLcash-stats:";
|
|
5
|
+
export type MintStats = {
|
|
6
|
+
at: number;
|
|
7
|
+
outstandingMsat?: number;
|
|
8
|
+
outstandingNotes?: number;
|
|
9
|
+
pendingMsat?: number;
|
|
10
|
+
pendingMelts?: number;
|
|
11
|
+
oldestPendingMeltAgeSecs?: number;
|
|
12
|
+
localBalanceMsat?: number;
|
|
13
|
+
coverage?: number;
|
|
14
|
+
reconciledAt?: number;
|
|
15
|
+
};
|
|
16
|
+
export declare const buildStats: (args: {
|
|
17
|
+
liabilities: Liabilities;
|
|
18
|
+
localBalanceMsat?: number | undefined;
|
|
19
|
+
reconciledAt?: number | undefined;
|
|
20
|
+
at?: number;
|
|
21
|
+
ratioOnly?: boolean;
|
|
22
|
+
}) => MintStats;
|
|
23
|
+
export declare const canonicalJson: (value: Record<string, number | string>) => string;
|
|
24
|
+
export declare const statsDigest: (stats: MintStats) => Uint8Array;
|
|
25
|
+
export declare const signStats: (stats: MintStats, privateKeyHex: string) => string;
|
|
26
|
+
export declare const verifyStatsSignature: (stats: MintStats, signatureHex: string, mintPubkeyHex: string) => boolean;
|
|
27
|
+
export declare const statsSnapshotContent: (stats: MintStats, privateKeyHex: string) => string;
|
|
28
|
+
export declare const verifyStatsSnapshot: (content: string, mintPubkeyHex: string) => {
|
|
29
|
+
valid: boolean;
|
|
30
|
+
stats: MintStats | null;
|
|
31
|
+
};
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
2
|
+
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
3
|
+
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
4
|
+
// What the mint owes, what its node holds, and the ratio between the two.
|
|
5
|
+
//
|
|
6
|
+
// Because LNURLcash notes are not blinded, a mint can state its
|
|
7
|
+
// liabilities exactly: no epochs, no blinded sums, no proof ceremony. The
|
|
8
|
+
// numbers below are the whole of it, and none of them is per-note - a
|
|
9
|
+
// stats endpoint that leaked which notes exist would be worse than no
|
|
10
|
+
// stats endpoint at all.
|
|
11
|
+
export const STATS_KIND = 30078;
|
|
12
|
+
export const STATS_D_TAG = 'lnurlcash-liabilities';
|
|
13
|
+
export const STATS_MESSAGE_PREFIX = 'LNURLcash-stats:';
|
|
14
|
+
export const buildStats = (args) => {
|
|
15
|
+
const { liabilities, localBalanceMsat } = args;
|
|
16
|
+
const at = args.at ?? Date.now();
|
|
17
|
+
// Nothing outstanding means nothing to cover: any ratio would be
|
|
18
|
+
// infinite, and "infinitely covered" is not a claim worth making.
|
|
19
|
+
const coverage = localBalanceMsat !== undefined && liabilities.outstandingMsat > 0
|
|
20
|
+
? Math.round((localBalanceMsat / liabilities.outstandingMsat) * 10_000) / 10_000
|
|
21
|
+
: undefined;
|
|
22
|
+
if (args.ratioOnly === true) {
|
|
23
|
+
return { at, ...(coverage !== undefined ? { coverage } : {}) };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
at,
|
|
27
|
+
...liabilities,
|
|
28
|
+
...(localBalanceMsat !== undefined ? { localBalanceMsat } : {}),
|
|
29
|
+
...(coverage !== undefined ? { coverage } : {}),
|
|
30
|
+
...(args.reconciledAt !== undefined ? { reconciledAt: args.reconciledAt } : {})
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
// RFC 8785 canonical JSON, for the flat object of numbers this module
|
|
34
|
+
// signs: keys sorted by code unit, no whitespace, and ECMAScript's own
|
|
35
|
+
// number-to-string, which is what the RFC specifies. Anything nested or
|
|
36
|
+
// non-finite is refused rather than serialised a way a verifier might not
|
|
37
|
+
// reproduce.
|
|
38
|
+
export const canonicalJson = (value) => {
|
|
39
|
+
const parts = Object.keys(value)
|
|
40
|
+
.sort()
|
|
41
|
+
.map(key => {
|
|
42
|
+
const item = value[key];
|
|
43
|
+
if (typeof item === 'string')
|
|
44
|
+
return `${JSON.stringify(key)}:${JSON.stringify(item)}`;
|
|
45
|
+
if (typeof item === 'number' && Number.isFinite(item))
|
|
46
|
+
return `${JSON.stringify(key)}:${item}`;
|
|
47
|
+
throw new Error(`Cannot canonicalise ${key}: only finite numbers and strings are signed.`);
|
|
48
|
+
});
|
|
49
|
+
return `{${parts.join(',')}}`;
|
|
50
|
+
};
|
|
51
|
+
// The same "Lightning Signed Message" wrapping the notes themselves use,
|
|
52
|
+
// over a different message, so a stats signature can never be replayed as
|
|
53
|
+
// a note signature or the other way round.
|
|
54
|
+
export const statsDigest = (stats) => sha256(sha256(utf8ToBytes(`Lightning Signed Message:${STATS_MESSAGE_PREFIX}${canonicalJson(stats)}`)));
|
|
55
|
+
// Signed with the NOTE signing key, deliberately: a holder already checks
|
|
56
|
+
// their notes against that key, so the liabilities history checks against
|
|
57
|
+
// the same one with nothing new to trust.
|
|
58
|
+
export const signStats = (stats, privateKeyHex) => {
|
|
59
|
+
const priv = hexToBytes(privateKeyHex);
|
|
60
|
+
const lead = secp256k1.sign(statsDigest(stats), priv, { format: 'recovered', prehash: false });
|
|
61
|
+
// r || s || recovery_id, the LUD-25 wire layout.
|
|
62
|
+
return bytesToHex(new Uint8Array([...lead.subarray(1), lead[0]]));
|
|
63
|
+
};
|
|
64
|
+
export const verifyStatsSignature = (stats, signatureHex, mintPubkeyHex) => {
|
|
65
|
+
let wireSig;
|
|
66
|
+
try {
|
|
67
|
+
wireSig = hexToBytes(signatureHex);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (wireSig.length !== 65)
|
|
73
|
+
return false;
|
|
74
|
+
let digest;
|
|
75
|
+
try {
|
|
76
|
+
digest = statsDigest(stats);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const target = mintPubkeyHex.trim().toLowerCase();
|
|
82
|
+
// Both byte orders are tried for the same reason lnurlcash-kit tries
|
|
83
|
+
// both: recovery-id-first is what noble emits, recovery-id-last is what
|
|
84
|
+
// the wire carries.
|
|
85
|
+
const recoveryIdFirst = new Uint8Array([wireSig[64], ...wireSig.subarray(0, 64)]);
|
|
86
|
+
for (const candidate of [recoveryIdFirst, wireSig]) {
|
|
87
|
+
try {
|
|
88
|
+
if (bytesToHex(secp256k1.recoverPublicKey(candidate, digest, { prehash: false })) === target)
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// wrong recovery id for this order - try the other
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
};
|
|
97
|
+
// The published snapshot's content: the stats exactly as signed, plus the
|
|
98
|
+
// signature. A verifier strips `sig` and canonicalises what is left.
|
|
99
|
+
export const statsSnapshotContent = (stats, privateKeyHex) => JSON.stringify({ ...stats, sig: signStats(stats, privateKeyHex) });
|
|
100
|
+
// The reverse: parse a published snapshot and check it against the mint's
|
|
101
|
+
// advertised note-signing pubkey.
|
|
102
|
+
export const verifyStatsSnapshot = (content, mintPubkeyHex) => {
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(content);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { valid: false, stats: null };
|
|
109
|
+
}
|
|
110
|
+
const { sig, ...stats } = parsed;
|
|
111
|
+
if (typeof sig !== 'string')
|
|
112
|
+
return { valid: false, stats: null };
|
|
113
|
+
const asStats = stats;
|
|
114
|
+
return { valid: verifyStatsSignature(asStats, sig, mintPubkeyHex), stats: asStats };
|
|
115
|
+
};
|
package/dist/store.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type MintInvoiceRow = {
|
|
|
10
10
|
grossMsat: number;
|
|
11
11
|
netMsat: number;
|
|
12
12
|
settled: boolean;
|
|
13
|
+
outputId: string | null;
|
|
13
14
|
};
|
|
14
15
|
export type MeltRow = {
|
|
15
16
|
paymentHash: string;
|
|
@@ -18,6 +19,58 @@ export type MeltRow = {
|
|
|
18
19
|
amountMsat: number;
|
|
19
20
|
outcome: 'paid' | 'restored' | null;
|
|
20
21
|
};
|
|
22
|
+
export type ZapInvoiceRow = {
|
|
23
|
+
paymentHash: string;
|
|
24
|
+
name: string;
|
|
25
|
+
recipient: string;
|
|
26
|
+
pr: string;
|
|
27
|
+
grossMsat: number;
|
|
28
|
+
netMsat: number;
|
|
29
|
+
zapRequest: string | null;
|
|
30
|
+
settled: boolean;
|
|
31
|
+
noteId: string | null;
|
|
32
|
+
wrapJson: string | null;
|
|
33
|
+
receiptJson: string | null;
|
|
34
|
+
settledAt: number | null;
|
|
35
|
+
};
|
|
36
|
+
export declare const swapFingerprint: (args: {
|
|
37
|
+
inputIds: string[];
|
|
38
|
+
h: string;
|
|
39
|
+
h2?: string | undefined;
|
|
40
|
+
amountMsat?: number | undefined;
|
|
41
|
+
}) => string;
|
|
42
|
+
export type Liabilities = {
|
|
43
|
+
outstandingMsat: number;
|
|
44
|
+
outstandingNotes: number;
|
|
45
|
+
pendingMsat: number;
|
|
46
|
+
pendingMelts: number;
|
|
47
|
+
oldestPendingMeltAgeSecs: number;
|
|
48
|
+
};
|
|
49
|
+
export type ZapNameRow = {
|
|
50
|
+
name: string;
|
|
51
|
+
pubkey: string;
|
|
52
|
+
createdAt: number;
|
|
53
|
+
paidMsat: number;
|
|
54
|
+
source: 'env' | 'self';
|
|
55
|
+
};
|
|
56
|
+
export type NoteListRow = NoteRow & {
|
|
57
|
+
createdAt: number;
|
|
58
|
+
updatedAt: number;
|
|
59
|
+
};
|
|
60
|
+
export type MeltListRow = MeltRow & {
|
|
61
|
+
createdAt: number;
|
|
62
|
+
resolvedAt: number | null;
|
|
63
|
+
};
|
|
64
|
+
export type StoreTotals = {
|
|
65
|
+
mints: number;
|
|
66
|
+
unsettledMintInvoices: number;
|
|
67
|
+
zaps: number;
|
|
68
|
+
melts: {
|
|
69
|
+
paid: number;
|
|
70
|
+
restored: number;
|
|
71
|
+
pending: number;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
21
74
|
export declare class NotePendingError extends Error {
|
|
22
75
|
}
|
|
23
76
|
export declare class NoteUnavailableError extends Error {
|
|
@@ -26,28 +79,77 @@ export declare class OutputCollisionError extends Error {
|
|
|
26
79
|
}
|
|
27
80
|
export declare class NoteStore {
|
|
28
81
|
private db;
|
|
29
|
-
|
|
82
|
+
readonly readOnly: boolean;
|
|
83
|
+
constructor(path: string, options?: {
|
|
84
|
+
readOnly?: boolean;
|
|
85
|
+
});
|
|
30
86
|
private tx;
|
|
31
87
|
noteById(id: string): NoteRow | null;
|
|
32
88
|
private insertNote;
|
|
33
89
|
private setNoteState;
|
|
90
|
+
outputIdInUse(id: string): boolean;
|
|
34
91
|
private assertOutputIdFree;
|
|
35
92
|
private assertOutstanding;
|
|
36
93
|
swap(inputIds: string[], outputs: Array<{
|
|
37
94
|
id: string;
|
|
38
95
|
amountMsat: number;
|
|
39
|
-
}
|
|
96
|
+
}>, fingerprint?: string): void;
|
|
97
|
+
swapByFingerprint(fingerprint: string): Array<{
|
|
98
|
+
id: string;
|
|
99
|
+
amountMsat: number;
|
|
100
|
+
}> | null;
|
|
40
101
|
markPending(noteId: string, paymentHash: string, pr: string, amountMsat: number): void;
|
|
41
102
|
finalizeMelt(paymentHash: string): void;
|
|
42
103
|
restoreMelt(paymentHash: string): void;
|
|
43
104
|
meltByHash(paymentHash: string): MeltRow | null;
|
|
44
105
|
pendingMelts(): MeltRow[];
|
|
45
|
-
recordMintInvoice(paymentHash: string, pr: string, grossMsat: number, netMsat: number): void;
|
|
106
|
+
recordMintInvoice(paymentHash: string, pr: string, grossMsat: number, netMsat: number, outputId?: string | null): void;
|
|
46
107
|
mintInvoiceByHash(paymentHash: string): MintInvoiceRow | null;
|
|
108
|
+
mintInvoiceByOutputId(outputId: string): MintInvoiceRow | null;
|
|
47
109
|
unsettledMintInvoices(): MintInvoiceRow[];
|
|
48
110
|
deleteUnsettledMintInvoice(paymentHash: string): void;
|
|
49
111
|
settleMintInvoice(paymentHash: string): void;
|
|
112
|
+
recordZapInvoice(row: {
|
|
113
|
+
paymentHash: string;
|
|
114
|
+
name: string;
|
|
115
|
+
recipient: string;
|
|
116
|
+
pr: string;
|
|
117
|
+
grossMsat: number;
|
|
118
|
+
netMsat: number;
|
|
119
|
+
zapRequest: string | null;
|
|
120
|
+
}): void;
|
|
121
|
+
private zapRow;
|
|
122
|
+
private static readonly ZAP_COLUMNS;
|
|
123
|
+
zapInvoiceByHash(paymentHash: string): ZapInvoiceRow | null;
|
|
124
|
+
unsettledZapInvoices(): ZapInvoiceRow[];
|
|
125
|
+
deleteUnsettledZapInvoice(paymentHash: string): void;
|
|
126
|
+
settleZapInvoice(paymentHash: string, noteId: string, wrapJson: string, receiptJson: string | null): boolean;
|
|
127
|
+
unpublishedZaps(): ZapInvoiceRow[];
|
|
128
|
+
markZapPublished(paymentHash: string): void;
|
|
129
|
+
zapName(name: string): ZapNameRow | null;
|
|
130
|
+
zapNames(): ZapNameRow[];
|
|
131
|
+
putOperatorZapName(name: string, pubkey: string): void;
|
|
132
|
+
buyZapName(args: {
|
|
133
|
+
name: string;
|
|
134
|
+
pubkey: string;
|
|
135
|
+
noteId?: string;
|
|
136
|
+
paidMsat: number;
|
|
137
|
+
}): void;
|
|
138
|
+
removeZapName(name: string): boolean;
|
|
139
|
+
zapNameCountFor(pubkey: string): number;
|
|
50
140
|
creditNote(id: string, amountMsat: number): void;
|
|
141
|
+
liabilities(nowMs?: number): Liabilities;
|
|
142
|
+
notes(filter?: {
|
|
143
|
+
state?: NoteState;
|
|
144
|
+
limit?: number;
|
|
145
|
+
}): NoteListRow[];
|
|
146
|
+
melts(filter?: {
|
|
147
|
+
pendingOnly?: boolean;
|
|
148
|
+
limit?: number;
|
|
149
|
+
}): MeltListRow[];
|
|
150
|
+
totals(): StoreTotals;
|
|
151
|
+
snapshot(path: string): void;
|
|
51
152
|
outstandingLiabilityMsat(): number;
|
|
153
|
+
busyTimeoutMs(): number;
|
|
52
154
|
close(): void;
|
|
53
155
|
}
|