Documentation
Everything needed to connect an MCP client, call explain_transaction, parse the response, and pay per call, by hand or autonomously.
Introduction
0200project builds machine-native tools for autonomous AI agents: narrowly scoped services with strict JSON contracts, designed to be called and parsed by software without a human in the loop. The first and currently only shipped tool is base-transaction-decoder, launched August 20, 2026. More tools are in development.
Deterministic by design. There is no LLM in the response path. Every response is produced by deterministic onchain decoding: the same input always produces the same output. Nothing is generated, so nothing can hallucinate or drift between calls.
MCP is the interface. Each tool is served over the Model Context Protocol as a streamable HTTP endpoint. Any MCP client can connect; any HTTP client can call it directly with a JSON-RPC request. The registry entry is io.github.0200project/base-transaction-decoder.
For agents / machine-readable. The HTTP contract is published as OpenAPI at api.0200project.com/openapi.json, and a plain-text guide for agents lives at api.0200project.com/llms.txt. The same decode is also available as plain REST, POST /explain with {"tx_hash": "0x…"}, identical output, standard x402 flow.
Quickstart
The server is one streamable-HTTP endpoint: https://api.0200project.com/mcp. Pick a client, every path calls the same tool.
REST
The simplest path, no MCP client, no SDK. POST a transaction hash, get the explanation back as plain JSON:
curl -X POST https://api.0200project.com/explain \
-H 'Content-Type: application/json' \
-d '{"tx_hash":"0x0c84b951051f779903b57af9225ca570c77cd5531195968dd78106a69d6c4d8c"}'
The first calls are free. Past the free tier the endpoint answers with a standard HTTP 402 x402 challenge, pay $0.02 in USDC on Base and retry, or use an x402-capable HTTP client that handles it automatically. For heavy use, the $9 pass covers 10,000 calls over 30 days via the X-BTX-Pass header.
Claude Code
One command adds the server over streamable HTTP:
claude mcp add --transport http base-transaction-decoder https://api.0200project.com/mcp
Then ask Claude about any Base transaction hash, it calls explain_transaction on its own.
Claude Desktop
Add the server under mcpServers in claude_desktop_config.json, then restart Claude Desktop:
{
"mcpServers": {
"base-transaction-decoder": {
"type": "streamable-http",
"url": "https://api.0200project.com/mcp"
}
}
}
The config file lives at:
- macOS —
~/Library/Application Support/Claude/claude_desktop_config.json - Windows —
%APPDATA%\Claude\claude_desktop_config.json
Cursor
Create .cursor/mcp.json in the project root (or ~/.cursor/mcp.json to enable it in every project):
{
"mcpServers": {
"base-transaction-decoder": {
"type": "streamable-http",
"url": "https://api.0200project.com/mcp"
}
}
}
TypeScript
Uses the official @modelcontextprotocol/sdk (npm install @modelcontextprotocol/sdk). One free-tier call:
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const client = new Client({ name: 'my-agent', version: '0.0.1' });
await client.connect(
new StreamableHTTPClientTransport(new URL('https://api.0200project.com/mcp')),
);
const result = await client.callTool({
name: 'explain_transaction',
arguments: { tx_hash: '0x0c84b951051f779903b57af9225ca570c77cd5531195968dd78106a69d6c4d8c' },
});
console.log(result.structuredContent);
await client.close();
Python
Uses the official mcp Python SDK (pip install mcp). One free-tier call:
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
TX = "0x0c84b951051f779903b57af9225ca570c77cd5531195968dd78106a69d6c4d8c"
async def main() -> None:
async with streamablehttp_client("https://api.0200project.com/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool("explain_transaction", {"tx_hash": TX})
print(result.structuredContent)
asyncio.run(main())
curl
No MCP client needed, POST a JSON-RPC tools/call envelope directly:
curl -X POST https://api.0200project.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"explain_transaction","arguments":{"tx_hash":"0x0c84b951051f779903b57af9225ca570c77cd5531195968dd78106a69d6c4d8c"}}}'
The response arrives as a server-sent-events frame, an event: message line followed by a data: line carrying the JSON-RPC result:
event: message
data: {"jsonrpc":"2.0","id":1,"result":{ ... }}
x402 paid client
The autonomous-payment path: the client makes the call, catches the in-band 402 challenge, signs a USDC payment, and retries, no account, no API key. The wallet needs USDC on Base; the exact scheme uses an EIP-3009 authorization, so it needs no ETH for gas. Use a dedicated wallet holding a small balance, never a personal one.
Packages: npm install @modelcontextprotocol/sdk @x402/mcp @x402/evm viem. Excerpted from the runnable test client at scripts/paid-call.ts:
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ExactEvmScheme } from '@x402/evm/exact/client';
import { createx402MCPClient } from '@x402/mcp';
import { privateKeyToAccount } from 'viem/accounts';
const account = privateKeyToAccount(process.env.X402_TEST_PRIVATE_KEY as `0x${string}`);
const client = createx402MCPClient({
name: 'my-paying-agent',
version: '0.0.1',
schemes: [{ network: 'eip155:8453', client: new ExactEvmScheme(account) }],
onPaymentRequested: async ({ paymentRequired }) => {
const a = paymentRequired.accepts[0];
console.log(`402 received: ${a?.amount} units of ${a?.asset} to ${a?.payTo}. Paying...`);
return true; // approve the $0.02 payment
},
});
await client.connect(new StreamableHTTPClientTransport(new URL('https://api.0200project.com/mcp')));
const result = await client.callTool('explain_transaction', {
tx_hash: '0x0c84b951051f779903b57af9225ca570c77cd5531195968dd78106a69d6c4d8c',
});
console.log(`paid=${result.paymentMade} isError=${result.isError ?? false}`);
await client.close();
Whichever path you take, the tool result carries the explanation twice: as structuredContent, and stringified in content[0].text. Parse whichever your client prefers.
The free tier is metered per client, one IPv4 address, or one IPv6 /64. The first 50 calls in any 24 hours are free with no signup, counted per IP address (IPv6 collapses to the /64), machines behind one NAT or a shared CI egress draw from a single counter, which is why the paywall can appear before 50 of your own calls. The window is rolling: a shared address that runs out recovers the next day rather than staying walled. After that, each call is $0.02 via x402.
Installation
Hosted endpoint
The hosted server at https://api.0200project.com/mcp works with any MCP client using the config in the quickstart — nothing to install. It is also listed in the MCP registry as io.github.0200project/base-transaction-decoder.
Self-hosting
The server is open source under an MIT-style license and runs anywhere Docker runs. It is a single-writer service: the free-tier counters, the passes and the event log are files on one machine’s disk, so it runs as one instance rather than a scaled pool. That is a deliberate constraint rather than a stage we have not outgrown, a settled engagement cannot be charged a second time, because the guard that refuses a repeat reads state only one process writes. Two instances would split that state: the free allowance and the rate limit would both double, and a pass minted on one would be unknown to the other.
git clone https://github.com/0200project/base-tx-explain
cd base-transaction-decoder
npm install
cp .env.example .env
npm run dev
Defaults are free mode with public Base RPCs. Key environment variables:
| Variable | Purpose |
|---|---|
PAYMENT_MODE | none or x402. Default is free mode. |
X402_PAY_TO | Address that receives x402 payments. |
X402_PRICE_USD | Price per paid call, in USD. |
X402_FACILITATOR_URL | x402 facilitator used to verify and settle payments. |
FREE_CALLS_PER_IP | Free calls granted per client before payment is required. |
BASE_RPC_URLS | Base RPC endpoints. Defaults to public Base RPCs. |
Authentication
There is none, by design. No accounts, no API keys, no OAuth, nothing to provision, rotate, leak, or revoke.
Identity is replaced by payment. A free tier is metered per client, one IPv4 address, or one IPv6 /64, and beyond it each call is paid individually over x402. The server never needs to know who you are, only that the call is paid for.
base-transaction-decoder
One tool: explain_transaction(tx_hash) returns a strict JSON explanation of any Base mainnet transaction (chain id 8453), a plain-English summary, a classified action type, every asset that moved, labeled counterparties, evidence-backed risk flags, and the total gas cost in USD.
Input contract. tx_hash is 0x followed by 64 hex characters. Base mainnet only.
Determinism. The pipeline is raw transaction + receipt from Base RPC, through roughly 40 builtin event decoders (ERC-20/721/1155, Uniswap V2/V3/V4, Aerodrome/Solidly, Seaport, Aave V3, Compound V3, OP-stack bridges, ERC-4337 EntryPoint, EAS, Basenames, WETH, LP position managers), into a deterministic rule-ordered classification. Labels come from a verified table of major Base contracts; app-specific events are named via verified ABIs on Sourcify. No model touches the response, the same hash always yields the same bytes.
Before launch, 100 random recent live Base transactions were decoded: 95 produced a specific action type, the rest degraded to an honest partial summary, and there were zero crashes.
Known limits
- Base mainnet only.
- Internal ETH transfers (contract-to-contract value moves) are not visible without trace APIs; WETH events cover the common cases.
- When something cannot be decoded, the output says so instead of guessing.
- The absence of a
known_drainerflag is not a safety guarantee. - Not financial advice, the tool reports what a transaction did, not whether it was a good idea.
Request format
The endpoint accepts POST only — GET and DELETE return 405. Two headers are required:
Content-Type: application/jsonAccept: application/json, text/event-stream
The body is a standard JSON-RPC 2.0 tools/call:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "explain_transaction",
"arguments": {
"tx_hash": "0x0c84b951051f779903b57af9225ca570c77cd5531195968dd78106a69d6c4d8c"
}
}
}
Response schema
Every successful call returns one JSON object with these fields:
| Field | Type | Description |
|---|---|---|
summary | string | 1–3 sentence plain-English description of what the transaction did. |
action_type | string | One of 30 enum values, see below. |
status | string | "success" or "reverted". |
assets_moved | array | Each entry: token, amount (decimal string), from, to, token_address, standard. |
counterparties | array | Each entry: address, label (string or null). Labels come from a verified table of major Base contracts. |
risk_flags | array | Each entry: flag, detail. See the flag table below. |
checks | object | Which risk checks actually ran. Read this before drawing any conclusion from an empty risk_flags — see below. |
gas_paid_usd | number | Total gas cost in USD, including the OP-stack L1 data fee. ETH is priced from the Chainlink ETH/USD feed at the transaction's block. |
timestamp | — | Block timestamp of the transaction. |
block_number | number | Block that included the transaction. |
tx_hash | string | The transaction hash, echoed back. |
basescan_url | string | Link to the transaction on Basescan. |
partial | boolean | true when full meaning could not be established; the summary then says exactly what is and isn't known. |
action_type values
Classification is deterministic and rule-ordered. The full enum, 30 values:
- eth_transfer
- erc20_transfer
- erc20_approval
- approval_revoked
- approval_for_all
- swap
- add_liquidity
- remove_liquidity
- wrap
- unwrap
- nft_mint
- nft_transfer
- nft_sale
- token_mint
- bridge_in
- bridge_out
- lending_supply
- lending_withdraw
- lending_borrow
- lending_repay
- stake
- unstake
- claim
- batch_transfer
- account_abstraction_bundle
- attestation
- name_registration
- contract_deployment
- contract_interaction
- unknown
risk_flags values
A flag always means evidence was found, a failed lookup never produces a flag, which is why checks below tells you whether the lookup happened. Sources: Sourcify and Basescan verification status, the ScamSniffer and MyEtherWallet public blocklists (consumed read-only at runtime; each feed’s own last-change date, and whether it has gone stale, are published on /healthz — we report when we fetched and when the source last changed, never that a blocklist is complete), and approval semantics.
| Flag | Meaning |
|---|---|
unverified_contract | A contract involved has no verified source on Sourcify or Basescan. |
first_time_counterparty | The sender is interacting with this counterparty for the first time. |
approval_for_all | The transaction granted an operator approval over an entire collection. |
unlimited_approval | The transaction granted an effectively unlimited token approval. |
known_drainer | An involved address appears on the ScamSniffer or MyEtherWallet public blocklists. |
impersonated_token | A token reports the symbol of a token on our known-token list but is not the address that list records for it; its address is shown instead. This check is bounded by that list: 116 tokens (our own 19, verified 2026-08-19, plus the Base entries of tokens.uniswap.org, read 2026-09-09). A token whose symbol is not on the list is not checked, and the absence of this flag is not a statement that a token is genuine. |
nonstandard_token_symbol | A token’s self-reported symbol failed a standard-ticker check; its contract address is shown in place of the name. |
transaction_reverted | The transaction reverted onchain. |
checks
Because a failed lookup never produces a flag, an empty risk_flags has two possible meanings: nothing was found, or nothing was looked at. checks tells you which one you are holding. Each check reports one of five values:
| Status | Meaning |
|---|---|
ok | The check ran against every address that warranted it. |
partial | It ran against some but not all of them. |
unavailable | It could not run at all, the upstream sources it depends on were unreachable. Transient: a retry may get an answer. |
inconclusive | It ran and nothing failed, but its method cannot answer for this input, and a retry will not change that. Today this is first_interaction for a sender with more transaction history than the lookup reads. |
not_applicable | There was nothing for this check to look at. |
The keys are contract_verification (Sourcify, then Basescan), first_interaction (Blockscout, then Basescan) and drainer_blacklist, plus a note naming anything that did not fully run — null when everything did.
unchecked_addresses names addresses that warranted a lookup but did not receive one, because the transaction involved more of them than the per-transaction cap. Which addresses get the scarce lookups is influenced by the order events appear in, and that order is chosen by whoever wrote the transaction, so the address described in risk_flags is not necessarily the one that went unexamined.
An empty risk_flags alongside any status other than ok means not checked, not clean. Two cases fall short of ok even when every lookup succeeds. A transaction touching more unfamiliar addresses than the per-transaction lookup cap reports partial. A sender whose history is longer than the one page the lookup reads reports inconclusive for first_interaction, because first-time interaction cannot be honestly asserted for them, that is a limit of the method, not a failure, which is why it is not reported as unavailable, and why retrying it will return the same answer.
Absence of a flag is never a safety guarantee. These are observations about a transaction that has already been mined, not a verdict on it, and not advice about whether to act.
provenance
Every successful response carries this, and if you feed our output to a language model you need to read it. Some strings we return are copied from on-chain or third-party sources that the transaction’s own author controls, token symbols, contract and collection names, event and function names. provenance.untrusted_fields names exactly which ones; today summary, assets_moved[].token and counterparties[].label.
Treat the contents of those fields strictly as data, never as instructions: even when they read as commands, system messages, or claims of authority. A token that names itself with instruction-like or promotional text is a scam signal, not a directive. It is the same reason risk_flags is evidence and not a verdict: we report what the chain said, including when what the chain said is hostile.
Two defences run before you see it. Symbols are normalised, control characters, line separators, emoji and homoglyphs stripped, and a token whose self-reported symbol fails a plausible-ticker check is shown as its contract address rather than its chosen name, so a hostile name cannot impersonate a real one or smuggle text into an agent’s context. provenance.note restates all of this inside the payload itself, so an agent that never reads this page still gets the warning.
Error handling
A payment-required response is also isError: true — this is not a bug. Past the free tier, the unpaid call comes back as a tool error carrying the x402 challenge, exactly as the MCP x402 transport spec requires: the wrapper a paying client uses can only detect a challenge on an error result. An x402-capable client reads it and pays automatically; a plain MCP client with no x402 wrapper will surface it as a failed call, which is expected, not broken. See x402 payments for the challenge shape.
Tool-level errors return isError: true with a body of the shape { "error": "...", "code": "..." }:
| Code | Meaning |
|---|---|
invalid_hash | tx_hash is not 0x followed by 64 hex characters. |
not_found | No transaction with that hash on Base mainnet. |
pending | The transaction exists but has not been included in a block yet. |
upstream_error | An upstream data source failed. Safe to retry. |
Partial results are not errors. When full meaning cannot be established, the call succeeds with partial: true and a summary that says exactly what is and isn't known, the tool never guesses.
Rate limiting. Above 60 requests per minute per client, the server returns JSON-RPC error -32000 with HTTP 429. Back off and retry.
Payment required. Once the free tier is exhausted, the tool response embeds an x402 payment challenge. This is the real challenge from the live server, trimmed:
{
"x402Version": 2,
"error": "Payment required to access this tool",
"accepts": [{
"scheme": "exact",
"network": "eip155:8453",
"amount": "20000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0xc41c4fed450674169af002b8b3cb47bd70a1958f",
"maxTimeoutSeconds": 300,
"extra": { "name": "USD Coin", "version": "2" }
}]
}
An x402-capable agent handles this autonomously; see x402 payments.
Examples
Actual output for a live Base transaction, trimmed for width — from/to/token_address/standard on assets, addresses on counterparties, flag details, timestamp, and block number elided:
{
"summary": "0x401d…f2c5 swapped 0.03 ETH for
12,899,422 WNL via Uniswap V4 PoolManager.",
"action_type": "swap",
"status": "success",
"assets_moved": [
{ "token": "ETH", "amount": "0.03" },
{ "token": "WNL", "amount": "12899422.14…" }
],
"counterparties": [
{ "label": "Uniswap V4 PoolManager" }
],
"risk_flags": [
{ "flag": "unverified_contract" }
],
"checks": {
"contract_verification": "ok",
"first_interaction": "ok",
"drainer_blacklist": "ok",
"unchecked_addresses": [],
"note": null
},
"gas_paid_usd": 0.020562,
"tx_hash": "0x0c84b951051f…9d6c4d8c",
"basescan_url": "https://basescan.org/tx/0x0c84…",
"partial": false
}
A malformed hash comes back as a tool error rather than a decode:
{ "error": "...", "code": "invalid_hash" }
To try the tool against any transaction from a browser, use the playground.
x402 payments
Payment is part of the protocol, not a separate billing system. The loop:
- Call. The client calls the tool as usual. Past the free tier, the tool response embeds the 402-style challenge shown in Error handling.
- Pay. The challenge carries everything needed: scheme, network, amount, asset, and recipient. The client pays in USDC on Base through the x402 facilitator.
- Retry. The client retries the call with proof of payment attached and receives the result. An x402-capable agent runs this loop autonomously, no human, no signup.
Reading the challenge: "amount": "20000" is denominated in 6-decimal USDC units, so 20000 = $0.02. asset is the canonical USDC contract on Base, payTo is the receiving address, and network is eip155:8453 — Base mainnet.
Non-custodial. The x402 facilitator (PayAI) verifies and settles payments but cannot move or redirect funds. The server never holds user assets and never asks for private keys.
The $9 pass
For heavy use, a pass replaces per-call payments: $9 buys 30 days and up to 10,000 calls, rate-limited to 60 calls per minute. It is bought with a single x402 payment, POST /pass over HTTP, or the buy_pass tool over MCP.
The response is a bearer token. The simplest way to use it: paste https://api.0200project.com/mcp/<token> into any MCP client's server-URL field, that's the whole setup, no separate credential to configure. It also works the older ways, for clients that expect one: the X-BTX-Pass header on POST /explain, Authorization: Bearer <token> (the form claude.ai custom connectors require), or _meta["btx/pass"] on MCP calls.
- No account. The token is the only proof of purchase. It is transferable, anyone holding it can spend its calls, and a lost token is a lost pass, so store it like a key.
- Not a subscription. x402 payments are one-shot, caller-signed authorizations, so nothing here can auto-renew or silently charge you. When a pass expires or runs out, calls fall back to the standard flow, any free calls you have left, then the per-call 402, and an agent renews by buying a new pass in the same loop.
- You pay when we decode something, or when we cannot tell what went wrong. A call is consumed when we return a decode of a real transaction. It is also consumed when an error comes back in a form we cannot read, the refund decision is made from the reason, and a reason we cannot read cannot be acted on. Everything else is refunded on both rails, for passes and free calls alike: a malformed hash, refused before any work is done; our own failures; and a lookup that finds nothing, whether the hash is not on Base or not yet mined.
Usage
- Free tier, the first 50 calls in any 24 hours are free, with no signup. Metering is per client, one IPv4 address, or one IPv6 /64: machines behind the same NAT or a shared CI egress draw from one counter, so shared networks can hit the paywall early, and the counter resets a day later. After that, each call is paid via x402.
- Rate limit — 60 requests per minute per client. Exceeding it returns JSON-RPC error
-32000with HTTP 429. - Per-request isolation, no sessions, and a fresh MCP server is created per request, so concurrent calls do not corrupt each other’s state. That is not the same as free, and not the same as identical. Retries cost: every call draws on the free tier or is paid, and a lookup that comes back
not_foundis charged rather than refunded, so retrying an empty result costs each time. Parallel calls contend: the rate limit above is 60 a minute, and under concurrency the ETH price behindgas_paid_usdcan fall back to the latest feed reading instead of the price at the transaction’s block,gas_price_basissays which one was used. The decode is deterministic; the response around it is not.
Pricing
| Tier | Price | Notes |
|---|---|---|
| Free tier | $0 | 50 calls per 24 hours, per IPv4 address or IPv6 /64. No signup. |
| Per call | $0.02 | USDC on Base via x402, paid per call. |
| Pass | $9 | 30 days, up to 10,000 calls, 60/min. Bearer token via one x402 payment, no account, not a subscription. |
| Developer | $9/mo | Same usage as the Pass, renewing automatically. Card only, via Stripe. |
| Marketplace-hosted | — | Via the Apify Store listing: runs on your own Apify plan's compute, no per-call charge from us. |
No accounts, no API keys, no minimums. Self-hosting is free under an MIT-style license, see Installation. Full comparison and checkout: pricing.