Skip to content

Integration Guide

Use the SDK

Most integrations should use the SDK, which calls the Quoter API and the Intents contract for you. Use this page if you can't use the SDK, for example because you aren't using TypeScript, or if you want to control every step yourself.

This guide walks through one complete intent: a user pays 100 USDC on Ethereum and receives native TAO on Subtensor EVM. It uses TypeScript and viem. The same steps work for any other supported route.

You will:

  1. Approve USDC for the Intents contract.
  2. Get a quote from the Quoter API.
  3. Build the order.
  4. Call open on the Intents contract.
  5. Read the order ID from the Open event.
  6. Track the order until it is filled or refunded.

Each step below is one section of a single script. Run the sections in order, in one file, to get the complete example.

Before you start

You need:

  • A wallet private key, exposed as PRIVATE_KEY. The wallet needs at least 100 USDC on Ethereum and some ETH for gas. In a real app the user signs with their own wallet instead of a private key.
  • An Ethereum RPC URL, exposed as ETH_RPC_URL.
  • Node.js 20 or later, with viem and tsx installed:
npm install viem tsx

Save the script as open-intent.mts and run it with npx tsx open-intent.mts.

Setup

The setup defines the chains, contract addresses, and the route. To change the route, change ORIGIN, DESTINATION, and OUTPUT_TOKEN. See Other routes.

import {
  createPublicClient,
  createWalletClient,
  defineChain,
  encodeAbiParameters,
  erc20Abi,
  http,
  pad,
  parseAbi,
  parseAbiParameters,
  parseEventLogs,
  zeroAddress,
  type Address,
  type Hex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "viem/chains";

// ---------------------------------------------------------------- config
const QUOTER_URL = "https://www.tao.com/api/v1/intents/quotes";
const NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";

const subtensor = defineChain({
  id: 964,
  name: "Subtensor EVM",
  nativeCurrency: { name: "TAO", symbol: "TAO", decimals: 18 },
  rpcUrls: { default: { http: ["https://lite.chain.opentensor.ai"] } },
});

const CHAINS = {
  1: {
    chain: mainnet,
    rpcUrl: process.env.ETH_RPC_URL, // your own Ethereum RPC
    intents: "0x2831Ea5524f171735aF8FEB2e331821bef176C8A" as Address,
    usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" as Address,
  },
  964: {
    chain: subtensor,
    rpcUrl: undefined, // uses the public RPC above
    intents: "0x24834012973b6e70997a23C7aAB01481d46CB8E1" as Address,
    usdc: "0x9001dbe4D68d36ab87923A2a9Dfb0c745fd25001" as Address,
  },
} as const;

// The route: 100 USDC on Ethereum in, native TAO on Subtensor EVM out.
const ORIGIN = 1;
const DESTINATION = 964;
const OUTPUT_TOKEN = NATIVE_TOKEN;
const INPUT_AMOUNT = 100_000_000n; // 100 USDC (6 decimals)

const intentsAbi = parseAbi([
  "function open((uint32 fillDeadline, bytes32 orderDataType, bytes orderData) order) payable",
  "function ONCHAIN_ORDER_DATA_TYPEHASH() view returns (bytes32)",
  "function protocolFeeBps() view returns (uint16)",
  "function fillRecords(bytes32 orderId) view returns (bytes32)",
  "struct Output { bytes32 token; uint256 amount; bytes32 recipient; uint256 chainId; }",
  "struct FillInstruction { uint256 destinationChainId; bytes32 destinationSettler; bytes originData; }",
  "struct ResolvedCrossChainOrder { address user; uint256 originChainId; uint32 openDeadline; uint32 fillDeadline; bytes32 orderId; Output[] maxSpent; Output[] minReceived; FillInstruction[] fillInstructions; }",
  "event Open(bytes32 indexed orderId, ResolvedCrossChainOrder resolvedOrder)",
  "event Refunded(bytes32 indexed orderId, address indexed user, uint256 amount)",
  "error EnforcedPause()",
  "error FullSettler__InvalidOrderDataType()",
  "error FullSettler__InvalidOrderUser()",
  "error FullSettler__InvalidInputToken()",
  "error FullSettler__InvalidAmount()",
  "error FullSettler__TimestampPassed()",
  "error FullSettler__FillDeadlineTooFar()",
  "error FullSettler__DestinationSettlerNotSet()",
  "error FullSettler__NonCanonicalRecipient()",
  "error FullSettler__ProtocolFeeExceedsMax()",
  "error FullSettler__InvalidCall()",
]);

const inputOrderDataType = parseAbiParameters(
  "(address user, address inputTokenToSwapFrom, uint24 poolFee, uint256 inputAmountToSwapFrom, uint32 swapDeadline, address inputToken, uint256 inputAmount, address outputToken, uint256 outputAmount, uint256 destinationChainId, bytes32 recipient, uint16 maxProtocolFeeBps, bytes subtensorData) order",
);

const origin = CHAINS[ORIGIN];
const destination = CHAINS[DESTINATION];
const account = privateKeyToAccount(process.env.PRIVATE_KEY as Hex);
const originClient = createPublicClient({ chain: origin.chain, transport: http(origin.rpcUrl) });
const destinationClient = createPublicClient({ chain: destination.chain, transport: http(destination.rpcUrl) });
const wallet = createWalletClient({ account, chain: origin.chain, transport: http(origin.rpcUrl) });

1. Approve USDC

The Intents contract pulls the USDC, so the user must approve it first. Do this before you request a quote. The approval has to be mined, and quotes last only about 60 seconds, so approving after the quote can use up its whole validity window.

const allowance = await originClient.readContract({
  address: origin.usdc,
  abi: erc20Abi,
  functionName: "allowance",
  args: [account.address, origin.intents],
});
if (allowance < INPUT_AMOUNT) {
  const approveHash = await wallet.writeContract({
    address: origin.usdc,
    abi: erc20Abi,
    functionName: "approve",
    args: [origin.intents, INPUT_AMOUNT],
  });
  await originClient.waitForTransactionReceipt({ hash: approveHash });
}

2. Get a quote

Pick a fillDeadline 5 minutes from now. Send it to the Quoter API and use the same value in the order, so the solver prices the quote for the window the order will use.

The script also reads the current protocol fee here, so it can cap the fee in the order.

const fillDeadline = Math.floor(Date.now() / 1000) + 300; // 5 minutes from now

// Read the protocol fee first. It becomes the fee cap in the order.
const protocolFeeBps = await originClient.readContract({
  address: origin.intents,
  abi: intentsAbi,
  functionName: "protocolFeeBps",
});

const res = await fetch(QUOTER_URL, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    originChainId: String(ORIGIN),
    destinationChainId: String(DESTINATION),
    inputTokenAddress: origin.usdc,
    outputTokenAddress: OUTPUT_TOKEN,
    inputAmount: INPUT_AMOUNT.toString(),
    userAddress: account.address,
    recipient: account.address,
    fillDeadline,
  }),
});
if (!res.ok) throw new Error(`Quote failed: ${res.status} ${await res.text()}`);

const { quote } = await res.json();
if (quote.validUntil <= Math.floor(Date.now() / 1000)) throw new Error("Quote expired");
const outputAmount = BigInt(quote.preview.outputs[0].amount);
console.log(`Quote: ${outputAmount} base units, valid until ${quote.validUntil}`);

The output amount from the quote goes into the order unchanged. If the request fails, do not open an intent. See Quoter API errors.

3. Build the order

The order has an envelope (fillDeadline, orderDataType, orderData) and an ABI-encoded InputOrderData struct. For a USDC input, the swap fields are zero and subtensorData is empty. The Smart Contract page describes every field.

const orderData = encodeAbiParameters(inputOrderDataType, [
  {
    user: account.address, // must be the account that calls open()
    inputTokenToSwapFrom: zeroAddress, // zero for a USDC input
    poolFee: 0,
    inputAmountToSwapFrom: 0n,
    swapDeadline: 0,
    inputToken: origin.usdc,
    inputAmount: INPUT_AMOUNT,
    outputToken: OUTPUT_TOKEN,
    outputAmount,
    destinationChainId: BigInt(DESTINATION),
    recipient: pad(account.address, { size: 32 }),
    maxProtocolFeeBps: protocolFeeBps,
    subtensorData: "0x", // empty for a transfer to an EVM address
  },
]);

const order = {
  fillDeadline,
  orderDataType: await originClient.readContract({
    address: origin.intents,
    abi: intentsAbi,
    functionName: "ONCHAIN_ORDER_DATA_TYPEHASH",
  }),
  orderData,
};

4. Open the order

The script simulates open and then sends it. Simulating first means a bad order fails with a readable error before the user pays for gas. Quotes last about 60 seconds, so the script checks validUntil right before it sends the transaction. If the quote has expired, go back to step 2 and request a new one.

// simulateContract surfaces a revert reason before the user pays gas.
const { request } = await originClient.simulateContract({
  account,
  address: origin.intents,
  abi: intentsAbi,
  functionName: "open",
  args: [order],
});
if (quote.validUntil <= Math.floor(Date.now() / 1000)) {
  throw new Error("Quote expired. Request a new quote (step 2) and build a new order.");
}
const openHash = await wallet.writeContract(request);
const receipt = await originClient.waitForTransactionReceipt({ hash: openHash });

Partner attribution

If you have a partner ID, pass dataSuffix to both simulateContract and the write. viem appends it to the calldata. See Attribution for how to build the suffix, or use the SDK, which does it with partnerId.

If simulateContract fails, the ABI in this script includes the contract's errors, so the error name is available on the thrown error:

import { BaseError, ContractFunctionRevertedError } from "viem";

try {
  await originClient.simulateContract({ account, address: origin.intents, abi: intentsAbi, functionName: "open", args: [order] });
} catch (error) {
  const revert = (error as BaseError).walk((e) => e instanceof ContractFunctionRevertedError);
  console.error(revert instanceof ContractFunctionRevertedError ? revert.data?.errorName : error);
}

5. Read the order ID

open emits an Open event that contains the order ID. Store the ID with the transaction hash. You need it to track the order.

const [openEvent] = parseEventLogs({ abi: intentsAbi, eventName: "Open", logs: receipt.logs });
const orderId = openEvent.args.orderId;
console.log(`Opened order ${orderId} in ${openHash}`);

6. Track the order

A solver fills the order on the destination chain. The destination contract records every fill, so reading fillRecords(orderId) on the destination contract tells you whether the order was filled. If the deadline passes without a fill, the USDC is refunded on the origin chain and a Refunded event is emitted.

const ZERO_HASH = `0x${"0".repeat(64)}`;
while (Math.floor(Date.now() / 1000) < fillDeadline) {
  const fillRecord = await destinationClient.readContract({
    address: destination.intents,
    abi: intentsAbi,
    functionName: "fillRecords",
    args: [orderId],
  });
  if (fillRecord !== ZERO_HASH) {
    console.log("Filled: the output was delivered to the recipient.");
    process.exit(0);
  }
  await new Promise((resolve) => setTimeout(resolve, 5_000));
}

const refunds = await originClient.getContractEvents({
  address: origin.intents,
  abi: intentsAbi,
  eventName: "Refunded",
  args: { orderId },
  fromBlock: receipt.blockNumber,
});
console.log(
  refunds.length > 0
    ? "Refunded: the USDC was returned to the user."
    : "Not filled before the deadline. The USDC will be refunded on the origin chain.",
);

Fills usually land within a few minutes. Do not treat a missing fill as a failure before fillDeadline has passed.

Testing without real funds

There is no separate test network. To test without spending real funds, run the script against a local fork of Ethereum with Foundry:

anvil --fork-url <your Ethereum RPC URL> --auto-impersonate

Point ETH_RPC_URL in the script at http://127.0.0.1:8545. Fund your test wallet on the fork with cast rpc anvil_setBalance for ETH, and by transferring USDC from any holder with cast send --unlocked --from <holder>. The --auto-impersonate flag above allows this.

Quotes do not depend on the wallet's balance, so the real Quoter API works with a test wallet. The order is opened on your fork only, so no solver fills it, and the script ends with the not-filled message once fillDeadline passes.

Other routes

Change three constants at the top of the script:

Route ORIGIN DESTINATION OUTPUT_TOKEN
USDC on Ethereum to native TAO on Subtensor EVM 1 964 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE
USDC on Ethereum to wTAO on Subtensor EVM 1 964 0x134f59E8B8637FD70ae12f263492B1dc73A25D1e
USDC on Ethereum to USDC on Subtensor EVM 1 964 0x9001dbe4D68d36ab87923A2a9Dfb0c745fd25001
USDC on Subtensor EVM to USDC on Ethereum 964 1 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
USDC on Subtensor EVM to ETH on Ethereum 964 1 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE

When the origin is Subtensor EVM, the wallet needs USDC and TAO for gas on Subtensor EVM. The script uses a public Subtensor EVM RPC by default. Replace it with your own for production.

Handling problems

Problem What to do
The quote request fails Do not open an intent. If the error is 502, try a different amount or output token, and retry later. See the error table.
The quote expired before the order was sent Request a new quote and build a new order with a new fillDeadline. Approve USDC first (step 1) so confirmations don't use up the quote's window.
simulateContract or open reverts Read the error name as shown above and match it against the revert table.
FullSettler__ProtocolFeeExceedsMax The protocol fee changed after you read it. Read protocolFeeBps() again and request a new quote.
EnforcedPause The contract is paused. Tell the user to try again later.
The order is not filled before fillDeadline Nothing to do. The USDC is refunded to the user on the origin chain.

Checklist

  • The user is msg.sender and user in the order is the same address.
  • inputTokenToSwapFrom is the zero address, and inputAmountToSwapFrom, poolFee, and swapDeadline are zero.
  • inputAmount and fillDeadline match what you sent to the Quoter API.
  • outputAmount is the quoted amount, unchanged.
  • recipient is a left-padded 32-byte address.
  • USDC allowance for the Intents contract is at least inputAmount.
  • USDC is approved before you request the quote, and open is sent before the quote's validUntil.