Skip to main content
Version: Endpoint V2

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.

OVault Comparison OVault Comparison

Prerequisites

Before implementing OVault, you should understand:

  1. OFT Standard: How Omnichain Fungible Tokens work and what the typical deployment looks like
  2. Composer Pattern: Understanding of composeMsg encoding and cross-chain message workflows
  3. 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:

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 your vault config.

  • Add any changes necessary to your Vault and ShareOFT config contract or metadata.

// 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;
tip

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 and assetOFTAddress for the _hubEid network under your vault config.

  • Add any changes necessary to your ShareOFT config contract or metadata.

// 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;
info

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, and shareOFTAdapterAddress for the _hubEid network under your vault config.

  • Add any changes necessary to your composer config under vault.

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;
caution

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;
info

This configuration will deploy all core OVault contracts for a full fresh setup.

3.2 Build

Compile your contracts:

pnpm compile
info

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.

caution

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, the VaultComposerSync, and the share's OFTAdapter (lockbox)
  • Spoke Chains: Host OFT assets and OFT 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.

info

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.

info

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

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.

info

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.

caution

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 (assetsshares)
  • ShareOFT caller → Triggers redeem operation (sharesassets)

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 in composeMsg 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

  1. Start Simple: Deploy a basic vault first, add yield strategies later
  2. Test Thoroughly: Each operation type has different gas requirements
  3. Monitor Closely: Set up alerts for failed compose messages
  4. Plan Recovery: Document procedures for each failure scenario
  5. 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 refund
  • SlippageExceeded - 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:

  1. LZ Executor calls lzReceive() - tokens credited to composer ✓
  2. OFT calls endpoint.sendCompose() - composeMsg stored ✓
  3. LZ Executor calls lzCompose() on VaultComposerSync ✓
  4. Try-catch around handleCompose() catches InsufficientMsgValue revert
  5. 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:

  1. LZ Executor calls lzReceive() - tokens credited to composer ✓
  2. OFT calls endpoint.sendCompose() - composeMsg stored ✓
  3. LZ Executor calls lzCompose() on VaultComposerSync ✓
  4. Try-catch around handleCompose() executes vault operation ✓
  5. Vault returns actualAmount (shares or assets)
  6. Slippage check: actualAmount >= minAmountLD FAILS
  7. SlippageExceeded revert caught by try-catch
  8. 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 stale previewDeposit/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

  1. Switch wallet to hub chain network
  2. Call composer refund function
  3. Original tokens returned to source chain
  4. 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