# Security Audit: SvoStaking and DeployStaking (SovrnOne/sovrn-contracts @ 8a3222d)

| | |
|---|---|
| Repository | https://github.com/SovrnOne/sovrn-contracts |
| Branch / commit | `imd-pair` / `8a3222ded9c51bfb3324f954928cfc83e12fedbe` ("DeployStaking script (deploy, then separate fund step) with offline script test and real-IMD fork test") |
| In scope | `src/SvoStaking.sol` (269 lines), `script/DeployStaking.s.sol` (67 lines, `run()` and `fund()`) |
| Read for context | `src/SovrnToken.sol`, `src/Interfaces.sol` (`Guard`), `STAKING.md`, `test/SvoStaking.t.sol`, `test/DeployStaking.t.sol` |
| Out of scope | `src/LifeForceVault.sol`, `src/SovrnHook.sol`, the launch contracts and scripts (separate audits). Mentioned only where they touch the staking contract. |
| Toolchain | forge 1.8.3 (`cae51ad`), solc 0.8.26, via-IR, optimizer 200, cancun (per `foundry.toml`) |
| Date | 2026-10-10 |
| Reviewer | One automated reviewer (an AI model). This is not an independent multi-person audit. See "Limits". |

Line references such as `SvoStaking.sol:126` are to the file at commit 8a3222d, e.g.
`https://github.com/SovrnOne/sovrn-contracts/blob/8a3222d/src/SvoStaking.sol#L126`.

---

## 1. Executive summary

**Verdict.** I found **no Critical or High issue** in `SvoStaking.sol` and no way for a third party to steal or permanently lock stakers' SVO principal or earned IMD. Reward accounting is conservative. Every rounding step rounds against stakers, so the contract cannot become insolvent through rounding. Principal and the burn-match pool are kept apart: across 8,192 random stateful calls, the pool never used staked SVO. Unstaking never touches IMD.

The findings are Low and Informational. They are about trust assumptions, deployment safety and small accounting leaks:

| ID | Title | Severity | Status of evidence |
|---|---|---|---|
| L-1 | `DeployStaking.fund()` trusts whatever `STAKING_CONTRACT` points at and approves it the full fund amount | Low (high impact, needs operator error) | PoC passes (`test_F5`) |
| L-2 | Two funded burn-match contracts on the same SVO match each other's burns, so either pool can be drained for gas only | Low (only if a second pool is deployed) | PoC passes (`test_F6`) |
| L-3 | IMD's owner is a single EOA with `setBlocked`; it can freeze all claims (principal stays safe) | Low (trust assumption, partly documented) | Fork test on chain 4663 |
| L-4 | Constructor accepts `rewardToken == token`; staked principal would then be streamed as rewards | Low (the deploy script prevents it) | PoC passes (`test_F4`) |
| L-5 | A staker who adds ≥10% of the remainder can restart a running stream over 7 days and cut its rate | Low (griefing; costs real IMD) | PoC passes (`test_F3`) |
| L-6 | Deploy script identifies SVO only by `symbol()` and `totalSupply()`, and `fund()` does not check the chain | Low | Code reading |
| I-1 | Truncation in `rewardPerTokenStored`; free `claim()` calls make it happen more often | Informational (quantified, negligible at 18 decimals) | PoC passes (`test_F1`, `test_F1b`) |
| I-2 | Truncation dust stays in `rewardsReserved` forever and never returns to a stream; the STAKING.md "below a millionth of a token" bound holds per event, not in total | Informational | PoC passes (`test_F2`) |
| I-3 | Rolled-over (`undistributed`) emission and small Safe deposits cannot restart during a running stream | Informational (design) | PoC passes |
| I-4 | A burn just before `reclaim()` makes the pool burn the same amount first (documented by the project) | Informational | PoC passes (`test_F7`) |
| I-5 | Minor: event field naming, stale `undistributed` view, strict `fund` minimum, reclaim to a lost funder | Informational | Code reading |

---

## 2. Method and evidence

The checks I ran and their results (facts):

1. **Project build and tests.** `forge build && forge test --match-contract "SvoStaking|DeployStaking"`: 24/24 pass (23 in `SvoStakingTest`, 1 in `DeployStakingTest`). The fuzz test ran 256 times.
2. **Project fork test.** `StakingForkTest` ran against Robinhood Chain (chain id 4663) and passed: `test_overflowStreamsWithRealIMD`. The fork used a local RPC provider for chain 4663 that falls back to the public endpoint named in the brief. Chain head was about block 85,072,551 at query time.
3. **My own PoC suite** (`artifacts/poc/AuditPoC.t.sol`, 12 tests, all pass). Drop it into `test/audit/` of the repository at 8a3222d.
4. **My own stateful invariant suite** (`artifacts/poc/AuditInvariant.t.sol`). A handler drives stake, unstake, claim, IMD deposit, notify, time warp up to 60 days, external burns, `matchBurn` and funder `reclaim` across 4 actors. All 4 invariants hold over 128 runs × 64 depth (8,192 calls):
   - `svoSolvent`: `totalStaked == Σ ghost principal` and `SVO.balanceOf(staking) ≥ totalStaked + matchRemaining()`
   - `imdSolvent`: `IMD.balanceOf(staking) ≥ rewardsReserved ≥ undistributed + streamRemaining + Σ pendingRewards`
   - `imdConservation`: `totalClaimed + IMD.balanceOf(staking) == Σ deposited`, and `totalNotified == totalClaimed + rewardsReserved`
   - `matchAccounting`: `matchUsed ≤ matchTotal`, and `matchAvailable()` never reverts
5. **My own fork tests** (`artifacts/poc/AuditFork.t.sol`, 3 tests, all pass on chain 4663). They exercise the real IMD at `0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127`.
6. **On-chain reads** of IMD via `cast` (chain 4663):
   - `name` "Identity.md", `symbol` "IMD", `decimals` 18, `sharedDecimals` 6
   - `transfersEnabled` true
   - `owner` `0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7`, which has **no code** (an EOA)
   - EIP-1967 implementation slot is empty (not an EIP-1967 proxy)
   - The selector set includes LayerZero OFT functions (`send`, `quoteSend`, `setPeer`, `lzReceive`) and `setBlocked(address,bool)` / `blocked(address)`, `enableTransfers()`, `setV4Config(address,address,bool)`, `updateName/Symbol`, `setMsgInspector`, `setPreCrime`
   - The refuel Safe `0xEb57…697C` is a 2-of-3 Safe (`getThreshold()` = 2)
7. Manual line-by-line review of the in-scope files.

Below, every finding marks what is **Fact** (observed in code, tests or chain), what is **Inference** (my reasoning from facts) and what is **Uncertain / open**.

---

## 3. System overview (fact, from code)

`SvoStaking` combines three features in one contract with no owner, no pause and no upgrade path:

- **Staking SVO for IMD rewards.** This is a Synthetix-style `rewardPerToken` accumulator (`PRECISION = 1e18`). The contract does not pull IMD from anywhere. The refuel Safe transfers IMD to the contract, and anyone calls `notify()` (`SvoStaking.sol:126-145`). `notify()` takes `added = balance − rewardsReserved` and streams `added + leftover + undistributed` over `REWARD_PERIOD = 7 days`. The integer-division remainder goes back into `undistributed` (line 141). While a stream is running, `notify()` reverts unless `added ≥ leftover/10` and `added > 0` (line 137). Emission that falls while `totalStaked == 0` is added to `undistributed` in `_updateGlobal` (line 252).
- **Burn-match pool.** The `funder` calls `fund(amount)` once. The pool vests linearly over 365 days (`matchAvailable`, line 205). Anyone can call `matchBurn()` (lines 162, 185-196). It burns from the pool as much SVO as has reached `DEAD` since the last snapshot, capped at the vested amount, and moves `burnSnapshot` forward by `2 × matched` so the pool does not match its own burn. After 365 days the funder may `reclaim()` once (lines 171-182). Burns that are still waiting get matched first.
- **Access tier.** `hasAccess(a)` requires `staked ≥ 1,000,000 SVO` and `now ≥ stakedSince + 1 day`. Every `stake()` resets `stakedSince`.

`SovrnToken` is a plain ERC-20 with no hooks and a fixed supply. Burning means transferring to `DEAD`, which increments `totalBurned` (`SovrnToken.sol:43-47`). `Guard` is a single-slot reentrancy lock (`Interfaces.sol`).

Ways value can leave the contract (fact, exhaustive by inspection):

- SVO: `unstake` (principal, to `msg.sender`), `_match` (to `DEAD`), `reclaim` (to `funder`)
- IMD: `claim` (to `msg.sender`)

There is no sweep, rescue or admin path.

---

## 4. Detailed review of the questions in the brief

### 4.1 `rewardsReserved` versus the real balance

**Facts.**
- `rewardsReserved` only rises in `notify()`, where it is set to the observed balance (line 131). It only falls in `claim()`, by exactly the amount transferred (line 112).
- `notify()` treats any balance above `rewardsReserved` as new reward. This means fee-on-transfer *into* the contract is handled correctly.
- My invariants show `balance ≥ rewardsReserved ≥ (all liabilities)` and the exact identity `totalNotified == totalClaimed + rewardsReserved` over 8,192 random calls with a standard ERC-20.
- On the real IMD fork, the contract balance went up by exactly the amount sent, and down by exactly the amount `claim()` paid (`test_noTransferFeeBothSides`). So, for the paths I tested, real IMD charges no transfer fee in either direction.

**Inference.**
- The only ways `balance < rewardsReserved` can happen are outside this contract: a negative rebase, an IMD owner action that changes balances, or a fee charged to the sender on outgoing transfers. I saw none of these in IMD's selector set, but I could not read IMD's source (see "Uncertain").
- If it did happen, the last claimers' `claim()` would revert with `TransferFailed` (a "last one out" shortfall). Later Safe deposits would silently fill the gap first, because `notify()` only counts balance above `rewardsReserved`.
- Stakers' SVO principal would not be affected.

**Uncertain.**
- IMD's verified source was not fetched; the explorer is behind a Cloudflare challenge. The unresolved selectors `0x059c9548(uint256)`, `0x4478d7ba`, `0x547ef3d3` and `0x84e4b73a` (all currently returning 0) and `setV4Config` might add transfer conditions. I did not establish what they do.

### 4.2 Rounding and dust

**Facts.**
1. `notify()` streams exactly `rewardRate × 7 days`. The division remainder goes to `undistributed` and is streamed later (line 141). This dust is not lost.
2. `_updateGlobal` truncates `emitted × 1e18 / totalStaked` (line 253). Per global update, stakers lose less than `totalStaked / 1e18` wei of emission. For SVO (1e27 total supply) that is at most about 1e9 wei = 1e-9 IMD. This matches the claim in STAKING.md.
3. `pendingRewards` truncates per account (line 239), losing less than 1 wei per account per update.
4. Lost wei from (2) and (3) stay inside `rewardsReserved` and are **never re-streamed**. Only the `notify` remainder goes to `undistributed`, and there is no sweep. PoC `test_F2`: with about 3e26 SVO staked and one 1-IMD stream, after a full claim `rewardsReserved` was 133,345,689 wei. Only 105,145 wei of that was `undistributed`. The other 133,240,544 wei is permanently locked dust from a single update.
5. Every rounding step favors the contract. My invariants never found a liability above `rewardsReserved`, so `rewardsReserved -= reward` in `claim()` cannot underflow through rounding.

**Overflow check (inference).**
- `staked × (rpt − paid)` is bounded by `emitted × 1e18`, because `rpt` grows by `emitted × 1e18 / totalStaked` and `totalStaked ≥ staked`. Even an IMD supply far above today's 5.49e22 cannot reach 2^256.
- A 1-wei staker makes `rewardPerTokenStored` grow by `emitted × 1e18` per update. That is still about 1e45 per stream, far from overflow.

See I-1 and I-2 for how much this matters.

### 4.3 Undistributed rewards

**Facts.**
- Emission is added to `undistributed` only inside `_updateGlobal`, which runs on any stake, unstake, claim or notify while `totalStaked == 0`.
- `stake()` calls `_update` *before* raising `totalStaked` (lines 89-92). The empty interval is therefore credited to `undistributed`, and the new staker gets none of it. A late staker cannot take emission from a period when nothing was staked.
- `undistributed` is only streamed again by `notify()`.
- PoC `test_edge_noStakerThenFirstStaker`: a 700-IMD stream started with nobody staked; a staker joined after 3 days. They received about 400 IMD for the 4 days staked. 300 IMD rolled into `undistributed` and was paid out in full by the next `notify()`.
- PoC `test_edge_lastStakerLeaves`: the last staker leaves on day 2 and keeps about 200 IMD; about 500 IMD rolls over and is not lost.

**Inference.** The only drawback is timing. While a stream is running, rolled-over IMD cannot be restarted until the stream ends, or until someone adds new IMD of at least a tenth of the remainder (I-3).

### 4.4 Edge cases: first staker, last staker, no stakers

- **No stakers, `notify()` called.** Allowed (fact). The whole stream goes to `undistributed` and waits for the next valid `notify()`. Calling `notify()` repeatedly with no stakers just re-rolls the stream; nothing is lost.
- **First staker of any size takes the whole rate** while alone (fact; the project documents this). Even 1 wei works. This is not an inflation or donation attack: there are no shares, and rewards are per second, so a just-in-time stake around `notify()` earns nothing extra.
- **Last staker leaves.** Covered in 4.3 (fact, PoC).
- **`notify()` with nothing new.** Reverts with `InvalidAmount`, both during a stream and when `total < REWARD_PERIOD` wei (fact; project tests and PoC).
- **Before any stream.** `_updateGlobal` sets `lastUpdate = block.timestamp` when `periodFinish == 0` (line 255). `_emitted()` returns 0 (fact).
- **Burn-match edge cases** (fact, project tests and my invariants):
  - Burns before `fund()` are excluded (line 157).
  - After `reclaim()`, `matchTotal == matchUsed` and `matchAvailable()` returns 0 without underflow, because `elapsed` is capped at the duration.
  - `reclaim()` with nothing left returns 0 and still closes the pool.
- **Access tier.** Only `msg.sender` can stake for itself, so nobody can reset another account's `stakedSince` (fact). Rotating the same SVO across accounts once a day is documented by the project and remains possible.

### 4.5 Theft, lock-up and griefing of staked SVO and earned IMD

- **Theft of staked SVO.** None found (fact plus invariant). The pool can only send `≤ matchTotal − matchUsed` (lines 189, 178), and `unstake` is bounded by `staked[msg.sender]`.
- **Lock-up of staked SVO.**
  - `unstake` makes no external call except `SVO.transfer`, and `_update` calls nothing external (fact). A frozen, paused or blocklisting IMD cannot block it.
  - A project test and my fork test with the real IMD's `setBlocked(staking, true)` confirm this (fact).
  - SVO is immutable and has no hooks.
- **Theft of earned IMD.** None found. `claim` follows checks-effects-interactions and holds the reentrancy lock (fact).
- **Lock-up of earned IMD.**
  - The IMD owner can freeze claims (L-3).
  - A blocked staker cannot claim to another address, because `claim` has no recipient argument. The fork test shows a claim to a blocked staker reverts. That staker's IMD then stays reserved until they are unblocked (fact). Other stakers are unaffected.
- **Griefing.**
  - The stream can be stretched (L-5).
  - Anyone can trigger truncation for free (I-1).
  - A burn just before `reclaim` hurts the funder (I-4).
  - A pool-versus-pool loop is possible if a second pool exists (L-2).

---

## 5. Findings

### L-1. `DeployStaking.fund()` trusts the target contract it is given

**Location.** `script/DeployStaking.s.sol:49-66`

**Facts.**
- `fund()` reads `STAKING_CONTRACT` from the environment.
- It takes `token()` and `funder()` from that address with no other check.
- It checks only that the address has code and that `matchStart() == 0`.
- It then runs `svo.approve(staking, amount)` and `staking.fund(amount)` from the funder wallet. The default amount is 100,000,000 SVO.

**PoC.** `test_F5_fundScriptTrustsStakingContract` deploys a `FakeStaking` that returns the real SVO as `token()` and the broadcaster as `funder()`. Its `fund()` calls `transferFrom` to a thief. The script runs to completion, and the thief receives 100,000,000 SVO.

**Impact / likelihood (inference).**
- Impact: total loss of the founder allocation.
- Likelihood: low. It needs a wrong or phished `STAKING_CONTRACT` value, such as an address copied from a malicious page or a typo that lands on an attacker's contract.
- The deploy and fund steps are deliberately separate, so a human copies the address between them. That is exactly where this kind of mistake happens.

**Recommendation.** In `fund()`:
- require `address(staking).codehash` to equal the expected runtime code hash of `SvoStaking` (constructor arguments are immutables, so compare after `run()` and record the hash, or check `type(SvoStaking).runtimeCode` with the immutables masked);
- require `staking.rewardToken() == IMD`;
- require `address(staking.token()) == vm.envAddress("SVO_TOKEN")`;
- require `block.chainid == 4663`.

Alternatively, have `run()` write the deployed address to a file and have `fund()` read it from there.

### L-2. Two burn-match pools on the same SVO feed each other

**Location.** `SvoStaking.sol:185-196`. The function relies on `token.totalBurned()`, which counts every transfer to `DEAD` by anyone, including another matcher.

**Facts.**
- Each pool excludes only *its own* burns, by advancing `burnSnapshot` by `2 × matched`.
- A second funded `SvoStaking` on the same SVO (or any other contract that burns SVO in response to `totalBurned`) sees the first pool's match burns as external burns, and the reverse.

**PoC.** `test_F6_twoPoolsMatchEachOther` funds two pools with 100M SVO each and waits a year. A single external burn of **1 SVO** followed by 10 rounds of permissionless `matchBurn()` on both pools burns **39 SVO** from the pools. Each extra round burns more. The only limits are gas and the vested amounts.

**Impact (inference).**
- If the project ever has two funded matchers live at once, anyone can drain both vested pools to `DEAD` for gas only. Examples: a re-deploy while the old pool still holds SVO, or a second matcher for another feature.
- An attacker who funds their *own* pool gets only 1:1 leverage: every SVO their pool burns costs them one SVO. That is no worse than the documented burn-before-reclaim case.
- With a single pool the problem does not exist.
- `LifeForceVault.burn()` (out of scope) also burns SVO to `DEAD` (`LifeForceVault.sol:88`), and those burns are matched. This looks intended, but it is not stated in STAKING.md.

**Recommendation.**
- Document "never fund more than one matcher on the same SVO".
- Alternatively, keep an allowlist of addresses whose burns are ignored, or match only burns made through a dedicated burn entry point.

### L-3. IMD owner (a single EOA) can freeze every claim

**Facts (chain 4663).**
- IMD `owner()` is `0x047F…54B7`, which has no code.
- IMD has `setBlocked(address,bool)`.
- Fork test `test_ownerBlocklistFreezesClaimNotPrincipal`: after the owner blocks the staking contract, `claim()` reverts with `TransferFailed`, while `unstake` still returns full principal. `balanceOf` keeps working.
- STAKING.md mentions that "a misbehaving IMD (blocklist, pause) can block `claim`". It does not say that the blocklist exists today and is controlled by one EOA.

**Impact (inference).**
- All accrued and future IMD rewards can be frozen at the discretion of one key, or of whoever compromises it.
- Principal is safe.
- I did not test whether a blocked staking contract can still *receive* IMD from the Safe.

**Recommendation.**
- State the dependency explicitly in STAKING.md and in the app.
- Optionally add a `claimTo(address)` so that a staker whose own address is blocked can still move rewards. Beware that this weakens a deliberate IMD compliance control; that choice is the project's.

### L-4. Constructor does not forbid `rewardToken == token`

**Location.** `SvoStaking.sol:78-83`

**Fact.** If IMD and SVO were the same address, `_rewardBalance()` would include staked principal and the match pool. `notify()` would then stream them as rewards.

**PoC.** `test_F4` streams Alice's 100M staked SVO to Bob and Alice. Alice's later `unstake` of her full principal reverts.

**Likelihood (inference).** Very low. `DeployStaking.run()` hardcodes `IMD` and checks that `SVO_TOKEN` has symbol "SVO" and 1B supply, so the deploy script cannot produce this configuration. The risk applies only to hand-made deployments.

**Recommendation.** Add `if (rewardToken_ == address(token_)) revert Unauthorized();` to the constructor.

### L-5. A running stream can be restarted over a new 7 days for 10% of what is left

**Location.** `SvoStaking.sol:134-143`

**Fact.**
- Any `notify()` with `added ≥ leftover/10` resets `periodFinish` to `now + 7 days` and spreads the whole remainder over the new week.
- PoC `test_F3`: with 1,000 IMD left and 1 day to go, a donor adds 100 IMD.
  - The rate falls from 0.01157 to 0.00182 IMD/s, about 6.4 times slower.
  - A 10% staker who planned to leave at the original end receives 615.7 IMD instead of 700. The remainder is deferred, not lost.

**Inference.**
- The 10% rule (an audit fix in commit c86905c) stops *free* stretching, but stretching is still cheap for a whale. A 90% staker gets back about 90% of the donation, so the net cost is about 10 IMD here.
- The damage is to the time value of rewards, not their total. No funds are lost.

**Recommendation.** If this matters:
- extend by `max(remaining time, added × REWARD_PERIOD / total)`, or
- add new IMD at the current period end without resetting it, or
- require `added ≥ leftover` before a reset is allowed.

### L-6. Deploy script: weak token identity, and no chain check in `fund()`

**Facts.**
- `run()` accepts any contract whose `symbol()` is "SVO" and `totalSupply()` is 1e27 (`DeployStaking.s.sol:30-33`). A look-alike token passes this check.
- `fund()` has no `block.chainid` check, unlike `run()`.
- `STAKING_FUNDER` defaults to the broadcaster. The address is permanent, and the script only logs it.
- The SVO address is not yet fixed anywhere in the repository. `launch-attestation.json` says "no deployment".

**Recommendation.**
- Once SVO is deployed, pin its address, or compare its runtime code hash with the attested build.
- Add the chain check to `fund()`.
- Consider requiring `STAKING_FUNDER` to be set explicitly, with no default.

### I-1. Truncation of `rewardPerTokenStored` can be triggered for free

**Facts.**
- `claim()` with nothing earned returns 0 but still runs `_updateGlobal`. Any account can therefore force a truncation step at the cost of gas.
- `test_F1`: 6e26 SVO staked, a 1-IMD weekly stream, an update every 120 seconds for a week. Stakers lose **2.51e12 wei = 2.5e-6 IMD (2.5 ppm)**. The no-spam control lost 4.0e8 wei.
- `test_F1b`: at the minimum allowed rate (1 wei/s, a 604,800-wei stream) with 3e26 staked and an update every minute, stakers receive **0**. All 604,800 wei stay reserved forever.

**Inference.**
- With an 18-decimal IMD this is economically negligible. It only becomes material if IMD is worth enough that a few nano-IMD per update matter, or if `REWARD_PERIOD` and the minimum stream are tiny.
- The loss formula assumes 18 decimals. IMD reports 18 (fact), and a fork test confirmed it.

**Recommendation.** Optional:
- carry the truncation remainder (`emitted × 1e18 − Δrpt × totalStaked`) into `undistributed`, or
- use a higher `PRECISION` (1e36 is safe given the bounds in 4.2).

### I-2. Dust in `rewardsReserved` is permanently stranded; the STAKING.md bound is per event

**Fact.** See 4.2(4). STAKING.md says "Rounding dust (below a millionth of a token) stays in the contract".

**Inference.**
- The bound holds per update.
- In total, dust accumulates without limit with the number of updates. I-1 shows 2.5e-6 IMD in one week of 2-minute updates on one stream.
- It is still economically trivial.

**Recommendation.** Reword the documentation, or route the dust into `undistributed` (see I-1).

### I-3. Rolled-over emission and small deposits wait while a stream runs

**Facts.**
- `test_edge_undistributedWaitsDuringStream`: `notify()` reverts during a stream even when a large `undistributed` is waiting.
- A Safe deposit below `leftover/10` waits up to 7 days. This is documented.
- If nobody stakes for most of a stream, that IMD is not paid until a later `notify()`. Anyone can call `notify()` after `periodFinish`.

**Recommendation.** None needed. Consider letting `undistributed` count toward the 10% threshold, or allowing a restart when `undistributed ≥ leftover/10`.

### I-4. A burn just before `reclaim()` reduces the funder's refund

**Fact.** `test_F7`: a 100M SVO burn by a holder before `reclaim()` makes the pool burn its whole 100M, and `reclaim()` returns 0. STAKING.md documents this as accepted.

**Inference.** The attacker pays 1:1, so this is an economic choice, not a vulnerability.

### I-5. Minor observations

Facts unless marked otherwise:

- `RewardAdded(added, streamed, …)` emits `total` as "streamed". The amount actually streamed is `rewardRate × REWARD_PERIOD`; the remainder is re-rolled. Off-chain indexers may overcount by the remainder.
- The public `undistributed` getter (and `streamRemaining`) are stale until the next state-changing call while `totalStaked == 0`. Emission since `lastUpdate` is not yet included.
- `fund(amount)` requires `amount ≥ 365 days` (31,536,000 wei). This is harmless; it guarantees non-zero vesting per second.
- `matchAvailable` floors `matchTotal × elapsed / DURATION`. This is safe: `matchTotal ≤ 1e27`, so no overflow, and the full pool is available at the end.
- If the funder loses its key or Safe, the unvested and unmatched pool can never be reclaimed. It stays matchable forever, so nothing is lost, it just never returns.
- SVO sent by plain transfer, and tokens other than IMD, are stranded. This is documented.
- The `Guard` lock is shared by all external functions, so cross-function reentrancy through an IMD callback is blocked. IMD is a LayerZero OFT with no ERC-777-style hooks in its selector set (inference).
- Test gap (fact): the project's `testFuzz_contractStaysSolvent` covers two stakers and two deposits. It does not cover `reclaim` interacting with staking, multiple streams with `totalStaked` reaching 0 in between, or more than one matcher. My invariant suite covers the first two.

---

## 6. Properties that hold

These are facts, backed by tests or invariants as noted:

1. Staked SVO can only leave through its owner's `unstake`. Pool outflows are bounded by `matchTotal − matchUsed`. (Invariant `svoSolvent`, 8,192 calls.)
2. `unstake` succeeds whatever state IMD is in. (Project test with a broken IMD, plus a fork test with the real IMD blocklist.)
3. IMD is conserved: `totalClaimed + balance == deposits`, and `totalNotified == totalClaimed + rewardsReserved`. (Invariant `imdConservation`.)
4. `rewardsReserved` covers everything owed. (Invariant `imdSolvent`.)
5. Burn-match never matches its own burns or pre-funding burns, and never goes past the vested amount. (Project tests plus invariant.)
6. There are no privileged roles other than `funder`, whose powers are `fund` (once) and `reclaim` (once, after 365 days).

---

## 7. Open questions for the project

1. What do IMD's unresolved functions (`0x059c9548(uint256)`, `setV4Config`) do? Can they ever change balances or impose a transfer fee? Is IMD's source verified on the Robinhood Chain explorer?
2. Can a blocked address still *receive* IMD? If the staking contract were blocked, would Safe deposits fail or silently succeed?
3. Will any other contract that matches burns via `totalBurned` ever be deployed alongside this one (L-2)?
4. Who controls IMD's owner EOA, and is there a plan to move it to a multisig or timelock?
5. Is matching `LifeForceVault.burn()` volume from the founder pool intended?

---

## 8. Limits of this review

- This was a single-reviewer, time-boxed review of about 340 in-scope lines, produced by an AI model. The PoCs and invariants raise confidence but do not prove correctness. No formal verification was done.
- IMD was assessed from bytecode selectors, on-chain reads and fork behavior only, not from source code.
- Out-of-scope contracts (`LifeForceVault`, `SovrnHook`, launch scripts) were read only where they affect burns or IMD flows.
- Fork results reflect chain state at about block 85.07M on 2026-10-10. IMD's owner can change IMD's configuration after that.
- The verifier for this deliverable checks file paths and bytes only. It does not certify these findings. An independent human audit is recommended before significant value is placed in the contract.

## Appendix A: Reproducing

```bash
git clone https://github.com/SovrnOne/sovrn-contracts && cd sovrn-contracts && git checkout 8a3222d
forge build && forge test --match-contract "SvoStaking|DeployStaking"          # 24 pass
mkdir -p test/audit && cp <artifacts>/poc/*.sol test/audit/
forge test --match-path "test/audit/*" -vv                                       # PoCs + invariants
FORK_4663_RPC=https://rpc.mainnet.chain.robinhood.com \
  forge test --match-contract "StakingForkTest|AuditForkTest" -vv                 # real IMD
```

Key PoC outputs (from my run):

| Test | Output |
|---|---|
| `test_F1_truncationLossFromFrequentUpdates` | rate 1,653,439,153,439 wei/s; streamed 999,999,999,999,907,200; owed 999,997,488,000,000,000; lost 2,511,999,907,200 wei |
| `test_F1_control_noSpam` | lost 399,907,200 wei |
| `test_F2_dustNeverReturnsToStream` | rewardsReserved 133,345,689; undistributed 105,145; balance 133,345,689 |
| `test_F3_tenPercentDonorStretchesStream` | left ≈ 1,000 IMD; rate 11,574,074,074,074,074 → 1,818,783,068,783,068 wei/s; 10% staker at the original finish: 615.71 IMD (700 without the stretch) |
| `test_F6_twoPoolsMatchEachOther` | 39 SVO burned from pools after a 1 SVO external burn and 10 rounds |
| Fork `test_ownerBlocklistFreezesClaimNotPrincipal` | `claim` reverts `TransferFailed`; `unstake` returns full principal; a later `notify` reverted with `InvalidAmount` only because nothing new had arrived (trace shows `balanceOf` still works for a blocked account) |
