Chapter 29: Soft-Fork Mechanism
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 3 for ErgoTree version field and header format
- Chapter 7 for serialization framework
- Chapter 24 for validation rules
Learning Objectives
By the end of this chapter, you will be able to:
- Explain version context and how script versioning enables protocol upgrades
- Implement validation rules with configurable status (enabled, disabled, soft-fork)
- Handle unknown opcodes gracefully to support future soft-forks
- Describe the transition from AOT (Ahead-of-Time) to JIT (Just-in-Time) costing
Version Context Architecture
The soft-fork mechanism enables protocol upgrades without breaking consensus12:
Soft-Fork Version Architecture
══════════════════════════════════════════════════════════════════
┌─────────────────────────────────────────────────────────────────┐
│ Block Header │
│ │
│ Block Version: 1, 2, 3, 4 │
│ │
│ Activated Script Version = Block Version - 1 │
└────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ErgoTree Header │
│ │
│ 7 6 5 4 3 2 1 0 │
│ ├───┼───┼───┼───┼───┼───┼───┤ │
│ │ M │ G │ C │ S │ Z │ V │ V │ V │
│ └───┴───┴───┴───┴───┴───┴───┘ │
│ M = More bytes follow │
│ G = GZIP (reserved) │
│ C = Context costing (reserved) │
│ S = Constant segregation │
│ Z = Size included │
│ V = Version (0-7) │
└─────────────────────────────────────────────────────────────────┘
ErgoTree Version
Script version is encoded in header bits 0-234:
const ErgoTreeVersion = struct {
value: u3, // 0-7
const VERSION_MASK: u8 = 0x07;
/// Version 0 - Initial mainnet (v3.x)
pub const V0 = ErgoTreeVersion{ .value = 0 };
/// Version 1 - Height monotonicity (v4.x)
pub const V1 = ErgoTreeVersion{ .value = 1 };
/// Version 2 - JIT interpreter (v5.x)
pub const V2 = ErgoTreeVersion{ .value = 2 };
/// Version 3 - Sub-blocks, new ops (v6.x)
pub const V3 = ErgoTreeVersion{ .value = 3 };
/// Maximum supported script version
pub const MAX_SCRIPT_VERSION = V3;
/// Parse version from header byte
pub fn parseVersion(header_byte: u8) ErgoTreeVersion {
return .{ .value = @truncate(header_byte & VERSION_MASK) };
}
pub fn toU8(self: ErgoTreeVersion) u8 {
return @as(u8, self.value);
}
};
ErgoTree Header
Header byte encoding with flags56:
const ErgoTreeHeader = struct {
version: ErgoTreeVersion,
is_constant_segregation: bool,
has_size: bool,
const CONSTANT_SEGREGATION_FLAG: u8 = 0b0001_0000;
const HAS_SIZE_FLAG: u8 = 0b0000_1000;
/// Parse header from byte
pub fn parse(header_byte: u8) !ErgoTreeHeader {
return .{
.version = ErgoTreeVersion.parseVersion(header_byte),
.is_constant_segregation = (header_byte & CONSTANT_SEGREGATION_FLAG) != 0,
.has_size = (header_byte & HAS_SIZE_FLAG) != 0,
};
}
/// Serialize header to byte
pub fn serialize(self: *const ErgoTreeHeader) u8 {
var header_byte: u8 = self.version.toU8();
if (self.is_constant_segregation) {
header_byte |= CONSTANT_SEGREGATION_FLAG;
}
if (self.has_size) {
header_byte |= HAS_SIZE_FLAG;
}
return header_byte;
}
/// Create v0 header
pub fn v0(constant_segregation: bool) ErgoTreeHeader {
return .{
.version = ErgoTreeVersion.V0,
.is_constant_segregation = constant_segregation,
.has_size = false,
};
}
/// Create v1 header (size is mandatory)
pub fn v1(constant_segregation: bool) ErgoTreeHeader {
return .{
.version = ErgoTreeVersion.V1,
.is_constant_segregation = constant_segregation,
.has_size = true,
};
}
};
Version Context
Thread-local context tracks activated and tree versions78:
const VersionContext = struct {
activated_version: u8,
ergo_tree_version: u8,
/// JIT costing activation version (v5.0)
const JIT_ACTIVATION_VERSION: u8 = 2;
/// v6.0 soft-fork version
const V6_SOFT_FORK_VERSION: u8 = 3;
pub fn init(activated: u8, tree: u8) !VersionContext {
// ergoTreeVersion must never exceed activatedVersion
if (activated >= JIT_ACTIVATION_VERSION and tree > activated) {
return error.InvalidVersionContext;
}
return .{
.activated_version = activated,
.ergo_tree_version = tree,
};
}
/// True if JIT costing is activated (v5.0+)
pub fn isJitActivated(self: *const VersionContext) bool {
return self.activated_version >= JIT_ACTIVATION_VERSION;
}
/// True if v6.0 protocol is activated
pub fn isV6Activated(self: *const VersionContext) bool {
return self.activated_version >= V6_SOFT_FORK_VERSION;
}
/// True if v3+ ErgoTree version
pub fn isV3OrLaterErgoTree(self: *const VersionContext) bool {
return self.ergo_tree_version >= V6_SOFT_FORK_VERSION;
}
};
/// Thread-local version context
threadlocal var current_context: ?VersionContext = null;
pub fn withVersions(
activated: u8,
tree: u8,
comptime block: fn (*VersionContext) anyerror!void,
) !void {
const ctx = try VersionContext.init(activated, tree);
const prev = current_context;
current_context = ctx;
defer current_context = prev;
try block(&ctx);
}
pub fn currentContext() !*const VersionContext {
return &(current_context orelse return error.VersionContextNotSet);
}
Version History
Protocol Version History
══════════════════════════════════════════════════════════════════
┌─────────────┬────────────────┬──────────────┬────────────────────┐
│ Block Ver │ Script Ver │ Protocol │ Features │
├─────────────┼────────────────┼──────────────┼────────────────────┤
│ 1 │ 0 │ v3.x │ Initial mainnet │
│ 2 │ 1 │ v4.x │ Height monotonicity│
│ 3 │ 2 │ v5.x │ JIT interpreter │
│ 4 │ 3 │ v6.x │ Sub-blocks, new ops│
└─────────────┴────────────────┴──────────────┴────────────────────┘
Relation: activated_script_version = block_version - 1
Rule Status
Validation rules have configurable status910:
const RuleStatus = union(enum) {
/// Default: rule is active and enforced
enabled,
/// Rule is disabled (via voting)
disabled,
/// Rule replaced by new rule
replaced: struct { new_rule_id: u16 },
/// Rule parameters changed
changed: struct { new_value: []const u8 },
const StatusCode = enum(u8) {
enabled = 1,
disabled = 2,
replaced = 3,
changed = 4,
};
pub fn statusCode(self: RuleStatus) StatusCode {
return switch (self) {
.enabled => .enabled,
.disabled => .disabled,
.replaced => .replaced,
.changed => .changed,
};
}
};
Validation Rules
Rules define validation behavior with soft-fork support1112:
const ValidationRule = struct {
id: u16,
description: []const u8,
soft_fork_checker: SoftForkChecker,
checked: bool = false,
pub fn checkRule(self: *ValidationRule, settings: *const ValidationSettings) !void {
if (!self.checked) {
if (settings.getStatus(self.id) == null) {
return error.ValidationRuleNotFound;
}
self.checked = true;
}
}
pub fn throwValidationException(
self: *const ValidationRule,
cause: anyerror,
args: []const u8,
) ValidationError {
return ValidationError{
.rule = self,
.args = args,
.cause = cause,
};
}
};
const ValidationError = struct {
rule: *const ValidationRule,
args: []const u8,
cause: anyerror,
};
Core Validation Rules
const ValidationRules = struct {
const FIRST_RULE_ID: u16 = 1000;
/// Check primitive type code is valid
pub const CheckPrimitiveTypeCode = ValidationRule{
.id = 1007,
.description = "Check primitive type code is supported or added via soft-fork",
.soft_fork_checker = .code_added,
};
/// Check non-primitive type code is valid
pub const CheckTypeCode = ValidationRule{
.id = 1008,
.description = "Check non-primitive type code is supported or added via soft-fork",
.soft_fork_checker = .code_added,
};
/// Check data can be serialized for type
pub const CheckSerializableTypeCode = ValidationRule{
.id = 1009,
.description = "Check data values of type can be serialized",
.soft_fork_checker = .when_replaced,
};
/// Check reader position limit
pub const CheckPositionLimit = ValidationRule{
.id = 1014,
.description = "Check Reader position limit",
.soft_fork_checker = .when_replaced,
};
};
Soft-Fork Checkers
Detect soft-fork conditions from validation failures1314:
const SoftForkChecker = enum {
none,
when_replaced,
code_added,
pub fn isSoftFork(
self: SoftForkChecker,
settings: *const ValidationSettings,
rule_id: u16,
status: RuleStatus,
args: []const u8,
) bool {
return switch (self) {
.none => false,
.when_replaced => switch (status) {
.replaced => true,
else => false,
},
.code_added => switch (status) {
.changed => |c| std.mem.indexOf(u8, c.new_value, args) != null,
else => false,
},
};
}
};
Validation Settings
Configurable settings from blockchain state1516:
const ValidationSettings = struct {
rules: std.AutoHashMap(u16, struct { rule: *ValidationRule, status: RuleStatus }),
pub fn getStatus(self: *const ValidationSettings, id: u16) ?RuleStatus {
if (self.rules.get(id)) |entry| {
return entry.status;
}
return null;
}
pub fn updated(self: *const ValidationSettings, id: u16, new_status: RuleStatus) !ValidationSettings {
var new_rules = try self.rules.clone();
if (new_rules.getPtr(id)) |entry| {
entry.status = new_status;
}
return .{ .rules = new_rules };
}
/// Check if exception represents a soft-fork condition
pub fn isSoftFork(self: *const ValidationSettings, ve: ValidationError) bool {
const entry = self.rules.get(ve.rule.id) orelse return false;
// Don't tolerate replaced v5.0 rules after v6.0 activation
switch (entry.status) {
.replaced => {
const ctx = currentContext() catch return false;
if (ctx.isV6Activated() and
(ve.rule.id == 1011 or ve.rule.id == 1007 or ve.rule.id == 1008))
{
return false;
}
return true;
},
else => return entry.rule.soft_fork_checker.isSoftFork(
self,
ve.rule.id,
entry.status,
ve.args,
),
}
}
};
Soft-Fork Execution Wrapper
Execute code with soft-fork fallback:
pub fn trySoftForkable(
comptime T: type,
settings: *const ValidationSettings,
when_soft_fork: T,
block: fn () anyerror!T,
) T {
return block() catch |err| {
if (@errorCast(ValidationError, err)) |ve| {
if (settings.isSoftFork(ve)) {
return when_soft_fork;
}
}
return err;
};
}
// Usage: handling unknown opcodes
fn deserializeValue(
reader: *Reader,
settings: *const ValidationSettings,
) !Value {
return trySoftForkable(
Value,
settings,
// Soft-fork fallback: return unit placeholder
Value.unit,
// Normal deserialization
struct {
fn f() !Value {
const op_code = try reader.readByte();
const serializer = getSerializer(op_code) orelse
return error.UnknownOpCode;
return serializer.parse(reader);
}
}.f,
);
}
AOT to JIT Transition
Script Validation Rules Across Versions
══════════════════════════════════════════════════════════════════
Rule │ Block │ Block Type │ Script │ v4.0 Action │ v5.0 Action
─────┼───────┼────────────┼────────┼─────────────────┼─────────────
1 │ 1,2 │ candidate │ v0/v1 │ AOT-cost,verify │ AOT-cost,verify
2 │ 1,2 │ mined │ v0/v1 │ AOT-cost,verify │ AOT-cost,verify
3 │ 1,2 │ candidate │ v2 │ skip-pool-tx │ skip-pool-tx
4 │ 1,2 │ mined │ v2 │ skip-reject │ skip-reject
─────┼───────┼────────────┼────────┼─────────────────┼─────────────
5 │ 3 │ candidate │ v0/v1 │ skip-pool-tx │ JIT-verify
6 │ 3 │ mined │ v0/v1 │ skip-accept │ JIT-verify
7 │ 3 │ candidate │ v2 │ skip-pool-tx │ JIT-verify
8 │ 3 │ mined │ v2 │ skip-accept │ JIT-verify
Actions:
AOT-cost,verify Cost estimation + verification using v4.0 AOT
JIT-verify Verification using v5.0 JIT interpreter
skip-pool-tx Skip mempool transaction (can't handle)
skip-accept Accept block without verification (trust majority)
skip-reject Reject transaction/block (invalid version)
Consensus Equivalence Properties
For safe transition between interpreter versions:
// Property 1: AOT-verify preserved between releases
// forall s:ScriptV0/V1, R4.0-AOT-verify(s) == R5.0-AOT-verify(s)
// Property 2: AOT-cost preserved
// forall s:ScriptV0/V1, R4.0-AOT-cost(s) == R5.0-AOT-cost(s)
// Property 3: JIT can replace AOT
// forall s:ScriptV0/V1, R5.0-JIT-verify(s) == R4.0-AOT-verify(s)
// Property 4: JIT cost bounded by AOT
// forall s:ScriptV0/V1, R5.0-JIT-cost(s) <= R4.0-AOT-cost(s)
// Property 5: ScriptV2 rejected before soft-fork
// forall s:ScriptV2, if not SF active => reject
Version-Aware Interpreter
pub fn verify(
ergo_tree: *const ErgoTree,
ctx: *const Context,
) !bool {
const script_version = ergo_tree.header.version;
const activated_version = ctx.activatedScriptVersion();
// Execute with proper version context
var version_ctx = try VersionContext.init(
activated_version.toU8(),
script_version.toU8(),
);
const prev = current_context;
current_context = version_ctx;
defer current_context = prev;
// Version-specific execution
if (version_ctx.isJitActivated()) {
return verifyJit(ergo_tree, ctx);
} else {
return verifyAot(ergo_tree, ctx);
}
}
fn verifyJit(tree: *const ErgoTree, ctx: *const Context) !bool {
const reduced = try fullReduction(tree, ctx);
return verifySignature(reduced, ctx.messageToSign());
}
fn verifyAot(tree: *const ErgoTree, ctx: *const Context) !bool {
// Legacy AOT interpreter path
const result = try aotEvaluate(tree, ctx);
return verifySignature(result, ctx.messageToSign());
}
Block Extension Voting
Rule status changes via blockchain extension voting:
Extension Voting Flow
══════════════════════════════════════════════════════════════════
┌────────────────────────────────────────────────────────────────────┐
│ Block Extension Section │
│ │
│ Key (2 bytes) │ Value │
│ ─────────────────┼─────────────────────────────────────────────── │
│ Rule ID │ RuleStatus + data │
│ 0x03EF (1007) │ ChangedRule([0x5A, 0x5B]) │
│ │ (new opcodes 0x5A, 0x5B allowed) │
└────────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ Voting Epoch │
│ │
│ Epoch 1: □ □ □ ■ □ ■ ■ □ ■ ■ (5/10 = 50%) │
│ Epoch 2: ■ ■ □ ■ ■ ■ □ ■ ■ ■ (8/10 = 80%) │
│ Epoch 3: ■ ■ ■ ■ ■ □ ■ ■ ■ ■ (9/10 = 90%) → ACTIVATED │
└────────────────────────────────────────────────────────────────────┘
New Opcode Addition:
1. Before soft-fork: Unknown opcode → ValidationException
2. Extension update: ChangedRule(Array(newOpcode)) for rule 1001
3. After activation: Old nodes recognize soft-fork via SoftForkWhenCodeAdded
4. Result: Old nodes skip verification; new nodes execute new opcode
Unknown Opcode Handling
fn handleUnknownOpcode(
reader: *Reader,
settings: *const ValidationSettings,
op_code: u8,
) !Expr {
// Check if this is a soft-fork condition
const rule = &ValidationRules.CheckTypeCode;
const status = settings.getStatus(rule.id) orelse return error.RuleNotFound;
switch (status) {
.changed => |c| {
// Check if opcode was added via soft-fork
if (std.mem.indexOfScalar(u8, c.new_value, op_code) != null) {
// Soft-fork: skip remaining bytes, return placeholder
reader.skipToEnd();
return Expr{ .constant = Constant.unit };
}
},
else => {},
}
// Not a soft-fork condition - fail hard
return rule.throwValidationException(error.UnknownOpCode, &[_]u8{op_code});
}
Summary
- ErgoTreeVersion encodes script version in 3-bit header field (0-7)
- VersionContext tracks activated protocol and tree versions
- RuleStatus can be Enabled, Disabled, Replaced, or Changed
- SoftForkChecker detects soft-fork conditions from validation failures
- trySoftForkable provides graceful fallback for unknown constructs
- AOT→JIT transition demonstrated soft-fork for major interpreter change
- Block extension voting enables parameter changes via miner consensus
- Old nodes remain compatible by trusting majority on unverifiable blocks
Next: Chapter 30: Cross-Platform Support
Scala: VersionContext.scala:17-35
Rust: context.rs:46-53
Scala: ErgoTree.scala (header)
Rust: tree_header.rs:122-145
Scala: ErgoTree.scala:57-84
Rust: tree_header.rs:27-109
Scala: VersionContext.scala:47-56
Rust: context.rs:12-54
Scala: RuleStatus.scala:4-53
Rust: Not directly present in sigma-rust; validation handled at higher level
Scala: ValidationRules.scala:13-51
Rust: Validation rules embedded in deserializer implementations
Scala: SoftForkChecker.scala:4-42
Rust: Soft-fork handling at application level (ergo-lib)
Rust: parameters.rs (blockchain parameters)