Skip to content

key-wallet: mark_instant_send_utxos can downgrade a mined record to InstantSend, erasing its block height — ChainLock promotion then skips it forever #1020

Description

@romchornyi

Expected Behavior

An InstantSend lock applied to a transaction the wallet already holds as InBlock or InChainLockedBlock (or has already finalized) keeps the block context and its BlockInfo, the same way check_core_transaction already treats it. A later ChainLock at or above that height promotes the record to InChainLockedBlock.

Current Behavior

WalletInfoInterface::mark_instant_send_utxos overwrites the record's context unconditionally:

fn mark_instant_send_utxos(&mut self, txid: &Txid, lock: &InstantLock) -> bool {
if !self.instant_send_locks.insert(*txid) {
return false;
}
let mut any_changed = false;
// Kept for the sweep below: it needs the locked transaction's inputs,
// and this signature carries only its txid.
let mut locked_transaction = None;
for mut account in self.accounts.all_accounts_mut() {
if account.mark_utxos_instant_send(txid) {
any_changed = true;
}
if let Some(record) = account.transactions_mut().get_mut(txid) {
record.update_context(TransactionContext::InstantSend(lock.clone()));
any_changed = true;
if locked_transaction.is_none() {
locked_transaction = Some(record.transaction.clone());
}
}

and TransactionRecord::update_context is a plain assignment with no ordering guard:

pub fn update_context(&mut self, context: TransactionContext) {
self.context = context;
}

TransactionContext::InstantSend(InstantLock) carries no BlockInfo, so the record's height() becomes None:

Mempool,
/// Transaction is in the mempool with an InstantSend lock
InstantSend(InstantLock),
/// Transaction is in a block at the given height
InBlock(BlockInfo),
/// Transaction is in a chain-locked block at the given height
InChainLockedBlock(BlockInfo),

apply_chain_lock only promotes InBlock records and intentionally skips InstantSend, so a downgraded record is never promoted again, however far the chain advances:

/// Promote any `InBlock` records at height `<= cl_height` to
/// [`TransactionContext::InChainLockedBlock`] and return their txids.
///
/// Under the default `keep-finalized-transactions=OFF` feature
/// configuration the promoted records are immediately dropped via
/// [`Self::drop_finalized_transaction`], with their txids retained
/// only in `finalized_txids`. With the feature on the records stay
/// in `transactions` with the updated context.
///
/// Idempotent: records already in `InChainLockedBlock` or already
/// dropped to `finalized_txids` are not revisited and do not appear
/// in the result. Records still in `Mempool` or `InstantSend`
/// context are intentionally skipped, since chainlock-driven
/// promotion only applies to records that have already been mined.
pub(crate) fn apply_chain_lock(&mut self, cl_height: CoreBlockHeight) -> Vec<Txid> {
let candidates: Vec<(Txid, BlockInfo)> = self
.transactions
.iter()
.filter_map(|(txid, record)| match &record.context {
TransactionContext::InBlock(info) if info.height() <= cl_height => {
Some((*txid, *info))
}
_ => None,
})
.collect();
let mut promoted = Vec::with_capacity(candidates.len());
for (txid, info) in candidates {
if let Some(record) = self.transactions.get_mut(&txid) {
record.update_context(TransactionContext::InChainLockedBlock(info));
promoted.push(txid);
}
}
#[cfg(not(feature = "keep-finalized-transactions"))]
for txid in &promoted {
self.drop_finalized_transaction(txid);
}
promoted
}

The same transition through check_core_transaction is already guarded — an IS lock on a transaction that is finalized or is_confirmed() is ignored ("Only accept IS transitions for unconfirmed transactions"):

if !is_new {
// IS lock on a transaction that is already confirmed is stale — ignore
if context.is_instant_send() {
if !self.instant_send_locks.insert(txid) {
return result;
}
// Only accept IS transitions for unconfirmed transactions.
// A chainlocked tx may have had its full record dropped
// under the default feature config — `transaction_is_finalized`
// catches that case via `finalized_txids` and the in-map
// record check covers `InBlock`.
let already_confirmed = result.affected_accounts.iter().any(|am| {
let Some(account) =
self.accounts.get_by_account_type_match(&am.account_type_match)
else {
return false;
};
if account.transaction_is_finalized(&txid) {
return true;
}
account.transactions().get(&txid).is_some_and(|r| r.is_confirmed())
});
if already_confirmed {
return result;
}

mark_instant_send_utxos has no equivalent guard. It is reached from WalletManager::process_instant_send_lock:

fn process_instant_send_lock(&mut self, instant_lock: InstantLock) {
let txid = instant_lock.txid;
// `mark_instant_send_utxos` recomputes balances internally when any
// UTXO is newly marked, so we have to snapshot per-account balances
// up front to surface the diff afterwards.
let mut prior_account_balances: BTreeMap<
WalletId,
BTreeMap<AccountType, WalletCoreBalance>,
> = self.wallet_infos.iter().map(|(id, info)| (*id, info.account_balances())).collect();
let mut affected_wallets = Vec::new();
for (wallet_id, info) in self.wallet_infos.iter_mut() {
if info.mark_instant_send_utxos(&txid, &instant_lock) {
info.update_balance();
affected_wallets.push(*wallet_id);
}
}

which dash-spv's mempool manager calls only while the transaction is still in its mempool map; otherwise the lock is parked in pending_is_locks and later re-applied through the guarded check_core_transaction path:

let instant_lock_opt = if let Some(tx) = self.transactions.get_mut(&txid) {
tx.is_instant_send = true;
tracing::debug!("Marked mempool tx {} as InstantSend-locked", txid);
Some(instant_lock)
} else if self.pending_is_locks.len() < MAX_PENDING_IS_LOCKS {
self.pending_is_locks.insert(txid, (instant_lock, Instant::now()));
tracing::debug!("IS lock arrived before tx {}, remembering for later", txid);
None
} else {
tracing::warn!(
"Pending IS locks at capacity ({}), dropping IS lock for {}",
MAX_PENDING_IS_LOCKS,
txid
);
None
};
if let Some(lock) = instant_lock_opt {
let mut wallet = self.wallet.write().await;
wallet.process_instant_send_lock(lock);

So in steady state the unguarded overwrite is not a routine path. It is reachable in the ordering window between the wallet applying a block that contains the transaction and the mempool manager evicting it (remove_confirmed, driven by SyncEvent::BlockProcessed) — an IS lock that arrives in that window reaches mark_instant_send_utxos for an already-mined record. Any future caller of the public trait method inherits the same hazard.

Visible symptom downstream: ChainLockProcessed with no locked transactions for the affected record, and consumers that need a mined height for it (for example rs-platform-wallet's asset-lock ChainLock proof) wait for a promotion that cannot happen.

Possible Solution

Make mark_instant_send_utxos mirror the guard in wallet_checker.rs (L202-L212): skip the context update when account.transaction_is_finalized(txid) or record.is_confirmed(). Whether the UTXOs should still be flagged IS-locked and the conflict sweep still run in that case is a maintainer call; the sweep is harmless for a mined transaction.

A more general option is a guard inside update_context that refuses to move from a block context back to Mempool / InstantSend; that would also cover the re-broadcast re-sighting downgrade described in dashpay/platform#4238, but touches more callers. Keeping the InstantLock on a mined record is the reverse-direction problem in #763 and can be solved together.

Wallets already in this state are repaired on the consumer side (dashpay/platform, ticket 32167); this change stops new records from entering it.

Steps to Reproduce

Unit-level, no network needed:

  1. Record a wallet-owned transaction with TransactionContext::InBlock(BlockInfo { height: H, .. }).
  2. Apply its InstantLock — in a key-wallet-manager unit test via process_instant_send_lock (private; see event_tests.rs for an in-crate caller), or by calling the WalletInfoInterface::mark_instant_send_utxos trait method directly.
  3. Observe: the record's context is InstantSend(_) and height() is None.
  4. Call apply_chain_lock(H + 10): the record is not in the promoted list and stays InstantSend.

For comparison, feeding the same lock through process_mempool_transaction(&tx, Some(lock)) (→ check_core_transaction) leaves the record InBlock at height H.

Context

Your Environment

  • rust-dashcore dev @ 0da39ebfd716fb47e11a4ac1bdd9a68da27ead16; identical for the cited files at e4208c90786a6854bd498315bcb571ef24182c15, the revision pinned by dashpay/platform v4.2-dev.
  • Steps verified with the unit test below, added to key-wallet-manager/src/event_tests.rs (reuses its setup_manager_with_wallet, create_tx_paying_to, make_block, dummy_instant_lock helpers). cargo test -p key-wallet-manager --lib verify_ -- --nocapture at 0da39ebf (the run also printed the context after each step; those println!s are omitted from the code below):
after block: ctx=block 2000 height=Some(2000)
after process_instant_send_lock: ctx=instant send height=None
ChainLockProcessed locked_transactions: [{}]
after apply_chain_lock(2010): ctx=instant send height=None
after process_mempool_transaction(tx, Some(lock)): ctx=block 2000 height=Some(2000)
test result: ok. 2 passed; 0 failed
Test code
fn bip44_context(
    manager: &WalletManager<ManagedWalletInfo>,
    wallet_id: &WalletId,
    txid: &Txid,
) -> Option<(TransactionContext, Option<u32>)> {
    manager
        .get_wallet_info(wallet_id)
        .unwrap()
        .accounts
        .standard_bip44_accounts
        .get(&0)
        .unwrap()
        .transactions()
        .get(txid)
        .map(|r| (r.context.clone(), r.height()))
}

#[tokio::test]
async fn verify_late_is_lock_downgrades_mined_record() {
    let (mut manager, wallet_id, addr) = setup_manager_with_wallet();
    let wallets = BTreeSet::from([wallet_id]);
    let tx = create_tx_paying_to(&addr, 0x67);
    let block = make_block(vec![tx.clone()], 0x67, 1_500);
    manager.process_block_for_wallets(&block, block.block_hash(), 2_000, &wallets).await;
    let (ctx, h) = bip44_context(&manager, &wallet_id, &tx.txid()).expect("record");
    assert!(matches!(ctx, TransactionContext::InBlock(_)));
    assert_eq!(h, Some(2_000));

    manager.process_instant_send_lock(dummy_instant_lock(tx.txid()));
    let (ctx, h) = bip44_context(&manager, &wallet_id, &tx.txid()).expect("record");
    assert!(matches!(ctx, TransactionContext::InstantSend(_)));
    assert_eq!(h, None);

    manager.apply_chain_lock(ChainLock::dummy(2_010));
    let (ctx, _) = bip44_context(&manager, &wallet_id, &tx.txid()).expect("record still stored");
    assert!(matches!(ctx, TransactionContext::InstantSend(_)));
}

#[tokio::test]
async fn verify_check_core_transaction_path_keeps_block_context() {
    let (mut manager, wallet_id, addr) = setup_manager_with_wallet();
    let wallets = BTreeSet::from([wallet_id]);
    let tx = create_tx_paying_to(&addr, 0x68);
    let block = make_block(vec![tx.clone()], 0x68, 1_500);
    manager.process_block_for_wallets(&block, block.block_hash(), 2_000, &wallets).await;

    manager.process_mempool_transaction(&tx, Some(dummy_instant_lock(tx.txid()))).await;
    let (ctx, h) = bip44_context(&manager, &wallet_id, &tx.txid()).expect("record");
    assert!(matches!(ctx, TransactionContext::InBlock(_)));
    assert_eq!(h, Some(2_000));
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions