OVault EVM Implementation
Create an omnichain ERC-4626 vault that enable users to deposit assets or redeem shares on any blockchain network through a single transaction.
Prerequisites
Before implementing OVault, you should understand:
- OFT Standard: How Omnichain Fungible Tokens work and what the typical deployment looks like
- Composer Pattern: Understanding of
composeMsg
encoding and cross-chain message workflows - ERC-4626 Vaults: How the tokenized vault standard interface works for
deposit
/redeem
operations
An Omnichain Vault (OVault) takes a new or existing ERC4626 vault, and connects the underlying asset or share to many blockchain networks using the Omnichain Fungible Token (OFT) standard.
An OVault implementation requires five core contracts to be deployed:
- an
OFT
asset - an
ERC4626
vault - an
OFTAdapter
to transform the vault's share into an omnichain token - a
VaultComposerSync
to orchestrate omnichain deposits and redemptions between the asset and share - an
OFT
to represent the shares on spoke chains
You can review the implementation of these contracts under Contracts Overview.
Step 1: Project Installation
To start using LayerZero OVault contracts in a new project, use the LayerZero CLI tool, create-lz-oapp. The CLI tool allows developers to create any omnichain application in <4 minutes! Get started by running the following from your command line:
LZ_ENABLE_OVAULT_EXAMPLE=1 npx create-lz-oapp@latest --example ovault-evm
After running, select the directory for the scaffold project to be cloned into:
➜ ~ LZ_ENABLE_OVAULT_EXAMPLE=1 npx create-lz-oapp@latest --example ovault-evm
╭─────────────────────────────────────────╮
│ ▓▓▓ LayerZero DevTools ▓▓▓ │
│ ═══════════════════════════════════ │
│ /*\ │
│ /* *\ BUILD ANYTHING │
│ ('v') │
│ //-=-\\ ▶ OMNICHAIN │
│ (\_=_/) │
│ ^^ ^^ │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
╰─────────────────────────────────────────╯
✔ Where do you want to start your project? … ./<YOUR_DIRECTORY>
? Which example would you like to use as a starting point? › - Use arrow-keys. Return to submit.
❯ OVault EVM
OApp
OFT
OFTAdapter
ONFT721
After the installer completes, copy and paste the .env.example
in the project root, add your PRIVATE_KEY
, RPC_URL
you will be working with, and rename the file to .env
:
cp .env.example .env
You can find the sample codebase in devtools/examples/ovault-evm.
Step 2. Network Configuration
Update hardhat.config.ts
to include your desired networks. Modify your .env
file or the URL directly to change network RPCs:
const config: HardhatUserConfig = {
networks: {
base: {
eid: EndpointId.BASESEP_V2_TESTNET,
url: process.env.RPC_URL_BASESEP_TESTNET || 'https://base-sepolia.gateway.tenderly.co',
accounts,
},
arbitrum: {
eid: EndpointId.ARBSEP_V2_TESTNET,
url: process.env.RPC_URL_ARBSEP_TESTNET || 'https://arbitrum-sepolia.gateway.tenderly.co',
accounts,
},
optimism: {
eid: EndpointId.OPTSEP_V2_TESTNET,
url: process.env.RPC_URL_OPTSEP_TESTNET || 'https://optimism-sepolia.gateway.tenderly.co',
accounts,
},
},
// ... rest of config
};
Step 3. Deployment Configuration
Configure your vault deployment in devtools/deployConfig.ts
. This file controls which contracts to deploy and to what chains.
The deployConfig
supports several modes, depending on what contracts already have been deployed on the hub chain. If your asset
token is already an OFT
, you do not need to deploy a new OFT
contract mesh. Both Stargate Hydra assets (e.g., USDC.e
) and standard OFTs (e.g., USDT0
) can be used as the asset inside the ERC4626
vault:
- 3.1a Existing AssetOFT
- 3.1b Existing AssetOFT and Vault
- 3.1c Existing AssetOFT, Vault, and ShareOFTAdapter
For a completely fresh deployment of the AssetOFT, OVault, and ShareOFT:
3.1a Existing AssetOFT
The only requirement is that your assetOFT
is deployed on the hub
chain defined in the deployConfig
file.
-
Update the
_hubEid
and the_spokeEids
for the networks you plan to deploy to accordingly. -
Add the
assetOFTAddress
contract for the_hubEid
network under yourvault
config. -
Add any changes necessary to your
Vault
andShareOFT
configcontract
ormetadata
.
// Hub network where ERC4626 lives
const _hubEid = EndpointId.ARBSEP_V2_TESTNET;
// Spoke networks where ShareOFT lives (excluding hub)
const _spokeEids = [EndpointId.OPTSEP_V2_TESTNET, EndpointId.BASESEP_V2_TESTNET];
// ============================================
// Deployment Export
// ============================================
// devtools/deployConfig.ts
export const DEPLOYMENT_CONFIG: DeploymentConfig = {
vault: {
contracts: {
vault: 'MyERC4626',
shareAdapter: 'MyShareOFTAdapter',
composer: 'MyOVaultComposer',
},
deploymentEid: _hubEid,
vaultAddress: undefined, // Existing ERC4626 vault
assetOFTAddress: '<YOUR_ASSET_OFT_ADDRESS>', // Existing AssetOFT
shareOFTAdapterAddress: undefined, // Deploy ShareOFTAdapter
},
// Share OFT configuration (only on spoke chains)
shareOFT: {
contract: 'MyShareOFT',
metadata: {
name: 'MyShareOFT',
symbol: 'SHARE',
},
deploymentEids: _spokeEids,
},
// Asset OFT configuration (deployed on specified chains)
assetOFT: {
contract: 'MyAssetOFT',
metadata: {
name: 'MyAssetOFT',
symbol: 'ASSET',
},
deploymentEids: [_hubEid, ..._spokeEids],
},
} as const;
assetOFT
and shareOFT
networks do not need to perfectly overlap, as long as both contain deployments on the _hubEid
. Configure based on your deployment requirements.
3.1b Existing AssetOFT and Vault
If your assetOFT
and ERC4626
contracts are already deployed, you only need to deploy the ShareOFTAdapter
and Composer
.
-
Update the
_hubEid
and the_spokeEids
for the networks you plan to deploy to accordingly. -
Add the
vaultAddress
andassetOFTAddress
for the_hubEid
network under yourvault
config. -
Add any changes necessary to your
ShareOFT
configcontract
ormetadata
.
// devtools/deployConfig.ts
// Hub network where ERC4626 lives
const _hubEid = EndpointId.ARBSEP_V2_TESTNET;
// Spoke networks where ShareOFT lives (excluding hub)
const _spokeEids = [EndpointId.OPTSEP_V2_TESTNET, EndpointId.BASESEP_V2_TESTNET];
// ============================================
// Deployment Export
// ============================================
export const DEPLOYMENT_CONFIG: DeploymentConfig = {
vault: {
contracts: {
vault: 'MyERC4626',
shareAdapter: 'MyShareOFTAdapter',
composer: 'MyOVaultComposer',
},
deploymentEid: _hubEid,
vaultAddress: '<YOUR_VAULT_ADDRESS>', // Existing ERC4626 vault
assetOFTAddress: '<YOUR_ASSET_OFT_ADDRESS>', // Existing AssetOFT token
shareOFTAdapterAddress: undefined, // Deploy ShareOFTAdapter
},
// Share OFT configuration (only on spoke chains)
shareOFT: {
contract: 'MyShareOFT',
metadata: {
name: 'MyShareOFT',
symbol: 'SHARE',
},
deploymentEids: _spokeEids,
},
// Asset OFT configuration (deployed on specified chains)
assetOFT: {
contract: 'MyAssetOFT',
metadata: {
name: 'MyAssetOFT',
symbol: 'ASSET',
},
deploymentEids: [_hubEid, ..._spokeEids],
},
} as const;
This configuration will skip deploying the AssetOFT
and ERC4626 Vault
contracts, deploying only the ShareOFTAdapter
and Composer
.
3.1c Existing AssetOFT, Vault, and ShareOFT
If your assetOFT
, ERC4626
, and ShareOFTAdapter
have already been deployed, you only need to deploy the Composer
.
-
Update the
_hubEid
and the_spokeEids
for the networks you plan to deploy to accordingly. -
Add the
vaultAddress
,assetOFTAddress
, andshareOFTAdapterAddress
for the_hubEid
network under yourvault
config. -
Add any changes necessary to your
composer
config undervault
.
The Vault
, ShareOFT
, and AssetOFT
configs and deployments will be skipped.
// devtools/deployConfig.ts
// Hub network where ERC4626 lives
const _hubEid = EndpointId.ARBSEP_V2_TESTNET;
// Spoke networks where ShareOFT lives (excluding hub)
const _spokeEids = [EndpointId.OPTSEP_V2_TESTNET, EndpointId.BASESEP_V2_TESTNET];
// ============================================
// Deployment Export
// ============================================
export const DEPLOYMENT_CONFIG: DeploymentConfig = {
vault: {
contracts: {
vault: 'MyERC4626',
shareAdapter: 'MyShareOFTAdapter',
composer: 'MyOVaultComposer',
},
deploymentEid: _hubEid,
vaultAddress: '<YOUR_VAULT_ADDRESS>', // Existing ERC4626 vault
assetOFTAddress: '<YOUR_ASSET_OFT_ADDRESS>', // Existing AssetOFT
shareOFTAdapterAddress: <'YOUR_SHARE_OFT_ADAPTER_ADDRESS'>, // Existing ShareOFTAdapter
},
// Share OFT configuration (only on spoke chains)
shareOFT: {
contract: 'MyShareOFT',
metadata: {
name: 'MyShareOFT',
symbol: 'SHARE',
},
deploymentEids: _spokeEids,
},
// Asset OFT configuration (deployed on specified chains OR use existing address)
assetOFT: {
contract: 'MyAssetOFT',
metadata: {
name: 'MyAssetOFT',
symbol: 'ASSET',
},
deploymentEids: [_hubEid, ..._spokeEids],
},
} as const;
This configuration will skip deployment of the AssetOFT
, ERC4626
vault, and ShareOFT
contracts. Only the Composer
will be deployed.
3.1d New AssetOFT, Vault, and ShareOFT
If you have no existing OVault contracts, this configuration will deploy AssetOFT
, ERC4626
vault, ShareOFTAdapter
, and the Composer
.
const _hubEid = EndpointId.ARBSEP_V2_TESTNET;
const _spokeEids = [EndpointId.OPTSEP_V2_TESTNET, EndpointId.BASESEP_V2_TESTNET];
// ============================================
// Deployment Export
// ============================================
export const DEPLOYMENT_CONFIG: DeploymentConfig = {
vault: {
contracts: {
vault: 'MyERC4626',
shareAdapter: 'MyShareOFTAdapter',
composer: 'MyOVaultComposer',
},
deploymentEid: _hubEid,
vaultAddress: undefined,
assetOFTAddress: undefined,
shareOFTAdapterAddress: undefined,
},
// Share OFT configuration (only on spoke chains)
ShareOFT: {
contract: 'MyShareOFT',
metadata: {
name: 'MyShareOFT',
symbol: 'SHARE',
},
deploymentEids: _spokeEids,
},
// Asset OFT configuration (deployed on specified chains)
AssetOFT: {
contract: 'MyAssetOFT',
metadata: {
name: 'MyAssetOFT',
symbol: 'ASSET',
},
deploymentEids: [_hubEid, ..._spokeEids],
},
} as const;
This configuration will deploy all core OVault contracts for a full fresh setup.
3.2 Build
Compile your contracts:
pnpm compile
If you're deploying the asset OFT from scratch for testing purposes, you'll need to mint an initial supply. Uncomment the _mint
line in the MyAssetOFT
constructor to provide initial liquidity. This ensures you have tokens to test deposit and cross-chain transfer functionality.
Do NOT mint share tokens directly in MyShareOFT
. Share tokens must only be minted by the vault contract during deposits to maintain the correct share-to-asset ratio. Manually minting share tokens breaks the vault's accounting and can lead to incorrect redemption values. The mint line in MyShareOFT
should only be uncommented for UI/integration testing, never in production.
3.3 Deploy
Deploy all vault contracts across all configured chains:
pnpm hardhat lz:deploy --tags ovault
Based on your deployConfig.ts
, this single command will begin deploying the defined contracts on your target _hubEid
and _spokeEids
.
The deployment scripts automatically skip existing deployments, so you can safely run this command when expanding to new chains. Simply add the new chain endpoints to your deployConfig.ts
and run the deploy command again.
Tip: To deploy to specific networks only, use the
--networks
flag:pnpm hardhat lz:deploy --tags ovault --networks arbitrum,optimism
Step 4. Wiring New Mesh
This establishes the peer relationships between each OFT deployment, enabling cross-chain token transfers. See the OFT Wiring Step for more information.
Depending on your deployment configuration in Step 3, you will have to wire either your newly deployed ShareOFT
, AssetOFT
, or both.
4.1 Existing Asset
After modifying your layerzero.share.config.ts
:
pnpm hardhat lz:oapp:wire --oapp-config layerzero.share.config.ts
4.2 Existing Asset & Share
No action needed.
4.3 New Asset & Share
After modifying your layerzero.asset.config.ts
and layerzero.share.config.ts
:
# Configure LayerZero connections
pnpm hardhat lz:oapp:wire --oapp-config layerzero.asset.config.ts
pnpm hardhat lz:oapp:wire --oapp-config layerzero.share.config.ts
Step 5: Usage
OVault enables two main operation patterns: deposits and redemptions. Each uses the standard OFT.send()
interface with the composer
handling vault operations automatically.
The provided project scaffold demonstrates how to create send calls in devtools/examples/ovault-evm/tasks/sendOVaultComposer.ts.
Deposit Assets → Receive Shares
Scenario: Deposit asset
from a _spokeEid
, receive vault shares
on the same _spokeEid
# Using the CLI task (recommended)
npx hardhat lz:ovault:send \
--src-eid 30110 --dst-eid 30110 \
--amount 100.0 --to 0xRecipient \
--token-type asset
Flow:
Scenario: Deposit asset
from a _spokeEid
, receive vault shares
on a different _spokeEid
# Using the CLI task (recommended)
npx hardhat lz:ovault:send \
--src-eid 30110 --dst-eid 30111 \
--amount 100.0 --to 0xRecipient \
--token-type asset
Flow:
Scenario: Deposit asset
from _spokeEid
, receive vault shares
on the _hubEid
chain
npx hardhat lz:ovault:send \
--src-eid 30110 --dst-eid 30184 \
--amount 100.0 --to 0xRecipient \
--token-type asset
Flow:
Redeem Shares → Receive Assets
Scenario: Redeem vault shares
from _spokeEid
, receive asset
on different _spokeEid
npx hardhat lz:ovault:send \
--src-eid 30111 --dst-eid 30110 \
--amount 50.0 --to 0xRecipient \
--token-type share
Flow:
Scenario: Redeem vault
shares from _spokeEid
, receive asset
on the _hubEid
chain
npx hardhat lz:ovault:send \
--src-eid 30111 --dst-eid 30184 \
--amount 50.0 --to 0xRecipient \
--token-type share
Flow:
SDK Integration
For programmatic integration, use the official SDK @layerzerolabs/ovault-evm/src
which simplifies OVault operations by using viem to generate the necessary calldata for calling OFT.send()
with the proper composeMsg
for the hub composer.
The SDK's OVaultMessageBuilder.generateOVaultInputs()
method handles all the complex message encoding and returns ready-to-use transaction parameters for viem
wallet clients.
const input = {
srcEid: 40245, // eid for base-sepolia
hubEid: 40231, // eid for arbitrum-sepolia
dstEid: 40245, // eid for base-sepolia
// Optional. If dstAddress is not specified it will default to the walletAddress on the dst chain
dstAddress: '0x0000000000000000000000000000000000000000',
walletAddress: '0x0000000000000000000000000000000000000000',
vaultAddress: '0x0000000000000000000000000000000000000000',
// Address of the OVault Composer on the Hub Chain. Should implement IVaultComposerSync
composerAddress: '0x0000000000000000000000000000000000000000',
// Supply the Viem Chain Definitions for the hub and source chain. This is so the sdk can
// quote fees and perform read operations
hubChain: arbitrumSepolia,
sourceChain: baseSepolia,
operation: OVaultOperations.DEPOSIT,
amount: 100000000000000000n,
slippage: 0.01, // 1% slippage
// Address of the token/oft. The token is an ERC20. They can be the same address.
// If tokenAddress isn't specified it defaults to the oftAddress
tokenAddress: '0x0000000000000000000000000000000000000000',
oftAddress: '0x0000000000000000000000000000000000000000',
} as const;
const inputs = await OVaultMessageBuilder.generateOVaultInputs(input);
For complete usage examples, API reference, and advanced configuration, see the SDK repository.
For manual integration and advanced usage, see the Technical Reference section below.
Technical Reference
Contracts Overview
An Omnichain Vault (OVault) takes a new or existing ERC4626 vault, and connects the underlying asset or share to many blockchain networks using the Omnichain Fungible Token (OFT) standard.
An OVault implementation requires five core contracts to be deployed:
- an
OFT
asset - an
ERC4626
vault - an
OFTAdapter
to transform the vault's share into an omnichain token - a
VaultComposerSync
to orchestrate omnichain deposits and redemptions between the asset and share - an
OFT
to represent the shares on spoke chains
OVault uses a hub-and-spoke model:
- Hub Chain: Hosts the
OFT
asset,ERC4626
vault, theVaultComposerSync
, and the share'sOFTAdapter
(lockbox) - Spoke Chains: Host
OFT
assets andOFT
shares that connect to the hub implementations
These connections enable a user to transfer an amount of the asset or share OFT
from a source blockchain, deposit or redeem the token amount in the ERC4626
vault, and receive the corresponding output token amount back on the source network.
If you have an existing assetOFT
, vault
, or ShareOFT
implementation, you may only need to deploy some of the contracts provided in the ovault-evm
example repo:
Asset OFT (All Chains)
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { OFT } from "@layerzerolabs/oft-evm/contracts/OFT.sol";
/**
* @title MyAssetOFT
* @notice ERC20 representation of the vault's asset token on a spoke chain for cross-chain functionality
* @dev This contract represents the vault's underlying asset on spoke chains. It inherits from
* LayerZero's OFT (Omnichain Fungible Token) to enable seamless cross-chain transfers of the
* vault's asset tokens between the hub chain and spoke chains.
*
* The asset OFT acts as a bridgeable ERC20 representation of the vault's collateral asset, allowing
* users to move their assets across supported chains while maintaining fungibility.
*/
contract MyAssetOFT is OFT {
/**
* @notice Constructs the Asset OFT contract
* @dev Initializes the OFT with LayerZero endpoint and sets up ownership
* @param _name The name of the asset token
* @param _symbol The symbol of the asset token
* @param _lzEndpoint The address of the LayerZero endpoint on this chain
* @param _delegate The address that will have owner privileges
*/
constructor(
string memory _name,
string memory _symbol,
address _lzEndpoint,
address _delegate
) OFT(_name, _symbol, _lzEndpoint, _delegate) Ownable(_delegate) {
// NOTE: Uncomment the line below if you need to mint initial supply
// This can be useful for testing or if the asset needs initial liquidity
// _mint(msg.sender, 1 ether);
}
}
MyAssetOFT
is an example of a standard OFT
and ERC20
token that will be the asset
inside the ERC4626
vault.
The asset
token must be deployed on at least the hub
and one spoke
chain.
If your intended vault asset
is already an OFT
(e.g., USDT0, USDe), you do not need to deploy this contract. if your vault asset
is not an OFT
(e.g., USDC via CCTP), you will need to convert the asset
into an OFT
compatible asset (e.g., USDC via Stargate Hydra, OFTAdapter).
See the OFT API /list
endpoint for a detailed list of all known tokens using the OFT standard.
Vault + Share Adapter (Hub Chain)
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.22;
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import { OFTAdapter } from "@layerzerolabs/oft-evm/contracts/OFTAdapter.sol";
/**
* @title MyERC4626
* @notice ERC4626 tokenized vault implementation for cross-chain vault operations
* @dev SECURITY CONSIDERATIONS:
* - Donation/inflation attacks on empty or low-liquidity vaults
* - Share price manipulation via large donations before first deposit
* - Slippage during deposit/redeem operations in low-liquidity conditions
* - First depositor advantage scenarios
*
* See OpenZeppelin ERC4626 documentation for full risk analysis:
* https://docs.openzeppelin.com/contracts/4.x/erc4626#inflation-attack
*
* MITIGATIONS:
* - OpenZeppelin v4.9+ includes virtual assets/shares to mitigate inflation attacks
* - Deployers should consider initial deposits to prevent manipulation
*/
contract MyERC4626 is ERC4626 {
/**
* @notice Creates a new ERC4626 vault
* @dev Initializes the vault with virtual assets/shares protection against inflation attacks
* @param _name The name of the vault token
* @param _symbol The symbol of the vault token
* @param _asset The underlying asset that the vault accepts
*/
constructor(string memory _name, string memory _symbol, IERC20 _asset) ERC20(_name, _symbol) ERC4626(_asset) {}
}
/**
* @title MyShareOFTAdapter
* @notice OFT adapter for vault shares enabling cross-chain transfers
* @dev The share token MUST be an OFT adapter (lockbox).
* @dev A mint-burn adapter would not work since it transforms `ShareERC20::totalSupply()`
*/
contract MyShareOFTAdapter is OFTAdapter {
/**
* @notice Creates a new OFT adapter for vault shares
* @dev Sets up cross-chain token transfer capabilities for vault shares
* @param _token The vault share token to adapt for cross-chain transfers
* @param _lzEndpoint The LayerZero endpoint for this chain
* @param _delegate The account with administrative privileges
*/
constructor(
address _token,
address _lzEndpoint,
address _delegate
) OFTAdapter(_token, _lzEndpoint, _delegate) Ownable(_delegate) {}
}
MyERC4626
is the standard tokenized vault contract. Given an _asset
address for a valid ERC20
contract in the constructor, the vault will create a corresponding share
token using the vanilla ERC4626
implementation.
This share
must then be transformed into an Omnichain Fungible Token using MyShareOFTAdapter
.
If you have an existing ERC4626
vault deployed, you will only need to deploy MyShareOFTAdapter
using the share
token address as the address _token
argument in the constructor.
Composer (Hub Chain)
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.22;
import { VaultComposerSync } from "@layerzerolabs/ovault-evm/contracts/VaultComposerSync.sol";
/**
* @title MyOVaultComposer
* @notice Cross-chain vault composer enabling omnichain vault operations via LayerZero
*/
contract MyOVaultComposer is VaultComposerSync {
/**
* @notice Creates a new cross-chain vault composer
* @dev Initializes the composer with vault and OFT contracts for omnichain operations
* @param _vault The vault contract implementing ERC4626 for deposit/redeem operations
* @param _assetOFT The OFT contract for cross-chain asset transfers
* @param _shareOFT The OFT contract for cross-chain share transfers
*/
constructor(
address _vault,
address _assetOFT,
address _shareOFT
) VaultComposerSync(_vault, _assetOFT, _shareOFT) {}
}
VaultComposerSync
is the orchestrator contract that enables cross-chain vault operations between the OFT standard and ERC-4626 vaults, automatically handling deposits and redemptions based on incoming token transfers.
The "Sync" in VaultComposerSync
refers to synchronous vault operations - meaning the vault must support immediate, single-transaction deposits and redemptions without delays or waiting periods.
For asynchronous vaults that require multi-transaction redemptions, you will need to modify the MyOVaultComposer
contract.
Share OFT (Spoke Chains)
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { OFT } from "@layerzerolabs/oft-evm/contracts/OFT.sol";
/**
* @title MyShareOFT
* @notice ERC20 representation of the vault's share token on a spoke chain for cross-chain functionality
* @dev This contract represents the vault's share tokens on spoke chains. It inherits from
* LayerZero's OFT (Omnichain Fungible Token) to enable seamless cross-chain transfers of
* vault shares between the hub chain and spoke chains. This contract is designed to work
* with ERC4626-compliant vaults, enabling standardized cross-chain vault interactions.
*
* Share tokens represent ownership in the vault and can be redeemed for the underlying
* asset on the hub chain. The OFT mechanism ensures that shares maintain their value and can be freely
* moved across supported chains while preserving the vault's accounting integrity.
*/
contract MyShareOFT is OFT {
/**
* @notice Constructs the Share OFT contract
* @dev Initializes the OFT with LayerZero endpoint and sets up ownership
* @param _name The name of the share token
* @param _symbol The symbol of the share token
* @param _lzEndpoint The address of the LayerZero endpoint on this chain
* @param _delegate The address that will have owner privileges
*/
constructor(
string memory _name,
string memory _symbol,
address _lzEndpoint,
address _delegate
) OFT(_name, _symbol, _lzEndpoint, _delegate) Ownable(_delegate) {
// WARNING: Do NOT mint share tokens directly as this breaks the vault's share-to-asset ratio
// Share tokens should only be minted by the vault contract during deposits to maintain
// the correct relationship between shares and underlying assets
// _mint(msg.sender, 1 ether); // ONLY uncomment for testing UI/integration, never in production
}
}
Similar to MyAssetOFT
, MyShareOFT
is a standard OFT
representation of the share
token from the ERC4626
vault to be used on other spoke chains. This contract requires MyShareOFTAdapter
to be deployed on the hub chain using the share
address as the _token
argument.
If your intended vault share
is already an OFT
(e.g., sUSDe), you do not need to deploy this contract, and will only need to deploy MyOVaultComposer
.
You should NEVER implement _mint()
in the constructor or externally in share
tokens. Since shares
can be redeemed for assets
on the hub chain, minting new supply breaks the conversion rate inside the ERC4626
vault.
OVault operations follow a two-phase architecture where failures and slippage protection occur in distinct stages:
Two-Phase Operation Flow
Phase 1: Source → Hub (Standard OFT)
- User calls
OFT.send()
targeting the hub composer - Standard LayerZero transfer with compose message
- Reliable transfer with minimal failure modes
Phase 2: Hub Operations + Output Routing
- Composer executes vault operations (
deposit
/redeem
) - Critical slippage point: Vault conversion rates may have changed
- Output tokens routed to final destination (local or cross-chain)
Operation Detection
The composer automatically determines the vault operation based on which OFT sent the tokens:
- AssetOFT caller → Triggers
deposit
operation (assets
→shares
) - ShareOFT caller → Triggers
redeem
operation (shares
→assets
)
Slippage Protection Strategy
Since the real slippage occurs during vault operations on the hub, the composeMsg
contains the critical slippage parameters:
- Phase 1
minAmountLD
: Set for source token (not critical for vault rates) - Phase 2
minAmountLD
: Set incomposeMsg
for vault output (critical protection)
1. Standard OFT Transfer Initiation
Users call the standard OFT interface with compose instructions:
// Standard OFT send with compose message
assetOFT.send(
SendParam({
dstEid: hubEid, // Always send to hub first
to: bytes32(composer), // VaultComposerSync address
amountLD: depositAmount,
minAmountLD: minDepositAmount, // Slippage protection
extraOptions: "...", // Gas for compose + second hop
composeMsg: composeMsg, // Second SendParam + minMsgValue
oftCmd: ""
}),
MessagingFee(msg.value, 0),
refundAddress
);
2a. Composer Message Reception
When tokens arrive at the hub via lzReceive()
, the composer is triggered via lzCompose()
:
function lzCompose(
address _composeCaller, // Either ASSET_OFT or SHARE_OFT
bytes32 _guid,
bytes calldata _message, // Contains routing instructions
address _executor,
bytes calldata _extraData
) external payable
The compose message contains the Phase 2 routing instructions with critical slippage protection:
// Decoded in handleCompose() - this controls Phase 2 behavior
(SendParam memory sendParam, uint256 minMsgValue) = abi.decode(
_composeMsg,
(SendParam, uint256)
);
// SendParam for vault output routing:
// - dstEid: Target chain for output tokens
// - to: Final recipient address
// - amountLD: Updated by composer to actual vault output
// - minAmountLD: CRITICAL - protects against vault rate slippage
// - extraOptions: Gas settings for destination transfer
// - composeMsg: Empty (no nested compose)
// - oftCmd: Empty (no OFT commands)
2b. Operation Detection & Execution
The composer automatically determines the vault operation based on which OFT sent the tokens:
Asset Deposit Flow (AssetOFT → Composer):
function _depositAndSend() {
// 1. Deposit assets into vault
uint256 shareAmount = VAULT.deposit(_assetAmount, address(this));
// 2. Verify slippage protection
_assertSlippage(shareAmount, _sendParam.minAmountLD);
// 3. Route shares to final destination
_send(SHARE_OFT, shareAmount, _refundAddress);
}
Share Redemption Flow (ShareOFT → Composer):
function _redeemAndSend() {
// 1. Redeem shares from vault
uint256 assetAmount = VAULT.redeem(_shareAmount, address(this), address(this));
// 2. Verify slippage protection
_assertSlippage(assetAmount, _sendParam.minAmountLD);
// 3. Route assets to final destination
_send(ASSET_OFT, assetAmount, _refundAddress);
}
2c. Smart Output Routing
The _send()
function handles both local and cross-chain delivery:
function _send(address _oft, SendParam memory _sendParam, address _refundAddress) {
if (_sendParam.dstEid == VAULT_EID) {
// Same chain: Direct ERC20 transfer (no LayerZero fees)
address erc20 = _oft == ASSET_OFT ? ASSET_ERC20 : SHARE_ERC20;
IERC20(erc20).safeTransfer(_sendParam.to.bytes32ToAddress(), _sendParam.amountLD);
} else {
// Cross-chain: Standard OFT send
IOFT(_oft).send{ value: msg.value }(_sendParam, MessagingFee(msg.value, 0), _refundAddress);
}
}
Key Implementation Tips
- Start Simple: Deploy a basic vault first, add yield strategies later
- Test Thoroughly: Each operation type has different gas requirements
- Monitor Closely: Set up alerts for failed compose messages
- Plan Recovery: Document procedures for each failure scenario
- Optimize Gas: Use the task's automatic optimization, adjust as needed
Troubleshooting
OVault operations have only two possible final outcomes: Success
or Failed
(but Refunded). Understanding the failure flow helps determine appropriate recovery actions.
Refund Scenarios and Recovery
The VaultComposerSync
uses a try-catch pattern around handleCompose()
to ensure robust error handling:
try this.handleCompose{ value: msg.value }(/*...*/) {
emit Sent(_guid);
} catch (bytes memory _err) {
// Automatic refund for any handleCompose failures
_refund(_composeCaller, _message, amount, tx.origin);
emit Refunded(_guid);
}
Common scenarios caught by try-catch:
InsufficientMsgValue
- insufficient gas for destination delivery → Auto refundSlippageExceeded
- vault output below minimum → Manual refund available- Vault operational errors (paused, insufficient liquidity) → Manual refund available
Transaction Revert: Gas or Fee Issues
What happens: OFT transfer fails on source chain before any tokens move
Common causes:
- Insufficient native tokens for LayerZero fees
- Invalid destination endpoint configuration
- Gas estimation errors
User experience: Transaction reverts immediately, no tokens transferred
Recovery: User can retry immediately after fixing the issue
- Use
quoteSend()
to get accurate fee estimation - Verify destination chain configuration
- Ensure sufficient native tokens for cross-chain fees
Automatic Refund: Insufficient msg.value for Second Hop
What happens: LayerZero completes lzReceive and lzCompose successfully, but insufficient gas for destination delivery
Technical flow:
- LZ Executor calls
lzReceive()
- tokens credited to composer ✓ - OFT calls
endpoint.sendCompose()
- composeMsg stored ✓ - LZ Executor calls
lzCompose()
on VaultComposerSync ✓ - Try-catch around
handleCompose()
catchesInsufficientMsgValue
revert - Automatic
_refund()
triggered back to source chain
Common causes:
- Underestimated gas for second hop during
quoteSend()
- Gas price fluctuations between quote and execution
- Complex destination chain operations requiring more gas
User experience:
- Cross-chain transfer appears successful initially
- Composer automatically triggers refund to source chain
- Original tokens returned within minutes
Recovery: Automatic - no user action required
- Monitor source chain for refunded tokens
- Retry with higher gas estimate from
quoteSend()
Manual Refund: Vault Operation Issues
What happens: LayerZero flow completes successfully, but vault operation fails slippage check
Technical flow:
- LZ Executor calls
lzReceive()
- tokens credited to composer ✓ - OFT calls
endpoint.sendCompose()
- composeMsg stored ✓ - LZ Executor calls
lzCompose()
on VaultComposerSync ✓ - Try-catch around
handleCompose()
executes vault operation ✓ - Vault returns
actualAmount
(shares or assets) - Slippage check:
actualAmount >= minAmountLD
FAILS SlippageExceeded
revert caught by try-catch- Manual
_refund()
available (user must trigger)
Common causes:
- Vault share/asset price changed during cross-chain transfer
- Vault hit deposit/withdrawal limits between quote and execution
minAmountLD
set too high based on stalepreviewDeposit/previewRedeem
data
User experience:
- Cross-chain transfer succeeds
- Vault operation fails on hub with slippage error
- Tokens held by composer awaiting user action
Recovery: User must manually trigger refund from hub chain
- Switch wallet to hub chain network
- Call composer refund function
- Original tokens returned to source chain
- Retry operation with adjusted slippage tolerance
Prevention:
- Use wider slippage tolerance (2-5% for volatile vaults)
- Check vault limits:
vault.maxDeposit()
,vault.maxRedeem()
- Monitor vault state with
vault.previewDeposit()
before large operations - Account for time delays in cross-chain operations when setting
minAmountLD