LayerZero V2 Aptos Move OFT
Below is comprehensive documentation for Aptos Move OFT modules, explaining both the OFT and OFT Adapter, mirroring the LayerZero V2 OFT Standard you might see on EVM or Solana.
The Omnichain Fungible Token (OFT) Standard allows fungible tokens to be transferred across multiple blockchains without asset wrapping or middlechains.
This standard works by either debiting (burn
/ lock
) tokens on the source chain, sending a message via LayerZero, and delivering a function call to credit (mint
/ unlock
) the same number of tokens on the destination chain.
This creates a unified supply across all networks that the OFT supports.
What is OFT?
An Omnichain Fungible Token (OFT) is a LayerZero-based token that can be sent across chains without wrapping or middle-chains. It supports:
- Burn + Mint (OFT): Remove supply from the source chain, re-create it on the destination.
- Lock + Unlock (OFT Adapter): Move supply into an escrow on the source, release it on the destination.
On EVM, you see this logic embedded in an OFT.sol
or OFTAdapter.sol
contract. In Aptos Move, we achieve the same through specialized modules:
oft::oft_fa
– OFT “mint/burn” approach.oft::oft_adapter_fa
– OFT Adapter “lock/unlock” approach.oft::oft
– Unified interface for user-level send, quote, and receive entry points.oft::oft_core
– Core bridging logic shared by both OFT and OFT Adapter.oft::oft_impl_config
– Central config for fees, blocklisting, rate limits (used by both).oft::oft_store
– Tracks shared vs. local decimals so each chain can represent the token with different local decimals if needed.oft::oapp_core
/oft::oapp_store
– The OApp plumbing for bridging messages cross-chain, handling admin/delegate roles, peer configuration, etc.
The EVM/Solana OFT relies on ERC20
/SPL
logic for mint/burn or lock/unlock.
The Aptos OFT relies on Move’s Fungible Asset
standard. oft::oft_fa
does actual mint/burn, while oft::oft_adapter_fa
locks/unlocks an existing Fungible Asset
in an escrow.
2. Relating the OApp and OFT Modules
The OApp Standard gives your contract the ability to:
Send cross-chain messages (
lz_send
)Receive cross-chain messages (via
lz_receive
)Quote cross-chain fees
Enforce admin- or delegate-level controls
The OFT Standard then builds on top of that to specifically handle:
Fungible Asset bridging
Local token manipulations (burn/mint or lock/unlock)
Additional rate-limiting, blocklists, bridging fees, etc.
All cross-chain calls still flow through lz_send
and lz_receive
in oft_core
, which rely on the OApp’s ability to call the LayerZero Endpoint. This is exactly how the EVM OFT
extends OApp
to unify cross-chain token operations.
OFT: oft::oft_fa
When tokens are sent cross-chain, the module burns tokens from the sender’s local supply.
On the receiving chain, it mints newly created tokens for the recipient.
In EVM, you might see this with an ERC20
implementation that calls _burn
in send()
and _mint
in lzReceive()
. On Aptos, oft_fa.move
uses Move’s FungibleAsset
:
Code Snippet: debit_fungible_asset
(Burn on Send)
public(friend) fun debit_fungible_asset(
sender: address,
fa: &mut FungibleAsset,
min_amount_ld: u64,
dst_eid: u32,
): (u64, u64) acquires OftImpl {
// 1. Check blocklist
assert_not_blocklisted(sender);
// 2. Determine the “send” and “receive” amounts (minus dust/fees)
let amount_ld = fungible_asset::amount(fa);
let (amount_sent_ld, amount_received_ld) = debit_view(amount_ld, min_amount_ld, dst_eid);
// 3. Rate limit checks (no exceeding capacity)
try_consume_rate_limit_capacity(dst_eid, amount_received_ld);
// 4. Subtract the fee from the total
let extracted_fa = fungible_asset::extract(fa, amount_sent_ld);
if (fee_ld > 0) { ... }
// 5. Burn the final extracted tokens
fungible_asset::burn(&store().burn_ref, extracted_fa);
(amount_sent_ld, amount_received_ld)
}
Code Snippet: credit
(Mint on Receive)
public(friend) fun credit(
to: address,
amount_ld: u64,
src_eid: u32,
lz_receive_value: Option<FungibleAsset>,
): u64 acquires OftImpl {
// 1. (Optional) deposit cross-chain wrapped asset to the admin
option::for_each(lz_receive_value, |fa| primary_fungible_store::deposit(@oft_admin, fa));
// 2. Release rate limit capacity for net inflows
release_rate_limit_capacity(src_eid, amount_ld);
// 3. Mint the tokens to the final recipient (or redirect if blocklisted)
primary_fungible_store::mint(
&store().mint_ref,
redirect_to_admin_if_blocklisted(to, amount_ld),
amount_ld
);
amount_ld
}
You will want to use the oft::oft_fa
implementation when you want:
a brand new token on Aptos representing the cross-chain supply.
each chain to independently mint/burn.
to bridge a new “canonical” supply for an existing non-Aptos asset on an Aptos chain.
Adapter OFT: oft::oft_adapter_fa
Instead of burning/minting tokens, the module locks tokens into an escrow on send and unlocks them from escrow on receive.
This can be used if you already have an existing token on an Aptos Move chain that can’t share its mint/burn capabilities.
On EVM, you might see an OFT that uses a "lockbox" to hold user tokens, sending representations of that held asset cross-chain. The approach is the same on Aptos:
Code Snippet: debit_fungible_asset
(Lock on Send)
public(friend) fun debit_fungible_asset(
sender: address,
fa: &mut FungibleAsset,
min_amount_ld: u64,
dst_eid: u32,
): (u64, u64) acquires OftImpl {
// 1. Check blocklist
assert_not_blocklisted(sender);
// 2. Determine the “send” and “receive” amounts
let (amount_sent_ld, amount_received_ld) = debit_view(amount_ld, min_amount_ld, dst_eid);
// 3. Subtract fees if any
let extracted_fa = fungible_asset::extract(fa, amount_sent_ld);
if (fee_ld > 0) { ... }
// 4. Deposit the net tokens into an “escrow” account
primary_fungible_store::deposit(escrow_address(), extracted_fa);
(amount_sent_ld, amount_received_ld)
}
Code Snippet: credit
(Unlock on Receive)
public(friend) fun credit(
to: address,
amount_ld: u64,
src_eid: u32,
lz_receive_value: Option<FungibleAsset>,
): u64 acquires OftImpl {
// 1. (Optional) deposit cross-chain “wrapped” asset to admin
option::for_each(lz_receive_value, |fa| primary_fungible_store::deposit(@oft_admin, fa));
// 2. Release rate limit capacity
release_rate_limit_capacity(src_eid, amount_ld);
// 3. Unlock from escrow into the final recipient
let escrow_signer = &object::generate_signer_for_extending(&store().escrow_extend_ref);
primary_fungible_store::transfer(
escrow_signer,
metadata(),
redirect_to_admin_if_blocklisted(to, amount_ld),
amount_ld
);
amount_ld
}
You will want to use the oft::oft_adapter_fa
implementation when you:
- have a pre-existing token on Aptos and cannot or do not want to grant mint/burn to the bridging contract.
The adapter approach “locks” user tokens, so be mindful of ensuring adequate liquidity in the adapter if bridging in from other chains.
Typically, only one chain uses the adapter approach (since the “escrow” is meant to represent the supply on that chain). Other chains should use full mint/burn logic.
Common Interface: oft::oft
Both oft_fa
and oft_adapter_fa
feed into the same top-level interface (oft::oft
). This module:
Exposes user-facing functions like
send_withdraw(...)
,send(...)
,quote_oft(...)
, etc.Delegates the actual bridging logic to either “OFT” or "OFT Adapter" code (by depending on whichever you’ve chosen).
Implements the final
lz_receive_impl(...)
function so that cross-chain messages fromoft_core
eventually call yourcredit(...)
.
Example from oft.move
:
public entry fun send_withdraw(
account: &signer,
dst_eid: u32,
to: vector<u8>,
amount_ld: u64,
...
) {
// 1. Withdraw tokens from user
let send_value = primary_fungible_store::withdraw(account, metadata(), amount_ld);
// 2. Withdraw cross-chain fees
let (native_fee_fa, zro_fee_fa) = withdraw_lz_fees(account, native_fee, zro_fee);
// 3. Call OFT core “send” logic
send_internal(
sender,
dst_eid,
to_bytes32(to),
&mut send_value,
...
);
// 4. Refund leftover fees & deposit any leftover tokens
refund_fees(sender, native_fee_fa, zro_fee_fa);
primary_fungible_store::deposit(sender, send_value);
}
Core Logic: oft::oft_core
Regardless of whether it’s an OFT or OFT Adapter, the cross-chain bridging sequence is the same:
send(...)
– Encodes the message, calls yourdebit
function, and dispatches it over the LayerZero Endpoint.receive(...)
– Decodes the message, calls yourcredit
function, and optionally calls “compose” logic if there is a follow-up message.
In an EVM environment, OFT variants do something similar with _burn
, _mint
, or _transfer
. The separation is conceptually the same.
public(friend) inline fun send(
user_sender: address,
dst_eid: u32,
to: Bytes32,
compose_payload: vector<u8>,
send_impl: |vector<u8>, vector<u8>| MessagingReceipt,
debit: |bool| (u64, u64),
build_options: |u64, u16| vector<u8>,
inspect: |&vector<u8>, &vector<u8>|,
): (MessagingReceipt, u64, u64) {
let (amount_sent_ld, amount_received_ld) = debit(true);
// Construct the message to contain 'amount_received_ld' and 'to' address
let (message, msg_type) = encode_oft_msg(user_sender, amount_received_ld, to, compose_payload);
let options = build_options(amount_received_ld, msg_type);
inspect(&message, &options);
let messaging_receipt = send_impl(message, options);
// Emit an event for cross-chain reference
...
}
Implementation Config: oft::oft_impl_config
Both OFT and OFT Adapter share the same configuration for:
Fees:
fee_bps
,fee_deposit_address
.Blocklist: Addresses can be disallowed from sending. Inbound tokens to them are re-routed to the admin.
Rate Limits: Each endpoint (chain) can be rate-limited to prevent large surges of bridging.
Example for setting fees:
public entry fun set_fee_bps(admin: &signer, fee_bps: u64) {
assert_admin(address_of(admin));
oft_impl_config::set_fee_bps(fee_bps);
}
Internal Store: oft::oft_store
Holds two critical values:
shared_decimals
: The universal decimals used across all chains.decimal_conversion_rate
: The factor bridging from local decimals to shared decimals.
This matches the approach on EVM-based OFT, where you might define a consistent “decimals” across all chains, and each chain adapts locally if it wants a different local representation.
public(friend) fun initialize(shared_decimals: u8, decimal_conversion_rate: u64) acquires OftStore {
assert!(store().decimal_conversion_rate == 0, EALREADY_INITIALIZED);
store_mut().shared_decimals = shared_decimals;
store_mut().decimal_conversion_rate = decimal_conversion_rate;
}
Comparison Between OFT and OFT Adapter
Feature | OFT (mint/burn) | OFT Adapter (lock/unlock) |
---|---|---|
Token Ownership / Supply | The OFT can create (mint) or destroy (burn) tokens. Perfect for brand-new token supply across multiple chains. | The OFT does not create or destroy tokens. It merely locks them into an escrow, then unlocks them upon cross-chain receive. |
Use Case | Great for truly omnichain tokens that unify supply. Each chain can hold a minted portion. | Ideal if an existing token is already deployed, and you can’t share mint/burn privileges with the bridging contract. |
Implementation Module | oft_fa.move | oft_adapter_fa.move |
credit(...) Behavior | Mint the inbound tokens for the recipient. | Unlock from escrow and deposit to the recipient. |
debit(...) Behavior | Burn from the sender’s local supply. | Lock tokens in an escrow address. |
Rebalancing or Liquidity Management | Not required for new tokens (the total supply is burned on one side, minted on the other). | Must ensure enough tokens remain in escrow to handle inbound “unlocks” from other chains. If many tokens flow out, local liquidity may be depleted. |
Putting It All Together
Deploy & Initialize
- Deploy the modules (
oft_adapter_fa
,oft_fa
,oft
, etc.). - Call
init_module
orinit_module_for_test
. - For the chosen path (native vs. adapter), run the relevant
initialize(...)
function (e.g.,oft_fa.initialize
oroft_adapter_fa.initialize
).
- Deploy the modules (
Configure
- Adjust fees, blocklists, or rate-limits using
oft::oft_impl_config
. - For the adapter approach, ensure the escrow has enough tokens to handle inbound bridging from other chains.
- Adjust fees, blocklists, or rate-limits using
Sending
- A user calls
send_withdraw(...)
fromoft::oft
. - This performs the local “debit” logic (burn or lock) and constructs a cross-chain message.
- Then calls the underlying
lz_send(...)
from the OApp layer.
- A user calls
Receiving
- The LayerZero Executor calls your OApp’s
lz_receive_impl(...)
. - This triggers
oft_core::receive(...)
, which decodes the message and calls yourcredit(...)
logic (mint or unlock).
- The LayerZero Executor calls your OApp’s
Monitor
- Check events:
OftSent
andOftReceived
inoft_core
. - Track blocklist changes, fee deposit addresses, and rate limit usage in
oft_impl_config
.
- Check events:
Conclusion
Whether you choose an OFT (mint/burn) or an OFT Adapter (lock/unlock):
The core bridging is consistent with the LayerZero V2 OFT Standard on EVM/Solana.
Fee, blocklist, and rate-limit logic is shared in
oft_impl_config
.Message encoding/decoding and compose features align with
oft_core
.Shared decimals plus local decimals ensure consistent cross-chain supply.
OFT are perfect for new tokens that do not exist outside of the bridging context, while OFT Adapter allow you to adopt bridging on an existing, fully deployed token.
Both approaches integrate seamlessly with LayerZero’s cross-chain messaging on Aptos, providing a robust, modular framework for omnichain fungible tokens.