# Bansun API

For the current product overview, endpoint index and Replit runtime guidance, see [Bansun Docs](/docs#api). The localhost port below is the standalone default; the Replit service uses its configured PORT and same-origin requests.

Base URL: your server (default `http://localhost:8787`). All bodies are JSON (`content-type: application/json`). Errors return `{ "error": "<code>", "message": "..." }` with a matching HTTP status.

**Credits.** When the server runs with `ENFORCE_CREDITS=true`, the AI endpoints (`/api/chat`, `/api/compare`, `/api/simplify`, `/api/agent`) require the header `x-bansun-credit: <token_b64url>.<signature_b64url>`. The response field `credit` is `"spent"`, `"refunded"` or `"none"`. A credit is refunded automatically when the request fails.

**Language.** There is no language parameter. The interface is English and the model replies in the language the user writes in. All timestamps are UTC (ISO 8601).

**Limits.** Rate limit per client (`RATE_LIMIT_PER_MIN`, default 40/min; GETs cost ¼, `/api/agent` 2, `/api/chain/approvals` 4). `429` responses carry `retry-after`.

**Numbers.** Large integers (wei, raw token amounts) are returned as decimal strings.

---

## `GET /api/health`
```json
{ "ok": true, "name": "Bansun", "version": "0.2.0", "enforceCredits": false, "models": ["qwen2.5:3b"],
  "chain": { "network": "Ethereum mainnet", "chainId": 1, "rpcProviders": ["ethereum-rpc.publicnode.com"], "priceApi": "coins.llama.fi" },
  "agentTools": ["get_price","get_gas","check_token","lookup_transaction","explain_calldata","check_approvals","wallet_balances","resolve_ens","scam_check","position_size"] }
```

## `GET /api/models`
Probes the model server. `{ "reachable": true, "configured": [{ "id": "qwen2.5:3b", "available": true }] }`

## `POST /api/chat`
| field | type | |
|---|---|---|
| `message` | string ≤ 8000 | required |
| `history` | `[{role:"user"\|"assistant", content}]` | optional, last 12 kept |
| `model` | string | must be in `LLM_MODELS` |

```json
{
  "answer": "Markdown…",
  "confidence": 78,
  "confidence_reason": "General answer; local details may differ.",
  "needs_expert": false,
  "prediction": { "statement": "…", "probability": 0.6 },
  "structured": true,
  "model": "qwen2.5:3b",
  "latencyMs": 2140,
  "guard": { "inputFlags": [] },
  "credit": "none"
}
```
`confidence` is `null` when the model did not follow the JSON contract (`structured: false`).

Errors: `llm_unreachable` (503), `llm_timeout` (504), `llm_http_error` / `llm_bad_response` (502).

## `POST /api/compare`
`{ "message": "…", "models": ["a","b"] }` (models optional, max 3). Returns `{ "results": [ {ok, model, answer, confidence, …} | {ok:false, model, error, message} ], "agreement": 0.42 }`. `agreement` is the mean pairwise word-overlap (0–1) of successful answers.

## `POST /api/simplify`
`{ "text": "≤ 30000 chars", "style": "plain|steps|table|summary" }` → `{ "result": "Markdown", "model", "latencyMs", "style", "guard": { "inputFlags": ["override"] } }`

The text is wrapped as untrusted data; `inputFlags` lists prompt-injection patterns found in it.

## `POST /api/scam`
`{ "text": "…", "explain": false }`
```json
{
  "score": 100, "level": "high",
  "signals": [{ "id": "credential_request", "weight": 40, "evidence": "verification code", "text": "Asks for a one-time code, PIN or password" }],
  "links": [{ "url": "http://paypal-verify.xyz", "host": "paypal-verify.xyz", "registrable": "paypal-verify.xyz", "issues": [{ "id": "link_brand_lookalike", "brand": "paypal", "official": "paypal.com", "text": "…" }] }],
  "ai": null
}
```
With `explain: true`, `ai` is `{ text, model }` or `{ error }`. The same analysis runs offline in the browser via `/shared/scam.js`.

## `POST /api/agent`
`{ "message": "Is 0x… safe to buy? And what is gas now?", "history": [...], "model": "qwen2.5:3b" }`

The agent first runs the tools the message obviously needs, then lets the model call more (up to `AGENT_MAX_STEPS`) using a JSON step protocol that works with any OpenAI-compatible model. Tool results are passed to the model as untrusted data.
```json
{
  "answer": "Markdown…", "confidence": 70, "confidence_reason": "…", "needs_expert": false, "prediction": null, "structured": true,
  "steps": [
    { "tool": "check_token", "args": { "address": "0x…" }, "why": "You mentioned a contract address.", "ok": true,
      "result": { "symbol": "MRI", "risk": { "score": 100, "level": "high", "reasons": [ … ] }, … },
      "receipt": { "source": "Ethereum mainnet JSON-RPC", "providers": ["ethereum-rpc.publicnode.com"], "fetchedAt": "2026-10-08T12:00:00.000Z" },
      "latencyMs": 412 }
  ],
  "model": "qwen2.5:3b", "latencyMs": 5210, "guard": { "inputFlags": [] }, "credit": "none"
}
```

---

## Ethereum mainnet

Every chain endpoint returns `{ "ok": true, "data": {…}, "receipt": {…} }` or `{ "ok": false, "reason": "…", "detail": "…" }` (HTTP 200 in both cases; the reason explains what failed). Reasons: `invalid_address`, `invalid_hash`, `invalid_name`, `name_not_found`, `tx_not_found`, `rpc_unreachable`, `rpc_timeout`, `rpc_error`, `rpc_http_error`, `wrong_chain`, `no_prices`, `price_http_error`.

The receipt names the source, the RPC hosts used, the JSON-RPC methods and call count, the fetch time (UTC) and what was shared with the provider. Requests always leave from the server; providers never see the user's IP.

Inputs that take an address also accept an ENS name (`name.eth`).

### `GET /api/chain/status`
`{ "chainId": 1, "block": 26100000 }`. Fails with `wrong_chain` if the RPC is not mainnet.

### `GET /api/chain/gas`
Base fee and priority-fee percentiles (10/50/90) from `eth_feeHistory` over 20 blocks, block fullness, ETH/USD, and three tiers with the cost of common actions:
```json
{ "block": 26100000, "baseFeeGwei": 8, "baseFeeTrend": [6.1, …], "busy": 0.645, "ethUsd": 2451.37,
  "tiers": [{ "name": "normal", "priorityGwei": 0.08, "maxFeeGwei": 9.08,
    "costs": { "ethTransfer": { "units": 21000, "eth": 0.00017, "usd": 0.42 }, "erc20Transfer": {…}, "approve": {…}, "swap": {…} } }, …] }
```

### `POST /api/chain/market`
`{ "tokens": ["eth", "0xA0b8…eB48"] }` (max 30; empty = defaults) → rows with `symbol`, `name`, `price`, `change24h` (%), `chart` (hourly `[unixSeconds, price]` for 24h), `confidence`, `timestamp`. Source: DefiLlama.

### `POST /api/chain/token`
`{ "address": "0x…" }`
```json
{ "address": "0x…", "isContract": true, "name": "…", "symbol": "…", "decimals": 18, "totalSupply": "1,000,000,000",
  "owner": "0x…", "ownerRenounced": false,
  "proxy": { "kind": "EIP-1967 proxy", "implementation": "0x…", "admin": "0x…" } | null,
  "codeSizeBytes": 9120,
  "capabilities": [{ "id": "mint", "level": "bad", "text": "Owner can mint new tokens (dilutes holders)", "functions": ["mint(address,uint256)"] }],
  "liquidity": { "pools": [{ "venue": "Uniswap V2", "address": "0x…", "weth": 0.82 }], "wethTotal": 0.82 },
  "price": { "price": 0.0000412, "change24h": 38.5, … } | null, "marketCapUsd": 41200,
  "wellKnown": false, "rating": { "score": 100, "level": "high", "reasons": [ … ] }, "limits": "…" }
```
Capabilities are found by walking the bytecode's opcodes and collecting `PUSH4` dispatcher selectors (push data is skipped). Proxy logic is read from the implementation. Capability ids: `mint`, `blacklist`, `fees`, `pause`, `trading_switch`, `limits`, `fee_exempt`, `upgrade`, `ownable`, `renounce`, `selfdestruct`.

### `POST /api/chain/explain`
Transaction: `{ "to": "0x…", "data": "0x…", "value": "wei", "from": "0x… (optional)", "live": true }`. Signature request: `{ "typedData": { …EIP-712… } }`.
```json
{ "kind": "tx", "summary": "Lets 0xdEAd…ef01 spend UNLIMITED USDC from your wallet.", "level": "high",
  "decoded": { "selector": "0x095ea7b3", "signature": "approve(address,uint256)", "name": "approve", "args": [{ "type": "address", "value": "0x…" }, { "type": "uint256", "value": "115792…" }] },
  "findings": [{ "level": "bad", "id": "spender_is_wallet", "text": "…" }, { "level": "warn", "id": "unlimited_approval", "text": "…" }] }
```
The same decoder runs offline in the browser (`/shared/evm.js`); the server adds the live check whether a spender has contract code.

### `POST /api/chain/tx`
`{ "hash": "0x…64 hex" }` → `from`, `to`, `valueEth`, `block`, `status` (`success`/`failed`/`pending`), `feeEth`, `analysis` (as above) and decoded ERC-20 `transfers`.

### `POST /api/chain/portfolio`
`{ "addresses": ["0x…", "name.eth"], "tokens": ["0x… extra token"] }` (max 10 addresses, 40 extra tokens) → `rows` (`owner`, `token`, `symbol`, `amount`, `amountText`, `priceUsd`, `change24h`, `valueUsd`, `unpriced`), `totalUsd`, `tokensChecked`.

### `POST /api/chain/approvals`
`{ "owner": "0x… | name.eth", "deep": true }` → `active` approvals sorted by risk:
```json
{ "token": "0x…", "spender": "0x…", "kind": "erc20" | "nft", "symbol": "PEPE", "spenderName": null, "unlimited": true,
  "amountText": "Unlimited", "risk": "high", "revoke": { "to": "0x…token", "data": "0x095ea7b3…" } }
```
Plus `checkedPairs`, `scanned` (`fromBlock`, `toBlock`) and `partial` (true when the RPC refused part of the event scan). Known token × known spender pairs are always checked with `allowance()`; `deep` also scans `Approval` / `ApprovalForAll` events over `APPROVAL_SCAN_BLOCKS`.

### `POST /api/chain/ens`
`{ "name": "vitalik.eth" }` → `{ "name", "address", "resolver" }` (EIP-137 namehash, registry → resolver → `addr`).

## Anonymous credits

### `GET /api/credits/key`
`{ "jwk": { "kty":"RSA", "n":"…", "e":"AQAB" }, "fingerprint": "32 hex", "scheme": "rsa-fdh-blind-v1", "perDay": 50, "enforced": false }`

### `POST /api/credits/issue`
`{ "blinded": ["b64url", …] }` (1–10 values) → `{ "signatures": ["b64url", …], "remainingToday": 45 }`

Client flow (see `shared/blindrsa.js`):
1. `token = 32 random bytes`; `m = FDH(token)` (MGF1-SHA256 into Z_n).
2. `blinded = m · r^e mod n` with random `r`.
3. Server returns `s' = blinded^d mod n`.
4. `s = s' · r⁻¹ mod n`; check `s^e mod n == m`.
5. Spend as `token.s` (both base64url, `s` fixed-length big-endian).

### `POST /api/credits/redeem`
`{ "credit": "token.signature" }` → `{ "ok": true }` or `402` with `credit_already_spent`, `credit_invalid_signature`, `credit_malformed`.

## Static paths
`/` landing · `/app` application · `/shared/*` isomorphic modules (keccak, ABI, EVM analysis, trading maths, scam, Shamir, blind RSA, guard, ledger, answer) · `/vendor/web-llm/*` the local-AI engine when vendored · `/docs/*` these documents.

## Security headers
Strict CSP (`default-src 'self'`; scripts only from self plus the WebLLM CDN as a fallback for opt-in local AI), `referrer-policy: no-referrer`, `x-frame-options: DENY`, `cross-origin-opener-policy: same-origin`, restrictive `permissions-policy`, `cache-control: no-store` for all API responses.
