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:
- 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.
- 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 withtokenError: true(see Error handling). Views stop here and return the simulated result. - Complete the footprint. Trading calls declare every listed order and the entries of their makers,
updateandcanceldeclare the order IDs (see Footprint completion). - Sign. The callback receives the envelope and
{networkPassphrase}. - Submit. The transaction is sent and polled until it is applied. A failed transaction throws
Transaction <hash> failed: <result>. - Decode. The contract's return value comes back as JS values,
bigintfor 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 ofcrossfill, every step ofswap, and for aLimittrade the remainder IDorderId(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
Balanceentry 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#
sellandbuygenerate a fresh nonce for every call:(Date.now() << 22) | 22 random bits. Callers cannot pass a nonce. Anoncefield 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.