Skip to content

Intents Smart Contract

Use the SDK

Most integrations should use the SDK, which builds and submits orders to this 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.

Intents are submitted by calling open on the Intents contract (FullSettler) on the origin chain. The contract follows the ERC-7683 open(OnchainCrossChainOrder) interface.

open pulls the user's USDC into escrow and emits an Open event with the order ID. It does not call any TAO API, and it needs no API credentials. The Quoter API gives you the output amount to put in the order.

Addresses

Chain Chain ID Intents contract USDC
Ethereum 1 0x2831Ea5524f171735aF8FEB2e331821bef176C8A 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
Subtensor EVM 964 0x24834012973b6e70997a23C7aAB01481d46CB8E1 0x9001dbe4D68d36ab87923A2a9Dfb0c745fd25001

Tokens

Chain Token Address Decimals Use
Ethereum USDC 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 6 Input and output
Ethereum ETH (native) 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE 18 Output
Subtensor EVM USDC (on-chain symbol USDC.e) 0x9001dbe4D68d36ab87923A2a9Dfb0c745fd25001 6 Input and output
Subtensor EVM wTAO 0x134f59E8B8637FD70ae12f263492B1dc73A25D1e 18 Output
Subtensor EVM TAO (native) 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE 18 Output

The USDC on Subtensor EVM is a bridged token whose on-chain symbol is USDC.e, so wallets label it USDC.e. These docs call it USDC. The native-token address is a placeholder that the contract and the Quoter API use for ETH and TAO.

Use these addresses for inputToken and outputToken.

Both chains expose the same interface. Each contract is the origin contract for orders opened on its chain and the destination contract for orders filled on its chain.

The open function

function open(OnchainCrossChainOrder calldata order) external payable;

struct OnchainCrossChainOrder {
    uint32  fillDeadline;   // Unix time (seconds) by which the order must be filled
    bytes32 orderDataType;  // Must equal ONCHAIN_ORDER_DATA_TYPEHASH()
    bytes   orderData;      // abi.encode(InputOrderData)
}
  • fillDeadline must be strictly after the current block time and at most 86,400 seconds (1 day) later. Use the value you sent to the Quoter API.
  • orderDataType must equal the contract's ONCHAIN_ORDER_DATA_TYPEHASH(). Read it from the contract instead of hardcoding it.
  • orderData is the ABI-encoded InputOrderData struct described below.
  • Send no native value (msg.value must be 0). The input is USDC.

InputOrderData

struct InputOrderData {
    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;
}

For a USDC input, set the fields like this:

Field Value
user The address calling open. It must equal msg.sender. Refunds go to this address.
inputTokenToSwapFrom 0x0000000000000000000000000000000000000000
poolFee 0
inputAmountToSwapFrom 0
swapDeadline 0
inputToken USDC on the origin chain.
inputAmount The USDC amount the user pays, in base units. The same value you sent to the Quoter API.
outputToken Token to deliver on the destination chain. 0xEeee...EEeE for the native token.
outputAmount The output amount from the quote, unchanged.
destinationChainId The destination chain ID. It must be a supported destination for this contract.
recipient The recipient as a bytes32: the 20-byte address left-padded with zeros.
maxProtocolFeeBps The most the user accepts as a protocol fee, in basis points. Read protocolFeeBps() before quoting and use that value. 65535 means no limit.
subtensorData 0x for a transfer to an EVM address. Otherwise see subtensorData.

The inputTokenToSwapFrom, poolFee, inputAmountToSwapFrom, and swapDeadline fields are for inputs that are swapped to USDC on open. They must be zero for a USDC input. Swapped inputs are not covered in this documentation. subtensorData is for deliveries to Substrate accounts and subnet tokens, and it must be empty for a transfer to an EVM address. See subtensorData.

Set inputTokenToSwapFrom to the zero address

Do not set inputTokenToSwapFrom to the USDC address. Any non-zero value selects the swap path and the call reverts with FullSettler__InvalidCall.

Encoding orderData

orderData is the whole InputOrderData struct encoded as one tuple, the same as Solidity's abi.encode(inputOrderData). Do not encode the 13 fields as separate values. That produces different bytes, and open reverts without any error data.

import { encodeAbiParameters, parseAbiParameters } from "viem";

const orderData = encodeAbiParameters(
  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",
  ),
  [order], // order is an object with the fields above
);
import { ethers } from "ethers";

const orderData = ethers.AbiCoder.defaultAbiCoder().encode(
  [
    "tuple(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)",
  ],
  [[user, ethers.ZeroAddress, 0, 0n, 0, usdc, inputAmount, outputToken, outputAmount, destinationChainId, recipient, maxProtocolFeeBps, "0x"]],
);

Encoding the recipient

recipient must be a canonical left-padded address. A value with non-zero bytes in the upper 12 bytes reverts with FullSettler__NonCanonicalRecipient.

import { pad } from "viem";

const recipient = pad("0x1111111111111111111111111111111111111111", { size: 32 });
// 0x0000000000000000000000001111111111111111111111111111111111111111

subtensorData

subtensorData tells the contract that the output is not an ordinary transfer to an EVM address. It applies only when the destination is Subtensor EVM (964). On other destinations the contract does not decode it.

Empty bytes (0x) mean a normal payout to the EVM recipient. Otherwise subtensorData is one ABI-encoded SubtensorData struct:

enum SubtensorOperation { None, AlphaStake, TransferTao }

struct SubtensorData {
    SubtensorOperation operationType; // encoded as uint8
    uint32  netuid;
    bytes32 delegatorHotkey;
    bytes32 recipientColdkey;
    uint256 amount;
}

The ABI tuple is tuple(uint8,uint32,bytes32,bytes32,uint256). All 32-byte keys are Substrate AccountId32 values: an SS58 address decoded to its 32 raw bytes.

operationType Kind What the solver delivers
0 None Nothing special. Treat as a normal EVM payout.
1 AlphaStake Subnet alpha, staked for a Substrate coldkey.
2 TransferTao Native TAO, credited to the free balance of a Substrate coldkey.

Which fields each kind uses:

Field AlphaStake (1) TransferTao (2)
netuid The subnet to stake into. 0
delegatorHotkey The validator hotkey the stake goes to. 0x00…00
recipientColdkey The coldkey that owns the stake. The coldkey that receives the TAO. Required.
amount The minimum alpha the stake must yield, in rao (9 decimals). 0. outputAmount is the amount paid.

For both kinds, set outputToken to the native-token address (0xEeee…EEeE) and outputAmount to the native TAO, in wei, that the solver delivers or stakes. Do not use wTAO. The payout destination comes from recipientColdkey, not from the order's recipient. The SDK sets recipient to the user's own EVM address, and you should do the same.

TransferTao

The solver pays native TAO, and the settler credits it to recipientColdkey's free balance on the Substrate side. amount is 0: the TAO paid is outputAmount.

import { encodeAbiParameters, zeroHash } from "viem";

const subtensorData = encodeAbiParameters(
  [{ type: "tuple", components: [
    { name: "operationType", type: "uint8" },
    { name: "netuid", type: "uint32" },
    { name: "delegatorHotkey", type: "bytes32" },
    { name: "recipientColdkey", type: "bytes32" },
    { name: "amount", type: "uint256" },
  ] }],
  [{ operationType: 2, netuid: 0, delegatorHotkey: zeroHash, recipientColdkey, amount: 0n }],
);

AlphaStake

Alpha is not an ERC-20 that a solver can transfer. The solver provides native TAO, and the settler stakes it into subnet netuid through the Subtensor staking precompile, to delegatorHotkey, and moves the stake to recipientColdkey. The fill reverts if the stake would yield less than amount alpha, so amount is the user's guaranteed minimum. It is not derived from outputAmount.

Encode it the same way with operationType: 1, the subnet, the hotkey, the coldkey, and the minimum alpha as amount:

[{ operationType: 1, netuid, delegatorHotkey, recipientColdkey, amount: minAlpha }]

netuid 0 is root stake.

Get the data from the Quoter API

Don't compute amount yourself. For these outputs the quote carries a ready-made subtensorData, with the alpha minimum already filled in. Request the quote with the matching Substrate output, and place the returned value in the order unchanged. Before signing, decode it and check that the operation, subnet, validator, and coldkey match what the user asked for. The SDK does this check for you.

Checks at open

For a Subtensor destination, open validates subtensorData:

Check AlphaStake TransferTao
outputToken is the native sentinel or wTAO (the wrapped native token on Subtensor EVM) Yes Yes
recipientColdkey is non-zero Not checked Required
Encoded length is at least 160 bytes Not checked Required
operationType is at most 2 Yes Yes

An invalid value reverts with FullSettler__InvalidCall. open accepts some orders that can never be filled, and they are refunded after fillDeadline:

  • Don't use wTAO (0x134f59E8B8637FD70ae12f263492B1dc73A25D1e) as outputToken for these kinds. open accepts it, but a fill requires the native sentinel.

Always set recipientColdkey for AlphaStake

open does not check recipientColdkey for AlphaStake. A zero coldkey is accepted, and the fill then fails or the stake goes to the wrong owner. Set the coldkey before signing.

Attribution

Partners can have orders attributed to them with an ERC-8021 (schema 0) suffix on the open transaction's calldata. Append these bytes after the ABI-encoded open call. The contract ignores the extra trailing bytes, so the order is unaffected. The SDK does this for you when you pass partnerId.

calldata = abi.encodeCall(open, (order)) ‖ codes ‖ codesLength ‖ 0x00 ‖ 0x80218021802180218021802180218021
Part Size Value
codes 1 to 100 bytes Your partner ID as ASCII. Printable characters only, no spaces or commas.
codesLength 1 byte The length of codes in bytes.
schema ID 1 byte 0x00
marker 16 bytes 0x80218021802180218021802180218021

For example, my-app becomes 0x6d792d617070 06 00 80218021802180218021802180218021.

Use your partner ID, and add the suffix to the transaction you send and to any simulation of it.

Before you call open

The user must approve the Intents contract to spend their USDC:

IERC20(usdc).approve(intentsContract, inputAmount);

Approve exactly inputAmount rather than an unlimited amount.

Fees and amounts

open pulls exactly inputAmount USDC from the user. The protocol fee (protocolFeeBps(), currently 30 basis points) is deducted from that amount, and the rest stays in escrow for the solver. The user never pays more than inputAmount.

For example, with a 30 basis point fee, an inputAmount of 100000000 (100 USDC) pulls 100 USDC, keeps 0.30 USDC as the fee, and escrows 99.70 USDC.

If protocolFeeBps() is higher than maxProtocolFeeBps when open runs, the call reverts with FullSettler__ProtocolFeeExceedsMax.

Events and order status

Open

Emitted on the origin chain when an order is opened.

event Open(bytes32 indexed orderId, ResolvedCrossChainOrder resolvedOrder);

orderId is the identifier for the order on both chains. Read it from the transaction receipt and store it with the transaction hash.

Fill

A solver fills the order by calling fill on the Intents contract on the destination chain. To check whether an order has been filled, read fillRecords(orderId) on the destination contract. It returns a non-zero value once the order is filled.

function fillRecords(bytes32 orderId) external view returns (bytes32);

The destination contract also emits IntentFilled(address indexed solver, OrderData order) when it fills an order.

Refund

If the order is not filled before fillDeadline, the protocol refunds the escrowed USDC to user on the origin chain. The refund includes the protocol fee, so the user gets back the full inputAmount. The origin contract emits:

event Refunded(bytes32 indexed orderId, address indexed user, uint256 amount);

Refunds are sent by the protocol after the deadline passes. You do not need to call anything, and users cannot trigger a refund themselves.

Order status

orderStatus(orderId) on the origin contract returns:

Value Meaning
0 Unknown order.
1 Deposited. The USDC is escrowed. A filled order also stays in this state on the origin chain, so use fillRecords on the destination to detect a fill.
2 Not used.
3 Refunded.

To decide what to show the user:

Check Result
fillRecords(orderId) on the destination is non-zero Filled. The output was delivered.
A Refunded event exists for orderId on the origin Refunded.
Neither, and fillDeadline has not passed Waiting for a solver.
Neither, and fillDeadline has passed Waiting for the refund.

Dry run with resolve

resolve(order) is a view function you can use to check an order before asking the user to sign. It runs the same order-shape validation as open (deadline, token, fee cap, recipient) without moving any funds, but it skips two checks that only make sense with a real caller and a live contract:

  • It does not check user == msg.sender. resolve has no caller context to check against, so it accepts any user.
  • It does not check whether the contract is paused. open reverts with EnforcedPause while paused; resolve does not.

So a successful resolve does not guarantee open will succeed: it can still revert on the caller check or on the pause state. It also does not check the caller's allowance or USDC balance, which open needs to pull the input.

function resolve(OnchainCrossChainOrder calldata order) external view returns (ResolvedCrossChainOrder memory);

The orderId it returns does not match the one open will emit, because open adds a per-order nonce. Use resolve for validation only.

Reverts

The contract uses custom errors, so a failed call returns a 4-byte selector instead of a message. Include the error definitions from the Minimal ABI so your library can show the name. Otherwise, match the selector in the table.

Error Selector Cause
EnforcedPause 0xd93c0665 The contract is paused. Try again later.
FullSettler__InvalidOrderDataType 0x226b9fe3 orderDataType does not match ONCHAIN_ORDER_DATA_TYPEHASH().
FullSettler__InvalidOrderUser 0xbb9cbd62 user is not the caller.
FullSettler__InvalidInputToken 0xb656cc64 inputToken is not the chain's USDC.
FullSettler__InvalidAmount 0xf4111478 inputAmount is zero.
FullSettler__TimestampPassed 0xe30a999a fillDeadline is not in the future.
FullSettler__FillDeadlineTooFar 0xe3399579 fillDeadline is more than 1 day in the future.
FullSettler__DestinationSettlerNotSet 0x22fa3d33 destinationChainId is not a supported destination. This includes the origin chain itself.
FullSettler__NonCanonicalRecipient 0x5a5d77fa recipient is not a left-padded 20-byte address.
FullSettler__ProtocolFeeExceedsMax 0x6a52296e The live protocol fee is higher than maxProtocolFeeBps.
FullSettler__InvalidCall 0x43eebf0d inputTokenToSwapFrom or inputAmountToSwapFrom is non-zero for a USDC input, msg.value is not zero, or (destination is Subtensor EVM only) subtensorData is not empty and malformed. For the flows in this guide, pass 0x. For Substrate deliveries, see subtensorData.

If the USDC transfer fails because the allowance or balance is too low, open reverts with the USDC token's own error.

Minimal ABI

Enough to call open, read the state used in this guide, decode the Open event, and decode reverts. This block uses viem's human-readable syntax. For ethers, see the note below it.

import { parseAbi } from "viem";

export const intentsAbi = parseAbi([
  "function open((uint32 fillDeadline, bytes32 orderDataType, bytes orderData) order) payable",
  "function resolve((uint32 fillDeadline, bytes32 orderDataType, bytes orderData) order) view returns ((address user, uint256 originChainId, uint32 openDeadline, uint32 fillDeadline, bytes32 orderId, (bytes32 token, uint256 amount, bytes32 recipient, uint256 chainId)[] maxSpent, (bytes32 token, uint256 amount, bytes32 recipient, uint256 chainId)[] minReceived, (uint256 destinationChainId, bytes32 destinationSettler, bytes originData)[] fillInstructions))",
  "function ONCHAIN_ORDER_DATA_TYPEHASH() view returns (bytes32)",
  "function protocolFeeBps() view returns (uint16)",
  "function paused() view returns (bool)",
  "function orderStatus(bytes32 orderId) view returns (uint8)",
  "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()",
]);

ethers does not accept struct declarations. Declare the Open event with an inline tuple instead:

"event Open(bytes32 indexed orderId, (address user, uint256 originChainId, uint32 openDeadline, uint32 fillDeadline, bytes32 orderId, (bytes32 token, uint256 amount, bytes32 recipient, uint256 chainId)[] maxSpent, (bytes32 token, uint256 amount, bytes32 recipient, uint256 chainId)[] minReceived, (uint256 destinationChainId, bytes32 destinationSettler, bytes originData)[] fillInstructions) resolvedOrder)"

Next

See the Integration Guide for a complete example that gets a quote, opens the order, and waits for the fill.