Skill: walioo/predictclaw-publish-docs-20260320 (version 0.1.11)

--- name: predictclaw description: Predict.fun skill with a PolyClaw-style CLI for markets, wallet funding, trading, positions, and hedging. metadata: {"openclaw":{"emoji":"🔮","homepage":"https://predict.fun","requires":{"bins":["uv"],"env":["PREDICT_ENV","PREDICT_WALLET_MODE"]},"install":[{"id":"uv-brew","kind":"brew","formula":"uv","bins":["uv"],"label":"Install uv (brew)"}]}} --- # PredictClaw PredictClaw is the predict.fun-native OpenClaw skill for browsing markets, checking wallet readiness, viewing deposit addresses, withdrawing funds, placing buys, inspecting positions, and scanning hedge opportunities. ## Install ### ClawHub / packaged install ```bash clawhub install predictclaw cd ~/.openclaw/skills/predictclaw uv sync cp .env.example .env ``` In packaged installs, the skill base directory is usually `~/.openclaw/skills/predictclaw`. In OpenClaw manifests and examples, this same location may appear as `{baseDir}`. ### Manual install 1. Copy or symlink the repo’s `predict/` folder into `~/.openclaw/skills/predictclaw/` 2. From the installed skill directory, run: ```bash cd {baseDir} && uv sync cd {baseDir} && cp .env.example .env ``` ## How configuration actually works PredictClaw only reads standard environment variables. The supported, tested inputs are: - the process environment, for example `export PREDICT_ENV=testnet` - a local `{baseDir}/.env` file, auto-loaded by `scripts/predictclaw.py` when present If both are present, exported environment variables win and `.env` only fills missing values. If your OpenClaw host version injects environment variables into the skill process, that also works because PredictClaw receives normal env vars either way. Older docs used `skills.entries.predictclaw.env`; treat that as a host-version-specific convenience, not the canonical PredictClaw config surface. The SKILL frontmatter metadata intentionally lists only the universal entry variables: `PREDICT_ENV` and `PREDICT_WALLET_MODE`. OpenClaw's runtime metadata is flat rather than mode-aware, so listing every optional signer or vault variable there would incorrectly imply they are all required at the same time. The mode-specific requirements are documented below and enforced by the runtime config validator. ## Mode reminders 1. Run `uv sync` in the installed skill directory. 2. Copy `.env.example` to `.env` inside `~/.openclaw/skills/predictclaw/`. 3. Pick exactly one wallet mode. 4. Fill only the variables required for that mode. 5. Verify with: ```bash cd {baseDir} && uv run python scripts/predictclaw.py --help cd {baseDir} && uv run python scripts/predictclaw.py markets trending ``` For signer-backed modes, the next verification step is: ```bash cd {baseDir} && uv run python scripts/predictclaw.py wallet status --json cd {baseDir} && uv run python scripts/predictclaw.py wallet deposit --json ``` ## Configuration examples The snippets below are `.env` examples. Put them in `{baseDir}/.env` or export the same names in your shell. `OPENROUTER_API_KEY` appears in the signer examples only for optional `hedge scan` / `hedge analyze` usage. It is not required for market, wallet, or buy flows and is only needed for non-fixture hedge analysis. ### read-only mode ```dotenv PREDICT_ENV=test-fixture PREDICT_WALLET_MODE=read-only ``` Use this for market browsing only. Switch to `eoa`, `predict-account`, or `mandated-vault` before using wallet or trade subcommands. ### eoa mode ```dotenv PREDICT_ENV=testnet PREDICT_WALLET_MODE=eoa PREDICT_API_BASE_URL=https://dev.predict.fun PREDICT_PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY ``` ### predict-account mode ```dotenv PREDICT_ENV=testnet PREDICT_WALLET_MODE=predict-account PREDICT_API_BASE_URL=https://dev.predict.fun PREDICT_ACCOUNT_ADDRESS=0xYOUR_PREDICT_ACCOUNT PREDICT_PRIVY_PRIVATE_KEY=0xYOUR_PRIVY_EXPORTED_KEY ``` ### mandated-vault mode (explicit deployed vault) ```dotenv PREDICT_ENV=testnet PREDICT_WALLET_MODE=mandated-vault ERC_MANDATED_VAULT_ADDRESS=0xYOUR_DEPLOYED_VAULT ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp ERC_MANDATED_CHAIN_ID=97 ``` Add [REDACTED_SECRET] when your current MCP path needs the single-key preflight signer. ### mandated-vault mode (predicted / undeployed vault) ```dotenv PREDICT_ENV=testnet PREDICT_WALLET_MODE=mandated-vault ERC_MANDATED_FACTORY_ADDRESS=0xYOUR_FACTORY ERC_MANDATED_VAULT_ASSET_ADDRESS=0xYOUR_ASSET ERC_MANDATED_VAULT_NAME=Mandated Vault ERC_MANDATED_VAULT_SYMBOL=MVLT ERC_MANDATED_VAULT_AUTHORITY=0xYOUR_AUTHORITY ERC_MANDATED_VAULT_SALT=0xYOUR_SALT ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp ERC_MANDATED_CONTRACT_VERSION=v0.3.0-agent-contract ERC_MANDATED_CHAIN_ID=97 ``` [REDACTED_SECRET] is optional. When it is unset, PredictClaw reuses [REDACTED_SECRET] as the executor signer for the current Preflight MVP contract. In that path, PredictClaw asks the MCP to predict the vault address and, when the vault is still undeployed, returns create-vault preparation guidance only. It does **not** auto-broadcast. ### predict-account + vault overlay (recommended advanced funding route) ```dotenv PREDICT_ENV=testnet PREDICT_WALLET_MODE=predict-account PREDICT_API_BASE_URL=https://dev.predict.fun PREDICT_ACCOUNT_ADDRESS=0xYOUR_PREDICT_ACCOUNT PREDICT_PRIVY_PRIVATE_KEY=0xYOUR_PRIVY_EXPORTED_KEY ERC_MANDATED_VAULT_ADDRESS=0xYOUR_DEPLOYED_VAULT ERC_MANDATED_VAULT_ASSET_ADDRESS=0xYOUR_ASSET ERC_MANDATED_VAULT_AUTHORITY=0xYOUR_AUTHORITY ERC_MANDATED_AUTHORITY_PRIVATE_KEY=0xYOUR_VAULT_AUTHORITY_KEY ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp ERC_MANDATED_CONTRACT_VERSION=v0.3.0-agent-contract ERC_MANDATED_CHAIN_ID=97 ``` In the overlay route, Predict Account remains the deposit/trading account while Vault funds the Predict Account through MCP-backed session and asset-transfer planning. If you do **not** have an explicit deployed vault address yet, keep the same Predict Account pair and replace `ERC_MANDATED_VAULT_ADDRESS` with the full derivation tuple: `ERC_MANDATED_FACTORY_ADDRESS`, [REDACTED_SECRET], `ERC_MANDATED_VAULT_NAME`, `ERC_MANDATED_VAULT_SYMBOL`, `ERC_MANDATED_VAULT_AUTHORITY`, and `ERC_MANDATED_VAULT_SALT`. The optional `ERC_MANDATED_FUNDING_*` envs cap Vault→Predict transfers by per-tx amount, per-window cumulative amount, and window duration. On BSC mainnet USDT, `5U = 5000000000000000000` and `10U = 10000000000000000000`. ## Wallet-mode contract - `read-only` — browse market data only; no signer-backed wallet actions. - `eoa` — direct signer path for wallet, trade, and funding flows. - `predict-account` — smart-wallet funding/trading path using `PREDICT_ACCOUNT_ADDRESS` plus `PREDICT_PRIVY_PRIVATE_KEY`. - `mandated-vault` — advanced explicit opt-in control-plane path for protected vault-only status/deposit flows. ## First-time setup - Default local posture is `test-fixture` or `testnet`. - `mainnet` requires `PREDICT_API_KEY`. - `read-only` is browse-only. Start with `markets ...`, not signer-backed wallet or trade commands. - `eoa` requires `PREDICT_PRIVATE_KEY` and rejects Predict Account or mandated-vault inputs. - `predict-account` requires both `PREDICT_ACCOUNT_ADDRESS` and `PREDICT_PRIVY_PRIVATE_KEY`. - `wallet deposit` shows the funding address for the active signer mode. - `wallet withdraw` performs safety validation before any transfer logic. - In pure `mandated-vault`, `wallet status` and `wallet deposit` are the intended v1 entry points. - In `predict-account + ERC_MANDATED_*` overlay, `wallet status` / `wallet deposit` expose `vault-to-predict-account` funding semantics while Predict Account remains the trade identity. - Overlay `buy` can proceed when the Predict Account balance is sufficient; otherwise it returns deterministic `funding-required` guidance that points to `wallet deposit --json`. - Pure `mandated-vault` needs a working `ERC_MANDATED_MCP_COMMAND`; overlay mode also needs [REDACTED_SECRET] and `ERC_MANDATED_VAULT_AUTHORITY` for funding orchestration. - Hedge analysis uses OpenRouter; `OPENROUTER_API_KEY` is only required for non-fixture hedge analysis, and fixture mode stays secret-free. ```bash cd {baseDir} && uv run python scripts/predictclaw.py --help cd {baseDir} && uv run python scripts/predictclaw.py wallet status --json cd {baseDir} && uv run python scripts/predictclaw.py wallet deposit --json cd {baseDir} && uv run python scripts/predictclaw.py wallet withdraw usdt 1 0xb30741673D351135Cf96564dfD15f8e135f9C310 --json ``` ## Command surface ```bash cd {baseDir} && uv run python scripts/predictclaw.py markets trending cd {baseDir} && uv run python scripts/predictclaw.py markets search "election" cd {baseDir} && uv run python scripts/predictclaw.py market 123 --json cd {baseDir} && uv run python scripts/predictclaw.py wallet status --json cd {baseDir} && uv run python scripts/predictclaw.py wallet approve --json cd {baseDir} && uv run python scripts/predictclaw.py wallet deposit --json cd {baseDir} && uv run python scripts/predictclaw.py wallet withdraw usdt 1 0xb30741673D351135Cf96564dfD15f8e135f9C310 --json cd {baseDir} && uv run python scripts/predictclaw.py wallet withdraw bnb 0.1 0xb30741673D351135Cf96564dfD15f8e135f9C310 --json cd {baseDir} && uv run python scripts/predictclaw.py buy 123 YES 25 --json cd {baseDir} && uv run python scripts/predictclaw.py positions --json cd {baseDir} && uv run python scripts/predictclaw.py position pos-123-yes --json cd {baseDir} && uv run python scripts/predictclaw.py hedge scan --query election --json cd {baseDir} && uv run python scripts/predictclaw.py hedge analyze 101 202 --json ``` ## Environment variables | Variable | Purpose | | --- | --- | | `PREDICT_STORAGE_DIR` | Local journal and position storage | | `PREDICT_ENV` | Defaults to `testnet`; accepted values are `testnet`, `mainnet`, or `test-fixture` | | `PREDICT_WALLET_MODE` | Explicit mode override: `read-only`, `eoa`, `predict-account`, or `mandated-vault` | | `PREDICT_API_BASE_URL` | Optional REST base override; leave empty to use the env-specific default (`dev.predict.fun` for `testnet` / `test-fixture`, `api.predict.fun` for `mainnet`) | | `PREDICT_API_KEY` | Mainnet-authenticated predict.fun API access | | `PREDICT_PRIVATE_KEY` | EOA trading and funding path | | `PREDICT_ACCOUNT_ADDRESS` | Predict Account smart-wallet address | | `PREDICT_PRIVY_PRIVATE_KEY` | Privy-exported signer for Predict Account mode | | `ERC_MANDATED_VAULT_ADDRESS` | Explicit deployed mandated vault address | | `ERC_MANDATED_FACTORY_ADDRESS` | Factory address used to predict a vault when no explicit vault address is supplied | | [REDACTED_SECRET] | ERC-4626 asset used in mandated-vault prediction/create preparation | | `ERC_MANDATED_VAULT_NAME` | Vault name used in mandated-vault prediction/create preparation | | `ERC_MANDATED_VAULT_SYMBOL` | Vault symbol used in mandated-vault prediction/create preparation | | `ERC_MANDATED_VAULT_AUTHORITY` | Authority address and create-vault `from` address for manual preparation | | `ERC_MANDATED_VAULT_SALT` | Deterministic salt used for vault prediction/create preparation | | [REDACTED_SECRET] | Preflight Vault signer key for the current single-key MVP contract | | [REDACTED_SECRET] | Optional dedicated executor signer; falls back to [REDACTED_SECRET] when unset | | `ERC_MANDATED_MCP_COMMAND` | MCP launcher command (defaults to `erc-mandated-mcp`) | | `ERC_MANDATED_CONTRACT_VERSION` | Passed through to the mandated-vault MCP client | | `ERC_MANDATED_CHAIN_ID` | Optional explicit chain selection for the MCP bridge | | [REDACTED_SECRET] | Optional 32-byte hex `allowedAdaptersRoot` used for Vault execution mandates; defaults to `0x11…11` for the current single-key MVP / PoC path | | [REDACTED_SECRET] | Optional Vault→Predict funding-policy `maxAmountPerTx` in raw token units | | [REDACTED_SECRET] | Optional Vault→Predict funding-policy `maxAmountPerWindow` in raw token units | | [REDACTED_SECRET] | Optional Vault→Predict funding-policy `windowSeconds` | | `OPENROUTER_API_KEY` | Optional OpenRouter credential; only required for non-fixture hedge analysis | | `PREDICT_MODEL` | OpenRouter model override | | `PREDICT_SMOKE_ENV` | Enables the smoke suite | | `PREDICT_SMOKE_API_BASE_URL` | Optional smoke REST base override | | `PREDICT_SMOKE_PRIVATE_KEY` | Enables signer/JWT smoke checks | | `PREDICT_SMOKE_ACCOUNT_ADDRESS` | Predict Account smoke mode | | `PREDICT_SMOKE_PRIVY_PRIVATE_KEY` | Predict Account smoke signer | | `PREDICT_SMOKE_API_KEY` | Smoke REST auth | ## Architecture note - **SDK for chain-aware/signed flows** - **REST for auth, data, order submission, and query** ## Safety notes - Do not treat fixture mode as proof of funded-wallet behavior. - Do not assume live liquidity from testnet or mainnet docs alone. - Keep only limited funds on automation keys. - Withdrawal commands are public; transfer validation happens before chain interaction, but users still own the operational risk. - `mandated-vault` is an advanced explicit opt-in mode. Only enable it when you intentionally want MCP-assisted vault control-plane behavior. - `predict-account + ERC_MANDATED_*` is the recommended advanced trading route when you want Vault to fund the Predict Account while keeping the official Predict Account order model. - Explicit-vs-predicted vault semantics: `ERC_MANDATED_VAULT_ADDRESS` targets an existing vault directly; otherwise PredictClaw uses the derivation tuple to ask the MCP for the predicted vault address. - If a predicted vault is undeployed, `wallet deposit` can return create-vault preparation details (`predictedVault`, transaction summary, `manual-only`) without broadcasting. - Trust boundary: the MCP orchestrates transport and preparation; the vault contract policy authorizes what the vault can actually execute. - Pure `mandated-vault` does not provide predict.fun trading parity. `wallet approve`, `wallet withdraw`, `buy`, `positions`, `position`, `hedge scan`, and `hedge analyze` fail closed with [REDACTED_SECRET]. - Overlay funding currently plans the vault leg and surfaces deterministic `funding-required` guidance when buy needs top-up; it does not auto-execute the funding leg in the current local signer context.

path: README.md
content: # PredictClaw

[中文说明 / Chinese Version](./README.zh-CN.md)

PredictClaw is the predict.fun-native OpenClaw skill for browsing markets, checking wallet readiness, viewing deposit addresses, withdrawing funds, placing buys, inspecting positions, and scanning hedge opportunities.

This `predict/` directory is an isolated Python subproject inside `mandated-vault-factory`. It keeps its own CLI, runtime configuration, and tests without changing the repository's Foundry workflow.

## What PredictClaw Is For

PredictClaw gives OpenClaw a predictable command surface for predict.fun workflows:

- market discovery and market detail lookup
- wallet readiness and deposit guidance
- trading through EOA or Predict Account paths
- position journaling and query flows
- optional hedge analysis for users who enable model access

For the advanced funding route, PredictClaw supports a **Vault funding overlay** on top of the official Predict Account path. In that route, **Predict Account remains** the deposit address, trade identity, and order path while Vault acts as the funding source.

## Install

### ClawHub / packaged install

```bash
clawhub install predictclaw
cd ~/.openclaw/skills/predictclaw
uv sync
cp .env.example .env
```

In packaged installs, the skill base directory is usually `~/.openclaw/skills/predictclaw`. In OpenClaw manifests and examples, this same location may appear as `{baseDir}`.

### Manual install

1. Copy or symlink this `predict/` folder into `~/.openclaw/skills/predictclaw/`
2. From the installed skill directory, run:

```bash
cd {baseDir} && uv sync
cd {baseDir} && cp .env.example .env
```

### Local repo development

From `mandated-vault-factory/predict`:

```bash
uv sync
uv run pytest -q
uv run python scripts/predictclaw.py --help
```

## How configuration actually works

PredictClaw only reads standard environment variables. The supported, tested inputs are:

- the process environment, for example `export PREDICT_ENV=testnet`
- a local `{baseDir}/.env` file, auto-loaded by `scripts/predictclaw.py` when present

If both are present, exported environment variables win and `.env` only fills missing values.

If your OpenClaw host version injects environment variables into the skill process, that also works because PredictClaw receives normal env vars either way. Older docs used `skills.entries.predictclaw.env`; treat that as a host-version-specific convenience, not the canonical PredictClaw config surface.

The SKILL frontmatter metadata intentionally lists only the universal entry variables: `PREDICT_ENV` and `PREDICT_WALLET_MODE`. OpenClaw's runtime metadata is flat rather than mode-aware, so listing every optional signer or vault variable there would incorrectly imply they are all required at the same time. The mode-specific requirements are documented below and enforced by the runtime config validator.

## First-time setup (recommended)

1. Install the skill and run `uv sync`.
2. Copy `.env.example` to `.env` inside `~/.openclaw/skills/predictclaw/`.
3. Pick exactly one wallet mode.
4. Fill only the variables required for that mode.
5. Verify the install with `uv run python scripts/predictclaw.py --help`.
6. Then run a mode-appropriate command:
   - `read-only` -> `uv run python scripts/predictclaw.py markets trending`
   - `eoa` / `predict-account` -> `uv run python scripts/predictclaw.py wallet status --json`
   - `mandated-vault` -> `uv run python scripts/predictclaw.py wallet deposit --json`

## Configuration examples

The snippets below are `.env` examples. Put them in `{baseDir}/.env` or export the same names in your shell.

`OPENROUTER_API_KEY` only matters for non-fixture `hedge scan` / `hedge analyze` usage. It is not required for market, wallet, or buy flows.

### read-only mode

```dotenv
PREDICT_ENV=test-fixture
PREDICT_WALLET_MODE=read-only
```

Use this for market browsing only. Switch to `eoa`, `predict-account`, or `mandated-vault` before using wallet or trade subcommands.

### eoa mode

```dotenv
PREDICT_ENV=testnet
PREDICT_WALLET_MODE=eoa
PREDICT_API_BASE_URL=https://dev.predict.fun
PREDICT_PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY
```

### predict-account mode

```dotenv
PREDICT_ENV=testnet
PREDICT_WALLET_MODE=predict-account
PREDICT_API_BASE_URL=https://dev.predict.fun
PREDICT_ACCOUNT_ADDRESS=0xYOUR_PREDICT_ACCOUNT
PREDICT_PRIVY_PRIVATE_KEY=0xYOUR_PRIVY_EXPORTED_KEY
```

### mandated-vault mode (explicit deployed vault)

```dotenv
PREDICT_ENV=testnet
PREDICT_WALLET_MODE=mandated-vault
ERC_MANDATED_VAULT_ADDRESS=0xYOUR_DEPLOYED_VAULT
ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp
ERC_MANDATED_CHAIN_ID=97
```

Add `ERC_MANDATED_AUTHORITY_PRIVATE_KEY` when your current MCP path needs the single-key preflight signer.

### mandated-vault mode (predicted / undeployed vault)

```dotenv
PREDICT_ENV=testnet
PREDICT_WALLET_MODE=mandated-vault
ERC_MANDATED_FACTORY_ADDRESS=0xYOUR_FACTORY
ERC_MANDATED_VAULT_ASSET_ADDRESS=0xYOUR_ASSET
ERC_MANDATED_VAULT_NAME=Mandated Vault
ERC_MANDATED_VAULT_SYMBOL=MVLT
ERC_MANDATED_VAULT_AUTHORITY=0xYOUR_AUTHORITY
ERC_MANDATED_VAULT_SALT=0xYOUR_SALT
ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp
ERC_MANDATED_CONTRACT_VERSION=v0.3.0-agent-contract
ERC_MANDATED_CHAIN_ID=97
```

`ERC_MANDATED_EXECUTOR_PRIVATE_KEY` is optional. When it is unset, PredictClaw reuses `ERC_MANDATED_AUTHORITY_PRIVATE_KEY` as the executor signer for the current Preflight MVP contract.

In that path, PredictClaw asks the MCP to predict the vault address and, when the vault is still undeployed, returns create-vault preparation guidance only. It does **not** auto-broadcast and instead reports `manual-only` preparation details.

### predict-account + vault overlay (recommended advanced funding route)

```dotenv
PREDICT_ENV=testnet
PREDICT_WALLET_MODE=predict-account
PREDICT_API_BASE_URL=https://dev.predict.fun
PREDICT_ACCOUNT_ADDRESS=0xYOUR_PREDICT_ACCOUNT
PREDICT_PRIVY_PRIVATE_KEY=0xYOUR_PRIVY_EXPORTED_KEY
ERC_MANDATED_VAULT_ADDRESS=0xYOUR_DEPLOYED_VAULT
ERC_MANDATED_VAULT_ASSET_ADDRESS=0xYOUR_ASSET
ERC_MANDATED_VAULT_AUTHORITY=0xYOUR_AUTHORITY
ERC_MANDATED_AUTHORITY_PRIVATE_KEY=0xYOUR_VAULT_AUTHORITY_KEY
ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp
ERC_MANDATED_CONTRACT_VERSION=v0.3.0-agent-contract
ERC_MANDATED_CHAIN_ID=97
```

If you do **not** have an explicit deployed vault address yet, keep the same Predict Account pair and replace `ERC_MANDATED_VAULT_ADDRESS` with the full derivation tuple: `ERC_MANDATED_FACTORY_ADDRESS`, `ERC_MANDATED_VAULT_ASSET_ADDRESS`, `ERC_MANDATED_VAULT_NAME`, `ERC_MANDATED_VAULT_SYMBOL`, `ERC_MANDATED_VAULT_AUTHORITY`, and `ERC_MANDATED_VAULT_SALT`.

Optional overlay caps:

```dotenv
ERC_MANDATED_FUNDING_MAX_AMOUNT_PER_TX=5000000000000000000
ERC_MANDATED_FUNDING_MAX_AMOUNT_PER_WINDOW=10000000000000000000
ERC_MANDATED_FUNDING_WINDOW_SECONDS=3600
```

In the overlay route, Predict Account remains the deposit/trading account while Vault funds the Predict Account through MCP-backed session and asset-transfer planning.

## Wallet Modes

PredictClaw supports four explicit wallet modes:

- `read-only` — browse market data only; no signer-backed wallet actions.
- `eoa` — direct signer path for wallet, trade, and funding flows.
- `predict-account` — smart-wallet funding/trading path using `PREDICT_ACCOUNT_ADDRESS` plus `PREDICT_PRIVY_PRIVATE_KEY`.
- `mandated-vault` — advanced explicit opt-in control-plane path for protected vault-only status/deposit flows.

### Recommended route

If you want to keep the official Predict trading identity while letting Vault supply funds, use:

- `PREDICT_WALLET_MODE=predict-account`
- plus the required `ERC_MANDATED_*` overlay inputs

This route exposes `vault-to-predict-account` semantics in `wallet status --json` and `wallet deposit --json`.

### Pure mandated-vault boundaries

`mandated-vault` is an advanced explicit opt-in mode. Only enable it when you intentionally want MCP-assisted vault control-plane behavior.

Pure `mandated-vault` does **not** provide predict.fun trading parity. `wallet approve`, `wallet withdraw`, `buy`, `positions`, `position`, `hedge scan`, and `hedge analyze` fail closed with `unsupported-in-mandated-vault-v1`.

### Common configuration mistakes

- `read-only` is browse-only. Start with `markets ...`, not signer-backed wallet or trade commands.
- `eoa` requires `PREDICT_PRIVATE_KEY` and rejects Predict Account or mandated-vault inputs.
- `predict-account` requires both `PREDICT_ACCOUNT_ADDRESS` and `PREDICT_PRIVY_PRIVATE_KEY`.
- `mainnet` requires `PREDICT_API_KEY`.
- pure `mandated-vault` needs a working `ERC_MANDATED_MCP_COMMAND`; overlay mode also needs `ERC_MANDATED_VAULT_ASSET_ADDRESS` and `ERC_MANDATED_VAULT_AUTHORITY` for funding orchestration.

## The mandated MCP dependency (`ERC_MANDATED_MCP_COMMAND`)

`ERC_MANDATED_MCP_COMMAND` is the launcher command that PredictClaw uses to talk to the mandated-vault MCP runtime. The default value is `erc-mandated-mcp`.

This is the practical bridge between PredictClaw and the Vault control plane:

1. **Vault prediction / preparation** — predict a vault address when only the derivation tuple is available.
2. **Vault overlay orchestration** — expose `vault-to-predict-account` routing, funding-policy context, and session planning.
3. **Control-plane safety boundary** — if the MCP is missing or unhealthy, PredictClaw surfaces a fail-closed error instead of silently guessing.

If your environment packages that runtime through something like an `@erc-mandated/mcp` package, point `ERC_MANDATED_MCP_COMMAND` at the launcher it installs. PredictClaw's public contract is the command path, not a hard-coded package manager dependency.

The MCP orchestrates transport and preparation; the vault contract policy authorizes what the vault can actually execute.

## Command Surface

```bash
uv run python scripts/predictclaw.py markets trending
uv run python scripts/predictclaw.py markets search "election"
uv run python scripts/predictclaw.py market 123 --json
uv run python scripts/predictclaw.py wallet status --json
uv run python scripts/predictclaw.py wallet approve --json
uv run python scripts/predictclaw.py wallet deposit --json
uv run python scripts/predictclaw.py wallet withdraw usdt 1 0xb30741673D351135Cf96564dfD15f8e135f9C310 --json
uv run python scripts/predictclaw.py wallet withdraw bnb 0.1 0xb30741673D351135Cf96564dfD15f8e135f9C310 --json
uv run python scripts/predictclaw.py buy 123 YES 25 --json
uv run python scripts/predictclaw.py positions --json
uv run python scripts/predictclaw.py position pos-123-yes --json
uv run python scripts/predictclaw.py hedge scan --query election --json
uv run python scripts/predictclaw.py hedge analyze 101 202 --json
```

## Core workflow notes

- `wallet status` reports signer mode, funding address, balances, and approval readiness.
- `wallet deposit` shows the active funding address and accepted assets (`BNB`, `USDT`).
- `wallet withdraw` validates checksum destination, positive amount, available balance, and BNB gas headroom before attempting transfer logic.
- In fixture mode, withdraw commands return deterministic placeholder transaction hashes instead of touching a chain.
- In `predict-account + ERC_MANDATED_*` overlay, `wallet status` / `wallet deposit` expose:
  - `predictAccountAddress`
  - `tradeSignerAddress`
  - `vaultAddress`
  - `fundingRoute = vault-to-predict-account`
- Predict Account remains the deposit address / trading account.
- The funding target is the Predict Account, not the Vault and not the owner EOA.
- Optional Vault funding-policy envs let you cap Vault→Predict transfers by per-tx amount, cumulative window amount, and window duration.
- Those funding-policy amounts use raw token units; for BSC mainnet USDT (18 decimals), `5 U = 5000000000000000000` and `10 U = 10000000000000000000`.
- If the Predict Account already has enough balance, `buy` continues through the normal official Predict Account order path.
- If balance is short, `buy` fails cleanly with deterministic `funding-required` guidance and points users to `wallet deposit --json`; it does not auto-execute the vault funding leg in the current local signer context.

## Runtime Modes

- `test-fixture` — uses local JSON fixtures and deterministic wallet/hedge/trade behavior; ideal for development, integration tests, and CI.
- `testnet` — intended for live but non-mainnet checks; use `PREDICT_API_BASE_URL` or `PREDICT_SMOKE_API_BASE_URL` if your target endpoint is `https://dev.predict.fun`.
- `mainnet` — requires `PREDICT_API_KEY` and should be treated as a live-trading environment.

## Environment Variables

| Variable | Purpose |
| --- | --- |
| `PREDICT_STORAGE_DIR` | Local journal and position storage |
| `PREDICT_ENV` | Defaults to `testnet`; accepted values are `testnet`, `mainnet`, or `test-fixture` |
| `PREDICT_WALLET_MODE` | Explicit mode override: `read-only`, `eoa`, `predict-account`, or `mandated-vault` |
| `PREDICT_API_BASE_URL` | Optional REST base override; leave empty to use the env-specific default (`dev.predict.fun` for `testnet` / `test-fixture`, `api.predict.fun` for `mainnet`) |
| `PREDICT_API_KEY` | Mainnet-authenticated predict.fun API access |
| `PREDICT_PRIVATE_KEY` | EOA trading and funding path |
| `PREDICT_ACCOUNT_ADDRESS` | Predict Account smart-wallet address |
| `PREDICT_PRIVY_PRIVATE_KEY` | Privy-exported signer for Predict Account mode |
| `ERC_MANDATED_VAULT_ADDRESS` | Explicit deployed mandated vault address |
| `ERC_MANDATED_FACTORY_ADDRESS` | Factory address used to predict a vault when no explicit vault address is supplied |
| [REDACTED_SECRET] | ERC-4626 asset used in mandated-vault prediction/create preparation |
| `ERC_MANDATED_VAULT_NAME` | Vault name used in mandated-vault prediction/create preparation |
| `ERC_MANDATED_VAULT_SYMBOL` | Vault symbol used in mandated-vault prediction/create preparation |
| `ERC_MANDATED_VAULT_AUTHORITY` | Authority address and create-vault `from` address for manual preparation |
| `ERC_MANDATED_VAULT_SALT` | Deterministic salt used for vault prediction/create preparation |
| `ERC_MANDATED_AUTHORITY_PRIVATE_KEY` | Preflight Vault signer key for the current single-key MVP contract |
| `ERC_MANDATED_EXECUTOR_PRIVATE_KEY` | Optional dedicated executor signer; falls back to `ERC_MANDATED_AUTHORITY_PRIVATE_KEY` when unset |
| `ERC_MANDATED_MCP_COMMAND` | MCP launcher command (defaults to `erc-mandated-mcp`) |
| `ERC_MANDATED_CONTRACT_VERSION` | Passed through to the mandated-vault MCP client |
| `ERC_MANDATED_CHAIN_ID` | Optional explicit chain selection for the MCP bridge |
| [REDACTED_SECRET] | Optional 32-byte hex `allowedAdaptersRoot` used for Vault execution mandates; defaults to `0x11…11` for the current single-key MVP / PoC path |
| `ERC_MANDATED_FUNDING_MAX_AMOUNT_PER_TX` | Optional Vault→Predict funding-policy `maxAmountPerTx` in raw token units |
| `ERC_MANDATED_FUNDING_MAX_AMOUNT_PER_WINDOW` | Optional Vault→Predict funding-policy `maxAmountPerWindow` in raw token units |
| `ERC_MANDATED_FUNDING_WINDOW_SECONDS` | Optional Vault→Predict funding-policy `windowSeconds` |
| `OPENROUTER_API_KEY` | Hedge analysis model access |
| `PREDICT_MODEL` | OpenRouter model override |
| `PREDICT_SMOKE_ENV` | Enables the smoke suite |
| `PREDICT_SMOKE_API_BASE_URL` | Optional smoke REST base override |
| `PREDICT_SMOKE_PRIVATE_KEY` | Enables signer/JWT smoke checks |
| `PREDICT_SMOKE_ACCOUNT_ADDRESS` | Predict Account smoke mode |
| `PREDICT_SMOKE_PRIVY_PRIVATE_KEY` | Predict Account smoke signer |
| `PREDICT_SMOKE_API_KEY` | Smoke REST auth |

## Hedge notes

- Hedge analysis uses OpenRouter over plain HTTP with a JSON-only contract.
- `OPENROUTER_API_KEY` is only required for non-fixture hedge analysis.
- Fixture mode uses deterministic keyword- and pairing-based hedge portfolios so CLI and integration tests stay secret-free.
- The current public command surface remains PolyClaw-parity plus `wallet deposit` / `wallet withdraw`; there is no public `sell` command in v1.

## Project Layout

- `scripts/predictclaw.py` — top-level CLI router
- `scripts/` — command-specific entry points
- `lib/` — config, auth, REST, wallet, funding, trade, positions, hedge, and MCP bridge logic
- `tests/` — unit, integration, and smoke coverage for the Python skill package

## Verification Layers

```bash
# unit + command tests
uv run pytest -q

# fixture-backed end-to-end CLI checks
uv run pytest tests/integration -q

# env-gated smoke (passes or skips)
uv run pytest tests/smoke/test_testnet_smoke.py -q
```

## Safety Notes

- Do not treat fixture mode as proof of funded-wallet behavior.
- Do not assume live liquidity from testnet or mainnet docs alone.
- Keep only limited funds on automation keys.
- Withdrawal commands are public; transfer validation happens before chain interaction, but users still own the operational risk.
- `predict-account + ERC_MANDATED_*` is the recommended advanced trading route when you want Vault to fund the Predict Account while keeping the official Predict Account order model.
- Explicit-vs-predicted vault semantics: `ERC_MANDATED_VAULT_ADDRESS` targets an existing vault directly; otherwise PredictClaw uses the derivation tuple to ask the MCP for the predicted vault address.
- If a predicted vault is undeployed, `wallet deposit` can return create-vault preparation details (`predictedVault`, transaction summary, `manual-only`) without broadcasting.
- Pure `mandated-vault` does not provide predict.fun trading parity and intentionally fails closed for unsupported paths with `unsupported-in-mandated-vault-v1`.


path: README.zh-CN.md
content: # PredictClaw

[English Version / 英文版](./README.md)

PredictClaw 是一个面向 **OpenClaw** 的 `predict.fun` skill / CLI，用于浏览市场、检查钱包 readiness、查看入金地址、执行买入、查询持仓，以及在需要时分析对冲机会。

`predict/` 目录是 `mandated-vault-factory` 中的独立 Python 子项目。它拥有自己的 CLI、运行时配置和测试体系，不会改动仓库根目录的 Foundry 工作流。

## PredictClaw 的作用

PredictClaw 为 OpenClaw 提供一套稳定的 predict.fun 工作流接口：

- 浏览市场与查看单市场详情
- 检查钱包 readiness 与入金引导
- 通过 EOA 或 Predict Account 路径交易
- 记录并查询持仓
- 在启用模型能力时执行可选 hedge 分析

对于高级资金路线，PredictClaw 支持在官方 Predict Account 路径上叠加 **Vault funding overlay**。在这条路线中，**Predict Account remains** 交易身份与入金地址，而 Vault 作为资金来源。

## 安装

### 通过 ClawHub 安装

```bash
clawhub install predictclaw
cd ~/.openclaw/skills/predictclaw
uv sync
cp .env.example .env
```

通过打包方式安装后，skill 目录通常就在 `~/.openclaw/skills/predictclaw`。在 OpenClaw 的一些清单或示例里，这个路径也可能写成 `{baseDir}`。

### 手动安装

1. 将仓库中的 `predict/` 目录复制或软链接到 `~/.openclaw/skills/predictclaw/`
2. 在安装后的 skill 目录执行：

```bash
cd {baseDir} && uv sync
cd {baseDir} && cp .env.example .env
```

### 在仓库内本地开发

进入 `mandated-vault-factory/predict` 后执行：

```bash
uv sync
uv run pytest -q
uv run python scripts/predictclaw.py --help
```

## 配置实际上是怎么生效的

PredictClaw 本身只读取标准环境变量。当前已验证、可用的输入方式只有两种：

- 进程环境变量，例如 `export PREDICT_ENV=testnet`
- 本地 `{baseDir}/.env` 文件；`scripts/predictclaw.py` 启动时会自动加载它

如果两者同时存在，显式导出的环境变量优先，`.env` 只会补齐缺失项。

如果你的 OpenClaw 宿主版本会把配置注入成环境变量，PredictClaw 也能工作，因为它最终看到的仍然是普通 env。旧文档里提到过 `skills.entries.predictclaw.env`；现在应把它视为“某些宿主版本可能支持的便利映射”，而不是 PredictClaw 自己保证的标准配置入口。

SKILL frontmatter 的 metadata 里故意只声明通用入口变量：`PREDICT_ENV` 和 `PREDICT_WALLET_MODE`。OpenClaw 当前的 runtime metadata 是扁平的，不是按 mode 感知的；如果把所有 signer / vault 变量都塞进去，就会错误地暗示它们需要一次性全部填写。各模式自己的必填项以下面的示例和运行时配置校验为准。

## 首次配置（推荐）

1. 安装 skill 并执行 `uv sync`。
2. 在 `~/.openclaw/skills/predictclaw/` 中把 `.env.example` 复制为 `.env`。
3. 只选择一种 wallet mode。
4. 只填写该模式需要的最小变量集。
5. 先用 `uv run python scripts/predictclaw.py --help` 确认安装无误。
6. 再按模式做一次验证：
   - `read-only` -> `uv run python scripts/predictclaw.py markets trending`
   - `eoa` / `predict-account` -> `uv run python scripts/predictclaw.py wallet status --json`
   - `mandated-vault` -> `uv run python scripts/predictclaw.py wallet deposit --json`

## 配置示例

下面的片段全部使用 `.env` 写法。你可以直接写进 `{baseDir}/.env`，也可以在 shell 里 `export` 同名变量。

`OPENROUTER_API_KEY` 只在非 fixture 的 `hedge scan` / `hedge analyze` 中需要；market、wallet、buy 流程都不依赖它。

### read-only 模式

```dotenv
PREDICT_ENV=test-fixture
PREDICT_WALLET_MODE=read-only
```

这个模式只适合浏览市场。需要执行 wallet / trade 子命令时，请切换到 `eoa`、`predict-account` 或 `mandated-vault`。

### eoa 模式

```dotenv
PREDICT_ENV=testnet
PREDICT_WALLET_MODE=eoa
PREDICT_API_BASE_URL=https://dev.predict.fun
PREDICT_PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY
```

### predict-account 模式

```dotenv
PREDICT_ENV=testnet
PREDICT_WALLET_MODE=predict-account
PREDICT_API_BASE_URL=https://dev.predict.fun
PREDICT_ACCOUNT_ADDRESS=0xYOUR_PREDICT_ACCOUNT
PREDICT_PRIVY_PRIVATE_KEY=0xYOUR_PRIVY_EXPORTED_KEY
```

### mandated-vault 模式（已知 deployed vault）

```dotenv
PREDICT_ENV=testnet
PREDICT_WALLET_MODE=mandated-vault
ERC_MANDATED_VAULT_ADDRESS=0xYOUR_DEPLOYED_VAULT
ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp
ERC_MANDATED_CHAIN_ID=97
```

如果你当前的 MCP 路径需要 single-key preflight signer，再额外设置 `ERC_MANDATED_AUTHORITY_PRIVATE_KEY`。

### mandated-vault 模式（predicted / 未部署 vault）

```dotenv
PREDICT_ENV=testnet
PREDICT_WALLET_MODE=mandated-vault
ERC_MANDATED_FACTORY_ADDRESS=0xYOUR_FACTORY
ERC_MANDATED_VAULT_ASSET_ADDRESS=0xYOUR_ASSET
ERC_MANDATED_VAULT_NAME=Mandated Vault
ERC_MANDATED_VAULT_SYMBOL=MVLT
ERC_MANDATED_VAULT_AUTHORITY=0xYOUR_AUTHORITY
ERC_MANDATED_VAULT_SALT=0xYOUR_SALT
ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp
ERC_MANDATED_CONTRACT_VERSION=v0.3.0-agent-contract
ERC_MANDATED_CHAIN_ID=97
```

`ERC_MANDATED_EXECUTOR_PRIVATE_KEY` 是可选项。未设置时，PredictClaw 会在当前 Preflight MVP 合约路径中复用 `ERC_MANDATED_AUTHORITY_PRIVATE_KEY` 作为 executor signer。

在这条路径中，PredictClaw 会通过 MCP 预测 vault 地址；如果 vault 尚未部署，则返回 create-vault preparation 信息，而不会自动广播。该准备信息会以 `manual-only` 语义暴露。

### predict-account + vault overlay（推荐的高级资金路线）

```dotenv
PREDICT_ENV=testnet
PREDICT_WALLET_MODE=predict-account
PREDICT_API_BASE_URL=https://dev.predict.fun
PREDICT_ACCOUNT_ADDRESS=0xYOUR_PREDICT_ACCOUNT
PREDICT_PRIVY_PRIVATE_KEY=0xYOUR_PRIVY_EXPORTED_KEY
ERC_MANDATED_VAULT_ADDRESS=0xYOUR_DEPLOYED_VAULT
ERC_MANDATED_VAULT_ASSET_ADDRESS=0xYOUR_ASSET
ERC_MANDATED_VAULT_AUTHORITY=0xYOUR_AUTHORITY
ERC_MANDATED_AUTHORITY_PRIVATE_KEY=0xYOUR_VAULT_AUTHORITY_KEY
ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp
ERC_MANDATED_CONTRACT_VERSION=v0.3.0-agent-contract
ERC_MANDATED_CHAIN_ID=97
```

在 overlay 路线中，Predict Account remains 入金地址与交易身份，而 Vault 通过 MCP 支持的 session / asset-transfer 规划来为 Predict Account 提供资金。

如果你还没有显式的 vault 地址，保留同一组 Predict Account 凭据，并把 `ERC_MANDATED_VAULT_ADDRESS` 改成完整 derivation tuple：`ERC_MANDATED_FACTORY_ADDRESS`、`ERC_MANDATED_VAULT_ASSET_ADDRESS`、`ERC_MANDATED_VAULT_NAME`、`ERC_MANDATED_VAULT_SYMBOL`、`ERC_MANDATED_VAULT_AUTHORITY`、`ERC_MANDATED_VAULT_SALT`。

可选的 overlay 限额：

```dotenv
ERC_MANDATED_FUNDING_MAX_AMOUNT_PER_TX=5000000000000000000
ERC_MANDATED_FUNDING_MAX_AMOUNT_PER_WINDOW=10000000000000000000
ERC_MANDATED_FUNDING_WINDOW_SECONDS=3600
```

这些变量会限制 Vault→Predict 的单笔额度、窗口累计额度和窗口时长。以 BSC mainnet USDT 为例：`5U = 5000000000000000000`，`10U = 10000000000000000000`。

## 钱包模式

PredictClaw 支持四种显式 wallet mode：

- `read-only` — 仅浏览市场数据，不允许 signer-backed 钱包动作。
- `eoa` — 直接私钥 signer 路径，用于钱包、交易和资金相关流程。
- `predict-account` — 使用 `PREDICT_ACCOUNT_ADDRESS` 与 `PREDICT_PRIVY_PRIVATE_KEY` 的官方 Predict Account 路径。
- `mandated-vault` — 高级显式 opt-in control-plane 路径，仅用于受保护的 vault 状态与入金准备流程。

### 推荐路线

如果你希望保留官方 Predict 交易身份，同时由 Vault 提供资金，请使用：

- `PREDICT_WALLET_MODE=predict-account`
- 再叠加所需的 `ERC_MANDATED_*` overlay 输入

这样在 `wallet status --json` 与 `wallet deposit --json` 中会暴露 `vault-to-predict-account` 语义。

### pure mandated-vault 边界

`mandated-vault` 是一个高级显式 opt-in 模式，只适合你明确需要 MCP 辅助的 vault control-plane 时使用。

pure `mandated-vault` 不提供 predict.fun trading parity。`wallet approve`、`wallet withdraw`、`buy`、`positions`、`position`、`hedge scan`、`hedge analyze` 都会 fail closed，并返回 `unsupported-in-mandated-vault-v1`。

### 常见配置错误

- `read-only` 只能浏览市场；不要直接跑 signer-backed 的 wallet / trade 命令。
- `eoa` 必须配置 `PREDICT_PRIVATE_KEY`，并且不能混用 Predict Account 或 mandated-vault 输入。
- `predict-account` 必须同时配置 `PREDICT_ACCOUNT_ADDRESS` 和 `PREDICT_PRIVY_PRIVATE_KEY`。
- `mainnet` 必须配置 `PREDICT_API_KEY`。
- pure `mandated-vault` 需要一个可用的 `ERC_MANDATED_MCP_COMMAND`；overlay 模式还需要 `ERC_MANDATED_VAULT_ASSET_ADDRESS` 与 `ERC_MANDATED_VAULT_AUTHORITY` 才能完成 funding orchestration。

## `ERC_MANDATED_MCP_COMMAND` / `@erc-mandated/mcp` 依赖说明

`ERC_MANDATED_MCP_COMMAND` 是 PredictClaw 与 mandated-vault MCP runtime 通信时使用的启动命令，默认值为 `erc-mandated-mcp`。

它在实际工作流中的作用有三层：

1. **Vault 预测与准备** — 当只有 derivation tuple 时，用 MCP 预测 vault 地址并生成准备信息。
2. **Vault overlay 编排** — 在 overlay 模式下暴露 `vault-to-predict-account` 路由、funding-policy 上下文与会话规划。
3. **控制面安全边界** — 当 MCP 不存在或不可用时，PredictClaw 会显式 fail closed，而不是静默猜测。

如果你的环境通过类似 `@erc-mandated/mcp` 的包来提供这套 runtime，那么你真正需要给 PredictClaw 配置的是该 runtime 对应的启动命令，也就是 `ERC_MANDATED_MCP_COMMAND`。PredictClaw 对外公开的契约是“命令入口”，而不是某个固定的包管理器依赖。

MCP orchestrates transport and preparation; the vault contract policy authorizes what the vault can actually execute.

## 命令面

```bash
uv run python scripts/predictclaw.py markets trending
uv run python scripts/predictclaw.py markets search "election"
uv run python scripts/predictclaw.py market 123 --json
uv run python scripts/predictclaw.py wallet status --json
uv run python scripts/predictclaw.py wallet approve --json
uv run python scripts/predictclaw.py wallet deposit --json
uv run python scripts/predictclaw.py wallet withdraw usdt 1 0xb30741673D351135Cf96564dfD15f8e135f9C310 --json
uv run python scripts/predictclaw.py wallet withdraw bnb 0.1 0xb30741673D351135Cf96564dfD15f8e135f9C310 --json
uv run python scripts/predictclaw.py buy 123 YES 25 --json
uv run python scripts/predictclaw.py positions --json
uv run python scripts/predictclaw.py position pos-123-yes --json
uv run python scripts/predictclaw.py hedge scan --query election --json
uv run python scripts/predictclaw.py hedge analyze 101 202 --json
```

## 核心工作流说明

- `wallet status` 会报告 signer mode、funding address、余额和 approval readiness。
- `wallet deposit` 会展示当前有效的 funding address 与可接受资产（`BNB`、`USDT`）。
- `wallet withdraw` 在尝试执行转账逻辑前会先验证目标地址 checksum、金额为正、余额充足以及 BNB gas 余量。
- 在 fixture 模式下，withdraw 会返回确定性的占位 tx hash，而不会触链。
- 在 `predict-account + ERC_MANDATED_*` overlay 中，`wallet status` / `wallet deposit` 会暴露：
  - `predictAccountAddress`
  - `tradeSignerAddress`
  - `vaultAddress`
  - `fundingRoute = vault-to-predict-account`
- Predict Account remains 入金地址与交易账户。
- 资金目标是 Predict Account，而不是 Vault，也不是 owner EOA。
- 可选的 Vault funding-policy 环境变量允许你限制 Vault→Predict 转账的单笔额度、窗口累计额度和窗口时长。
- 这些 funding-policy 金额使用 raw token units；以 BSC mainnet USDT（18 decimals）为例：`5 U = 5000000000000000000`，`10 U = 10000000000000000000`。
- 如果 Predict Account 已有足够余额，`buy` 会继续走官方 Predict Account 下单路径。
- 如果余额不足，`buy` 会以确定性的 `funding-required` 引导失败，并提示用户查看 `wallet deposit --json`；当前本地 signer 上下文不会自动执行 vault 资金腿。

## 运行模式

- `test-fixture` — 使用本地 JSON fixture 和确定性的 wallet / hedge / trade 行为，适合开发、集成测试与 CI。
- `testnet` — 用于 live 但非 mainnet 的检查；如果目标端点是 `https://dev.predict.fun`，可通过 `PREDICT_API_BASE_URL` 或 `PREDICT_SMOKE_API_BASE_URL` 覆盖。
- `mainnet` — 需要 `PREDICT_API_KEY`，应视为真实交易环境。

## 环境变量

| 变量 | 作用 |
| --- | --- |
| `PREDICT_STORAGE_DIR` | 本地 journal 与持仓存储 |
| `PREDICT_ENV` | 默认为 `testnet`；可选 `testnet`、`mainnet`、`test-fixture` |
| `PREDICT_WALLET_MODE` | 显式模式覆盖：`read-only`、`eoa`、`predict-account`、`mandated-vault` |
| `PREDICT_API_BASE_URL` | 可选 REST base override；留空时按环境自动选择（`testnet` / `test-fixture` -> `dev.predict.fun`，`mainnet` -> `api.predict.fun`） |
| `PREDICT_API_KEY` | mainnet 认证后的 predict.fun API 访问 |
| `PREDICT_PRIVATE_KEY` | EOA 交易与资金路径 |
| `PREDICT_ACCOUNT_ADDRESS` | Predict Account 智能钱包地址 |
| `PREDICT_PRIVY_PRIVATE_KEY` | Predict Account 模式下的 Privy 导出 signer |
| `ERC_MANDATED_VAULT_ADDRESS` | 已知部署好的 mandated vault 显式地址 |
| `ERC_MANDATED_FACTORY_ADDRESS` | 当未提供显式 vault 地址时，用于预测 vault 的 factory 地址 |
| [REDACTED_SECRET] | mandated-vault 预测 / create 准备时使用的 ERC-4626 资产 |
| `ERC_MANDATED_VAULT_NAME` | mandated-vault 预测 / create 准备时使用的 Vault 名称 |
| `ERC_MANDATED_VAULT_SYMBOL` | mandated-vault 预测 / create 准备时使用的 Vault 符号 |
| `ERC_MANDATED_VAULT_AUTHORITY` | Authority 地址，以及手动 create-vault 准备中的 `from` 地址 |
| `ERC_MANDATED_VAULT_SALT` | 用于 vault 预测 / create 准备的确定性 salt |
| `ERC_MANDATED_AUTHORITY_PRIVATE_KEY` | 当前单密钥 MVP 合约路径的 Preflight Vault signer key |
| `ERC_MANDATED_EXECUTOR_PRIVATE_KEY` | 可选的 executor signer；未设置时回退到 `ERC_MANDATED_AUTHORITY_PRIVATE_KEY` |
| `ERC_MANDATED_MCP_COMMAND` | MCP 启动命令（默认 `erc-mandated-mcp`） |
| `ERC_MANDATED_CONTRACT_VERSION` | 透传给 mandated-vault MCP client 的版本号 |
| `ERC_MANDATED_CHAIN_ID` | MCP bridge 的可选显式链选择 |
| [REDACTED_SECRET] | 可选的 32-byte hex `allowedAdaptersRoot`，用于 Vault 执行 mandate；当前单密钥 MVP / PoC 路径默认 `0x11…11` |
| `ERC_MANDATED_FUNDING_MAX_AMOUNT_PER_TX` | 可选的 Vault→Predict funding-policy `maxAmountPerTx`（raw token units） |
| `ERC_MANDATED_FUNDING_MAX_AMOUNT_PER_WINDOW` | 可选的 Vault→Predict funding-policy `maxAmountPerWindow`（raw token units） |
| `ERC_MANDATED_FUNDING_WINDOW_SECONDS` | 可选的 Vault→Predict funding-policy `windowSeconds` |
| `OPENROUTER_API_KEY` | hedge analysis 模型访问 |
| `PREDICT_MODEL` | OpenRouter 模型覆盖 |
| `PREDICT_SMOKE_ENV` | 启用 smoke suite |
| `PREDICT_SMOKE_API_BASE_URL` | 可选 smoke REST base override |
| `PREDICT_SMOKE_PRIVATE_KEY` | 启用 signer/JWT smoke 检查 |
| `PREDICT_SMOKE_ACCOUNT_ADDRESS` | Predict Account smoke 模式 |
| `PREDICT_SMOKE_PRIVY_PRIVATE_KEY` | Predict Account smoke signer |
| `PREDICT_SMOKE_API_KEY` | smoke REST auth |

## Hedge 说明

- Hedge analysis 通过 OpenRouter 的纯 HTTP JSON 合约实现。
- `OPENROUTER_API_KEY` 只在非 fixture 的 hedge analysis 中需要。
- fixture 模式使用确定性的关键字 / 配对逻辑，因此 CLI 与 integration tests 可以保持 secret-free。
- 当前公开命令面仍是 PolyClaw 对齐加上 `wallet deposit` / `wallet withdraw`；v1 仍没有公开 `sell` 命令。

## 项目结构

- `scripts/predictclaw.py` — 顶层 CLI router
- `scripts/` — 各命令入口脚本
- `lib/` — config、auth、REST、wallet、funding、trade、positions、hedge 与 MCP bridge 逻辑
- `tests/` — Python skill package 的 unit、integration、smoke 覆盖

## 验证层

```bash
# unit + command tests
uv run pytest -q

# fixture-backed end-to-end CLI checks
uv run pytest tests/integration -q

# env-gated smoke (passes or skips)
uv run pytest tests/smoke/test_testnet_smoke.py -q
```

## 安全说明

- 不要把 fixture 模式视作 funded-wallet 行为的证明。
- 不要仅凭 testnet 或 mainnet 文档就假设 live liquidity 一定存在。
- 自动化密钥应只保留有限资金。
- Withdraw 命令是公开能力；虽然转账前会做验证，但实际操作风险仍由用户承担。
- `predict-account + ERC_MANDATED_*` 是推荐的高级交易路线：Vault 给 Predict Account 提供资金，但 Predict Account 保持官方订单模型。
- 显式 vault 与 predicted vault 语义：`ERC_MANDATED_VAULT_ADDRESS` 直接指向已有 vault；否则 PredictClaw 会使用 derivation tuple 通过 MCP 预测 vault 地址。
- 如果 predicted vault 尚未部署，`wallet deposit` 可以返回包含 `predictedVault`、交易摘要和 `manual-only` 的 create-vault preparation 信息，但不会自动广播。
- pure `mandated-vault` 不提供 predict.fun trading parity，并会对不支持的路径以 `unsupported-in-mandated-vault-v1` 明确 fail closed。


path: lib/__init__.py
content: """PredictClaw shared library package."""

from .api import PredictApiClient, PredictApiError
from .auth import PredictAuthenticator
from .config import ConfigError, PredictConfig, RuntimeEnv, WalletMode
from .funding_service import FundingService
from .wallet_manager import ApprovalSnapshot, WalletManager

__all__ = [
    "ConfigError",
    "PredictApiClient",
    "PredictApiError",
    "PredictAuthenticator",
    "PredictConfig",
    "RuntimeEnv",
    "WalletMode",
    "FundingService",
    "ApprovalSnapshot",
    "WalletManager",
]


path: lib/api.py
content: from __future__ import annotations

import asyncio
from collections.abc import Awaitable, Callable, Mapping
from typing import Any

import httpx

from .config import PredictConfig, redact_text
from .models import (
    AuthMessageResponse,
    AuthRequest,
    JwtResponse,
    LastSaleRecord,
    MarketRecord,
    MarketStatsRecord,
    OrderBookRecord,
    OrderRecord,
    PositionRecord,
    extract_list,
    extract_object,
)


class PredictApiError(RuntimeError):
    """Raised when the predict.fun REST API returns an error or cannot be reached."""

    def __init__(
        self,
        message: str,
        *,
        status_code: int | None = None,
        method: str | None = None,
        path: str | None = None,
    ) -> None:
        super().__init__(message)
        self.status_code = status_code
        self.method = method
        self.path = path


class PredictApiClient:
    def __init__(
        self,
        config: PredictConfig,
        *,
        client: httpx.AsyncClient | None = None,
        jwt_provider: Callable[[], Awaitable[str]] | None = None,
        sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
    ) -> None:
        self._config = config
        self._jwt_provider = jwt_provider
        self._sleep = sleep
        self._owns_client = client is None
        self._client = client or httpx.AsyncClient(
            base_url=config.api_base_url,
            timeout=config.http_timeout_seconds,
        )

    async def close(self) -> None:
        if self._owns_client:
            await self._client.aclose()

    async def get_auth_message(self) -> AuthMessageResponse:
        payload = await self._request_json("GET", "/v1/auth/message")
        return AuthMessageResponse.from_api(payload)

    async def get_jwt(self, auth_request: AuthRequest) -> JwtResponse:
        payload = await self._request_json(
            "POST",
            "/v1/auth",
            json=auth_request.model_dump(),
        )
        return JwtResponse.from_api(payload)

    async def get_markets(self, **params: Any) -> list[MarketRecord]:
        payload = await self._request_json("GET", "/v1/markets", params=params)
        return [
            MarketRecord.model_validate(item)
            for item in extract_list(payload, "markets", "items")
        ]

    async def get_market(self, market_id: str | int) -> MarketRecord:
        payload = await self._request_json("GET", f"/v1/markets/{market_id}")
        return MarketRecord.model_validate(extract_object(payload, "market", "data"))

    async def get_market_stats(self, market_id: str | int) -> MarketStatsRecord:
        payload = await self._request_json("GET", f"/v1/markets/{market_id}/stats")
        return MarketStatsRecord.model_validate(
            extract_object(payload, "stats", "data")
        )

    async def get_market_last_sale(self, market_id: str | int) -> LastSaleRecord:
        payload = await self._request_json("GET", f"/v1/markets/{market_id}/last-sale")
        return LastSaleRecord.model_validate(
            extract_object(payload, "lastSale", "data")
        )

    async def get_orderbook(self, market_id: str | int) -> OrderBookRecord:
        payload = await self._request_json("GET", f"/v1/markets/{market_id}/orderbook")
        return OrderBookRecord.model_validate(extract_object(payload, "book", "data"))

    async def get_order(self, order_hash: str) -> OrderRecord:
        payload = await self._request_json(
            "GET",
            f"/v1/orders/{order_hash}",
            authenticated=True,
        )
        return _parse_order_record(payload)

    async def get_orders(self) -> list[OrderRecord]:
        payload = await self._request_json("GET", "/v1/orders", authenticated=True)
        return [_normalize_order_record(item) for item in extract_list(payload, "orders", "items")]

    async def create_order(self, order_payload: Mapping[str, Any]) -> OrderRecord:
        payload = await self._request_json(
            "POST",
            "/v1/orders",
            json=_serialize_create_order_payload(order_payload),
            authenticated=True,
        )
        return _parse_order_record(payload)

    async def get_positions(self) -> list[PositionRecord]:
        payload = await self._request_json("GET", "/v1/positions", authenticated=True)
        return [
            _normalize_position_record(item)
            for item in extract_list(payload, "positions", "items")
        ]

    async def _request_json(
        self,
        method: str,
        path: str,
        *,
        authenticated: bool = False,
        params: Mapping[str, Any] | None = None,
        json: Mapping[str, Any] | None = None,
    ) -> dict[str, Any]:
        headers = await self._build_headers(authenticated=authenticated)
        attempts = self._config.retry_attempts

        for attempt in range(attempts):
            try:
                response = await self._client.request(
                    method,
                    path,
                    headers=headers,
                    params=params,
                    json=json,
                )
            except httpx.HTTPError as error:
                if attempt < attempts - 1:
                    await self._sleep(
                        self._config.retry_backoff_seconds * (attempt + 1)
                    )
                    continue
                raise PredictApiError(
                    self._format_transport_error(method, path, error),
                    method=method,
                    path=path,
                ) from error

            if response.status_code == 429 or 500 <= response.status_code < 600:
                if attempt < attempts - 1:
                    await self._sleep(
                        self._config.retry_backoff_seconds * (attempt + 1)
                    )
                    continue

            if response.is_error:
                raise PredictApiError(
                    self._format_response_error(method, path, response),
                    status_code=response.status_code,
                    method=method,
                    path=path,
                )

            payload = response.json()
            if isinstance(payload, dict):
                return payload
            return {"data": payload}

        raise PredictApiError(
            f"predict.fun API request exhausted retries: {method} {path}",
            method=method,
            path=path,
        )

    async def _build_headers(self, *, authenticated: bool) -> dict[str, str]:
        headers = {"Accept": "application/json"}
        if self._config.[REDACTED_SECRET]"X-API-Key"] = self._config.api_key.get_secret_value()
        if authenticated:
            if self._jwt_provider is None:
                raise PredictApiError(
                    "Authenticated predict.fun request requires a JWT provider.",
                    method="AUTH",
                )
            headers["Authorization"] = f"Bearer {await self._jwt_provider()}"
        return headers

    def _format_transport_error(self, method: str, path: str, error: Exception) -> str:
        message = f"predict.fun API transport error during {method} {path}: {error}"
        return redact_text(message, self._secrets())

    def _format_response_error(
        self, method: str, path: str, response: httpx.Response
    ) -> str:
        body = response.text[:240]
        message = f"predict.fun API request failed for {method} {path} with status {response.status_code}: {body}"
        return redact_text(message, self._secrets())

    def _secrets(self) -> list[str | None]:
        return [
            self._config.api_key.get_secret_value() if self._config.api_key else None,
            self._config.private_key_value,
            self._config.privy_private_key_value,
            self._config.openrouter_api_key.get_secret_value()
            if self._config.openrouter_api_key
            else None,
        ]


_CREATE_ORDER_META_FIELD_MAP = {
    "pricePerShare": "pricePerShare",
    "price_per_share": "pricePerShare",
    "strategy": "strategy",
    "slippageBps": "slippageBps",
    "slippage_bps": "slippageBps",
    "expirationMinutes": "expirationMinutes",
    "expiration_minutes": "expirationMinutes",
}


def _serialize_create_order_payload(order_payload: Mapping[str, Any]) -> dict[str, Any]:
    payload = dict(order_payload)
    if isinstance(payload.get("data"), Mapping):
        return {**payload, "data": _normalize_create_order_data(payload["data"])}
    return {"data": _normalize_create_order_data(payload)}


def _normalize_create_order_data(data: Mapping[str, Any]) -> dict[str, Any]:
    normalized: dict[str, Any] = {}
    order_value = data.get("order")

    if isinstance(order_value, Mapping):
        normalized["order"] = _camelize_order_payload(order_value)
    else:
        normalized["order"] = _camelize_order_payload(data)

    for source_key, target_key in _CREATE_ORDER_META_FIELD_MAP.items():
        value = data.get(source_key)
        if value is not None:
            normalized[target_key] = value

    return _drop_none(normalized)


def _camelize_order_payload(order_payload: Mapping[str, Any]) -> dict[str, Any]:
    return {
        _camelize_key(key): value
        for key, value in order_payload.items()
        if value is not None and key not in _CREATE_ORDER_META_FIELD_MAP
    }


def _camelize_key(key: str) -> str:
    if "_" not in key:
        return key
    head, *tail = key.split("_")
    return head + "".join(part[:1].upper() + part[1:] for part in tail)


def _drop_none(value: Any) -> Any:
    if isinstance(value, Mapping):
        return {
            key: _drop_none(item)
            for key, item in value.items()
            if item is not None
        }
    if isinstance(value, list):
        return [_drop_none(item) for item in value if item is not None]
    return value


def _parse_order_record(payload: Mapping[str, Any]) -> OrderRecord:
    return _normalize_order_record(extract_object(dict(payload), "order", "data"))


def _normalize_order_record(payload: Mapping[str, Any]) -> OrderRecord:
    normalized = dict(payload)
    if "hash" not in normalized and isinstance(normalized.get("orderHash"), str):
        normalized["hash"] = normalized["orderHash"]
    if "status" not in normalized and isinstance(normalized.get("orderStatus"), str):
        normalized["status"] = normalized["orderStatus"]
    if "marketId" not in normalized and normalized.get("market_id") is not None:
        normalized["marketId"] = normalized["market_id"]
    return OrderRecord.model_validate(normalized)


def _normalize_position_record(payload: Mapping[str, Any]) -> PositionRecord:
    normalized = dict(payload)
    market = normalized.get("market")
    outcome = normalized.get("outcome")

    if "positionId" not in normalized and isinstance(normalized.get("id"), str):
        normalized["positionId"] = normalized["id"]
    if "marketId" not in normalized and isinstance(market, Mapping):
        normalized["marketId"] = market.get("id")
    if "tokenId" not in normalized and isinstance(outcome, Mapping):
        normalized["tokenId"] = outcome.get("onChainId") or outcome.get("tokenId")
    if "outcomeName" not in normalized and isinstance(outcome, Mapping):
        normalized["outcomeName"] = outcome.get("name")
    if "quantity" not in normalized and normalized.get("amount") is not None:
        normalized["quantity"] = normalized["amount"]
    if "status" not in normalized:
        if isinstance(market, Mapping) and isinstance(market.get("tradingStatus"), str):
            normalized["status"] = market.get("tradingStatus")
        elif isinstance(market, Mapping) and isinstance(market.get("status"), str):
            normalized["status"] = market.get("status")
    return PositionRecord.model_validate(normalized)


path: lib/auth.py
content: from __future__ import annotations

from collections.abc import Callable
from typing import Any, Protocol

from eth_account import Account
from eth_account.messages import encode_defunct
from predict_sdk import OrderBuilder, OrderBuilderOptions

from .config import ConfigError, PredictConfig, WalletMode
from .models import AuthMessageResponse, AuthRequest, JwtResponse


class AuthApiClientProtocol(Protocol):
    async def get_auth_message(self) -> AuthMessageResponse: ...
    async def get_jwt(self, auth_request: AuthRequest) -> JwtResponse: ...


class PredictAccountBuilderProtocol(Protocol):
    def sign_predict_account_message(self, message: str) -> str: ...


class PredictAuthenticator:
    def __init__(
        self,
        config: PredictConfig,
        api_client: AuthApiClientProtocol,
        *,
        account_from_key: Callable[[str], Any] = Account.from_key,
        builder_factory: Callable[[PredictConfig], PredictAccountBuilderProtocol]
        | None = None,
    ) -> None:
        self._config = config
        self._api_client = api_client
        self._account_from_key = account_from_key
        self._builder_factory = builder_factory or _make_predict_account_builder
        self._jwt_cache: dict[str, str] = {}

    async def get_jwt(self, *, force_refresh: bool = False) -> str:
        signer = self._config.auth_signer_address
        if signer is None:
            raise ConfigError(
                "Authenticated predict.fun actions require PREDICT_PRIVATE_KEY or the Predict Account credential pair."
            )

        cache_key = f"{self._config.env.value}:{signer}"
        if not force_refresh and cache_key in self._jwt_cache:
            return self._jwt_cache[cache_key]

        auth_message = await self._api_client.get_auth_message()
        auth_request = self.build_auth_request(auth_message.message)
        jwt_response = await self._api_client.get_jwt(auth_request)
        self._jwt_cache[cache_key] = jwt_response.token
        return jwt_response.token

    def build_auth_request(self, message: str) -> AuthRequest:
        if self._config.wallet_mode == WalletMode.EOA:
            if not self._config.private_key_value:
                raise ConfigError("EOA mode requires PREDICT_PRIVATE_KEY.")
            signer = self._account_from_key(self._config.private_key_value)
            signed = signer.sign_message(encode_defunct(text=message))
            return AuthRequest(
                signer=signer.address,
                message=message,
                signature=signed.signature.hex(),
            )

        if self._config.wallet_mode == WalletMode.PREDICT_ACCOUNT:
            builder = self._builder_factory(self._config)
            signature = builder.sign_predict_account_message(message)
            signer = self._config.auth_signer_address
            if signer is None:
                raise ConfigError(
                    "Predict Account mode requires both PREDICT_ACCOUNT_ADDRESS and PREDICT_PRIVY_PRIVATE_KEY."
                )
            return AuthRequest(
                signer=signer,
                message=message,
                signature=signature,
            )

        raise ConfigError(
            "Authenticated predict.fun actions require PREDICT_PRIVATE_KEY or the Predict Account credential pair."
        )


def _make_predict_account_builder(
    config: PredictConfig,
) -> PredictAccountBuilderProtocol:
    if not config.privy_private_key_value or not config.predict_account_address:
        raise ConfigError(
            "Predict Account mode requires both PREDICT_ACCOUNT_ADDRESS and PREDICT_PRIVY_PRIVATE_KEY."
        )

    return OrderBuilder.make(
        config.chain_id,
        config.privy_private_key_value,
        OrderBuilderOptions(predict_account=config.predict_account_address),
    )


path: lib/config.py
content: from __future__ import annotations

import os
import re
from enum import Enum
from pathlib import Path
from typing import Mapping

from eth_account import Account
from pydantic import BaseModel, ConfigDict, SecretStr, ValidationError, model_validator
from predict_sdk import ChainId


class RuntimeEnv(str, Enum):
    MAINNET = "mainnet"
    TESTNET = "testnet"
    TEST_FIXTURE = "test-fixture"


class WalletMode(str, Enum):
    READ_ONLY = "read-only"
    EOA = "eoa"
    PREDICT_ACCOUNT = "predict-account"
    MANDATED_VAULT = "mandated-vault"


class ConfigError(ValueError):
    """Raised when predict runtime configuration is invalid."""


MANDATED_MCP_COMMAND_DEFAULT = "erc-mandated-mcp"
MANDATED_CONTRACT_VERSION_DEFAULT = "v0.3.0-agent-contract"
MANDATED_ALLOWED_ADAPTERS_ROOT_DEFAULT = "0x" + "11" * 32
MANDATED_VAULT_V1_UNSUPPORTED_CODE = "unsupported-in-mandated-vault-v1"
MANDATED_MODE_REQUIRED_ERROR = (
    "Mandated vault configuration requires an explicit PREDICT_WALLET_MODE "
    "(predict-account or mandated-vault). If you do not want vault overlay "
    "behavior, clear all ERC_MANDATED_* values."
)
HEX32_RE = re.compile(r"^0x[a-fA-F0-9]{64}$")
MANDATED_INPUT_ENV_NAMES = (
    "ERC_MANDATED_VAULT_ADDRESS",
    "ERC_MANDATED_FACTORY_ADDRESS",
    "ERC_MANDATED_VAULT_ASSET_ADDRESS",
    "ERC_MANDATED_VAULT_NAME",
    "ERC_MANDATED_VAULT_SYMBOL",
    "ERC_MANDATED_VAULT_AUTHORITY",
    "ERC_MANDATED_VAULT_SALT",
    "ERC_MANDATED_AUTHORITY_PRIVATE_KEY",
    "ERC_MANDATED_EXECUTOR_PRIVATE_KEY",
    "ERC_MANDATED_CHAIN_ID",
    "ERC_MANDATED_ALLOWED_ADAPTERS_ROOT",
    "ERC_MANDATED_FUNDING_MAX_AMOUNT_PER_TX",
    "ERC_MANDATED_FUNDING_MAX_AMOUNT_PER_WINDOW",
    "ERC_MANDATED_FUNDING_WINDOW_SECONDS",
)


def redact_text(text: str, secrets: list[str | None]) -> str:
    redacted = text
    for secret in secrets:
        if [REDACTED_SECRET] = redacted.replace(secret, "<redacted>")
    redacted = re.sub(r"Bearer\s+[A-Za-z0-9._-]+", "Bearer <redacted>", redacted)
    redacted = re.sub(r"0x[a-fA-F0-9]{64}", "0x<redacted>", redacted)
    return redacted


def _default_api_base_url(runtime_env: RuntimeEnv) -> str:
    if runtime_env == RuntimeEnv.MAINNET:
        return "https://api.predict.fun"
    return "https://dev.predict.fun"


class PredictConfig(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)

    env: RuntimeEnv = RuntimeEnv.TESTNET
    storage_dir: Path = Path("~/.openclaw/predict").expanduser()
    wallet_mode_override: WalletMode | None = None
    [REDACTED_SECRET] | None = None
    private_key: SecretStr | None = None
    predict_account_address: str | None = None
    privy_private_key: SecretStr | None = None
    mandated_vault_address: str | None = None
    mandated_factory_address: str | None = None
    mandated_vault_asset_address: str | None = None
    mandated_vault_name: str | None = None
    mandated_vault_symbol: str | None = None
    mandated_vault_authority: str | None = None
    mandated_vault_salt: str | None = None
    mandated_authority_private_key: SecretStr | None = None
    mandated_executor_private_key: SecretStr | None = None
    mandated_mcp_command: str = MANDATED_MCP_COMMAND_DEFAULT
    mandated_contract_version: str = MANDATED_CONTRACT_VERSION_DEFAULT
    mandated_chain_id: int | None = None
    mandated_allowed_adapters_root: str = MANDATED_ALLOWED_ADAPTERS_ROOT_DEFAULT
    mandated_funding_max_amount_per_tx: str | None = None
    mandated_funding_max_amount_per_window: str | None = None
    mandated_funding_window_seconds: int | None = None
    has_mandated_config_input: bool = False
    openrouter_api_key: SecretStr | None = None
    model_name: str | None = None
    api_base_url: str = "https://api.predict.fun"
    http_timeout_seconds: float = 15.0
    retry_attempts: int = 3
    retry_backoff_seconds: float = 0.25

    @model_validator(mode="after")
    def validate_runtime_contract(self) -> "PredictConfig":
        if self.env == RuntimeEnv.MAINNET and not self.api_key:
            raise ValueError("PREDICT_API_KEY is required for mainnet.")

        has_eoa = self.private_key is not None
        has_predict_account_address = bool(self.predict_account_address)
        has_privy_key = self.privy_private_key is not None
        has_predict_account_credentials = has_predict_account_address or has_privy_key
        has_predict_account_pair = has_predict_account_address and has_privy_key
        mandated_derivation_fields = (
            self.mandated_factory_address,
            self.mandated_vault_asset_address,
            self.mandated_vault_name,
            self.mandated_vault_symbol,
            self.mandated_vault_authority,
            self.mandated_vault_salt,
        )
        has_mandated_vault_address = bool(self.mandated_vault_address)
        has_any_mandated_derivation = any(mandated_derivation_fields)
        has_all_mandated_derivation = all(mandated_derivation_fields)

        def validate_mandated_overlay_inputs() -> None:
            if not self.has_mandated_config_input:
                return
            if has_any_mandated_derivation and not has_all_mandated_derivation:
                raise ValueError(
                    "mandated-vault mode requires ERC_MANDATED_VAULT_ADDRESS or all mandated vault derivation inputs."
                )
            if not has_mandated_vault_address and not has_all_mandated_derivation:
                raise ValueError(
                    "mandated-vault mode requires ERC_MANDATED_VAULT_ADDRESS or all mandated vault derivation inputs."
                )

        if not HEX32_RE.fullmatch(self.mandated_allowed_adapters_root):
            raise ValueError(
                "ERC_MANDATED_ALLOWED_ADAPTERS_ROOT must be a 32-byte hex string."
            )

        if self.wallet_mode_override is None:
            if self.has_mandated_config_input:
                raise ValueError(MANDATED_MODE_REQUIRED_ERROR)

            if has_predict_account_address != has_privy_key:
                raise ValueError(
                    "Predict Account mode requires both PREDICT_ACCOUNT_ADDRESS and PREDICT_PRIVY_PRIVATE_KEY."
                )

            if has_eoa and has_predict_account_credentials:
                raise ValueError(
                    "Use either PREDICT_PRIVATE_KEY for EOA mode or the Predict Account pair, not both."
                )

            return self

        if self.wallet_mode_override == WalletMode.READ_ONLY:
            if (
                has_eoa
                or has_predict_account_credentials
                or self.has_mandated_config_input
            ):
                raise ValueError(
                    "read-only mode does not allow EOA, Predict Account, or mandated-vault configuration."
                )
            return self

        if self.wallet_mode_override == WalletMode.EOA:
            if not has_eoa:
                raise ValueError("EOA mode requires PREDICT_PRIVATE_KEY.")
            if has_predict_account_credentials or self.has_mandated_config_input:
                raise ValueError(
                    "EOA mode does not allow Predict Account or mandated-vault configuration."
                )
            return self

        if self.wallet_mode_override == WalletMode.PREDICT_ACCOUNT:
            if has_predict_account_address != has_privy_key:
                raise ValueError(
                    "Predict Account mode requires both PREDICT_ACCOUNT_ADDRESS and PREDICT_PRIVY_PRIVATE_KEY."
                )
            if not has_predict_account_pair:
                raise ValueError(
                    "Predict Account mode requires both PREDICT_ACCOUNT_ADDRESS and PREDICT_PRIVY_PRIVATE_KEY."
                )
            if has_eoa:
                raise ValueError("Predict Account mode does not allow EOA credentials.")
            validate_mandated_overlay_inputs()
            return self

        if has_eoa or has_predict_account_credentials:
            raise ValueError(
                "mandated-vault mode does not allow EOA or Predict Account credentials."
            )
        validate_mandated_overlay_inputs()

        return self

    @property
    def wallet_mode(self) -> WalletMode:
        if self.wallet_mode_override is not None:
            return self.wallet_mode_override
        if self.predict_account_address and self.privy_private_key:
            return WalletMode.PREDICT_ACCOUNT
        if self.private_key:
            return WalletMode.EOA
        return WalletMode.READ_ONLY

    @property
    def chain_id(self) -> ChainId:
        if self.env == RuntimeEnv.MAINNET:
            return ChainId.BNB_MAINNET
        return ChainId.BNB_TESTNET

    @property
    def private_key_value(self) -> str | None:
        return self.private_key.get_secret_value() if self.private_key else None

    @property
    def privy_private_key_value(self) -> str | None:
        return (
            self.privy_private_key.get_secret_value()
            if self.privy_private_key
            else None
        )

    @property
    def mandated_authority_private_key_value(self) -> str | None:
        return (
            self.mandated_authority_private_key.get_secret_value()
            if self.mandated_authority_private_key
            else None
        )

    @property
    def mandated_executor_private_key_value(self) -> str | None:
        if self.mandated_executor_private_key is not None:
            return self.mandated_executor_private_key.get_secret_value()
        return self.mandated_authority_private_key_value

    @property
    def mandated_executor_address(self) -> str | None:
        if self.mandated_executor_private_key_value:
            return Account.from_key(self.mandated_executor_private_key_value).address
        return self.mandated_vault_authority

    @property
    def auth_signer_address(self) -> str | None:
        if self.wallet_mode == WalletMode.PREDICT_ACCOUNT:
            return self.predict_account_address
        if self.wallet_mode == WalletMode.EOA and self.private_key_value:
            return Account.from_key(self.private_key_value).address
        return None

    @classmethod
    def from_env(cls, env: Mapping[str, str] | None = None) -> "PredictConfig":
        source = env or os.environ
        runtime_env = RuntimeEnv(source.get("PREDICT_ENV", RuntimeEnv.TESTNET.value))
        try:
            return cls(
                env=runtime_env,
                storage_dir=Path(
                    source.get("PREDICT_STORAGE_DIR", "~/.openclaw/predict")
                ).expanduser(),
                wallet_mode_override=_wallet_mode_or_none(
                    source.get("PREDICT_WALLET_MODE")
                ),
                [REDACTED_SECRET]"PREDICT_API_KEY")),
                private_key=_secret_or_none(source.get("PREDICT_PRIVATE_KEY")),
                predict_account_address=_value_or_none(
                    source.get("PREDICT_ACCOUNT_ADDRESS")
                ),
                privy_private_key=_secret_or_none(
                    source.get("PREDICT_PRIVY_PRIVATE_KEY")
                ),
                mandated_vault_address=_value_or_none(
                    source.get("ERC_MANDATED_VAULT_ADDRESS")
                ),
                mandated_factory_address=_value_or_none(
                    source.get("ERC_MANDATED_FACTORY_ADDRESS")
                ),
                mandated_vault_asset_address=_value_or_none(
                    source.get("ERC_MANDATED_VAULT_ASSET_ADDRESS")
                ),
                mandated_vault_name=_value_or_none(
                    source.get("ERC_MANDATED_VAULT_NAME")
                ),
                mandated_vault_symbol=_value_or_none(
                    source.get("ERC_MANDATED_VAULT_SYMBOL")
                ),
                mandated_vault_authority=_value_or_none(
                    source.get("ERC_MANDATED_VAULT_AUTHORITY")
                ),
                mandated_vault_salt=_value_or_none(
                    source.get("ERC_MANDATED_VAULT_SALT")
                ),
                mandated_authority_private_key=_secret_or_none(
                    source.get("ERC_MANDATED_AUTHORITY_PRIVATE_KEY")
                ),
                mandated_executor_private_key=_secret_or_none(
                    source.get("ERC_MANDATED_EXECUTOR_PRIVATE_KEY")
                ),
                mandated_mcp_command=_value_or_none(
                    source.get("ERC_MANDATED_MCP_COMMAND")
                )
                or MANDATED_MCP_COMMAND_DEFAULT,
                mandated_contract_version=_value_or_none(
                    source.get("ERC_MANDATED_CONTRACT_VERSION")
                )
                or MANDATED_CONTRACT_VERSION_DEFAULT,
                mandated_chain_id=_int_or_none(source.get("ERC_MANDATED_CHAIN_ID")),
                mandated_allowed_adapters_root=_value_or_none(
                    source.get("ERC_MANDATED_ALLOWED_ADAPTERS_ROOT")
                )
                or MANDATED_ALLOWED_ADAPTERS_ROOT_DEFAULT,
                mandated_funding_max_amount_per_tx=_value_or_none(
                    source.get("ERC_MANDATED_FUNDING_MAX_AMOUNT_PER_TX")
                ),
                mandated_funding_max_amount_per_window=_value_or_none(
                    source.get("ERC_MANDATED_FUNDING_MAX_AMOUNT_PER_WINDOW")
                ),
                mandated_funding_window_seconds=_int_or_none(
                    source.get("ERC_MANDATED_FUNDING_WINDOW_SECONDS")
                ),
                has_mandated_config_input=any(
                    _value_or_none(source.get(env_name)) is not None
                    for env_name in MANDATED_INPUT_ENV_NAMES
                ),
                openrouter_api_key=_secret_or_none(source.get("OPENROUTER_API_KEY")),
                model_name=_value_or_none(source.get("PREDICT_MODEL")),
                api_base_url=_value_or_none(source.get("PREDICT_API_BASE_URL"))
                or _default_api_base_url(runtime_env),
            )
        except (ValidationError, ValueError) as error:
            raise ConfigError(
                redact_text(
                    str(error),
                    [
                        source.get("PREDICT_API_KEY"),
                        source.get("PREDICT_PRIVATE_KEY"),
                        source.get("PREDICT_PRIVY_PRIVATE_KEY"),
                        source.get("ERC_MANDATED_AUTHORITY_PRIVATE_KEY"),
                        source.get("ERC_MANDATED_EXECUTOR_PRIVATE_KEY"),
                        source.get("OPENROUTER_API_KEY"),
                    ],
                )
            ) from error


def _value_or_none(raw: str | None) -> str | None:
    if raw is None:
        return None
    value = raw.strip()
    return value or None


def _secret_or_none(raw: str | None) -> SecretStr | None:
    value = _value_or_none(raw)
    return SecretStr(value) if value else None


def _wallet_mode_or_none(raw: str | None) -> WalletMode | None:
    value = _value_or_none(raw)
    return WalletMode(value) if value else None


def _int_or_none(raw: str | None) -> int | None:
    value = _value_or_none(raw)
    return int(value) if value else None


def mandated_vault_v1_unsupported_error(flow: str) -> ConfigError:
    return ConfigError(
        f"{flow}: {MANDATED_VAULT_V1_UNSUPPORTED_CODE}. "
        "mandated-vault v1 currently covers protected funding/control-plane operations "
        "and does not provide predict.fun trading parity."
    )


path: lib/coverage.py
content: from __future__ import annotations

from dataclasses import dataclass
from typing import cast

MIN_COVERAGE = 0.85
NECESSARY_PROBABILITY = 0.98
TIER_THRESHOLDS: list[tuple[float, int, str, str]] = [
    (0.95, 1, "HIGH", "near-arbitrage"),
    (0.90, 2, "GOOD", "strong hedge"),
    (0.85, 3, "MODERATE", "decent hedge"),
    (0.00, 4, "LOW", "speculative"),
]


@dataclass
class HedgePortfolio:
    target_market_id: str
    target_question: str
    target_side: str
    target_price: float
    cover_market_id: str
    cover_question: str
    cover_side: str
    cover_price: float
    coverage: float
    loss_probability: float
    expected_profit: float
    total_cost: float
    tier: int
    tier_label: str
    relationship: str

    def to_dict(self) -> dict[str, object]:
        return {
            "targetMarketId": self.target_market_id,
            "targetQuestion": self.target_question,
            "targetSide": self.target_side,
            "targetPrice": self.target_price,
            "coverMarketId": self.cover_market_id,
            "coverQuestion": self.cover_question,
            "coverSide": self.cover_side,
            "coverPrice": self.cover_price,
            "coverage": self.coverage,
            "lossProbability": self.loss_probability,
            "expectedProfit": self.expected_profit,
            "totalCost": self.total_cost,
            "tier": self.tier,
            "tierLabel": self.tier_label,
            "relationship": self.relationship,
        }


def calculate_coverage_metrics(
    target_price: float, cover_probability: float, total_cost: float
) -> dict[str, float]:
    p_target = target_price
    p_not_target = 1 - target_price
    coverage = p_target + p_not_target * cover_probability
    loss_probability = p_not_target * (1 - cover_probability)
    expected_profit = coverage - total_cost
    return {
        "coverage": round(coverage, 4),
        "loss_probability": round(loss_probability, 4),
        "expected_profit": round(expected_profit, 4),
    }


def classify_tier(coverage: float) -> tuple[int, str]:
    for threshold, tier, label, _desc in TIER_THRESHOLDS:
        if coverage >= threshold:
            return tier, label
    return 4, "LOW"


def build_portfolio(
    *,
    target_market: dict[str, object],
    cover_market: dict[str, object],
    target_position: str,
    cover_position: str,
    cover_probability: float,
    relationship: str,
) -> HedgePortfolio | None:
    target_price = float(
        cast(
            float | int | str,
            target_market["yes_price"]
            if target_position == "YES"
            else target_market["no_price"],
        )
    )
    cover_price = float(
        cast(
            float | int | str,
            cover_market["yes_price"]
            if cover_position == "YES"
            else cover_market["no_price"],
        )
    )
    total_cost = round(target_price + cover_price, 4)
    if total_cost <= 0 or total_cost > 2.0:
        return None

    metrics = calculate_coverage_metrics(target_price, cover_probability, total_cost)
    if metrics["coverage"] < MIN_COVERAGE:
        return None
    tier, tier_label = classify_tier(metrics["coverage"])
    return HedgePortfolio(
        target_market_id=str(target_market["id"]),
        target_question=str(target_market["question"]),
        target_side=target_position,
        target_price=round(target_price, 4),
        cover_market_id=str(cover_market["id"]),
        cover_question=str(cover_market["question"]),
        cover_side=cover_position,
        cover_price=round(cover_price, 4),
        total_cost=total_cost,
        tier=tier,
        tier_label=tier_label,
        relationship=relationship,
        **metrics,
    )


def filter_portfolios_by_tier(
    portfolios: list[HedgePortfolio], max_tier: int = 2
) -> list[HedgePortfolio]:
    return [portfolio for portfolio in portfolios if portfolio.tier <= max_tier]


def filter_portfolios_by_coverage(
    portfolios: list[HedgePortfolio], min_coverage: float = MIN_COVERAGE
) -> list[HedgePortfolio]:
    return [portfolio for portfolio in portfolios if portfolio.coverage >= min_coverage]


def sort_portfolios(portfolios: list[HedgePortfolio]) -> list[HedgePortfolio]:
    return sorted(
        portfolios,
        key=lambda portfolio: (
            portfolio.tier,
            -portfolio.coverage,
            portfolio.total_cost,
        ),
    )


path: lib/fixture_api.py
content: from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from .models import (
    LastSaleRecord,
    MarketRecord,
    MarketStatsRecord,
    OrderBookRecord,
    PositionRecord,
)


class FixturePredictApiClient:
    def __init__(self, fixture_path: Path | None = None) -> None:
        root = Path(__file__).resolve().parents[1]
        self._fixture_path = (
            fixture_path or root / "tests" / "fixtures" / "market_api.json"
        )
        self._payload = json.loads(self._fixture_path.read_text())

    async def close(self) -> None:
        return None

    async def get_markets(self, **params: Any) -> list[MarketRecord]:
        status = params.get("status")
        first = int(params.get("first", len(self._payload["markets"])))
        skip = int(params.get("skip", 0))
        markets = self._payload["markets"]

        if status:
            markets = [market for market in markets if market.get("status") == status]

        return [
            MarketRecord.model_validate(item) for item in markets[skip : skip + first]
        ]

    async def get_market(self, market_id: str | int) -> MarketRecord:
        for item in self._payload["markets"]:
            if str(item["id"]) == str(market_id):
                return MarketRecord.model_validate(item)
        raise KeyError(f"Unknown fixture market: {market_id}")

    async def get_market_stats(self, market_id: str | int) -> MarketStatsRecord:
        return MarketStatsRecord.model_validate(self._payload["stats"][str(market_id)])

    async def get_market_last_sale(self, market_id: str | int) -> LastSaleRecord:
        return LastSaleRecord.model_validate(
            self._payload["last_sales"][str(market_id)]
        )

    async def get_orderbook(self, market_id: str | int) -> OrderBookRecord:
        return OrderBookRecord.model_validate(
            self._payload["orderbooks"][str(market_id)]
        )

    async def get_positions(self) -> list[PositionRecord]:
        return [
            PositionRecord.model_validate(item)
            for item in self._payload.get("positions", [])
        ]


path: lib/funding_service.py
content: from __future__ import annotations

import asyncio
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Any, Callable, Protocol, cast

from predict_sdk._internal.contracts import make_contract
from predict_sdk.abis import KERNEL_ABI
from web3 import Web3

from .config import (
    ConfigError,
    PredictConfig,
    WalletMode,
    mandated_vault_v1_unsupported_error,
)
from .mandated_mcp_bridge import MandatedVaultMcpBridge
from .wallet_manager import (
    FixtureWalletSdk,
    MandatedVaultBridgeProtocol,
    PredictSdkWallet,
    WalletSdkProtocol,
    build_vault_to_predict_account_orchestration,
    load_wallet_bnb_balance_wei,
    load_wallet_usdt_balance_wei,
    make_wallet_sdk,
    resolve_mandated_vault,
)


TOKEN_DECIMALS = {"usdt": 18, "bnb": 18}
MIN_GAS_HEADROOM_WEI = 100_000_000_000_000


@dataclass
class DepositDetails:
    mode: str
    funding_address: str
    signer_address: str
    chain: str
    accepted_assets: list[str]
    bnb_balance_wei: int
    usdt_balance_wei: int
    vault_address_source: str | None = None
    vault_exists: bool | None = None
    create_vault_preparation: dict[str, object] | None = None
    funding_route: str | None = None
    predict_account_address: str | None = None
    trade_signer_address: str | None = None
    vault_address: str | None = None
    funding_orchestration: dict[str, object] | None = None

    def to_dict(self) -> dict[str, object]:
        payload: dict[str, object] = {
            "mode": self.mode,
            "fundingAddress": self.funding_address,
            "signerAddress": self.signer_address,
            "chain": self.chain,
            "acceptedAssets": self.accepted_assets,
            "bnbBalanceWei": self.bnb_balance_wei,
            "usdtBalanceWei": self.usdt_balance_wei,
        }
        if self.vault_address_source is not None:
            payload["vaultAddressSource"] = self.vault_address_source
        if self.vault_exists is not None:
            payload["vaultExists"] = self.vault_exists
        if self.vault_exists is not None:
            payload["createVaultPreparation"] = self.create_vault_preparation
        elif self.create_vault_preparation is not None:
            payload["createVaultPreparation"] = self.create_vault_preparation
        if self.funding_route is not None:
            payload["fundingRoute"] = self.funding_route
            payload["predictAccountAddress"] = self.predict_account_address
            payload["tradeSignerAddress"] = self.trade_signer_address
            payload["vaultAddress"] = self.vault_address
            payload["fundingOrchestration"] = self.funding_orchestration
        return payload


@dataclass
class WithdrawalResult:
    asset: str
    amount_wei: int
    destination: str
    result: Any

    def to_dict(self) -> dict[str, object]:
        return {
            "asset": self.asset,
            "amountWei": self.amount_wei,
            "destination": self.destination,
            "result": self.result,
        }


class TransferCapableWallet(Protocol):
    @property
    def mode(self) -> object: ...

    @property
    def signer_address(self) -> str: ...

    @property
    def funding_address(self) -> str: ...

    @property
    def chain_name(self) -> str: ...

    def get_bnb_balance_wei(self) -> int: ...

    def get_usdt_balance_wei(self) -> int: ...


class FundingService:
    def __init__(
        self,
        config: PredictConfig,
        *,
        sdk_factory: Callable[[PredictConfig], TransferCapableWallet] = make_wallet_sdk,
        bridge_factory: Callable[
            [PredictConfig], MandatedVaultBridgeProtocol
        ] = MandatedVaultMcpBridge,
    ) -> None:
        self._config = config
        self._sdk_factory = sdk_factory
        self._bridge_factory = bridge_factory

    def get_deposit_details(self) -> DepositDetails:
        if self._config.wallet_mode.value == "mandated-vault":
            return self._get_mandated_deposit_details()

        sdk = self._require_sdk()
        if (
            self._config.wallet_mode == WalletMode.PREDICT_ACCOUNT
            and self._config.has_mandated_config_input
        ):
            return self._get_predict_account_overlay_deposit_details(sdk)

        return DepositDetails(
            mode=str(getattr(sdk.mode, "value", sdk.mode)),
            funding_address=sdk.funding_address,
            signer_address=sdk.signer_address,
            chain=sdk.chain_name,
            accepted_assets=["BNB", "USDT"],
            bnb_balance_wei=sdk.get_bnb_balance_wei(),
            usdt_balance_wei=sdk.get_usdt_balance_wei(),
        )

    def _get_predict_account_overlay_deposit_details(
        self, sdk: TransferCapableWallet
    ) -> DepositDetails:
        bridge = self._bridge_factory(self._config)

        async def _load_and_close() -> DepositDetails:
            await bridge.connect()
            try:
                bnb_balance = await load_wallet_bnb_balance_wei(sdk)
                usdt_balance = await load_wallet_usdt_balance_wei(sdk)
                orchestration = await build_vault_to_predict_account_orchestration(
                    self._config,
                    bridge,
                    predict_account_address=sdk.funding_address,
                    trade_signer_address=sdk.signer_address,
                    current_usdt_balance_wei=usdt_balance,
                    wallet_sdk=sdk,
                )
                return DepositDetails(
                    mode=str(getattr(sdk.mode, "value", sdk.mode)),
                    funding_address=sdk.funding_address,
                    signer_address=sdk.signer_address,
                    chain=sdk.chain_name,
                    accepted_assets=["BNB", "USDT"],
                    bnb_balance_wei=bnb_balance,
                    usdt_balance_wei=usdt_balance,
                    vault_address_source=orchestration.vault_address_source,
                    vault_exists=orchestration.vault_exists,
                    funding_route=orchestration.funding_route,
                    predict_account_address=orchestration.predict_account_address,
                    trade_signer_address=orchestration.trade_signer_address,
                    vault_address=orchestration.vault_address,
                    funding_orchestration=orchestration.to_dict(),
                )
            finally:
                await bridge.close()

        return asyncio.run(_load_and_close())

    def _get_mandated_deposit_details(self) -> DepositDetails:
        bridge = self._bridge_factory(self._config)

        async def _load_and_close() -> DepositDetails:
            await bridge.connect()
            try:
                resolution = await resolve_mandated_vault(
                    self._config,
                    bridge,
                    include_create_prepare=True,
                )
                preparation: dict[str, object] | None = None
                if (
                    not resolution.vault_deployed
                    and resolution.create_vault_prepare is not None
                ):
                    tx = resolution.create_vault_prepare.txRequest
                    preparation = {
                        "predictedVault": resolution.create_vault_prepare.predictedVault,
                        "txSummary": {
                            "from": tx.from_address,
                            "to": tx.to,
                            "data": tx.data,
                            "value": tx.value,
                            "gas": tx.gas,
                        },
                        "broadcast": "manual-only",
                    }

                return DepositDetails(
                    mode=self._config.wallet_mode.value,
                    funding_address=resolution.vault_address,
                    signer_address=self._config.mandated_vault_authority
                    or "not-required",
                    chain=(
                        "BNB Mainnet"
                        if self._config.env.value == "mainnet"
                        else "BNB Testnet"
                    ),
                    accepted_assets=["BNB", "USDT"],
                    bnb_balance_wei=0,
                    usdt_balance_wei=0,
                    vault_address_source=resolution.vault_address_source,
                    vault_exists=resolution.vault_deployed,
                    create_vault_preparation=preparation,
                )
            finally:
                await bridge.close()

        return asyncio.run(_load_and_close())

    def withdraw(
        self, asset: str, amount: str, destination: str, *, withdraw_all: bool = False
    ) -> WithdrawalResult:
        if self._config.wallet_mode == WalletMode.MANDATED_VAULT:
            raise mandated_vault_v1_unsupported_error("wallet withdraw")

        sdk = self._require_sdk()
        asset_key = asset.lower()
        if asset_key not in TOKEN_DECIMALS:
            raise ConfigError("Withdraw only supports BNB and USDT.")
        if not Web3.is_checksum_address(destination):
            raise ConfigError("Destination address must be a checksum address.")

        bnb_balance = sdk.get_bnb_balance_wei()
        usdt_balance = sdk.get_usdt_balance_wei()

        if withdraw_all:
            available = (
                usdt_balance
                if asset_key == "usdt"
                else max(bnb_balance - MIN_GAS_HEADROOM_WEI, 0)
            )
            amount_wei = available
        else:
            amount_wei = _parse_amount_to_wei(amount, TOKEN_DECIMALS[asset_key])

        if amount_wei <= 0:
            raise ConfigError("Withdrawal amount must be greater than zero.")
        if bnb_balance <= MIN_GAS_HEADROOM_WEI:
            raise ConfigError("Insufficient BNB gas headroom for withdrawal.")

        if asset_key == "usdt":
            if usdt_balance < amount_wei:
                raise ConfigError("Insufficient USDT balance for withdrawal.")
            result = _transfer_usdt(sdk, destination, amount_wei)
        else:
            if bnb_balance - MIN_GAS_HEADROOM_WEI < amount_wei:
                raise ConfigError(
                    "Insufficient BNB balance after reserving gas headroom."
                )
            result = _transfer_bnb(sdk, destination, amount_wei)

        return WithdrawalResult(
            asset=asset_key,
            amount_wei=amount_wei,
            destination=destination,
            result=result,
        )

    def _require_sdk(self) -> TransferCapableWallet:
        if self._config.auth_signer_address is None:
            raise ConfigError(
                "Wallet actions require signer configuration. Set PREDICT_PRIVATE_KEY or both PREDICT_ACCOUNT_ADDRESS and PREDICT_PRIVY_PRIVATE_KEY."
            )
        return self._sdk_factory(self._config)


def _parse_amount_to_wei(raw_amount: str, decimals: int) -> int:
    try:
        amount = Decimal(raw_amount)
    except InvalidOperation as error:
        raise ConfigError("Withdrawal amount must be numeric.") from error

    if amount <= 0:
        return 0
    scale = Decimal(10) ** decimals
    return int(amount * scale)


def _transfer_usdt(
    sdk: TransferCapableWallet, destination: str, amount_wei: int
) -> Any:
    if hasattr(sdk, "transfer_usdt"):
        return cast(Any, getattr(sdk, "transfer_usdt"))(destination, amount_wei)

    if isinstance(sdk, FixtureWalletSdk):
        return {"success": True, "txHash": "0xfixture-usdt"}

    if not isinstance(sdk, PredictSdkWallet):
        raise ConfigError("Wallet SDK does not support USDT withdrawals.")

    builder = sdk._builder
    contracts = builder.contracts
    if contracts is None:
        raise ConfigError("USDT withdrawal requires initialized predict.fun contracts.")

    checksum_to = Web3.to_checksum_address(destination)
    if sdk.mode.value == "predict-account":
        encoded = contracts.usdt.encode_abi(
            abi_element_identifier="transfer",
            args=[checksum_to, amount_wei],
        )
        calldata = builder._encode_execution_calldata(
            contracts.usdt.address, encoded, value=0
        )
        web3 = getattr(builder, "_web3", None)
        predict_account = getattr(builder, "_predict_account", None)
        execution_mode = getattr(builder, "_execution_mode", None)
        if web3 is None or predict_account is None or execution_mode is None:
            raise ConfigError(
                "Predict Account withdrawal requires initialized Kernel execution state."
            )
        kernel = make_contract(web3, predict_account, KERNEL_ABI)
        return builder._run_async(
            builder._handle_transaction_async(
                kernel, "execute", execution_mode, calldata
            )
        )

    return builder._run_async(
        builder._handle_transaction_async(
            contracts.usdt, "transfer", checksum_to, amount_wei
        )
    )


def _transfer_bnb(sdk: TransferCapableWallet, destination: str, amount_wei: int) -> Any:
    if hasattr(sdk, "transfer_bnb"):
        return cast(Any, getattr(sdk, "transfer_bnb"))(destination, amount_wei)

    if isinstance(sdk, FixtureWalletSdk):
        return {"success": True, "txHash": "0xfixture-bnb"}

    if not isinstance(sdk, PredictSdkWallet):
        raise ConfigError("Wallet SDK does not support BNB withdrawals.")

    builder = sdk._builder
    checksum_to = Web3.to_checksum_address(destination)
    if sdk.mode.value == "predict-account":
        web3 = getattr(builder, "_web3", None)
        predict_account = getattr(builder, "_predict_account", None)
        execution_mode = getattr(builder, "_execution_mode", None)
        if web3 is None or predict_account is None or execution_mode is None:
            raise ConfigError(
                "Predict Account withdrawal requires initialized Kernel execution state."
            )
        calldata = builder._encode_execution_calldata(
            checksum_to, "0x", value=amount_wei
        )
        kernel = make_contract(web3, predict_account, KERNEL_ABI)
        return builder._run_async(
            builder._handle_transaction_async(
                kernel, "execute", execution_mode, calldata
            )
        )

    web3 = getattr(builder, "_web3", None)
    signer = getattr(builder, "_signer", None)
    if web3 is None or signer is None or not sdk._config.private_key_value:
        raise ConfigError("EOA BNB withdrawal requires initialized signer state.")

    tx = {
        "from": signer.address,
        "to": checksum_to,
        "value": amount_wei,
        "nonce": web3.eth.get_transaction_count(signer.address),
        "gasPrice": web3.eth.gas_price,
        "chainId": web3.eth.chain_id,
    }
    estimated_gas = web3.eth.estimate_gas(tx)
    tx["gas"] = (estimated_gas * 125) // 100
    signed = web3.eth.account.sign_transaction(tx, sdk._config.private_key_value)
    tx_hash = web3.eth.send_raw_transaction(signed.raw_transaction)
    receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
    return {"success": True, "txHash": tx_hash.hex(), "receipt": dict(receipt)}


path: lib/hedge_matching.py
content: from __future__ import annotations

from collections.abc import Sequence
from typing import Any


def normalize_question(value: str) -> str:
    return " ".join(value.lower().strip().split())


def match_market_reference(
    *, market_id: str, market_question: str, markets: Sequence[dict[str, Any]]
) -> dict[str, Any] | None:
    if market_id:
        for market in markets:
            if str(market.get("id")) == str(market_id):
                return market

    normalized_question = normalize_question(market_question)
    for market in markets:
        if normalize_question(str(market.get("question", ""))) == normalized_question:
            return market

    for market in markets:
        candidate = normalize_question(str(market.get("question", "")))
        if normalized_question and (
            normalized_question in candidate or candidate in normalized_question
        ):
            return market
    return None


path: lib/hedge_prompt.py
content: from __future__ import annotations

import json


IMPLICATION_SYSTEM_PROMPT = """Find ONLY logically necessary relationships between prediction market events.
Reject correlations, trends, and merely plausible relationships. Return JSON only."""


def build_implication_prompt(
    target_question: str, candidates: list[dict[str, object]]
) -> str:
    return json.dumps(
        {
            "task": "Find logically necessary implications only.",
            "counterexampleDiscipline": "If you can imagine any world where the implication fails, reject it.",
            "targetQuestion": target_question,
            "candidates": candidates,
            "return": {
                "implied_by": [
                    {
                        "market_id": "exact id",
                        "market_question": "exact question",
                        "explanation": "why candidate YES guarantees target YES",
                        "counterexample_attempt": "brief explanation",
                    }
                ],
                "implies": [
                    {
                        "market_id": "exact id",
                        "market_question": "exact question",
                        "explanation": "why target YES guarantees candidate YES",
                        "counterexample_attempt": "brief explanation",
                    }
                ],
            },
        },
        indent=2,
    )


path: lib/hedge_service.py
content: from __future__ import annotations

import asyncio
import json
from typing import Any, Callable

from .config import PredictConfig, RuntimeEnv
from .coverage import (
    NECESSARY_PROBABILITY,
    HedgePortfolio,
    build_portfolio,
    filter_portfolios_by_coverage,
    filter_portfolios_by_tier,
    sort_portfolios,
)
from .hedge_prompt import IMPLICATION_SYSTEM_PROMPT, build_implication_prompt
from .llm_client import OpenRouterLLMClient
from .market_service import MarketService, MarketSummary, summary_to_dict


class HedgeService:
    def __init__(
        self,
        config: PredictConfig,
        *,
        market_service_factory: Callable[
            [PredictConfig], MarketService
        ] = MarketService,
        llm_client_factory: Callable[
            [PredictConfig], OpenRouterLLMClient
        ] = OpenRouterLLMClient,
    ) -> None:
        self._config = config
        self._market_service_factory = market_service_factory
        self._llm_client_factory = llm_client_factory

    async def scan(
        self,
        *,
        query: str | None = None,
        limit: int = 20,
        min_coverage: float = 0.85,
        tier: int = 2,
        model: str | None = None,
    ) -> list[HedgePortfolio]:
        market_service = self._market_service_factory(self._config)
        summaries = await (
            market_service.search(query, limit=limit)
            if query
            else market_service.get_trending(limit=limit)
        )
        portfolios = await self._scan_portfolios(summaries, model=model)
        return sort_portfolios(
            filter_portfolios_by_tier(
                filter_portfolios_by_coverage(portfolios, min_coverage), tier
            )
        )

    async def analyze(
        self, left_market_id: str, right_market_id: str, *, model: str | None = None
    ) -> list[HedgePortfolio]:
        market_service = self._market_service_factory(self._config)
        left = await market_service.get_trending(limit=50)
        pair = {summary.id: summary for summary in left}
        if left_market_id not in pair or right_market_id not in pair:
            left_detail = await market_service.get_detail(left_market_id)
            right_detail = await market_service.get_detail(right_market_id)
            left_summary = MarketSummary(
                id=str(left_detail.market.id),
                title=left_detail.market.title or "",
                question=left_detail.market.question or left_detail.market.title or "",
                category_slug=left_detail.market.categorySlug or "",
                fee_rate_bps=left_detail.market.feeRateBps,
                yes_mark_price=left_detail.yes_mark_price,
                no_mark_price=left_detail.no_mark_price,
                volume_24h_usd=left_detail.stats.volume24hUsd or 0.0,
                decimal_precision=left_detail.market.decimalPrecision or 2,
            )
            right_summary = MarketSummary(
                id=str(right_detail.market.id),
                title=right_detail.market.title or "",
                question=right_detail.market.question
                or right_detail.market.title
                or "",
                category_slug=right_detail.market.categorySlug or "",
                fee_rate_bps=right_detail.market.feeRateBps,
                yes_mark_price=right_detail.yes_mark_price,
                no_mark_price=right_detail.no_mark_price,
                volume_24h_usd=right_detail.stats.volume24hUsd or 0.0,
                decimal_precision=right_detail.market.decimalPrecision or 2,
            )
        else:
            left_summary = pair[left_market_id]
            right_summary = pair[right_market_id]
        portfolios = await self._pair_portfolios(
            left_summary, right_summary, model=model
        )
        return sort_portfolios(portfolios)

    async def _scan_portfolios(
        self, summaries: list[MarketSummary], *, model: str | None
    ) -> list[HedgePortfolio]:
        if len(summaries) < 2:
            return []
        semaphore = asyncio.Semaphore(3)
        anchor = summaries[0]

        async def analyze_candidate(summary: MarketSummary) -> list[HedgePortfolio]:
            async with semaphore:
                return await self._pair_portfolios(anchor, summary, model=model)

        results = await asyncio.gather(
            *(analyze_candidate(summary) for summary in summaries[1:]),
            return_exceptions=False,
        )
        portfolios: list[HedgePortfolio] = []
        for result in results:
            portfolios.extend(result)
        return portfolios

    async def _pair_portfolios(
        self, left: MarketSummary, right: MarketSummary, *, model: str | None
    ) -> list[HedgePortfolio]:
        if self._config.env == RuntimeEnv.TEST_FIXTURE:
            return _fixture_pair_portfolios(left, right)

        client = self._llm_client_factory(self._config)
        try:
            payload = await client.complete_json(
                build_implication_prompt(left.question, [summary_to_dict(right)]),
                system_prompt=IMPLICATION_SYSTEM_PROMPT,
                model=model,
            )
        finally:
            await client.close()

        if not payload:
            return []
        relationship = _relationship_from_payload(payload)
        if relationship is None:
            return []
        portfolio = build_portfolio(
            target_market=_summary_to_market_dict(left),
            cover_market=_summary_to_market_dict(right),
            target_position=relationship["target_position"],
            cover_position=relationship["cover_position"],
            cover_probability=NECESSARY_PROBABILITY,
            relationship=relationship["relationship"],
        )
        return [portfolio] if portfolio else []


def _summary_to_market_dict(summary: MarketSummary) -> dict[str, object]:
    return {
        "id": summary.id,
        "question": summary.question,
        "yes_price": summary.yes_mark_price,
        "no_price": summary.no_mark_price,
    }


def _fixture_pair_portfolios(
    left: MarketSummary, right: MarketSummary
) -> list[HedgePortfolio]:
    relationship = (
        f"necessary (fixture): {left.question} YES implies {right.question} YES"
    )
    portfolio = build_portfolio(
        target_market=_summary_to_market_dict(left),
        cover_market=_summary_to_market_dict(right),
        target_position="NO",
        cover_position="YES",
        cover_probability=NECESSARY_PROBABILITY,
        relationship=relationship,
    )
    return [portfolio] if portfolio else []


def _relationship_from_payload(payload: dict[str, Any]) -> dict[str, str] | None:
    implied_by = payload.get("implied_by") or []
    implies = payload.get("implies") or []
    if implied_by:
        explanation = implied_by[0].get("explanation", "necessary implication")
        return {
            "target_position": "YES",
            "cover_position": "NO",
            "relationship": f"necessary (contrapositive): {explanation}",
        }
    if implies:
        explanation = implies[0].get("explanation", "necessary implication")
        return {
            "target_position": "NO",
            "cover_position": "YES",
            "relationship": f"necessary (direct): {explanation}",
        }
    return None


path: lib/llm_client.py
content: from __future__ import annotations

import asyncio
import json
import re
from typing import Any

import httpx

from .config import ConfigError, PredictConfig, redact_text

LLM_TIMEOUT = 60.0
LLM_MAX_RETRIES = 3


def extract_json_from_response(text: str) -> dict[str, Any] | None:
    stripped = text.strip()
    fenced = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", stripped)
    candidates = []
    if fenced:
        candidates.append(fenced.group(1))
    candidates.append(stripped)
    wrapped = re.search(r"(\{[\s\S]*\})", stripped)
    if wrapped:
        candidates.append(wrapped.group(1))
    for candidate in candidates:
        try:
            payload = json.loads(candidate)
        except json.JSONDecodeError:
            continue
        if isinstance(payload, dict):
            return payload
    return None


class OpenRouterLLMClient:
    def __init__(
        self, config: PredictConfig, *, client: httpx.AsyncClient | None = None
    ) -> None:
        self._config = config
        self._owns_client = client is None
        self._client = client or httpx.AsyncClient(
            base_url="https://openrouter.ai/api/v1", timeout=LLM_TIMEOUT
        )

    async def close(self) -> None:
        if self._owns_client:
            await self._client.aclose()

    async def complete_json(
        self, prompt: str, *, system_prompt: str, model: str | None = None
    ) -> dict[str, Any] | None:
        if not self._config.openrouter_api_key:
            raise ConfigError("OPENROUTER_API_KEY is required for hedge analysis.")
        payload = {
            "model": model
            or self._config.model_name
            or "openrouter/openai/gpt-4.1-mini",
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": prompt},
            ],
            "response_format": {"type": "json_object"},
        }
        for attempt in range(LLM_MAX_RETRIES):
            try:
                response = await self._client.post(
                    "/chat/completions",
                    headers={
                        "Authorization": f"Bearer {self._config.openrouter_api_key.get_secret_value()}",
                        "Content-Type": "application/json",
                    },
                    json=payload,
                )
                if response.is_error:
                    raise OpenRouterLLMError(
                        redact_text(
                            f"OpenRouter request failed with status {response.status_code}: {response.text[:180]}",
                            [self._config.openrouter_api_key.get_secret_value()],
                        )
                    )
                raw = response.json()["choices"][0]["message"]["content"]
                if not isinstance(raw, str):
                    return None
                return extract_json_from_response(raw)
            except (httpx.RequestError, OpenRouterLLMError):
                if attempt == LLM_MAX_RETRIES - 1:
                    raise
                await asyncio.sleep(attempt + 1)
        return None


class OpenRouterLLMError(RuntimeError):
    pass


path: lib/mandated_mcp_bridge.py
content: from __future__ import annotations

import asyncio
import json
import os
import shlex
from collections.abc import Mapping, Sequence
from typing import Any, Literal, Self, TypeVar

from pydantic import BaseModel, ConfigDict, Field, ValidationError
from predict_sdk.constants import RPC_URLS_BY_CHAIN_ID

from .config import PredictConfig, redact_text


MANDATED_V1_REQUIRED_TOOLS = frozenset(
    {
        "factory_predict_vault_address",
        "factory_create_vault_prepare",
        "vault_health_check",
    }
)
MANDATED_BOOTSTRAP_REQUIRED_TOOLS = frozenset({"vault_bootstrap"})

MANDATED_AGENT_SESSION_TOOLS = frozenset(
    {
        "agent_account_context_create",
        "agent_funding_policy_create",
        "agent_build_fund_and_action_plan",
        "agent_fund_and_action_session_create",
        "agent_fund_and_action_session_next_step",
        "agent_fund_and_action_session_apply_event",
        "agent_follow_up_action_result_create",
    }
)

MANDATED_ASSET_TRANSFER_TOOLS = frozenset(
    {
        "vault_asset_transfer_result_create",
        "vault_check_asset_transfer_policy",
        "vault_build_asset_transfer_plan_from_context",
        "vault_simulate_asset_transfer_from_context",
        "vault_prepare_asset_transfer_from_context",
    }
)

_CLIENT_PROTOCOL_VERSION = "2024-11-05"
_CLIENT_NAME = "predictclaw"
_CLIENT_VERSION = "0.1.0"
_DEFAULT_RPC_URL_BY_CHAIN_ID = {
    int(chain_id): rpc_url for chain_id, rpc_url in RPC_URLS_BY_CHAIN_ID.items()
}
_RPC_ENV_KEYS_BY_CHAIN_ID = {
    56: ("BSC_MAINNET_RPC_URL", "BSC_RPC_URL", "ERC_MANDATED_RPC_URL"),
    97: ("BSC_TESTNET_RPC_URL", "BSC_RPC_URL", "ERC_MANDATED_RPC_URL"),
}


class MandatedVaultMcpError(RuntimeError):
    pass


class MandatedVaultMcpUnavailableError(MandatedVaultMcpError):
    pass


class MandatedVaultMcpMissingToolsError(MandatedVaultMcpError):
    def __init__(
        self,
        missing_tools: Sequence[str],
        *,
        operation: str | None = None,
    ) -> None:
        unique_tools = tuple(sorted(set(missing_tools)))
        self.missing_tools = frozenset(unique_tools)
        missing = ", ".join(unique_tools)
        if operation:
            message = f"Mandated-vault MCP cannot perform {operation}; missing required tools: {missing}."
        else:
            message = f"Mandated-vault MCP is missing required tools: {missing}."
        super().__init__(message)


class _BridgeModel(BaseModel):
    model_config = ConfigDict(extra="forbid", populate_by_name=True)


class McpToolDescriptor(_BridgeModel):
    model_config = ConfigDict(extra="allow", populate_by_name=True)

    name: str
    description: str | None = None


class McpToolError(_BridgeModel):
    code: str
    message: str
    details: dict[str, Any] | None = None
    suggestion: str | None = None


class McpTxRequest(_BridgeModel):
    from_address: str = Field(alias="from")
    to: str
    data: str
    value: str
    gas: str | None = None


class FactoryPredictVaultAddressResult(_BridgeModel):
    predictedVault: str


class FactoryCreateVaultPrepareResult(_BridgeModel):
    predictedVault: str
    txRequest: McpTxRequest


class VaultHealthCheckResult(_BridgeModel):
    blockNumber: int
    vault: str
    mandateAuthority: str
    authorityEpoch: str
    pendingAuthority: str
    nonceThreshold: str
    totalAssets: str


VaultBootstrapMode = Literal["plan", "execute"]
VaultBootstrapAuthorityMode = Literal["single_key", "dual_key"]
VaultBootstrapDeploymentStatus = Literal[
    "planned", "submitted", "confirmed", "reverted", "receipt_unknown"
]
VaultBootstrapReceiptStatus = Literal["success", "reverted", "timeout"]


class VaultBootstrapAuthorityConfig(_BridgeModel):
    mode: VaultBootstrapAuthorityMode
    authority: str
    executor: str


class VaultBootstrapCreateTx(_BridgeModel):
    mode: VaultBootstrapMode
    txRequest: McpTxRequest | None = None
    txHash: str | None = None
    receiptStatus: VaultBootstrapReceiptStatus | None = None
    blockNumber: int | None = None
    confirmations: int | None = None
    receipt: dict[str, Any] | None = None


class VaultBootstrapResult(_BridgeModel):
    chainId: int
    mode: VaultBootstrapMode
    factory: str
    asset: str
    signerAddress: str
    predictedVault: str
    deployedVault: str
    alreadyDeployed: bool
    deploymentStatus: VaultBootstrapDeploymentStatus
    authorityConfig: VaultBootstrapAuthorityConfig
    createTx: VaultBootstrapCreateTx | None = None
    vaultHealth: VaultHealthCheckResult | None = None
    accountContext: AgentAccountContext | None = None
    fundingPolicy: AgentFundingPolicy | None = None
    envBlock: str
    configBlock: str


PayloadBinding = Literal["actionsDigest", "none"]
FollowUpActionExecutionMode = Literal["offchain-api", "custom"]
FollowUpActionExecutionStatus = Literal[
    "pending", "submitted", "succeeded", "failed", "skipped"
]
FundAndActionExecutionSessionStatus = Literal[
    "pendingFunding", "pendingFollowUp", "succeeded", "failed", "skipped"
]
FundAndActionExecutionCurrentStep = Literal[
    "fundTargetAccount", "followUpAction", "none"
]
FundAndActionFundingStepStatus = Literal[
    "pending", "submitted", "succeeded", "failed", "skipped"
]
FundAndActionFollowUpStepStatus = Literal[
    "pending", "submitted", "succeeded", "failed", "skipped"
]
FundAndActionExecutionTaskKind = Literal[
    "submitFunding",
    "pollFundingResult",
    "submitFollowUp",
    "pollFollowUpResult",
    "completed",
]
AssetTransferExecutionStatus = Literal[
    "pending", "submitted", "confirmed", "failed", "skipped"
]


class AccountContextDefaults(_BridgeModel):
    allowedAdaptersRoot: str | None = None
    maxDrawdownBps: str | None = None
    maxCumulativeDrawdownBps: str | None = None
    payloadBinding: PayloadBinding | None = None
    extensions: str | None = None


class AgentAccountContext(_BridgeModel):
    agentId: str
    chainId: int
    vault: str
    authority: str
    executor: str
    assetRegistryRef: str | None = None
    fundingPolicyRef: str | None = None
    defaults: AccountContextDefaults | None = None
    createdAt: str
    updatedAt: str


class AgentAccountContextCreateResult(_BridgeModel):
    accountContext: AgentAccountContext


class AgentFundingPolicy(_BridgeModel):
    policyId: str
    allowedTokenAddresses: list[str] | None = None
    allowedRecipients: list[str] | None = None
    maxAmountPerTx: str | None = None
    maxAmountPerWindow: str | None = None
    windowSeconds: int | None = None
    expiresAt: str | None = None
    repeatable: bool | None = None
    createdAt: str
    updatedAt: str


class AgentFundingPolicyCreateResult(_BridgeModel):
    fundingPolicy: AgentFundingPolicy


class PolicyViolation(_BridgeModel):
    code: str
    field: str
    message: str


class PolicyEvaluationContext(_BridgeModel):
    now: str | None = None
    currentSpentInWindow: str | None = None


class PolicyCheckResult(_BridgeModel):
    allowed: bool
    fundingPolicy: AgentFundingPolicy
    violations: list[PolicyViolation]


class FundAndActionBalanceSnapshot(_BridgeModel):
    snapshotAt: str
    maxStalenessSeconds: int
    observedAtBlock: str | None = None
    source: str | None = None


class FollowUpActionIntent(_BridgeModel):
    kind: str
    target: str | None = None
    payload: dict[str, Any] | None = None


class AssetRequirement(_BridgeModel):
    tokenAddress: str
    amountRaw: str


class FollowUpActionPlan(_BridgeModel):
    kind: str
    target: str | None = None
    executionMode: FollowUpActionExecutionMode
    summary: str
    assetRequirement: AssetRequirement | None = None
    payload: dict[str, Any] | None = None


class FollowUpActionExecutionReference(_BridgeModel):
    type: Literal["requestId", "orderId", "txHash", "custom"]
    value: str


class FollowUpActionExecutionError(_BridgeModel):
    code: str
    message: str
    retriable: bool | None = None
    details: dict[str, Any] | None = None


class FollowUpActionResult(_BridgeModel):
    kind: str
    target: str | None = None
    executionMode: FollowUpActionExecutionMode
    status: FollowUpActionExecutionStatus
    summary: str
    updatedAt: str
    startedAt: str | None = None
    completedAt: str | None = None
    attempt: int
    reference: FollowUpActionExecutionReference | None = None
    output: dict[str, Any] | None = None
    error: FollowUpActionExecutionError | None = None
    plan: FollowUpActionPlan


class FollowUpActionResultCreateResult(_BridgeModel):
    followUpActionResult: FollowUpActionResult


class Mandate(_BridgeModel):
    vault: str
    executor: str
    nonce: str
    deadline: str
    authorityEpoch: str
    allowedAdaptersRoot: str
    maxDrawdownBps: str
    maxCumulativeDrawdownBps: str
    payloadDigest: str
    extensionsHash: str


class ExecuteBaseInput(_BridgeModel):
    chainId: int | None = None
    vault: str
    from_address: str = Field(alias="from")
    mandate: Mandate
    signature: str
    actions: list[dict[str, Any]]
    adapterProofs: list[list[str]]
    extensions: str


class AssetTransferSummary(_BridgeModel):
    kind: Literal["erc20Transfer"]
    tokenAddress: str
    to: str
    amountRaw: str
    symbol: str | None = None
    decimals: int | None = None


class SignRequestResult(_BridgeModel):
    typedData: dict[str, Any]
    mandate: Mandate
    mandateHash: str
    actionsDigest: str
    extensionsHash: str


class SimulateResult(_BridgeModel):
    ok: bool
    blockNumber: int
    preAssets: str | None = None
    postAssets: str | None = None
    revertDecoded: dict[str, Any] | None = None


class AssetTransferPlanResult(_BridgeModel):
    action: dict[str, Any]
    erc20Call: dict[str, Any]
    humanReadableSummary: AssetTransferSummary
    signRequest: SignRequestResult


class AssetTransferPlanWithContextResult(_BridgeModel):
    accountContext: AgentAccountContext
    action: dict[str, Any]
    erc20Call: dict[str, Any]
    humanReadableSummary: AssetTransferSummary
    signRequest: SignRequestResult
    policyCheck: PolicyCheckResult | None = None
    simulateExecuteInput: ExecuteBaseInput | None = None
    prepareExecuteInput: ExecuteBaseInput | None = None


class AssetTransferSimulateWithContextResult(_BridgeModel):
    accountContext: AgentAccountContext
    action: dict[str, Any]
    erc20Call: dict[str, Any]
    humanReadableSummary: AssetTransferSummary
    signRequest: SignRequestResult
    policyCheck: PolicyCheckResult | None = None
    simulate: SimulateResult


class AssetTransferPrepareWithContextResult(_BridgeModel):
    accountContext: AgentAccountContext
    action: dict[str, Any]
    erc20Call: dict[str, Any]
    humanReadableSummary: AssetTransferSummary
    signRequest: SignRequestResult
    policyCheck: PolicyCheckResult | None = None
    txRequest: McpTxRequest


class AssetTransferReceipt(_BridgeModel):
    blockNumber: str
    blockHash: str | None = None
    confirmations: int | None = None


class AssetTransferExecutionError(_BridgeModel):
    code: str
    message: str
    retriable: bool | None = None
    details: dict[str, Any] | None = None


class AssetTransferResult(_BridgeModel):
    status: AssetTransferExecutionStatus
    summary: str
    updatedAt: str
    submittedAt: str | None = None
    completedAt: str | None = None
    attempt: int
    chainId: int | None = None
    txHash: str | None = None
    receipt: AssetTransferReceipt | None = None
    output: dict[str, Any] | None = None
    error: AssetTransferExecutionError | None = None
    plan: AssetTransferPlanResult | AssetTransferPlanWithContextResult


class AssetTransferResultCreateResult(_BridgeModel):
    assetTransferResult: AssetTransferResult


class FundAndActionTargetResult(_BridgeModel):
    label: str
    recipient: str
    tokenAddress: str
    requiredAmountRaw: str
    currentBalanceRaw: str
    balanceSnapshot: FundAndActionBalanceSnapshot
    fundingShortfallRaw: str
    symbol: str | None = None
    decimals: int | None = None


class FundAndActionStep(_BridgeModel):
    kind: Literal["fundTargetAccount", "followUpAction"]
    status: Literal["required", "skipped", "pending"]
    summary: str


class FundAndActionPlanResult(_BridgeModel):
    accountContext: AgentAccountContext
    fundingPolicy: AgentFundingPolicy | None = None
    fundingTarget: FundAndActionTargetResult
    evaluatedAt: str
    fundingRequired: bool
    fundingPlan: AssetTransferPlanWithContextResult | None = None
    followUpAction: FollowUpActionIntent
    followUpActionPlan: FollowUpActionPlan
    steps: list[FundAndActionStep]


class FundAndActionFundingStepExecution(_BridgeModel):
    required: bool
    status: FundAndActionFundingStepStatus
    summary: str
    updatedAt: str
    result: AssetTransferResult | None = None


class FundAndActionFollowUpStepExecution(_BridgeModel):
    status: FundAndActionFollowUpStepStatus
    summary: str
    updatedAt: str
    reference: FollowUpActionExecutionReference | None = None
    result: FollowUpActionResult | None = None


class FundAndActionExecutionSession(_BridgeModel):
    sessionId: str
    status: FundAndActionExecutionSessionStatus
    currentStep: FundAndActionExecutionCurrentStep
    createdAt: str
    updatedAt: str
    fundAndActionPlan: FundAndActionPlanResult
    fundingStep: FundAndActionFundingStepExecution
    followUpStep: FundAndActionFollowUpStepExecution


class FundAndActionSessionCreateResult(_BridgeModel):
    session: FundAndActionExecutionSession


class FundAndActionExecutionTask(_BridgeModel):
    kind: FundAndActionExecutionTaskKind
    summary: str
    fundingPlan: AssetTransferPlanWithContextResult | None = None
    assetTransferResult: AssetTransferResult | None = None
    followUpActionPlan: FollowUpActionPlan | None = None
    reference: FollowUpActionExecutionReference | None = None
    status: FundAndActionExecutionSessionStatus | None = None
    result: FollowUpActionResult | None = None


class FundAndActionSessionNextStepResult(_BridgeModel):
    session: FundAndActionExecutionSession
    task: FundAndActionExecutionTask


T_Result = TypeVar("T_Result", bound=BaseModel)


def _drop_none_values(value: Any) -> Any:
    if isinstance(value, Mapping):
        cleaned: dict[str, Any] = {}
        for key, nested_value in value.items():
            normalized = _drop_none_values(nested_value)
            if normalized is not None:
                cleaned[str(key)] = normalized
        return cleaned

    if isinstance(value, (list, tuple)):
        cleaned_items: list[Any] = []
        for nested_value in value:
            normalized = _drop_none_values(nested_value)
            if normalized is not None:
                cleaned_items.append(normalized)
        return cleaned_items

    return value


class _SubprocessMcpClient:
    def __init__(self, config: PredictConfig) -> None:
        self._config = config
        self._process: asyncio.subprocess.Process | None = None
        self._stderr_task: asyncio.Task[None] | None = None
        self._stderr_chunks: list[str] = []
        self._stdout_buffer = bytearray()
        self._request_id = 0

    async def start(self) -> None:
        argv = shlex.split(self._config.mandated_mcp_command)
        if not argv:
            raise MandatedVaultMcpUnavailableError(
                "Mandated-vault MCP command is empty."
            )

        env = os.environ.copy()
        env["ERC_MANDATED_CONTRACT_VERSION"] = self._config.mandated_contract_version
        if self._config.mandated_chain_id is not None:
            env["ERC_MANDATED_CHAIN_ID"] = str(self._config.mandated_chain_id)
        _apply_default_rpc_env(env, self._config)

        try:
            self._process = await asyncio.create_subprocess_exec(
                *argv,
                stdin=asyncio.subprocess.PIPE,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                limit=1024 * 1024,
                env=env,
            )
        except OSError as error:
            raise MandatedVaultMcpUnavailableError(
                self._redact(
                    f"Failed to start mandated-vault MCP command {argv[0]!r}: {error}"
                )
            ) from error

        self._stderr_task = asyncio.create_task(self._capture_stderr())

        try:
            await self.request(
                "initialize",
                {
                    "protocolVersion": _CLIENT_PROTOCOL_VERSION,
                    "capabilities": {"tools": {"listChanged": False}},
                    "clientInfo": {
                        "name": _CLIENT_NAME,
                        "version": _CLIENT_VERSION,
                    },
                },
            )
            await self.notify("notifications/initialized", {})
        except Exception:
            await self.close()
            raise

    async def close(self) -> None:
        if self._process is None:
            return

        process = self._process
        self._process = None

        if process.stdin is not None and not process.stdin.is_closing():
            process.stdin.close()

        try:
            await asyncio.wait_for(process.wait(), timeout=1)
        except asyncio.TimeoutError:
            process.terminate()
            try:
                await asyncio.wait_for(process.wait(), timeout=1)
            except asyncio.TimeoutError:
                process.kill()
                await process.wait()

        if self._stderr_task is not None:
            self._stderr_task.cancel()
            try:
                await self._stderr_task
            except asyncio.CancelledError:
                pass
            self._stderr_task = None

    async def list_tools(self) -> list[McpToolDescriptor]:
        payload = await self.request("tools/list", {})
        tools = payload.get("tools")
        if not isinstance(tools, list):
            raise MandatedVaultMcpUnavailableError(
                self._redact(
                    "Mandated-vault MCP tools/list response did not include a tools array."
                )
            )
        return [McpToolDescriptor.model_validate(item) for item in tools]

    async def call_tool(
        self, name: str, arguments: Mapping[str, Any] | None = None
    ) -> dict[str, Any]:
        payload = await self.request(
            "tools/call",
            {
                "name": name,
                "arguments": dict(arguments or {}),
            },
        )
        structured = payload.get("structuredContent")
        if not isinstance(structured, dict):
            raise MandatedVaultMcpUnavailableError(
                self._redact(
                    f"Mandated-vault MCP returned malformed structuredContent for {name}."
                )
            )
        return structured

    async def notify(self, method: str, params: Mapping[str, Any]) -> None:
        await self._write_message(
            {
                "jsonrpc": "2.0",
                "method": method,
                "params": dict(params),
            }
        )

    async def request(self, method: str, params: Mapping[str, Any]) -> dict[str, Any]:
        self._require_process()
        self._request_id += 1
        request_id = self._request_id
        await self._write_message(
            {
                "jsonrpc": "2.0",
                "id": request_id,
                "method": method,
                "params": dict(params),
            }
        )

        while True:
            message = await self._read_message()
            if message.get("id") != request_id:
                continue

            error = message.get("error")
            if isinstance(error, dict):
                raise MandatedVaultMcpUnavailableError(
                    self._redact(
                        f"Mandated-vault MCP {method} request failed: {error.get('message', error)}"
                    )
                )

            result = message.get("result")
            if not isinstance(result, dict):
                raise MandatedVaultMcpUnavailableError(
                    self._redact(
                        f"Mandated-vault MCP {method} response did not include an object result."
                    )
                )
            return result

    async def _write_message(self, message: Mapping[str, Any]) -> None:
        process = self._require_process()
        if process.stdin is None:
            raise MandatedVaultMcpUnavailableError(
                self._redact("Mandated-vault MCP process did not expose stdin.")
            )

        payload = (json.dumps(message) + "\n").encode("utf-8")
        process.stdin.write(payload)
        await process.stdin.drain()

    async def _read_message(self) -> dict[str, Any]:
        process = self._require_process()
        if process.stdout is None:
            raise MandatedVaultMcpUnavailableError(
                self._redact("Mandated-vault MCP process did not expose stdout.")
            )

        line = await self._read_stdout_line(process)
        if not line:
            raise MandatedVaultMcpUnavailableError(self._build_process_exit_message())

        try:
            stripped = line.decode("utf-8").strip()
        except UnicodeDecodeError as error:
            raise MandatedVaultMcpUnavailableError(
                self._redact(
                    f"Mandated-vault MCP emitted undecodable stdout payload: {error}"
                )
            ) from error

        if stripped.lower().startswith("content-length:"):
            headers: dict[str, str] = {}
            header_line = stripped
            while True:
                if not header_line:
                    break
                try:
                    key, value = header_line.split(":", 1)
                except ValueError as error:
                    raise MandatedVaultMcpUnavailableError(
                        self._redact(
                            f"Mandated-vault MCP emitted malformed response headers: {header_line!r}"
                        )
                    ) from error
                headers[key.lower()] = value.strip()
                next_line = await self._read_stdout_line(process)
                if not next_line:
                    raise MandatedVaultMcpUnavailableError(
                        self._build_process_exit_message()
                    )
                header_line = next_line.decode("utf-8").strip()

            content_length = headers.get("content-length")
            if content_length is None:
                raise MandatedVaultMcpUnavailableError(
                    self._redact(
                        "Mandated-vault MCP response omitted Content-Length header."
                    )
                )

            try:
                body = await self._read_stdout_exactly(process, int(content_length))
                payload = json.loads(body.decode("utf-8"))
            except (ValueError, json.JSONDecodeError, asyncio.IncompleteReadError) as error:
                raise MandatedVaultMcpUnavailableError(
                    self._redact(
                        f"Mandated-vault MCP emitted invalid JSON-RPC payload: {error}"
                    )
                ) from error
        else:
            try:
                payload = json.loads(stripped)
            except json.JSONDecodeError as error:
                raise MandatedVaultMcpUnavailableError(
                    self._redact(
                        f"Mandated-vault MCP emitted invalid JSON-RPC payload: {error}"
                    )
                ) from error

        if not isinstance(payload, dict):
            raise MandatedVaultMcpUnavailableError(
                self._redact("Mandated-vault MCP response payload was not an object.")
            )
        return payload

    async def _read_stdout_line(self, process: asyncio.subprocess.Process) -> bytes:
        if process.stdout is None:
            raise MandatedVaultMcpUnavailableError(
                self._redact("Mandated-vault MCP process did not expose stdout.")
            )

        while True:
            newline_index = self._stdout_buffer.find(b"\n")
            if newline_index != -1:
                line = bytes(self._stdout_buffer[: newline_index + 1])
                del self._stdout_buffer[: newline_index + 1]
                return line

            chunk = await process.stdout.read(64 * 1024)
            if not chunk:
                if self._stdout_buffer:
                    line = bytes(self._stdout_buffer)
                    self._stdout_buffer.clear()
                    return line
                return b""
            self._stdout_buffer.extend(chunk)

    async def _read_stdout_exactly(
        self, process: asyncio.subprocess.Process, length: int
    ) -> bytes:
        if process.stdout is None:
            raise MandatedVaultMcpUnavailableError(
                self._redact("Mandated-vault MCP process did not expose stdout.")
            )

        remaining = length
        chunks: list[bytes] = []
        if self._stdout_buffer:
            buffered = bytes(self._stdout_buffer[:remaining])
            if buffered:
                chunks.append(buffered)
                remaining -= len(buffered)
                del self._stdout_buffer[: len(buffered)]

        while remaining > 0:
            chunk = await process.stdout.read(remaining)
            if not chunk:
                raise asyncio.IncompleteReadError(partial=b"".join(chunks), expected=length)
            chunks.append(chunk)
            remaining -= len(chunk)

        return b"".join(chunks)

    async def _capture_stderr(self) -> None:
        process = self._process
        if process is None or process.stderr is None:
            return

        while True:
            line = await process.stderr.readline()
            if not line:
                break
            self._stderr_chunks.append(line.decode("utf-8", errors="replace").rstrip())
            if len(self._stderr_chunks) > 20:
                self._stderr_chunks = self._stderr_chunks[-20:]

    def _build_process_exit_message(self) -> str:
        process = self._require_process()
        suffix = ""
        if self._stderr_chunks:
            suffix = f" stderr={self._redact(' | '.join(self._stderr_chunks)[-400:])}"
        return self._redact(
            f"Mandated-vault MCP process exited before completing the request (returncode={process.returncode}).{suffix}"
        )

    def _redact(self, text: str) -> str:
        return redact_text(text, self._secrets())

    def _secrets(self) -> list[str | None]:
        return [
            self._config.api_key.get_secret_value() if self._config.api_key else None,
            self._config.private_key_value,
            self._config.privy_private_key_value,
            self._config.openrouter_api_key.get_secret_value()
            if self._config.openrouter_api_key
            else None,
        ]

    def _require_process(self) -> asyncio.subprocess.Process:
        if self._process is None:
            raise MandatedVaultMcpUnavailableError(
                "Mandated-vault MCP process has not been started."
            )
        return self._process


def _apply_default_rpc_env(env: dict[str, str], config: PredictConfig) -> None:
    chain_id = config.mandated_chain_id or int(config.chain_id)
    env_keys = _RPC_ENV_KEYS_BY_CHAIN_ID.get(chain_id)
    if env_keys is None:
        return
    if any(env.get(key) for key in env_keys):
        return
    rpc_url = _DEFAULT_RPC_URL_BY_CHAIN_ID.get(chain_id)
    if rpc_url:
        env["ERC_MANDATED_RPC_URL"] = rpc_url


class MandatedVaultMcpBridge:
    def __init__(self, config: PredictConfig) -> None:
        self._config = config
        self._client: _SubprocessMcpClient | None = None
        self._available_tools: dict[str, McpToolDescriptor] = {}

    async def __aenter__(self) -> Self:
        await self.connect()
        return self

    async def __aexit__(self, *_: object) -> None:
        await self.close()

    @property
    def available_tools(self) -> frozenset[str]:
        return frozenset(self._available_tools)

    @property
    def supports_vault_bootstrap(self) -> bool:
        return MANDATED_BOOTSTRAP_REQUIRED_TOOLS.issubset(self.available_tools)

    @property
    def missing_required_tools(self) -> frozenset[str]:
        if self.runtime_ready:
            return frozenset()
        return MANDATED_V1_REQUIRED_TOOLS.difference(self.available_tools)

    @property
    def runtime_ready(self) -> bool:
        return self.supports_vault_bootstrap or not MANDATED_V1_REQUIRED_TOOLS.difference(
            self.available_tools
        )

    async def connect(self) -> None:
        if self._client is not None:
            return

        client = _SubprocessMcpClient(self._config)
        await client.start()
        self._client = client
        try:
            tools = await client.list_tools()
        except Exception:
            await client.close()
            self._client = None
            self._available_tools = {}
            raise

        self._available_tools = {tool.name: tool for tool in tools}

    async def close(self) -> None:
        client = self._client
        self._client = None
        self._available_tools = {}
        if client is not None:
            await client.close()

    async def health_check(self, vault: str) -> VaultHealthCheckResult:
        structured = await self._call_tool(
            "vault_health_check",
            {"vault": vault, "chainId": self._chain_id_value()},
            tx_preparation=False,
        )
        return self._parse_result(
            "vault_health_check", structured, VaultHealthCheckResult
        )

    async def predict_vault_address(
        self,
        *,
        factory: str | None,
        asset: str,
        name: str,
        symbol: str,
        authority: str,
        salt: str,
    ) -> FactoryPredictVaultAddressResult:
        structured = await self._call_tool(
            "factory_predict_vault_address",
            {
                "chainId": self._chain_id_value(),
                "factory": factory,
                "asset": asset,
                "name": name,
                "symbol": symbol,
                "authority": authority,
                "salt": salt,
            },
            tx_preparation=False,
        )
        return self._parse_result(
            "factory_predict_vault_address",
            structured,
            FactoryPredictVaultAddressResult,
        )

    async def prepare_create_vault(
        self,
        *,
        from_address: str,
        factory: str | None,
        asset: str,
        name: str,
        symbol: str,
        authority: str,
        salt: str,
    ) -> FactoryCreateVaultPrepareResult:
        structured = await self._call_tool(
            "factory_create_vault_prepare",
            {
                "chainId": self._chain_id_value(),
                "factory": factory,
                "from": from_address,
                "asset": asset,
                "name": name,
                "symbol": symbol,
                "authority": authority,
                "salt": salt,
            },
            tx_preparation=True,
        )
        return self._parse_result(
            "factory_create_vault_prepare",
            structured,
            FactoryCreateVaultPrepareResult,
        )

    async def vault_bootstrap(
        self,
        *,
        factory: str | None,
        asset: str,
        name: str,
        symbol: str,
        salt: str,
        signer_address: str | None = None,
        mode: VaultBootstrapMode = "plan",
        authority_mode: VaultBootstrapAuthorityMode | None = None,
        authority: str | None = None,
        executor: str | None = None,
        create_account_context: bool | None = None,
        create_funding_policy: bool | None = None,
        account_context_options: Mapping[str, Any] | None = None,
        funding_policy_options: Mapping[str, Any] | None = None,
    ) -> VaultBootstrapResult:
        structured = await self._call_tool(
            "vault_bootstrap",
            {
                "chainId": self._chain_id_value(),
                "factory": factory,
                "asset": asset,
                "name": name,
                "symbol": symbol,
                "salt": salt,
                "signerAddress": signer_address,
                "mode": mode,
                "authorityMode": authority_mode,
                "authority": authority,
                "executor": executor,
                "createAccountContext": create_account_context,
                "createFundingPolicy": create_funding_policy,
                "accountContextOptions": dict(account_context_options)
                if account_context_options is not None
                else None,
                "fundingPolicyOptions": dict(funding_policy_options)
                if funding_policy_options is not None
                else None,
            },
            tx_preparation=False,
            required_tools=MANDATED_BOOTSTRAP_REQUIRED_TOOLS,
        )
        return self._parse_result("vault_bootstrap", structured, VaultBootstrapResult)

    async def create_agent_account_context(
        self,
        *,
        agent_id: str,
        vault: str,
        authority: str,
        executor: str,
        asset_registry_ref: str | None = None,
        funding_policy_ref: str | None = None,
        defaults: Mapping[str, Any] | None = None,
        created_at: str | None = None,
        updated_at: str | None = None,
    ) -> AgentAccountContextCreateResult:
        structured = await self._call_tool(
            "agent_account_context_create",
            {
                "agentId": agent_id,
                "chainId": self._chain_id_value(),
                "vault": vault,
                "authority": authority,
                "executor": executor,
                "assetRegistryRef": asset_registry_ref,
                "fundingPolicyRef": funding_policy_ref,
                "defaults": defaults,
                "createdAt": created_at,
                "updatedAt": updated_at,
            },
            tx_preparation=False,
        )
        return self._parse_result(
            "agent_account_context_create",
            structured,
            AgentAccountContextCreateResult,
        )

    async def create_agent_funding_policy(
        self,
        *,
        policy_id: str,
        allowed_token_addresses: Sequence[str] | None = None,
        allowed_recipients: Sequence[str] | None = None,
        max_amount_per_tx: str | None = None,
        max_amount_per_window: str | None = None,
        window_seconds: int | None = None,
        expires_at: str | None = None,
        repeatable: bool | None = None,
        created_at: str | None = None,
        updated_at: str | None = None,
    ) -> AgentFundingPolicyCreateResult:
        structured = await self._call_tool(
            "agent_funding_policy_create",
            {
                "policyId": policy_id,
                "allowedTokenAddresses": list(allowed_token_addresses)
                if allowed_token_addresses is not None
                else None,
                "allowedRecipients": list(allowed_recipients)
                if allowed_recipients is not None
                else None,
                "maxAmountPerTx": max_amount_per_tx,
                "maxAmountPerWindow": max_amount_per_window,
                "windowSeconds": window_seconds,
                "expiresAt": expires_at,
                "repeatable": repeatable,
                "createdAt": created_at,
                "updatedAt": updated_at,
            },
            tx_preparation=False,
        )
        return self._parse_result(
            "agent_funding_policy_create",
            structured,
            AgentFundingPolicyCreateResult,
        )

    async def build_agent_fund_and_action_plan(
        self,
        *,
        account_context: Mapping[str, Any],
        funding_target: Mapping[str, Any],
        funding_context: Mapping[str, Any],
        follow_up_action: Mapping[str, Any],
        funding_policy: Mapping[str, Any] | None = None,
    ) -> FundAndActionPlanResult:
        structured = await self._call_tool(
            "agent_build_fund_and_action_plan",
            {
                "accountContext": dict(account_context),
                "fundingPolicy": dict(funding_policy) if funding_policy else None,
                "fundingTarget": dict(funding_target),
                "fundingContext": dict(funding_context),
                "followUpAction": dict(follow_up_action),
            },
            tx_preparation=False,
        )
        return self._parse_result(
            "agent_build_fund_and_action_plan",
            structured,
            FundAndActionPlanResult,
        )

    async def create_agent_fund_and_action_session(
        self,
        *,
        fund_and_action_plan: Mapping[str, Any],
        session_id: str | None = None,
        created_at: str | None = None,
    ) -> FundAndActionSessionCreateResult:
        structured = await self._call_tool(
            "agent_fund_and_action_session_create",
            {
                "fundAndActionPlan": dict(fund_and_action_plan),
                "sessionId": session_id,
                "createdAt": created_at,
            },
            tx_preparation=False,
        )
        return self._parse_result(
            "agent_fund_and_action_session_create",
            structured,
            FundAndActionSessionCreateResult,
        )

    async def next_agent_fund_and_action_session_step(
        self,
        *,
        session: Mapping[str, Any],
    ) -> FundAndActionSessionNextStepResult:
        structured = await self._call_tool(
            "agent_fund_and_action_session_next_step",
            {"session": dict(session)},
            tx_preparation=False,
        )
        return self._parse_result(
            "agent_fund_and_action_session_next_step",
            structured,
            FundAndActionSessionNextStepResult,
        )

    async def apply_agent_fund_and_action_session_event(
        self,
        *,
        session: Mapping[str, Any],
        event: Mapping[str, Any],
    ) -> FundAndActionSessionCreateResult:
        structured = await self._call_tool(
            "agent_fund_and_action_session_apply_event",
            {
                "session": dict(session),
                "event": dict(event),
            },
            tx_preparation=False,
        )
        return self._parse_result(
            "agent_fund_and_action_session_apply_event",
            structured,
            FundAndActionSessionCreateResult,
        )

    async def create_agent_follow_up_action_result(
        self,
        *,
        follow_up_action_plan: Mapping[str, Any],
        status: str,
        updated_at: str,
        started_at: str | None = None,
        completed_at: str | None = None,
        attempt: int | None = None,
        reference: Mapping[str, Any] | None = None,
        output: Mapping[str, Any] | None = None,
        error: Mapping[str, Any] | None = None,
    ) -> FollowUpActionResultCreateResult:
        structured = await self._call_tool(
            "agent_follow_up_action_result_create",
            {
                "followUpActionPlan": dict(follow_up_action_plan),
                "status": status,
                "updatedAt": updated_at,
                "startedAt": started_at,
                "completedAt": completed_at,
                "attempt": attempt,
                "reference": dict(reference) if reference else None,
                "output": dict(output) if output else None,
                "error": dict(error) if error else None,
            },
            tx_preparation=False,
        )
        return self._parse_result(
            "agent_follow_up_action_result_create",
            structured,
            FollowUpActionResultCreateResult,
        )

    async def create_vault_asset_transfer_result(
        self,
        *,
        asset_transfer_plan: Mapping[str, Any],
        status: str,
        updated_at: str,
        submitted_at: str | None = None,
        completed_at: str | None = None,
        attempt: int | None = None,
        chain_id: int | None = None,
        tx_hash: str | None = None,
        receipt: Mapping[str, Any] | None = None,
        output: Mapping[str, Any] | None = None,
        error: Mapping[str, Any] | None = None,
    ) -> AssetTransferResultCreateResult:
        structured = await self._call_tool(
            "vault_asset_transfer_result_create",
            {
                "assetTransferPlan": dict(asset_transfer_plan),
                "status": status,
                "updatedAt": updated_at,
                "submittedAt": submitted_at,
                "completedAt": completed_at,
                "attempt": attempt,
                "chainId": chain_id,
                "txHash": tx_hash,
                "receipt": dict(receipt) if receipt else None,
                "output": dict(output) if output else None,
                "error": dict(error) if error else None,
            },
            tx_preparation=False,
        )
        return self._parse_result(
            "vault_asset_transfer_result_create",
            structured,
            AssetTransferResultCreateResult,
        )

    async def check_vault_asset_transfer_policy(
        self,
        *,
        funding_policy: Mapping[str, Any],
        token_address: str,
        to: str,
        amount_raw: str,
        now: str | None = None,
        current_spent_in_window: str | None = None,
    ) -> PolicyCheckResult:
        structured = await self._call_tool(
            "vault_check_asset_transfer_policy",
            {
                "fundingPolicy": dict(funding_policy),
                "tokenAddress": token_address,
                "to": to,
                "amountRaw": amount_raw,
                "now": now,
                "currentSpentInWindow": current_spent_in_window,
            },
            tx_preparation=False,
        )
        return self._parse_result(
            "vault_check_asset_transfer_policy",
            structured,
            PolicyCheckResult,
        )

    async def build_vault_asset_transfer_plan_from_context(
        self,
        *,
        account_context: Mapping[str, Any],
        token_address: str,
        to: str,
        amount_raw: str,
        nonce: str,
        deadline: str,
        authority_epoch: str,
        funding_policy: Mapping[str, Any] | None = None,
        allowed_adapters_root: str | None = None,
        max_drawdown_bps: str | None = None,
        max_cumulative_drawdown_bps: str | None = None,
        payload_binding: PayloadBinding | None = None,
        extensions: str | None = None,
        symbol: str | None = None,
        decimals: int | None = None,
        policy_evaluation: Mapping[str, Any] | None = None,
    ) -> AssetTransferPlanWithContextResult:
        structured = await self._call_tool(
            "vault_build_asset_transfer_plan_from_context",
            {
                "accountContext": dict(account_context),
                "fundingPolicy": dict(funding_policy) if funding_policy else None,
                "tokenAddress": token_address,
                "to": to,
                "amountRaw": amount_raw,
                "nonce": nonce,
                "deadline": deadline,
                "authorityEpoch": authority_epoch,
                "allowedAdaptersRoot": allowed_adapters_root,
                "maxDrawdownBps": max_drawdown_bps,
                "maxCumulativeDrawdownBps": max_cumulative_drawdown_bps,
                "payloadBinding": payload_binding,
                "extensions": extensions,
                "symbol": symbol,
                "decimals": decimals,
                "policyEvaluation": dict(policy_evaluation)
                if policy_evaluation
                else None,
            },
            tx_preparation=False,
        )
        return self._parse_result(
            "vault_build_asset_transfer_plan_from_context",
            structured,
            AssetTransferPlanWithContextResult,
        )

    async def simulate_vault_asset_transfer_from_context(
        self,
        *,
        account_context: Mapping[str, Any],
        token_address: str,
        to: str,
        amount_raw: str,
        nonce: str,
        deadline: str,
        authority_epoch: str,
        signature: str,
        adapter_proofs: Sequence[Sequence[str]],
        funding_policy: Mapping[str, Any] | None = None,
        from_address: str | None = None,
        allowed_adapters_root: str | None = None,
        max_drawdown_bps: str | None = None,
        max_cumulative_drawdown_bps: str | None = None,
        payload_binding: PayloadBinding | None = None,
        extensions: str | None = None,
        symbol: str | None = None,
        decimals: int | None = None,
        policy_evaluation: Mapping[str, Any] | None = None,
    ) -> AssetTransferSimulateWithContextResult:
        structured = await self._call_tool(
            "vault_simulate_asset_transfer_from_context",
            {
                "accountContext": dict(account_context),
                "fundingPolicy": dict(funding_policy) if funding_policy else None,
                "from": from_address,
                "tokenAddress": token_address,
                "to": to,
                "amountRaw": amount_raw,
                "nonce": nonce,
                "deadline": deadline,
                "authorityEpoch": authority_epoch,
                "allowedAdaptersRoot": allowed_adapters_root,
                "maxDrawdownBps": max_drawdown_bps,
                "maxCumulativeDrawdownBps": max_cumulative_drawdown_bps,
                "payloadBinding": payload_binding,
                "extensions": extensions,
                "symbol": symbol,
                "decimals": decimals,
                "policyEvaluation": dict(policy_evaluation)
                if policy_evaluation
                else None,
                "signature": signature,
                "adapterProofs": [list(group) for group in adapter_proofs],
            },
            tx_preparation=False,
        )
        return self._parse_result(
            "vault_simulate_asset_transfer_from_context",
            structured,
            AssetTransferSimulateWithContextResult,
        )

    async def prepare_vault_asset_transfer_from_context(
        self,
        *,
        account_context: Mapping[str, Any],
        token_address: str,
        to: str,
        amount_raw: str,
        nonce: str,
        deadline: str,
        authority_epoch: str,
        signature: str,
        adapter_proofs: Sequence[Sequence[str]],
        funding_policy: Mapping[str, Any] | None = None,
        from_address: str | None = None,
        allowed_adapters_root: str | None = None,
        max_drawdown_bps: str | None = None,
        max_cumulative_drawdown_bps: str | None = None,
        payload_binding: PayloadBinding | None = None,
        extensions: str | None = None,
        symbol: str | None = None,
        decimals: int | None = None,
        policy_evaluation: Mapping[str, Any] | None = None,
    ) -> AssetTransferPrepareWithContextResult:
        structured = await self._call_tool(
            "vault_prepare_asset_transfer_from_context",
            {
                "accountContext": dict(account_context),
                "fundingPolicy": dict(funding_policy) if funding_policy else None,
                "from": from_address,
                "tokenAddress": token_address,
                "to": to,
                "amountRaw": amount_raw,
                "nonce": nonce,
                "deadline": deadline,
                "authorityEpoch": authority_epoch,
                "allowedAdaptersRoot": allowed_adapters_root,
                "maxDrawdownBps": max_drawdown_bps,
                "maxCumulativeDrawdownBps": max_cumulative_drawdown_bps,
                "payloadBinding": payload_binding,
                "extensions": extensions,
                "symbol": symbol,
                "decimals": decimals,
                "policyEvaluation": dict(policy_evaluation)
                if policy_evaluation
                else None,
                "signature": signature,
                "adapterProofs": [list(group) for group in adapter_proofs],
            },
            tx_preparation=False,
        )
        return self._parse_result(
            "vault_prepare_asset_transfer_from_context",
            structured,
            AssetTransferPrepareWithContextResult,
        )

    async def _call_tool(
        self,
        tool_name: str,
        arguments: Mapping[str, Any],
        *,
        tx_preparation: bool,
        required_tools: Sequence[str] | None = None,
    ) -> dict[str, Any]:
        await self.connect()
        self._assert_tool_available(
            tool_name,
            tx_preparation=tx_preparation,
            required_tools=required_tools,
        )

        client = self._require_client()
        filtered_arguments = _drop_none_values(arguments)
        if not isinstance(filtered_arguments, dict):
            raise MandatedVaultMcpUnavailableError(
                f"Mandated-vault MCP {tool_name} arguments did not normalize to an object."
            )
        return await client.call_tool(tool_name, filtered_arguments)

    def _assert_tool_available(
        self,
        tool_name: str,
        *,
        tx_preparation: bool,
        required_tools: Sequence[str] | None = None,
    ) -> None:
        if tool_name not in self._available_tools:
            raise MandatedVaultMcpMissingToolsError([tool_name], operation=tool_name)

        if required_tools is not None:
            missing = frozenset(required_tools).difference(self.available_tools)
            if missing:
                raise MandatedVaultMcpMissingToolsError(
                    sorted(missing),
                    operation=tool_name,
                )
            return

        if tx_preparation and self.missing_required_tools:
            raise MandatedVaultMcpMissingToolsError(
                sorted(self.missing_required_tools),
                operation=tool_name,
            )

    def _require_client(self) -> _SubprocessMcpClient:
        if self._client is None:
            raise MandatedVaultMcpUnavailableError(
                "Mandated-vault MCP client is not connected."
            )
        return self._client

    def _chain_id_value(self) -> int:
        return self._config.mandated_chain_id or int(self._config.chain_id)

    def _parse_result(
        self,
        tool_name: str,
        structured: Mapping[str, Any],
        model: type[T_Result],
    ) -> T_Result:
        tool_error = structured.get("error")
        if isinstance(tool_error, dict):
            parsed_error = McpToolError.model_validate(tool_error)
            raise MandatedVaultMcpError(
                f"Mandated-vault MCP tool {tool_name} failed: {parsed_error.code} {parsed_error.message}"
            )

        payload = structured.get("result")
        if not isinstance(payload, dict):
            raise ValueError(f"Malformed {tool_name} response: missing result object.")

        try:
            return model.model_validate(payload)
        except ValidationError as error:
            raise ValueError(f"Malformed {tool_name} response: {error}") from error


path: lib/models.py
content: from __future__ import annotations

from typing import Any

from pydantic import BaseModel, ConfigDict


class PredictModel(BaseModel):
    model_config = ConfigDict(extra="allow", populate_by_name=True)


class AuthRequest(PredictModel):
    signer: str
    message: str
    signature: str


class AuthMessageResponse(PredictModel):
    message: str

    @classmethod
    def from_api(cls, payload: dict[str, Any]) -> "AuthMessageResponse":
        message = payload.get("message") or payload.get("data", {}).get("message")
        if not isinstance(message, str) or not message.strip():
            raise ValueError(
                "predict.fun auth message response did not include a message string"
            )
        return cls(message=message)


class JwtResponse(PredictModel):
    token: str

    @classmethod
    def from_api(cls, payload: dict[str, Any]) -> "JwtResponse":
        data = payload.get("data", {})
        if not isinstance(data, dict):
            data = {}
        token = (
            payload.get("token")
            or payload.get("jwt")
            or payload.get("accessToken")
            or data.get("token")
            or data.get("jwt")
            or data.get("accessToken")
        )
        if not isinstance(token, str) or not token.strip():
            raise ValueError("predict.fun auth response did not include a JWT token")
        return cls(token=token)


class OutcomeRecord(PredictModel):
    id: str | int | None = None
    name: str | None = None
    tokenId: str | None = None
    onChainId: str | None = None


class MarketRecord(PredictModel):
    id: str | int
    title: str | None = None
    question: str | None = None
    status: str | None = None
    categorySlug: str | None = None
    decimalPrecision: int | None = None
    feeRateBps: int | None = None
    volume24hUsd: float | None = None
    isNegRisk: bool | None = None
    isYieldBearing: bool | None = None
    outcomes: list[OutcomeRecord] | None = None


class MarketStatsRecord(PredictModel):
    marketId: str | int | None = None
    volume24hUsd: float | None = None
    liquidityUsd: float | None = None


class LastSaleRecord(PredictModel):
    marketId: str | int | None = None
    price: float | None = None
    side: str | None = None


class OrderBookRecord(PredictModel):
    marketId: str | int | None = None
    asks: list[list[float]] | list[tuple[float, float]] | None = None
    bids: list[list[float]] | list[tuple[float, float]] | None = None
    updateTimestampMs: int | None = None


class OrderRecord(PredictModel):
    hash: str | None = None
    status: str | None = None
    marketId: str | int | None = None


class PositionRecord(PredictModel):
    positionId: str | None = None
    marketId: str | int | None = None
    tokenId: str | None = None
    outcomeName: str | None = None
    quantity: str | float | None = None
    status: str | None = None


def extract_object(payload: dict[str, Any], *candidate_keys: str) -> dict[str, Any]:
    for key in candidate_keys:
        candidate = payload.get(key)
        if isinstance(candidate, dict):
            return candidate
    return payload


def extract_list(
    payload: dict[str, Any] | list[dict[str, Any]], *candidate_keys: str
) -> list[dict[str, Any]]:
    if isinstance(payload, list):
        return payload

    for key in candidate_keys:
        candidate = payload.get(key)
        if isinstance(candidate, list):
            return [item for item in candidate if isinstance(item, dict)]

    data = payload.get("data")
    if isinstance(data, list):
        return [item for item in data if isinstance(item, dict)]

    return []


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector:  ()