# ixo-chain

> Read-only IXO chain data for the model: live balances, and claims, collections, entities, DID documents, 1155 tokens and transactions from IXO Blocksync.

**Source:** [`packages/oracle-runtime-workers/src/plugins/ixo-chain/`](https://github.com/ixoworld/qiforge/blob/main/packages/oracle-runtime-workers/src/plugins/ixo-chain/)

| Attribute | Value |
| --- | --- |
| Feature key | `ixo-chain` |
| Runtime | Cloudflare Workers runtime only (`@ixo/oracle-runtime-workers` 0.28.0 and later) |
| Visibility | `on-demand` |
| Stability | `beta` |
| Category | `data` |
| Default state | On unless `IXO_CHAIN_TOOLS=off` |
| Depends on | — |
| Requires (UCAN) | — (everything it reads is public) |

## Summary

The `ixo-chain` plugin gives the model two read-only tools over public IXO chain data, so it can answer questions such as "what is my IXO balance?", "did I get paid for my claims in collection 8250?" or "what is `did:ixo:entity:…`?". It is part of `BUNDLED_WORKERS_PLUGINS`, so an oracle that spreads that list into the `plugins` of `createOracleWorker` loads it unless `IXO_CHAIN_TOOLS=off`. The Node.js runtime (`@ixo/oracle-runtime`) does not ship it.

| Tool | Reads | Freshness |
| --- | --- | --- |
| `get_account_balances` | Chain REST (LCD) gateway: bank balances and denom metadata | Live chain state |
| `chain_lookup` | IXO Blocksync GraphQL (`BLOCKSYNC_GRAPHQL_URL`) | The index, seconds behind the chain |

Every account field defaults to **the user**. The user's IXO account address is the suffix of their `did:ixo:ixo1…` DID (see [What the model is told about the user](/build-an-oracle/develop/identity-and-auth#what-the-model-is-told-about-the-user)). A user with a `did:key:` or `did:web:` DID has no address, and a request that needs it answers `invalid_input` ("the user has no IXO account address; give one"). An account field also takes any `ixo1…` address or `did:ixo:ixo1…` DID.

## Load it on every turn

The plugin is `on-demand`: its tools exist on a turn only after the model loads it with `load_capability`, or after the runtime's capability router preloads it for that turn. To bind the tools on every turn instead, override its [visibility tier](/build-an-oracle/understand/visibility-tiers) with `manifestOverrides`:

```ts
// src/index.ts
import {
  createOracleWorker,
  BUNDLED_WORKERS_PLUGINS,
} from '@ixo/oracle-runtime-workers';

export default createOracleWorker({
  config,
  plugins: [...BUNDLED_WORKERS_PLUGINS],
  manifestOverrides: {
    'ixo-chain': { visibility: 'always' },
  },
});
```

The override is shallow-merged onto the plugin's manifest at boot and validated like an authored manifest; see [Retune a bundled plugin's manifest](/build-an-oracle/develop/enable-bundled-plugins#retune-a-bundled-plugins-manifest). The rules the model needs (live versus indexed data, units, read-only, untrusted text) are in the plugin's operating guide **and** in both tool descriptions, so a preload that binds the tools without the guide still carries them.

## Environment variables

| Var | Required | Description |
| --- | --- | --- |
| `IXO_CHAIN_TOOLS` | no | `on` (default) or `off`. `off` leaves the plugin out of the oracle. |
| `CHAIN_REST_URL` | no | Chain REST (LCD) base URL for balances and denom metadata. `https`, no credentials, query or fragment (a `/rest` path is fine). Defaults per `NETWORK`, below. |
| `BLOCKSYNC_GRAPHQL_URL` | — | Read but not owned (core). The IXO Blocksync endpoint `chain_lookup` queries. A value that is not an `http` or `https` URL makes `chain_lookup` answer `unavailable` without sending a request. |
| `NETWORK` | — | Read but not owned (core). Selects the default REST gateway and is named in every result. |

| Network | Chain REST default | Blocksync GraphQL (`BLOCKSYNC_GRAPHQL_URL`) |
| --- | --- | --- |
| mainnet | `https://impacthub.ixo.world/rest` | `https://blocksync-graphql.ixo.earth/graphql` |
| testnet | `https://testnet.ixo.earth/rest` | `https://testnet-blocksync-graphql.ixo.earth/graphql` |
| devnet | `https://devnet.ixo.earth/rest` | `https://devnet-blocksync-graphql.ixo.earth/graphql` |

## What it contributes

- **Tools:** `get_account_balances`, `chain_lookup` (both `effect: 'read'`).
- **Sub-agents:** none.
- **Middleware:** none.
- **HTTP routes:** none.
- **Shared state:** none.

## `get_account_balances`

Input: `{ address? }`, an `ixo1…` address or `did:ixo:ixo1…` DID; default the user's account. The tool reads one page of up to 50 bank balances from the chain REST gateway and shows each denom through the chain's own bank metadata:

- `uixo` is shown as `IXO` with 6 decimals, verified, and is never looked up.
- Any other denom is looked up in the bank metadata. It is **verified** when the metadata lists its `display` unit in `denom_units` with an exponent of 0 to 18; that exponent becomes its decimals, and its symbol is the metadata `symbol` or, when that is empty, the display unit upper-cased. So 5,740,000 `upay` reads as `5.74` `PAY`.
- A denom with no metadata, or whose display unit is not listed, is **unverified**: 0 decimals, in base units, named by its full denom.
- A token whose metadata could not be read (a failed read, or no time left in the tool's budget) is shown unverified with `metadataUnavailable: true`: it may still be verified.

Answer:

```text
{ ok, network, source: 'chain', fetchedAt, address, subject,
  tokens: [{ denom, symbol, decimals, verified, amountBaseUnits, amount, metadataUnavailable? }],
  moreDenoms?, truncated? }
```

`amount` is a decimal string in display units (`9000000` uixo is `"9"`). Tokens are sorted `uixo` first, then verified tokens, then by amount. `moreDenoms: true` means the account holds more than 50 denoms. An empty list is an answer, not an error. This is the only tool whose `amount` is converted.

## `chain_lookup`

Input: `{ requests: [...] }`, 1 to 8 requests, each with a `kind`. All of them go to IXO Blocksync as **one** GraphQL request, and each answers on its own. Batch related questions into one call.

| `kind` | What it answers | Key inputs |
| --- | --- | --- |
| `claims` | Claims submitted by an account, newest first, with each claim's evaluation and payment status | `agent?` (default: the user), `collectionId?`, `first?`, `after?` (the previous page's `endCursor`) |
| `claim` | One claim and its current evaluation | `claimId` (a CID, for example `bafkrei…`) |
| `claim_collection` | One claim collection: counts, state and payment terms | `id` (for example `"8250"`) |
| `claim_collections` | Collections by admin or by entity, newest start date first | `entity?`, `admin?` (default: the user), `first?` |
| `entities` | Entities owned by an account | `owner?` (default: the user), `type?` (for example `"dao"`), `first?` |
| `entity` | One entity | `id` (`did:ixo:entity:` followed by 32 hex characters) |
| `did_document` | One DID document | `id?` (any `did:ixo:…` DID; default: the user's DID) |
| `token_balances` | 1155 tokens an account holds | `address?` (default: the user), `first?` |
| `transactions` | Transactions an account signed, or messages it received | `address?` (default: the user), `direction?` (`signed`, the default, or `received`), `first?` |
| `transaction` | One transaction, with its signers, fee and up to 50 messages | `hash` (64 hex characters) |

The input schema is strict. An unknown key in **one** request (`agentDid` instead of `agent`), an unknown `kind`, a `first` outside 1 to 20, or more than 8 requests rejects the **whole** call with a tool error that names the mismatch. A request whose id fails its pattern (for example a malformed hash) answers `invalid_input` on its own; the others still run.

Answer:

```text
{ ok: true, network, source: 'blocksync', fetchedAt,
  results: [ { kind, ok: true, subject?, first?, data, truncated? }
           | { kind, ok: false, reason, detail, retryable } ] }
```

Only a failed request to IXO Blocksync as a whole (after its retry) makes the whole answer `ok: false`. What `data` holds:

- Claim and evaluation status as words (`pending`, `approved`, `rejected`, `disputed`, `invalidated`, `flagged`); collection state `open`, `paused` or `closed`. `claims` carries `hasNextPage` and `endCursor` for `after`.
- Transactions as `{ hash, height, time, code, succeeded, memo?, messages, messageCount? }`. Message values are never returned raw: `MsgSend` is `{ from, to, amount }`, `MsgTransferToken` is `{ from, to, tokens }`, `MsgSubmitClaim` and `MsgEvaluateClaim` are `{ claimId, collectionId, status? }`, an authz `MsgExec` is its first 5 inner summaries, and anything else is a 240-character JSON excerpt.
- An entity's `status` stays the chain's number, with a `statusNote`: the chain gives entity status no fixed meaning. `entities` adds `orderedBy: 'id (text)'`, because an entity id is a hash and its order says nothing about age.

## Reading the results

- **`network`** is in every answer. The model is told to say which network an answer is from.
- **`subject`** is `'user'` or `'other'`: whether the data belongs to the user. A read by account knows it before the read. A read by id learns it from the answer: a claim is the user's when its `agentAddress` is the user's address, a collection when its `admin` is, an entity when its `owner` is, a transaction when one of its signers is, a DID document when its id is the user's DID. `claim_collections` by entity alone carries no `subject`.
- **`amount` versus `amountBaseUnits`.** Only `get_account_balances` converts (`amount`, display units). Inside `chain_lookup`, every bank coin (a claim's or evaluation's amount, a `MsgSend` amount, a transaction fee, a collection payment) and every cw20 payment amount is `{ denom, amountBaseUnits }`: uixo ÷ 1,000,000 = IXO.
- **1155 tokens are whole units.** The IXO token module stores no decimals, so `token_balances` rows, `MsgTransferToken` amounts and cw1155 payments keep `amount` in whole units. `token_balances` rows are `{ tokenId, name, class, collection, amount, retired, contractAddress }`.
- **Failures are results**, each with one plain-sentence `detail` the model can relay:

| `reason` | When | `retryable` |
| --- | --- | --- |
| `rate_limited` | HTTP 429 twice, or the upstream is marked unhealthy after a 429 | true |
| `timeout` | An attempt ran out of time | true |
| `index_timeout` | IXO Blocksync's statement timeout for that request ("ask for fewer rows or a narrower filter") | false |
| `unavailable` | 5xx, 404, network error, redirect, unreadable body, a field error, a request the answer does not carry, no valid `BLOCKSYNC_GRAPHQL_URL` | true for 502 to 504, network, transient and missing-request errors |
| `not_found` | A by-id read answered null with no error | false |
| `too_large` | The body was over the read limit, or a request was left out to keep the answer small | false (ask for it on its own) |
| `invalid_input` | A field that fails its pattern, or no IXO account address for the user | false |

## Limits

| Limit | Value |
| --- | --- |
| Requests per `chain_lookup` call | 1 to 8, sent as one GraphQL request |
| Rows per list (`first`) | 1 to 20, default 10. With `n > 1` requests in a call, each list reads at most `max(5, floor(40 / n))` (2 requests: 20, 4: 10, 8: 5); every list result reports the `first` it used. |
| Bank balances | One page of 50 denoms (`moreDenoms: true` beyond that) |
| Nested lists | A transaction in a list: its first 5 messages, with `messageCount` for the total. One `transaction`: up to 10 signers and 50 messages. Collection payment lists: 10. Entity and DID document lists: 20. |
| Strings | Clipped: ids, addresses, hashes and dates 120 characters; names and labels 200; memos 256; excerpts 240 |
| Answer size | At most 12,000 JSON characters. Each result gets a share; a result over its share has its largest list halved and is marked `truncated: true`; if the answer is still too big, the largest results are replaced by `too_large`. |
| Time | 20 s budget per tool call; each attempt at most 8 s (chain REST) or 11 s (IXO Blocksync, above its 8 s statement timeout) |
| Retries | One retry on HTTP 429, 502, 503 or 504, a network error or a transient GraphQL error, after the upstream's `Retry-After` (capped at 5 s) or a random 1 to 3 s, and only when at least 1 s of budget would be left. Never retried: other 4xx, 501, a timeout, an unreadable or oversized body, a statement timeout. |
| Unhealthy upstream | A 429 or 503 marks that upstream for 10 s per isolate; other calls answer `rate_limited` or `unavailable` (retryable) at once instead of asking it |
| Body read | At most 512 KiB (chain REST) or 1 MiB (IXO Blocksync); no redirects are followed |

## Read-only and security

- **Read-only.** Neither tool signs or sends anything. For a transfer, the model is told to use the turn's transfer tools instead.
- **Public data only.** The manifest declares no [`requires`](/build-an-oracle/reference/manifest-schema#requires): nothing it reads is guarded by a User-controlled Authorization Network (UCAN) capability, and anyone can read the same data from the chain.
- **Chain text is data, never instructions.** Memos, names, descriptions and excerpts are written by anyone on a public chain. The operating guide and the `chain_lookup` description tell the model to treat them as data.
- **Inputs are checked before they reach a query.** Every id is matched against its kind's pattern and written into the GraphQL document as a literal.
- **Logs carry no user data.** Each failed attempt logs one `[ixo-chain] <read> attempt N failed: <status or error class>` warning, never a request body or an address.

## Known limits

- **Large histories can time out.** IXO Blocksync has no composite index for reading claims, signed transactions or received messages of one account in order: each read filters on the account and sorts what it finds, so an address with a very large history can hit the 8 s statement timeout (`index_timeout`).
- **Received transactions** come from each message's recipient field. A `MsgMultiSend` output, and a send that is not the first message inside an authz `MsgExec`, are not seen.
- **IBC tokens can show unverified.** The IBC transfer module registers metadata whose display unit is the trace path with only the base unit listed, so on mainnet (metadata read October 29, 2026) IBC USDC shows unverified, in base units.
- **On-demand by default.** The model must load the plugin, or the capability router preload it, before the tools exist on a turn. Use the [override above](#load-it-on-every-turn) to have them on every turn.

## When to use it

- The user asks about their IXO balance or tokens.
- The user asks about their claims, a claim collection, or an entity or DID.
- The user asks what happened on chain (sent or received transactions).
- The user asks what a `did:ixo:…` identifier is.

## When NOT to use it

- Preparing or sending a transfer: these tools only read.
- Anything that is not public chain data.

## Where to read next

<CardGroup cols={2}>
  <Card title="Identity and auth" icon="shield-halved" href="/build-an-oracle/develop/identity-and-auth#what-the-model-is-told-about-the-user">
    How the model knows the user's DID and IXO account address.
  </Card>
  <Card title="Environment variables" icon="key" href="/build-an-oracle/reference/environment-variables">
    `IXO_CHAIN_TOOLS`, `CHAIN_REST_URL` and the core `BLOCKSYNC_GRAPHQL_URL`.
  </Card>
</CardGroup>
