DevelopersGuides
Running a keeper
The AXIS contract has no operator. Keepers refresh oracle prices, pay for oracle feed access and extend the contract lifetime, all through permissionless calls.
Why keepers exist#
The safety admin can only freeze the contract and change a few settings (see Safety admin and invariants). Everything else that keeps AXIS usable is a public function anyone can call, paid by whoever calls it:
| Call | Authorization | What it maintains |
|---|---|---|
requote(selling, buying) |
None | The market's oracle listing and the cached prices of its assets |
subsidize(sponsor, selling, buying, amount) |
The sponsor, who burns XRF | Oracle feed access for the market's listed assets |
ExtendFootprintTTL operation (keepalive(days) in the JS client) |
None, it is not a contract call | The lifetime of the contract instance and code |
The keeper behind requote and the lifetime extension only needs a funded account to pay the network fee and rent. Market makers have the strongest reason to keep these calls running, since stale prices block their order placement and updates, but any app operator or user can run a keeper. If nobody does:
| Lapse | Effect | Not affected |
|---|---|---|
No requote, so cached prices age past 72 hours |
New Limit orders and order updates fail with AssetPriceOracleFetchFailed (722) |
Fills, swaps, crossfills, removals |
| Oracle feed access expires | requote cannot fetch new prices, so the 722 failures follow once the cache ages out |
Same |
| No lifetime extension | Every state-changing call extends the contract to 3 days when fewer than 3 are left, so traders pay the contract's rent | Everything works, at a cost to traders |
Duties#
Refresh prices with requote#
requote(selling, buying) re-checks both assets of a market against the oracle and caches the latest oracle price of each listed asset. trade and update never call the oracle: they value new and changed orders from this cache alone, and a cached price is usable for 72 hours after the oracle's timestamp (see Price cache). The cache holds one price per asset, shared by every market of that asset.
requote writes only what changed and emits a refresh event on every call. AxisContractClient.requote simulates first and returns the simulated record without submitting anything when the call would write nothing, so calling it often costs network fees only when there is something new to cache. It fails with Frozen (730) while the contract is frozen.
Keep oracle feed access paid with subsidize#
The contract reads prices with the oracle's lastprice, which requires active feed access. Access is bought per asset, for the AXIS contract as the consumer, by burning XRF through the oracle's track, so the access bought for an asset serves every market that lists it. subsidize buys it through the AXIS contract:
- New market. A
subsidizeon a pair without a market opens it.amountmust cover the listing fee, and any amount above the fee buys more access. See Listing fee. - Existing market. The whole
amountextends the access of the market's listed assets, split evenly between them: days per asset =amount / (listed assets * daily_fee). It also re-checks the market and caches fresh prices, asrequotedoes. - Testnet numbers. The listing fee is 18,000 XRF for 90 days, so the daily fee is 200 XRF per asset, and 12,000 XRF on a market with two listed assets adds 30 days to each.
It returns the new access expiration of every listed asset, as UNIX timestamps in seconds. The sponsor signs the subsidize call together with the oracle's track and the XRF burn it performs, so check the configured oracle before signing.
Extend the contract lifetime#
A Soroban contract instance and its code have a ledger lifetime (TTL) like any other entry. Every call that changes state extends them when fewer than 3 days are left, and only to 3 days, so without a keeper the traders pay the contract's rent as they go. A keeper extends both entries further ahead.
The rent is not negligible. Almost all of it is for the contract code, a 45 KB Wasm entry. Simulated on Testnet in October 2026:
| Extension from 3 days left to | Instance rent | Code rent |
|---|---|---|
| 30 days | 0.10 XLM | 67 XLM |
| 90 days | 0.34 XLM | 216 XLM |
| 180 days | 0.69 XLM | 439 XLM, over the per-transaction fee cap |
That is about 2.5 XLM per day of contract lifetime, whoever pays it. Rent rates move with the network's state size and fee settings, so simulate before you budget, and expect mainnet figures to differ.
The contract has no entry point for its lifetime. A keeper extends both entries with the standard ExtendFootprintTTL operation, with the instance and code keys in its read-only footprint, for a horizon it picks up to the network maximum. It needs no authorization beyond the keeper's own transaction and works while the contract is frozen. Nothing is charged for an entry that already lives past the horizon, so a keeper can run it on a fixed schedule. AxisContractClient.keepalive(days) in the JS client (0.8.0 and later) builds, simulates, signs and sends it, 30 days by default, and first restores an archived instance or code with a separate RestoreFootprint transaction.
A single transaction can declare at most 4,294,967,295 stroops of fee, about 429 XLM. At the current code size a single extension to 180 days costs more than that and fails, so keep the horizon under about 170 days per transaction. See Contract lifetime.
Optional: crossfill crossed books#
A crossed book is not broken: every order still fills at its own price, and the cross is an arbitrage opportunity. A keeper that already watches the markets can match crossed orders with crossfill and keep the surplus. See Bots and arbitrage.
Schedule#
A suggested schedule. Simulating requote and reading the oracle are free, so check often and submit only when needed.
| Duty | Call | Suggested cadence | Paid by |
|---|---|---|---|
| Prices | requote(base, quote) for every market |
Every few hours, so no cached price gets near 72 hours | The keeper, only when something changed |
| Feed access | subsidize(...) |
Weeks before tracked_until of a listed asset |
The sponsor, in XRF, plus the network fee |
| Contract lifetime | keepalive(days), an ExtendFootprintTTL on the instance and code |
Every few weeks, before the extension runs down to 3 days | The keeper: about 2.5 XLM of rent per day added, on Testnet |
| Crossed books | crossfill(...) |
When a cross appears and the surplus beats the fee | The caller pays the fee and keeps the surplus |
Keeper code#
With the contract client#
import {AxisContractClient} from '@axis-markets/client'
import {Keypair, Networks, contract} 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 keeper = Keypair.fromSecret(process.env.KEEPER_SECRET)
const {signTransaction} = contract.basicNodeSigner(keeper, Networks.TESTNET)
const client = new AxisContractClient({
publicKey: keeper.publicKey(),
signTransaction,
rpcUrl: RPC_URL,
contractId: AXIS_CONTRACT,
networkPassphrase: Networks.TESTNET
})
// re-check the market and cache fresh prices, submitted only when something changed
const market = await client.requote(XLM, USDC) // undefined for a pair without a market
// burn 12,000 XRF from the keeper to extend the feed access of the market's listed assets
const access = await client.subsidize({sponsor: keeper.publicKey(), selling: EURC, buying: XLM, amount: 120_000_000_000n})
console.log(access.map(ts => new Date(Number(ts) * 1000).toISOString()))
// extend the contract instance and code to 30 days with an ExtendFootprintTTL operation (about 67 XLM of rent on
// testnet from 3 days left). Returns the ledger both entries live until
const liveUntil = await client.keepalive(30)
Without the JS client, build the operation yourself. Both keys go in the read-only footprint, and
prepareTransaction simulates it and adds the resource fee:
import {Contract, Operation, SorobanDataBuilder, TransactionBuilder, rpc, xdr} from '@stellar/stellar-sdk'
const server = new rpc.Server(RPC_URL)
const instanceKey = new Contract(AXIS_CONTRACT).getFootprint()
const {entries} = await server.getLedgerEntries(instanceKey)
const {wasmHash} = entries[0].val.contractData.val.instance.executable
const codeKey = xdr.LedgerKey.contractCode(new xdr.LedgerKeyContractCode({hash: wasmHash}))
const extendTx = new TransactionBuilder(await server.getAccount(keeper.publicKey()), {fee: '100000', networkPassphrase: Networks.TESTNET})
.addOperation(Operation.extendFootprintTtl({extendTo: 30 * 17_280})) // 30 days of 5-second ledgers
.setSorobanData(new SorobanDataBuilder().setReadOnly([instanceKey, codeKey]).build())
.setTimeout(60)
.build()
const prepared = await server.prepareTransaction(extendTx)
prepared.sign(keeper)
await server.sendTransaction(prepared)
Note
In 0.7.0, AxisContractClient.keepalive() and Axis.keepalive() call a contract keepalive entry point that later contract versions do not have. Use 0.8.0 or later.
With Axis and AxisMarket#
Axis follows every open market, and AxisMarket.requote signs with the signer option:
import {Axis} from '@axis-markets/client'
const axis = new Axis({
apiUrl: 'https://demo-api.axis.markets',
rpcUrl: RPC_URL,
contractId: AXIS_CONTRACT,
networkPassphrase: Networks.TESTNET,
signer: {publicKey: keeper.publicKey(), signTransaction}
})
await axis.connect()
for (const market of axis.markets.values()) {
try {
await market.requote()
} catch (e) {
console.error(market.key, e.code ?? e.message) // 730 while frozen
}
}
axis.close()
market.subsidize({amount}) extends the feed access of one market, with the signer as the sponsor unless you pass sponsor.
Reading feed access#
The oracle's tracked_until(consumer, assets) view returns, per asset, when the AXIS contract's feed access ends. fee_config() returns the fee token and the daily fee per asset.
const {oracle} = await client.loadConfig()
const reflector = await contract.Client.from({contractId: oracle, networkPassphrase: Networks.TESTNET, rpcUrl: RPC_URL})
const assets = [XLM, USDC, EURC].map(asset => ({tag: 'Stellar', values: [asset]}))
const {result: until} = await reflector.tracked_until({consumer: AXIS_CONTRACT, assets})
// one UNIX timestamp in seconds per asset
const {result: fee} = await reflector.fee_config()
// {tag: 'Some', values: [[feeToken, dailyFee]]}
Both are simulations: they cost nothing and need no signature.
Reading the contract lifetime#
import {rpc} from '@stellar/stellar-sdk'
import {instanceLedgerKey} from '@axis-markets/client/footprint'
const server = new rpc.Server(RPC_URL)
const {entries, latestLedger} = await server.getLedgerEntries(instanceLedgerKey(AXIS_CONTRACT))
const daysLeft = (entries[0].liveUntilLedgerSeq - latestLedger) / 17_280 // 17,280 ledgers per day
Keepers extend the code entry together with the instance, so the instance lifetime is a good proxy for both.
Monitoring#
GET /contract on the AXIS API, or AxisApiClient.getContract(), returns the contract state the indexer tracks (shortened to one market):
{
"address": "CA6P26K4QNNIMTYP22ILTSCXQQDEKNWJIZJPC34YEZYOLBNT7YUPX7XS",
"frozen": false,
"config": {
"safetyAdmin": "GBDCULE53LUPK4XHUCXBI35MAZFQHENMZ3JRKAJS2PPYBV646M6XKVHG",
"oracle": "CDEIQLTE3Y3XQMYPSZFJC2OXIS67F4MURGP73NCE2C2NZZ22RNSPV7ZI",
"listingMinDays": 90,
"marketListingFee": "180000000000",
"minTradeSize": "10000",
"ledgerTime": 5
},
"markets": [
{
"base": "CCUUDM434BMZMYWYDITHFXHDMIVTGGD6T2I5UKNX5BSLXLW7HVR4MCGZ",
"quote": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
"created": "2026-10-05 21:13:02",
"refreshed": "2026-10-05 21:13:02"
}
]
}
refreshed is the time of the market's last refresh event, the last requote or subsidize that executed for it. Read it with two caveats. The price cache is per asset, so a market can show an old refreshed while its prices are current: another market of the same assets refreshed them, and a requote with nothing new is never submitted by the JS client. And refreshed does not show the age of the oracle's own record. Treat an old refreshed across all markets of an asset as a sign of missing keeper activity, and rely on the requote simulation to decide what to submit.
The WebSocket contract channel pushes the same state: a snapshot, then a message with kind set to market, freeze or config on every change, and ledger messages as ledgers are processed. In the JS client, Axis emits frozen, config and market events from it. See WebSocket API.
Also alert on:
tracked_untilof any listed asset coming within your margin.- The contract instance lifetime dropping below your extension interval.
- Users hitting
AssetPriceOracleFetchFailed(722) on a market you maintain.
Frozen mode#
While the contract is frozen, requote and subsidize fail with Frozen (730), while the lifetime extension still works. A long freeze can let cached prices age past 72 hours and feed access lapse. When frozen turns false, run subsidize for any asset whose access lapsed, then requote every market, before makers try to place or update orders. See Frozen mode.