Developers
Indexer
The indexer is an open-source Node.js library that rebuilds the AXIS orderbook from contract events and serves it to your code and over HTTP.
What it is#
- The npm package
@axis-markets/indexer, MIT licensed, with its source at https://github.com/axis-markets/indexer. - A library you embed in your own Node.js process. It consumes AXIS contract events from a data source you provide, keeps the live orderbook and the backing of every maker in memory, writes orders, trades, swaps and failed transactions to a history storage you provide, and can serve read-only HTTP routes.
- The base of the AXIS API. The hosted service embeds it with its own streaming Stellar RPC data source and adds quotes, depth, candles, the 24h ticker and the WebSocket API, which are not part of the package.
What it is not:
- Not a router. It finds no routes and computes no quotes. It exposes the book so you can build a router on it.
- Not a wallet. It never signs or submits transactions.
- Not a standalone service. There is no binary, CLI or configuration file, and running the package directly does nothing.
- Not a complete stack. It ships no
DataSourceimplementation and only an in-memoryHistoryStorage.
How it builds the book#
Contract events#
Each AXIS event changes the book as follows:
| Event | Effect |
|---|---|
new |
Creates an ACTIVE order (quote holds the initial amount), inserts it in the price-sorted book and starts tracking the maker's backing in both order tokens. An expired order with the same ID is archived as EXPIRED and replaced. |
mod |
With an amount above zero: new price, amount and expiration, and the order moves to its new price level. This also revives an expired order. With amount zero: the order becomes CANCELED and is archived with its last amount, price and expiration. |
trade |
Sets the maker order's amount to left. left = 0 archives it as FILLED. Stores a Trade whose ID is the event position. With a polling data source, also reloads the backing of maker and taker in both tokens. The taker order fill of a crossfill is flagged crossfill: true. The flag comes from the event's crossfill field when the data source reads the call tree. Otherwise the indexer recognizes the fill that follows the call's maker fills in the opposite direction for exactly what their taker paid. |
swap |
Stores a Swap record in the trade log. The hops arrive as their own trade events. |
skip |
Leaves the order unchanged. Records the time on the maker's backing of the token the order sells (skipped) and reloads the maker's backing. |
refresh |
Adds the market to the contract state on first sight, then updates its refreshed time. Starts tracking the AXIS contract's own record in both market assets, which shows whether the contract can hold an asset that requires issuer authorization. |
freeze |
Sets the frozen flag. |
config |
Stores the configuration: safety admin, oracle, listing days, listing fee, minimum trade size and ledger close time. |
A new event does not report the allowance granted in the same call, so with a polling data source the indexer
reloads the maker's backing of the sold token after every new. Every touched order is written to the history
storage, the contract state is saved after refresh, freeze and config, and every change is emitted as an
event. The event layouts are in Events.
Expiration#
The contract emits nothing when an order expires. An order whose expires time has passed simply stops filling.
Every expirationInterval (10 seconds by default) the indexer:
- Takes the expired orders out of the live book.
- Marks them
EXPIRED, writes them to the archive and emitsorderwith the actionexpire. - Stops tracking the backing those orders needed.
The contract keeps the entry of an expired order, so the indexer keeps the order in memory too. Its owner can still
revive it with update (a mod with an amount) or remove it (a mod with zero), and a new order can take its ID. A
revived order moves back to the active orders as the same record. Expired orders are never served as live:
/order/:id returns 404 for them. See Order lifecycle and rent.
Effective depth and backing#
Every order is backed by its maker's balance and allowance in the token it sells, shared by all the maker's orders
selling that token (see Backing). The indexer keeps one backing record per maker and
token for every live order: the token it sells, which funds it, and the token it buys, which the maker must be able to
receive, with room under a trustline limit for the payment (headroom).
- The budget is
min(balance, allowance). It is zero while the record is not loaded, when the maker is not authorized for the token, and once the indexed ledger has passed the allowance'sliveUntil. - The budget is split across the maker's live orders selling the token, oldest first. Each order's share is its
backedamount in the API. - A maker's orders in other tokens have their own budgets.
See Effective depth for how routers use these numbers. How the records stay current depends on the data source:
| Polling data source | Streaming data source | |
|---|---|---|
| First load | loadBacking when a maker and token are first tracked |
Snapshot from subscribeBacking |
After new, mod or trade |
loadBacking at once, then again recheckDelay after the latest event. The record shows pending: true meanwhile. |
The data source pushes changes with onBackingEvent |
After skip |
Same as above | One loadBacking per token of the skipped order (the one it sells and the one it buys) |
| Periodic | A sweep every refreshInterval reloads records older than staleAfter |
None |
| Allowance expiry | The budget drops to zero once the indexed ledger passes liveUntil |
Same, and a backing event is emitted at that ledger |
At most concurrency loads run at once. A failed load is logged. A record that never loaded is retried by the next
sweep, one that loaded before only once it is older than staleAfter. With a streaming data source there is no sweep,
so a failed subscribeBacking stays failed while the pair is tracked.
Restart and resume#
init() restores the indexer from its history storage:
- It reads the last cursor (
getCursor) and the contract state (loadContractState). - It loads the active orders and the
EXPIREDarchive, keeps the newest record per order ID and replays them in creation order. Orders that expired while the indexer was down are archived asEXPIREDwithout anorderevent. - It starts loading, or subscribing to, the backing of every live order. Backing is not persisted.
- It starts its timers and calls
dataSource.init(network, contractAddress, cursor), which resumes after the stored cursor.
Until the backing of every loaded order has arrived, the missing budgets count as zero and the built-in GET /
reports loading.
Install and embed#
pnpm add @axis-markets/indexer
The package is CommonJS. It depends on @stellar/stellar-sdk 17 and, for its HTTP server, on Express 5.
const {Indexer, InMemoryHistoryStorage} = require('@axis-markets/indexer')
const {MyDataSource} = require('./my-data-source') //your DataSource implementation
const indexer = new Indexer({
dataSource: new MyDataSource(),
historyStorage: new InMemoryHistoryStorage(), //development only
network: 'testnet',
contractAddress: 'CA6P26K4QNNIMTYP22ILTSCXQQDEKNWJIZJPC34YEZYOLBNT7YUPX7XS',
apiPort: 8070 //optional: serve the built-in HTTP routes
})
indexer.on('order', ({action, order}) => console.log(action, order.id.toString(), order.amount.toString()))
indexer.on('trade', trade => console.log('fill', trade.toJSON()))
indexer.init()
.then(() => console.log('indexer running'))
.catch(e => {
console.error(e)
process.exit(1)
})
process.on('SIGTERM', () => {
indexer.dispose() //does not close the HTTP server
process.exit(0)
})
Attach your listeners before init(), since the data source can deliver events as soon as it starts. With apiPort
set, the routes answer at once, for example curl "http://localhost:8070/order?limit=5&pretty_print". /markets
caches its list for 30 minutes, so a request made while the book is still loading keeps showing a partial list.
Options#
| Option | Type | Default | Description |
|---|---|---|---|
dataSource |
DataSource |
required | Delivers contract events and reads backing |
historyStorage |
HistoryStorage |
required | Persists orders, trades, swaps, the contract state and the cursor |
network |
'public' or 'testnet' |
none | Stellar network, passed to dataSource.init |
contractAddress |
string | none | AXIS contract address. Passed to dataSource.init and used as the allowance spender |
apiPort |
number | none | Starts the built-in HTTP server on this port. Without it there is no server. |
backing.refreshInterval |
number, ms | 60000 |
Period of the sweep that reloads stale backing records |
backing.staleAfter |
number, ms | 300000 |
Age after which the sweep reloads a record |
backing.concurrency |
number | 4 |
Maximum loadBacking calls in flight |
backing.recheckDelay |
number, ms | 30000 |
Delay of the second load after an event-driven reload. 0 disables it. |
expirationInterval |
number, ms | 10000 |
Period of the expiration check |
The second load exists because a data source may serve ledger state that lags behind its event stream. With a
streaming data source the sweep and the second load are off, so refreshInterval, staleAfter and recheckDelay
have no effect, and concurrency only bounds the reloads after skip events.
Methods, properties and events#
Methods#
| Method | Returns | Description |
|---|---|---|
init() |
Promise<void> |
Restores the state from the history storage, wires the data source handlers, starts the timers and calls dataSource.init. Call it once. |
dispose() |
void |
Stops the timers and calls dispose() on the data source and the history storage. It does not close the HTTP server started by apiPort. |
watchBacking(owner, assets) |
Promise<void> |
Tracks the backing of an account in the given tokens, with or without orders. Resolves once the records are loaded. |
unwatchBacking(owner, assets) |
void |
Releases the references taken by watchBacking |
expireOrders(now?) |
Order[] |
Runs the expiration check now and returns the orders that expired since the previous check. now is in UNIX seconds and defaults to the host clock. |
watchBacking is how a service shows balances and allowances of a connected wallet before it places any order. The
AXIS API uses it for accounts subscribed on the WebSocket account channel.
Properties#
| Property | Type | Description |
|---|---|---|
dispatcher |
OrderBookDispatcher |
The book. dispatcher.graph is the in-memory order graph. dispatcher.ready is true when no backing load is in flight, so it turns true once the initial loads after startup are done. |
backing |
BackingTracker |
Backing records per maker and token. backing.getBudget(owner, asset, ledger) returns the effective budget. |
contractState |
ContractState |
address, frozen, config and markets. toJSON() returns the /contract body. |
dataSource |
DataSource |
As passed |
historyStorage |
HistoryStorage |
As passed |
network, contractAddress |
string | As passed |
The dispatcher also exposes the reads behind the HTTP routes: getOrder(id), getOrders(filter), getMarkets(filter),
getAccount(owner) and getBacking(filter). serialize(order) returns an order as the API shows it, with backed and
backing, and allocateBacking(owner, asset) returns a Map from order ID to backed amount.
Events#
Indexer is an EventEmitter. Payloads are live objects that keep changing, so serialize them before you queue or
send them: order.toJSON(), trade.toJSON(), or indexer.dispatcher.serialize(order) to include the backing.
| Event | Payload | When |
|---|---|---|
order |
{action, order, fill?} |
An order changed. action is new, fill (partial), filled, update (including a revival), cancel or expire. |
trade |
Trade |
A fill was stored |
swap |
Swap |
A swap was stored |
backing |
{owner, asset} |
A record loaded for the first time, a tracked balance, allowance, liveUntil, authorization, headroom or pending flag changed, or, with a streaming data source, an allowance expired |
contract |
{kind, market?} |
kind is freeze, config or market. For market, market is {base, quote, created, refreshed} with times in UNIX seconds. |
failure |
Failure |
An AXIS call failed, as reported by a data source that sees failures. Diagnostics only: nothing in the book or the backing changes. |
ledger |
number | Once per ledger. With a streaming data source after the ledger's events and backing changes, for every ledger. Otherwise just before the first event of each new ledger is applied. |
fill comes with the fill and filled actions and describes the fill from the maker's side:
{sold, bought, taker, trade, ts}, where sold is the amount of the order's selling token delivered, bought the
amount of its buying token received, trade the trade ID and ts the time in UNIX seconds. The fill of a
crossfill taker order also carries crossfill: true. Amounts and IDs are bigint.
Reading the book#
dispatcher.graph.sellingGraph.get(x)?.get(y) returns the live orders that a taker selling x can fill to receive
y, best price first. Their price is what the taker pays per unit received: the maker's buying per selling,
times 10^18. Combine it with the backing to build your own router or depth view:
const {graph} = indexer.dispatcher
const XLM = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'
const USDC = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'
//orders selling USDC for XLM, cheapest first
for (const order of graph.sellingGraph.get(XLM)?.get(USDC) ?? []) {
const backed = indexer.dispatcher.allocateBacking(order.owner, order.selling).get(order.id) ?? 0n
console.log(order.id, order.price, order.amount, backed)
}
The market vectors hold live orders only. graph.allOrders also holds the expired orders that can still be revived,
and graph.isLive(id) tells them apart. graph.lastLedger is the last ledger the indexer has seen.
The DataSource interface#
A data source connects the indexer to the chain. It delivers the AXIS contract events in chain order and reads makers'
balances and allowances. The package ships only the interface: implement it on top of Stellar RPC getEvents or any
event feed. The data source of the AXIS API, which streams every transaction from Stellar RPC, is part of the hosted
service and is not published. Extend the exported DataSource class, which provides defaults for the optional members.
Handlers#
The indexer assigns these handlers in init(), before it calls dataSource.init(). Call them one event at a time, in
chain order. The indexer applies every event as it arrives and does not deduplicate.
| Handler | Payload | Contract event |
|---|---|---|
onOrderEvent |
OrderEvent |
new, mod |
onTradeEvent |
TradeEvent |
trade |
onSwapEvent |
SwapEvent |
swap |
onSkipEvent |
SkipEvent |
skip |
onMarketEvent |
MarketRefreshEvent |
refresh |
onFreezeEvent |
FreezeEvent |
freeze |
onConfigEvent |
ConfigEvent |
config |
onFailureEvent |
FailureEvent |
None: a transaction calling the contract failed. Optional, for data sources that see failed transactions. |
onError |
Error |
Any failure of the data source. The indexer logs it. |
Data source methods#
| Method | Required | Description |
|---|---|---|
init(network, contractAddress, cursor) |
Yes | Start delivering the events that follow cursor, the last cursor the history storage holds. cursor is undefined on a fresh index. |
loadBacking(asset, owner, spender) |
Yes | Resolve the BackingRecord of owner in the token asset, with the allowance granted to spender, the AXIS contract |
dispose() |
No | Stop and release resources |
A BackingRecord has these fields:
| Field | Type | Description |
|---|---|---|
balance |
bigint |
What the owner can transfer. For Classic accounts the token's balance() also counts amounts locked by Classic DEX selling liabilities and, for XLM, the reserve, so subtract them. |
authorized |
boolean | Whether the owner can send and receive the token: an authorized trustline or a balance entry. For a contract address without a balance entry, true unless the issuer has AUTH_REQUIRED set. The indexer also requests the record of the AXIS contract itself in every market asset. |
allowance |
bigint |
Allowance granted to spender |
liveUntil |
number | Ledger sequence the allowance lives until, 0 without an allowance |
headroom |
bigint or undefined |
Optional. Amount the owner can still receive: the trustline limit minus the balance and buying liabilities. undefined when unlimited or unknown. |
Streaming data sources#
A data source that follows ledger state itself can push backing changes instead of being polled:
| Member | Description |
|---|---|
streamsBacking |
Set to true as a class field or in the constructor, before the data source is passed to new Indexer(). A getter does not work, since the base class declares it as a field. |
subscribeBacking(asset, owner, spender) |
Start streaming the pair and resolve its current BackingRecord. The indexer subscribes each owner and token once and unsubscribes when nothing needs it anymore. |
unsubscribeBacking(asset, owner, spender) |
Stop streaming the pair |
onBackingEvent(event) |
Handler assigned by the indexer. Call it with a BackingRecord plus owner, asset, spender and ledger whenever a subscribed pair changes. Events for another spender or for an untracked pair are ignored. |
onLedger(ledger, ts) |
Handler assigned by the indexer. Call it after every processed ledger, with or without AXIS events, once that ledger's events and backing changes are delivered. |
cursor |
Optional getter: a resume position that covers everything dispatched so far. The indexer reads it in onLedger and saves it with storeCursor, so a quiet contract keeps a recent resume point. |
A streaming source still implements loadBacking, which the indexer calls after each skip event, once for each token of the skipped order.
Event objects#
Every event object carries these fields:
| Field | Type | Description |
|---|---|---|
cursor |
string | Opaque resume token. The indexer stores it and passes the latest one back to init after a restart. |
position |
bigint |
Monotonic ordinal in chain order. Orders are sorted and paginated by it, and it is the ID of trades and swaps. Recognizing a crossfill without the crossfill field groups trades by transaction with position >> 28n, which requires the TOID layout of the skeleton. |
ledger |
number | Ledger sequence of the event |
ts |
number | Ledger close time, UNIX seconds |
fn |
string | Optional. The AXIS function whose call emitted the event, when the data source reads the call tree. Informational, the indexer ignores it. |
The other fields depend on the event:
| Object | Fields |
|---|---|
OrderEvent |
action ('new' or 'mod'), id, price, amount (0 when removed), expires (number, UNIX seconds, 0 = never), and for new only owner, selling, buying |
TradeEvent |
id (equal to position), order, taker, maker, soldAsset and boughtAsset (the tokens the taker sold and bought), sold, bought, left, and optionally crossfill (boolean, true on the fill of a crossfill taker order). A data source that reads the call tree should set it to true or false on every trade. When it is absent, the indexer falls back to the event pattern. |
SwapEvent |
id (equal to position), trader, soldAsset, boughtAsset, sold, bought |
SkipEvent |
order |
MarketRefreshEvent |
base, quote, the market assets in canonical order |
FreezeEvent |
frozen (boolean) |
ConfigEvent |
safetyAdmin, oracle, listingMinDays (number), marketListingFee, minTradeSize, ledgerTime (number, seconds) |
IDs, prices and amounts are bigint, and addresses are strings (G..., C...).
A FailureEvent describes a failed AXIS call rather than an event, so it has position, ledger and ts but no
cursor. Its position is the transaction position plus the index of the failed call within it, and it becomes the
Failure ID, which is the deduplication key and the /failures cursor. The call may come from the transaction or from another contract, and caught: true marks a failure that a
calling contract caught, in a transaction that succeeded. Its other fields are txHash, fn, caller, orders,
takerOrder (for crossfill), result, reason (contract, transfer, resources, auth or unknown), and,
when the diagnostic events show them, error ({contract, code, name?}) and transfer
({token, fn, from, to, amount}). The indexer stores it as a Failure, served by
GET /failures.
Skeleton implementation#
An outline of a polling data source on Stellar RPC, without retries, rate limiting or gap handling. Decoding follows
Decoding with Stellar RPC. An RPC event ID has the form
<toid>-<event index>, so shifting the TOID left by 16 bits and adding the index gives a monotonic position. This
scheme yields the same trade and swap IDs as the AXIS API.
const {DataSource} = require('@axis-markets/indexer')
const {rpc, scValToNative} = require('@stellar/stellar-sdk')
class RpcEventSource extends DataSource {
constructor({rpcUrl, startLedger}) {
super()
this.server = new rpc.Server(rpcUrl)
this.startLedger = startLedger //first ledger to scan on a fresh index
}
async init(network, contractAddress, cursor) {
this.contractAddress = contractAddress
this.pageCursor = cursor //undefined on a fresh index
this.timer = setInterval(() => this.poll().catch(e => this.onError(e)), 5000)
}
async poll() {
if (this.polling)
return
this.polling = true
try {
const filters = [{type: 'contract', contractIds: [this.contractAddress]}]
const request = this.pageCursor ?
{filters, cursor: this.pageCursor, limit: 200} :
{filters, startLedger: this.startLedger, limit: 200}
const {events, cursor} = await this.server.getEvents(request)
for (const event of events) {
this.dispatch(event)
}
this.pageCursor = cursor
} finally {
this.polling = false
}
}
dispatch(event) {
const [name, ...topics] = event.topic.map(topic => scValToNative(topic))
const data = scValToNative(event.value)
const [toid, index] = event.id.split('-')
const base = {
cursor: event.id,
position: BigInt(toid) << 16n | BigInt(index),
ledger: event.ledger,
ts: Math.floor(Date.parse(event.ledgerClosedAt) / 1000)
}
switch (name) {
case 'new': {
const [id, owner, price, amount, expires] = data
const [selling, buying] = topics
return this.onOrderEvent({...base, action: 'new', id, owner, selling, buying, price, amount, expires: Number(expires)})
}
case 'mod': {
const [id, price, amount, expires] = data
return this.onOrderEvent({...base, action: 'mod', id, price, amount, expires: Number(expires)})
}
case 'trade': {
const [order, taker, maker, sold, bought, left] = data
const [soldAsset, boughtAsset] = topics
return this.onTradeEvent({...base, id: base.position, order, taker, maker, soldAsset, boughtAsset, sold, bought, left})
}
case 'swap': {
const [trader, sold, bought] = data
const [soldAsset, boughtAsset] = topics
return this.onSwapEvent({...base, id: base.position, trader, soldAsset, boughtAsset, sold, bought})
}
case 'skip':
return this.onSkipEvent({...base, order: data})
case 'refresh':
return this.onMarketEvent({...base, base: topics[0], quote: topics[1]})
case 'freeze':
return this.onFreezeEvent({...base, frozen: data})
case 'config':
return this.onConfigEvent({
...base,
safetyAdmin: data.safety_admin,
oracle: data.oracle,
listingMinDays: data.listing_min_days,
marketListingFee: data.market_listing_fee,
minTradeSize: data.min_trade_size,
ledgerTime: data.ledger_time
})
}
}
async loadBacking(asset, owner, spender) {
//read the spendable balance and the trustline authorization of `owner` in `asset`,
//and allowance(owner, spender) from the token contract, for example with simulated calls
throw new Error('Not implemented')
}
async dispose() {
clearInterval(this.timer)
}
}
module.exports = {RpcEventSource}
getEvents returns events after the cursor it receives, and its response cursor moves past the scanned ledgers even
when they hold no AXIS event. The cursor stored with each event is the event ID, which RPC also accepts as a cursor,
so a restart resumes right after the last stored cursor. A skip or a failure that arrived after the last stored
write is replayed, which is harmless: a skip only reloads backing and failures are deduplicated by ID.
The HistoryStorage interface#
The history storage persists what the indexer cannot rebuild from memory: active and archived orders, the trade log,
the contract state and the cursor. Extend the exported HistoryStorage class.
Write methods#
Implement them as async methods, or return a Promise. The indexer calls them without awaiting them, calls .catch()
on the result and only logs a failure. The same applies to dispose() of the data source and of the storage.
| Method | Required | Description |
|---|---|---|
storeTrade(record, cursor) |
Yes | Append a Trade or a Swap to the trade log. record.type tells them apart. |
storeOrder(order, cursor) |
Yes | Upsert by (id, position). An ACTIVE order replaces the active record with the same ID. A FILLED, CANCELED or EXPIRED order moves to the archive. An ACTIVE order whose record was archived, a revival, moves back to the active set. |
storeContractState(state, cursor) |
No | Persist {frozen, config, markets} |
storeCursor(cursor) |
No | Persist the per-ledger resume point of a streaming data source |
storeFailure(failure) |
No | Store a failed transaction. Records are unique by id, so a failure replayed after a restart is stored once. |
cursor is the data source cursor of the last processed event. Order IDs can be reused once an order is gone, so the
archive can hold several records with the same ID, told apart by position.
Read methods#
| Method | Required | Description |
|---|---|---|
getCursor() |
Yes | The latest cursor passed to any store call |
loadContractState() |
No | The state saved by storeContractState, or undefined |
loadTrades({limit, pair?, trader?, cursor?}) |
Yes | Trades and swaps, newest first. Return the records with an id below cursor. trader matches the taker or maker of a trade and the trader of a swap. |
loadActiveOrders({limit, owner?, pair?, cursor?}) |
Yes | Active orders, newest first by position. Return the orders with a position below cursor. |
loadArchivedOrders({limit, owner?, pair?, status?, cursor?}) |
Yes | Archived orders with the same ordering and cursor. status selects one final status by number: 1 FILLED, 2 CANCELED, 3 EXPIRED (Order.ORDER_STATUS). |
loadFailures({limit, account?, fn?, cursor?}) |
No | Failed transactions, newest first by id. account matches the caller and both parties of the failed transfer. Return the records with an id below cursor. |
dispose() |
No | Release resources |
pair is the canonical key returned by the exported toPair(x, y), base/quote. Cursors, IDs and positions are bigint,
and every cursor is exclusive. At startup the indexer pages through loadActiveOrders and through loadArchivedOrders
with status: 3, so these queries must be complete and stable.
When you implement a storage on a database:
- Write each record and its cursor in one transaction. After a crash the indexer resumes after the stored cursor, so a record saved without its cursor is replayed, and a cursor saved without its record loses that record.
- Apply the writes in call order and stop the process when a write fails, rather than let memory and storage diverge.
- Make
storeTradeidempotent byidif your data source can deliver an event twice. - Index orders by
position,owner,pairandstatus, and the trade log byid,pair, taker and maker. - Without
storeContractStateandloadContractState, the contract state starts empty after a restart and stays incomplete until newconfig,freezeandrefreshevents arrive.
Records#
The load methods must return instances of the exported entry classes, not plain objects: the indexer calls
isExpired() and applyExpiration() on replayed orders, and the history routes call toJSON() on every row.
| Class | Fields |
|---|---|
Order |
id, status (number, Order.ORDER_STATUS), selling, buying, price, quote, amount, owner, expires, created, updated (UNIX seconds), position, cursor |
Trade |
type, id, order, taker, maker, soldAsset, boughtAsset, sold, bought, left, crossfill (optional), ledger, cursor, ts |
Swap |
type, id, trader, soldAsset, boughtAsset, sold, bought, ledger, cursor, ts |
Failure |
type, id and the fields of the FailureEvent |
Trade.fromEvent, Swap.fromEvent and Failure.fromEvent rebuild a stored record from its fields. Order has no
deserializer: assign the stored fields to new Order(). IDs, amounts and positions are bigint.
InMemoryHistoryStorage#
InMemoryHistoryStorage ships with the package. It keeps everything in process memory, scans linearly and loses all
data on restart, after which the next init starts from an undefined cursor. Use it for tests and development, and
implement HistoryStorage on a database for production. It serves orders newest first by position, trades, swaps
and failures newest first by id, and stores each trade and failure once per id.
Built-in HTTP routes#
With apiPort, the indexer starts an Express server with open CORS, ?pretty_print and the JSON error shape of the
REST API. It serves these read-only routes:
| Route | Description |
|---|---|
GET / |
{status, ts, ledger, frozen, commission}. status is loading while backing loads are in flight, as at startup, and active otherwise. ts is YYYY-MM-DD HH:MM:SS UTC. commission is always {"maker": 0, "taker": 0}. |
GET /markets |
Pairs with live orders |
GET /order/:id |
One live order |
GET /order |
Live orders by owner and assets |
GET /account/:address |
Orders and backing of an account |
GET /backing |
Backing of an owner in a token |
GET /contract |
Contract state |
GET /order-history |
Archived orders, read from the history storage |
GET /trades |
Trades and swaps, read from the history storage |
GET /failures |
Failed transactions, read from the history storage |
Parameters, limits and response shapes match the AXIS API. To mount the routes in your own Express app instead, call
orderbookRoutes(app, indexer) (markets, orders, accounts, backing and contract) and historyRoutes(app, indexer)
(order history, trades and failures). apiPort itself calls the exported initApiServer(indexer, port), which listens on all interfaces. GET / is not
part of the exported route sets: build your own status route from indexer.dispatcher.ready,
indexer.dispatcher.graph.lastLedger and indexer.contractState.frozen.
The package also exports OrderBookDispatcher, BackingTracker, ContractState, the entry classes, toPair,
canonicalPair, compareAssets and PRECISION.
Operational notes#
- Memory. The indexer holds every live order, every expired order that can still be revived and the backing records in memory. An expired order stays until its owner removes or revives it, or a new order takes its ID. History lives in the storage.
- Restarts. With a persistent storage, a restart replays the stored orders, reloads the backing and resumes the data source from the stored cursor, so only the downtime is caught up.
- Event retention. Stellar RPC servers keep events for a limited window. A stored cursor older than that window cannot
be resumed. A streaming data source can expose
cursor, which the indexer saves after every ledger, so the resume point stays recent while the contract is quiet. A polling data source moves the stored cursor only with AXIS events. - Fresh index. The book is built from events only. Start a new index at or before the contract's first event, or from an event feed with full history. Orders created before the starting point are missing, and their later fills cannot be applied.
- Switching data sources. Cursors are opaque to the indexer and specific to each data source, and positions (trade IDs and order cursors) can be computed differently. Start a fresh index with an empty storage when you change the data source, the network or the contract.
- Clock. Expirations use the host clock, so keep it synchronized.
- Shutdown.
dispose()does not stop the server started byapiPort. Mount the routes in your own server if you need a clean shutdown.
For the place of the indexer in the system, see Architecture.