@spirobel/monero-wallet-api 0.3.18 → 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/dist/api.d.ts +5 -2
- package/dist/api.js +5 -1
- package/dist/io/atomicWrite.js +3 -1
- package/dist/io/indexedDB.js +7 -2
- package/dist/io/logging.d.ts +40 -0
- package/dist/io/logging.js +172 -0
- package/dist/keypairs-seeds/keypairs.d.ts +21 -0
- package/dist/keypairs-seeds/keypairs.js +21 -0
- package/dist/node-interaction/binaryEndpoints.d.ts +31 -2
- package/dist/node-interaction/binaryEndpoints.js +61 -43
- package/dist/node-interaction/jsonEndpoints.d.ts +257 -130
- package/dist/node-interaction/jsonEndpoints.js +159 -119
- package/dist/node-interaction/nodeUrl.d.ts +21 -6
- package/dist/node-interaction/nodeUrl.js +50 -8
- package/dist/scanning-syncing/backgroundWorker.d.ts +10 -6
- package/dist/scanning-syncing/backgroundWorker.js +88 -39
- package/dist/scanning-syncing/blocksbuffer/blocksBufferFetchLoop.d.ts +44 -0
- package/dist/scanning-syncing/blocksbuffer/blocksBufferFetchLoop.js +355 -0
- package/dist/scanning-syncing/blocksbuffer/blocksbufferCoordination.d.ts +15 -0
- package/dist/scanning-syncing/blocksbuffer/blocksbufferCoordination.js +28 -0
- package/dist/scanning-syncing/{scanresult → blocksbuffer}/getBlocksbinBuffer.d.ts +5 -5
- package/dist/scanning-syncing/{scanresult → blocksbuffer}/getBlocksbinBuffer.js +7 -5
- package/dist/scanning-syncing/connectionStatus.d.ts +33 -9
- package/dist/scanning-syncing/connectionStatus.js +89 -19
- package/dist/scanning-syncing/scanSettings.d.ts +21 -0
- package/dist/scanning-syncing/scanSettings.js +29 -0
- package/dist/scanning-syncing/scanresult/reorg.d.ts +0 -11
- package/dist/scanning-syncing/scanresult/reorg.js +39 -16
- package/dist/scanning-syncing/scanresult/scanCache.d.ts +16 -2
- package/dist/scanning-syncing/scanresult/scanCache.js +65 -5
- package/dist/scanning-syncing/scanresult/scanCacheOpened.d.ts +18 -0
- package/dist/scanning-syncing/scanresult/scanCacheOpened.js +56 -11
- package/dist/scanning-syncing/scanresult/scanCoordination.d.ts +127 -0
- package/dist/scanning-syncing/scanresult/scanCoordination.js +602 -0
- package/dist/scanning-syncing/scanresult/scanLoop.d.ts +20 -0
- package/dist/scanning-syncing/scanresult/scanLoop.js +116 -0
- package/dist/scanning-syncing/scanresult/scanResult.d.ts +24 -3
- package/dist/scanning-syncing/scanresult/scanResult.js +159 -97
- package/dist/scanning-syncing/worker-entrypoints/worker.js +1 -1
- package/dist/scanning-syncing/worker-mains/coordinator-main.d.ts +1 -0
- package/dist/scanning-syncing/worker-mains/coordinator-main.js +24 -0
- package/dist/scanning-syncing/worker-mains/cpubound-main.d.ts +5 -0
- package/dist/scanning-syncing/worker-mains/cpubound-main.js +122 -0
- package/dist/scanning-syncing/worker-mains/worker.d.ts +1 -1
- package/dist/scanning-syncing/worker-mains/worker.js +62 -6
- package/dist/viewpair/ViewPair.d.ts +30 -7
- package/dist/viewpair/ViewPair.js +56 -118
- package/dist/wasm-processing/wasmFile.js +1 -1
- package/package.json +22 -24
- package/dist/io/extension.d.ts +0 -18
- package/dist/io/extension.js +0 -11
|
@@ -1,10 +1,72 @@
|
|
|
1
|
-
import { atomicWrite } from "../api";
|
|
1
|
+
import { atomicWrite, } from "../api";
|
|
2
2
|
import { SCAN_SETTINGS_STORE_NAME_DEFAULT } from "./scanSettings";
|
|
3
3
|
export const DEFAULT_CONNECTION_STATUS_PREFIX = "ConnectionStatus-";
|
|
4
|
-
export function
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
export function msToHHMM(ms) {
|
|
5
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
6
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
7
|
+
const hours = Math.floor(minutes / 60);
|
|
8
|
+
const remainingMinutes = minutes % 60;
|
|
9
|
+
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) {
|
|
10
|
+
return "00:00";
|
|
11
|
+
}
|
|
12
|
+
const paddedHours = String(hours).padStart(2, "0");
|
|
13
|
+
const paddedMinutes = String(remainingMinutes).padStart(2, "0");
|
|
14
|
+
return `${paddedHours}:${paddedMinutes}`;
|
|
15
|
+
}
|
|
16
|
+
export function emptyConnectionStatus(overrides) {
|
|
17
|
+
const defaultStatus = {
|
|
18
|
+
last_packet: {
|
|
19
|
+
status: "no_connection_yet",
|
|
20
|
+
bytes_read: 0,
|
|
21
|
+
node_url: "",
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
},
|
|
24
|
+
sync: {
|
|
25
|
+
scanned_ranges: [],
|
|
26
|
+
daemon_height: 0,
|
|
27
|
+
current_scan_height: 0,
|
|
28
|
+
eta: "00:00",
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
return overrides ? { ...defaultStatus, ...overrides } : defaultStatus;
|
|
33
|
+
}
|
|
34
|
+
export async function updateSyncETA(daemon_height, current_scan_height, last_height, last_timestamp, scan_settings_path) {
|
|
35
|
+
const blocks_till_tip = daemon_height - current_scan_height;
|
|
36
|
+
let blocks_since_last_update = null;
|
|
37
|
+
let duration = null;
|
|
38
|
+
if (typeof last_height === "number") {
|
|
39
|
+
blocks_since_last_update = current_scan_height - last_height;
|
|
40
|
+
}
|
|
41
|
+
if (typeof last_timestamp === "number") {
|
|
42
|
+
duration = Date.now() - last_timestamp;
|
|
43
|
+
}
|
|
44
|
+
let eta = "00:00";
|
|
45
|
+
if (blocks_since_last_update !== null &&
|
|
46
|
+
duration !== null &&
|
|
47
|
+
duration > 0 &&
|
|
48
|
+
blocks_till_tip > 0) {
|
|
49
|
+
const blocks_per_ms = blocks_since_last_update / duration;
|
|
50
|
+
const eta_ms = blocks_till_tip / blocks_per_ms;
|
|
51
|
+
eta = msToHHMM(eta_ms);
|
|
52
|
+
}
|
|
53
|
+
await readWriteConnectionStatusFile((cs) => {
|
|
54
|
+
cs.sync = {
|
|
55
|
+
...cs.sync,
|
|
56
|
+
daemon_height,
|
|
57
|
+
current_scan_height,
|
|
58
|
+
eta,
|
|
59
|
+
timestamp: new Date().toISOString(),
|
|
60
|
+
};
|
|
61
|
+
}, scan_settings_path);
|
|
62
|
+
return { last_height: current_scan_height, last_timestamp: Date.now() };
|
|
63
|
+
}
|
|
64
|
+
export function connectionStatusFilePath(scan_settings_path = SCAN_SETTINGS_STORE_NAME_DEFAULT) {
|
|
65
|
+
const parts = scan_settings_path.split("/");
|
|
66
|
+
const basename = parts.pop();
|
|
67
|
+
const dir = parts.join("/");
|
|
68
|
+
const prefix = dir ? `${dir}/` : "";
|
|
69
|
+
return `${prefix}${DEFAULT_CONNECTION_STATUS_PREFIX}${basename}`;
|
|
8
70
|
}
|
|
9
71
|
export async function readConnectionStatusDefaultLocation(scan_settings_path) {
|
|
10
72
|
return await readConnectionStatusFile(connectionStatusFilePath(scan_settings_path));
|
|
@@ -15,21 +77,29 @@ export async function readConnectionStatusFile(connectionStatusFilePath) {
|
|
|
15
77
|
.catch(() => undefined);
|
|
16
78
|
return jsonString ? JSON.parse(jsonString) : undefined;
|
|
17
79
|
}
|
|
18
|
-
export async function writeConnectionStatusFile(
|
|
19
|
-
const connectionStatus = {
|
|
20
|
-
last_packet: {
|
|
21
|
-
status,
|
|
22
|
-
bytes_read,
|
|
23
|
-
node_url,
|
|
24
|
-
timestamp: new Date().toISOString(),
|
|
25
|
-
},
|
|
26
|
-
};
|
|
80
|
+
export async function writeConnectionStatusFile(connectionStatus, scan_settings_path) {
|
|
27
81
|
return await atomicWrite(connectionStatusFilePath(scan_settings_path), JSON.stringify(connectionStatus, null, 2));
|
|
28
82
|
}
|
|
29
83
|
export async function readWriteConnectionStatusFile(writeCB, scan_settings_path) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
84
|
+
let connectionStatus = await readConnectionStatusDefaultLocation(scan_settings_path);
|
|
85
|
+
if (!connectionStatus)
|
|
86
|
+
connectionStatus = emptyConnectionStatus();
|
|
87
|
+
await writeCB(connectionStatus);
|
|
88
|
+
await writeConnectionStatusFile(connectionStatus, scan_settings_path);
|
|
89
|
+
return connectionStatus;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* read the connection status from disk (typical /path/to/ConnectionStatus-ScanSettings.json)
|
|
93
|
+
* and persist empty inital status if not found
|
|
94
|
+
* @param scan_settings_path path to the scan settings file /path/to/ScanSettings.json
|
|
95
|
+
* @returns connection status or empty initalized connection status
|
|
96
|
+
*/
|
|
97
|
+
export async function readOrInitConnectionStatus(scan_settings_path) {
|
|
98
|
+
const cs = await readConnectionStatusFile(connectionStatusFilePath(scan_settings_path));
|
|
99
|
+
if (typeof cs === "undefined") {
|
|
100
|
+
const emptyInitial = emptyConnectionStatus();
|
|
101
|
+
await writeConnectionStatusFile(emptyInitial, scan_settings_path);
|
|
102
|
+
return emptyInitial;
|
|
103
|
+
}
|
|
104
|
+
return cs;
|
|
35
105
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LogSetting, PossibleLogs } from "../io/logging";
|
|
1
2
|
export declare const SCAN_SETTINGS_STORE_NAME_DEFAULT = "ScanSettings.json";
|
|
2
3
|
export type ScanSetting = {
|
|
3
4
|
primary_address: string;
|
|
@@ -17,6 +18,9 @@ export type WriteScanSettingParams = {
|
|
|
17
18
|
wallet_route?: string;
|
|
18
19
|
wallet_name?: string;
|
|
19
20
|
wallet_slot?: number;
|
|
21
|
+
logs?: LogSetting;
|
|
22
|
+
logs_include?: PossibleLogs[];
|
|
23
|
+
logs_exclude?: PossibleLogs[];
|
|
20
24
|
};
|
|
21
25
|
export type ScanSettingOpened = {
|
|
22
26
|
primary_address: string;
|
|
@@ -35,12 +39,20 @@ export type ScanSettings = {
|
|
|
35
39
|
node_url: string;
|
|
36
40
|
start_height: number | null;
|
|
37
41
|
merchant_confirmations?: number | null;
|
|
42
|
+
cpu_worker_count?: number;
|
|
43
|
+
logs?: LogSetting;
|
|
44
|
+
logs_include?: PossibleLogs[];
|
|
45
|
+
logs_exclude?: PossibleLogs[];
|
|
38
46
|
};
|
|
39
47
|
export type ScanSettingsOpened = {
|
|
40
48
|
wallets: (ScanSettingOpened | undefined)[];
|
|
41
49
|
node_url: string;
|
|
42
50
|
start_height: number | null;
|
|
43
51
|
merchant_confirmations?: number | null;
|
|
52
|
+
cpu_worker_count?: number;
|
|
53
|
+
logs?: LogSetting;
|
|
54
|
+
logs_include?: PossibleLogs[];
|
|
55
|
+
logs_exclude?: PossibleLogs[];
|
|
44
56
|
};
|
|
45
57
|
/**
|
|
46
58
|
* Writes scan settings to the default or specified storage file in json.
|
|
@@ -140,4 +152,13 @@ export declare function walletSettingsPlusKeys(wallet_settings: ScanSettingOpene
|
|
|
140
152
|
export declare function writeWalletToScanSettings(params: WriteScanSettingParams): Promise<number>;
|
|
141
153
|
export declare function openScanSettingsFile(scan_settings_path?: string): Promise<ScanSettings | undefined>;
|
|
142
154
|
export declare function openNonHaltedWallets(scan_settings_path?: string): Promise<ScanSettingOpened[]>;
|
|
155
|
+
export declare function getNonHaltedWallets(scan_settings?: ScanSettings): ScanSetting[];
|
|
143
156
|
export declare function doesScanSettingsFileExist(scan_settings_path?: string): Promise<boolean>;
|
|
157
|
+
/**
|
|
158
|
+
* Either 1. the consumer explicitly sets a pathprefix, we extract it from the scan_settings_path
|
|
159
|
+
* or we pick the default which is nothing
|
|
160
|
+
* @param scan_settings_path optional scan settings path
|
|
161
|
+
* @param pathPrefix optional path prefix
|
|
162
|
+
* @returns definite path prefix
|
|
163
|
+
*/
|
|
164
|
+
export declare function getPathPrefix(scan_settings_path?: string, pathPrefix?: string): string;
|
|
@@ -239,10 +239,19 @@ export async function writeWalletToScanSettings(params) {
|
|
|
239
239
|
},
|
|
240
240
|
],
|
|
241
241
|
node_url: params.node_url,
|
|
242
|
+
logs: params.logs,
|
|
243
|
+
logs_include: params.logs_include,
|
|
244
|
+
logs_exclude: params.logs_exclude,
|
|
242
245
|
start_height: params.start_height || null,
|
|
243
246
|
}, params.scan_settings_path);
|
|
244
247
|
}
|
|
245
248
|
scanSettings.node_url = params.node_url || scanSettings.node_url;
|
|
249
|
+
if (params.logs)
|
|
250
|
+
scanSettings.logs = params.logs;
|
|
251
|
+
if (params.logs_include)
|
|
252
|
+
scanSettings.logs_include = params.logs_include;
|
|
253
|
+
if (params.logs_exclude)
|
|
254
|
+
scanSettings.logs_exclude = params.logs_exclude;
|
|
246
255
|
scanSettings.start_height =
|
|
247
256
|
params.start_height === undefined
|
|
248
257
|
? scanSettings.start_height
|
|
@@ -290,6 +299,12 @@ export async function openNonHaltedWallets(scan_settings_path = SCAN_SETTINGS_ST
|
|
|
290
299
|
throw new Error("no non halted wallets in scan settings");
|
|
291
300
|
return nonHaltedWallets;
|
|
292
301
|
}
|
|
302
|
+
export function getNonHaltedWallets(scan_settings) {
|
|
303
|
+
if (!scan_settings)
|
|
304
|
+
return [];
|
|
305
|
+
const nonHaltedWallets = scan_settings.wallets.filter((wallet) => !wallet?.halted);
|
|
306
|
+
return nonHaltedWallets;
|
|
307
|
+
}
|
|
293
308
|
export async function doesScanSettingsFileExist(scan_settings_path = SCAN_SETTINGS_STORE_NAME_DEFAULT) {
|
|
294
309
|
const scan_settings_file_content = await Bun.file(scan_settings_path)
|
|
295
310
|
.text()
|
|
@@ -301,3 +316,17 @@ export async function doesScanSettingsFileExist(scan_settings_path = SCAN_SETTIN
|
|
|
301
316
|
return false;
|
|
302
317
|
}
|
|
303
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* Either 1. the consumer explicitly sets a pathprefix, we extract it from the scan_settings_path
|
|
321
|
+
* or we pick the default which is nothing
|
|
322
|
+
* @param scan_settings_path optional scan settings path
|
|
323
|
+
* @param pathPrefix optional path prefix
|
|
324
|
+
* @returns definite path prefix
|
|
325
|
+
*/
|
|
326
|
+
export function getPathPrefix(scan_settings_path = SCAN_SETTINGS_STORE_NAME_DEFAULT, pathPrefix) {
|
|
327
|
+
const parts = scan_settings_path.split("/");
|
|
328
|
+
const basename = parts.pop();
|
|
329
|
+
const dir = parts.join("/");
|
|
330
|
+
const prefix = dir ? `${dir}/` : "";
|
|
331
|
+
return pathPrefix ?? prefix;
|
|
332
|
+
}
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
import type { BlockInfo, Output } from "../../api";
|
|
2
|
-
import type { KeyImage } from "./computeKeyImage";
|
|
3
1
|
import { type ScanResult } from "./scanResult";
|
|
4
2
|
import type { CacheRange, ChangedOutput, ScanCache } from "./scanCache";
|
|
5
|
-
export type ReorgInfo = {
|
|
6
|
-
split_height: BlockInfo;
|
|
7
|
-
removed_outputs: ReorgedOutput[];
|
|
8
|
-
reverted_spends: ReorgedOutput[];
|
|
9
|
-
};
|
|
10
|
-
export type ReorgedOutput = {
|
|
11
|
-
old_output_state: Output;
|
|
12
|
-
key_image: KeyImage;
|
|
13
|
-
};
|
|
14
3
|
export declare function handleReorg(current_range: CacheRange, result: ScanResult, cache: ScanCache, oldRange: CacheRange): [CacheRange, ChangedOutput[]];
|
|
@@ -1,25 +1,48 @@
|
|
|
1
1
|
import { makeNewRange } from "./scanResult";
|
|
2
2
|
export function handleReorg(current_range, result, cache, oldRange) {
|
|
3
3
|
let changed_outputs = [];
|
|
4
|
+
console.log("[handleReorg] oldRange hashes:", oldRange.block_hashes.map((h) => h.block_height + ":" + h.block_hash.slice(0, 8)));
|
|
5
|
+
console.log("[handleReorg] cache outputs:", Object.entries(cache.outputs).map(([id, o]) => "h=" + o.block_height + " spent=" + (o.spent_block_height ?? "no")));
|
|
6
|
+
console.log("[handleReorg] result infos:", result.block_infos.length +
|
|
7
|
+
" blocks, first=" +
|
|
8
|
+
result.block_infos[0]?.block_height, "last=" + result.block_infos.at(-1)?.block_height);
|
|
4
9
|
// we need to check where anchor candidate is and if not found, try the same for anchor
|
|
5
10
|
// if else throw on catastrophic reorg
|
|
6
11
|
for (const block_hash of oldRange.block_hashes) {
|
|
7
12
|
const split_height_index = result.block_infos.findIndex((b) => b.block_hash === block_hash.block_hash);
|
|
13
|
+
if (!(split_height_index > 0))
|
|
14
|
+
continue;
|
|
15
|
+
console.log("[handleReorg] trying hash", block_hash.block_height, block_hash.block_hash.slice(0, 8), "found at idx", split_height_index);
|
|
8
16
|
const split_height = result.block_infos[split_height_index];
|
|
9
17
|
// still a chance to find the split height, (could be candidate_anchor or anchor)
|
|
10
18
|
if (!split_height)
|
|
11
19
|
continue;
|
|
12
20
|
// we found the split height & do the reorg
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
console.log("[handleReorg] SPLIT FOUND at height", split_height.block_height);
|
|
22
|
+
if (!cache.reorg_info) {
|
|
23
|
+
cache.reorg_info = {
|
|
24
|
+
split_heights: [split_height],
|
|
25
|
+
removed_outputs: [],
|
|
26
|
+
reverted_spends: [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
cache.reorg_info.split_heights.push(split_height);
|
|
31
|
+
}
|
|
32
|
+
// First, collect reverted spends from all outputs (before any removals)
|
|
33
|
+
// so that outputs with block_height < split_height but spent_block_height >= split_height are captured
|
|
34
|
+
const reverted_outputs = Object.entries(cache.outputs).filter(([id, output]) => output.spent_block_height !== undefined &&
|
|
35
|
+
output.spent_block_height >= split_height.block_height);
|
|
18
36
|
const removed_outputs = Object.entries(cache.outputs).filter(([id, output]) => output.block_height >= split_height.block_height);
|
|
37
|
+
console.log("[handleReorg] removed_outputs count:", removed_outputs.length, "reverted count:", reverted_outputs.length);
|
|
19
38
|
for (const [id, old_output_state] of removed_outputs) {
|
|
20
39
|
// 1. find key_image of output to be removed (as it was reorged)
|
|
21
40
|
const [key_image] = Object.entries(cache.own_key_images).find(([own_key_image, globalid]) => globalid === id) || [""]; // if this is viewonly the key_image will be empty
|
|
22
|
-
reorg_info.removed_outputs.push({
|
|
41
|
+
cache.reorg_info.removed_outputs.push({
|
|
42
|
+
old_output_state,
|
|
43
|
+
key_image,
|
|
44
|
+
split_height,
|
|
45
|
+
});
|
|
23
46
|
// 2. remove from outputs and own_key_images
|
|
24
47
|
delete cache.outputs[id];
|
|
25
48
|
delete cache.own_key_images[key_image];
|
|
@@ -28,21 +51,22 @@ export function handleReorg(current_range, result, cache, oldRange) {
|
|
|
28
51
|
change_reason: "reorged",
|
|
29
52
|
});
|
|
30
53
|
}
|
|
31
|
-
//for reverted spents, just do the same again with spent_height
|
|
32
|
-
const reverted_outputs = Object.entries(cache.outputs).filter(([id, output]) => output.spent_block_height !== undefined &&
|
|
33
|
-
output.spent_block_height >= split_height.block_height);
|
|
34
54
|
for (const [id, old_output_state_pointer] of reverted_outputs) {
|
|
35
55
|
const [key_image] = Object.entries(cache.own_key_images).find(([own_key_image, globalid]) => globalid === id) || [""]; // if this is viewonly the key_image will be empty
|
|
36
56
|
const old_output_state = Object.assign({}, old_output_state_pointer);
|
|
37
|
-
reorg_info.reverted_spends.push({
|
|
57
|
+
cache.reorg_info.reverted_spends.push({
|
|
38
58
|
old_output_state,
|
|
39
59
|
key_image, // in this case key_image only used here, does not get removed
|
|
60
|
+
split_height,
|
|
40
61
|
});
|
|
41
|
-
// remove spend info from original cache
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
// remove spend info from original cache (if output still exists
|
|
63
|
+
// it may have been deleted above in the removed_outputs loop)
|
|
64
|
+
if (cache.outputs[id]) {
|
|
65
|
+
delete cache.outputs[id].spent_relative_index;
|
|
66
|
+
delete cache.outputs[id].spent_in_tx_hash;
|
|
67
|
+
delete cache.outputs[id].spent_block_height;
|
|
68
|
+
delete cache.outputs[id].spent_block_timestamp;
|
|
69
|
+
}
|
|
46
70
|
changed_outputs.push({
|
|
47
71
|
output: old_output_state,
|
|
48
72
|
change_reason: "reorged_spent",
|
|
@@ -51,7 +75,6 @@ export function handleReorg(current_range, result, cache, oldRange) {
|
|
|
51
75
|
// find current range in scanned ranges and change its end value + latest_block_hash
|
|
52
76
|
oldRange.end = split_height.block_height;
|
|
53
77
|
oldRange.block_hashes[0] = split_height;
|
|
54
|
-
cache.reorg_info = reorg_info;
|
|
55
78
|
// fix current_range
|
|
56
79
|
let anchor = result.block_infos.slice(-100)[0]; // if we got lots of new blocks
|
|
57
80
|
const old_anchor = oldRange.block_hashes.at(-1);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type KeyImage } from "./computeKeyImage";
|
|
2
|
-
import type
|
|
3
|
-
import type { BlockInfo, FeeEstimateResponse, GetBlockHeadersRange, GetBlockHeadersRangeParams, Output, SendRawTransactionResult, ViewPair } from "../../api";
|
|
2
|
+
import { type BlockInfo, type FeeEstimateResponse, type GetBlockHeadersRange, type GetBlockHeadersRangeParams, type Output, type SendRawTransactionResult, type ViewPair } from "../../api";
|
|
4
3
|
import type { Payment } from "../../send-functionality/inputSelection";
|
|
5
4
|
export declare function initScanCache(viewpair: ViewPair, start_height: number, scan_settings_path?: string, pathPrefix?: string): Promise<CacheRange>;
|
|
5
|
+
export declare function getBlockInfoForHeight(height: number, node_url: string): Promise<BlockInfo>;
|
|
6
|
+
export declare function makeCacheRangeForHeight(height: number, node_url: string): Promise<CacheRange>;
|
|
7
|
+
export declare function initScanCacheFile(viewpair: ViewPair, scan_settings_path?: string, pathPrefix?: string): Promise<ScanCache>;
|
|
6
8
|
export declare function readCacheFile(cacheFilePath: string): Promise<ScanCache | undefined>;
|
|
7
9
|
export declare function cacheFileDefaultLocation(primary_address: string, pathPrefix?: string): string;
|
|
8
10
|
export declare function readCacheFileDefaultLocation(primary_address: string, pathPrefix?: string): Promise<ScanCache | undefined>;
|
|
@@ -14,8 +16,10 @@ export type WriteCacheFileParams = {
|
|
|
14
16
|
export declare function writeCacheFileDefaultLocationThrows(params: WriteCacheFileParams): Promise<void>;
|
|
15
17
|
export declare function writeCacheToFile(cache: ScanCache, pathPrefix?: string): Promise<number>;
|
|
16
18
|
export declare function lastRange(ranges: CacheRange[]): CacheRange | undefined;
|
|
19
|
+
export declare function lastRangeThrows(ranges: CacheRange[]): CacheRange;
|
|
17
20
|
export declare function mergeRanges(ranges: CacheRange[]): CacheRange[];
|
|
18
21
|
export declare const findRange: (ranges: CacheRange[], value: number) => CacheRange | null;
|
|
22
|
+
export declare function findRangeThrows(ranges: CacheRange[], value: number): CacheRange;
|
|
19
23
|
export type CacheRange = {
|
|
20
24
|
start: number;
|
|
21
25
|
end: number;
|
|
@@ -44,6 +48,16 @@ export type ScanCache = {
|
|
|
44
48
|
reorg_info?: ReorgInfo;
|
|
45
49
|
daemon_height: number;
|
|
46
50
|
};
|
|
51
|
+
export type ReorgInfo = {
|
|
52
|
+
split_heights: BlockInfo[];
|
|
53
|
+
removed_outputs: ReorgedOutput[];
|
|
54
|
+
reverted_spends: ReorgedOutput[];
|
|
55
|
+
};
|
|
56
|
+
export type ReorgedOutput = {
|
|
57
|
+
old_output_state: Output;
|
|
58
|
+
key_image: KeyImage;
|
|
59
|
+
split_height: BlockInfo;
|
|
60
|
+
};
|
|
47
61
|
export type TxLog = {
|
|
48
62
|
inputs_index: string[];
|
|
49
63
|
payments: Payment[];
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {} from "./computeKeyImage";
|
|
2
|
+
import { get_block_headers_range, } from "../../api";
|
|
2
3
|
import { atomicWrite } from "../../io/atomicWrite";
|
|
4
|
+
import { log } from "../../io/logging";
|
|
3
5
|
export async function initScanCache(viewpair, start_height, scan_settings_path, pathPrefix) {
|
|
4
6
|
const initialCache = await readCacheFileDefaultLocation(viewpair.primary_address, pathPrefix);
|
|
5
7
|
let cache = {
|
|
@@ -43,6 +45,44 @@ export async function initScanCache(viewpair, start_height, scan_settings_path,
|
|
|
43
45
|
await writeCacheToFile(cache, pathPrefix);
|
|
44
46
|
return current_range;
|
|
45
47
|
}
|
|
48
|
+
export async function getBlockInfoForHeight(height, node_url) {
|
|
49
|
+
const blockHeaderResponse = await get_block_headers_range(node_url, {
|
|
50
|
+
start_height: height,
|
|
51
|
+
end_height: height,
|
|
52
|
+
});
|
|
53
|
+
const header = blockHeaderResponse.headers[0];
|
|
54
|
+
return {
|
|
55
|
+
block_hash: header.hash,
|
|
56
|
+
block_height: header.height,
|
|
57
|
+
block_timestamp: header.timestamp,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export async function makeCacheRangeForHeight(height, node_url) {
|
|
61
|
+
const hash_at_height = await getBlockInfoForHeight(height, node_url);
|
|
62
|
+
const newRange = {
|
|
63
|
+
start: height,
|
|
64
|
+
end: height,
|
|
65
|
+
block_hashes: [hash_at_height, hash_at_height, hash_at_height],
|
|
66
|
+
};
|
|
67
|
+
return newRange;
|
|
68
|
+
}
|
|
69
|
+
export async function initScanCacheFile(viewpair, scan_settings_path, pathPrefix) {
|
|
70
|
+
const initialCache = await readCacheFileDefaultLocation(viewpair.primary_address, pathPrefix);
|
|
71
|
+
let cache = {
|
|
72
|
+
daemon_height: 0,
|
|
73
|
+
outputs: {},
|
|
74
|
+
own_key_images: {},
|
|
75
|
+
scanned_ranges: [],
|
|
76
|
+
primary_address: viewpair.primary_address,
|
|
77
|
+
};
|
|
78
|
+
if (initialCache)
|
|
79
|
+
cache = initialCache;
|
|
80
|
+
cache.scanned_ranges = mergeRanges(cache.scanned_ranges);
|
|
81
|
+
await viewpair.addSubaddressesToScanCache(cache, scan_settings_path);
|
|
82
|
+
// write to cache
|
|
83
|
+
await writeCacheToFile(cache, pathPrefix);
|
|
84
|
+
return cache;
|
|
85
|
+
}
|
|
46
86
|
export async function readCacheFile(cacheFilePath) {
|
|
47
87
|
const jsonString = await Bun.file(cacheFilePath)
|
|
48
88
|
.text()
|
|
@@ -56,6 +96,10 @@ export async function readCacheFile(cacheFilePath) {
|
|
|
56
96
|
: undefined;
|
|
57
97
|
}
|
|
58
98
|
export function cacheFileDefaultLocation(primary_address, pathPrefix) {
|
|
99
|
+
if (pathPrefix && pathPrefix.endsWith("/"))
|
|
100
|
+
pathPrefix = pathPrefix.slice(0, -1);
|
|
101
|
+
if (pathPrefix)
|
|
102
|
+
pathPrefix += "/";
|
|
59
103
|
return `${pathPrefix ?? ""}${primary_address}_cache.json`;
|
|
60
104
|
}
|
|
61
105
|
export async function readCacheFileDefaultLocation(primary_address, pathPrefix) {
|
|
@@ -78,6 +122,11 @@ export function lastRange(ranges) {
|
|
|
78
122
|
return undefined;
|
|
79
123
|
return ranges.reduce((maxRange, current) => (current.end > maxRange.end ? current : maxRange), ranges[0]);
|
|
80
124
|
}
|
|
125
|
+
export function lastRangeThrows(ranges) {
|
|
126
|
+
if (!ranges.length)
|
|
127
|
+
throw new Error("ranges is empty");
|
|
128
|
+
return ranges.reduce((maxRange, current) => (current.end > maxRange.end ? current : maxRange), ranges[0]);
|
|
129
|
+
}
|
|
81
130
|
export function mergeRanges(ranges) {
|
|
82
131
|
if (ranges.length <= 1)
|
|
83
132
|
return ranges.map((r) => ({ ...r }));
|
|
@@ -89,8 +138,10 @@ export function mergeRanges(ranges) {
|
|
|
89
138
|
// If last range overlaps or touches current range
|
|
90
139
|
if (curr.start <= last.end) {
|
|
91
140
|
// Extend last range to cover both (take max end value)
|
|
92
|
-
|
|
93
|
-
|
|
141
|
+
if (curr.end > last.end) {
|
|
142
|
+
last.end = curr.end;
|
|
143
|
+
last.block_hashes = curr.block_hashes;
|
|
144
|
+
}
|
|
94
145
|
}
|
|
95
146
|
else {
|
|
96
147
|
// No overlap: add current range as new merged interval
|
|
@@ -101,11 +152,17 @@ export function mergeRanges(ranges) {
|
|
|
101
152
|
}
|
|
102
153
|
// find the cache range that contains the given height, if not found return null
|
|
103
154
|
export const findRange = (ranges, value) => ranges.find((r) => value >= r.start && value <= r.end) ?? null;
|
|
155
|
+
export function findRangeThrows(ranges, value) {
|
|
156
|
+
const range = findRange(ranges, value);
|
|
157
|
+
if (!range)
|
|
158
|
+
throw new Error(`range not found for value: ${value}`);
|
|
159
|
+
return range;
|
|
160
|
+
}
|
|
104
161
|
export function handleScanError(error) {
|
|
105
162
|
// treat errno 0 code "ConnectionRefused" as non fatal outcome, and rethrow,
|
|
106
163
|
// so that UI can be informed after catching it higher up
|
|
107
164
|
if (isConnectionError(error)) {
|
|
108
|
-
|
|
165
|
+
log("handleScanError", "Scan stopped. node might be offline. Connection Refused");
|
|
109
166
|
throw error;
|
|
110
167
|
}
|
|
111
168
|
// Treat AbortError as a normal, non-fatal outcome
|
|
@@ -113,11 +170,14 @@ export function handleScanError(error) {
|
|
|
113
170
|
typeof error === "object" &&
|
|
114
171
|
(("name" in error && error.name === "AbortError") ||
|
|
115
172
|
("code" in error && error.code === 20))) {
|
|
116
|
-
|
|
173
|
+
log("handleScanError", "Scan was aborted.");
|
|
117
174
|
return;
|
|
118
175
|
}
|
|
119
176
|
else {
|
|
120
|
-
|
|
177
|
+
log("handleScanError", [
|
|
178
|
+
error,
|
|
179
|
+
"\n, scanWithCache in scanning-syncing/scanWithCache.ts`",
|
|
180
|
+
]);
|
|
121
181
|
throw error;
|
|
122
182
|
}
|
|
123
183
|
}
|
|
@@ -28,6 +28,22 @@ export declare class ScanCacheOpened {
|
|
|
28
28
|
private scan_settings_path?;
|
|
29
29
|
private pathPrefix?;
|
|
30
30
|
private workerError?;
|
|
31
|
+
/** how many decoys to sample per input (default 20, ring size is 11) */
|
|
32
|
+
decoySampleCount: number;
|
|
33
|
+
/**
|
|
34
|
+
* when true, retry makeInput with higher sample counts on failure.
|
|
35
|
+
*
|
|
36
|
+
* PRIVACY WARNING: retrying contacts the node multiple times for the same
|
|
37
|
+
* input, each time with a different set of candidate indices. this lets the
|
|
38
|
+
* node correlate which output is the real spend across the retries.
|
|
39
|
+
* only ever enable this on your own local trusted node, never on a remote
|
|
40
|
+
* public node.
|
|
41
|
+
*
|
|
42
|
+
* defaults to false, on failure the original error propagates.
|
|
43
|
+
*/
|
|
44
|
+
decoyRetry: boolean;
|
|
45
|
+
/** sample sizes to try when decoyRetry is enabled, in order */
|
|
46
|
+
readonly decoyRetrySizes: number[];
|
|
31
47
|
static create(params: ScanCacheOpenedCreateParams): Promise<ScanCacheOpened>;
|
|
32
48
|
get start_height(): number | null;
|
|
33
49
|
get current_height(): number | null;
|
|
@@ -120,6 +136,8 @@ export declare class ScanCacheOpened {
|
|
|
120
136
|
feed(params: CacheChangedCallbackParameters): Promise<void>;
|
|
121
137
|
private _cache;
|
|
122
138
|
private worker?;
|
|
139
|
+
private last_eta_height;
|
|
140
|
+
private last_eta_timestamp;
|
|
123
141
|
private constructor();
|
|
124
142
|
private _stats;
|
|
125
143
|
private notifyListeners;
|
|
@@ -5,7 +5,7 @@ import { spendable } from "./scanResult";
|
|
|
5
5
|
import { openScanSettingsFile, readPrivateSpendKeyFromEnv, readWalletFromScanSettings, SCAN_SETTINGS_STORE_NAME_DEFAULT, walletSettingsPlusKeys, writeNodeUrlToScanSettings, writeStartHeightToScanSettings, writeWalletToScanSettings, } from "../scanSettings";
|
|
6
6
|
import { findRange, lastRange, readCacheFileDefaultLocation, writeCacheFileDefaultLocationThrows, } from "./scanCache";
|
|
7
7
|
import { alignScanStatsWithCache, isSelfSpent, processTxlogInputs, processTxlogPayments, writeStatsFileDefaultLocation, } from "./scanStats";
|
|
8
|
-
import { readWriteConnectionStatusFile, } from "../connectionStatus";
|
|
8
|
+
import { updateSyncETA, readWriteConnectionStatusFile, } from "../connectionStatus";
|
|
9
9
|
export class ScanCacheOpened {
|
|
10
10
|
view_pair;
|
|
11
11
|
wallet_route;
|
|
@@ -17,6 +17,22 @@ export class ScanCacheOpened {
|
|
|
17
17
|
scan_settings_path;
|
|
18
18
|
pathPrefix;
|
|
19
19
|
workerError;
|
|
20
|
+
/** how many decoys to sample per input (default 20, ring size is 11) */
|
|
21
|
+
decoySampleCount = 20;
|
|
22
|
+
/**
|
|
23
|
+
* when true, retry makeInput with higher sample counts on failure.
|
|
24
|
+
*
|
|
25
|
+
* PRIVACY WARNING: retrying contacts the node multiple times for the same
|
|
26
|
+
* input, each time with a different set of candidate indices. this lets the
|
|
27
|
+
* node correlate which output is the real spend across the retries.
|
|
28
|
+
* only ever enable this on your own local trusted node, never on a remote
|
|
29
|
+
* public node.
|
|
30
|
+
*
|
|
31
|
+
* defaults to false, on failure the original error propagates.
|
|
32
|
+
*/
|
|
33
|
+
decoyRetry = false;
|
|
34
|
+
/** sample sizes to try when decoyRetry is enabled, in order */
|
|
35
|
+
decoyRetrySizes = [20, 50, 100, 200, 500];
|
|
20
36
|
static async create(params) {
|
|
21
37
|
const theCatchToBeOpened = await readCacheFileDefaultLocation(params.primary_address, params.pathPrefix);
|
|
22
38
|
const walletSettings = await readWalletFromScanSettings(params.primary_address, params.scan_settings_path);
|
|
@@ -193,16 +209,30 @@ export class ScanCacheOpened {
|
|
|
193
209
|
// 4. get output distribution
|
|
194
210
|
const node = await NodeUrl.create(this.node_url);
|
|
195
211
|
const distibution = await node.getOutputDistribution();
|
|
196
|
-
const
|
|
212
|
+
const inputs = [];
|
|
197
213
|
for (const input of selectedInputs) {
|
|
198
214
|
// 5. sample decoys & get outs from node: here is where a privacy compromising event could happen
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
215
|
+
const sizesToTry = this.decoyRetry
|
|
216
|
+
? this.decoyRetrySizes
|
|
217
|
+
: [this.decoySampleCount];
|
|
218
|
+
let madeInput;
|
|
219
|
+
for (const size of sizesToTry) {
|
|
220
|
+
if (madeInput)
|
|
221
|
+
break;
|
|
222
|
+
try {
|
|
223
|
+
const prepared = prepareInput(node, distibution, input, size);
|
|
224
|
+
const wasmInput = node.makeInput(prepared.input, prepared.sample.candidates, await prepared.outsResponse);
|
|
225
|
+
madeInput = wasmInput;
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
if (size === sizesToTry[sizesToTry.length - 1])
|
|
229
|
+
throw e;
|
|
230
|
+
// fall through to next size
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!madeInput)
|
|
234
|
+
throw new Error("failed to make input");
|
|
235
|
+
inputs.push(madeInput);
|
|
206
236
|
}
|
|
207
237
|
// 7. make transaction: combine inputs, payments + fee info
|
|
208
238
|
const unsignedTx = this.view_pair.makeTransaction({
|
|
@@ -429,10 +459,12 @@ export class ScanCacheOpened {
|
|
|
429
459
|
if (!this.worker && !this.no_worker) {
|
|
430
460
|
this.worker = await createWebworker(async (params) => await this.feed(params), this.scan_settings_path, this.pathPrefix, (error) => {
|
|
431
461
|
const workerErrCB = this.workerError;
|
|
462
|
+
this.stopWorker();
|
|
432
463
|
readWriteConnectionStatusFile((cs) => {
|
|
433
464
|
if (cs?.last_packet.status === "catastrophic_reorg")
|
|
434
465
|
return;
|
|
435
466
|
const connectionStatus = {
|
|
467
|
+
...cs,
|
|
436
468
|
last_packet: {
|
|
437
469
|
status: "connection_failed",
|
|
438
470
|
bytes_read: 0,
|
|
@@ -441,7 +473,7 @@ export class ScanCacheOpened {
|
|
|
441
473
|
},
|
|
442
474
|
};
|
|
443
475
|
return connectionStatus;
|
|
444
|
-
}).then(() => {
|
|
476
|
+
}, this.scan_settings_path).then(() => {
|
|
445
477
|
if (workerErrCB)
|
|
446
478
|
workerErrCB(error);
|
|
447
479
|
});
|
|
@@ -471,7 +503,13 @@ export class ScanCacheOpened {
|
|
|
471
503
|
selectOneInput(amount) {
|
|
472
504
|
return this.spendableInputs()
|
|
473
505
|
.filter((output) => output.amount >= amount)
|
|
474
|
-
.sort((a, b) =>
|
|
506
|
+
.sort((a, b) => {
|
|
507
|
+
if (b.amount > a.amount)
|
|
508
|
+
return -1;
|
|
509
|
+
if (b.amount < a.amount)
|
|
510
|
+
return 1;
|
|
511
|
+
return a.block_height - b.block_height;
|
|
512
|
+
})[0];
|
|
475
513
|
}
|
|
476
514
|
/**
|
|
477
515
|
* selectMultipleInputs larger than amount, sorted from largest to smallest until total reaches amount
|
|
@@ -509,6 +547,11 @@ export class ScanCacheOpened {
|
|
|
509
547
|
return;
|
|
510
548
|
this._cache = params.newCache;
|
|
511
549
|
this._stats = await alignScanStatsWithCache(this._cache, this.view_pair, this.primary_address, this.pathPrefix, undefined, lastRange(this._cache.scanned_ranges)?.end);
|
|
550
|
+
if (!this.no_worker) {
|
|
551
|
+
const etaResult = await updateSyncETA(this._cache.daemon_height, this.current_height || 0, this.last_eta_height, this.last_eta_timestamp, this.scan_settings_path);
|
|
552
|
+
this.last_eta_height = etaResult.last_height;
|
|
553
|
+
this.last_eta_timestamp = etaResult.last_timestamp;
|
|
554
|
+
}
|
|
512
555
|
for (const listener of this.notifyListeners) {
|
|
513
556
|
if (listener)
|
|
514
557
|
listener(params);
|
|
@@ -522,6 +565,8 @@ export class ScanCacheOpened {
|
|
|
522
565
|
primary_address: "",
|
|
523
566
|
};
|
|
524
567
|
worker = undefined;
|
|
568
|
+
last_eta_height = null;
|
|
569
|
+
last_eta_timestamp = null;
|
|
525
570
|
constructor(view_pair, wallet_route, wallet_name, wallet_slot, no_worker, masterCacheChanged, _start_height, scan_settings_path, pathPrefix, workerError) {
|
|
526
571
|
this.view_pair = view_pair;
|
|
527
572
|
this.wallet_route = wallet_route;
|