Skip to content

Undetected double-spent #1740

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
ErikDeSmedt opened this issue Nov 21, 2024 · 10 comments · Fixed by #1839 or #1857
Closed

Undetected double-spent #1740

ErikDeSmedt opened this issue Nov 21, 2024 · 10 comments · Fixed by #1839 or #1857
Assignees
Labels
bug Something isn't working
Milestone

Comments

@ErikDeSmedt
Copy link

Describe the bug

  1. Alice has an empty wallet
  2. Alice receives a transaction (called tx1a) from Bob and observes it in the mempool.
  3. Bob double-spends tx1a creating tx1b
  4. A block is mined containing tx1b
  5. Alice observes tx1b in a block.

Alice will still consider tx1a as a pending transaction.

To Reproduce
See this https://github.com/ErikDeSmedt/bdk-gists/blob/master/tests/wallet.rs

Expected behavior

I would expect that Alice her wallet wouldn't use tx1a anymore. The output

  • should not be used for coin-selection
  • should not be part of the balance (untrusted_pending)

Build environment

  • BDK tag/commit: 1.0.0-beta.5
  • OS+version: debian
  • Rust/Cargo version: cargo 1.79.0 (ffa9cf99a 2024-06-03)

Additional context

I discovered this bug in a test-case where the entire wallet was unusable.

@ErikDeSmedt ErikDeSmedt added the bug Something isn't working label Nov 21, 2024
@notmandatory notmandatory moved this to Todo in BDK Wallet Nov 21, 2024
@notmandatory notmandatory modified the milestone: 1.0.0-beta Nov 21, 2024
@ValuedMammal
Copy link
Collaborator

tx1b is not discovered on the second sync because it's not "relevant" to alice's wallet, so you'd have to manually insert it similar to tx1a for it to be seen as conflicting (I wonder if this situation is alleviated by using compact block filters).

@ErikDeSmedt
Copy link
Author

I've edited the script and applied tx1b manually.

Using wallet.apply_unconfirmed_transaction([tx1b, 101)] doesn't change the state of the wallet. The apply_unconfirmed_transaction also considers tx1b as not "relevant".

Is there any method to remove tx1b from the wallet?

PS: This is related to bitcoindevkit/bdk_wallet#41. I tried to explicitly cancel the transaction as a work-around

@ValuedMammal
Copy link
Collaborator

ValuedMammal commented Nov 28, 2024

Thanks for sharing the example code. You're right that apply_unconfirmed_txs won't work in the case of tx1b. I think the way to do this currently is to apply an update to alice's wallet containing tx1b with a later timestamp.

    // We also add tx1b to the wallet
    let tx_update = TxUpdate {
        txs: vec![std::sync::Arc::new(tx1b.clone())],
        ..Default::default()
    };
    let update = bdk_wallet::Update {
        tx_update,
        ..Default::default()
    };
    let _ = alice.apply_update_at(update, Some(101)).unwrap();
    
    println!("After applying tx1b");
    println!("- alice.list_unspent(): {:?}", alice.list_unspent().map(|o| o.outpoint).collect::<Vec<_>>());
    println!("- alice.list_transaction(): {:?}", alice.transactions().map(|t| t.tx_node.txid).collect::<Vec<_>>());
    println!("- alice.list_transaction(): {:?}", alice.transactions().map(|t| t.chain_position).collect::<Vec<_>>());
    println!("- alice.balance: {:?}", alice.balance());

    // I expect list_unspent to be empty.
    // (tx1a) was double-spent
    assert_eq!(alice.list_unspent().collect::<Vec<_>>(), vec![]);
    assert_eq!(alice.transactions().collect::<Vec<_>>().len(), 1);
    let tx = alice.transactions().next().unwrap().tx_node;
    assert_eq!(tx.txid, tx1b.compute_txid());

@ValuedMammal
Copy link
Collaborator

ValuedMammal commented Nov 28, 2024

I'm not sure if that's the ideal solution long term, depending on the use case. One thing that's unclear from the example is whether alice and Bitcoin Core represent distinct entities or if there's reason to think alice should be aware of the other wallet. If so, I can imagine a feature that would let you add extra keychains to the wallet for the purpose of watching other transactions.

I agree that cancel_tx is under-powered and probably requires more engineering of the internals to make sense.

Also I seem to recall that doing a sync via electrum or esplora would allow you to watch for the status of a txid of interest, in this case the unconfirmed tx1a. We have some example code demonstrating this https://github.com/bitcoindevkit/bdk/tree/master/example-crates/example_esplora

*digging deeper it appears for esplora you would actually watch for the spend status of an outpoint of interest, in this case unspent[0]

@ErikDeSmedt
Copy link
Author

I discovered the issue when testing the unilateral exit procedure of our Ark implementation. This issue contains a write-up of what the issue is and how it affects us.

However, to my understanding the issue is broader.
If Alice receives a transaction tx1 which is double-spent by an external actor.

  • Alice will always have that transaction tx1 in her wallet.
  • Alice has no good method to get rid of that Transaction
  • If she makes a payment that combines an output from tx1 and a utxo from her own wallet tx2 will be stuck in the wallet. This will actually lock funds that Alice owns

@notmandatory
Copy link
Member

It seems we may need to monitor during a sync/scan not just the histories of our wallet's descriptors SPKs but also the UTXOs that are used as inputs to one of our unconfirmed transactions. If we knew that a UTXO was now invalid (ie. spent and confirmed in a different TX) then we could mark our TX as "double spent" or something like that so they could be filtered out. Have you seen bitcoindevkit/bdk_wallet#40? the redesign that @LLFourn has in mind should probably take into account this situation from also.

One question, do you know how other wallets handle this situation? If you setup the same situation in Core's wallet or in Sparrow do you know how they handle it?

@stevenroose
Copy link
Contributor

I have no idea what other wallets do, I'm sorry. I'm not sure many wallets allow foreign inputs. (And we are eternally grateful that BDK does!)

@evanlinjin
Copy link
Member

Thanks for taking the time to report this. This is a big problem (and I'm also not sure if other wallets/libraries would handle this properly). @LLFourn talked to me briefly this morning about how to fix this. This is my recollection + some of my own thoughts thrown in.

To fix this problem, we need to redefine what is considered relevant by our receiving structures, and make sure our chain sources emit the replacing tx.

Step 1: Redefine what is considered "relevant" in IndexedTxGraph.

  • Affected methods: batch_insert_relevant_unconfirmed, batch_insert_relevant and apply_block_relevant.
  • Current behavior: a tx's relevancy is determined purely by the spks referenced by the tx's inputs/outputs.
  • Proposed behavior: Keep the current behavior, but add the following rule: if a tx shares inputs with anything contained in the TxGraph, we should also consider it relevant. We can call TxGraph::direct_conflicts for this.

Step 2: Ensure our chain sources emit the replacement txs.

For the bdk_bitcoind_rpc crate, all blocks and all mempool transactions are emitted so the changes in Step 1 alone should allow us to detect replacement txs. We can use this to write a test to validate the validity of Step 1 changes.

Things get more tricky for spk-based chain sources (Electrum and Esplora). I'll do a bit more thinking this weekend.

@evanlinjin
Copy link
Member

@LagginTimes agreed to help out on this. He will implement the Step 1 fixes and also testing this scenario with bdk_bitcoind_rpc.

@ValuedMammal
Copy link
Collaborator

idea: #1764

ValuedMammal added a commit that referenced this issue May 1, 2025
…d_at`

8513d83 feat(bitcoind_rpc)!: Reduce friction of `Emitter` API. (志宇)
0a02d26 feat(chain): Add convenience conversions for `CanonicalTx` (志宇)
d11f6ef feat(graph): add convenience function for inserting relevant `evicted_at`s (Wei Chen)
28ef7c9 refactor(rpc)!: update `mempool` interface and test code (valued mammal)

Pull request description:

  Fixes #1740

  ### Description

  Work for this began as part of #1811, based on this [comment](#1811 (comment)).

  `Emitter::mempool` now returns a `MempoolEvent` which provides the data for tracking `evicted_at`:

  ```
  pub struct MempoolEvent {
      /// Unemitted transactions or transactions with ancestors that are unseen by the receiver.
      ///
      /// To understand the second condition, consider a receiver which filters transactions based on
      /// whether it alters the UTXO set of tracked script pubkeys. If an emitted mempool transaction
      /// spends a tracked UTXO which is confirmed at height `h`, but the receiver has only seen up to
      /// block of height `h-1`, we want to re-emit this transaction until the receiver has seen the
      /// block at height `h`.
      pub new_txs: Vec<(Transaction, u64)>,

      /// [`Txid`]s of all transactions that have been evicted from mempool.
      pub evicted_txids: HashSet<Txid>,

      /// The latest timestamp of when a transaction entered the mempool.
      ///
      /// This is useful for setting the timestamp for evicted transactions.
      pub latest_update_time: u64,
  }
  ```

  ### Changelog notice

  * Change `Emitter::mempool` to return `MempoolEvent`s which contain mempool-eviction data.
  * Change `Emitter::client` to have more relaxed generic bounds. `C: Deref, C::Target: RpcApi` are the new bounds.
  * Add conversion impls for `CanonicalTx` to `Txid`/`Arc<Transaction>`.
  * Add `ChainPosition::is_unconfirmed` method.

  ### Checklists

  #### All Submissions:

  * [x] I've signed all my commits
  * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md)
  * [x] I ran `cargo fmt` and `cargo clippy` before committing

  #### New Features:

  * [x] I've added tests for the new feature
  * [x] I've added docs for the new feature

  #### Bugfixes:

  * [x] This pull request breaks the existing API
  * [x] I've added tests to reproduce the issue which are now passing
  * [x] I'm linking the issue being fixed by this PR

ACKs for top commit:
  ValuedMammal:
    ACK 8513d83

Tree-SHA512: 28149458085dc4cefefe06656769d701b53f891c1ecb5d400aba8b23d63c026d6e4db1c9e6f47c8ad167edc1559d897d28e9bb71e7bd144792b5cecc0bcd31ce
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment