Skip to content

Build a Chain Abstracted App

For this tutorial, we will be building a chain abstracted app, powered by the Biconomy Modular Execution Environment!

Goals

We want to build an app with the following features:

Unified Balance

User sees a unified balance across all chains

Cross-Chain Transactions with Intents

User can deposit to any AAVE market in a single signature. Intents are used to move the liquidity around

Pay for Gas With ERC20 Tokens on Any Chain

The user will be able to pay for the execution of all transactions on all chains with on any chain where they have either native or ERC20 tokens

Withdraw from Multiple AAVE Positions with a Single Signature

The user can withdraw all of their AAVE positions across all chains with a single signature.

The entire interface should be extremely straightforward for the user, with no chain switching, bridging or managing gas.

Core concepts

The app which we build in this guide will leverage all the latest features available in AbstractJS SDK and the Biconomy stack! Some highlights:

  • Using a Unified Multichain Balance
  • Triggering Intents and Transactions in as single operation
  • Executing multiple transactions on multiple chains with a single signature
  • Using automatic orchestration provided by the Biconomy MEE Node

Step-by-Step Tutorial

Building the app

Creating a New Project

For this tutorial we'll be using bun to create a new project.

If you don't have bun, you can install it by running:

Install Bun
curl -fsSL https://bun.sh/install | bash

Then create a new project called chapp-example

Create a new project
mkdir chapp-example & cd ./chapp-example & bun init

Install Required Dependencies: AbstractJS and Viem

Install AbstractJS and Viem
bun add @biconomy/abstractjs viem @rhinestone/module-sdk@0.2.3

Create a TypeScript File

Create app.ts
touch app.ts

Connect to the Smart Account

Our chain abstracted app will be powered by the Biconomy Nexus smart account. Since we're working in a multichain environment, AbstractJS comes with a helpful utility function to manage instances of smart accounts across multiple chains.

In this example, we'll create a smart account which has an EOA wallet as the owner. In order to easily create an EOA for testing purposes, we'll create the EOA through a viem utility, by providing a private key.

Create EOA signer
const eoa = privateKeyToAccount('0x... Private Key Goes Here')

Then, let's connect to our Smart Account with the utility function:

Initialize Multichain Smart Account
const mcNexus = await toMultichainNexusAccount({
  chains: [optimism, base, polygon, arbitrum],
  transports: [http(), http(), http(), http()],
  signer: eoa
})

Initialize the meeClient

In order to execute transactions through the Biconomy MEE Node, we need to establish a connection to it. AbstractJS makes this easy with a helper function:

Connect to MEE Node
const meeClient = await createMeeClient({
  account: mcNexus
})

Fund the Smart Account Address

Fetch the Smart Account address on the chain where you want to fund your wallet. For example, let's fetch the address for Optimism

console.log(
  mcNexus.deploymentOn(optimism.id).address
)

Then send USDC to the Smart Account address!

Load the Required Smart Contracts

For this app, we'll need access to three contracts.

  • USDC Token Contract
  • aUSDC Token Contract
  • AAVE V3 Pool Contract

In order to load the AaveV3Pool contract, we can simply call the getMultichainContract utility function and load it up with the addresses of the AAVE Pool on different chains:

Multichain Contract
const mcAaveV3Pool = getMultichainContract({
  abi: parseAbi([
    "function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)",
    "function withdraw(address asset, uint256 amount, address to)"
  ]),
  deployments: [
    ["0x794a61358D6845594F94dc1DB02A252b5b4814aD", optimism.id],
    ["0xA238Dd80C259a72e81d7e4664a9801593F98d1c5", base.id],
    ["0x794a61358D6845594F94dc1DB02A252b5b4814aD", polygon.id],
    ["0x794a61358D6845594F94dc1DB02A252b5b4814aD", arbitrum.id]
  ],
});
Import and Use USDC
import { mcuSDC } from "./utils/tokens";
 
const address = mcUSDC.addressOn(optimism.id)
 
console.log(address)

Get the Unified Multichain Balance

Use the built-in getUnifiedERC20Balance function to fetch the unified ERC20 Balance.

const unifiedBalance = await getUnifiedERC20Balance({
  account: mcNexus,
  mcToken: mcUSD0
})

Execute a Cross-Chain Supply With Intents

Encode an Intent for Moving Funds to the Target Chain

We'll use a built-in AbstractJS function for encoding intents. This function will make sure that the tokens are transferred from whichever chain they're on to the target chain.

const supplyAmount = parseUnits('100', 6 ) // 100 USDC
 
if(unifiedBalance.balance < supplyAmount) {
  console.log("Not enough funds")
}
 
const intent = await mcNexus.build({
  type: 'intent',
  data: {
    mcToken: mcUSDC,
    amount: supplyAmount,
    chain: optimism
  }
})

Select Chain for Paying Gas

Working on MEE stack gives developers automatic access to cross-chain gas abstraction - meaning you can pay for gas on any chain, while executing on any other.

In practice - this means that no matter how much transactions we encode across chains - we'll pay the transaction fee only once and only on one chain.

Here, we will choose the chain with the biggest balance.

Selecting Biggest Balance for Gas
const payingGasOnChain = unifiedBalance.breakdown.reduce((max, current) => {
  return current.balance > max.balance ? current : max;
}).chainId;

Make Sure to Account for Slippage

When using intents and bridges, the amount you receive on the destination will be less than the amount provided. This is due to the fact that Solvers take fees for executing these cross-chain transfers.

For now, this tutorial will use a fixed slippage buffer - a hacky solution - until we add the composability stack documentation.

const slippageBuffer = parseUnits('0.5', 6)

Encode Approve + Execute

Now we will encode two transactions.

  1. Approve AAVE to Spend USDC on Optimism
  2. Execute Supply
const approveSupply: Instruction[] = [
  // Approve tx
  mcUSDC.on(optimism.id).approve({
    args: [
      mcAaveV3Pool.addressOn(optimism.id),
      supplyAmount
    ]
  }),
  // Supply tx
  mcAaveV3Pool.on(optimism.id).supply({
    args: [
      mcUSDC.addressOn(optimism.id), // asset to supply
      supplyAmount - slippageBuffer, // how much to supply
      zeroAddress, // on behalf of - zeroaddress if supplying for oneself
      0 // referral code
    ]
  })
]

Execute Supertransaction

Now let's execute the Supertransaction which supplies to AAVE across chains:

const { hash } = await meeClient.execute({
  instructions: [
    ...intent,
    ...approveSupply
  ],
  feeToken: {
    chainId: payingGasOnChain.id,
    address: mcUSDC.addressOn(payingGasOnChain.id)
  }
})

Track the Execution of Your Supertransaction

Use the built-in function to get the link to MEEScan

console.log(
  getMeeScanLink(hash)
)

Wait for the Supertransaction to Execute

const receipt = await meeClient.waitForSupertransactionReceipt({
  hash
})

Unwind AAVE Positions on Multiple Chains With a Single Signature

Fetch AAVE Positions Across Chains

We'll use the getUnifiedERC20Balance utility to fetch all aUSDC positions across chains.

Fetch aUSDC Balances
const mcAUSDC = getMultichainContract({
  abi: parseAbi([
    "function balanceOf(address owner) view returns (uint256)"
  ]),
  deployments: [
    ["0x625E7708f30cA75bfd92586e17077590C60eb4cD", optimism.id],
    ["0x forced to be brief here - add real address", base.id],
    ["0x625E7708f30cA75bfd92586e17077590C60eb4cD", polygon.id],
    ["0x625E7708f30cA75bfd92586e17077590C60eb4cD", arbitrum.id]
  ],
});
 
const aUSDCPositions = await getUnifiedERC20Balance({
  account: mcNexus,
  mcToken: mcAUSDC
})
 
const activePositions = aUSDCPositions.breakdown.filter(p => p.balance > 0n)

Encode Withdrawal Instructions

For each chain where we have a position, we need to encode two transactions:

  1. Approve AAVE Pool to burn our aUSDC
  2. Execute the withdrawal
Encode Withdrawals
const withdrawalInstructions = activePositions.flatMap(position => ([
  // Approve tx
  mcAUSDC.on(position.chainId).approve({
    args: [
      mcAaveV3Pool.addressOn(position.chainId),
      position.balance
    ]
  }),
  // Withdraw tx
  mcAaveV3Pool.on(position.chainId).withdraw({
    args: [
      mcUSDC.addressOn(position.chainId),
      position.balance,
      mcNexus.deploymentOn(position.chainId).address
    ]
  })
]))

Select Chain for Paying Gas

Let's check USDC balances across chains to choose where to pay gas from.

Get Unified Balance
const unifiedBalance = await getUnifiedERC20Balance({
  account: mcNexus,
  mcToken: mcUSDC
})
 
const payingGasOnChain = unifiedBalance.breakdown.reduce((max, current) => {
  return current.balance > max.balance ? current : max;
}).chainId;

Execute the Multichain Withdrawal

Now we can execute all of our withdrawal transactions with a single signature:

Execute Withdrawals
const { hash } = await meeClient.execute({
  instructions: withdrawalInstructions,
  feeToken: {
    chainId: payingGasOnChain.id,
    address: mcUSDC.addressOn(payingGasOnChain.id)
  }
})

Track the Execution Progress

Just like with supplying, we can track our transaction on MEEScan:

Get MEEScan Link
console.log(
  getMeeScanLink(hash)
)
 
const receipt = await meeClient.waitForSupertransactionReceipt({
  hash
})