How to add token swaps to your app on NEAR

Any NEAR app can let its users swap tokens without writing a contract or running a backend. The Intear DEX Aggregator finds the best route across Rhea, Rhea DCL and Plach pools on Intear DEX, and returns it as transactions for the user's wallet to sign.

This guide builds a swap form step by step. The examples on this page send real transactions from your browser to the same endpoints your app would use.

What you'll use

ServiceWhat it doesWhere
DEX AggregatorQuotes, and the transactions that execute themhttps://router.intear.tech
Intear APIToken list, prices, icons and balanceshttps://api.intea.rs
NEAR ConnectConnecting the user's walletNPM: @hot-labs/near-connect

You can start by calling the APIs straight from the browser, with no API keys (rate limited), and no server is needed from you unless you prefer to self-host the aggregator. Everything runs on NEAR mainnet only, testnet is not supported.

1. Name tokens and amounts

The aggregator calls native NEAR near, and any other token nep141: followed by its contract, like nep141:usdt.tether-token.near. The Intear API uses just the contract ID, and has native NEAR's price and icon under wNEAR, wrap.near.

Amounts are whole numbers in the token's smallest unit, sent as strings: 1 USDt is 1000000 because USDt has 6 decimals, and 1 NEAR is 1 followed by 24 zeros. Get each token's decimals, icon and price from the Intear API, and use BigInt for every amount you send, since floating-point numbers lose precision long before 24 decimals.

const params = new URLSearchParams({
  q: 'usdc',
  fields: 'symbol,decimals,icon_hash,price_usd,reputation',
  limit: 6,
});
const response = await fetch(`https://api.intea.rs/tokens/search?${params}`);
const { columns, rows } = await response.json();
// columns: ["account_id", "symbol", "decimals", "icon_hash", "price_usd", "reputation"], then a row per token
// icons: https://api.intea.rs/icon/<icon_hash>.webp

function toRaw(amount, decimals) {
  const [whole, fraction = ''] = amount.split('.');
  return BigInt(whole + fraction.padEnd(decimals, '0').slice(0, decimals));
}
toRaw('1.5', 6); // 1500000n

Anyone can create a token with a familiar symbol: search for USDC above and you get several tokens called USDC. Mark the tokens whose reputation is Reputable (green check above) or NotFake (blue check), so users can tell them apart. The other values are Unknown and Spam.

For a token picker that keeps up with typing, send tokens.search over the API's WebSocket with no debounce for best speed. The search always ranks real verified tokens (like USDC) first, sorts by volume, liquidity, and other factors, so the list is ready to present as-is.

2. Get a quote

GET /route takes the two tokens, the amount and the slippage, and returns a quote from each DEX that found a route. They come best first, ranked by what the user gets after gas and storage costs, so you can just always take the first one.

Live example


  
  
const params = new URLSearchParams({
  token_in: 'near',
  token_out: 'nep141:usdt.tether-token.near',
  amount_in: '1000000000000000000000000', // 1 NEAR
  slippage_type: 'Auto',
  min_slippage: '0.001',
  max_slippage: '0.05',
  trader_account_id: 'alice.near',
});
const response = await fetch(`https://router.intear.tech/route?${params}`);
if (!response.ok) throw new Error(await response.text());
const [best] = await response.json(); // undefined when no DEX found a route

What to show from a quote:

FieldShow it as
estimated_amountWhat the user gets / what the user pays if exact-output
worst_case_amountMinimum received / maximum paid. If the price moves past the slippage before the swap executes, it fails and the input is refunded
estimated_gas_usageGas fee, in yoctoNEAR
estimated_max_gas_cost + storage_depositsThe most NEAR the swap can cost on top of the amount. Check the user has it before enabling the Swap button

Pass trader_account_id if a wallet is connected: without it, the route can't include the storage deposits the user is missing, and the swap can fail. Before wallet is connected, you can quote without it to show rates. Auto slippage picks a limit between min_slippage and max_slippage depending on how volatile the tokens are, so established tokens get a tighter one than fresh memecoins.

No route is an empty array, not an error. Errors are 4xx responses with a plain-text message for you, the developer, rather than for users.

3. Keep the quote live

Pools can change every block, so a quote gets old fast. Instead of polling, open a WebSocket to wss://router.intear.tech/route/subscribe and send the same query string with an id. The server sends new quotes every time the result changes, and each message you send replaces the request before it.

Send a new request on every change of the form, with no debounce, and ignore updates whose id isn't the latest you sent: they belong to a request that's already replaced. Clear the quote on screen as soon as the form changes, so nobody signs a quote for an amount they've already edited. Open the socket below and type in the amount to see it happen.

Live example

    class RouteSocket {
      #socket = null; #requestId = 0; #query = null; #delay = 1500;
      constructor(onRoutes, onError) { this.onRoutes = onRoutes; this.onError = onError; this.#open(); }
      follow(query) { this.#query = query; this.#send(); } // a query string, or null to stop
      #open() {
        const socket = new WebSocket('wss://router.intear.tech/route/subscribe');
        this.#socket = socket;
        socket.onopen = () => { this.#delay = 1500; if (this.#query !== null) this.#send(); };
        socket.onmessage = (event) => {
          const update = JSON.parse(event.data);
          if (update.id !== this.#requestId) return; // an update of a replaced request
          if ('error' in update) this.onError(update.error); else this.onRoutes(update.routes);
        };
        // Reconnect on every close
        socket.onclose = () => {
          setTimeout(() => this.#open(), this.#delay);
          this.#delay = Math.min(Math.floor(this.#delay * 1.8), 30000);
        };
      }
      #send() {
        this.#requestId += 1;
        if (this.#socket.readyState !== WebSocket.OPEN) return;
        this.#socket.send(JSON.stringify({ id: this.#requestId, query: this.#query ?? undefined }));
      }
    }

    4. See what the user signs

    A quote's execution_instructions are the transactions that execute it, in order: wrapping NEAR, storage deposits, registrations and the swap itself. Send them one after another, each once the one before has executed. Their arguments are base64-encoded JSON. Here are the transactions of the quote you picked in step 2:

    Live example

    • NearTransaction is the only kind of instruction, and FunctionCall the only kind of action, for now. If you see anything else, show an error instead of skipping it.
    • Transactions with is_fee_transaction: true pay your app's fee (step 7). Hold them back until you know the swap went through.

    5. Sign with the user's wallet

    NEAR Connect shows a wallet picker and gives you the connected wallet: Intear Wallet, HOT, Meteor, NEAR Mobile and others.

    import { NearConnector } from 'https://cdn.jsdelivr.net/npm/@hot-labs/near-connect@0.11.4/+esm';
    
    const connector = new NearConnector({ network: 'mainnet' });
    connector.on('wallet:signIn', (event) => useWallet(event.wallet, event.accounts[0].accountId));
    connector.on('wallet:signOut', () => useWallet(null, null));
    connectButton.onclick = () => connector.connect();
    
    async function send(wallet, instructions) {
      const transactions = instructions.map(({ NearTransaction: tx }) => ({
        receiverId: tx.receiver_id,
        actions: tx.actions.map(({ FunctionCall: call }) => ({
          type: 'FunctionCall',
          params: {
            methodName: call.method_name,
            args: JSON.parse(atob(call.args)), // the aggregator sends base64, near-connect accepts an object
            gas: String(call.gas),
            deposit: call.deposit,
          },
        })),
      }));
      const outcomes = await wallet.signAndSendTransactions({ transactions });
      return outcomes.map((outcome) => outcome.transaction_outcome.id);
    }
    • When the promise rejects, the user most likely closed the wallet's popup: show it as cancelled rather than as an error.
    • The wallet resolves even when the swap inside a transaction fails, so check the outcome before saying it's done.
    • The connected account is part of the query, so quote again when it changes.

    Building a wallet, a bot or a Telegram app that signs with its own keys? Pass signing_public_key with your route requests, and each quote comes with the nonce and block hash to sign with, so you can send transactions without asking an RPC for them first. See Executing a route.

    6. Check the outcome

    Ask /route/outcome what the transactions did: how much the user spent and received, and why the swap failed if it did, in words written for users. Call it right after sending: it waits for the transactions to execute. With wait_until=swap, it answers as soon as the swap executes, before the output reaches the user's wallet.

    const params = new URLSearchParams({
      token_in: 'near',
      token_out: 'nep141:usdt.tether-token.near',
      trader_account_id: 'alice.near',
      tx_hashes: hashes.join(','), // without the fee transactions
      fee_amount: quote.fee_amount,
      wait_until: 'swap',
    });
    const response = await fetch(`https://router.intear.tech/route/outcome?${params}`);
    if (!response.ok) throw new Error(await response.text());
    const outcome = await response.json();
    {
      "charge_fee": true,
      "amount_in": "1000000000000000000000000",
      "amount_out": "4880007",
      "balance_changes": [],
      "next_steps": [],
      "error": null,
      "settled": false
    }
    1. charge_fee: true means the swap went through: show amount_out, what the user actually got, rather than the estimate. Otherwise show error, like "The price moved more than your slippage allows while the transaction was on its way. Try again, or increase the slippage."
    2. If charge_fee is true and the quote has fee transactions, send them now.
    3. If settled is false, ask again with wait_until=executed and send that answer's next_steps. They move tokens that ended up somewhere else to where the user wanted them, like the wNEAR left over from a failed swap from NEAR. Usually there are none.

    7. Charge your own frontend fee

    Add fees to your route requests: a JSON object of up to 4 accounts and the fee each one charges, as a fraction of the swap, 5% at most together. The fee is charged once, on the input. Each account gets 80% of its fee and the aggregator keeps 20%. Quotes already have it taken out, so you show them as they are, and there's nothing to set up first.

    Live example

    
      

    URL-encoded:

    Where the fee lands depends on the route:

    • Plach pays it inside the swap, into your account's Intear DEX balance of the input token. See what you've collected and withdraw it on the Developers page.
    • Rhea and Rhea DCL don't have a native fee system, so their routes add fee transactions that send it to your wallet to the end, with NEAR transferred as wNEAR. Send them only when the outcome says charge_fee: true.

    Pass referrer_id with your app's account too, to see the volume you bring on the Developers page, can be useful for tracking. If you have a referral system and split the fee with other accounts, on the same page you will see breakdown by who received the fees (your app's referrers).

    8. Put it together

    Here's everything above as one swap form: live quotes over the socket, balances that follow the account over the Intear API socket, and NEAR Connect for signing. The code is in examples.js.

    This swaps real tokens on NEAR mainnet.

    Live on mainnet

    You pay
    You receive
    0

    Before you ship

    • Show the estimate, the minimum received, the route's DEX, your fee, and the most the swap can cost in NEAR.
    • Check balances before enabling the Swap button: the input, plus NEAR for gas and storage. Keep about 0.05 NEAR back when the user presses Max on NEAR.
    • After the swap, show the outcome's amount_out, and link the transactions on an explorer like NearBlocks.
    • Follow balances with balances.subscribe instead of fetching them, so they update a block or two after the swap.

    Next steps