Compare commits

...

3 Commits

Author SHA1 Message Date
Ivan Vershigora
bf1517582d
REF: constant-time tx upsert in fetchTransactions mapping
The upsert-by-txid step linearly re-scanned the target cell for every
inserted transaction, which is quadratic when many transactions share
one address. Keep a per-cell txid -> position map instead.

Measured on a wallet emulating one address with 63,017 real
transactions (genesis address), median of 3 runs each on real chain
data: mapping went from 32.1s to 0.47s. In an isolated 60k-tx CPU
benchmark: 4.4s to 0.11s (the pre-refactor implementation took 16.6s
on the same benchmark).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:31:25 +01:00
Ivan Vershigora
55842b6f7f
OPS: remove duplicate @babel/preset-env from devDependencies
It is pinned in dependencies (needed by the --omit=dev iOS release
build); the second entry in devDependencies was ignored by npm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:30:12 +01:00
Ivan Vershigora
bb11ba87f4
REF: single-pass tx-to-address mapping in HD fetchTransactions (~8x faster)
Instead of re-deriving and re-scanning every transaction for every
address index (quadratic), build address->index lookup maps once and
do a single pass over fetched transactions. Behavior is unchanged;
covered by the new unit test which passes against both the old and
new implementation. Bench: 2000 txs x 120 addresses went from 92ms
to 11ms in node; the gap grows with wallet size on Hermes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:25:53 +01:00
4 changed files with 197 additions and 171 deletions

View File

@ -424,137 +424,96 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet {
// now, we need to put transactions in all relevant `cells` of internal hashmaps:
// this._txs_by_internal_index, this._txs_by_external_index & this._txs_by_payment_code_index
// instead of re-scanning every transaction for every address index (quadratic),
// we build address -> index lookup maps once and do a single pass over transactions
const externalIndexByAddress = new Map<string, number>();
for (let c = 0; c < next_free_address_index + this.gap_limit; c++) {
for (const tx of Object.values(txdatas)) {
for (const vin of tx.vin) {
if (vin.addresses && vin.addresses.indexOf(this._getExternalAddressByIndex(c)) !== -1) {
// this TX is related to our address
this._txs_by_external_index[c] = this._txs_by_external_index[c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_external_index[c].length; cc++) {
if (this._txs_by_external_index[c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_external_index[c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_external_index[c].push(clonedTx);
}
}
for (const vout of tx.vout) {
if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getExternalAddressByIndex(c)) !== -1) {
// this TX is related to our address
this._txs_by_external_index[c] = this._txs_by_external_index[c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_external_index[c].length; cc++) {
if (this._txs_by_external_index[c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_external_index[c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_external_index[c].push(clonedTx);
}
}
}
externalIndexByAddress.set(this._getExternalAddressByIndex(c), c);
}
const internalIndexByAddress = new Map<string, number>();
for (let c = 0; c < next_free_change_address_index + this.gap_limit; c++) {
for (const tx of Object.values(txdatas)) {
for (const vin of tx.vin) {
if (vin.addresses && vin.addresses.indexOf(this._getInternalAddressByIndex(c)) !== -1) {
// this TX is related to our address
this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) {
if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_internal_index[c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_internal_index[c].push(clonedTx);
}
}
for (const vout of tx.vout) {
if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getInternalAddressByIndex(c)) !== -1) {
// this TX is related to our address
this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) {
if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_internal_index[c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_internal_index[c].push(clonedTx);
}
}
}
internalIndexByAddress.set(this._getInternalAddressByIndex(c), c);
}
const paymentCodeIndexByAddress = new Map<string, { pc: string; c: number }>();
for (const pc of this._receive_payment_codes) {
for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) {
for (const tx of Object.values(txdatas)) {
// since we are iterating PCs who can pay us, we can completely ignore `tx.vin` and only iterate `tx.vout`
for (const vout of tx.vout) {
if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getBIP47AddressReceive(pc, c)) !== -1) {
// this TX is related to our address
this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {};
this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || [];
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
paymentCodeIndexByAddress.set(this._getBIP47AddressReceive(pc, c), { pc, c });
}
}
// trying to replace tx if it exists already (because it has lower confirmations, for example)
let replaced = false;
for (let cc = 0; cc < this._txs_by_payment_code_index[pc][c].length; cc++) {
if (this._txs_by_payment_code_index[pc][c][cc].txid === clonedTx.txid) {
replaced = true;
this._txs_by_payment_code_index[pc][c][cc] = clonedTx;
}
}
if (!replaced) this._txs_by_payment_code_index[pc][c].push(clonedTx);
}
}
// per-cell txid -> position lookup, so upserting does not linearly re-scan the cell
// for every transaction (which is quadratic when many transactions share one address)
const cellPositionsByTxid = new Map<Transaction[], Map<string, number>>();
const getCellPositions = (cell: Transaction[]): Map<string, number> => {
let positions = cellPositionsByTxid.get(cell);
if (!positions) {
positions = new Map();
for (let cc = 0; cc < cell.length; cc++) positions.set(cell[cc].txid, cc);
cellPositionsByTxid.set(cell, positions);
}
return positions;
};
for (const tx of Object.values(txdatas)) {
// collecting which of our address `cells` this transaction touches:
const externalCells = new Set<number>();
const internalCells = new Set<number>();
const paymentCodeCells = new Map<string, { pc: string; c: number }>();
const matchAddress = (address: string, isVout: boolean) => {
const externalIndex = externalIndexByAddress.get(address);
if (externalIndex !== undefined) externalCells.add(externalIndex);
const internalIndex = internalIndexByAddress.get(address);
if (internalIndex !== undefined) internalCells.add(internalIndex);
if (isVout) {
// since we are iterating PCs who can pay us, we can completely ignore `tx.vin` and only check `tx.vout`
const paymentCodeIndex = paymentCodeIndexByAddress.get(address);
if (paymentCodeIndex) paymentCodeCells.set(address, paymentCodeIndex);
}
};
for (const vin of tx.vin) {
for (const address of vin.addresses ?? []) matchAddress(address, false);
}
for (const vout of tx.vout) {
for (const address of vout.scriptPubKey.addresses ?? []) matchAddress(address, true);
}
if (externalCells.size === 0 && internalCells.size === 0 && paymentCodeCells.size === 0) continue;
// this TX is related to our address(es)
const upsertClone = (cell: Transaction[]) => {
const { vin: txVin, vout: txVout, ...txRest } = tx;
const clonedTx = {
...txRest,
inputs: txVin.slice(0),
outputs: txVout.slice(0),
timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */,
};
// trying to replace tx if it exists already (because it has lower confirmations, for example)
const positions = getCellPositions(cell);
const existingPosition = positions.get(clonedTx.txid);
if (existingPosition !== undefined) {
cell[existingPosition] = clonedTx;
} else {
positions.set(clonedTx.txid, cell.length);
cell.push(clonedTx);
}
};
for (const c of externalCells) {
this._txs_by_external_index[c] = this._txs_by_external_index[c] || [];
upsertClone(this._txs_by_external_index[c]);
}
for (const c of internalCells) {
this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || [];
upsertClone(this._txs_by_internal_index[c]);
}
for (const { pc, c } of paymentCodeCells.values()) {
this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {};
this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || [];
upsertClone(this._txs_by_payment_code_index[pc][c]);
}
}

47
package-lock.json generated
View File

@ -115,7 +115,6 @@
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
"@jest/reporters": "^27.5.1",
"@react-native/eslint-config": "^0.85.3",
@ -584,7 +583,6 @@
},
"node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
"version": "7.28.5",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
@ -599,7 +597,6 @@
},
"node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -613,7 +610,6 @@
},
"node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -629,7 +625,6 @@
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.3.tgz",
"integrity": "sha512-SRS46DFR4HqzUzCVgi90/xMoL+zeBDBvWdKYXSEzh79kXswNFEglUpMKxR04//dPqwYXWUBJ3mpUd933ru9Kmg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6",
@ -644,7 +639,6 @@
},
"node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
@ -660,7 +654,6 @@
},
"node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6",
@ -688,7 +681,6 @@
},
"node_modules/@babel/plugin-proposal-private-property-in-object": {
"version": "7.21.0-placeholder-for-preset-env.2",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -782,7 +774,6 @@
},
"node_modules/@babel/plugin-syntax-import-assertions": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
@ -796,7 +787,6 @@
},
"node_modules/@babel/plugin-syntax-import-attributes": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
@ -950,7 +940,6 @@
},
"node_modules/@babel/plugin-syntax-unicode-sets-regex": {
"version": "7.18.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.18.6",
@ -1008,7 +997,6 @@
},
"node_modules/@babel/plugin-transform-block-scoped-functions": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1049,7 +1037,6 @@
},
"node_modules/@babel/plugin-transform-class-static-block": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-create-class-features-plugin": "^7.28.6",
@ -1082,7 +1069,6 @@
},
"node_modules/@babel/plugin-transform-computed-properties": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6",
@ -1111,7 +1097,6 @@
},
"node_modules/@babel/plugin-transform-dotall-regex": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.28.5",
@ -1126,7 +1111,6 @@
},
"node_modules/@babel/plugin-transform-duplicate-keys": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1140,7 +1124,6 @@
},
"node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": {
"version": "7.29.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.28.5",
@ -1155,7 +1138,6 @@
},
"node_modules/@babel/plugin-transform-dynamic-import": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1169,7 +1151,6 @@
},
"node_modules/@babel/plugin-transform-explicit-resource-management": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6",
@ -1184,7 +1165,6 @@
},
"node_modules/@babel/plugin-transform-exponentiation-operator": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
@ -1198,7 +1178,6 @@
},
"node_modules/@babel/plugin-transform-export-namespace-from": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1240,7 +1219,6 @@
},
"node_modules/@babel/plugin-transform-function-name": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-compilation-targets": "^7.27.1",
@ -1256,7 +1234,6 @@
},
"node_modules/@babel/plugin-transform-json-strings": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
@ -1270,7 +1247,6 @@
},
"node_modules/@babel/plugin-transform-literals": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1284,7 +1260,6 @@
},
"node_modules/@babel/plugin-transform-logical-assignment-operators": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
@ -1298,7 +1273,6 @@
},
"node_modules/@babel/plugin-transform-member-expression-literals": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1312,7 +1286,6 @@
},
"node_modules/@babel/plugin-transform-modules-amd": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-transforms": "^7.27.1",
@ -1343,7 +1316,6 @@
"version": "7.29.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz",
"integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-transforms": "^7.28.6",
@ -1360,7 +1332,6 @@
},
"node_modules/@babel/plugin-transform-modules-umd": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-transforms": "^7.27.1",
@ -1389,7 +1360,6 @@
},
"node_modules/@babel/plugin-transform-new-target": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1416,7 +1386,6 @@
},
"node_modules/@babel/plugin-transform-numeric-separator": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
@ -1430,7 +1399,6 @@
},
"node_modules/@babel/plugin-transform-object-rest-spread": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-compilation-targets": "^7.28.6",
@ -1448,7 +1416,6 @@
},
"node_modules/@babel/plugin-transform-object-super": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
@ -1490,7 +1457,6 @@
},
"node_modules/@babel/plugin-transform-parameters": {
"version": "7.27.7",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1533,7 +1499,6 @@
},
"node_modules/@babel/plugin-transform-property-literals": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1616,7 +1581,6 @@
},
"node_modules/@babel/plugin-transform-regexp-modifiers": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.28.5",
@ -1631,7 +1595,6 @@
},
"node_modules/@babel/plugin-transform-reserved-words": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1676,7 +1639,6 @@
},
"node_modules/@babel/plugin-transform-spread": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6",
@ -1691,7 +1653,6 @@
},
"node_modules/@babel/plugin-transform-sticky-regex": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1718,7 +1679,6 @@
},
"node_modules/@babel/plugin-transform-typeof-symbol": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1749,7 +1709,6 @@
},
"node_modules/@babel/plugin-transform-unicode-escapes": {
"version": "7.27.1",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
@ -1763,7 +1722,6 @@
},
"node_modules/@babel/plugin-transform-unicode-property-regex": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.28.5",
@ -1792,7 +1750,6 @@
},
"node_modules/@babel/plugin-transform-unicode-sets-regex": {
"version": "7.28.6",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.28.5",
@ -1809,7 +1766,6 @@
"version": "7.29.5",
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.5.tgz",
"integrity": "sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.29.3",
@ -1895,7 +1851,6 @@
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz",
"integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-define-polyfill-provider": "^0.6.8",
@ -1907,7 +1862,6 @@
},
"node_modules/@babel/preset-modules": {
"version": "0.1.6-no-external-plugins",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.0.0",
@ -9833,7 +9787,6 @@
},
"node_modules/esutils": {
"version": "2.0.3",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"

View File

@ -16,7 +16,6 @@
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
"@jest/reporters": "^27.5.1",
"@react-native/eslint-config": "^0.85.3",

View File

@ -0,0 +1,115 @@
import assert from 'assert';
import { HDSegwitBech32Wallet } from '../../class/wallets/hd-segwit-bech32-wallet';
const mockState: { histories: Record<string, any>; txdata: Record<string, any> } = { histories: {}, txdata: {} };
jest.mock('../../blue_modules/BlueElectrum', () => {
return {
multiGetHistoryByAddress: jest.fn(async (addresses: string[]) => {
const ret: Record<string, any> = {};
for (const addr of addresses) {
if (mockState.histories[addr]) ret[addr] = mockState.histories[addr];
}
return ret;
}),
multiGetTransactionByTxid: jest.fn(async (txids: string[]) => {
const ret: Record<string, any> = {};
for (const txid of txids) {
if (mockState.txdata[txid]) ret[txid] = JSON.parse(JSON.stringify(mockState.txdata[txid]));
}
return ret;
}),
};
});
describe('AbstractHDElectrumWallet.fetchTransactions() tx-to-cell mapping', () => {
it('puts transactions into correct external & internal cells', async () => {
const hd = new HDSegwitBech32Wallet();
hd.setSecret('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');
assert.ok(hd.validateMnemonic());
const e0 = hd._getExternalAddressByIndex(0);
const e1 = hd._getExternalAddressByIndex(1);
const i0 = hd._getInternalAddressByIndex(0);
// fetchTransactions assumes fetchBalance already populated balance cells
for (let c = 0; c < hd.next_free_address_index + hd.gap_limit + 1; c++) {
// @ts-ignore accessing internals for test setup
hd._balances_by_external_index[c] = { c: 0, u: 0 };
// @ts-ignore accessing internals for test setup
hd._balances_by_internal_index[c] = { c: 0, u: 0 };
}
// tx1: receive 100000 sats to e0 (funded by a foreign tx we don't have data for)
// tx2: spend from e0, sending to a foreign address with change to i0 and a small amount to e1
mockState.histories = {
[e0]: [
{ tx_hash: 'tx1', height: 100 },
{ tx_hash: 'tx2', height: 101 },
],
[e1]: [{ tx_hash: 'tx2', height: 101 }],
[i0]: [{ tx_hash: 'tx2', height: 101 }],
};
mockState.txdata = {
tx1: {
txid: 'tx1',
confirmations: 10,
blocktime: 1700000000,
vin: [{ txid: 'aa'.repeat(32), vout: 0 }],
vout: [{ value: 0.001, n: 0, scriptPubKey: { addresses: [e0] } }],
},
tx2: {
txid: 'tx2',
confirmations: 9,
blocktime: 1700000100,
vin: [{ txid: 'tx1', vout: 0 }],
vout: [
{ value: 0.0005, n: 0, scriptPubKey: { addresses: ['bc1qforeignforeignforeignforeignforeign00'] } },
{ value: 0.0003, n: 1, scriptPubKey: { addresses: [i0] } },
{ value: 0.0001, n: 2, scriptPubKey: { addresses: [e1] } },
],
},
};
await hd.fetchTransactions();
// external cell 0: tx1 (vout match) and tx2 (vin spends e0's utxo), exactly one entry each
const ext0 = hd._txs_by_external_index[0];
assert.deepStrictEqual(ext0.map(tx => tx.txid).sort(), ['tx1', 'tx2']);
// external cell 1: only tx2 (vout match)
const ext1 = hd._txs_by_external_index[1];
assert.deepStrictEqual(
ext1.map(tx => tx.txid),
['tx2'],
);
// internal cell 0: only tx2 (change output)
const int0 = hd._txs_by_internal_index[0];
assert.deepStrictEqual(
int0.map(tx => tx.txid),
['tx2'],
);
// untouched cells stay empty
assert.strictEqual((hd._txs_by_external_index[2] || []).length, 0);
assert.strictEqual((hd._txs_by_internal_index[1] || []).length, 0);
// clones must carry inputs/outputs/timestamp, with vin enriched from the prev tx (tx1 vout0 -> e0)
const tx2clone = ext0.find(tx => tx.txid === 'tx2') as any;
assert.ok(Array.isArray(tx2clone.inputs) && Array.isArray(tx2clone.outputs));
assert.strictEqual(tx2clone.timestamp, 1700000100);
assert.deepStrictEqual(tx2clone.inputs[0].addresses, [e0]);
assert.strictEqual(tx2clone.inputs[0].value, 0.001);
// re-fetching must not duplicate entries (upsert by txid)
await hd.fetchTransactions();
assert.deepStrictEqual(hd._txs_by_external_index[0].map(tx => tx.txid).sort(), ['tx1', 'tx2']);
assert.strictEqual(hd._txs_by_internal_index[0].length, 1);
// getTransactions() must dedupe across cells: tx2 lives in 3 cells but is one tx
const all = hd.getTransactions();
assert.deepStrictEqual(all.map(tx => tx.txid).sort(), ['tx1', 'tx2']);
});
});