AXIS Docs

DevelopersJavaScript client

API and stream clients

REST and WebSocket clients for the AXIS API, the TokenBalance RPC helper and the utility functions of the JS client.

The Axis class builds on these pieces (see Axis, markets and accounts). Use them directly when you need raw API data, for example in a router, a bot or a backend that already has its own state. The wire formats are documented in the REST API and WebSocket API pages.

AxisApiClient#

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

const api = new AxisApiClient('https://demo-api.axis.markets')
const {ticker} = await api.getTicker24h()

new AxisApiClient(serverUrl) trims trailing slashes and throws a TypeError without a URL. It uses the global fetch and has no other dependency. Responses are the API's JSON as is, with assets as C... token contract addresses. Order, trade, quote and backing responses carry integer base units and 18-decimal prices as strings, while getDepth, getCandles and getTicker24h return display decimals.

Methods#

Method Route Parameters Result
quoteSell({sellingAsset, buyingAsset, amount, direct, maxPrice}) GET /quote, direction=strict_send amount: exact input in base units. direct: true restricts the route to one hop. maxPrice (with direct) quotes the crossing of a limit order. Quote with up to 10 routes, best first
quoteBuy({sellingAsset, buyingAsset, amount, direct, maxPrice}) GET /quote, direction=strict_receive amount: exact output in base units. Quote
getDepth({market, depth, step, limit}) GET /depth market is BASE/QUOTE. Aggregated bids and asks
getCandles({market, from, to, resolution, order}) GET /candles Always pass from (UNIX seconds). Without it the window starts at 0 and comes back empty. [ts, open, high, low, close, baseVolume, quoteVolume, trades] rows
getTicker24h() GET /ticker/24h none {ledger, timestamp, ticker}
getMarkets({cursor, limit}) GET /markets Paging. Pairs with live orders
getContract() GET /contract none {address, frozen, config, markets}
getOrders({owner, asset, cursor, limit}) GET /order asset is one address or an array. Two addresses select a pair. Live orders, oldest first
getOrder(id) GET /order/:id Decimal ID or bigint. Live order, AxisApiError with status 404 when it is gone
getAccount(address) GET /account/:address {address, ledger, orders, backing}
getOrderHistory({owner, pair, cursor, limit}) GET /order-history pair is an array of two token addresses. Filled, canceled and expired orders, newest first
getTrades({trader, pair, cursor, limit}) GET /trades pair is an array of two token addresses. Trade and swap records, newest first
getFailures({account, fn, cursor, limit}) GET /failures account matches the caller and both parties of a failed transfer, fn the contract function. Failed AXIS calls (also through another contract, or caught by one), newest first, for diagnostics

Parameters that are undefined or null are left out of the query, and arrays become repeated keys. Quote and market parameters accept XLM, native, CODE:ISSUER, CODE-ISSUER or a C... address. Order IDs are not sequential: page with the cursor of the last row.

Quotes#

const XLM = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'
const USDC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'

// sell 10 XLM for USDC
const quote = await api.quoteSell({sellingAsset: XLM, buyingAsset: USDC, amount: '100000000'})
if (quote.status === 'success') {
    const best = quote.paths[0]
    console.log(best.sold, best.bought, best.path.length)
}

A quote has a status:

  • success: paths holds up to 10 routes, best first. Each route has sold, bought and path, a list of hops {selling, buying, orders, worstPrice}.
  • unfeasible: the book cannot fill the whole amount. error says why. A quote returns a partial route only with maxPrice, see GET /quote.
  • rejected: the contract is frozen, or the asset to buy requires issuer authorization that the AXIS contract does not have yet. error says which.

These are normal responses. AxisApiError is thrown only for HTTP errors, for example status 409 while the service is not ready to quote. A strict_send route can sell slightly less than amount: input worth less than one unit of output at the worst crossed price stays with the trader, so a quote for 100000000 can report sold: "99999999".

From a quote to a swap#

Each hop becomes a TradeStep: the hop's buying asset and its order IDs. The amounts come from the route, with a slippage margin on the side that is not fixed:

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

function swapFromQuote(quote, trader, slippageBps = 50n) {
    const best = quote.paths[0]
    const path = best.path.map(hop => ({asset: hop.buying, orders: hop.orders.map(id => BigInt(id))}))
    const sold = BigInt(best.sold)
    const bought = BigInt(best.bought)
    if (quote.direction === 'strict_send') {
        // exact input, minimum output
        const buyingAmount = bought * (10_000n - slippageBps) / 10_000n
        return {direction: TradeDirection.Sell, trader, selling: quote.sellingAsset, sellingAmount: sold, buyingAmount, path}
    }
    // exact output, maximum input
    const sellingAmount = (sold * (10_000n + slippageBps) + 9_999n) / 10_000n
    return {direction: TradeDirection.Buy, trader, selling: quote.sellingAsset, sellingAmount, buyingAmount: bought, path}
}

const params = swapFromQuote(quote, trader)
const [sold, bought] = await client.swap({...params, approve: {amount: params.sellingAmount, liveUntil}})

client, trader and liveUntil are set up as in Contract client. The contract checks the bounds on the whole route and fails with 709 when the book moved beyond them.

From a quote to a market order limit#

A direct quote (direct: true) has one hop whose worstPrice is the highest price among the crossed orders, in the makers' terms (maker buying per maker selling). Use it as the limit of a Fill trade:

  • A Buy uses worstPrice as is: it is the maximum selling per buying.
  • A Sell uses invertPrice(worstPrice), rounded down: the minimum buying per selling.

Either limit accepts every quoted order.

import {OrderKind, invertPrice} from '@axis-markets/client'

const direct = await api.quoteSell({sellingAsset: XLM, buyingAsset: USDC, amount: '100000000', direct: true})
if (direct.status !== 'success') {
    throw new Error(direct.error)
}
const [hop] = direct.paths[0].path
const [sold, bought] = await client.sell({
    kind: OrderKind.Fill,
    trader,
    amount: 100_000_000n,
    selling: XLM,
    buying: USDC,
    price: invertPrice(BigInt(hop.worstPrice)),
    orders: hop.orders.map(id => BigInt(id))
})

This is what AxisAccount does for a sell or buy without a price.

AxisApiError#

Every non-success HTTP response throws an AxisApiError. message is the API's error text (or the HTTP status text when the body is not JSON) and status is the status of the JSON body, or the HTTP status code when the body has none.

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

try {
    await api.getOrder('104716339259087188276464058217096427616')
} catch (e) {
    if (e instanceof AxisApiError && e.status === 404) {
        console.log('the order is gone: filled, removed or expired')
    } else {
        throw e
    }
}

AxisStreamClient#

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

const stream = new AxisStreamClient('wss://demo-api.axis.markets/ws')
stream.on('open', () => console.log('connected'))
stream.on('close', () => console.log('disconnected, reconnecting'))

const unsubscribe = stream.subscribe('trades', {market: '*'}, message => {
    console.log(message.type, message.topic)
})

Stream options#

new AxisStreamClient(url, options) throws a TypeError without a URL, and an Error when no WebSocket implementation is available.

Option Default Meaning
WebSocket global WebSocket WebSocket class to use.
pingInterval 20000 Milliseconds between {op: 'ping'} messages. With no message received for two intervals, the connection counts as dead and is reopened.
minReconnectDelay 1000 First reconnect delay in milliseconds, doubled after every failed attempt.
maxReconnectDelay 30000 Upper bound of the reconnect delay.

Subscriptions#

subscribe(channel, params, handler) returns an unsubscribe function. The first subscription opens the connection.

  • The client sends {op: 'subscribe', id, channel, ...params}. The server answers with a snapshot that echoes id and names a topic, then pushes updates tagged with that topic.
  • The handler receives the snapshot, every update, and a message with type: 'error' when the server rejects the subscription. A rejected subscription is dropped.
  • Unsubscribing sends {op: 'unsubscribe', channel, ...params} once no other local subscription shares the topic.
  • A handler that throws is logged and does not affect the others.
  • connected tells whether the socket is open. close() closes it, drops every subscription and stops reconnecting.
  • The client emits open, close, and error for server errors that are not tied to a subscription.
Channel Params Content
contract none Contract state (frozen flag, configuration, markets) and new ledgers.
account address Open orders, backing, swaps and the trades in which the account was the taker.
trades market (BASE/QUOTE) or * Recent trades, then every new trade.
ticker none 24h ticker of every market, then updates.
depth market, depth, step, limit Aggregated depth, then updates.
candles market, resolution, limit (1 to 200) Recent candles, then every candle a trade changes.

The server accepts at most 20 subscriptions and 10 accounts per connection. Message formats and the other limits are in the WebSocket API.

Reconnection#

When the socket closes, the client emits close and schedules a reconnect after minReconnectDelay * 2^failures, capped at maxReconnectDelay. The failure count resets after a successful open. On open it sends every active subscription again, so each handler receives a fresh snapshot, and emits open. It stops reconnecting after close() or once no subscription is left. Changes that happened while disconnected arrive only through the new snapshots, so handlers should treat every snapshot as a full replacement.

TokenBalance#

TokenBalance reads token state over RPC with free simulations. Nothing is submitted. Axis creates one as axis.tokenBalances when rpcUrl is set.

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

const tokens = new TokenBalance({
    rpcUrl: 'https://soroban-testnet.stellar.org',
    networkPassphrase: Networks.TESTNET,
    spender: 'CA6P26K4QNNIMTYP22ILTSCXQQDEKNWJIZJPC34YEZYOLBNT7YUPX7XS'
})

const allowance = await tokens.getAllowance(USDC, trader)
const balance = await tokens.getBalance(USDC, trader)
const ledger = await tokens.getLatestLedger()
Member Returns Meaning
new TokenBalance({rpcUrl, networkPassphrase, spender, server}) spender is the AXIS contract. server reuses an existing rpc.Server.
getAllowance(token, owner) Promise<bigint> The token's allowance(owner, spender). Zero once expired. Throws when the simulation fails.
getBalance(token, owner) Promise<bigint> The token's balance(owner). Zero when the simulation fails, for example without a trustline. For XLM it includes the account reserve.
getLatestLedger() Promise<number> Latest ledger sequence.

The backing the contract checks at match time is min(balance, allowance), read the same way (see Effective depth).

Helper functions#

Function Returns Meaning
canonicalPair(x, y) [base, quote] The pair in the contract's market order, which compares encoded addresses, not strings.
compareAssets(x, y) number Comparator for that order: negative when x sorts first, 0 when equal.
pairKey(x, y) string Canonical key base/quote, used by axis.markets and market.key.
invertPrice(price) bigint floor(10^36 / price), the price seen from the other side. 0n for a non-positive price.
invertTicker(entry) ticker entry The 24h entry of base/quote seen as quote/base: prices inverted, high and low swapped, volumes swapped, change recomputed.
parseApiOrder(raw) AccountOrder API order JSON to the client form: bigint amount and price, timestamps in milliseconds, expires in seconds, backing fields.
parseApiDate(value) number API timestamp YYYY-MM-DD HH:MM:SS (UTC) to UNIX milliseconds. 0 when missing or invalid. Numbers pass through.
toWsUrl(apiUrl) string WebSocket endpoint of an API base URL. https://demo-api.axis.markets becomes wss://demo-api.axis.markets/ws.
maxQuoteSpend(amount, price) bigint ceil(amount * price / 10^18), the most a Buy of amount at limit price can cost. 0n for non-positive input.
planApproval(params) approval or undefined The approval decision of automatic allowance management.
orderId(owner, nonce) bigint Order ID for an owner and a u64 nonce. Throws a RangeError outside u64.

planApproval({required, committed, allowance, liveUntil, ledger}) returns undefined when allowance covers required + committed and liveUntil is 0 or not within APPROVAL_RENEW_LEDGERS (17,280) of ledger. Otherwise it returns {amount: required + committed, liveUntil: ledger + APPROVAL_TTL_LEDGERS} with APPROVAL_TTL_LEDGERS equal to 518,400. The amount can be lower than the current allowance when the call renews an allowance close to expiry.

import {PRICE_SCALE, canonicalPair, invertPrice, maxQuoteSpend, pairKey, planApproval} from '@axis-markets/client'

const [base, quote] = canonicalPair(XLM, USDC) // [USDC, XLM]
console.log(pairKey(XLM, USDC) === `${base}/${quote}`) // true

console.log(invertPrice(PRICE_SCALE / 4n)) // 4000000000000000000n, 0.25 seen from the other side is 4
console.log(maxQuoteSpend(1_000_000_000n, PRICE_SCALE / 4n)) // 250000000n, 100 XLM at 0.25 costs at most 25 USDC

// sell 100 XLM with 50 XLM already in open orders and an allowance of 120 XLM
console.log(planApproval({
    required: 1_000_000_000n,
    committed: 500_000_000n,
    allowance: 1_200_000_000n,
    liveUntil: 6_000_000,
    ledger: 5_000_000
})) // {amount: 1500000000n, liveUntil: 5518400}

Emitter is the event base class of Axis, AxisAccount and AxisStreamClient: on(event, listener) and once(event, listener) return an unsubscribe function, and off(event, listener) removes a listener.