Skip to content

Operator Guide

Who is this for?

Node operators, contract owners, security reviewers, and DevOps engineers responsible for managing validator onboarding on the Centurion network.


1. Overview

The Seat Manager operates the DepositContractCTN allowlist — a gatekeeper that controls which validator pubkeys may deposit on the Centurion chain.

A seat is a tracked (pubkey, withdrawal_credentials) pair. Each seat has an intentHash = keccak256(pubkey || wc) that the contract uses to allow or deny deposits.

Capability Description
Local seat tracking SQLite-backed lifecycle state for every (pubkey, wc) pair
Calldata generation Every admin action produces calldata and evidence — propose-only by default
Optional tx sending Transactions are only sent when you explicitly pass --send
Real-time watchers EL/CL watchers observe deposits and validator activation as they happen
Evidence bundles Tamper-evident JSON files for every proposed or executed action
Cross-check preflight Every dangerous action is verified across multiple endpoint clients

2. Architecture & Trust Model

graph TB
    subgraph CLI["Seat Manager CLI"]
        CL[Config Loader] --> CD[Command Dispatcher]
        PE[Preflight Engine] --> CD
        EW[Evidence Writer] --> CD
        CD --> SS[(SQLite Store)]
        CD --> CE[Calldata Encoder]
        CD --> SG[Signer — optional]
    end

    CD -->|read-only RPC| NODES[EL / CL Nodes<br/>label-based]
    SG -->|signed txs| DC[Deposit Contract<br/>on-chain]

Trust Boundaries

Component What It Does Trust Level Signs Txs?
Config Loader Reads canonical files from centurion-infra Read-only No
Preflight Engine Read-only RPC calls to verify chain state Read-only No
Evidence Writer Local file I/O for audit bundles Local only No
SQLite Store Local seat lifecycle state Local only No
Calldata Encoder Generates ABI-encoded calldata Compute only No
Signer Holds owner key in memory, signs when --send Privileged Yes

Default: no signing

By default, the signer is not activated. The tool generates calldata and evidence without ever touching a private key.


3. Installation & Setup

Prerequisites

Requirement Details
Node.js Version 18 or higher
Infra repo Access to /home/user/centurion/centurion-infra (or equivalent)
SSH access Key-based, non-interactive access to node labels in deploy-node.sh
RPC access Optional — if nodes aren't directly reachable, the tool auto-falls back to SSH tunnels

Build & Verify

cd /home/user/centurion/centurion-seat-manager
npm install
npm run build
npm test                      # 30 unit tests
npm run lint                  # ESLint
node dist/index.js status     # Read-only health check

4. Configuration

4.1 Infra Pointer

seat-manager.config.json in the repo root:

{
  "centurionInfraRoot": "/home/user/centurion/centurion-infra",
  "centurionNetworksRoot": "/home/user/centurion/centurion-networks",
  "dbPath": "./data/seat-manager.sqlite"
}

Override with environment variables:

export CENTURION_INFRA_ROOT=/custom/path/to/centurion-infra
export CENTURION_NETWORKS_ROOT=/custom/path/to/centurion-networks

4.2 What Gets Loaded

The config loader reads three canonical files and cross-validates them:

File What It Provides
centurion-networks/mainnet/network.yaml Chain ID, deposit contract address, owner, RPC/CL ports
centurion-networks/mainnet/bundle/el/deposit_contract_fingerprint.json Runtime bytecode keccak256, expected length, empty deposit root
centurion-infra/deployments/mainnet/deploy-node.sh Endpoint label inventory, EL/CL client types, SSH user

Cross-validation is enforced

The contract address in network.yaml must match the address in fingerprint.json. If it doesn't, the tool refuses to start.

4.3 Endpoint Labels

Endpoints are discovered from the NODE_IP, NODE_EL_SVC, and NODE_CL_SVC arrays in deploy-node.sh. Service names are mapped to client types:

Service Name Mapped Client
go-centurion geth
rustcen reth
nethermind nethermind
lighthouse-beacon lighthouse
grandine grandine
lodestar-beacon lodestar

URL privacy

Endpoint URLs are never printed. All output uses labels like [aws-a-1].

RPC transport defaults to auto:

  1. Try direct http://<node-ip>:<port> first
  2. If unreachable, open an SSH local-forward tunnel to 127.0.0.1:<port> on that node
  3. Keep output label-only (no URL/IP printing)

4.4 Environment Variables

Variable Required Purpose
SEAT_MANAGER_OWNER_PRIVATE_KEY For --send 0x-prefixed 32-byte hex private key for admin txs
SEAT_MANAGER_DEPOSITOR_PRIVATE_KEY For seat deposit --send 0x-prefixed 32-byte hex private key for deposit sender
CENTURION_INFRA_ROOT No Override infra path
CENTURION_NETWORKS_ROOT No Override networks repo path
SEAT_MANAGER_RPC_MODE No auto (default), ssh, or direct
CONFIRM_FREEZE For freeze execute Must be exactly I_UNDERSTAND

5. Operation Modes

When SEAT_MANAGER_OWNER_PRIVATE_KEY is not set:

Behavior Details
Calldata generation Every admin command generates calldata and writes an evidence bundle
Transaction sending Never — the --send flag is silently ignored
Local state updates Only for local operations (create, operational revoke)
Best for Planning, review, governance workflows

When SEAT_MANAGER_OWNER_PRIVATE_KEY is set:

Behavior Details
Calldata generation Same as PROPOSE-ONLY — evidence is always written first
Transaction sending Adding --send to a command also sends the transaction
Private key handling Read from env, never stored or logged
Receipts Transaction receipts recorded in the database and evidence bundle

Per-command opt-in

Both modes require --send per command. Having the key alone does not auto-send anything.


6. Preflight & Safety System

6.1 Single-Label Preflight

Used by status. Checks against one reachable endpoint:

Check RPC Method Pass Condition
Chain ID eth_chainId Matches network.yaml
Code exists eth_getCode Non-empty bytecode at contract address
Fingerprint keccak256(bytecode) Matches fingerprint.json
Owner owner() Matches network.yaml owner
Additional state reads (informational)

These are read during preflight but are informational — they don't gate operations:

Read What It Shows
pubkeyAllowlistEnabled() Current on/off state
pubkeyAllowlistDisabledForever() Irreversible flag
disableAllowlistNotBefore() Scheduled freeze timestamp
depositCount() Total deposit count
get_deposit_root() Merkle root

6.2 Cross-Check Preflight (2+ Labels)

Used by all admin commands (seat approve, seat revoke, switch, freeze).

Step What Happens
1. Discovery Iterates endpoints until 2 reachable ones are found
2. Full preflight Runs all checks on each endpoint
3. Consensus All must pass — if any single check fails on any label, the operation aborts
4. Diversity Prefers endpoints with distinct EL clients (e.g., geth + reth)

Guardian pattern

This is modeled after Lido's guardian attestation pattern: no single node can authorize a dangerous action.

6.3 Freeze Safety Gates

freeze execute requires all four gates to pass:

Gate Mechanism Error If Missing
1 --dangerous CLI flag "requires --dangerous flag (this is IRREVERSIBLE)"
2 CONFIRM_FREEZE=I_UNDERSTAND env var "requires env CONFIRM_FREEZE=I_UNDERSTAND"
3 Cross-check preflight on 2+ labels "Cross-check preflight failed"
4 Timelock maturity (notBefore <= now) "Timelock not mature: Ns remaining"

All four gates must pass

Missing even one gate causes the operation to abort immediately. This is intentionally strict — freeze execute is irreversible.


7. Command Reference

status

Read-only health check. Tries each endpoint until one responds.

seat-manager status
Example output
Mode: PROPOSE_ONLY
Endpoints: aws-a-1(geth), aws-b-1(reth), aws-c-1(geth), hetz-1(geth), linode-1(nethermind)
Contract: 0x1cf70D9361DA31eE571625C2fA732614CcE5f011
Connecting to [aws-a-1] (geth)...

  Chain ID:        ✓ 286 (expected 286)
  Contract code:   ✓ present
  Fingerprint:     ✓ matches
  Owner:           ✓ matches canonical
  Allowlist:       ON
  Disabled forever: no
  Timelock:        none scheduled
  Deposit count:   40
  Deposit root:    0xd70a...5e

All preflight checks passed via [aws-a-1]

seat create

Store a new seat locally. No on-chain action.

seat-manager seat create \
  --operator <id> \
  --pubkey <0x-prefixed 48-byte hex> \
  --wc <0x-prefixed 32-byte hex>

Validates pubkey (48 bytes) and withdrawal credentials (32 bytes), computes intentHash, inserts into the seats table with status CREATED.


seat approve <seatId>

Propose (or send) addAllowedDeposit(pubkey, wc).

seat-manager seat approve 1           # Propose only — writes evidence
seat-manager seat approve 1 --send    # Send tx (needs owner key)
Requirement Details
Seat status Must be CREATED
Preflight Cross-checks on 2 labels
On --send success Seat transitions to ALLOWLISTED

seat deposit <seatId>

Simulate or send deposit(pubkey, wc, signature, root) for an existing seat.

seat-manager seat deposit 12 --amount-ctn 1 --simulate-only
seat-manager seat deposit 12 --amount-ctn 1 --simulate-only --expect-revert
seat-manager seat deposit 12 --amount-ctn 1 --send
Step What Happens
Preflight Cross-check on 2 labels before simulation or send
Deposit data Resolves matching deposit data for the seat intent using local deposit-cli tooling
Simulation Always simulates via eth_call before send
Post-send verification Exactly one DepositEvent, matching fields, depositCount increments by exactly 1

seat revoke <seatId>

Behavior depends on seat state:

Current State What Happens
CREATED Marked REVOKED immediately — no on-chain action
ALLOWLISTED (no deposits) Propose/send removeAllowedDeposit, then mark REVOKED
DEPOSITED / SEEN_BY_CL / ACTIVE Marked REVOKED operationally with audit note

Post-deposit revoke is honest

Validators cannot be removed from the beacon chain through the deposit contract. Post-deposit revoke is an operational marking only.

seat-manager seat revoke 3           # Propose only
seat-manager seat revoke 3 --send    # Send tx (if ALLOWLISTED)

seat list

seat-manager seat list                              # All seats
seat-manager seat list --status CREATED,ALLOWLISTED # Filter by status
Example output
  ID    Operator     Status         Intent Hash
  ───── ──────────── ────────────── ──────────────────────────────────
  1     op-1         CREATED        0x9c2634c962...
  2     op-2         ALLOWLISTED    0xa1b2c3d4e5...
  3     op-3         DEPOSITED      0xf6e7d8c9ba...

switch on / switch off

Toggle setPubkeyAllowlistEnabled(bool).

seat-manager switch on              # Propose only
seat-manager switch on --send       # Send tx
seat-manager switch off --send
Pre-check Details
Cross-check Preflight on 2 labels
Not frozen Allowlist must not be permanently disabled
State differs Warns if already in desired state

freeze schedule

Schedule the irreversible allowlist disable.

seat-manager freeze schedule --delay-seconds 172800          # Propose
seat-manager freeze schedule --delay-seconds 172800 --send   # Send

Calls scheduleDisablePubkeyAllowlistForever(delaySeconds). The contract sets disableAllowlistNotBefore = block.timestamp + delaySeconds.


freeze cancel

Cancel an active disable schedule.

seat-manager freeze cancel
seat-manager freeze cancel --send

Resets disableAllowlistNotBefore to 0, keeping the allowlist reversible.


freeze execute

IRREVERSIBLE

This permanently disables the allowlist. There is no undo.

export CONFIRM_FREEZE=I_UNDERSTAND
seat-manager freeze execute --dangerous           # Propose only
seat-manager freeze execute --dangerous --send     # Actually execute

All four safety gates must pass (see section 6.3).


watch

Run EL + CL watchers continuously.

seat-manager watch      # Ctrl+C to stop

Updates seat statuses automatically: DEPOSITEDSEEN_BY_CLACTIVE. See section 9 for watcher details.


8. Seat Lifecycle

stateDiagram-v2
    [*] --> CREATED : seat create
    CREATED --> ALLOWLISTED : seat approve --send
    CREATED --> REVOKED : seat revoke

    ALLOWLISTED --> DEPOSITED : EL watcher sees DepositEvent
    ALLOWLISTED --> REVOKED : seat revoke --send\n(removeAllowedDeposit)

    DEPOSITED --> SEEN_BY_CL : CL watcher — 1st label
    DEPOSITED --> REVOKED : seat revoke\n(operational only)

    SEEN_BY_CL --> ACTIVE : CL watcher — 2+ labels
    SEEN_BY_CL --> REVOKED : seat revoke\n(operational only)

Key invariant

Status only moves forward (CREATED → ALLOWLISTED → DEPOSITED → SEEN_BY_CL → ACTIVE), or to REVOKED from any state. There is no backward transition.


9. Watchers

EL Watcher

Monitors DepositEvent logs from the deposit contract.

Parameter Value
Poll interval 12 seconds
Max backoff 60 seconds
Endpoints used 2+ with distinct EL clients (e.g., geth + reth)
Start block Current block - 10 on first run

Per block range, the watcher:

Step Action
1 Fetch logs from the deposit contract address
2 Decode DepositEvent(pubkey, wc, amount, signature, index)
3 Compute intentHash from the pubkey and wc
4 Look up the seat by intent hash
5 Record the deposit (tx hash, block, amount in gwei, index)
6 Transition the seat to DEPOSITED if appropriate

Amount/index encoding

Amount and index are decoded from 8-byte little-endian fields, matching the Eth2 deposit contract event format.

CL Watcher

Monitors validator visibility on beacon nodes.

Parameter Value
Poll interval 24 seconds (~2 slots)
Max backoff 120 seconds
Endpoints used 2+ with distinct CL clients (e.g., lighthouse + grandine)
Beacon API /eth/v1/node/health + /eth/v1/beacon/states/head/validators/{pubkey}

Per poll cycle:

Step Action
1 Query seats table for statuses DEPOSITED or SEEN_BY_CL
2 For each CL endpoint, check health (200/206 = healthy)
3 For each seat, query the validator by pubkey
4 Record the observation (label, seen/not-seen)
5 First visibility → SEEN_BY_CL; 2+ distinct labels → ACTIVE

Both watchers use bounded exponential backoff and respond to SIGINT/SIGTERM for graceful shutdown.


10. Evidence Bundles

Every admin action (approve, revoke, switch, freeze) writes a JSON evidence file to ./evidence/ before any transaction is sent.

Evidence bundle structure
{
  "timestamp": "2026-02-25T10:30:00.000Z",
  "action": "seat.approve",
  "mode": "PROPOSE_ONLY",
  "canonicalSnapshot": {
    "chainId": 286,
    "depositContractAddress": "0x1cf7...",
    "depositContractOwner": "0xf39F...",
    "runtimeBytecodeKeccak256": "0x64e6...",
    "endpointLabels": ["aws-a-1", "aws-b-1", "aws-c-1", "hetz-1", "linode-1"]
  },
  "preflightResults": [
    { "label": "aws-a-1", "chainIdOk": true, "fingerprintOk": true, "...": "..." },
    { "label": "aws-b-1", "chainIdOk": true, "fingerprintOk": true, "...": "..." }
  ],
  "functionName": "addAllowedDeposit",
  "args": ["0xaabb...", "0xccdd..."],
  "calldata": "0x1234...",
  "notes": "PROPOSE-ONLY: calldata generated, no tx sent"
}

When --send is used, the bundle also includes txHash, txStatus, and blockNumber.

Governance Workflow

Step What Happens
1 Operator runs command without --send → evidence bundle produced
2 Evidence is reviewed by governance / security team
3 If approved, operator re-runs with --send → second bundle with receipt
4 Both bundles form a verifiable audit trail

11. Database

SQLite at ./data/seat-manager.sqlite (WAL mode).

Table Purpose
seats Seat lifecycle: id, operator, pubkey, wc, intent_hash, status
allowlist_actions On-chain tx records: intent_hash, add/remove, tx_hash, block, status
deposits Observed DepositEvent logs: intent_hash, tx_hash, block, amount_gwei, index
cl_observations Beacon API checks: intent_hash, label, seen, details
audit_log Immutable action log: action_type, actor, payload_json

Local state only

All timestamps use SQLite datetime('now'). The database is gitignored — it contains local operational state, not source of truth.


12. Runbook: Common Operations

Onboarding a New Validator

# 1. Create the seat
seat-manager seat create \
  --operator "foundation-aws-5" \
  --pubkey 0x8a3f... \
  --wc 0x020000...
# Output: Seat created: id=12 intentHash=0x4a5b...

# 2. Propose allowlisting (generates evidence, no tx)
seat-manager seat approve 12
# Output: PROPOSE-ONLY: calldata written to evidence/

# 3. After governance approval, send the tx
export SEAT_MANAGER_OWNER_PRIVATE_KEY=0x...
seat-manager seat approve 12 --send
unset SEAT_MANAGER_OWNER_PRIVATE_KEY
# Output: Seat 12 ALLOWLISTED at block 54321

# 4. Monitor deposit + activation
seat-manager watch
# Watcher updates: DEPOSITED → SEEN_BY_CL → ACTIVE

Batch Review

seat-manager seat list                              # All seats
seat-manager seat list --status CREATED             # Pending approvals
seat-manager seat list --status ACTIVE              # Active validators
seat-manager seat list --status DEPOSITED,SEEN_BY_CL  # Deposited but not yet active

Revoking a Seat

seat-manager seat revoke 12 --send
# Sends removeAllowedDeposit if ALLOWLISTED
seat-manager seat revoke 12
# Operational revoke only — audit note written
# Output: Seat 12 has deposits — marked REVOKED operationally

Toggling the Allowlist

seat-manager switch off --send    # Disable (allow anyone to deposit)
seat-manager switch on --send     # Re-enable

Running as a Background Service

# Using systemd, screen, or tmux:
node dist/index.js watch

# Or with npm:
npm run seat-manager:watch

The watch command handles SIGINT/SIGTERM gracefully and closes the database cleanly on shutdown.


13. Runbook: Emergency Procedures

Temporarily Disable Allowlist

Reversible

This is reversible. Re-enable with switch on --send.

export SEAT_MANAGER_OWNER_PRIVATE_KEY=0x...
seat-manager switch off --send
unset SEAT_MANAGER_OWNER_PRIVATE_KEY

Cancel a Scheduled Freeze

export SEAT_MANAGER_OWNER_PRIVATE_KEY=0x...
seat-manager freeze cancel --send
unset SEAT_MANAGER_OWNER_PRIVATE_KEY

Permanent Allowlist Disable (Freeze)

Nuclear option — no undo

Once executed, the allowlist can never be re-enabled. The four safety gates exist specifically to prevent accidental execution.

Day Action
Day 0 Schedule with timelock (e.g., 48 hours)
Day 0–2 Review period — cancel if needed
Day 2+ Execute after timelock matures
# Day 0: Schedule
export SEAT_MANAGER_OWNER_PRIVATE_KEY=0x...
seat-manager freeze schedule --delay-seconds 172800 --send
unset SEAT_MANAGER_OWNER_PRIVATE_KEY

# Day 2+: Execute
export SEAT_MANAGER_OWNER_PRIVATE_KEY=0x...
export CONFIRM_FREEZE=I_UNDERSTAND
seat-manager freeze execute --dangerous --send
unset SEAT_MANAGER_OWNER_PRIVATE_KEY
unset CONFIRM_FREEZE

Endpoint Unreachable

If all endpoints are unreachable, status and admin commands fail with:

All endpoints unreachable. Last error: ...

No corruption risk

This does not corrupt local state. The tool simply refuses to operate without a live chain connection. Watchers will back off and retry automatically.


14. Security Considerations

Private Key Hygiene

Rule Details
Never stored The owner key is never in any file, config, or database
Read once Read from SEAT_MANAGER_OWNER_PRIVATE_KEY at process startup only
Never logged Never logged, printed, or included in evidence bundles
Unset after use unset SEAT_MANAGER_OWNER_PRIVATE_KEY immediately after use

Subshell pattern

(export SEAT_MANAGER_OWNER_PRIVATE_KEY=0x...; node dist/index.js seat approve 1 --send)
The key is automatically unset when the subshell exits.

URL Privacy

Guarantee Details
URLs never printed Endpoint URLs (IP + port) are constructed internally only
Labels in output All external-facing output uses labels (e.g., [aws-a-1])
Not in evidence The host field exists in config but is never serialized to output

Evidence Integrity

Property Details
Written first Evidence bundles are written before any transaction is sent
Full snapshot Captures canonical config, preflight results, and calldata
Two-phase audit PROPOSE-ONLY bundles serve as proposals; OWNER-SEND bundles add tx receipts
Gitignored Evidence files are gitignored to prevent accidental commit

Attack Surface Minimization

PROPOSE-ONLY default means...

  • A compromised seat-manager process cannot send transactions
  • Evidence bundles can be reviewed offline before any key is exposed
  • The signing surface is limited to the explicit --send code path
  • Cross-check on 2+ endpoints means a single compromised node cannot trick the tool

Gitignored Paths

Path Contents
data/ SQLite database
evidence/ Evidence bundles
.env Environment files
*.sqlite* Database files

Never commit these

None of these paths should ever be committed to version control.