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-01·

Bonds bought after February 2106 are mature at purchase because startTimestamp is truncated to uint32

Acknowledgedethereumstakingeth-bonds
TL;DR

buyBond stores the purchase time as uint32(block.timestamp).

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

Affected files

03Section · Description

Description

buyBond stores the purchase time as uint32(block.timestamp). Once block.timestamp passes type(uint32).max (7 February 2106, 06:28:15 UTC) the cast silently wraps, so a bond bought at time 2^32 + k records startTimestamp = k. Both maturity checks widen the stored values and compare them with the full timestamp:

solidity
uint256(holder.duration) + holder.startTimestamp <= block.timestamp

Every allowed duration is below 2^32 (MAX_DURATION is 50 years), so k + duration < k + 2^32 <= block.timestamp already holds in the purchase block: every bond sold after the rollover is mature the moment it is minted. The widened arithmetic and the NatSpec on the cast ("fits uint32 until year 2106") handle bonds bought before 2106 that mature after it, but not purchases made after the boundary. Coffer clones, the factory and the bond NFT are not upgradeable, so a Coffer deployed now that is still selling in 2106 cannot be repaired in place.

Impact: After February 2106, an ordinary buyer can immediately redeem a fresh long-duration bond and take the full term's interest from the validator or pooled execution balance that already covers matured holders; an uncovered fresh bond can instead trigger default and open the validator-exit path at purchase.

04Section · Recommendations

Recommendations

Widen the stored start time. HolderConditions still fits one storage slot (128 + 32 + 64 = 224 bits):

diff
struct HolderConditions {
uint128 bondMaturityValue;
uint32 duration;
- uint32 startTimestamp;
+ uint64 startTimestamp;
}
@@ buyBond
- // forge-lint: disable-next-line(unsafe-typecast) block.timestamp fits uint32 until year 2106
- startTimestamp: uint32(block.timestamp),
+ startTimestamp: uint64(block.timestamp),

Apply the same type to the readers of the public sHolderConditions getter: ICoffer.sHolderConditions and CofferBondNft.tokenURI in this repository, and ICoffer / CofferMarketplace.getBondData in the marketplace repository, plus any off-chain decoder that reads the third return value as uint32.

A checked narrowing (SafeCast.toUint32) also removes the early maturity, but turns it into every buyBond reverting after 2106; widening keeps the Coffer usable.

05Section · Proof of Concept

Proof of Concept

Add the test below as test/unit/TimestampRollover.t.sol and run:

forge build # once: BaseTest loads the DepositContract mock from its build artifact forge test --match-path test/unit/TimestampRollover.t.sol -vv
solidity
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.34;
import {BaseTest} from "./BaseTest.sol";
import {Coffer} from "../../src/Coffer.sol";
contract TimestampRolloverTest is BaseTest {
Coffer private target;
address private targetAddress;
function setUp() public override {
super.setUp();
targetAddress = createDefaultCoffer(); // durations 1 month .. 1 year
target = Coffer(payable(targetAddress));
}
function _defaulted() private view returns (bool d) {
(,,,,,,,,, d) = target.sValidatorConditions();
}
/// A one-year bond bought after the uint32 rollover is paid its full maturity value in the same block.
function test_PostRollover_OneYearBondPaidImmediately() public {
vm.warp(uint256(type(uint32).max) + 2);
vm.prank(holder1);
uint256 bondId = target.buyBond{value: 5 ether}(ONE_YEAR, 1);
(uint128 maturityValue,,) = target.sHolderConditions(bondId);
// The validator keeps the contract funded, as it must at all times
vm.prank(validator);
(bool funded,) = targetAddress.call{value: maturityValue}("");
assertTrue(funded);
uint256 holderBefore = holder1.balance;
vm.prank(holder1); // same block as the purchase, no time has passed
bool paidInFull = target.holderRedeemBondOrDefault(bondId);
assertTrue(paidInFull, "bond not paid");
assertEq(holder1.balance - holderBefore, maturityValue, "one year of interest paid on day zero");
assertGt(maturityValue, 5 ether, "maturity value includes the full year of interest");
}
/// The same bond on an unfunded Coffer lets anyone declare a default in the purchase block.
function test_PostRollover_FreshBondDefaultsImmediately() public {
vm.warp(uint256(type(uint32).max) + 2);
vm.prank(holder1);
uint256 bondId = target.buyBond{value: 5 ether}(ONE_YEAR, 1);
target.declareDefault(bondId); // anyone, same block
assertTrue(_defaulted(), "fresh one-year bond triggered a default");
}
/// Sweep: every allowed duration, every post-rollover purchase time.
function testFuzz_PostRollover_PurchasesImmediatelyMature(uint32 offset, uint32 duration) public {
duration = uint32(bound(duration, defaultMinDuration, defaultMaxDuration));
vm.warp(uint256(type(uint32).max) + 1 + uint256(offset));
vm.prank(holder1);
uint256 bondId = target.buyBond{value: 5 ether}(duration, 1);
(uint128 maturityValue,,) = target.sHolderConditions(bondId);
vm.prank(validator);
(bool funded,) = targetAddress.call{value: maturityValue}("");
assertTrue(funded);
uint256 holderBefore = holder1.balance;
vm.prank(holder1);
target.holderRedeemBondOrDefault(bondId);
assertEq(holder1.balance - holderBefore, maturityValue, "matured at purchase");
}
}

Output at 793f6a7:

Ran 3 tests for test/unit/TimestampRollover.t.sol:TimestampRolloverTest [PASS] testFuzz_PostRollover_PurchasesImmediatelyMature(uint32,uint32) (runs: 1024, μ: 198951, ~: 199024) [PASS] test_PostRollover_FreshBondDefaultsImmediately() (gas: 217108) [PASS] test_PostRollover_OneYearBondPaidImmediately() (gas: 194050) Suite result: ok. 3 passed; 0 failed; 0 skipped

With the uint64 change above applied, all three tests fail with HoldersTimeHasNotExpiredYet(), including the fuzzed sweep over every allowed duration and purchase time after the rollover, and a bond bought after the rollover redeems exactly duration seconds after purchase.

06Section · Mitigation

Mitigation

Acknowledged. Commit 55868a1 documents the February 2106 rollover, its effect on new purchases, and the need to stop sales before the boundary. The stored timestamp remains uint32, so the executable condition is retained as an accepted long-horizon risk.

07Section · 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-01