--- title: "Example: USDT to USDC" description: A complete cross-asset transfer, 2 USDT on Arbitrum to USDC on Solana, explained step by step with the full code at the end. --- This walks through one real transfer end to end: the user holds 2 USDT on Arbitrum and wants USDC in a Solana wallet. It is a cross-asset transfer, which makes it a good second example after the [quickstart](/integration/quickstart): the code is nearly identical, but what happens under the hood and what shows up in the quote are different. ## 1. Connect ```ts import { createHularClient } from "@mayanfinance/hular-sdk"; const { hular } = createHularClient({ apiKey: process.env.HULAR_API_KEY, }); ``` Keyless requests work but are rate limited and priced with extra spread. See [API keys](/integration/api-keys). ## 2. Find the tokens Addresses come from the catalog, never hardcoded. Both tokens have six decimals, so 2 USDT is the base-unit string `"2000000"`. ```ts const { tokens } = await hular.listTokens({}); const usdt = tokens.find((t) => t.chain === "arbitrum" && t.symbol === "USDT")!; const usdc = tokens.find((t) => t.chain === "solana" && t.symbol === "USDC")!; ``` ## 3. Quote `recipient` is the Solana wallet that receives the USDC. `refund_address` is an Arbitrum address, because refunds are always paid on the source chain in the deposited token. ```ts import { TradeType, quoteOf } from "@mayanfinance/hular-sdk"; const response = await hular.getQuote({ srcChain: "arbitrum", dstChain: "solana", srcToken: usdt.address, dstToken: usdc.address, tradeType: TradeType.EXACT_INPUT, amount: "2000000", recipient: recipientSolanaAddress, refundAddress: userArbitrumAddress, gasDrop: "", }); const quote = quoteOf(response); if (!quote) throw new Error("no quote"); ``` What comes back for this trade: | Field | Typical value | Meaning | | --- | --- | --- | | `quote_hash` | `0x9f2c...` | Identifies the order for the rest of its life. Store it before signing. | | `amount_in` | `2000000` | Echoes the request: deposit exactly this. | | `amount_out` | `1996xxx` | Expected USDC, net of every fee including the conversion. | | `min_amount_out` | slightly below `amount_out` | The enforced floor. If the lane cannot deliver at least this, the order refunds instead. | | `router_address` | escrow contract on Arbitrum | Where the deposit goes. | On a 2 USDT transfer the fixed gas components are the dominant cost, so the percentage difference between `amount_in` and `amount_out` is larger than it would be on a bigger trade. Render `amount_out` and `min_amount_out` to the user; the breakdown in `fee_breakdown.components` explains the gap. A quote's price is fixed when issued. If the user sits on a confirmation screen, re-quote before signing. See [Get a quote](/integration/quote#refresh-before-signing). ## 4. Deposit `src_token` equals `bridge_token_src`, so on this lane the quote comes back with `direct_transfer: true` and the whole deposit collapses into one transaction: a plain ERC-20 `transfer` of the USDT to the escrow, with the 32-byte quote hash appended after the transfer calldata. No approval, no router call, the cheapest gas of any path. ```ts const params = quote.orderParams!; await wallet.sendTransaction({ to: params.srcToken, data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [params.routerAddress, BigInt(params.amountIn)], }) + quote.quoteHash.replace(/^0x/, ""), }); ``` This works because the indexer watches transfers into the escrow the same way it watches router deposits: it reads the transaction's calldata, takes the trailing 32 bytes as the quote hash, and attributes the deposit to that order. That is also why the transfer must be the transaction's own calldata, sent from the user's wallet straight to the token contract. A transfer routed through a contract wallet or a batching layer hides the calldata and the deposit will not be attributed. The SDK's `ChainAdapter` builds exactly this transaction when you pass `{ directTransfer: quote.directTransfer }` to `steps`. If the flag comes back `false`, fall back to the router path: approve the vault if the allowance is short, wait for the approval to confirm, then call `depositToken` with the quote hash. See [Deposit on EVM](/integration/deposit-evm). Either way, deposit exactly `amount_in`; a different amount is a mismatched deposit and is refunded, not paid out proportionally. There is nothing to do on Solana. No claim, no attestation, no destination transaction. The operator's fulfillment creates the recipient's USDC token account if it does not exist yet. ## 5. Track Poll `GetOrder` with the quote hash until the state is terminal. Before the deposit is indexed, the call fails with `NotFound`; that is expected for the first few seconds, so treat it as "keep polling" rather than an error. ```ts import { isTerminalState, orderOutcome } from "@mayanfinance/hular-sdk"; import { Code, ConnectError } from "@connectrpc/connect"; for (;;) { try { const detail = await hular.getOrder({ quoteHash: quote.quoteHash }); const state = detail.order?.state; if (state && isTerminalState(state)) { console.log(orderOutcome(state)); break; } } catch (error) { if (!(error instanceof ConnectError) || error.code !== Code.NotFound) { throw error; } console.log("order not indexed yet"); } await new Promise((r) => setTimeout(r, 2000)); } ``` The outcome is `fulfilled` in the normal case. If the lane is suspended or the peg moves past `min_amount_out` before fulfillment, the outcome is `refunded` and the 2 USDT returns to `refund_address` on Arbitrum, minus the refund fee. See [Refunds](/concepts/refunds). The same order is viewable at `https://hular.dev/explorer/`. ## Full code A complete Node script. It uses the SDK for the API and [viem](https://viem.sh) for the Arbitrum transactions, so it runs standalone with a private key; in a browser app you would replace the viem section with `createChainAdapter` and the connected wallet, as in the [quickstart](/integration/quickstart). ```bash npm install @mayanfinance/hular-sdk @connectrpc/connect viem export HULAR_API_KEY=... export PRIVATE_KEY=0x... export SOLANA_RECIPIENT=... ``` ```ts import { createHularClient, TradeType, isTerminalState, orderOutcome, } from "@mayanfinance/hular-sdk"; import { createPublicClient, createWalletClient, encodeFunctionData, http, parseAbi, } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { Code, ConnectError } from "@connectrpc/connect"; import { arbitrum } from "viem/chains"; const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`); const recipient = process.env.SOLANA_RECIPIENT!; const publicClient = createPublicClient({ chain: arbitrum, transport: http() }); const walletClient = createWalletClient({ account, chain: arbitrum, transport: http(), }); const { hular } = createHularClient({ apiKey: process.env.HULAR_API_KEY, }); // 1. Resolve tokens from the catalog const { tokens } = await hular.listTokens({}); const usdt = tokens.find((t) => t.chain === "arbitrum" && t.symbol === "USDT")!; const usdc = tokens.find((t) => t.chain === "solana" && t.symbol === "USDC")!; // 2. Quote 2 USDT (six decimals) for USDC on Solana const response = await hular.getQuote({ srcChain: "arbitrum", dstChain: "solana", srcToken: usdt.address, dstToken: usdc.address, tradeType: TradeType.EXACT_INPUT, amount: "2000000", recipient, refundAddress: account.address, gasDrop: "", }); if (response.quote.case !== "crossChain") throw new Error("no quote"); const quote = response.quote.value; const params = quote.orderParams!; console.log(`quote ${quote.quoteHash}`); console.log( `deposit ${params.amountIn} USDT, receive ~${quote.amountOut} USDC (min ${quote.minAmountOut})`, ); // 3. Deposit exactly amount_in, bound to the quote hash const erc20Abi = parseAbi([ "function transfer(address to, uint256 amount) returns (bool)", "function allowance(address owner, address spender) view returns (uint256)", "function approve(address spender, uint256 amount) returns (bool)", ]); const routerAbi = parseAbi([ "function depositToken(bytes32 quoteHash, address token, uint256 amount)", ]); const amountIn = BigInt(params.amountIn); const vault = params.routerAddress as `0x${string}`; const token = params.srcToken as `0x${string}`; const quoteHash = quote.quoteHash as `0x${string}`; let depositHash: `0x${string}`; if (quote.directTransfer) { // One transaction: transfer to the vault, quote hash appended to the calldata depositHash = await walletClient.sendTransaction({ to: token, data: (encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [vault, amountIn], }) + quoteHash.slice(2)) as `0x${string}`, }); } else { // Router path: approve the vault if the allowance is short, then depositToken const allowance = await publicClient.readContract({ address: token, abi: erc20Abi, functionName: "allowance", args: [account.address, vault], }); if (allowance < amountIn) { const approveHash = await walletClient.sendTransaction({ to: token, data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [vault, amountIn], }), }); await publicClient.waitForTransactionReceipt({ hash: approveHash }); console.log(`approved ${approveHash}`); } depositHash = await walletClient.sendTransaction({ to: vault, data: encodeFunctionData({ abi: routerAbi, functionName: "depositToken", args: [quoteHash, token, amountIn], }), }); } await publicClient.waitForTransactionReceipt({ hash: depositHash }); console.log(`deposited ${depositHash}`); // 5. Poll until the order reaches a terminal state for (;;) { try { const detail = await hular.getOrder({ quoteHash: quote.quoteHash }); const state = detail.order?.state; if (state && isTerminalState(state)) { console.log(orderOutcome(state)); break; } } catch (error) { if (!(error instanceof ConnectError) || error.code !== Code.NotFound) { throw error; } console.log("order not indexed yet"); } await new Promise((r) => setTimeout(r, 2000)); } ``` Expected output: ``` quote 0x9f2c... deposit 2000000 USDT, receive ~1996xxx USDC (min 1994xxx) deposited 0xcd56... fulfilled ``` With `direct_transfer: true`, which is the normal case on this lane, the whole transfer costs the user a single transaction. The `approved` line only appears on the fallback router path. ## Next Guarantee the exact USDC amount with EXACT_OUTPUT. What the asset_conversion component covers on this lane. Permit, permit2, direct transfer, and the swap paths. What happens if the lane cannot fill at the floor.