Redeem Bonds guidance omits funding reserved for other matured bonds
The Redeem Bonds guide describes the app's Value to send field as max(0, selectedPayout - cofferBalance) and instructs validators to send that amount.
Affected files
coffer-smart-contracts/src/Coffer.sol#L436-L441
Description
The Redeem Bonds guide describes the app's Value to send field as max(0, selectedPayout - cofferBalance) and instructs validators to send that amount. This omits funds needed by matured bonds outside the selected batch. The contract's NatSpec explicitly requires including those unpaid maturities; the contract itself checks only that the selected batch can be paid.
A validator can fund a matured bond, then redeem a different bond using the guide's amount. The redemption spends the first bond's funding. Its holder's subsequent redemption receives no ETH and declares a default, opening permissionless exit requests while that bond remains outstanding.
Following this guidance can therefore interrupt a funded holder's payment and expose the validator to an avoidable default. The validator can restore payment by topping up and settling the remaining claims; an accepted consensus-layer exit request cannot be recalled. This is a funding-guidance defect, rather than an additional contract guard failure. The guide's assurance that redemption is never detrimental should apply only to the selected holders.
Recommendations
While the Coffer is not defaulted, size the guide's recommended payment and the corresponding app field as:
required = selectedPayout + maturedUnpaidOutsideBatchvalueToSend = max(0, required - preTransactionCofferBalance)
Exclude selected bonds from the matured-unpaid sum. Read outstanding bond values, maturities and balance from consistent, fresh state, and account for bonds maturing while the transaction is pending. This can require more ETH from the validator and additional bond-state reads. Qualify the holder-benefit assurance and explain that claims during default follow the default settlement rules; this formula does not guarantee full funding of all accelerated claims or undo an exit request.
Apply the same reserve to the Withdraw · Execution panel's "Available" figure, which the Funds guide presents as the liquid balance bounded by the issue size. ETH sent to fund a maturity is also credited to issueSize, so until the holder redeems, that funding shows as withdrawable; display min(issueSize, balance - maturedUnpaid) instead.
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/RedeemSizing.t.sol -vv. The first test sizes msg.value as the app does; the second sizes it as the NatSpec does.
// SPDX-License-Identifier: BUSL-1.1pragma solidity 0.8.34;import {BaseTest} from "./BaseTest.sol";import {Coffer} from "../../src/Coffer.sol";contract RedeemSizing is BaseTest {Coffer internal c;function _vc() internal view returns (uint128 issueSize, uint32 version, uint32 outstanding, uint16 bufferBps, bool defaulted) {(issueSize,,,,, version, outstanding, bufferBps,, defaulted) = c.sValidatorConditions();}function _mv(uint256 id) internal view returns (uint128 m) {(m,,) = c.sHolderConditions(id);}function test_D1_docsSizedValidatorRedemptionDefundsMaturedHolder() public {c = Coffer(payable(createDefaultCoffer()));(, uint32 v,,,) = _vc();uint256 b1 = buyBond(address(c), holder1, 10 ether, ONE_MONTH, v); // matures firstuint256 b2 = buyBond(address(c), holder2, 10 ether, ONE_YEAR, v); // long bonduint128 m1 = _mv(b1);uint128 m2 = _mv(b2);// Honest validator funds bond 1 before its maturity (docs: "keep the contract funded")vm.prank(validator);(bool ok,) = address(c).call{value: m1}("");assertTrue(ok);advanceTime(ONE_MONTH + 1 days); // bond 1 matured, holder1 has not redeemed yet (no deadline in docs)// Validator settles bond 2 early following the redeem-bonds page sizing: max(0, payout - balance)uint256 docsValue = m2 > address(c).balance ? m2 - address(c).balance : 0;uint256[] memory ids = new uint256[](1);ids[0] = b2;vm.prank(validator);c.validatorRedeemBonds{value: docsValue}(ids);// holder1 now redeems at maturity, as documenteduint256 h1Before = holder1.balance;vm.prank(holder1);bool paid = c.holderRedeemBondOrDefault(b1);(,,,, bool defaulted) = _vc();assertFalse(paid, "holder1 not paid");assertTrue(defaulted, "default declared");assertEq(holder1.balance, h1Before, "holder1 received nothing");assertEq(address(c).balance, 0, "funding of bond 1 was spent on bond 2");// and anyone can now force the honest validator's full exitvm.deal(unauthorizedUser, 1 ether);vm.prank(unauthorizedUser);c.exitValidator{value: getWithdrawalFee()}();}/// Counterfactual: NatSpec sizing (payout + maturedUnpaid - balance) keeps holder1 paid.function test_D1_natspecSizedRedemptionKeepsMaturedHolderPaid() public {c = Coffer(payable(createDefaultCoffer()));(, uint32 v,,,) = _vc();uint256 b1 = buyBond(address(c), holder1, 10 ether, ONE_MONTH, v);uint256 b2 = buyBond(address(c), holder2, 10 ether, ONE_YEAR, v);uint128 m1 = _mv(b1);uint128 m2 = _mv(b2);vm.prank(validator);(bool ok,) = address(c).call{value: m1}("");assertTrue(ok);advanceTime(ONE_MONTH + 1 days);uint256 natspecValue = uint256(m2) + m1 - address(c).balance;uint256[] memory ids = new uint256[](1);ids[0] = b2;vm.prank(validator);c.validatorRedeemBonds{value: natspecValue}(ids);vm.prank(holder1);assertTrue(c.holderRedeemBondOrDefault(b1));}}
[PASS] test_D1_docsSizedValidatorRedemptionDefundsMaturedHolder()
[PASS] test_D1_natspecSizedRedemptionKeepsMaturedHolderPaid()
Mitigation
Fixed. Commit fdd83b8, the public documentation and the live application now size a validator redemption as the selected payout plus matured unpaid bonds outside the batch, minus the Coffer balance. The application refreshes this minimum before submission and excludes matured unpaid liabilities from the amount shown as withdrawable.
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.