Skip to content

Found-021: TransactionRecord::update_context silently drops InstantLock on InBlock/InChainLockedBlock promotion #763

Description

@lklimek

User Story

As a Dash Platform application developer using rs-platform-wallet (which depends on key-wallet) to fund identity operations from a Core wallet, I need the wallet's TransactionRecord to preserve the InstantLock proof material when the underlying transaction transitions from InstantSend to InBlock (and onward to InChainLockedBlock), so that:

1. InstantAssetLockProof construction works at any time. Today, AssetLockManager::wait_for_proof only finds the IS-lock if it reads the record before block confirmation lands. Once update_context(InBlock(info)) runs, the lock is gone and the manager has to wait for a chainlock before falling back to ChainAssetLockProof — that adds minutes of latency, bounded by the 5-minute wait_for_proof timeout hard-coded at dashpay/platform packages/rs-platform-wallet/src/wallet/asset_lock/build.rs:334. Identity registration and top-up flows are on this critical path; any production caller whose transaction happens to be block-confirmed before they read the record silently drops onto the slow proof path.

Timeline of a wallet that misses the IS-lock and falls onto the chainlock branch (illustrative; concrete numbers depend on block-time and chainlock-quorum scheduling and are not normatively specified):

Time Event TransactionContext
T+0 tx broadcast Mempool
T+~5 s IS-lock arrives at SPV (if it does) InstantSend(lock) — caller can return proof immediately. If wait_for_proof reads here, returns InstantAssetLockProof.
T+~block-time next block mined containing tx InBlock(info)InstantLock dropped by this bug if the wallet had reached the InstantSend state; loop continues waiting because Platform consensus does not accept a bare InBlock as proof.
T+~block-time + chainlock-quorum latency ChainLock for that block arrives InChainLockedBlock(info) — caller returns ChainAssetLockProof { core_chain_locked_height, out_point }.
T+300 s (worst case) wait_for_proof timeout fires before chainlock

The chainlock-fallback path is legitimate: Dash Platform consensus accepts ChainAssetLockProof as valid finality. The cost of this bug is therefore latency on the proof, not correctness — but on identity-registration and top-up flows that minute-scale wait is a measurable user-experience regression vs the IS-lock path.

2. Wallet recovery / reload preserves proof material. If a wallet is taken offline (process restart, disk-state reload, SPV catch-up after disconnect) and the block confirmation arrives during the offline window, the IS-lock is permanently unrecoverable from local state once the wallet re-syncs — the SPV layer does not redeliver IS-locks for already-confirmed transactions. The wallet's only chance to retain the lock is to merge it into the persistent TransactionRecord before the block-arrival overwrite. Today it doesn't.

3. Audit trails remain accurate. Operators inspecting tracked asset-locks should be able to answer "was this transaction instant-locked?" by reading TransactionRecord directly. With the bug, the answer flips from "yes" to "no" silently the moment a block lands. Forensic, debugging, and monitoring queries get inconsistent results depending on when they run.

4. Symmetric ordering is safe. If a block confirmation arrives before the IS-lock notification (less common but possible under network partitions or SPV catch-up), the next update_context(InstantSend(lock)) would strip the InBlock(info) symmetrically. Today both directions are lossy.

Current Behavior

On v0.42-dev HEAD 5297d61a, TransactionRecord::update_context at key-wallet/src/managed_account/transaction_record.rs:182-184 is a naive replace:

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

TransactionContext (at key-wallet/src/transaction_checking/transaction_context.rs:41) has four variants:

pub enum TransactionContext {
    Mempool,
    InstantSend(InstantLock),
    InBlock(BlockInfo),
    InChainLockedBlock(BlockInfo),
}

There is no merged variant carrying both InstantLock and BlockInfo. When a tracked transaction transitions InstantSend(lock)InBlock(info), the InstantLock is silently overwritten and unrecoverable from the record.

Related amplifier

The production transition InBlock(info)InChainLockedBlock(info) at key-wallet/src/managed_account/managed_core_keys_account.rs:129-139 calls record.update_context(TransactionContext::InChainLockedBlock(info)) for already-InBlock records. By the time a record reaches that path, the IS-lock is already gone (lost at the prior IS→InBlock hop). Two compounding lossy hops to chain-lock proof, both caused by the same naive replace.

Reproduction

Pure unit test (no harness, no network, no async) at dashpay/platform packages/rs-platform-wallet/tests/e2e/cases/found_021_instant_lock_dropped_on_context_promotion.rs (on branch feat/rs-platform-wallet-e2e, PR #3549).

The test executes:

let lock = InstantLock::default();
let mut record = TransactionRecord::new(
    dummy_tx(),
    AccountType::Standard { index: 0, standard_account_type: StandardAccountType::BIP44Account },
    TransactionContext::InstantSend(lock),
    TransactionType::AssetLock,
    TransactionDirection::Outgoing,
    vec![], vec![],
    -100_000,
);
// Pre-condition: record.context is InstantSend(lock) ✓
record.update_context(TransactionContext::InBlock(block_info));
// Assertion: record.context still carries the InstantLock — FAILS today

Run with:

cargo test -p platform-wallet --test e2e -- --ignored found_021_instant_lock_dropped_on_context_promotion

Expected (after fix): record.context after update_context(InBlock(..)) either (a) carries the original InstantLock via a merged variant (e.g. InBlockWithInstantLock { info, lock }) or (b) the record exposes a dedicated instant_lock field that retains the lock independently of context.

Actual (today): record.context == InBlock(info) with no lock accessible. The IS-lock is gone.

Deterministic across runs — pure unit test, 0.00s execution time.

Root Cause

  1. Primary defect: TransactionRecord::update_context at transaction_record.rs:182-184 performs an unconditional field replacement. There is no inspection of the prior context to determine whether information is being lost.
  2. Secondary cause — type system: TransactionContext has no variant that carries both InstantLock and BlockInfo simultaneously. Even if update_context wanted to merge, there is no target type to merge into. The fix shape requires either a new variant or a new dedicated field on TransactionRecord.
  3. Amplifier: managed_core_keys_account.rs:129-139 performs a similar replace for InBlockInChainLockedBlock. The chain is two-step lossy: any record that has accumulated an InstantLock loses it at either hop.

Affected Versions

  • v0.42-dev HEAD 5297d61a (today) — confirmed via direct unit test
  • All prior key-wallet versions since TransactionRecord::update_context was introduced (search returned 0 issues / 0 PRs touching the function — this surface has never been addressed)

Cross-Repository Impact

In dashpay/platform (rs-platform-wallet):

Surface File / line Exposure
AssetLockManager::wait_for_proof packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs:341-419 Slow-waiter paths drop onto chainlock fallback (minutes of added latency, bounded by the 5-minute proof timeout at build.rs:334) instead of returning InstantAssetLockProof
AssetLockManager::recover_asset_lock_blocking packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs:36-280 Wallet-recovery path is maximally exposed — re-read happens AFTER any persistence-bracketed block confirmation. The IS-lock is unrecoverable once the wallet re-syncs.

e2e tests that surface the bug today or will after dependencies clear:

Severity

HIGH — silent data loss on the asset-lock proof critical path. The flow degrades in failure rather than failing loudly (minutes of added latency per affected transaction in the wait_for_proof slow-waiter case; recovery scenarios outright lose proof material). Production callers driving identity registration / top-up flows hit this whenever block confirmation outruns the proof read.

Notes

Per repo conventions for issue filing, no fix proposal in this body. Two options are referenced in dashpay/platform packages/rs-platform-wallet/tests/e2e/TEST_SPEC.md Found-021 entry (Option A: extend TransactionContext with InBlockWithInstantLock { info, lock }; Option B: store the most recent InstantLock on TransactionRecord independently of context) as implementer notes — the choice is left to the implementer.

The fix is a precondition for:

  • Implementing dashpay/platform packages/rs-platform-wallet/tests/e2e/cases/found_013_recover_asset_lock_silent_failure.rs end-to-end (currently blocked-scaffold)
  • Removing the chainlock-fallback latency hit in concurrent / slow-waiter paths through AssetLockManager::wait_for_proof

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions