Skip to content

WithdrawalVault v2 Implementation Report

Date: 2026-03-19 Chain: Centurion Mainnet (chain ID 286) Status: Full e2e verified — contract, seat-manager, staker console, on-chain claim flow


Summary

Complete implementation of WithdrawalVault v2 across 4 repositories, with a fresh mainnet deployment, full validator lifecycle (deposit → activation → attestation), and comprehensive on-chain e2e testing of the v2 claim-delay mechanism, ExitSettlement phase, and conservation law.


A. Solidity Contract

File: centurion-networks/mainnet/contracts/src/WithdrawalVault.sol

New Features

  • Claim delay mechanism: CLAIM_DELAY immutable (constructor arg, default 86400s / 24h), MIN_CLAIM_DELAY constant = 3600s
  • Pending claim state: pendingClaimWei, pendingClaimInitiatedAt storage variables
  • Running-phase flow: initiateClaimRewards(amountWei) -> wait CLAIM_DELAY -> finalizeClaimRewards(to), with cancelPendingClaim() escape hatch
  • Phase-dependent claimableRewardsWei(): Running = address(this).balance; ExitSettlement = cumulative formula totalLifetimeWei - principalTargetWei - rewardsClaimedWei
  • Balance-capped claimablePrincipalWei(): prevents reverts when Running-phase claims + shortfall reduce available balance
  • claimRewards() restricted to ExitSettlement only (reverts with ExitSettlementNotStarted)
  • startExitSettlement() clears pending claims and emits ClaimCancelled if any
  • pendingClaimStatus() view: returns (amount, readyAt, isReady) tuple for frontend
  • New errors: ClaimDelayTooShort, RunningPhaseRequired, ClaimAlreadyPending, NoPendingClaim, ClaimNotReady, NothingToClaim
  • New events: ClaimInitiated, ClaimFinalized, ClaimCancelled
  • VaultDeployed event now includes claimDelay parameter
  • _resolveClaimAmount() uses NothingToClaim when claimableWei == 0

Conservation Law

totalLifetimeWei() = address(this).balance + principalClaimedWei + rewardsClaimedWei
Verified at every step of e2e testing. Final verification: 35e18 = 0 + 32e18 + 3e18 ✓

Accounting Separation

  • rewardsClaimedWei — only incremented by onlyBeneficiary calls (finalizeClaimRewards in Running phase, claimRewards in ExitSettlement). Never incremented by treasury operations.
  • principalClaimedWei — only incremented by onlyTreasury call (claimPrincipal in ExitSettlement). Never incremented by beneficiary operations.
  • This clean separation means the staker console can show "Rewards Paid to You" using rewardsClaimedWei with confidence that it represents actual payouts to the user.

B. Forge Tests

File: centurion-networks/mainnet/contracts/test/WithdrawalVault.t.sol

93 tests, all passing. Coverage:

Section Tests
Constructor validation 7
Execution withdrawal credentials 1
Authorization guards 6
Running phase (initiate/finalize/cancel) 12
Phase transition 5
ExitSettlement accounting 8
Shortfall scenarios 3
Claim ordering 3
Race conditions (protocol delay + claim delay + phase gate) 3
Claim amount validation 7
Destination routing (zero address -> msg.sender) 5
Timing independence 3
Accounting invariants 3
Pending claim status view 4
Edge cases (empty vault, dust, max uint, etc.) 8
Reentrancy (initiate, finalize, claimRewards, claimPrincipal) 4
Events 7
Fuzz tests (256 runs each) 4

Helper contracts: ReentrantBeneficiary and ReentrantTreasury with configurable attack vectors.

forge test -vvv  # All 93 pass
forge fmt --check  # Clean

C. Seat-Manager

File: centurion-seat-manager/src/vault/contract.ts

  • Added claimDelay?: bigint to DeployWithdrawalVaultParams interface
  • Default: 86400n (24 hours)
  • Passed as 6th constructor argument alongside treasury, beneficiary, validatorPubkey, principalTargetWei, shortfallPolicy
const args = [
  params.treasury,
  params.beneficiary,
  params.validatorPubkey,
  params.principalTargetWei,
  SHORTFALL_POLICY_PRINCIPAL_FIRST,
  claimDelay,  // new v2 arg
] as const;

Quality: tsc --noEmit clean, npm run build clean.


D. Staker Console

ABI

File: centurion-staker-console/src/abi/withdrawalVault.ts

Added v2 function entries: - initiateClaimRewards(uint256) - finalizeClaimRewards(address) - cancelPendingClaim() - pendingClaimStatus() -> (uint256 amount, uint256 readyAt, bool isReady) - CLAIM_DELAY() -> uint256 - pendingClaimWei() -> uint256 - pendingClaimInitiatedAt() -> uint256 - settlementPhase() -> uint8 - claimablePrincipalWei() -> uint256 - principalClaimedWei() -> uint256 - Fixed claimRewards outputs to include uint256 claimedWei return type (was missing)

Hook

File: centurion-staker-console/src/hooks/useVaultProgress.ts

  • Added SettlementPhaseValue type (0 | 1)
  • Added settlementPhase to VaultProgressInfo interface
  • Multicall reads 6 fields per validator (was 5): added settlementPhase
  • FIELDS_PER_VALIDATOR = 6
  • thresholdCrossed logic:
  • Running phase: claimableRewardsWei > 0n
  • ExitSettlement: totalLifetimeWei > principalTargetWei

UI Changes

Seat Economics (src/components/SeatEconomics/SeatEconomics.tsx): - Made collapsible with expand/collapse chevron toggle - Collapsed by default — users see validator table first - All content (How It Works, Reward Flow, Vault Progress, Current Status) inside collapse

Dashboard (src/pages/Dashboard/Dashboard.tsx): - Seat Economics section moved below the Validator Seats table (was above)

Validator Table (src/components/DashboardValidatorsTable/DashboardValidatorsTable.tsx): - Fixed "Foundation-backed" pill text wrapping by adding whitespace-nowrap

Dashboard Metrics (verified accurate for production)

Metric Source What it shows
Foundation Principal validatorCount * 32 Total Foundation-funded capital
Validator Consensus Balance API validator.balance (live beacon) Current CL balance
Your Available Rewards sum(claimableRewardsWei) across readable vaults What user can claim now
Rewards Paid to You sum(rewardsClaimedWei) across readable vaults Lifetime payouts to user

"Rewards Paid to You" accuracy: rewardsClaimedWei is exclusively beneficiary reward claims. Treasury claimPrincipal increments a separate principalClaimedWei counter. The metric and its tooltip ("Total rewards already claimed and paid out to you across all your validator seats.") are accurate for production.

Quality: tsc --noEmit clean.


E. Mainnet Deployment & E2E Test

Chain Setup

  • Fresh genesis generated with ethpandaops genesis generator
  • 3 genesis validators, 2048 CTN each
  • Fulu fork from genesis (all fork epochs = 0)
  • Boot node + syncing node topology
  • Chain finalizing normally at epoch 106+ (slot 3400+)

V2 Vault Deployments

Vault 1 — Manual deploy (initial verification):

Field Value
Vault Address 0x5FbDB2315678afecb367f032d93F642f64180aa3
Beneficiary 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Treasury 0x59644844b6bde7F691F8bb84B2C205117cFdb774
Settlement Phase 0 (Running)
Balance ~1.008 CTN
Notes Used for initial synthetic claim test. Not linked to seat-manager.

Vault 2 — Seat-manager e2e (full lifecycle test):

Field Value
Vault Address 0x412a91402f3D3bAeE3612d5b5d0Ba43542B7AdF9
Deployed via seat-manager create-with-vault (seat 2, operator e2e-v2-test)
Beneficiary 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Treasury 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Principal Target 32 CTN
CLAIM_DELAY 86400 seconds (24 hours)
Settlement Phase 1 (ExitSettlement) — started during e2e test
rewardsClaimedWei 3 CTN
principalClaimedWei 32 CTN
Current Balance ~0.0042 CTN (ongoing protocol inflows)
Validator Index 4, active_ongoing, 32.0 CTN balance

Validator Lifecycle (seat-manager flow)

Step Result
create-with-vault Seat 2 + Vault 0x412a91... deployed
approve (allowlist) Block 2810
deposit (32 CTN) Block 2813
Deposit processing Epoch 90 (finalized slot ≥ deposit slot)
Eligibility assigned Epoch 91
Activation Epoch 97
First attestation Slot 3110 (validator 4)
Ongoing attestations Both v3 and v4 attesting (19+ attestations logged)

E2E Claim Flow Test Results

Test Result
Fund vault (receive CTN) totalLifetimeWei updated, claimableRewardsWei = balance in Running phase
initiateClaimRewards(0) Pending claim set, ClaimInitiated event
pendingClaimStatus() Correct (amount, readyAt=+24h, isReady=false)
Premature finalizeClaimRewards Reverts ClaimNotReady
Duplicate initiateClaimRewards Reverts ClaimAlreadyPending
cancelPendingClaim() Resets pending state, ClaimCancelled event
Partial claim (0.5 CTN) pendingClaimWei = 0.5 CTN
Auth guard (non-beneficiary) Reverts Unauthorized on all functions
claimRewards in Running phase Reverts ExitSettlementNotStarted
startExitSettlement() Phase 0→1, ExitSettlementStarted event
claimRewards (3 CTN excess) Beneficiary receives 3 CTN
claimPrincipal (31 CTN remaining) Treasury receives 31 CTN
Claim remaining principal (1 CTN) Treasury receives 1 CTN (total 32 CTN)
Final balance 0 (fully drained)
Conservation law 35e18 = 0 + 32e18 + 3e18 ✓ (verified at every step)
NothingToClaim guard Both claim functions revert on empty vault

Staker Console UI Verification

Element Status
Dashboard loads with correct wallet ✓ (empty with wrong wallet — correct behavior)
Foundation Principal: 32 CTN
Validator Consensus Balance: 32.0000 CTN (live)
Your Available Rewards: 0.0040 CTN (green)
Rewards Paid to You: 3.0000 CTN ✓ (accurate — rewardsClaimedWei is beneficiary-only)
Validator table: index 4, Active, vault, progress bar
Foundation-backed pill (single line) ✓ (fixed)
Seat Economics collapsible below table
Performance indicator (Earning/Declining/Flat)
Vault Progress bar (Unlocked)

Files Modified

Repository File Change
centurion-networks mainnet/contracts/src/WithdrawalVault.sol Full v2 rewrite
centurion-networks mainnet/contracts/test/WithdrawalVault.t.sol 93 test cases
centurion-seat-manager src/vault/contract.ts claimDelay constructor arg
centurion-staker-console src/abi/withdrawalVault.ts v2 ABI entries
centurion-staker-console src/hooks/useVaultProgress.ts settlementPhase + 6-field multicall
centurion-staker-console src/components/SeatEconomics/SeatEconomics.tsx Collapsible dropdown
centurion-staker-console src/components/DashboardValidatorsTable/DashboardValidatorsTable.tsx Foundation-backed pill nowrap
centurion-staker-console src/pages/Dashboard/Dashboard.tsx Seat Economics below validator table

Production Readiness Assessment

Ready

  • Contract: Fully tested (93 forge tests + fuzz), conservation law verified on-chain, all error guards confirmed
  • Accounting separation: rewardsClaimedWei (beneficiary) vs principalClaimedWei (treasury) cleanly separated — UI metrics are accurate
  • Claim delay: 24h delay with initiate/cancel/finalize flow working on live chain
  • Staker console: All dashboard metrics accurate, v2 ABI complete, settlementPhase-aware
  • Seat-manager: Vault deployment with claimDelay arg, watcher integration working

Not yet tested on-chain

  • Full 24h claim delay wait + finalize — impractical for live testing, but forge tests cover timing exhaustively
  • Multiple validators per beneficiary — only tested with 1 vault per beneficiary (multicall aggregation logic exists but untested with >1)

Notes for next mainnet deploy

  • Vaults start fresh (rewardsClaimedWei = 0, phase = Running) — no test artifacts
  • Beneficiary and treasury should be different addresses in production
  • Seat-manager deposit contract fingerprint must match the canonical config