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¶
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:
- Try direct
http://<node-ip>:<port>first - If unreachable, open an SSH local-forward tunnel to
127.0.0.1:<port>on that node - 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.
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
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.
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.
Updates seat statuses automatically: DEPOSITED → SEEN_BY_CL → ACTIVE.
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¶
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:
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
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
--sendcode 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.