M0 engaged Guardian to review the security of their M0’s Liquidity Delivery. From the 8th of December 2025 to the 7th of January 2026, a team of 3 auditors reviewed the source code in scope.
- Published
- Review window
- December 8, 2025 to January 7, 2026
- Rounds
- Main Review, Remediation Review
- Language
- Solidity, Rust
- Chains
- Ethereum, Arbitrum, Optimism, Linea, Unichain, Solana
- Sector
- Stablecoins
- 3 Critical
- 5 High
- 10 Medium
- 14 Low
- 27 Informational
Scope
Overview
M0 engaged Guardian to review the security of their M0’s Liquidity Delivery. From the 8th of December 2025 to the 7th of January 2026, a team of 3 auditors reviewed the source code in scope.
Findings 59
Main Review
49 findings-
C-01 Critical Orderbook Drained Due To Cancelled Orders Validation Resolved
Description
Proof of concept: PoC
OrderBook._fillOrder()doesn't check the status of the order to be filled. It just transferstokenOutfrom the solver to the recipient and gives the appropriate amount oftokenInto the solver. This works because the address that requested the order already transferred these tokens when the order was opened.However, orders can be cancelled by calling
requestCancelOrder()which will trigger the logic in_claimRefund()and the order requester will receive back thetokenInthey have put upon creating the order.Notice how the filled amounts stay unchanged as well. Therefore, an order can be filled after it was cancelled, which makes the orderbook double spend the
tokenInfor local orders.A malicious user can leverage this to drain the orderbook by creating a local order with
amountIn =tokenIn.balanceOf(orderBook)and a smallamountOut. The user can then first cancel the order to receive their tokens back and then fill it themselves to drain all the tokens out of the orderbook.Recommendation
Revert
_fillOrder()if the status of the local order is different thanCreated.Resolution
M0 Team: The issue was resolved in commit b36bd28.
-
C-02 Critical Anyone Can Replay The M0 Transfer Between Spokes Access Control Resolved
Description
VAAs are multicast by default in Wormhole. There is no default target chain for a given message -the application developer must enforce destination validation.
From Wormhole docs:
"VAAs are multicast by default. This means there is no default target chain for a given message. The application developer decides on the format of the message and its treatment upon receipt."
Looking at parseAndVerifyVM in Wormhole's core library, the hash verified against guardian signatures does *not* include the destination chain:
https://github.com/wormhole-foundation/wormhole/blob/c113791abd5241bc7a23655e3a7475085
d51dab7/ethereum/contracts/Messages.sol#L50
Attack Scenario If the protocol is live on ETH, ARB, and OP:
- A legitimate message is sent from ETH → ARB
- If guardians for ARB and OP overlap, the same VAA could potentially be replayed on OP
- Guardians can indeed overlap across chains (e.g., Chorus One guardian appears on multiple chains
with same address)
- See guardian overlap:
https://wormhole-foundation.github.io/wormhole-dashboard/#/?endpoint=Mainnet
Recommendation
Consider passing destination chain ID and destination address in the payload, then verify in
executeVAAv1:- Destination chain ID should match
block.chainid - Destination address should match
address(this)
Resolution
M0 Team: The issue was resolved in PR#19.
-
C-03 Critical Cross Chain Orders Can Be Canceled From Source Unexpected Behavior Resolved
Description
Proof of concept: PoC
The finality buffer design was removed and currently the action of canceling an order must be initiated on the destination chain.
This will mark the order as
Cancelledand solvers won't be able to fill it anymore. If it was a crosschain one,sendCancelReport()will notify the source chain and the funds will be released to the original sender.However, there is no validation performed to ensure the current chain is the same as the destination chain for the order. Because of this, any order that originated on the current chain can be instantly canceled, which will execute
_claimRefund().if (orderData_.originChainId == chainId) { // Local orders can be immediately refunded _claimRefund(orderId_, order); }This opens up a griefing vector where the creator of the order can cancel their order as soon as it's filled on the destination chain, resulting in their profit and loss for the solver.
Recommendation
Add validation to
_cancelOrder()that reverts if the chain id doesn't match the order destination chain.Resolution
M0 Team: The issue was resolved in commit e945bc6.
-
H-01 High Crosschain Orders Cannot Be Filled On EVM DoS Resolved
Description
An order on the destination chain is filled by executing the internal
OrderBook._fillOrder()function. In case the order originated from a different chain, a fill report is sent back to that chain.The
IMessenger.sendFillReport()function signature accepts 3 arguments.The external
Portal.sendFillReport()function has two overloads - one of them accepts 4 arguments and the other 5, but none accepts 3.The first is:
function sendFillReport( uint32 destinationChainId, IOrderBookLike.FillReport calldata report, bytes32 refundAddress, bytes calldata bridgeAdapterArgs ) external;And the second is:
function sendFillReport( uint32 destinationChainId, IOrderBookLike.FillReport calldata report, bytes32 refundAddress, address bridgeAdapter, bytes calldata bridgeAdapterArgs ) external;Because of this, attempts to fill any crosschain order will be always failing.
In addition to that,
sendFillReportshould be called with an appropriatevalueto pay for crosschain fees. The current implementation doesn’t forward any value which will result in all messages failing, even if the right interface was used.Recommendation
Use one of the correct overloads when sending the fill report and make sure to specify an appropriate amount value sent.
Resolution
M0 Team: The issue was resolved in PR#11.
-
H-02 High Portals Cannot Send Cancel Reports DoS Resolved
Description
When a crosschain order is cancelled,
IMessenger(messenger).sendCancelReport()is called. The messenger is thePortal, where such a function doesn't exist. Because of this the cancel feature won't work as the transaction will always be reverting.The same is true for the Solana portal - there isn't an instruction that sends the cancel report.
Recommendation
Implement the needed functionality in the portals.
Resolution
M0 Team: The issue was resolved in PR#25.
-
H-03 High Stuck Funds Due To Non-isolated Spokes DoS Resolved
Description
The new
crossSpokeTokenTransferEnabledflag shows if a spoke can send tokens to other spokes or only to the hub. If the flag is set tofalse, cross spoke transfers are not enabled and each time tokens are sent from the hub to that spoke,bridgedPrincipalfor that spoke is increased.Similarly, when tokens are sent from that spoke to the hub, the hub decreases the
bridgedPrincipalwith the appropriate amount. However, this flag limits the spokes only for sending tokens, not receiving. Because of this it's possible to send tokens from a non-isolated spoke to an isolated one.This will result in the second spoke having more principal than recorded in the hub. The Hub reverts if a spoke tries to unlock more principal than the bridged amount.
uint248 principalAmount = IndexingMath.getPrincipalAmountRoundedDown(uint240(amount), _currentIndex()); // Prevents unlocking more than was bridged to the Spoke if (principalAmount > totalBridgedPrincipal) revert InsufficientBridgedBalance();In result, the tokens that have been sent will remain stuck and the transfer message will be reverting.
Recommendation
Consider fully isolating these spokes. For this to be possible, each spoke must track the flag for the other spokes and not allow sending tokens to an isolated one.
Resolution
M0 Team: The issue was resolved in PR#29.
-
H-04 High Incorrect Scaling Of Token Amount Unexpected Behavior Resolved
Description
On Solana,
send_tokenforwardsm_amountderived fromext_swap::unwrapdirectly into the bridge payload.The unwrap path computes and transfers principal units of $M (not scaled UI units).
On the EVM side, the
HubPortalreleases the exact payload amount with no scaling. Because the payload carries principal units, the EVM unlock will be under‑issued by the current multiplier (e.g., multiplier=2 → 500 principal becomes 500 released instead of 1000).This creates a cross‑chain supply/amount mismatch whenever the multiplier ≠ 1, leading to losses for users.
Recommendation
Ensure the payload amount is scaled/UI units before sending to EVM.
Resolution
M0 Team: The issue was resolved in PR#31.
-
M-01 Medium Portal Initialize Logic Cannot Be Executed Upgradeability Resolved
Description
The portals are upgradeable contracts with
initialize()functions. Theinitialize()function inHubPortal()sets the value of thedisableEarningIndex.function initialize(address owner, address pauser, address operator) external initializer { _initialize(owner, pauser, operator); HubPortalStorageStruct storage \$ = _getHubPortalStorageLocation(); \$.disableEarningIndex = IndexingMath.EXP_SCALED_ONE; }This contract is intended to become the new implementation of the current HubPortal. The portal is currently initialized at version 4. After its logic is updated, attempts to call
initialize()will revert because of the initializer modifier. If left uninitialized,_isEarningEnabled()will return false even when earning is enabled and lead to wrong index updates.function _isEarningEnabled() internal view returns (bool) { return wasEarningEnabled() && disableEarningIndex() == IndexingMath.EXP_SCALED_ONE; }If the storage of the old portal is cleared before the upgrade, to let
initialize()be called again, then the initialized version of the contract would be reset from 4 to 1.Recommendation
Use the
reinitializer()modifier instead.Resolution
M0 Team: The issue was resolved in PR#15.
-
M-02 Medium Merkle Root Updates Cannot Be Send To SVM Unexpected Behavior Resolved
Description
The
receive_messageinstruction in thesolana-portalprogram decodes the received payload and if its type isEarnerMerkleRoot, the merkle root for the earners is updated.Payload::EarnerMerkleRoot(payload) => { msg!("Received EarnerMerkleRoot Payload"); ctx.accounts.portal_global.update_index(payload.index); return Self::handle_index_payload(&ctx, payload); }However, it's impossible to send such a message from the EVM portal because there is no function for it and the payload type doesn't exist.
enum PayloadType { TokenTransfer, Index, RegistrarKey, RegistrarList, FillReport }Recommendation
Implement a way to send the merkle root from EVM to SVM.
Resolution
M0 Team: The issue was resolved in PR#14.
-
M-03 Medium Loss For Solvers Due To A Cancel Race Condition Unexpected Behavior Resolved
Description
Orders are both filled and canceled from the destination chain. It's done by sending crosschain messages to the source chain where the order status is handled. If the order is fulfilled, it can't be canceled anymore because its status is set to
Completed.On the other hand, for partial fills the status is set to
Created, which means the order is still cancelable. Previously, there was a finality buffer which protected solvers from instant cancellations.Now that it's removed, a race condition resulting in a loss for the solver and gain for the order creator can happen. For example:
- Alice opens order on OP to be filled on Arb:
3000 USDCin,1 WETHout. - Bob fills half of it on Arb: pays
0.5 WETHto Alice on Arb => fill report is sent - Alice cancels the order on Arb => cancel report is sent
- If the cancel report arrives on OP before the fill report, Alice will receive back
3000 USDCand any
attempts to fill the order will fail. In result, Alice will have stolen
1500 USDCfrom Bob.Recommendation
You can:
- Include the filled amounts in the cancel report
- Use these amounts in
reportCancel()and forward them to_claimRefund()instead of reading them
from storage. This ensures the user will be refunded only what's left for refund after all inflight fills.
- Allow
reportFill()to be executed for orders wherestatus == Cancelled. This would allow all of the
inflight fills to be executed after the cancel.
Resolution
M0 Team: The issue was resolved in PR#31.
- Alice opens order on OP to be filled on Arb:
-
M-04 Medium Hardcoded Chain ID Vulnerable To Replay Configuration Resolved
Description
The contract caches the chain ID in an immutable variable during construction:
constructor(uint32 chainId_, address messenger_) { chainId = chainId_; messenger = messenger_; }This cached
chainIdis used throughout the contract for critical logic including: Order creation and ID generation (line 191) Cross-chain vs same-chain detection (line 323, line 385, line 439) Destination chain validation (line 579)If a blockchain undergoes a contentious hard fork (like Ethereum/Ethereum Classic or Ethereum PoW/PoS), both chains will initially have the same chain ID (cached).
This creates a replay attack vulnerability similar to the Omni Bridge exploit during the
ETHPoWfork.https://chainbulletin.com/ethw-replay-exploit-caused-by-omni-contract-vulnerability
The cached chain ID doesn't update after a fork, so both chains believe they are the "correct" chain with that ID.
Recommendation
Consider implementing dynamic chain ID validation to detect forks: Option 1 - Read
block.chainiddirectlyOption 2 - Add runtime validation to check
cached == block.chainidResolution
M0 Team: The issue was resolved in PR#34.
-
M-05 Medium SVM: Race Condition For Earner Merkle Roots DoS Resolved
Description
The SVM portal implementation allows any account to call
send_merkle_rootandsend_indexinstructions without access control checks. These functions enable any SVM chain in the system to broadcast earner merkle root updates to any other SVM chain.When a message with
PayloadData::EarnerMerkleRootis received, the portal callsearn::cpi::propagate_index()which updates the earn program's state. While the earn program protects against stale index updates usingnew_multiplier >= current_multiplier, the merkle root itself doesn't have ordering protection.Attack Scenario Consider a system with Hub (EVM), SVM Chain 1, and SVM Chain 2: At T0: Hub propagates fresh merkle root
ROOT_100(index = 100) to SVM Chain 1 At T1: SVM Chain 1 successfully updates:{ index: 100, merkle_root: ROOT_100 }At T2: Hub propagates newer merkle rootROOT_100_v2(index = 100, different earner set) to SVM Chain 1 At T3: SVM Chain 1 successfully updates:{ index: 100, merkle_root: ROOT_100_v2 }At T4: Attacker on SVM Chain 2 callssend_merkle_rootwith their stale snapshotROOT_100(index = 100) and sends it to SVM Chain 1 At T5: SVM Chain 1 receives message from SVM Chain 2 with{ index: 100, merkle_root: ROOT_100 }At T6: Earn program checks:new_multiplier (from index 100) >= current_multiplier (from index 100) →PASSESAt T7: Sincemerkle_root != [0; 32], it overwrites the current root SVM Chain 1 now has staleROOT_100instead of the freshROOT_100_v2, even though the index remains100.This means legitimate earners in the newer merkle tree can no longer claim rewards, while earners only in the old tree can still claim (potentially including removed malicious earners).
Recommendation
Consider allowing propagation of merkle earner root only from hub instead of even allowing between spokes for svm.
Resolution
M0 Team: The issue was resolved in PR#32.
-
M-06 Medium SVM: M Supply Inflation Due To Missing Burn Logical Error Resolved
Description
The SVM portal implementation doesn't burn M as its done in EVM spoke portals.
User's wrapped token (e.g., mUSDC) is transferred to portal
Portal calls
ext_swap::cpi::unwrap()which burns the wrapped token and releases underlying M tokens.M tokens are deposited into portal's custody account.
(send_token.rs:148-161)Bridge message is sent with token transfer payload
No burn operation occurs - M tokens remain locked in portal's m_token_account
On the receiving chain,
receive_message.rs:126-158mints new M tokens to the recipient.This leads to permanent supply inflation.
Every SVM-to-SVM transfer permanently increases the total M token supply.
In a system with N SVM chains, each transfer inflates the supply by the transfer amount. After K transfers totaling T tokens, the excess supply is exactly T tokens locked across all portals' custody accounts.
Recommendation
Consider adding burn inside send on SVM as well.
Resolution
M0 Team: The issue was resolved in PR#24.
-
L-01 Low Users Can Receive Native M Token Access Control Acknowledged
Description
Portals allow users to set the destination token as M token and receive M token directly. This behavior bypasses the swap facility's restrictions which controls who can obtain M tokens.
Even if users receive native M token through this path, they cannot do anything meaningful with it since:
- All DeFi integrations are expected to use wrapped versions of M
- Users cannot swap native M into wrapped versions because
swapInMenforces
_revertIfNotApprovedSwapperon the swap facilityRecommendation
Consider revisiting whether users should be allowed to hold native M token:
- If allowing native M is acceptable - No changes needed
- If allowing native M is not acceptable - Consider:
- Whether there are any legal repercussions of users holding native M
- If legal concerns exist: Block setting M token as destination token in portal transfers
- If no legal concerns: Add a warning when users set destination token as M Token in portal transfers
to inform them of the limited utility
Resolution
M0 Team: Acknowledged.
-
L-02 Low Gasless Order VERSION Is Not Verified Validation Resolved
Description
The
GaslessOrderParamsstruct is used to create an order for another user that has signed a message for it. In_openOrderFor(), theoriginChainIdandnonceare verified against the actual current values of the contract.if (orderParams_.originChainId != chainId) revert InvalidOriginChain(); OrderBookStorageStruct storage \$ = _getOrderBookStorageLocation(); // Requiring a nonce in the order provides replay protection for the sender if (orderParams_.nonce != \$.senderNonces[orderParams_.sender]) revert InvalidNonce();The
versionfield is never verified. This may allow executing the message with another version of the contract, not the one the user specified.Although, if the contract is properly updated, this should not happen, because users sign the result of
_getDigest(), which includes the domain separator, and the contract version is encoded inside.Recommendation
Either remove the version field of the struct (because it's already included in the domain separator), or validate it against the actual contract
VERSION.Resolution
M0 Team: The issue was resolved in PR#12.
-
L-03 Low Do Not Reset Finality On Removed Support Unexpected Behavior Resolved
Description
Each destination chain can be disabled in the Orderbook by calling
setDestinationConfig(). ThenewFinalityBufferwill then be reset to 0, and will be used as the new effective buffer after the old buffer expires.This can lead to a situation where a solver fulfills an order on a destination chain that was disabled on the source and the order creator cancels it immediately to grief the solver. For example:
- Day1: Alice opens an order,
finalityBuffer= 1 day - Day2: The destination chain is disabled, effective buffer is still 1 day, but the new buffer is 0
- Day3: Solver fills the order on the destination chain. Alice is now able to immediately cancel her
order because the new finality is 0. Now
reportFillwill revert and the solver won't receive their tokens.The same behavior is present on Solana
Recommendation
Consider not resetting the finality buffer when a chain is being disabled, and when enabling it later not applying
newFinalityBufferEffectiveTimestampResolution
M0 Team: The issue was resolved in PR#7.
- Day1: Alice opens an order,
-
L-04 Low Risk Of Broken Accounting After Disabling Portal Unexpected Behavior Acknowledged
Description
In the event that
HubPortalis removed from the list of earners, the permissionlessHubPortal.disableEarning()function will be executed. It will callmToken.stopEarning()and will save the current index in thedisableEarningIndexvariable.This shows earning is disabled, therefore
_currentIndex()will be capped to the latest index before the portal was disabled.This ensures index doesn't continue growing on spoke chains.
Once an earner is disabled,
mToken.stopEarning(address)can be called by anyone to stop its earning. Meaning there may be a period of time between callingmToken.stopEarning()andHubPortal.disableEarning().In this period, the index of the
mTokenwill be increasing, which will result in inflateddisableEarningIndexpropagated to all the spokes. If this happens, users are incentivized to send back their funds to the hub, because:- they will receive more tokens on the hub than they should
- sending tokens to the hub earlier reduces the risk of being unable to withdraw due to portal
insolvency
Recommendation
Since the earners list is controlled by the M0 team, make sure that in the event of the hub portal being disabled, the action is always batched together with a call to
HubPortal.disableEarning().Resolution
M0 Team: Acknowledged.
-
L-05 Low Index Should Be Updated During All xChain Interactions Unexpected Behavior Resolved
Description
There are 3 custom payloads that can be sent from the hub to a spoke. The first, of type
Index, fetches theMTokenindex on the hub and sends it to a spoke in order for that spoke to update its index. The other two -RegistrarKeyandRegistrarList- update the registry values.This can lead to unexpected situations if the registry is updated, but the index is not. For example, a user is not an earner and has 1000 tokens on a spoke.
The index on the hub has grown from 1 to 1.1 and the user is approved as earner after that. Now a registry update can be sent to the spoke, which would allow the user to enter the system with index 1, leading to instant profit.
On the other side, users may end up losing as well. If they are removed from the earners list and the index is not updated, when
stopEarningis called for them, their principal amount will be calculated using the old index.The same is true for
reportFillforHub ⇒Spoke. The amount transferred to the solver would (if earner) would be more.Since the index would not be updated, the solver would receive more
mTokensRecommendation
Consider refreshing the index as well when a registry key or list is updated.
Resolution
M0 Team: The issue was resolved in PR#34.
-
L-06 Low Missing Check If Default Adapter Is Supported Validation Resolved
Description
All the functions for sending crosschain messages through the portals -
sendToken(),sendFillReport(),sendMTokenIndex(),sendRegistrarKey()andsendRegistrarListStatus()- have two overrides. One of them allows the user to specify a desiredbridgeAdapter, while the other uses the default one.In case a bridge adapter is specified, the following function will check if it's enabled in the
supportedBridgeAdaptermapping for thatchainID._revertIfUnsupportedBridgeAdapter(destinationChainId, bridgeAdapter);However, this check is not present in the other override - the one using the default adapter. The only check performed there is about the default adapter not being
address(0).It's important to highlight that in order to disable a default adapter, the right course of action is not to set it to
address(0)because thenaddress(0)will be irreversibly as a supported adapter.What should be done instead is to disable the default adapter by calling
setSupportedBridgeAdapter(). This will modify thesupportedBridgeAdaptermapping setting the flag tofalse.Because one of the two overrides of these functions doesn't check that mapping, a disabled adapter may end up still being usable, leading to unexpected behaviors.
Recommendation
Add the following check to all the overrides using the default bridge adapter.
_revertIfUnsupportedBridgeAdapter(destinationChainId, bridgeAdapter);Resolution
M0 Team: The issue was resolved in PR#1cc8a120be2ddb635abf14918fe4f79746ace80e.
-
L-07 Low Incorrect msgSender() When Tokens Are Received Logical Error Acknowledged
Description
All permissionless functions in
SwapFacilityimplement theisNotLockedmodifier.It serves two purposes - one is guarding against reentrancy - if the locker is currently not
address(0), the call will revert, so any attempt for reentrancy will fail. The second is thatSwapFacility.msgSender()will return the address of thelocker.As it can be seen, if the actual
msg.senderis a trusted router, thelockerwill be set to whatever that router returns asmsgSender(). The intended routers for the system are the portals. So every time the portal callsSwapFacility, thelockerof the facility will be set toPortal.msgSender().The
Portalimplements the same pattern as the facility - itsmsgSender()function returns the current locker.There are two flows where the portal interacts with the facility - when sending and receiving tokens. In case of sending tokens,
sendTokenimplements thewhenNotLockedmodifier, somsgSender()will return the address that initiated the action.However, this modifier is not applied to the
receiveMessagefunction or any of the functions called inside it. This will lead to thelockerin the portal stayingaddress(0)and in result:- there will be no reentrancy protection in the portal
- the locker in the facility will be set to
address(0), leading to wrong address usage by the extension the
portal is wrapping into and potentially causing unexpected behaviors, depending on the exact implementation
- the swap facility reentrancy guard will not work as intended and technically reentrancy would be also
possible at the facility level.
Recommendation
Set the locker to an appropriate address. You should choose what that address is. If you apply the modifier to the
receiveMessage()function, thelockerwill be set as the bridge adapter.Another approach you can take is to set the
lockermanually to thesenderof the message. However, there should be a workaround for addresses that don't fit in 20 bytes, for example for tokens coming from Solana.Resolution
M0 Team: Acknowledged.
-
L-08 Low Orderbooks Should Have Minimum Fill Amount Validation Acknowledged
Description
Orders created with permissionless solvers can be fulfilled by anyone. Several solvers may compete for getting their transaction executed first.
Because there is no minimum amount enforced on the tokens spent or the tokens received, a solver may end up fulfilling a foreign order with minuscule amount and still pay for crosschain fees.
For example, there is an order for 1000e18 tokens. Solver A sends a fill transaction for all of the 1000e18 tokens, but solver B's transaction for 1000e18 - 1 is executed first. Now Solver A will fill only 1 wei, but still pay for the crosschain message.
Recommendation
Consider adding minimum amount in in the filler params and use it when filling orders.
Resolution
M0 Team: Acknowledged.
-
L-09 Low Other Programs Can't CPI SendFillReport Unexpected Behavior Acknowledged
Description
The Order Book's
FillForeignOrderinstruction performs a deep chain of Cross-Program Invocations (CPI).The current flow is:
order_book::fill_foreign_order()at
a Guardian proof of concept
s/order_book/src/lib.rs#L114
portal::send_fill_report()at
a Guardian proof of concept
s/order_book/src/instructions/fill.rs#L444 and
a Guardian proof of concept
l/src/lib.rs#L92
hyperlane_adapter::send_message()at
a Guardian proof of concept
l/src/instructions/mod.rs#L103 and
a Guardian proof of concept
rlane-adapter/src/lib.rs#L80
- IGP's
PayForGasat
a Guardian proof of concept
rlane-adapter/src/instructions/send_message.rs#L271 and
- IGP sending lamports at
That makes it already hit the current CPI depth ceiling of 4 CPIs. This may not be a problem for your average solver using a Phantom wallet, for example. But it is indeed a problem if another program attempts to solve (this could happen for aggregators, automated solver and more commonly multi-sigs and smart wallets).
Solving foreign orders is essentially bricked for any such user.
Recommendation
I don't honestly know what to recommend without you having to undergo significant architectural change. Potentially adapter logic could be more coupled to the portal? Or you could accept this limitation.
Note that with
SIMD-0268having been approved, this may not be a problem soon as the depth will increase from 4 to 8. (https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0268-raise-cpi-nesting-limit.md)Resolution
M0 Team: Acknowledged.
-
L-10 Low Sender Cancellation For Cross-Chain Orders Suggestion Acknowledged
Description
In the
cancelOrderfunction at lines 287-291, the authorization logic prevents senders from canceling cross-chain orders before the deadline, only allowing the recipient or sender of same-chain orders to cancel.The suspected intention is to handle address incompatibility between different chain types (e.g., EVM addresses vs Solana addresses in EVM↔SVM scenarios). However, this restriction impacts legitimate use cases:
EVM↔EVM transfers: Users with the same key pair controlling the same address on both chains cannot cancel their cross-chain orders
SVM↔SVM transfers: Similarly restricted despite having the same address derivation
Recommendation
Consider implementing a more nuanced cancellation policy that allows sender cancellation for compatible chain pairs.
Resolution
M0 Team: Acknowledged.
-
L-11 Low Inaccurate Comment Regarding Chain ID Definition Best Practices Resolved
Description
The comment for the
chainIdstate variable at line 56 is incorrect:/// @notice the chain ID of this chain according to the messaging network used by this contract uint32 public immutable chainId;The comment states the chain ID is "according to the messaging network," but the implementation shows that this is actually the chain ID according to consensus (i.e., the EVM's chain identifier), not a messaging-network-specific identifier.
Recommendation
Consider correcting the comment.
Resolution
M0 Team: The issue was resolved in PR#34.
-
L-12 Low Incorrect Comment _revertIfTokenTransferDisabled Best Practices Resolved
Description
The
NatSpeccomment for the_revertIfTokenTransferDisabledfunction inSpokePortal.sol:157incorrectly describes the revert condition.Current comment:
/// @dev Reverts if the destination chain is the Hub chain
Actual behavior: The function reverts when the destination chain is NOT the Hub chain (and cross-spoke transfers are disabled).
Recommendation
Consider correcting the comment.
Resolution
M0 Team: The issue was resolved in commit c2661a4.
-
I-01 Informational The Sanity Check In _fillOrder() Is Needed Warning Resolved
Description
There is the following sanity check in
OrderBook._fillOrder()which validates that the passedorderDatamatches theorderId.// Ensure the provided order ID matches the computed order ID from the order data // This check is not strictly required, but it is a useful sanity check for solvers // to ensure they have the order data correct if (orderId_ != getOrderId(orderData_)) revert OrderIdMismatch();The comment above the check says it's not strictly required, but it's useful. However, if the check wasn't there, anyone could have provided arbitrary order data (including tokens and amounts) with existing
orderIdand easily drain the contract.Recommendation
Remove that part of the comment that says the check is not needed and be careful to keep the validation if the code is changed in the future.
// Ensure the provided order ID matches the computed order ID from the order data • // This check is not strictly required, but it is a useful sanity check for solvers • // to ensure they have the order data correct if (orderId_ != getOrderId(orderData_)) revert OrderIdMismatch();Resolution
M0 Team: The issue was resolved in PR#14.
-
I-02 Informational Order Cannot Be Filled If Solver == Recipient Warning Resolved
Description
During order fulfillment,
tokenOutis transferred from the solver to the recipient by callingsafeTransferExactFrom().// Transfer tokens from the solver to the recipient IERC20(orderData_.tokenOut.toAddress()).safeTransferExactFrom( msg.sender, orderData_.recipient.toAddress(), uint256(amountOutToFill_) );If the solver is the same address as the recipient, the net change in their balance will be <= 0 and the transaction will revert.
Recommendation
Consider reverting if
solver == recipientwhen an order is being opened.Resolution
M0 Team: The issue was resolved in PR#13.
-
I-03 Informational Index Mismatch Can Cause Dormant Funds & Losses Informational Acknowledged
Description
M token is not a standard ERC20 - it's yield-bearing with an index that grows over time. The index updates on different chains are not real time but stepwise, which creates edge cases during cross-chain transfers.
Scenario 1: Hub to Spoke (ETH → ARB)
- Lock X on ETH, release X on ARB
- User plays with X on ARB, balance grows to X+1
- Index on ETH updates, locked amount becomes X+2 (but not yet propagated to ARB)
- User bridges X+1 from ARB back to ETH
- Contract releases X+1, leaving +1 dormant inside the contract
This doesn't cause insolvency on ETH since ARB index is always ≤ ETH index (monotonically increasing). At worst: small net loss for users and dormant funds accumulating on hub.
Scenario 2: Spoke to Spoke (ARB → OP)
- Index on ARB = A
- Index on OP = O, where O > A
- Transfer from ARB to OP
- Since OP index is higher, receive doesn't update the index
- Principal burned on ARB:
Amount/A - Principal received on OP:
Amount/O - Since O > A:
principalOP < principalARB - End balance transferred is the same, but principal differs
- Users of OP (and OP locker) take a small loss as index progresses
Recommendation
Be aware of these edge cases around index mismatch between chains.
Resolution
M0 Team: Acknowledged.
-
I-04 Informational Out-of-order Message Finalization Informational Acknowledged
Description
In general, the Portal doesn't enforce ordering of operations since it doesn't need to - token transfer for User B happening before User A (even if User A initiated the cross-chain transfer first) doesn't change anything.
However, for protocol state updates like:
- Index updates
- Registrar key updates
- Registrar List updates
Order does matter when two updates are sent at the same time. The stale update could be finalized later, resulting in outdated state on the destination chain overwriting the newer state.
Recommendation
Be aware of this edge case. If out-of-order finalization occurs for state updates, remember to initiate a fresh update afterward to ensure destination chain has the correct current state.
Resolution
M0 Team: Acknowledged.
-
I-05 Informational Tighten Constraints For Filling Orders Best Practices Resolved
Description
Malicious solvers could attempt to operate on a native order in the
fill_foreign_orderinstruction. Since there is no validation that the originating chain id is not the current one, it’s possible to do that.The first three fields of
NativeOrdercan be decoded intoForeignOrderThere:
ForeignOrder.status = NativeOrder.statusForeignOrder.amount_in_released = version + sender[0..14]ForeignOrder.amount_out_released = sender[16..30]
The following if statement won’t be executed for an already existing order since its status is
CreatedThe execution will proceed to compute
amount_out_remainingandamount_in_remainingwhich will most likely result in revert, due toamount_out_releasedandamount_in_releasedbeing very large numbers.P.S.: Note this is also the case for cancelling. A simple and quick example would be that a grifter can operate on a
NativeOrderwithCancelForeignOrderonce it expires which would allow them to cancel it without refunding the user. The only thing that stops them in this case is that the portal would (should?) disallow same chain transfers.Recommendation
- Add a check for origin chain to not be equal to dest chain in
fill_foreign_order(same for cancelling
functions)
- Add checks for
order_typein bothfill_native_orderandfill_foreign_order(same for cancelling
functions)
- Explore separating
Order<ForeignOrder>andOrder<NativeOrderinto something likeOrderForeign
and
OrderNativeso that discriminators would actually be different. Maybe even explore having a different order type for each possible scenario (Sol → Sol, Sol → Eth, Eth → Sol).Resolution
M0 Team: The issue was resolved in PR#35.
-
I-06 Informational Fee On Transfer Tokens Can Be Sent To Order Book Unexpected Behavior Resolved
Description
On Solana, a user can open an order where
token_outis a fee-on-transfer (Token-2022) mint. This differs from the EVM implementation, which prevents opening and filling FoT orders by usingsafeTransferExact()to enforce exact received amounts.In the Solana flow, FoT behavior can make an order impossible to fully fill, since each transfer reduces the received amount by a fee. As a result:
- Orders may only be partially fillable, up to approximately
initial_amount - fee, unless additional
tokens are transferred into the order’s ATA beyond what was deposited in
open_order().- Any remaining portion may become stuck, leading to degraded UX and solver confusion.
- For cross-chain orders, this can cause downstream failures if the remote side expects the order to
be fully fillable or relies on exact amounts, potentially resulting in failed fills or reverted execution on the destination chain.
Recommendation
Check for
token2022tokens that have fees and disallow them, as you do on the EVM side.Resolution
M0 Team: The issue was resolved in PR#36.
- Orders may only be partially fillable, up to approximately
-
I-07 Informational Rounding Difference May Not Always Be Covered Warning Acknowledged
Description
During
MTokentransfers from non-earners to earners, the transferred amount is converted to principal amount before it's added to the recipient balance. The calculation rounds down and because of that the receiver may bear losses.This is applicable to the
HubPortalbecause it's an approved earner. When tokens are sent crosschain, the portal checks uses_getTransferAmount(), which tolerates_getMaxRoundingError()of rounding error in favor of the user. Which means that if the actual amount received by portal is in these bounds, the user will still receive their original requested amount. There is a comment saying that yield will cover that difference.This is true because if the recipient on the destination chain is an earner, the calculation will use the same index and the principal amount will increase with the same amount on the two chains. On the other hand, if the recipient is not an earner, as times goes on the yield will be accumulating unilaterally, only on the hub side, therefore the system won't be left insolvent.
However, there are some edge cases that may leave the portal underfunded. For example, let's say
index = 2and the user is not an earner 1. User bridges 5MTokensfrom hub to spoke:- hub principal = 5 / 2 = 2
- user balance on spoke = 5
- User bridges 3 more
MTokensfrom hub to spoke
- hub principal = 2 + 3 / 2 = 3
- user balance on spoke = 5 + 3 = 8
- User is approved as an earner so their raw balance is converted to principal
- user principal on spoke = 8 / 2 = 4
- At this point the principal of the user is more than the principal of the
HubPortal, therefore the portal won't be able to
pay out all of the tokens, no matter the index. For example, let's say the index becomes 20 on both chains 5. User bridges 80 MToken back to hub
- user principal on spoke = 4 - 80 / 20 = 0
- Hub has to transfer 80
MTokensto the user
- hub has to burn 80 / 20 = 4 principal, but it has only 3
Recommendation
This case should be extremely rare, especially because the hub portal may be holding additional funds due to natural user losses, for example the issue described in [I-03](I-03%202c78bda5828c81e4aac9d34c6af9b937.md). Having said that, the team may be monitoring the portal and ensure it's balance is enough to cover transfers.
Resolution
M0 Team: Acknowledged.
-
I-08 Informational Having Shared Lists May Not Be Ideal Informational Acknowledged
Description
The current design of the system stores all keys and lists in the registrar on the Hub and the data is being propagated to the spokes.
This means that once an address is added to a list, it's in that list on every chain. This may lead to unexpected behavior, for example, two different contract have the same address across chains, leading to unauthorized earning by one of them.
Recommendation
Consider whether this is acceptable.
Resolution
M0 Team: Acknowledged.
-
I-09 Informational HubPortal Is Giving Out More Value Informational Acknowledged
Description
Because the
HubPortalis enabled as an earner, when_mintOrUnlock()is executed, the amount of principal transferred will be rounded up, which can lead to losses up tocurrentIndex / 1e12.This should not cause insolvency because it's expected that portal will have dust values due to the stepwise index behavior.
Recommendation
Keep this behavior in mind.
Resolution
M0 Team: Acknowledged.
-
I-10 Informational TokenReceived NatSpec Could Be Improved Best Practices Resolved
Description
The
TokenReceived()event is emitted when a token transfer is received by the portal. One of the parameters there, theindex, is the index on the chain the transfer originated to.The documentation of this parameter is vague. It could be understood that this is the new token index on the current chain, which may not be the case, especially if the transfer is from spoke to hub.
/// @param index The \$M token indexRecommendation
Consider explaining that this index is on the source chain.
Resolution
M0 Team: The issue was resolved in commit db90e53.
-
I-11 Informational Existing Griefing Vector In _receiveToken() Informational Acknowledged
Description
The
Portal._receiveToken()function is executed when a crosschain token transfer is received. If the desired destination token is an extension token,_wrap()will execute a low level call to theswapFacilityin order to swap the receivedmTokeninto an extension and send it to the recipient. If the operation fails, the rawmTokenwill be transferred to the recipient instead.There are currently two ways to execute this flow - through Hyperlane or Wormhole. For example, with hyperlane the flow is
Mailbox.process() -> HyperlaneBridgeAdapter.handle() ->Portal.receiveMessage() -> Portal._receiveToken(). In theory, this flow can be called with such a gas value that 63/64 of it are less than the gas used by theswapFacility, but the 1/64 of it is enough to execute the fallback logic.In this case, anyone would be able to grief crosschain transfers for normal users resulting in these users receiving mToken instead. The current fallback logic needs roughly ~33000 gas. This means
1/64 * totalGas >= 33000 => totalGas >= 2112000. Since 63/64 of that would be forwarded to theswapFacility, the facility must use at least 63/64 * 2112000 = 2079000 gas in order for the attack to be executed.When the currently deployed SwapFacility was tested with the MUSD extension, the gas spent was 166731. This shows that under normal circumstances the issue should not be exploitable. However, if the
SwapFacilitylogic or the extension'swrap()function introduce new gas-heavy features, the probability of exploiting the vector increases.Recommendation
Monitor gas usage of the facility and the extensions to avoid this situation.
Resolution
M0 Team: Acknowledged.
-
I-12 Informational Payload Gas Limit Could Be Validated Best Practices Resolved
Description
The
_sendMessage()function in the Portal callspayloadGasLimit()to determine the gas that should be forwarded for the current call.function _sendMessage( uint32 destinationChainId, PayloadType payloadType, bytes32 refundAddress, bytes memory payload, address bridgeAdapter, bytes calldata bridgeAdapterArgs ) internal { IBridgeAdapter(bridgeAdapter).sendMessage{ value: msg.value }( destinationChainId, payloadGasLimit(destinationChainId, payloadType), refundAddress, payload, bridgeAdapterArgs ); }It's possible for messages to be sent before such a value is configured. This will result in the call using 0.
Recommendation
Consider whether additional validation or a fallback is needed before sending the message.
Resolution
M0 Team: The issue was resolved in commit 45b1400.
-
I-13 Informational Outdated Comment Best Practices Resolved
Description
There is a comment left above the call to
_burnOrLock()that says there is bridged principal amount tracked for the hub portal. In this version of the project, there is no such feature.// In case of Hub, only update the bridged principal amount as tokens already transferred. _burnOrLock(transferAmount);Recommendation
Update the comment.
Resolution
M0 Team: Resolved.
-
I-14 Informational Portal Lacks On Chain Replay Protection Validation Resolved
Description
There is no replay guard in Portal for processed
messageIds. If a bridge adapter is buggy, misconfigured, or compromised and re-delivers (or allows re-delivery of) the same payload,receiveMessagewill mint/unlock tokens, update the registry or re-execute fill reports again. Relying solely on adapters for replay protection creates a single point of failure.Recommendation
Consider adding replay protection in the portal as well.
Resolution
M0 Team: The issue was resolved in commit 9ab37d8.
-
I-15 Informational Potential For Temp Stuck Messages With Hyperlane Informational Acknowledged
Description
Right now you pay the IGP (interchain gas payments) as part of the transaction where you also dispatch the message with no direct easy way of either bumping the payment or paying another Relayer for the same message.
The trust assumptions of Hyperlane are that a Relayer could take your IGP funds and still not relay the message.
This should usually not be a problem unless the Relayer chosen by a user is dishonest which I'd say is not very likely. But, because on the SVM implementation the gas amount to send is globally set (in
hyperlane_global.igp_gas_amount) it could be that it's simply not enough (unless you set it so you always overpay) and as such even an honest Relayer may not relay.If a Relayer doesn't relay, the user always has the option of manually constructing a
PayForGastransaction to bump the payment or pay another Relayer, but this would be a bit of a nuisance.References:
a Guardian proof of concept
80d411f6143e6034bcca81/programs/hyperlane-adapter/src/instructions/send_message.rs#L238
Recommendation
Make the gas amount for IGP dynamic and/or implement a way for the user to repay a dispatched message without having to manually figure it out.
Resolution
M0 Team: Acknowledged.
-
I-16 Informational The Name Solana-earners Is Misleading Best Practices Acknowledged
Description
The
SVM_EARNER_LISTconstant in theHubPortalis set tosolana-earnersbytes32 public constant SVM_EARNER_LIST = bytes32("solana-earners");The earner list may be sent to other SVM chains as well, not only Solana. In this case,
solana-earnerswill be misleading.Recommendation
Consider changing the list name to
svm-earners.Resolution
M0 Team: Acknowledged.
-
I-17 Informational User Can Omit Overhead Even If It's Configured Informational Acknowledged
Description
The
hyperlane_global.igp_overhead_account == Some(igp_overhead_account.key()) @BridgeError::InvalidIgpAccountconstraint only runs when a user provides anigp_overhead_accountregardless of ifhyperlane_global.igp_overhead_accountis set or not.A user could still choose to not pass an account at all even if you want them to do so which gives them a risk of underpaying and the message not getting relayed.
The message would as such get temporarily stuck and the user would have to find another way to pay for it to be relayed.
Recommendation
Add a constraint that the
igp_overhead_accountmust be provided when it's set inhyperlane_global.igp_overhead_account.Resolution
M0 Team: Acknowledged.
-
I-18 Informational Unused Message_account.consumed Property Superfluous Code Acknowledged
Description
The
message_account.consumedproperty is set to true when receiving a message, but it's never checked anywhere.This still doesn't allow for replay because the message account uses init (a Guardian proof of concept 80d411f6143e6034bcca81/programs/portal/src/instructions/receive_message.rs#L29) which would fail if someone tries to receive an already received message ID.
Recommendation
Unless
message_account.consumedis somehow used off-chain, I believe it can be safely removed.Resolution
M0 Team: Acknowledged.
-
I-19 Informational Nonce Inconsistency For Message Ids Best Practices Acknowledged
Description
Each message sent by the portal is assigned an id, which is equal to the
keccak256hash of the originating chain, the destination chain and the nonce of the portal.There is an inconsistency between EVM and SVM. On EVM, the nonce is first used, then incremented.
function _getMessageId(uint32 destinationChainId) internal returns (bytes32) { return keccak256(abi.encode(currentChainId, destinationChainId, _getPortalStorageLocation().nonce++)); }While on SVM, it's first incremented and used afterwards
pub fn generate_message_id(&mut self, destination_chain_id: u32) -> [u8; 32] { self.message_nonce += 1; let mut encoded = [0u8; 96]; // ABI encode: each value is padded to 32 bytes (left-padded for integers) encoded[28..32].copy_from_slice(&self.chain_id.to_be_bytes()); encoded[60..64].copy_from_slice(&destination_chain_id.to_be_bytes()); encoded[88..96].copy_from_slice(&self.message_nonce.to_be_bytes()); keccak::hash(&encoded).to_bytes() }This will result in SVM messages having higher nonces than ones coming from EVM and may be unexpected for external integrators.
Recommendation
Consider sticking to the same approach on both VMs.
Resolution
M0 Team: Acknowledged.
-
I-20 Informational Orderbooks: Version Upgrade Best Practices Resolved
Description
The contract implements a versioning system (VERSION constant at line 45) and validates order versions during fills (line 387):
uint16 public constant VERSION = 1; // In _fillOrder if (orderData_.version != VERSION) revert InvalidOrderVersion();However, there is no mechanism to handle in-flight orders during a version upgrade, which could lead to denial of service or loss of assets if not handled gracefully.
Recommendation
Consider adding pause on new orders before version upgrade.
Let all old orders settle or cancel, and then upgrade.
Resolution
M0 Team: The issue was resolved in PR#25.
-
I-21 Informational Cannot Close Order Accounts Gas Optimization Acknowledged
Description
Right now you do not close order accounts but simply let them live in perpetuity even once successfully cancelled or filled.
At first sight, I believe closing them will be safe (no replay vector) because of the way the order ID is encoded (contains nonce,
created_at,fill_deadline).This should result in deleting ~257 Order bytes from the network which (at current SOL and rent prices) should be roughly ~$0.27 ($0.65 if you also close the order's ATA).
Not much for a single order, but a high volume user/solver could have an observable amount of money over time if they can get this back.
This has the added benefit of keeping the network and program state cleaner, potentially making indexing and off-chain account fetching easier/faster.
Recommendation
Implement account closing for orders once they are successfully cancelled or fully filled.
Resolution
M0 Team: Acknowledged.
-
I-22 Informational Lacking Token Validation For Filling Local Order Validation Resolved
Description
The
token_in_mintaccount in theFillNativeOrderstruct is not validated against thetoken_inof theorder_data#[account( mint::token_program = token_in_program, )] pub token_in_mint: InterfaceAccount<'in fo, Mint>Therefore, any token mint can be provided here. In result, the
solver_token_in_accountandorder_token_in_atamay not be the tokens specified in the order. This should be generally safe, but if the order account holds other real tokens, they can be stolen.Even if such tokens don't exist, malicious user can create a fake token and use it instead of the real one. This will lead to a loss for them because they receive fake token instead of the real one.
Recommendation
Validate that the provided
token_in_mintis the right token account specified in the order.Resolution
M0 Team: The issue was resolved in commit f94d1c0.
-
I-23 Informational SVM Chain ID Assignment And EVM Collision Risk Configuration Acknowledged
Description
SVMs technically do not have native chain IDs like EVM chains do. In the M0 Portal cross-chain system, M0 must assign custom chain IDs to SVM chains for routing and peer identification purposes.
However, this creates a collision risk where a custom SVM chain ID assigned by M0 could conflict with a real EVM chain ID that M0 may want to support in the future.
Example: M0 assigns chain ID 888 to a new SVM chain. Later, Nasdaq launches an L2 with the official EVM chain ID 888. Now:
EVM contracts use
block.chainiddirectly, which would return 888 on the Nasdaq L2M0's system already has 888 mapped to the SVM chain in peer configurations
Ambiguity arises in cross-chain message routing and validation
Breaking changes would be required to remap the SVM chain to a different ID
Recommendation
Consider defining a high-value range for SVM chains that is extremely unlikely to conflict with EVM chain IDs and beware of possibility of this scenario.
Resolution
M0 Team: Acknowledged.
-
I-24 Informational Use Of The Wrong Multiplier Best Practices Acknowledged
Description
When getting the principal amount of $M you use
new_multiplierinstead of what seems to be the better practice of usingScaledUiAmountConfig::current_multiplier().I don't see a strong reason to do this. I haven't unfortunately had the time to explore exploitability (hence marking it as an info now), but intuitively it could result in a stale multiplier (or even an empty
0multiplier ifnew_multiplierhas a clearing mechanism) which can be problematic.Recommendation
Use
ScaledUiAmountConfig::current_multiplier()instead ofnew_multiplier.Resolution
M0 Team: Acknowledged.
Remediation Review
10 findings-
H-01 High SVM Payload Is Malformed Compatibility Resolved
Description
The
PayloadHeaderon SVM doesn't include theindex#[derive(Debug, Clone)] pub struct PayloadHeader { pub payload_type: u8, pub destination_chain_id: u32, pub destination_peer: [u8; 32], pub message_id: [u8; 32], }Instead, it treats the index as part of the message body. But as it can be seen from the EVM part, the index is encoded in the header.
This will lead to unexpected behavior for crosschain messages.
For example, decoding a token transfer on Solana will load wrong data from the payload, as the index is the first element, not the last.
This will most likely lead to a failure and the user tokens will be stuck on the EVM side. But if the data matches, there exists a possibility of exploiting the malformed payload and minting a large amount of tokens.
Recommendation
Fix the
payload.rsfile to decode the data correctly and update the index for all actions, like you do on the EVM side.Resolution
M0 Team: The issue was resolved in PR#38.
-
M-01 Medium Stuck Orders During Orderbook Upgrades DoS Acknowledged
Description
As described in PR#25, the plan of action for
OrderBookupgrades is to pause all the orderbooks, wait for inflight messages to be processed and execute the upgrade. Pausing the contracts ensures no new crosschain messages will be sent.However,
_cancel()andfillOrder()both implement thewhenNotPausedmodifier. This means existing orders can be neither filled nor cancelled, even if they expire.After the upgrade, the order cannot be filled and if a major change in the cancelation logic has been made, the order may become stuck forever.
The same is true for the SVM part - there the cancel instructions are paused.
Recommendation
Remove the
whenNotPausedmodifier from_cancel()and the cancel instructions. After the orderbook is paused, give a grace period to users to cancel their orders. Execute the upgrade after this period.Resolution
M0 Team: Acknowledged.
-
M-02 Medium Delivery Of Signed Cancellations Can Be Griefed Gaming Resolved
Description
The
OrderBookallows users to sign cancel messages for their orders that can be executed by anyone providing the given signature. Besides the domain separator, the digest consists only of theorderIdto be cancelled. This allows execution ofcancelOrderFor()with arbitrarybridgeAdapter_andbridgeAdapterArgs_.This enables griefing of the delivery of all signed requests once the wormhole adapter is enabled in the Portal. Since both of the above parameters are arbitrary, all cancellations can be routed through Wormhole. In
WormholeBridgeAdapter, thebridgeAdapterArgs_are used assignedQuote.Therefore, the user calling
cancelOrderFormust pay only gas for execution on the current chain, as well ascoreBridgeFeeto thecoreBridge, but as it can be seen on Etherscan, this fee is currently disabled.Any user can direct cancellations to Wormhole and send no msg.value, choose themselves as quoter or both. In result, the message will be successfully recorded and the order will be marked as cancelled, but it won't be automatically delivered to the source chain and the order will be stuck until someone executes it.
Recommendation
Partial validation where only the bridge adapter is signed by the user may be helpful.
Full validation is more complex and will likely cause increase in the price of the transaction, but it would look like this:
- if chosen adapter is hyperlane, no further validation is needed
- otherwise, validate
bridgeAdapterParamswith the following steps:
- Make sure the user signed the exact same
signedQuote - Fetch the
gasLimitfrom the portal - Use the data in the
signedQuoteto calculate how much msg.value has to be sent.
Resolution
M0 Team: The issue was resolved in PR#44.
-
M-03 Medium Isolated Spokes Can Receive Tokens From SVM Validation Resolved
Description
Additional check was added in response to H-03. Each spoke tracks the isolated flag of every other spoke. The check ensures a spoke cannot send tokens to an isolated spoke.
However, this design is not present on the SVM side. SVM spokes can send tokens to any supported chain. Isolated spokes are protected by additional check in
handle_token_transfer_payloadthat reverts if the transfer doesn't come from the Hub.Because this check is not present on the EVM side, once an SVM becomes a non-isolated spoke, the limits of any other EVM isolated spoke can be exploited. For example:
- Users have bridged 10 000 tokens from Hub to the isolated OP portal.
- A malicious user bridges 10 000 tokens in total from Hub -> SVM -> OP
- This decreases the bridged principal of OP in the
HubPortalto 0. - M0 has to make OP non-isolated in order to allow users to bridge their tokens back
This attack can be repeated for any EVM spoke, nullifying the isolated spokes feature.
Recommendation
Revert in
_receiveToken()on the EVM side if the current spoke is isolated.If you implement this, you can get away without having each spoke tracking the state of the other spokes, which will also reduce complexity, because if a spoke status changes, you will have to update only two chains, not all of them.
One downside of this approach is that accidental transfers to an isolated spoke will be stuck until the spoke is toggled to a non-isolated.
Resolution
M0 Team: The issue was resolved in PR#48.
-
M-04 Medium Cancel Race Condition Reintroduced On SVM Validation Resolved
Description
The cancel/fill race condition described in M-03 was fixed by encoding the amount to be refunded in the
CancelReportand allowing orders to be filled even if their status is Cancelled to let fill reports arriving later to be executed.A new
instruction - close_order_token_account.rs-was introduced to the orderbook program. It's permissionless and it closes theorder_token_in_ataby sweeping any leftover tokens to theorder.senderand refundingorder.payerbecause he initialized the account.This instruction can be executed if the order is either Completed or Cancelled. A malicious user can execute the same steps as in M-03 and immediately close the account once the cancel report arrives. They will receive the full amount back + what the solver gave them on the destination chain, while the expected fill report will later fail.
Recommendation
You can encode the
amountOut - amountOutFilledto theCancelReport. Then inclose_accountyou can infer if an order is really cancelled not by its status, but iffilled_amount_out +remaining_out_when_cancelled == order.amount_out.Resolution
M0 Team: The issue was resolved in PR#51.
-
L-01 Low Lack Of Address(0) Check For Default Adapter Validation Resolved
Description
Portal.setDefaultBridgeAdapter()doesn't check if the passedbridgeAdapterisaddress(0). Because of this, it's technically possible to setaddress(0)as a default bridge adapter.The problem is that
setSupportedBridgeAdapter()reverts if thebridgeAdapterto be modified isaddress(0). In result, once set as a default adapter,address(0)will always stay supported.Recommendation
Add the
address(0)check insetDefaultBridgeAdapter()as well.Resolution
M0 Team: The issue was resolved in PR#50.
-
L-02 Low Unscaled Amount Emitted In TokenSent Events Resolved
Description
The principal
m_amountis now scaled to normal token units when the payload forsend_token.rsis constructed.let scaled_m_amount = common::principal_to_amount_down( m_amount, common::get_scaled_ui_config(&ctx.accounts.m_mint.to_account_info())? .multiplier .into(), );However, the
TokenSentevent is emitted with the principal value.emit!(TokenSent { ... amount: m_amount as u128, ... });On the EVM side, this event is emitted with the actual value transferred.
Recommendation
Use
scaled_m_amountfor the event emission.Resolution
M0 Team: The issue was resolved in PR#39.
-
I-01 Informational Index Can Be Updated Before Calling Orderbook Best Practices Resolved
Description
Portal._receiveFillReport()andPortal_receiveCancelReport()first call theOrderBookand then update the M index. If the orderbook works with M tokens, wrong amounts may be transferred.Recommendation
It's better to first update the index and then call the orderbook.
Resolution
M0 Team: The issue was resolved in PR#51.
-
I-02 Informational Incomplete NatSpec Best Practices Resolved
Description
The
NatSpecofOrderBook.portaldescribes how the portal is responsible for sending fill reports, but doesn't mention that it also handles cancel reports./// @dev sends crosschain messages to report fills on this chain to other chains /// receive crosschain messages to report fills on other chains to this chainRecommendation
Include information about cancel reports as well.
Resolution
M0 Team: The issue was resolved in PR#c4fd6833a82d509e5bf1b8096fbaa44c50e564de.
-
I-03 Informational A Note About FOT Tokens Informational Acknowledged
Description
When cancel or fill is received, exact transfers are not used and the following comment says it's to not DOS bridges. This implies there can be tokens with fee on transfer feature that is being turned on/off. If the system works with these tokens, it may lead to unexpected behaviors, like incorrect event emissions, etc…
// We do not check exact amount received here to avoid DoS refund / bridge message if amount_in_to_refund > 0 { transfer_tokens_from_program( &ctx.accounts.order_token_in_ata, &ctx.accounts.sender_token_in_ata, amount_in_to_refund, &ctx.accounts.token_in_mint, &ctx.accounts.order.to_account_info(), &[&[ ORDER_SEED_PREFIX, &cancel_report.order_id, &[ctx.accounts.order.bump], ]], &ctx.accounts.token_in_program, )?; } else { return err!(OrderBookError::OrderFilled); }Recommendation
Be aware of these quirks of FOT on/off tokens.
Resolution
M0 Team: Acknowledged.
No findings match.
More from M0
All 10 reportsPut 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.
