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
Brief/Intro
ergo-libperforms stateful ERG preservation checks using uncheckedu64summation inTransactionContext::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 trustsergo-libvalidation 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 intoBoxValue:The problem is that the overflow happens before
BoxValue::new(...)can reject it:.sum::<u64>()panics.u64.That means the function does not reliably return
TxValidationError::InputSumOverflowfor oversized aggregates. Instead: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_debugvalidate_wraps_on_input_sum_overflow_in_releaseBoth tests construct three input boxes, each with
i64::MAX as u64, which is individually acceptable as aBoxValuebut whose aggregate exceedsu64bounds when summed.Impact Details
InputSumOverflowwhen in reality the function panics or misclassifies the error.This can lead to:
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.rsto show both build modes.Files edited
sigma-rust/ergo-lib/src/wallet/tx_context.rsI also added one test only import of
TxIdand 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
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
ErgPreservationErrorafter wrapping arithmetic, proving the overflow was not trapped at the summation step.How to run
From the repository root:
Expected output
Each command should show: