AXIS Docs

DevelopersJavaScript client

Contract client

AxisContractClient calls the AXIS contract directly: simulation, footprint completion, signing and submission.

AxisContractClient needs only a Stellar RPC server. It does not use the AXIS API, so you list the orders to cross yourself, from a quote, your own indexer or any other source. The high-level AxisAccount uses it internally.

Constructor#

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

const client = new AxisContractClient({
    publicKey: keypair.publicKey(),
    signTransaction,
    rpcUrl: 'https://soroban-testnet.stellar.org',
    contractId: 'CA6P26K4QNNIMTYP22ILTSCXQQDEKNWJIZJPC34YEZYOLBNT7YUPX7XS',
    networkPassphrase: Networks.TESTNET
})
Option Type Default Meaning
publicKey string required to submit Transaction source account. It signs and pays every submitted transaction.
signTransaction function required to submit Signing callback, see Signing transactions.
rpcUrl string required Stellar RPC server. Plain http URLs are accepted.
contractId string required AXIS contract address.
networkPassphrase string public network Pass Networks.TESTNET on Testnet.
fee string '100000' Inclusion fee bid in stroops (0.01 XLM). Simulation adds the resource fee on top.
autoFootprint boolean true Complete the footprint of trading transactions (see Footprint completion).

The account that must authorize a call (trader, sponsor) should be publicKey. As the transaction source it authorizes the call and any in-call approval with the envelope signature. The client does not collect separate authorization signatures from other accounts.

Call pipeline#

Every method follows the same steps:

  1. Simulate. The SDK contract client loads the source account from RPC, builds the invocation and simulates it. The simulation sets the footprint, the resources and the resource fee.
  2. Map errors. A simulation that fails with an AXIS code throws Contract execution error: #NNN Name, and one that fails in a token transfer throws an error with tokenError: true (see Error handling). Views stop here and return the simulated result.
  3. Complete the footprint. Trading calls declare every listed order and the entries of their makers, update and cancel declare the order IDs (see Footprint completion).
  4. Sign. The callback receives the envelope and {networkPassphrase}.
  5. Submit. The transaction is sent and polled until it is applied. A failed transaction throws Transaction <hash> failed: <result>.
  6. Decode. The contract's return value comes back as JS values, bigint for amounts and IDs.

requote returns after step 2 when the simulation writes nothing, without signing or submitting.

Methods#

Method Contract function Returns Submits
order(id) order Order, or undefined when missing or expired no
loadConfig() config Config (safety_admin, oracle, listing_min_days, market_listing_fee, min_trade_size, ledger_time) no
isFrozen() frozen boolean no
getMarket(selling, buying) market Market, or undefined no
sell(params) trade with Sell [sold, bought, orderId], orderId is undefined when no order is created yes
buy(params) trade with Buy [sold, bought, orderId] yes
estimateSell(params), estimateBuy(params) trade, simulated only {fee, inclusionFee, resourceFee, sold, bought, orderId}, fees in stroops no
update(params) update, up to 90 orders per call IDs updated or removed (bigint[]) yes
cancel(trader, ids) update with amount 0 per ID, up to 90 IDs per call nothing yes
crossfill(trader, takerOrderId, orders) crossfill [paid, received, surplus] yes
swap(params) swap [sold, bought] yes
requote(selling, buying) requote Market, or undefined for an unknown pair only when the simulation writes state
subsidize(params) subsidize, opens the market when it does not exist yet (the amount must then cover market_listing_fee) new feed expirations (bigint[]) yes
keepalive(days) none, an ExtendFootprintTTL operation on the contract instance and code, after a RestoreFootprint transaction when they are archived ledger both entries live until yes

The safety admin functions are not wrapped. publicKey is a read-only property with the source account.

Parameters of sell and buy:

Param Type Meaning
kind OrderKind Limit, Fill or FillOrKill.
trader string Trader address, normally publicKey.
amount bigint sell: amount of selling. buy: amount of buying.
selling, buying string Token contract addresses.
price bigint Limit price, 18 decimals.
orders bigint[] Maker orders to match, optional.
expires bigint or number Expiration of the remainder, UNIX seconds, optional.
approve {amount, liveUntil, asset} In-call approval, optional. asset defaults to selling.

swap takes {direction, trader, selling, sellingAmount, buyingAmount, path, approve} where path is an array of {asset, orders}. update takes {trader, updates, approvals} where every update is {id, amount, price, expires} and every approval must name its asset. subsidize takes {sponsor, selling, buying, amount}.

Examples#

The examples use the Testnet constants and the keypair and signTransaction from Getting started.

import {AxisContractClient} from '@axis-markets/client'
import {Networks, rpc} from '@stellar/stellar-sdk'

const RPC_URL = 'https://soroban-testnet.stellar.org'
const XLM = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'
const USDC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'
const EURC = 'CCUUDM434BMZMYWYDITHFXHDMIVTGGD6T2I5UKNX5BSLXLW7HVR4MCGZ'
const trader = keypair.publicKey()

const client = new AxisContractClient({
    publicKey: trader,
    signTransaction,
    rpcUrl: RPC_URL,
    contractId: 'CA6P26K4QNNIMTYP22ILTSCXQQDEKNWJIZJPC34YEZYOLBNT7YUPX7XS',
    networkPassphrase: Networks.TESTNET
})

// approvals expire by ledger sequence: about 30 days ahead
const {sequence} = await new rpc.Server(RPC_URL).getLatestLedger()
const liveUntil = sequence + 518_400

Sell with an approval#

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

// sell 100 XLM at 0.25 USDC per XLM, the remainder stays on the book for one day
const [sold, bought, id] = await client.sell({
    kind: OrderKind.Limit,
    trader,
    amount: 1_000_000_000n,
    selling: XLM,
    buying: USDC,
    price: 250_000_000_000_000_000n,
    orders: [],
    expires: Math.floor(Date.now() / 1000) + 86_400,
    // absolute amount: cover this order plus every other open order selling XLM
    approve: {amount: 1_000_000_000n, liveUntil}
})
console.log({sold, bought, id})

Update and cancel#

// reprice to 0.26 USDC and shrink to 50 XLM, no expiration
await client.update({
    trader,
    updates: [{id, amount: 500_000_000n, price: 260_000_000_000_000_000n, expires: 0}]
})

// grow the order and raise the allowance in the same transaction
await client.update({
    trader,
    updates: [{id, amount: 2_000_000_000n, price: 260_000_000_000_000_000n}],
    approvals: [{asset: XLM, amount: 2_000_000_000n, liveUntil}]
})

// revoke the XLM allowance without touching any order
await client.update({trader, updates: [], approvals: [{asset: XLM, amount: 0n, liveUntil: sequence}]})

// the whole list goes in one transaction, up to 90 IDs
await client.cancel(trader, [id])

The amounts of update are absolute, so a fill that lands between signing and execution is not taken into account. To cut exposure whatever is in flight, remove the order or lower the allowance in the same call with approvals (see Absolute amounts and fills in flight).

Crossfill#

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

const api = new AxisApiClient('https://demo-api.axis.markets')

// ID of an existing order priced better than the opposite side of the book
const takerOrderId = process.argv[2]
const taker = await api.getOrder(takerOrderId)
const quote = await api.quoteSell({sellingAsset: taker.selling, buyingAsset: taker.buying, amount: taker.amount, direct: true})
const makers = quote.status === 'success' ? quote.paths[0].path[0].orders.map(orderId => BigInt(orderId)) : []

const [paid, received, surplus] = await client.crossfill(trader, BigInt(taker.id), makers)
console.log({paid, received, surplus})

The result can be zeros: the contract skips makers priced worse than the taker order and fills that would leave its owner short, and a taker order its owner cannot back is skipped as a whole. The caller must be able to receive the taker order's buying asset even when there is no surplus (708 otherwise). The taker order's owner pays the makers, so a token error on those payments is never the caller's: leave that maker out and retry. See Crossfill.

Swap along a quoted route#

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

const api = new AxisApiClient('https://demo-api.axis.markets')

// best route to sell 10 XLM for EURC
const quote = await api.quoteSell({sellingAsset: XLM, buyingAsset: EURC, amount: '100000000'})
if (quote.status !== 'success') {
    throw new Error(quote.error)
}
const best = quote.paths[0]
const sellingAmount = BigInt(best.sold)

const [sold, bought] = await client.swap({
    direction: TradeDirection.Sell,
    trader,
    selling: XLM,
    sellingAmount,
    buyingAmount: BigInt(best.bought) * 995n / 1000n, // accept 0.5% less than quoted
    path: best.path.map(hop => ({asset: hop.buying, orders: hop.orders.map(orderId => BigInt(orderId))})),
    approve: {amount: sellingAmount, liveUntil} // add your open XLM orders to this amount
})

For a Buy swap use quoteBuy, pass the quoted bought as buyingAmount and a slightly larger sellingAmount than the quoted sold. See API and stream clients.

Keeper calls#

// re-check the USDC/XLM market and cache fresh oracle prices
const requoted = await client.requote(USDC, XLM)

// extend the market's oracle feed access by burning 1,000 XRF from the trader
const expirations = await client.subsidize({sponsor: trader, selling: USDC, buying: XLM, amount: 10_000_000_000n})

// extend the contract instance and code to 30 days (the default) with an ExtendFootprintTTL operation
const contractLiveUntil = await client.keepalive(30)

keepalive(days) is not a contract call. It builds an ExtendFootprintTTL operation with the contract instance and code in the read-only footprint, simulates, signs and submits it, and resolves with the ledger both entries live until. An archived instance or code is first restored with a separate RestoreFootprint transaction, so the callback may be asked twice, and it receives {networkPassphrase, address} here. days defaults to 30, must be a positive integer within the network maximum, and is converted into ledgers with the contract's ledger_time. Mind the rent, about 2.5 XLM per day of lifetime on Testnet. See Extend the contract lifetime.

All three are permissionless. See Running a keeper for schedules.

Views#

const config = await client.loadConfig() // {safety_admin, oracle, listing_min_days, market_listing_fee, min_trade_size, ledger_time}
const frozen = await client.isFrozen()
const market = await client.getMarket(XLM, USDC)
const order = await client.order(id) // undefined when missing or expired

Views only simulate. When publicKey is set, the SDK loads that account for every call, views included, so it must exist on-chain. A client created without publicKey reads views with a placeholder source account.

Footprint completion#

A Soroban transaction declares every ledger entry it reads or writes. Simulation records only what the contract touched against the ledger at simulation time: matching stops once the taker is filled, and skipped orders leave their makers' entries read-only, or unread when the maker cannot receive. If the book moves before the transaction is applied (an order is partly filled, repriced back into range, its maker restores the backing, an expired order is revived), the contract needs entries that are not declared and the transaction fails.

With autoFootprint on, sell, buy, crossfill and swap patch the simulated transaction before signing. estimateSell and estimateBuy patch it the same way, so their fees include the padding:

  • Every listed order ID is declared read-write: orders, the taker order of crossfill, every step of swap, and for a Limit trade the remainder ID orderId(trader, nonce), even when the simulation filled everything.
  • For the maker behind each listed order that exists, three entries are declared read-write: the balance of the asset the maker sells, the maker's allowance to the AXIS contract on it, and the balance of the asset the maker receives. A balance is an account entry for XLM, a trustline for a classic asset, or the token's Balance entry for a contract address. The issuer of an asset has none.
  • The AXIS contract's own balance of every asset passing through it and the receiver's balance of the bought asset are declared read-write too, since the makers' assets reach the trader through the contract.
  • Makers of tokens that are not Stellar Asset Contracts keep what the simulation recorded, because their storage layout is unknown.
  • The trader's own balance and allowance of the asset it sells are not added. The simulation declares them only when it filled at least one order, so a trade whose simulation filled nothing fails if an order becomes fillable before execution.
  • The client loads the listed orders and the token instances with RPC getLedgerEntries (200 keys per request) and caches the classic asset behind each token per client instance.
  • Entries are added orders first, in list order, while the network limits allow: 200 read-write entries and 400 entries in total.

update and cancel declare the listed order IDs only, without an RPC call.

Resource padding#

Each added entry raises the declared resources and the resource fee. The values are exported from @axis-markets/client/footprint:

Constant Value Added for
ORDER_ENTRY_SIZE 320 bytes Write bytes per added order entry.
ORDER_INSTRUCTIONS 2,000,000 Instructions per added order entry.
ORDER_RESOURCE_FEE 100,000 stroops Resource fee per added order entry.
MAKER_ENTRY_SIZE 256 bytes Write bytes per added maker or settlement entry, and disk read bytes per added account or trustline entry the simulation did not read.
MAKER_ENTRY_RESOURCE_FEE 20,000 stroops Resource fee per added maker or settlement entry.
MAKER_INSTRUCTIONS 3,000,000 Instructions per maker whose entries were added.
MAKER_RESOURCE_FEE 20,000 stroops Resource fee per maker whose entries were added.
FORWARD_INSTRUCTIONS 2,000,000 Instructions added once when settlement entries were added, for the transfer from the contract to the receiver.
FORWARD_RESOURCE_FEE 10,000 stroops Resource fee added once when settlement entries were added.

Declared instructions are capped at the network limit of 400,000,000. Declared resources are charged whether or not the transaction uses them, so list only the orders you expect to fill. Routers should also respect the event budget of 20 fills per call, with room for 3 skip events (see Resource limits and costs).

Turning it off#

Pass autoFootprint: false to send the simulated footprint as is. The transaction then fails on-chain whenever the book changed in a way that needs an undeclared entry. Turn it off only when you build the footprint yourself.

Footprint helpers#

The @axis-markets/client/footprint subpath exports the building blocks:

Export Meaning
completeFootprint(tx, contractId, orderIds, options) Patches a simulated AssembledTransaction as described above. Returns the numbers of entries added, {orders, settlementEntries, makerEntries}. See the options below.
ensureOrdersFootprint(tx, contractId, orderIds) Declares order entries only, without RPC calls. Returns the number added.
collectOrderIds(...lists) Flattens, deduplicates and converts IDs to bigint, keeping the first appearance order.
orderLedgerKey(contractId, id) Ledger key of an order entry.
balanceLedgerKey(owner, token, asset) Ledger key of a balance, or null for an issuer or a non-SAC token.
allowanceLedgerKey(owner, token, spender) Ledger key of a SAC allowance (temporary entry).
instanceLedgerKey(contractId) Ledger key of a contract instance.
decodeStoredOrder(val) {owner, selling, buying} from a stored order value.
decodeSacAsset(val) Classic Asset behind a SAC instance, or null.

Options of completeFootprint:

Option Meaning
server rpc.Server that loads the listed orders and the token instances. Required.
tokens Tokens the call trades, resolved together with the orders.
assetCache Map of the classic asset behind each resolved token (null for other tokens), reused between calls.
passThrough Tokens that move through the contract's own balance: the bought asset of a trade, every hop asset of a swap. Their contract balance entries are declared.
receiver Account the bought asset is forwarded to: the trader, or the crossfill caller who receives the surplus.
bought Token the receiver gets. Its balance entry of the receiver is declared.
takerOrder Taker order of a crossfill. The asset it buys passes through the contract.

The contract balance and receiver balance entries described above are declared only when passThrough, receiver and bought are passed.

To choose your own nonce, call the contract through the SDK's generic contract client and complete the footprint yourself:

import {contract, rpc, Networks} from '@stellar/stellar-sdk'
import {orderId, OrderKind, TradeDirection} from '@axis-markets/client'
import {collectOrderIds, completeFootprint} from '@axis-markets/client/footprint'

const AXIS = 'CA6P26K4QNNIMTYP22ILTSCXQQDEKNWJIZJPC34YEZYOLBNT7YUPX7XS'

const axisContract = await contract.Client.from({
    contractId: AXIS,
    rpcUrl: RPC_URL,
    networkPassphrase: Networks.TESTNET,
    publicKey: trader,
    signTransaction
})

const nonce = 42n // any u64 not used by a live order of this trader
const tx = await axisContract.trade({
    direction: TradeDirection.Sell,
    kind: OrderKind.Limit,
    trader,
    amount: 1_000_000_000n,
    selling: XLM,
    buying: USDC,
    price: 250_000_000_000_000_000n,
    orders: [],
    nonce,
    expires: 0n,
    approve: undefined
})
if (tx.simulation.error) {
    throw new Error(tx.simulation.error)
}
await completeFootprint(tx, AXIS, collectOrderIds([], [orderId(trader, nonce)]), {
    server: new rpc.Server(RPC_URL),
    tokens: [XLM, USDC],
    passThrough: [USDC], // makers deliver to the contract
    receiver: trader, // which forwards the bought asset to the trader
    bought: USDC
})
const {result} = await tx.signAndSend()
console.log(result) // [sold, bought, id]

contract.Client.from downloads the contract's interface from the network. A failed simulation does not throw: the AssembledTransaction keeps the error in tx.simulation.error, hence the check before completing the footprint.

Archived entries#

Order, market and token entries that are not touched for long enough are archived (see Archival and restore). When a call touches an archived entry, the simulation marks it for restore in the transaction data, and footprint completion keeps that mark. The transaction restores the entry as it runs and pays a fresh rent chunk for it, which the simulated fee includes. A listed order the simulation did not reach, such as a spare one at the end of the list, is declared without the mark, and the transaction fails if that order is archived. keepalive restores an archived contract instance and code with a separate RestoreFootprint transaction, since an ExtendFootprintTTL operation cannot restore them.

Error handling#

Situation What is thrown
Simulation fails with an AXIS code Error with message Contract execution error: #709 NotFilled and numeric code (709).
Simulation fails in a token transfer Error with tokenError: true, the token's numeric code (for example 10, BalanceError), contract (the token address, when the event log names it) and a message that names both possible sides: the payer's balance or allowance, or the recipient's trustline.
Simulation fails with anything else (host, auth) The RPC's simulation error string, unchanged.
Wallet rejects or fails The callback's error, or the SDK's wallet errors.
Transaction fails on-chain Error with message Transaction <hash> failed: <result> and a hash property.
update approval without asset TypeError: An update approval must name its asset.
keepalive with invalid days TypeError: Invalid keepalive days: <days>.
keepalive of an unknown contract Error: Contract <id> instance not found.

ContractErrors maps every AXIS code to its name, IntermediaryCannotReceive (712) included. TokenErrors maps the Stellar Asset Contract error codes 1 to 13 to their names, for example 10 to BalanceError. A token error does not tell whether the payer could not pay or the recipient could not be credited, for example on a full trustline, so do not report it to a user as a shortage of funds. See Errors for the meaning of each code and for token and host errors.

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

try {
    await client.sell({kind: OrderKind.FillOrKill, trader, amount: 1_000_000_000n, selling: XLM, buying: USDC, price: 250_000_000_000_000_000n, orders: []})
} catch (e) {
    if (e?.tokenError) {
        console.log('token transfer failed', e.code, e.contract) // the payer or the recipient, not known which
    } else if (e?.code === 709) {
        console.log('not enough liquidity at this price, nothing was traded')
    } else if (e?.code === 712) {
        console.log('the issuer of the bought asset has not authorized the AXIS contract')
    } else if (ContractErrors[e?.code]) {
        console.log('AXIS error', e.code, ContractErrors[e.code].message)
    } else {
        throw e
    }
}

Order IDs and nonces#

  • sell and buy generate a fresh nonce for every call: (Date.now() << 22) | 22 random bits. Callers cannot pass a nonce. A nonce field in the parameters is ignored.
  • The ID of the order created for the remainder is the third element of the result. It is also declared in the footprint before signing, so the order entry can be created even if the book changed after the simulation.
  • orderId(owner, nonce) computes an ID locally. The contract has no view for it. See order ID derivation.
  • OrderExists (711) means a live order of the trader already uses the ID. With generated nonces this is practically impossible, and a retry generates a new nonce.
  • To pick nonces yourself, for example to know an order ID before submitting, use the custom pipeline above.