# Audit report

> Audit only the two new contracts in this repository, src/IMDuelStaking.sol and src/IMDuelFeeRouter.sol, before their mainnet deployment; the rest of the repository (IMDuelHook, the engine, IMDuelToken) is live and was audited in jobs 6acefe89 and 74895075, and is in scope only where these two contracts depend on it. IMDuelStaking is OpenZeppelin 5.0.2's ERC-4626 over $IMDUEL (shares: sIMDUEL, 18 decimals, no offset, no owner, nothing adjustable). totalAssets() is a stored staked amount plus the release so far; any $IMDUEL sent to the vault joins a reserve at the next action (after the release so far); the reserve releases 1% of itself a day, per second (KEEP^t by squaring in 1e27 fixed point), only while shares exist. The first sync() once the reserve holds 1 $IMDUEL stakes it 1:1 for 0x...dEaD (a stake that can never leave); deposits and mints are closed until then. IMDuelFeeRouter becomes the requester of IMD LaunchFactory launch 2843 (the live main duel, 0xA92F72DcDd90229C32F6d51229F4cc5c916410CC, $IMDUEL 0xc012b8Db13C57beCd950eEfB70540e6f973A3C73): anyone's collect() claims the launch's fees, sends the $IMDUEL to the vault and syncs it, and sends the IMD to the team; the admin can hand the requester on after a 3-day notice (handOn collects first) or lock the router for good; the constructor checks its wiring against the factory's positionOf, the hook's imduel()/imd() and the vault's asset(), and syncs the vault. Planned launch order: deploy both; 1 $IMDUEL to the vault and sync (the dead stake); the team stakes and stays staked; the 10M $IMDUEL seed and sync; claimFees(2843); setRequester(2843, router). Check: solvency (the vault's $IMDUEL always equals totalAssets() + reserve(), every holder can redeem what previewRedeem says); a share's worth never falls; rounding always favours the vault; the release never exceeds the reserve and is never paid while nothing is staked; first-depositor, donation and near-empty vault attacks; flash-loaned stakes around collect or sync; the window between deployment and each launch step (front-running the dead stake, prefunding the vault's address); uint128 and uint64 bounds; the router's wiring checks, notice, handoff and lock, and whether fees can be stuck in the router or the factory; ERC-4626 conformance for integrators. Known and accepted: a stolen admin key is stopped only by lock() within the notice, and no automated watcher is planned; the release emits no event; the dead stake's Deposit names the vault as its sender; a plain transfer starts releasing only at the next action, so the router syncs after forwarding; deposits have no minimum-shares argument (standard ERC-4626; a share's worth can't be moved within a block).

| | |
|---|---|
| Repository | https://github.com/nitro-slav/imduel.git |
| Commit | `5620c36398d3b37165f4e904b59d2e86cf742873` |
| Job | `d944c9f4-820f-4a57-87f6-eca2032d2ede` |
| Judged | 2026-11-03 21:36 UTC |
| Findings | 2 low · 4 info |

Four agents audited the code as it is at `5620c36`, 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. Low: The release is paid while only the irredeemable dead stake holds shares, burning 1% of the reserve a day with no live staker

`src/IMDuelStaking.sol:87`

```
        if (totalSupply() != 0) {
```

_now() releases the reserve whenever totalSupply() != 0. After the first sync, totalSupply() is never zero again because 0x...dEaD holds 1e18 shares that nobody can redeem, so the release also runs when the dead stake is the only holder, and everything paid in that state raises the worth of shares that can never leave: it is lost for good. The code does what the brief says ('only while shares exist') and test_theDeadStakeTakesTheReleaseWhileAlone documents it, but the brief's check 'never paid while nothing is staked' only holds if the dead stake counts as staked; for the team's purposes it does not, since the money is gone. Two states reach it: (a) the launch window, if the 10M seed is synced before the team's deposit, if the team's deposit is delayed, or if someone prefunds the vault's address before deployment (the router's constructor then syncs, makes the dead stake, and the rest of the prefund starts releasing to DEAD at once); (b) operation, if every redeemable staker leaves for a while (the team is expected to stay staked, but nothing enforces it). Solvency is untouched: the vault's balance stays equal to totalAssets() + reserve(). Merged from audit_economics and audit_math, which reported the same mechanism. Fix if wanted (a scope decision, since it changes the stated rule from 'while shares exist' to 'while redeemable shares exist'): gate the release on totalSupply() != balanceOf(DEAD); updated still advances so the reserve simply waits. Verified that the attached proof passes with exactly that one-line change on a copy of the vault. Otherwise keep the design and make 'the team's stake lands before the seed and never fully leaves' an explicit runbook rule.

**Reproduction**

Fresh vault; transfer 1e18 $IMDUEL and sync() (dead stake, totalSupply 1e18). State (a): transfer 10_000_000e18, sync(), warp +1 day. Expected if the reserve waited for a live staker: reserve ~ 10_000_000e18 and convertToAssets(balanceOf(DEAD)) ~ 1e18. Actual: convertToAssets(balanceOf(DEAD)) = 100000.999999999999900977e18 and reserve ~ 9_900_000e18; redeem(1, anyone, DEAD) reverts (no allowance), so ~100,000 $IMDUEL of the seed are unrecoverable after one day. State (b): alice deposits 1_000_000e18, transfer 10_000_000e18, sync(), alice redeems everything, warp +7 days. Expected: reserve unchanged at 10_000_000e18. Actual: reserve = 9320653.479069899999993566e18; 679,346.52 $IMDUEL went to DEAD. Both reproduced with forge test --match-path test/scratch/Proof_dead.t.sol (2 failures on the current code); the same file passes against a copy of the vault with line 87 changed to `if (totalSupply() != balanceOf(DEAD)) {`.

**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 {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IMDuelToken} from "src/IMDuelToken.sol";
import {IMDuelStaking} from "src/IMDuelStaking.sol";

/// While 0x...dEaD is the only holder, the reserve must not be released: whatever it is paid can never be redeemed.
contract DeadStakeReleaseTest is Test {
    IMDuelToken token;
    IMDuelStaking vault;
    address constant DEAD = 0x000000000000000000000000000000000000dEaD;
    address alice = makeAddr("alice");
    uint256 now_ = 1_762_000_000;

    function setUp() public {
        vm.warp(now_);
        token = new IMDuelToken();
        vault = new IMDuelStaking(IERC20(address(token)));
        token.transfer(address(vault), 1e18);
        vault.sync(); // the dead stake
    }

    function _wait(uint256 s) internal {
        now_ += s;
        vm.warp(now_);
    }

    /// The 10M seed synced one day before the team's stake: 1% of it is paid to the dead stake, lost for good.
    function test_seedSyncedBeforeAnyStakerIsNotBurned() public {
        token.transfer(address(vault), 10_000_000e18);
        vault.sync();
        _wait(1 days);
        uint256 deadWorth = vault.convertToAssets(vault.balanceOf(DEAD));
        assertLt(deadWorth, 2e18, "the dead stake alone should not receive the release");
        assertGe(vault.reserve(), 10_000_000e18 - 1e18, "the reserve waits for a redeemable staker");
    }

    /// Every staker gone for a week: 6.8% of the reserve is paid to the dead stake.
    function test_reserveWaitsWhileNobodyRedeemableIsStaked() public {
        token.transfer(alice, 1_000_000e18);
        vm.startPrank(alice);
        token.approve(address(vault), 1_000_000e18);
        uint256 shares = vault.deposit(1_000_000e18, alice);
        vm.stopPrank();
        token.transfer(address(vault), 10_000_000e18);
        vault.sync();
        vm.prank(alice);
        vault.redeem(shares, alice, alice);
        uint256 reserveBefore = vault.reserve();
        _wait(7 days);
        assertEq(vault.reserve(), reserveBefore, "nothing redeemable is staked: nothing should be released");
    }
}
```

### 2. Low: A matured handoff proposal never expires: handOn later runs with zero notice

`src/IMDuelFeeRouter.sol:96`

```
        if (next == address(0) || block.timestamp < readyAt) revert NotReady();
```

The 3-day notice is the router's only defence against a stolen admin key (accepted: 'a stolen admin key is stopped only by lock() within the notice'). handOn checks only that readyAt has passed, never how long ago. A proposal the admin made and neither executed nor withdrew with propose(0) stays executable forever, so once it has matured the notice is zero: whoever holds the admin key at any later time moves launch 2843's requester away from the router in one transaction, and no watcher gets the 3 days the model relies on. The destination is the address the team once proposed, so the damage is bounded to 'the handoff happens when the team no longer wants it' (for example a next router later found faulty, which the team simply never handed on to instead of withdrawing the proposal). Fix: give a proposal a window, e.g. revert in handOn when block.timestamp > readyAt + NOTICE, so a stale proposal must be re-proposed and re-waited; propose(0) stays the withdrawal.

**Reproduction**

State: router is the launch's requester (FakePoolFees from test/FeeRouter.t.sol, requester set to the router); admin calls propose(next) at T = 1_762_000_000; nobody calls handOn or propose(0). At T + 365 days the admin key calls handOn(). Expected under the notice model: NotReady for a stale proposal (or a fresh 3-day wait). Actual: handOn succeeds immediately and factory.requester(2843) == next; all later fees go to next. Reproduced in test/scratch/Judge.t.sol::test_staleProposal (passes = the state is reachable).

### 3. Info: Each settle rounds the release up by one wei, so frequent syncs move dust from the reserve to the stake

`src/IMDuelStaking.sol:88`

```
            uint256 release = p.reserve - p.reserve * _keep(block.timestamp - p.updated) / ONE;
```

release = reserve - floor(reserve * keep / ONE) rounds the kept amount down and therefore the release up, by at most 1 wei per _settle(). Every action (sync, deposit, mint, withdraw, redeem, the router's collect) settles, so a caller syncing every second can move up to 86,400 wei a day from the reserve into the stake beyond what one settle would. Solvency holds (both sides are the vault's own $IMDUEL and balance == totalAssets() + reserve()), a share's worth still never falls, and the amount is dust; it is the one place where rounding favours shareholders over the reserve, reported because the brief asks that rounding always favour the vault. No fix is required; if wanted, compute the kept amount with Math.mulDiv(reserve, keep, ONE, Math.Rounding.Ceil) so the release rounds down.

**Reproduction**

Two vaults, each opened with 1e18 + sync() and alice staking 1_000_000e18, both receiving 10_000_000e18 and synced. Vault A: sync() every second for 3,600 seconds. Vault B: one sync() after 3,600 seconds. Expected if rounding were neutral: equal reserves. Actual: A.reserve() = 9995813236755073749085769, B.reserve() = 9995813236755073749087544; A released 1,775 wei more. balance == totalAssets() + reserve() held in both. Reproduced in test/scratch/Judge.t.sol::test_settleRounding.

### 4. Info: withdraw/redeem with the vault itself as receiver silently turns the withdrawn assets into reserve for everyone else

`src/IMDuelStaking.sol:76`

```
        super._withdraw(caller, receiver, owner, assets, shares);
```

_withdraw() lowers _pool.staked by `assets` and super._withdraw() then transfers `assets` to `receiver`. When receiver == address(this) the token self-transfer leaves the vault's balance unchanged, so at the next action _now() sees `arrived == assets` and folds the owner's withdrawn $IMDUEL into the reserve, where it releases to the remaining holders (0x...dEaD included). The Withdraw event still names `receiver` as paid. This follows the stated rule that any $IMDUEL sent to the vault joins the reserve, OpenZeppelin's base ERC-4626 behaves the same way (the assets would be recounted in totalAssets), and solvency and the share price are unaffected, so it is an integrator footgun rather than a defect: zappers and routers must never pass the vault as receiver. A `receiver != address(this)` check in _withdraw would close it.

**Reproduction**

State: vault opened (1e18 + sync), alice deposits 1_000e18. Input: alice calls vault.withdraw(500e18, address(vault), alice). Expected by an integrator reading the Withdraw event: the receiver holds 500e18. Actual: token.balanceOf(vault) unchanged, alice's shares burned for 500e18, vault.reserve() == 500e18, vault.totalAssets() == 501e18, and the 500e18 now release at 1%/day to the remaining holders. Reproduced in test/scratch/Judge.t.sol::test_withdrawToVault.

### 5. Info: launch.json wires the router's admin and team to the same launch wallet, so the notice is guarded by the key that would be stolen (trust assumption)

`launch.json:19`

```
        "0x6AA302EFf055259c9Cf177f33Ae3cBA5d4135B23"
```

Not a contract defect: the constructor accepts either wiring (only team == vault and zero addresses are refused), and the fork rehearsal passes. The deployment manifest passes 0x6AA302EFf055259c9Cf177f33Ae3cBA5d4135B23 as both team (line 18) and admin (line 19), while test/StakingFork.t.sol line 38 deliberately rehearses with the admin as 'a key of its own, not the team wallet'. The accepted model (a stolen admin key is stopped only by lock() within the 3-day notice, no watcher planned) assumes the legitimate holder can still call lock(); with one key, whoever holds it is both the thief and the only party able to lock, that same key receives every collect()'s IMD at once with no notice (team is immutable), and it holds the seed. Also for the record: team cannot change except by handing the requester to a new router after the notice; admin has no rotation (its only state change is lock() to zero), so retiring the key means a permanent lock; and after lock() the IMD destination is frozen for the launch's life. Merged from audit_permissions, audit_economics and audit_math. If wanted: a separate cold key or multisig as admin in launch.json before setRequester(2843, router).

**Reproduction**

Deploy with launch.json as is: router.admin() == router.team() == 0x6AA302EFf055259c9Cf177f33Ae3cBA5d4135B23. With that key compromised: propose(attacker) at T, handOn() at T + 3 days moves the $IMDUEL stream; the only stop, lock() before T + 3 days, needs the same compromised key; and every collect() already pays the IMD half to that wallet. Expected per the fork rehearsal's model: admin is a distinct key. Actual per launch.json lines 18 and 19: the same address.

### 6. Info: The shipped staking suite is red: test_budgets' later-stake gas budget is exceeded (73,354 >= 70,000) on forge 1.8.3

`test/Staking.t.sol:340`

```
        assertLt(g - gasleft(), 70_000, "a later stake");
```

Not a contract defect. On the pinned toolchain here (forge 1.8.3, solc 0.8.26, via-IR, 200 runs) a second deposit by the same staker after an hour costs 73,354 gas, above the 70,000 asserted at this line, both with and without --isolate (the comment says the budgets were measured with --isolate). Every other test passes: 17 of 18 StakingTest unit tests, all 3 invariants (256 runs, 128,000 calls, no reverts), all 12 FeeRouterTest tests, and the 3 StakingForkTest tests against live mainnet state. A red suite can block a launch gate or hide a real regression; re-measure and raise the budget (e.g. 80_000) or drop the assertion. Merged from audit_permissions, audit_economics and audit_math.

**Reproduction**

Run `forge test --match-path test/Staking.t.sol --match-test test_budgets` and again with `--isolate`. Expected: pass. Actual in both modes: [FAIL: a later stake: 73354 >= 70000] test_budgets() (gas: 649939).

---

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