Skip to main content
Version: Endpoint V2 Docs

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.

OFT Example OFT Example

  • Lock + Unlock (OFT Adapter): Move supply into an escrow on the source, release it on the destination.

OFT Example OFT Example

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:

  1. oft::oft_fa – OFT “mint/burn” approach.

  2. oft::oft_adapter_fa – OFT Adapter “lock/unlock” approach.

  3. oft::oft – Unified interface for user-level send, quote, and receive entry points.

  4. oft::oft_core – Core bridging logic shared by both OFT and OFT Adapter.

  5. oft::oft_impl_config – Central config for fees, blocklisting, rate limits (used by both).

  6. oft::oft_store – Tracks shared vs. local decimals so each chain can represent the token with different local decimals if needed.

  7. 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.

danger

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 from oft_core eventually call your credit(...).

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:

  1. send(...) – Encodes the message, calls your debit function, and dispatches it over the LayerZero Endpoint.

  2. receive(...) – Decodes the message, calls your credit 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:

  1. shared_decimals: The universal decimals used across all chains.

  2. 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

FeatureOFT (mint/burn)OFT Adapter (lock/unlock)
Token Ownership / SupplyThe 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 CaseGreat 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 Moduleoft_fa.moveoft_adapter_fa.move
credit(...) BehaviorMint the inbound tokens for the recipient.Unlock from escrow and deposit to the recipient.
debit(...) BehaviorBurn from the sender’s local supply.Lock tokens in an escrow address.
Rebalancing or Liquidity ManagementNot 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

  1. Deploy & Initialize

    • Deploy the modules (oft_adapter_fa, oft_fa, oft, etc.).
    • Call init_module or init_module_for_test.
    • For the chosen path (native vs. adapter), run the relevant initialize(...) function (e.g., oft_fa.initialize or oft_adapter_fa.initialize).
  2. 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.
  3. Sending

    • A user calls send_withdraw(...) from oft::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.
  4. Receiving

    • The LayerZero Executor calls your OApp’s lz_receive_impl(...).
    • This triggers oft_core::receive(...), which decodes the message and calls your credit(...) logic (mint or unlock).
  5. Monitor

    • Check events: OftSent and OftReceived in oft_core.
    • Track blocklist changes, fee deposit addresses, and rate limit usage in oft_impl_config.

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.