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:
- The proposer is in the current
ValidatorSet. - The block header
authorityfield matches the proposer's address. - All transactions have valid ML-DSA-65 signatures (or supported legacy/fallback PQ signatures) verified via
WitnessBundle. - The block timestamp is within the allowed drift window.
- 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
blockGasRewardsystem 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 blockattestation_counts— votes per block hashquorum_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:
- Highest finalized height (safety over liveness)
- Highest cumulative validator weight (WPoA tiebreak)
- 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