Chapter 24: Transaction Validation
PRE-ALPHA WARNING: This is a pre-alpha version of The Sigma Book. Content may be incomplete, inaccurate, or subject to change. Do not use as a source of truth. For authoritative information, consult the official repositories:
- sigmastate-interpreter — Reference Scala implementation
- sigma-rust — Rust implementation
- ergo — Ergo node
Prerequisites
- Chapter 14 for script verification
- Chapter 22 for box structure and tokens
- Chapter 23 for interpreter wrappers
Learning Objectives
By the end of this chapter, you will be able to:
- Explain the two-phase validation pipeline (stateless then stateful)
- Implement stateless validation rules (input/output counts, no duplicates)
- Perform stateful validation with cost accumulation
- Verify ERG and token preservation across transaction inputs and outputs
Validation Pipeline
Transaction validation occurs in two phases12:
Transaction Validation Pipeline
─────────────────────────────────────────────────────
┌─────────────────────────────────────────────────────┐
│ STATELESS VALIDATION │
│ (No blockchain state required) │
├─────────────────────────────────────────────────────┤
│ • Has inputs? (at least 1) │
│ • Has outputs? (at least 1) │
│ • Count limits (≤ 32,767 each) │
│ • No negative values (outputs ≥ 0) │
│ • Output sum valid (no overflow) │
│ • Unique inputs (no double-spend) │
└──────────────────────────┬──────────────────────────┘
│ Pass
▼
┌─────────────────────────────────────────────────────┐
│ STATEFUL VALIDATION │
│ (Requires UTXO state and blockchain context) │
├─────────────────────────────────────────────────────┤
│ 1. Calculate initial cost │
│ 2. Verify outputs (dust, height, size) │
│ 3. Check ERG preservation │
│ 4. Verify asset preservation │
│ 5. Verify input scripts (accumulate cost) │
│ 6. Check re-emission rules (EIP-27) │
└─────────────────────────────────────────────────────┘
Transaction Structure
const Transaction = struct {
/// Transaction ID (Blake2b256 of serialized tx without proofs)
tx_id: TxId,
/// Input boxes to spend (with proofs)
inputs: TxIoVec(Input),
/// Read-only input references (no proofs)
data_inputs: ?TxIoVec(DataInput),
/// Output box candidates
output_candidates: TxIoVec(ErgoBoxCandidate),
/// Materialized outputs (with tx_id and index)
outputs: TxIoVec(ErgoBox),
pub const MAX_OUTPUTS_COUNT: usize = std.math.maxInt(u16);
pub fn init(
inputs: TxIoVec(Input),
data_inputs: ?TxIoVec(DataInput),
output_candidates: TxIoVec(ErgoBoxCandidate),
) !Transaction {
// First pass: compute outputs with zero tx_id
const zero_outputs = try output_candidates.mapIndexed(
struct {
fn f(idx: usize, bc: *const ErgoBoxCandidate) !ErgoBox {
return ErgoBox.fromBoxCandidate(bc, TxId.zero(), @intCast(idx));
}
}.f,
);
var tx = Transaction{
.tx_id = TxId.zero(),
.inputs = inputs,
.data_inputs = data_inputs,
.output_candidates = output_candidates,
.outputs = zero_outputs,
};
// Compute actual tx_id
tx.tx_id = try tx.calcTxId();
// Update outputs with correct tx_id
tx.outputs = try output_candidates.mapIndexed(
struct {
fn f(idx: usize, bc: *const ErgoBoxCandidate) !ErgoBox {
return ErgoBox.fromBoxCandidate(bc, tx.tx_id, @intCast(idx));
}
}.f,
);
return tx;
}
};
Validation Error Types
const TxValidationError = error{
/// Output ERG sum overflow
OutputSumOverflow,
/// Input ERG sum overflow
InputSumOverflow,
/// Same box spent twice
DoubleSpend,
/// ERG not preserved (inputs != outputs)
ErgPreservationError,
/// Token amounts not preserved
TokenPreservationError,
/// Output below dust threshold
DustOutput,
/// Creation height > current height
InvalidHeightError,
/// Creation height < max input height (v3+)
MonotonicHeightError,
/// Negative creation height (v1+)
NegativeHeight,
/// Box exceeds 4KB limit
BoxSizeExceeded,
/// Script exceeds size limit
ScriptSizeExceeded,
/// Script verification failed
ReducedToFalse,
/// Verifier error
VerifierError,
};
Stateless Validation
Checks that don't require blockchain state34:
/// Validate transaction structure without blockchain state
pub fn validateStateless(tx: *const Transaction) TxValidationError!void {
// BoundedVec ensures 1 ≤ count ≤ 32767, so no explicit checks needed
// Check output sum doesn't overflow
var output_sum: i64 = 0;
for (tx.outputs.items()) |out| {
output_sum = std.math.add(i64, output_sum, out.value.as_i64()) catch
return error.OutputSumOverflow;
}
// Check no double-spend (unique inputs)
var seen = std.AutoHashMap(BoxId, void).init(allocator);
defer seen.deinit();
for (tx.inputs.items()) |input| {
const result = seen.getOrPut(input.box_id);
if (result.found_existing) {
return error.DoubleSpend;
}
}
}
Stateless Rules Table
Stateless Validation Rules
─────────────────────────────────────────────────────
Rule Check Limit
─────────────────────────────────────────────────────
txNoInputs inputs.len >= 1 min 1
txNoOutputs outputs.len >= 1 min 1
txManyInputs inputs.len <= MAX 32,767
txManyDataInputs data_inputs.len <= MAX 32,767
txManyOutputs outputs.len <= MAX 32,767
txNegativeOutput all outputs >= 0 -
txOutputSum sum(outputs) no overflow -
txInputsUnique no duplicate box_ids -
─────────────────────────────────────────────────────
Stateful Validation
Requires UTXO state and blockchain context56:
/// Validate transaction against blockchain state
pub fn validateStateful(
tx: *const Transaction,
boxes_to_spend: []const ErgoBox,
data_boxes: []const ErgoBox,
state_context: *const ErgoStateContext,
accumulated_cost: u64,
verifier: *const Verifier,
) TxValidationError!u64 {
const params = state_context.current_parameters;
const max_cost = params.max_block_cost;
// 1. Calculate initial cost
const initial_cost = calculateInitialCost(
tx,
boxes_to_spend.len,
data_boxes.len,
params,
);
var current_cost = accumulated_cost + initial_cost;
if (current_cost > max_cost) {
return error.CostExceeded;
}
// 2. Verify outputs
const max_input_height = maxCreationHeight(boxes_to_spend);
for (tx.outputs.items()) |out| {
try verifyOutput(out, state_context, max_input_height);
}
// 3. Check ERG preservation (inputs must equal outputs exactly)
const input_sum = try sumValues(boxes_to_spend);
const output_sum = try sumValues(tx.outputs.items());
if (input_sum != output_sum) {
return error.ErgPreservationError;
}
// 4. Verify asset preservation
current_cost = try verifyAssets(
tx,
boxes_to_spend,
state_context,
current_cost,
);
// 5. Verify each input script
for (boxes_to_spend, 0..) |box, idx| {
current_cost = try verifyInput(
tx,
boxes_to_spend,
data_boxes,
box,
@intCast(idx),
state_context,
current_cost,
verifier,
);
}
return current_cost;
}
Initial Cost Calculation
Transaction cost starts with fixed overhead78:
const CostConstants = struct {
pub const INTERPRETER_INIT_COST: u64 = 10_000;
};
pub fn calculateInitialCost(
tx: *const Transaction,
inputs_count: usize,
data_inputs_count: usize,
params: *const BlockchainParameters,
) u64 {
return CostConstants.INTERPRETER_INIT_COST +
inputs_count * params.input_cost +
data_inputs_count * params.data_input_cost +
tx.outputs.len() * params.output_cost;
}
Output Verification
Each output must pass structural checks910:
pub fn verifyOutput(
out: *const ErgoBox,
state_context: *const ErgoStateContext,
max_input_height: u32,
) TxValidationError!void {
const params = state_context.current_parameters;
const block_version = state_context.block_version;
const current_height = state_context.current_height;
// Dust check: value >= minimum for box size
const min_value = BoxUtils.minimalErgoAmount(out, params);
if (out.value.as_u64() < min_value) {
return error.DustOutput;
}
// Future check: creation height <= current height
if (out.creation_height > current_height) {
return error.InvalidHeightError;
}
// Non-negative height (after v1)
if (block_version > 1 and out.creation_height < 0) {
return error.NegativeHeight;
}
// Monotonic height (after v3): output height >= max input height
if (block_version >= 3 and out.creation_height < max_input_height) {
return error.MonotonicHeightError;
}
// Size limits
if (out.serializedSize() > ErgoBox.MAX_BOX_SIZE) {
return error.BoxSizeExceeded;
}
if (out.propositionBytes().len > ErgoBox.MAX_SCRIPT_SIZE) {
return error.ScriptSizeExceeded;
}
}
Asset Verification
pub fn verifyAssets(
tx: *const Transaction,
boxes_to_spend: []const ErgoBox,
state_context: *const ErgoStateContext,
current_cost: u64,
) TxValidationError!u64 {
// Extract input assets
var in_assets = std.AutoHashMap(TokenId, u64).init(allocator);
defer in_assets.deinit();
for (boxes_to_spend) |box| {
if (box.tokens) |tokens| {
for (tokens.items()) |token| {
const entry = in_assets.getOrPut(token.token_id);
if (entry.found_existing) {
entry.value_ptr.* += token.amount.value;
} else {
entry.value_ptr.* = token.amount.value;
}
}
}
}
// Extract output assets
var out_assets = std.AutoHashMap(TokenId, u64).init(allocator);
defer out_assets.deinit();
for (tx.outputs.items()) |out| {
if (out.tokens) |tokens| {
for (tokens.items()) |token| {
const entry = out_assets.getOrPut(token.token_id);
if (entry.found_existing) {
entry.value_ptr.* += token.amount.value;
} else {
entry.value_ptr.* = token.amount.value;
}
}
}
}
// First input box ID can mint new tokens
const new_token_id = TokenId{ .digest = tx.inputs.items()[0].box_id.digest };
// Verify each output token
var iter = out_assets.iterator();
while (iter.next()) |entry| {
const out_id = entry.key_ptr.*;
const out_amount = entry.value_ptr.*;
const in_amount = in_assets.get(out_id) orelse 0;
// Output amount <= input amount OR it's a new token
if (out_amount > in_amount) {
if (!std.mem.eql(u8, &out_id.digest, &new_token_id.digest) or out_amount == 0) {
return error.TokenPreservationError;
}
}
}
// Add token access cost
const token_access_cost = calculateTokenAccessCost(
in_assets.count(),
out_assets.count(),
state_context.current_parameters.token_access_cost,
);
return current_cost + token_access_cost;
}
Input Script Verification
The most expensive step—verify each input's script1314:
pub fn verifyInput(
tx: *const Transaction,
boxes_to_spend: []const ErgoBox,
data_boxes: []const ErgoBox,
box: *const ErgoBox,
input_index: u16,
state_context: *const ErgoStateContext,
current_cost: u64,
verifier: *const Verifier,
) TxValidationError!u64 {
const max_cost = state_context.current_parameters.max_block_cost;
const input = tx.inputs.items()[input_index];
const proof = input.spending_proof;
// Check for storage rent spending first
const ctx = try buildContext(
tx,
boxes_to_spend,
data_boxes,
input_index,
state_context,
max_cost - current_cost,
);
if (trySpendStorageRent(&input, box, state_context, &ctx)) |_| {
// Storage rent conditions satisfied, skip script verification
return current_cost + StorageConstants.STORAGE_CONTRACT_COST;
}
// Normal script verification
const result = verifier.verify(
&box.ergo_tree,
&ctx,
proof.proof,
tx.messageToSign(),
) catch |err| {
return error.VerifierError;
};
if (!result.result) {
return error.ReducedToFalse;
}
const new_cost = current_cost + result.cost;
if (new_cost > max_cost) {
return error.CostExceeded;
}
return new_cost;
}
Context Construction
Build evaluation context for input verification1516:
pub fn buildContext(
tx: *const Transaction,
boxes_to_spend: []const ErgoBox,
data_boxes: []const ErgoBox,
input_index: u16,
state_context: *const ErgoStateContext,
cost_limit: u64,
) !Context {
return Context{
.height = state_context.pre_header.height,
.self_box = &boxes_to_spend[input_index],
.inputs = boxes_to_spend,
.data_inputs = data_boxes,
.outputs = tx.outputs.items(),
.pre_header = &state_context.pre_header,
.headers = state_context.headers,
.extension = tx.contextExtension(input_index),
.cost_limit = cost_limit,
.tree_version = @intCast(state_context.block_version - 1),
};
}
Storage Rent Spending
Expired boxes can be spent without script verification1718:
const StorageConstants = struct {
/// Blocks before box is eligible (~4 years)
pub const STORAGE_PERIOD: u32 = 1_051_200;
/// Context extension key for output index
pub const STORAGE_EXTENSION_INDEX: u8 = 127;
/// Cost for storage rent verification
pub const STORAGE_CONTRACT_COST: u64 = 50;
};
pub fn trySpendStorageRent(
input: *const Input,
input_box: *const ErgoBox,
state_context: *const ErgoStateContext,
ctx: *const Context,
) ?void {
// Must have empty proof
if (!input.spending_proof.proof.isEmpty()) return null;
return checkStorageRentConditions(input_box, state_context, ctx);
}
pub fn checkStorageRentConditions(
input_box: *const ErgoBox,
state_context: *const ErgoStateContext,
ctx: *const Context,
) ?void {
// Check time elapsed
const age = ctx.pre_header.height - ctx.self_box.creation_height;
if (age < StorageConstants.STORAGE_PERIOD) return null;
// Get output index from context extension
const output_idx_value = ctx.extension.values.get(
StorageConstants.STORAGE_EXTENSION_INDEX,
) orelse return null;
const output_idx = output_idx_value.extractAs(i16) orelse return null;
const output = ctx.outputs[@intCast(output_idx)];
// Calculate storage fee
const storage_fee = input_box.serializedSize() *
state_context.parameters.storage_fee_factor;
// Dust boxes can always be spent
if (ctx.self_box.value.as_u64() <= storage_fee) return {};
// Verify recreation rules
if (output.creation_height != state_context.pre_header.height) return null;
if (output.value.as_u64() < ctx.self_box.value.as_u64() - storage_fee) return null;
// Registers must be preserved (except R0 value and R3 creation info)
for (0..10) |i| {
const reg_id = RegisterId.fromByte(@intCast(i));
if (reg_id == .r0 or reg_id == .r3) continue;
if (!std.meta.eql(
ctx.self_box.getRegister(reg_id),
output.getRegister(reg_id),
)) return null;
}
return {};
}
Cost Accumulation Flow
Cost Accumulation
─────────────────────────────────────────────────────
Block accumulated cost (from previous txs)
│
├── + INTERPRETER_INIT_COST (10,000)
├── + inputs.len × inputCost
├── + data_inputs.len × dataInputCost
├── + outputs.len × outputCost
│
▼
startCost
│
├── Input[0] script → + scriptCost₀
├── Input[1] script → + scriptCost₁
├── ...
├── Input[n] script → + scriptCostₙ
│
├── Token access → + tokenAccessCost
│
▼
finalCost ≤ maxBlockCost
Each input verification receives remaining budget:
ctx.cost_limit = maxBlockCost - current_cost
Validation Rules Summary
Validation Rules Reference
─────────────────────────────────────────────────────
ID Name Phase Description
─────────────────────────────────────────────────────
100 txNoInputs Stateless ≥1 input
101 txNoOutputs Stateless ≥1 output
102 txManyInputs Stateless ≤32,767
103 txManyDataInputs Stateless ≤32,767
104 txManyOutputs Stateless ≤32,767
105 txNegativeOutput Stateless values ≥ 0
106 txOutputSum Stateless no overflow
107 txInputsUnique Stateless no duplicates
─────────────────────────────────────────────────────
120 txScriptValidation Stateful scripts pass
121 bsBlockTransactionsCost Stateful cost in limit
122 txDust Stateful min value
123 txFuture Stateful valid height
124 txErgPreservation Stateful inputs == outputs
125 txAssetsPreservation Stateful tokens balanced
126 txBoxSize Stateful ≤4KB
127 txReemission Stateful EIP-27 rules
─────────────────────────────────────────────────────
Complete Validation Flow
/// Full transaction validation
pub fn validateTransaction(
tx: *const Transaction,
utxo_state: *const UtxoState,
state_context: *const ErgoStateContext,
verifier: *const Verifier,
accumulated_cost: u64,
) !u64 {
// Phase 1: Stateless validation
try validateStateless(tx);
// Phase 2: Resolve input boxes
var boxes_to_spend: []ErgoBox = &.{};
for (tx.inputs.items()) |input| {
const box = utxo_state.boxById(input.box_id) orelse
return error.InputBoxNotFound;
boxes_to_spend = append(boxes_to_spend, box);
}
// Phase 3: Resolve data input boxes
var data_boxes: []ErgoBox = &.{};
if (tx.data_inputs) |data_inputs| {
for (data_inputs.items()) |data_input| {
const box = utxo_state.boxById(data_input.box_id) orelse
return error.DataInputBoxNotFound;
data_boxes = append(data_boxes, box);
}
}
// Phase 4: Stateful validation
return validateStateful(
tx,
boxes_to_spend,
data_boxes,
state_context,
accumulated_cost,
verifier,
);
}
Summary
- Two-phase validation: Stateless (structural) then stateful (UTXO-dependent)
- Stateless: Count limits, no negatives, no overflow, unique inputs
- Stateful: Cost tracking, output checks, preservation rules, script verification
- Cost accumulation: Tracks across inputs, bounded by maxBlockCost
- Storage rent: Expired boxes (~4 years) spendable by anyone with recreation
- Asset preservation: ERG exactly preserved (inputs == outputs), tokens can only decrease (or mint new)
Next: Chapter 25: Cost Limits and Parameters
Scala: ErgoTransaction.scala:57-64
Rust: transaction.rs:60-96
Scala: ErgoTransaction.scala:91-115
Rust: transaction.rs:200-300
Scala: ErgoInterpreter.scala:93-96
Rust: signing.rs:143-180
Scala: ErgoContext.scala:12-29
Rust: signing.rs:46-116
Scala: ErgoInterpreter.scala:42-55
Rust: storage_rent.rs:12-78