Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ build/
.log/

**/proptest-regressions
.serena/
.codex
docs/superpowers/
5 changes: 4 additions & 1 deletion bindings/ergo-lib-wasm/src/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,5 +454,8 @@ pub fn validate_tx(
data_boxes,
)
.map_err(to_js)?;
tx_context.validate(&state_context.0).map_err(to_js)
tx_context
.validate(&state_context.0)
.map(|_| ())
.map_err(to_js)
}
1 change: 1 addition & 0 deletions ergo-lib/src/chain/parameters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ impl Default for Parameters {
parameters_table.insert(Parameter::DataInputCost, 100);
parameters_table.insert(Parameter::OutputCost, 100);
parameters_table.insert(Parameter::MaxBlockSize, 512 * 1024);
parameters_table.insert(Parameter::MaxBlockCost, 1_000_000);
parameters_table.insert(Parameter::BlockVersion, 1);
Self { parameters_table }
}
Expand Down
13 changes: 13 additions & 0 deletions ergo-lib/src/chain/transaction/ergo_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ pub enum TxValidationError {
/// Verifying input script failed
#[error("Verifier error on input {0}: {1}")]
VerifierError(usize, VerifierError),
/// Cumulative transaction cost exceeds `max_block_cost`. Phase identifies where the
/// breach was detected; `block_cost` is the accumulated block-scale cost at breach.
/// Matches Scala's per-input post-check at
/// `ErgoTransaction.scala:159` (`currCost <= maxCost`).
#[error("Cost limit exceeded at {phase}: block_cost={block_cost}, limit={limit}")]
CostLimitExceeded {
/// Where the breach was detected (`init`, `input {idx}`, `storage_rent {idx}`, or `post_eval {idx}`).
phase: alloc::string::String,
/// Accumulated block-scale cost at the moment of breach.
block_cost: u64,
/// Configured `max_block_cost` from protocol parameters.
limit: u64,
},
}

/// Exposes common properties for signed and unsigned transactions
Expand Down
2 changes: 2 additions & 0 deletions ergo-lib/src/wallet/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ pub fn make_context<'ctx, T: ErgoTransaction>(
headers: state_ctx.headers.clone(),
tree_version: Default::default(),
extension_provider: &tx_ctx.spending_tx,
jit_cost_accum: Default::default(),
jit_cost_limit: Some(state_ctx.parameters.max_block_cost() as u64 * 10),
})
}
// Updates a Context, changing its self box and context extension to transaction.inputs[i]
Expand Down
269 changes: 256 additions & 13 deletions ergo-lib/src/wallet/tx_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,33 @@ use alloc::vec::Vec;
use hashbrown::hash_map::Entry;
use hashbrown::HashMap;

use std::collections::HashSet;

use crate::chain::ergo_state_context::ErgoStateContext;
use crate::chain::parameters::Parameters;
use crate::chain::transaction::ergo_transaction::{ErgoTransaction, TxValidationError};
use crate::chain::transaction::{verify_tx_input_proof, Transaction, TransactionError};
use crate::chain::transaction::storage_rent::try_spend_storage_rent;
use crate::chain::transaction::{Transaction, TransactionError};
use crate::ergotree_ir::chain::ergo_box::BoxId;
use ergotree_interpreter::sigma_protocol::verifier::VerificationResult;
use ergotree_interpreter::eval::cost_accum::CostError;
use ergotree_interpreter::eval::reduce_to_crypto;
use ergotree_interpreter::eval::EvalError;
use ergotree_interpreter::sigma_protocol::crypto_cost::estimate_crypto_cost;
use ergotree_interpreter::sigma_protocol::verifier::{verify_signature, VerificationResult};
use ergotree_ir::chain::context::TxIoVec;
use ergotree_ir::chain::ergo_box::box_value::BoxValue;
use ergotree_ir::chain::ergo_box::{BoxTokens, ErgoBox};
use ergotree_ir::chain::token::{TokenAmount, TokenId};
use ergotree_ir::serialization::SigmaSerializable;
use ergotree_ir::sigma_protocol::sigma_boolean::SigmaBoolean;
use thiserror::Error;

use super::signing::make_context;

/// Cost (in block-cost scale) charged per successful storage-rent spend, matching
/// Scala's `Constants.StorageContractCost = 50` (ErgoInterpreter.scala:81).
const STORAGE_CONTRACT_COST_BLOCK: u64 = 50;

/// Transaction and an additional info required for signing or verification
#[derive(PartialEq, Eq, Debug, Clone)]
pub struct TransactionContext<T: ErgoTransaction> {
Expand Down Expand Up @@ -99,11 +112,45 @@ impl<T: ErgoTransaction> TransactionContext<T> {
}
}

const INTERPRETER_INIT_COST: u64 = 10_000;

fn count_tokens(boxes: &[ErgoBox]) -> (u64, u64) {
let mut total_entries = 0u64;
let mut distinct: HashSet<TokenId> = HashSet::new();
for b in boxes {
for t in b.tokens.iter().flatten() {
total_entries += 1;
distinct.insert(t.token_id);
}
}
(total_entries, distinct.len() as u64)
}

fn compute_tx_init_cost(
tx: &Transaction,
boxes_to_spend: &[ErgoBox],
parameters: &Parameters,
) -> u64 {
let n_data_inputs = tx.data_inputs.as_ref().map_or(0, |d| d.len()) as u64;
let structural = INTERPRETER_INIT_COST
+ tx.inputs.len() as u64 * parameters.input_cost() as u64
+ n_data_inputs * parameters.data_input_cost() as u64
+ tx.outputs.len() as u64 * parameters.output_cost() as u64;

let (in_entries, in_distinct) = count_tokens(boxes_to_spend);
let (out_entries, out_distinct) = count_tokens(tx.outputs.as_slice());
// Token cost formula matches Scala: distinct computed separately per side
let token_cost = ((in_entries + out_entries) + (in_distinct + out_distinct))
* parameters.token_access_cost() as u64;

structural + token_cost
}

impl TransactionContext<Transaction> {
/// Verify transaction using blockchain parameters
// TODO: costing
/// Verify transaction using blockchain parameters.
/// Returns the total block cost (JIT / 10) matching the Scala reference.
// This is based on validateStateful() in Ergo: https://github.com/ergoplatform/ergo/blob/48239ef98ced06617dc21a0eee5670235e362933/ergo-core/src/main/scala/org/ergoplatform/modifiers/mempool/ErgoTransaction.scala#L357
pub fn validate(&self, state_context: &ErgoStateContext) -> Result<(), TxValidationError> {
pub fn validate(&self, state_context: &ErgoStateContext) -> Result<u64, TxValidationError> {
// Check that input sum does not overflow
let input_sum = BoxValue::new(
self.boxes_to_spend
Expand Down Expand Up @@ -145,17 +192,130 @@ impl TransactionContext<Transaction> {
let in_assets = extract_assets(self.boxes_to_spend.iter().map(|b| &b.tokens))?;
let out_assets = extract_assets(self.spending_tx.outputs.iter().map(|b| &b.tokens))?;
verify_assets(self.spending_tx.inputs_ids(), in_assets, out_assets)?;
// Verify input proofs. This is usually the most expensive check so it's done last
// Verify input proofs with full cost pipeline
let bytes_to_sign = self.spending_tx.bytes_to_sign()?;
let mut context = make_context(state_context, self, 0)?;

// Init cost (block cost units -> JIT scale)
let init_cost = compute_tx_init_cost(
&self.spending_tx,
self.boxes_to_spend.as_slice(),
&state_context.parameters,
);
let max_block_cost = state_context.parameters.max_block_cost() as u64;
// Scala ref: ErgoTransaction.scala ~L133 — init cost alone exceeding
// maxBlockCost is a tx-level rejection, distinct from per-input overruns.
if init_cost > max_block_cost {
return Err(TxValidationError::CostLimitExceeded {
phase: alloc::string::ToString::to_string("init"),
block_cost: init_cost,
limit: max_block_cost,
});
}
let mut running_jit: u64 = init_cost * 10;

for input_idx in 0..self.spending_tx.inputs.len() {
if let res @ VerificationResult { result: false, .. } =
verify_tx_input_proof(self, &mut context, state_context, input_idx, &bytes_to_sign)?
{
return Err(TxValidationError::ReducedToFalse(input_idx, res));
// Fresh context per input (jit_cost_accum starts at 0). Per Scala
// (ErgoTransaction.scala:135), each input's interpreter runs with
// `costLimit = maxCost - currentTxCost` — the remaining budget in
// block-scale, converted back to JIT scale for the accumulator.
let mut ctx = make_context(state_context, self, input_idx)?;
let current_block_cost = running_jit / 10;
let remaining_block = max_block_cost.saturating_sub(current_block_cost);
ctx.jit_cost_limit = Some(remaining_block.saturating_mul(10));

let input = self
.spending_tx
.inputs
.get(input_idx)
.ok_or(TransactionContextError::InputBoxNotFound(input_idx))?;
let input_box = self
.get_input_box(&input.box_id)
.ok_or(TransactionContextError::InputBoxNotFound(input_idx))?;

// Storage rent bypass: Scala charges Constants.StorageContractCost = 50
// (block-cost scale) per expired-box spend instead of script verification.
// Scala ref: ErgoInterpreter.scala:81, Constants.scala:35.
if try_spend_storage_rent(input, input_box, state_context, &ctx).is_some() {
running_jit += STORAGE_CONTRACT_COST_BLOCK * 10;
let block_cost = running_jit / 10;
if block_cost > max_block_cost {
return Err(TxValidationError::CostLimitExceeded {
phase: alloc::format!("storage_rent {}", input_idx),
block_cost,
limit: max_block_cost,
});
}
continue;
}

let pre_input = running_jit;

// Reduce to crypto (eval cost + sigma prop). Mid-eval limit breaches
// surface as EvalError::CostError(LimitExceeded); map to a transaction-
// level CostLimitExceeded so callers don't have to unpack VerifierError.
let reduction = reduce_to_crypto(&input_box.ergo_tree, &ctx).map_err(|e| {
if matches!(e, EvalError::CostError(CostError::LimitExceeded(_))) {
// Interpreter tripped on remaining budget; running_jit hasn't
// absorbed this input's cost yet, so report one block above.
TxValidationError::CostLimitExceeded {
phase: alloc::format!("input {}", input_idx),
block_cost: max_block_cost + 1,
limit: max_block_cost,
}
} else {
TxValidationError::VerifierError(input_idx, e.into())
}
})?;
running_jit += reduction.cost;

// Snap to block boundary (drop JitCost mod-10 remainder per input)
let input_delta = running_jit - pre_input;
running_jit -= input_delta % 10;

// Crypto verification cost
running_jit += estimate_crypto_cost(&reduction.sigma_prop);

// Scala ref: ErgoTransaction.scala:159 — post-input check
// (currCost <= maxCost) after script + crypto cost is folded in.
let block_cost = running_jit / 10;
if block_cost > max_block_cost {
return Err(TxValidationError::CostLimitExceeded {
phase: alloc::format!("post_eval {}", input_idx),
block_cost,
limit: max_block_cost,
});
}

// Verify proof
let verified = match &reduction.sigma_prop {
SigmaBoolean::TrivialProp(b) => *b,
SigmaBoolean::ProofOfKnowledge(sb) => verify_signature(
SigmaBoolean::ProofOfKnowledge(sb.clone()),
&bytes_to_sign,
input.spending_proof.proof.as_ref(),
)
.map_err(|e| TxValidationError::VerifierError(input_idx, e))?,
SigmaBoolean::SigmaConjecture(sb) => verify_signature(
SigmaBoolean::SigmaConjecture(sb.clone()),
&bytes_to_sign,
input.spending_proof.proof.as_ref(),
)
.map_err(|e| TxValidationError::VerifierError(input_idx, e))?,
};
if !verified {
return Err(TxValidationError::ReducedToFalse(
input_idx,
VerificationResult {
result: false,
cost: reduction.cost,
diag: reduction.diag,
},
));
}
}
Ok(())

// Floor division to block cost
Ok(running_jit / 10)
}
}

Expand Down Expand Up @@ -732,10 +892,93 @@ mod test {
other => panic!("Expected validation to succeed, got {other:?}")
}
match (monotonic_valid, tx_context.validate(&context3)) {
(true, Ok(())) => {},
(true, Ok(_)) => {},
(false, Err(TxValidationError::MonotonicHeightError(_, _))) => {},
other => panic!("Expected validation to fail, got {other:?}")
}
});
}

/// Adversarial cross-input cost exhaustion: each input individually would
/// fit under a fresh `max_block_cost` budget, but cumulative tx cost trips
/// the post-input check (Scala ref: `ErgoTransaction.scala:159`).
///
/// Strategy: measure the tx's true block cost with default parameters,
/// then re-validate with `max_block_cost` set just below it. The breach
/// must be reported as `CostLimitExceeded` on a non-first input (either
/// mid-eval via remaining-budget interpreter limit, or at the post-eval
/// cumulative check).
#[test]
fn test_cost_limit_exceeded_cumulative() {
use crate::chain::parameters::Parameters;
let state_context: ErgoStateContext = force_any_val();
proptest!(ProptestConfig::with_cases(8), |((boxes, tx) in valid_transaction_generator())| {
// Need ≥2 inputs for the cumulative breach to be on a later input.
prop_assume!(tx.inputs.len() >= 2);
let tx_context = TransactionContext::new(tx, boxes, vec![]).unwrap();
let valid_cost = tx_context.validate(&state_context).unwrap();
prop_assume!(valid_cost > 0);

let p = &state_context.parameters;
let mut tight_ctx = state_context.clone();
tight_ctx.parameters = Parameters::new(
p.block_version(),
p.storage_fee_factor(),
p.min_value_per_byte(),
p.max_block_size(),
(valid_cost - 1) as i32,
p.token_access_cost(),
p.input_cost(),
p.data_input_cost(),
p.output_cost(),
);
match tx_context.validate(&tight_ctx) {
Err(TxValidationError::CostLimitExceeded { phase, block_cost, limit }) => {
prop_assert!(block_cost > limit, "breach must exceed limit");
// Breach reported on init or any input — all are valid
// surfacings. What matters is that cumulative check fires
// rather than a false-pass.
prop_assert!(
phase == "init"
|| phase.starts_with("input ")
|| phase.starts_with("storage_rent ")
|| phase.starts_with("post_eval "),
"unexpected phase: {phase}"
);
}
other => panic!("Expected CostLimitExceeded, got {other:?}"),
}
});
}

/// Init cost alone exceeding `max_block_cost` short-circuits before any
/// input is evaluated — `phase = "init"`.
#[test]
fn test_cost_limit_exceeded_init() {
use crate::chain::parameters::Parameters;
let state_context: ErgoStateContext = force_any_val();
proptest!(ProptestConfig::with_cases(4), |((boxes, tx) in valid_transaction_generator())| {
let tx_context = TransactionContext::new(tx, boxes, vec![]).unwrap();
let p = &state_context.parameters;
let mut tiny_ctx = state_context.clone();
tiny_ctx.parameters = Parameters::new(
p.block_version(),
p.storage_fee_factor(),
p.min_value_per_byte(),
p.max_block_size(),
1,
p.token_access_cost(),
p.input_cost(),
p.data_input_cost(),
p.output_cost(),
);
match tx_context.validate(&tiny_ctx) {
Err(TxValidationError::CostLimitExceeded { phase, limit, .. }) => {
prop_assert_eq!(phase, "init");
prop_assert_eq!(limit, 1);
}
other => panic!("Expected CostLimitExceeded(init), got {other:?}"),
}
});
}
}
Loading
Loading