Zero to Hero Guide: Building a Chain Abstracted Application
This will be a zero-to-hero introduction to the Klaster SDK. Here, we will explore all of the main concepts pertaining to the development of chain abstracted apps and flows.
In this tutorial, you will get all the required steps to build a chain abstracted application. If you're interested in what a chain abstracted application looks like, check out our video on AbstraktAAVE - a chain abstracted implementation of AAVE.
You can also use the app yourself at:
Install Klaster and Viem
npm i klaster-sdk viem
Create a signer object
Usign viem, create a signer object - which will be used to sign transactions.
import {
buildMultichainReadonlyClient,
buildRpcInfo,
initKlaster,
klasterNodeHost,
loadBicoV2Account,
} from "klaster-sdk";
import { createWalletClient, custom, http } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { arbitrum, base, optimism, polygon, scroll } from "viem/chains";
// When in browser environment create a signer
// which uses the injected wallet (e.g. MetaMask, Rabby, ...)
const signer = createWalletClient({
transport: custom((window as any).ethereum),
});
// When in non-browser environment you can use private key
// to sign messages.
const privateKey = generatePrivateKey();
const signerAccount = privateKeyToAccount(privateKey);
const signer = createWalletClient({
transport: http(),
});
Initialize Klaster SDK
To initialize the Klaster SDK, you must provide it with the following information:
- Klaster Node URL
- ERC4337 Account Provider
You can use multiple account providers (check documentation reference for supported account providers). In this tutorial, we will use the Biconomy account provider:
// Fetch the address from viem. You might need to call signer.requestAddresses()
// depending on your injected Web3 provider.
const [address] = await signer.getAddresses();
// Initializing the Klaster SDK with Biconomy as the smart contract account
// provider.
const klaster = await initKlaster({
accountInitData: loadBicoV2Account({
owner: address, // Fetch
}),
nodeUrl: klasterNodeHost.default,
});
Initialize a multichain readonly RPC client
In order to achieve certain functionality for chain abstraction, such as getting a unified token balance across chains or encoding bridging actions - you will need to have a public RPC for every chain you wish to interact with. Do note that this is only a public RPC, you don't need to have the abilty to write to chain - as this is handled by the Klaster nodes.
// Build a multichain client by calling the buildMultichainReadonlyClient
// and passing the buildRpcInfo objects into the array
const mcClient = buildMultichainReadonlyClient([
buildRpcInfo(optimism.id, "<op-rpc-url>"),
buildRpcInfo(base.id, "<base-rpc-url>"),
]);
Since this is a tutorial project, we can use the viem
default RPCs.
// This is a hacky way of doing this, just to get you quickly started
// with the tutorial. Please don't initialize the client like this in
// production projects.
const mcClient = buildMultichainReadonlyClient(
[optimism, base, polygon, arbitrum, scroll].map((x) => {
return {
chainId: x.id,
rpcUrl: x.rpcUrls.default.http[0],
};
})
);
Map token deployments across multiple blockchains
As well as working with the Multichain RPC provider, the Klaster SDK works best when provided with MultichainTokenMapping
objects. These objects represent mappings of tokens you consider equivalent across various blockchains. One example would
be USDC - which is widely deployed by Circle across multiple blockchains.
You can use the Circle Token Deployments to see which address the canonical version of which token is deployed to.
However, nothing limits you to using only offical token deployments. If you consider the axlUSDC
to be equivalent to cannonical
USDC
- you can simply map them together and the Klaster SDK will treat them as if they're the same token. You can provide one
token per chain.
// Build a token mapping by calling the buildTokenMapping function and passing instances of
// deployment object into the array. These represent the chainId β address mappings.
const mcUSDC = buildTokenMapping([
deployment(optimism.id, "0x<USDC-ON-OPTIMISM-ADDRESS>"),
deployment(base.id, "0x<USDC-ON-OPTIMISM-ADDRESS>"),
]);
The Klaster SDK exposes some multichain tokens by default. These are mcUSDC
and mcUSDT
. They are available to you
as exported constants. They expose deployments on a lot of blockchains so it's important that you truncate them before
usage only to blockchains which you use.
// A lambda which intersects the chains available in the
// token mapping with the ones available in the multichain client.
// In general, you should not be using this in a production project.
// Instead, you should create your own tokenmappings by the method shown
// above.
const intersectTokenAndClients = (
token: MultichainTokenMapping,
mcClient: MultichainClient
) => {
return token.filter((deployment) =>
mcClient.chainsRpcInfo
.map((info) => info.chainId)
.includes(deployment.chainId)
);
};
// Store the intersection of the Klaster provided token and the chains your project is using.
const mUSDC = intersectTokenAndClients(mcUSDC, mcClient);
Create a Bridging Plugin
Klaster introduces a concept of Bridging Plugigs. Yoa can think of them as templates, which the Klaster SDK
uses to encode and execute a bridging action whenever it needs to. Since Klaster is an agnostic protocol and SDK,
every function that might require bridging can accept the function of type BridgePlugin
as input.
For this tutorial we have prepared a LiFi bridging plugin example.
But in general, what is a Klaster Bridging Plugin?
It's a simple function which provides a data
object containing things needed to encode a brdiging transaction such as
sourceChainId
, destinationChainId
, sourceToken
, destinationToken
, amount
, etc... and expects an object in return
which tells the Klaster SDK i) which transactions need to be executed ii) how much will be received on destination.
For this tutorial, we can create a LiFi bridging plugin, which will leverage LiFi Aggregation to get us the fastest route available for every transfer. Since Klaster is primarily a user experience oriented framework, the speed of execution is paramout!
First, install the LiFi SDK
npm i @lifi/sdk
Then write the plugin:
import { getRoutes, RoutesRequest } from "@lifi/sdk";
import { Address, batchTx, BridgePlugin, rawTx } from "klaster-sdk";
import { Hex } from "viem";
export const liFiBrigePlugin: BridgePlugin = async (data) => {
const routesRequest: RoutesRequest = {
fromChainId: data.sourceChainId,
toChainId: data.destinationChainId,
fromTokenAddress: data.sourceToken,
toTokenAddress: data.destinationToken,
fromAmount: data.amount.toString(),
options: {
order: "FASTEST",
},
};
const result = await getRoutes(routesRequest);
const route = result.routes.at(0)
if (!route) {
throw Error('...');
}
const routeSteps = route.steps.map(step => {
if(!step.transactionRequest) { throw Error('...') }
const { to, gasLimit, data, value} = step.transactionRequest
if(!to || !gasLimit || !data || !value) { throw Error('...') }
return rawTx({
to: to as Address,
gasLimit: BigInt(gasLimit),
data: data as Hex,
value: BigInt(value)
})
})
return {
receivedOnDestination: BigInt(route.toAmountMin),
txBatch: batchTx(data.sourceChainId, routeSteps)
}
};
It's as simple as that! Now - you Klaster SDK has LiFi support π₯³
Fetching a Unified Balance
In order to achieve true chain abstraction, the user should be presented with a unified ERC20 balance across all of the chains that the app is using.
For example, in the Klaster AbstraktAAVE Demo app, you can see the unified balance:
and then the breakdown per chain:
In order to achieve this, you can use the Multichain token and Multichain RPC objects from earlier:
const uBalance = await mcClient.getUnifiedErc20Balance({
tokenMapping: mUSDC,
account: klaster.account,
});
// Total balance across all used chains, expressed in base units
uBalance.balance;
// Breakdown of balances across each separate blockchain
uBalance.breakdown;
// The decimals of the token. In order for tokenMapping to be created,
// all instances must have the same number of decimals.
uBalance.decimals;
Encoding the bridging + execution actions
Okay! Finally our entire setup is complete. This is most definitely one of the more complex cases which Klaster covers - a fully chain abstracted app, so the amount of setup is a bit higher. For simpler cases, such as a unified deposit address of one-click checkout flows, a lot of these steps can be omitted.
So, for this demo, we'll say that we want to send the amount we receive from bridging on the destination chain to some other address. This in an intentionally simplistic case, and it could have been solved by just using LiFi bridge function, but for the purposes of this tutorial it just demonstrates how Klaster works.
Encoding the bridging operations
In your own project, you'll be able to encode arbitrarily complex actions across any number of blockchains. For example, in
the AAVE case, after a token lands on the destination chain, the supply
function is called on the AAVE Smart Contract
Anyways let's continue. The SDK is intelligent enough to calculate on its own the required steps to get some amount of tokens on some desired destination chain.
Let's assume a following situation. You have:
- 10 USDC on Polygon
- 20 USDC on Optimism
- 12 USDC on Scroll
and you're trying to call some function on Base with 35 USDC while having zero USDC on Base. What the Klaster SDK will do is calculate a route for bridging your tokens:
e.g. take 20 USDC (total balance) from OP, 12 USDC (total balance) from Scroll and 3 USDC (+ additional for what was lost to bridge fees when bridging from chains where you took the total balance) from Polygon and bridge it all to Base. Once the funds arrive to Base - call the desired function there!
For most cases, the SDK will be able to calculate te exact routes for bridging to get your desired outcome. If this is not possible, you can always encode them yourself.
The function that does the calculations is called encodeBridgingOps
. Simply pass all of the required parameters and as
an output you'll get a series of steps (EVM Transactions) to be executed on one or more blockchains to get your desired amount
onto your desired destinationChain
const bridgingOps = await encodeBridgingOps({
tokenMapping: mUSDC,
account: klaster.account,
amount: uBalance.balance - parseUnits("1", uBalance.decimals), // Don't send entire balance
bridgePlugin: liFiBrigePlugin,
client: mcClient,
destinationChainId: base.id,
unifiedBalance: uBalance,
});
Encoding the desired action
Now we can encode the transfer
call on the destination chain. This is done with the call to the Klaster SDK rawTx
helper
function:
const sendERC20Op = rawTx({
gasLimit: 100000n,
to: destChainTokenAddress,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [recipient, bridgingOps.totalReceivedOnDestination],
}),
});
Note one thing here, the usage of bridgingOps.totalReceivedOnDestination
- this is a variable which has calculated
how much assets will be available on the destination chain after bridging has completed. If you had zero on the destination
then this will be the total amount available. If you had > 0
available then the total available will be
totalReceivedOnDestination + balanceOnDestination
Creating an Interchain Transaction
Now, we can use all of the transactions we have created to encode an interchain transaction or an iTx. An iTx is an object containing one or more transactions across one or more blockchains. In our case, the iTx will contain
- All necessary approve + bridging transactions calculated by the
encodeBridgingOps
function - on the source chain(s) - The send transaction on the destination chain
To create an iTx, we use the helper function buildItx
.
const iTx = buildItx({
// BridgingOPs + Execution on the destination chain
// added as steps to the iTx
steps: bridgingOps.steps.concat(singleTx(destinationChainId, sendERC20Op)),
// Klaster works with cross-chain gas abstraction. This instructs the Klaster
// nodes to take USDC on Optimism as tx fee payment.
feeTx: klaster.encodePaymentFee(optimism.id, "USDC"),
});
Executing the Interchain Transaction
Now that we have everything we need encoded in the iTx object, we can send it to the Klaster network for execution.
Getting a quote
Executing an Interchain transaction is a two-step process. The first part of the process is getting a quote
from the
Klaster Network. The quote will tell you how much the node wants to charge for the execution of the transaction in the
token which you set in the feeTx
field when building an iTx.
To get the quote, call:
const quote = await klaster.getQuote(iTx);
// The amount of USD that the node is willing to execute the iTx for
quote.tokenValue;
Signing the quote
If you're satisfied with the quoted execution price, prompt the user to sign the iTx hash. You can get the hash from the
quote
object. By signing the iTx hash you're giving the Klaster Nodes approval to execute the transactions within the
iTx. This is cryptographically verified by smart contracts on every chain where the data is being executed - so the nodes
are unable to execute anything without the explicit approval from the user.
const signed = await signer.signMessage({
message: {
raw: quote.itxHash,
},
account: signerAccount,
});
Sending the iTx for execution
After the user has signed the iTx hash, pass the QuoteResponse
together with the signed hash to the execute
function.
const result = await klaster.execute(quote, signed)
Tracking the execution
After the iTx has been received by the Klaster Network, you will receive a result from the execute
function. In that result,
you will have the iTx hash. You can go to the Klaster Explorer to check the status of the hash:
https://explorer.klaster.io/details/{iTxHash}
Conclusion
Congrats - you have sucessfully executed your first Interchain Transaction and prepared your project for chain abstraction.