[High] Treasury: bond payments never raise totalReserves → deterministic bond-market halt

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

  1. Seed reserves properly: treasury.deposit(reserve, X, X_value/2) →
    excessReserves = E0 > 0.
  2. Execute bonds. After each bond assert:
    treasury.totalReserves() unchanged, ftso.totalSupply() up by payout,
    treasury.excessReserves() down by exactly payout.
  3. Keep bonding; eventually depository.deposit(...) reverts
    InsufficientReserves() from Treasury.mint inside addNote.
  4. Confirm weth.balanceOf(treasury) > 0 (funds present — accounting only).
  5. 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.

Thank you for your report!

It looks like you’ve spotted an issue inherited from Olympus DAO V2. This is more a matter of monetary policy and game theory than of technology. As I understand it, the intent is to ensure that the backing price - that is, the risk-free value (RFV) - is not affected by bond purchases, so that user actions don’t mix into it.

For that reason, there is an auditReserves function that acts as a governance trigger to recalculate the backing price. If you see a more in-depth or complex abuse of this mechanism, please continue the thread.

Thanks for the thoughtful response! Two data points, respectfully:

1. On the Olympus V2 inheritance: I checked the Version-2 branch of OlympusDAO/olympus-contracts directly. There, Treasury.mint is indeed gated on excessReserves() (and restricted to REWARDMANAGER) but BondDepository.sol contains zero mint() calls, so V2’s bond flow never touches that gate. In GHOST, NoteKeeper.addNote calls _TREASURY.mint on every bond, which is what routes the bond flow through the gate. So the toxic combination intake bypassing Treasury.deposit() plus payout gated on excessReserves() is GHOST’s own composition, not a faithful inheritance. (If you mean a different V2 lineage, happy to be pointed at the exact contract.)

2. On intent vs. effect: if the goal is “RFV unaffected by bond purchases,” the accounting should credit reserves by the intake amount (that’s what Treasury.deposit() is for) then RFV moves only by the protocol’s discount profit per bond, which is the actual Olympus model. The current code does the opposite: reserves flat, supply up, so RFV strictly decays with every bond until InsufficientReserves bricks the market. The mechanism achieves the inverse of the stated intent.

3. The deeper abuse you asked for: the drain is attacker-accelerable and profitable. Each bond earns the bonder the discount while reducing excessReserves by the full payout, so a griefer can deterministically halt the bond market for all users at net profit, with recovery gated on a trusted auditReserves() call. Core-market liveness depending on manual governance intervention is an availability issue with a concrete abuse path.

Suggested fix stays minimal: route bond intake through Treasury.deposit() (or otherwise credit totalReserves). Happy to demonstrate end-to-end on testnet if useful.

After an internal discussion, we’ve identified what amounts to a trap here:

  1. We can’t allow deposit to be called during a bond purchase, because that’s a direct way to manipulate totalReserves via flash loans.
  2. Meanwhile, auditReserves is a governance-only function called via an external Schnorr signature, which minimizes the risk of misuse.

From what we can see right now, staying with the current version is better than the alternatives, though it isn’t the best possible solution. Any thoughts on this?

Appreciate the straight answer and fair pushback on the naive version of my suggestion. A few thoughts:

1. You’re right to reject the naive deposit() call but the blocking reason is the mint side-effect, not flash loans. I re-read Treasury.deposit(): it’s permissioned (RESERVEDEPOSITOR), pulls real tokens via safeTransferFrom, and credits tokenValue(token, amount) a deterministic function of the amount actually moved, with no balance snapshot or spot oracle in the reserve-token branch. For the bond flow, that amount would be the genuine payment, so a flash loan can’t inflate the credit beyond real intake and an external attacker can’t call deposit() at all. The real problems with the naive call are (a) it would require granting the depository RESERVEDEPOSITOR (broad), and (b) it mints value − profit FTSO to the caller on top of the bond payout. So: trap correctly identified, slightly different mechanism than stated.

2. There’s a third option that escapes the trap: a scoped, mint-free intake credit. E.g. a depository-only creditBondIntake(token, amount) on the Treasury that just does totalReserves += tokenValue(token, amount) no transfers, no minting, callable only by the depository, invoked in BondDepository.deposit before addNote (note the mint gate currently runs before the payment even arrives). Flash loans can’t touch it since amount is the actual payment in the same call, permissions stay minimal, and excessReserves then decays by the true per-bond discount cost instead of the full payout.

3. On hardened auditReserves: genuinely good Schnorr-gated governance recovery minimizes misuse risk, credit where due. But note the liveness implication: a heavier recovery path means longer halt windows when the drift trips the gate, and the griefing profit (the bond discount) doesn’t depend on auditReserves at all. The hardening makes misuse safer, not the halt shorter.

So: status quo vs. naive deposit() I agree status quo wins. Status quo vs. the scoped credit the scoped credit keeps your flash-loan and permission concerns at zero while removing the deterministic halt. If you still prefer to accept the risk, that’s a legitimate call for a High I’d just suggest documenting the auditReserves cadence the market needs, since the halt is deterministic (cumulative payout vs. buffer), not probabilistic.