L1 and EVM

Token bridges and gateways

Token bridges build on L1 and EVM message passing to connect a token on its native domain to a wrapper on the other domain. A pair of contracts, called gateways, manages the tokens on each side. The escrow gateway holds the canonical tokens, while the mint gateway manages their wrapper.

The protocol provides default gateway pairs that handle this message passing and token accounting for L1-native and EVM-native ERC-20 tokens. These pairs can operate before a Core asset exists and are designed so that the existing bridge and wrapper can be linked to that asset later.

Choosing gateways

When launching a token or enabling transfers between L1 and EVM, consider whether you want the bridge and its wrapper to support future Core integration.

The default gateways provide a ready-made bridge with that compatibility built in. If you need more control over the bridge's behavior, you can implement a custom pair. To preserve the option of linking it to a Core asset later, follow the gateway requirements and external wrapper requirements in Token Linking. Gateway selection can connect the pair to the Core asset after registration.

If you do not need future Core integration, you can still use the default gateways or build your own token bridge on the messaging infrastructure. A standalone L1–EVM bridge does not have to implement the interfaces required for Core integration and can define its own transfer rules and interfaces.

These compatibility requirements concern linking an existing bridge and wrapper to Core. Registering the canonical asset on Core is a separate process and does not itself require a gateway pair.

Transferring through the default gateways

The procedures below apply to the protocol's default gateways. Find their deployed addresses in Chain Info. A custom bridge defines its own transfer interface.

The default gateways do not support fee-on-transfer or rebasing tokens. Their accounting assumes that the amount received matches the amount transferred and that escrowed balances do not change independently of transfers.

To find a token's wrapper address, call computeTokenAddress(canonicalToken) on the default mint gateway on the wrapper's domain. If the wrapper does not yet exist, it is deployed when the first incoming transfer is successfully delivered. An existing wrapper is reused.

Bridging L1-native tokens

Deposit (L1 to EVM)

Approve L1EscrowGateway to spend the canonical L1 token, then call sendTokens(...) on that gateway.

function sendTokens(address token, address to, uint256 amount, uint256 gasLimit) external payable;
function sendTokensCustomRefund(
    address token, address to, uint256 amount,
    address refundAddress, uint256 gasLimit
) external payable;
ParameterDescription
tokenThe canonical token address on L1.
toRecipient on Lighter EVM.
amountAmount in the token's smallest units on L1.
gasLimitGas limit for message execution on EVM.
refundAddressL1 address that receives excess fees. Defaults to msg.sender when using sendTokens(...).

Supply the appropriate priority request fee as msg.value.

The gateway escrows the canonical tokens and sends a replayable message to EvmMintGateway. On successful delivery, the mint gateway mints the corresponding wrapped tokens to the recipient.

Withdraw (EVM to L1)

Approve EvmMintGateway to spend the wrapped token, then call sendTokens(...) on that gateway.

function sendTokens(address token, address to, uint256 amount) external;
ParameterDescription
tokenThe wrapped token address on EVM.
toRecipient on L1.
amountAmount in the wrapped token's smallest units on EVM.

No ETH value is passed to this gateway call. The transaction still incurs EVM gas and the L1 data fee.

The gateway burns the wrapped tokens and creates an L1 Call. After its batch has been executed on L1, follow the EVM-to-L1 execution flow to release the canonical tokens from L1EscrowGateway to the recipient.

Bridging EVM-native tokens

Deposit (EVM to L1)

Approve EvmEscrowGateway to spend the canonical EVM token, then call sendTokens(...) on that gateway.

function sendTokens(address token, address to, uint256 amount) external;
ParameterDescription
tokenThe canonical token address on EVM.
toRecipient on L1.
amountAmount in the token's smallest units on EVM.

No ETH value is passed to this gateway call. The transaction still incurs EVM gas and the L1 data fee.

The gateway escrows the canonical tokens and creates an L1 Call. After its batch has been executed on L1, follow the EVM-to-L1 execution flow to mint the corresponding wrapped tokens through L1MintGateway.

Withdraw (L1 to EVM)

Approve L1MintGateway to spend the wrapped token, then call sendTokens(...) on that gateway.

function sendTokens(address token, address to, uint256 amount, uint256 gasLimit) external payable;
function sendTokensCustomRefund(
    address token, address to, uint256 amount,
    address refundAddress, uint256 gasLimit
) external payable;
ParameterDescription
tokenThe wrapped token address on L1.
toRecipient on EVM.
amountAmount in the wrapped token's smallest units on L1.
gasLimitGas limit for message execution on EVM.
refundAddressL1 address that receives excess fees. Defaults to msg.sender when using sendTokens(...).

Supply the appropriate priority request fee as msg.value.

The gateway burns the wrapped tokens and sends a replayable message to EvmEscrowGateway. On successful delivery, the escrow gateway releases the canonical tokens to the recipient.

Failed message handling

A successful source transaction does not guarantee delivery. If the destination call fails, the default gateways use the retry mechanisms provided by the messaging infrastructure.

  • L1 to EVM. Follow the replayable-message flow to retry delivery, increasing the gas limit if needed.
  • EVM to L1. Retry the L1 Call execution using the same proof. A successful call cannot be executed again.

ETH transfers

ETH transfers use the messaging contracts directly and do not require token gateways.

ETH from L1 to EVM

The following TypeScript example sends ETH from L1 to an address on Lighter EVM using L1ReplayableMessenger.sendMessage, derives the corresponding L2 transaction hash, and waits for the L2 receipt.

import {
  createPublicClient,
  createWalletClient,
  decodeFunctionResult,
  encodeFunctionData,
  http,
  keccak256,
  numberToHex,
  parseAbi,
  parseAbiItem,
  parseEther,
  parseEventLogs,
  toRlp,
  type Address,
  type Hex,
  type TransactionReceipt,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";

// L1 and Lighter EVM RPC endpoints
const L1_RPC_URL = process.env.LIGHTER_L1_RPC_URL;
const L2_RPC_URL = process.env.LIGHTER_EVM_RPC_URL;
if (!L1_RPC_URL || !L2_RPC_URL) {
  throw new Error("Set LIGHTER_L1_RPC_URL and LIGHTER_EVM_RPC_URL");
}

// L1 contract addresses
const L1_REPLAYABLE_MESSENGER =
  process.env.LIGHTER_L1_REPLAYABLE_MESSENGER as Address;
const ZK_LIGHTER_BRIDGE = process.env.LIGHTER_ZK_LIGHTER_BRIDGE as Address;

// Use a disposable account that you control and fund it with L1 ETH.
const privateKey = process.env.PRIVATE_KEY as Hex | undefined;
if (!privateKey) throw new Error("Set PRIVATE_KEY");
const account = privateKeyToAccount(privateKey);

const l1PublicClient = createPublicClient({
  transport: http(L1_RPC_URL),
});

const l1WalletClient = createWalletClient({
  account,
  transport: http(L1_RPC_URL),
});

const l2PublicClient = createPublicClient({
  transport: http(L2_RPC_URL),
});

const messengerAbi = parseAbi([
  "function sendMessage(address target, bytes message, uint256 gasLimit, uint256 value, address refundAddress) external payable",
]);

const bridgeAbi = parseAbi([
  "function computePriorityFee(uint256 gasLimit) public view returns (uint256)",
]);

const newEvmPriorityRequest = parseAbiItem(
  "event NewEvmPriorityRequest(uint64 indexed requestId, address indexed sender, address indexed to, uint256 mintAmount, uint256 value, uint256 gasLimit, bool isCreation, bytes data, uint64 expirationTimestamp, bytes32 prefixHash)"
);

// RLP encodes integers as minimal big-endian bytes; 0 is the empty string.
const rlpUint = (v: bigint): Hex => (v === 0n ? "0x" : numberToHex(v));

function deriveL2TxHash(receipt: TransactionReceipt): Hex {
  const [{ args }] = parseEventLogs({ abi: [newEvmPriorityRequest], logs: receipt.logs });
  const rlp = toRlp([
    "0x01", // exogenous type: 1 (priority)
    keccak256(args.prefixHash),
    args.sender,
    args.to,
    rlpUint(args.mintAmount),
    rlpUint(args.value),
    rlpUint(args.gasLimit),
    args.isCreation ? "0x01" : "0x",
    args.data,
  ]);
  return keccak256(`0x7e${rlp.slice(2)}`);
}

async function depositEth() {
  const recipient = account.address;
  const depositAmount = parseEther("0.001");
  const gasLimit = 200_000n;

  // Pin the quote to the latest L1 block's base fee and blob base fee.
  const block = await l1PublicClient.getBlock({ blockTag: "latest" });
  const { data } = await l1PublicClient.call({
    account: account.address,
    to: ZK_LIGHTER_BRIDGE,
    blockNumber: block.number,
    // computePriorityFee is a cheap view call; 100k gas is plenty.
    gas: 100_000n,
    // gasPrice must be set explicitly because it affects the quote. Without it,
    // the node may run eth_call with block.basefee = 0, dropping the execution fee.
    gasPrice: (block.baseFeePerGas ?? 0n) + 1n,
    // A nonzero gasPrice makes eth_call check that the caller can pay
    // gas * gasPrice, so give the caller a large balance for this simulation.
    stateOverride: [{ address: account.address, balance: parseEther("1000000") }],
    data: encodeFunctionData({
      abi: bridgeAbi,
      functionName: "computePriorityFee",
      args: [gasLimit],
    }),
  });
  if (!data) throw new Error("Priority fee quote returned no data");
  const quotedFee = decodeFunctionResult({
    abi: bridgeAbi,
    functionName: "computePriorityFee",
    data,
  });
  // Fees can change before inclusion, so add a margin; excess is refunded.
  const fee = (quotedFee * 120n) / 100n;

  // Send the deposit transaction on L1
  const l1Hash = await l1WalletClient.writeContract({
    address: L1_REPLAYABLE_MESSENGER,
    abi: messengerAbi,
    functionName: "sendMessage",
    args: [
      recipient, // target on Lighter EVM
      "0x",      // empty calldata — just an ETH transfer
      gasLimit,
      depositAmount,
      account.address, // refund excess fees to sender
    ],
    value: fee + depositAmount,
  });

  console.log("L1 tx hash:", l1Hash);
  const l1Receipt = await l1PublicClient.waitForTransactionReceipt({ hash: l1Hash });

  // Derive the deterministic L2 exogenous tx hash from the L1 receipt
  const l2Hash = deriveL2TxHash(l1Receipt);
  console.log("Waiting for L2 tx:", l2Hash);

  const l2Receipt = await l2PublicClient.waitForTransactionReceipt({ hash: l2Hash });
  console.log("L2 status:", l2Receipt.status);
}

depositEth();

ETH from EVM to L1

The following TypeScript example withdraws ETH from Lighter EVM to L1. It initiates the withdrawal on L2, waits for L1 finalization, fetches the Merkle proof from the node, then claims the withdrawal on L1.

import {
  createPublicClient,
  createWalletClient,
  hexToBigInt,
  http,
  parseAbi,
  parseEther,
  parseEventLogs,
  rpcSchema,
  type Address,
  type Hex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";

// L1 and Lighter EVM RPC endpoints
const L1_RPC_URL = process.env.LIGHTER_L1_RPC_URL;
const L2_RPC_URL = process.env.LIGHTER_EVM_RPC_URL;
if (!L1_RPC_URL || !L2_RPC_URL) {
  throw new Error("Set LIGHTER_L1_RPC_URL and LIGHTER_EVM_RPC_URL");
}

// Contract addresses
const EVM_TO_L1_MESSENGER =
  process.env.LIGHTER_EVM_TO_L1_MESSENGER as Address;
const ZK_LIGHTER_BRIDGE = process.env.LIGHTER_ZK_LIGHTER_BRIDGE as Address;

// Use a disposable account that you control and fund it with Lighter EVM ETH.
const privateKey = process.env.PRIVATE_KEY as Hex | undefined;
if (!privateKey) throw new Error("Set PRIVATE_KEY");
const account = privateKeyToAccount(privateKey);

// Response of lighter_getWithdrawalProof.
type WithdrawalProof = {
  batchNumber: number;
  proof: Hex[];
  isLeft: Hex;
  l1CallHash: Hex;
  root: Hex;
  l2Sender: Address;
  to: Address;
  value: Hex;
  msgNonce: Hex;
  data: Hex;
};

type LighterRpcSchema = [
  {
    Method: "lighter_getWithdrawalProof";
    Parameters: [txHash: Hex, index: number];
    ReturnType: WithdrawalProof;
  },
];

const l1PublicClient = createPublicClient({
  transport: http(L1_RPC_URL),
});

const l1WalletClient = createWalletClient({
  account,
  transport: http(L1_RPC_URL),
});

const l2PublicClient = createPublicClient({
  transport: http(L2_RPC_URL),
  rpcSchema: rpcSchema<LighterRpcSchema>(), // types the custom lighter_ method
});

const l2WalletClient = createWalletClient({
  account,
  transport: http(L2_RPC_URL),
});

const evmToL1MessengerAbi = parseAbi([
  "function initiateWithdrawal(address _target, bytes memory _data) public payable",
  "event WithdrawalInitiated(address indexed sender, address indexed to, uint256 value, uint256 nonce, uint8 kind, bytes data)",
]);

const bridgeAbi = parseAbi([
  "function executeEvmL1Call(uint64 batchNumber, bytes32[] proof, uint256 isLeft, address _l2Sender, address to, uint256 value, uint256 msgNonce, bytes data) external",
]);

// The proof RPC returns an error until the withdrawal is finalized on L1, so poll it.
async function waitForWithdrawalProof(
  txHash: Hex,
  index = 0
): Promise<WithdrawalProof> {
  while (true) {
    try {
      return await l2PublicClient.request({
        method: "lighter_getWithdrawalProof",
        params: [txHash, index],
      });
    } catch {
      console.log("Not finalized on L1 yet, retrying...");
      await new Promise((r) => setTimeout(r, 5_000));
    }
  }
}

async function withdrawEth() {
  const recipient = account.address;
  const withdrawAmount = parseEther("0.001");

  // Check the L1 balance before the withdrawal
  const l1BalanceBefore = await l1PublicClient.getBalance({
    address: recipient,
  });
  console.log("L1 balance before:", l1BalanceBefore);

  // Step 1: Initiate the withdrawal on Lighter EVM
  const l2Hash = await l2WalletClient.writeContract({
    address: EVM_TO_L1_MESSENGER,
    abi: evmToL1MessengerAbi,
    functionName: "initiateWithdrawal",
    args: [
      recipient, // target on L1
      "0x",      // empty calldata — just an ETH transfer
    ],
    value: withdrawAmount,
  });

  console.log("L2 tx hash:", l2Hash);
  const l2Receipt = await l2PublicClient.waitForTransactionReceipt({
    hash: l2Hash,
  });

  // Decode the WithdrawalInitiated event
  const [withdrawalEvent] = parseEventLogs({
    abi: evmToL1MessengerAbi,
    eventName: "WithdrawalInitiated",
    logs: l2Receipt.logs,
  });
  console.log("Withdrawal initiated — nonce:", withdrawalEvent.args.nonce);

  // Step 2: Fetch the Merkle proof once the withdrawal is finalized on L1.
  // `0` is the index of the WithdrawalInitiated event within this transaction.
  console.log("Waiting for L1 finalization...");
  const proof = await waitForWithdrawalProof(l2Hash, 0);
  console.log("Finalized in batch", proof.batchNumber);

  // Step 3: Claim on L1. Simulate first so a revert is surfaced as a decoded error.
  const { request } = await l1PublicClient.simulateContract({
    account,
    address: ZK_LIGHTER_BRIDGE,
    abi: bridgeAbi,
    functionName: "executeEvmL1Call",
    args: [
      BigInt(proof.batchNumber),
      proof.proof,
      hexToBigInt(proof.isLeft),
      proof.l2Sender,
      proof.to,
      hexToBigInt(proof.value),
      hexToBigInt(proof.msgNonce),
      proof.data,
    ],
  });
  const hash = await l1WalletClient.writeContract(request);

  console.log("L1 claim tx hash:", hash);
  await l1PublicClient.waitForTransactionReceipt({ hash });

  const l1BalanceAfter = await l1PublicClient.getBalance({
    address: recipient,
  });
  console.log("L1 balance after:", l1BalanceAfter);
}

withdrawEth();

Plain ETH transfers

EvmToL1Messenger also has a receive() fallback. Sending ETH to it with empty calldata is equivalent to initiateWithdrawal(msg.sender, ""): a withdrawal of msg.value to the same address on L1.


Did this page help you?