[H9] BondDepository.deposit() mishandles native collateral — unrefunded overpayment, double-pay paths; getKValue underflow bricks valuation

Summary

GhostBondDepository.deposit() is payable (src/BondDepository.sol:52) and its payment logic
(:82-85) wraps all of msg.value into WETH and forwards it to the treasury — then
additionally pulls amount of quoteToken from the depositor whenever
msg.value < amount || quoteToken != WETH. Three concrete consequences were proven in Foundry:
(a) overpaid native (msg.value > amount on a WETH market) is never refunded and silently accrues
to the treasury; (b) partial native + approved WETH double-pays (user pays both); (c) stray native
sent to a non-WETH market double-pays (native wrapped to treasury plus full quote-token pull).
Separately, GhostBondingCalculator.getKValue reverts on underflow under Solidity 0.8.20 (it was
wrap-around-safe under 0.7.5 semantics) — a fail-closed drift reachable via valuation() →
Treasury.deposit() / tokenValue() / auditReserves() (and markdown()), bricking valuation
for odd-decimal pairs.

Severity

Part A — Medium — CVSS 3.1: 5.3 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

deposit() is permissionless and payable; the loss triggers through normal use (overpaying, or
attaching native on a non-WETH market). The victim is the depositor themself — fund
misdirection, not third-party theft. User funds are silently diverted to the treasury with no
refund path.

Part B — Medium (availability, fail-closed): getKValue underflow reverts brick valuation()
and every downstream caller (Treasury.deposit, tokenValue, auditReserves, markdown) for
affected pairs. No fund loss — fail-closed — but bond markets for odd-decimal quote pairs become
unusable until fixed. A pure 0.7.5 → 0.8.20 port artifact (checked arithmetic now reverts where
it previously wrapped).

Affected code

// src/BondDepository.sol:82-85 — the two payment legs are never reconciled
IWETH9(WETH).deposit{value: msg.value}();
IERC20(WETH).safeTransfer(TREASURY, msg.value);
if (msg.value < amount || market.quoteToken != WETH) {
    IERC20(market.quoteToken).safeTransferFrom(msg.sender, TREASURY, amount);
}
// There is NO refund path anywhere in deposit().
// src/StandardBondingCalculator.sol:24-26 — underflows when token0+token1 < 18
uint256 token0 = IERC20Metadata(IUniswapV2Pair(pair).token0()).decimals();
uint256 token1 = IERC20Metadata(IUniswapV2Pair(pair).token1()).decimals();
uint256 decimals = token0 + token1 - IERC20Metadata(pair).decimals();
// Uniswap V2 pairs ALWAYS have 18 decimals (UniswapV2ERC20.sol:11: `uint8 public constant decimals = 18`).
// A 6+6-decimal pair computes 6 + 6 - 18 -> underflow -> panic 0x11 under solc 0.8.20.

Steps to reproduce

Local Foundry only — no testnet/mainnet interaction. Starting from a clean state:

  1. git clone --depth 1 https://git.ghostchain.io/ghostchain/ghost-dao-contracts && cd ghost-dao-contracts
  2. git rev-parse HEAD → b87dff4c7a6ac34b35698c42b4d1f34b5f930864
  3. The repo’s remappings.txt/foundry.toml map forge-std-1.9.2/ →
    dependencies/forge-std-1.9.2/ (missing /src/), so no repo test compiles as-is
    (environment quirk, not part of the finding). Add /src/ to both remapping entries.
  4. Save the two PoC files below as test/h9/H9_PartA.t.sol and test/h9/H9_PartB.t.sol
  5. Run: forge test --match-path test/h9/H9_PartA.t.sol -vv and
    forge test --match-path test/h9/H9_PartB.t.sol -vv (forge 1.6.0-nightly, solc 0.8.20)

Expected: Part A — 3 passed, 0 failed; Part B — 6 passed, 0 failed. Each test asserts the
mishandled accounting described above.

PoC

test/h9/H9_PartA.t.sol — native-collateral quirks (click to expand)
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

// H9 PART A PoC — BondDepository.deposit() native-collateral quirks
//
// Code under test: src/BondDepository.sol, GhostBondDepository.deposit()
//   L82: IWETH9(WETH).deposit{value: msg.value}();          -> wraps ALL native
//   L83: IERC20(WETH).safeTransfer(TREASURY, msg.value);    -> all of it to treasury
//   L84: if (msg.value < amount || market.quoteToken != WETH) {
//   L85:     IERC20(market.quoteToken).safeTransferFrom(msg.sender, TREASURY, amount);
//        }
// There is NO refund path anywhere in deposit().

import {Test} from "forge-std-1.9.2/Test.sol";
import {console} from "forge-std-1.9.2/console.sol";

import {Fatso} from "../../src/FatsoERC20.sol";
import {Stinky} from "../../src/StinkyERC20.sol";
import {Ghost} from "../../src/GhstERC20.sol";
import {GhostAuthority} from "../../src/GhostAuthority.sol";
import {GhostTreasury} from "../../src/Treasury.sol";
import {GhostStaking} from "../../src/Staking.sol";
import {GhostBondDepository} from "../../src/BondDepository.sol";
import {ERC20Mock} from "../../src/mocks/ERC20Mock.sol";
import {WETH9} from "../../src/mocks/WETH9.sol";
import {GhostBondingCalculator} from "../../src/StandardBondingCalculator.sol";

import {ITreasury} from "../../src/interfaces/ITreasury.sol";
import {IERC20} from "@openzeppelin-contracts/token/ERC20/IERC20.sol";

contract H9PartA_NativeQuirks is Test {
    uint256 public constant INITIAL_MINT  = 1000000000000000000000000;
    uint256 public constant CAPACITY       = 10000e9;
    uint256 public constant INITIAL_PRICE  = 400e9;
    uint256 public constant BUFFER         = 2e5;

    address constant INITIALIZER = 0x0000000000000000000000000000000000000001;
    address constant GOVERNOR     = 0x0000000000000000000000000000000000000003;
    address constant GUARDIAN     = 0x0000000000000000000000000000000000000004;
    address constant POLICY       = 0x0000000000000000000000000000000000000005;
    address constant VAULT        = 0x0000000000000000000000000000000000000006;
    address constant ALICE        = 0x0000000000000000000000000000000000000007;

    uint256 public constant VESTING            = 100;
    uint256 public constant TIME_TO_CONCLUSION = 60 * 60 * 24;
    uint256 public constant DEPOSIT_INTERVAL   = 60 * 60 * 4;
    uint256 public constant TUNE_INTERVAL      = 60 * 60;

    uint256 public conclusion;

    ERC20Mock reserve;   // 18-dec non-WETH quote token
    Fatso ftso;
    Stinky stnk;
    Ghost ghst;
    GhostStaking staking;
    GhostTreasury treasury;
    GhostAuthority authority;
    GhostBondDepository depository;
    GhostBondingCalculator calculator;
    WETH9 weth;

    // market 0 = non-WETH quote (reserve), market 1 = WETH quote
    function setUp() public {
        vm.startPrank(INITIALIZER);
        weth = new WETH9();
        authority = new GhostAuthority(GOVERNOR, GUARDIAN, POLICY, VAULT);
        reserve = new ERC20Mock("Reserve Token", "RET");
        ftso = new Fatso(address(authority), "Fatso", "FTSO");
        stnk = new Stinky(10819917194513808e56, "Stinky", "STNK");
        ghst = new Ghost(address(stnk), "Ghost", "GHST");
        staking = new GhostStaking(
            address(ftso), address(stnk), address(ghst),
            2200, 1, 1337, address(authority)
        );
        treasury = new GhostTreasury(address(ftso), address(authority));
        calculator = new GhostBondingCalculator(address(ftso), 1, 1);
        stnk.initialize(address(staking), address(treasury), address(ghst));
        ghst.initialize(address(staking));
        depository = new GhostBondDepository(
            address(authority), address(ftso), address(ghst),
            address(staking), address(treasury), address(weth)
        );
        vm.stopPrank();

        // market 0: non-WETH quote token
        vm.startPrank(GOVERNOR);
        authority.pushVault(address(treasury));
        treasury.enable(ITreasury.STATUS.REWARDMANAGER, address(depository), address(0));
        treasury.enable(ITreasury.STATUS.RESERVEDEPOSITOR, ALICE, address(0));
        treasury.enable(ITreasury.STATUS.RESERVETOKEN, address(reserve), address(calculator));
        treasury.enable(ITreasury.STATUS.RESERVETOKEN, address(weth), address(calculator));
        vm.stopPrank();

        // Seed FTSO supply so _debtRatio() (divides by baseSupply) doesn't revert
        vm.startPrank(ALICE);
        reserve.mint(ALICE, INITIAL_MINT);
        reserve.approve(address(treasury), type(uint256).max);
        treasury.deposit(address(reserve), INITIAL_MINT, treasury.tokenValue(address(reserve), INITIAL_MINT) / 2);
        vm.stopPrank();

        vm.startPrank(ALICE);
        reserve.mint(ALICE, INITIAL_MINT);
        reserve.approve(address(depository), type(uint256).max);
        vm.stopPrank();

        conclusion = block.timestamp + TIME_TO_CONCLUSION;

        vm.startPrank(POLICY);
        depository.create( // market 0: non-WETH quote
            [CAPACITY, INITIAL_PRICE, 0, BUFFER],
            [VESTING, conclusion],
            address(reserve),
            [uint32(DEPOSIT_INTERVAL), uint32(TUNE_INTERVAL)],
            [false, true]
        );
        depository.create( // market 1: WETH quote
            [CAPACITY, INITIAL_PRICE, 0, BUFFER],
            [VESTING, conclusion],
            address(weth),
            [uint32(DEPOSIT_INTERVAL), uint32(TUNE_INTERVAL)],
            [false, true]
        );
        vm.stopPrank();
    }

    // CASE (a): overpaid native on a WETH market. msg.value (2e18) > amount (1e18).
    // Expected per code: L84 condition false -> no transferFrom; treasury keeps ALL 2e18;
    // payout computed on `amount` (1e18) only; no refund.
    function test_H9a_overpaidNativeNeverRefunded() public {
        uint256 amount = 1 ether;
        uint256 value = 2 ether;
        vm.deal(ALICE, value);

        // ALICE holds NO WETH and gave NO WETH approval: if the L84 branch were
        // taken, this deposit would revert. It must NOT revert per the hypothesis.
        assertEq(IERC20(address(weth)).balanceOf(ALICE), 0);

        uint256 treasuryWethBefore = IERC20(address(weth)).balanceOf(address(treasury));
        uint256 expectedPayout = depository.payoutFor(1, amount);

        vm.prank(ALICE);
        (uint256 payout,,) = depository.deposit{value: value}(1, amount, INITIAL_PRICE, ALICE);

        uint256 treasuryWethAfter = IERC20(address(weth)).balanceOf(address(treasury));

        assertEq(treasuryWethAfter - treasuryWethBefore, value, "treasury keeps ALL msg.value");
        assertEq(ALICE.balance, 0, "no native refunded");
        assertEq(payout, expectedPayout, "payout computed on amount only");
        assertEq(IERC20(address(weth)).balanceOf(ALICE), 0, "no WETH pulled via transferFrom");
    }

    // CASE (b): partial native (1e18) + approved WETH covering full amount (2e18).
    // Expected per code: L82-83 wrap+send 1e18; L84 true (msg.value < amount) ->
    // transferFrom pulls FULL amount (2e18) of WETH. Treasury gets 3e18 total.
    function test_H9b_partialNativePlusApprovedWETHDoublePays() public {
        uint256 amount = 2 ether;
        uint256 value = 1 ether;
        vm.deal(ALICE, 3 ether);

        vm.startPrank(ALICE);
        weth.deposit{value: amount}(); // ALICE now holds 2e18 WETH, 1e18 native
        IERC20(address(weth)).approve(address(depository), type(uint256).max);
        vm.stopPrank();

        uint256 treasuryWethBefore = IERC20(address(weth)).balanceOf(address(treasury));
        uint256 expectedPayout = depository.payoutFor(1, amount);

        vm.prank(ALICE);
        (uint256 payout,,) = depository.deposit{value: value}(1, amount, INITIAL_PRICE, ALICE);

        uint256 treasuryWethAfter = IERC20(address(weth)).balanceOf(address(treasury));

        assertEq(treasuryWethAfter - treasuryWethBefore, value + amount, "treasury got native + full amount");
        assertEq(payout, expectedPayout, "payout computed on amount only");
        assertEq(IERC20(address(weth)).balanceOf(ALICE), 0, "all approved WETH pulled");
    }

    // CASE (c): stray native (1e18) on a NON-WETH market.
    // Expected per code: L82-83 wrap+send 1e18 to treasury; L84 true
    // (quoteToken != WETH) -> transferFrom pulls full amount of reserve.
    function test_H9c_strayNativeOnNonWethMarketDoublePays() public {
        uint256 amount = 1000e18; // reserve tokens
        uint256 value = 1 ether;
        vm.deal(ALICE, value);

        uint256 treasuryWethBefore = IERC20(address(weth)).balanceOf(address(treasury));
        uint256 treasuryReserveBefore = reserve.balanceOf(address(treasury));
        uint256 expectedPayout = depository.payoutFor(0, amount);

        vm.prank(ALICE);
        (uint256 payout,,) = depository.deposit{value: value}(0, amount, INITIAL_PRICE, ALICE);

        uint256 treasuryWethAfter = IERC20(address(weth)).balanceOf(address(treasury));
        uint256 treasuryReserveAfter = reserve.balanceOf(address(treasury));

        assertEq(treasuryWethAfter - treasuryWethBefore, value, "stray native wrapped to treasury");
        assertEq(treasuryReserveAfter - treasuryReserveBefore, amount, "full amount also pulled");
        assertEq(payout, expectedPayout, "payout computed on amount only");
        assertEq(ALICE.balance, 0, "no native refunded");
    }
}
test/h9/H9_PartB.t.sol — getKValue underflow (click to expand)
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

// H9 PART B PoC — GhostBondingCalculator.getKValue decimal underflow
//
// Code under test: src/StandardBondingCalculator.sol
//   L24: uint256 token0 = IERC20Metadata(IUniswapV2Pair(pair).token0()).decimals();
//   L25: uint256 token1 = IERC20Metadata(IUniswapV2Pair(pair).token1()).decimals();
//   L26: uint256 decimals = token0 + token1 - IERC20Metadata(pair).decimals();
// Under solc 0.8.20 the subtraction reverts on underflow (panic 0x11).
//
// Uniswap V2 pairs ALWAYS have 18 decimals
// (dependencies/@uniswap-v2-core-1.0.1/contracts/UniswapV2ERC20.sol:11:
//  `uint8 public constant decimals = 18;`), regardless of the underlying
// tokens. A USDC/USDT-style pair (6 + 6 decimals) therefore computes
// 6 + 6 - 18 -> underflow -> revert.
//
// Reachability: Treasury.tokenValue() (src/Treasury.sol L263-272) calls
// IBondingCalculator(...).valuation() for any registered LIQUIDITYTOKEN, and
// Treasury.deposit() (L43-61) calls tokenValue() internally. A registered
// odd-decimal pair therefore bricks Treasury.deposit() for that token.

import {Test} from "forge-std-1.9.2/Test.sol";
import {console} from "forge-std-1.9.2/console.sol";
import {stdError} from "forge-std-1.9.2/StdError.sol";

import {GhostBondingCalculator} from "../../src/StandardBondingCalculator.sol";
import {GhostAuthority} from "../../src/GhostAuthority.sol";
import {GhostTreasury} from "../../src/Treasury.sol";
import {Fatso} from "../../src/FatsoERC20.sol";
import {ITreasury} from "../../src/interfaces/ITreasury.sol";
import {IERC20} from "@openzeppelin-contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin-contracts/token/ERC20/ERC20.sol";

contract ERC20DecMock is ERC20 {
    uint8 private immutable _d;
    constructor(string memory n, string memory s, uint8 d) ERC20(n, s) { _d = d; }
    function decimals() public view override returns (uint8) { return _d; }
    function mint(address a, uint256 v) external { _mint(a, v); }
}

contract MockV2Pair is ERC20 {
    address public token0;
    address public token1;
    uint112 public reserve0;
    uint112 public reserve1;

    constructor(address t0, address t1, uint112 r0, uint112 r1, uint256 ts)
        ERC20("Mock LP", "MLP")
    {
        token0 = t0;
        token1 = t1;
        reserve0 = r0;
        reserve1 = r1;
        _mint(msg.sender, ts);
    }

    function getReserves() external view returns (uint112, uint112, uint32) {
        return (reserve0, reserve1, uint32(block.timestamp));
    }
}

contract H9PartB_KValueUnderflow is Test {
    address constant GOVERNOR = 0x0000000000000000000000000000000000000003;
    address constant GUARDIAN = 0x0000000000000000000000000000000000000004;
    address constant POLICY   = 0x0000000000000000000000000000000000000005;
    address constant VAULT    = 0x0000000000000000000000000000000000000006;

    GhostBondingCalculator calculator;
    GhostAuthority authority;
    GhostTreasury treasury;
    Fatso ftso;

    ERC20DecMock token6a;   // 6-decimal token (USDC-style)
    ERC20DecMock token6b;   // 6-decimal token (USDT-style)
    ERC20DecMock token18a;  // 18-decimal token
    ERC20DecMock token18b;  // 18-decimal token
    MockV2Pair pair66;      // odd-decimal pair: 6 + 6 vs pair 18
    MockV2Pair pair1818;    // standard pair: 18 + 18 vs pair 18
    MockV2Pair pair69;      // FTSO(9) + 6-dec pair: 9 + 6 - 18 underflows (markdown path)

    function setUp() public {
        authority = new GhostAuthority(GOVERNOR, GUARDIAN, POLICY, VAULT);
        ftso = new Fatso(address(authority), "Fatso", "FTSO"); // 9 decimals

        token6a = new ERC20DecMock("Six A", "SIXA", 6);
        token6b = new ERC20DecMock("Six B", "SIXB", 6);
        token18a = new ERC20DecMock("Eighteen A", "EGA", 18);
        token18b = new ERC20DecMock("Eighteen B", "EGB", 18);

        pair66 = new MockV2Pair(address(token6a), address(token6b), 1_000_000e6, 1_000_000e6, 1_000_000e18);
        pair1818 = new MockV2Pair(address(token18a), address(token18b), 1_000_000e18, 1_000_000e18, 1_000_000e18);
        pair69 = new MockV2Pair(address(token6a), address(ftso), 1_000_000e6, 1_000_000e9, 1_000_000e18);

        treasury = new GhostTreasury(address(ftso), address(authority));
        calculator = new GhostBondingCalculator(address(ftso), 1, 1);

        // register the odd-decimal pair as a treasury liquidity token
        vm.startPrank(GOVERNOR);
        treasury.enable(ITreasury.STATUS.LIQUIDITYTOKEN, address(pair66), address(calculator));
        treasury.enable(ITreasury.STATUS.LIQUIDITYDEPOSITOR, GOVERNOR, address(0));
        vm.stopPrank();

        pair66.transfer(GOVERNOR, 1000e18);
        vm.prank(GOVERNOR);
        pair66.approve(address(treasury), type(uint256).max);
    }

    // Control: a standard 18/18 pair (18+18-18 = 18) works fine.
    function test_H9b_control_standardPairWorks() public {
        uint256 k = calculator.getKValue(address(pair1818), false);
        assertGt(k, 0);
        uint256 v = calculator.valuation(address(pair1818), 1e18);
        assertGt(v, 0);
    }

    // Core: 6 + 6 - 18 underflows under solc 0.8.20 -> panic 0x11.
    function test_H9b_getKValue_revertsOnOddDecimalPair() public {
        vm.expectRevert(stdError.arithmeticError);
        calculator.getKValue(address(pair66), false);
    }

    function test_H9b_valuation_revertsOnOddDecimalPair() public {
        vm.expectRevert(stdError.arithmeticError);
        calculator.valuation(address(pair66), 1e18);
    }

    function test_H9b_markdown_revertsOnOddDecimalPair() public {
        // markdown() requires one leg to be FTSO (9 dec). With a 6-dec token:
        // 6 + 9 - 18 underflows -> reverts.
        vm.expectRevert(stdError.arithmeticError);
        calculator.markdown(address(pair69));
    }

    // Deposit-path reachability: Treasury.deposit() -> tokenValue() ->
    // valuation() -> getKValue() reverts, bricking LP deposits for that pair.
    function test_H9b_treasuryDeposit_brickedForOddDecimalPair() public {
        vm.prank(GOVERNOR);
        vm.expectRevert(stdError.arithmeticError);
        treasury.deposit(address(pair66), 1000e18, 0);
    }

    // Direct view-path reachability: tokenValue() reverts, so auditReserves()
    // (which sums tokenValue over ALL registered tokens) is bricked too.
    function test_H9b_treasuryTokenValue_revertsForOddDecimalPair() public {
        vm.expectRevert(stdError.arithmeticError);
        treasury.tokenValue(address(pair66), 1000e18);
    }
}

Observed output

Part A (forge test --match-path test/h9/H9_PartA.t.sol -vv): 3 passed, 0 failed

  • test_H9a_overpaidNativeNeverRefunded — treasury keeps all 2 ETH of msg.value, ALICE
    balance 0 (no refund), payout computed on amount only, no WETH pulled via transferFrom.
  • test_H9b_partialNativePlusApprovedWETHDoublePays — treasury receives native (1 ETH) +
    full amount (2 ETH) = 3 ETH total; all approved WETH pulled; payout on amount only.
  • test_H9c_strayNativeOnNonWethMarketDoublePays — stray 1 ETH wrapped to treasury and
    full 1000 reserve tokens pulled; no native refunded.

Part B (forge test --match-path test/h9/H9_PartB.t.sol -vv): 6 passed, 0 failed

  • Control (18/18 pair) works; 6/6 pair → getKValue, valuation, markdown all revert with
    arithmetic underflow (panic 0x11); Treasury.deposit() and tokenValue() for the registered
    6/6 pair revert — LP deposits and reserve audits bricked for that pair.

Impact

  • Part A: users silently lose funds to the treasury on the most natural interactions —
    overpaying native “to be safe”, or attaching native value out of habit on a non-WETH market.
    Funds are not stolen by an attacker, but they are irrecoverable by the depositor (no refund
    path exists), and the accounting treats the treasury as the beneficiary of user error.
  • Part B: any bond market whose quote pair triggers the underflow has its valuation path
    hard-revert, bricking deposits, reserve audits, and markdown for that market — a
    functionality DoS introduced purely by the 0.7.5 → 0.8.20 port (checked arithmetic now
    reverts where it previously wrapped).

Remediation

  • Part A: reconcile the two payment legs — compute the shortfall as
    amount - min(wrappedNativeValue, amount) (in quote-token terms) and only pull the remainder
    via safeTransferFrom; refund msg.value exceeding amount (unwrap WETH back to native or
    return WETH) instead of forwarding it to the treasury; consider rejecting msg.value > 0 on
    non-WETH markets outright.
  • Part B: audit getKValue for the 0.8.20 checked-arithmetic drift — use explicit
    unchecked blocks where wrap-around was intended, or restructure to avoid the underflow;
    add a regression test with odd-decimal quote pairs covering valuation() →
    Treasury.deposit() / tokenValue() / auditReserves().

Thanks again to @Chaplin - another brilliant find.

Part A

In a sense, you’re right, but the rule of thumb here is that the protocol always wins. From that perspective, I don’t see how this could be turned against the protocol itself, which is why I don’t consider it a bug.

We could add an if statement checking msg.value == amount, but I don’t think that would change anything for the protocol either way, while user could do whatever he wants on his side (for example small donation for the protocol during bond purchase).

Part B

You’re right in the general case, but we are planning to use only tokens that have 18 decimals (WETH or DAI) as reserves, and our own tokens range from 9 to 18 decimals. So this situation can only affect non-protocol-oriented tokens such as pairs you’ve mentioned above.

Conclusion

For now, I don’t see a direct exploit path from this finding. That said, if you have a more detailed attack vector in mind, I’d be happy to hear it - please continue the investigation.

Thanks for engaging with both parts seriously. A few points:

Part A — “the protocol always wins”

I agree the funds end up with the protocol rather than an attacker — that’s why I rated it Medium (user fund misdirection), not High (theft). But “not turned against the protocol” isn’t the same as “not a bug”: the victims here are ordinary users, and the loss is silent and irrecoverable.

The “small donation” framing doesn’t cover the double-pay paths. Take case (b) from the PoC: a user attaching 1 ETH native with 2 ETH of approved WETH for a 2 ETH bond pays 3 ETH total — the native leg is wrapped and forwarded and the full amount is pulled via transferFrom. That’s not a donation, it’s a trap in the most natural interaction pattern (attach native “to be safe” while also having an approval set). No refund path exists anywhere in deposit(), and nothing warns the user. A user losing 50% extra on a bond purchase to silent accounting is a bug even if the beneficiary is the treasury.

The msg.value == amount check you mention would fix the overpayment case but not the double-pay cases — the real fix is reconciling the two payment legs (pull only the shortfall) and refunding or rejecting the excess.

Part B — “planning to use only 18-decimal tokens”

“Planning to” isn’t an on-chain invariant. Market creation is permissionless — anyone can register a quote pair that triggers the underflow, and valuation() → Treasury.deposit() / tokenValue() / auditReserves() all hard-revert for that market from that point on. A defense that relies on future operational choices doesn’t remove the code-level defect, and the 0.7.5 → 0.8.20 checked-arithmetic drift is exactly the kind of thing that bites when a new token gets listed without anyone re-auditing the calculator.

On the exploit path question: Part A needs no attacker — the “attacker” is the user’s own wallet interacting normally, which is precisely why it’s insidious. Part B’s path is any permissionless market creation with an odd-decimal pair, bricking that market’s valuation permanently until the code is fixed.

Happy to go deeper on either if useful.


Both parts proven in Foundry in the original report (test/h9/H9_PartA.t.sol, test/h9/H9_PartB.t.sol) — 3 + 6 passing tests on the production code paths.