LayerZero V2 Part 6: Developer Gotchas
This is Part 6 of a series on LayerZero V2. Part 1 covers the basics.
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));
// = 0x00010000000000000000000000000000000000000000000000000000000000030d40Use 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
-
enforceOptionsset for SEND and SEND_AND_CALL -
lzComposevalidatesmsg.senderand_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
- Part 1: What It Is and Why It Exists
- Part 2: How a Message Travels
- Part 3: The DVN Security Model
- Part 4: OFT Token Bridging
- Part 5: Composed Messages
- Part 6: Developer Gotchas (this post)
Source code for the BRG Bridge (a working OFT bridge across 4 chains): github.com/gnuser/brg-bridge