Onchain Tokenomist has a native executor role—a vault-scoped identity specifically designed to be fulfilled by an AI agent, distinct from the vault's creator and administrators. This API is the interface for that role, enabling AI agents to inspect protocol state and construct correctly encoded, protocol-specific transactions, including proposals, token pass mints, and Merkle-proof operations, for the vaults they are authorized to serve. Access is gated by x402 nanopayments through Circle Gateway on Arc.
This API does not hold custody of any private key and never signs or broadcasts transactions on your behalf. Every write route returns unsigned calldata (to + data); the caller signs and submits it with their own wallet. msg.sender on-chain is always the caller's own address — never this API's.
- Call any endpoint below.
- If unpaid, you get
402 Payment Requiredwith anacceptsarray describing the payment terms (amount in USDC atomic units, network,payToaddress). - Pay via Circle Gateway (batched, gas-free USDC nanopayments) and retry with a
PAYMENT-SIGNATUREheader containing your signed authorization. - On success, you get the actual response — for write routes, unsigned transaction calldata to sign and broadcast yourself; for read routes, live on-chain data.
The easiest way to call this API is via Circle's @circle-fin/x402-batching GatewayClient, which handles the full 402 → sign → settle → retry cycle automatically:
import { GatewayClient } from '@circle-fin/x402-batching/client';
const gateway = new GatewayClient({
chain: 'arcTestnet',
privateKey: YOUR_PRIVATE_KEY,
});
const result = await gateway.pay('https://<your-deployment>.workers.dev/v1/vaults/3/summary', {
method: 'GET',
});
console.log(result.data);https://executor.perfectinformationlabs.com
Machine-readable OpenAPI specification available at:
GET /openapi.yaml
This page is available as rendered HTML (default) or raw Markdown, depending on your Accept header:
GET /docs Accept: text/markdown
Returns the raw Markdown source instead of the rendered HTML page — useful if your agent's tooling prefers parsing plain Markdown over HTML.
GET /health
Free, unauthenticated, no payment required. Returns { "status": "ok", "service": "tokenomist-executor" } if the Worker is running. Use this for uptime monitoring — it never touches the chain or payment system.
Example:
GET https://executor.perfectinformationlabs.com/health
{ "status": "ok", "service": "tokenomist-executor" }Before calling any paid endpoint, an agent needs:
- An EVM wallet with USDC on Arc Testnet. Get testnet USDC from faucet.circle.com.
- A funded Gateway balance. Payment settles from your Gateway balance, not your wallet balance directly — you must deposit once before your first paid call:
const gateway = new GatewayClient({ chain: 'arcTestnet', privateKey: YOUR_KEY });
await gateway.deposit('5'); // deposits 5 USDC into your Gateway balance, one-timeIf your Gateway balance is 0, every pay() call will fail with PaymentFailed, even if your wallet holds plenty of USDC. Check anytime with await gateway.getBalances().
3. Your wallet must already be configured as creator or executor for the specific vault you want to act on. This is set once by the vault deployer at createVault time — it's not something this API or your agent can set. Every write route (propose, mint, mint-direct) checks msg.sender against the vault's stored creator/executor addresses on-chain; if your signing wallet isn't one of those two, the transaction will revert with NotAuthorized after you've already paid and signed. Confirm your address matches via GET /v1/vaults/:vaultId/summary (Route 5) before attempting any write.
POST /v1/vaults/:vaultId/proposals
Price: $0.01
Builds calldata for proposeMintCategory. Computes a merkle root per tier from a plain address list — you don't need to build the tree yourself. tiers is an array — send one tier or several; a proposal isn't bound to a single-tier or multi-tier shape, it's just whatever tiers you include, all created together under one category in a single transaction.
Request body (single tier):
{
"category": "MockTokenomics",
"tiers": [
{ "tier": "Staff", "addresses": ["0x...", "0x..."], "supplyCount": 10 }
]
}Response (single tier):
{
"vaultId": "3",
"category": "MockTokenomics",
"to": "0x...",
"data": "0x...",
"tiers": [
{ "tier": "Staff", "merkleRoot": "0x...", "supplyCount": 10, "addressCount": 10 }
]
}Request body (multiple tiers, same call):
{
"category": "Ecosystem",
"tiers": [
{ "tier": "Builder", "addresses": ["0x...", "0x...", "0x...", "0x...", "0x..."], "supplyCount": 5 },
{ "tier": "Partner", "addresses": ["0x...", "0x...", "0x...", "0x...", "0x..."], "supplyCount": 5 },
{ "tier": "Contributor", "addresses": ["0x...", "0x...", "0x...", "0x...", "0x..."], "supplyCount": 5 }
]
}Response (multiple tiers) — shape matches whatever was sent, one entry per tier included:
{
"vaultId": "3",
"category": "Ecosystem",
"to": "0x...",
"data": "0x...",
"tiers": [
{ "tier": "Builder", "merkleRoot": "0x...", "supplyCount": 5, "addressCount": 5 },
{ "tier": "Partner", "merkleRoot": "0x...", "supplyCount": 5, "addressCount": 5 },
{ "tier": "Contributor", "merkleRoot": "0x...", "supplyCount": 5, "addressCount": 5 }
]
}Important: category/tier strings are case-sensitive on-chain. Use the exact casing configured for the vault (e.g. "MockTokenomics", not "MOCKTOKENOMICS") — check with Route 6/7 if unsure.
POST /v1/proposals/:proposalId/mint
Price: $0.01
Builds calldata for mintPasses. Requires the proposal to already be approved on-chain by the vault's admins.
Request body:
{
"tier": "Staff",
"addresses": ["0x...", "0x..."],
"recipients": ["0x...", "0x..."]
}addresses: the full original allowlist used when the proposal was created (needed to rebuild the identical merkle tree).recipients: which addresses to mint in this call — can be a subset. Minting is resumable: you can mint part of the list now and the rest later under the same proposal, as long as the total across all calls doesn't exceed the tier's supply.
Response:
{
"proposalId": "5",
"tier": "Staff",
"transactions": [
{ "to": "0x...", "data": "0x...", "chunkIndex": 0, "recipientCount": 60 },
{ "to": "0x...", "data": "0x...", "chunkIndex": 1, "recipientCount": 20 }
],
"totalChunks": 2,
"totalRecipients": 80
}Batches of more than 60 recipients are automatically split into multiple transactions (Arc's per-tx gas ceiling). Sign and broadcast each in order — they're sequential and rely on correct nonce ordering.
POST /v1/vaults/:vaultId/mint-direct
Price: $0.01
Builds calldata for mintDirect. Only valid for vaults with no admins configured — use Route 1/2 instead if the vault has admins.
Request body:
{
"category": "Community",
"tier": "Standard",
"recipients": ["0x...", "0x..."]
}Response: same shape as Route 2 (array of { to, data, chunkIndex, recipientCount }, batched at 60).
GET /v1/accounts/:account/proposals
Price: $0.0001
Returns every proposal tied to any vault where :account is the creator or executor — not just proposals that address personally submitted.
Response:
{
"account": "0x...",
"proposals": [
{
"proposalId": "5",
"vaultId": "3",
"category": "MockTokenomics",
"proposer": "0x...",
"admin1": "0x...",
"admin2": "0x...",
"admin1Approved": true,
"admin2Approved": true,
"rejected": false,
"expired": false,
"executed": true,
"proposedAt": "1784322186",
"deadline": "1784840586"
}
]
}Returns an empty array (not an error) if the account isn't a creator/executor on any vault. expired reflects live deadline status even if not yet finalized on-chain. Fully-approved proposals never expire, regardless of elapsed time.
GET /v1/vaults/:vaultId/summary
Price: $0.0005
Returns aggregated vault-level data: roles, deposits, allocation totals, finalization status.
Response:
{
"vaultId": "3",
"tokenAddress": "0x...",
"creator": "0x...",
"admin1": "0x...",
"admin2": "0x...",
"executor": "0x...",
"totalDeposited": "10000000000000000000000000000",
"totalAllocated": "675000000000000000000000000",
"totalClaimed": "0",
"createdAt": "1782653758",
"startTime": "1783518953",
"finalized": false,
"categoryCount": "10",
"totalPassesMinted": "25",
"totalCompletedClaims": "0",
"totalActivePasses": "25"
}All numeric fields are raw on-chain values (wei-scale where applicable) as strings, since they can exceed JS's safe integer range.
GET /v1/vaults/:vaultId/categories/:category/tiers
Price: $0.0001
Returns supply/allocation detail for every tier in a category — useful to check before calling Routes 1-3.
Response:
{
"vaultId": "3",
"category": "MockTokenomics",
"tiers": [
{
"tier": "Core",
"allocationPerPass": "50000000000000000000000000",
"maxSupply": "14",
"mintedCount": "10",
"remainingSupply": "4",
"totalAllocated": "500000000000000000000000000",
"passHolderCount": "10"
}
]
}GET /v1/vaults/:vaultId/categories
Price: $0.0001
Returns every category configured for a vault, decoded to plain strings. Use this as the starting point for discovering a vault's structure before calling any other route.
Response:
{
"vaultId": "3",
"categories": [
"Liquidity",
"Reserve",
"MockTokenomics",
"Advisors",
"SeedInvestors",
"PublicSale",
"Ecosystem",
"Marketing",
"Airdrop",
"CommunityRewards"
]
}This was the single most likely way a write call silently failed on-chain during development — now fully avoidable.
category and tier values are encoded exactly as you send them (UTF-8 bytes, no normalization). "MockTokenomics" and "MOCKTOKENOMICS" produce completely different on-chain keys. If the casing doesn't exactly match what the vault deployer configured, the write transaction will revert — ExceedsMaxSupply or InvalidTier are the typical symptoms, since the lookup silently resolves to an empty/zero config instead of erring on the mismatch itself.
This API does not validate category/tier strings against the vault's actual configuration — a mismatched string still returns valid-looking calldata; the mismatch only surfaces after you sign, broadcast, and pay gas.
Correct discovery flow before ever calling Routes 1-3 on a vault for the first time:
GET /v1/vaults/:vaultId/categories(Route 7) — get the exact category strings for this vault.GET /v1/vaults/:vaultId/categories/:category/tiers(Route 6), using an exact string from step 1 — get the exact tier strings and their remaining supply.- Use the exact strings returned in steps 1-2, verbatim, in your Route 1-3 request bodies. Don't retype or reformat them.
If you already have a proposalId (from your own records or from Route 4), you don't need steps 1-2 to mint — getInvolvedProposals already returns the correct category for that proposal directly, since it was stored on-chain at propose time.
Recommended pattern for any agent integrating this API for a new vault:
- Call Route 7 to list categories.
- Call Route 6 per category of interest to get tiers + supply.
- Use those exact strings for any Route 1-3 call.
This walks through the full lifecycle for a vault with admins (team mode).
1. Discover the vault's structure (optional, if you don't already know it):
const categories = await gateway.pay(`${BASE}/v1/vaults/3/categories`, { method: 'GET' });
// → ["Liquidity", "Reserve", "MockTokenomics", ...]
const tiers = await gateway.pay(`${BASE}/v1/vaults/3/categories/MockTokenomics/tiers`, { method: 'GET' });
// → tiers with exact casing + remaining supply2. Propose a mint (requires your wallet to be creator/executor for this vault):
const proposeResult = await gateway.pay(`${BASE}/v1/vaults/3/proposals`, {
method: 'POST',
body: {
category: 'MockTokenomics',
tiers: [{ tier: 'Staff', addresses: [...], supplyCount: 10 }],
},
});
const txHash = await gateway.walletClient.sendTransaction({
to: proposeResult.data.to,
data: proposeResult.data.data,
});
// Wait for this to mine, then read the ProposalCreated event (or query Route 4)
// to get the real proposalId — it is NOT returned by this API directly.3. Wait for admin approval. This happens outside this API entirely — the vault's admins call approveMintProposal themselves (via their own tooling/dashboard). Poll for status:
const proposals = await gateway.pay(`${BASE}/v1/accounts/${gateway.address}/proposals`, { method: 'GET' });
// Find your proposalId in the list, check admin1Approved && admin2Approved4. Mint once approved — send the exact same addresses list used in step 2, so the proof-generation rebuilds an identical merkle tree:
const mintResult = await gateway.pay(`${BASE}/v1/proposals/${proposalId}/mint`, {
method: 'POST',
body: {
tier: 'Staff',
addresses: [...], // same list as step 2 — required to regenerate valid proofs
recipients: [...], // who to mint now — can be a subset; the rest can be minted later
},
});
for (const tx of mintResult.data.transactions) {
await gateway.walletClient.sendTransaction({ to: tx.to, data: tx.data });
}Note: minting is resumable — you can mint part of recipients now and the remainder later under the same proposal, as long as total minted never exceeds the tier's supply.
All errors follow this shape:
{ "error": "ErrorName", "message": "human-readable detail" }| Error | Status | Meaning |
|---|---|---|
InvalidCategoryError |
400 | Category string exceeds 31 bytes |
InvalidTierError |
400 | Tier string exceeds 31 bytes |
InvalidAddressError |
400 | Malformed EVM address |
EmptyAddressListError |
400 | Address list is empty |
EmptyRecipientsError |
400 | Recipients list is empty |
VaultNotFoundError |
404 | Vault doesn't exist |
RateLimited |
429 | Too many requests — see limits below |
InternalError |
500 | Unexpected server error |
Input validation runs before payment — a malformed request is rejected for free, with no charge.
| Route type | Limit |
|---|---|
| Writes (propose, mint, mint-direct) | 20 / minute |
| Reads (involved-proposals) | 60 / minute |
| Tier details / vault summary | 60-120 / minute |
Does: encode correct calldata, compute merkle roots/proofs from plain address lists, batch large recipient lists into ≤60-per-tx chunks, decode on-chain reads into clean JSON.
Doesn't: hold any private key, sign or submit any transaction, validate business logic (supply limits, tier existence, recipient eligibility) — that's the calling agent's responsibility, optionally checked in advance via Routes 4-6.
Arc Testnet — CAIP-2 eip155:5042002. Payment settles via Circle Gateway's testnet facilitator (https://gateway-api-testnet.circle.com).