Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
L-05·

Stale bond purchases can settle below the buyer's quoted maturity value

Acknowledgedethereumstakingeth-bonds
TL;DR

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.

Severity
LOW
Method
MManual review
CAT.
02Section · Affected files

Affected files

03Section · Description

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.

04Section · Recommendations

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:

solidity
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.

05Section · Proof of Concept

Proof of Concept

Add the following test as test/FeeSlippage.t.sol and run:

bash
forge test --match-path test/FeeSlippage.t.sol -vv
solidity
// SPDX-License-Identifier: BUSL-1.1
pragma 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)
private
returns (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)
private
pure
returns (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:

code
[PASS] test_adjacentFeeDayExecutesBelowQuotedClaim()
[PASS] test_longStaleTransactionExecutesBelowQuotedClaim()
Suite result: ok. 2 passed; 0 failed; 0 skipped.
06Section · Additional purchase-state binding

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.

07Section · Mitigation

Mitigation

Acknowledged. Commits fcdfbbb and 1309c97 document that buyBond uses the fee, bond terms and owner that exist when the transaction lands. No deadline, maximum fee, minimum maturity value or expected owner is bound in calldata, so adverse landing-time changes remain an accepted risk.

08Section · Report provenance

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.

L-05