@forgesworn/moneyer 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
@@ -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
- constructor(path: string);
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
- }>): void;
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
  }