← All posts
INTEGRATIONS

Placing and managing on-chain orders through the Tirio API

TE
Tirio Engineering · 7 min read

Build limit, stop and DCA orders into your app: the place endpoint, its preview, verifying calldata byte for byte, and reading, cancelling and editing.

Tirio's limit, stop-loss with take-profit and DCA orders live in an on-chain escrow contract, and the same API that serves swaps can build every transaction around them. Your app asks for a placement, checks it, has the user sign it and reads the result straight from the chain. The API keeps no order state of its own: every answer about an order is read from the contract.

This guide covers placing an order, verifying it, and reading, cancelling and editing orders afterwards, with TypeScript and viem. Everything below was checked against the live API on 2026-10-01. If you have not read it yet, our swap integration guide sets up the clients and the tirio helper used here.

The contract and what it costs

  • Orders contract on BNB Chain, live since 2026-09-30. Its address is in the code below and on our contracts page.
  • Fee: 10 bps of what each fill receives, 3 bps between stablecoins, plus any partner fee you add. Nothing is charged until an order fills, and the gas of fills is paid for the user.
  • Floors are net of fees. The minimum you set is what the maker receives after the order fee and any partner fee.

Step 1: ask for a placement

GET /bsc/orders/place checks an order against every rule of the contract and returns the transaction that places it.

const ORDERS: Address = "0x0000000008BCCFD5793B6dEA2FEFCDd2320f8a4b";
 
const expiry = Math.floor(Date.now() / 1000) + 7 * 86400;
const placement = await tirio<Placement>("orders/place", {
  kind: "limit",
  tokenIn: NATIVE,
  tokenOut: USDT,
  amountIn: "1000000000000000000",
  price: "900",
  expiry: String(expiry),
  book: "true",
  sender: account,
});

That asks to sell 1 BNB for at least 900 USDT after fees, listed on Tirio's order book. Instead of price, a decimal number of tokenOut per tokenIn, you can pass minOut, the raw output for the whole amount. Exactly one of the two is required.

The other kinds use the same endpoint:

  • kind=stop needs triggerOut, the output at which it fires, with minOut as the worst acceptable output and an optional maxOut above the trigger as a take-profit.
  • kind=dca needs chunks (at least 2) and interval in seconds (60 to 31,536,000), with minOut and an optional maxOut bounding each chunk's price.
  • minFill allows partial fills of a limit or stop order down to that amount. partner and partnerFeeBps add your fee to every fill.

A broken rule answers 400 with a readable message, such as "a DCA order needs at least 2 chunks", and a token with a transfer tax is refused with unsupportedToken.

Step 2: read the preview

The response carries the transaction plus a few things worth showing the user before they sign:

FieldMeaning
orderThe exact order written into the calldata, with minOut computed from price
preview.marketOutWhat the amount would fetch at the market right now, for one chunk
preview.distanceBpsHow far the floor (or a stop's trigger) sits from the market, in basis points
preview.estimatedFeeThe order fee plus partner fee, in tokenOut
issues.balance, issues.minimumThe sender holds too little, or the first chunk is worth under $10
permit, approvalFor token inputs, the same signing or approval paths as a swap quote

When we placed the example above against production, minOut came back as 900 USDT, marketOut around 767 USDT, a distanceBps of about 1,700 (the order sits roughly 17 % above the market, so it will wait) and an estimatedFee of 0.9009 USDT, which is 10 bps of the output needed to clear the 900 USDT net floor.

Step 3: verify the calldata byte for byte

The strongest check is to encode the order yourself and require the API's calldata to be exactly that encoding. The kind field is 0 for limit, 1 for stop and 2 for DCA.

import { decodeFunctionData, encodeFunctionData, parseAbi, zeroAddress } from "viem";
 
const ordersAbi = parseAbi([
  "struct Place { address tokenIn; address tokenOut; uint256 amountIn; uint256 minOut; uint256 maxOut; uint256 triggerOut; uint256 minFill; uint32 chunks; uint40 interval; uint40 expiry; bool startNow; address partner; uint16 partnerFeeBps; uint8 kind; bool book; }",
  "function place(Place p, bytes permit) payable returns (uint256 id)",
]);
 
const order = {
  tokenIn: NATIVE,
  tokenOut: USDT,
  amountIn: 1000000000000000000n,
  minOut: 900000000000000000000n,
  maxOut: 0n,
  triggerOut: 0n,
  minFill: 0n,
  chunks: 1,
  interval: 0,
  expiry,
  startNow: true,
  partner: zeroAddress,
  partnerFeeBps: 0,
  kind: 0,
  book: true,
};
 
const { tx } = placement;
if (tx.to.toLowerCase() !== ORDERS.toLowerCase()) throw new Error("unexpected target");
if (BigInt(tx.value) !== order.amountIn) throw new Error("unexpected value");
const [, permit] = decodeFunctionData({ abi: ordersAbi, data: tx.data }).args;
if (tx.data !== encodeFunctionData({ abi: ordersAbi, functionName: "place", args: [order, permit] }))
  throw new Error("the placement differs from the order");

Run against production on 2026-10-01, this check passed: the API's calldata was byte-identical to our own encoding, with an empty permit for the native input. For a token input, handle permit or approval as described in our permit guide, with the Orders contract as the spender.

Step 4: simulate and send

A placement has no gas field, so let the wallet or the client estimate it. Simulate first, then send.

const transaction = { account, to: ORDERS, data: tx.data, value: BigInt(tx.value) };
await publicClient.call(transaction);
const hash = await wallet.sendTransaction(transaction);
const receipt = await publicClient.waitForTransactionReceipt({ hash });

The receipt's Placed event carries the new order's id.

Reading orders

  • GET /bsc/orders?maker=<address> returns a wallet's newest orders, closed ones included, up to 200.
  • GET /bsc/orders/<id> returns one order, or 404 when no order has that id.

Each order includes its status (open, filled, cancelled or expired), what is still escrowed in remainingIn, the DCA schedule, and filledIn, receivedOut and avgPrice once it has filled. Amounts are raw units, and minOut, maxOut and triggerOut are totals for the whole amountIn.

Cancelling, pausing and editing

Each action is a GET that returns { tx: { to, data, value } } with value 0:

EndpointAction
/bsc/orders/cancel?id=Close an order and refund the rest
/bsc/orders/cancelAll?ids=Close up to 100 orders in one transaction
/bsc/orders/pause?id= and /resume?id=Pause or resume a DCA order
/bsc/orders/update?id=&chunks=&interval=&minOut=&maxOut=Edit a DCA order's remaining chunks, interval and bounds

These calls are simple enough to verify completely by encoding them yourself:

const cancelAbi = parseAbi(["function cancel(uint256 id)"]);
const id = 7n;
const action = await tirio<{ tx: { to: Address; data: Hex; value: string } }>("orders/cancel", { id: id.toString(), sender: account });
if (action.tx.data !== encodeFunctionData({ abi: cancelAbi, functionName: "cancel", args: [id] })) throw new Error("unexpected call");

With sender, the API reads the order first and refuses with 400 when it does not exist, belongs to someone else or is already closed, before any transaction is built.

The order book

GET /bsc/book?tokenIn=&tokenOut=&depth= returns the limit orders listed on the book for a pair, as bids and asks grouped by price, next to 16 depth points from the pools and the current spot price. It is what the trade page draws, and it can be up to five seconds old.

Claiming a payout

If the contract could not deliver a fill's output or a refund, for example to a contract wallet that rejects BNB, the amount is kept for the account it is owed to. There is no API endpoint for it: the account calls claim(token) on the Orders contract, and claimable(account, token) reads the balance.

The complete reference, including every rule the placement endpoint checks, is in the API documentation, and what the contract guarantees is on the orders page.

KEEP READING