@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.
Files changed (51) hide show
  1. package/dist/api.d.ts +5 -2
  2. package/dist/api.js +5 -1
  3. package/dist/io/atomicWrite.js +3 -1
  4. package/dist/io/indexedDB.js +7 -2
  5. package/dist/io/logging.d.ts +40 -0
  6. package/dist/io/logging.js +172 -0
  7. package/dist/keypairs-seeds/keypairs.d.ts +21 -0
  8. package/dist/keypairs-seeds/keypairs.js +21 -0
  9. package/dist/node-interaction/binaryEndpoints.d.ts +31 -2
  10. package/dist/node-interaction/binaryEndpoints.js +61 -43
  11. package/dist/node-interaction/jsonEndpoints.d.ts +257 -130
  12. package/dist/node-interaction/jsonEndpoints.js +159 -119
  13. package/dist/node-interaction/nodeUrl.d.ts +21 -6
  14. package/dist/node-interaction/nodeUrl.js +50 -8
  15. package/dist/scanning-syncing/backgroundWorker.d.ts +10 -6
  16. package/dist/scanning-syncing/backgroundWorker.js +88 -39
  17. package/dist/scanning-syncing/blocksbuffer/blocksBufferFetchLoop.d.ts +44 -0
  18. package/dist/scanning-syncing/blocksbuffer/blocksBufferFetchLoop.js +355 -0
  19. package/dist/scanning-syncing/blocksbuffer/blocksbufferCoordination.d.ts +15 -0
  20. package/dist/scanning-syncing/blocksbuffer/blocksbufferCoordination.js +28 -0
  21. package/dist/scanning-syncing/{scanresult → blocksbuffer}/getBlocksbinBuffer.d.ts +5 -5
  22. package/dist/scanning-syncing/{scanresult → blocksbuffer}/getBlocksbinBuffer.js +7 -5
  23. package/dist/scanning-syncing/connectionStatus.d.ts +33 -9
  24. package/dist/scanning-syncing/connectionStatus.js +89 -19
  25. package/dist/scanning-syncing/scanSettings.d.ts +21 -0
  26. package/dist/scanning-syncing/scanSettings.js +29 -0
  27. package/dist/scanning-syncing/scanresult/reorg.d.ts +0 -11
  28. package/dist/scanning-syncing/scanresult/reorg.js +39 -16
  29. package/dist/scanning-syncing/scanresult/scanCache.d.ts +16 -2
  30. package/dist/scanning-syncing/scanresult/scanCache.js +65 -5
  31. package/dist/scanning-syncing/scanresult/scanCacheOpened.d.ts +18 -0
  32. package/dist/scanning-syncing/scanresult/scanCacheOpened.js +56 -11
  33. package/dist/scanning-syncing/scanresult/scanCoordination.d.ts +127 -0
  34. package/dist/scanning-syncing/scanresult/scanCoordination.js +602 -0
  35. package/dist/scanning-syncing/scanresult/scanLoop.d.ts +20 -0
  36. package/dist/scanning-syncing/scanresult/scanLoop.js +116 -0
  37. package/dist/scanning-syncing/scanresult/scanResult.d.ts +24 -3
  38. package/dist/scanning-syncing/scanresult/scanResult.js +159 -97
  39. package/dist/scanning-syncing/worker-entrypoints/worker.js +1 -1
  40. package/dist/scanning-syncing/worker-mains/coordinator-main.d.ts +1 -0
  41. package/dist/scanning-syncing/worker-mains/coordinator-main.js +24 -0
  42. package/dist/scanning-syncing/worker-mains/cpubound-main.d.ts +5 -0
  43. package/dist/scanning-syncing/worker-mains/cpubound-main.js +122 -0
  44. package/dist/scanning-syncing/worker-mains/worker.d.ts +1 -1
  45. package/dist/scanning-syncing/worker-mains/worker.js +62 -6
  46. package/dist/viewpair/ViewPair.d.ts +30 -7
  47. package/dist/viewpair/ViewPair.js +56 -118
  48. package/dist/wasm-processing/wasmFile.js +1 -1
  49. package/package.json +22 -24
  50. package/dist/io/extension.d.ts +0 -18
  51. 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 connectionStatusFilePath(scan_settings_path) {
5
- if (!scan_settings_path)
6
- scan_settings_path = SCAN_SETTINGS_STORE_NAME_DEFAULT;
7
- return `${DEFAULT_CONNECTION_STATUS_PREFIX}${scan_settings_path}`;
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(status, node_url, bytes_read = 0, scan_settings_path) {
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
- const connectionStatus = await readConnectionStatusDefaultLocation(scan_settings_path);
31
- const cb = await writeCB(connectionStatus);
32
- if (typeof cb === "undefined")
33
- return;
34
- return await writeConnectionStatusFile(cb.last_packet.status, cb.last_packet.node_url, cb.last_packet.bytes_read, scan_settings_path);
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
- const reorg_info = {
14
- split_height,
15
- removed_outputs: [],
16
- reverted_spends: [],
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({ old_output_state, key_image });
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
- delete cache.outputs[id].spent_relative_index;
43
- delete cache.outputs[id].spent_in_tx_hash;
44
- delete cache.outputs[id].spent_block_height;
45
- delete cache.outputs[id].spent_block_timestamp;
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 { ReorgInfo } from "./reorg";
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
- last.end = Math.max(last.end, curr.end);
93
- last.block_hashes = curr.block_hashes;
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
- console.log("Scan stopped. node might be offline. Connection Refused");
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
- console.log("Scan was aborted.");
173
+ log("handleScanError", "Scan was aborted.");
117
174
  return;
118
175
  }
119
176
  else {
120
- console.log(error, "\n, scanWithCache in scanning-syncing/scanWithCache.ts`");
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 preparedInputs = [];
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
- preparedInputs.push(prepareInput(node, distibution, input));
200
- }
201
- const inputs = [];
202
- for (const preparedInput of preparedInputs) {
203
- // 6. make input: combine output with sampled and verified unlocked decoys
204
- const input = node.makeInput(preparedInput.input, preparedInput.sample.candidates, await preparedInput.outsResponse);
205
- inputs.push(input);
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) => b.amount > a.amount ? -1 : b.amount < a.amount ? 1 : 0)[0];
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;