Block Validation
Nexis Appchain uses the OP Stack fault proof system to ensure all blocks are valid. Unlike traditional consensus where nodes vote on validity, Nexis uses an optimistic approach: blocks are assumed valid unless proven otherwise.
Overview
Optimistic Validation Blocks are assumed valid by default, executed immediately
Challenge Period 7-day window for anyone to dispute invalid state
Fault Proof Game Bisection game narrows disputes to single instruction
Economic Security Bonds ensure honest behavior from proposers and challengers
Validation Architecture
State Proposal System
Output Root Submission
The proposer periodically submits state root commitments to Base L2:
// L2OutputOracle.sol on Base Sepolia
contract L2OutputOracle {
struct OutputProposal {
bytes32 outputRoot;
uint128 timestamp;
uint128 l2BlockNumber;
}
mapping ( uint256 => OutputProposal) public proposals;
uint256 public latestProposalIndex;
uint256 public constant SUBMISSION_INTERVAL = 120 ; // blocks
uint256 public constant FINALIZATION_PERIOD = 7 days ;
uint256 public constant PROPOSAL_BOND = 1 ether ;
event OutputProposed (
bytes32 indexed outputRoot ,
uint256 indexed l2BlockNumber ,
uint256 indexed proposalIndex ,
uint256 timestamp
);
function proposeL2Output (
bytes32 _outputRoot ,
uint256 _l2BlockNumber ,
bytes32 _l1BlockHash ,
uint256 _l1BlockNumber
) external payable {
require ( msg .value == PROPOSAL_BOND, "Insufficient bond" );
require (
_l2BlockNumber == latestProposalL2Block () + SUBMISSION_INTERVAL,
"Invalid block number"
);
uint256 proposalIndex = ++ latestProposalIndex;
proposals[proposalIndex] = OutputProposal ({
outputRoot : _outputRoot,
timestamp : uint128 ( block .timestamp),
l2BlockNumber : uint128 (_l2BlockNumber)
});
emit OutputProposed (
_outputRoot,
_l2BlockNumber,
proposalIndex,
block .timestamp
);
}
function isFinalized ( uint256 proposalIndex ) public view returns ( bool ) {
OutputProposal memory proposal = proposals[proposalIndex];
return block .timestamp >= proposal.timestamp + FINALIZATION_PERIOD;
}
}
Output Root Calculation
The output root is a commitment to the L3 state at a specific block:
// Output root structure
type OutputRoot struct {
Version [ 32 ] byte // Always 0x0000...0000
StateRoot [ 32 ] byte // L3 state root
MessagePasser [ 32 ] byte // Withdrawal storage root
BlockHash [ 32 ] byte // L3 block hash
}
// Compute output root
func ComputeOutputRoot ( block * types . Block ) common . Hash {
output := OutputRoot {
Version : [ 32 ] byte {},
StateRoot : block . Root (),
MessagePasser : getMessagePasserRoot ( block ),
BlockHash : block . Hash (),
}
// Hash all components
return crypto . Keccak256Hash (
output . Version [:],
output . StateRoot [:],
output . MessagePasser [:],
output . BlockHash [:],
)
}
Derivation and Verification
Any validator can independently derive the correct state and check proposals:
Derivation Process
class BlockValidator :
def __init__ ( self , l2_rpc , l3_rpc ):
self .l2 = Web3(Web3.HTTPProvider(l2_rpc))
self .l3 = Web3(Web3.HTTPProvider(l3_rpc))
self .output_oracle = self .l2.eth.contract(
address = OUTPUT_ORACLE_ADDRESS ,
abi = OUTPUT_ORACLE_ABI
)
def validate_proposal ( self , proposal_index ):
"""Validate a state root proposal"""
# 1. Get proposed output
proposal = self .output_oracle.functions.proposals(proposal_index).call()
proposed_root = proposal[ 0 ]
l2_block = proposal[ 2 ]
print ( f "Validating proposal { proposal_index } for block { l2_block } " )
# 2. Derive correct state from L2 data
derived_state = self .derive_state(l2_block)
# 3. Compute correct output root
correct_root = self .compute_output_root(derived_state)
# 4. Compare
if proposed_root != correct_root:
print ( f "❌ INVALID PROPOSAL DETECTED!" )
print ( f " Proposed: { proposed_root.hex() } " )
print ( f " Correct: { correct_root.hex() } " )
return False
else :
print ( f "✅ Proposal is valid" )
return True
def derive_state ( self , target_block ):
"""Derive L3 state from L2 batches"""
# Get all batch transactions from L2
batches = self .get_batches_up_to(target_block)
# Reconstruct L3 blocks
l3_blocks = []
for batch in batches:
# Decompress and decode
decompressed = zlib.decompress(batch[ 'data' ])
transactions = rlp.decode_list(decompressed)
# Group into blocks (every 2 seconds)
blocks = self .group_into_blocks(transactions)
l3_blocks.extend(blocks)
# Execute all blocks to get state
state = self .execute_blocks(l3_blocks)
return state
def compute_output_root ( self , state ):
"""Compute output root from state"""
return Web3.keccak(
state[ 'version' ] +
state[ 'state_root' ] +
state[ 'message_passer_root' ] +
state[ 'block_hash' ]
)
Automatic Monitoring
Validators run continuous monitoring to detect invalid proposals:
// Validator monitoring service
class ProposalMonitor {
constructor ( l2Provider , l3Provider ) {
this . l2 = l2Provider ;
this . l3 = l3Provider ;
this . oracle = new ethers . Contract (
OUTPUT_ORACLE_ADDRESS ,
OUTPUT_ORACLE_ABI ,
l2Provider
);
}
async start () {
console . log ( 'Starting proposal monitoring...' );
// Listen for new proposals
this . oracle . on ( 'OutputProposed' , async (
outputRoot ,
l2BlockNumber ,
proposalIndex ,
timestamp
) => {
console . log ( `New proposal ${ proposalIndex } for block ${ l2BlockNumber } ` );
// Validate in background
this . validateProposal ( proposalIndex , outputRoot , l2BlockNumber )
. catch ( console . error );
});
// Also check existing pending proposals
setInterval (() => this . checkPendingProposals (), 60000 ); // Every minute
}
async validateProposal ( index , proposedRoot , l2Block ) {
// Derive correct state
const derivedState = await this . deriveState ( l2Block );
const correctRoot = this . computeOutputRoot ( derivedState );
// Compare
if ( proposedRoot !== correctRoot ) {
console . error ( `🚨 INVALID PROPOSAL ${ index } !` );
console . error ( `Proposed: ${ proposedRoot } ` );
console . error ( `Correct: ${ correctRoot } ` );
// Initiate dispute
await this . initiateDispute ( index , correctRoot );
} else {
console . log ( `✅ Proposal ${ index } is valid` );
}
}
async initiateDispute ( proposalIndex , correctRoot ) {
// Submit challenge with bond
const disputeGame = new ethers . Contract (
DISPUTE_GAME_ADDRESS ,
DISPUTE_GAME_ABI ,
this . l2 . getSigner ()
);
const tx = await disputeGame . challenge (
proposalIndex ,
correctRoot ,
{ value: ethers . parseEther ( '1' ) } // Challenge bond
);
await tx . wait ();
console . log ( `Dispute initiated: ${ tx . hash } ` );
}
}
Fault Proof Game
When a validator challenges an invalid proposal, a bisection game begins:
Game Phases
Claim Submission
Challenger submits their claimed correct output root with a bond
Bisection
Proposer and challenger iteratively narrow down the point of disagreement
Single Step Proof
Game converges to a single instruction execution
On-Chain Execution
Disputed instruction is executed on-chain in a VM
Resolution
Winner receives both bonds, loser is slashed
Bisection Algorithm
// DisputeGame.sol
contract DisputeGame {
struct Claim {
uint128 position; // Position in execution trace
bytes32 claim; // State hash at this position
address claimant;
uint256 bond;
}
mapping ( uint256 => Claim[]) public claims;
uint256 public constant MAX_DEPTH = 73 ; // log2(9_000_000_000_000_000)
function attack (
uint256 gameId ,
uint256 claimIndex ,
bytes32 counterClaim
) external payable {
Claim memory parent = claims[gameId][claimIndex];
uint128 newPosition = parent.position / 2 ; // Bisect
claims[gameId]. push ( Claim ({
position : newPosition,
claim : counterClaim,
claimant : msg.sender ,
bond : msg .value
}));
emit Attacked (gameId, claimIndex, newPosition, counterClaim);
// If at max depth, execute single step
if ( depth (gameId) >= MAX_DEPTH) {
executeSingleStep (gameId);
}
}
function executeSingleStep ( uint256 gameId ) internal {
// Get the leaf claims (deepest in tree)
Claim memory claim1 = getLeaf (gameId, 0 );
Claim memory claim2 = getLeaf (gameId, 1 );
// Load execution trace at disputed step
bytes memory preState = loadPreState (claim1.position);
// Execute one instruction in on-chain VM
bytes32 postState = VM. executeStep (preState);
// Determine winner
address winner;
if (postState == claim1.claim) {
winner = claim1.claimant;
} else if (postState == claim2.claim) {
winner = claim2.claimant;
} else {
revert ( "Neither claim is correct" );
}
// Resolve game
resolveGame (gameId, winner);
}
function resolveGame ( uint256 gameId , address winner ) internal {
// Transfer both bonds to winner
uint256 totalBond = getTotalBonds (gameId);
payable (winner). transfer (totalBond);
// Update output oracle
if (winner == challenger) {
outputOracle. deleteProposal (gameId);
}
emit GameResolved (gameId, winner);
}
}
Bisection Example
Let’s trace through a dispute:
Initial disagreement:
Position 0: Block 1000 -> Proposer claims: 0xAAA...
Position 100: Block 1100 -> Challenger claims: 0xBBB...
Round 1: Bisect at position 50
Proposer: State at block 1050 = 0xCCC...
Challenger: State at block 1050 = 0xDDD...
→ Disagreement is in first half (blocks 1000-1050)
Round 2: Bisect at position 25
Proposer: State at block 1025 = 0xEEE...
Challenger: State at block 1025 = 0xEEE... (agree!)
→ Disagreement is in blocks 1025-1050
Round 3: Bisect at position 37
Proposer: State at block 1037 = 0xFFF...
Challenger: State at block 1037 = 0xGGG...
→ Continue narrowing...
... 73 rounds later ...
Final: Single block (e.g., block 1033)
Proposer claims executing block 1033 results in state 0xXXX...
Challenger claims it results in state 0xYYY...
→ Execute block 1033 on-chain in VM
→ Actual result: 0xYYY...
→ Challenger wins, gets both bonds
Challenge Period
All proposals must survive a 7-day challenge period before finalization:
Timeline
Day 0: Proposer submits output root
Challenge period begins
Day 0-7: Anyone can challenge if invalid
- Must post 1 ETH bond
- Bisection game starts
- Can take hours to days
Day 7+: If no valid challenges:
- Proposal finalizes
- Withdrawals can proceed
- State is "finalized"
Finalization Check
// Check if a proposal is finalized
function canFinalize ( uint256 proposalIndex ) public view returns ( bool ) {
OutputProposal memory proposal = proposals[proposalIndex];
// Check time passed
if ( block .timestamp < proposal.timestamp + FINALIZATION_PERIOD) {
return false ;
}
// Check no active disputes
if ( hasActiveDispute (proposalIndex)) {
return false ;
}
return true ;
}
// Process withdrawal (only after finalization)
function finalizeWithdrawal (
uint256 proposalIndex ,
WithdrawalTransaction memory withdrawal
) external {
require ( canFinalize (proposalIndex), "Not finalized" );
// Verify withdrawal was included in finalized state
bytes32 outputRoot = proposals[proposalIndex].outputRoot;
require ( verifyWithdrawal (withdrawal, outputRoot), "Invalid withdrawal" );
// Execute withdrawal
( bool success, ) = withdrawal.target.call{value : withdrawal.value}(
withdrawal.data
);
require (success, "Withdrawal failed" );
}
Withdrawal Delays
Due to the challenge period, withdrawing from Nexis L3 to Base L2 (and subsequently to Ethereum L1) requires waiting:
Withdrawal Process
Initiate on L3
User calls L2ToL1MessagePasser.initiateWithdrawal() on Nexis
Wait for Proposal
Wait for proposer to include withdrawal in a state root (~4 minutes)
Challenge Period
Wait 7 days for challenge period to pass
Prove on L2
Submit Merkle proof of withdrawal to Base L2
Finalize
Claim withdrawal after proof verification
Withdrawal Example
// Step 1: Initiate withdrawal on L3
interface IL2ToL1MessagePasser {
function initiateWithdrawal (
address _target ,
uint256 _gasLimit ,
bytes memory _data
) external payable ;
}
// User initiates
l2ToL1MessagePasser.initiateWithdrawal{value : 1 ether }(
userAddress, // Receive on L2
100000 , // Gas limit
"" // No calldata
);
// Step 2-3: Wait 7 days + 4 minutes
// Step 4: Prove withdrawal on L2
interface IOptimismPortal {
function proveWithdrawalTransaction (
Types . WithdrawalTransaction memory _tx ,
uint256 _l2OutputIndex ,
Types . OutputRootProof calldata _outputRootProof ,
bytes [] calldata _withdrawalProof
) external ;
}
// Submit proof
optimismPortal. proveWithdrawalTransaction (
withdrawalTx,
outputIndex,
outputRootProof,
merkleProof
);
// Step 5: Finalize (immediately after proof)
optimismPortal. finalizeWithdrawalTransaction (withdrawalTx);
Fast Withdrawal Services
To avoid 7-day delays, users can use fast withdrawal services (liquidity providers):
// Fast withdrawal using a liquidity provider
class FastWithdrawalService {
async withdraw ( amount , l3Address ) {
// 1. User initiates withdrawal on L3
const withdrawalTx = await l3Contract . initiateWithdrawal (
amount ,
{ from: l3Address }
);
// 2. Liquidity provider immediately pays user on L2
const lpPayment = await this . requestLiquidity (
l3Address ,
amount ,
withdrawalTx . hash
);
// 3. LP waits 7 days and claims the withdrawal
// (User gets funds immediately, LP gets reimbursed after delay)
return {
immediate: lpPayment ,
finalizes: Date . now () + 7 * 24 * 60 * 60 * 1000
};
}
async requestLiquidity ( user , amount , withdrawalHash ) {
// User pays small fee (e.g., 0.1%) to LP
const fee = amount * 0.001 ;
const netAmount = amount - fee ;
// LP pays user on L2 immediately
const tx = await this . l2Contract . transfer ( user , netAmount );
// LP records the withdrawal to claim later
await this . db . recordPendingClaim ( withdrawalHash , amount );
return tx ;
}
}
Security Considerations
Economic Security
The fault proof system is secured by economic incentives:
Role Bond Risk Reward Proposer 1 ETH Slashed if invalid Keep bond if valid Challenger 1 ETH Lost if wrong challenge Earn proposer’s bond
Attack Cost : To finalize an invalid state, attacker must:
Post 1 ETH bond as proposer
Defeat all challengers in bisection games
Wait 7 days without valid challenges
Defense Cost : Any honest validator can challenge with 1 ETH
Validator Incentives
Running a validator is currently altruistic, but benefits include:
Protect your own assets/contracts
Earn trust as a verifier
Future: Direct rewards for successful challenges
Griefing Resistance
The system is designed to resist griefing attacks:
// Anti-griefing measures
contract DisputeGame {
uint256 public constant MIN_BOND = 1 ether ;
uint256 public constant MAX_GAME_DURATION = 30 days ;
uint256 public constant MOVE_DEADLINE = 3 days ;
// Griefer must keep posting bonds
function requireBond () internal {
require ( msg .value >= MIN_BOND, "Insufficient bond" );
// Bond is locked until game resolves
}
// Game expires if griefer stops responding
function checkTimeout ( uint256 gameId ) public {
Game memory game = games[gameId];
if ( block .timestamp > game.lastMove + MOVE_DEADLINE) {
// Griefer didn't respond, they lose
resolveByTimeout (gameId);
}
}
}
Validator Dashboard
// Real-time validation monitoring
class ValidationDashboard {
async getStatus () {
const latest = await this . oracle . latestProposalIndex ();
const proposals = [];
for ( let i = latest ; i > latest - 10 ; i -- ) {
const proposal = await this . oracle . proposals ( i );
const age = Date . now () - proposal . timestamp * 1000 ;
const finalized = age > 7 * 24 * 60 * 60 * 1000 ;
// Validate if not finalized
const valid = finalized ? null : await this . validate ( i );
proposals . push ({
index: i ,
block: proposal . l2BlockNumber ,
age: this . formatDuration ( age ),
finalized ,
valid
});
}
return proposals ;
}
render () {
const status = await this . getStatus ();
console . table ( status );
}
}
Learn More
Always wait for finalization before processing high-value operations! The 7-day challenge period is a critical security feature.