nautechsystems/nautilus_trader · error · anyhow::Error

Invalid execution transition for intent {intent_id}: {curren

Error message

Invalid execution transition for intent {intent_id}: {current_status} -> replaced

What it means

State-machine guard in add_execution_replacement_hash (database.rs:3801-3804) backed by execution_transition_allowed (database.rs:3990-4028): a transition to 'replaced' is only legal from 'signed', 'broadcast', 'included', 'replaced', 'dropped', or 'reorged'. It is rejected from terminal states ('finalized', 'reverted'), from 'recoverable', from 'prepared', and from any unrecognized status string. The error rejects the replacement recording atomically before any writes.

Source

Thrown at crates/adapters/blockchain/src/cache/database.rs:3801

        &self,
        intent_id: i64,
        chain_id: u32,
        transaction_hash: &str,
    ) -> anyhow::Result<ExecutionTransactionHashRow> {
        let chain_id_db = i32::try_from(chain_id)
            .with_context(|| format!("Chain ID {chain_id} exceeds PostgreSQL INTEGER"))?;
        let mut transaction = self.pool.begin().await.map_err(|e| {
            anyhow::anyhow!("Failed to start replacement transaction persistence: {e}")
        })?;
        let current_status = sqlx::query_scalar::<_, String>(
            "SELECT status FROM execution_intent WHERE id = $1 AND active FOR UPDATE",
        )
        .bind(intent_id)
        .fetch_optional(&mut *transaction)
        .await
        .map_err(|e| anyhow::anyhow!("Failed to lock active execution intent {intent_id}: {e}"))?
        .ok_or_else(|| anyhow::anyhow!("Active execution intent {intent_id} was not found"))?;
        anyhow::ensure!(
            execution_transition_allowed(&current_status, TransactionStatus::Replaced),
            "Invalid execution transition for intent {intent_id}: {current_status} -> replaced"
        );

        sqlx::query(
            "
            UPDATE execution_transaction_hash
            SET current = FALSE, status = 'replaced', updated_at = NOW()
            WHERE intent_id = $1 AND current
            ",
        )
        .bind(intent_id)
        .execute(&mut *transaction)
        .await
        .map_err(|e| anyhow::anyhow!("Failed to retire replaced execution hash: {e}"))?;

        let row = sqlx::query_as::<_, ExecutionTransactionHashRow>(
            "

View on GitHub (pinned to 2114cf6f76)

Solutions

  1. Inspect the intent's current status (SELECT status FROM execution_intent WHERE id = $1) and compare against the allowed-from set above
  2. If the intent is finalized/reverted, drop the replacement event - the nonce was consumed by the canonical transaction and the state is terminal
  3. If the intent is 'prepared', the replacement hash points at the wrong intent; re-check the hash-to-intent attribution (nonce, wallet, chain) in the watcher
  4. If the intent is 'recoverable', run the recovery flow first - it re-enters a replaceable state and replacement can then be recorded
  5. Never bypass the guard by writing status directly; the transition table exists to keep the audit trail consistent

Example fix

// before: unconditional replacement recording fails on terminal intents
let row = db.add_execution_replacement_hash(intent_id, chain_id, &hash).await?;

// after: check the transition is legal first, skip stale replacements
let hashes = db.get_execution_transaction_hashes(intent_id).await?;
let current = /* load intent status via your cached row or a small SELECT */ intent.status.as_str();
let replaceable = matches!(current, "signed" | "broadcast" | "included" | "replaced" | "dropped" | "reorged");
let row = if replaceable {
    Some(db.add_execution_replacement_hash(intent_id, chain_id, &hash).await?)
} else {
    None // already terminal/recoverable: stale replacement event
};
Defensive patterns

Strategy: validation

Validate before calling

// mirror of execution_transition_allowed for the 'replaced' target
fn replacement_allowed(current_status: &str) -> bool {
    matches!(
        current_status,
        "signed" | "broadcast" | "included" | "replaced" | "dropped" | "reorged"
    )
}

Try / catch

match db.add_execution_replacement_hash(intent_id, chain_id, &hash).await {
    Ok(row) => Ok(Some(row)),
    Err(e) if e.to_string().contains("Invalid execution transition") => {
        // stale or misattributed replacement: log with current status and skip
        Ok(None)
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: Calling add_execution_replacement_hash when the intent is already finalized or reverted (the replacement was observed after finality - a late or reordered chain event); the intent is still 'prepared' (never signed, so a replacement hash indicates the hash was matched to the wrong intent); the intent is 'recoverable' and must be recovered, not replaced.

Common situations: RPC event reordering where the replacement notification lands after the finalize notification; a reorg causing both a finalize and a replacement observation for the same nonce; hash-to-intent matching logic attributing another wallet's replacement to this intent.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of nautechsystems/nautilus_trader@2114cf6f76 (2026-08-21). Data as JSON: /api/errors/05bfa156b987ae28. Report an issue: GitHub.