Skip to content

RBF State Synchronization Issue: Sender Wallet Balance Incorrect After Successful RBF Transaction #321

@will-bitlightlabs

Description

@will-bitlightlabs

Summary

When an original transaction fails to broadcast due to low fee rate and is subsequently replaced by a successful RBF transaction, the sender wallet shows incorrect balance (0 tokens instead of expected change amount). The receiver wallet state is correct.

Environment

Test Scenario

This issue was discovered during comprehensive RBF edge case testing. The specific scenario:

  1. Initial State: Sender has 600 tokens, receiver has 0 tokens
  2. Original Transaction: Create transfer of 400 tokens with 100 sats fee (intentionally low)
  3. Broadcast Failure: Original transaction fails with "min relay fee not met, 100 < 153"
  4. RBF Transaction: Create replacement transaction with 1000 sats fee
  5. RBF Success: RBF transaction broadcasts and confirms successfully
  6. State Issue: Sender shows 0 balance instead of expected 200 tokens

Expected Behavior

After successful RBF transaction:

  • Sender Balance: 200 tokens (600 - 400 transferred)
  • Receiver Balance: 400 tokens (amount received)
  • State Consistency: Both state_all and state_own should reflect correct balances

Actual Behavior

After successful RBF transaction:

  • Sender Balance: 0 tokens (incorrect)
  • Receiver Balance: 400 tokens (correct)
  • State Inconsistency:
    • state_all(contract_id).owned contains genesis state (600 tokens)
    • state_own(contract_id).owned returns empty array []

Reproduction Steps

#[test]
fn rbf_original_unbroadcast_rbf_success() {
    initialize();

    let mut wlt_1 = get_wallet(&DescriptorType::Wpkh);
    let mut wlt_2 = get_wallet(&DescriptorType::Wpkh);

    // Issue NIA asset with 600 tokens
    let mut params = NIAIssueParams::new("RBFTestAsset", "RBF", "centiMilli", 600);
    let outpoint = wlt_1.get_utxo(None);
    params.add_allocation(outpoint, 600);
    let contract_id = wlt_1.issue_nia_with_params(params);
    wlt_1.send_contract("RBFTestAsset", &mut wlt_2);
    wlt_2.reload_runtime();

    let invoice = wlt_2.invoice(contract_id, 400, false, Some(0), None);

    // Stop mining to test RBF behavior
    stop_mining();
    let initial_height = get_height();

    // Create original transaction with low fee (will fail to broadcast)
    let params = TxParams::with(Sats(100)); // Below minimum relay fee
    let (psbt, payment) = wlt_1
        .runtime
        .pay_invoice(
            &invoice,
            wlt_1.coinselect_strategy,
            params,
            Some(Sats(2000)),
        )
        .unwrap();
    let (consignment_1, first_tx) = wlt_1.consign(
        contract_id,
        psbt,
        &payment.terminals,
        Duration::default(),
        false,
        None,
    );

    // Verify original transaction broadcast fails
    assert!(wlt_1.broadcast_tx(&first_tx).is_err());
    let first_txid = first_tx.txid();

    // Receiver accepts the transfer
    wlt_2.accept_transfer(&consignment_1, None).unwrap();

    // Sync wallets (should cause state rollback due to unbroadcast transaction)
    wlt_1.sync();
    wlt_2.sync();

    // Create and broadcast successful RBF transaction
    let (consignment_2, tx) = wlt_1.transfer_rbf(contract_id, payment, 1000, None);
    let second_txid = tx.txid();

    // Broadcast and confirm the RBF transaction
    wlt_1.mine_tx(&tx.txid(), true);

    // Receiver accepts the successful RBF transfer
    wlt_2.accept_transfer(&consignment_2, None).unwrap();

    // Sync both wallets
    wlt_1.sync();
    wlt_2.sync();

    // BUG: This assertion fails
    wlt_1.check_allocations(contract_id, AssetSchema::RGB20, vec![200]); // Expected: 200, Actual: 0
    wlt_2.check_allocations(contract_id, AssetSchema::RGB20, vec![400]); // This passes correctly
}

Error Output

thread 'rbf_original_unbroadcast_rbf_success' panicked at tests/utils/helper/wallet.rs:606:9:
assertion `left == right` failed
  left: []
 right: [200]

Debug Information

State Analysis:

// After RBF transaction success
dbg!(wlt_1.runtime.state_all(contract_id).owned);
// Output: Contains genesis state with 600 tokens

dbg!(wlt_1.runtime.state_own(contract_id).owned);
// Output: Empty array []

Transaction Status:

  • Original TX: Unknown status (failed to broadcast)
  • RBF TX: Mined(MiningInfo { height: 234, ... }) (successfully confirmed)

Original TX Broadcast Error (as expected):

HttpResponse {
    status: 400,
    message: "sendrawtransaction RPC error: {\"code\":-26,\"message\":\"min relay fee not met, 100 < 153\"}"
}

Analysis

The issue appears to be related to RGB state management during RBF scenarios where:

  1. Original transaction is created but fails to broadcast
  2. RBF transaction successfully replaces and confirms
  3. Wallet state synchronization doesn't properly handle the transition from failed original to successful RBF

Additional Context

This issue was discovered during comprehensive RBF edge case testing. Other RBF scenarios work correctly:

  • ✅ Standard RBF (original tx in mempool, RBF replaces): Works correctly
  • ✅ Both transactions fail to broadcast: State correctly rolls back
  • ✅ RBF with fee overflow: Error handling works correctly
  • ❌ Original fails, RBF succeeds: This specific scenario shows the state issue

Related Files

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions