Skip to content

Unchecked ERG Sum Overflow in Validation #881

Description

@Carrotrust

Brief/Intro

ergo-lib performs stateful ERG preservation checks using unchecked u64 summation in TransactionContext::validate(). In debug builds this can panic, and in release builds it wraps and continues with an incorrect total. While this does not appear directly exploitable on canonical Ergo mainnet because real supply bounds constrain input values, it is still a real correctness and safety issue for off-chain validation, wallet tooling, fuzzing, and any downstream integration that trusts ergo-lib validation in isolation.

Vulnerability Details

The stateful validation path computes input and output ERG totals with plain .sum::<u64>() and only then converts the input total into BoxValue:

pub fn validate(&self, state_context: &ErgoStateContext) -> Result<(), TxValidationError> {
    let input_sum = BoxValue::new(
        self.boxes_to_spend
            .iter()
            .map(|b| b.value.as_u64())
            .sum::<u64>(),
    )
    .map_err(|_| TxValidationError::InputSumOverflow)?;

    let output_sum = self
        .spending_tx
        .outputs
        .iter()
        .map(|b| b.value.as_u64())
        .sum();
    if *input_sum.as_u64() != output_sum {
        return Err(TxValidationError::ErgPreservationError(
            *input_sum.as_u64(),
            output_sum,
        ));
    }
    // ...
}

The problem is that the overflow happens before BoxValue::new(...) can reject it:

  • In debug builds, integer overflow during .sum::<u64>() panics.
  • In release builds, integer overflow wraps modulo u64.

That means the function does not reliably return TxValidationError::InputSumOverflow for oversized aggregates. Instead:

  • debug builds abort via panic;
  • release builds continue using a wrapped total and eventually return a logically different error such as ErgPreservationError.

This is a runtime bug, not just a theoretical code-quality concern. I validated it with dedicated tests added to ergo-lib/src/wallet/tx_context.rs:

  • validate_panics_on_input_sum_overflow_in_debug
  • validate_wraps_on_input_sum_overflow_in_release

Both tests construct three input boxes, each with i64::MAX as u64, which is individually acceptable as a BoxValue but whose aggregate exceeds u64 bounds when summed.

Impact Details

  • local validators can panic on adversarial or synthetic test cases;
  • release-mode tooling can reason over wrapped ERG totals;
  • wallets, fuzzers, offline analyzers, and external integrations can receive the wrong validation result;
  • developers may incorrectly believe overflow is surfaced as InputSumOverflow when in reality the function panics or misclassifies the error.

This can lead to:

  • incorrect off-chain transaction acceptance/rejection;
  • broken invariant checks in tooling;
  • misleading error handling;
  • denial of service in debug/test environments.

I would classify this as a medium-severity correctness/security-hardening issue rather than a mainnet asset-loss bug in the library alone.

References

Proof of Concept

This PoC consists of two tests added to ergo-lib/src/wallet/tx_context.rs to show both build modes.

Files edited

  • Modified: sigma-rust/ergo-lib/src/wallet/tx_context.rs

I also added one test only import of TxId and cfg-gated the panic helper import so the file compiles cleanly in both debug and release test runs. These changes are support edits for the PoC and do not alter the vulnerable validation logic.

Code added

#[test]
#[cfg(debug_assertions)]
fn validate_panics_on_input_sum_overflow_in_debug() {
    let huge_value = BoxValue::try_from(i64::MAX as u64).unwrap();
    let boxes = (0..3)
        .map(|_| {
            ErgoBox::new(
                huge_value,
                force_any_val::<ErgoTree>(),
                None,
                NonMandatoryRegisters::empty(),
                0,
                force_any_val::<TxId>(),
                0,
            )
            .unwrap()
        })
        .collect::<Vec<_>>();

    let inputs = boxes
        .iter()
        .map(|b| Input {
            box_id: b.box_id(),
            spending_proof: ProverResult {
                proof: ProofBytes::Empty,
                extension: ContextExtension::empty(),
            },
        })
        .collect::<Vec<_>>();
    let output = ErgoBoxCandidate {
        value: BoxValue::SAFE_USER_MIN,
        ergo_tree: force_any_val::<ErgoTree>(),
        tokens: None,
        additional_registers: NonMandatoryRegisters::empty(),
        creation_height: 0,
    };
    let tx = Transaction::new_from_vec(inputs, vec![], vec![output]).unwrap();
    let tx_context = TransactionContext::new(tx, boxes, vec![]).unwrap();
    let state_context: ErgoStateContext = force_any_val();

    let res = catch_unwind(AssertUnwindSafe(|| tx_context.validate(&state_context)));
    assert!(res.is_err());
}

#[test]
#[cfg(not(debug_assertions))]
fn validate_wraps_on_input_sum_overflow_in_release() {
    let huge_value = BoxValue::try_from(i64::MAX as u64).unwrap();
    let boxes = (0..3)
        .map(|_| {
            ErgoBox::new(
                huge_value,
                force_any_val::<ErgoTree>(),
                None,
                NonMandatoryRegisters::empty(),
                0,
                force_any_val::<TxId>(),
                0,
            )
            .unwrap()
        })
        .collect::<Vec<_>>();

    let inputs = boxes
        .iter()
        .map(|b| Input {
            box_id: b.box_id(),
            spending_proof: ProverResult {
                proof: ProofBytes::Empty,
                extension: ContextExtension::empty(),
            },
        })
        .collect::<Vec<_>>();
    let output = ErgoBoxCandidate {
        value: BoxValue::SAFE_USER_MIN,
        ergo_tree: force_any_val::<ErgoTree>(),
        tokens: None,
        additional_registers: NonMandatoryRegisters::empty(),
        creation_height: 0,
    };
    let tx = Transaction::new_from_vec(inputs, vec![], vec![output]).unwrap();
    let tx_context = TransactionContext::new(tx, boxes, vec![]).unwrap();
    let state_context: ErgoStateContext = force_any_val();

    let err = tx_context.validate(&state_context).unwrap_err();
    assert!(matches!(err, TxValidationError::ErgPreservationError(_, _)));
}

What the code proves

Debug test

Step 1. Create three valid individual input boxes with extremely large values.
Step 2. Call TransactionContext::validate().
Step 3. Observe that validation does not return InputSumOverflow; it panics during unchecked addition.

Release test

Step 1. Reuse the same oversized input set.
Step 2. Run validate() in release mode.
Step 3. Observe that validation returns ErgPreservationError after wrapping arithmetic, proving the overflow was not trapped at the summation step.

How to run

From the repository root:

cargo +stable test -p ergo-lib --features arbitrary wallet::tx_context::test::validate_panics_on_input_sum_overflow_in_debug -- --exact
cargo +stable test --release -p ergo-lib --features arbitrary wallet::tx_context::test::validate_wraps_on_input_sum_overflow_in_release -- --exact

Expected output

Each command should show:

running 1 test
test ... ... ok
test result: ok. 1 passed; 0 failed

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