Version 1. TernSwap is deployed on every supported chain, and TernPrivateSwap on Starknet, with no owner and no upgrade path. Their addresses are in Contract addresses.
In short: TernSwap runs a same-chain swap in one transaction. It takes the user’s tokens, trades them through a DEX aggregator’s route, and checks the result: the user gets at least the quoted minimum, or the whole transaction reverts. This page is the full technical specification, for auditors and for teams calling the contract directly. To integrate through the API, see Same-chain swaps instead.

1. Summary

A swap whose two tokens are on the same chain needs no escrows, no execution condition, and no solver. It settles in the one transaction the user sends, through that chain’s TernSwap contract. TernSwap takes the user’s input, has a separate executor run a DEX aggregator’s route, and measures what comes back. It takes Tern’s fee from the output, pays the recipient at least the order’s minimum or reverts, refunds unspent input to the sender, and emits one Swapped event. The event has the same shape whichever aggregator built the route, so an indexer follows a swap the way it follows escrows: from chain events alone, with no client hints and no per-aggregator decoding. To an API client a same-chain swap is still a quote, with the same legs, status, and execution data as a cross-chain one. The key words MUST, MUST NOT, SHOULD, and MAY are used as in RFC 2119.

2. Terminology

3. Design

  1. The executor runs the route; nobody approves the executor. A route is arbitrary calldata from an aggregator. If TernSwap ran it, a route could spend approvals users gave TernSwap, by calling a token’s transferFrom. The executor holds only the current swap’s input, and only during the swap.
  2. Settlement by balance change. TernSwap measures its own balance of both tokens before and after the route, so a route can neither fake its output nor spend another swap’s tokens. Donations to either contract only add to the next swap of that token.
  3. The minimum protects the user, whatever the route does. A route that delivers too little, delivers elsewhere, or fails makes the whole swap revert.
  4. No admin. No owner, pause, allowlist, or upgrade path. The service that builds routes chooses which router contracts it uses.
  5. One event for every route. Swapped carries the quote ID and the actual amounts.

4. EVM interface

The executor sits at TernSwap’s CREATE address for nonce 1. TernSwap deploys through CreateX, so one deployer gets the same address on every EVM chain.

5. Starknet interface

The Swapped event carries the same fields in snake case, with quote_id, sender, and recipient as keys. Starknet has no native coin: STRK and ETH are ERC-20 tokens. A route is a list of calls, because aggregators take their input in different ways:
  • By approval (AVNU): spender is AVNU’s exchange, and the route is its multi_route_swap call.
  • By transfer first (Ekubo): there is no spender. The calls transfer the input to Ekubo’s router, multi_multihop_swap, clear_minimum the bought token, and clear the sold token back to the executor.
TernSwap deploys the executor in its constructor (deploy_syscall, salt 0, no calldata), so the executor’s address follows from TernSwap’s address and the executor class hash.

6. Behavior of swap

swap MUST revert when:
  • the block time is after deadline;
  • sellToken equals buyToken;
  • sellAmount or minBuyAmount is zero;
  • recipient is zero;
  • feeBps is over 1000;
  • on EVM, the call’s value is not exactly sellAmount when selling the native coin, or is not zero otherwise.
Otherwise:
  1. Record TernSwap’s balances of both tokens (on EVM, without the value just received).
  2. Move sellAmount from the caller to the executor: transferFrom for a token, or forward the value for the native coin.
  3. Call the executor. It approves route.spender for exactly sellAmount (if set and not selling the native coin), runs the route, resets the approval to zero, and sends its whole balance of both tokens back to TernSwap. A failing call reverts the swap.
  4. Compute what came back:
    • output is the increase in TernSwap’s bought-token balance;
    • unspent is the increase in its sold-token balance;
    • fee = floor(output × feeBps / 10000);
    • buyAmount = output − fee.
  5. If buyAmount < minBuyAmount, revert.
  6. Pay fee to feeRecipient if it is not zero, pay buyAmount to recipient, and refund unspent to the caller if it is not zero.
  7. Emit Swapped with sellAmount − unspent as the amount sold.
swap MUST be non-reentrant. TernSwap MUST accept the native coin only from its executor. Neither contract holds anything between swaps.

7. Rules for the service that builds swaps

  1. The quote ID MUST be text of 1 to 31 printable ASCII characters. On EVM it is written left-aligned and zero-padded into the bytes32 (as Solidity’s bytes32("q_…")); on Starknet it is a Cairo short string. Explorers then show the ID as text.
  2. Routes MUST be built with the executor as the aggregator’s taker and recipient.
  3. The service SHOULD only accept routes whose calls and spender are known router contracts. On Starknet, it should also accept a transfer of the sold token to one of those routers.
  4. The route’s own minimum MUST be high enough that the recipient still receives minBuyAmount after the fee.
  5. deadline SHOULD be short, around a minute, because routes go stale.
  6. An indexer MUST NOT treat a Swapped event as settling a quote just because the event names the quote’s ID: anyone can call swap with any ID. The event settles the quote only when all of these match the quote:
    • the chain, sender, recipient, sellToken and buyToken;
    • the amount sold is at most the quoted amount;
    • buyAmount is at least the quoted minimum;
    • the fee is the quoted rate, paid to the quoted recipient.

8. Security considerations

9. API usage (informative)

POST /v1/quote with from and to on one chain returns a quote with route: "swap". Without sender and recipient it is indicative; with both, it is firm, and its execute holds the transactions to send: on EVM, an approve of TernSwap when the allowance is short, then swap; on Starknet, the [approve, swap] multicall. to.amount is the expected output after Tern’s fee, and to.minAmount is the order’s minBuyAmount. GET /v1/quote/{id} is awaiting_deposit until the swap’s transaction is final, then completed, or expired if it wasn’t sent before expiresAt. Both legs report that one transaction, with the amounts spent and received. See Same-chain swaps for the full flow.

10. Private swaps on Starknet

TernPrivateSwap lets a user swap from a shielded balance in Starknet’s privacy pool (STRK20) and get the output back as a new shielded note. It is a helper (“anonymizer”) that the pool invokes inside the user’s private transaction. It wraps the deployed TernSwap, so the order checks, Tern’s fee, the minimum, and the Swapped event are all the ones above. It is deployed on Starknet mainnet: see Contract addresses. See Private swaps for the API.
The private transaction has four actions:
  1. withdraw order.sell_amount of the sold token to the helper;
  2. withdraw the relayer’s fee, in STRK, to the relayer;
  3. open a note of the bought token for the user;
  4. invoke the helper with the order, the route, and that note’s ID.
privacy_invoke MUST revert unless order.recipient is the helper. Otherwise:
  1. Record the helper’s balances of both tokens.
  2. Approve TernSwap for sell_amount and call swap(order, route). The helper is both the swap’s sender and its recipient.
  3. Revert unless the route spent the whole sell_amount (PrivateSwap: partial fill).
  4. Approve the caller (the pool) for the output and return it as one deposit into note_id. The pool pulls it.
There is no change note. The pool requires every open note a transaction creates to be filled in that transaction, and it refuses empty deposits, so a partial fill reverts the whole private transaction and nothing is spent. Each swap counts only what it adds to the helper’s balances, so tokens sent to the helper outside a swap never reach a note. The helper has no owner and stores only TernSwap’s address. The pool takes its own fee, in STRK, from whoever sends the private transaction, before it applies the actions. Tern’s relayer therefore sends [STRK.approve(pool, fee), pool.apply_actions(…)] and fronts the fee. Action 2 repays it, plus the relayer’s gas.