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:
- The account requests a direct quote for
amount(quoteSellforsell,quoteBuyforbuy, single hop). - The limit is the quote's
worstPrice, the highest maker price crossed. Abuyuses it as is, asellusesinvertPrice(worstPrice). Every quoted order passes the price check. - The kind defaults to
Fill: what cannot be filled is dropped and nothing stays on the book. Passkind: OrderKind.FillOrKillto 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:
- It requests a quote:
quoteSellforTradeDirection.Sell(the default,amountis the exact input) orquoteBuyforTradeDirection.Buy(amountis the exact output). - It takes the best route and turns each hop into a
TradeStep(asset= the hop'sbuying,orders= its IDs). - It sets the bounds.
Sell: the input is the quotedsoldand the minimum output is the quotedboughtreduced byslippage, rounded down.Buy: the output is the quotedboughtand the maximum input is the quotedsoldincreased byslippage, rounded up. - 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:
requiredis what the call may spend: theamountof asell,ceil(amount * price / 10^18)for abuy, the input (exact or maximum) of aswap, the growth of anupdate.- The target is
required + committed(asset), wherecommittedincludes 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
updateapproval of0(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,
AxispollsGET /contractand every account pollsGET /account/:addresseveryfallbackPollInterval(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 asnewandremove. - 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}. |