Across has settled more than $27.5 billion in transfers across 17.3 million individual deposits for over 4 million users, according to the protocol’s own mid-2025 usage figures, making it one of the most active intent-based bridges connecting Ethereum to its Layer 2 network. Almost everyone who touches it does the same thing though: open app.across.to, connect a wallet, pick a chain, click a button. That flow works fine for a one-off transfer. It falls apart the moment you need a treasury script to rebalance collateral across five chains overnight, a trading bot that has to move funds before a position expires, or a checkout flow that accepts deposits from any chain a customer happens to be holding funds on.
This tutorial shows how to drive Across programmatically, using both its REST API and the officially maintained @across-protocol/app-sdk package, so bridging becomes a function call in your own code instead of a manual click-through. By the end you’ll have a working Node.js bot that fetches a quote, checks and sets token allowances, executes a cross-chain deposit, polls for the fill, retries safely when a call fails, and sends a notification when a transfer lands.
We’ll also walk through the $4.5 million incident Across disclosed on July 17, 2026, because it tells you something concrete about where risk actually concentrates in automated bridging. It wasn’t a smart contract bug. It was a flaw in the off-chain relay software that reads deposit events on Solana, and it’s exactly the kind of failure your error handling needs to anticipate once you’re running this stuff unattended.
You don’t need prior experience with Across specifically to follow along, but you should be comfortable writing Node.js, have used an EVM client library like viem or ethers before, and understand how ERC-20 approvals work. The wallet setup and API calls are the same whether you’re moving $50 or $5 million, so test everything with small amounts first regardless of your eventual scale.
Why Manual Bridging Doesn’t Scale in 2026
Across now routes transfers across 24 chains through what it calls a unified Swap API, according to its own developer documentation, covering Ethereum mainnet and major rollups including Arbitrum, Base, Optimism, and Polygon. That list changes over time. Blast support, for instance, is scheduled to deprecate on July 20, 2026, which means any hardcoded chain list from an older tutorial or a stale internal config will quietly start failing calls without an obvious error. A production integration has to treat the supported-chains list as data to fetch, not a constant to hardcode.
The other reason to automate is speed. Across advertises settlement times around two seconds for typical transfers, a number that only matters if your application can actually act on it the instant funds land. A human clicking through a bridge UI can’t meaningfully react within two seconds of a fill. A script polling a deposit-status endpoint can, whether that means releasing an order, crediting a user balance, or kicking off the next leg of a multi-chain trade.
Scale is the third driver. As of Across’s April 2026 public relayer stats, the network runs more than 40 active relayers competing to fill deposits, which is what keeps quotes tight even during volatile periods. Any team moving funds regularly across chains, whether that’s a market maker rebalancing inventory, a payment processor settling merchant deposits, or a DeFi protocol managing cross-chain collateral, ends up needing the same four building blocks: a way to get a live quote, a way to execute a deposit, a way to confirm the fill, and a way to handle the calls that fail. That’s the rest of this tutorial.
How Across Protocol’s Intent-Based Architecture Works
Across doesn’t lock your tokens in a contract on one chain and mint a wrapped version on the other, which is the model that produced some of crypto’s largest bridge hacks. Instead it runs on an intent architecture: you submit what you want (input token, output token, origin chain, destination chain, amount) and a network of relayers, which Across’s own documentation calls solvers, compete to fill that intent using their own pre-funded inventory on the destination chain.
A relayer watches for your deposit event on the origin chain, fronts you the equivalent funds on the destination chain immediately from its own balance, and only afterward gets reimbursed by the protocol once the origin-chain deposit is confirmed and reconciled. That’s why fills complete in roughly two seconds instead of the ten-to-fifteen-minute waits typical of lock-and-mint bridges, and why withdrawals don’t carry the multi-day fraud-proof challenge period that canonical optimistic-rollup bridges require. You’re not waiting on the destination chain’s security assumptions. You’re waiting on a relayer’s willingness to front the trade, priced into the quote you receive up front.
Every deposit on Across emits a V3FundsDeposited event on the origin chain, and that event’s depositId paired with the origin chain ID is how you, or Across’s own API, track a transfer from submission through fill. Keep that pair in your own database the moment you submit a deposit. It’s the only handle you have on a transfer once it leaves your wallet, and losing it means falling back to scanning transaction logs by hand.
Across’s own documentation describes the system as three modular layers: an RFQ layer that accepts the intent and broadcasts it for pricing, a relayer layer where competing solvers price the fill against their own inventory, and a settlement layer that reconciles the advance against the confirmed origin-chain deposit. Because each layer is separate, Across keeps adding chains and asset types without redesigning the core protocol, which is why the supported-chains list keeps shifting and why Step 3 fetches it live instead of hardcoding it.
Prerequisites: Tools and Versions You Need
| Tool | Version / Notes | Purpose |
|---|---|---|
| Node.js | 20 LTS or newer | Running the bridging scripts in this tutorial |
| npm or pnpm | Latest stable release | Installing viem, dotenv, and the Across SDK |
| viem | Latest published version | Wallet clients, ABI calls, and transaction signing |
| @across-protocol/app-sdk | Latest published version | TypeScript wrapper over Across’s REST API |
| An EVM wallet with a funded private key | Small test balance to start (a few dollars of ETH and USDC) | Signing approvals and deposits |
| An Across integrator ID | Requested via Across’s developer onboarding | Required query parameter on every API call |
| Block explorer access | Etherscan, Arbiscan, Basescan, or Optimistic Etherscan | Manually verifying transactions on both chains |
Getting an Integrator ID and API Access
Every call to Across’s production API at app.across.to/api requires a Bearer token in the Authorization header plus an integratorId query parameter on the request. Across issues integrator IDs through its developer channels rather than a self-serve signup form, so budget time for that step before you plan a launch date. Store both the bearer token and integrator ID in environment variables, never in source code, since this credential is what Across uses to attribute and rate-limit your traffic.
Keep the wallet you use for bridging automation separate from any long-term cold storage. That’s basic hygiene for any scripted signing process, not something unique to Across, but it matters more here because a bug in your retry logic (Step 9 below) could in theory resubmit a transaction you didn’t intend to send twice.
Step 1: Scaffold the Project and Install Dependencies
Start with a clean Node.js project and install viem for wallet handling, dotenv for secrets, and the Across app SDK for typed access to routes and quotes.
mkdir across-bridge-bot && cd across-bridge-bot
npm init -y
npm install viem dotenv @across-protocol/app-sdk
touch .env index.js
Add your secrets to .env. Never commit this file, and add it to .gitignore before your first commit rather than after.
PRIVATE_KEY=0xyourprivatekeyhere
ACROSS_BEARER_TOKEN=your_bearer_token
ACROSS_INTEGRATOR_ID=your_integrator_id
RPC_URL_MAINNET=https://your-ethereum-rpc
RPC_URL_ARBITRUM=https://your-arbitrum-rpc
RPC_URL_BASE=https://your-base-rpc
Set "type": "module" in package.json so you can use ES module imports throughout, matching the syntax viem’s own examples and the Across SDK both use.
Step 2: Configure Wallet Clients with viem
You need two client types per chain: a public client for reading balances and waiting on receipts, and a wallet client for signing and sending transactions. Build both once and reuse them across the script rather than recreating them per call.
// clients.js
import 'dotenv/config';
import { createPublicClient, createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { mainnet, arbitrum, base } from 'viem/chains';
export const account = privateKeyToAccount(process.env.PRIVATE_KEY);
const chains = {
1: { chain: mainnet, rpc: process.env.RPC_URL_MAINNET },
42161: { chain: arbitrum, rpc: process.env.RPC_URL_ARBITRUM },
8453: { chain: base, rpc: process.env.RPC_URL_BASE },
};
export function getClients(chainId) {
const { chain, rpc } = chains[chainId];
const transport = http(rpc);
return {
publicClient: createPublicClient({ chain, transport }),
walletClient: createWalletClient({ account, chain, transport }),
};
}
Keep RPC endpoints in environment variables so you can swap a rate-limited public endpoint for a paid provider without touching code. Public RPC endpoints tend to throttle exactly when you need them most, mid-bridge, so this isn’t a hypothetical concern.
Step 3: Authenticate and List Supported Routes
Before requesting a quote, confirm the origin chain, destination chain, and token you want are actually live on Across right now. Hardcoding a chain list is exactly the mistake that breaks integrations when a chain like Blast gets deprecated, so fetch this dynamically instead.
// routes.js
import 'dotenv/config';
const BASE_URL = 'https://app.across.to/api';
const auth = { Authorization: `Bearer ${process.env.ACROSS_BEARER_TOKEN}` };
export async function getSupportedChains() {
const url = `${BASE_URL}/swap/chains?integratorId=${process.env.ACROSS_INTEGRATOR_ID}`;
const res = await fetch(url, { headers: auth });
if (!res.ok) throw new Error(`swap/chains failed: ${res.status}`);
return res.json();
}
export async function getSupportedTokens(chainId) {
const url = `${BASE_URL}/swap/tokens?chainId=${chainId}&integratorId=${process.env.ACROSS_INTEGRATOR_ID}`;
const res = await fetch(url, { headers: auth });
if (!res.ok) throw new Error(`swap/tokens failed: ${res.status}`);
return res.json();
}
Run getSupportedChains() once at startup and cache the result for a few minutes rather than calling it on every bridge attempt. A typical response lists each chain’s ID, name, and whether it’s currently accepting deposits, something like this:
[
{ "chainId": 1, "name": "Ethereum", "depositsEnabled": true },
{ "chainId": 42161, "name": "Arbitrum", "depositsEnabled": true },
{ "chainId": 8453, "name": "Base", "depositsEnabled": true }
]
Step 4: Get a Bridge Quote Programmatically
Quotes and the transaction data needed to execute them both come from the same /swap/approval endpoint. Pass the origin chain, destination chain, input token, output token, and amount, and Across returns a price along with everything you need to sign.
// quote.js
import 'dotenv/config';
const BASE_URL = 'https://app.across.to/api';
export async function getBridgeQuote({
originChainId,
destinationChainId,
inputToken,
outputToken,
amount,
depositor,
}) {
const params = new URLSearchParams({
originChainId,
destinationChainId,
inputToken,
outputToken,
amount,
depositor,
integratorId: process.env.ACROSS_INTEGRATOR_ID,
});
const res = await fetch(`${BASE_URL}/swap/approval?${params}`, {
headers: { Authorization: `Bearer ${process.env.ACROSS_BEARER_TOKEN}` },
});
if (!res.ok) throw new Error(`Quote request failed: ${res.status}`);
return res.json();
}
The response includes the expected output amount, the relayer fee baked into that number, and a transaction object ready for signing. Log the full quote before executing anything, since comparing the quoted output against what actually lands is your first line of defense against a stale price.
Step 5: Approve Tokens the Safe Way
If you’re bridging an ERC-20 like USDC rather than native ETH, Across needs an allowance before it can pull funds. Approve the exact amount you intend to bridge, not an unlimited allowance, even though unlimited approvals are more convenient for repeated calls.
// approve.js
import { erc20Abi } from 'viem';
import { getClients, account } from './clients.js';
export async function approveIfNeeded({ chainId, tokenAddress, spender, amount }) {
const { publicClient, walletClient } = getClients(chainId);
const current = await publicClient.readContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'allowance',
args: [account.address, spender],
});
if (current >= amount) return null;
const hash = await walletClient.writeContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'approve',
args: [spender, amount],
});
await publicClient.waitForTransactionReceipt({ hash });
return hash;
}
An unlimited approval left sitting on a wallet is exactly what token-approval scanners and revoke tools exist to clean up after the fact. Scoping the approval to the exact bridge amount means a compromised spender contract can only drain what you actually intended to move, not your entire balance.
Step 6: Execute the Cross-Chain Deposit
Once the allowance is set, or you’re bridging native ETH, which needs no approval, sign and broadcast the transaction object that came back from the quote request in Step 4.
// deposit.js
import { getClients } from './clients.js';
export async function executeDeposit({ originChainId, quote }) {
const { walletClient, publicClient } = getClients(originChainId);
const hash = await walletClient.sendTransaction({
to: quote.tx.to,
data: quote.tx.data,
value: BigInt(quote.tx.value ?? 0),
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
return { hash, receipt };
}
Broadcasting the transaction only confirms it landed on the origin chain. It says nothing yet about whether a relayer has picked it up on the destination side, which is what the next step checks.
Step 7: Track Fill Status via the Deposit API
Extract the depositId from the V3FundsDeposited event in your transaction receipt, then poll Across’s /deposit/status endpoint until the fill completes.
// track.js
const BASE_URL = 'https://app.across.to/api';
export async function pollDepositStatus(
{ originChainId, depositId },
{ intervalMs = 3000, timeoutMs = 120000 } = {}
) {
const start = Date.now();
const params = new URLSearchParams({
originChainId,
depositId,
integratorId: process.env.ACROSS_INTEGRATOR_ID,
});
while (Date.now() - start < timeoutMs) {
const res = await fetch(`${BASE_URL}/deposit/status?${params}`, {
headers: { Authorization: `Bearer ${process.env.ACROSS_BEARER_TOKEN}` },
});
const data = await res.json();
if (data.status === 'filled') return data;
if (data.status === 'expired' || data.status === 'refunded') {
throw new Error(`Deposit ${data.status}: ${JSON.stringify(data)}`);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error('Deposit status polling timed out');
}
Given the roughly two-second settlement Across targets, a fill usually shows up within the first one or two polling cycles. If it’s still pending after a minute, treat that as a signal worth investigating rather than something to silently keep waiting on.
Step 8: Confirm Finality and Handle Reorgs
A transaction receipt on a fast L2 doesn’t guarantee the block it’s in survives a reorg. Wait for a small number of confirmations before you treat a deposit as irreversible, especially on chains with faster, less battle-tested block production than Ethereum mainnet.
// finality.js
export async function waitForFinality(publicClient, hash, confirmations = 3) {
return publicClient.waitForTransactionReceipt({
hash,
confirmations,
});
}
Three confirmations is a reasonable default for most L2s in this tutorial, but raise it for any chain you haven’t personally benchmarked, and treat the number as configurable per chain rather than a single constant across your whole codebase.
Step 9: Add Retry Logic and Error Handling
Network calls fail. RPC nodes time out, Across’s API occasionally returns a 5xx during high load, and a gas estimate can go stale between quote and execution. Wrap the flow in retries with exponential backoff, but never retry the deposit transaction itself blindly, since resubmitting after an ambiguous failure risks a double spend.
// retry.js
export async function withRetry(fn, { retries = 3, baseDelayMs = 1000 } = {}) {
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === retries) break;
const delay = baseDelayMs * 2 ** attempt;
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastError;
}
Apply withRetry to read-only calls like quote fetching and status polling freely. For the actual sendTransaction call, check the origin chain for an existing transaction with the same nonce before resubmitting, so a slow response doesn’t turn into a duplicate deposit.
Step 10: Wire Up Completion Notifications
Once pollDepositStatus resolves, fire a notification so the rest of your system (or a human on call) knows the transfer landed, rather than requiring someone to check a dashboard.
// notify.js
export async function notifyComplete(webhookUrl, payload) {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'bridge_filled',
depositId: payload.depositId,
originChainId: payload.originChainId,
destinationChainId: payload.destinationChainId,
amount: payload.amount,
timestamp: new Date().toISOString(),
}),
});
}
Point webhookUrl at a Slack incoming webhook, a Discord webhook, or your own internal event bus. Include the deposit ID and both chain IDs in every notification so a failed or delayed transfer is traceable without digging back through logs.
Step 11: Validate with a Minimal-Value Mainnet Transfer
Across’s documentation doesn’t advertise a public testnet deployment, so validation happens on mainnet with real, small amounts rather than a sandbox chain. Run your full pipeline, quote, approve, deposit, poll, notify, with an amount you’d be comfortable losing entirely if something in your own code is wrong.
Start with something like $5 of USDC from Arbitrum to Base. Confirm the amount that lands on the destination matches the quote within the expected fee margin, confirm your webhook fires, and confirm your logs capture the deposit ID correctly before you increase the transfer size. Only scale up once a handful of small transfers have completed cleanly end to end.
Step 12: Deploy and Monitor in Production
Run the bot under a process manager like pm2 or as a scheduled job, not as a script left open in a terminal tab. Log every quote, transaction hash, and status transition to a persistent store, since that history is what you’ll need if a transfer ever needs manual reconciliation.
pm2 start index.js --name across-bridge-bot
pm2 logs across-bridge-bot
pm2 save
Set an alert on any deposit stuck pending past your normal fill window, plus a separate alert on native gas balance for every origin chain you bridge from. A bot that can’t pay gas fails silently, often until a counterparty asks where their funds are.
Checking Rate Limits Before You Scale Up
Across exposes a dedicated /limits endpoint (same base URL and auth headers as the other calls in this tutorial) that returns the current minimum and maximum transfer size for a given route. Query it with originChainId, destinationChainId, and inputToken once when your bot starts working a new route, and cache the result. Limits move with available relayer liquidity, so a route that supported a $50,000 transfer last week isn’t guaranteed to support the same amount today, particularly for less liquid token pairs. Checking it before a large automated transfer is cheaper than discovering the cap when your deposit reverts.
Complete Working Project: The Cross-Chain Rebalancer Bot
Putting the pieces together, here’s a single entry point that checks a wallet’s USDC balance on Arbitrum, and if it drops below a threshold, pulls funds from Base to top it back up automatically. This is the pattern behind most treasury-rebalancing use cases for Across.
// index.js
import 'dotenv/config';
import { erc20Abi, parseUnits } from 'viem';
import { getClients, account } from './clients.js';
import { getBridgeQuote } from './quote.js';
import { approveIfNeeded } from './approve.js';
import { executeDeposit } from './deposit.js';
import { pollDepositStatus } from './track.js';
import { notifyComplete } from './notify.js';
import { withRetry } from './retry.js';
const USDC_ARBITRUM = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831';
const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const REBALANCE_THRESHOLD = parseUnits('100', 6);
const TOP_UP_AMOUNT = parseUnits('250', 6);
async function checkAndRebalance() {
const { publicClient } = getClients(42161);
const balance = await publicClient.readContract({
address: USDC_ARBITRUM,
abi: erc20Abi,
functionName: 'balanceOf',
args: [account.address],
});
if (balance >= REBALANCE_THRESHOLD) {
console.log('Balance healthy, no rebalance needed:', balance.toString());
return;
}
console.log('Balance low, initiating rebalance from Base...');
const quote = await withRetry(() =>
getBridgeQuote({
originChainId: 8453,
destinationChainId: 42161,
inputToken: USDC_BASE,
outputToken: USDC_ARBITRUM,
amount: TOP_UP_AMOUNT.toString(),
depositor: account.address,
})
);
await approveIfNeeded({
chainId: 8453,
tokenAddress: USDC_BASE,
spender: quote.tx.to,
amount: TOP_UP_AMOUNT,
});
const { hash, receipt } = await executeDeposit({
originChainId: 8453,
quote,
});
const depositId = receipt.logs
.find((l) => l.topics[0]?.toLowerCase().includes('v3fundsdeposited'))
?.data;
console.log('Deposit sent:', hash, 'waiting for fill...');
const filled = await pollDepositStatus({
originChainId: 8453,
depositId,
});
await notifyComplete(process.env.SLACK_WEBHOOK_URL, {
depositId,
originChainId: 8453,
destinationChainId: 42161,
amount: TOP_UP_AMOUNT.toString(),
});
console.log('Rebalance complete:', filled);
}
checkAndRebalance().catch((err) => {
console.error('Rebalance failed:', err);
process.exit(1);
});
Schedule this with a cron job or pm2’s cron restart option to run every few minutes. The deposit-log parsing above is simplified for readability, a production version should decode the log against the real event ABI instead of string-matching the topic, since topic hashes aren’t reliably human-readable across every RPC provider’s log format.
Common Pitfalls When Automating Bridge Transfers
Most failures teams hit moving Across from a manual UI into an automated pipeline trace back to a handful of repeatable mistakes.
- Hardcoding chain IDs and token addresses. Chain support changes, as Blast’s July 20, 2026 deprecation shows. Fetch the current route list at startup instead of baking it into a config file you’ll forget to update.
- Approving unlimited token allowances. It saves one transaction per bridge but turns a single compromised spender into a total balance drain. Approve exact amounts and re-approve per transfer.
- Ignoring the difference between transaction confirmation and fill confirmation. A confirmed origin-chain transaction is not a completed bridge. Always poll
/deposit/statusbefore assuming funds landed. - Blindly retrying a failed deposit transaction. If a send times out without a clear success or failure, check the chain for an existing transaction with that nonce before resubmitting, or you risk a double spend.
- Running one RPC endpoint with no fallback. A single public RPC provider rate-limiting your bot mid-transfer looks identical to a chain outage from inside your code. Configure at least one backup provider per chain.
- Treating relayer fees as fixed. Quotes reflect live relayer competition and shift with network conditions. Re-fetch a quote if more than a minute passes between quoting and executing.
- Skipping the route limits check. A route that handles small transfers can reject a large one if relayer liquidity on that pair is thin. Query
/limitsbefore scaling up, not after a revert. - Logging secrets by accident. A debug line that dumps the full request object can leak your bearer token or private key into a log aggregator. Redact secret fields before anything touches a logger.
Troubleshooting Guide
These are the errors and stuck states that come up most often once a bridging bot moves past local testing into real, unattended use.
- Quote request returns a 401. Your bearer token is missing, expired, or malformed. Confirm the Authorization header reads exactly
Bearer <token>with no extra whitespace. - Quote request returns a 400 with an unsupported route error. The chain, token, or pair you requested isn’t currently active. Re-run
getSupportedChains()andgetSupportedTokens()to confirm the route is live before retrying. - Deposit transaction reverts on-chain. Usually a stale allowance or an amount that no longer matches the quote’s expected input. Re-fetch the quote and re-check the allowance before resending.
- Deposit status stays pending well past two minutes. Confirm the origin-chain transaction actually confirmed and wasn’t dropped from the mempool. If it confirmed and status is still pending, check Across’s status page for a relayer network issue before assuming your code is at fault.
- depositId extraction returns undefined. The event log parsing is fragile if you’re string-matching topic hashes. Decode the receipt logs against the actual contract ABI instead of pattern-matching on hex strings.
- Approval transaction succeeds but the deposit still reverts on allowance. You approved the wrong spender address. The spender must be the exact
quote.tx.toaddress from that specific quote response, not a cached address from an earlier call. - Webhook notification never fires. Check that
pollDepositStatusactually resolved rather than throwing, since an uncaught rejection upstream will silently skip the notification step if your error handling doesn’t log it. - Bot works locally but fails in production. Almost always an environment variable that didn’t get set on the deployment target. Log a startup check that fails loudly if any required env var is missing, rather than letting a blank string reach the API call.
- Gas estimation fails on the deposit transaction. Your wallet likely doesn’t hold enough native gas token on the origin chain. This is unrelated to the bridged asset balance and needs to be monitored separately per chain.
Advanced Tips for Production-Grade Bridging
Once the basic flow is stable, add idempotency keys to every deposit request in your own database before you broadcast the transaction, not after. That way a crash between broadcasting and logging doesn’t leave you unsure whether a transfer actually went out. Store the quote, the transaction hash, and the deposit ID as one atomic record, and design your rebalancing logic to check that record before initiating a new transfer for the same intent.
For teams moving meaningful volume, build a circuit breaker that halts automated bridging if a fill takes too long or relayer fees spike past a set threshold. The July 2026 relay-software exploit that cost Across $4.5 million came from a bug in event verification on Solana, not a smart contract flaw, a reminder that off-chain code, including your own polling and parsing logic, deserves the same scrutiny as the contracts you call. Across confirmed its core contracts and Solana programs weren’t touched and user funds were unaffected, but the incident is a useful case study in defense in depth for anyone automating around any bridge.
If your application needs to bridge specifically to enable a destination-chain action, like swapping into a specific token the moment funds land, look at Across’s gasless swap endpoint (/swap/gasless), which is built to chain a bridge and a destination-chain action into a single signed intent rather than two sequential transactions your bot has to orchestrate manually.
Across vs Other Cross-Chain Bridge Options
Across isn’t the only way to move assets programmatically between chains, and the right choice depends on what you’re bridging and how much control you need over the trust model.
| Option | Trust Model | Typical Speed | Best For |
|---|---|---|---|
| Across Protocol | Intent-based, relayer-fronted liquidity | ~2 seconds | General-purpose ETH, USDC, and token bridging with a developer API |
| Canonical L2 bridges (Arbitrum, Base, Optimism) | Native rollup security, no third party | Minutes in, ~7 days out | Maximum trust minimization for large, infrequent transfers |
| Circle CCTP V2 | Issuer-secured burn-and-mint | Minutes | Native USDC transfers where issuer backing matters more than speed |
| Aggregators (LI.FI, Socket, Jumper) | Varies by underlying route selected | Varies | Comparing multiple bridges’ pricing before committing to one |
Across trades the zero-trust purity of a canonical bridge for speed, since you’re relying on a relayer network rather than waiting out a challenge period. For most application-level automation, where a two-second fill unlocks the next step in a user flow or a trading strategy, that tradeoff is exactly the point. For a treasury moving a nine-figure sum once a quarter, the canonical bridge’s slower, more conservative security model is probably still the better call.
Frequently Asked Questions
Is the Across API free to use?
Across doesn’t charge a separate API access fee on top of its bridging costs. You still pay the relayer fee baked into every quote, plus gas on the origin chain, but there’s no additional subscription layer to query routes, quotes, or deposit status.
Do I need an integrator ID to test locally?
Yes. Every request to the production API, including read-only calls like listing supported chains, requires a valid integratorId query parameter alongside your bearer token. Request one through Across’s developer channels before writing any code that calls the live API.
What happens if a relayer never fills my deposit?
Across’s deposit lifecycle includes expired and refunded states specifically for this scenario. If no relayer fills the intent within the protocol’s window, the deposit becomes eligible for a refund back to the depositor on the origin chain, which is why the polling logic in this tutorial explicitly checks for those statuses rather than looping forever.
Can I bridge directly between two Layer 2s without routing through Ethereum mainnet?
Yes, and it’s one of the main reasons to use an intent-based bridge like Across instead of each chain’s canonical bridge, which typically only connects back to mainnet. Set your origin and destination chain IDs to any two supported L2s, such as Base and Arbitrum, and Across routes the transfer directly without a mainnet hop.
Is my automation at risk from a relay software bug like the July 2026 incident?
That specific incident drained roughly $4.5 million from a relayer’s own reserves due to a bug in event verification on Solana, and Across confirmed user funds and core contracts were unaffected. Your own bot doesn’t hold relayer inventory, so it wasn’t directly exposed, but the episode is a good reason to build monitoring around unusually long fill times or fee anomalies rather than assuming any single component in the chain is infallible.
Should I use the REST API or the app-sdk package?
The @across-protocol/app-sdk package wraps the same REST endpoints shown in this tutorial with TypeScript types and helper functions, which is convenient if your project is already TypeScript-based and uses viem, its peer dependency. Calling the REST API directly, as this tutorial does, keeps your dependency footprint smaller and works identically from any language or runtime that can make an HTTP request.
How much native gas token should my bot keep on hand per chain?
There’s no universal number since gas prices move with network conditions, but a reasonable floor is enough for 20 to 30 typical transactions on each origin chain your bot uses, refreshed automatically rather than topped up manually. Running out of native gas mid-operation is one of the most common and most avoidable causes of a stalled bridging bot.
Does Across support non-EVM chains like Solana?
Yes, Across’s relay infrastructure extends to Solana, which is exactly the surface where the July 2026 relay software exploit occurred. If you’re bridging to or from a non-EVM chain, expect your wallet and signing setup to differ from the viem-based EVM flow shown throughout this tutorial, since Solana uses its own transaction and signature format entirely.




