---
name: "intear-swap-frontend"
description: "Build NEAR token swap UIs on the Intear DEX aggregator and Intear API: non-custodial (NEAR Connect) or custodial (wallets, burner wallets, bots signing with near-api-js)."
---

# Swap frontends on Intear (NEAR)

A swap app is a thin layer: live quotes from the Intear DEX aggregator, live tokens/prices/balances from
the Intear API, and a signer. Nothing to deploy on chain. Mainnet only.

Live docs win over this file: https://docs.intear.tech/llms.txt (each page also as `<page>.md`),
API schema at https://api.intea.rs/schema. Check a shape with one quick request before building on it.

## Endpoints

| What | Where |
| --- | --- |
| Live quotes | `wss://router.intear.tech/route/subscribe` (one-off: `GET https://router.intear.tech/route?...`) |
| Swap result, errors, follow-up txs | `GET https://router.intear.tech/route/outcome` |
| Tokens, prices, balances, search | `wss://api.intea.rs/ws` (one-off methods also `POST https://api.intea.rs/<path>` with a JSON body) |
| Token icons | `https://api.intea.rs/icon/<icon_hash>.webp` |
| Wallet selector (non-custodial) | `@hot-labs/near-connect`, pinned exact version (0.11.4 as of 2026-10) |
| Signing library (custodial) | `near-api-js` 7.x (7.4.0 as of 2026-10) |
| RPCs that allow browser CORS | `https://rpc.shitzuapes.xyz`, `https://rpc.intea.rs` (1 s slowdown without key), `https://rpc.mainnet.near.org`, `https://free.rpc.fastnear.com` |

Hosting: serve the page over http(s). claude.ai artifacts block fetch/WebSocket to other hosts and
wallet iframes, so a swap page can't run as an artifact. Deliver an `.html` file or a project.

## Pick the custody model first

- **Non-custodial (NEAR Connect):** the user's external wallet signs. Every batch is a wallet
  confirmation (fee txs and next steps each add one). The wallet picks nonce and block hash.
- **Custodial (your code holds the key):** a full wallet app (extension, mobile, web wallet) adding a
  swap screen, an in-page burner/"quick" wallet, or a bot/server. Your code signs, so swaps can be
  one tap or no confirmation at all. Pass `signing_public_key` on route requests so quotes carry
  `signing: { nonce, block_hash }`, skip RPC lookups before sending, and broadcast to several RPCs.

A page can offer both; keep the active signer as state and re-quote when it changes (trader id and
signing key are part of the query).

## Conventions that cause most bugs

- Amounts are integer strings in smallest units. `BigInt` for everything sent; floats only for display.
- Token naming: the router takes `near` for native NEAR and `nep141:<contract>` for a wallet token
  (`rhea-nep141:` / `intear-dex:` for DEX inner balances); the Intear API uses bare contract ids, with
  `near` in balances and `wrap.near` in token data and trades. Convert when quoting. Show native NEAR
  with wNEAR's icon and price.
- Gas in gas units, deposits in yoctoNEAR. Route `gas` is a number, `deposit` a string, `args` base64
  JSON. NEAR Connect wants strings and parsed args; near-api-js wants `bigint`s and arg bytes.
- Router `fees` are decimal fractions: `{"fees.myapp.near":0.01}` = 1%. Max 4 accounts, 5% total,
  steps of 0.000001; over that is a `400`.
- Tables (`{columns, rows}`) from the Intear API: key column first (`account_id` for tokens).
- Unknown instruction kinds (not `NearTransaction`) or actions (not `FunctionCall`): throw, never skip.

## Quotes

Query params: `token_in`, `token_out` (`near` or `nep141:<contract>`), `amount_in` (or `amount_out`),
`slippage_type=Auto&min_slippage=0.001&max_slippage=0.1` (or `Fixed&slippage=0.005`),
`trader_account_id` (needed for storage deposits), `fees` (JSON), `referrer_id`, for custodial
signers `signing_public_key=ed25519:...`, and `key` (API key; lifts the per-IP rate limit, `429`
otherwise; for the socket put `?key=` in the URL, it's ignored inside `query`).

- Send a new request on every input change: no debounce needed. Each message replaces the previous
  one; ignore replies whose `id` isn't the latest. Clear the shown quote immediately on change so a
  stale one is never signed.
- Routes come best first, already ranked after gas and storage costs. Show `estimated_amount` and
  `worst_case_amount` (minimum received); both already net of fees.
- Balance check: trader needs input + `storage_deposits` + `estimated_max_gas_cost` NEAR (input counts
  only when paying with NEAR; with `amount_out` the input is `worst_case_amount.amount_in`). Keep ~0.05
  NEAR back on Max for NEAR.
- A quote's `token_output` can differ from `token_out` (e.g. exact-in to an inner balance): the
  output lands at `token_output`, and the `/route/outcome` `next_steps` below convert it. Don't skip them.
- Custodial: no `signing` in the quote means the key isn't on chain yet (unfunded implicit account, or
  wrong key for the account). Re-quote once balances show the first deposit.
- Router socket never sends a close frame: reconnect on every close with growing delay.

```js
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(); } // query string, or null to stop
  #open() {
    const s = new WebSocket("wss://router.intear.tech/route/subscribe");
    this.#socket = s;
    s.onopen = () => { this.#delay = 1500; if (this.#query !== null) this.#send(); };
    s.onmessage = (e) => {
      const u = JSON.parse(e.data);
      if (u.id !== this.#requestId) return;
      if ("error" in u) this.onError(u.error); else this.onRoutes(u.routes);
    };
    s.onclose = () => { this.#socket = null; 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 }));
  }
}
```

## Executing a swap (both models)

1. Split `execution_instructions` into main (`is_fee_transaction: false`) and fee transactions.
2. Send main transactions in order; each but the last must execute before the next is sent.
3. Call `/route/outcome?wait_until=swap` with `token_in`, `token_out`, `trader_account_id`,
   `tx_hashes` (main only), `fee_amount` (from the quote) immediately; it waits for the transactions.
   When you sign yourself, start it in parallel with sending the last transaction.
4. Show the result now and unlock the form: `charge_fee` true → show `amount_in`/`amount_out`;
   false → show `error` (written for end users).
5. In the background: if `charge_fee`, send fee txs. If `settled` is false, ask again with
   `wait_until=executed` and run its `next_steps` (e.g. unwrap leftover wNEAR, or move output from a
   differing `token_output`). Usually empty.

```js
async function routeOutcome({ tokenIn, tokenOut, trader, hashes, feeAmount, waitUntil }) {
  const p = new URLSearchParams({ token_in: tokenIn, token_out: tokenOut, trader_account_id: trader,
    tx_hashes: hashes.join(","), fee_amount: feeAmount, wait_until: waitUntil });
  const res = await fetch(`https://router.intear.tech/route/outcome?${p}`);
  if (!res.ok) throw new Error(`Outcome: ${await res.text()}`); // plain-text errors
  return res.json();
}

// send(instructions) -> { hashes, sent } where `sent` settles once the last tx is accepted.
async function executeSwap(route, send, args) {
  const main = route.execution_instructions.filter((i) => !i.NearTransaction?.is_fee_transaction);
  const fees = route.execution_instructions.filter((i) => i.NearTransaction?.is_fee_transaction);
  const { hashes, sent } = await send(main);
  const atSwap = routeOutcome({ ...args, hashes, feeAmount: route.fee_amount ?? "0", waitUntil: "swap" });
  atSwap.catch(() => {}); // surfaced below if `sent` throws first
  await sent;
  const outcome = await atSwap;
  const finish = (async () => {
    if (outcome.charge_fee && fees.length) await (await send(fees)).sent;
    const settled = outcome.settled ? outcome
      : await routeOutcome({ ...args, hashes, feeAmount: route.fee_amount ?? "0", waitUntil: "executed" });
    if (settled.next_steps.length) await (await send(settled.next_steps)).sent;
  })();
  return { outcome, hashes, finish }; // show `outcome` now, report errors from `finish` separately
}

function nearTransaction(instruction) {
  if (!("NearTransaction" in instruction)) throw new Error(`Unknown route instruction: ${Object.keys(instruction)[0]}`);
  const tx = instruction.NearTransaction;
  for (const a of tx.actions) if (!("FunctionCall" in a)) throw new Error(`Unknown route action: ${Object.keys(a)[0]}`);
  return tx;
}
```

Latency: a NEAR → token swap through wrap.near + Rhea logs the swap ~2 blocks (~1.2 s) after the
transaction's block; 2–4 s end to end from the click is normal. Plach (Intear DEX) routes are similar.
To see where time goes, read the tx with `EXPERIMENTAL_tx_status` and each receipt's block timestamp.

## Non-custodial: NEAR Connect

```js
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", (e) => use(e.wallet, e.accounts[0]?.accountId ?? null));
connector.on("wallet:signOut", () => use(null, null));
// restore: try { const w = await connector.wallet(); const [a] = await w.getAccounts(); } catch { /* none */ }

async function sendWithConnect(wallet, instructions) {
  const transactions = instructions.map((ins) => {
    const tx = nearTransaction(ins);
    return { receiverId: tx.receiver_id, actions: tx.actions.map(({ FunctionCall: c }) => ({ type: "FunctionCall", params: {
      methodName: c.method_name, args: JSON.parse(atob(c.args)), gas: String(c.gas), deposit: c.deposit } })) };
  });
  const outcomes = await wallet.signAndSendTransactions({ transactions });
  return { hashes: outcomes.map((o) => o.transaction_outcome.id), sent: Promise.resolve() };
}
```

- A rejected promise usually means the user closed the popup: show "cancelled", not an error.
- Pass plain objects (no framework proxies); wallets receive them through postMessage.
- The wallet resolves even if a receipt failed; `/route/outcome` is the source of truth.
- Account switch arrives as a new `wallet:signIn`: re-follow balances and re-quote.

## Custodial: signing with near-api-js

Use near-api-js 7.x (`npm i near-api-js`; in a single-file page,
`https://cdn.jsdelivr.net/npm/near-api-js@7.4.0/+esm`). Its `Signer` interface (`getPublicKey`,
`signTransaction`) is the seam: `KeyPairSigner` for a key in memory, or your own `Signer` subclass
wrapping a wallet keystore, Ledger, or a KMS/HSM.

### Keys and accounts

```js
import { KeyPair, KeyPairSigner, keyToImplicitAddress } from "near-api-js";
const keyPair = KeyPair.fromRandom("ed25519");                  // new burner/quick wallet
const accountId = keyToImplicitAddress(keyPair.getPublicKey()); // 64-hex implicit account
const secret = keyPair.toString();                              // "ed25519:...", the format wallets import
const signer = new KeyPairSigner(KeyPair.fromString(secret));
```

- An implicit account exists once someone sends it NEAR. Named accounts (`alice.near`) work the same
  with their own key.
- Route transactions attach deposits (`near_deposit`, `storage_deposit`, 1 yocto on `ft_transfer_call`),
  so the key must be a **full-access** key. Function-call access keys can't attach deposits.
- Burner wallets: show the private key right after creation with a warning, store it only on the
  device (clearing site data loses localStorage), and make deletion a two-step confirm.

### Simplest: let near-api-js handle nonce and sending

One RPC round trip slower per transaction, no `signing_public_key` needed:

```js
import { Account, actions } from "near-api-js";
const account = new Account(accountId, "https://free.rpc.fastnear.com", signer);
const toActions = (tx) => tx.actions.map(({ FunctionCall: c }) =>
  actions.functionCall(c.method_name, Uint8Array.from(atob(c.args), (ch) => ch.charCodeAt(0)), BigInt(c.gas), BigInt(c.deposit)));

async function sendWithAccount(instructions) {
  const hashes = [];
  for (const ins of instructions) {
    const tx = nearTransaction(ins);
    const result = await account.signAndSendTransaction({ receiverId: tx.receiver_id, actions: toActions(tx), waitUntil: "EXECUTED_OPTIMISTIC" });
    hashes.push(result.transaction_outcome.id);
  }
  return { hashes, sent: Promise.resolve() };
}
```

### Fastest: sign from the quote's `signing`, broadcast to several RPCs

```js
import { createTransaction, baseEncode, baseDecode, JsonRpcProvider } from "near-api-js";
const providers = RPCS.map((url) => new JsonRpcProvider({ url }));

/** First success wins; duplicates of an accepted tx just fail. If every RPC rejects, throw the first error. */
async function broadcast(signedTransaction, waitUntil) {
  try { return await Promise.any(providers.map((p) => p.sendTransactionUntil(signedTransaction, waitUntil))); }
  catch (err) { throw err.errors[0]; }
}

class SwapSigner {
  #queue = Promise.resolve();
  #lastNonce = 0n;
  constructor(accountId, signer) { this.accountId = accountId; this.signer = signer; }

  // Every tx but the last waits for optimistic execution; the last is only sent (`sent`) while
  // /route/outcome already watches for it. Batches queue so nonces land in order.
  send(instructions, signing) {
    const run = this.#queue.then(async () => {
      if (!signing) throw new Error("Quote has no signing info: key not on chain yet");
      const publicKey = await this.signer.getPublicKey();
      const blockHash = baseDecode(signing.block_hash);
      const signed = [];
      for (const ins of instructions) { // sign all first: hashes are known before sending
        const tx = nearTransaction(ins);
        const quoted = BigInt(signing.nonce);
        this.#lastNonce = (quoted > this.#lastNonce ? quoted : this.#lastNonce) + 1n;
        const transaction = createTransaction(this.accountId, publicKey, tx.receiver_id, this.#lastNonce, toActions(tx), blockHash);
        const { txHash, signedTransaction } = await this.signer.signTransaction(transaction);
        signed.push({ hash: baseEncode(txHash), signedTransaction });
      }
      for (const t of signed.slice(0, -1)) {
        const result = await broadcast(t.signedTransaction, "EXECUTED_OPTIMISTIC");
        const failure = [result.transaction_outcome, ...result.receipts_outcome].find((o) => o.outcome.status.Failure);
        if (failure) throw new Error(`Transaction ${t.hash} failed: ${JSON.stringify(failure.outcome.status.Failure)}`);
      }
      return { hashes: signed.map((t) => t.hash), sent: broadcast(signed.at(-1).signedTransaction, "INCLUDED") };
    });
    this.#queue = run.then((r) => r.sent).catch(() => {});
    return run;
  }
}
```

Nonce rules: next nonce = max(`signing.nonce`, last nonce used by this key) + 1, per transaction;
quotes can predate your last send. `block_hash` stays valid 86,400 blocks (~14 h at current block
time). Without `signing` (key not on
chain, or a quote fetched without `signing_public_key`), get the nonce with
`provider.viewAccessKey` and a recent block hash with `provider.viewBlock({ finality: "final" })`, or use
the simplest path. `FailoverRpcProvider` tries RPCs one after another: fine for reads, not for racing
sends.

### Integrating into a full wallet

- Signer: wrap the wallet's existing keystore (and hardware keys) in a `Signer`; Intear only ever sees
  the public key and signed transactions.
- One nonce queue per access key, shared by every feature of the wallet that sends from that key
  (transfers, staking, swaps), or concurrent sends collide on nonces.
- Confirmation: show the wallet's own review before signing (you pay, minimum received, route, app
  fee, max network cost), then sign main, fee and next-step transactions without asking again: they
  are part of the approved swap. Offer a no-confirm "instant" mode only as an explicit setting.
- Multiple accounts: quote with the active account's `trader_account_id` and key, and re-quote on switch.
- Hardware keys sign slowly: quotes go stale while waiting, but the route's slippage limit protects
  the trade; re-quote if the user takes long. Plach quotes with a TEAR fee discount fail after ~1000
  blocks (~10 min).
- Use the wallet's own balance/price sources if it has them; otherwise the Intear API feeds below.

### Bots and servers

Same `SwapSigner` with a `KeyPairSigner` from an env var, or a KMS-backed `Signer`; one-off quotes via
`GET /route?...&signing_public_key=...`, then `executeSwap`. near-kit is the Rust equivalent.

### Without dependencies (single-file pages only)

Possible with WebCrypto Ed25519 (current Chrome/Firefox/Safari) plus ~40 lines of Borsh and base58,
but prefer near-api-js. If you do: seed = last 32 bytes of the PKCS#8 export, PKCS#8 prefix
`302e020100300506032b657004220420`, secret = `ed25519:` + base58(seed + public key); `Transaction` =
signer_id string, key type u8 0 + 32-byte key, nonce u64, receiver_id string, block_hash [32],
Vec<Action> with FunctionCall = tag 2 (method string, args Vec<u8>, gas u64, deposit u128), all
little-endian, lengths u32; sign sha256(tx bytes), not the bytes; signed = tx bytes + u8 0 + 64-byte
signature; send base64 via `send_tx`; tx hash = base58(sha256). Compare one hash with near-api-js
before real funds.

## Live data from the Intear API

Protocol: wait for `{hello}`, send `{id, method, params}`, replies carry the same `id`
(`result` or `error:{code,message}`). `*.subscribe` creates a subscription named by its `id`; pushes
carry `sub`. `{sub, end}` means re-subscribe. `idle` arrives after 15 s of silence; treat 45 s of
nothing as dead and reconnect, re-sending pending requests and re-making subscriptions (read their
params again). Limits: 4,096 pending requests, 100 subscriptions, 2,000 tokens per list, 100 accounts
in `balances.subscribe`, messages under 64 KiB.

Subscriptions a swap page needs:
- **Top tokens:** `tokens.subscribe { fields, sort: { by: "volume_usd_24h", desc: true }, limit: 60,
  min_change: { price_usd: "0.1%", volume_usd_24h: "1%" }, min_interval_ms: 1000 }`. The sort field must
  be in `fields`. Push: `set` (changed fields per token id; all fields for a newly entered token),
  `left` (dropped out).
- **Watched tokens:** a second `tokens.subscribe { fields, ids: [...] }` for selected, held and
  searched tokens (always include `wrap.near` for NEAR's price). Grow it with
  `tokens.update { sub, add }` (reply has the new rows). If ids were added before its first reply,
  send the update for the missing ones from the reply handler.
- **Balances:** `balances.subscribe { account, sources: ["direct", "native"] }`. Reply: `balances`
  table of `{ token, source, balance }`; push: `balances: { "<token>": "<balance>" }` (changed only).
  Close and re-subscribe on account change. Add held tokens to the watched list.
- Keep token data in one map by id and keep selected tokens as ids, so pushes update everything shown.
  Re-render on pushes, but don't rebuild icons whose token didn't change (flicker).
- Fields: `symbol`, `name`, `decimals`, `icon_hash`, `price_usd` (string, per whole token),
  `volume_usd_24h`. Never request `icon` (whole image per row).

Search that feels instant:
1. On each keystroke, filter tokens already in the map locally (exact symbol, then prefix, then
   substring of symbol/name/id; tie-break by volume) and render right away.
2. Send `tokens.search { q, fields, limit: 30, holder: account }` over the open socket (~130 ms), with
   no debounce: one request in flight, newest query queued, older queued ones dropped.
3. Cache results per query; render server results first, then local matches not among them, only if
   the input still equals that query. Merge result rows into the token map.

## App fee and referral

- `fees` on every route request; `referrer_id` (your app account) to attribute volume.
- Each fee account gets 80% of its fee; 20% goes to `dex-aggregator.intear.near`. Charged once, on the
  input token.
- Plach (Intear DEX) pays fees inside the swap, into the receiver's Intear DEX inner balance of the
  input asset. A receiver is only paid if that asset is registered for it; with `trader_account_id`
  the route registers it (trader pays, in `storage_deposits`), without it the fee is silently skipped.
  Check with view `are_assets_registered` on `dex.intear.near`,
  `{"asset_ids":["near","nep141:<token>"],"for":{"Account":"<id>"}}`. See and withdraw collected fees
  at https://dex.intea.rs/developers.
- Rhea / Rhea DCL routes pay app fees via `is_fee_transaction` transactions instead (NEAR fees as
  wNEAR), which only go out when `charge_fee` is true. Exception: Rhea swaps between Rhea balances
  (`rhea-nep141:` in and out) pay with `mft_transfer` inside the swap transaction, no fee txs.

## UI checklist

- Show: estimate, minimum received, route `dex_id`, app fee %, max network cost
  (`estimated_max_gas_cost` + `storage_deposits`), slippage (Auto + a few fixed values).
- Button states, in order: no wallet, tokens loading, same token, unfunded custodial account, no
  amount, not enough input, quoting, route error, not enough NEAR for fees, missing `signing`, ready.
- After a swap: show `amount_out` from the outcome (not the estimate), elapsed time, explorer links
  (`https://nearblocks.io/txns/<hash>`). Balances update from the socket within a block or two.
- Contracts aren't audited: say so where users put money in.

## Checks to offer (don't run unasked beyond a syntax check)

1. Small live swap each way (NEAR → USDC, USDC → NEAR); check the network tab for the broadcast and
   the outcome timing.
2. Fixed 0.5% slippage on a thin token to force a failure; check `error` and `next_steps` handling.
3. Two swaps back to back from one custodial key: no nonce errors.
4. Fast typing then backspace in search: the list never shows an older query's results.
5. Fee receiver registration for the main input assets.
6. Hand-rolled signing only: compare one transaction hash with near-api-js for the same inputs.