Skip to content

Orders SDK

@tao.com/orders-sdk is the recommended way to integrate the Order service. It calls the Orders API for you, in the right order, and leaves signing to your wallet.

What it handles:

  • Logs the wallet in and keeps the access token current.
  • Authors the order, which is a dry run, and returns the fee estimates, a quote, and any pre-flight warnings.
  • Refuses to sign an order that failed a pre-flight check.
  • Calls your beforeSign hook so you can refuse an order before anything is signed.
  • Calls your signer, then registers the signed order, which opens it.
  • Reads, lists, and cancels orders.
  • Turns failures into typed errors.

The SDK never holds keys: you pass it functions that sign with whatever wallet you use. It is ESM-only, has no runtime dependencies, and uses the global fetch (Node 20+ and browsers).

If you can't use TypeScript, or you want to control every step, see the Orders API.

Install

npm install @tao.com/orders-sdk

Quick start

Stake 1 TAO into subnet 18, allowing at most 1% slippage against the current price:

import { createWalletAuth, OrderServiceClient } from "@tao.com/orders-sdk";

const walletAuth = createWalletAuth({
  address: coldkey, // the wallet's SS58 address
  sign: (bytes) => wallet.signHex(bytes), // the wallet's signature over the login challenge, as hex
});

const client = new OrderServiceClient({
  auth: () => walletAuth.getToken(),
});

const order = await client.placeMarketOrder({
  source: { currency: "urn:bittensor:mainnet:native", amount: 1_000_000_000n },
  destination: {
    currency: "urn:bittensor:mainnet:alpha:18",
    address: hotkey, // the validator to stake with
    limitPrice: 0.01, // max slippage as a fraction: 0.01 = 1%
  },
  signerAddress: coldkey,
  signer: signInstruction, // see "Signing"
});

console.log(order.id, order.status); // "OPEN"

placeMarketOrder does everything in order:

  1. Asks the service to author the order. This is a dry run: it returns the unsigned instruction, a single-use provenance token, the fee estimates, and any pre-flight warnings, and opens nothing.
  2. Throws PreflightError if the authored order has warnings. The order failed a pre-flight check, so the service would reject it, and nothing is signed.
  3. Calls your beforeSign hook, if any, so you can refuse an order before anything is signed.
  4. Calls your signer to sign the instruction.
  5. Registers the signed order, which is what opens it.

Amounts are integers in rao (1 TAO = 1_000_000_000). Pass a bigint or a digit string. Currencies are URNs: urn:bittensor:<network>:native for TAO and urn:bittensor:<network>:alpha:<netuid> for a subnet's alpha, where netuid 0 is Root.

An order that is registered still has to execute on chain. See order status for what happens next, and fees and minimum sizes before you test with small amounts.

Configuration

new OrderServiceClient({
  baseUrl: "https://api.tao.com", // the service origin, default production. Routes live under /orders/v2
  auth: () => walletAuth.getToken(), // a token string, or a function called on every request
  network: "mainnet", // "mainnet" (default) or "testnet". Picks the URNs for the Root helpers
  hotkey: myValidatorHotkey, // used for an alpha leg that has no `address`
  timeoutMs: 30_000, // per request, default 30s
  fetch: customFetch, // defaults to the global fetch
});

Every option is optional. Out of the box, the client and createWalletAuth talk to production (https://api.tao.com, exported as DEFAULT_BASE_URL and DEFAULT_AUTH_BASE_URL) on mainnet. For testnet, override all three:

const walletAuth = createWalletAuth({ authBaseUrl: "https://api.dev.tao.com", address, sign });
const client = new OrderServiceClient({
  baseUrl: "https://api.dev.tao.com",
  network: "testnet",
  auth: () => walletAuth.getToken(),
});

Authentication

Every order call needs a Bearer token. The service requires its username claim to equal the order's signerAddress, otherwise it answers 403. The token comes from proving ownership of the wallet: the authentication service sends a challenge, the wallet signs it, and the service returns an access token and a refresh token.

createWalletAuth does this and keeps the token current. getToken() signs in when needed, reuses the token until shortly before it expires, then refreshes it. When the refresh token nears the end of its life it signs in again. Concurrent callers share one login. Pass it to the client as auth: () => walletAuth.getToken().

  • sign receives the challenge bytes and must sign them verbatim (sr25519 or ed25519) and return the signature as hex. Do not wrap or hash them.
  • The first successful login for a wallet that is not registered yet creates its account.
  • If you already have a token from somewhere else, skip all of this and pass auth: token, either a string or a function called on each request.

This is a different token from the API key and refresh token in Authentication. It is tied to one wallet, which is what lets the service check that a user only reads and places their own orders.

Delegate tokens

An integration that sits in front of the wallet, such as a bot or a mini-app host, must exchange the wallet's access token for a delegate token that names the integration. The delegate token has the same power as the wallet token and carries an act claim. It lasts 30 minutes and has no refresh token.

Pass delegate and getToken() returns the delegate token. When it expires, the SDK takes a current wallet token (cached, refreshed, or a new login) and exchanges again.

const walletAuth = createWalletAuth({
  address,
  sign,
  delegate: {
    actorTokenType: "<a type the auth service supports>",
    actorToken: () => launchPayload(), // read fresh on every exchange, do not pre-encode it
    params: {
      /* extra form fields that type needs */
    },
  },
});

Supported actor types

actorTokenType actorToken params
telegram:bot:init-data Telegram.WebApp.initData, the raw string from a Telegram mini app. { bot_id }, where bot_id is the numeric part of the Telegram bot token (the digits before the :).

To exchange once by hand, call exchangeToken({ subjectToken, actorTokenType, actorToken, params }). It talks to production by default; for testnet, also pass authBaseUrl: "https://api.dev.tao.com". It returns { accessToken, tokenType, expiresIn }. What actorToken must contain, and which params a type needs, is defined by the authentication service. If it answers invalid_grant, the wallet token was rejected, and the SDK signs in again and retries once.

Signing

The service never sees your keys. Both the login challenge and every order are signed by functions you provide.

An order's instruction is one of:

  • SUBSTRATE_EXTRINSIC: payload is a Polkadot-API TxPayloadV1 JSON describing a batched call. Build the signed extrinsic from it exactly as authored (immortal era, call data, and extensions untouched) and return its full hex.
  • RAW: payload is hex bytes. Return the signature over them.
import type { InstructionSigner } from "@tao.com/orders-sdk";

const signInstruction: InstructionSigner = async (instruction, { signerAddress, order }) => {
  // `instruction.payload` carries no chain metadata: fetch it from a node or take it from your wallet.
  const signedExtrinsicHex = await wallet.signExtrinsic(instruction.payload, signerAddress);
  return { type: "sr25519", value: signedExtrinsicHex }; // or "ed25519", e.g. for a Ledger
};

The service accepts only sr25519 and ed25519. It rejects a mortal era or a changed call with invalid-signature.

Orders

Describe both sides of the swap and the service works out the order type. order.type tells you which it chose:

Swap order.type
TAO to subnet alpha tao_to_alpha
Subnet alpha to TAO alpha_to_tao
TAO to Root, Root to TAO tao_to_sn0, sn0_to_tao
Alpha to other alpha, same validator (Root counts as alpha) alpha_swap
Same subnet, different validator alpha_move
Different subnet and different validator alpha_swap_move

Every alpha leg needs address, the validator hotkey (SS58). The two legs of a swap may carry different hotkeys: that is how you move stake between validators. For a leg you leave without a hotkey, the SDK uses the other leg's, or the client's hotkey option.

// Move stake from SN18 to SN44, keeping the validator. Both legs get the same hotkey.
await client.placeMarketOrder({
  source: { currency: "urn:bittensor:mainnet:alpha:18", amount: 5_000_000_000n, address: hotkey },
  destination: {
    currency: "urn:bittensor:mainnet:alpha:44",
    limitPrice: 0.01,
  },
  signerAddress: coldkey,
  signer: signInstruction,
});

For Root there are two shortcuts: client.stakeToRoot({ amount, hotkey, signerAddress, signer }) and client.unstakeFromRoot(...).

TAO_COM_VALIDATOR_HOTKEYS.mainnet and .testnet are the TAO.com validator's hotkeys. They are only constants: nothing uses them unless you pass one as hotkey.

Retries

Pass idempotencyKey to placeMarketOrder (or registerOrder) to make a retry safe: the same key with the same order returns the same order. By default each call gets a fresh key. Retry with the same key only after a failure of the register step. A new author call issues a new single-use provenance token.

Slippage limits

Market orders carry a limitPrice on one leg for some swaps. It is the maximum slippage against the current subnet price, as a fraction from 0 to 1: 0.05 means the price may move at most 5%. The service simulates the order against it and turns it into the price limit the transaction enforces on chain: if the price moved past it, the transaction fails and nothing fills. When a required limit is missing, the service answers 422 and names the field.

Swap limitPrice goes on
TAO to subnet alpha destination
Subnet alpha to TAO source
Alpha to other alpha, Root included destination
TAO to or from Root, same-subnet validator move not needed

If the simulated slippage is already beyond limitPrice when the order is authored, it comes back with a SLIPPAGE_EXCEEDED warning. A limit that is too tight makes the order fail on chain and still costs the network fee, so leave room for the price to move between authoring and execution.

spotPrice({ taoIn, alphaIn }) (rao of TAO per alpha, ×1e9) is still exported for showing prices. Read taoIn and alphaIn from the chain (SubtensorModule.SubnetTAO and SubnetAlphaIn). Root has no pool: its price is the constant ROOT_PRICE.

Review before you submit

Authoring is a dry run, so you can show the user what an order will cost before anything is signed or opened. authorOrder returns the unsigned instruction, the fee estimates, and a provenance token. submitAuthored signs that same authored order and posts it back to the instruction link it returned, which is the step that opens the order. The order that opens is exactly the one you showed.

const authored = await client.authorOrder({ source, destination, signerAddress: coldkey });

const { fees, net } = netAfterFees(source.amount, authored.estimatedFees, source.currency);
// fees: what you pay in the source currency, in rao. They come out of the amount.
// net: source.amount minus those fees, which is what is actually swapped or staked.
feesUsd(authored.estimatedFees); // estimated USD total of the fees you pay

if (authored.warnings.length > 0) {
  // The order would be rejected: show why (see "Pre-flight checks") instead of asking to sign.
  return showWarnings(authored.warnings);
}

// ...show it, and only if the user confirms:
const order = await client.submitAuthored(authored, { signer: signInstruction });

submitAuthored throws PreflightError before signing if authored.warnings is not empty, so check the pre-flight warnings in your review step too.

Dropping authored abandons the order at no cost: the provenance token is a signed, stateless claim that is only spent when an order is registered.

The authored instruction is built from the wallet's current nonce and chain state, so submit it promptly and author again if anything changed, such as another transaction from the wallet or a long wait.

Each fee in authored.estimatedFees lists its amount in its own currency, a TAO conversion when it has one, and USD last. userPayable: false marks fees you do not pay (the subnet AMM fee). The helpers netAfterFees, feesInCurrency, and feesUsd are exported.

Quote what the user receives

getQuote estimates the payout of a swap without signing or opening anything. It authors the order (a dry run) and returns the service's quote: what the user receives after every fee, in the destination currency, in native TAO when that differs, and in USD last. The fees behind it come with it.

Give destination.amount instead of source.amount and the quote is reversed: what the user pays to receive that amount, every fee added, in the source currency first. Set the amount on exactly one leg, otherwise the SDK throws a RangeError.

const quote = await client.getQuote({ source, destination, signerAddress: coldkey });
quote.amount; // [{ currency: destination (source for a destination.amount quote), value }, { currency: TAO, value }?, { currency: "USD", value }]
quote.fees; // the EstimatedFee[] that were subtracted, the subnet AMM fee included
quote.warnings; // failed pre-flight checks, e.g. SLIPPAGE_EXCEEDED. [] when none

The quote is an estimate from the pool as the service sees it now. The fill can differ as the price moves, bounded by the limitPrice you set. Like authorOrder, it needs the access token and signerAddress. authorOrder returns the same thing as authored.quotes, so a review step can show it before submitAuthored.

A receive-side order works with authorOrder and placeMarketOrder too. The service solves for the smallest source amount that pays out at least destination.amount after every fee, and the authored order carries both amounts: authored.order.source.amount is what the signed instruction spends.

Pre-flight checks

When it authors an order, the service also checks that it can succeed. An order that fails a check is still authored, but with warnings, and registering it is rejected. Each warning has a code, and some carry a hint:

code Meaning
INSUFFICIENT_BALANCE, INSUFFICIENT_STAKE The wallet does not hold enough to cover the order.
INVALID_DESTINATION_ADDRESS The destination address is not valid.
DESTINATION_AMOUNT_TOO_LOW, FEES_EXCEED_AMOUNT The amount is too small. minimumAmount is the smallest source amount that passes.
SLIPPAGE_EXCEEDED The simulated slippage is beyond limitPrice. maximumAmount is the largest source amount that stays within it.
INSUFFICIENT_LIQUIDITY The pool cannot fill the order.
INVALID_SWAP The swap cannot be made.

Treat the list as open: new codes may be added. Signing an order with warnings is wasted, so placeMarketOrder and submitAuthored throw PreflightError (with warnings) before asking for a signature. getQuote returns the warnings with the quote instead of throwing, so you can show them. The service runs the checks again when the order is registered, and if one fails then, it answers preflight-failed.

Fees and minimum sizes

When you pay in TAO, the service deducts the fees (network, platform, and MEV shield) from source.amount and stakes what is left. The chain refuses a stake below its minimum (0.002 TAO, AmountTooLow). Such a transaction is still included and still pays the network fee, and nothing is staked. A very small swap can therefore be eaten entirely by fees and fail.

Use beforeSign to refuse an order that fees would eat. It receives the authored order, fees included, before anything is signed, and throwing aborts with nothing signed or registered:

const nativeUrn = "urn:bittensor:mainnet:native";
const amount = 1_000_000_000n; // 1 TAO, in rao
const minStake = 2_000_000n; // 0.002 TAO, the chain minimum

await client.placeMarketOrder({
  source: { currency: nativeUrn, amount },
  destination: { currency: "urn:bittensor:mainnet:alpha:18", address: hotkey, limitPrice },
  signerAddress: coldkey,
  signer: signInstruction,
  beforeSign: ({ estimatedFees }) => {
    const { net } = netAfterFees(amount, estimatedFees, nativeUrn);
    if (net < minStake) throw new Error("Too small: fees would leave less than the chain minimum");
  },
});

Order status

const order = await client.getOrder(id);
order.status; // see below
order.events; // lifecycle events: type, time, and `failureReason` on a failure
order.items; // once filled: what was bought, sold, and charged

An order's status is the last thing the executor reported:

Status Meaning
OPEN Registered and waiting to be executed. You can cancel.
CLAIMED Execution has picked the order up.
SUBMITTED The transaction was sent to the network.
ACCEPTED The network accepted the transaction. It is not final until FILLED.
FILLED Executed on chain.
FAILED The order did not execute. The event's failureReason says why.
ERROR An error was reported with the order. It is not a final status. See the event's failureReason.
CANCELLED You cancelled it while it was OPEN.

FILLED, FAILED, and CANCELLED are final. Poll getOrder until the order reaches one of them, and set a timeout so a poll never runs forever. If you see ERROR, show the event's failureReason to the user and keep polling until the order reaches a final status.

await client.cancelOrder(id); // only while OPEN. Otherwise throws `cancel-not-allowed`

let page = await client.listOrders({ status: "FILLED", limit: 20 }); // your orders, market orders by default
while (page.nextCursor) page = await client.listOrders({ cursor: page.nextCursor });

listOrders also filters by orderType and orderCategory, and returns { orders, nextCursor, prevCursor }. It can filter by OPEN, SUBMITTED, FILLED, FAILED, and CANCELLED. limit is 1 to 100.

Errors

Failures throw one of four classes:

Class When
OrderServiceError The service answered with a problem (RFC 9457). Has status, code, problem (with field for a bad input), and requestId.
OrderServiceNetworkError No usable response: a network failure, a timeout, or an error that did not come from the service, such as the gateway rejecting a token. Has status when a response arrived.
PreflightError The authored order failed a pre-flight check, so the SDK did not ask for a signature. Has warnings.
AuthenticationError Login, refresh, or token exchange failed. code is UNAUTHORIZED, HTTP_ERROR, NETWORK_ERROR, or PROTOCOL. oauthError carries the exchange's OAuth code.

OrderServiceError.code is the last segment of the problem type:

code Status Meaning
invalid-request 422 (400 for an unreadable body) A field is wrong or missing, including a required limitPrice. See problem.field.
invalid-address 422 An address is not valid SS58.
unsupported-swap, same-asset 422 The pair is not supported.
invalid-provenance 403 The provenance token does not match the order, or was already used. Author again.
preflight-failed 422 The order failed a pre-flight check when it was registered. See problem.errors. Nothing was submitted.
invalid-signature 422 The signature does not verify, or the call or era differs from what was authored.
forbidden 403 The token's username is not the order's signerAddress.
idempotency-conflict 409 The idempotency key was used with a different order.
order-not-found 404 No such order for this wallet.
cancel-not-allowed 409 The order is no longer OPEN.
chain-unavailable, store-unavailable 503 A dependency is down. Retry later.
internal-error 500 Unexpected. Report the requestId.
try {
  await client.placeMarketOrder({ ... });
} catch (e) {
  if (e instanceof OrderServiceError && e.code === "invalid-request") show(`${e.problem.field}: ${e.problem.detail}`);
  else if (e instanceof OrderServiceError && e.status === 503) retryLater();
  else throw e;
}

Arguments are checked before any request is sent. Amounts must be non-negative integers and limitPrice a fraction from 0 to 1, otherwise the SDK throws a RangeError.