routstrd 0.4.0 → 0.4.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/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
- package/README.md +5 -0
- package/dist/daemon/index.js +1096 -143
- package/dist/index.js +572 -124
- package/package.json +1 -1
- package/src/cli.ts +160 -26
- package/src/daemon/fatal-error.test.ts +104 -0
- package/src/daemon/fatal-error.ts +48 -0
- package/src/daemon/wallet/auto-refill.ts +4 -6
- package/src/daemon/wallet/cleanup.test.ts +168 -0
- package/src/daemon/wallet/cleanup.ts +104 -0
- package/src/daemon/wallet/coco-client.test.ts +191 -30
- package/src/daemon/wallet/coco-client.ts +867 -74
- package/src/daemon/wallet/cocod-client.ts +49 -1
- package/src/daemon/wallet/diagnostics.test.ts +378 -0
- package/src/daemon/wallet/diagnostics.ts +471 -0
- package/src/daemon/wallet/migration.test.ts +30 -6
- package/src/daemon/wallet/migration.ts +30 -85
- package/src/daemon/wallet/receive-dedup.test.ts +238 -0
- package/src/daemon/wallet/receive-dedup.ts +384 -0
- package/src/daemon/wallet/wallet-state.ts +75 -0
- package/src/start-daemon.ts +21 -1
- package/tests/integrations/hermes.test.ts +42 -5
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { ReceiveOperation } from "@cashu/coco-core";
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
import {
|
|
5
|
+
clearInterruptedReceiveReservations,
|
|
6
|
+
deleteReceiveTokenReservation,
|
|
7
|
+
groupExecutingReceives,
|
|
8
|
+
hashReceiveToken,
|
|
9
|
+
initReceiveDedupSchema,
|
|
10
|
+
listProcessingReceiveTokens,
|
|
11
|
+
reconcileExecutingReceives,
|
|
12
|
+
reserveReceiveToken,
|
|
13
|
+
updateReceiveToken,
|
|
14
|
+
type ReceiveReconcileSource,
|
|
15
|
+
} from "./receive-dedup";
|
|
16
|
+
|
|
17
|
+
function operation(
|
|
18
|
+
id: string,
|
|
19
|
+
secret: string,
|
|
20
|
+
outputB: string,
|
|
21
|
+
createdAt = 1,
|
|
22
|
+
): Extract<ReceiveOperation, { state: "executing" }> {
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
mintUrl: "https://mint.example",
|
|
26
|
+
unit: "sat",
|
|
27
|
+
amount: 1,
|
|
28
|
+
state: "executing",
|
|
29
|
+
createdAt,
|
|
30
|
+
updatedAt: createdAt,
|
|
31
|
+
fee: 0,
|
|
32
|
+
inputProofs: [{ amount: 1, id: "keyset", secret, C: "02" }],
|
|
33
|
+
outputData: {
|
|
34
|
+
keep: [
|
|
35
|
+
{
|
|
36
|
+
blindedMessage: { amount: 1, id: "keyset", B_: outputB },
|
|
37
|
+
blindingFactor: "1",
|
|
38
|
+
secret: "00",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
send: [],
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function source(options: {
|
|
47
|
+
operations: ReceiveOperation[];
|
|
48
|
+
states?: string[];
|
|
49
|
+
restored?: string[];
|
|
50
|
+
saved?: string[];
|
|
51
|
+
}) {
|
|
52
|
+
const rolledBack: Array<{ id: string; reason: string }> = [];
|
|
53
|
+
let stateChecks = 0;
|
|
54
|
+
let restoreChecks = 0;
|
|
55
|
+
const reconcileSource: ReceiveReconcileSource = {
|
|
56
|
+
listExecuting: async () => options.operations,
|
|
57
|
+
checkProofStates: async (op) => {
|
|
58
|
+
stateChecks++;
|
|
59
|
+
return (options.states ?? op.inputProofs.map(() => "SPENT")).map((state) => ({ state }));
|
|
60
|
+
},
|
|
61
|
+
restoreOutputs: async (_mintUrl, outputs) => {
|
|
62
|
+
restoreChecks++;
|
|
63
|
+
const restored = new Set(options.restored ?? []);
|
|
64
|
+
return outputs.filter((output) => restored.has(output.B_));
|
|
65
|
+
},
|
|
66
|
+
hasSavedOutputs: async (op) => (options.saved ?? []).includes(op.id),
|
|
67
|
+
rollBack: async (op, reason) => {
|
|
68
|
+
rolledBack.push({ id: op.id, reason });
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
source: reconcileSource,
|
|
73
|
+
rolledBack,
|
|
74
|
+
stateChecks: () => stateChecks,
|
|
75
|
+
restoreChecks: () => restoreChecks,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("receive token hashing and reservation", () => {
|
|
80
|
+
it("hashes the exact encoded token deterministically", () => {
|
|
81
|
+
expect(hashReceiveToken("cashuA")).toBe(hashReceiveToken("cashuA"));
|
|
82
|
+
expect(hashReceiveToken("cashuA")).not.toBe(hashReceiveToken("cashuB"));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("atomically reserves an exact token once", () => {
|
|
86
|
+
const database = new Database(":memory:");
|
|
87
|
+
initReceiveDedupSchema(database);
|
|
88
|
+
const first = reserveReceiveToken(database, "cashuA");
|
|
89
|
+
const second = reserveReceiveToken(database, "cashuA");
|
|
90
|
+
expect(first.acquired).toBe(true);
|
|
91
|
+
expect(second).toMatchObject({
|
|
92
|
+
acquired: false,
|
|
93
|
+
existing: { state: "processing", operationId: null },
|
|
94
|
+
});
|
|
95
|
+
database.close();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("persists successful status and releases retryable reservations", () => {
|
|
99
|
+
const database = new Database(":memory:");
|
|
100
|
+
initReceiveDedupSchema(database);
|
|
101
|
+
const reservation = reserveReceiveToken(database, "cashuA");
|
|
102
|
+
updateReceiveToken(database, reservation.tokenHash, {
|
|
103
|
+
state: "succeeded",
|
|
104
|
+
operationId: "receive-1",
|
|
105
|
+
});
|
|
106
|
+
expect(reserveReceiveToken(database, "cashuA").existing).toMatchObject({
|
|
107
|
+
state: "succeeded",
|
|
108
|
+
operationId: "receive-1",
|
|
109
|
+
});
|
|
110
|
+
deleteReceiveTokenReservation(database, reservation.tokenHash);
|
|
111
|
+
expect(reserveReceiveToken(database, "cashuA").acquired).toBe(true);
|
|
112
|
+
database.close();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("clears only interrupted reservations without Coco operation ids", () => {
|
|
116
|
+
const database = new Database(":memory:");
|
|
117
|
+
initReceiveDedupSchema(database);
|
|
118
|
+
const orphan = reserveReceiveToken(database, "orphan");
|
|
119
|
+
const linked = reserveReceiveToken(database, "linked");
|
|
120
|
+
updateReceiveToken(database, linked.tokenHash, {
|
|
121
|
+
state: "processing",
|
|
122
|
+
operationId: "receive-1",
|
|
123
|
+
});
|
|
124
|
+
expect(clearInterruptedReceiveReservations(database)).toBe(1);
|
|
125
|
+
expect(listProcessingReceiveTokens(database)).toEqual([
|
|
126
|
+
expect.objectContaining({ tokenHash: linked.tokenHash, operationId: "receive-1" }),
|
|
127
|
+
]);
|
|
128
|
+
expect(reserveReceiveToken(database, "orphan").tokenHash).toBe(orphan.tokenHash);
|
|
129
|
+
database.close();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("legacy receive grouping", () => {
|
|
134
|
+
it("groups identical input proofs independent of operation id", () => {
|
|
135
|
+
const groups = groupExecutingReceives([
|
|
136
|
+
operation("one", "same", "B1"),
|
|
137
|
+
operation("two", "same", "B2"),
|
|
138
|
+
operation("three", "different", "B3"),
|
|
139
|
+
]);
|
|
140
|
+
expect(groups.map((group) => group.map((op) => op.id))).toEqual([
|
|
141
|
+
["one", "two"],
|
|
142
|
+
["three"],
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("checks an unspent duplicate group once and retains one operation", async () => {
|
|
147
|
+
const mock = source({
|
|
148
|
+
operations: [operation("one", "same", "B1", 1), operation("two", "same", "B2", 2)],
|
|
149
|
+
states: ["UNSPENT"],
|
|
150
|
+
});
|
|
151
|
+
const result = await reconcileExecutingReceives(mock.source);
|
|
152
|
+
expect(mock.stateChecks()).toBe(1);
|
|
153
|
+
expect(mock.restoreChecks()).toBe(0);
|
|
154
|
+
expect(mock.rolledBack.map((entry) => entry.id)).toEqual(["two"]);
|
|
155
|
+
expect(result).toMatchObject({ rolledBack: 1, retained: 1, unresolved: 0 });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("rolls back all spent duplicates when restore finds no outputs", async () => {
|
|
159
|
+
const mock = source({
|
|
160
|
+
operations: [operation("one", "same", "B1"), operation("two", "same", "B2")],
|
|
161
|
+
states: ["SPENT"],
|
|
162
|
+
restored: [],
|
|
163
|
+
});
|
|
164
|
+
const result = await reconcileExecutingReceives(mock.source);
|
|
165
|
+
expect(mock.stateChecks()).toBe(1);
|
|
166
|
+
expect(mock.restoreChecks()).toBe(1);
|
|
167
|
+
expect(mock.rolledBack.map((entry) => entry.id)).toEqual(["one", "two"]);
|
|
168
|
+
expect(result.rolledBack).toBe(2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("retains the operation whose output the mint can restore", async () => {
|
|
172
|
+
const mock = source({
|
|
173
|
+
operations: [operation("one", "same", "B1"), operation("two", "same", "B2")],
|
|
174
|
+
states: ["SPENT"],
|
|
175
|
+
restored: ["B2"],
|
|
176
|
+
});
|
|
177
|
+
const result = await reconcileExecutingReceives(mock.source);
|
|
178
|
+
expect(mock.rolledBack.map((entry) => entry.id)).toEqual(["one"]);
|
|
179
|
+
expect(result).toMatchObject({ rolledBack: 1, retained: 1 });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("leaves a group untouched when the mint returns incomplete states", async () => {
|
|
183
|
+
const first = operation("one", "same", "B1");
|
|
184
|
+
first.inputProofs.push({ amount: 1, id: "keyset", secret: "other", C: "03" });
|
|
185
|
+
const second = operation("two", "same", "B2");
|
|
186
|
+
second.inputProofs.push({ amount: 1, id: "keyset", secret: "other", C: "03" });
|
|
187
|
+
const mock = source({
|
|
188
|
+
operations: [first, second],
|
|
189
|
+
states: ["SPENT"],
|
|
190
|
+
});
|
|
191
|
+
const result = await reconcileExecutingReceives(mock.source);
|
|
192
|
+
expect(mock.rolledBack).toEqual([]);
|
|
193
|
+
expect(mock.restoreChecks()).toBe(0);
|
|
194
|
+
expect(result.unresolved).toBe(2);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("leaves a group untouched when proof states are mixed", async () => {
|
|
198
|
+
const first = operation("one", "same", "B1");
|
|
199
|
+
first.inputProofs.push({ amount: 1, id: "keyset", secret: "other", C: "03" });
|
|
200
|
+
const second = operation("two", "same", "B2");
|
|
201
|
+
second.inputProofs.push({ amount: 1, id: "keyset", secret: "other", C: "03" });
|
|
202
|
+
const mock = source({
|
|
203
|
+
operations: [first, second],
|
|
204
|
+
states: ["SPENT", "UNSPENT"],
|
|
205
|
+
});
|
|
206
|
+
const result = await reconcileExecutingReceives(mock.source);
|
|
207
|
+
expect(mock.rolledBack).toEqual([]);
|
|
208
|
+
expect(result.unresolved).toBe(2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("scales mint checks with unique input groups rather than row count", async () => {
|
|
212
|
+
const operations: ReceiveOperation[] = [];
|
|
213
|
+
for (let group = 0; group < 47; group++) {
|
|
214
|
+
const repeats = group < 44 ? 41 : 40; // 1,924 rows total
|
|
215
|
+
for (let index = 0; index < repeats; index++) {
|
|
216
|
+
operations.push(
|
|
217
|
+
operation(
|
|
218
|
+
`operation-${group}-${index}`,
|
|
219
|
+
`secret-${group}`,
|
|
220
|
+
`B-${group}-${index}`,
|
|
221
|
+
index,
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
expect(operations).toHaveLength(1924);
|
|
227
|
+
const mock = source({ operations, states: ["SPENT"], restored: [] });
|
|
228
|
+
const result = await reconcileExecutingReceives(mock.source);
|
|
229
|
+
expect(mock.stateChecks()).toBe(47);
|
|
230
|
+
expect(mock.restoreChecks()).toBe(47);
|
|
231
|
+
expect(result).toMatchObject({
|
|
232
|
+
executing: 1924,
|
|
233
|
+
uniqueGroups: 47,
|
|
234
|
+
rolledBack: 1924,
|
|
235
|
+
unresolved: 0,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import type { ReceiveOperation } from "@cashu/coco-core";
|
|
3
|
+
import type { Database } from "bun:sqlite";
|
|
4
|
+
|
|
5
|
+
const TOKEN_TABLE = "routstrd_receive_tokens";
|
|
6
|
+
const MAINTENANCE_TABLE = "routstrd_wallet_maintenance";
|
|
7
|
+
const RECONCILE_BACKUP_KEY = "receive_reconcile_v1_backup";
|
|
8
|
+
|
|
9
|
+
export type StoredReceiveTokenState = "processing" | "succeeded" | "failed";
|
|
10
|
+
|
|
11
|
+
export interface StoredReceiveTokenRow {
|
|
12
|
+
tokenHash: string;
|
|
13
|
+
state: StoredReceiveTokenState;
|
|
14
|
+
operationId: string | null;
|
|
15
|
+
error: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ReceiveTokenReservation {
|
|
19
|
+
acquired: boolean;
|
|
20
|
+
tokenHash: string;
|
|
21
|
+
existing?: StoredReceiveTokenRow;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReceiveReconcileResult {
|
|
25
|
+
executing: number;
|
|
26
|
+
uniqueGroups: number;
|
|
27
|
+
rolledBack: number;
|
|
28
|
+
retained: number;
|
|
29
|
+
unresolved: number;
|
|
30
|
+
recoveryOperationIds: string[];
|
|
31
|
+
backupPath?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SerializedBlindedMessageLike {
|
|
35
|
+
amount: number;
|
|
36
|
+
id: string;
|
|
37
|
+
B_: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SerializedOutput {
|
|
41
|
+
blindedMessage: SerializedBlindedMessageLike;
|
|
42
|
+
secret: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface SerializedOutputDataLike {
|
|
46
|
+
keep: SerializedOutput[];
|
|
47
|
+
send: SerializedOutput[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ExecutingReceive = Extract<ReceiveOperation, { state: "executing" }>;
|
|
51
|
+
|
|
52
|
+
export interface ReceiveReconcileSource {
|
|
53
|
+
listExecuting(): Promise<ReceiveOperation[]>;
|
|
54
|
+
checkProofStates(operation: ExecutingReceive): Promise<Array<{ state: string }>>;
|
|
55
|
+
restoreOutputs(
|
|
56
|
+
mintUrl: string,
|
|
57
|
+
outputs: SerializedBlindedMessageLike[],
|
|
58
|
+
): Promise<Array<{ B_: string }>>;
|
|
59
|
+
hasSavedOutputs(operation: ExecutingReceive): Promise<boolean>;
|
|
60
|
+
rollBack(operation: ExecutingReceive, reason: string): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function hashReceiveToken(token: string): string {
|
|
64
|
+
return createHash("sha256").update(token).digest("hex");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function initReceiveDedupSchema(database: Database): void {
|
|
68
|
+
database.exec(`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS ${TOKEN_TABLE} (
|
|
70
|
+
tokenHash TEXT PRIMARY KEY,
|
|
71
|
+
state TEXT NOT NULL CHECK (state IN ('processing', 'succeeded', 'failed')),
|
|
72
|
+
operationId TEXT,
|
|
73
|
+
error TEXT,
|
|
74
|
+
createdAt INTEGER NOT NULL,
|
|
75
|
+
updatedAt INTEGER NOT NULL
|
|
76
|
+
);
|
|
77
|
+
CREATE TABLE IF NOT EXISTS ${MAINTENANCE_TABLE} (
|
|
78
|
+
key TEXT PRIMARY KEY,
|
|
79
|
+
value TEXT NOT NULL,
|
|
80
|
+
updatedAt INTEGER NOT NULL
|
|
81
|
+
);
|
|
82
|
+
`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function listProcessingReceiveTokens(database: Database): StoredReceiveTokenRow[] {
|
|
86
|
+
return database
|
|
87
|
+
.query(
|
|
88
|
+
`SELECT tokenHash, state, operationId, error
|
|
89
|
+
FROM ${TOKEN_TABLE} WHERE state = 'processing'`,
|
|
90
|
+
)
|
|
91
|
+
.all() as StoredReceiveTokenRow[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function deleteReceiveTokenReservation(database: Database, tokenHash: string): void {
|
|
95
|
+
database.query(`DELETE FROM ${TOKEN_TABLE} WHERE tokenHash = ?`).run(tokenHash);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function clearInterruptedReceiveReservations(database: Database): number {
|
|
99
|
+
const result = database
|
|
100
|
+
.query(
|
|
101
|
+
`DELETE FROM ${TOKEN_TABLE}
|
|
102
|
+
WHERE state = 'processing' AND operationId IS NULL`,
|
|
103
|
+
)
|
|
104
|
+
.run();
|
|
105
|
+
return result.changes;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function reserveReceiveToken(database: Database, token: string): ReceiveTokenReservation {
|
|
109
|
+
const tokenHash = hashReceiveToken(token);
|
|
110
|
+
const now = Math.floor(Date.now() / 1000);
|
|
111
|
+
const result = database
|
|
112
|
+
.query(
|
|
113
|
+
`INSERT OR IGNORE INTO ${TOKEN_TABLE}
|
|
114
|
+
(tokenHash, state, operationId, error, createdAt, updatedAt)
|
|
115
|
+
VALUES (?, 'processing', NULL, NULL, ?, ?)`,
|
|
116
|
+
)
|
|
117
|
+
.run(tokenHash, now, now);
|
|
118
|
+
|
|
119
|
+
if (result.changes > 0) return { acquired: true, tokenHash };
|
|
120
|
+
|
|
121
|
+
const existing = database
|
|
122
|
+
.query(
|
|
123
|
+
`SELECT tokenHash, state, operationId, error
|
|
124
|
+
FROM ${TOKEN_TABLE} WHERE tokenHash = ?`,
|
|
125
|
+
)
|
|
126
|
+
.get(tokenHash) as StoredReceiveTokenRow | null;
|
|
127
|
+
return { acquired: false, tokenHash, ...(existing ? { existing } : {}) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function releaseReceiveToken(database: Database, tokenHash: string): void {
|
|
131
|
+
database
|
|
132
|
+
.query(`DELETE FROM ${TOKEN_TABLE} WHERE tokenHash = ? AND operationId IS NULL`)
|
|
133
|
+
.run(tokenHash);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function updateReceiveToken(
|
|
137
|
+
database: Database,
|
|
138
|
+
tokenHash: string,
|
|
139
|
+
update: { state: StoredReceiveTokenState; operationId?: string; error?: string },
|
|
140
|
+
): void {
|
|
141
|
+
database
|
|
142
|
+
.query(
|
|
143
|
+
`UPDATE ${TOKEN_TABLE}
|
|
144
|
+
SET state = ?, operationId = COALESCE(?, operationId), error = ?, updatedAt = ?
|
|
145
|
+
WHERE tokenHash = ?`,
|
|
146
|
+
)
|
|
147
|
+
.run(
|
|
148
|
+
update.state,
|
|
149
|
+
update.operationId ?? null,
|
|
150
|
+
update.error ?? null,
|
|
151
|
+
Math.floor(Date.now() / 1000),
|
|
152
|
+
tokenHash,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function receiveInputFingerprint(operation: ReceiveOperation): string {
|
|
157
|
+
// Legacy cleanup is intentionally stricter than new-request token hashing:
|
|
158
|
+
// only operations with the exact same proof material are grouped. A shared
|
|
159
|
+
// secret alone is not enough evidence to discard an operation.
|
|
160
|
+
const proofs = operation.inputProofs
|
|
161
|
+
.map((proof) => ({
|
|
162
|
+
amount: proof.amount,
|
|
163
|
+
id: proof.id,
|
|
164
|
+
secret: proof.secret,
|
|
165
|
+
C: proof.C,
|
|
166
|
+
witness: proof.witness ?? null,
|
|
167
|
+
}))
|
|
168
|
+
.sort((left, right) =>
|
|
169
|
+
JSON.stringify(left).localeCompare(JSON.stringify(right)),
|
|
170
|
+
);
|
|
171
|
+
return createHash("sha256")
|
|
172
|
+
.update(JSON.stringify([operation.mintUrl, operation.unit, proofs]))
|
|
173
|
+
.digest("hex");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function groupExecutingReceives(
|
|
177
|
+
operations: ReceiveOperation[],
|
|
178
|
+
): ExecutingReceive[][] {
|
|
179
|
+
const groups = new Map<string, ExecutingReceive[]>();
|
|
180
|
+
for (const operation of operations) {
|
|
181
|
+
if (operation.state !== "executing") continue;
|
|
182
|
+
const key = receiveInputFingerprint(operation);
|
|
183
|
+
const group = groups.get(key) ?? [];
|
|
184
|
+
group.push(operation);
|
|
185
|
+
groups.set(key, group);
|
|
186
|
+
}
|
|
187
|
+
return [...groups.values()].map((group) =>
|
|
188
|
+
group.sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function outputData(operation: ExecutingReceive): SerializedOutputDataLike | null {
|
|
193
|
+
const value = operation.outputData as unknown;
|
|
194
|
+
if (!value || typeof value !== "object") return null;
|
|
195
|
+
const candidate = value as Partial<SerializedOutputDataLike>;
|
|
196
|
+
if (!Array.isArray(candidate.keep) || !Array.isArray(candidate.send)) return null;
|
|
197
|
+
return { keep: candidate.keep, send: candidate.send };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function outputBlindedMessages(operation: ExecutingReceive): SerializedBlindedMessageLike[] {
|
|
201
|
+
const data = outputData(operation);
|
|
202
|
+
if (!data) return [];
|
|
203
|
+
return [...data.keep, ...data.send]
|
|
204
|
+
.map((entry) => entry?.blindedMessage)
|
|
205
|
+
.filter((message): message is SerializedBlindedMessageLike =>
|
|
206
|
+
Boolean(
|
|
207
|
+
message &&
|
|
208
|
+
typeof message.B_ === "string" &&
|
|
209
|
+
message.B_ &&
|
|
210
|
+
typeof message.id === "string" &&
|
|
211
|
+
Number.isFinite(message.amount),
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function rollBackOthers(
|
|
217
|
+
source: ReceiveReconcileSource,
|
|
218
|
+
group: ExecutingReceive[],
|
|
219
|
+
retainedIds: Set<string>,
|
|
220
|
+
reason: string,
|
|
221
|
+
): Promise<number> {
|
|
222
|
+
let count = 0;
|
|
223
|
+
for (const operation of group) {
|
|
224
|
+
if (retainedIds.has(operation.id)) continue;
|
|
225
|
+
await source.rollBack(operation, reason);
|
|
226
|
+
count++;
|
|
227
|
+
}
|
|
228
|
+
return count;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Reduce legacy executing receives before coco-core's per-operation recovery.
|
|
233
|
+
*
|
|
234
|
+
* No state is changed unless the evidence is conclusive:
|
|
235
|
+
* - saved local outputs identify the successful operation;
|
|
236
|
+
* - all inputs are unspent, so one deterministic retry is sufficient; or
|
|
237
|
+
* - all inputs are spent and restore identifies an output owner (or confirms
|
|
238
|
+
* that none of the stored deterministic outputs exist).
|
|
239
|
+
*
|
|
240
|
+
* Unreachable mints, malformed output data, partial restore ownership, and
|
|
241
|
+
* mixed proof states are left untouched for normal recovery/manual review.
|
|
242
|
+
*/
|
|
243
|
+
export async function reconcileExecutingReceives(
|
|
244
|
+
source: ReceiveReconcileSource,
|
|
245
|
+
): Promise<ReceiveReconcileResult> {
|
|
246
|
+
const executing = (await source.listExecuting()).filter(
|
|
247
|
+
(operation): operation is ExecutingReceive => operation.state === "executing",
|
|
248
|
+
);
|
|
249
|
+
const groups = groupExecutingReceives(executing);
|
|
250
|
+
const result: ReceiveReconcileResult = {
|
|
251
|
+
executing: executing.length,
|
|
252
|
+
uniqueGroups: groups.length,
|
|
253
|
+
rolledBack: 0,
|
|
254
|
+
retained: 0,
|
|
255
|
+
unresolved: 0,
|
|
256
|
+
recoveryOperationIds: [],
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
for (const group of groups) {
|
|
260
|
+
try {
|
|
261
|
+
const savedOwners = new Set<string>();
|
|
262
|
+
for (const operation of group) {
|
|
263
|
+
if (await source.hasSavedOutputs(operation)) savedOwners.add(operation.id);
|
|
264
|
+
}
|
|
265
|
+
if (savedOwners.size > 0) {
|
|
266
|
+
result.rolledBack += await rollBackOthers(
|
|
267
|
+
source,
|
|
268
|
+
group,
|
|
269
|
+
savedOwners,
|
|
270
|
+
"Duplicate receive: outputs already persisted by another operation",
|
|
271
|
+
);
|
|
272
|
+
result.retained += savedOwners.size;
|
|
273
|
+
result.recoveryOperationIds.push(...savedOwners);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const states = await source.checkProofStates(group[0]!);
|
|
278
|
+
const completeStates = states.length === group[0]!.inputProofs.length;
|
|
279
|
+
const allUnspent = completeStates && states.every((state) => state.state === "UNSPENT");
|
|
280
|
+
const allSpent = completeStates && states.every((state) => state.state === "SPENT");
|
|
281
|
+
|
|
282
|
+
if (allUnspent) {
|
|
283
|
+
const canonical = group.find((operation) => outputBlindedMessages(operation).length > 0);
|
|
284
|
+
if (!canonical) {
|
|
285
|
+
result.unresolved += group.length;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
result.rolledBack += await rollBackOthers(
|
|
289
|
+
source,
|
|
290
|
+
group,
|
|
291
|
+
new Set([canonical.id]),
|
|
292
|
+
"Duplicate receive: identical inputs retained by canonical operation",
|
|
293
|
+
);
|
|
294
|
+
result.retained++;
|
|
295
|
+
result.recoveryOperationIds.push(canonical.id);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!allSpent) {
|
|
300
|
+
result.unresolved += group.length;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const outputOwners = new Map<
|
|
305
|
+
string,
|
|
306
|
+
{ message: SerializedBlindedMessageLike; owners: Set<string> }
|
|
307
|
+
>();
|
|
308
|
+
let malformed = false;
|
|
309
|
+
for (const operation of group) {
|
|
310
|
+
const outputs = outputBlindedMessages(operation);
|
|
311
|
+
if (outputs.length === 0) {
|
|
312
|
+
malformed = true;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
for (const output of outputs) {
|
|
316
|
+
const entry = outputOwners.get(output.B_) ?? {
|
|
317
|
+
message: output,
|
|
318
|
+
owners: new Set<string>(),
|
|
319
|
+
};
|
|
320
|
+
entry.owners.add(operation.id);
|
|
321
|
+
outputOwners.set(output.B_, entry);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (malformed || outputOwners.size === 0) {
|
|
325
|
+
result.unresolved += group.length;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const restored = await source.restoreOutputs(
|
|
330
|
+
group[0]!.mintUrl,
|
|
331
|
+
[...outputOwners.values()].map((entry) => entry.message),
|
|
332
|
+
);
|
|
333
|
+
const restoredOwners = new Set<string>();
|
|
334
|
+
for (const output of restored) {
|
|
335
|
+
for (const owner of outputOwners.get(output.B_)?.owners ?? []) {
|
|
336
|
+
restoredOwners.add(owner);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (restoredOwners.size === 0) {
|
|
341
|
+
for (const operation of group) {
|
|
342
|
+
await source.rollBack(
|
|
343
|
+
operation,
|
|
344
|
+
"Recovered: input proofs spent without recoverable outputs",
|
|
345
|
+
);
|
|
346
|
+
result.rolledBack++;
|
|
347
|
+
}
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
result.rolledBack += await rollBackOthers(
|
|
352
|
+
source,
|
|
353
|
+
group,
|
|
354
|
+
restoredOwners,
|
|
355
|
+
"Duplicate receive: recoverable outputs belong to another operation",
|
|
356
|
+
);
|
|
357
|
+
result.retained += restoredOwners.size;
|
|
358
|
+
result.recoveryOperationIds.push(...restoredOwners);
|
|
359
|
+
} catch {
|
|
360
|
+
// A network, parsing, or persistence failure is not evidence that an
|
|
361
|
+
// operation is safe to abandon. Leave the complete group untouched.
|
|
362
|
+
result.unresolved += group.length;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function getReceiveReconcileBackup(database: Database): string | null {
|
|
370
|
+
const row = database
|
|
371
|
+
.query(`SELECT value FROM ${MAINTENANCE_TABLE} WHERE key = ?`)
|
|
372
|
+
.get(RECONCILE_BACKUP_KEY) as { value: string } | null;
|
|
373
|
+
return row?.value ?? null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function setReceiveReconcileBackup(database: Database, path: string): void {
|
|
377
|
+
database
|
|
378
|
+
.query(
|
|
379
|
+
`INSERT INTO ${MAINTENANCE_TABLE} (key, value, updatedAt)
|
|
380
|
+
VALUES (?, ?, ?)
|
|
381
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updatedAt = excluded.updatedAt`,
|
|
382
|
+
)
|
|
383
|
+
.run(RECONCILE_BACKUP_KEY, path, Math.floor(Date.now() / 1000));
|
|
384
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure wallet-migration state detection, shared by the actual migration and by
|
|
6
|
+
* the doctor diagnostics so the two cannot disagree about startup behavior.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type WalletState = "absent" | "database-only" | "initialized";
|
|
10
|
+
|
|
11
|
+
export function walletState(configPath: string, dbPath: string): WalletState {
|
|
12
|
+
const hasConfig = existsSync(configPath);
|
|
13
|
+
const hasDb = existsSync(dbPath);
|
|
14
|
+
if (hasConfig) return "initialized";
|
|
15
|
+
if (hasDb) return "database-only";
|
|
16
|
+
return "absent";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function filesEqual(left: string, right: string): boolean {
|
|
20
|
+
if (!existsSync(left) || !existsSync(right)) return false;
|
|
21
|
+
if (statSync(left).size !== statSync(right).size) return false;
|
|
22
|
+
return readFileSync(left).equals(readFileSync(right));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type WalletMigrationClass =
|
|
26
|
+
| { kind: "fresh" }
|
|
27
|
+
| { kind: "already-current" }
|
|
28
|
+
| { kind: "migrate" }
|
|
29
|
+
| { kind: "conflict" }
|
|
30
|
+
| { kind: "database-only"; targetState: WalletState; sourceState: WalletState }
|
|
31
|
+
| { kind: "orphaned-sidecars" };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Classify the two wallet locations exactly the way `migrateLegacyWallet`
|
|
35
|
+
* decides what to do. This is the single source of truth for startup behavior;
|
|
36
|
+
* `migrateLegacyWallet` maps it onto actions/errors and the doctor maps it onto
|
|
37
|
+
* verdict text.
|
|
38
|
+
*/
|
|
39
|
+
export function classifyWalletMigration(
|
|
40
|
+
targetDir: string,
|
|
41
|
+
sourceDir: string,
|
|
42
|
+
): WalletMigrationClass {
|
|
43
|
+
const targetConfig = join(targetDir, "config.json");
|
|
44
|
+
const targetDb = join(targetDir, "coco.db");
|
|
45
|
+
const sourceConfig = join(sourceDir, "config.json");
|
|
46
|
+
const sourceDb = join(sourceDir, "coco.db");
|
|
47
|
+
const sourceWal = `${sourceDb}-wal`;
|
|
48
|
+
const sourceShm = `${sourceDb}-shm`;
|
|
49
|
+
|
|
50
|
+
const targetState = walletState(targetConfig, targetDb);
|
|
51
|
+
const sourceState = walletState(sourceConfig, sourceDb);
|
|
52
|
+
|
|
53
|
+
// config.json is sufficient for a newly initialized wallet; coco.db is
|
|
54
|
+
// created on first open. A database without its mnemonic is never usable.
|
|
55
|
+
if (targetState === "initialized" && sourceState === "initialized") {
|
|
56
|
+
// A prior migration may have committed successfully but failed to remove
|
|
57
|
+
// its source files. Identical leftovers are safe; divergent wallets are not.
|
|
58
|
+
const configsMatch = filesEqual(targetConfig, sourceConfig);
|
|
59
|
+
const databasesMatch =
|
|
60
|
+
!existsSync(targetDb) && !existsSync(sourceDb)
|
|
61
|
+
? true
|
|
62
|
+
: filesEqual(targetDb, sourceDb);
|
|
63
|
+
if (configsMatch && databasesMatch) return { kind: "already-current" };
|
|
64
|
+
return { kind: "conflict" };
|
|
65
|
+
}
|
|
66
|
+
if (targetState === "initialized") return { kind: "already-current" };
|
|
67
|
+
if (!existsSync(sourceDb) && (existsSync(sourceWal) || existsSync(sourceShm))) {
|
|
68
|
+
return { kind: "orphaned-sidecars" };
|
|
69
|
+
}
|
|
70
|
+
if (targetState === "database-only" || sourceState === "database-only") {
|
|
71
|
+
return { kind: "database-only", targetState, sourceState };
|
|
72
|
+
}
|
|
73
|
+
if (sourceState === "absent") return { kind: "fresh" };
|
|
74
|
+
return { kind: "migrate" };
|
|
75
|
+
}
|