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:
- Approve USDC for the Intents contract.
- Get a quote from the Quoter API.
- Build the order.
- Call
openon the Intents contract. - Read the order ID from the
Openevent. - 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
viemandtsxinstalled:
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.senderanduserin the order is the same address. inputTokenToSwapFromis the zero address, andinputAmountToSwapFrom,poolFee, andswapDeadlineare zero.inputAmountandfillDeadlinematch what you sent to the Quoter API.outputAmountis the quoted amount, unchanged.recipientis 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
openis sent before the quote'svalidUntil.