Skip to content

Chain abstraction

ZeroDev is the first smart account solution to support cross-chain transactions, also sometimes known as "chain abstraction."

More specifically, a ZeroDev smart account can use its tokens from one chain on another chain, without bridging. Some examples are:

  • Stake ETH on Ethereum mainnet using ETH on Polygon.
  • Spend USDC on Base to purchase an NFT sold in USDT on Arbitrum.
  • Execute a transaction on Blast by paying gas in DAI from Optimism.

Features

Cross-chain without bridging

Don't ask your users to visit a bridge. With ZeroDev, your users can make cross-chain transactions just like how they make normal transactions, without ever leaving your app.

Unparalleled speed

Cross-chain transactions with ZeroDev are very fast, thanks to intents -- solvers compete on the destination chain to fill your intents as quickly as possible.

Deep liquidity

Most "chain abstraction" solutions on the market are toys that fail under any real amount of traffic. ZeroDev's cross-chain transactions network is built on top of protocols with the deepest liquidity, such as Relay and Across.

Code example

Here is a complete code example for using CAB. You can run the example by cloning the repo.

Setting up chain abstraction

Installation

Chain abstraction is implemented with the @zerodev/intent package:

npm
npm i @zerodev/intent @zerodev/sdk

Choosing a validator

While chain abstraction works with any validator, we highly recommend using a multi-chain validator like @zerodev/multi-chain-ecdsa-validator for new projects. Multi-chain validators are optimized for cross-chain transactions because they can sign for multiple input chains with a single signature, making the process more efficient.

If you're starting a new project, you can install the multi-chain ECDSA validator:

npm
npm i @zerodev/multi-chain-ecdsa-validator

Setting up the Kernel account

First, make sure you know how to create ZeroDev accounts.

The flow for creating a chain-abstracted ZeroDev account is the same, with the following exceptions:

  • For your kernelVersion, make sure to use KERNEL_V3_2 or above.
const kernelVersion = KERNEL_V3_2;
  • When setting up the kernelAccount, set up the initConfig to install the intent executor:
import { installIntentExecutor, INTENT_V0_3 } from '@zerodev/intent'
 
const kernelAccount = await createKernelAccount(publicClient, {
  plugins: {
    sudo: ecdsaValidator,
  },
  kernelVersion,
  entryPoint,
  initConfig: [installIntentExecutor(INTENT_V0_3)]
})
  • Instead of using the normal createKernelAccountClient, use createIntentClient instead:
const intentClient = createIntentClient({
  chain,
  account: kernelAccount,
  bundlerTransport: http(bundlerRpc),
  version: INTENT_V0_3
})

Using chain abstraction

At the core of chain abstraction is the idea of a "chain-abstracted balance" (CAB), which is a unified balance across all the chains. For example, if you have 100 USDC on Base and 200 on Arbitrum, then you have 300 "chain-abstracted USDC" which you can spend on any chain.

Getting Chain-abstracted Balances

To get your account's chain abstracted balances, use getCAB:

const cab = await intentClient.getCAB({
  // Specify any networks you want to aggregate.
  // If you skip this flag, it will aggregate from all the networks we support, but it may be slower.
  networks: [arbitrum.id, base.id],
 
  // Specify the tokens you want to aggregate balances for.
  // If you skip this flag, it will return all the tokens we support, but it may be slower.
  tokenTickers: ["USDC", "WETH"],
})

This will return not just the unified balance, but also the breakdown of the balance across chains, so that you know on which chains you actually have the tokens.

Spending chain-abstracted balance

To spend your CAB on a transaction, use sendUserIntent:

const result = await intentClient.sendUserIntent({
  // You can batch any number of calls here
  calls: [
    {
      // USDC on base
      to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
      value: BigInt(0),
      data: encodeFunctionData({
        abi: erc20Abi,
        functionName: 'transfer',
        args: [account.address, parseUnits('1', 6)],
      }),
    },
  ],
  outputTokens: [
    {
      chainId: base.id,
      address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on base
      amount: parseUnits('1', 6), // 1 USDC
    },
  ],
})
 
// Wait for the intent to be executed on the destination chain
const receipt = await intentClient.waitForUserIntentExecutionReceipt({
  uiHash: result.outputUiHash.uiHash,
});
console.log(`Intent executed on chain: ${receipt?.executionChainId}`);

Here:

  • calls is a batch of calls to execute atomically.
  • outputTokens is the chain-abstracted token that your calls require. For example, if your calls are looking to spend 500 USDC to buy an NFT, then you'd specify 500 USDC with the outputTokens.
    • If you want to use ETH as the output token, set the address to zeroAddress.

Note that in this example, it doesn't matter whether you actually have USDC on Base. So long as you have enough USDC across all the chains, the transaction will execute.

Specifying input tokens

By default, when you spend CAB, the SDK uses tokens from chains with the highest balance. If you need more fine-grained control, you can manually specify the input tokens with inputTokens, which makes the API look like this:

const result = await intentClient.sendUserIntent({
  calls: [
    // ...
  ],
  inputTokens: [
    // ...
  ],
  outputTokens: [
    // ...
  ],
})

Here's a real example for using USDC on Arbitrum to execute a transaction on Base:

const result = await intentClient.sendUserIntent({
  calls: [
    {
      // USDC on base
      to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
      value: BigInt(0),
      data: encodeFunctionData({
        abi: erc20Abi,
        functionName: 'transfer',
        args: [account.address, parseUnits('1', 6)],
      }),
    },
  ],
  inputTokens: [
    {
      chainId: arbitrum.id,
      address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // USDC on arb
    },
  ],
  outputTokens: [
    {
      chainId: base.id,
      address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on base
      amount: parseUnits('1', 6), // 1 USDC
    },
  ],
})

Note that you do NOT need to specify an amount for your inputTokens. Your input token amount is estimated from your output token amount, plus gas (assuming you want to pay gas with the input tokens -- see below).

Gas configurations

When using intents, you may use the gasToken flag to configure how to pay gas.

  • By default (with no gasToken specified), gas is paid in CAB (input tokens) itself.
  • If you specify gasToken: 'NATIVE', the account will pay gas with the native token (e.g. ETH).
  • If you specify gasToken: 'SPONSORED', you will be sponsoring gas for the account.

Waiting for intents to resolve

Cross-chain transactions happen in multiple steps: first opening on the input chains (where your tokens come from), then executing on the destination chain. The sendUserIntent function returns hash identifiers that let you track both steps:

// Send the intent
const result = await intentClient.sendUserIntent({
  // ... your intent configuration ...
});
 
 
// Wait for the intent to be opened on all input chains
// NOTE: if you just want to wait for the intent to fully resolve, you don't need to wait
// for the input intents.  Just wait for the execution intent.
await Promise.all(
  result.inputsUiHash.map(async (data) => {
    const openReceipts = await intentClient.waitForUserIntentOpenReceipt({
      uiHash: data.uiHash,
    });
    console.log(
      `Intent opened on chain ${openReceipts?.openChainId} with transaction hash: ${openReceipts?.receipt.transactionHash}`
    );
  })
);
 
// Wait for final execution on the destination chain
const receipt = await intentClient.waitForUserIntentExecutionReceipt({
  uiHash: result.outputUiHash.uiHash,
});
console.log(
  `Intent executed on chain: ${receipt?.executionChainId} with transaction hash: ${receipt?.receipt.transactionHash}`
);

This tracking system ensures you have complete visibility into your cross-chain transaction's progress, from initiation through to final execution. If you simply want to wait until the intent fully resolves, just waiting for the execution receipt is enough.

Supported tokens and networks

Feel free to reach out to us with the link on top of this page if you want to see more tokens or networks supported.

Mainnet

We support USDC, USDT, and ETH for cross-chain transactions, on the following networks:

  • Ethereum mainnet
  • Arbitrum
  • Optimism
  • Base
  • Polygon
  • Binance Smart Chain (BSC)

Testnet

We support USDC and ETH on the following testnets:

  • Ethereum Sepolia
  • Base Sepolia

Pricing

  • Developer pricing (charged to the developer)

    • Chain Abstraction is rate limited for projects on the free or Growth plans.
    • To use Chain Abstraction in production, please subscribe to the Scale or Enterprise plan.
  • User pricing (charged to the user by the input token)

    • For projects on the Scale plan, we charge:
      • 20 basis points (0.2%) for cross-chain transactions below $500.
      • 10 basis points (0.1%) for cross-chain transactions worth $500 or above.
    • For projects on the enterprise plan, the rates are negotiable.