Dispatching an Asset Transfer
Let's dive into interoperability by performing a complex cross-chain operation programmatically. While this interoperability touches upon several technical concepts like asset standards, altVMs, indexing, light clients, and storage proofs, our main goal is to execute an end-to-end operation and understand the components involved. We'll explore the theoretical foundations in later chapters.
We will implement a TypeScript program that can manage EVM (Ethereum Virtual Machine) wallets, interact with multiple chains, and dispatch asset transfers through smart contract interactions. Finally, we will query an indexing service to trace our transfer's progress. While this code works in both frontend and backend environments thanks to TypeScript, we recommend Rust for production backends.
Setting up the project
mkdir asset-dispatcher
Create a flake.nix
with the following configuration. This sets up Deno for your local development environment and adds code formatters (run with nix fmt
). Enable the development environment by running nix develop
.
{
description = "Example Union TypeScript SDK usage";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs =
inputs@{ flake-parts, nixpkgs, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [
"aarch64-darwin"
"aarch64-linux"
"x86_64-darwin"
"x86_64-linux"
];
perSystem =
{
config,
self',
inputs',
pkgs,
lib,
system,
...
}:
let
denortPerSystem = {
"aarch64-darwin" = {
target = "aarch64-apple-darwin";
sha256 = lib.fakeHash;
};
"aarch64-linux" = {
target = "aarch64-unknown-linux-gnu";
sha256 = lib.fakeHash;
};
"x86_64-darwin" = {
target = "x86_64-apple-darwin";
sha256 = lib.fakeHash;
};
"x86_64-linux" = {
target = "x86_64-unknown-linux-gnu";
sha256 = "sha256-7reSKyqBLw47HLK5AdgqL1+qW+yRP98xljtcnp69sw4=";
};
}.${system};
platform = builtins.trace "Sourcing DENORT with target: ${denortPerSystem.target}" denortPerSystem.target;
packageJson = lib.importJSON ./package.json;
pnpm = pkgs.pnpm;
deno = pkgs.deno;
denort = pkgs.fetchzip {
url = "https://dl.deno.land/release/v${deno.version}/denort-${platform}.zip";
sha256 = denortPerSystem.sha256;
stripRoot = false;
};
in
{
packages = {
default = pkgs.buildNpmPackage rec {
pname = packageJson.name;
inherit (packageJson) version;
src = ./.;
npmConfigHook = pnpm.configHook;
nativeBuildInputs = [
deno
denort
];
npmDepsHash = "sha256-ZztV7LzNfN2fGX1+cUq77DQLfxYPiCh4IK/fk/HbrAE=";
pnpmDeps = pnpm.fetchDeps {
inherit
pname
src
version
;
hash = npmDepsHash;
};
npmDeps = pnpmDeps;
doCheck = true;
checkPhase = ''
deno check 'src/**/*.ts'
'';
buildPhase = ''
runHook preBuild
DENORT_BIN=${denort}/denort deno compile --no-remote --output out src/index.ts
runHook postBuild
'';
installPhase = ''
mkdir -p $out
cp ./out $out
'';
doDist = false;
};
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs
pnpm
deno
#nodePackages_latest.typescript-language-server
biome
nixfmt
];
};
};
};
}
Next, create src/index.ts
. This will contain most of our logic. Add a simple test:
console.log("hello, world");
Run it with deno run src/index.ts
to verify your environment works. You should see hello, world
in your terminal.
Managing wallets
Let's modify index.ts
to create and fund two wallets. Note: This example hardcodes mnemonics for demonstration purposes. In production, always use proper key management services.
import { createWalletClient, http } from "npm:viem";
import { mnemonicToAccount } from "npm:viem/accounts";
import { holesky, sepolia } from "npm:viem/chains";
const sepoliaWallet = createWalletClient({
account: mnemonicToAccount(mnemonic),
chain: sepolia,
transport: http(),
});
const holeskyWallet = createWalletClient({
account: mnemonicToAccount(mnemonic),
chain: holesky,
transport: http(),
});
console.log(`Sepolia address: ${sepoliaWallet.account.address}`);
console.log(`Holesky address: ${holeskyWallet.account.address}`);
Create two variables, mnemonic1
and mnemonic2
, each containing a 12-word sentence (space-separated) as a string. Run the script and save your addresses. You can use the same mnemonic if you prefer.
To fund our Sepolia address for contract interactions, we'll use a faucet.
Let's verify our faucet funding by checking the balance: create-client
import { createPublicClient, formatEther } from "npm:viem";
const sepoliaClient = createPublicClient({
chain: sepolia,
transport: http(),
});
const gasBalance = await sepoliaClient.getBalance({
address: sepoliaWallet.account.address,
});
const erc20Balance = await sepoliaClient.readContract({
address: WETH_ADDRESS,
abi: erc20Abi,
functionName: "balanceOf",
args: [sepoliaWallet.account.address],
});
console.log([
`Sepolia Gas Balance: ${formatEther(gasBalance)} ETH (${gasBalance} wei)`,
`Sepolia Token Balance: ${formatEther(erc20Balance)} WETH`,
].join("\n"));
We use formatEther
for human-readable output. The parenthesized value shows the raw balance. We'll discuss sats, decimals, and asset standards later, but note that ETH is stored in wei on-chain (1 ETH = 10^18 wei).
At this point, we have secured testnet funds and set up a local wallet (though not production-ready).
Performing the Asset Transfer
To do the bridge operation, we'll directly interact with the Union contracts through their ABI. We will use the Union SDK package to import some types and the required ABIs. The sdk provides both low-level bindings to various contracts, as well as backend clients and effects based on effect.website.
For now we are going to use the raw bindings, to show what happens under the hood. To perform an asset transfer, we need to perform 3 distinct steps:
- Gather configuration parameters.
- Approve the contracts.
- Sending the bridge transfer.
For step 1. we will rely on etherscan and the Union API. In production you might want to store hardcoded mappings, or dynamically fetch these values from your own APIs. Constructing the transaction is simple for an asset transfer. This is also the stage where we might add 1-click swaps, or DEX integration later down the road.
Although step 3 seems trivial, it is actually quite annoying when dealing with multiple, independent ecosystems. That's why we are doing EVM to EVM for now, so we are only dealing with one execution environment implementation.
Configuration Parameters
Since Union leverages channels, we will need to query the channel-id to use between Sepolia and Holesky. We're using the ucs03-zkgm-0
protocol, so that's what we'll filter on. The v2_channel_recommendations
shows officially supported channels by the Union team.
query RecommendedZkgmChannels @cached(ttl: 60) {
v2_channels(args: {
p_limit: 5,
p_recommended: true,
p_version: "ucs03-zkgm-0"
}) {
source_universal_chain_id
source_client_id
source_connection_id
source_channel_id
source_port_id
destination_universal_chain_id
destination_client_id
destination_connection_id
destination_channel_id
destination_port_id
}
}
For our transfer, we are interested in the source_channel_id
for Sepolia (ethereum.11155111
).
Since we are doing a WETH transfer, we can use etherscan to find the asset parameters (symbol, decimals and name). Union does verify onchain that the provided parameters are correct. We do pass them to the contract because we want to calculate the packet hash ahead of time. You might wonder why we even use these values in the contract? That is to ensure that when Union instantiates a new asset on the destination chain, it is configured correctly (same symbol, decimals, and name).
Per chain, we can find the Union contracts here. For testnet deployments, these might be updated as of writing this book.
Finally we need to obtain the quote token address (the address of the asset on the destination side).
query GetTransferRequestDetails {
v2_util_get_transfer_request_details(args: {
p_source_universal_chain_id: "union.union-testnet-10",
p_destination_universal_chain_id: "ethereum.11155111",
p_base_token: "0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9"
}) {
quote_token
source_channel_id
destination_channel_id
already_exists
wrap_direction
}
}
This should return
{
"data": {
"get_wrapped_transfer_request_details": [
{
"quote_token": "0x685a6d912eced4bdd441e58f7c84732ceccbd1e4",
"source_channel_id": 8,
"destination_channel_id": 47,
"already_exists": true
}
]
}
}
The source_channel_id
should match the channel from the v2_channel_recommendations
query.
The quote_token
is deterministically generated depending on the contract addresses and channel_ids. If already_exists
is false, the Union contract on the destination chain will instantiate a new asset, hence why the deterministically derived address algorithm is so important.
Approvals
Under the hood, the Union contract will withdraw funds from our account before bridging them to Holesky. This withdrawal is normally not allowed (for security reasons, imagine if smart contracts were allowed to just remove user funds!), so we need to approve
the Union contract to allow it to withdraw.
import { erc20Abi } from "npm:viem";
await sepoliaWallet.writeContract({
address: "0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9",
abi: erc20Abi,
functionName: "approve",
args: [ucs03address, 100000000000n],
});
For convenience, we are allowing the contract MaxUint256
, so that we do not need to do further approvals. From now on, the Union ucs03 contract can withdraw WETH on Sepolia.
Bridging
Executing the actual bridge operation seems like quite a lot of lines of code. Later we will use the alternative typescript client and effects API, to simplify the flow.
When we interact with the send
entrypoint, we submit a program. Union's bridge standard leverages a lightweight, non-Turing complete VM. That way, we can do 1-click swaps, forwards, or other arbitrary logic. The args
for our call in this case is the Batch
instruction, which is effectively a list of instructions to execute. Inside the batch, we have two FungibleAssetOrder
s. The first order is transferring wrapped Eth using a 1:1 ratio (meaning that on the receiving side, the user will receive 100% of the amount). The second order has a 1:0 ratio, meaning that the user receives nothing on the destination side. Effectively, we are 'tipping' the protocol here. An alternative way to ensure this transfer is funded, is altering the ratio of the first transfer. For example, a 100:99 ratio would be a 1% transfer fee.
import { Instruction } from "npm:@unionlabs/sdk/ucs03";
import { ucs03abi } from "npm:@unionlabs/sdk/evm/abi";
import { type Hex, toHex } from "npm:viem";
function generateSalt() {
const rawSalt = new Uint8Array(32);
crypto.getRandomValues(rawSalt);
return toHex(rawSalt) as Hex;
}
// We're actually enqueuing two transfers, the main transfer, and fee.
const instruction = new Instruction.Batch({
operand: [
// Our main transfer.
new Instruction.FungibleAssetOrder({
operand: [
sepoliaWallet.account.address,
holeskyWallet.account.address,
WETH_ADDRESS,
4n,
// symbol
"WETH",
// name
"Wrapped Ether",
// decimals
18,
// path
0n,
// quote token
"0xb476983cc7853797fc5adc4bcad39b277bc79656",
// quote amount
4n,
],
}),
// Our fee transfer.
new Instruction.FungibleAssetOrder({
operand: [
sepoliaWallet.account.address,
holeskyWallet.account.address,
WETH_ADDRESS,
1n,
// symbol
"WETH",
// name
"Wrapped Ether",
// decimals
18,
// path
0n,
// quote token
"0xb476983cc7853797fc5adc4bcad39b277bc79656",
// quote amount
0n,
],
}),
],
});
const transferHash = await sepoliaWallet.writeContract({
abi: ucs03abi,
functionName: "send",
address: ucs03address,
args: [
// obtained from the graphql Channels query
sourceChannelId,
// this transfer is timeout out by timestamp, so we set height to 0.
0n,
// The actual timeout. It is current time + 2 hours.
BigInt(Math.floor(Date.now() / 1000) + 7200),
generateSalt(),
{
opcode: instruction.opcode,
version: instruction.version,
operand: Instruction.encodeAbi(instruction),
},
],
});
The denomAddress is the ERC20 address of the asset we want to send. You might notice that regular ETH does not have an address, because it is not an ERC20. To perform the transfer, ETH must be wrapped to WETH (optional if you already own WETH):
import { parseEther } from "npm:viem";
// WETH ABI - we only need the deposit function for wrapping
const WETH_ABI = [
{
name: "deposit",
type: "function",
stateMutability: "payable",
inputs: [],
outputs: [],
},
] as const;
// Create the wallet client and transaction
const hash = await sepoliaWallet.writeContract({
address: WETH_ADDRESS,
abi: WETH_ABI,
functionName: "deposit",
value: parseEther("0.0001"), // Amount of ETH to wrap
});
console.log(`Wrapping ETH: ${hash}`);
Once this transaction is included, the transfer is enqueued and will be picked up by a solver. Next we should monitor the transfer progression using an indexer. The easiest solution is [graphql.union.build], which is powered by [hubble
]. Later we will endeavour to obtain the data directly from public RPCs as well.
Tracking Transfer Progression
Once the transfer is enqueued onchain, we go through a pipeline of backend operations, which normally are opaque to the enduser, but useful for us for debugging (and fun to look at). Union refers to these steps as Traces
, and they are indexed and stored for us by Hubble. Some of these include:
PACKET_SEND
PACKET_SEND_LC_UPDATE_L0
PACKET_RECV
PACKET_ACK
The PACKET_SEND
was actually us performing the transfer. The other steps are executed by solvers. Later we will write a solver to explore what each entails.
To get the tracing data, we'll make a Graphql query. For now we will just use fetch
calls, but there are many high quality graphql clients around.
const query = `
query {
v2_transfers(where: {transfer_send_transaction_hash:{_eq: "${transferHash}"}}) {
traces {
type
height
chain {
display_name
universal_chain_id
}
}
}
}`;
const result = await new Promise((resolve, reject) => {
const interval = setInterval(async () => {
try {
const request = fetch("https://graphql.union.build/v1/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
variables: {},
}),
});
const response = await request;
const json = await response.json();
const transfers = json.data["v2_transfers"];
console.log({ json });
if (transfers?.length) {
clearInterval(interval);
resolve(transfers);
}
} catch (err) {
clearInterval(interval);
reject(err);
}
}, 5_000);
});
console.log(result);
For example, for the transaction hash 0xa7389117b99b7de4dcd71dc2acbe21d42826dd4d35174c72f23c0adb64144863
, we get the following data:
{
"data": {
"v2_transfers": [
{
"traces": [
{
"type": "PACKET_SEND",
"height": 7839514,
"chain": {
"display_name": "Sepolia",
"universal_chain_id": "11155111.sepolia"
}
},
{
"type": "PACKET_SEND_LC_UPDATE_L0",
"height": null,
"chain": {
"display_name": "Union Testnet 9",
"universal_chain_id": "union-testnet-9.union"
}
},
{
"type": "PACKET_RECV",
"height": null,
"chain": {
"display_name": "Union Testnet 9",
"universal_chain_id": "union-testnet-9.union"
}
},
{
"type": "WRITE_ACK",
"height": null,
"chain": {
"display_name": "Union Testnet 9",
"universal_chain_id": "union-testnet-9.union"
}
},
{
"type": "WRITE_ACK_LC_UPDATE_L0",
"height": null,
"chain": {
"display_name": "Sepolia",
"universal_chain_id": "11155111.sepolia"
}
},
{
"type": "PACKET_ACK",
"height": null,
"chain": {
"display_name": "Sepolia",
"universal_chain_id": "11155111.sepolia"
}
}
]
}
]
}
}
Universal chain IDs are chain identifiers specifically used by Union, which are, as the name implies, universally unique. The reason for deviating from what the chains themselves use, is described here.
If we want to monitor the progression of a transfer, we would poll this query. There are three important trace types to watch for.
PACKET_SEND
: our transaction was included on the source chain. From this moment on, explorer links using the transaction hash should return data. (on average, the Union API is about 5-10 seconds faster than Etherscan though.)PACKET_RECV
: the relayer has submitted a proof and the packet for the transfer. Funds are now usable on the destination side. The transfer flow is now 'completed' from the user's perspective.PACKET_ACK
: the relayer has acknowledged the transfer on the source chain. If the open-filling API was used, this event will also trigger payment for the solver. This is only of interest for solvers/backend engineers.
Once we see the PACKET_RECV
event, our funds will be usable on Holesky. The traces after that are used by the system to pay the solver, and maintain bookkeeping.
We can query Holesky for our balance to verify that we received funds:
const holeskyClient = createPublicClient({
chain: holesky,
transport: http(),
});
const holeskyBalance = await holeskyClient.readContract({
address: "0xb476983cc7853797fc5adc4bcad39b277bc79656",
abi: erc20Abi,
functionName: "balanceOf",
args: [holeskyWallet.account.address],
});
const formattedBalance = gasBalance / 10n ** BigInt(18);
console.log(
`Token balance: ${formatEther(formattedBalance)} (${holeskyBalance})`,
);
This should now return the amount sent in the first FungibleAssetOrder
.
Summary
This was a hands-on way to introduce you to multichain programming. We have ommitted the implementation details of many of the individual steps. You have now experienced the transfer flow that a regular user experiences when interacting through UIs. In the next chapter, we will go deeper into what each trace meant. Later we will write a simple solver, and show orders are filled.