Docs time consensus funding at "roughly two days", but partial withdrawals wait in the exit queue, so a validator following them defaults
The README requires the contract balance to cover every matured, unpaid bond and correctly says consensus partials take about 28 hours only with an empty exit queue.
Affected files
Description
The README requires the contract balance to cover every matured, unpaid bond and correctly says consensus partials take about 28 hours only with an empty exit queue.
The public Funds guide instead says a request "waits in the beacon queue at least about 27 hours" and arrives "roughly two days after the request". Electra schedules a partial through compute_exit_epoch_and_update_churn and then adds 256 epochs. On 15 October 2026 the exit queue was about 5.4 days, so a request arrived after about 6.5 days.
A validator that follows the guide and requests three days ahead is short at maturity. The holder's holderRedeemBondOrDefault declares default, every bond accelerates, and anyone can force the exit. A pending partial makes the consensus layer drop the first exit request, but exitValidator is permissionless and retryable after the partial is processed. Holders are paid in full; the validator loses its validator and repays a 365-day bond about 271 days early-about 0.45 ETH of time value on 30 ETH at 2%.
The Validators overview adds that consensus rewards "flow to the contract on their own", which a 0x02 validator below 2,048 ETH does not get.
Recommendations
State the consensus funding time as the current exit-queue wait plus about 27 hours, and for an Exit up to one more withdrawal-sweep cycle (about a week at today's validator count), link a live queue source, and show the expected arrival epoch in the Withdraw · Consensus panel before the validator signs. Refresh the estimate when the panel opens and present it as approximate because queue state can change. Point to receive() top-ups as the only instant way to fund a maturity, and say that a request should be counted as funding only once it has arrived.
Apply the same correction to the "about two days" on the validator and holder Default pages. Remove "Your consensus rewards flow to the contract on their own" from the Validators overview, or limit it to the excess above 2,048 ETH for 0x02 validators.
PoC
Add to test/unit/, run forge build once (the BaseTest fixture loads the DepositContract mock from its build artifact), then forge test --match-path test/unit/Y03FundingLatency.t.sol -vv. The consensus layer is modelled: the partial withdrawal arrives after the exit-queue wait measured on 15 October 2026 (about 5.4 days) plus 256 epochs. The control uses the guide's two days, and the third test funds with the queue-aware timing.
//SPDX-License-Identifier: BUSL-1.1pragma solidity 0.8.34;import {BaseTest} from "./BaseTest.sol";import {Coffer} from "../../src/Coffer.sol";/// Y03: an honest validator funds a maturity through Withdraw · Consensus, timed by the docs'/// "the ETH arrives in the contract with the beacon sweep, roughly two days after the request"./// The consensus layer is MODELLED: Electra process_withdrawal_request puts a partial through/// compute_exit_epoch_and_update_churn (the shared exit churn, 256 ETH/epoch) and then/// MIN_VALIDATOR_WITHDRAWABILITY_DELAY (256 epochs ~27.3h). Live exit queue 2026-10-15/// (measured from beacon state, 2026-10-15): about 5.4 days. The partial's arrival is applied with deal().contract Y03FundingLatency is BaseTest {uint32 constant RATE_2PCT = 2_000_000;uint32 constant D90 = 90 days;uint32 constant D365 = 365 days;uint16 constant BUF = 200;uint128 constant START = 64 ether; // a 0x02 validator with excess above 32 ETH, so partials can funduint256 constant WITHDRAWABILITY = 256 * 384; // 256 epochs * 6.4 minuint256 constant EXIT_QUEUE_NOW = 5 days + 9 hours; // measured 2026-10-15 from beacon state: newest pending partial withdrawable_epoch - 256 - head = 1,209 epochsuint256 constant DOCS_LATENCY = 2 days; // "roughly two days after the request"Coffer c;uint256 bondA;uint256 bondB;uint128 mA;uint128 mB;uint256 t0;function setUp() public override {super.setUp();address a = createCoffer(validator, validPublicKeyPart1, validPublicKeyPart2, RATE_2PCT, D90, D365, 1 ether, BUF, START);c = Coffer(payable(a));t0 = block.timestamp;(,,,,, uint32 v,,,,) = c.sValidatorConditions();bondA = buyBond(a, holder1, 20 ether, D90, v); // matures day 90bondB = buyBond(a, holder2, 30 ether, D365, v); // matures day 365(mA,,) = c.sHolderConditions(bondA);(mB,,) = c.sHolderConditions(bondB);}/// Validator requests a partial of mA `lead` seconds before bond A matures; it lands `latency` later.function _run(uint256 lead, uint256 latency) internal returns (bool defaulted, uint256 bPaidAt) {uint256 maturityA = t0 + D90;vm.warp(maturityA - lead);uint256 fee = uint256(bytes32(_fee()));vm.prank(validator);c.validatorWithdrawFromConsensus{value: fee * 6}(uint64(mA / 1 gwei) + 1); // app sends 6x the feeuint256 landsAt = maturityA - lead + latency;uint256 bal = address(c).balance;if (landsAt <= maturityA) {vm.warp(landsAt);vm.deal(address(c), address(c).balance + mA + 1 gwei);}// Holder A redeems at maturity, as the docs tell it tovm.warp(maturityA);vm.prank(holder1);bool paid = c.holderRedeemBondOrDefault(bondA);(,,,,,,,,, defaulted) = c.sValidatorConditions();assertEq(paid, !defaulted);if (defaulted) {// Holder B forces the validator's exit (irrevocable) and waits for fundsuint256 f2 = uint256(bytes32(_fee()));vm.deal(holder2, holder2.balance + f2);vm.prank(holder2);c.exitValidator{value: f2}();// The consensus partial finally landsvm.warp(landsAt);vm.deal(address(c), address(c).balance + mA + 1 gwei);vm.prank(holder1);c.holderRedeemBondInDefault(bondA);// B is accelerated: claimable now at full maturity value; the rest of the stake sweeps in latervm.deal(address(c), address(c).balance + 44 ether); // modelled exit sweep of the remaining stakeuint256 before = holder2.balance;vm.prank(holder2);c.holderRedeemBondInDefault(bondB);assertEq(holder2.balance - before, mB, "B paid in full, early");bPaidAt = block.timestamp;} else {bPaidAt = t0 + D365;}bal;}function _fee() internal view returns (bytes memory d) {(, d) = address(0x00000961Ef480Eb55e80D19ad83579A64c007002).staticcall("");}/// Docs-literal: request 3 days ahead (more than "roughly two days"); today's queue applies.function test_Y03_docsTimingDefaultsHonestValidator() public {(bool defaulted, uint256 bPaidAt) = _run(3 days, EXIT_QUEUE_NOW + WITHDRAWABILITY);assertTrue(defaulted, "honest validator defaulted");uint256 early = t0 + D365 - bPaidAt;emit log_named_uint("mA (wei)", mA);emit log_named_uint("mB (wei)", mB);emit log_named_uint("B paid days before its maturity", early / 1 days);// Validator pays B ~259 days early (cost at its own 2% rate) and loses its validator (forced exit)emit log_named_uint("time-value cost of acceleration at 2%/yr (wei)", uint256(mB) * 2 * early / (100 * 365 days));assertGt(early, 250 days);}/// Control A: the docs' latency holds (empty exit queue) -> no default.function test_Y03_control_emptyQueue() public {(bool defaulted,) = _run(3 days, 5 * 384 + WITHDRAWABILITY);assertFalse(defaulted);}/// Mutation: guidance says "exit-queue wait + ~27h; check the live queue" -> validator requests in time.function test_Y03_mutation_queueAwareGuidance() public {(bool defaulted,) = _run(EXIT_QUEUE_NOW + WITHDRAWABILITY + 1 days, EXIT_QUEUE_NOW + WITHDRAWABILITY);assertFalse(defaulted);}}
Output:
[PASS] test_Y03_control_emptyQueue()[PASS] test_Y03_docsTimingDefaultsHonestValidator()B paid days before its maturity: 271time-value cost of acceleration at 2%/yr (wei): 455116716529680365[PASS] test_Y03_mutation_queueAwareGuidance()
Additional validated locations for the funding-timing fix
The same fixed-time assumption appears in the in-scope NatSpec for exitValidator and validatorWithdrawFromConsensus. Both describe the pending tail as about 28 hours without stating that the exit-queue backlog at request time is added first. These comments should be corrected with the public guides.
The fix should also apply the issue's “count only once arrived” rule to validatorAddFundsToConsensus. That function increases issueSize immediately, while the deposit waits for consensus processing. Its all-zero signature cannot register a new validator, so a top-up made before a valid initial deposit reaches the key can be discarded. Buyer guidance should treat the added capacity as backed only after the validator's beacon balance or pending deposits show the amount, and should state that the function is intended for an already registered key or one whose valid initial deposit is earlier in the queue.
Mitigation
Fixed. Commits 83551d4 and 268c2c3 correct the in-scope NatSpec and consensus top-up guidance. The public documentation and application now state that a partial withdrawal waits in the exit queue and then roughly 28 hours, that a full exit also waits for the sweep, and that direct ETH transfer is the instant funding path. The application does not yet calculate a live pre-sign arrival estimate, but the reported fixed two-day assumption has been removed.
Report provenance
Source: the corrected final report dated 22 October 2026. Final status and residual conditions are preserved in this write-up. Read the full report.