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:| Kind | What it is | When it gets added |
|---|---|---|
asset | The Core asset pubkey (the agent mint) | First prepare* call against the agent, or first receipt ingest. |
treasury | The Asset Signer PDA derived from the asset | Same trigger; computed via findAssetSignerPda. |
treasury_ata | A stable ATA owned by the treasury PDA | When provision/prepare, withdraw/prepare, or treasury/balances runs; on indexer ATA discovery. |
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 relevanttreasury_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}andPOST /v1/agents/{mint}/pull-target— register the asset (no ATA discovery, since neither call needs it).
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 oflast_signature.last_run_at— wall-clock timestamp of the most recent tick.backfill_complete—0while the row is paging backwards,1once it has reached its origin.
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 withgetTransaction(signature, { jsonParsed, maxSupportedTransactionVersion: 0 })
and matches program logs against a known table:
| Program | Logs / signal we match | EventKind |
|---|---|---|
mpl-agent-identity | CreateIdentity, SetAgentToken | agent.identity.register, agent.token.set |
mpl-agent-tools | CreateExecutive, Delegate, Revoke | agent.executive.register, agent.executive.delegate |
mpl-core (Execute wrapping SPL/Sys) | Approve, TransferChecked, lamport delta < 0 | agent.delegation.set, agent.treasury.withdraw[_sol] |
| SPL Token / Token-2022 | Approve, Revoke, TransferChecked | Same 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 PDA | agent.treasury.fund (one row per mint that increased) |
system | lamportDeltas[treasury].delta ≥ 5 000 with no matching withdraw row in the same tx | agent.treasury.fund_sol (sub-fee wobbles dropped as noise) |
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 runsrunReceiptPullTick on a (slower) cadence:
- Iterate every row in
pull_targetsfor the active network. GET <url>and parse JSON or JSONL.- For each item, call the same
ingestReceiptthe public API uses. - On a non-duplicate, write a
receipt.pulledevent linked to the agent.
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
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.
