Skip to content

How It Works

This document is a complete, detailed explanation of the Centurion Seat Manager system. It covers what the system is, why each piece exists, and exactly how every component works together to take a validator from onboarding through activation, reward distribution, and eventual exit. After reading this document you should understand the entire system end to end.


The Problem This System Solves

Centurion is a proof-of-stake blockchain. To run a validator, someone must deposit 32 CTN into the chain's deposit contract. That deposit is irreversible. The validator then earns rewards by attesting to blocks.

In the Centurion model, the foundation provides the 32 CTN capital (the "principal"), while an external operator runs the validator node and earns the rewards. This creates two problems:

  1. How does the foundation control who can deposit? Without controls, anyone could deposit and activate a validator. The foundation needs a gating mechanism.
  2. How do you separate the principal from the rewards? The foundation's 32 CTN and the operator's earned rewards all flow to the same place on-chain. There must be a way to give the operator their rewards while ensuring the foundation gets its 32 CTN back when the validator exits.

The Seat Manager, combined with four on-chain contracts, solves both problems.


System Overview

The system has two layers: an off-chain control plane (the Seat Manager) and an on-chain protocol layer (four Solidity contracts).

                       OFF-CHAIN CONTROL PLANE
 ┌────────────────────────────────────────────────────────────────┐
 │                                                                │
 │  Seat Manager         API + CLI + PostgreSQL + Watchers        │
 │  Admin Frontend       Next.js dashboard for operators          │
 │  Staker Console       React SPA for reward claims              │
 │                                                                │
 └────────────────────────────────────────────────────────────────┘
                              │  deploys, calls, monitors
                       ON-CHAIN CONTRACTS
 ┌────────────────────────────────────────────────────────────────┐
 │                                                                │
 │  DepositContractCTN   Gated deposit contract (allowlist)       │
 │  TreasuryRouter       Foundation's on-chain signing proxy      │
 │  VaultFactory         Deploys per-validator vaults             │
 │  WithdrawalVault v3   Holds rewards, enforces principal split  │
 │                                                                │
 └────────────────────────────────────────────────────────────────┘

What Is a "Seat"?

A seat is the central concept. It is a record in the Seat Manager's database that tracks one validator through its entire lifecycle. Each seat contains:

  • A BLS public key (identifies the validator on the beacon chain)
  • Withdrawal credentials (the on-chain address where the validator's balance will be sent upon exit)
  • A WithdrawalVault address (the contract that holds and splits funds between operator and foundation)
  • A status (where the validator is in its lifecycle: CREATED, ALLOWLISTED, DEPOSITED, SEEN_BY_CL, ACTIVE, or REVOKED)
  • An operator (the entity responsible for running the validator)
  • A beneficiary (the wallet address that receives rewards)

One seat = one validator = one vault = one beneficiary.


The Four On-Chain Contracts

Before explaining the lifecycle, you need to understand what each contract does and why it exists.

1. DepositContractCTN

Purpose: Controls who is allowed to activate validators on the chain.

This is the chain's official deposit contract. To activate a validator, you send 32 CTN to this contract along with the validator's BLS public key, withdrawal credentials, and a BLS signature. The beacon chain reads deposit events from this contract and queues validators for activation.

The Centurion version adds two gates that the foundation can toggle:

Gate 1 - Allowlist: When enabled, every deposit must match a pre-registered "intent." An intent is a commitment to a specific (pubkey, withdrawal_credentials, amount, depositor) tuple. The foundation registers intents before deposits, and each intent can only be used once. This prevents unauthorized validator activations.

The intent hash is computed as:

intentHash = keccak256(pubkey || withdrawal_credentials || amountGwei || authorizedDepositor || ownershipEpoch)

Once a deposit consumes an intent, it is marked as "consumed" in a separate mapping and can never be re-registered. This prevents replay attacks.

Gate 2 - Owner-only depositor: When enabled, only the contract owner's address can call deposit(). Even if an intent exists, a third party cannot use it.

When both gates are active (the current configuration), the foundation has full control: it decides which validators are allowed (via the allowlist) and it is the only address that can actually send the deposit transaction.

The contract also supports:

  • Batch operations: addAllowedDeposits() and addAllowedDepositsFor() for registering multiple intents in a single transaction.
  • Per-depositor intents: addAllowedDepositFor(pubkey, wc, amountGwei, authorizedDepositor) allows the foundation to authorize a specific third party to deposit for a specific validator, with a specific amount. This is the mechanism that enables future permissionless deposits.
  • Timelocked permanent disable: The foundation can permanently and irreversibly disable the allowlist, but only after scheduling it with a timelock (minimum 1 day) and waiting for the delay to expire. This is the path to full permissionlessness.
  • Two-step ownership transfer: transferOwnership(newOwner) sets a pending owner, who must then call acceptOwnership(). This prevents accidental transfers to wrong addresses. acceptOwnership() also increments ownershipEpoch, invalidating stale allowlist intents from the prior ownership era.

2. TreasuryRouter

Purpose: A single on-chain identity for the foundation that controls all vaults.

Every WithdrawalVault is deployed with its treasury set to the TreasuryRouter's address. This means the foundation does not interact with vaults directly from an EOA. Instead, the foundation's EOA (the "active signer") calls TreasuryRouter.execute(target, calldata), and the router forwards the call to the target vault or factory.

Why use a router instead of a direct EOA?

  • Signer rotation: If the foundation needs to change which private key controls all vaults (e.g., key compromise, operational handoff), it can rotate the signer with a 7-day time-locked process. The current signer proposes a new signer, waits 7 days, and the new signer accepts. No vault redeployment required.
  • Single treasury address: All vaults share the same treasury address. The router acts as that single address. Without it, each vault would be tied to a specific EOA, and rotating keys would require redeploying every vault.
  • Receive ETH: The router has a receive() function, so principal claims from vaults can flow back to it.
  • Recovery + safety hardening: sweepETH() lets the signer recover router-held ETH, and signer rotation rejects newSigner == address(this) to avoid bricking signer control.

3. VaultFactory

Purpose: Deploys WithdrawalVault instances with enforced parameters and maintains an on-chain registry.

The factory can only be called through the TreasuryRouter (it checks msg.sender == treasuryRouter). When called, it:

  1. Validates that principalTargetWei == 32 ether (enforced standard)
  2. Checks that no vault already exists for this validator pubkey (prevents duplicates)
  3. Deploys a new WithdrawalVault with:
    • treasury = TreasuryRouter address
    • shortfallPolicy = PrincipalFirst (hardcoded)
    • exitRequestContract = the CIP-7002 system contract address (set at factory construction)
  4. Registers the vault in three on-chain mappings:
    • isVault[address] — boolean, lets anyone verify an address is a legitimate vault
    • vaultByValidatorPubkeyHash[hash] — lookup vault by validator
    • allVaults[] — enumerable array with vaultCount()

4. WithdrawalVault v3

Purpose: Holds a validator's funds and enforces the economic split between the operator (beneficiary) and the foundation (treasury).

Each validator gets its own vault, deployed at seat creation time. The vault's address becomes the validator's withdrawal credentials (0x01 + 11 zero bytes + vault_address). This means all of the validator's funds — both the 32 CTN principal and any earned rewards — flow into this vault contract.

The vault solves the core economic problem: how to give the operator their rewards while guaranteeing the foundation gets its 32 CTN back.

Immutable Parameters (set at deployment, never change)

Parameter Value Purpose
treasury TreasuryRouter address Foundation's claim authority
validatorPubkeyHash sha256(pubkey) Binds vault to exactly one validator
principalTargetWei 32 CTN (32e18 wei) Foundation's capital to recover
shortfallPolicy PrincipalFirst Foundation recovers first if slashed
CLAIM_DELAY 86400 seconds (24 hours) Time lock on Running-phase claims
exitRequestContract CIP-7002 system contract For BLS-keyless force exit
maxClaimPerPeriod configurable (default: unlimited) Rate limit on beneficiary claims

Mutable State

Field Purpose
beneficiary Reward recipient (can be rotated with 7-day delay + treasury approval)
settlementPhase Current phase: Running (0), SettlementProposed (1), ExitSettlement (2)
rewardsClaimedWei Total rewards paid to beneficiary (lifetime counter)
principalClaimedWei Total principal recovered by treasury (lifetime counter)
pendingClaimWei Amount in the current pending Running-phase claim

The Three Phases

The vault operates in three sequential phases. Each phase has different rules for who can claim what.

Phase 0: Running (normal operation while the validator is active)

During Running phase, the validator is online and earning rewards. The Ethereum protocol periodically sweeps any excess balance above 32 CTN from the validator's consensus balance into the vault (this happens automatically via the "Capella sweep" mechanism, roughly every 5 days).

The key insight is that while the vault balance is below 32 CTN, it is all reward overflow (the principal is still staked on the consensus layer). So the beneficiary can claim the entire vault balance.

if vault.balance < 32 CTN:  claimable = vault.balance     (all rewards)
if vault.balance >= 32 CTN: claimable = 0                  (principal protection)

When the balance hits or exceeds 32 CTN (which happens after the validator exits and its full balance is swept to the vault), Running-phase claims are blocked with PrincipalProtectionActive. This prevents the beneficiary from draining the principal.

The claim flow during Running phase is time-delayed:

  1. Beneficiary calls initiateClaimRewards(amount) — locks in the claim amount and starts a 24-hour timer. Passing 0 means "claim everything available."
  2. After 24 hours, beneficiary calls finalizeClaimRewards(to) — transfers CTN to the specified address.
  3. At any point before finalization, beneficiary can call cancelPendingClaim() to abort.

Only one claim can be pending at a time. The rate limit (if configured) is checked at initiation (as early feedback) and enforced at finalization (as the authoritative check). The rate limit uses a rolling 30-day period.

Phase 1: SettlementProposed (transitional, after validator exits)

When the validator exits the beacon chain and its full balance is withdrawn to the vault, the foundation initiates settlement. Treasury calls proposeSettlement(maxRewardsClaimedWei).

This immediately:

  • Moves the vault to SettlementProposed phase
  • Cancels any pending Running-phase claim
  • Pauses all beneficiary claims (claimableRewardsWei returns 0)

The maxRewardsClaimedWei parameter is a front-running guard. If the beneficiary finalized a claim in the same block (or just before) the settlement proposal, rewardsClaimedWei would be higher than the treasury expected. The contract reverts if rewardsClaimedWei > maxRewardsClaimedWei, alerting the treasury to re-evaluate.

Settlement can be cancelled during this phase: treasury calls cancelSettlement() to return to Running.

After SETTLEMENT_DELAY (1 hour), treasury calls finalizeSettlement() to make it irreversible.

Phase 2: ExitSettlement (final, irreversible)

Now the vault's total lifetime inflows are known and the accounting is final:

totalLifetimeWei = vault.balance + principalClaimedWei + rewardsClaimedWei
claimableRewards = totalLifetimeWei - 32 CTN - rewardsClaimedWei
claimablePrincipal = min(totalLifetimeWei, 32 CTN) - principalClaimedWei

Both claims are instant (no delay):

  • Beneficiary calls claimRewards(to, amount) — gets everything above 32 CTN
  • Treasury calls claimPrincipal(to, amount) — recovers up to 32 CTN

Shortfall (PRINCIPAL_FIRST): If the validator was slashed and its total lifetime inflows are below 32 CTN, the treasury recovers everything available first. The beneficiary only receives whatever is left after the treasury's claim. If totalLifetime < 32 CTN, the beneficiary gets nothing.

After 90 days (DRAIN_DELAY), treasury can call drainRemainder(to) to sweep any residual balance (e.g., late protocol inflows that arrived after both parties claimed). This credits principalClaimedWei to maintain the conservation invariant.

The Conservation Law

At all times, the following equation holds:

totalLifetimeWei = address(this).balance + principalClaimedWei + rewardsClaimedWei

This is verifiable by anyone reading the contract. The left side is "total CTN ever received." The right side is "what's still in the vault + what was already withdrawn." They must always be equal.

The two claimed counters are strictly separated:

  • rewardsClaimedWei is only incremented by beneficiary calls. Never by treasury.
  • principalClaimedWei is only incremented by treasury calls (including drainRemainder). Never by beneficiary.

This separation is why the staker console can show "Rewards Paid to You" using rewardsClaimedWei — it accurately reflects only what the operator received.

Beneficiary Rotation

The beneficiary is not permanently locked. It can be changed via a two-step, time-delayed process:

  1. Current beneficiary calls proposeBeneficiaryRotation(newAddress) — starts a 7-day delay
  2. After 7 days, treasury calls approveBeneficiaryRotation() — completes the rotation and cancels any pending claim under the old beneficiary
  3. Treasury can call cancelBeneficiaryRotation() at any time to reject the rotation

The new beneficiary must be an EOA (no contracts) and cannot be the treasury address.

CIP-7002 Validator Exit (Kill Switch)

The vault enables the foundation to force-exit a validator without the operator's BLS signing key. This is critical for the BYO-BLS model where the foundation never has the signing key.

Treasury calls triggerValidatorExit(validatorPubkey) on the vault. The vault calls the CIP-7002 system contract (0x00000961Ef480Eb55e80D19ad83579A64c007002) with the validator's pubkey. Because the vault IS the withdrawal credentials address, the beacon chain honors the exit request.

This is a one-way operation — the validator will exit regardless of the operator's wishes. It requires a small msg.value to cover the CIP-7002 dynamic fee.


The Seat Lifecycle

Now that you understand the contracts, here is how a validator goes from zero to earning rewards, step by step.

Step 1: Create Seat

The lifecycle begins when the foundation creates a seat for an operator. The primary onboarding model is BYO-BLS (bring your own BLS key): the operator generates their own BLS keypair and gives the foundation only the public key. The foundation never sees, generates, or stores the operator's signing key.

When the foundation runs seat create-with-vault:

graph TD
    A["Operator provides BLS public key"] --> B
    B["Foundation calls VaultFactory.deployVault()<br/>via TreasuryRouter.execute()"] --> C
    C["VaultFactory deploys WithdrawalVault<br/>(treasury=router, beneficiary, pubkey, 32 CTN)"] --> D
    D["Factory registers vault in on-chain registry<br/>(isVault, vaultByPubkeyHash, allVaults)"] --> E
    E["Seat Manager derives withdrawal credentials<br/>wc = 0x01 + 11 zero bytes + vault address"] --> F
    F["Seat Manager verifies 12 on-chain invariants<br/>(wc, pubkeyHash, treasury, beneficiary, principal, policy, ...)"] --> G
    G["Seat stored in PostgreSQL as CREATED<br/>with vault address, wc, intent hash"]

Post-deployment verification is critical. After the vault is deployed, the Seat Manager reads back 12 properties from the on-chain contract and verifies every one matches what was intended: withdrawal credentials, validator pubkey hash binding, treasury address, beneficiary, principal target, shortfall policy, rate limit setting, and chain ID. If any check fails, the operation is recorded as FAILED.

After creation, the foundation shares the vault address with the operator. The operator then generates deposit data (using any standard tool like ethstaker-deposit-cli) signed over the vault's withdrawal credentials, and submits it to the Seat Manager API:

POST /v1/seats/:id/deposit-data
{
  "pubkey": "0x...",
  "withdrawal_credentials": "0x01000...vaultAddress",
  "signature": "0x...",
  "deposit_data_root": "0x...",
  "amount": "32000000000"
}

The API validates that the pubkey matches the seat, the withdrawal credentials match the vault, and the amount matches the principal target. If any field is wrong, the submission is rejected.

Internal key generation

For foundation-managed validators (not external operators), there is an alternative "Generate Keys" flow that uses the foundation's mnemonic to auto-generate a BLS keypair via ethstaker-deposit-cli. The mnemonic and keystore password are passed to the CLI via stdin (never as command-line arguments) to prevent exposure in process listings. This flow is internal-only.

Step 2: Approve (Allowlist On-Chain)

The foundation approves the seat, which registers the deposit intent on the deposit contract.

Before any on-chain action, the Seat Manager runs a cross-check preflight. This queries 2 or more endpoints (ideally running different EL client software) and verifies they all agree on:

Check What it verifies
Chain ID You are on the correct chain
Contract code Bytecode exists at the deposit contract address
Bytecode fingerprint keccak256(bytecode) matches the expected hash (no upgrades/replacements)
Owner The contract's owner() matches the expected address

All endpoints must pass all core checks. If any endpoint disagrees, the operation aborts. This prevents sending transactions to the wrong chain, a replaced contract, or a compromised owner.

After preflight passes, the Seat Manager calls addAllowedDeposit(pubkey, withdrawalCredentials) on the deposit contract. This registers the intent hash. The seat transitions to ALLOWLISTED.

Step 3: Deposit 32 CTN

The foundation sends the actual deposit transaction.

The deposit command follows this sequence:

  1. Validate amount — must be exactly 32 CTN (matches vault's principal target)
  2. Cross-check preflight — same as approve
  3. Resolve deposit data — uses BYO-BLS operator-submitted data (if available), otherwise falls back to generated keystores or mnemonic-based generation
  4. Simulate — calls eth_call (read-only) to verify the deposit would succeed without actually sending it. Catches issues like missing allowlist entries, wrong amounts, or wrong sender before spending gas.
  5. Send transaction — calls deposit(pubkey, wc, signature, depositDataRoot) with 32 CTN as value
  6. Post-send verification — this is extensive:
    • Decode the transaction receipt and find exactly 1 DepositEvent
    • Verify the event's pubkey, withdrawal credentials, signature, and amount all match
    • Verify depositCount on the contract incremented by exactly 1
    • Read back the allowlist entry and confirm it was consumed (returns false)
    • Record the deposit in the database
    • Transition the seat to DEPOSITED

Step 4: Watchers (Automatic Progression)

After the deposit, two background processes (watchers) monitor the blockchain and automatically advance the seat through its remaining states.

EL Watcher

The EL watcher polls execution layer nodes every 12 seconds (one slot interval).

Each cycle:

  1. Selects 2+ endpoints with distinct EL client software
  2. Reads the finalized block number (not latest — this prevents reorg issues)
  3. Fetches DepositEvent logs from the deposit contract between lastScannedBlock + 1 and currentFinalizedBlock
  4. For each event: decodes the pubkey and wc, computes the intent hash, looks up the seat, records the deposit in the database
  5. If the seat exists and is in a pre-DEPOSITED state, transitions it to DEPOSITED

The EL watcher also runs vault reconciliation every 5 minutes: it reads each vault's on-chain state (balance, phase, pending claims, claimed amounts) and checks for anomalies like conservation law violations or unexpected phase transitions.

CL Watcher

The CL watcher polls beacon (consensus layer) nodes every 24 seconds (two slot intervals).

It has two responsibilities:

Activation monitoring (for seats in DEPOSITED and SEEN_BY_CL):

  1. For each relevant seat, queries /eth/v1/beacon/states/head/validators/{pubkey} on the beacon API
  2. If the validator is visible for the first time: records the observation and transitions the seat from DEPOSITED to SEEN_BY_CL
  3. If the validator's status is active_ongoing and has been seen by 2+ beacon endpoints: transitions to ACTIVE

Exit monitoring (for ACTIVE seats):

  1. Checks if the validator's status has changed to active_exiting, exited_unslashed, exited_slashed, withdrawal_possible, or withdrawal_done
  2. When the status reaches withdrawal_done (meaning the validator has fully exited and its balance has been withdrawn to the EL), the watcher evaluates auto-settlement

Auto-settlement is the automated process of settling a vault after a validator exits. The CL watcher:

  1. Confirms the validator status is definitively withdrawal_done
  2. Reads the vault's on-chain state
  3. If the vault is still in Running phase and has sufficient balance, it calls proposeSettlement(rewardsClaimedWei) via the TreasuryRouter
  4. After the 1-hour settlement delay, on the next cycle, it calls finalizeSettlement() to complete the settlement
  5. Records evidence and audit entries for both transactions

The Complete State Machine

stateDiagram-v2
    [*] --> CREATED : seat create / create-with-vault

    CREATED --> ALLOWLISTED : approve (addAllowedDeposit on-chain)
    CREATED --> REVOKED : revoke

    ALLOWLISTED --> DEPOSITED : EL watcher observes DepositEvent
    ALLOWLISTED --> REVOKED : revoke (removeAllowedDeposit on-chain)

    DEPOSITED --> SEEN_BY_CL : CL watcher sees validator on beacon chain
    DEPOSITED --> REVOKED : operational revoke

    SEEN_BY_CL --> ACTIVE : CL watcher confirms active_ongoing from 2+ endpoints
    SEEN_BY_CL --> REVOKED : operational revoke

    ACTIVE --> REVOKED : operational revoke

Key properties of the state machine:

  • Forward-only: Seats only move forward or to REVOKED. There is no backward transition.
  • Optimistic concurrency: Each seat has a version column. Every status transition requires the version to match, then increments it. This prevents race conditions between watchers and API operations (e.g., a watcher and an admin both trying to update the same seat simultaneously).
  • Post-deposit revoke is operational only: Once a validator is deposited, there is no on-chain mechanism to un-deposit. Revoking a DEPOSITED, SEEN_BY_CL, or ACTIVE seat only marks it in the database. The validator continues to exist on-chain. To actually stop it, the foundation must use the CIP-7002 force-exit mechanism.

The Reward Claim Flow (Staker's Perspective)

From the operator/staker's point of view, the process is straightforward.

While the Validator Is Active (Running Phase)

  1. The validator earns rewards. Roughly every 5 days, the protocol sweeps any excess balance above 32 CTN into the vault.
  2. The staker connects their wallet to the Staker Console. The console calls GET /v1/validators/:walletAddress on the Seat Manager API, which returns all validators whose vault beneficiary matches the connected wallet.
  3. The staker sees their validator's status, vault balance, and claimable rewards.
  4. To claim: the staker calls initiateClaimRewards(amount) on the vault contract (a direct on-chain transaction from the staker's wallet, not through the Seat Manager).
  5. After 24 hours, the staker calls finalizeClaimRewards(to) to receive the CTN.

If a rate limit is configured, the staker cannot claim more than maxClaimPerPeriod in any 30-day window.

After the Validator Exits (ExitSettlement Phase)

  1. The validator exits (either voluntarily or via force-exit).
  2. The full balance is swept to the vault.
  3. The foundation settles the vault (propose + finalize, either manually or via auto-settlement).
  4. The staker calls claimRewards(to, amount) — instant, no delay.
  5. The foundation calls claimPrincipal(to, amount) to recover the 32 CTN.

Safety Mechanisms

Cross-Check Preflight

Every dangerous operation (approve, deposit, revoke, switch, freeze) must pass a cross-check preflight that queries 2+ independent nodes and verifies they agree on the chain state. This prevents:

  • Sending to the wrong chain
  • Interacting with a replaced or upgraded contract
  • Acting on a chain where the contract owner has changed

Evidence Bundles

Every admin action writes a JSON evidence file to the ./evidence/ directory before any transaction is sent. The bundle captures:

  • The canonical configuration used
  • All preflight results from every endpoint
  • The exact calldata that was (or will be) sent
  • If a transaction was sent: the tx hash, block number, and receipt status

These bundles create a tamper-evident paper trail for every operation.

Idempotency

POST API requests support an Idempotency-Key header. If the same key is sent twice, the second request returns the cached result without performing the operation again. This makes it safe to retry after network errors.

Audit Trail

Every mutation in the system is recorded in the audit_log table with:

  • What action was performed
  • Who performed it (user ID from session auth)
  • The request ID (for correlating with logs)
  • The client's IP address
  • The seat ID (if applicable)
  • A free-text reason field

Session Auth and RBAC

The API uses session-based authentication with a role hierarchy:

Role Capabilities
ADMIN Everything: user management, approve, revoke, deposit, generate, freeze, switch
OPERATOR Create seats, create operators, view all data
VIEWER Read-only access to seats, operators, audit logs

Session tokens are HMAC-SHA256 hashed before storage — the plaintext token is never persisted. User passwords are hashed with scrypt (N=32768, r=8, p=1). Admin token comparison uses timingSafeEqual to prevent timing attacks.


Deposit Contract Controls

The foundation has several controls over the deposit contract:

Allowlist Toggle

The allowlist can be temporarily disabled (setPubkeyAllowlistEnabled(false)) to allow any deposit, and re-enabled later. This is a reversible operation.

Owner-Only Depositor Toggle

setOwnerOnlyDepositorEnabled(false) allows anyone (not just the owner) to call deposit(). Combined with per-depositor intents (addAllowedDepositFor), this enables a model where specific third parties are authorized to deposit for specific validators.

Freeze Pipeline (Irreversible Transition to Permissionlessness)

The freeze pipeline permanently and irreversibly disables the allowlist. It has three steps and multiple safety gates:

  1. Schedule: scheduleDisablePubkeyAllowlistForever(delaySeconds) — sets the earliest execution time. Minimum delay is 1 day. The delay cannot be shortened once set (only extended or cancelled).
  2. Cancel: cancelDisablePubkeyAllowlistForever() — resets the schedule. Available until execution.
  3. Execute: disablePubkeyAllowlistForever() — permanently disables the allowlist. Requires: timelock expired, owner-only depositor already disabled, and the --dangerous CLI flag + CONFIRM_FREEZE=I_UNDERSTAND environment variable.

After execution, pubkeyAllowlistDisabledForever = true and can never be changed. The allowlist cannot be re-enabled. This is the path from a permissioned network to a permissionless one.


Off-Chain Architecture

Database (PostgreSQL + Prisma)

All state is stored in PostgreSQL 18 via the Prisma ORM. Key tables:

Table Purpose
seats One row per validator: pubkey, wc, status, vault metadata, version counter
seat_events Append-only history of every status transition
seat_bls_material BLS keystores and deposit data (1:1 with seat, for managed keys only)
operators Operator entities (name, description, enabled flag)
users Admin accounts with RBAC roles and scrypt password hashes
sessions Session tokens (HMAC-hashed), expiry, revocation status
allowlist_actions Record of every on-chain allowlist transaction
deposits Observed DepositEvent logs from the EL watcher
cl_observations Beacon API validator visibility checks from the CL watcher
audit_log Full audit trail with userId, requestId, ipAddress
idempotency_keys Request deduplication for POST routes

API Server (Fastify)

The Fastify API server (node dist/index.js api --port 8080) exposes:

Public endpoints (no auth, exposed via reverse proxy for the staker console):

  • GET /health — health check
  • GET /v1/validators/:address — all validators for a wallet (matches by beneficiary address)
  • GET /v1/validator/:pubkey — single validator lookup

Admin endpoints (session or token auth required):

  • Seat CRUD and lifecycle actions (create, approve, deposit, revoke, force-exit, settle)
  • Operator CRUD (create, list, enable/disable)
  • User management (create, list, enable/disable, password reset)
  • Audit log query
  • Deposit data submission (BYO-BLS)

Admin Frontend (Next.js 15)

A web dashboard at localhost:3003 that proxies API calls to the Fastify backend. Provides:

  • System health dashboard (API, database, and RPC status)
  • Seat management with status-based action buttons and confirmation dialogs
  • Operator and user management
  • Searchable audit log
  • Allowlist and freeze pipeline controls

Staker Console (React SPA)

A wallet-connected frontend for operators/stakers. It calls the public API endpoints to display validators matched to the connected wallet, and interacts directly with vault contracts for reward claims.


Putting It All Together: A Complete Example

Here is the full journey of a single validator from start to finish.

Day 0 — Onboarding: An external operator generates a BLS keypair and sends the public key to the foundation. The foundation creates a seat with vault via the admin UI. A WithdrawalVault is deployed on-chain via VaultFactory (through TreasuryRouter). The seat is now CREATED. The foundation shares the vault address with the operator. The operator generates deposit data signed over the vault's withdrawal credentials and submits it via the API.

Day 0 — Allowlisting: The foundation clicks "Approve" in the admin UI. The preflight engine checks 2+ nodes, confirms everything matches, and sends addAllowedDeposit(pubkey, wc). The seat is now ALLOWLISTED.

Day 0 — Deposit: The foundation clicks "Deposit." The system simulates the transaction, then sends 32 CTN to the deposit contract. Post-send verification confirms exactly one DepositEvent with matching fields. The seat is now DEPOSITED.

Day 0 (minutes later) — EL Watcher: The EL watcher picks up the DepositEvent log and records it in the database.

Day 0 (~13 minutes later) — CL Watcher: The CL watcher sees the validator appear on the beacon chain. Seat transitions to SEEN_BY_CL. A few epochs later, the validator's status changes to active_ongoing. The CL watcher confirms this from 2+ endpoints. Seat transitions to ACTIVE.

Day 5, 10, 15... — Reward Claims: The protocol sweeps reward overflows to the vault every ~5 days. The operator connects their wallet to the staker console, sees their available rewards, and initiates a claim. After 24 hours, they finalize and receive CTN.

Day 365 — Validator Exit: The foundation decides to exit the validator. It calls seat force-exit <id> --send. The vault calls the CIP-7002 system contract. The beacon chain processes the exit. Within a few days the validator's full balance is withdrawn to the vault.

Day 365 (after withdrawal) — Auto-Settlement: The CL watcher detects the validator's status is withdrawal_done. It calls proposeSettlement() via the TreasuryRouter. One hour later, it calls finalizeSettlement(). The vault is now in ExitSettlement phase.

Day 365 — Final Claims: The operator calls claimRewards(to, 0) on the vault and receives all rewards above 32 CTN (instant, no delay). The foundation calls claimPrincipal(to, 0) and recovers the 32 CTN principal. If the validator was slashed and the total is below 32 CTN, the foundation gets everything and the operator gets nothing (PRINCIPAL_FIRST policy).


Failure Modes and Recovery

Failure What Happens Recovery
Cross-check preflight fails Operation aborts before any transaction Investigate endpoint disagreement, retry when resolved
Vault deployment fails Seat operation recorded as FAILED Retry — idempotency keys prevent duplicate deploys
Deposit simulation reverts No transaction sent, error message shows cause Fix the cause (missing allowlist, wrong amount, wrong sender)
Transaction reverts on-chain Receipt shows failure, recorded in evidence Investigate revert reason, retry if appropriate
CL visibility delay Validator does not appear immediately after deposit Normal — CL processing takes a few epochs. Watcher will catch up
Watchers are not running Seat status stalls at DEPOSITED Restart watchers — they resume from last scanned block
Optimistic concurrency conflict Status update fails, logs the conflict Automatic retry on next watcher cycle or manual retry
Validator is slashed Reduced balance withdrawn to vault PRINCIPAL_FIRST ensures foundation recovers first
Operator's BLS key compromised Operator can be slashed Foundation uses CIP-7002 force-exit, settles vault
Foundation key compromised Attacker could drain vaults TreasuryRouter signer rotation (7-day delay gives time to react)

Future: Permissionless Transition

The system is designed to transition from fully permissioned to permissionless over time:

Setting Current (Permissioned) Target (Permissionless)
pubkeyAllowlistDisabledForever false true
ownerOnlyDepositorEnabled true false

After this transition, two modes coexist on the same chain:

  • Self-custody: Anyone deposits directly to the deposit contract. Withdrawal credentials point to their own address. No vault, no foundation involvement.
  • Operator service: Operators who want the vault-backed economic structure (principal protection, managed infrastructure) continue to use the seat manager flow voluntarily.