> For the complete documentation index, see [llms.txt](https://docs.lighter.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.lighter.xyz/evm/interop/tokens/token-transfers/l1-and-evm.md).

# L1 and EVM

## Token bridges and gateways

Token bridges build on [L1 and EVM message passing](/evm/interop/message-passing/l1-and-evm.md) 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**](/evm/interop/tokens/token-linking.md#default-gateways) 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](/evm/interop/tokens/token-linking.md#gateway-requirements) and [external wrapper requirements](/evm/interop/tokens/token-linking.md#external-wrapper-requirements) in Token Linking. [Gateway selection](/evm/interop/tokens/token-linking.md#selecting-the-gateway-pair) 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](/evm/interop/tokens/token-linking.md#registering-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](/evm/interop/tokens/token-linking.md#default-gateways). Find their deployed addresses in [Chain Info](/evm/chain-info.md). 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`](https://github.com/elliottech/lighter-contracts/blob/lighter-evm-devnet/contracts/gateways/l1/DefaultL1EscrowGateway.sol) to spend the canonical L1 token, then call `sendTokens(...)` on that gateway.

```solidity
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;
```

| Parameter       | Description                                                                                  |
| --------------- | -------------------------------------------------------------------------------------------- |
| `token`         | The canonical token address on L1.                                                           |
| `to`            | Recipient on Lighter EVM.                                                                    |
| `amount`        | Amount in the token's smallest units on L1.                                                  |
| `gasLimit`      | Gas limit for message execution on EVM.                                                      |
| `refundAddress` | L1 address that receives excess fees. Defaults to `msg.sender` when using `sendTokens(...)`. |

Supply the appropriate [priority request fee](/evm/interop/message-passing/l1-and-evm.md#priority-request-fee-calculation) as `msg.value`.

The gateway escrows the canonical tokens and sends a [replayable message](/evm/interop/message-passing/l1-and-evm.md#l1replayablemessenger) to [`EvmMintGateway`](https://github.com/elliottech/lighter-contracts/blob/lighter-evm-devnet/contracts/gateways/l1/DefaultEvmMintGateway.sol). On successful delivery, the mint gateway mints the corresponding wrapped tokens to the recipient.

#### Withdraw (EVM to L1)

Approve [`EvmMintGateway`](https://github.com/elliottech/lighter-contracts/blob/lighter-evm-devnet/contracts/gateways/l1/DefaultEvmMintGateway.sol) to spend the wrapped token, then call `sendTokens(...)` on that gateway.

```solidity
function sendTokens(address token, address to, uint256 amount) external;
```

| Parameter | Description                                          |
| --------- | ---------------------------------------------------- |
| `token`   | The wrapped token address on EVM.                    |
| `to`      | Recipient on L1.                                     |
| `amount`  | Amount 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](/evm/differences-from-l1-evm/l1-data-fee.md).

The gateway burns the wrapped tokens and creates an [**L1 Call**](/evm/interop/message-passing.md). After its batch has been [executed on L1](/evm/technical-architecture.md#batch-lifecycle-and-finalization), follow the [EVM-to-L1 execution flow](/evm/interop/message-passing/l1-and-evm.md#executing-the-call-on-l1) to release the canonical tokens from [`L1EscrowGateway`](https://github.com/elliottech/lighter-contracts/blob/lighter-evm-devnet/contracts/gateways/l1/DefaultL1EscrowGateway.sol) to the recipient.

### Bridging EVM-native tokens

#### Deposit (EVM to L1)

Approve [`EvmEscrowGateway`](https://github.com/elliottech/lighter-contracts/blob/lighter-evm-devnet/contracts/gateways/evm/DefaultEvmEscrowGateway.sol) to spend the canonical EVM token, then call `sendTokens(...)` on that gateway.

```solidity
function sendTokens(address token, address to, uint256 amount) external;
```

| Parameter | Description                                  |
| --------- | -------------------------------------------- |
| `token`   | The canonical token address on EVM.          |
| `to`      | Recipient on L1.                             |
| `amount`  | Amount 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](/evm/differences-from-l1-evm/l1-data-fee.md).

The gateway escrows the canonical tokens and creates an [**L1 Call**](/evm/interop/message-passing.md). After its batch has been [executed on L1](/evm/technical-architecture.md#batch-lifecycle-and-finalization), follow the [EVM-to-L1 execution flow](/evm/interop/message-passing/l1-and-evm.md#executing-the-call-on-l1) to mint the corresponding wrapped tokens through [`L1MintGateway`](https://github.com/elliottech/lighter-contracts/blob/lighter-evm-devnet/contracts/gateways/evm/DefaultL1MintGateway.sol).

#### Withdraw (L1 to EVM)

Approve [`L1MintGateway`](https://github.com/elliottech/lighter-contracts/blob/lighter-evm-devnet/contracts/gateways/evm/DefaultL1MintGateway.sol) to spend the wrapped token, then call `sendTokens(...)` on that gateway.

```solidity
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;
```

| Parameter       | Description                                                                                  |
| --------------- | -------------------------------------------------------------------------------------------- |
| `token`         | The wrapped token address on L1.                                                             |
| `to`            | Recipient on EVM.                                                                            |
| `amount`        | Amount in the wrapped token's smallest units on L1.                                          |
| `gasLimit`      | Gas limit for message execution on EVM.                                                      |
| `refundAddress` | L1 address that receives excess fees. Defaults to `msg.sender` when using `sendTokens(...)`. |

Supply the appropriate [priority request fee](/evm/interop/message-passing/l1-and-evm.md#priority-request-fee-calculation) as `msg.value`.

The gateway burns the wrapped tokens and sends a [replayable message](/evm/interop/message-passing/l1-and-evm.md#l1replayablemessenger) to [`EvmEscrowGateway`](https://github.com/elliottech/lighter-contracts/blob/lighter-evm-devnet/contracts/gateways/evm/DefaultEvmEscrowGateway.sol). 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](/evm/interop/message-passing/l1-and-evm.md#l1replayablemessenger) to retry delivery, increasing the gas limit if needed.
* **EVM to L1.** Retry the [L1 Call execution](/evm/interop/message-passing/l1-and-evm.md#executing-the-call-on-l1) 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`](https://github.com/elliottech/lighter-contracts/blob/lighter-evm-devnet/contracts/messaging/replayable/L1ReplayableMessenger.sol), derives the corresponding L2 transaction hash, and waits for the L2 receipt.

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

```typescript
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`](https://github.com/elliottech/lighter-contracts/blob/lighter-evm-devnet/contracts/messaging/EvmToL1Messenger.sol) 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.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.lighter.xyz/evm/interop/tokens/token-transfers/l1-and-evm.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
