Consensus Details

Shell Chain uses WPoA (Weighted Proof of Authority) as its current consensus model, paired with async STARK proof aggregation.


Table of Contents


WPoA Engine

Shell Chain's live WPoaEngine uses weighted authority selection with deterministic slot scheduling. A block is valid if:

  1. The proposer is in the current ValidatorSet.
  2. The block header authority field matches the proposer's address.
  3. All transactions have valid ML-DSA-65 signatures (or supported legacy/fallback PQ signatures) verified via WitnessBundle.
  4. The block timestamp is within the allowed drift window.
  5. State root and witness root match the executed result.

Demand-driven production and rewards

Testnet/mainnet production ticks every 2 seconds, but validators only build a normal block when executable transactions are available. When the mempool is idle, validators skip empty slots and produce only a 600-second heartbeat block. Heartbeat blocks carry no user transactions, consume zero gas, and do not pay block or STARK rewards.

For normal transaction blocks, the reward model is:

  • the block producer receives 100% of effective gas fees as a deterministic blockGasReward system transaction (Σ gas_used × effective_gas_price);
  • L1 STARK settlement receives a mint-only reward of 100 SHELL / 2^1 × source_count (no gas share); source_count = number of covered source blocks with ≥1 user tx (min 1);
  • L2+ recursive STARK settlements receive 100 SHELL / 2^L × source_count (mint only).

Reward records are not fake signed transactions. They are first-class system transactions with deterministic hashes, receipts, block inclusion indexes, and address-history indexing.

Configuration

[consensus]
engine = "wpoa"
enable_stark_aggregation = false  # enable async STARK proofs (see PROVER_GUIDE.md)

Base authority scheduling

WPoaEngine builds on deterministic authority-slot scheduling. Validators accrue stake-weight over time, and the fork-choice rule favours the chain with highest cumulative weight rather than longest chain.

WPoaConfig fields:

Field Default Description
slot_duration_ms 2000 Slot length in milliseconds
min_validators 1 Minimum validators to produce blocks
max_missed_slots 10 Missed slots before offline detection

WPoA configuration:

[consensus]
engine = "wpoa"

Validator Set

The validator set is maintained in the ValidatorRegistry system contract (0x0000…0001) and the in-memory ValidatorSet. Changes take effect at the next epoch boundary.

ValidatorStatus

Status Description
Active Participating in block production
Pending Added via governance, not yet active
Suspended Temporarily suspended (missed slots)
Slashed Permanently removed due to misbehaviour

Epoch management

ValidatorSetConfig controls epoch length. At each epoch boundary:

  • Pending additions become active
  • Slashed validators are removed
  • Validator weights are recalculated

The node's genesis consensus epoch_length selects Registry reload boundaries. Zero reloads after every canonical block. With a nonzero value, restarting between boundaries restores the active set and weights from the last boundary's canonical state, while the head Registry may already contain pending changes. For example, with length 5, a weight effective at block 5 remains active after a restart at block 7; a new weight written at block 6 activates at block 10. Restart must neither revert to genesis authorities nor apply that pending weight.

Recovery uses the canonical boundary header and state; it does not require the old block body. Missing or inconsistent boundary data stops startup with an explicit recovery error. Restore a trusted snapshot containing that epoch state if an older database has already pruned it. Keep the same genesis consensus parameters when restarting or restoring a snapshot.

Rolling state pruning retains the last finalized epoch and newer state, even when that exceeds keep_recent. Using finality rather than an unfinalized tip preserves authority recovery across permitted reorgs. Once the next boundary is finalized, normal pruning can release the preceding epoch. Archive/full policies and epoch-length-zero retention are unchanged. This Registry recovery does not persist off-chain slashing evidence or other transient consensus state.

The existing two-node Registry acceptance covers pending weight, addition and removal changes, activation at subsequent boundaries, history and idle-tip restarts at heights 7, 12, 17 and 21:

cargo build -p shell-cli --features libp2p
SHELL_SDK_ENTRY=/path/to/shell-sdk/dist/index.js \
node tests/e2e/native-aa-registry.mjs --epoch-restart

Use Node.js 20 or later and a compatible built source SDK (0.14.0-rc.1 tested). The script creates isolated nodes and fresh test keys. Its producer retains quorum weight when a candidate becomes active; it does not establish multi-authority availability or public SDK delivery. See the recovery decision.


Finality

Shell Chain uses a threshold attestation model for finality on top of WPoA. Blocks become final when ≥ 2/3 of validators have attested to them via Attestation messages.

FinalityState tracks:

  • finalized_height — highest finalized block
  • attestation_counts — votes per block hash
  • quorum_threshold — ceil(2/3 × validator_count)

A block at height H is safe once ≥ 1/3 + 1 validators have attested. A block is finalized once ≥ 2/3 have attested.

RPC block tags map to these states:

Tag Meaning
latest Most recent sealed block
safe Block with ≥ 1/3+1 attestations
finalized Block with ≥ 2/3 attestations
pending Not yet sealed
earliest Genesis block

Fork Choice

ForkChoice assigns a BlockScore to each candidate chain head. The rule prefers the chain with:

  1. Highest finalized height (safety over liveness)
  2. Highest cumulative validator weight (WPoA tiebreak)
  3. Lowest block hash (deterministic last tiebreak)

If validator weights are equal, rule 2 degenerates to longest-chain.


Slashing

The slashing system detects two categories of misbehaviour:

Double-sign (equivocation)

Detected by detect_double_sign(h1, h2) — triggered when the same validator proposes two different blocks at the same height with different hashes.

SlashType::DoubleSign
SlashEvidence::Equivocation { h1: BlockHeader, h2: BlockHeader }

EquivocationProof can be broadcast by any node that observes two conflicting headers from the same authority.

Offline

Detected by detect_offline(addr, last_proposed, current_block, config) — triggered when a validator hasn't proposed a block for more than SlashingConfig::offline_threshold slots.

SlashType::Offline

SlashingConfig defaults

Field Default Description
offline_threshold 100 Slots without a proposal before offline detection
slash_on_double_sign true Slash immediately on equivocation
slash_on_offline false Offline triggers suspension, not slash (configurable)

SlashRecord

SlashRecord {
    validator: Address,
    slash_type: SlashType,       // DoubleSign | Offline
    evidence: SlashEvidence,
    block_height: u64,
    epoch: u64,
}

Slash records are written to ValidatorSet and change the validator's status to Slashed. The validator is removed at the next epoch boundary.


Proof Challenges

When enable_stark_aggregation = true, received ProofAmendment messages are verified by all peers. If verification fails, the peer broadcasts a ProofChallenge:

ChallengeReason

Value Description
VerificationFailed Winterfell STARK verification returned false
BlockNotFound The referenced block is not found in the local canonical chain
MalformedPayload The proof payload cannot be deserialized
StateRootMismatch The referenced state root differs from the local view

Rate limiting

Incoming challenges use a separate ProofRateLimiter token bucket for each network peer. The transport peer identity selects the bucket; changing the message's claimed challenger address does not create a new allowance.

The Rust RateLimiterConfig fields and defaults are:

Field Default Meaning
initial_tokens 10 Initial allowance and bucket capacity
refill_rate 5 Tokens added per refill interval, capped at capacity
refill_interval 60 seconds Time between refills
gc_after 600 seconds Idle time before a bucket may be removed

These are source API fields, not TOML configuration keys. Excess challenges are dropped before lifecycle tracking, proof reads or responses. Each network peer has an independent allowance; challenge buckets are also separate from proof amendment submission limits. Periodic cleanup removes idle buckets, and process restart resets them. This receive-side enforcement is an unreleased source correction; verify the node build before relying on it in an older binary.

Challenge flow

Node A cannot verify ProofAmendment for block #N
  │
  └─► Broadcast ProofChallenge { block_hash: hash(N), block_number: N, reason, challenger: A, sequence: k }
          │
          └─► Any peer holding proof bytes broadcasts:
                ChallengeResponse { block_hash: hash(N), proof_bytes: [...], responder: B }
                      │
                      └─► Node A retries verification with raw proof bytes