Skip to content

Commit 828a261

Browse files
authored
Merge pull request #19 from bitcoinerlab/fix-getDescriptor
Refactor push/addTransaction conflict handling and fix getDescriptor
2 parents 35e76e7 + 3ab8006 commit 828a261

File tree

5 files changed

+190
-33
lines changed

5 files changed

+190
-33
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@bitcoinerlab/discovery",
33
"description": "A TypeScript library for retrieving Bitcoin funds from ranged descriptors, leveraging @bitcoinerlab/explorer for standardized access to multiple blockchain explorers.",
44
"homepage": "https://github.com/bitcoinerlab/discovery",
5-
"version": "1.4.0",
5+
"version": "1.4.1",
66
"author": "Jose-Luis Landabaso",
77
"license": "MIT",
88
"prettier": "@bitcoinerlab/configs/prettierConfig.json",

src/discovery.ts

Lines changed: 144 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,14 @@ import {
3636
TxHex,
3737
TxAttribution,
3838
TxWithOrder,
39-
TxoMap
39+
TxoMap,
40+
Txo
4041
} from './types';
42+
type Conflict = {
43+
descriptor: Descriptor;
44+
txo: Txo;
45+
index?: number;
46+
};
4147

4248
const now = () => Math.floor(Date.now() / 1000);
4349

@@ -1190,7 +1196,7 @@ export function DiscoveryFactory(
11901196
const split = txo.split(':');
11911197
if (utxo && split.length !== 2)
11921198
throw new Error(`Error: invalid utxo: ${utxo}`);
1193-
if (!utxo && split.length !== 2 && split.length !== 3)
1199+
if (!utxo && split.length !== 2 && split.length !== 4)
11941200
throw new Error(`Error: invalid txo: ${txo}`);
11951201
const txId = split[0];
11961202
if (!txId) throw new Error(`Error: invalid txo: ${txo}`);
@@ -1210,9 +1216,12 @@ export function DiscoveryFactory(
12101216
index?: number;
12111217
}
12121218
| undefined;
1213-
const { txoMap } = this.getUtxosAndBalance({ descriptors });
1214-
const indexedDescriptor = txoMap[txo];
1219+
const { utxos, txoMap } = this.getUtxosAndBalance({ descriptors });
1220+
const txoMapKey: Txo = `${txId}:${vout}`; //normalizes txos with 4 parts
1221+
const indexedDescriptor = txoMap[txoMapKey];
12151222
if (indexedDescriptor) {
1223+
if (utxo && !utxos.find(currentUtxo => currentUtxo === utxo))
1224+
return undefined;
12161225
const splitTxo = (str: string): [string, string] => {
12171226
const lastIndex = str.lastIndexOf('~');
12181227
if (lastIndex === -1)
@@ -1233,17 +1242,32 @@ export function DiscoveryFactory(
12331242
}
12341243

12351244
/**
1236-
* Pushes a transaction to the network and updates the internal state
1237-
* accordingly. This function ensures that the transaction is pushed,
1238-
* verifies its presence in the mempool, and updates the internal
1239-
* `discoveryData` to include the new transaction.
1245+
* Pushes a transaction to the network and updates the internal state.
12401246
*
1241-
* The `gapLimit` parameter is essential for managing descriptor discovery.
1242-
* When pushing a transaction, there is a possibility of receiving new funds
1243-
* as change. If the range for that index does not exist yet, the `gapLimit`
1244-
* helps to update the descriptor corresponding to a new UTXO for new
1245-
* indices within the gap limit.
1247+
* This function first broadcasts the transaction using the configured explorer.
1248+
* It then attempts to update the internal `discoveryData` by calling
1249+
* `addTransaction`.
1250+
*
1251+
* If `addTransaction` reports that one or more inputs of the pushed transaction
1252+
* were already considered spent by other transactions in the library's records
1253+
* (e.g., in an RBF scenario or a double-spend attempt already known to the
1254+
* library), `push` will automatically attempt to synchronize the state. It
1255+
* does this by calling `fetch` on all unique descriptors associated with the
1256+
* conflicting input(s). This helps ensure the library's state reflects the
1257+
* most current information from the blockchain/mempool regarding these conflicts.
1258+
*
1259+
* The `gapLimit` parameter is used both when `addTransaction` is called and
1260+
* during any subsequent automatic `fetch` operation triggered by conflicts.
1261+
* It helps discover new outputs (e.g., change addresses) that might become
1262+
* active due to the transaction.
12461263
*
1264+
* Note: The success of broadcasting the transaction via `explorer.push(txHex)`
1265+
* depends on the network and node policies. Even if broadcast is successful,
1266+
* the transaction might not be immediately visible in the mempool or might be
1267+
* replaced. A warning is logged if the transaction is not found in the
1268+
* mempool shortly after being pushed. The final state in the library will
1269+
* reflect the outcome of the internal `addTransaction` call and any
1270+
* automatic synchronization that occurred.
12471271
*/
12481272
async push({
12491273
txHex,
@@ -1265,24 +1289,52 @@ export function DiscoveryFactory(
12651289
await explorer.push(txHex);
12661290

12671291
//Now, make sure it made it to the mempool:
1268-
let found = false;
1292+
let foundInMempool = false;
12691293
for (let i = 0; i < DETECT_RETRY_MAX; i++) {
12701294
if (await explorer.fetchTx(txId)) {
1271-
found = true;
1295+
foundInMempool = true;
12721296
break;
12731297
}
12741298
await new Promise(resolve => setTimeout(resolve, DETECTION_INTERVAL));
12751299
}
12761300

12771301
const txData = { irreversible: false, blockHeight: 0, txHex };
1278-
this.addTransaction({ txData, gapLimit });
1279-
if (found === false)
1302+
const addResult = this.addTransaction({ txData, gapLimit });
1303+
1304+
let syncPerformed = false;
1305+
if (
1306+
!addResult.success &&
1307+
addResult.reason === 'INPUTS_ALREADY_SPENT' &&
1308+
addResult.conflicts.length > 0
1309+
) {
1310+
// A conflict occurred: one or more inputs of the pushed transaction were already
1311+
// considered spent by other transactions in our records.
1312+
// Fetch all unique descriptors that hold the conflicting TXOs to sync their state.
1313+
const uniqueDescriptorsToSync = Array.from(
1314+
new Set(addResult.conflicts.map(c => c.descriptor))
1315+
);
1316+
if (uniqueDescriptorsToSync.length > 0) {
1317+
await this.fetch({ descriptors: uniqueDescriptorsToSync, gapLimit });
1318+
syncPerformed = true;
1319+
}
1320+
// After fetching, the state for the conflicting descriptors is updated.
1321+
// The originally pushed transaction might or might not be the one that "won".
1322+
}
1323+
1324+
if (syncPerformed) {
1325+
console.warn(
1326+
`txId ${txId}: Input conflict(s) detected; state synchronization was performed for affected descriptors. The library state reflects this outcome.`
1327+
);
1328+
}
1329+
1330+
if (!foundInMempool) {
12801331
console.warn(
1281-
`txId ${txId} was pushed. However, it was then not found in the mempool. It has been set as part of the discoveryData anyway.`
1332+
`txId ${txId}: Pushed transaction was not found in the mempool immediately after broadcasting.`
12821333
);
1334+
}
12831335
}
12841336

1285-
/*
1337+
/**
12861338
* Given a transaction it updates the internal `discoveryData` state to
12871339
* include it.
12881340
*
@@ -1310,6 +1362,33 @@ export function DiscoveryFactory(
13101362
* adding new funds as change (for example). If the range for that index
13111363
* does not exist yet, the `gapLimit` helps to update the descriptor
13121364
* corresponding to a new UTXO for new indices within the gap limit.
1365+
*
1366+
* This function updates the internal `discoveryData` state to include the
1367+
* provided transaction, but only if it doesn't attempt to spend outputs
1368+
* already considered spent by the library.
1369+
*
1370+
* It first checks all inputs of the transaction. If any input corresponds to
1371+
* a `txo` (a previously known output) that is not a current `utxo`
1372+
* (i.e., it's already considered spent by another transaction in the
1373+
* library's records), this function will not modify the library state.
1374+
* Instead, it will return an object detailing all such conflicting inputs.
1375+
* This allows the caller (e.g., the `push` method) to handle these
1376+
* conflicts, for instance, by re-fetching the state of all affected descriptors.
1377+
*
1378+
* If no such input conflicts are found, the transaction is processed:
1379+
* its details are added to the `txMap`, and relevant `txId`s are associated
1380+
* with the `OutputData` of both its inputs (if owned) and outputs (if owned).
1381+
*
1382+
* For other types of errors (e.g., invalid input data), it may still throw.
1383+
*
1384+
* Refer to the `push` method's documentation for how it utilizes this
1385+
* return status for automatic state synchronization.
1386+
*
1387+
* @returns An object indicating the outcome:
1388+
* - `{ success: true }` if the transaction was added without conflicts.
1389+
* - `{ success: false; reason: 'INPUTS_ALREADY_SPENT'; conflicts: Array<{ descriptor: Descriptor; txo: Utxo; index?: number }> }`
1390+
* if one or more inputs of the transaction were already considered spent.
1391+
* `conflicts` contains an array of all such detected conflicts.
13131392
*/
13141393
addTransaction({
13151394
txData,
@@ -1324,13 +1403,50 @@ export function DiscoveryFactory(
13241403
* The gap limit for descriptor discovery. Defaults to 20.
13251404
*/
13261405
gapLimit?: number;
1327-
}): void {
1406+
}):
1407+
| { success: true }
1408+
| {
1409+
success: false;
1410+
reason: 'INPUTS_ALREADY_SPENT';
1411+
conflicts: Array<Conflict>;
1412+
} {
13281413
const txHex = txData.txHex;
13291414
if (!txHex)
13301415
throw new Error('txData must contain complete txHex information');
13311416
const { tx, txId } = this.#derivers.transactionFromHex(txHex);
13321417
const networkId = getNetworkId(network);
13331418

1419+
const conflicts: Array<Conflict> = [];
1420+
1421+
// First pass: Check all inputs for conflicts without modifying state yet.
1422+
for (let vin = 0; vin < tx.ins.length; vin++) {
1423+
const input = tx.ins[vin];
1424+
if (!input) throw new Error(`Error: invalid input for ${txId}:${vin}`);
1425+
const prevTxId = Buffer.from(input.hash).reverse().toString('hex');
1426+
const prevVout = input.index;
1427+
const prevUtxo: Utxo = `${prevTxId}:${prevVout}`;
1428+
1429+
const isSpendingKnownUtxo = this.getDescriptor({ utxo: prevUtxo });
1430+
if (!isSpendingKnownUtxo) {
1431+
// Not spending a known UTXO, check if it's spending a known TXO (already spent)
1432+
const txoDescriptorInfo = this.getDescriptor({ txo: prevUtxo });
1433+
if (txoDescriptorInfo) {
1434+
const conflict: Conflict = {
1435+
descriptor: txoDescriptorInfo.descriptor,
1436+
txo: prevUtxo
1437+
};
1438+
if (txoDescriptorInfo.index !== undefined)
1439+
conflict.index = txoDescriptorInfo.index;
1440+
conflicts.push(conflict);
1441+
}
1442+
}
1443+
}
1444+
1445+
if (conflicts.length > 0) {
1446+
return { success: false, reason: 'INPUTS_ALREADY_SPENT', conflicts };
1447+
}
1448+
1449+
// Second pass: No conflicts found, proceed to update discoveryData.
13341450
this.#discoveryData = produce(this.#discoveryData, discoveryData => {
13351451
const txMap = discoveryData[networkId].txMap;
13361452
const update = (descriptor: Descriptor, index: DescriptorIndex) => {
@@ -1348,29 +1464,28 @@ export function DiscoveryFactory(
13481464
if (!txMap[txId]) txMap[txId] = txData; //Only add it once
13491465
};
13501466

1351-
// search for inputs
1467+
// Process inputs (we know they are valid UTXOs or external)
13521468
for (let vin = 0; vin < tx.ins.length; vin++) {
13531469
const input = tx.ins[vin];
13541470
if (!input)
13551471
throw new Error(`Error: invalid input for ${txId}:${vin}`);
13561472
//Note we create a new Buffer since reverse() mutates the Buffer
13571473
const prevTxId = Buffer.from(input.hash).reverse().toString('hex');
1358-
const prevVout = input.index;
1474+
const prevVout = input!.index;
13591475
const prevUtxo: Utxo = `${prevTxId}:${prevVout}`;
13601476
const extendedDescriptor = this.getDescriptor({ utxo: prevUtxo });
1361-
if (extendedDescriptor)
1477+
if (extendedDescriptor) {
13621478
//This means this tx is spending an utxo tracked by this discovery instance
13631479
update(
13641480
extendedDescriptor.descriptor,
13651481
extendedDescriptor.index === undefined
13661482
? 'non-ranged'
13671483
: extendedDescriptor.index
13681484
);
1369-
else if (this.getDescriptor({ txo: prevUtxo }))
1370-
throw new Error(`Tx ${txId} was already spent.`);
1485+
}
13711486
}
13721487

1373-
// search for outputs
1488+
// Process outputs
13741489
for (let vout = 0; vout < tx.outs.length; vout++) {
13751490
const nextScriptPubKey = tx.outs[vout]?.script;
13761491
if (!nextScriptPubKey)
@@ -1380,12 +1495,14 @@ export function DiscoveryFactory(
13801495
scriptPubKey: nextScriptPubKey,
13811496
gapLimit
13821497
});
1383-
if (descriptorWithIndex)
1498+
if (descriptorWithIndex) {
13841499
//This means this tx is sending funds to a scriptPubKey tracked by
13851500
//this discovery instance
13861501
update(descriptorWithIndex.descriptor, descriptorWithIndex.index);
1502+
}
13871503
}
13881504
});
1505+
return { success: true };
13891506
}
13901507

13911508
/**

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export type IndexedDescriptor = string;
106106
* prevtxId:vout. Hovewer, we use a different type name to denote we're dealing
107107
* here with tx outputs that may have been spent or not
108108
*/
109-
type Txo = string;
109+
export type Txo = string;
110110
export type TxoMap = Record<Txo, IndexedDescriptor>;
111111

112112
/**

test/integration/regtest.test.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
//TODO: test the rest of methods
1515
import { vaultsTests } from './vaults';
1616
import { RegtestUtils } from 'regtest-client';
17-
import { Psbt, networks } from 'bitcoinjs-lib';
17+
import { Psbt, networks, Transaction } from 'bitcoinjs-lib';
1818
import * as secp256k1 from '@bitcoinerlab/secp256k1';
1919
import * as descriptors from '@bitcoinerlab/descriptors';
2020
import { mnemonicToSeedSync } from 'bip39';
@@ -503,7 +503,8 @@ describe('Discovery on regtest', () => {
503503
const address = output.getAddress();
504504

505505
//Create a new utxo received in nextIndex
506-
await regtestUtils.faucet(address, 100000);
506+
const faucetResult = await regtestUtils.faucet(address, 100000);
507+
const trackedUtxoString = `${faucetResult.txId}:${faucetResult.vout}`;
507508
for (let i = 0; i < 10; i++) {
508509
await discovery.fetch({ descriptor });
509510
if (discovery.getUtxos({ descriptor }).length > initialNUtxos) break;
@@ -516,6 +517,16 @@ describe('Discovery on regtest', () => {
516517

517518
expect(nextNextIndex).toBe(nextIndex + 1);
518519

520+
// Test getDescriptor for the UNSPENT trackedUtxoString
521+
let descriptorInfoUnspent = discovery.getDescriptor({
522+
utxo: trackedUtxoString
523+
});
524+
expect(descriptorInfoUnspent).toEqual({ descriptor, index: nextIndex });
525+
descriptorInfoUnspent = discovery.getDescriptor({
526+
txo: trackedUtxoString
527+
});
528+
expect(descriptorInfoUnspent).toEqual({ descriptor, index: nextIndex });
529+
519530
const { utxos, balance } = discovery.getUtxosAndBalance({ descriptor });
520531

521532
//Create a new tx that spends all utxos and sends them to nextNextIndex
@@ -557,6 +568,22 @@ describe('Discovery on regtest', () => {
557568
const txHex = psbt.extractTransaction(true).toHex();
558569
await discovery.push({ txHex });
559570
const nextNextNextIndex = discovery.getNextIndex({ descriptor });
571+
const spendingTx = Transaction.fromHex(txHex);
572+
const spendingTxId = spendingTx.getId();
573+
let spendingVin = -1;
574+
for (const [i, input] of spendingTx.ins.entries()) {
575+
const prevTxId = Buffer.from(input.hash).reverse().toString('hex');
576+
if (
577+
prevTxId === faucetResult.txId &&
578+
input.index === faucetResult.vout
579+
) {
580+
spendingVin = i;
581+
break;
582+
}
583+
}
584+
expect(spendingVin).not.toBe(-1); // Ensure we found the spending input
585+
const fourPartTxoString = `${trackedUtxoString}:${spendingTxId}:${spendingVin}`;
586+
560587
expect(nextNextNextIndex).toBe(nextNextIndex + 1);
561588
expect(discovery.getUtxosAndBalance({ descriptor }).balance).toBe(
562589
finalBalance
@@ -566,6 +593,19 @@ describe('Discovery on regtest', () => {
566593
//Now really fetch it and make sure the push routine already set all the
567594
//relevant data, even if a fetch was not involved:
568595
await discovery.fetch({ descriptor });
596+
597+
// Test getDescriptor for the SPENT trackedUtxoString
598+
let descriptorInfoSpent = discovery.getDescriptor({
599+
utxo: trackedUtxoString
600+
});
601+
expect(descriptorInfoSpent).toBeUndefined(); // Should be undefined as it's spent
602+
descriptorInfoSpent = discovery.getDescriptor({
603+
txo: trackedUtxoString
604+
});
605+
expect(descriptorInfoSpent).toEqual({ descriptor, index: nextIndex });
606+
// Test with 4-part txo string for the spent output
607+
descriptorInfoSpent = discovery.getDescriptor({ txo: fourPartTxoString });
608+
expect(descriptorInfoSpent).toEqual({ descriptor, index: nextIndex });
569609
expect(nextNextNextIndex).toBe(nextNextIndex + 1);
570610
expect(discovery.getUtxosAndBalance({ descriptor }).balance).toBe(
571611
finalBalance

0 commit comments

Comments
 (0)