Olympus engaged Guardian to review the security of their Migration Review of Olympus. From the 29th of January 2026 to the 2nd of February 2026, a team of 2 auditors reviewed the source code in scope.
- Published
- Review window
- January 29 to February 2, 2026
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Ethereum, Arbitrum, Optimism, Base, Berachain
- Sector
- Governance
- 0 Critical
- 0 High
- 0 Medium
- 3 Low
- 12 Informational
Scope
Overview
Olympus engaged Guardian to review the security of their Migration Review of Olympus. From the 29th of January 2026 to the 2nd of February 2026, a team of 2 auditors reviewed the source code in scope.
Findings 15
Main Review
13 findings-
L-01 Low Cannot Update Root Or Cap When Paused Unexpected Behavior Resolved
Description
The administrative setters for the Merkle root and migration cap are gated by
onlyEnabled. If the migrator is disabled for incident response, governance cannot update these values without first re enabling the policy. That creates a window where the old configuration is live or forces operators to re enable before fixing configuration.function setMerkleRoot(bytes32 merkleRoot_) external onlyEnabled onlyAdminOrLegacyMigrationAdmin { _currentMerkleNonce++; merkleRoot = merkleRoot_; emit MerkleRootUpdated(merkleRoot_, msg.sender); } function setMigrationCap(uint256 cap_) external onlyEnabled onlyAdminRole { _setMigrationCap(cap_); }The impact is reduced operational flexibility during a pause scenario.
Example: a bad merkle root is published that excludes a large set of eligible holders. The team disables the migrator to stop further migrations and wants to update the merkle root (or reduce the cap). Because both setters are gated by
onlyEnabled, they must re-enable the migrator to apply the fix, re-opening migrations under the broken configuration.Recommendation
Allow these admin setters to run while disabled, or provide separate admin only functions that can update configuration without re enabling the migrator. Keep migrate itself gated by
onlyEnabled.Resolution
Olympus Team: The issue was resolved in PR#205.
-
L-02 Low Proposal Activation Fails With Legacy Migrations Logical Error Partially resolved
Description
Proof of concept: PoC
MigrationProposalHelperalways migrates the fullOHMv1ToMigrateamount via the legacyTokenMigrator. If this configured amount exceeds the migrator’s actual capacity (bounded by its gOHM balance andoldSupplyheadroom), the legacy migrator reverts and the helper activation fails.Even if the amount was calibrated at planning time, a gOHM balance decrease before activation (e.g., legacy users migrating) creates the same failure mode. This can cause the governance proposal to fail if
OHMv1ToMigrateis mis‑calibrated or capacity drifts.Example flow
- Ops compute capacity off‑chain and set
OHMv1ToMigratevia the setup batch. - Ops mint tempOHM based on that stored amount.
- A normal user migrates through the legacy
TokenMigrator, reducing its gOHM balance. - Proposal executes
activate(), which calls the legacy migrator for the full (now‑stale)
OHMv1ToMigrateand reverts due to insufficient capacity.Recommendation
Compute and cap
OHMv1ToMigrateto the migrator’s capacity before execution (e.g.,min(gOHMbalance →OHMv1, oldSupply - currentSupply)with a small buffer), and include this check in the runbook or setup scripts.Resolution
Olympus Team: Partially Resolved.
- Ops compute capacity off‑chain and set
-
L-03 Low Migration Proposal Griefed Via Timelock Donation DoS Resolved
Description
MigrationProposal._validaterequires the time lock to hold zero OHMv2, OHMv1, gOHM, and tempOHM balances after execution:require(IERC20(OHMv2).balanceOf(timelock) == 0, "There should be no OHMv2 left in the Timelock"); require(IERC20(GOHM).balanceOf(timelock) == 0, "There should be no gOHM left in the Timelock");The
_validatefunction runs on-chain as part of proposal execution (not just simulation). An attacker can grief the migration proposal by:- Observing the migration proposal in the governance queue
- Donating 1 wei of OHMv2 (or gOHM) to the timelock address
- When the proposal executes,
_validate()reverts becausebalanceOf(timelock) != 0 - The entire governance proposal fails
Impact: DoS of migration governance proposal, requiring resubmission and new governance vote. The validation is overly strict—it checks tokens that are not part of the migration flow (OHMv2/gOHM can exist in the time lock for unrelated reasons). Only
tempOHMis migration-specific and should be zero after activation.Recommendation
- Remove OHMv2 and gOHM balance checks from
_validate()(they are not migration-specific) - Or use delta-based assertions: record pre-execution balances and assert no net increase
- Or add a sweep mechanism to handle unexpected donations before validation
Resolution
Olympus Team: The issue was resolved in PR#203.
-
I-01 Informational Helper Burns All tempOHM It Pulls Unexpected Behavior Resolved
Description
The helper transfers the entire
tempOHMbalance from the timelock, deposits only the computed amount into the legacy treasury, and then burns any remainingtempOHM. If the timelock holds extratempOHMfor any reason, that surplus is destroyed during activation.ERC20(TEMPOHM).safeTransferFrom(owner, address(this), ERC20(TEMPOHM).balanceOf(owner)); uint256 tempOHMToDeposit = getTempOHMToDeposit(); ERC20(TEMPOHM).safeApprove(TREASURY, tempOHMToDeposit); ohmV1Minted = IOlympusTreasury(TREASURY).deposit(tempOHMToDeposit, TEMPOHM, 0); uint256 excessTempOHM = ERC20(TEMPOHM).balanceOf(address(this)); if (excessTempOHM > 0) { ERC20Burnable(TEMPOHM).burn(excessTempOHM); }The impact is potential destruction of
tempOHMbalances beyond the intended migration amount.Recommendation
Transfer only the required
tempOHMamount, or explicitly document that any extratempOHMin timelock will be burned and ensure operational processes keep the balance tight.Resolution
Olympus Team: The issue was resolved in PR#202.
-
I-02 Informational Activation Fails If Category Already Exists Unexpected Behavior Resolved
Description
The helper always calls
addCategory("migration")before burning. The Burner policy reverts if the category is already approved.If the category was added earlier by any operational test or previous attempt, the helper activation will revert and the governance execution will fail even though all other steps are correct.
Burner(BURNER).addCategory(MIGRATION_CATEGORY); if (categoryApproved[category_]) revert Burner_CategoryApproved();Example: the DAO runs a dry-run proposal that adds the "migration" category in
Burner. Later, when the real proposal executes on mainnet (or when the helper is re-deployed for a second attempt after a failed run),addCategory("migration")reverts because the category already exists and the activation transaction fails even though everything else is correct.Recommendation
Make the category addition idempotent. Check whether the category is already approved before adding it, or use a unique category name for this specific migration to avoid collisions.
Resolution
Olympus Team: The issue was resolved in PR#204.
-
I-03 Informational Tenderly Sim Uses daoMS Unexpected Behavior Resolved
Description
The Tenderly simulation path in
OlyBatchhardcodes thefromaddress todaoMS, even when the batch is intended to be executed by the policy or emergency safe. InOlyBatch, the real sender issafe, which is set by the modifier used (isDaoBatch,isPolicyBatch,isEmergencyBatch).By forcing
daoMSinto the Tenderly payload, the simulation can succeed under DAO permissions even though the actual sender would fail on-chain.This is an operational/simulation mismatch. It can mislead reviewers into thinking a batch is safe to execute when the real sender lacks the required roles or balances.
Example: a batch guarded by
isPolicyBatchis supposed to be executed by the policy multisig.Tenderly simulation runs with
daoMSas the sender, passes due to DAO permissions, and the team believes the batch is safe. When executed from the policy multisig, the transaction can revert due to missing roles, causing an avoidable deployment or governance delay.Note: the newer
BatchScriptV2uses_owneras the sender in its Tenderly payload, which matches the real sender. This issue is specific toOlyBatchand was updated during this PR review (commite255afe).Recommendation
Use the actual
safeaddress (the batch sender) for the Tenderly payloadfromfield instead of alwaysdaoMS.Resolution
Olympus Team: The issue was resolved in PR#208.
-
I-04 Informational Helper Lacks Token Rescue Mechanism Warning Resolved
Description
The migration helper is a permanent contract with no method to recover tokens sent to it accidentally. Any
ERC20transferred to the helper outside of the activation flow will remain stuck forever.Recommendation
Add an
onlyOwnersweep function for arbitrary tokens, or explicitly document that the helper must never receive tokens outside the activation sequence.Resolution
Olympus Team: The issue was resolved in PR#209.
-
I-05 Informational Migration Cap Is Remaining Approval Warning Resolved
Description
The migration cap logic is implemented by synchronizing
MINTR mintApprovalto a target value. BecauseMINTRapprovals are consumed when minting, the value represents remaining allowance rather than a lifetime total.The function name and comments can be interpreted as setting an absolute cap, which can lead to operational mistakes if governance resets the approval without accounting for already minted amounts.
The impact is governance confusion and potential misconfiguration of the migration allowance.
Example: 600 OHM v2 has already been minted and the remaining approval is 400. Governance calls
setMigrationCap(1000)expecting the total cap to be 1000 overall.The code sets the remaining approval to 1000, allowing another 1000 to be minted, for a total of 1600. The system behaved correctly, but the operator expectation was wrong because the cap is “remaining allowance,” not “lifetime total.”
Recommendation
Clarify semantics in naming and documentation, or track a separate total cap in
V1Migratorand enforce minted total against it. If using remaining approvals, rename to something likesetRemainingMintApprovaland updateNatSpecaccordingly.Resolution
Olympus Team: The issue was resolved in PR#210.
-
I-06 Informational Partial Migrations Can Lose Dust Rounding Resolved
Description
Migration converts OHM v1 to OHM v2 by calling
gOHM.balanceToand thengOHM.balanceFrom. Both conversions round down. If a user migrates in multiple small transactions, dust can be lost on each conversion rather than once on the total amount.uint256 gohmAmount = _GOHM.balanceTo(amount_); uint256 ohmV2Amount = _GOHM.balanceFrom(gohmAmount); if (ohmV2Amount == 0) revert ZeroAmount(); function balanceFrom(uint256 _amount) public view override returns (uint256) { return _amount.mul(index()).div(10**decimals()); } function balanceTo(uint256 _amount) public view override returns (uint256) { return _amount.mul(10**decimals()).div(index()); }The impact is minor value loss for users who migrate in many small calls.
Recommendation
Add a
previewMigrateview helper and document that users should migrate in a single transaction when possible. Consider adding a minimum amount threshold or UI guidance to reduce dust loss.Resolution
Olympus Team: The issue was resolved in PR#207.
-
I-07 Informational incurDebt Fails To Handle Fee On Transfers Logical Error Acknowledged
Description
The
incurDebtfunction increasesreserveDebtandtotalDebtbyamount_before transferring tokens to the debtor. If the system supports fee-on-transfer or deflationary tokens, the debtor receives less thanamount_, but debt is recorded for the full amount.This overcharges the debtor and causes
getReserveBalance(balance +totalDebt) to overstate total reserves by the amount lost to fees.Recommendation
Either reject fee-on-transfer/deflationary tokens for debt operations, or measure the actual amount sent to the debtor (e.g., recipient balance delta) and record debt accordingly. Alternatively, perform a pre/post balance check on the treasury and adjust debt to the net amount that leaves the treasury.
Resolution
Olympus Team: Acknowledged.
-
I-08 Informational Bridge Deactivation Does Not Block Inbound Mints Logical Error Acknowledged
Description
setBridgeStatus(false)only affectssendOhm(outbound burns). Inbound minting vialzReceive/retryMessagecontinues even when the bridge is “deactivated,” because neither path checksbridgeActive.This makes the kill‑switch asymmetric: operators may believe a shutdown halts minting, but messages from trusted remotes will still mint OHM on the destination chain.
Recommendation
If shutdown should stop all bridge activity, add
bridgeActivechecks tolzReceiveandretryMessage(or explicitly document that deactivation is outbound‑only and use trusted‑remote removal to halt inbound).Resolution
Olympus Team: Acknowledged.
-
I-09 Informational retryMessage Ignores Trusted-Remote Checks Validation Acknowledged
Description
retryMessageonly verifies the stored payload hash and then calls_receiveMessagedirectly. It does not re‑check whether the trusted‑remote entry is still configured. As a result, a previously failed payload can be replayed to mint OHM even after operators clear trusted remotes.Recommendation
Re‑validate trusted remote/endpoint constraints (or explicitly document that retries bypass these controls). Consider invalidating stored payloads when removing trusted remotes.
Resolution
Olympus Team: Acknowledged.
-
I-10 Informational setMerkleRoot Lacks Same‑Root Guard Validation Resolved
Description
setMerkleRootincrements the migration nonce and replaces the root, without validating that the new root differs from the current one. Existing proofs for the previous root become invalid, and per‑user migrated amounts reset for the new nonce.A mid‑campaign root update can block users until new proofs are generated and distributed. If the same root is accidentally set again, the nonce still increments and migrated tracking resets, allowing a full re‑migration under the same tree.
Recommendation
Add a guard to reject
setMerkleRootwhenmerkleRoot_ == merkleRoot(or otherwise require an explicit “reset” flag), and document that root updates are campaign resets. If keeping the current behavior, update the runbook to warn that same‑root updates re‑open migrations.Resolution
Olympus Team: The issue was resolved in PR#206.
Remediation Review
2 findings-
I-01 Informational TempOHM Allowance Drift Can Revert Warning Resolved
Description
The governance proposal approves a fixed
tempOHMallowance based on the timelock balance at proposal build time, but the helper later transfers the timelock's full balance at execution time. If the timelock balance changes between those two moments, the transferFrom can exceed the approved amount and revert, causing the entire proposal execution to fail.Operationally, the migration plan expects the timelock to hold only the intended amount of
tempOHMand to have that balance remain unchanged until execution. That expectation is not enforced in code.tempOHMis a standardERC20and is transferable, so any holder can donate dust to the timelock and increase the balance.The same effect occurs if the
tempOHMowner mints additional tokens to the timelock after the proposal is queued. In either case the helper tries to pull the new, higher balance even though the allowance was set to the old balance, and the proposal reverts.This does not imply a loss of protocol funds and may never happen if the team strictly ensures that the timelock is the only
tempOHMholder and no additional minting occurs. The point is that this safety relies on an off-chain assumption rather than an on-chain invariant and the failure mode is a governance execution revert that requires a re-submission.Recommendation
Make the transfer deterministic relative to the approved amount. The simplest approach is to approve
type(uint256).maxand revoke to zero after execution. Alternatively, transfer only the expected amount (for example,getTempOHMToDepositormin(balance, allowance)) and handle any excess in a separate, explicit burn step. This removes the dependency on timelock balance stability and makes execution robust even if the balance drifts.Resolution
Olympus Team: The issue was resolved in PR#214.
-
I-02 Informational V1Migrator.rescue() Blocked When Disabled Access Control Resolved
Description
The newly added
rescue()function has theonlyEnabledmodifier, meaning tokens cannot be rescued when the contract is disabled/paused. This is counterintuitive for a rescue function — emergencies requiring pause are exactly when stuck tokens might need recovery.Scenario:
- Emergency discovered → admin calls
disable()to pause migrations - Tokens need to be rescued (e.g., accidentally sent or from exploit recovery)
rescue()reverts due toonlyEnabled- Admin must re-enable (potentially exposing users to the emergency) just to rescue tokens
Contrast:
setMerkleRoot()andsetRemainingMintApproval()correctly removedonlyEnabledto allow admin updates while paused.Recommendation
Remove
onlyEnabledfromrescue(). The access control (onlyAdminOrLegacyMigrationAdmin) is sufficient protection. This aligns with the pattern used forsetMerkleRoot()andsetRemainingMintApproval()which were updated to allow calls while disabled.Resolution
Olympus Team: The issue was resolved in PR#213.
- Emergency discovered → admin calls
No findings match.
More from Olympus
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
