POST /explain
Synchronous transaction analysis over SSE - streams progress events, ends with the full result. For plain-fetch callers, use
POST /explain/asyncand poll.
URL: https://mpp.oculr.xyz/explain
Auth: MPP/x402 metered session - requires tempo.session.manager().sse() from mppx, which signs voucher increments as the analysis accrues cost (metered pricing). A plain JSON request (no SSE) returns 402 with code: 'use_metered_sse'.
Request
Body
| Field | Type | Required | Description |
|---|---|---|---|
txHash | string | yes | EVM tx hash matching ^0x[0-9a-fA-F]{64}$. Works on any supported chain (50+ EVM mainnets); oculr auto-detects the chain. |
chainId | number | no | EIP-155 chain ID. When provided, skips multi-chain auto-detection. Must be one of the supported chains. |
context | string | no | Caller intent passed to the analysis agent. Improves accuracy on ambiguous transactions. |
model | string | no | claude-opus-5 | claude-opus-4-8 | claude-opus-4-7 | claude-sonnet-4-6 | claude-haiku-4-5-20251001. Defaults to a server-side default (claude-opus-5); GET /tool-spec.json publishes the live value in defaultModel. |
report | boolean | no | If true, the response body is HTML (Content-Type: text/html) instead of JSON. In SSE mode the rendered HTML arrives via a separate report event. |
Headers
| Header | Value | Notes |
|---|---|---|
Content-Type | application/json | Required. |
Accept | text/event-stream | Required for paid calls - sync analysis is metered over SSE. See SSE streaming. |
Response
200 OK - ExplanationResult
Delivered as the final { "type": "result", … } SSE event; the earlier events are progress. The example below is abbreviated - the field tables that follow list every field, and Analyze a transaction → Example result shows a response with all of them populated:
{
"txHash": "0x…",
"chain": "ethereum-mainnet",
"chainName": "Ethereum",
"status": "success",
"summary": "Uniswap V3 swap: 1,000 USDC → 0.42 WETH via the 0.05% fee pool",
"steps": ["Sender called exactInputSingle on Uniswap V3 Router", "..."],
"risks": [],
"protocol": "uniswap_v3",
"txType": "swap",
"confidence": "high",
"usdValue": 1000.00,
"addresses": [
{ "address": "0x…", "label": "Uniswap V3 Router", "role": "router" }
],
"contracts": [
{ "address": "0x…", "name": "UniswapV3Pool", "description": "0.05% fee USDC/WETH pool" }
],
"costs": {
"llms": 0.33,
"dataCollection": 0.03,
"codeExecution": 0.01,
"other": 0.00,
"totalUsd": 0.37
}
}Response fields
Always present (partial results populate these with empty or placeholder values rather than omitting them)
| Field | Type | Notes |
|---|---|---|
txHash | string | Echoes the request. |
chain | string | Auto-detected slug, e.g. ethereum-mainnet. |
chainName | string | Human-readable chain name. |
explorerBase | string | Block-explorer origin for the detected chain (no trailing slash) - build links as ${explorerBase}/tx/<hash>. |
status | "success" | "reverted" | See Analyze a transaction → status. |
analysisModel | string | The model that produced the result. Special value 'partial-synthesis' marks a partial result. |
summary | string | One-line plain-English explanation. |
steps | string[] | Ordered narrative of what the transaction did. |
risks | string[] | Empty when no risks flagged. |
protocol | string | null | Snake-case slug, e.g. uniswap_v3. |
txType | enum | null | See Analyze a transaction → txType. |
confidence | "high" | "medium" | "low" | Branch on this. |
addresses | Array<{ address, label, role }> | Resolved with labels. |
contracts | Array<{ address, name, description }> | Code at the contract addresses. |
usdValue | number | null | Primary-action USD value. |
costs | object | Category buckets - llms, dataCollection, codeExecution, other, totalUsd. See Analyze a transaction → costs. |
toolCalls | array | Each tool the agent invoked, with durationMs, ok, costUsd. |
prettyTrace | array | Annotated call tree used by the web app's Trace tab. |
rawTrace | object | null | Raw CallFrame from the RPC. null when the trace was compressed for transport (see prettyTraceMeta). |
txMeta | object | Block number, timestamp, gas used, gas price, sender, recipient. |
tokenTransfers | array | Every ERC-20/721/1155 transfer touched in the trace. |
mermaidDiagram | string | Mermaid sequence-diagram source, rendered by the web app's Flow tab. |
htmlReport | boolean | true when the agent called generate_report during analysis. |
| Field | Type | Meaning |
|---|---|---|
skillsUsed | string[] | Analysis skills/playbooks the agent engaged this run. |
balanceChanges | array | Per-address signed balance flow with USD values. Omitted when no balance flow could be computed. |
traceAnnotations | object | AI comments keyed by trace-node index, when the agent annotated the call tree. |
prettyTraceMeta | object | Set when a pathological trace was compressed for transport - original vs kept node counts plus collapsed-loop markers. |
findings | array | Structured exploit findings - one per distinct vulnerability. Populated when txType === 'exploit'. |
nonFindings | string[] | Things the analysis considered and ruled out - real observations that are not the cause of this transaction. Absent when there is nothing to report. |
Examples
TypeScript
import { tempo } from 'mppx/client'
const session = tempo.session.manager({ account, maxDeposit: '32' })
const stream = await session.sse('https://mpp.oculr.xyz/explain', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
body: JSON.stringify({
txHash: '0x4e4b8ed4de38be29e3a7a15e2b14b5d8262e3c5b3f1e9d6a7c8b9e0f1a2d3c4e',
model: 'claude-sonnet-4-6', // optional - cheaper than Opus
}),
})
let result
for await (const payload of stream) {
const msg = JSON.parse(payload)
if (msg.type === 'result') result = msg
}SSE streaming
Each agent step emits its own SSE message - preflight_start, preflight_done, preflight_status, iteration, agent_text (token-by-token reasoning), tool_call, tool_result, skill_call, tokens, complete, then either a final result (JSON) or report (HTML); fatal failures arrive as an error frame. The stream is what carries the metered payment: the session client signs voucher increments as cost accrues.
Prefer a plain fetch() and no stream? Use POST /explain/async - single-event polling, metered to the same total price.
Errors
| Code | When it happens | Body shape |
|---|---|---|
400 | txHash is missing or malformed (must be 0x + 64 hex). | { "error": "txHash must be a valid 32-byte hex hash (0x...)" } |
402 | No active MPP payment session (standard challenge, handled by the session client) - or a paid JSON request without SSE. | Standard MPP/x402 challenge, no code field - or { "error": "...", "code": "use_metered_sse" } for JSON-mode requests. |
500 | An internal error not caught by the partial-result path. On the SSE path, a transaction not found on any supported chain arrives as an SSE error event (or a partial result) rather than an HTTP status. | { "error": "Transaction 0x… not found on any supported chain (…)" } |
502 | Self-hosted deployments only. oculr could not pay an upstream service for this request. The hosted API never returns this status: the only branch that puts it on the wire is the unmetered blocking-JSON one, and if (!DEV_MODE) return 402 use_metered_sse stands in front of it - so a 502 reaches you only on a deployment running PRECOG_DEV_MODE=true. On the metered SSE path the same condition normally degrades to a partial result inside an HTTP 200; see below. | { "error": "...", "code": "upstream_payment_unavailable" } |
402 handling
If the body carries code: 'use_metered_sse', you sent a plain JSON request - switch to Accept: text/event-stream with tempo.session.manager().sse(), or use POST /explain/async for plain fetch. Otherwise, a 402 with the session client active usually means your wallet ran out of USDC.e - top up at the USDC.e contract on Tempo (0x20C000000000000000000000b9537d11c60E8b50).
upstream_payment_unavailable handling
The server's outbound payment to one of its upstreams failed. Your payment is fine, so do not retry it as a payment failure. Surface to the user as something like "the transaction analysis service is temporarily unable to bill its upstreams - try again shortly." Cap retries at 2.
One condition, three shapes - and on the hosted API the HTTP status is the wrong thing to branch on. Which shape you get depends on the rail:
| Rail | What actually arrives | Branch on |
|---|---|---|
Metered SSE POST /explain | HTTP 200 (the status was sent before the analysis ran). Mid-analysis the condition degrades to a partial result frame (confidence: "low", summary starting "Partial result") - that partial IS the upstream-payment signal on this rail. The stream's last-resort fatal frame {"type":"error","code":"internal_error","message":"…"} today always carries internal_error; upstream_payment_unavailable is part of the frame's code enum (shared with ErrorBody) but is currently never emitted on it. | the partial-result markers (confidence / summary), not the frame's code |
POST /explain/async | HTTP 202 at submit, then GET /result/:jobId answers HTTP 200 with {"status":"error","errorCode":"upstream_payment_unavailable"}. | errorCode |
Blocking JSON POST /explain - self-hosted PRECOG_DEV_MODE=true only | HTTP 502 with {"error":"...","code":"upstream_payment_unavailable"}. | code |
A client that branches only on res.status === 502 therefore never fires against the hosted API. The async pattern:
const { jobId } = await fetch('https://mpp.oculr.xyz/explain/async', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ txHash }),
}).then(r => r.json())
while (true) {
await new Promise(r => setTimeout(r, 5000))
// 200 even when the analysis failed: the status describes the poll, not the job.
const job = await fetch(`https://mpp.oculr.xyz/result/${jobId}`).then(r => r.json())
if (job.status === 'complete') return job.result
if (job.status === 'error') {
if (job.errorCode === 'upstream_payment_unavailable') {
throw new Error('oculr is temporarily unable to bill its upstreams - try again shortly')
}
throw new Error(job.error)
}
}Note: most upstream issues don't surface as
500or502. oculr returns200 OKwithconfidence: 'low'and a summary that starts with "Partial result" whenever it can produce a usable partial result. See Core concepts → Partial results.
Related
- POST /explain/async - non-blocking variant
- Analyze a transaction - worked example with the result schema explained
- Core concepts → Partial results