AXIS Docs

DevelopersJavaScript client

Axis, markets and accounts

Reference for the stateful Axis, AxisMarket and AxisAccount classes, which follow the live DEX state and trade with automatic allowances.

These classes keep the contract state, the markets and a trader's open orders in memory. The AXIS API pushes changes over WebSocket, with REST polling while the push connection is down. Transactions go through an internal AxisContractClient, so everything on that page about simulation, footprints and errors applies here too.

Axis#

import {Axis} from '@axis-markets/client'
import {Networks} from '@stellar/stellar-sdk'

const axis = new Axis({
    apiUrl: 'https://demo-api.axis.markets',
    rpcUrl: 'https://soroban-testnet.stellar.org',
    networkPassphrase: Networks.TESTNET
})
await axis.connect() // the contract address comes from the AXIS API
axis.on('frozen', frozen => console.log(frozen ? 'trading suspended' : 'trading resumed'))

Options#

Option Type Default Meaning
apiUrl string required AXIS API base URL.
contractId string reported by the AXIS API AXIS contract address. When set, connect() fails if the AXIS API reports a different one.
wsUrl string apiUrl with a ws or wss scheme plus /ws WebSocket endpoint of the AXIS API.
rpcUrl string none Stellar RPC server. Required to submit transactions. Also used to read allowances and the current ledger when pushed data is missing or stale.
networkPassphrase string public network Pass Networks.TESTNET on Testnet.
fee string '100000' Inclusion fee bid in stroops (0.01 XLM) for submitted transactions. Simulation adds the resource fee on top.
signer {publicKey, signTransaction} none Default signer for keepalive, market.requote() and market.subsidize(). Trading uses the callback passed to account().
WebSocket class global WebSocket WebSocket implementation.
fallbackPollInterval number 15000 REST polling period in milliseconds while the push connection is down.

Properties#

Property Type Meaning
contractId string or undefined AXIS contract address, from the contractId option or reported by the AXIS API on connect().
api AxisApiClient REST client of the AXIS API.
stream AxisStreamClient WebSocket client of the AXIS API.
tokenBalances TokenBalance or undefined RPC reader, created when rpcUrl is set.
nativeAsset string Token contract address of XLM on the configured network.
markets Map<string, AxisMarket> Open markets by canonical key base/quote.
tickers Map<string, TickerEntry> 24h ticker entries by canonical key, filled while subscribeTicker runs.
frozen boolean Whether the contract is frozen.
config object or undefined {safetyAdmin, oracle, listingMinDays, marketListingFee, minTradeSize, ledgerTime} as the AXIS API sends it: marketListingFee and minTradeSize as strings, listingMinDays and ledgerTime as numbers.
ledger, ledgerTime number Last ledger pushed by the AXIS API, and when it arrived (UNIX milliseconds).
loaded boolean Whether the contract state has loaded.

Methods#

Method Returns Meaning
connect() Promise<Axis> Loads the contract state and follows its changes. Subscribes to the contract channel and requests GET /contract in parallel, and resolves with whichever answers first. Takes the contract address from the snapshot, or checks it against the contractId option. Rejects when the REST request fails before the WebSocket connects, or when the contract address is missing or different. Calling it again returns the same promise, or retries after a rejection.
getContractId() Promise<string> AXIS contract address. Connects first when it is not known yet.
getMarket(asset1, asset2) AxisMarket or undefined Market of two token contract addresses, in either order. undefined when the pair has no market.
getOrder(id) Promise of AccountOrder or undefined Live order from a tracked account, otherwise from the AXIS API. undefined once filled, removed or expired.
account(address, {signTransaction}) AxisAccount Tracker for one address. Repeated calls return the same instance, and a new callback replaces the previous one.
subscribeTicker(callback) unsubscribe function Streams the 24h ticker of every market and fills tickers and market.ticker.
getLedger() Promise<number> Current ledger: the pushed one while connected and less than 60 seconds old, otherwise read over RPC.
keepalive({days, signer}) Promise<number> Extends the contract instance and code to days (30 by default) with an ExtendFootprintTTL operation and resolves with the ledger they live until, like AxisContractClient.keepalive. signer defaults to the signer option.
close() Closes the WebSocket, stops polling and stops every tracked account.

Events#

on(event, listener) returns an unsubscribe function. once and off work as usual. A listener that throws is logged and does not break the others.

Event Payload When
change none A contract state snapshot was applied.
frozen boolean The frozen flag toggled.
config config object The contract configuration changed.
market {market, created} A market appeared (created: true) or was checked against the oracle again (created: false).
connection boolean The WebSocket connection opened (true) or dropped (false).
ledger number The AXIS API processed a new ledger.

AxisMarket#

Get a market with axis.getMarket(x, y) or iterate axis.markets.values(). Assets are token contract addresses in the contract's canonical order, which compares encoded addresses rather than strings. For XLM and USDC on Testnet, base is USDC and quote is XLM.

Market properties#

Member Type Meaning
base, quote string Market assets in canonical order.
key string Canonical key base/quote.
created number When the market was opened, UNIX milliseconds.
refreshed number Last oracle check (refresh event), UNIX milliseconds.
ticker TickerEntry or undefined 24h ticker entry while axis.subscribeTicker runs.
has(asset) boolean Whether the asset belongs to the market.
counter(asset) string The other asset. Throws for an asset of another market.

Market data#

getDepth, subscribeDepth and subscribeCandles take a base asset that sets the orientation of prices (market.base by default). Prices are then quoted in counter(base) per base.

Method Returns Meaning
getDepth({base, depth, step, limit}) Promise<OrderbookDepth> Aggregated depth from GET /depth. Only backed amounts count. step is a decimal string such as '0.001'.
subscribeDepth({base, depth, step, limit}, callback) unsubscribe function Calls callback(depth) with a snapshot, then at most once per second whenever the aggregated levels change.
subscribeTrades(callback) unsubscribe function Calls callback(trades, true) with recent trades, then callback([trade], false) for each new trade.
subscribeCandles({base, resolution, limit}, callback) unsubscribe function Calls callback(candles, true) with a snapshot of up to limit candles (200 at most, 200 by default), then callback([candle], false) for every candle a trade changes. resolution is required: 300, 900, 1800, 3600, 7200, 14400, 43200, 86400, 259200, 604800 or 1209600 seconds, or an alias from '5m' to '2w'.
const XLM = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'
const USDC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'

const market = axis.getMarket(XLM, USDC)

// XLM priced in USDC, in levels of 0.001 USDC
const depth = await market.getDepth({base: XLM, step: '0.001', limit: 20})
console.log('best bid', depth.bestBid, 'best ask', depth.bestAsk)

const stopDepth = market.subscribeDepth({base: XLM, step: '0.001'}, update => console.log(update.bids[0], update.asks[0]))
const stopTrades = market.subscribeTrades((trades, snapshot) => console.log(snapshot ? 'recent' : 'new', trades.length))
const stopCandles = market.subscribeCandles({base: XLM, resolution: '15m'}, (candles, snapshot) => console.log(candles.length, snapshot))

Payload formats are documented in the REST API and the WebSocket API.

Oracle upkeep#

Method Returns Meaning
requote(signer) Promise of Market or undefined Calls the contract's requote for this market. Nothing is submitted when the simulation shows nothing to update.
subsidize({amount, sponsor, signer}) Promise<bigint[]> Burns amount XRF base units from sponsor (the signer's public key by default) to extend the market's oracle feed access. Returns the new expiration per listed asset.

signer defaults to the signer option of Axis. Both calls are permissionless. See Running a keeper.

AxisAccount#

const account = axis.account(address, {signTransaction})
await account.ready

signTransaction is required to trade and to estimate fees. Without it the account still tracks orders and balances.

Account state#

Member Type Meaning
ready Promise<void> Resolves when the first account snapshot has loaded.
loaded boolean Whether the snapshot has loaded.
orders Map<string, AccountOrder> Open orders confirmed by the indexer, by ID.
backing Map<string, AccountBacking> Balance and allowance per token contract.
getOrders({market, selling, buying}) AccountOrder[] Open orders, newest first, including pending orders this client created. market is an AxisMarket or any {base, quote} object, a key x/y or a pair [x, y].
getOrder(id) AccountOrder or undefined One open order.
committed(asset) bigint Remaining amount of the open orders selling the token, which the allowance must cover.
getBalance(asset) bigint or undefined Balance tracked by the AXIS API, undefined when the token is not tracked.
balances Map<string, bigint> Balances of every tracked token.
funded boolean or undefined Whether the account exists on the ledger, undefined until known.
getAllowance(asset) object {asset, known, balance, allowance, liveUntil, authorized, pending, available, committed}. available is the backing budget (see below).
refresh() Promise<void> Reloads the state from GET /account/:address.
close() Stops tracking the account.

An AccountBacking is {asset, balance, allowance, liveUntil, authorized, budget, updated, skipped, pending}. budget is min(balance, allowance), or 0 when the account is not authorized for the token or the allowance has expired. skipped is the time of the last skip event of an order selling the token, and pending means a reload confirming the latest change is still due. Times are UNIX milliseconds.

AccountOrder fields:

Field Type Meaning
id string Order ID as a decimal string.
owner, selling, buying string Maker and assets.
market string Canonical market key.
status string ACTIVE, FILLED, CANCELED or EXPIRED.
price bigint buying per 1 selling, 18 decimals.
amount bigint Amount of selling left.
quote bigint Amount of selling at creation.
expires number UNIX seconds, 0 for none.
created, updated number UNIX milliseconds.
cursor bigint or undefined Paging cursor, undefined for pending orders.
backed bigint or null Part of the account's budget in selling assigned to this order. The budget is split across orders oldest first.
backedPct number or null backed / amount, from 0 to 1.
backingPending boolean The backing figures are not confirmed yet.
filledPct number Filled share of the initial amount, from 0 to 1.
pending boolean Created by this client and not reported by the indexer yet.

Placing orders#

sell(params) sells a fixed amount, buy(params) buys a fixed amount. Both call the contract's trade.

Param Type Meaning
selling string Token paid.
buying string Token received.
amount bigint or string sell: amount of selling to sell. buy: amount of buying to acquire.
price bigint or string Limit, 18 decimals. sell: minimum buying per 1 selling. buy: maximum selling per 1 buying. Omit it for a market order.
kind OrderKind Limit by default with a price, Fill without one.
expires number Expiration of the remainder on the book, UNIX seconds.
orders array of IDs Orders to cross, in order. Looked up with a direct quote when omitted.
approve Approval or null Planned automatically when omitted. null sends no approval. An explicit {amount, liveUntil, asset} is sent as is.

The result is {sold, bought, orderId, order, approve}:

Field Meaning
sold Amount of selling paid.
bought Amount of buying received.
orderId ID of the order created for the remainder as a string, when one was created.
order The remainder as an AccountOrder with pending: true until the indexer reports it.
approve The approval sent with the trade, if any.
// limit buy: up to 50 XLM, paying at most 0.24 USDC per XLM, stays on the book for one day
const {orderId} = await account.buy({
    selling: USDC,
    buying: XLM,
    amount: 500_000_000n,
    price: 240_000_000_000_000_000n,
    expires: Math.floor(Date.now() / 1000) + 86_400
})

The remainder of a buy is stored as an order selling selling (see Order).

Without orders, a limit order lists the orders of a direct quote with the order's limit as maxPrice: every backed order priced within the limit, best first, up to 20, even when they cover only part of amount. The contract fills them and stores the remainder as an order. Pass orders yourself to choose the crossing, or orders: [] to place the whole order on the book without crossing anything.

estimateSell(params) and estimateBuy(params) take the same parameters, look up the same crossing and plan the same approval, then only simulate the trade. Nothing is signed or submitted. They return {fee, inclusionFee, resourceFee, sold, bought, orderId}, with the fees in stroops. fee is the maximum network fee of the transaction, the inclusion fee bid plus the resource fee, which includes the rent of a new order. Show it next to the order form.

const {fee} = await account.estimateSell({selling: XLM, buying: USDC, amount: 1_000_000_000n, price: 25n * 10n ** 16n})
console.log(`Network fee: up to ${Number(fee) / 10_000_000} XLM`)

Market orders#

Without price the trade is a market order:

  1. The account requests a direct quote for amount (quoteSell for sell, quoteBuy for buy, single hop).
  2. The limit is the quote's worstPrice, the highest maker price crossed. A buy uses it as is, a sell uses invertPrice(worstPrice). Every quoted order passes the price check.
  3. The kind defaults to Fill: what cannot be filled is dropped and nothing stays on the book. Pass kind: OrderKind.FillOrKill to require a full fill.
// buy 10 XLM with USDC at the best prices available
const {sold, bought} = await account.buy({selling: USDC, buying: XLM, amount: 100_000_000n})

A quote succeeds only when the book can fill the whole amount. A market order larger than the available liquidity therefore throws Not enough liquidity for a market order before anything is signed. Split it or lower the amount.

Updating and canceling#

Method Returns Meaning
update([{id, amount, price, expires}]) Promise<bigint[]> Changes orders in place. Omitted fields keep the order's current values. An order that is not in memory needs both amount and price, and an omitted expires then means no expiration.
cancel(ids) Promise<void> Removes orders. Works while the contract is frozen.
cancelAll({market}) Promise<string[]> Removes every open order, or those of one market, and returns their IDs.

All three send one transaction per 90 orders. When an update grows the amount on offer in a token, the account plans an approval for the growth and sends it with the first batch. An order whose expiration has passed needs a new expires (0 or in the future) to be revived.

Amounts are absolute, so a fill that lands between signing and execution is not counted against the new amount. To cut exposure whatever is in flight, cancel the order or lower the allowance (see Absolute amounts and fills in flight).

await account.update([{id: orderId, price: 260_000_000_000_000_000n}]) // reprice, keep the amount
await account.update([{id: orderId, amount: 500_000_000n, expires: 0}]) // resize, no expiration
await account.cancelAll({market: axis.getMarket(XLM, USDC)})

Crossfills#

crossfill(takerOrderId, orders) fills an existing order against cheaper orders with this account as the caller, which receives the surplus. Without orders it finds them with a direct quote for the taker order's full amount, which lists nothing when the book cannot fill that amount. The result is {sold, bought, surplus}: what the taker order's owner paid, what the makers delivered, and the part of it paid to this account, all zeros when nothing matched or the taker order was skipped. The account needs no funds but must be able to receive the taker order's buying asset. See Crossfill and Bots and arbitrage.

Swaps#

swap({selling, buying, amount, direction, slippage}) trades along the best route of the AXIS API, up to several hops:

  1. It requests a quote: quoteSell for TradeDirection.Sell (the default, amount is the exact input) or quoteBuy for TradeDirection.Buy (amount is the exact output).
  2. It takes the best route and turns each hop into a TradeStep (asset = the hop's buying, orders = its IDs).
  3. It sets the bounds. Sell: the input is the quoted sold and the minimum output is the quoted bought reduced by slippage, rounded down. Buy: the output is the quoted bought and the maximum input is the quoted sold increased by slippage, rounded up.
  4. It plans an approval for the input (exact or maximum) and calls the contract's swap.

slippage is a fraction rounded to basis points (0.005 is 0.5%) and defaults to 0. With 0 the swap succeeds only if the book still gives at least the quoted result, otherwise it fails with 709. A quote with no route throws the quote's error or No route available. The result is {sold, bought, approve}.

import {TradeDirection} from '@axis-markets/client'

const EURC = 'CCUUDM434BMZMYWYDITHFXHDMIVTGGD6T2I5UKNX5BSLXLW7HVR4MCGZ'

// sell 10 XLM for EURC, accept up to 0.5% less than quoted
const {sold, bought} = await account.swap({selling: XLM, buying: EURC, amount: 100_000_000n, slippage: 0.005})

// receive exactly 5 EURC, spend up to 1% more XLM than quoted
await account.swap({selling: XLM, buying: EURC, amount: 50_000_000n, direction: TradeDirection.Buy, slippage: 0.01})

Account events#

Event Payload When
ready none The first account snapshot loaded.
new AccountOrder An order of the account appeared, reported by the indexer.
pending AccountOrder This client created an order the indexer has not reported yet.
fill AccountFill An order was partially filled.
filled AccountFill An order was filled in full.
update AccountOrder An order changed amount, price or expiration, or was revived.
cancel AccountOrder The owner removed an order.
expire AccountOrder An order expired.
remove AccountOrder An order left the open orders: after filled, cancel or expire, or when a new snapshot no longer lists it.
order {action, order, fill} Every order change, with the indexer action new, fill, filled, update, cancel or expire.
backing {asset, backing} The balance, the allowance or the backing split of the orders selling a token changed.
approve {asset, amount, liveUntil} A call is about to send an approval.
trade trade record A trade in which the account was the taker. Fills of the account's own orders arrive as fill and filled. The taker order fill of a crossfill the account called is not pushed.
swap swap record A swap of the account, as pushed by the AXIS API.
change none The orders or the backing changed.
error Error The AXIS API rejected the account subscription.

AccountFill is {order, sold, bought, taker, trade, ts, partial}. sold is the amount of the order's selling asset delivered, bought the amount of buying received, trade the trade ID, ts the trade time in UNIX milliseconds, and partial tells whether the order stays open.

Automatic allowance management#

The contract fills an order only up to min(balance, allowance) of its owner, and one allowance backs every order selling that token in every market (see Allowances and your funds). Before each sell, buy, swap and growing update, the account plans an approval with planApproval:

  • required is what the call may spend: the amount of a sell, ceil(amount * price / 10^18) for a buy, the input (exact or maximum) of a swap, the growth of an update.
  • The target is required + committed(asset), where committed includes this client's pending orders.
  • No approval is sent when the current allowance already covers the target and does not expire within APPROVAL_RENEW_LEDGERS (17,280 ledgers, about 1 day).
  • Otherwise the call carries an approval of exactly the target, valid for APPROVAL_TTL_LEDGERS (518,400 ledgers, about 30 days) from the current ledger.
  • The account never revokes an allowance and does not shrink it after cancels. Every approval it sends sets the allowance to exactly this call plus the open orders selling the token, so a renewal near expiry can lower a larger allowance granted earlier. Revoke an allowance yourself with an update approval of 0 (see Contract client).

The approval travels inside the same transaction as the trade, so the user signs once. The account emits approve just before the call.

The current allowance comes from the pushed account state while the WebSocket is connected and the token is tracked. It is read over RPC when the connection is down, the token is not tracked, a backing reload is pending, or for 30 seconds after this client spent the token as a taker, because the push reporting the spend can lag. Without rpcUrl the last pushed value is used, and an untracked token throws. A failed RPC read counts as a zero allowance: the call then carries a redundant approval rather than fail.

Data freshness#

  • connect() and every account subscribe to WebSocket channels. Each change arrives as a push.
  • While the connection is down, Axis polls GET /contract and every account polls GET /account/:address every fallbackPollInterval (15 seconds by default). The stream reconnects with a delay that starts at 1 second and doubles up to 30 seconds, then resubscribes. The fresh snapshot reports orders that changed meanwhile as new and remove.
  • An account loads over REST when no push snapshot arrives within 3 seconds. Trading methods wait up to 15 seconds for the account state, then throw.
  • An order created by this client counts as committed for up to 2 minutes, or until the indexer reports it.
  • getLedger() trusts the pushed ledger for 60 seconds, then reads it over RPC.

Errors thrown by the library#

Besides contract errors (Contract execution error: #NNN Name with a numeric code, see Errors), token errors (tokenError: true, see Contract client) and AxisApiError from REST calls, the classes throw these errors:

Message Thrown by Cause
The AXIS API does not report the contract address: set the `contractId` option connect() No contractId option and the AXIS API sent no address.
The AXIS API tracks contract <address>, not <contractId> connect() The contractId option differs from the contract the AXIS API follows.
A signer ({publicKey, signTransaction}) is required to submit transactions keepalive, market.requote, market.subsidize No signer option and no signer argument.
The `rpcUrl` option is required to submit transactions every submitting call rpcUrl is not set.
No signTransaction callback for <address>: pass it to axis.account() trading methods, estimateSell, estimateBuy The account was created without a callback.
The account state is not available: ... trading methods No account snapshot within 15 seconds.
The allowance of <address> in <asset> is not tracked: set the `rpcUrl` option to read it approval planning The token is not tracked and there is no rpcUrl.
The current ledger is unknown: ... getLedger, approval planning No pushed ledger and no rpcUrl.
Not enough liquidity for a market order sell, buy without price The book cannot fill the whole amount, or the quote request failed.
No route available swap The quote found no route (the quote's own error message takes precedence).
Order <id> is not an open order of <address> update The order is not in memory and amount or price is missing.
Order <id> not found crossfill The taker order is unknown.
Asset <asset> does not belong to the market <key> market.counter and the market data methods base is not an asset of the market.
Transaction <hash> failed: <result> every submitting call The transaction failed on-chain. The error has a hash property.
Invalid keepalive days: <days> (TypeError) keepalive days is not a positive integer.
Contract <id> instance not found keepalive The contract address is wrong or the instance was never deployed.
Keepalive transaction was not signed: ... keepalive The callback resolved with {error}.