Summary
Bond payments in BondDepository.deposit go directly to the TREASURY
address via safeTransfer/safeTransferFrom never through
Treasury.deposit(), the only function that raises totalReserves (besides
governor-only auditReserves()). But every bond’s addNote calls
Treasury.mint(payout) (src/types/NoteKeeper.sol:49), which requires
amount <= excessReserves() where
excessReserves() = totalReserves - (FTSO.totalSupply() - totalDebt)
(src/Treasury.sol:260; totalDebt is never written, always 0).
So each bond lowers excessReserves by exactly its payout while
totalReserves stagnates. Once cumulative payouts exceed the initial buffer,
every new bond reverts with InsufficientReserves a deterministic,
permanent halt of the core bond market until governor/guardian calls
auditReserves(). The treasury demonstrably holds the bond payments the whole
time: pure accounting drift.
Severity
High. Complete, deterministic denial of the protocol’s core bond-issuance
function; recovery is gated on a trusted role. No fund loss, but the market
bricks itself through normal use.
Steps to Reproduce
- Seed reserves properly:
treasury.deposit(reserve, X, X_value/2)→
excessReserves = E0 > 0. - Execute bonds. After each bond assert:
treasury.totalReserves()unchanged,ftso.totalSupply()up bypayout,
treasury.excessReserves()down by exactlypayout. - Keep bonding; eventually
depository.deposit(...)reverts
InsufficientReserves()fromTreasury.mintinsideaddNote. - Confirm
weth.balanceOf(treasury) > 0(funds present — accounting only). vm.prank(governor); treasury.auditReserves();→ bonds work again.
Proof of Concept
Foundry test against the actual contracts (read-only reference). Full PoC
source available on request.
cd poc-foundry && forge test --match-test 'test_GH08' -vv
# PASS test_GH08_bondDoesNotRaiseTotalReserves (mechanism: -payout per bond)
# PASS test_GH08_bondDoSUntilAudit (drain loop -> revert -> audit recovery)
Key assertions from the passing tests:
// after one real bond:
assertEq(treasury.totalReserves(), reservesBefore); // flat
assertEq(treasury.excessReserves(), excessBefore - payout); // drained exactly
// after the drain loop:
vm.expectRevert();
depository.deposit{value: amount}(drainId, amount, type(uint256).max, ALICE); // InsufficientReserves
assertGt(weth.balanceOf(address(treasury)), 0); // funds are there
Impact
The bond market has a deterministic expiry measured in cumulative payout
volume. In production this reads as “bonds randomly stop working”; the only
fix is a trusted-role auditReserves(), i.e. recurring manual intervention
for a core function. It also makes buyBack() underpay while drifted.
Remediation
Route bond payments through Treasury.deposit() (or otherwise credit
totalReserves) instead of transferring directly to the TREASURY address.
Short-term: document the required auditReserves() cadence and consider
making it permissionless-but-rate-limited, since the values it reads are
on-chain anyway.