@@ -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
4248const 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 /**
0 commit comments