Guardian's review of Migration for Nunchi, published December 2025. The report records 20 findings, including 5 high and 5 medium.
- Published
- Review window
- November 24 to 27, 2025
- Language
- Solidity
- Chains
- Hyperliquid
- Sector
- Yield and vaults, Perpetuals
- 0 Critical
- 5 High
- 5 Medium
- 6 Low
- 4 Informational
Scope
6 files in scope · 744 nSLOC
| File | nSLOC | Lines |
|---|---|---|
src/migration/hub/GenesisVaultComposer.sol | 97 | 198 |
src/migration/hub/NLPClaim.sol | 256 | 442 |
src/migration/hub/VaultComposerSync.sol | 165 | 349 |
src/migration/source/VaultMigrator.sol | 183 | 358 |
src/migration/shared/libraries/MigrationErrors.sol | 25 | 151 |
src/migration/shared/libraries/MigrationTypes.sol | 18 | 49 |
Findings 20
-
H-01 High Unauthenticated Migration Processing Validation Resolved
Description
Locally, the public
depositAndSendonly checks thatcomposeMsgis non-empty; any EOA on the hub can call it with arbitraryMigrationData, mark a (vaultId, sourceEid) as processed, and register an allocation inNLPClaim, blocking the real migration.Cross-chain,
lzComposeonly verifies the sender isASSET_OFT/SHARE_OFT; it never validatescomposeFrom/srcEidagainst a trusted migrator allowlist, so any source-chain user can craft an OFT send with fakeMigrationDataand achieve the same result.This will result in a cheap, irreversible DoS/poisoning of migrations; attacker-controlled totalShares/nlpAmount skew claims
Recommendation
Restrict
depositAndSendto trusted LayerZero compose flows; add (srcEid => trusted migrator) checks oncomposeFrom/srcEid; validateMigrationDataagainst factory state or signatures before registering allocations. -
H-02 High Users cannot claim assets after a migration Unexpected Behavior Resolved
Description
Before claiming,
NLPClaimuseslzReadto fetch the share balance of the user on the source chain. When the state is read and the result is confirmed by the DVN,NLPClaim._lzReceive()will be executed.There, the following check is performed:
if (origin.srcEid != request.sourceEid) { revert MigrationErrors.ChainMismatch(request.sourceEid, origin.srcEid); }For
lzReadtheorigin.srcEidis equal to theREAD_CHANNEL. The app is set up in a way that the peer forREAD_CHANNELis the app itself.function setReadChannel(uint32 _channelId, bool _active) public virtual onlyOwner { _setPeer(_channelId, _active ? AddressCast.toBytes32(address(this)) : bytes32(0)); }In contrast,
request.sourceEidwill be equal to the chain the state is read from.// Build and send read request bytes memory cmd = _buildReadCommand(vaultId, user, sourceEid); bytes memory options = this.combineOptions(READ_CHANNEL, CLAIM_MSG_TYPE, hex""); MessagingReceipt memory receipt = _lzSend( READ_CHANNEL, cmd, options, MessagingFee(msg.value, 0), payable(msg.sender) ); // Store request data for response routing claimRequests[receipt.guid] = ClaimRequest({ user: user, vaultId: vaultId, sourceEid: sourceEid });Because of this,
_lzReceivewill always revert and users won't be able to claim their assets. The tokens will have to be recovered by callingNLPClaim.recoverTokens()and distributing them manually after that.POC
A contract using
lzReadwas deployed on Arbitrum Sepolia. The contract emits the following event in its_lzReceive()function.emit OriginSrcEidReceived(origin.srcEid);The contract reads
WETH.decimals()from Ethereum Sepolia. The LZ message is successfully delivered. Exploring the transaction, it can be observed that the event emits the value4294967295, which is the read channel id.Recommendation
Compare
origin.srcEidagainstREAD_CHANNEL, notrequest.sourceEid.- if (origin.srcEid != request.sourceEid) { + if (origin.srcEid != READ_CHANNEL) { - revert MigrationErrors.ChainMismatch(request.sourceEid, origin.srcEid); + revert MigrationErrors.ChainMismatch(READ_CHANNEL, origin.srcEid); } -
H-03 High Insufficient swap slippage MEV Resolved
Description
When a migration is executed through the
VaultMigrator, all of the assets held by the vault are swapped to the desired stablecoin through curve.uint256 expectedOut = curvePool.get_dy(fromIndex, toIndex, amountIn); uint256 minAmountOut = (expectedOut * (10000 - maxSlippageBps)) / 10000; // Approve Curve pool to spend input asset IERC20(assetIn).forceApprove(address(curvePool), amountIn); // Execute swap amountOut = curvePool.exchange(fromIndex, toIndex, amountIn, minAmountOut);The
maxSlippageBpsvariable is applied to the result ofget_dy()to determine the minimum amount of assets the contract is willing to accept. However,get_dy()returns a result based on the current chain state, so it will return the exact amount that will be received. Applying a discount to this value will always result in a smaller value than the tokens received, nullifying the slippage parameter. This means the swap is susceptible to being sandwiched in order to profit at the expense of the vault share holders.Recommendation
Instead of applying the slippage to the result of
get_dy(), consider having an exact value forminAmountOutpassed when migrating. -
H-04 High OFT Decimal Mismatch Causes Slippage Reverts Logical Error Resolved
Description
In
VaultMigrator._sendOFTthe migration usesamountLDandminAmountLDequal to the raw asset amount with no slippage buffer.If the target stable’s local decimals exceed the OFT’s shared decimals (e.g., an 18‑dec token like USD.e wrapped with an OFT set to 6 shared decimals),
OFTCorewill truncate “dust” when converting to shared units. The resulting amount credited on the destination is slightly less thanamountLD, causing the send to revert againstminAmountLDwith errorSlippageExceeded.Therefore, any migrated value that is not a multiple of
10**(token decimals-shared decimals)can thus stall migrations.Similarly, when claiming NLP, the cross chain flow uses the same value for
amountLDandminAmountLD. If theSHARE_ERC20decimals is greater than theSHARE_OFTshared decimals (6 by default), the transfer will revert due to slippage.Recommendation
Before sending, account for shared/local decimal differences: set
minAmountLDto 0 or to the OFT’samountReceivedLDfromquoteSend. Add an explicit check oftoken.decimals()vsIOFT.sharedDecimals()and adjust slippage accordingly.Keep in mind that this will leave some dust behind due to the truncation.
-
H-05 High SY Vault Users Can't Claim NLP Shares Logical Error Resolved
Description
During claim verification, an
lzReadis initiated to read the vault shares in the target chain. This read flow will call thegetUserSharesForClaimfunction in the configured factory contract for given chain.The current
VaultModule.getUserSharesForClaimwill return the share balance of users that deposited using the factory contract. Therefore, SY Vault users will have zero shares recorded in the factory, as the SY Vault itself is the share's owner.Additionally, the SY Token vault mint ERC20 tokens to users. Even if
NLPClaimcan read theburnedBalancemapping in vault, user will only be able to claim once, as the_hasClaimedflag is set to true after claiming. In case any user receives more SY tokens, they won't be able to burn or claim, so they will need to transfer them to a different address.Recommendation
Consider creating a separate
initiateClaimSYfunction that allows users to claim their shares from the SY Vaults, by reading thevault.getUserSharesForClaimwhich returns the user's burned balance.Additionally, consider pausing SY transfers once the vault is migrated. Alternatively, document this scenario to users so they are aware of NLP claiming for SY holders.
-
M-01 Medium LZ Fee Underestimated Logical Error Resolved
Description
The
quoteMigration()encodescomposeMsgusingMigrationDatapassing it to quoteSend, whileexecuteVaultMigrationsends a much larger payload:abi.encode(SendParam(innerParam with composeMsg=migrationData), uint256(0)).This underquotes the native fee, causing
executeVaultMigrationto revert at the send step if the operator uses the quoted fee.Recommendation
Build the compose payload in quoteMigration exactly as in executeVaultMigration: wrap MigrationData inside an inner SendParam and then ABI-encode (innerParam, minMsgValue) for the outer compose. Then use that exact payload with IOFT.quoteSend
-
M-02 Medium Block confirmations are hardcoded Unexpected Behavior Resolved
Description
NLPClaim._buildReadCommand()hardcodes the block confirmations for thelzReadto 15 for all chains. While this configuration may work for some chains, it can be too low or too high for others.For example, reading from a chain more susceptible to reorgs increases the risk of the
NLPcontract receiving state from a block that was removed.If we take a look at the LayerZeroV1 default configurations for sending messages, we can see popular chains like
OptimismandArbitrumuse 20.In addition, if the oApp configuration changes in the future, it won't be possible to sync the
NLPcontract with the current approach.Recommendation
Add a configurable mapping with block confirmations for each target chain and use that instead of hardcoding the blocks.
-
M-03 Medium Crosschain redeems for vault chains are refunded DoS Acknowledged
Description
There is an
OFTfor the shares token of the vault on the hub chain. These ofts can be redeemed cross chain via thelzComposeflow. In case thathandleCompose()fails, the tokens are refunded back to the sender on the source chain.try this.handleCompose{ value: msg.value }(_composeSender, composeFrom, composeMsg, amount) { emit Sent(_guid); } catch (bytes memory _err) { /// @dev A revert where the msg.value passed is lower than the min expected msg.value is handled separately /// This is because it is possible to re-trigger from the endpoint the compose operation with the right msg.value if (bytes4(_err) == InsufficientMsgValue.selector) { assembly { revert(add(32, _err), mload(_err)) } } _refund(_composeSender, _message, amount, tx.origin); emit Refunded(_guid); }It's expected that
msg.valueis a positive value, otherwise the_refundcall will fail since it callsOFT.send(). Thatmsg.valueis passed asvaluetohandleCompose().Then the flow is
handleCompose() -> _redeemAndSend() -> _send().In the
_send()function there is a check that requiresmsg.valueto be 0 if the desired chain is theVAULT_EID. It's meant to protect users from sending value unexpectedly for same chain redeems, but in the case described, this check will cause the transaction to fail.if (_sendParam.dstEid == VAULT_EID) { /// @dev Can do this because _oft is validated before this function is called address erc20 = _oft == ASSET_OFT ? ASSET_ERC20 : SHARE_ERC20; if (msg.value > 0) revert NoMsgValueExpected(); IERC20(erc20).safeTransfer(_sendParam.to.bytes32ToAddress(), _sendParam.amountLD); }Because of that, crosschain redeems where the destination chain is the hub will be refunded.
Recommendation
You can move the
NoMsgValueExpectedcheck in the publicredeemAndSendanddepositAndSendfunctions or remove theif(msg.value > 0) revert NoMsgValueExpected()check and adding appropriate warnings to the frontend so users don't send funds when they don't have to. -
M-04 Medium Crosschain redeems can be griefed DoS Resolved
Description
When
SHARE_OFTis sent from a source chain to the hub chain,lzComposewill try callinghandleCompose()and if it fails, the share tokens will be refunded back on the source chain.try this.handleCompose{ value: msg.value }(_composeSender, composeFrom, composeMsg, amount) { emit Sent(_guid); } catch (bytes memory _err) { /// @dev A revert where the msg.value passed is lower than the min expected msg.value is handled separately /// This is because it is possible to re-trigger from the endpoint the compose operation with the right msg.value if (bytes4(_err) == InsufficientMsgValue.selector) { assembly { revert(add(32, _err), mload(_err)) } } _refund(_composeSender, _message, amount, tx.origin); emit Refunded(_guid); }Inside
_handleCompose(), the_redeemAndSend()function will refund any surplusmsg.valuetotx.origin. This can be weaponized to grief the redeem:- A user delegates code to their account with a type 4 transaction
- The user hijacks the call to
lzCompose() - The users sends more value than needed in order to trigger the refund
- The user reverts the first refund
- The
catchblock will be entered and the share asset will be sent back to the original owner on the source chain.
In result the malicious user successfully griefed the redemption of the share assets at the expense of the send fee.
Recommendation
Consider refunding to
address(this)instead oftx.origininhandleCompose(). AfterhandleCompose()finishes you can measure the change in the balance of the contract and if it's less thanmsg.value, refund the difference totx.origin. -
M-05 Medium Withdrawal Delay Bypassed With Prefunded Address Logical Error Resolved
Description
Cooldown is copied to recipients only when
balanceOf(to) == 0. An attacker can pre-seed an address with a dust balance older than the delay, then transfer a large amount to that address; the recipient’slastDepositTimeis not updated, letting them redeem immediately and bypass the intended withdrawal delay.Recommendation
On every transfer propagate the max of sender/recipient cooldown (or use a balance-weighted accumulator), not just for zero-balance recipients, to prevent cooldown reset/bypass via pre-seeded addresses.
-
L-01 Low Native LayerZero Fee Refunds Can Become Stuck Logical Error Resolved
Description
The
_sendOFTfunction hardcodes the refund address toaddress(this)and the contract exposes no method to withdraw native currency. Any overpayment ofmsg.value, LayerZero refund, or direct ETH transfers become trapped, leaving migration operators unable to recover funds.Recommendation
Forward refunds to the caller/owner or add a payable withdraw function so native refunds are reclaimable.
-
L-02 Low Compose message options handling Warning Resolved
Description
When a compose message is received by
GenesisVaultComposer, themsg.valuesent should be positive if the end chain is different than the hub and 0 otherwise.If the OFT has enforced options for all composed messages to require
msg.valuesent, users would end up overpaying for the cases they don't need to send native tokens.If there aren't enforced options, users should carefully pass their own extra options, increasing the risk of failures.
Recommendation
Consider how should compose options be handled. If you decide to stick with enforced options, a refund mechanism for the unused native tokens may be helpful.
-
L-03 Low Some accounts may lose access to migrated tokens Unexpected Behavior Acknowledged
Description
During migration, all of the assets of the vaults are converted to a desired stablecoin and sent to the hub chain. Then each user can claim part of the shares of the vault on the hub chain. The amount they are eligible to claim depends on how much shares are they holding from the migrated vault. While this approach works for EOAs, it may fail for contracts. For example, a contract on chain A may not have the same address on chain B. This can happen for different reasons, one of which is the contract being deployed with the CREATE opcode after which the deployer's nonce is used to deploy another contract. In this scenario, the tokens of these contracts will be lost.
This can be mitigated if all of the contracts that don't have access to their address on the hub chain redeem their shares before the migration happens. This is possible because when a migration is initiated in
MigrationModule, only deposits are disabled, but withdrawals can still happen until the delay of minimum one day passes and the migration is started. However, accounts holding less value than the minimum withdrawable amount will not be able to get their funds out because of the following check inVaultModuleif (assets < vault.minWithdraw) { revert Errors.BelowMinimumWithdraw(vault.minWithdraw, assets); }Furthermore, if the same address is controlled by different entities on the two chains, one of them may claim the assets of the other.
Recommendation
Before you initiate a migration, make it clear that such risks for contracts exists and set a long enough migration delay so users are able to successfully withdraw. Also consider skipping the
minWithdrawcheck if the migration has already been initialized. -
L-04 Low Migration fails if
_depositAndSend()reverts Warning ResolvedDescription
During migrations,
VaultMigratorsends tokens to the hub chain through the OFT and marks the vault as migratedmigrated[vaultId]. Any further attempt to migrate the same vault will revert because it has already been migrated.On the hub chain, the OFTs will be received and
handleCompose()will be executed. IfhandleCompose()reverts, tokens will be refunded back to the source chain. This can happen for different reasons, one of which is a failure to deposit assets to the vault. In this case, the assets would be returned to theVaultMigrator, but the vault they correspond to will not be eligible for migration.Recommendation
Consider if the refund functionality is desired for migrations.
-
L-05 Low Unnecessary Approval For Native OFT Tokens Logical Error Resolved
Description
The
VaultMigratorwill approve the stable asset to be spent by the OFT adapter. This will allow theOFT.sendto pull assets from the migrator contract.However, not all stable tokens use the
OFTAdapterflow. For example, USD.e is anOFTtoken, which meanssendwill actually burn tokens from the caller, and does not usetransferFrom.Therefore, the approval for certain tokens is unnecessary, and allowance will never be used.
Recommendation
Consider calling
OFT.approvalRequiredto determine if aforceApproveis needed for given token. -
L-06 Low Missing Validation For
msg.valueValidation ResolvedDescription
Some functions are marked as
payableas it may involve a LZ fee to be paid in native currency.The
NLPClaim.claimfunction does not require anymsg.valueifdestEid == VAULT_EID. However, it does not enforce zero value sent, no refund at the end, and no ownable function to recover native currency in the contract.Recommendation
Consider adding the same validation as in composer when
destEid == VAULT_EID:if (msg.value > 0) revert NoMsgValueExpected(); -
I-01 Informational Missing Event Emission For Critical State Change Events Resolved
Description
The
setVaultFactoryAddressfunction updates critical configuration but does not emit an event, making it harder to track configuration changes off-chain.Recommendation
Add an event
VaultFactoryAddressSet(uint32 indexed eid, address vaultFactoryAddress)and emit it when the factory address is updated. -
I-02 Informational Unnecessary Params In Verification Quote Logical Error Resolved
Description
The
quoteVerificationusesoptionsto calculate the quote, but the actualinitiateClaimflow has empty options.Additionally, this function also uses
msg.senderas the user address, which is not ideal in view functions.Recommendation
Consider removing the options param, and use empty bytes, just like in the
initiateClaimflow, and instead add anaddressparam for the user. -
I-03 Informational Claim can be initiated for
address(0)Validation ResolvedDescription
NLPClaim.initiateClaim()can be executed withuser == address(0). This will result in a message that always fails in the_lzReceivecheck due to the following constraintif (request.user == address(0)) { revert MigrationErrors.InvalidGuid(guid); }Recommendation
Consider reverting if
initiateClaimis called foraddress(0) -
I-04 Informational State Variable Can Be Marked As Immutable Gas Optimization Resolved
Description
The
VaultMigrator.destEidstate variable is only set during deployment, just like thesourceEidandgenesisFactory. However,destEidis not marked as immutable.Recommendation
Set
destEidas an immutable param inVaultMigrator.
No findings match.
More from Nunchi
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.
