Skip to main content

Bridge

General Information

The bridge enables asset transfers between Kaia L1 and L2. This document covers:

  • Deposit: Moving assets from Kaia L1 to L2 (direct transaction submission)
  • Withdrawal: Moving assets from L2 to Kaia L1 (via API endpoint)

Key Bridge Addresses

Fixed Addresses

  • ArbSys Contract: 0x0000000000000000000000000000000000000064 (for L2 native KAIA withdrawal)

Deployment-Dependent Addresses

L1 Contracts:

  • Inbox Contract: L1 bridge entry point (for KAIA deposits)
  • L1 Gateway Router: Manages L1 ERC20 token deposits
  • L1 ERC20 Gateway: ERC20 gateway

L2 Contracts:

  • L2 Gateway Router: Manages L2 ERC20 token withdrawals
  • L2 ERC20 Gateway: L2 ERC20 gateway

Note: Deployment-dependent addresses are determined at network deployment time, so always verify you are using the correct address values.

Deposit (Kaia L1 → L2)

Deposits are initiated by sending transactions directly to bridge contracts on Kaia L1. No API endpoints are provided; users must construct and submit transactions directly to the L1 network.

1. KAIA Deposit (Native Token)

There are two valid paths to bridge native KAIA from L1 to L2. Pick the one that matches your L1 wallet:

  • Standard EOAs should call Inbox.depositEth(). This is the recommended default — it is simpler, and the entire value is credited on L2.
  • EIP-7702-delegated EOAs must call Inbox.createRetryableTicket() instead — depositEth() aliases the L2 destination when the L1 sender has non-empty code, so the deposit would otherwise land at an L2 address your L1 key cannot directly sign for.

We recommend bridging a small amount first and confirming it credits on L2 before sending the full deposit.

Path A: Standard EOA — depositEth()

Transaction Structure
{
from: "0x...", // User's L1 wallet address
to: "0x...", // Inbox contract address (L1 bridge entry point)
data: "0x439370b1", // depositEth() function selector
value: "0x...", // Amount of KAIA to deposit (in wei)
gasLimit: "0x30d40", // 200000 - sufficient gas
gasPrice: "0x...", // Current L1 network gas price
nonce: "...", // User account nonce
chainId: "..." // L1 chain ID (Kaia Testnet: 1001, Kaia Mainnet: 8217)
}
Field Descriptions
  • from: User's L1 network wallet address performing the deposit
  • to: Inbox contract address — official L1 bridge entry point deployed by bridge operators
  • data: depositEth() function selector — always 0x439370b1
  • value: KAIA amount to deposit (in wei, 1 KAIA = 10^18 wei)
  • gasLimit: 0x30d40 (200000) — sufficient gas for the deposit operation
  • gasPrice: current L1 network gas price
  • nonce: account's sequential transaction counter
  • chainId:
    • Kaia Testnet: 1001
    • Kaia Mainnet: 8217
Creating and Signing the Deposit Transaction
const { ethers } = require('ethers')

// Initialize Provider and Signer
const l1Provider = new ethers.providers.JsonRpcProvider(L1_RPC_URL)
const l1Signer = new ethers.Wallet(PRIVATE_KEY, l1Provider)

// Create Inbox contract interface
const inbox = new ethers.Contract(
INBOX_CONTRACT_ADDRESS, // L1 bridge entry point
['function depositEth() external payable returns (uint256)'],
l1Signer
)

const depositTx = await inbox.depositEth({
value: ethers.utils.parseEther('1.0'), // 1 KAIA
gasLimit: 200000
})
console.log('Deposit transaction:', depositTx.hash)
await depositTx.wait()

The full value is credited to the same address on L2.

Path B: EIP-7702-delegated EOA — createRetryableTicket()

EIP-7702-delegated EOAs cannot deposit KAIA via depositEth(). Submit the deposit as a retryable ticket on the Inbox instead. Standard EOAs may also use this path.

Transaction Structure
{
from: "0x...", // User's L1 wallet address
to: "0x...", // Inbox contract address
data: "0x...", // createRetryableTicket(...) encoded call data
value: "0x...", // l2CallValue + maxSubmissionCost + gasLimit * maxFeePerGas
gasLimit: "0x...", // L1 gas limit (execution of the L1 tx itself)
gasPrice: "0x...", // Current L1 network gas price
nonce: "...", // User account nonce
chainId: "..." // L1 chain ID
}
Function Parameters

createRetryableTicket(address,uint256,uint256,address,address,uint256,uint256,bytes)

#NameRecommended value
1todepositor address (L2 recipient)
2l2CallValueKAIA amount the recipient receives on L2
3maxSubmissionCost1000000000000000 (0.001 KAIA)
4excessFeeRefundAddressdepositor address
5callValueRefundAddressdepositor address
6gasLimit300000
7maxFeePerGas50000000000 (50 gwei)
8data0x

gasLimit, maxFeePerGas, and maxSubmissionCost are L2-side fee parameters. The defaults above work under normal conditions; tune them if L2 gas conditions deviate. The msg.value you send to the Inbox is l2CallValue + maxSubmissionCost + gasLimit * maxFeePerGas; the L1 transaction execution fee is paid by the wallet on top of that.

Creating and Signing the Deposit Transaction
const { ethers } = require('ethers')

const l1Provider = new ethers.providers.JsonRpcProvider(L1_RPC_URL)
const l1Signer = new ethers.Wallet(PRIVATE_KEY, l1Provider)
const me = l1Signer.address

const gasLimit = ethers.BigNumber.from(300000)
const maxFeePerGas = ethers.utils.parseUnits('50', 'gwei') // 50 gwei
const maxSubmissionCost = ethers.BigNumber.from('1000000000000000') // 0.001 KAIA

const l2CallValue = ethers.utils.parseEther('1.0') // 1 KAIA on L2
const l2Fees = maxSubmissionCost.add(gasLimit.mul(maxFeePerGas))
const msgValue = l2CallValue.add(l2Fees)

const inbox = new ethers.Contract(
INBOX_CONTRACT_ADDRESS,
['function createRetryableTicket(address to, uint256 l2CallValue, uint256 maxSubmissionCost, address excessFeeRefundAddress, address callValueRefundAddress, uint256 gasLimit, uint256 maxFeePerGas, bytes data) external payable returns (uint256)'],
l1Signer
)

const depositTx = await inbox.createRetryableTicket(
me, // to
l2CallValue,
maxSubmissionCost,
me, // excessFeeRefundAddress
me, // callValueRefundAddress
gasLimit,
maxFeePerGas,
'0x',
{ value: msgValue }
)
console.log('Deposit transaction:', depositTx.hash)
await depositTx.wait()

Notes

  1. Inbox Contract: the to address must be the official Inbox contract deployed on L1.
  2. Gas Requirements: L1 transactions require actual gas fees on top of value.
  3. Network Compatibility: use the correct chain ID and RPC endpoint for your target network.
  4. Refund address aliasing for EIP-7702 EOAs: the Inbox applies L1-to-L2 address aliasing to excessFeeRefundAddress and callValueRefundAddress whenever those addresses hold non-empty code, including EIP-7702-delegated EOAs. The principal l2CallValue still credits the original L1 address, but refunds will be credited to the aliased L2 address. Unless you fully understand L1-to-L2 address aliasing, deposit from a separate EOA that has no EIP-7702 delegation.

2. ERC20 Token Deposit

This section describes the transaction structure and process for depositing ERC20 tokens from L1 to L2. The deposited tokens reach the L2 recipient regardless of EIP-7702 delegation, but retryable refunds (excess fee, call-value on timeout) are subject to the same address aliasing as Path B — use a plain EOA if you want refunds to land at your original L1 address.

Transaction Structure

Step 1: Token Approval Transaction
{
from: "0x...", // User's L1 wallet address
to: "0x...", // ERC20 token contract address
data: "0x095ea7b3...", // approve(spender, amount) encoded data
value: "0x0", // No KAIA needed for ERC20 approval
gasLimit: "0xc350", // 50000 - sufficient gas
gasPrice: "0x...", // Current L1 network gas price
nonce: "...", // User account nonce
chainId: "..." // L1 chain ID (Kaia Testnet: 1001, Kaia Mainnet: 8217)
}
Step 2: Deposit Transaction via L1GatewayRouter
{
from: "0x...", // User's L1 wallet address
to: "0x...", // L1GatewayRouter contract address
data: "0x...", // outboundTransfer() encoded call data
value: "0x...", // L2 execution cost (bridge fee)
gasLimit: "0x7a120", // 500000 - sufficient gas
gasPrice: "0x...", // Current L1 network gas price
nonce: "...", // User account nonce
chainId: "..." // L1 chain ID
}

Field Descriptions

Token Approval Transaction
  • from: User's L1 wallet address performing the deposit
  • to: L1 ERC20 token contract address
  • data: approve(address spender, uint256 amount) function call encoding
    • spender: L1 ERC20 Gateway address (query from router)
    • amount: Token amount to approve (in smallest unit)
  • value: Always 0x0 (no KAIA needed for ERC20 approval)
  • gasLimit: 0xc350 (50000) - sufficient gas for approval operation
Deposit Transaction
  • from: User's L1 wallet address
  • to: L1GatewayRouter contract address - deployed bridge entry point
  • data: outboundTransfer() function call encoding
    • L1 token address
    • L2 recipient address (typically same as sender)
    • Token amount to deposit
    • Max gas for L2 execution
    • Gas price bid for L2 execution
    • Additional data
  • value: KAIA fee for L2 bridge processing
  • gasLimit: 0x7a120 (500000) - sufficient gas for deposit operation

Complete Implementation Flow

const { ethers } = require('ethers')

// ====================================================================
// Initial Setup
// ====================================================================
const l1Provider = new ethers.providers.JsonRpcProvider(L1_RPC_URL)
const l1Signer = new ethers.Wallet(PRIVATE_KEY, l1Provider)

// Load contract addresses from network configuration
const L1_GATEWAY_ROUTER = '0x...' // Get from network configuration
const L1_TOKEN = '0x...' // ERC20 token to deposit

// Create token contract interface
const token = new ethers.Contract(L1_TOKEN, [
'function approve(address spender, uint256 amount) returns (bool)',
'function allowance(address owner, address spender) view returns (uint256)',
'function decimals() view returns (uint8)', // For querying decimals
], l1Signer)

// Create router contract interface
const router = new ethers.Contract(L1_GATEWAY_ROUTER, [
'function getGateway(address) view returns (address)',
'function outboundTransfer(address,address,uint256,uint256,uint256,bytes) payable returns (bytes)',
], l1Signer)

// ====================================================================
// Step 0: Query Token Info and Gateway
// ====================================================================
// Query token decimals
const decimals = await token.decimals()
const depositAmount = ethers.utils.parseUnits('100', decimals) // 100 tokens

// Query gateway from router
const gateway = await router.getGateway(L1_TOKEN) // Query gateway from router

// ====================================================================
// Step 1: Token Approval Transaction
// ====================================================================
const allowance = await token.allowance(l1Signer.address, gateway)
if (allowance.lt(depositAmount)) {
// Encode approve() function data
const approveData = token.interface.encodeFunctionData('approve', [
gateway, // Address to approve (gateway)
depositAmount // Token amount to approve
])

// Construct transaction object
const approveTx = {
from: l1Signer.address,
to: L1_TOKEN, // ERC20 token contract
data: approveData, // approve(gateway, amount)
value: '0x0', // No KAIA needed for ERC20 approval
gasLimit: '0xc350', // 50000 - sufficient gas
gasPrice: await l1Provider.getGasPrice(),
nonce: await l1Provider.getTransactionCount(l1Signer.address),
chainId: (await l1Provider.getNetwork()).chainId
}

// Sign and send transaction
const signedApprove = await l1Signer.signTransaction(approveTx)
const approveReceipt = await l1Provider.sendTransaction(signedApprove)
await approveReceipt.wait()
console.log('Token approval complete:', approveReceipt.hash)
}

// ====================================================================
// Step 2: Deposit Transaction
// ====================================================================
// Set bridge parameters
const maxGas = ethers.BigNumber.from('1000000')
const gasPriceBid = ethers.utils.parseUnits('1', 'gwei')
const bridgeFee = ethers.utils.parseEther('0.01') // Fixed fee for L2 execution and submission costs

// Encode additional data
const extraData = ethers.utils.defaultAbiCoder.encode(
['uint256', 'bytes'],
[bridgeFee, '0x']
)

// Encode outboundTransfer() function data
const depositData = router.interface.encodeFunctionData('outboundTransfer', [
L1_TOKEN, // L1 token address
l1Signer.address, // L2 recipient address
depositAmount, // Token amount to deposit
maxGas, // Max gas for L2 execution
gasPriceBid, // Gas price bid for L2 execution
extraData // Additional data
])

// Construct transaction object
const depositTx = {
from: l1Signer.address,
to: L1_GATEWAY_ROUTER, // L1GatewayRouter contract
data: depositData, // outboundTransfer() call
value: bridgeFee.toHexString(), // Bridge fee (KAIA)
gasLimit: '0x7a120', // 500000 - sufficient gas
gasPrice: await l1Provider.getGasPrice(),
nonce: await l1Provider.getTransactionCount(l1Signer.address),
chainId: (await l1Provider.getNetwork()).chainId
}

// Sign and send transaction
const signedDeposit = await l1Signer.signTransaction(depositTx)
const depositReceipt = await l1Provider.sendTransaction(signedDeposit)
await depositReceipt.wait()
console.log('Deposit complete:', depositReceipt.hash)

Key Requirements

  1. Two-Step Process: ERC20 deposits always require two transactions
  2. Balance Requirements: Need both ERC20 token balance to deposit and KAIA for gas fees
  3. Network-Specific Settings: Contract addresses vary by network deployment
  4. Allowance Optimization: Check current allowance first to avoid unnecessary approval transactions

Withdrawal (L2 → Kaia L1)

Withdrawals are initiated on L2 via the DEX API endpoint. After creating and signing a withdrawal transaction on L2 and calling the /api/v1/wallet/withdraw API, the system automatically completes the withdrawal processing on Kaia L1.

POST /api/v1/wallet/withdraw

Submit a signed withdrawal transaction to move assets from L2 to Kaia L1.

Request Body:

{
"tx": "0x02f8730183...{complete signed transaction hex}"
}

Request Fields:

FieldTypeRequiredDescription
txSTRINGYESComplete signed withdrawal transaction in hex format

Response:

{
"code": 200,
"errMsg": "OK",
"result": "0x4c9980c4fd003e9aae2e7a8a87812382c84a695614225e423aaf1676089dbbfe"
}

3. KAIA Withdrawal (Native Token)

This section describes how to create a raw signed transaction for withdrawing native KAIA from L2 to L1.

Key Points

  • Gas Free: L2 network is gas-free, so gasPrice can be set to 0
  • Nonce: L2 supports both sequential nonces and time-based nonces (Unix timestamp in milliseconds)
  • Chain ID: Must use L2 chain ID
  • Target Contract: Fixed address 0x0000000000000000000000000000000000000064 (ArbSys precompile)

Transaction Structure

{
from: "0x...", // User's L2 wallet address
to: "0x0000000000000000000000000000000000000064", // ArbSys precompile address
data: "0x...", // Encoded withdrawEth(address) function call
value: "0x...", // Amount of KAIA to withdraw (in wei)
gasLimit: "0x7a120", // 500000 - sufficient gas
gasPrice: "0x0", // 0 (gas-free network)
nonce: nonce, // Sequential or time-based nonce
chainId: L2_CHAIN_ID // L2 chain ID
}

Field Descriptions

  • from: User's L2 wallet address performing the withdrawal
  • to: ArbSys precompile address - always 0x0000000000000000000000000000000000000064
  • data: withdrawEth(address destination) function call encoding
    • Function selector: 0x25e16063
    • destination: L1 recipient address (32 bytes with padding)
  • value: Amount of KAIA to withdraw (in wei, 1 KAIA = 10^18 wei)
  • gasLimit: 0x7a120 (500000) - sufficient gas for withdrawal operation
  • gasPrice: 0x0 - can be set to 0 as L2 is gas-free
  • nonce: Sequential nonce or time-based nonce (Date.now())
  • chainId: L2 network chain ID

Creating and Signing Transaction

const { ethers } = require('ethers')

// Initialize L2 RPC Provider and Signer
const l2Provider = new ethers.providers.JsonRpcProvider(L2_RPC_URL)
const l2Signer = new ethers.Wallet(PRIVATE_KEY) // No provider connection for manual signing

// Get L2 network info
const l2Network = await l2Provider.getNetwork()
const l2ChainId = l2Network.chainId // L2 chain ID

// Prepare ArbSys interface
const arbSysInterface = new ethers.utils.Interface([
'function withdrawEth(address destination) external payable returns (uint256)'
])

// Encode function data
const withdrawData = arbSysInterface.encodeFunctionData('withdrawEth', [
L1_RECIPIENT_ADDRESS // L1 recipient address
])

// Use time-based nonce
const nonce = Date.now() // Time-based nonce (milliseconds)

// Construct unsigned transaction
const unsignedTx = {
from: l2Signer.address,
to: '0x0000000000000000000000000000000000000064', // ArbSys precompile
data: withdrawData,
value: ethers.utils.parseEther('1.0').toHexString(), // 1 KAIA
gasLimit: ethers.BigNumber.from(500000).toHexString(), // Sufficient gas
gasPrice: '0x0', // Gas-free so can set to 0
nonce: nonce,
chainId: l2ChainId // L2 chain ID
}

// Sign transaction
const signedTx = await l2Signer.signTransaction(unsignedTx)
console.log('Signed transaction:', signedTx)

// Submit via API (see POST /api/v1/wallet/withdraw above)

4. ERC20 Token Withdrawal

This section describes how to create and sign transactions for withdrawing ERC20 tokens from L2 to L1.

Key Features

  • Gas-Free Network: L2 has no gas fees, so gasPrice can be set to 0
  • Nonce: Both sequential and time-based nonces are supported
  • Chain ID: Use L2 chain ID
  • Raw Signed Transaction: Create pre-signed transaction for API submission

Transaction Structure

{
from: "0x...", // User's L2 wallet address
to: "0x...", // L2 Gateway Router contract address
data: "0x7b3a3c8b...", // outboundTransfer function call data
value: "0x00", // Always 0 for ERC20 transfers
gasLimit: "0x0f4240", // 1000000 - sufficient gas
gasPrice: "0x00", // 0 (gas-free network)
nonce: nonce, // Sequential or time-based nonce
chainId: L2_CHAIN_ID // L2 chain ID
}

Field Descriptions

  • from: L2 wallet address initiating the withdrawal
  • to: L2 Gateway Router contract address - manages token bridging operations
  • data: outboundTransfer(address,address,uint256,bytes) function call encoding
    • L1 token address: L1 token contract address to receive
    • L1 recipient address: L1 address to receive tokens
    • Withdrawal amount: Token quantity in wei
    • Additional data: Typically "0x" (empty data)
  • value: Always 0x00 for ERC20 token transfers
  • gasLimit: 0x0f4240 (1000000) - sufficient gas for withdrawal operation
  • gasPrice: 0x00 - can be set to 0 as L2 is gas-free
  • nonce: Sequential nonce or time-based nonce (Date.now())
  • chainId: L2 network chain ID

Transaction Creation and Signing Process

const { ethers } = require('ethers')

// Configuration
const L2_RPC = 'https://l2-sequencer-testnet.alphasec.trade' // Testnet L2 RPC Endpoint
const PRIVATE_KEY = '0x...'
const L2_GATEWAY_ROUTER = '0x097209B15FB6cEefba90EA10e4c1c5439E6bC1Ea' // Testnet L2 Gateway Router

// Initialize L2 Provider and Signer
const l2Provider = new ethers.providers.JsonRpcProvider(L2_RPC)
const l2Signer = new ethers.Wallet(PRIVATE_KEY)

// Get L2 chain ID
const network = await l2Provider.getNetwork()
const l2ChainId = network.chainId

// Create router interface and encode function
const routerInterface = new ethers.utils.Interface([
'function outboundTransfer(address,address,uint256,bytes) returns (bytes)'
])

// Encode outboundTransfer function data
const transferData = routerInterface.encodeFunctionData('outboundTransfer', [
'0xac76d4a9985aba068dbae07bf5cc10be06a19f12', // L1 Testnet token address
recipient, // L1 recipient address
ethers.BigNumber.from('1000000000000000000'), // 1 token (18 decimals)
'0x' // Additional data (empty)
])

// Use time-based nonce
const nonce = Date.now() // Time-based nonce (milliseconds)

// Create transaction object
const unsignedTx = {
from: l2Signer.address,
to: L2_GATEWAY_ROUTER,
data: transferData,
value: ethers.BigNumber.from(0).toHexString(), // 0x00
gasLimit: ethers.BigNumber.from(1000000).toHexString(), // Sufficient gas
gasPrice: ethers.BigNumber.from(0).toHexString(), // 0x00 (gas-free)
nonce: nonce,
chainId: l2ChainId // L2 chain ID
}

// Sign transaction
const rawSignedTx = await l2Signer.signTransaction(unsignedTx)
console.log('Signed transaction:', rawSignedTx)

// Submit via API (see POST /api/v1/wallet/withdraw above)

Notes

  1. Chain ID: Must use L2 chain ID
  2. Gas Settings: L2 is a gas-free network, so gasPrice can be set to 0
  3. Nonce Options: Choose between sequential nonce or time-based nonce (Date.now())
  4. Transaction Flow: Create and sign on L2 → Submit via API → Automatic processing

Transaction Configuration Guidelines

Kaia L1 Transactions (Deposits)

  • Use standard Kaia gas pricing
  • KAIA required for gas fees
  • Include submission cost for L2 execution
  • Wait for L1 confirmation before expecting funds on L2

L2 Transactions (Withdrawals)

  • Gas price can be set to 0 (gas-free)
  • Set gas limit to a sufficient value
  • Use either sequential or time-based nonces
  • Transactions must target specific precompile/router addresses

Withdrawal Process

  1. Transaction Creation: Create and sign withdrawal transaction on L2
  2. API Submission: Call /api/v1/wallet/withdraw API
  3. Automatic Processing: System automatically handles L2 confirmation, batch submission, and Kaia L1 withdrawal