Stale bond purchases can settle below the buyer's quoted maturity value
buyBond lets a buyer bind the duration and ValidatorConditions.version, but it reads the protocol fee from the time-dependent FeeCurve only when the transaction executes.
Affected files
Description
buyBond lets a buyer bind the duration and ValidatorConditions.version, but it reads the
protocol fee from the time-dependent FeeCurve only when the transaction executes. Fee-curve time
movement does not change the Coffer version. The call has no deadline, maximum fee, or minimum
maturity value, so a queued or rebroadcast transaction can remain valid after the displayed quote
has worsened.
The higher fee is deducted from interest before bondMaturityValue is stored, and the exact
difference accrues to FeeCurve. At valid 1 ETH principal, 100% rate, and 50-year duration terms,
crossing the day-89/day-90 boundary reduces the claim by 0.01 ETH. In the valid 1 ETH, 100%, 10-year
long-staleness example, the same calldata stores 10.01 ETH instead of the quoted 10.9 ETH. Public
transactions will usually be included or nonce-replaced sooner, and ordinary builders do not
directly profit, which limits practical likelihood.
Recommendations
Require every purchase to bind a deadline, maximum fee rate, and minimum maturity value. Check the
deadline before pricing, then check the fee and maturity bounds immediately after reading
FeeCurve and before changing state or making external calls. For example, replace the unprotected
entry point with:
function buyBond(uint32 duration,uint32 version,uint256 maxFeeBps,uint256 minMaturityValue,uint256 deadline) external payable returns (uint256 bondId);
The frontend should derive these bounds from the quote shown to the buyer. Remove or disable the legacy unbounded selector in new deployments so integrations cannot silently keep using it.
Proof of Concept
Add the following test as test/FeeSlippage.t.sol and run:
forge test --match-path test/FeeSlippage.t.sol -vv
// SPDX-License-Identifier: BUSL-1.1pragma solidity 0.8.34;import {BaseTest} from "./unit/BaseTest.sol";import {Coffer} from "../src/Coffer.sol";import {Interest} from "../src/libraries/Interest.sol";contract FeeSlippageTest is BaseTest {uint32 private constant FIFTY_YEARS = 1_576_800_000;uint32 private constant TEN_YEARS = 315_360_000;uint32 private constant MAX_RATE = 100_000_000;function _newCoffer(bytes32 key, uint32 duration, uint128 balance)privatereturns (Coffer target){target = Coffer(payable(createCoffer(validator,key,bytes16(uint128(uint256(key))),MAX_RATE,duration,duration,1,0,balance)));}function _maturity(uint256 principal, uint32 duration, uint256 feeBps)privatepurereturns (uint256){uint256 interest = Interest.calculateInterest(principal, duration, MAX_RATE);return principal + interest - (interest * feeBps) / 10_000;}function test_adjacentFeeDayExecutesBelowQuotedClaim() public {Coffer target = _newCoffer(bytes32(uint256(101)), FIFTY_YEARS, 51 ether);uint256 start = feeCurve.START_TIME();vm.warp(start + 89 days + 23 hours + 59 minutes + 59 seconds);uint256 quotedClaim = _maturity(1 ether, FIFTY_YEARS, feeCurve.currentFeeBps());bytes memory callData = abi.encodeCall(Coffer.buyBond, (FIFTY_YEARS, 1));vm.warp(start + 90 days);uint256 feeBefore = feeCurve.sAccruedFees();vm.prank(holder1);(bool ok, bytes memory ret) = address(target).call{value: 1 ether}(callData);assertTrue(ok);uint256 bondId = abi.decode(ret, (uint256));(uint128 storedClaim,,) = target.sHolderConditions(bondId);uint256 feeDelta = feeCurve.sAccruedFees() - feeBefore;assertEq(quotedClaim - storedClaim, 0.01 ether);assertEq(feeDelta, 0.985 ether);assertEq(feeDelta - 0.975 ether, quotedClaim - storedClaim);}function test_longStaleTransactionExecutesBelowQuotedClaim() public {Coffer target = _newCoffer(bytes32(uint256(202)), TEN_YEARS, 12 ether);uint256 start = feeCurve.START_TIME();vm.warp(start);uint256 quotedClaim = _maturity(1 ether, TEN_YEARS, feeCurve.currentFeeBps());bytes memory callData = abi.encodeCall(Coffer.buyBond, (TEN_YEARS, 1));vm.warp(start + 3650 days);uint256 feeBefore = feeCurve.sAccruedFees();vm.prank(holder1);(bool ok, bytes memory ret) = address(target).call{value: 1 ether}(callData);assertTrue(ok);uint256 bondId = abi.decode(ret, (uint256));(uint128 storedClaim,,) = target.sHolderConditions(bondId);uint256 feeDelta = feeCurve.sAccruedFees() - feeBefore;assertEq(quotedClaim, 10.9 ether);assertEq(storedClaim, 10.01 ether);assertEq(quotedClaim - storedClaim, 0.89 ether);assertEq(feeDelta - 0.1 ether, quotedClaim - storedClaim);}}
Observed output at 793f6a78d805bfa2c83320633dc5984eca1038db:
[PASS] test_adjacentFeeDayExecutesBelowQuotedClaim()[PASS] test_longStaleTransactionExecutesBelowQuotedClaim()Suite result: ok. 2 passed; 0 failed; 0 skipped.
Additional purchase-state binding
When implementing the quoted-value and deadline bounds, also consider binding the Coffer owner or bumping version when ownership changes. buyBond validates version and then pays the current owner(), while inherited Ownable2Step ownership acceptance does not increment the Coffer version.
A transaction prepared while one validator address owns the Coffer can therefore execute after another address accepts ownership, paying the new owner without forcing the buyer to re-confirm. The bond terms and backing do not change, so this does not warrant a separate severity finding, but an expectedOwner bound or a version bump on ownership transfer closes the same stale-purchase trust gap while the purchase interface is being strengthened.
Mitigation
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.