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:
curl -fsSL https://bun.sh/install | bash
Then create a new project called chapp-example
mkdir chapp-example & cd ./chapp-example & bun init
Install Required Dependencies: AbstractJS and Viem
bun add @biconomy/abstractjs viem @rhinestone/module-sdk@0.2.3
Create a TypeScript File
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.
const eoa = privateKeyToAccount('0x... Private Key Goes Here')
Then, let's connect to our Smart Account with the utility function:
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:
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:
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 { 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.
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.
- Approve AAVE to Spend USDC on Optimism
- 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.
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:
- Approve AAVE Pool to burn our aUSDC
- Execute the withdrawal
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.
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:
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:
console.log(
getMeeScanLink(hash)
)
const receipt = await meeClient.waitForSupertransactionReceipt({
hash
})