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
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 |
InvalidBatchRoot |
batch_root_bytes doesn't match expected public output |
InvalidProverSignature |
Prover's PQ signature on the amendment is invalid |
UnregisteredProver |
Prover address not in ProverRegistry |
Rate limiting
Challenges are rate-limited per-challenger via ProofRateLimiter to prevent
DoS. RateLimiterConfig sets:
max_challenges_per_window— max challenges in any rolling windowwindow_seconds— rolling window duration
A challenger that exceeds the limit has its challenges silently dropped by peers.
Challenge flow
Node A cannot verify ProofAmendment for block #N
│
└─► Broadcast ProofChallenge { block_hash: N, reason, challenger: A, sequence: k }
│
└─► Any peer holding proof bytes broadcasts:
ChallengeResponse { block_hash: N, proof_bytes: [...] }
│
└─► Node A retries verification with raw proof bytes