DevelopersGuides
Wallet integration
How to add AXIS swaps and limit orders to a Stellar wallet: quotes, the swap call, approvals, trustlines, fees, open orders and error messages.
Before you start#
You need:
- A Stellar RPC endpoint and the network passphrase. On Testnet:
https://soroban-testnet.stellar.organdNetworks.TESTNET. - The AXIS contract address and the AXIS API URL from Networks and deployments.
- The JS client:
pnpm add @axis-markets/client @stellar/stellar-sdk. - A signing function shaped
(xdr, opts) => Promise<{signedTxXdr}>. This is where your wallet shows its confirmation screen.
All amounts are integer base units (bigint). Every Classic asset has 7 decimals, so 1 XLM is 10,000,000 base units. Prices are 18-decimal fixed point: buying base units per selling base unit, times 10^18. See Prices and rounding.
The snippets on this page share this setup. userAddress and signTransaction come from your wallet.
import {AxisApiClient, AxisContractClient} from '@axis-markets/client'
import {Networks} from '@stellar/stellar-sdk'
const AXIS_CONTRACT = 'CA6P26K4QNNIMTYP22ILTSCXQQDEKNWJIZJPC34YEZYOLBNT7YUPX7XS'
const RPC_URL = 'https://soroban-testnet.stellar.org'
const XLM = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'
const USDC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'
const EURC = 'CCUUDM434BMZMYWYDITHFXHDMIVTGGD6T2I5UKNX5BSLXLW7HVR4MCGZ'
const api = new AxisApiClient('https://demo-api.axis.markets')
const client = new AxisContractClient({
publicKey: userAddress,
signTransaction,
rpcUrl: RPC_URL,
contractId: AXIS_CONTRACT,
networkPassphrase: Networks.TESTNET
})
There are two ways to integrate. AxisApiClient with AxisContractClient is stateless and gives you full control, which suits a wallet that already manages its own state. AxisAccount keeps the user's orders and allowances live over WebSocket and handles quotes and approvals for you. Both are shown below.
Swaps#
A swap sells one asset for another in a single swap call. The AXIS API finds the route, through up to 3 markets. The contract either executes it within the bounds the user signed or fails with NotFilled (709), and nothing moves.
Get a quote#
// strict send: sell exactly 10 XLM for EURC
const quote = await api.quoteSell({sellingAsset: XLM, buyingAsset: EURC, amount: '100000000'})
if (quote.status !== 'success')
throw new Error(quote.error ?? 'No route')
const best = quote.paths[0]
console.log(best.sold, best.bought, best.path.map(hop => hop.buying))
quoteBuy takes the amount to receive instead (strict receive). Both call GET /quote with direction set to strict_send or strict_receive, and both accept XLM, CODE:ISSUER or a contract address for the assets. Check the result before you build anything:
| Result | Meaning | Show the user |
|---|---|---|
status: 'success' |
Up to 10 routes, best first. A route has up to 3 hops and crosses at most 20 orders. | The best route and its output |
status: 'unfeasible' |
Not enough AXIS liquidity for this amount. It arrives with HTTP 200. | "Not enough liquidity" |
status: 'rejected' |
The contract is frozen, or the asset to buy requires issuer authorization that the AXIS contract does not have yet. error says which. |
"Trading is temporarily frozen", or that this asset cannot be bought on AXIS for now |
AxisApiError with status 409 |
The service is not ready yet | Retry in a moment |
Quotes cover AXIS orders only. Classic DEX liquidity is not part of AXIS routes. Quotes also count only liquidity whose makers can actually deliver it (see Open orders and backing).
Build the swap call#
Each quote hop becomes a TradeStep: asset is the asset bought at that hop (hop.buying), orders the order IDs to cross. The direction decides which amount is fixed and which one is the bound:
| Direction | sellingAmount |
buyingAmount |
Quote with |
|---|---|---|---|
TradeDirection.Sell (strict send) |
Exact input | Minimum output: quoted bought minus slippage |
quoteSell |
TradeDirection.Buy (strict receive) |
Maximum input: quoted sold plus slippage, rounded up |
Exact output | quoteBuy |
import {TokenBalance, TradeDirection, planApproval} from '@axis-markets/client'
const slippageBps = 50n // 0.5%
const path = best.path.map(hop => ({asset: hop.buying, orders: hop.orders.map(id => BigInt(id))}))
const sellingAmount = BigInt(best.sold)
const buyingAmount = BigInt(best.bought) * (10_000n - slippageBps) / 10_000n
// the approval amount is absolute: cover this swap plus what the open orders still sell in XLM
const {orders, backing} = await api.getAccount(userAddress)
const committed = orders
.filter(order => order.selling === XLM)
.reduce((sum, order) => sum + BigInt(order.amount), 0n)
const tokens = new TokenBalance({rpcUrl: RPC_URL, networkPassphrase: Networks.TESTNET, spender: AXIS_CONTRACT})
const approve = planApproval({
required: sellingAmount,
committed,
allowance: await tokens.getAllowance(XLM, userAddress),
liveUntil: Number(backing[XLM]?.liveUntil ?? 0), // renews an allowance close to expiry
ledger: await tokens.getLatestLedger()
})
const [sold, bought] = await client.swap({
direction: TradeDirection.Sell,
trader: userAddress,
selling: XLM,
sellingAmount,
buyingAmount,
path,
approve // undefined when the current allowance already covers the swap
})
For a Buy swap, quote with quoteBuy, pass the quoted bought as the exact buyingAmount and the quoted sold plus slippage as sellingAmount:
const maxInput = (BigInt(best.sold) * (10_000n + slippageBps) + 9_999n) / 10_000n
client.swap simulates the call, declares every listed order and the entries of their makers in the footprint, calls your signTransaction and submits. It returns what was actually sold and bought. With Sell, any input the route cannot use stays with the user. Quotes age quickly, so re-quote right before you ask for the signature. See Trades, swaps and crossfills for how the contract plans and executes a route.
The easy path#
AxisAccount.swap does all of the above: it quotes, builds the path, applies the slippage, plans the approval from the user's open orders and submits.
import {Axis, TradeDirection} from '@axis-markets/client'
const axis = new Axis({
apiUrl: 'https://demo-api.axis.markets',
rpcUrl: RPC_URL,
contractId: AXIS_CONTRACT,
networkPassphrase: Networks.TESTNET
})
await axis.connect()
const account = axis.account(userAddress, {signTransaction})
await account.ready
const {sold, bought, approve} = await account.swap({
selling: XLM,
buying: EURC,
amount: 100_000_000n,
direction: TradeDirection.Sell,
slippage: 0.005
})
slippage is a fraction (0.005 is 0.5%) and defaults to 0. approve is the approval included in the transaction, if any. It throws when the quote is not success.
Limit orders#
AxisAccount.sell and buy first cross the orders a direct AXIS API quote finds within the order's limit price, even when they cover only part of the amount, then place what is left as a limit order and return its ID.
// limit sell: 100 XLM at 0.25 USDC per XLM
const {sold, bought, orderId} = await account.sell({
selling: XLM,
buying: USDC,
amount: 1_000_000_000n,
price: 250_000_000_000_000_000n
})
// limit buy: 100 XLM, paying at most 0.24 USDC per XLM
await account.buy({selling: USDC, buying: XLM, amount: 1_000_000_000n, price: 240_000_000_000_000_000n})
// market order: no price, crosses the book up to the quote's worst price and never creates an order
await account.sell({selling: XLM, buying: USDC, amount: 1_000_000_000n})
await account.cancel([orderId])
Things your UI should know:
- Any limit order (a
sellorbuywith a price) needs an open market for the pair (721otherwise) and a recent oracle price (722otherwise), even when it fills at once. Market orders and swaps need neither. - A market order uses a quote of the direct market for the whole amount. When the book cannot fill all of it,
sellandbuythrowNot enough liquidity for a market orderbefore anything is signed. Offer a smaller amount or a limit order. - The order must be worth at least the minimum order value (
720). On Testnet that is 0.001 USD. - Pass
expires(UNIX seconds) for an order that expires on its own. No event is emitted at expiry. - Orders are stored sell-equivalent. If nothing fills, the buy above is stored as an order selling 24 USDC at about 4.1667 XLM per USDC. Convert it back to the market's orientation for display (
invertPrice).
Orders explains order kinds for end users. Axis, markets and accounts documents every parameter.
Approvals#
The AXIS contract holds no funds between calls. To sell a token through AXIS, the user grants the contract a standing allowance on that token. One allowance backs every open order selling the token and the user's own trades in it. See Allowances and your funds and Settlement and allowances.
What the user signs#
An approval travels inside the trading call as Approval {asset, amount, live_until}. Before trading, the contract calls approve(user, AXIS contract, amount, live_until) on the token, as a sub-invocation the user signs together with the trade. Three properties matter for your UI:
- The amount is absolute. It replaces the current allowance. Plan it as this trade plus everything the user's open orders still sell in that token, which is what
planApprovalcomputes. An approval of only the trade amount would leave those orders without backing. - It expires.
live_untilis a ledger sequence, at most about 180 days ahead. The JS client grants about 30 days and renews when less than about a day is left, setting the allowance to exactly this call plus the open orders (see Automatic allowance management). - It is narrow. The contract can spend it only to settle fills of the user's own orders at the user's price, and the user's own trades and swaps.
How to present it#
Show the approval as its own line on the confirmation screen, with the absolute amount and the expiry:
Enable trading XLM on AXIS
Allowance: up to 150 XLM (replaces the current 20 XLM)
Expires: in about 30 days (ledger 5,520,400)
Covers: this swap (100 XLM) and your open orders selling XLM (50 XLM)
A ledger closes about every 5 seconds, so 17,280 ledgers are about one day. AxisAccount emits an approve event with {asset, amount, liveUntil} before it builds the transaction, which is a convenient hook for this screen.
When a dApp asks your wallet to sign an AXIS transaction it built, the approval shows up as an approve sub-invocation of trade, swap or update in the authorization entries. Present it with the same wording.
Show and revoke allowances#
List the allowance of every token the user trades:
const info = account.getAllowance(XLM)
// {asset, known, balance, allowance, liveUntil, authorized, pending, available, committed}
available is min(balance, allowance), committed what the open orders still sell. When committed exceeds available, some orders are not fully backed: the AXIS API stops proposing the uncovered part, and fills the budget cannot cover are skipped.
An approval with amount 0 revokes. update accepts an empty list of order changes, and it also works while the contract is frozen:
const ledger = await tokens.getLatestLedger()
await client.update({trader: userAddress, updates: [], approvals: [{asset: XLM, amount: 0n, liveUntil: ledger}]})
Warn the user first: open orders selling that token lose their backing.
Trustlines#
To receive a Classic asset through its Stellar Asset Contract, an account needs an authorized trustline, exactly as on the Classic DEX. XLM needs none, and an issuer needs none for its own asset. Check before you quote:
- A
tradewithout the trustline fails at simulation withCannotReceive(708). Aswaphas no such pre-check and fails with an error from the token contract, which is harder to explain to the user. - The trustline also needs room under its limit for what the user buys, since the contract forwards everything bought in one transfer at the end of the call. When that transfer does not fit, a
tradefails withCannotReceive(708) and aswapwith the token's error. - An asset whose issuer requires authorization can be bought only once the issuer has authorized the AXIS contract too. Until then, buying it fails with
IntermediaryCannotReceive(712) and the AXIS API rejects quotes into it. See Assets that require authorization.
Wallets usually know the account's trustlines already. To check through RPC:
import {Asset, Keypair, rpc, xdr} from '@stellar/stellar-sdk'
async function canReceive(server, address, asset) {
if (asset.isNative() || asset.getIssuer() === address)
return true
const key = xdr.LedgerKey.trustline(new xdr.LedgerKeyTrustLine({
accountId: Keypair.fromPublicKey(address).xdrAccountId(),
asset: asset.toTrustLineXDRObject()
}))
const {entries} = await server.getLedgerEntries(key)
if (!entries.length)
return false // no trustline
const {flags, balance, limit} = entries[0].val.trustLine
return (flags & 1) === 1 && balance < limit // authorized and not full
}
const usdc = new Asset('USDC', 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5')
const ok = await canReceive(new rpc.Server(RPC_URL), userAddress, usdc)
The field access follows the XDR objects of @stellar/stellar-sdk 17. balance < limit only proves there is some room: compare limit - balance with the amount the user receives, and remember that buying liabilities of the account's Classic offers use room too. If the check fails, offer to add the trustline, or to raise its limit, with a Classic changeTrust operation before the trade. asset.contractId(Networks.TESTNET) gives the token contract address AXIS uses for a Classic asset.
Fees#
AXIS charges no trading fees. The user pays the Stellar network fee, which depends on the resources of the call: the number of orders crossed, a new order's ledger rent, the size of the footprint. A call that touches an archived order, one nobody updated for about 120 days, also restores it and pays a fresh rent chunk. Never show a constant.
Before the user signs, account.estimateSell(params) and account.estimateBuy(params) simulate the trade with the same crossing and approval and return its fee: the inclusion fee bid (the fee option, 100,000 stroops by default in AxisContractClient) plus the resource fee. Run it when the order form settles, not on every keystroke.
const {fee} = await account.estimateBuy({selling: USDC, buying: XLM, amount: 100_000_000n, price: 25n * 10n ** 16n})
// show `Network fee: up to ${Number(fee) / 10_000_000} XLM` next to the order form
The transaction your signTransaction receives carries a higher fee field. When @stellar/stellar-sdk 17 rebuilds a contract call for signing, it counts the resource fee twice in the bid. The network still charges only the declared resource fee plus the inclusion fee at the current rate, so the user pays the same, but the wallet shows a higher maximum. Read the fee from the transaction if you need what the wallet will display:
import {Networks, TransactionBuilder} from '@stellar/stellar-sdk'
async function signTransaction(txXdr) {
const tx = TransactionBuilder.fromXdr(txXdr, Networks.TESTNET)
const maxFeeXlm = Number(tx.fee) / 10_000_000
// show `Network fee: up to ${maxFeeXlm} XLM` next to the trade and the approval, then sign
return {signedTxXdr: await signWithUserKey(txXdr)}
}
signWithUserKey stands for your wallet's signing routine. The fee field is the maximum: the unused part of the refundable resource fee is returned after execution, so the charged fee is often lower. Typical figures are in Fees and costs and Resource limits and costs.
Open orders and backing#
GET /account/:address returns every live order of an account and its backing per token:
{
"address": "GACV...CLY5",
"ledger": 5002319,
"orders": [
{
"id": "153390214124585565809379122683319801444",
"status": "ACTIVE",
"selling": "CC72...YHIC",
"buying": "CDLZ...CYSC",
"price": "319006000000000000",
"quote": "10341628",
"amount": "10341628",
"backed": "10341628",
"created": "2026-10-03 10:04:52"
}
],
"backing": {
"CDLZ...CYSC": {
"balance": "69240078389",
"allowance": "343495721",
"liveUntil": 5520702,
"authorized": true,
"budget": "343495721"
}
}
}
The example is shortened. For display:
amountis what is left to sell,quotethe amount at creation. Their ratio gives the filled share of an order that was never resized withupdate.backedis the part of the order the maker can actually deliver: one budget ofmin(balance, allowance)per token, split across the account's orders selling it, oldest first. Showbacked / amountas "60% backed" when it is below 100%.priceis buying base units per selling base unit, times 10^18. Invert it for orders that sell the quote asset of the market you display.headroom, present in a backing record for a trustline asset, is how much more of the token the account can receive. Warn before a trade that would buy more than that.- Dates are UTC strings shaped
YYYY-MM-DD HH:MM:SS.
For live updates, subscribe to the WebSocket account channel. It sends a snapshot, then order, backing, trade and swap messages. One connection can follow up to 10 accounts. See WebSocket API.
{"op": "subscribe", "channel": "account", "address": "G...", "id": 1}
AxisAccount wraps that channel:
account.on('change', () => render(account.getOrders()))
for (const order of account.getOrders())
console.log(order.id, order.amount, order.backedPct, order.filledPct)
getOrders() also returns orders the client has just created and the indexer has not confirmed yet, flagged pending: true.
Error messages#
Contract errors come back from simulation, before anything is signed, as Error('Contract execution error: #NNN Name') with a numeric code, and failed token transfers as an Error with tokenError: true (see Error handling).
| Code | Name | Message for users | What to do |
|---|---|---|---|
| 702 | InsufficientBalance |
Insufficient balance | Lower the amount |
| 703 | InsufficientAllowance |
Insufficient token allowance. Approve the asset and retry. | Include an approval |
| 705 | InvalidPrice |
Invalid price | Validate the input |
| 706 | InvalidAmount |
Invalid amount | Validate the input |
| 707 | InvalidExpiration |
The expiration must be in the future | Validate the input |
| 708 | CannotReceive |
Recipient cannot receive the asset (missing, unauthorized or full trustline) | Offer to add the trustline or raise its limit |
| 709 | NotFilled |
Order cannot be executed in full | Re-quote, or widen the slippage |
| 710 | OrderNotFound |
Order not found | Refresh the order list |
| 711 | OrderExists |
Order ID collision. Please retry. | Retry, the client picks a new nonce |
| 712 | IntermediaryCannotReceive |
The issuer of this asset has not authorized AXIS to hold it yet, so it cannot be bought for now | Explain that the issuer has to authorize AXIS first. Neither the user nor a maker is at fault. |
| 720 | OrderSizeTooSmall |
Order size is below the minimum trade size | Raise the amount |
| 721 | AssetsNotVerifiedByOracle |
Market is not open. The assets are not verified by the price oracle. | Offer a swap instead |
| 722 | AssetPriceOracleFetchFailed |
No recent oracle price for this market. Retry later. | Retry later, swaps and market orders still work |
| 730 | Frozen |
Trading is temporarily frozen | Canceling orders still works |
| 701, 704, 723, 740 | NotAuthorized, InvalidMatch, InvalidOracleConfig, Overflow |
Something went wrong, please try again | Log the details |
Token code with tokenError: true, for example 9, 10, 11 or 13 |
AllowanceError, BalanceError, BalanceDeauthorizedError, TrustlineMissingError |
A token payment in this trade failed. Please try again. | Re-quote and retry. Either side of the transfer may be at fault, the payer or the recipient, so never tell the user "insufficient balance" as a fact |
Cases that are not contract errors:
- The user declines in the wallet: "Transaction rejected in wallet".
- A quote with
status: 'unfeasible': "Not enough liquidity". AxisApiErrorwithstatus409: the AXIS API is still loading.
The full list with causes is in Errors. Failure modes covers what happens when the book changes between simulation and inclusion.
Testnet checklist#
- Every client uses
Networks.TESTNET, the passphraseTest SDF Network ; September 2015.AxisandAxisContractClientdefault to Pubnet. - The contract ID, the AXIS API URL and the RPC URL come from Networks and deployments.
- Test accounts are funded with Friendbot and hold trustlines for USDC, EURC and CETES.
- Quotes with status
unfeasibleorrejectedshow a message instead of a broken swap. - Swap bounds come from the quote plus the user's slippage, never from the quote alone.
- Approvals are absolute and cover the open orders. The confirmation screen shows the amount and the expiry.
- A missing or full trustline is caught before the quote.
- The fee shown comes from the transaction, not a constant.
- Open orders show their backed share and can be canceled.
- Every error code maps to a message, including 712, 730 while the contract is frozen, and token errors (
tokenError: true).