The Leash indexer is the worker behind api.leash.market that turns on-chain agent activity into the same event stream the API writes prepare/submit transitions to. It runs against devnet and mainnet at the same time, with strict per-network cursors, and powers the explorer’s per-agent timeline. It does not full-scan the cluster. It walks a watchlist seeded by the API itself.

Watchlist

Three roles are tracked per address:
KindWhat it isWhen it gets added
assetThe Core asset pubkey (the agent mint)First prepare* call against the agent, or first receipt ingest.
treasuryThe Asset Signer PDA derived from the assetSame trigger; computed via findAssetSignerPda.
treasury_ataA stable ATA owned by the treasury PDAWhen provision/prepare, withdraw/prepare, or treasury/balances runs; on indexer ATA discovery.
Why three? getSignaturesForAddress(treasury_pda) only surfaces transactions in which the PDA itself is in the account list — that covers every Execute-driven withdraw, but not plain SPL TransferChecked deposits, whose account list contains only the ATAs and the mint. To pick those up the indexer also walks signatures on each treasury ATA and emits agent.treasury.fund when the ATA’s owner (= the PDA) shows a positive tokenBalanceDeltas row. Adding to the watchlist is automatic; the API calls ensureWatched(...) and ensureWatchedAta(...) whenever a new agent or ATA crosses its surface. The triggers are:
  • Any prepare* call against /v1/agents/{mint}/... — adds the asset and treasury PDA.
  • provision/prepare, withdraw/prepare, withdraw-all/prepare — also add every relevant treasury_ata.
  • GET /v1/agents/{mint}/treasury/balances — adds the PDA + every SPL ATA the snapshot surfaces. This is the cheapest “force-register this agent” call available; it’s read-only, free, and side-effects all three watch kinds at once.
  • POST /v1/receipts/{agent} and POST /v1/agents/{mint}/pull-target — register the asset (no ATA discovery, since neither call needs it).
The indexer additionally self-bootstraps on startup: for every treasury row it has never discovered ATAs for, it issues one getTokenAccountsByOwner per token program (classic SPL + Token-2022) and registers each result as a treasury_ata row. You can list the watchlist (subset of your network) via GET /v1/indexer/status. Manual adds are not exposed in v1 — the goal is to keep the indexer scope strictly tied to “agents Leash has seen”, which matches the explorer’s product surface. For a per-event-kind cookbook of “which call do I make so this row shows up?”, see Explorer tracking.

Cursors

indexer_cursors(network, address, kind) stores:
  • last_signature — newest signature already ingested for the row.
  • last_slot — slot of last_signature.
  • last_run_at — wall-clock timestamp of the most recent tick.
  • backfill_complete0 while the row is paging backwards, 1 once it has reached its origin.
A tick walks getSignaturesForAddress in pages of 100. New rows page backwards until they hit the registry’s deploy slot or a configurable backfill_floor. Once backfill_complete = 1, every subsequent tick is forward-only: list new signatures since last_signature, page until empty, advance the cursor. This is identical behaviour on both networks, with two distinct cursor rows so a devnet replay never moves a mainnet pointer.

Decoding

Decoding is log-driven. The indexer fetches each transaction with getTransaction(signature, { jsonParsed, maxSupportedTransactionVersion: 0 }) and matches program logs against a known table:
ProgramLogs / signal we matchEventKind
mpl-agent-identityCreateIdentity, SetAgentTokenagent.identity.register, agent.token.set
mpl-agent-toolsCreateExecutive, Delegate, Revokeagent.executive.register, agent.executive.delegate
mpl-core (Execute wrapping SPL/Sys)Approve, TransferChecked, lamport delta < 0agent.delegation.set, agent.treasury.withdraw[_sol]
SPL Token / Token-2022Approve, Revoke, TransferCheckedSame as above when wrapped in Execute for an asset signer
Any program (no Execute)tokenBalanceDeltas[ata].delta > 0 where the ATA is on the watchlist and its owner is the treasury PDAagent.treasury.fund (one row per mint that increased)
systemlamportDeltas[treasury].delta ≥ 5 000 with no matching withdraw row in the same txagent.treasury.fund_sol (sub-fee wobbles dropped as noise)
Amounts are taken from the parsed instruction data (for SPL) or the postTokenBalances / postBalances deltas keyed to the treasury PDA (for Core Execute). Failed transactions are still ingested, flagged in metadata.failed = true, and counted in /v1/indexer/status.events_last_hour. Single signatures can produce multiple events (e.g. a multi-mint provision). The (network, signature, kind, mint) tuple is the deduplication key so re-runs are safe.

Receipt pull worker

The same process runs runReceiptPullTick on a (slower) cadence:
  1. Iterate every row in pull_targets for the active network.
  2. GET <url> and parse JSON or JSONL.
  3. For each item, call the same ingestReceipt the public API uses.
  4. On a non-duplicate, write a receipt.pulled event linked to the agent.
The puller respects per-target interval_seconds and a global floor (default 30s) so a misconfigured target can’t hammer your origin.

Networks

The indexer runs against devnet and mainnet in parallel with isolated cursors and watchlists per network. Tick cadence and page size are tuned server-side; from the outside you only see the result through /v1/indexer/status and the per-network event feed. A lsh_test_* key only sees devnet, a lsh_live_* key only sees mainnet.

Health: GET /v1/indexer/status

curl https://api.leash.market/v1/indexer/status \
  -H "Authorization: Bearer $LEASH_API_KEY"
{
  "network": "solana-mainnet",
  "watchlist_size": 1342,
  "cursors": {
    "total": 2684,
    "last_run_at": "2026-04-23T12:01:55.318Z"
  },
  "events_last_hour": {
    "agent.identity.register": 4,
    "agent.delegation.set": 22,
    "agent.treasury.withdraw": 11,
    "receipt.published": 1284,
    "receipt.pulled": 39
  }
}
If last_run_at lags by more than ~30 seconds something is wrong on our end — open an incident in Discord. The endpoint is scoped by API key prefix so you only see your network.