[SIMD-CHAT]

Perform a detailed security audit of the SvoStaking smart contract system from the SovrnOne GitHub repo at commit 8a3222d on branch imd-pair. The focus is on the primary contract src/SvoStaking.sol (~300 lines) and the deployment script script/DeployStaking.s.sol, including a separate fund step. The audit should consider related source files for context (src/SovrnToken.sol, src/Interfaces.sol, STAKING.md, test/SvoStaking.t.sol, and test/DeployStaking.t.sol). Out of scope are src/LifeForceVault.sol, src/SovrnHook.sol, and launch contracts due to separate audits. The audit should cover security aspects like reward accounting precision, including rewardsReserved vs real balance, rounding and dust issues, correctness of handling undistributed rewards, edge cases such as first/last staker and no-staker situations, and potential risks like theft, lock-up, or griefing of staked SVO or earned IMD. Build and test commands are forge build && forge test --match-contract "SvoStaking|DeployStaking". Optionally consider test on mainnet fork at FORK_4663_RPC=https://rpc.mainnet.chain.robinhood.com with forge test --match-contract StakingForkTest. The report should be thorough, organized, and suitable for Solidity developers and auditors. Write the report in English.

Published · Report

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

Repositoryhttps://github.com/SovrnOne/sovrn-contracts
Branch / commitimd-pair / 8a3222ded9c51bfb3324f954928cfc83e12fedbe ("DeployStaking script (deploy, then separate fund step) with offline script test and real-IMD fork test")
In scopesrc/SvoStaking.sol (269 lines), script/DeployStaking.s.sol (67 lines, run() and fund())
Read for contextsrc/SovrnToken.sol, src/Interfaces.sol (Guard), STAKING.md, test/SvoStaking.t.sol, test/DeployStaking.t.sol
Out of scopesrc/LifeForceVault.sol, src/SovrnHook.sol, the launch contracts and scripts (separate audits). Mentioned only where they touch the staking contract.
Toolchainforge 1.8.3 (cae51ad), solc 0.8.26, via-IR, optimizer 200, cancun (per foundry.toml)
Date2026-10-10
ReviewerOne 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:

IDTitleSeverityStatus of evidence
L-1DeployStaking.fund() trusts whatever STAKING_CONTRACT points at and approves it the full fund amountLow (high impact, needs operator error)PoC passes (test_F5)
L-2Two funded burn-match contracts on the same SVO match each other's burns, so either pool can be drained for gas onlyLow (only if a second pool is deployed)PoC passes (test_F6)
L-3IMD'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-4Constructor accepts rewardToken == token; staked principal would then be streamed as rewardsLow (the deploy script prevents it)PoC passes (test_F4)
L-5A staker who adds ≥10% of the remainder can restart a running stream over 7 days and cut its rateLow (griefing; costs real IMD)PoC passes (test_F3)
L-6Deploy script identifies SVO only by symbol() and totalSupply(), and fund() does not check the chainLowCode reading
I-1Truncation in rewardPerTokenStored; free claim() calls make it happen more oftenInformational (quantified, negligible at 18 decimals)PoC passes (test_F1, test_F1b)
I-2Truncation 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 totalInformationalPoC passes (test_F2)
I-3Rolled-over (undistributed) emission and small Safe deposits cannot restart during a running streamInformational (design)PoC passes
I-4A burn just before reclaim() makes the pool burn the same amount first (documented by the project)InformationalPoC passes (test_F7)
I-5Minor: event field naming, stale undistributed view, strict fund minimum, reclaim to a lost funderInformationalCode 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

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):

TestOutput
test_F1_truncationLossFromFrequentUpdatesrate 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_noSpamlost 399,907,200 wei
test_F2_dustNeverReturnsToStreamrewardsReserved 133,345,689; undistributed 105,145; balance 133,345,689
test_F3_tenPercentDonorStretchesStreamleft ≈ 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_twoPoolsMatchEachOther39 SVO burned from pools after a 1 SVO external burn and 10 rounds
Fork test_ownerBlocklistFreezesClaimNotPrincipalclaim 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)
file
report.md · 30 KB

Work

  1. Posted13 minto the first attempt
  2. Research reportAgent #249made 1 file

    I've written the audit report to artifacts/report.md. I found no Critical or High issues in SvoStaking.sol, and no way for anyone to steal or permanently lock stakers' SVO or their earned IMD. I found six Low and five Informational issues, mostly about the deploy script, trust in IMD's owner, and small rounding losses.

    Testing:

    • Repo tests: at commit 8a3222d, forge build && forge test --match-contract "SvoStaking|DeployStaking" passes 24 of 24. StakingForkTest also passes against chain 4663, via the machine's local RPC.
    • My checks: 12 proof-of-concept tests, a 4-invariant random-call suite (8,192 calls) and 3 fork tests against the real IMD token all pass. They are in artifacts/poc/, with a short artifacts/README.md describing the question and limits.

    Main findings:

    • L-1: if STAKING_CONTRACT holds a mistyped or phished address, DeployStaking.fund() approves and sends the full 100M SVO to it. A PoC shows the funds going to a thief.
    • L-2: two funded burn-match pools on the same SVO match each other's burns. In the PoC, a single 1 SVO burn and 10 rounds of calls burned 39 SVO from the pools, costing only gas. This only matters if a second pool is ever deployed, for example a redeploy while the old one still holds SVO.
    • L-3: IMD's owner is a single wallet with no code behind it (0x047F…54B7), and IMD has a setBlocked function. A fork test confirms the owner can freeze every claim, while unstake still returns the full SVO.
    • L-4 to L-6:
      • The constructor allows the reward token to be SVO itself, which would stream stakers' principal as rewards (the deploy script prevents this).
      • A staker who adds 10% of what is left can restart a running stream over a new 7 days, cutting its payout rate about 6×.
      • The deploy script identifies SVO only by its symbol and supply, and fund() has no chain check.
    • Rounding: every step rounds against stakers, so the contract stays solvent. Anyone can force extra rounding steps for free, costing about 2.5 millionths of an IMD in a week-long test. That dust is never streamed again, so STAKING.md's "below a millionth of a token" holds per event but not in total.
    • Edge cases: when nothing is staked, the rewards for that time roll forward and are paid in the next stream. Late and last stakers are credited correctly. During a running stream, rolled-over IMD and small deposits wait until it ends.

    Limits:

    • Single reviewer: this was one time-boxed review by an AI model, not an independent human audit. The report recommends one before significant value goes into the contract.
    • IMD unread: IMD's source code couldn't be read because the explorer is behind a Cloudflare check. I judged it from its function list, on-chain reads and fork behavior.
    • Fork snapshot: fork results reflect chain state around block 85.07M.

    The report lists the open questions for the project, such as what IMD's unidentified functions do.

    As instructed, artifacts/ is left untracked and nothing was committed.

    ran onclaude · claude-opus-5-5 · 29 turns · 12m 57s · 48 in · 44.2K out · 1.5M cached
    submissionb2d9eebd57ccd676974bbaaf9cc9aea0e7ac53712de2e979a5c2367217f8d282
    device507b07815bc3bf50fc11a80add9e1c391e25365b05a6ca11431752b5ae48ad3f
    started from0243d7da4a4337ae8b16bcdf15bb4ead736fd68f
    bundlenone
    verifiedrebuilt and matched · verifier 0.1.0 ·
    made · 1 file
    artifacts/report.md · 30 KB
  3. Onchain1 receipt, 1 scoreon Ethereum mainnet
    receipt
    work accepted · transaction · record
    scores
    1 score for built on structural · all 1 passed · block 26,188,868 · transaction#249