This guide walks you through implementing smart contract calls in your application. We’ll cover the complete three-step flow with production-ready code examples.

Quickstart

Execute any smart contract function across multiple blockchains with a single integration. This guide shows you how to transfer ERC20 tokens on Arbitrum using OneBalance’s calldata API.

What You’ll Build

Setup Project

Initialize a TypeScript project with OneBalance dependencies

Execute Calldata

Send arbitrary smart contract calls across any supported chain

Track Status

Monitor transaction execution in real-time

Handle Errors

Implement proper error handling

Quick Example

Here’s what you’ll accomplish - a complete working script:

index.ts
// Complete working example - see full code below
async function main() {
  // Step 1: Predict account address from session/admin keys
  const accountAddress = await predictAddress(sessionKey.address, adminKey.address);
  console.log('Predicted Address:', accountAddress);

  // Step 2: Check USDC balance
  const usdcBalances = await fetchUSDCBalance(accountAddress);
  if (!usdcBalances) throw new Error('No USDC balance found');

  // Step 3-5: Execute ERC20 transfer on Arbitrum
  await transferErc20OnChain({
    accountAddress,
    sessionAddress: sessionKey.address,
    adminAddress: adminKey.address,
  }, usdcBalances);
}

Now let’s build this step by step.

Project Setup

Set up your development environment:

mkdir onebalance-calldata && cd onebalance-calldata
pnpm init

Install dependencies:

pnpm add viem axios
pnpm add -D typescript @types/node ts-node tslib

Now, let’s create and configure tsconfig.json:

touch tsconfig.json
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "typeRoots": ["./node_modules/@types"],
    "types": ["node"]
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
  "ts-node": {
    "esm": false,
    "experimentalSpecifierResolution": "node"
  }
}

This guide uses a public API key for testing. For production, get your API key from sales@onebalance.io.

Calldata Flow

Step 1: Generate Keys & Predict Account

Create your main file with key generation and account prediction:

touch index.ts
index.ts
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import axios, { AxiosResponse } from 'axios';
import { HashTypedDataParameters, encodeFunctionData, parseAbi } from 'viem';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';

const BASE_URL = 'https://be.onebalance.io';

// Note: Using the production API endpoint will produce a different predicted address
const PUBLIC_API_KEY = '42bb629272001ee1163ca0dbbbc07bcbb0ef57a57baf16c4b1d4672db4562c11';

// Helper function to create authenticated headers
function createAuthHeaders(): Record<string, string> {
  return {
    'x-api-key': PUBLIC_API_KEY,
  };
}

async function apiRequest<RequestData, ResponseData>(
  method: 'get' | 'post',
  endpoint: string,
  data: RequestData,
  isParams = false,
): Promise<ResponseData> {
  try {
    const config = {
      headers: createAuthHeaders(),
      ...(isParams ? { params: data } : {}),
    };

    const url = `${BASE_URL}${endpoint}`;

    const response: AxiosResponse<ResponseData> =
      method === 'post' ? await axios.post(url, data, config) : await axios.get(url, { ...config, params: data });

    return response.data;
  } catch (error) {
    if (axios.isAxiosError(error) && error.response) {
      throw new Error(JSON.stringify(error.response.data));
    }
    throw error;
  }
}

// API methods
async function apiPost<RequestData, ResponseData>(endpoint: string, data: RequestData): Promise<ResponseData> {
  return apiRequest<RequestData, ResponseData>('post', endpoint, data);
}

async function apiGet<RequestData, ResponseData>(endpoint: string, params: RequestData): Promise<ResponseData> {
  return apiRequest<RequestData, ResponseData>('get', endpoint, params, true);
}

// Generate session key pair
function generateEOAKey() {
  const privateKey = generatePrivateKey();
  const account = privateKeyToAccount(privateKey);

  return {
    privateKey,
    address: account.address,
  };
}

function readOrCacheEOAKey(key: string) {
  if (existsSync(`${key}-key.json`)) {
    const cachedKeys = readFileSync(`${key}-key.json`, 'utf8');
    return JSON.parse(cachedKeys);
  }

  const keys = generateEOAKey();
  writeFileSync(`${key}-key.json`, JSON.stringify(keys, null, 2));

  return keys;
}

// Usage example
const sessionKey = readOrCacheEOAKey('session');

console.log('Session Address:', sessionKey.address);

const adminKey = readOrCacheEOAKey('admin');

console.log('Admin Address:', adminKey.address);

async function predictAddress(sessionAddress: string, adminAddress: string): Promise<string> {
  const response = await apiPost<{ sessionAddress: string; adminAddress: string }, { predictedAddress: string }>(
    '/api/account/predict-address',
    {
      sessionAddress,
      adminAddress,
    },
  );

  return response.predictedAddress;
}

Step 2: Check Balance

Add balance checking functionality with proper TypeScript interfaces:

index.ts
async function fetchBalances(address: string) {
  const response = await apiGet<
    { address: string },
    {
      balanceByAggregatedAsset: {
        aggregatedAssetId: string;
        balance: string;
        individualAssetBalances: { 
          assetType: string; 
          balance: string; 
          fiatValue: number 
        }[];
        fiatValue: number;
      }[];
      balanceBySpecificAsset: {
        assetType: string;
        balance: string;
        fiatValue: number;
      }[];
      totalBalance: {
        fiatValue: number;
      };
    }
  >('/api/v2/balances/aggregated-balance', { address });
  return response;
}

async function fetchUSDCBalance(address: string) {
  const response = await fetchBalances(address);
  return response.balanceByAggregatedAsset.find((asset) => asset.aggregatedAssetId === 'ds:usdc');
}

Step 3: Add TypeScript Interfaces

Add TypeScript interfaces for type safety:

index.ts
type Hex = `0x${string}`;

interface EvmAccount {
  accountAddress: Hex;
  sessionAddress: Hex;
  adminAddress: Hex;
}

interface EvmCall {
  to: Hex;
  value?: Hex;
  data?: Hex;
}

interface TokenRequirement {
  assetType: string;
  amount: string;
}

interface TokenAllowanceRequirement extends TokenRequirement {
  spender: Hex;
}

type StateMapping = {
  [slot: Hex]: Hex;
};

type StateDiff = {
  stateDiff?: StateMapping;
  code?: Hex;
  balance?: Hex;
};

type Override = StateDiff & {
  address: Hex;
};

interface PrepareCallRequest {
  account: EvmAccount;
  targetChain: string; // CAIP-2
  calls: EvmCall[];
  tokensRequired: TokenRequirement[];
  allowanceRequirements?: TokenAllowanceRequirement[];
  overrides?: Override[];
  // permits
  validAfter?: string;
  validUntil?: string;
}

interface SerializedUserOperation {
  sender: Hex;
  nonce: string;
  factory?: Hex;
  factoryData?: Hex;
  callData: Hex;
  callGasLimit: string;
  verificationGasLimit: string;
  preVerificationGas: string;
  maxFeePerGas: string;
  maxPriorityFeePerGas: string;
  paymaster?: Hex;
  paymasterVerificationGasLimit?: string;
  paymasterPostOpGasLimit?: string;
  paymasterData?: Hex;
  signature: Hex;
  initCode?: Hex;
  paymasterAndData?: Hex;
}

interface ChainOperationBasic {
  userOp: SerializedUserOperation;
  typedDataToSign: HashTypedDataParameters;
}

interface ChainOperation extends ChainOperationBasic {
  assetType: string;
  amount: string;
}

interface TargetCallQuote {
  account: EvmAccount;
  chainOperation: ChainOperation;
  tamperProofSignature: string;
}

interface CallRequest {
  account: EvmAccount;
  chainOperation: ChainOperation;
  tamperProofSignature: string;
  fromAggregatedAssetId: string;
}

interface AssetUsed {
  aggregatedAssetId: string;
  assetType: string[] | string;
  amount: string;
  minimumAmount?: string;
}

interface FiatValue {
  fiatValue: string;
  amount: string;
}

interface OriginAssetUsed extends AssetUsed {
  assetType: string[];
  fiatValue: FiatValue[];
}

interface DestinationAssetUsed extends AssetUsed {
  assetType: string;
  fiatValue: string;
  minimumAmount?: string;
  minimumFiatValue?: string;
}

interface Quote {
  id: string;
  account: EvmAccount;
  originChainsOperations: ChainOperation[];
  destinationChainOperation?: ChainOperation;

  originToken?: OriginAssetUsed;
  destinationToken?: DestinationAssetUsed;

  validUntil?: string; // block number, if empty the valid until will be MAX_UINT256
  validAfter?: string; // block number, if empty the valid after will be 0

  expirationTimestamp: string;
  tamperProofSignature: string;
}

interface OpGuarantees {
  non_equivocation: boolean;
  reorg_protection: boolean;
  valid_until?: number;
  valid_after?: number;
}

type BundleGuarantees = Record<Hex, OpGuarantees>;

interface BundleResponse {
  success: boolean;
  guarantees: BundleGuarantees | null;
  error: string | null;
}

type TransactionType = 'SWAP' | 'TRANSFER' | 'CALL';

type OperationStatus =
  | 'PENDING' // not yet begun processing but has been submitted
  | 'IN_PROGRESS' // processing the execution steps of the operation
  | 'COMPLETED' // all steps completed with success
  | 'REFUNDED' // none or some steps completed, some required step failed causing the whole operation to be refunded
  | 'FAILED'; // all steps failed

interface OperationDetails {
  hash?: Hex;
  chainId?: number;
  explorerUrl?: string;
}

interface HistoryTransaction {
  quoteId: string;
  type: TransactionType;

  originToken?: OriginAssetUsed;
  destinationToken?: DestinationAssetUsed;

  status: OperationStatus;

  user: Hex;
  recipientAccountId: string; // the caip-10 address of the recipient

  // if type is SWAP or TRANSFER
  originChainOperations?: OperationDetails[]; // the asset(s) that were sent from the source
  destinationChainOperations?: OperationDetails[]; // the asset that was received to the final destination
}

interface HistoryResponse {
  transactions: HistoryTransaction[];
  continuation?: string;
}

Step 4: Prepare Quote

Add the quote preparation logic with proper type safety:

index.ts
async function prepareCallQuote(quoteRequest: PrepareCallRequest): Promise<TargetCallQuote> {
  return apiPost<PrepareCallRequest, TargetCallQuote>('/api/quotes/prepare-call-quote', quoteRequest);
}

async function fetchCallQuote(callRequest: CallRequest): Promise<Quote> {
  return apiPost<CallRequest, Quote>('/api/quotes/call-quote', callRequest);
}

async function executeQuote(quote: Quote): Promise<BundleResponse> {
  return apiPost<Quote, BundleResponse>('/api/quotes/execute-quote', quote);
}

See all quote options in our Quotes API reference.

Step 5: Sign Operations

Add signing functionality:

index.ts
async function signOperation(operation: ChainOperation, key: Hex): Promise<ChainOperation> {
  return {
    ...operation,
    userOp: { ...operation.userOp, signature: await privateKeyToAccount(key).signTypedData(operation.typedDataToSign) },
  };
}

Step 6: Track Status

Add transaction monitoring with proper error handling:

index.ts
async function fetchTransactionHistory(address: string): Promise<HistoryResponse> {
  return apiGet<{ user: string; limit: number; sortBy: string }, HistoryResponse>('/api/status/get-tx-history', {
    user: address,
    limit: 1,
    sortBy: 'createdAt',
  });
}

Track all transaction states in our Status API reference.

Put It Together

Add the main transfer function and execution:

index.ts
async function transferErc20OnChain(
  account: EvmAccount,
  usdcBalances: {
    aggregatedAssetId: string;
    balance: string;
    individualAssetBalances: { assetType: string; balance: string; fiatValue: number }[];
  },
) {
  const largestUsdcBalanceEntry = usdcBalances.individualAssetBalances.reduce((max, current) => {
    return Number(current.balance) > Number(max.balance) ? current : max;
  });

  const chain = 'eip155:42161'; // Arbitrum
  const usdcAddress = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; // Arbitrum USDC address

  if (largestUsdcBalanceEntry.balance === '0') {
    throw new Error('No USDC balance found');
  }

  const transferDefinition = parseAbi(['function transfer(address to, uint256 amount) returns (bool)']);

  const transferCallData = encodeFunctionData({
    abi: transferDefinition,
    functionName: 'transfer',
    args: [adminKey.address, 1n],
  });

  const quoteRequest: PrepareCallRequest = {
    account,

    targetChain: chain,

    calls: [
      {
        to: usdcAddress as Hex,
        data: transferCallData,
        value: '0x0',
      },
    ],

    tokensRequired: [
      {
        assetType: `${chain}/erc20:${usdcAddress}`,
        amount: '100000',
      },
    ],
  };

  console.log(quoteRequest);

  const preparedQuote = await prepareCallQuote(quoteRequest);

  const signedChainOp = await signOperation(preparedQuote.chainOperation, sessionKey.privateKey);

  const callRequest: CallRequest = {
    fromAggregatedAssetId: 'ds:usdc',
    account,
    tamperProofSignature: preparedQuote.tamperProofSignature,
    chainOperation: signedChainOp,
  };

  console.log('callRequest', callRequest);

  const quote = await fetchCallQuote(callRequest);

  for (let i = 0; i < quote.originChainsOperations.length; i++) {
    const callQuoteSignedChainOperation = await signOperation(quote.originChainsOperations[i], sessionKey.privateKey);
    quote.originChainsOperations[i] = callQuoteSignedChainOperation;
  }

  console.log('quote', quote);

  const bundle = await executeQuote(quote);

  if (bundle.success) {
    console.log('Bundle executed');

    const timeout = 60_000;

    let completed = false;
    const startTime = Date.now();

    while (!completed) {
      try {
        console.log('fetching transaction history...');
        const transactionHistory = await fetchTransactionHistory(quote.account.accountAddress);

        console.log('transactionHistory', transactionHistory);

        if (transactionHistory.transactions.length > 0) {
          const [tx] = transactionHistory.transactions;

          if (tx.quoteId === quote.id) {
            if (tx.status === 'COMPLETED') {
              console.log('Transaction completed and operation executed');
              completed = true;
              break;
            }
            console.log('Transaction status: ', tx.status);
          }
        }
      } catch {}

      if (Date.now() - startTime > timeout) {
        throw new Error('Transaction not completed in time');
      }

      await new Promise((resolve) => setTimeout(resolve, 1_000));
    }
  } else {
    console.log('Bundle execution failed');
  }
}

async function main() {
  const predictedAddress = await predictAddress(sessionKey.address, adminKey.address);

  console.log('Predicted Address:', predictedAddress);

  const usdcBalances = await fetchUSDCBalance(predictedAddress);

  console.log('USDC Balances:', usdcBalances);

  if (!usdcBalances) {
    throw new Error('No USDC balance found');
  }

  await transferErc20OnChain(
    {
      accountAddress: predictedAddress as Hex,
      sessionAddress: sessionKey.address as Hex,
      adminAddress: adminKey.address as Hex,
    },
    usdcBalances,
  );
}

main();

Add scripts to your package.json to run the index.ts file:

package.json
{
  "name": "onebalance-calldata",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "calldata": "ts-node index.ts",
    "build": "tsc",
    "clean": "rm -rf dist"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "pnpm@10.9.0",
  "dependencies": {
    "axios": "^1.9.0",
    "viem": "^2.30.5"
  },
  "devDependencies": {
    "@types/node": "^22.15.28",
    "ts-node": "^10.9.2",
    "tslib": "^2.8.1",
    "typescript": "^5.8.3"
  }
}

Run your calldata execution:

pnpm run calldata

After running the script for the first time, you’ll see a predicted address printed in the console. You need to fund this address with USDC on any supported chain before the transaction will succeed. Transfer some USDC to the predicted address, then run the script again.

Checkpoint: You should see session/admin addresses printed, account prediction, balance check, and transaction execution in your console.

Next Steps

Common Issues

  • No USDC balance found - User needs to fund their predicted address with USDC.
  • Balance Too Low - Ensure your account has sufficient USDC. The API requires tokens for both gas and the transfer amount.
  • Transaction Timeout - Network congestion can delay execution. Increase timeout or check status manually using the quote ID.
  • Invalid Calldata - Verify your ABI encoding matches the target contract’s interface exactly.

Complete Example

The complete implementation is available in our OneBalance Examples repository.

import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import axios, { AxiosResponse } from 'axios';
import { HashTypedDataParameters, encodeFunctionData, parseAbi } from 'viem';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';

const BASE_URL = 'https://be.onebalance.io';

// Note: Using the production API endpoint will produce a different predicted address
const PUBLIC_API_KEY = '42bb629272001ee1163ca0dbbbc07bcbb0ef57a57baf16c4b1d4672db4562c11';

// Helper function to create authenticated headers
function createAuthHeaders(): Record<string, string> {
  return {
    'x-api-key': PUBLIC_API_KEY,
  };
}

async function apiRequest<RequestData, ResponseData>(
  method: 'get' | 'post',
  endpoint: string,
  data: RequestData,
  isParams = false,
): Promise<ResponseData> {
  try {
    const config = {
      headers: createAuthHeaders(),
      ...(isParams ? { params: data } : {}),
    };

    const url = `${BASE_URL}${endpoint}`;

    const response: AxiosResponse<ResponseData> =
      method === 'post' ? await axios.post(url, data, config) : await axios.get(url, { ...config, params: data });

    return response.data;
  } catch (error) {
    if (axios.isAxiosError(error) && error.response) {
      throw new Error(JSON.stringify(error.response.data));
    }
    throw error;
  }
}

// API methods
async function apiPost<RequestData, ResponseData>(endpoint: string, data: RequestData): Promise<ResponseData> {
  return apiRequest<RequestData, ResponseData>('post', endpoint, data);
}

async function apiGet<RequestData, ResponseData>(endpoint: string, params: RequestData): Promise<ResponseData> {
  return apiRequest<RequestData, ResponseData>('get', endpoint, params, true);
}

// Generate session key pair
function generateEOAKey() {
  const privateKey = generatePrivateKey();
  const account = privateKeyToAccount(privateKey);

  return {
    privateKey,
    address: account.address,
  };
}

function readOrCacheEOAKey(key: string) {
  if (existsSync(`${key}-key.json`)) {
    const cachedKeys = readFileSync(`${key}-key.json`, 'utf8');
    return JSON.parse(cachedKeys);
  }

  const keys = generateEOAKey();
  writeFileSync(`${key}-key.json`, JSON.stringify(keys, null, 2));

  return keys;
}

// Usage example
const sessionKey = readOrCacheEOAKey('session');

console.log('Session Address:', sessionKey.address);

const adminKey = readOrCacheEOAKey('admin');

console.log('Admin Address:', adminKey.address);

async function predictAddress(sessionAddress: string, adminAddress: string): Promise<string> {
  const response = await apiPost<{ sessionAddress: string; adminAddress: string }, { predictedAddress: string }>(
    '/api/account/predict-address',
    {
      sessionAddress,
      adminAddress,
    },
  );

  return response.predictedAddress;
}

async function fetchBalances(address: string) {
  const response = await apiGet<
    { address: string },
    {
      balanceByAggregatedAsset: {
        aggregatedAssetId: string;
        balance: string;
        individualAssetBalances: { 
          assetType: string; 
          balance: string; 
          fiatValue: number 
        }[];
        fiatValue: number;
      }[];
      balanceBySpecificAsset: {
        assetType: string;
        balance: string;
        fiatValue: number;
      }[];
      totalBalance: {
        fiatValue: number;
      };
    }
  >('/api/v2/balances/aggregated-balance', { address });
  return response;
}

async function fetchUSDCBalance(address: string) {
  const response = await fetchBalances(address);
  return response.balanceByAggregatedAsset.find((asset) => asset.aggregatedAssetId === 'ds:usdc');
}

type Hex = `0x${string}`;

interface EvmAccount {
  accountAddress: Hex;
  sessionAddress: Hex;
  adminAddress: Hex;
}

interface EvmCall {
  to: Hex;
  value?: Hex;
  data?: Hex;
}

interface TokenRequirement {
  assetType: string;
  amount: string;
}

interface TokenAllowanceRequirement extends TokenRequirement {
  spender: Hex;
}

type StateMapping = {
  [slot: Hex]: Hex;
};

type StateDiff = {
  stateDiff?: StateMapping;
  code?: Hex;
  balance?: Hex;
};

type Override = StateDiff & {
  address: Hex;
};

interface PrepareCallRequest {
  account: EvmAccount;
  targetChain: string; // CAIP-2
  calls: EvmCall[];
  tokensRequired: TokenRequirement[];
  allowanceRequirements?: TokenAllowanceRequirement[];
  overrides?: Override[];
  // permits
  validAfter?: string;
  validUntil?: string;
}

interface SerializedUserOperation {
  sender: Hex;
  nonce: string;
  factory?: Hex;
  factoryData?: Hex;
  callData: Hex;
  callGasLimit: string;
  verificationGasLimit: string;
  preVerificationGas: string;
  maxFeePerGas: string;
  maxPriorityFeePerGas: string;
  paymaster?: Hex;
  paymasterVerificationGasLimit?: string;
  paymasterPostOpGasLimit?: string;
  paymasterData?: Hex;
  signature: Hex;
  initCode?: Hex;
  paymasterAndData?: Hex;
}

interface ChainOperationBasic {
  userOp: SerializedUserOperation;
  typedDataToSign: HashTypedDataParameters;
}

interface ChainOperation extends ChainOperationBasic {
  assetType: string;
  amount: string;
}

interface TargetCallQuote {
  account: EvmAccount;
  chainOperation: ChainOperation;
  tamperProofSignature: string;
}

interface CallRequest {
  account: EvmAccount;
  chainOperation: ChainOperation;
  tamperProofSignature: string;
  fromAggregatedAssetId: string;
}

interface AssetUsed {
  aggregatedAssetId: string;
  assetType: string[] | string;
  amount: string;
  minimumAmount?: string;
}

interface FiatValue {
  fiatValue: string;
  amount: string;
}

interface OriginAssetUsed extends AssetUsed {
  assetType: string[];
  fiatValue: FiatValue[];
}

interface DestinationAssetUsed extends AssetUsed {
  assetType: string;
  fiatValue: string;
  minimumAmount?: string;
  minimumFiatValue?: string;
}

interface Quote {
  id: string;
  account: EvmAccount;
  originChainsOperations: ChainOperation[];
  destinationChainOperation?: ChainOperation;

  originToken?: OriginAssetUsed;
  destinationToken?: DestinationAssetUsed;

  validUntil?: string; // block number, if empty the valid until will be MAX_UINT256
  validAfter?: string; // block number, if empty the valid after will be 0

  expirationTimestamp: string;
  tamperProofSignature: string;
}

interface OpGuarantees {
  non_equivocation: boolean;
  reorg_protection: boolean;
  valid_until?: number;
  valid_after?: number;
}

type BundleGuarantees = Record<Hex, OpGuarantees>;

interface BundleResponse {
  success: boolean;
  guarantees: BundleGuarantees | null;
  error: string | null;
}

type TransactionType = 'SWAP' | 'TRANSFER' | 'CALL';

type OperationStatus =
  | 'PENDING' // not yet begun processing but has been submitted
  | 'IN_PROGRESS' // processing the execution steps of the operation
  | 'COMPLETED' // all steps completed with success
  | 'REFUNDED' // none or some steps completed, some required step failed causing the whole operation to be refunded
  | 'FAILED'; // all steps failed

interface OperationDetails {
  hash?: Hex;
  chainId?: number;
  explorerUrl?: string;
}

interface HistoryTransaction {
  quoteId: string;
  type: TransactionType;

  originToken?: OriginAssetUsed;
  destinationToken?: DestinationAssetUsed;

  status: OperationStatus;

  user: Hex;
  recipientAccountId: string; // the caip-10 address of the recipient

  // if type is SWAP or TRANSFER
  originChainOperations?: OperationDetails[]; // the asset(s) that were sent from the source
  destinationChainOperations?: OperationDetails[]; // the asset that was received to the final destination
}

interface HistoryResponse {
  transactions: HistoryTransaction[];
  continuation?: string;
}

async function prepareCallQuote(quoteRequest: PrepareCallRequest): Promise<TargetCallQuote> {
  return apiPost<PrepareCallRequest, TargetCallQuote>('/api/quotes/prepare-call-quote', quoteRequest);
}

async function fetchCallQuote(callRequest: CallRequest): Promise<Quote> {
  return apiPost<CallRequest, Quote>('/api/quotes/call-quote', callRequest);
}

async function executeQuote(quote: Quote): Promise<BundleResponse> {
  return apiPost<Quote, BundleResponse>('/api/quotes/execute-quote', quote);
}

async function fetchTransactionHistory(address: string): Promise<HistoryResponse> {
  return apiGet<{ user: string; limit: number; sortBy: string }, HistoryResponse>('/api/status/get-tx-history', {
    user: address,
    limit: 1,
    sortBy: 'createdAt',
  });
}

async function signOperation(operation: ChainOperation, key: Hex): Promise<ChainOperation> {
  return {
    ...operation,
    userOp: { ...operation.userOp, signature: await privateKeyToAccount(key).signTypedData(operation.typedDataToSign) },
  };
}

async function transferErc20OnChain(
  account: EvmAccount,
  usdcBalances: {
    aggregatedAssetId: string;
    balance: string;
    individualAssetBalances: { assetType: string; balance: string; fiatValue: number }[];
  },
) {
  const largestUsdcBalanceEntry = usdcBalances.individualAssetBalances.reduce((max, current) => {
    return Number(current.balance) > Number(max.balance) ? current : max;
  });

  const chain = 'eip155:42161'; // Arbitrum
  const usdcAddress = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; // Arbitrum USDC address

  if (largestUsdcBalanceEntry.balance === '0') {
    throw new Error('No USDC balance found');
  }

  const transferDefinition = parseAbi(['function transfer(address to, uint256 amount) returns (bool)']);

  const transferCallData = encodeFunctionData({
    abi: transferDefinition,
    functionName: 'transfer',
    args: [adminKey.address, 1n],
  });

  const quoteRequest: PrepareCallRequest = {
    account,

    targetChain: chain,

    calls: [
      {
        to: usdcAddress as Hex,
        data: transferCallData,
        value: '0x0',
      },
    ],

    tokensRequired: [
      {
        assetType: `${chain}/erc20:${usdcAddress}`,
        amount: '100000',
      },
    ],
  };

  console.log(quoteRequest);

  const preparedQuote = await prepareCallQuote(quoteRequest);

  const signedChainOp = await signOperation(preparedQuote.chainOperation, sessionKey.privateKey);

  const callRequest: CallRequest = {
    fromAggregatedAssetId: 'ds:usdc',
    account,
    tamperProofSignature: preparedQuote.tamperProofSignature,
    chainOperation: signedChainOp,
  };

  console.log('callRequest', callRequest);

  const quote = await fetchCallQuote(callRequest);

  for (let i = 0; i < quote.originChainsOperations.length; i++) {
    const callQuoteSignedChainOperation = await signOperation(quote.originChainsOperations[i], sessionKey.privateKey);
    quote.originChainsOperations[i] = callQuoteSignedChainOperation;
  }

  console.log('quote', quote);

  const bundle = await executeQuote(quote);

  if (bundle.success) {
    console.log('Bundle executed');

    const timeout = 60_000;

    let completed = false;
    const startTime = Date.now();

    while (!completed) {
      try {
        console.log('fetching transaction history...');
        const transactionHistory = await fetchTransactionHistory(quote.account.accountAddress);

        console.log('transactionHistory', transactionHistory);

        if (transactionHistory.transactions.length > 0) {
          const [tx] = transactionHistory.transactions;

          if (tx.quoteId === quote.id) {
            if (tx.status === 'COMPLETED') {
              console.log('Transaction completed and operation executed');
              completed = true;
              break;
            }
            console.log('Transaction status: ', tx.status);
          }
        }
      } catch {}

      if (Date.now() - startTime > timeout) {
        throw new Error('Transaction not completed in time');
      }

      await new Promise((resolve) => setTimeout(resolve, 1_000));
    }
  } else {
    console.log('Bundle execution failed');
  }
}

async function main() {
  const predictedAddress = await predictAddress(sessionKey.address, adminKey.address);

  console.log('Predicted Address:', predictedAddress);

  const usdcBalances = await fetchUSDCBalance(predictedAddress);

  console.log('USDC Balances:', usdcBalances);

  if (!usdcBalances) {
    throw new Error('No USDC balance found');
  }

  await transferErc20OnChain(
    {
      accountAddress: predictedAddress as Hex,
      sessionAddress: sessionKey.address as Hex,
      adminAddress: adminKey.address as Hex,
    },
    usdcBalances,
  );
}

main();

Integration Checklist

Before going to production, ensure you have:

  • Stored API keys securely (environment variables)
  • Implemented proper error handling
  • Added transaction monitoring
  • Tested with small amounts first
  • Implemented retry logic for network failures
  • Added logging for debugging
  • Validated all addresses and amounts
  • Handled signature rejection cases