Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
3a11130
docs: add JIT costing implementation plan
mwaddip Apr 9, 2026
853587c
feat: add JIT cost infrastructure to Context and cost types
mwaddip Apr 9, 2026
b5c1389
feat: add JIT costs to Const, Context, Global, and GlobalVars
mwaddip Apr 9, 2026
faedd9c
feat: add JIT costs to all eval operations
mwaddip Apr 9, 2026
6d2cb16
feat: propagate JIT cost through reduce_to_crypto and verifier
mwaddip Apr 9, 2026
6b52ba3
feat: wire cost limit into transaction validation and accumulate tota…
mwaddip Apr 9, 2026
a82123e
test: add JIT cost accumulation and limit enforcement tests
mwaddip Apr 9, 2026
93fd7af
chore: remove stale cost_accum comment from expr.rs
mwaddip Apr 9, 2026
f5141e6
chore: remove implementation plan from PR
mwaddip Apr 12, 2026
5e8cedc
fix: enforce JIT cost limit cumulatively across tx inputs
mwaddip Apr 21, 2026
7ed2ba8
fix: charge sigma-protocol verification cost per input
mwaddip Apr 21, 2026
4005634
fix: charge per-tx init cost before input evaluation
mwaddip Apr 21, 2026
807e2c8
fix: charge per-type equality cost in Eq/NEq
mwaddip Apr 21, 2026
285d8ba
fix: charge SubstConstants based on template constants count
mwaddip Apr 21, 2026
e1faa84
fix: charge ADD_TO_ENV cost per lambda invocation in coll ops
mwaddip Apr 21, 2026
5586f01
fix: charge Slice cost on requested range, not input length
mwaddip Apr 21, 2026
0daa18a
fix: charge P2PK trivial reduction at Scala's EvalSigmaPropConstant=50
mwaddip Apr 21, 2026
dc78df4
fix: carry resolved Constant on ConstantPlaceholder for in-place eval
mwaddip Apr 21, 2026
233b66f
fix: evaluate ConstPlaceholder in place at JitCost(1) per Scala
mwaddip Apr 21, 2026
a7d3175
test: add Scala cost parity harness with 78-tx mainnet vectors
mwaddip Apr 21, 2026
c6adcde
fix: charge ADD_TO_ENV_COST per ValDef in BlockValue
mwaddip Apr 21, 2026
4724992
refactor: unify Context::add_jit_cost on u64
mwaddip Apr 21, 2026
e11d418
fix: charge per-chunk cost for empty collections (n=0) per Scala PerI…
mwaddip May 30, 2026
9d2f183
test: add empty And / Blake2b256 per-item JIT cost regression vectors
mwaddip May 30, 2026
66b7ef0
fix: charge per-Constant cost for boolean-constant collections
mwaddip May 30, 2026
1e8d584
fix: charge ADD_TO_ENV_COST per lambda-arg binding in Apply
mwaddip May 31, 2026
c0c40ee
fix: SigmaPropBytes cost scales with the proposition's node count
mwaddip Jun 1, 2026
d404de5
fix: Coll.flatMap cost scales with the output length, not the input
mwaddip Jun 1, 2026
6b48aff
fix: Coll.indexOf cost reflects iterations performed, not full length
mwaddip Jun 1, 2026
84676c0
fix: nested-collection equality recurses per element (DataValueComparer)
mwaddip Jun 1, 2026
684f4df
fix: charge ADD_TO_ENV_COST per input element in Coll.flatMap
mwaddip Jun 1, 2026
3e839dc
style: rustfmt + clippy --all-targets cleanup for the jit-costing stack
mwaddip Jun 1, 2026
fcde02d
fix: charge per-comparison element equality cost in Coll.indexOf
mwaddip Jun 1, 2026
ea396bf
Fix Coll.updateMany JIT-cost: perChunkCost 1 -> 2
mwaddip Jun 2, 2026
3a918f7
fix: charge per-method FixedCost(5) costKind for numeric 6.0 methods
mwaddip Jun 2, 2026
f5cd2bd
fix: charge Scala nbits costKinds for Global.encodeNbits/decodeNbits
mwaddip Jun 2, 2026
728be33
fix: charge Scala PowHitCostKind formula for Global.powHit
mwaddip Jun 2, 2026
503ece0
fix: charge Scala PerItemCost costKinds for Coll reverse/startsWith/e…
mwaddip Jun 2, 2026
76b7e0e
fix: charge Scala FixedCost costKinds for UnsignedBigInt modular methods
mwaddip Jun 2, 2026
220540e
fix: charge Scala SigmaByteWriter per-put costs for Global.serialize
mwaddip Jun 2, 2026
d8847ab
fix: charge Scala serialize per-put costs for GroupElement/SigmaProp/…
mwaddip Jun 2, 2026
18954ed
fix: charge Scala serialize per-put costs for Box (box-level puts)
mwaddip Jun 2, 2026
07cd5a8
fix: charge Scala serialize cost for Box register type prefix
mwaddip Jun 2, 2026
3f35211
fix: charge Scala serialize per-put costs for AvlTree
mwaddip Jun 3, 2026
5e52981
fix: charge Scala serialize cost for fast-path register type codes
mwaddip Jun 3, 2026
fb0fba1
fix: stop over-charging serialize cost for box creationHeight
mwaddip Jun 3, 2026
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
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, 1000000);
parameters_table.insert(Parameter::BlockVersion, 1);
Self { parameters_table }
}
Expand Down
6 changes: 6 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,12 @@ pub enum TxValidationError {
/// Verifying input script failed
#[error("Verifier error on input {0}: {1}")]
VerifierError(usize, VerifierError),
/// Transaction initialization cost (INTERPRETER_INIT_COST + per-input/output/
/// data-input/token structural cost) already exceeds the per-tx JIT cost limit
/// before any input script evaluation begins. The u64 is the init cost in
/// JitCost units (block cost × 10).
#[error("Init cost {0} exceeds tx cost limit")]
InitCostExceeded(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: core::cell::Cell::new(0),
jit_cost_limit: None,
})
}
// Updates a Context, changing its self box and context extension to transaction.inputs[i]
Expand Down
205 changes: 192 additions & 13 deletions ergo-lib/src/wallet/tx_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,24 @@ use hashbrown::hash_map::Entry;
use hashbrown::HashMap;

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::reduce_to_crypto;
use ergotree_interpreter::sigma_protocol::crypto_cost::estimate_crypto_cost;
use ergotree_interpreter::sigma_protocol::verifier::{
verify_signature, VerificationResult, VerifierError,
};
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 thiserror::Error;

use super::signing::make_context;
use super::signing::{make_context, update_context};

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

/// Fixed JIT cost (in block-cost units) charged once per transaction for interpreter
/// initialization, matching Scala's `interpreterInitCost`.
pub(crate) const INTERPRETER_INIT_COST: u64 = 10_000;

/// Count (total_entries, distinct_token_count) across the given boxes' token sets.
fn count_tokens(boxes: &[ErgoBox]) -> (u64, u64) {
let mut total_entries = 0u64;
let mut distinct: hashbrown::HashSet<TokenId> = hashbrown::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)
}

/// Per-tx init cost in block-cost units. Port of PR 846's `compute_tx_init_cost`,
/// which was validated against 19,549 mainnet txs and matches Scala's
/// `ErgoTransaction.computeInitiationCost`.
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());
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 accumulated script evaluation cost (in block cost units).
// 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 +190,82 @@ 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 cost tracking.
// This is usually the most expensive check so it's done last.
let bytes_to_sign = self.spending_tx.bytes_to_sign()?;
let mut context = make_context(state_context, self, 0)?;
// Per-tx cost budget: MaxBlockCost * 10 (block cost → JitCost scale). The
// accumulator on `context` is NOT reset between inputs, so this limit is
// enforced cumulatively across the whole tx — matching Scala's semantics.
// Resetting per-input would let an attacker bypass MaxBlockCost by splitting
// expensive work across many inputs.
context.jit_cost_limit = Some(state_context.parameters.max_block_cost() as u64 * 10);

// Charge per-tx init cost (gaps S1 + S2): interpreter baseline + per-input,
// per-data-input, per-output, per-token structural costs. Goes into the
// shared accumulator before per-input work so subsequent add_jit_cost calls
// still see the correct cumulative floor. Reject upfront if init alone
// exceeds the tx budget — we can't honestly blame any specific input.
let init_cost_block = compute_tx_init_cost(
&self.spending_tx,
self.boxes_to_spend.as_slice(),
&state_context.parameters,
);
let init_cost_jit = init_cost_block.saturating_mul(10);
context
.add_jit_cost(init_cost_jit)
.map_err(|_| TxValidationError::InitCostExceeded(init_cost_jit))?;

let mut total_cost: u64 = init_cost_block;
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));
update_context(&mut context, self, input_idx)?;
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: consensus-exempted from script eval + sigma verification.
if try_spend_storage_rent(input, input_box, state_context, &context).is_some() {
continue;
}

// Reduce the ErgoTree to a SigmaBoolean. Eval cost accumulates on the
// shared `context.jit_cost` so the limit check fires if cumulative
// per-tx JIT cost overflows MaxBlockCost*10 (see gap S4 above).
let reduction = reduce_to_crypto(&input_box.ergo_tree, &context)
.map_err(|e| TxValidationError::VerifierError(input_idx, e.into()))?;

// Charge sigma-protocol verification cost through the same shared
// accumulator (gap S3).
let crypto_cost_jit = estimate_crypto_cost(&reduction.sigma_prop);
context.add_jit_cost(crypto_cost_jit).map_err(|e| {
TxValidationError::VerifierError(input_idx, VerifierError::EvalError(e.into()))
})?;

let verified = verify_signature(
reduction.sigma_prop.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,
},
));
}

total_cost += reduction.cost + (crypto_cost_jit / 10);
}
Ok(())
Ok(total_cost)
}
}

Expand Down Expand Up @@ -653,6 +763,75 @@ mod test {
}
});
}
// Regression for S4 (JIT_COSTING_FIX_PLAN.md): validate() must enforce
// jit_cost_limit against cumulative per-tx cost, not per-input. Pre-fix, each
// input reset the accumulator, letting a tx whose inputs individually fit under
// the limit still exceed MaxBlockCost in aggregate. Use Const(true) inputs
// (5 JitCost each, TrivialProp → 0 crypto cost) and zero-out structural
// Parameters so init cost reduces to the fixed INTERPRETER_INIT_COST baseline;
// then tune max_block_cost so cumulative eval (init + 2 × 5) fits and
// (init + 3 × 5) overflows the per-tx budget.
#[test]
fn test_validate_enforces_cumulative_jit_cost_across_inputs() {
use ergotree_interpreter::eval::EvalError;
use ergotree_interpreter::sigma_protocol::verifier::VerifierError;

// Non-segregated SigmaProp(TrivialProp(true)) tree: the proposition
// reduces via `trivial_reduce`, which charges exactly
// `EVAL_SIGMA_PROP_CONSTANT = 50` JitCost per input. 50 is a clean
// multiple of 10, so the JIT→block-cost round-trip in `Parameters`
// doesn't lose precision — critical for sizing the limit at the
// exact overflow boundary.
let true_tree = ErgoTree::new(
ErgoTreeHeader::v0(false),
&Expr::Const(Constant {
tpe: ergotree_ir::types::stype::SType::SSigmaProp,
v: Literal::SigmaProp(alloc::boxed::Box::new(
ergotree_ir::sigma_protocol::sigma_boolean::SigmaProp::new(
ergotree_ir::sigma_protocol::sigma_boolean::SigmaBoolean::TrivialProp(true),
),
)),
}),
)
.unwrap();
proptest!(|((boxes, tx) in valid_transaction_gen_with_tree(true_tree))| {
prop_assume!(tx.inputs.len() >= 3);

let mut state_context: ErgoStateContext = force_any_val();
let tx_context = TransactionContext::new(tx.clone(), boxes.clone(), vec![]).unwrap();
// Baseline: tx must validate cleanly with default params, otherwise the
// sample failed for non-cost-related reasons — skip.
prop_assume!(tx_context.validate(&state_context).is_ok());

// Zero out structural Parameters so compute_tx_init_cost reduces to the
// fixed INTERPRETER_INIT_COST regardless of tx shape, then size the
// budget to exactly the 3rd-input overflow boundary.
const PER_INPUT_JIT: u64 = 50; // trivial_reduce charges EVAL_SIGMA_PROP_CONSTANT
let init_jit = super::INTERPRETER_INIT_COST * 10;
let limit_jit = init_jit + 2 * PER_INPUT_JIT; // 2 inputs fit, 3 overflow
let mbc = i32::try_from(limit_jit / 10).unwrap();
state_context.parameters = crate::chain::parameters::Parameters::new(
1, 1_250_000, 360, 512 * 1024, mbc, 0, 0, 0, 0,
);
let tx_context = TransactionContext::new(tx, boxes, vec![]).unwrap();
match tx_context.validate(&state_context) {
Err(TxValidationError::VerifierError(_, verr)) => {
let is_cost = match &verr {
VerifierError::EvalError(EvalError::CostError(_)) => true,
VerifierError::EvalError(EvalError::Spanned(e)) => {
matches!(*e.error, EvalError::CostError(_))
}
VerifierError::ErgoTreeError(_)
| VerifierError::EvalError(_)
| VerifierError::SigParsingError(_)
| VerifierError::FiatShamirTreeSerializationError(_) => false,
};
prop_assert!(is_cost, "expected CostError, got {verr:?}");
}
other => panic!("expected cost-limit rejection, got {other:?}"),
}
});
}
#[test]
fn test_monotonic_box_creation() {
let true_tree = ErgoTree::new(
Expand Down Expand Up @@ -732,7 +911,7 @@ 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:?}")
}
Expand Down
Loading
Loading