Contents

LayerZero V2 Part 6: Developer Gotchas

This is Part 6 of a series on LayerZero V2. Part 1 covers the basics.

/images/part6-developer-gotchas.svg

These are the mistakes that cost us time building the BRG Bridge. Learn from our pain.

1. Wrong Option Type on Testnets

What happens: You use Type 3 options (the recommended format), but the testnet pathway doesn’t have Type 3 ULN config set. Transaction reverts with LZ_ULN_InvalidWorkerOptions.

Fix: Use legacy Type 1 options on testnets:

// Type 1: just a gas limit, nothing else
bytes memory options = abi.encodePacked(uint16(1), uint256(200000));
// = 0x00010000000000000000000000000000000000000000000000000000000000030d40

Use Type 3 with OptionsBuilder.newOptions() in production once ULN config is properly set.

2. DVN Addresses Not Sorted

What happens: You configure DVN addresses in random order. Configuration silently fails or produces unexpected verification behavior.

Fix: Always sort DVN addresses in ascending order:

// Wrong
const requiredDVNs = [GOOGLE_DVN, LZ_DVN];  // 0xD5... before 0x58...

// Correct
const requiredDVNs = [LZ_DVN, GOOGLE_DVN];  // 0x58... before 0xD5...

3. Send/Receive Config Mismatch

What happens: The DVNs configured on the send side (Chain A) don’t match what the receive side (Chain B) expects. Messages are verified by DVNs that Chain B doesn’t recognize.

Result: Messages are permanently stuck. They can never be delivered.

Fix: For every pathway (A → B), ensure the send config on A and receive config on B use the same DVNs. Configure both directions explicitly:

// A → B
makeConnection(A, B, { sendConfig: ulnConfig, receiveConfig: ulnConfig });
// B → A
makeConnection(B, A, { sendConfig: ulnConfig, receiveConfig: ulnConfig });

4. Multiple OFTAdapters

What happens: You deploy OFTAdapter on two different chains, thinking each wraps the local token.

Result: Each adapter creates a separate token pool. There’s no cross-chain coordination of pool balances. Race conditions cause sent tokens to exceed available supply → permanent token loss.

Fix: Exactly ONE OFTAdapter on the home chain where the original ERC20 lives. Deploy OFT (mint/burn) everywhere else. No exceptions.

5. Missing lzCompose Validation

What happens: Your lzCompose() function doesn’t check msg.sender and _from.

Result: Anyone can call your compose function with arbitrary data, triggering unauthorized swaps, stakes, or transfers.

Fix: Always validate both:

function lzCompose(address _from, ...) external {
    require(msg.sender == address(endpoint), "Only endpoint");
    require(_from == address(myOFT), "Only my OFT");
    // ... safe to proceed
}

6. Fee Too Low

What happens: You hardcode the fee or use a stale quote. Gas prices change, your msg.value is less than what LayerZero needs.

Result: Transaction reverts.

Fix: Always call quoteSend() immediately before send(), with the exact same SendParam:

const fee = await contract.quoteSend(sendParam, false);
await contract.send(sendParam, { nativeFee: fee, lzTokenFee: 0n }, sender, {
    value: fee,
});

Don’t cache fees for more than a few seconds.

7. Shared Decimals Mismatch

What happens: You set 6 shared decimals on Ethereum but 8 on Arbitrum.

Result: Amount conversion is off by a factor of 100. A transfer of 100 tokens might arrive as 10,000 or 1. Can enable double spending.

Fix: Use the same shared decimals (default: 6) on EVERY chain in your deployment. Set it in the constructor and never change it.

8. Nonce Blocking

What happens: A message fails to execute on the destination (maybe out of gas). All subsequent messages from the same sender are blocked — message N+1 can’t execute until N is resolved.

Fix options:

Action Effect
Retry the failed message Call lzReceive() directly with correct params
Skip the nonce Endpoint.skip(nonce) — message N is lost
Use unordered execution .addExecutorOrderedExecutionOption() — opt out of ordering

For most token bridges, unordered execution is fine. Strict ordering only matters when message sequence is critical.

9. Fee-on-Transfer Tokens

What happens: You use OFTAdapter with a token that charges transfer fees (e.g., 1% on every transfer).

Result: The adapter locks 100 tokens but only receives 99 (1% fee). The destination mints 100. Supply is now broken.

Fix: Override _debit() to check actual received amounts:

function _debit(...) internal override returns (...) {
    uint256 before = innerToken.balanceOf(address(this));
    innerToken.safeTransferFrom(_from, address(this), amountSentLD);
    uint256 actual = innerToken.balanceOf(address(this)) - before;
    amountReceivedLD = actual;  // use actual, not requested
}

10. Delegate vs Owner Confusion

What happens: Your contract’s owner and delegate are different addresses. You try to call setEnforcedOptions() through the delegate.

Result: Reverts with LZ_Unauthorized(). setEnforcedOptions is owner-only, not delegate-callable.

Fix: Keep owner and delegate as the same address:

endpoint.setDelegate(ownerAddress);

Bonus: LZ Scan Doesn’t Index Everything

What happens: You rely on the LayerZero Scan API to track testnet transfers. It returns nothing.

Result: You think the message wasn’t sent.

Reality: LZ Scan doesn’t index small testnet projects. The message was sent and may have arrived.

Fix: Verify on-chain. Extract the guid from the OFTSent event on the source chain, then check for OFTReceived with the same guid on the destination chain.

Production Checklist

Before going to mainnet:

  • Only ONE OFTAdapter (on home chain)
  • Same shared decimals on all chains
  • setPeer() called bidirectionally for every chain pair
  • DVN addresses sorted ascending
  • ≥2 required DVNs from different operators
  • Send/receive DVN configs match on both sides
  • enforceOptions set for SEND and SEND_AND_CALL
  • lzCompose validates msg.sender and _from
  • Owner is a multisig wallet (≥3 signers)
  • Rate limiting if high-value deployment
  • End-to-end tested on testnet
  • Fee quoting works correctly

The Full Series

Source code for the BRG Bridge (a working OFT bridge across 4 chains): github.com/gnuser/brg-bridge