RathRath Finance
Use Cases

Cross-Chain Swap

Quote, execute, and track a cross-chain swap between two xPath-supported chains.

This guide swaps an ERC-20 token from Base to Arbitrum. xPath can return a directBridge route when the same asset is available on both chains or a swapBridge route when a source or destination swap is required. Execution has two phases: submit the source-chain transaction, then track destination execution.

Prerequisites

  • xPath API key
  • Wallet connected to the source chain
  • Input token balance and source-chain gas
  • Recipient address valid on the destination chain

Request Cross-Chain Swap Routes

const API_URL = 'https://api.xpath.rath.fi'
const API_KEY = process.env.XPATH_API_KEY!
const sender = walletClient.account.address
const receiver = sender

async function xpath(path: string, init?: RequestInit) {
  const response = await fetch(`${API_URL}${path}`, {
    ...init,
    headers: {
      'api-key': API_KEY,
      'Content-Type': 'application/json',
      ...init?.headers,
    },
  })
  if (!response.ok) throw new Error(await response.text())
  const body = await response.json()
  if (body.code !== 0) throw new Error(body.message)
  return body.data
}

const params = new URLSearchParams({
  fromChain: '8453',
  toChain: '42161',
  fromToken: '0x4200000000000000000000000000000000000006',
  toToken: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
  amount: '1000000000000000',
  sender,
  receiver,
  slippage: '1',
  routeMode: 'suggested',
})

const routes = await xpath(`/quote?${params}`)
const route = routes.find((candidate) =>
  ['directBridge', 'swapBridge'].includes(candidate.routeKind)
)

if (!route) throw new Error('No cross-chain swap route is currently available')

Review Route Risk and Cost

console.log({
  routeKind: route.routeKind,
  providers: route.providers.map((provider) => provider.name),
  expectedOutput: route.amountOut,
  minimumOutput: route.minAmountOut,
  estimatedTimeMs: route.estimatedTimeMs,
  routeFeeUsd: route.fees.routeFeeUsd,
  gasFeeUsd: route.gasFee.gasFeeUsd,
})

Cross-chain completion time is an estimate. Do not promise completion based only on estimatedTimeMs.

Build and Approve

import { erc20Abi } from 'viem'

const tx = await xpath('/build-path-by-id', {
  method: 'POST',
  body: JSON.stringify({
    quoteId: route.quoteId,
    simulation: true,
  }),
})

const spender = tx.allowanceTarget ?? tx.to
const allowance = await publicClient.readContract({
  address: route.fromToken.address,
  abi: erc20Abi,
  functionName: 'allowance',
  args: [sender, spender],
})

if (allowance < BigInt(route.amount)) {
  const approvalHash = await walletClient.writeContract({
    address: route.fromToken.address,
    abi: erc20Abi,
    functionName: 'approve',
    args: [spender, BigInt(route.amount)],
  })
  await publicClient.waitForTransactionReceipt({ hash: approvalHash })

  // Quotes are short-lived. Request and build a fresh route after approval.
}

Submit the Source Transaction

const sourceTxHash = await walletClient.sendTransaction({
  to: tx.to,
  data: tx.data,
  value: BigInt(tx.value ?? '0'),
  gas: BigInt(tx.gasLimit),
})

await publicClient.waitForTransactionReceipt({ hash: sourceTxHash })
console.log('Source transaction confirmed:', sourceTxHash)

Source confirmation does not mean the destination tokens have arrived. Continue tracking the route.

Poll Cross-Chain Status

const terminalStatuses = new Set(['completed', 'failed'])
const timeoutAt = Date.now() + 30 * 60 * 1000

let routeStatus
while (Date.now() < timeoutAt) {
  const params = new URLSearchParams({
    txHash: sourceTxHash,
    fromChain: String(tx.chain),
    userAddress: sender,
  })

  routeStatus = await xpath(`/status?${params}`)
  console.log('Route status:', routeStatus.status)

  if (terminalStatuses.has(routeStatus.status.toLowerCase())) break
  await new Promise((resolve) => setTimeout(resolve, 5000))
}

if (!routeStatus || !terminalStatuses.has(routeStatus.status.toLowerCase())) {
  throw new Error('Cross-chain status polling timed out')
}
if (routeStatus.status.toLowerCase() === 'failed') {
  throw new Error('Cross-chain execution failed')
}

console.log('Destination transaction:', routeStatus?.destTxDetails?.txHash)

Production Recommendations

  • Persist the source transaction hash, source chain, receiver, quote ID, and providers.
  • Resume polling after page reload instead of relying on one browser session.
  • Show source and destination explorer links independently.
  • Distinguish source failure, cross-chain delay, and destination failure in the UI.
  • Escalate long-running routes with the identifiers required by provider support.

Full Script

Install viem and provide the required environment variables:

npm install viem

export XPATH_API_KEY="YOUR_API_KEY"
export PRIVATE_KEY="0xYOUR_PRIVATE_KEY"
export SOURCE_RPC_URL="https://mainnet.base.org"
import {
  createPublicClient,
  createWalletClient,
  erc20Abi,
  http,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { base } from 'viem/chains'

const API_URL = 'https://api.xpath.rath.fi'
const API_KEY = process.env.XPATH_API_KEY!
const account = privateKeyToAccount(process.env.PRIVATE_KEY! as `0x${string}`)

const publicClient = createPublicClient({
  chain: base,
  transport: http(process.env.SOURCE_RPC_URL),
})

const walletClient = createWalletClient({
  account,
  chain: base,
  transport: http(process.env.SOURCE_RPC_URL),
})

async function xpath(path: string, init?: RequestInit) {
  const response = await fetch(`${API_URL}${path}`, {
    ...init,
    headers: {
      'api-key': API_KEY,
      'Content-Type': 'application/json',
      ...init?.headers,
    },
  })

  if (!response.ok) {
    throw new Error(`xPath request failed: ${response.status} ${await response.text()}`)
  }

  const body = await response.json()
  if (body.code !== 0) throw new Error(body.message)
  return body.data
}

async function main() {
  const sender = account.address
  const quoteParams = new URLSearchParams({
    fromChain: '8453',
    toChain: '42161',
    fromToken: '0x4200000000000000000000000000000000000006',
    toToken: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
    amount: '1000000000000000',
    sender,
    receiver: sender,
    slippage: '1',
    routeMode: 'suggested',
  })

  async function getExecution() {
    const routes = await xpath(`/quote?${quoteParams}`)
    const route = routes.find((candidate) =>
      ['directBridge', 'swapBridge'].includes(candidate.routeKind)
    )

    if (!route) throw new Error('No cross-chain swap route is currently available')

    const tx = await xpath('/build-path-by-id', {
      method: 'POST',
      body: JSON.stringify({ quoteId: route.quoteId, simulation: true }),
    })

    return { route, tx }
  }

  let { route, tx } = await getExecution()

  console.log('Route:', route.routeKind)
  console.log('Providers:', route.providers.map((provider) => provider.name))
  console.log('Expected output:', route.amountOut)
  console.log('Minimum output:', route.minAmountOut)

  if (tx.chain !== base.id) throw new Error(`Switch wallet to chain ${tx.chain}`)

  const spender = (tx.allowanceTarget ?? tx.to) as `0x${string}`
  const token = route.fromToken.address as `0x${string}`
  const amount = BigInt(route.amount)

  const allowance = await publicClient.readContract({
    address: token,
    abi: erc20Abi,
    functionName: 'allowance',
    args: [sender, spender],
  })

  if (allowance < amount) {
    const approvalHash = await walletClient.writeContract({
      address: token,
      abi: erc20Abi,
      functionName: 'approve',
      args: [spender, amount],
    })
    await publicClient.waitForTransactionReceipt({ hash: approvalHash })

    // Stored quotes expire quickly; rebuild against a fresh quote after approval.
    ;({ route, tx } = await getExecution())

    const freshSpender = (tx.allowanceTarget ?? tx.to) as `0x${string}`
    const freshAllowance = await publicClient.readContract({
      address: route.fromToken.address as `0x${string}`,
      abi: erc20Abi,
      functionName: 'allowance',
      args: [sender, freshSpender],
    })

    if (freshAllowance < BigInt(route.amount)) {
      throw new Error('The fresh route uses a different spender; approve it and rebuild again')
    }
  }

  const sourceTxHash = await walletClient.sendTransaction({
    to: tx.to as `0x${string}`,
    data: tx.data as `0x${string}`,
    value: BigInt(tx.value ?? '0'),
    gas: BigInt(tx.gasLimit),
  })

  await publicClient.waitForTransactionReceipt({ hash: sourceTxHash })
  console.log('Source transaction:', sourceTxHash)

  const terminalStatuses = new Set(['completed', 'failed'])
  const timeoutAt = Date.now() + 30 * 60 * 1000

  while (Date.now() < timeoutAt) {
    const statusParams = new URLSearchParams({
      txHash: sourceTxHash,
      fromChain: String(tx.chain),
      userAddress: sender,
    })
    const status = await xpath(`/status?${statusParams}`)
    console.log('Cross-chain status:', status.status)

    if (terminalStatuses.has(status.status.toLowerCase())) {
      console.log('Destination transaction:', status.destTxDetails?.txHash)
      if (status.status.toLowerCase() === 'failed') process.exitCode = 1
      return
    }

    await new Promise((resolve) => setTimeout(resolve, 5000))
  }

  throw new Error('Cross-chain status polling timed out')
}

main().catch((error) => {
  console.error(error)
  process.exitCode = 1
})

On this page