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:
- How does the foundation control who can deposit? Without controls, anyone could deposit and activate a validator. The foundation needs a gating mechanism.
- 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()andaddAllowedDepositsFor()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 callacceptOwnership(). This prevents accidental transfers to wrong addresses.acceptOwnership()also incrementsownershipEpoch, 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 rejectsnewSigner == 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:
- Validates that
principalTargetWei == 32 ether(enforced standard) - Checks that no vault already exists for this validator pubkey (prevents duplicates)
- Deploys a new WithdrawalVault with:
treasury= TreasuryRouter addressshortfallPolicy= PrincipalFirst (hardcoded)exitRequestContract= the CIP-7002 system contract address (set at factory construction)
- Registers the vault in three on-chain mappings:
isVault[address]— boolean, lets anyone verify an address is a legitimate vaultvaultByValidatorPubkeyHash[hash]— lookup vault by validatorallVaults[]— enumerable array withvaultCount()
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:
- Beneficiary calls
initiateClaimRewards(amount)— locks in the claim amount and starts a 24-hour timer. Passing0means "claim everything available." - After 24 hours, beneficiary calls
finalizeClaimRewards(to)— transfers CTN to the specified address. - 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 (
claimableRewardsWeireturns 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:
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:
rewardsClaimedWeiis only incremented by beneficiary calls. Never by treasury.principalClaimedWeiis only incremented by treasury calls (includingdrainRemainder). 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:
- Current beneficiary calls
proposeBeneficiaryRotation(newAddress)— starts a 7-day delay - After 7 days, treasury calls
approveBeneficiaryRotation()— completes the rotation and cancels any pending claim under the old beneficiary - 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:
- Validate amount — must be exactly 32 CTN (matches vault's principal target)
- Cross-check preflight — same as approve
- Resolve deposit data — uses BYO-BLS operator-submitted data (if available), otherwise falls back to generated keystores or mnemonic-based generation
- 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. - Send transaction — calls
deposit(pubkey, wc, signature, depositDataRoot)with 32 CTN as value - 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
depositCounton 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
- Decode the transaction receipt and find exactly 1
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:
- Selects 2+ endpoints with distinct EL client software
- Reads the finalized block number (not latest — this prevents reorg issues)
- Fetches
DepositEventlogs from the deposit contract betweenlastScannedBlock + 1andcurrentFinalizedBlock - For each event: decodes the pubkey and wc, computes the intent hash, looks up the seat, records the deposit in the database
- 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):
- For each relevant seat, queries
/eth/v1/beacon/states/head/validators/{pubkey}on the beacon API - If the validator is visible for the first time: records the observation and transitions the seat from
DEPOSITEDtoSEEN_BY_CL - If the validator's status is
active_ongoingand has been seen by 2+ beacon endpoints: transitions toACTIVE
Exit monitoring (for ACTIVE seats):
- Checks if the validator's status has changed to
active_exiting,exited_unslashed,exited_slashed,withdrawal_possible, orwithdrawal_done - 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:
- Confirms the validator status is definitively
withdrawal_done - Reads the vault's on-chain state
- If the vault is still in Running phase and has sufficient balance, it calls
proposeSettlement(rewardsClaimedWei)via the TreasuryRouter - After the 1-hour settlement delay, on the next cycle, it calls
finalizeSettlement()to complete the settlement - 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
versioncolumn. 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)¶
- The validator earns rewards. Roughly every 5 days, the protocol sweeps any excess balance above 32 CTN into the vault.
- The staker connects their wallet to the Staker Console. The console calls
GET /v1/validators/:walletAddresson the Seat Manager API, which returns all validators whose vault beneficiary matches the connected wallet. - The staker sees their validator's status, vault balance, and claimable rewards.
- 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). - 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)¶
- The validator exits (either voluntarily or via force-exit).
- The full balance is swept to the vault.
- The foundation settles the vault (propose + finalize, either manually or via auto-settlement).
- The staker calls
claimRewards(to, amount)— instant, no delay. - 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:
- Schedule:
scheduleDisablePubkeyAllowlistForever(delaySeconds)— sets the earliest execution time. Minimum delay is 1 day. The delay cannot be shortened once set (only extended or cancelled). - Cancel:
cancelDisablePubkeyAllowlistForever()— resets the schedule. Available until execution. - Execute:
disablePubkeyAllowlistForever()— permanently disables the allowlist. Requires: timelock expired, owner-only depositor already disabled, and the--dangerousCLI flag +CONFIRM_FREEZE=I_UNDERSTANDenvironment 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 checkGET /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.