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:
git clone --depth 1 https://git.ghostchain.io/ghostchain/ghost-dao-contracts && cd ghost-dao-contractsgit rev-parse HEAD→b87dff4c7a6ac34b35698c42b4d1f34b5f930864- The repo’s
remappings.txt/foundry.tomlmapforge-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. - Save the two PoC files below as
test/h9/H9_PartA.t.solandtest/h9/H9_PartB.t.sol - Run:
forge test --match-path test/h9/H9_PartA.t.sol -vvand
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 ofmsg.value, ALICE
balance 0 (no refund), payout computed onamountonly, no WETH pulled viatransferFrom.test_H9b_partialNativePlusApprovedWETHDoublePays— treasury receives native (1 ETH) +
fullamount(2 ETH) = 3 ETH total; all approved WETH pulled; payout onamountonly.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,markdownall revert with
arithmetic underflow (panic 0x11);Treasury.deposit()andtokenValue()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
viasafeTransferFrom; refundmsg.valueexceedingamount(unwrap WETH back to native or
return WETH) instead of forwarding it to the treasury; consider rejectingmsg.value > 0on
non-WETH markets outright. - Part B: audit
getKValuefor the 0.8.20 checked-arithmetic drift — use explicit
uncheckedblocks where wrap-around was intended, or restructure to avoid the underflow;
add a regression test with odd-decimal quote pairs coveringvaluation()→
Treasury.deposit()/tokenValue()/auditReserves().