# Audit report

> Audit the contracts in this repository.

| | |
|---|---|
| Repository | https://github.com/nitro-slav/identitymachine.git |
| Commit | `bb0c59618ce3aeb205bd775e1bcbe7e554770d76` |
| Job | `0b474c5e-e1bc-4451-b0e3-9e6459e2007d` |
| Judged | 2026-10-16 19:13 UTC |
| Findings | 3 low · 6 info |

Four agents audited the code as it is at `bb0c596`, 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: sponsor() has no round guard and routes to the prize pot whenever nothing is placed, so a sponsorship meant for the running machine's builders can land in the next round's empty pot

`src/IdentityMachine.sol:553`

```
        _distribute(rounds[round], amount);
```

sponsor() is documented as 'IMD paid straight to every part of the running machine, by weight', but unlike build(r, ...) it carries no round id and is guarded only by _live(). It reads the global `round` at execution time. Two consequences, same root cause (merged from the economics and math specialists): (1) When the sponsor's transaction is sequenced after any mint or endRound() that closed the round they saw, the new round has totalWeight == 0, so _distribute() (line 622-624) falls through to `R.pot += amount`. The sponsor's IMD becomes prize money for the next round (70% to its last ten placers, 30% carried) instead of income for the builders the sponsor intended to pay, and Sponsored(who, newRound, amount) is emitted. build() reverts RoundOver on the same stale intent; sponsor() accepts it silently. (2) Even with no race, sponsoring a round in which nothing has been placed yet (the whole pre-start window and the start of every round) funds the pot, which fundPot() already exists for. Nothing leaves the protocol, so severity is low. Fix: add a `uint256 r` parameter and `if (r != round) revert RoundOver();`, and revert (or document the fallback) when rounds[round].totalWeight == 0.

**Reproduction**

Reproduced in test/scratch/Judge.t.sol (test_A_sponsorAfterRolloverGoesToNextPot, test_A_sponsorNothingPlaced), both pass on the current code. State: fresh deployment, basePrice 0.1 IMD. alice mints one part, it reveals as design 0 (STRAIGHT) and she builds it at (2,0) rot 0 plugging the core's E socket; round 1 has totalWeight 1. Warp to rounds[1].deadline. bob calls mint(1, max): round becomes 2 (totalWeight 0). Record earned(alice's id) and rounds[2].pot. bob calls sponsor(100 ether). Expected (per NatSpec): alice's part earns the 100 IMD, or the call reverts like build(1, ...) does. Actual: rounds[2].pot increases by exactly 100 ether and earned(alice's id) is unchanged. Second case: fresh deployment, sponsor(10 ether) with nothing placed: rounds[1].pot == 10 ether.

### 2. Low: check() reports fits and payees against the expired machine while price()/quote()/build() already treat the clock as run out

`src/IdentityMachine.sol:501`

```
        (uint256 links, uint256[8] memory into,) = _wires(round, n, cx, cy, m);
```

check(design, rot, x, y) is the documented fit oracle 'for agents and UIs'. It wires against rounds[round] unconditionally, whereas price(), quote() and _mint gate on _live() and treat an expired clock as the next round (k = 0, bare core), and build(r, ...) reverts RoundOver once block.timestamp >= deadline. Between the deadline passing and the next mint or endRound(), check() therefore says a placement fits and names payees on a machine that can never receive another part. An agent that trusts check() submits builds that always revert, and after the rollover the same spot is empty space next to nothing, so check() then reverts NotPlugged. Fix (one line, preserves all other behaviour): at the top of check(), `if (!_live()) revert RoundOver();`, or wire against a bare core when the clock has run out, matching price().

**Reproduction**

Reproduced in test/scratch/Judge.t.sol (test_G_checkIgnoresExpiredClock, test_G_buildRevertsWhereCheckSaidFits). State: round 1 running; alice places a STRAIGHT (design 0) at (2,0) rot 0 (plugs the core's E socket), id = that part. check(0, 0, 3, 0) returns [id]. Warp to T0 + 25 hours without calling endRound(). timeLeft() == 0 and price() == basePrice (it already prices round 2). Actual: check(0, 0, 3, 0) still returns [id]. alice's build(1, [], [], [Spot(id2, 0, 3, 0)], GrowthExit(3, 0, E)) reverts RoundOver. After any mint rolls the round to 2, check(0, 0, 3, 0) reverts NotPlugged. Expected: check() to be consistent with price()/build() (RoundOver, or the next round's bare core).

### 3. Low: Constructor accepts dev == address(0) and basePrice == 0; DeployLib.check rejects both but the swarm path (launch.json) never runs it, and all four values are immutable with no admin

`src/IdentityMachine.sol:188`

```
        if (delay_ > 30 days) revert BadDelay();
```

script/Deploy.s.sol's DeployLib.check requires basePrice > 0, dev != address(0) and that imd/beacon have code, but the constructor validates only delay_. launch.json instantiates IdentityMachine directly from constructor args (dev = the placeholder "$owner") and bypasses DeployLib, so in production the constructor is the only guard that runs. There is no owner or setter to repair a bad value. With dev == address(0), every mint still accrues 2% to devBalance but payDev() calls imd.safeTransfer(address(0), a), which an OpenZeppelin v5 ERC20 (the IMD token is one) reverts with ERC20InvalidReceiver, so that 2% of every mint is locked in the contract forever. With basePrice == 0 every mint is free while still issuing inventory items. Fix: mirror DeployLib.check in the constructor: revert when dev_ == address(0), basePrice_ == 0, or imd_/beacon_ have no code.

**Reproduction**

Reproduced in test/scratch/Judge.t.sol (test_D_zeroDev, test_D_zeroBase) with an OpenZeppelin v5 ERC20 as IMD. new IdentityMachine(imd, beacon, address(0), bytes32(0), 0.1 ether, 0) succeeds (expected: revert as DeployLib.check would). approve; mint(1, max): devBalance == 0.002 IMD. payDev() reverts (ERC20InvalidReceiver(0x0)) and devBalance is still 0.002 IMD afterwards, so it can never leave the contract. Separately, new IdentityMachine(imd, beacon, dev, bytes32(0), 0, 0) succeeds and mint(200, 0) returns total == 0 with nextId == 201 (200 free parts).

### 4. Info: With fewer than ten parts placed, the unfilled places' share of the 70% prize is carried over rather than shared, so a one-builder round pays 21% of the pot, not 70%

`src/IdentityMachine.sol:596`

```
        uint256 filled = placed < 10 ? placed : 10;
```

The header (line 34-35) and _endRound NatSpec say 'the last ten parts placed share 70% of the pot' and 'the rest of the pot starts the next machine's pot'. The loop pays prize * placeBps(i) / BPS only for i < filled, so when placed < 10 the basis points of the empty places are never paid and roll into the next pot with the 30%. One builder receives 30% of 70% = 21%; five builders receive 51%. Accounting is internally consistent (RoundEnded's `carried` shows the real amount) and no value is lost, so this is a specification mismatch for the requester to confirm. If the documented behaviour is intended, normalise the table over the filled places or pay the remainder to the last place; otherwise say so in the header.

**Reproduction**

Reproduced in test/scratch/Judge.t.sol (test_H_prizeWithOnePartIs21Percent). State: fundPot(100 ether); alice places one STRAIGHT at (2,0); pot = rounds[1].pot (100 IMD plus that mint's contributions). Warp to the deadline; endRound(). Actual: earned(alice's id) grows by exactly pot * 7000/10000 * 3000/10000 (21% of the pot) and rounds[2].pot == pot - that amount (79% carried). Expected per NatSpec: 70% to the placed parts, 30% carried.

### 5. Info: Header overstates the reserve match: with mint income alone the reserve holds only the previous mint's 5%, so it matches about 5% of the prior mint, not the 15% pot money

`src/IdentityMachine.sol:35`

```
///          starts the next machine's pot. The reserve matches every round's pot money mint for mint.
```

In _mint the reserve is debited by min(toPot, reserve) (line 311-312) before this mint's own 5% is credited (line 323), and it receives only 5% per mint while paying out up to 15%. Without external fundReserve() deposits the reserve never holds more than the previous mint's 5%, so the effective match is about 5% of the prior mint's price (one mint lag): the pot receives roughly 20% of mint volume, not 30%. The split comment on line 37 ('15% round pot (matched by the reserve)') has the same gap. The app's llms.txt says 'while it lasts', so the behaviour is intended; the on-chain header is the misleading surface. No value is lost or mis-accounted. Fix: reword lines 35 and 37 to 'matched by the reserve while it lasts'.

**Reproduction**

Reproduced in test/scratch/Judge.t.sol (test_B_reserveMatch). Fresh deployment, basePrice 0.1 IMD, reserve 0. mint(1) at price t1 = 0.1: toPot 0.015, matched = min(0.015, 0) = 0, reserve == t1 * 5% = 0.005. mint(1) again at t2 = 0.1 * (1501/1500)^2: toPot = 0.01502.. > 0.005, so matched = 0.005 (the whole reserve) and reserve afterwards == t2 * 5% only. Expected from the header: the pot money (0.01502..) matched 1:1. Actual: 0.005 matched. test/security/Lifecycle.t.sol:249-251 asserts the same one-mint behaviour.

### 6. Info: Reveal unpredictability rests on the sequencer's block.timestamp being within 9 s of drand's wall clock (DRAND_MARGIN = 3): a lagging L2 clock lets a minter pre-select drops

`src/IdentityMachine.sol:285`

```
        uint48 dr = uint48(beacon.roundAt(block.timestamp) + DRAND_MARGIN);
```

Merged from three specialists (economics, permissions, math), who all reported the same mechanism. Each mint commits to drand round roundAt(block.timestamp) + 3, which drand publishes 9 to 12 s after the block's own timestamp. The invariant 'the drop is unknown when the price is paid' therefore holds only while the chain clock trails real time by less than 9 s. Robinhood Chain is an Arbitrum Orbit rollup whose sequencer sets block.timestamp with only a wide protocol bound (24 h past / 1 h future against L1); if it lags 9 s or more, the signature for dr is public when the mint executes. A minting contract can then call beacon.prove(dr, sig) and, since nextId is public, compute rollTier(uint256(keccak256(abi.encode(randomness, nextId)))) and mint only when the result is legendary (1% odds, weight 20), or revert and pay only gas. The contract has no owner and cannot widen the margin after deployment. This is a trust assumption on the sequencer clock rather than a logic defect, recorded so it is documented; raising DRAND_MARGIN (e.g. 10 rounds, 30 s) or deriving the reveal round from beacon.latest() + margin would widen the safety margin at the cost of a few seconds of reveal latency. test/audit/Beacon.t.sol (IM05) already records 9 s as the exact limit.

**Reproduction**

Deterministic part reproduced in test/scratch/Judge.t.sol (test_I_revealDistance): mint(1, max) at block time T gives pending[id].drandRound = dr with beacon.timeOf(dr) - T in [9, 12]. Hence whenever wall time >= T + 9 s when the block is built, timeOf(dr) <= wall time and drand's signature for dr is already published. Failing state (not reproducible in Foundry without faking the clock relation): block.timestamp = T sequenced at wall time T + 9. Input: attacker contract calls beacon.prove(dr, sig) then mint(1, max) only if rollTier(uint256(keccak256(abi.encode(keccak256(sig), nextId)))) == 3. Expected: the mint outcome is unknown at commit time. Actual under that state: only legendary mints are submitted.

### 7. Info: mint() NatSpec says the reveal round is 'about 15 seconds later'; the code schedules it 9 to 12 seconds later

`src/IdentityMachine.sol:251`

```
    /// @notice Mint `n` parts (at most 200). Each is revealed by a drand round about 15 seconds later.
```

roundAt(ts) is the first round published at or after ts (0 to 3 s later); + DRAND_MARGIN (3 rounds of 3 s) puts the reveal round 9 to 12 s after block time, as the constant's own comment on line 112 states. The public NatSpec on mint says 15 s. Integrators read the ABI comment, and the number also sets expectations about the clock-lag tolerance in the previous finding. Align the NatSpec with the constant (or raise the margin to match the NatSpec).

**Reproduction**

Reproduced in test/scratch/Judge.t.sol (test_I_revealDistance). Input: mint(1, max) at block time T = 1791300000. Actual: beacon.timeOf(pending[id].drandRound) - T is in [9, 12] (asserted with assertGe/assertLe). Expected per NatSpec: about 15.

### 8. Info: Self-referral rebates 3% of the price, so mint() callers pay 3% more net than mintWithReferral(n, max, self) callers for identical parts

`src/IdentityMachine.sol:316`

```
        if (referrer != address(0) && referrer != address(this) && referrer != DEAD) {
```

The docstring on mintWithReferral states self-referrals are allowed, so this is intended, but the consequence is worth stating as a design note: any minter who passes their own address gets 3% of the price back through claimReferral(), while a caller of mint(), mintFor(), or EthMinter.mint without a referrer pays the same price and that 3% stays in the 26% shared pool. The effective price differs by 3% purely on which entry point the caller used, and the only way to be on the losing side is not to know the trick. There is no owner to adjust this after deployment. If the intent is a universal 3% rebate, mint()/mintFor() could default the referrer to the recipient; if the intent is a true referral programme, disallow referrer == recipient/msg.sender.

**Reproduction**

Reproduced in test/scratch/Judge.t.sol (test_E_selfReferral). alice calls mintWithReferral(1, max, alice) at price t. Actual: referralBalance[alice] == t * 300 / 10000 immediately, claimable via claimReferral(), so her net cost is 0.97 t; a mint(1, max) at the same k costs t with the 0.03 t added to _distribute. Expected under a uniform price: equal net cost.

### 9. Info: EthMinter swaps with no sqrtPriceLimit; the only slippage bound is msg.value, so any ETH sent as a buffer above the quote is extractable by a sandwich

`src/EthMinter.sol:107`

```
        int256 delta = poolManager.swap(poolKey(), EthSwapParams(true, int256(total), 4295128740), "");
```

sqrtPriceLimitX96 = 4295128740 is MIN_SQRT_PRICE + 1, i.e. no price limit for a zeroForOne swap. The exact-output swap for `total` IMD consumes as much ETH as the pool demands, capped only by `spent > budget` (line 112) where budget = msg.value (line 90). The `expires` deadline bounds time, not price. A client that follows the contract's own guidance (quote(n), then send a buffer above ethTotal so small price moves do not revert the transaction) exposes the whole buffer: a searcher who sees the pending transaction moves the ETH/IMD pool so the exact-output cost equals msg.value, then unwinds. The user still receives their parts, so this is value leakage from the ETH payer to the sandwicher, bounded by the buffer the payer chose; the payer controls that bound, so it is a hardening note, not a permission defect. Fix: accept a maxEth parameter distinct from msg.value (so the refund path and the slippage bound are decoupled), or pass a caller-supplied sqrtPriceLimitX96.

**Reproduction**

Reproduced in test/scratch/JudgeEth.t.sol (test_F_bufferIsFullyExtractable) with a pool mock whose ETH-per-IMD rate can be moved between quote and execution (the repository's DevEthPool has a fixed rate): quote(1) gives ethQuote; the rate is raised 20%; alice calls mint{value: 1.2 * ethQuote}(1, max, now). Actual: ethPaid == msg.value, refund 0, alice's balance drops by the full 1.2 * ethQuote. Generalised: user calls EthMinter.mint(1, maxImd, expires) with msg.value = 1.2 * ethQuote. Attacker front-runs with a large ETH->IMD swap in the same v4 pool so that the exact-output cost of `total` IMD rises to exactly 1.2 * ethQuote, and back-runs after. unlockCallback: ethDelta = -(1.2 * ethQuote), spent = 1.2 * ethQuote, `spent > budget` is false (equality), settle{value: spent}, refund = msg.value - ethPaid = 0. Expected: the user pays about ethQuote. Actual: the user pays 20% more than quoted and the attacker captures the difference.

---

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