Integrating a market with axmc
The reference: posting by API, the registration call, the resolver policies, opening questions, reading the outcome and its receipt, webhooks and the HTTP API, and the CPI path for a market that is a Solana program. To get going, read Quickstart: resolve your markets by API; to show the answer on your own pages, Go live.
Three ways to connect
| Your market | Registers with | Who signs | Questions are paid from |
|---|---|---|---|
| Posted by API: your backend sends each question over HTTP | POST /questions, which lists the market the first time |
axmc's posting key, which your venue names as one of its keys | Your venue's balance |
| Venue market: your backend or operator acts for it | register_venue_market, under your venue account |
One of your venue's keys | Your venue's balance |
| Program market: your Solana program calls the oracle itself | register_market by CPI |
Your program, for its market PDA | A token account your PDA owns |
A venue whose markets live off chain usually wants the first: see Post questions by API. The second is the same accounts with your own key signing, and most of this page is about it. The third is in CPI from your own Solana program. All three read the answer from the same Request account.
Your venue account
Governance creates your venue in the same click that approves you (see Go live). It holds your name, your market programs, the keys that act for you and the balance your questions are paid from. The authority manages the venue but cannot list markets or open questions. A venue starts with axmc's posting key as its one key and as its authority, held in trust until your Squads vault accepts it on Venue → Vault; from then on the vault is the authority. The keys, up to eight, list markets and open questions but cannot move the balance any other way.
The posting key is all that posting needs. Claiming compensation and closing a request are signed by one of the venue's keys too, and the posting key does neither for you, so to do them add a key of your own: Venue → Vault → A key of your own proposes set_venue_keys from the vault, keeping the posting key. The same instruction is client.setVenueKeysIx(venue, keys, vault) for your own Squads transaction.
| Instruction | Who signs | Does | SDK |
|---|---|---|---|
deposit_venue |
Anyone | Adds collateral to the balance | depositVenue(venue, amount) |
withdraw_venue |
Authority | Takes back what questions have not spent | withdrawVenue(venue, amount) |
withdraw_venue_rent |
Authority | Reclaims rent settled requests returned to the venue | withdrawVenueRent(venue) |
set_venue_keys |
Authority | Replaces the keys | setVenueKeys(venue, keys) |
set_venue_programs |
Authority | Replaces the market programs the venue claims | setVenuePrograms(venue, programs) |
propose_venue_authority, accept_venue_authority |
Authority, then its successor | Hands the venue on in two steps | proposeVenueAuthority, acceptVenueAuthority |
set_venue_paused |
Governance | Freezes the venue; live questions run to the end |
client.venueFor(key) finds the venue a key acts for, and client.venueBalance(venue) reads its balance. The venue account is ["venue", <id as u64 LE>]; its balance vault is ["venue_vault", <venue account>].
Keep enough in the balance for the questions you expect: a question whose reward and premium exceed it fails. The balance is the only account the oracle debits for your questions, and an unanswered question's reward returns to it.
Post questions by API
Your markets are created on your platform. axmc answers the questions a data feed cannot. When one of your markets needs an answer, your backend sends the question over HTTP and axmc signs it on chain for you:
- Get an API key on Venue → API, signed in with your email (see API keys).
- Let axmc post. A venue registered on approval already has axmc's posting key among its keys. An older venue's authority adds it once:
client.setVenueKeys(venue, [...keys, posterKey]), or the button on Venue → API.GET /posternames the key. It is a venue key like any other: it can list markets and open questions that your balance pays for, and as a key it cannot move the balance or change the venue. Your authority removes it the same way. - Post each question:
curl -sX POST https://api.axmc.xyz/questions \
-H 'authorization: Bearer axmc_...' \
-H 'content-type: application/json' \
-d '{
"marketId": "1042",
"title": "Will it rain in Madrid on 1 December 2026?",
"outcomes": ["Yes", "No"],
"rules": "Resolves Yes if AEMET records any rain at Madrid Retiro on 1 December 2026.",
"sources": "https://www.aemet.es",
"closesAt": 1796083200,
"reward": "5000000",
"tags": [724, 7]
}'
# -> 201 {"request":"<pubkey>","market":"<pubkey>","marketId":"1042","signature":"...","listing":"...","tier":"T1",
# "proposerBond":"...","premium":"...","windowSecs":21600}
| Field | Required | Meaning |
|---|---|---|
marketId |
Yes | Your own id for the market, a whole number up to 2^64−1. The oracle market is pda.oracle.market(venue, marketId) |
title, rules, sources |
Yes | What the question asks, how it resolves, and where to look. Hashed into the manifest and stored in the evidence store |
reward |
Yes | Base units of the collateral mint, from your balance, plus the premium the tier sets |
tags |
On a market's first question | One to eight region or category tags. The first question on a marketId lists it Permissionless, priced on reported open interest; later questions ignore tags |
outcomes |
No | Two to 32 names, index 0 first. Default ["Yes", "No"] |
category |
No | A label for the question card |
closesAt, resolvesAt |
No | Unix seconds. Default now, and an hour after closesAt |
openInterest |
No | Base units at stake, reported. Default 0, which prices as the smallest question |
primarySources, fallbackSources, edgeCases, sourceKind, tieBreak |
No | The manifest's structure, as in Open a question. Defaults 2, 1, 3, officialAnnouncement, primarySourceWins |
A retry is safe: when the market's latest question has the same rules text, the call answers 200 with that request and "repeated": true instead of paying again. A different question on the same marketId opens the market's next request.
| Status | Means |
|---|---|
| 201 | Opened. listing is the signature that listed the market, or null if it already was |
| 200 | The same question was already open; nothing was paid |
| 400 | A field is wrong, or a new market came without tags |
| 403 | The key belongs to no venue: mint it on Venue → API, signed in with your email |
| 409 | The posting key is not one of your venue's keys yet, or governance paused the venue |
| 422 | The chain refused it, in the program's words: most often a balance that cannot cover the reward and the premium |
| 429 | Over the limit: bursts of ten, then one a second per key |
| 503 | This deployment does not post for venues |
Read the answer the way any venue does: webhooks, GET /requests/<request>, or the account itself. Questions posted this way are categorical. To commit a judge, choose a council, or price on your vault, list the market yourself first (Register a market); posting then opens questions on it as it stands.
Register a market
Registering lists a market your program already runs, under your market id, with open interest read from your collateral vault. The policy, the tags and the open-interest source are permanent.
import { TruthClient, readVenueMarket } from '@axmc/sdk'
const client = TruthClient.connect(connection, listingWallet) // one of your venue's keys
const me = await client.venueFor() // your venue; null if you have none yet
const market = await readVenueMarket(connection, marketAccount) // id, admin, vault, end time, status, read from your program
const { market: record } = await client.registerVenueMarket(me.publicKey, market, {
policy: 'permissionless', // or 'council' / 'hybrid' with council: <council pubkey>
tags: [840, 7], // one to eight distinct region or category tag ids
})
The oracle checks the signer against your venue's keys on chain. The oracle market is ["market", <your venue account>, <your market id as u64 LE>], so rotating keys strands nothing.
Describe your program
readVenueMarket needs a layout: your market account's fields in declaration order, with the ones the oracle needs named. The SDK ships GoMarket's two programs (GOMARKET_CLOB, GOMARKET_LMSR). Yours has the same shape:
import { PublicKey } from '@solana/web3.js'
import { registerVenueLayout, type VenueLayout } from '@axmc/sdk'
export const MY_VENUE: VenueLayout = {
id: 'my-venue',
name: 'My Venue',
description: 'Binary markets, one vault per market.',
programIds: [new PublicKey('<your program id>')],
marketAccount: 'Market', // the Anchor account name; its discriminator is checked
marketSeed: 'market', // if markets are PDAs of [seed, market_id as u64 LE]; omit otherwise
market: [
{ type: 'u64', as: 'marketId' },
{ type: 'pubkey', as: 'admin' },
{ type: 'pubkey', rust: 'yes_mint' }, // no `as`: stepped over
{ type: 'pubkey', rust: 'no_mint' },
{ type: 'pubkey', as: 'collateralMint' },
{ type: 'pubkey', as: 'vault' },
{ type: 'i64', as: 'endTime' },
{ type: 'u8', as: 'status' },
{ type: 'optionU8', as: 'outcome' },
],
statuses: ['Open', 'Closed', 'Resolved'], // your status enum, in order
outcomes: ['Yes', 'No'], // your outcome enum, in order
configAccount: 'Config', // optional: program-wide roles that may also list a market
configSeed: 'config',
config: [{ type: 'pubkey', as: 'admin' }, { type: 'pubkey', as: 'resolver' }],
}
registerVenueLayout(MY_VENUE)
Field types are u8, u16, u32, u64, i64, bool, pubkey, optionPubkey and optionU8. market must name marketId, admin, vault, collateralMint, endTime and status; resolutionAuthority, createdAt and outcome are optional. Config roles are admin, resolver and operator. A layout that cannot produce a registration is refused when you register it. Register it in your own build to use it now, or add it to the SDK's venue layouts in a pull request so the app lists your program by name. Add a resolve block if a key can tell your program the answer.
Resolver policy
| Policy | Who rules a dispute | Pass |
|---|---|---|
permissionless |
A sampled panel of staked jurors, then a grand panel. One that cannot decide rules Unresolvable |
No council |
council |
Your council, directly | council: <council pubkey> |
hybrid |
A sampled panel, then your council instead of a grand panel | council: <council pubkey> |
A council is created in the court program by an authority such as your venue or an observer body (client.createCouncil). Members post a bond and vote openly with a rationale. Members who vote against the final answer, or do not vote, lose part of their bond. A council that cannot agree rules Unresolvable, so the market refunds.
Where open interest comes from
Open interest sets the risk tier, which sets the bond, the window, the panel size and the premium.
| Source | The oracle uses | Use when |
|---|---|---|
vault |
The balance of your market's collateral vault, bound by address | Your vault holds the protocol's collateral mint. The number cannot be understated |
reported |
The openInterest you pass on each request |
Your market settles in a different token |
registerVenueMarket picks vault when your vault holds the protocol's collateral mint and reported otherwise. Pass openInterest in the options to choose. The choice is public on the market account and the MarketRegistered event.
Open a question
One request per question, signed by one of your venue's keys. The reward and premium come from the venue balance.
import { BN } from '@coral-xyz/anchor'
import { questionText, textHash } from '@axmc/sdk'
// Hash and publish THIS string: its header carries your title, category and outcome names.
const rulesText = questionText(
{ title: 'Will the Fed cut rates at the December 2026 meeting?', category: 'Economics', outcomes: ['Yes', 'No'] },
'Resolves YES if the FOMC statement lowers the target range. ...',
)
const { request } = await client.requestResolution(record, {
manifest: {
answer: { categorical: { count: 2 } }, // or { scalar: { decimals: 2 } }
marketClose: new BN(closeUnixSecs),
resolutionTime: new BN(resolveUnixSecs), // answers are refused before this
primarySources: 2,
fallbackSources: 1,
sourceKind: { officialAnnouncement: {} },
edgeCases: 31,
tieBreak: { primarySourceWins: {} },
textHash: Array.from(textHash(rulesText)),
sourcesHash: Array.from(textHash(sourceList)),
},
openInterest: new BN(1_000_000_000), // 1,000 units at 6 decimals; ignored for a `vault` market
reward: new BN(10_000_000), // 10 units, escrowed now
volumeSpike: false,
priceFlip: false,
})
| Manifest field | Values |
|---|---|
answer |
{ categorical: { count } } or { scalar: { decimals } } |
marketClose, resolutionTime |
Unix seconds |
primarySources, fallbackSources |
How many sources of each your list names |
sourceKind |
hardData, officialAnnouncement, mediaConsensus, subjective |
edgeCases |
Bitset of cases your rules cover: postponed 1, cancelled 2, source down 4, tie 8, partial 16 |
tieBreak |
none, primarySourceWins, earliestPublished, unresolvable |
textHash, sourcesHash |
sha256 of the rules text and of the source list |
The oracle scores the manifest and writes the terms onto the request: tier, bonds, window, premium and panel size. Better rules cost less. Naming sources and a tie-break and covering every edge case lower the tier, which shrinks the bonds and shortens the window. Read terms from the new request to show your users the cost.
A question nobody answers within the maximum open time (14 days by default) resolves Unresolvable, and your market refunds. To draw a tag's subsidy, pass the tag as a third argument: requestResolution(market, args, tag).
The question text. The chain stores only the hash of your rules text, not a title or outcome names. questionText adds a header under the same hash:
Question: Will the Fed cut rates at the December 2026 meeting?
Category: Economics
Outcomes: Yes | No
---
Resolves YES if the FOMC statement lowers the target range. ...
The app then shows your title and category and labels the answer buttons. Name every outcome or none: a list that does not match the outcome count is ignored. readQuestionText reads it back. Publish the rules text and source list to the evidence store so curators can read them.
Addresses exist before accounts. A venue market's oracle address is pda.oracle.market(venue, marketId) and its first question is pda.oracle.request(market, 0n). You can store the request address in your own market record before you open the question; the indexer answers 404 for it until then, and answers from the receipt after the account is closed. client.nextRequest(market) gives the next one.
Read the outcome
Read the Request account with client.fetchRequest(request), or declare it as Account<'info, truth_oracle::state::Request> in your program. The answer is final when status is Resolved or Closed and outcome is set:
outcome |
Do |
|---|---|
Index(i) or Scalar(v) |
Settle your market on that answer |
Unresolvable |
Refund at par. The reward goes back to whoever paid it |
TooEarly is never final: it reopens the request and the status goes back to Requested.
Off chain, GET /requests/<pubkey> on the indexer returns the same answer, with the receipt's cause code: see the request summary.
Two limits today. Disputes are categorical: the court votes on outcome slots, so a disputed scalar question ends Unresolvable. And there is one collateral: bonds, rewards, premiums and a vault account are all in the protocol's collateral mint (USDC on mainnet), not your own token.
The receipt
Every path that makes an answer final emits a ResolutionReceipt event: the outcome, a cause code, the tier and terms, the committed judge, the evidence hash, whether anyone disputed and whether the answer changed, and the hashes of your rules and sources. receiptFromRequest(request, account) in the SDK rebuilds it from the account. Venues on other chains get the same receipt: see The EVM spoke and Receipt wire format.
| Cause | Meaning | Cause | Meaning |
|---|---|---|---|
| 0 | None stated | 5 | Criteria ambiguous |
| 1 | Event cancelled | 6 | Nobody answered in time |
| 2 | Participant withdrew | 7 | The court ruled Unresolvable |
| 3 | Sources contradict | 8 | Corrected |
| 4 | Not yet observable |
causeName(code) gives the label. Causes 1 to 5 are stated by whoever answered Unresolvable.
Corrections. For 7 days after an answer is final (the default), a correction petition can overturn it. That emits Corrected with the old and new outcomes, and your venue can claim compensation from the insurance fund with client.payCompensation(request, loss), capped at a share of the fund and at the question's open interest. It is signed by one of your venue's own keys (see Your venue account), and a question that reported no open interest has nothing to claim.
Settling and closing
These are permissionless, and the crank that runs with the deployment does them. Call them yourself to control the timing.
| Call | When |
|---|---|
finalizeUndisputed(request, attesters) |
The window closed with no challenge |
openRound(request) |
After a dispute: seats the first panel |
expire(request) |
Nobody answered in time |
settle(request, attesters) |
Resolved: pays bonds, reward and fees, and returns the vault rent |
Pass await client.listAttestations(request) as attesters: that is how co-signers get paid. After settlement, one of your keys can reclaim the request's rent with client.closeRequest(request) once jurors have had time to claim. finalizeUndisputedIx, openRoundIx and settleIx return the bare instruction instead.
Webhooks and the HTTP API
The indexer remembers what happened on chain and tells you. Your payouts never depend on it: the SDK reads state from the chain. Responses are JSON. Every call below needs an API key, except GET /health and the webhook routes that take their own token.
| Host | Cluster | Venue keys from | Curator keys from |
|---|---|---|---|
https://dev-api.axmc.xyz |
Devnet, now and after launch | dev-app.axmc.xyz/ask/creator#api | dev-app.axmc.xyz/earn/keys |
https://api.axmc.xyz |
Devnet until launch, then mainnet | app.axmc.xyz/ask/creator#api | app.axmc.xyz/earn/keys |
Build against dev-api while you test. Until launch both names reach the same indexer, so a key from either app works on both. api moves to mainnet on launch day, and devnet keys and request addresses mean nothing there. The examples below use api; swap the host to match your cluster.
Webhooks
curl -sX POST https://api.axmc.xyz/webhooks \
-H 'authorization: Bearer axmc_...' \
-H 'content-type: application/json' \
-d '{"url":"https://you.example/axmc-hook","events":["Resolved","Settled"]}'
# -> 201 {"id":"a1b2c3d4e5f60718","url":"...","events":["Resolved","Settled"],"token":"whk_..."}
url must resolve to a public address. events is optional and takes any event name the programs emit; the default is Resolved, ReOpened and Settled. Add ResolutionReceipt for the full record, or Corrected.
Keep the token. It is shown once and is the only way to read or remove your hook. No route lists hooks, because a webhook URL is often a credential.
curl -s https://api.axmc.xyz/webhooks/a1b2c3d4e5f60718 -H 'authorization: Bearer whk_...'
curl -sX POST https://api.axmc.xyz/webhooks/a1b2c3d4e5f60718/remove -H 'authorization: Bearer whk_...'
Each delivery is one row of the event log, as a JSON POST:
{
"signature": "<transaction signature>",
"slot": 312345678,
"blockTime": 1792345678,
"program": "truth_oracle",
"name": "Resolved",
"request": "<request pubkey>",
"data": { "request": "<request pubkey>", "outcome": { "Index": { "0": 1 } }, "disputed": false, "overturned": false }
}
data is the event as the program emits it, which is not the shape of the request summary: field names are snake_case (cause_code, finalized_at), enum variants are capitalised, and a variant's fields are keyed by position, so { "Index": { "0": 1 } } is outcome 1, { "Scalar": { "0": "42" } } a scalar and { "Unresolvable": {} } no answer. Keys are base58, amounts and timestamps are strings, and 32-byte hashes are hex.
Delivery is best effort:
- Not signed. Anyone who learns your URL can post to it, so treat a delivery as a doorbell and confirm with
fetchRequestorGET /requests/<pubkey>before you pay out. - Two attempts with a 5-second timeout each, back to back, with no backoff. A delivery that fails both is dropped, not queued.
- Any 2xx counts as received.
So a webhook should never be your only path to an answer. Poll GET /events?since=<seq> from the last seq you processed and you miss nothing, even across a restart on either side. The app's Webhooks page registers a hook for you.
Endpoints
| Route | Returns |
|---|---|
GET /requests/<pubkey> |
The request summary and its events. 404 until the question is opened |
GET /requests |
Every request, summarised |
GET /events?request=&name=&key=&limit=&since= |
The most recent events, in order, filtered by request, event name or any account they mention. limit up to 1000 (default 200). Each row has a seq; since=<seq> reads forwards without missing or repeating an event |
GET /watermark |
{ seq }, the log position, for cheap polling |
GET /ledger?keys=<pubkey>,… |
Earned, slashed and owed totals per account, up to 100 keys |
POST /evidence |
Stores the raw body (up to 1 MB) and returns { hash, bytes } |
GET /evidence/<hash> |
The stored bytes; ?as=text for UTF-8 |
POST /questions |
Lists the market if new and opens the question, signed by the posting key. Post questions by API |
GET /poster |
{ enabled, key, lamports }: the posting key a venue adds to its keys. No API key needed |
GET /faucet, POST /faucet |
Devnet only: the test-money drip, and { "to": "<wallet or vault>" } to send it. No API key needed; once every 12 hours per address |
POST /webhooks, GET /webhooks/<id>, POST /webhooks/<id>/remove |
As above |
GET /health |
Service status |
The request summary
GET /requests/<pubkey>, and each row of GET /requests:
| Field | Type | Meaning |
|---|---|---|
request, market |
base58 | The request and its oracle market |
index |
number | The question's position in its market |
status |
string | requested, proposed, disputed, resolved or closed |
tier |
string | T0 to T4 |
proposerBond, disputerBond, reward, attesterStake |
string | Base units of the collateral mint |
proposer, disputer |
base58 | The default key (111…1) until someone takes the role |
windowEndsAt |
number or null | When the challenge window closes, unix seconds |
attestations, attestSeats |
number | Co-signers so far, and the seats the request opened with |
outcome |
object, string or null | { "index": n }, { "scalar": "s" }, "tooEarly" or "unresolvable"; null with no answer |
outcomeText |
string or null | The outcome in words (Outcome 1); use your own names for it |
cause |
number | The receipt's cause code. Zero until the answer is final |
statedCause |
number or null | What the proposer stated with an Unresolvable answer. It reads 0 on an expiry or a court ruling, so settle on cause |
proposalKind |
string | open or judge |
resolvedAt |
number or null | When the answer went final, unix seconds |
accountClosed |
boolean | The venue has reclaimed the account's rent. See below |
events |
array | Every logged event for the request, oldest first. Only on GET /requests/<pubkey> |
After close_request. Once a venue reclaims the rent, the account is gone from the chain, and fetchRequest returns null for good. The indexer still answers from the ResolutionReceipt it logged: status is closed, accountClosed is true, and the answer, cause, tier, proposerBond, proposalKind and resolvedAt come from the receipt. The fields only the account held are null. GET /requests lists open accounts only. A request the indexer never saw a receipt for still answers 404.
API keys
The API needs a key. For a venue, the usual way is the email account: signed in on the app, Venue → API mints keys that belong to the venue registered when you were approved (GET/POST /accounts/keys, POST /accounts/keys/:id/revoke, with Authorization: Session <token>). Otherwise a key needs a wallet with an on-chain role. A wallet qualifies if it is:
- a venue's authority, or one of its keys;
- an allowlisted creator, for a market let in before venues existed;
- a curator with stake, or an operator whose agents have stake;
- the oracle admin.
Any other wallet is refused with a 403.
Create keys on Venue → API (/ask/creator#api) as a venue, or API keys (/earn/keys) as a curator, on the app for your cluster (the hosts): signed in with your email as a venue, or by connecting the wallet and signing a message. Each is shown once; a venue or a wallet holds up to ten live keys. Use one key per service, so revoking one stops only that service. Send it on every call:
Authorization: Bearer axmc_...
A call without a key, or with an unknown or revoked one, gets a 401. The webhook read and remove routes take their own token in this header instead.
From a backend. A server that holds the wallet can mint a key without the app. Sign the UTF-8 message axmc operator <unix seconds> POST /keys with the wallet's ed25519 key and send:
curl -sX POST https://api.axmc.xyz/keys \
-H 'authorization: Operator <wallet base58>:<unix seconds>:<signature base64>' \
-H 'content-type: application/json' \
-d '{"label":"resolution-engine"}'
# -> 201 {"key":{"id":"3f9a0c1b2d4e","label":"resolution-engine","hint":"axmc_AbCd…",...},"secret":"axmc_..."}
The timestamp must be within five minutes of the server's clock, and each signature works once. GET /keys with a header signed over axmc operator <unix seconds> lists the wallet's keys, never their secrets. POST /keys/<id>/revoke with a header signed over axmc operator <unix seconds> POST /keys/<id>/revoke revokes one.
Keys do not expire. Standing is checked when a key is minted, not afterwards: a key keeps working after its wallet loses the role (a key rotated off the venue, a curator who unstaked) until someone revokes it. When you rotate a venue key on chain, revoke the API keys it minted.
Rate limits. Reads (/requests, /events, /ledger, /watermark, /evidence/<hash>) have no per-key limit today; poll /watermark and read only when seq moves. Limited routes answer 429:
| Route | Limit |
|---|---|
POST /questions |
Bursts of 10, then 1 a second, per key |
POST /evidence |
With a key: bursts of 200, then 1 a second. Without: bursts of 20, then 1 every 10 seconds |
/keys and its sub-routes |
Bursts of 10, then about 10 a minute, per address |
Evidence
The evidence store keeps bytes under their sha256, which is what a manifest's textHash and sourcesHash, a juror's rationale and a judge's commitment commit to. Anyone can check that what they read is what was committed.
curl -sX POST https://api.axmc.xyz/evidence -H 'authorization: Bearer axmc_...' --data-binary @rules.txt # -> {"hash":"<sha256 hex>","bytes":1234}
curl -s 'https://api.axmc.xyz/evidence/<sha256 hex>?as=text' -H 'authorization: Bearer axmc_...'
Uploads are rate-limited. When the store is full it evicts the oldest bytes nothing on chain refers to; anything a request or receipt names is kept.
Source proofs as evidence
A judged answer records one hash, evidence_hash: the sha256 of what its proposer published. The program never opens it; it is copied into the receipt. An open answer records zero, so a proof that must be on the record goes in through propose_judged. An open proposer or a disputer publishes the same document and cites its hash in their reasons, where the panel reads it.
Without proofs, the hash is over the rationale text. With proofs, it is over one JSON document:
{"format":"axmc.evidence/1","rationale":"ESPN's final reads KC 27, BUF 24.","evidence":[{"kind":"zktls","provider":"reclaim","source":"https://site.api.espn.com/apis/site/v2/sports/football/nfl/summary?event=401671789","claim":"final: KC 27, BUF 24","proof":{}}]}
Every item has a kind and an http(s) source, which should be on the market's source list. An agent's judge returns the same list and the agent publishes it (Connect your agent).
kind |
How to make it | Also required | Proves | Does not prove |
|---|---|---|---|---|
zktls |
Fetch the source through Reclaim's zkFetch (@reclaimprotocol/zk-fetch) or Opacity; put the proof in proof |
provider (reclaim, opacity), proof |
The source's server sent bytes containing claim at that time |
That the page was right or final |
official |
A TxODDS scores packet with its anchoring Solana transaction, or an AP race call with its API record | provider (txodds, ap), packet or anchor |
The provider published that value at that time | That the provider is a permitted source, or later revisions |
snapshot |
Archive the page (https://web.archive.org/save/<url>, archive.today) and hash the archived bytes |
archive (the archived URL), sha256 |
The archived copy existed and has not changed | That everyone saw the same page. The weakest kind |
A proof settles what a source said, not how to read your rule, so an answer with proofs is still bonded and can be challenged. It does not shorten the window; co-signatures do.
Committing a judge
A market can commit to an AI judge once, before its first question: a hash over the model and version, the exact prompt, the permitted sources and the retrieval cut-off, plus the key the judge signs with. Traders can inspect it before they trade, and nobody can change it.
import { textHash } from '@axmc/sdk'
await client.setJudgeCommitment(market, textHash(spec), judgeKey) // signed by one of your venue's keys
await client.fetchMarketJudge(market) // the committed key, or null
The judge answers with propose_judged (client.proposeJudged(request, outcome, evidenceHash)): a normal bonded answer that also records the evidence hash. Only the committed key may post one. Anyone else still answers with propose.
Publish the spec's exact bytes to the evidence store so anyone can check the hash, and write it so a stranger can run it. Venue → AI judge in the app hashes and publishes it for you. The agent hands the spec to its judge as judge.spec (Connect your agent).
Checking a judge
Anyone can replay a committed judge against the answer it posted:
RPC_URL=<endpoint> INDEXER_URL=https://api.axmc.xyz AXMC_API_KEY=axmc_... \
pnpm judge:replay <request address> --judge 'node my-copy-of-the-judge.mjs' --runs 5
It cannot sign, so it never sends a transaction. The judge command uses the agent's interface: a brief as JSON on stdin, one verdict on stdout. Without --judge it uses AGENT_JUDGE.
| Step | Checks |
|---|---|
| Spec | The published spec's sha256 against the judge commitment (--spec <file> to supply it). On a mismatch the judge is not run |
| Evidence | The published evidence's sha256 against the answer's evidence_hash (--evidence <file> to supply it) |
| Replay | Runs the judge on the brief the agent would have built when it answered, and compares the outcome across runs |
It exits 0 on a match, 1 on a mismatch and 2 when it can conclude nothing; --json prints the report. A match says the committed configuration, run today, reaches the posted outcome. It does not prove the judge ran it: the chain checks the judge's key, not its computation.
Attestations shorten the wait
Curators who share a tag with your market and have earned reputation on it can co-sign an answer with a small stake. Each attestation cuts what is left of the window by a quarter, down to a quarter of the tier's window and never below 30 minutes. A request offers three seats, fixed when it opens, and an attestation that could not shorten the window is refused. Each filled seat is paid a third of 20% of the reward when the answer stands, and an empty seat's share goes to the insurance fund. Attesters lose their stake when the answer is overturned. For a countdown, re-read proposal.windowEndsAt after each Attested event. The Specification has the times per tier.
CPI from your own Solana program
A market that is a Solana program can settle on chain with no server in the path, in three calls. The reference is the example_market program in the repository, about 300 lines with an end-to-end test.
Your program is its own creator: it signs for its market PDA and pays from a token account that PDA owns, with no venue. While the guarded launch is on, it needs an AllowedCreator approval for the PDA, which only governance can create, so say so when you apply. Add the oracle as a dependency with features = ["cpi"].
1. Register the market. The oracle market is ["market", <your market PDA>, <market_id as u64 LE>].
truth_oracle::cpi::register_market(
CpiContext::new_with_signer(
ctx.accounts.truth_oracle.key(),
truth_oracle::cpi::accounts::RegisterMarket {
payer: ctx.accounts.authority.to_account_info(), // any wallet, pays rent
creator: ctx.accounts.market.to_account_info(), // your market PDA, signing
config: ctx.accounts.oracle_config.to_account_info(),
market: ctx.accounts.oracle_market.to_account_info(),
allowed: None, // the AllowedCreator account while the allowlist is on
oi_vault: Some(ctx.accounts.escrow.to_account_info()),
system_program: ctx.accounts.system_program.to_account_info(),
},
&[seeds],
),
RegisterMarketArgs { market_id, policy, tags, council, oi_source: OpenInterestSource::Vault },
)
2. Open a question. Call truth_oracle::cpi::request_resolution the same way: your PDA as authority, your escrow as creator_token, venue and venue_vault as None, the oi_vault you registered, and the RequestArgs from Open a question. The request is ["request", <oracle market>, <request_count as u64 LE>]. Check that your market's own authority signs, since the question is paid from your escrow. To be priced on the verifiers watching your tags rather than as if nobody were, append two read-only accounts after the instruction's own: the court config the oracle's GlobalConfig.court names, and the court pool (["pool", <tag as u16 LE>] under the court program) of whichever of your tags has the most members. Leave them off and the question scores as unwatched, which is the dearer tier. client.requestResolution adds them for you.
3. Read the answer.
#[derive(Accounts)]
pub struct SettleMarket<'info> {
#[account(mut, has_one = request)]
pub market: Box<Account<'info, MarketState>>,
pub request: Box<Account<'info, truth_oracle::state::Request>>, // Anchor checks the oracle owns it
}
// in the handler
require!(matches!(request.status, Status::Resolved | Status::Closed), MarketError::NotResolved);
let outcome = request.outcome.ok_or(MarketError::NotResolved)?;
When the oracle's settle runs, the vault rent goes to your PDA. An Unresolvable reward (a request nobody answered, or one the court ruled Unresolvable) goes back to the creator_token passed, which must be a token account of the collateral mint that your PDA owns. client.settle passes the oi_vault you registered when your PDA owns it, which for a vault market like example_market is your escrow, so keepers settle your requests with no extra step. For a reported market, or an escrow that is not your oi_vault, pass the account yourself: client.settle(request, attesters, escrow) or settleIx(request, caller, escrow). Do not create an associated token account for your PDA unless your program can spend from it.
close_request and pay_compensation need the market's creator to sign, so a program market must expose an instruction for each. example_market does not. A venue's keys sign them directly.
Paying out from a program that does not read the oracle: the bridge
If your market program settles through its own resolve instruction, signed by a resolver key, the bridge carries the oracle's answer into it. Run it next to that key:
BRIDGE_LAYOUT=<your layout id> RPC_URL=<endpoint> BRIDGE_WALLET=<resolver keypair> pnpm bridge
Every pass it reads your venue's oracle markets, finds resolved requests, reads your market with the same id and, when your market's status and outcome enum fit, builds your resolve instruction from the layout and signs it. Your resolver key is one of your venue's keys, so the bridge finds the venue itself.
| Setting | Default | Does |
|---|---|---|
BRIDGE_LAYOUT |
Required | Your layout id: one the bridge's build knows, with a resolve block |
RPC_URL |
Localnet | The Solana endpoint |
BRIDGE_WALLET |
ANCHOR_WALLET, then ~/.config/solana/id.json |
The resolver keypair your program accepts |
BRIDGE_PROGRAM_ID |
The layout's first program id | Your program, if it is not the layout's known deployment |
BRIDGE_STATE |
.localnet/bridge-<layout>.json |
Requests already written, so a restart never signs twice |
BRIDGE_INTERVAL_SECS |
30 |
Time between passes |
ALERT_WEBHOOK |
None | Receives one JSON line per answer left to a person |
BRIDGE_CREATOR |
Looked up | Whose markets to resolve; only for a market a wallet registered before venues existed |
pnpm bridge --once runs one pass and exits 0 when nothing is pending, for a cron.
The bridge never guesses. It logs these, and posts them to ALERT_WEBHOOK, for a person:
| Left to you | Because |
|---|---|
| Your market is in your own dispute flow, or in a status your resolve instruction does not accept | Two courts on one question is one too many |
The answer is Unresolvable |
Your refund or cancel path is yours to run |
| The answer is a scalar, or an index your enum does not have | Nothing fits your outcome enum |
| Your market could not be read | Wrong id, wrong program, or not created yet |
Create court-routed markets with a zero dispute window if your program allows it. The court is the dispute window, so a second one only delays payout.
The resolve block names your instruction, its arguments and its accounts by role; the bridge fills in the addresses. GoMarket's CLOB is:
resolve: {
instruction: 'resolve_market', // discriminator: sha256("global:resolve_market")[..8]
args: ['marketId', 'outcome'], // u64, then your outcome enum's u8 index
accounts: [{ role: 'signer' }, { role: 'config', writable: true }, { role: 'market' }, { role: 'vault' }, { role: 'program', id: 'token' }],
from: ['Closed'], // statuses the instruction accepts
// outcomeMap: { 0: 1, 1: 0 }, // if your manifest lists outcomes in another order
}
Roles are signer, config, market, vault, pda with seeds ({ literal }, { marketId: true }, { market: true }) and program (token, system, associatedToken). List outcomes in the manifest in your enum's order, so Index(0) is your variant 0, or map them with outcomeMap.
What you pay
| How much | Where it goes | |
|---|---|---|
| Registering a market | Rent for one account | Returned if the record is closed |
| Reward, per question | You set it | 75% to the curator whose answer stands, 5% to the treasury, and 20% across three co-signer seats; an empty seat's share goes to the insurance fund |
| Premium, per question | 5 to 50 bps of open interest by tier, up to 4x while the insurance fund is below target. None on a question that reports no open interest | The insurance fund |
| Bonds | Posted by curators, never by you | The side that was right |
| Request rent | Paid by whoever opens it: axmc's posting key for a question posted by API | The vault's rent returns to the market at settlement; the request's to the key that closes it |
No protocol token, no staking, no subscription. The full numbers are in Economics and the Specification.
Source: docs/INTEGRATION.md in the axmc repo. Edit the file; the next deploy rebuilds this page.