Skill: walioo/predictclaw (version 0.1.34)

--- 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","erc-mandated-mcp"],"env":["PREDICT_ENV","PREDICT_WALLET_MODE","PREDICT_API_KEY","PREDICT_EOA_PRIVATE_KEY","PREDICT_ACCOUNT_ADDRESS","PREDICT_PRIVY_PRIVATE_KEY","ERC_MANDATED_MCP_COMMAND","ERC_MANDATED_VAULT_ADDRESS","ERC_MANDATED_FACTORY_ADDRESS",[REDACTED_SECRET],"ERC_MANDATED_VAULT_AUTHORITY",[REDACTED_SECRET],[REDACTED_SECRET],[REDACTED_SECRET],"OPENROUTER_API_KEY"]},"install":[{"id":"uv-brew","kind":"brew","formula":"uv","bins":["uv"],"label":"Install uv (brew)"},{"id":"erc-mandated-mcp-node","kind":"node","package":"@erc-mandated/mcp","bins":["erc-mandated-mcp"],"label":"Install erc-mandated-mcp (manual prerequisite for vault flows)"}]}} --- # PredictClaw PredictClaw is the predict.fun-native OpenClaw skill for browsing markets, checking wallet readiness, viewing funding guidance, withdrawing funds, placing buys, inspecting positions, and scanning hedge opportunities. PredictClaw's version source of truth is the repository-root `pyproject.toml`. When checking GitHub or packaging metadata, use this repository root directly. ## Install ### ClawHub / packaged install ```bash clawhub install predictclaw cd ~/.openclaw/skills/predictclaw uv sync cp template.env .env ``` The installed skill directory `~/.openclaw/skills/predictclaw` is the only canonical user config root. In OpenClaw manifests and examples, this same installed path may appear as `{baseDir}`. Any repository checkout or workspace copy is a development-only artifact, not a user config root. ### Manual install 1. Copy or symlink this repository into `~/.openclaw/skills/predictclaw/` 2. From the installed skill directory, run: ```bash cd {baseDir} && uv sync cd {baseDir} && cp template.env .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 `~/.openclaw/skills/predictclaw/.env` file, auto-loaded by `scripts/predictclaw.py` when present; in manifests and examples, that same installed path may appear as `{baseDir}` 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 now declares the external runtime and conditionally used env surfaces so ClawHub users can review them before installation. The mode-specific requirements are still documented below and enforced by the runtime config validator; not every listed variable is required at the same time. ## Mode-first onboarding Choose the mode first, then show only the minimum fields for that mode. - `read-only` - Browsing only. - Minimum fields: `PREDICT_ENV`, `PREDICT_WALLET_MODE`, and `PREDICT_API_KEY` for mainnet reads. - `eoa` - Direct signer trading. - Minimum fields: `PREDICT_ENV`, `PREDICT_WALLET_MODE=eoa`, `PREDICT_API_KEY`, `PREDICT_EOA_PRIVATE_KEY`. - `predict-account + ERC_MANDATED_*` - Recommended funded-trading path. - Ask first: **Do you already have a vault?** - **Have a vault** -> minimum fields: `PREDICT_ENV`, [REDACTED_SECRET], `PREDICT_API_KEY`, `PREDICT_ACCOUNT_ADDRESS`, `PREDICT_PRIVY_PRIVATE_KEY`, `ERC_MANDATED_MCP_COMMAND`, `ERC_MANDATED_CHAIN_ID`, `ERC_MANDATED_VAULT_ADDRESS`, optional `ERC_MANDATED_CONTRACT_VERSION`. - **Need a vault** -> deploy or redeploy a vault first with the pure `mandated-vault` bootstrap path, then return to overlay. - pure `mandated-vault` - Recommended governance/control-plane path. - Minimum fields: `PREDICT_ENV`, [REDACTED_SECRET], `PREDICT_API_KEY`, `PREDICT_EOA_PRIVATE_KEY`, `ERC_MANDATED_MCP_COMMAND`, `ERC_MANDATED_CHAIN_ID`. Advanced authority / executor / bootstrap private keys are follow-up fields, not default first-screen requirements. Only surface them when the selected workflow really executes vault-side actions. Do not treat the full derivation tuple as the primary first-step answer for overlay onboarding when the user already has a deployed vault. ## Mode reminders 1. Run `uv sync` in the installed skill directory. 2. Pick a bootstrap file: - `template.env` -> secret-free local fixture bootstrap - `template.readonly.env` -> live read-only market reads - `template.eoa.env` -> direct private-key trading - `template.predict-account.env` -> recommended funded-trading path (Predict Account trades, Vault can fund it) - `template.mandated-vault.env` -> recommended governance/control-plane path for advanced pure vault workflows 3. Copy the chosen file to `.env` inside `~/.openclaw/skills/predictclaw/`. 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 ``` `wallet status` requires signer configuration. For `read-only`, start with `markets trending` or `market <id> --json` instead. mainnet market reads require PREDICT_API_KEY. Non-mainnet usage remains explicit-only and is no longer the packaged default. `test-fixture` only knows the bundled local market IDs (`123`, `456`, `789`, `101`, `202`). Switch to the live read-only template before querying a real production market ID. ## Recommended operating model - For user-facing funded trading, recommend `predict-account + ERC_MANDATED_*`. - In that model, Predict Account remains the trading identity and deposit address, while Vault acts as the funding/control plane. - For governance-first or bootstrap-only workflows, recommend pure `mandated-vault` instead. - Do not present pure `mandated-vault` as a co-equal default for trading because it still fails closed on buy / positions / hedge flows. - For overlay onboarding, ask whether the user already has a vault before asking for advanced vault metadata. 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 status --json ``` Canonical user-facing templates now include: - `template.predict-account.env` - `template.predict-account-vault.env` `template.mandated-vault.env` remains only as the internal/bootstrap compatibility template. ## Configuration examples The snippets below are `.env` examples. Put them in `{baseDir}/.env` or export the same names in your shell. The installed skill directory `~/.openclaw/skills/predictclaw` remains the only canonical user config root; `{baseDir}` is just the manifest/example placeholder for that installed path. `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. Do not lead with the full env matrix when a user only asks how to configure the skill. Ask for the mode first, then show only the minimum fields for that mode. ### bootstrap-safe fixture mode ```dotenv PREDICT_ENV=test-fixture PREDICT_WALLET_MODE=read-only ``` Use this for secret-free CLI verification and local market browsing only. It does not hit the live API. ### live read-only mode ```dotenv PREDICT_ENV=mainnet PREDICT_WALLET_MODE=read-only PREDICT_API_KEY=YOUR_PREDICT_API_KEY ``` Use the live templates as mainnet-first examples. ### eoa mode ```dotenv PREDICT_ENV=mainnet PREDICT_WALLET_MODE=eoa PREDICT_API_BASE_URL=https://api.predict.fun PREDICT_API_KEY=YOUR_PREDICT_API_KEY PREDICT_EOA_PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY ``` ### predict-account mode ```dotenv PREDICT_ENV=mainnet PREDICT_WALLET_MODE=predict-account PREDICT_API_BASE_URL=https://api.predict.fun PREDICT_API_KEY=YOUR_PREDICT_API_KEY PREDICT_ACCOUNT_ADDRESS=0xYOUR_PREDICT_ACCOUNT PREDICT_PRIVY_PRIVATE_KEY=0xYOUR_PRIVY_EXPORTED_KEY ``` ### predict-account + vault route The user-facing advanced mode is `predict-account + vault`, not a standalone `mandated-vault` mode. Use the canonical template: ```dotenv PREDICT_ENV=mainnet PREDICT_API_BASE_URL=https://api.predict.fun PREDICT_API_KEY=YOUR_PREDICT_API_KEY PREDICT_WALLET_MODE=predict-account 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=56 ``` If the vault does not exist yet, run the bootstrap helper first. ### Internal bootstrap subflow (`mandated-vault`) The older `mandated-vault` path remains only as the internal/bootstrap compatibility subflow used to create or prepare a vault before returning to `predict-account + vault`. ```dotenv PREDICT_ENV=mainnet PREDICT_API_BASE_URL=https://api.predict.fun PREDICT_API_KEY=YOUR_PREDICT_API_KEY PREDICT_WALLET_MODE=mandated-vault PREDICT_EOA_PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp ERC_MANDATED_CHAIN_ID=56 ``` PredictClaw uses the fixed product factory [REDACTED_SECRET], previews first, requires explicit confirmation, and backfills `.env` with the deployed vault address and resolved values. On `--confirm`, PredictClaw automatically enables the MCP broadcast gate for that subprocess and bridges the bootstrap signer key. The standard flow does not require manually setting `ERC_MANDATED_ENABLE_BROADCAST=1` or [REDACTED_SECRET], but it no longer auto-edits `.env`. Preview first: ```bash cd {baseDir} && uv run python scripts/predictclaw.py wallet bootstrap-vault --json ``` Confirm and broadcast: ```bash cd {baseDir} && uv run python scripts/predictclaw.py wallet bootstrap-vault --confirm --json ``` ### Internal bootstrap compatibility paths If you intentionally need the internal bootstrap path directly, the following compatibility variants remain: #### Explicit deployed vault ```dotenv PREDICT_ENV=mainnet PREDICT_API_BASE_URL=https://api.predict.fun PREDICT_API_KEY=YOUR_PREDICT_API_KEY PREDICT_WALLET_MODE=mandated-vault ERC_MANDATED_VAULT_ADDRESS=0xYOUR_DEPLOYED_VAULT ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp ERC_MANDATED_CHAIN_ID=56 ``` #### Predicted / undeployed vault ```dotenv PREDICT_ENV=mainnet PREDICT_API_BASE_URL=https://api.predict.fun PREDICT_API_KEY=YOUR_PREDICT_API_KEY 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=56 ``` [REDACTED_SECRET] is optional. When it is unset, PredictClaw reuses [REDACTED_SECRET] as the executor signer for the current Preflight MVP contract. This advanced/manual path can still expose `manual-only` create-vault preparation guidance when you intentionally stay on the manual route. For advanced overrides, [REDACTED_SECRET] can replace the default bootstrap signer resolution and `ERC_MANDATED_ENABLE_BROADCAST` can explicitly force the execute-mode gate on or off. In the standard `--confirm` path, PredictClaw auto-bridges both values to the MCP subprocess for you. ### predict-account + vault overlay (recommended advanced funding route) ```dotenv PREDICT_ENV=mainnet PREDICT_API_BASE_URL=https://api.predict.fun PREDICT_API_KEY=YOUR_PREDICT_API_KEY PREDICT_WALLET_MODE=predict-account PREDICT_ACCOUNT_ADDRESS=0xYOUR_PREDICT_ACCOUNT PREDICT_PRIVY_PRIVATE_KEY=0xYOUR_PRIVY_EXPORTED_KEY ERC_MANDATED_VAULT_ADDRESS=0xYOUR_DEPLOYED_VAULT ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp ERC_MANDATED_CONTRACT_VERSION=v0.3.0-agent-contract ERC_MANDATED_CHAIN_ID=56 ``` Ask first: **Do you already have a vault?** - **Have a vault** -> this is the default overlay path. Provide `ERC_MANDATED_VAULT_ADDRESS` and let PredictClaw resolve the remaining vault metadata where possible. - **Need a vault** -> deploy or redeploy a vault first with the pure `mandated-vault` bootstrap path, then return to overlay. Only if automatic resolution fails should you manually add advanced vault metadata such as [REDACTED_SECRET], `ERC_MANDATED_VAULT_AUTHORITY`, or authority/executor private keys. 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 a vault yet, the recommended answer is to deploy or redeploy one first with the pure `mandated-vault` bootstrap flow. 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`) remains available as an advanced/manual path rather than the default first step. 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 PredictClaw exposes four user-facing 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`. - `predict-account + vault` — user-facing advanced route where Predict Account remains the trading identity and Vault acts as the advanced funding source. ## How to answer funding-address questions - In `predict-account + vault`, the default user-facing answer is the Vault deposit flow. - Predict Account remains the trading identity and receives the downstream vault-driven top-up afterward. - `wallet deposit --json` / `wallet status --json` therefore distinguish the default funding entry (`manualTopUpAddress` / `fundingAddress`) from the trading identity (`predictAccountAddress`, `tradingIdentityAddress`). - Only answer with the Predict Account deposit address when the active route is plain `predict-account` without the vault overlay. ## First-time setup - Default local posture is `test-fixture` or `mainnet`. - `mainnet` market reads require `PREDICT_API_KEY`. - Explicit non-mainnet routing is supported only when you opt into it yourself. - `read-only` is browse-only. Start with `markets ...`, not signer-backed wallet or trade commands. - `wallet status requires signer configuration`. - `eoa` requires `PREDICT_EOA_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 default funding entry for the active signer mode. - `wallet bootstrap-vault` is the helper used during `predict-account + vault` onboarding when a vault still needs to be created or prepared. - `wallet redeem-vault --preview --json` inspects vault-share redeemability before any real redeem flow is attempted. - Redeem preview returns machine-readable `redeemableNow`, `blockingReason`, and `contractError` fields, including contract errors such as `ERC4626ExceededMaxRedeem`. - `wallet withdraw` performs safety validation before any transfer logic. - `mandated-vault` remains only as an internal/bootstrap compatibility path and is not a standalone user mode. - Default bootstrap only needs the signer EOA, deployment fee funding, and any optional `ERC_MANDATED_FUNDING_*` amount caps. - In `predict-account + ERC_MANDATED_*` overlay, `wallet status` / `wallet deposit` expose `manualTopUpAddress`, `tradingIdentityAddress`, `orchestrationVaultAddress`, and `vault-to-predict-account` funding semantics while Predict Account remains the trade identity. - In that route, the default funding ingress is the Vault deposit flow, not the Predict Account address. - Vault-related JSON also exposes `vaultAuthority`, `vaultExecutor`, `bootstrapSigner`, `allowedTokenAddresses`, and `allowedRecipients` so OpenClaw can reason about configured permissions. - 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`; in overlay mode the default path is an explicit `ERC_MANDATED_VAULT_ADDRESS`, with asset/authority metadata resolved automatically where possible and only escalated to manual fields when that resolution fails. - To detect whether the runtime is already available, run `cd {baseDir} && uv run python scripts/predictclaw.py setup mandated-mcp`. - The default path does not globally install packages and does not auto-edit `.env`. - Install the external `erc-mandated-mcp` runtime yourself, then set `ERC_MANDATED_MCP_COMMAND` manually. - 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 bootstrap-vault --json cd {baseDir} && uv run python scripts/predictclaw.py wallet bootstrap-vault --confirm --json cd {baseDir} && uv run python scripts/predictclaw.py wallet redeem-vault --share-token 0x4a88c1c95d0f59ee87c3286ed23e9dcdf4cf08d7 --holder 0x7df0ba782D85B93266b595d496088ABFAc823950 --all --preview --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 bootstrap-vault --json cd {baseDir} && uv run python scripts/predictclaw.py wallet bootstrap-vault --confirm --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 `mainnet` in code; `template.env` intentionally bootstraps `test-fixture`; accepted values are `mainnet`, `testnet`, 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; packaged live templates pin this to `https://api.predict.fun`, while leaving it empty uses the env-specific default | | `PREDICT_API_KEY` | Mainnet-authenticated predict.fun API access; required for mainnet market reads and trading | | `PREDICT_EOA_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` | Product default factory for pure bootstrap and manual derivation override; current default is [REDACTED_SECRET] | | [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 | | [REDACTED_SECRET] | Optional execute-mode bootstrap signer override; when unset, PredictClaw falls back to `PREDICT_EOA_PRIVATE_KEY`, then [REDACTED_SECRET] | | `ERC_MANDATED_ENABLE_BROADCAST` | Optional execute-mode MCP gate override; `wallet bootstrap-vault --confirm` auto-bridges this to `1` unless you explicitly override it | | `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 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. - When the goal is to keep Predict Account as the trading identity and let Vault only fund it, immediately choose `predict-account + ERC_MANDATED_*`. - `mandated-vault` is an advanced explicit opt-in mode. Treat it as a separate control-plane path, not a co-equal answer for Predict Account trading. - `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. - `wallet bootstrap-vault --json` is preview-only, requires explicit confirmation before broadcast, and backfills `.env` after a confirmed deployment. - Advanced/manual derivation flows can still 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 funding guidance, withdrawing funds, placing buys, inspecting positions, and scanning hedge opportunities.

This repository packages PredictClaw as a standalone OpenClaw skill with its own CLI, runtime configuration, and tests.

PredictClaw's version source of truth is the repository-root `pyproject.toml`. When checking GitHub or building from source, use this repository root directly.

## 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 template.env .env
```

The installed skill directory `~/.openclaw/skills/predictclaw` is the only canonical user config root. In OpenClaw manifests and examples, this same installed path may appear as `{baseDir}`. Any repository checkout or workspace copy is a development-only artifact, not a user config root.

### Manual install

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

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

### Local repo development

From the repository root:

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

Use the repository root for development and tests only. Do not treat it as the canonical location for end-user `.env` edits or normal installed-skill CLI usage.

## 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 `~/.openclaw/skills/predictclaw/.env` file, auto-loaded by `scripts/predictclaw.py` when present; in manifests and examples, that same installed path may appear as `{baseDir}`

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 now declares the external runtime and conditionally used env surfaces so ClawHub users can review them before installation. The mode-specific requirements are still documented below and enforced by the runtime config validator; not every listed variable is required at the same time.

## Mode-first onboarding (recommended)

Choose the mode first, then fill only the minimum fields for that mode.

- `read-only`
  - Use for browsing only.
  - Minimum fields: `PREDICT_ENV`, `PREDICT_WALLET_MODE`, `PREDICT_API_KEY` for mainnet reads.
- `eoa`
  - Use for direct signer trading without Predict Account overlay.
  - Minimum fields: `PREDICT_ENV`, `PREDICT_WALLET_MODE=eoa`, `PREDICT_API_KEY`, `PREDICT_EOA_PRIVATE_KEY`.
- `predict-account + ERC_MANDATED_*` (recommended funded-trading path)
  - Use when Predict Account stays the trading identity and Vault may fund it.
  - Ask first: **Do you already have a vault?**
  - **Have a vault** -> minimum fields: `PREDICT_ENV`, `PREDICT_WALLET_MODE=predict-account`, `PREDICT_API_KEY`, `PREDICT_ACCOUNT_ADDRESS`, `PREDICT_PRIVY_PRIVATE_KEY`, `ERC_MANDATED_MCP_COMMAND`, `ERC_MANDATED_CHAIN_ID`, `ERC_MANDATED_VAULT_ADDRESS`, optional `ERC_MANDATED_CONTRACT_VERSION`.
  - **Need a vault** -> deploy or redeploy a vault first with the pure `mandated-vault` bootstrap flow, then come back to overlay.
- pure `mandated-vault` (recommended governance/control-plane path)
  - Use for bootstrap, governance, and vault-only control-plane workflows.
  - Minimum fields: `PREDICT_ENV`, `PREDICT_WALLET_MODE=mandated-vault`, `PREDICT_API_KEY`, `PREDICT_EOA_PRIVATE_KEY`, `ERC_MANDATED_MCP_COMMAND`, `ERC_MANDATED_CHAIN_ID`.

Advanced executor/authority/bootstrap private keys remain mode-specific follow-up inputs. Do not present them as day-one required fields unless the current workflow actually executes vault-side actions.
Do not treat the full derivation tuple as the primary first-step answer for overlay onboarding when the user already has a deployed vault.

## First-time setup (recommended)

1. Install the skill and run `uv sync`.
2. Pick a bootstrap file:
    - `template.env` -> secret-free local fixture bootstrap
    - `template.readonly.env` -> live read-only market reads
    - `template.eoa.env` -> direct private-key trading
    - `template.predict-account.env` -> Predict Account trading
    - `template.predict-account-vault.env` -> Predict Account + vault
    - `template.mandated-vault.env` -> internal/compatibility bootstrap template
3. Copy the chosen template to `.env` inside `~/.openclaw/skills/predictclaw/`.
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:
   - fixture bootstrap -> `uv run python scripts/predictclaw.py markets trending`
   - live read-only -> `uv run python scripts/predictclaw.py markets trending`
   - `eoa` / `predict-account` -> `uv run python scripts/predictclaw.py wallet status --json`
   - `predict-account + vault` -> `uv run python scripts/predictclaw.py wallet status --json`

### Choose your route first

- `read-only` for browsing only.
- When the goal is to keep Predict Account as the trading identity and let Vault only fund it, immediately choose `predict-account + ERC_MANDATED_*`.
- pure `mandated-vault` is a separate control-plane path for creating a new Vault or directly operating Vault control-plane flows.

If you want Vault funding without changing the trading identity, start from `template.predict-account.env`, use `PREDICT_WALLET_MODE=predict-account`, and treat that as the default answer for the "keep the official trading identity, let Vault fund it" workflow. Do not start from pure `wallet bootstrap-vault` unless you are creating a new vault or working on the control plane directly.

For overlay onboarding, ask the vault question first:

- **Have a vault** -> provide `ERC_MANDATED_VAULT_ADDRESS`, then let PredictClaw resolve or validate the remaining vault metadata where possible.
- **Need a vault** -> deploy or redeploy a vault first with the pure `mandated-vault` bootstrap flow.

Do not treat the full derivation tuple as the primary first-step answer for overlay onboarding unless the user is explicitly on the advanced/manual recovery path.

## Bootstrap templates

- `template.env` -> safest first install; uses `test-fixture` + `read-only` so the CLI can start without secrets or network access
- `template.readonly.env` -> live market reads; mainnet market reads require PREDICT_API_KEY
- `template.eoa.env` -> EOA signer flow, pinned to mainnet with `https://api.predict.fun`
- `template.predict-account.env` -> Predict Account signer flow, pinned to mainnet with `https://api.predict.fun`
- `template.predict-account-vault.env` -> canonical user-facing template for Predict Account + vault
- `template.mandated-vault.env` -> internal/compatibility bootstrap template used during vault creation or recovery

### Recommended operating model

- For user-facing funded trading, recommend `predict-account + vault`.
- In that model, Predict Account remains the trading identity and deposit address, while Vault acts as the funding/control plane.
- `mandated-vault` is not a standalone user mode; it remains the internal/bootstrap path used when a vault still needs to be created or prepared.

## Real first-install paths

### A. CLI boots successfully

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

### B. Secret-free local verification

Copy `template.env` and run:

```bash
uv run python scripts/predictclaw.py markets trending
```

This uses `test-fixture`, so it proves the skill boots and routes commands correctly without touching the live API.
Fixture mode only knows the bundled fixture market IDs (`123`, `456`, `789`, `101`, `202`). For real market IDs, switch to the live read-only template first.

### C. Live read-only market reads

Copy `template.readonly.env` to read live production markets on mainnet.

```bash
uv run python scripts/predictclaw.py markets trending
uv run python scripts/predictclaw.py market <market_id> --json
```

If mainnet reads fail with `401 unauthorized`, your `PREDICT_API_KEY` is missing or invalid.

### D. Signer-backed flows

wallet status requires signer configuration. `wallet status --json` is the right next step for `eoa` and `predict-account`, but it is not the first command to run in `read-only` mode.

### E. Mode-first minimum field rule

Do not paste the full env matrix first. Ask which mode the user is choosing, then show only the minimum fields for that mode. Add advanced authority/executor/bootstrap keys only when the selected flow actually needs vault-side execution.

## Configuration examples

The snippets below are `.env` examples. Put them in `~/.openclaw/skills/predictclaw/.env` (the same installed path sometimes shown as `{baseDir}`) 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.

### bootstrap-safe fixture mode

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

Use this for secret-free CLI verification and local market browsing only. It does not hit the live API. Switch to `eoa`, `predict-account`, or `predict-account + vault` before using wallet or trade subcommands.

### live read-only mode

```dotenv
PREDICT_ENV=mainnet
PREDICT_WALLET_MODE=read-only
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
```

Mainnet market reads require `PREDICT_API_KEY`.

### eoa mode

```dotenv
PREDICT_ENV=mainnet
PREDICT_WALLET_MODE=eoa
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
PREDICT_EOA_PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY
```

### predict-account mode

```dotenv
PREDICT_ENV=mainnet
PREDICT_WALLET_MODE=predict-account
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
PREDICT_ACCOUNT_ADDRESS=0xYOUR_PREDICT_ACCOUNT
PREDICT_PRIVY_PRIVATE_KEY=0xYOUR_PRIVY_EXPORTED_KEY
```

### predict-account + vault onboarding

The user-facing advanced mode is `predict-account + vault`, not a standalone `mandated-vault` mode.

Start from the canonical template:

```dotenv
PREDICT_ENV=mainnet
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
PREDICT_WALLET_MODE=predict-account
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=56
```

If you already have a vault, bind it through `ERC_MANDATED_VAULT_ADDRESS` and the related authority/asset values.

If you do not yet have a vault, use the bootstrap helper first.

### Internal bootstrap subflow (`mandated-vault`)

The older `mandated-vault` path still exists internally as the bootstrap/compatibility subflow that creates or prepares a vault before you return to the user-facing `predict-account + vault` route.

```dotenv
PREDICT_ENV=mainnet
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
PREDICT_WALLET_MODE=mandated-vault
PREDICT_EOA_PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY
ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp
ERC_MANDATED_CHAIN_ID=56
```

PredictClaw uses the fixed product factory [REDACTED_SECRET], derives the signer address from `PREDICT_PRIVATE_KEY`, previews the deployment first, requires explicit confirmation before broadcast, then backfills `.env` with the deployed vault address and resolved values.

On `--confirm`, PredictClaw automatically enables the MCP broadcast gate for that subprocess and bridges the bootstrap signer key. The standard flow does not require manually setting `ERC_MANDATED_ENABLE_BROADCAST=1` or `ERC_MANDATED_BOOTSTRAP_PRIVATE_KEY`, but it no longer auto-edits `.env` for you.

Preview first:

```bash
uv run python scripts/predictclaw.py wallet bootstrap-vault --json
```

Confirm and broadcast:

```bash
uv run python scripts/predictclaw.py wallet bootstrap-vault --confirm --json
```

Optional bootstrap amount / funding controls:

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

### Internal bootstrap compatibility paths

If you intentionally need the internal bootstrap path directly, these legacy/compatibility variants still exist:

#### Explicit deployed vault

```dotenv
PREDICT_ENV=mainnet
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
PREDICT_WALLET_MODE=mandated-vault
ERC_MANDATED_VAULT_ADDRESS=0xYOUR_DEPLOYED_VAULT
ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp
ERC_MANDATED_CHAIN_ID=56
```

Use this only when you intentionally need the internal bootstrap path to target an already deployed vault.

#### Full derivation tuple

```dotenv
PREDICT_ENV=mainnet
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
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=56
```

`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. This is the advanced/manual recovery path; if the predicted vault is still undeployed, PredictClaw can still surface preparation details and `manual-only` guidance without broadcasting.

For advanced overrides, `ERC_MANDATED_BOOTSTRAP_PRIVATE_KEY` can replace the default bootstrap signer resolution and `ERC_MANDATED_ENABLE_BROADCAST` can explicitly force the execute-mode gate on or off. In the standard `--confirm` path, PredictClaw auto-bridges both values to the MCP subprocess for you.

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

```dotenv
PREDICT_ENV=mainnet
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
PREDICT_WALLET_MODE=predict-account
PREDICT_ACCOUNT_ADDRESS=0xYOUR_PREDICT_ACCOUNT
PREDICT_PRIVY_PRIVATE_KEY=0xYOUR_PRIVY_EXPORTED_KEY
ERC_MANDATED_VAULT_ADDRESS=0xYOUR_DEPLOYED_VAULT
ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp
ERC_MANDATED_CONTRACT_VERSION=v0.3.0-agent-contract
ERC_MANDATED_CHAIN_ID=56
```

If you already have a deployed vault, this is the primary overlay path: provide `ERC_MANDATED_VAULT_ADDRESS` and let PredictClaw resolve the remaining vault metadata where possible.

If you do **not** have a vault yet, the recommended answer is to deploy or redeploy one first with the pure `mandated-vault` bootstrap flow. 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`) remains available as an advanced/manual path rather than the default first step.

Only if automatic resolution fails should you manually add advanced vault metadata such as `ERC_MANDATED_VAULT_ASSET_ADDRESS`, `ERC_MANDATED_VAULT_AUTHORITY`, or authority/executor private keys.

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.

This is the correct route when Predict Account remains the deposit/trading identity and Vault only supplies funds.

## Wallet Modes

PredictClaw supports four user-facing 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`.
- `predict-account + vault` — Predict Account remains the trading identity while Vault acts as the advanced funding source.

### Recommended route

If the goal is to keep Predict Account as the trading identity while Vault only funds it, use:

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

This is the default route for the Predict Account trading-identity workflow. It exposes `vault-to-predict-account` semantics in `wallet status --json` and `wallet deposit --json`.

### How to answer "what address should I fund?"

- In `predict-account + vault`, the default user-facing answer is: fund the Vault deposit flow first.
- Predict Account remains the trading identity and receives the downstream vault-driven top-up afterward.
- `wallet deposit --json` / `wallet status --json` therefore distinguish:
  - the default funding entry (`manualTopUpAddress` / `fundingAddress`) -> Vault
  - the trading identity / recipient (`predictAccountAddress`, `tradingIdentityAddress`) -> Predict Account
- Only answer with the Predict Account deposit page when the active route is plain `predict-account` without the vault funding overlay.

### Internal bootstrap note

<<<<<<< HEAD
`mandated-vault` is an advanced explicit opt-in mode. Treat it as a separate control-plane path, not a co-equal answer for Predict Account trading.

Bundled factory defaults and the returned manual env block make pure bootstrap more convenient, but they do not replace deployment-time signer inputs or the overlay-specific env required by the `predict-account` funding route.

For the default pure bootstrap flow, users only need an EOA signer, deployment-fee funding, and any optional amount caps. PredictClaw handles the product-configured factory, previews before broadcast, requires explicit confirmation, and returns a manual env block after success.

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`.
=======
`mandated-vault` still exists in the runtime as an internal/bootstrap compatibility subflow, but it is **not a standalone user mode**. It is used when PredictClaw needs to create or prepare a vault before returning to `predict-account + vault`.
>>>>>>> 7e22c30 (docs: reframe vault onboarding as predict-account plus vault)

### Common configuration mistakes

- `read-only` is browse-only. Start with `markets ...`, not signer-backed wallet or trade commands.
- `wallet status` requires signer configuration. In `read-only`, start with `markets trending` or `market <id> --json` instead.
- `mainnet` market reads require `PREDICT_API_KEY`; missing keys fail early and invalid keys return `401 unauthorized`.
- `eoa` requires `PREDICT_EOA_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`; in overlay mode the default path is an explicit `ERC_MANDATED_VAULT_ADDRESS`, with asset and authority metadata resolved automatically where possible and only escalated to manual fields when that resolution fails.

## 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 bootstrap execution** — preview and confirm pure mandated-vault deployment through `vault_bootstrap`.
3. **Vault overlay orchestration** — expose `vault-to-predict-account` routing, funding-policy context, and session planning.
4. **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.

For the default safe path, run:

```bash
uv run python scripts/predictclaw.py setup mandated-mcp
```

PredictClaw only detects whether an `erc-mandated-mcp` launcher is available. It does not globally install packages and does not auto-edit `.env` in the default path. Install the external `erc-mandated-mcp` runtime yourself, then set `ERC_MANDATED_MCP_COMMAND` manually.

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 bootstrap-vault --json
uv run python scripts/predictclaw.py wallet bootstrap-vault --confirm --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 guidance, balances, and approval readiness.
- `wallet deposit` is a funding-guidance command: in `predict-account + vault`, it shows the Vault deposit flow as the default funding entry, while still separating the Predict Account recipient / trading identity and the orchestration vault metadata.
- `wallet bootstrap-vault` is the pure mandated-vault preview / confirmation entry point.
- The default bootstrap flow uses the fixed factory [REDACTED_SECRET] and backfills `.env` after a confirmed deployment.
- `wallet redeem-vault --preview --json` previews vault-share redemption and reports `redeemableNow`, `blockingReason`, and decoded contract errors such as `ERC4626ExceededMaxRedeem`.
- `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:
  - `manualTopUpAddress`
  - `tradingIdentityAddress`
  - `predictAccountAddress`
  - `tradeSignerAddress`
  - `orchestrationVaultAddress`
  - `vaultAddress`
  - `fundingRoute = vault-to-predict-account`
- Default funding now goes through the Vault deposit flow.
- Predict Account remains the trading identity / order account.
- The internal orchestration target remains the Predict Account, but the user-facing funding ingress is the Vault.
- Optional Vault funding-policy envs let you cap Vault→Predict transfers by per-tx amount, cumulative window amount, and window duration.
- Vault-related JSON now also surfaces `vaultAuthority`, `vaultExecutor`, `bootstrapSigner`, `allowedTokenAddresses`, and `allowedRecipients` so users and OpenClaw can reason about permissions directly.
- 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.

### Redeem preview

Use the preview-only redeem diagnostic before attempting any future exit flow:

```bash
uv run python scripts/predictclaw.py wallet redeem-vault --share-token 0x4a88c1c95d0f59ee87c3286ed23e9dcdf4cf08d7 --holder 0x7df0ba782D85B93266b595d496088ABFAc823950 --all --preview --json
```

This reads the share token, underlying asset, `maxRedeem`, `maxWithdraw`, and a simulated redeem call. The response includes `redeemableNow`, `blockingReason`, and `contractError`. The current flow is intentionally `preview-only`.

## Runtime Modes

- `test-fixture` — uses local JSON fixtures and deterministic wallet/hedge/trade behavior; ideal for development, integration tests, and secret-free first-install verification.
- `mainnet` — requires `PREDICT_API_KEY` even for market reads and should be treated as a live-trading environment.
- `testnet` — still supported only when you explicitly opt into a non-mainnet environment; it is no longer a packaged default or recommended onboarding path.

## Environment Variables

| Variable | Purpose |
| --- | --- |
| `PREDICT_STORAGE_DIR` | Local journal and position storage |
| `PREDICT_ENV` | Defaults to `mainnet` in code; `template.env` intentionally bootstraps `test-fixture`; accepted values are `mainnet`, `testnet`, 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; packaged live templates pin this to `https://api.predict.fun`, while leaving it empty uses the env-specific default |
| `PREDICT_API_KEY` | Mainnet-authenticated predict.fun API access; required for mainnet market reads and trading |
| `PREDICT_EOA_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` | Product default factory for pure bootstrap and manual derivation override; current default is [REDACTED_SECRET] |
| [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_BOOTSTRAP_PRIVATE_KEY` | Optional execute-mode bootstrap signer override; when unset, PredictClaw falls back to `PREDICT_EOA_PRIVATE_KEY`, then `ERC_MANDATED_AUTHORITY_PRIVATE_KEY` |
| `ERC_MANDATED_ENABLE_BROADCAST` | Optional execute-mode MCP gate override; `wallet bootstrap-vault --confirm` auto-bridges this to `1` unless you explicitly override it |
| `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 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 bootstrap-vault --json` returns preview details (`predictedVault`, transaction summary, confirmation required) without broadcasting.
- Advanced/manual derivation flows can still return create-vault preparation details with `manual-only` guidance when you intentionally stay on the manual path.
- 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、查看资金引导、执行买入、查询持仓，以及在需要时分析对冲机会。

这个仓库将 PredictClaw 打包为一个独立的 OpenClaw skill，并包含自己的 CLI、运行时配置和测试体系。

PredictClaw 的版本以仓库根目录的 `pyproject.toml` 为唯一 source of truth。无论你是在 GitHub 上核对版本，还是准备从源码安装，都应该直接看这个仓库根目录。

## 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 template.env .env
```

安装后的 skill 目录 `~/.openclaw/skills/predictclaw` 是唯一规范的用户配置根目录。在 OpenClaw 的一些清单或示例里，这个已安装路径也可能写成 `{baseDir}`。任何仓库 checkout 或 workspace 副本都只是 development-only artifact，不是用户配置根目录。

### 手动安装

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

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

### 在仓库内本地开发

在仓库根目录执行：

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

仓库根目录只用于开发和测试，不应被当作日常用户编辑 `.env` 或调用已安装 skill CLI 的规范位置。

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

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

- 进程环境变量，例如 `export PREDICT_ENV=testnet`
- 本地 `~/.openclaw/skills/predictclaw/.env` 文件；`scripts/predictclaw.py` 启动时会自动加载它，在清单或示例里这个已安装路径也可能写成 `{baseDir}`

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

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

SKILL frontmatter 的 metadata 现在会把外部 runtime 和条件性使用的 env 面暴露出来，方便 ClawHub 用户在安装前审查。各模式自己的必填项仍然以下面的示例和运行时配置校验为准；列出来的变量并不代表必须一次性全部填写。

## 先选模式，再填字段（推荐）

先确定模式，再只填写该模式的最小字段集。

- `read-only`
  - 只适合浏览。
  - 最小字段：`PREDICT_ENV`、`PREDICT_WALLET_MODE`，以及 mainnet 读取所需的 `PREDICT_API_KEY`。
- `eoa`
  - 适合直接用 signer 做交易，不经过 Predict Account overlay。
  - 最小字段：`PREDICT_ENV`、`PREDICT_WALLET_MODE=eoa`、`PREDICT_API_KEY`、`PREDICT_EOA_PRIVATE_KEY`。
- `predict-account + ERC_MANDATED_*`（推荐的带资金交易路径）
  - 适合 Predict Account 保持交易身份、Vault 负责供资的场景。
  - 先问一句：**你是否已经有 vault？**
  - **已有 vault** -> 最小字段：`PREDICT_ENV`、`PREDICT_WALLET_MODE=predict-account`、`PREDICT_API_KEY`、`PREDICT_ACCOUNT_ADDRESS`、`PREDICT_PRIVY_PRIVATE_KEY`、`ERC_MANDATED_MCP_COMMAND`、`ERC_MANDATED_CHAIN_ID`、`ERC_MANDATED_VAULT_ADDRESS`，以及可选的 `ERC_MANDATED_CONTRACT_VERSION`。
  - **还没有 vault** -> 默认建议先用 pure `mandated-vault` 路线部署或重建 vault，再回到 overlay。
- pure `mandated-vault`（推荐的治理 / control-plane 路径）
  - 适合 bootstrap、治理、vault-only 控制平面工作流。
  - 最小字段：`PREDICT_ENV`、`PREDICT_WALLET_MODE=mandated-vault`、`PREDICT_API_KEY`、`PREDICT_EOA_PRIVATE_KEY`、`ERC_MANDATED_MCP_COMMAND`、`ERC_MANDATED_CHAIN_ID`。

authority / executor / bootstrap 私钥属于按需追加的高级字段。只有当前工作流真的需要 vault 侧执行时，才应该继续展示它们。
对于 overlay onboarding，不要把完整 derivation tuple 当成第一屏默认答案；只有进入高级 / 手动恢复路径时才展示。

## 首次配置（推荐）

1. 安装 skill 并执行 `uv sync`。
2. 先选择一个启动模板：
    - `template.env` -> 无 secret 的本地 fixture 启动
    - `template.readonly.env` -> live 只读市场读取
    - `template.eoa.env` -> 直接私钥交易
    - `template.predict-account.env` -> Predict Account 交易
    - `template.predict-account-vault.env` -> Predict Account + vault
    - `template.mandated-vault.env` -> 内部/兼容 bootstrap 模板
3. 在 `~/.openclaw/skills/predictclaw/` 中把选中的模板复制为 `.env`。
4. 只填写该模式需要的最小变量集。
5. 先用 `uv run python scripts/predictclaw.py --help` 确认安装无误。
6. 再按模式做一次验证：
   - fixture 启动 -> `uv run python scripts/predictclaw.py markets trending`
   - live read-only -> `uv run python scripts/predictclaw.py markets trending`
   - `eoa` / `predict-account` -> `uv run python scripts/predictclaw.py wallet status --json`
   - `predict-account + vault` -> `uv run python scripts/predictclaw.py wallet status --json`

### 先选路线，再填配置

- `read-only` 只用于浏览。
- 当目标是保留 Predict Account 作为交易身份，并让 Vault 仅供资时，立即选择 `predict-account + ERC_MANDATED_*`。
- pure `mandated-vault` 是独立的 control-plane 路径，只适合你要创建新 Vault，或直接操作 Vault control-plane 流程时使用。

如果你想让 Vault 提供资金、但不改变交易身份，请从 `template.predict-account.env` 开始，使用 `PREDICT_WALLET_MODE=predict-account`。这就是“保留官方交易身份、让 Vault 仅供资”这个工作流的默认答案；除非你正在创建新 vault，或明确在处理 control-plane，否则不要先从 pure `wallet bootstrap-vault` 开始。

对于 overlay onboarding，先回答 vault 问题：

- **已有 vault** -> 先提供 `ERC_MANDATED_VAULT_ADDRESS`，再让 PredictClaw 解析或校验剩余 vault metadata。
- **还没有 vault** -> 默认先走 pure `mandated-vault` 的 bootstrap / redeploy。

不要把完整 derivation tuple 当成 overlay 的第一屏默认答案，除非用户明确进入高级 / 手动恢复路径。

## 启动模板

- `template.env` -> 最安全的首装入口；使用 `test-fixture` + `read-only`，不需要 secrets，也不会访问网络
- `template.readonly.env` -> live 市场读取；mainnet 的市场读取需要 PREDICT_API_KEY
- `template.eoa.env` -> EOA signer 路线，固定走主网 `https://api.predict.fun`
- `template.predict-account.env` -> Predict Account signer 路线，固定走主网 `https://api.predict.fun`
- `template.predict-account-vault.env` -> 面向用户的 Predict Account + vault 模板
- `template.mandated-vault.env` -> 仅用于内部/兼容 bootstrap 的模板

### 推荐的运行模型

- 面向用户的带资金交易，主推 `predict-account + vault`。
- 在这个模型里，Predict Account 仍然是交易身份和充值地址，Vault 只承担资金 / control-plane 角色。
- `mandated-vault` 不是独立模式，而是创建或准备 Vault 时使用的内部/bootstrap 兼容路径。

## 真实首装路径

### A. 先确认 CLI 能启动

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

### B. 无 secret 的本地验证

复制 `template.env` 后运行：

```bash
uv run python scripts/predictclaw.py markets trending
```

这条路径使用 `test-fixture`，只证明 skill 能正确启动、加载配置并路由命令，不会访问 live API。
fixture 模式只认识仓库自带的本地 market ID（`123`、`456`、`789`、`101`、`202`）。如果你要查询真实 market ID，请先切到 live read-only 模板。

### C. live read-only 市场读取

复制 `template.readonly.env`，直接在主网上读取生产市场数据。

```bash
uv run python scripts/predictclaw.py markets trending
uv run python scripts/predictclaw.py market <market_id> --json
```

如果 mainnet 读取返回 `401 unauthorized`，通常表示 `PREDICT_API_KEY` 缺失或无效。

### D. signer 路线

wallet status 需要 signer 配置。它适合 `eoa` 和 `predict-account` 的下一步验证，但不是 `read-only` 的第一条命令。

### E. mode-first 最小字段规则

不要一开始就把完整 env 矩阵贴给用户。应该先问用户选择了哪个模式，再只给该模式的最小字段；authority / executor / bootstrap 这些高级密钥，只在所选流程真的需要 vault 执行时再补充。

## 配置示例

下面的片段全部使用 `.env` 写法。你可以直接写进 `~/.openclaw/skills/predictclaw/.env`（同一个已安装路径有时也会写成 `{baseDir}`），也可以在 shell 里 `export` 同名变量。

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

### bootstrap-safe fixture 模式

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

这个模式只适合无 secret 的 CLI 验证与本地市场浏览，不会访问 live API。需要执行 wallet / trade 子命令时，请切换到 `eoa`、`predict-account` 或 `predict-account + vault`。

### live read-only 模式

```dotenv
PREDICT_ENV=mainnet
PREDICT_WALLET_MODE=read-only
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
```

mainnet 的市场读取需要 `PREDICT_API_KEY`。

### eoa 模式

```dotenv
PREDICT_ENV=mainnet
PREDICT_WALLET_MODE=eoa
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
PREDICT_EOA_PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY
```

### predict-account 模式

```dotenv
PREDICT_ENV=mainnet
PREDICT_WALLET_MODE=predict-account
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
PREDICT_ACCOUNT_ADDRESS=0xYOUR_PREDICT_ACCOUNT
PREDICT_PRIVY_PRIVATE_KEY=0xYOUR_PRIVY_EXPORTED_KEY
```

### predict-account + vault 路线

用户-facing 的高级模式是 `predict-account + vault`，不是单独的 `mandated-vault` 模式。

先从规范模板开始：

```dotenv
PREDICT_ENV=mainnet
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
PREDICT_WALLET_MODE=predict-account
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=56
```

如果你已经有 Vault，就直接用这些变量绑定。

如果你还没有 Vault，就先运行 bootstrap helper。

### 内部 bootstrap 子流程（`mandated-vault`）

较旧的 `mandated-vault` 路径仍然作为内部/兼容 bootstrap 子流程存在，用来在返回 `predict-account + vault` 之前创建或准备 Vault。

```dotenv
PREDICT_ENV=mainnet
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
PREDICT_WALLET_MODE=mandated-vault
PREDICT_EOA_PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY
ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp
ERC_MANDATED_CHAIN_ID=56
```

PredictClaw 会使用固定产品 factory [REDACTED_SECRET]，从 `PREDICT_PRIVATE_KEY` 推导 signer 地址，先给出预览，再要求显式确认，最后在成功后把 deployed vault 地址和解析后的值回填到 `.env`。

执行 `--confirm` 时，PredictClaw 会自动为该 MCP 子进程打开 broadcast 开关，并桥接 bootstrap signer key。标准流程下，你不需要再手动设置 `ERC_MANDATED_ENABLE_BROADCAST=1` 或 `ERC_MANDATED_BOOTSTRAP_PRIVATE_KEY`，但它也不会再自动修改 `.env`。

先做预览：

```bash
uv run python scripts/predictclaw.py wallet bootstrap-vault --json
```

确认并广播：

```bash
uv run python scripts/predictclaw.py wallet bootstrap-vault --confirm --json
```

可选的部署 / 资金额度控制：

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

### 内部 bootstrap 兼容路径

如果你确实需要直接进入内部 bootstrap 路径，仍然保留以下 legacy/compatibility 变体：

#### 已知 deployed vault

```dotenv
PREDICT_ENV=mainnet
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
PREDICT_WALLET_MODE=mandated-vault
ERC_MANDATED_VAULT_ADDRESS=0xYOUR_DEPLOYED_VAULT
ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp
ERC_MANDATED_CHAIN_ID=56
```

只有在你明确需要内部 bootstrap 路径直接绑定已部署 Vault 时，才使用这条兼容路径。

#### 完整 derivation tuple

```dotenv
PREDICT_ENV=mainnet
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
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=56
```

`ERC_MANDATED_EXECUTOR_PRIVATE_KEY` 是可选项。未设置时，PredictClaw 会在当前 Preflight MVP 合约路径中复用 `ERC_MANDATED_AUTHORITY_PRIVATE_KEY` 作为 executor signer。这是高级 / 手动恢复路径；如果预测到的 vault 还未部署，PredictClaw 仍然可以给出 preparation 信息和 `manual-only` 指引，但不会自动广播。

对于高级 override，`ERC_MANDATED_BOOTSTRAP_PRIVATE_KEY` 可以替换默认 bootstrap signer 解析规则，`ERC_MANDATED_ENABLE_BROADCAST` 可以显式强制 execute-mode gate 开或关。但标准 `--confirm` 路径里，PredictClaw 会自动把这两项桥接给 MCP 子进程。

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

```dotenv
PREDICT_ENV=mainnet
PREDICT_API_BASE_URL=https://api.predict.fun
PREDICT_API_KEY=YOUR_PREDICT_API_KEY
PREDICT_WALLET_MODE=predict-account
PREDICT_ACCOUNT_ADDRESS=0xYOUR_PREDICT_ACCOUNT
PREDICT_PRIVY_PRIVATE_KEY=0xYOUR_PRIVY_EXPORTED_KEY
ERC_MANDATED_VAULT_ADDRESS=0xYOUR_DEPLOYED_VAULT
ERC_MANDATED_MCP_COMMAND=erc-mandated-mcp
ERC_MANDATED_CONTRACT_VERSION=v0.3.0-agent-contract
ERC_MANDATED_CHAIN_ID=56
```

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

当 Predict Account 继续作为入金/交易身份，而 Vault 只负责供资时，这就是正确路线。

如果你已经有已部署的 vault，这就是 overlay 的主路径：先提供 `ERC_MANDATED_VAULT_ADDRESS`，再让 PredictClaw 尽量自动解析其余 metadata。

如果你**还没有 vault**，默认建议先用 pure `mandated-vault` 路线部署或重建 vault，再回到 overlay。完整 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`）保留为高级 / 手动路径，而不是默认首步。

只有在自动解析失败时，才再手动补充 `ERC_MANDATED_VAULT_ASSET_ADDRESS`、`ERC_MANDATED_VAULT_AUTHORITY`，以及 authority / executor 私钥这类高级字段。

可选的 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 只对外暴露四种用户模式：

- `read-only` — 仅浏览市场数据，不允许 signer-backed 钱包动作。
- `eoa` — 直接私钥 signer 路径，用于钱包、交易和资金相关流程。
- `predict-account` — 使用 `PREDICT_ACCOUNT_ADDRESS` 与 `PREDICT_PRIVY_PRIVATE_KEY` 的官方 Predict Account 路径。
- `predict-account + vault` — Predict Account 保持交易身份，Vault 作为高级资金来源。

### 推荐路线

如果目标是保留 Predict Account 作为交易身份，同时让 Vault 仅供资，请使用：

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

这是 Predict Account 交易身份工作流的默认路线。这样在 `wallet status --json` 与 `wallet deposit --json` 中会暴露 `vault-to-predict-account` 语义。

### 当用户问“充值地址是什么”时应如何回答

- 在 `predict-account + vault` 中，面向用户的默认回答应是：先走 Vault deposit flow。
- Predict Account 继续保持交易身份，后续再接受由 Vault 驱动的补资。
- 因此 `wallet deposit --json` / `wallet status --json` 需要区分：
  - 默认补资入口（`manualTopUpAddress` / `fundingAddress`）-> Vault
  - 交易身份 / 最终收款目标（`predictAccountAddress`、`tradingIdentityAddress`）-> Predict Account
- 只有在 plain `predict-account`（不带 vault overlay）时，才默认回答 Predict Account deposit 地址。

### 内部 bootstrap 说明

<<<<<<< HEAD
`mandated-vault` 是一个高级显式 opt-in 模式，应把它视为独立的 control-plane 路径，而不是 Predict Account 交易的平级答案。

内置 factory 默认值和成功后返回的手动 env block，只是在 pure bootstrap 场景里提供便利，并不能替代部署时 signer 输入，也不能替代 `predict-account` overlay 所需的专用 env。

对于默认 pure bootstrap 流程，用户只需要提供 EOA signer、部署手续费资金，以及可选的额度控制。PredictClaw 会处理固定 factory、预览、确认，并在成功后返回手动 env block。

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`。
=======
`mandated-vault` 仍然在运行时里存在，但只作为内部/兼容 bootstrap 子流程，**不是独立模式**。它的职责是在回到 `predict-account + vault` 路线前创建或准备 Vault。
>>>>>>> 7e22c30 (docs: reframe vault onboarding as predict-account plus vault)

### 常见配置错误

- `read-only` 只能浏览市场；不要直接跑 signer-backed 的 wallet / trade 命令。
- `wallet status` 需要 signer 配置；在 `read-only` 下请先跑 `markets trending` 或 `market <id> --json`。
- mainnet 的市场读取需要 `PREDICT_API_KEY`；没配会在配置阶段直接失败，配错通常会返回 `401 unauthorized`。
- `eoa` 必须配置 `PREDICT_EOA_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_ADDRESS`，再由系统尽量自动解析 asset 与 authority，只有自动解析失败时才升级到手动字段。

## `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 bootstrap 执行** — 通过 `vault_bootstrap` 支持 pure mandated-vault 的预览与确认部署。
3. **Vault overlay 编排** — 在 overlay 模式下暴露 `vault-to-predict-account` 路由、funding-policy 上下文与会话规划。
4. **控制面安全边界** — 当 MCP 不存在或不可用时，PredictClaw 会显式 fail closed，而不是静默猜测。

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

默认安全路径可以运行：

```bash
uv run python scripts/predictclaw.py setup mandated-mcp
```

PredictClaw 在默认路径里只会检测 `erc-mandated-mcp` launcher 是否可用。它不会全局安装包，也不会自动修改 `.env`。请先自行安装外部 `erc-mandated-mcp` runtime，然后再手动设置 `ERC_MANDATED_MCP_COMMAND`。

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 bootstrap-vault --json
uv run python scripts/predictclaw.py wallet bootstrap-vault --confirm --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 guidance、余额和 approval readiness。
- `wallet deposit` 是一个资金引导命令：在 `predict-account + vault` 中，它会把 Vault deposit flow 作为默认补资入口，同时继续分开展示 Predict Account 收款/交易身份和 orchestration vault 元数据，并给出可接受资产（`BNB`、`USDT`）。
- `wallet bootstrap-vault` 是 pure mandated-vault 的预览 / 确认入口。
- 默认 bootstrap 流程会使用固定 factory [REDACTED_SECRET]，并在确认成功后返回可手动复制的 env block。
- `wallet redeem-vault --preview --json` 会预检 vault share 赎回，并返回 `redeemableNow`、`blockingReason` 以及像 `ERC4626ExceededMaxRedeem` 这样的合约错误。
- `wallet withdraw` 在尝试执行转账逻辑前会先验证目标地址 checksum、金额为正、余额充足以及 BNB gas 余量。
- 在 fixture 模式下，withdraw 会返回确定性的占位 tx hash，而不会触链。
- 在 `predict-account + ERC_MANDATED_*` overlay 中，`wallet status` / `wallet deposit` 会暴露：
  - `manualTopUpAddress`
  - `tradingIdentityAddress`
  - `predictAccountAddress`
  - `tradeSignerAddress`
  - `orchestrationVaultAddress`
  - `vaultAddress`
  - `fundingRoute = vault-to-predict-account`
- 默认补资入口是 Vault deposit flow。
- Predict Account 继续是交易身份 / 下单账户。
- 内部 orchestration 的最终资金目标仍然是 Predict Account，但用户-facing 的补资入口是 Vault。
- 可选的 Vault funding-policy 环境变量允许你限制 Vault→Predict 转账的单笔额度、窗口累计额度和窗口时长。
- 与 vault 相关的 JSON 现在还会提供 `vaultAuthority`、`vaultExecutor`、`bootstrapSigner`、`allowedTokenAddresses`、`allowedRecipients`，方便用户和 OpenClaw 直接理解权限边界。
- 这些 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 资金腿。

### 赎回预检

在任何真实赎回动作之前，先使用 preview-only 预检：

```bash
uv run python scripts/predictclaw.py wallet redeem-vault --share-token 0x4a88c1c95d0f59ee87c3286ed23e9dcdf4cf08d7 --holder 0x7df0ba782D85B93266b595d496088ABFAc823950 --all --preview --json
```

这个命令会读取 share token、底层资产、`maxRedeem`、`maxWithdraw` 和一次模拟赎回调用，并输出 `redeemableNow`、`blockingReason`、`contractError`。当前版本故意保持 `preview-only`，不会广播真实赎回交易。

## 运行模式

- `test-fixture` — 使用本地 JSON fixture 和确定性的 wallet / hedge / trade 行为，适合开发、集成测试、CI，以及无 secret 的首装验证。
- `mainnet` — 需要 `PREDICT_API_KEY`，即使只是市场读取也应视为真实交易环境。
- `testnet` — 仍然支持，但只适合你显式选择非主网环境时使用；它不再是打包模板或首装引导里的默认路径。

## 环境变量

| 变量 | 作用 |
| --- | --- |
| `PREDICT_STORAGE_DIR` | 本地 journal 与持仓存储 |
| `PREDICT_ENV` | 代码默认是 `mainnet`；`template.env` 刻意以 `test-fixture` 做 bootstrap；可选 `mainnet`、`testnet`、`test-fixture` |
| `PREDICT_WALLET_MODE` | 显式模式覆盖：`read-only`、`eoa`、`predict-account`、`mandated-vault` |
| `PREDICT_API_BASE_URL` | 可选 REST base override；打包的 live 模板会直接固定成 `https://api.predict.fun`，留空时才按环境自动选择 |
| `PREDICT_API_KEY` | mainnet 认证后的 predict.fun API 访问；mainnet 市场读取与交易都需要它 |
| `PREDICT_EOA_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_BOOTSTRAP_PRIVATE_KEY` | 可选的 execute-mode bootstrap signer override；未设置时，PredictClaw 会依次回退到 `PREDICT_EOA_PRIVATE_KEY`、`ERC_MANDATED_AUTHORITY_PRIVATE_KEY` |
| `ERC_MANDATED_ENABLE_BROADCAST` | 可选的 execute-mode MCP gate override；`wallet bootstrap-vault --confirm` 默认会自动桥接成 `1`，除非你显式覆盖 |
| `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: docs/plans/2026-03-20-mandated-mcp-install-ux-design.md
content: # Mandated MCP Install UX Design

## Goal

Make `mandated-vault` and vault-overlay setup understandable for non-expert users by explicitly surfacing the external MCP dependency, while still offering a one-click path to install `erc-mandated-mcp` and auto-fill `ERC_MANDATED_MCP_COMMAND`.

## Current State

- PredictClaw integrates with the mandated-vault control plane through `lib/mandated_mcp_bridge.py`.
- The bridge starts an external subprocess from `ERC_MANDATED_MCP_COMMAND` rather than embedding the MCP runtime into the skill process.
- `lib/config.py` already provides a default launcher name of `erc-mandated-mcp`.
- Docs already explain that pure `mandated-vault` requires a working MCP command, but the product UX still assumes users understand how to install and wire that dependency.

## Problem

Users hear that PredictClaw "supports" mandated-vault and assume the MCP runtime is already bundled. In reality, the skill only knows how to talk to the runtime once it exists on the machine. That mismatch creates three failure modes:

1. Users select `mandated-vault` and hit a missing-binary error later in the flow.
2. Users believe they must manually fill `ERC_MANDATED_MCP_COMMAND`, even when the default command name would work after install.
3. Users cannot tell which settings can be auto-filled and which vault credentials still require explicit confirmation.

## Product Constraints

- Recommended UX path: explicit prompt plus one-click install plus automatic backfill.
- One-click install only covers the MCP runtime itself.
- Missing prerequisites such as Node, npm, uvx, or other host tooling should fail clearly, but should not be auto-installed.
- Vault identity, derivation inputs, authority/executor keys, and chain settings remain explicit user-controlled values.
- The system must fail closed if the MCP command is unavailable, unhealthy, or missing required tools.

## Options Considered

### Option A: Explicit prompt + one-click install + auto-backfill (recommended)

When the user selects pure `mandated-vault` or `predict-account + ERC_MANDATED_*` overlay, OpenClaw immediately checks whether a usable MCP command exists. If not, it explains the dependency and offers a one-click install for `erc-mandated-mcp`. On success, it writes the resolved launcher back into `ERC_MANDATED_MCP_COMMAND` and resumes the flow.

Pros:
- Best balance of usability and explicitness.
- Keeps failure messages local to mode selection instead of surfacing them later during vault actions.
- Avoids making users hand-edit the command in the common case.

Cons:
- Requires host-side installer and detection logic.

### Option B: Explicit prompt + manual install only

The host explains the dependency and stops. Users must install `erc-mandated-mcp` themselves and then fill or confirm the launcher.

Pros:
- Smallest implementation surface.
- Lowest automation risk.

Cons:
- High drop-off for non-expert users.
- More support burden because the most common case still feels broken.

### Option C: Silent automatic install + silent backfill

The host installs the MCP runtime as soon as users select the mode, with minimal explanation.

Pros:
- Lowest apparent friction.

Cons:
- Too implicit for a control-plane dependency.
- Harder to reason about failures, PATH issues, and security expectations.
- Violates the desired product boundary for sensitive vault flows.

## Recommended Design

Use Option A.

The UX should treat the mandated MCP as an external runtime dependency that PredictClaw can detect and optionally install, not as an invisible built-in component.

### User Flow

1. User selects `mandated-vault` or overlay mode.
2. Host displays a short explanation:
   - PredictClaw includes the bridge logic.
   - This mode still requires an external mandated-vault MCP runtime.
   - The host can install that runtime now and auto-fill the command entry.
3. Host checks for a usable launcher in this order:
   - Explicit `ERC_MANDATED_MCP_COMMAND` from current config
   - Default launcher `erc-mandated-mcp`
4. If a launcher works, host stores that resolved command and continues.
5. If no launcher works, host offers a one-click install of `erc-mandated-mcp`.
6. After successful install, host re-runs detection and writes the resolved launcher into `ERC_MANDATED_MCP_COMMAND`.
7. Host continues into vault-specific configuration.

### Auto-Fill Boundaries

Auto-fill:
- `ERC_MANDATED_MCP_COMMAND` after successful detection or install

Do not auto-fill:
- `ERC_MANDATED_VAULT_ADDRESS`
- derivation tuple fields
- authority/executor private keys
- chain-sensitive vault parameters unless the user explicitly confirms them

### Error Handling

#### Missing command

Show: mandated-vault mode needs an external MCP runtime and none was found.

Action:
- install now
- retry detection
- switch back to another wallet mode

#### Missing prerequisite

Show:
- MCP install could not continue because a prerequisite is missing
- exactly which prerequisite is missing
- the recommended manual install command

Do not auto-install the prerequisite.

#### Command starts but runtime is unhealthy

Show:
- launcher was found but the MCP runtime failed its health check or is missing required tools
- short stderr or missing-tool summary

Do not proceed with vault mode.

## Copy Recommendations

### Mode-selection prompt

"This mode uses PredictClaw's mandated-vault bridge and an external MCP runtime. We can install `erc-mandated-mcp` now and auto-fill the command entry for you."

### Success copy

"Mandated MCP runtime is ready. `ERC_MANDATED_MCP_COMMAND` has been set to `<resolved command>`. Continue with vault configuration."

### Failure copy

"PredictClaw can talk to a mandated-vault MCP runtime, but none is currently available. Install `erc-mandated-mcp` now, or switch to another wallet mode."

## Implementation Notes

- Reuse the existing bridge and config default instead of inventing a second launcher setting.
- Add a host-side preflight before users reach any pure `mandated-vault` command flow.
- Store the resolved command exactly as executed so custom launchers remain supported.
- Keep documentation aligned: "integrated bridge" must never be phrased as "bundled runtime".

## Verification Expectations

- Selecting `read-only`, `eoa`, or plain `predict-account` does not trigger MCP installation.
- Selecting pure `mandated-vault` triggers MCP detection immediately.
- Successful install backfills `ERC_MANDATED_MCP_COMMAND`.
- Missing prerequisites produce explicit guidance and no hidden installs.
- Broken MCP runtime fails closed with actionable diagnostics.


path: docs/plans/2026-03-20-mandated-mcp-install-ux-plan.md
content: # Mandated MCP Install UX Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add an explicit-but-low-friction mandated-vault setup flow that can detect, optionally install, and auto-backfill `ERC_MANDATED_MCP_COMMAND` without silently installing prerequisites or guessing vault credentials.

**Architecture:** The install UX belongs primarily in the OpenClaw host, because mode selection, installer execution, and config persistence happen there. `predictfunclaw` remains the runtime contract: it documents the behavior, keeps the launcher env var stable, and fails closed when the MCP command is unavailable or unhealthy.

**Tech Stack:** OpenClaw host installer/config flow, PredictClaw docs and tests, external `erc-mandated-mcp` runtime.

---

### Task 1: Add host-side mandated MCP preflight hook

**Files:**
- Modify: `<openclaw-host>/mode-selection flow`
- Modify: `<openclaw-host>/skill installer flow`
- Test: `<openclaw-host>/tests` covering mode selection and dependency detection

**Step 1: Write the failing host test**

- Selecting `mandated-vault` should trigger MCP detection.
- Selecting `read-only`, `eoa`, or plain `predict-account` should not.

**Step 2: Run host test to verify RED**

Run the host test command for the mode-selection suite.
Expected: failure because no mandated MCP preflight exists yet.

**Step 3: Add minimal implementation**

- On vault-mode selection, look for:
  - existing `ERC_MANDATED_MCP_COMMAND`
  - fallback launcher `erc-mandated-mcp`
- If neither is usable, surface the install prompt.

**Step 4: Re-run host test to verify GREEN**

### Task 2: Add one-click MCP install with explicit boundary

**Files:**
- Modify: `<openclaw-host>/installer integration`
- Modify: `<openclaw-host>/user prompt copy`
- Test: `<openclaw-host>/tests` for install success and missing-prerequisite failure

**Step 1: Write failing install tests**

- Successful install should auto-backfill `ERC_MANDATED_MCP_COMMAND`.
- Missing prerequisite should fail with explicit guidance and no silent secondary installs.

**Step 2: Run host tests to verify RED**

Expected: failures because install/backfill behavior does not exist yet.

**Step 3: Implement minimal install flow**

- Offer "Install now" only after explicit user selection of vault mode.
- Install only `erc-mandated-mcp`.
- Do not auto-install Node, npm, uvx, or other prerequisites.
- If install succeeds, re-run launcher detection and persist the resolved command.

**Step 4: Re-run host tests to verify GREEN**

### Task 3: Keep PredictClaw docs aligned with the host UX

**Files:**
- Modify: `README.md`
- Modify: `README.zh-CN.md`
- Modify: `SKILL.md`
- Test: `tests/test_docs_examples.py`

**Step 1: Write the failing docs test**

- Require docs to say vault mode can trigger MCP detection/install.
- Require docs to say PredictClaw integrates the bridge, not the bundled runtime.

**Step 2: Run `uv run pytest tests/test_docs_examples.py` to verify RED**

**Step 3: Update docs minimally**

- Add one paragraph in README/SKILL explaining the host-assisted install path.
- Keep the explicit boundary: vault credentials are still user-confirmed.

**Step 4: Run `uv run pytest tests/test_docs_examples.py` to verify GREEN**

### Task 4: Preserve runtime contract and fail-closed behavior

**Files:**
- Modify: `lib/config.py` only if the launcher contract needs clarification
- Modify: `lib/mandated_mcp_bridge.py` only if diagnostics need improvement
- Test: `tests/test_mandated_mcp_bridge.py`

**Step 1: Write failing regression tests if behavior changes**

- Missing command should still fail closed.
- Unhealthy MCP should still fail closed with actionable diagnostics.

**Step 2: Run `uv run pytest tests/test_mandated_mcp_bridge.py` to verify RED if any behavior is changed**

**Step 3: Make the smallest runtime adjustment needed**

- Prefer copy/diagnostics changes over contract changes.
- Keep `ERC_MANDATED_MCP_COMMAND` as the public launcher contract.

**Step 4: Re-run `uv run pytest tests/test_mandated_mcp_bridge.py` to verify GREEN**

### Task 5: Final verification and integration

**Files:**
- No new files beyond changes above

**Step 1: Run standalone repo verification**

Run: `uv run pytest tests/test_docs_examples.py tests/test_mandated_mcp_bridge.py -q`

**Step 2: Run host-side verification**

- Run the host mode-selection / installer tests.
- Manually verify:
  - select `mandated-vault`
  - see explicit dependency copy
  - install `erc-mandated-mcp`
  - observe `ERC_MANDATED_MCP_COMMAND` auto-filled
  - continue into vault config without auto-filling credentials

**Step 3: Commit in small pieces**

- host preflight/install flow
- docs updates
- runtime/diagnostic changes if any


path: docs/plans/2026-03-20-vault-auto-bootstrap-design.md
content: # Vault Auto Bootstrap Design

## Goal

Replace the current pure `mandated-vault` onboarding flow that requires users to supply an explicit deployed vault address or full derivation tuple. The new default path should let users provide only an EOA signer plus deployment-fee funding and selected amount controls, then preview, confirm, auto-deploy, and backfill the user vault configuration.

## Current Mismatch

The current repo still assumes manual vault addressing:

- `template.mandated-vault.env` starts with [REDACTED_SECRET]
- `README.md` and `SKILL.md` document either an explicit vault address or a full derivation tuple
- `lib/funding_service.py` returns `createVaultPreparation` with `broadcast: manual-only`
- `lib/wallet_manager.py::resolve_mandated_vault()` calls `vault_bootstrap(... mode="plan")` and never promotes that plan into an execute-and-backfill path

That is inconsistent with the desired user story: users should not need to know their future vault address or fill derivation fields if the protocol factory is fixed and the product intends to deploy the vault on their behalf.

## Assumptions

- The current supported protocol factory is fixed to [REDACTED_SECRET]
- The intended bootstrap signer is an EOA private key that can pay deployment gas / fees
- The host should continue to fail closed if deployment prerequisites or MCP health checks are not satisfied
- Sensitive signing/broadcasting still requires explicit user confirmation before the chain-side deployment action happens

## Options Considered

### Option A: Keep manual-only bootstrap

Users must continue to provide a deployed vault address or the full derivation tuple.

Pros:
- Minimal implementation changes
- Preserves current control-plane model

Cons:
- Conflicts with the intended user experience
- Causes the exact hard block the user reported
- Keeps docs and OpenClaw assistance overly expert-oriented

### Option B: Auto-deploy immediately inside `wallet deposit`

When the system detects an undeployed vault, `wallet deposit` automatically broadcasts deployment and writes the resolved vault address.

Pros:
- Lowest friction once users choose vault mode

Cons:
- `wallet deposit` stops being an informational command and gains a hidden side effect
- Harder to explain and harder to trust in a safety-sensitive control-plane flow
- Makes preview/review of factory, chain, signer, and fee assumptions less explicit

### Option C: Preview -> explicit confirm -> auto-deploy -> backfill (recommended)

`wallet deposit` and/or setup flow first surfaces the predicted vault, fixed factory, signer, chain, and estimated deployment action. Users then confirm deployment explicitly. After confirmation, PredictClaw uses MCP `vault_bootstrap(... mode="execute")`, captures the deployed vault address, and writes the resolved values back into `.env`.

Pros:
- Removes the hard block for normal users
- Preserves an explicit safety checkpoint before on-chain side effects
- Matches the desired product mental model: users bring signer + funding, product handles vault bootstrap

Cons:
- Requires coordinated runtime and doc changes
- Needs careful backfill rules so the final config is deterministic

## Recommended Design

Use Option C.

The system should stop treating explicit vault address and full derivation tuple as the default user entry point. Instead:

1. Users choose `mandated-vault` mode.
2. They supply only:
   - signer EOA private key
   - chain selection (or default chain)
   - any user-tunable funding/deployment amount controls
3. PredictClaw fills the protocol-level factory address automatically.
4. PredictClaw asks MCP for a bootstrap plan first.
5. PredictClaw shows a preview of what will happen.
6. User confirms.
7. PredictClaw calls MCP bootstrap execute mode.
8. PredictClaw writes back the actual deployed vault address and resolved chain/command state.

## UX Flow

### Step 1: Minimal configuration

The initial vault template should move away from this requirement:

- [REDACTED_SECRET]

And instead guide the user toward:

- `PREDICT_WALLET_MODE=mandated-vault`
- `PREDICT_PRIVATE_KEY=...` (or whichever single EOA path is selected)
- `ERC_MANDATED_CHAIN_ID=...`
- optional deployment/funding caps if relevant
- `ERC_MANDATED_MCP_COMMAND` handled by the setup helper

The fixed factory address should be injected by the product default rather than required in user docs.

### Step 2: Preview

The first stateful bootstrap action should produce a preview payload containing at least:

- fixed factory address
- target chain id / chain name
- signer address
- predicted vault address
- whether the vault already exists
- deployment transaction summary (to / value / gas estimate if available)
- explicit note that confirmation is required before broadcast

### Step 3: Confirmation

The user confirms the deployment action. This should be a distinct confirmation step, not an implicit side effect of simply reading deposit guidance.

### Step 4: Execute and backfill

After confirmation:

- call MCP `vault_bootstrap(... mode="execute")`
- read the resulting `deployedVault`
- backfill `.env` with:
  - `ERC_MANDATED_VAULT_ADDRESS=<actual deployed vault>`
  - fixed `ERC_MANDATED_FACTORY_ADDRESS` if you still want it materialized for transparency
  - `ERC_MANDATED_CHAIN_ID`
  - `ERC_MANDATED_MCP_COMMAND` if already resolved by setup

### Step 5: Post-bootstrap status

From that point onward:

- `wallet deposit --json` becomes informational again
- `wallet status --json` should show `vaultAddressSource=explicit` or another resolved post-bootstrap state
- docs should no longer tell normal users to hand-author the vault address first

## Config Model Changes

### Defaults

- Treat `ERC_MANDATED_FACTORY_ADDRESS` as a product default, not a required first-time user input
- Current fixed default: [REDACTED_SECRET]

### New user-facing minimum

For first-time pure `mandated-vault`, the minimum path should become:

- signer EOA private key
- deployment-fee funding on the signer
- chain selection if non-default
- MCP launcher readiness

### Deprecated as default-first guidance

- manual explicit vault address
- full derivation tuple as the primary onboarding path

These can remain documented as advanced/manual recovery paths.

## Runtime Changes

### `lib/config.py`

- Add fixed factory default instead of requiring the user to provide it manually for the standard path
- Ensure config validation distinguishes between:
  - first-time bootstrap mode
  - already-deployed explicit vault mode
  - advanced manual derivation override mode

### `lib/wallet_manager.py`

- Extend `resolve_mandated_vault()` or a sibling flow so it can produce both:
  - preview plan
  - execute-and-backfill result
- Stop treating `mode="plan"` as the terminal bootstrap state for normal onboarding

### `lib/funding_service.py`

- Replace `broadcast: manual-only` as the only undeployed-vault path
- Return a preview object that clearly indicates confirmable deployment
- After execution, return the deployed vault address and refreshed status

### CLI / host flow

- Add an explicit bootstrap command or interactive confirm path rather than making plain `wallet deposit` silently broadcast
- Good candidates:
  - `predictclaw wallet bootstrap-vault --json`
  - or a two-step `wallet deposit --json` preview + `wallet bootstrap-vault --confirm`

## Documentation Changes

Update all of these to remove the current default-first manual path:

- `README.md`
- `README.zh-CN.md`
- `SKILL.md`
- `template.mandated-vault.env`
- `.env.mandated-vault.example`

New docs should say:

- default onboarding only needs signer + funding + MCP runtime
- factory address is product-configured
- deployment preview happens before broadcast
- advanced users can still override with explicit vault address or manual derivation tuple if needed

## Testing Changes

Add or update tests to cover:

- config default uses fixed factory address
- bootstrap preview path no longer hard-blocks on missing explicit vault address
- execute path backfills vault address after successful deployment
- docs no longer instruct default users to start with `0xYOUR_DEPLOYED_VAULT`
- old advanced/manual paths still work when explicitly chosen

## Hard Block Resolution

After this design is implemented, the reported hard block should disappear:

- current failure: placeholder [REDACTED_SECRET]
- desired behavior: no placeholder vault address is required for the normal onboarding path

The new hard block, if any, should instead be limited to truly necessary inputs:

- missing signer key
- insufficient deployment fee funding
- missing MCP runtime
- failed bootstrap execution


path: docs/plans/2026-03-20-vault-auto-bootstrap-implementation.md
content: # Vault Auto Bootstrap Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Make pure `mandated-vault` onboarding default to preview + confirm + auto-deploy + `.env` backfill, so users no longer need to prefill a deployed vault address or full derivation tuple.

**Architecture:** Keep PredictClaw's MCP bridge as the execution backend, but move the user-facing entry point from manual vault addressing to a product-configured bootstrap flow. The runtime should use a fixed factory default, generate a preview via MCP, require an explicit confirmation step before broadcasting, then persist the resolved vault address and related values back into the local `.env` file.

**Tech Stack:** Python 3.11+, pytest, pydantic config validation, ClawHub/OpenClaw skill CLI, external `erc-mandated-mcp` runtime.

---

### Task 1: Lock the new bootstrap contract in tests

**Files:**
- Modify: `tests/test_config.py`
- Modify: `tests/test_wallet.py`
- Modify: `tests/test_funding.py`
- Modify: `tests/test_docs_examples.py`
- Modify: `tests/test_cli_router.py`

**Step 1: Write the failing config tests**

- Add a test that pure `mandated-vault` can start with only:
  - `PREDICT_WALLET_MODE=mandated-vault`
  - `PREDICT_PRIVATE_KEY`
  - default/fixed factory path
- Add a test that `ERC_MANDATED_FACTORY_ADDRESS` defaults to [REDACTED_SECRET]
- Add a test that explicit deployed vault and advanced manual derivation still remain supported

**Step 2: Run the focused config test to verify RED**

Run: `uv run pytest tests/test_config.py -q`
Expected: failures because the current validator still expects explicit vault address or manual derivation inputs.

**Step 3: Write the failing wallet/funding tests**

- Add a wallet-status/deposit test that undeployed vaults return a preview object instead of only `manual-only` guidance
- Add a confirm/execute-path test that bootstrap can produce a deployed vault result and backfill-ready payload
- Add CLI/help tests for the new bootstrap command and confirmation flags

**Step 4: Run focused wallet/funding/CLI tests to verify RED**

Run: `uv run pytest tests/test_wallet.py tests/test_funding.py tests/test_cli_router.py -q`
Expected: failures because no preview/confirm/execute path exists yet.

**Step 5: Write the failing docs tests**

- Require docs to remove the default-first `0xYOUR_DEPLOYED_VAULT` onboarding path
- Require docs to explain the fixed factory default and preview-before-broadcast flow

**Step 6: Run docs tests to verify RED**

Run: `uv run pytest tests/test_docs_examples.py -q`
Expected: failures because the docs still describe manual-only bootstrap.

### Task 2: Add fixed factory defaults and split bootstrap modes

**Files:**
- Modify: `lib/config.py`
- Test: `tests/test_config.py`

**Step 1: Implement the fixed factory default**

- Set the default factory address to [REDACTED_SECRET]
- Keep explicit override support for advanced/manual workflows

**Step 2: Split configuration meaningfully**

- Distinguish:
  - initial bootstrap flow
  - explicit deployed vault flow
  - advanced manual derivation flow
- Allow pure `mandated-vault` to start from signer-driven bootstrap intent rather than failing early on missing vault address

**Step 3: Run focused config tests to verify GREEN**

Run: `uv run pytest tests/test_config.py -q`
Expected: pass

### Task 3: Add preview and execute bootstrap runtime path

**Files:**
- Modify: `lib/wallet_manager.py`
- Modify: `lib/funding_service.py`
- Modify: `lib/mandated_mcp_bridge.py` only if extra result parsing is needed
- Test: `tests/test_wallet.py`
- Test: `tests/test_funding.py`

**Step 1: Implement preview-first behavior**

- Rework the current undeployed-vault path so it returns a preview payload instead of terminal `manual-only`
- Include:
  - fixed factory address
  - predicted vault
  - signer address
  - chain id
  - tx summary / fee-related metadata if available
  - explicit `confirmationRequired: true`

**Step 2: Implement execute path**

- Add a runtime function that calls MCP `vault_bootstrap(... mode="execute")`
- Capture deployed vault address and any result metadata needed for user feedback

**Step 3: Preserve fail-closed behavior**

- Missing MCP
- unhealthy MCP
- insufficient signer prerequisites
- failed bootstrap execution

All should still produce actionable failures without hidden side effects.

**Step 4: Run wallet/funding tests to verify GREEN**

Run: `uv run pytest tests/test_wallet.py tests/test_funding.py -q`
Expected: pass

### Task 4: Add a user-facing bootstrap command with confirmation

**Files:**
- Modify: `scripts/predictclaw.py`
- Modify: `scripts/wallet.py` or add a dedicated bootstrap script if that fits the current command structure better
- Test: `tests/test_cli_router.py`

**Step 1: Add a preview command**

- Add a clear user-facing command such as:
  - `predictclaw wallet bootstrap-vault --json`
  - or equivalent under the existing command layout

The preview command must not broadcast.

**Step 2: Add a confirm/execute command**

- Add an explicit execution form such as:
  - `predictclaw wallet bootstrap-vault --confirm --json`

This step should be the only point where deployment is broadcast.

**Step 3: Emit backfill-ready results**

- Return deployed vault address
- Return fixed factory and chain values
- Return enough information for `.env` persistence and UX messaging

**Step 4: Run CLI tests to verify GREEN**

Run: `uv run pytest tests/test_cli_router.py -q`
Expected: pass

### Task 5: Add `.env` backfill after successful deployment

**Files:**
- Create or modify: a helper near `lib/` or `scripts/` that updates `.env`
- Modify: CLI execution path from Task 4
- Test: new or existing test file covering `.env` writes

**Step 1: Write the failing backfill test**

- Start from a bootstrap-style `.env`
- Simulate successful bootstrap execute result
- Assert `.env` gains:
  - `ERC_MANDATED_VAULT_ADDRESS=<actual deployed vault>`
  - [REDACTED_SECRET]
  - `ERC_MANDATED_CHAIN_ID=<resolved chain>`
  - existing `ERC_MANDATED_MCP_COMMAND` remains or is preserved

**Step 2: Run the test to verify RED**

**Step 3: Implement minimal backfill logic**

- Update or append env values deterministically
- Do not overwrite unrelated user secrets

**Step 4: Run the test to verify GREEN**

### Task 6: Rewrite user-facing docs and templates

**Files:**
- Modify: `README.md`
- Modify: `README.zh-CN.md`
- Modify: `SKILL.md`
- Modify: `template.mandated-vault.env`
- Modify: `.env.mandated-vault.example`
- Test: `tests/test_docs_examples.py`
- Test: `tests/test_scaffold.py`

**Step 1: Remove the old default-first guidance**

- Stop telling default users to start with [REDACTED_SECRET]
- Stop telling default users to fill the whole derivation tuple first

**Step 2: Document the new default flow**

- signer private key
- deployment fee funding
- fixed factory handled by product
- preview before broadcast
- explicit confirmation for deployment
- auto-backfill after success

**Step 3: Keep advanced/manual escape hatches**

- explicit vault address remains documented as advanced/manual mode
- manual derivation tuple remains documented as recovery/advanced mode

**Step 4: Run docs/scaffold tests to verify GREEN**

Run: `uv run pytest tests/test_docs_examples.py tests/test_scaffold.py -q`
Expected: pass

### Task 7: Final verification and release prep

**Files:**
- Modify only if verification reveals small gaps

**Step 1: Run the broader regression suite**

Run: `uv run pytest -q`
Expected: all pass

**Step 2: Run build verification**

Run: `uv build`
Expected: `dist/predictclaw-<version>.tar.gz` and wheel build cleanly

**Step 3: Run CLI/manual checks**

Run:
- `uv run python scripts/predictclaw.py --help`
- `uv run python scripts/predictclaw.py wallet bootstrap-vault --help` (or the final chosen command)

**Step 4: Commit in small pieces**

- config/bootstrap defaults
- runtime preview/execute/backfill
- docs/templates
- tests
- version bump if release is needed


path: docs/plans/2026-03-22-vault-permissions-and-redeem-design.md
content: # Vault Permissions And Redeem Design

## Goal

Make PredictClaw explain vault asset permissions and signer safety boundaries clearly to both humans and OpenClaw, then add a safe preview-first redemption flow for vault share tokens before any real redeem broadcast path is considered.

## Current Problems

- `wallet status` and `wallet deposit` expose addresses and balances, but they do not explain the permission model in a structured way.
- Users cannot easily tell which signer is the authority, which signer executes, which signer bootstraps deployment, or what token/recipient limits apply.
- OpenClaw receives fragmented text instead of stable JSON fields describing those limits and roles.
- The current withdrawal command only supports direct `BNB` / `USDT` wallet withdrawals and explicitly blocks pure `mandated-vault` flows.
- There is no product path for previewing a vault share redemption into the underlying asset, so users hit raw chain/runtime errors when they try to reason about redeemability.

## Observed Redeem Constraint

The requested vault token [REDACTED_SECRET] behaves like an ERC4626-style share token:

- name: `Predict Mainnet USDT Vault`
- symbol: `pmUSDT`
- decimals: `18`
- underlying asset: USDT

However, a static full-share redeem call currently returns `ERC4626ExceededMaxRedeem`, which means the token is not presently redeemable through an unconditional "redeem all now" path. The product should surface this as a structured preview result instead of blindly broadcasting.

## Options Considered

### Option A: Docs-only clarification

Update README/templates/help text without changing JSON/status shape or adding a redeem preview command.

Pros:
- Smallest implementation

Cons:
- OpenClaw still cannot reliably infer permission boundaries or redeemability
- Users still need to manually reason through raw chain errors

### Option B: Add full redeem execution immediately

Add `wallet redeem-vault --confirm` right away.

Pros:
- Fastest path to a possible on-chain action

Cons:
- Unsafe while redeemability is currently blocked
- Risks turning a chain-specific vault rule into a user-facing broadcast failure

### Option C: Structured permissions + redeem preview first (recommended)

Expose permission and safety information in stable JSON fields, then add a preview-only redeem command that reports balances, underlying asset, redeem limits, and blocking reasons. Only after preview proves safe should a future confirm/broadcast path be added.

Pros:
- Safer for real funds
- Clear for users and OpenClaw
- Converts raw contract errors into actionable product state

Cons:
- Requires cross-cutting CLI, service, docs, and test changes

## Recommended Design

Use Option C.

### 1. Structured vault permission model

For `wallet status --json` and `wallet deposit --json`, add a stable object that explains:

- `vaultAuthority`
- `vaultExecutor`
- `bootstrapSigner`
- `underlyingAsset`
- `shareToken` when known
- `permissionModel`
- `fundingPolicy.allowedTokenAddresses`
- `fundingPolicy.allowedRecipients`
- `fundingPolicy.maxAmountPerTx`
- `fundingPolicy.maxAmountPerWindow`
- `fundingPolicy.windowSeconds`
- `safetyNotes`

This object should be explicit enough that OpenClaw can explain what is allowed without reverse-engineering env variables.

### 2. Human-readable CLI summary

Plain-text `wallet status` / `wallet deposit` should summarize:

- who controls the vault
- who can execute funding actions
- what asset is permitted
- which recipients are allowed
- whether limits are configured or effectively unlimited

### 3. Preview-first redeem command

Add a new command family:

- `predictclaw wallet redeem-vault --preview --json`
- `predictclaw wallet redeem-vault --all --preview --json`

The command should inspect a share token and return:

- share token address
- holder address
- token metadata
- underlying asset metadata
- share balance
- `previewRedeem`
- `maxRedeem`
- `maxWithdraw`
- `redeemableNow`
- `blockingReason`
- `contractError`
- `recommendedNextAction`

### 4. No immediate confirm path

Do not add a real `--confirm` redeem path in this iteration unless preview proves there is a consistently safe execution path. The current on-chain state already shows a redeemability block, so the product should diagnose first.

## Files Likely To Change

- `lib/wallet_manager.py`
- `lib/funding_service.py`
- `scripts/wallet.py`
- `README.md`
- `README.zh-CN.md`
- `SKILL.md`
- `template.env`
- `template.mandated-vault.env`
- tests under `tests/test_wallet.py`, `tests/test_funding.py`, `tests/test_cli_router.py`, `tests/test_docs_examples.py`

## Success Criteria

- Users can tell, from the product output alone, which signer and policy constraints protect vault funds.
- OpenClaw receives structured fields instead of raw prose for vault permissions.
- `redeem-vault --preview` explains whether a share token is redeemable right now and why.
- Current blocked redemptions fail as structured preview results, not as blind broadcast attempts.


path: docs/plans/2026-03-22-vault-permissions-and-redeem-implementation.md
content: # Vault Permissions And Redeem Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add structured vault permission/safety output and a preview-first vault share redemption flow so users and OpenClaw can understand signer authority, funding limits, and redeemability without blind broadcasts.

**Architecture:** Extend the current wallet status/deposit surfaces with a shared permission summary object, then add a read-only redeem preview service that interrogates the share token, underlying asset, and ERC4626 limits. Keep redeem execution out of scope for now unless preview proves it is safe.

**Tech Stack:** Python 3.11+, pytest, web3, existing PredictClaw CLI, mandated vault config/runtime helpers.

---

### Task 1: Lock the permission and redeem-preview contract in tests

**Files:**
- Modify: `tests/test_wallet.py`
- Modify: `tests/test_funding.py`
- Modify: `tests/test_cli_router.py`
- Modify: `tests/test_docs_examples.py`

**Step 1: Write failing permission-shape tests**

- Add tests that `wallet status --json` for mandated-vault emits a structured permission object with authority, executor, bootstrap signer, and safety notes.
- Add tests that `wallet deposit --json` includes enough permission/funding-policy context for users/OpenClaw to understand allowed asset and recipient behavior.

**Step 2: Run focused tests to verify RED**

Run: `uv run pytest tests/test_wallet.py tests/test_funding.py -q`

**Step 3: Write failing redeem-preview tests**

- Add tests for a new redeem preview command/output shape.
- Include a redeemable case and a blocked case with a structured `blockingReason`.

**Step 4: Run focused redeem tests to verify RED**

Run: `uv run pytest tests/test_wallet.py tests/test_cli_router.py -q`

### Task 2: Add a shared vault permission summary model

**Files:**
- Modify: `lib/wallet_manager.py`
- Modify: `lib/funding_service.py`
- Test: `tests/test_wallet.py`
- Test: `tests/test_funding.py`

**Step 1: Introduce a stable permission summary object**

- Add a small serializer/model that captures authority, executor, bootstrap signer, underlying asset, share token (if known), and safety notes.

**Step 2: Thread the object into mandated-vault outputs**

- Add it to `wallet status --json`.
- Add it to `wallet deposit --json`.
- If overlay/funding-policy details exist, include allowed asset/recipient/window fields.

**Step 3: Run focused tests to verify GREEN**

Run: `uv run pytest tests/test_wallet.py tests/test_funding.py -q`

### Task 3: Add redeem preview service logic

**Files:**
- Modify: `lib/funding_service.py` or add a sibling helper module under `lib/`
- Test: `tests/test_wallet.py`

**Step 1: Implement read-only share-token inspection**

- Query token name, symbol, decimals, balance, underlying asset, `previewRedeem`, `maxRedeem`, and `maxWithdraw`.
- Convert ERC4626 custom errors into structured preview blockers instead of raw exceptions.

**Step 2: Define preview output**

- Include `redeemableNow`, `blockingReason`, `contractError`, and `recommendedNextAction`.

**Step 3: Run focused tests to verify GREEN**

Run: `uv run pytest tests/test_wallet.py -q`

### Task 4: Add CLI command for redeem preview

**Files:**
- Modify: `scripts/wallet.py`
- Test: `tests/test_cli_router.py`

**Step 1: Add the command shape**

- Add `predictclaw wallet redeem-vault --preview --json`
- Add `--all` support for the preview path

**Step 2: Keep execution out of scope**

- If the user requests confirm, fail cleanly with a message that preview-first support exists and confirm is not yet enabled.

**Step 3: Run CLI tests to verify GREEN**

Run: `uv run pytest tests/test_cli_router.py -q`

### Task 5: Rewrite docs/templates for users and OpenClaw

**Files:**
- Modify: `README.md`
- Modify: `README.zh-CN.md`
- Modify: `SKILL.md`
- Modify: `template.env`
- Modify: `template.mandated-vault.env`
- Test: `tests/test_docs_examples.py`

**Step 1: Document vault permission boundaries clearly**

- Explain authority / executor / bootstrap signer roles.
- Explain funding-policy constraints and how they bound asset movement.

**Step 2: Document redeem preview semantics**

- Explain that preview reads redeemability first.
- Explain why a vault can hold shares but still be non-redeemable right now.

**Step 3: Run docs tests to verify GREEN**

Run: `uv run pytest tests/test_docs_examples.py -q`

### Task 6: Final verification and release prep

**Files:**
- Modify only if verification reveals small gaps

**Step 1: Run full tests**

Run: `uv run pytest -q`

**Step 2: Run build verification**

Run: `uv build`

**Step 3: Run CLI help/manual checks**

Run:
- `uv run python scripts/predictclaw.py --help`
- `uv run python scripts/predictclaw.py wallet status --help`
- `uv run python scripts/predictclaw.py wallet redeem-vault --help`

**Step 4: Commit in small pieces**

- permission summary
- redeem preview service/CLI
- docs/templates/tests
- version bump and publish


path: docs/plans/2026-03-23-vault-mode-semantics-design.md
content: # Vault Mode Semantics Design

## Goal

Correct the user-facing documentation so PredictClaw presents only four user-facing modes:

1. `read-only`
2. `eoa`
3. `predict-account`
4. `predict-account + vault`

The existing `mandated-vault` label should remain only as an internal/bootstrap implementation detail and compatibility term, not as a standalone user mode.

## Current Problem

README, README.zh-CN, SKILL, top-level CLI help, and template descriptions currently describe `mandated-vault` as a separate mode. That causes two kinds of confusion:

- Humans think there are five modes instead of four.
- OpenClaw sees `mandated-vault` as a first-class user choice instead of understanding it as the internal subflow used to create/bind a vault for `predict-account + vault`.

## Recommended Approach

Use a user-facing / implementation-facing split.

### User-facing semantics

Only describe four modes:

- `read-only`
- `eoa`
- `predict-account`
- `predict-account + vault`

For `predict-account + vault`, explain the onboarding decision tree:

- If the user already has a vault, bind it with env variables.
- If the user does not yet have a vault, use the bootstrap helper to create one, then bind the result.

### Implementation-facing semantics

Keep `mandated-vault` visible only as:

- an internal bootstrap subflow
- an internal/compatibility template
- a technical term needed to explain current env/runtime behavior

Do **not** present it as a standalone user mode.

## Template Strategy

Add a new canonical user-facing template:

- `template.predict-account-vault.env`

Keep the existing:

- `template.mandated-vault.env`

but relabel it as an internal/compatibility bootstrap template.

This preserves runtime compatibility while giving users/OpenClaw a canonical entry point that matches the intended product model.

## Copy Changes

### README / README.zh-CN / SKILL

- Replace all user-facing “mandated-vault mode” language with “predict-account + vault”.
- Add a short explanation that vault creation uses an internal bootstrap subflow.
- Reframe `wallet bootstrap-vault` as the helper command used during `predict-account + vault` onboarding.

### CLI help

- Top-level help should no longer imply `mandated-vault` is a user-facing mode.
- Environment notes should explain that `PREDICT_WALLET_MODE=mandated-vault` still exists internally for the bootstrap path, but is not a standalone user-facing mode.

### Templates

- `template.predict-account-vault.env`: canonical user-facing template.
- `template.mandated-vault.env`: internal/compatibility bootstrap template.

## Success Criteria

- Documentation consistently describes only four user-facing modes.
- `predict-account + vault` becomes the canonical advanced route.
- `mandated-vault` remains discoverable only as internal/bootstrap vocabulary.
- Template and docs tests lock this distinction to prevent regression.


path: docs/plans/2026-03-23-vault-mode-semantics-implementation.md
content: # Vault Mode Semantics Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Rewrite PredictClaw docs/help/templates so only four user-facing modes remain visible while `mandated-vault` is repositioned as an internal bootstrap/compatibility detail.

**Architecture:** Keep runtime behavior unchanged. Add a canonical `predict-account + vault` template, demote `template.mandated-vault.env` to internal/compatibility status, and update docs/tests/help text to enforce the user-facing semantic split.

**Tech Stack:** Markdown docs, env templates, Python CLI help text, pytest docs/template tests.

---

### Task 1: Lock the new semantic contract in tests

**Files:**
- Modify: `tests/test_docs_examples.py`
- Modify: `tests/test_scaffold.py`

**Step 1: Write failing docs tests**

- Assert docs describe four user-facing modes.
- Assert docs present `predict-account + vault` as the canonical vault-enabled route.
- Assert `template.predict-account-vault.env` exists and appears in first-install guidance.
- Assert `template.mandated-vault.env` is described as internal/compatibility bootstrap, not a user-facing mode.

**Step 2: Run focused tests to verify RED**

Run: `uv run pytest tests/test_docs_examples.py tests/test_scaffold.py -q`

### Task 2: Add the canonical predict-account + vault template

**Files:**
- Create: `template.predict-account-vault.env`
- Create: `.env.predict-account-vault.example`
- Modify: `template.env`

**Step 1: Add the new template**

- Keep `PREDICT_WALLET_MODE=predict-account`
- Include the vault-binding envs needed after a vault exists
- Explain that if the vault does not exist yet, users should run `wallet bootstrap-vault` first

**Step 2: Reframe the old mandated-vault template**

- Keep the file for compatibility
- Rewrite comments so it is explicitly internal/bootstrap-oriented

**Step 3: Run template tests to verify GREEN**

Run: `uv run pytest tests/test_scaffold.py -q`

### Task 3: Rewrite README and README.zh-CN

**Files:**
- Modify: `README.md`
- Modify: `README.zh-CN.md`

**Step 1: Rewrite the mode list**

- Remove user-facing wording that presents `mandated-vault` as a mode
- Present only the four user-facing modes

**Step 2: Rewrite vault onboarding**

- Explain `predict-account + vault` as the advanced route
- Explain the “create vault first or bind existing vault” decision tree
- Explicitly call `mandated-vault` an internal bootstrap subflow

**Step 3: Run docs tests to verify GREEN**

Run: `uv run pytest tests/test_docs_examples.py -q`

### Task 4: Rewrite SKILL.md and CLI help

**Files:**
- Modify: `SKILL.md`
- Modify: `scripts/predictclaw.py`

**Step 1: Align skill documentation**

- Reframe `wallet bootstrap-vault` as part of `predict-account + vault` onboarding
- Clarify internal/bootstrap-only references to `mandated-vault`

**Step 2: Align top-level help text**

- Remove wording that implies `mandated-vault` is a user-facing mode
- Add wording that `mandated-vault` exists as an internal bootstrap subflow

**Step 3: Run help/docs verification**

Run:
- `uv run python scripts/predictclaw.py --help`
- `uv run pytest tests/test_docs_examples.py -q`

### Task 5: Final verification

**Files:**
- No new files unless verification reveals a small doc/test gap

**Step 1: Run focused docs/template suite**

Run: `uv run pytest tests/test_docs_examples.py tests/test_scaffold.py -q`

**Step 2: Run full test suite**

Run: `uv run pytest -q`

**Step 3: If user asks for publishing, then commit/push/publish**

Keep commits doc-focused and atomic.


path: docs/plans/2026-03-24-predictclaw-order-session-continuation-implementation.md
content: # PredictClaw Order Session Continuation Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Repair PredictClaw's overlay workflow by reconciling local tracked positions with remote truth, binding funding sessions to trade identity, and exposing a public CLI continuation surface for funding/follow-up session execution.

**Architecture:** Persist trade-bound overlay session records in local storage when overlay buys stop at `funding-required`, use those records to enrich wallet status/deposit output and drive continuation commands, and reconcile tracked positions against both remote positions and remote order truth. Preserve explicit wallet mode semantics and do not auto-broadcast chain actions.

**Tech Stack:** Python 3.11+, pytest, existing PredictClaw CLI, local JSON storage, MandatedVault MCP bridge, Predict API client.

---

### Task 1: Add failing tests for position/order reconciliation

**Files:**
- Modify: `tests/test_positions.py`
- Modify: `tests/test_storage.py`

**Step 1: Write a failing merge test**

- Seed a tracked local position with `status=OPEN` and `order_hash`.
- Stub remote positions as empty.
- Stub remote order lookup to return `CANCELLED`.
- Assert the merged view is no longer `OPEN` and the local storage status is updated.

**Step 2: Run the test to verify RED**

Run: `uv run pytest tests/test_positions.py tests/test_storage.py -q`

### Task 2: Implement local position reconciliation

**Files:**
- Modify: `lib/positions_service.py`
- Modify: `lib/position_storage.py`
- Modify: `lib/models.py` if order data shape needs expansion
- Test: `tests/test_positions.py`
- Test: `tests/test_storage.py`

**Step 1: Add order-truth lookup path**

- Add a small helper in `PositionsService` to look up remote order status by `order_hash`.
- Use it only when there is no matching remote position.

**Step 2: Add local storage update support**

- Add a targeted storage update method or reuse `upsert` so resolved status can be written back to `positions.json`.

**Step 3: Apply merge priority**

- Use `remote position truth > remote order truth > local tracked fallback`.

**Step 4: Re-run focused tests to verify GREEN**

Run: `uv run pytest tests/test_positions.py tests/test_storage.py -q`

### Task 3: Add failing tests for trade-bound overlay session persistence

**Files:**
- Modify: `tests/test_trade.py`
- Create or modify: `tests/test_session_storage.py`

**Step 1: Write a failing overlay buy test**

- For insufficient overlay balance, assert PredictClaw persists a trade-bound session record with:
  - `positionId`
  - `marketId`
  - `outcome`
  - `orderHash` (nullable)
  - `sessionId`

**Step 2: Run tests to verify RED**

Run: `uv run pytest tests/test_trade.py tests/test_session_storage.py -q`

### Task 4: Implement session storage and identity binding

**Files:**
- Create: `lib/session_storage.py`
- Modify: `lib/trade_service.py`
- Modify: `lib/wallet_manager.py`
- Modify: `lib/funding_service.py`
- Test: `tests/test_trade.py`
- Test: `tests/test_session_storage.py`
- Test: `tests/test_wallet.py`
- Test: `tests/test_funding.py`

**Step 1: Add local session storage**

- Create a JSON-backed storage helper for active fund-and-action sessions.
- Persist session metadata separately from MCP session payloads.

**Step 2: Bind trade identity at creation time**

- When overlay buy stops at `funding-required`, create a deterministic `positionId` and persist `marketId`, `outcome`, and nullable `orderHash` with the session record.
- Do not mutate MCP session schemas with extra fields; keep trade binding alongside the session wrapper record.

**Step 3: Enrich wallet status/deposit output**

- If an active stored session exists for the current Predict Account overlay, expose `linkedPositionId`, `linkedMarketId`, `linkedOrderHash`, and `sessionScope` in wallet output.

**Step 4: Re-run focused tests to verify GREEN**

Run: `uv run pytest tests/test_trade.py tests/test_session_storage.py tests/test_wallet.py tests/test_funding.py -q`

### Task 5: Add failing tests for public continuation CLI

**Files:**
- Modify: `tests/test_cli_router.py`
- Modify: `tests/integration/test_cli_flows.py`
- Modify: `tests/integration/test_openclaw_host_contract.py`

**Step 1: Write a failing `continue-funding` test**

- Seed an active stored session.
- Use a fake MCP server that supports:
  - `vault_asset_transfer_result_create`
  - `agent_fund_and_action_session_apply_event`
  - `agent_fund_and_action_session_next_step`
- Assert the command applies `fundingConfirmed`, updates the stored session, and returns the next step.

**Step 2: Write a failing `continue-follow-up` test**

- Seed a pending-follow-up session and a next-step that includes a follow-up action plan.
- Assert the command creates a follow-up result, applies `followUpResultReceived`, and returns the updated session.

**Step 3: Run tests to verify RED**

Run: `uv run pytest tests/test_cli_router.py tests/integration/test_cli_flows.py tests/integration/test_openclaw_host_contract.py -q`

### Task 6: Implement public wallet continuation commands

**Files:**
- Modify: `scripts/wallet.py`
- Modify: `lib/funding_service.py`
- Modify: `lib/wallet_manager.py` only if shared helpers belong there
- Modify: `lib/mandated_mcp_bridge.py` only if helper wrappers are needed
- Test: `tests/test_cli_router.py`
- Test: `tests/integration/test_cli_flows.py`
- Test: `tests/integration/test_openclaw_host_contract.py`

**Step 1: Add parser surface**

- Add public wallet subcommands for continuation, for example:
  - `wallet continue-funding`
  - `wallet continue-follow-up`

**Step 2: Implement funding confirmation flow**

- Load the active stored session.
- Build `assetTransferResult` from the next-step funding plan and CLI inputs such as `--tx-hash`.
- Apply `fundingConfirmed` and store the updated session.

**Step 3: Implement follow-up result flow**

- Load the active stored session.
- Use `create_agent_follow_up_action_result` with the stored follow-up plan.
- Apply `followUpResultReceived` and store the updated session.

**Step 4: Re-run focused tests to verify GREEN**

Run: `uv run pytest tests/test_cli_router.py tests/integration/test_cli_flows.py tests/integration/test_openclaw_host_contract.py -q`

### Task 7: Final verification

**Files:**
- Modify only if verification reveals gaps

**Step 1: Run targeted regression suite**

Run: `uv run pytest tests/test_positions.py tests/test_storage.py tests/test_trade.py tests/test_wallet.py tests/test_funding.py tests/test_cli_router.py tests/integration/test_cli_flows.py tests/integration/test_openclaw_host_contract.py -q`

**Step 2: Run full suite**

Run: `uv run pytest -q`

**Step 3: Run CLI smoke checks**

Run:
- `uv run python scripts/predictclaw.py wallet --help`
- `uv run python scripts/wallet.py continue-funding --help`
- `uv run python scripts/wallet.py continue-follow-up --help`

**Step 4: Keep changes grouped by concern**

- position reconciliation
- session storage and binding
- public continuation CLI


path: docs/plans/2026-03-24-predictclaw-route-conflict-diagnostics-design.md
content: # PredictClaw Route Conflict Diagnostics Design

## Goal

Keep `PREDICT_WALLET_MODE` as the explicit execution truth, but make PredictClaw explain route conflicts and route intent clearly when users mix `mandated-vault` mode with Predict Account credentials or try to use the wrong local route for their funding goal.

## Current Problem

- The repository intentionally treats pure `mandated-vault` and `predict-account + ERC_MANDATED_*` as different product routes.
- `lib/config.py` rejects `PREDICT_ACCOUNT_*` credentials when `PREDICT_WALLET_MODE=mandated-vault`, but the current error only says the credentials are not allowed.
- That error is technically correct, but it does not explain the routing implication: the current mode is still the pure vault control-plane path, not the Predict Account funding path.
- `wallet status` and `wallet deposit` describe the active route once config is valid, but they do not help enough when the configuration is in a route-conflict state.

## Constraints

- Do not silently change the active route.
- Do not infer a different execution mode from extra credentials.
- Keep `PREDICT_WALLET_MODE` as the single source of execution truth.
- Improve diagnostics and guidance without weakening current safety boundaries.

## Options Considered

### Option A: Auto-switch to overlay when Predict Account credentials are present

If `PREDICT_ACCOUNT_ADDRESS` and `PREDICT_PRIVY_PRIVATE_KEY` are present together with `ERC_MANDATED_*`, auto-route to `predict-account` semantics even when the explicit mode still says `mandated-vault`.

Pros:
- Fewer manual config edits for users who clearly want Predict Account funding.

Cons:
- Breaks the current contract that `PREDICT_WALLET_MODE` is authoritative.
- Silently changes execution semantics and trust boundaries.
- Risks surprising users during real-funds workflows.

### Option B: Keep explicit mode authoritative and add route-aware diagnostics (recommended)

Keep the current validation boundary, but raise a richer route-conflict error and expose route intent explicitly in valid `wallet status` / `wallet deposit` outputs.

Pros:
- Preserves safety and predictability.
- Solves the actual UX problem: users cannot tell why the current route does not match their funding goal.
- Keeps docs/tests aligned with the current architecture.

Cons:
- Users still need to edit `.env` to switch routes.

### Option C: Add a read-only overlay preview on top of conflicts

Keep failures strict, but attach a preview of the recommended route when credentials suggest the user wants Predict Account funding.

Pros:
- More helpful than a plain failure.

Cons:
- More complex CLI and JSON semantics.
- Easier for users to misread a failed command as partially successful.

## Recommended Design

Use Option B.

### 1. Add a structured route-conflict error

When config validation detects `PREDICT_WALLET_MODE=mandated-vault` together with `PREDICT_ACCOUNT_ADDRESS` and `PREDICT_PRIVY_PRIVATE_KEY`, raise a dedicated route-aware config error instead of a generic mutual-exclusion message.

The error should include:

- `activeMode`
- `activeRoute`
- `recommendedMode`
- `recommendedRoute`
- `routeConflictReason`
- `detectedCapabilities`
- `nextStep`

For text output, the message should explain that pure `mandated-vault` is the vault control-plane route, while Predict Account top-up requires `PREDICT_WALLET_MODE=predict-account`.

### 2. Emit structured JSON errors for wallet commands

`scripts/wallet.py` should not print only a string on config failure in JSON mode. Instead it should emit a stable error payload that preserves the route-aware guidance fields.

This keeps `wallet status --json` and `wallet deposit --json` useful even when the configuration is invalid.

### 3. Expose active route metadata in successful wallet outputs

For valid configurations, enrich `wallet status` / `wallet deposit` output with explicit route semantics:

- pure `mandated-vault` -> `activeRoute = vault-control-plane`
- overlay `predict-account + ERC_MANDATED_*` -> `activeRoute = vault-to-predict-account`

Also add a short human-readable route purpose, such as:

- `bootstrap-or-direct-vault-ops`
- `predict-account-top-up-and-trading`

### 4. Improve plain-text guidance

Plain-text `wallet status` and `wallet deposit` should say which route is currently active and whether that route funds the vault itself or the Predict Account.

For pure `mandated-vault`, add a direct line explaining that the current route funds the vault itself and is not the Predict Account top-up route.

## Files Likely To Change

- `lib/config.py`
- `lib/wallet_manager.py`
- `lib/funding_service.py`
- `scripts/wallet.py`
- `tests/test_config.py`
- `tests/test_wallet.py`
- `tests/test_funding.py`
- `tests/test_cli_router.py`

## Success Criteria

- Route-conflict failures tell users exactly why the current mode and credentials do not match.
- JSON wallet commands return structured route guidance on failure.
- Successful pure-vault outputs explicitly identify themselves as vault control-plane routes.
- Successful overlay outputs continue to identify themselves as `vault-to-predict-account` routes.
- No command silently changes route semantics behind the user's back.


path: docs/plans/2026-03-24-predictclaw-route-conflict-diagnostics-implementation.md
content: # PredictClaw Route Conflict Diagnostics Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Keep `PREDICT_WALLET_MODE` authoritative while making route conflicts and active route semantics explicit in PredictClaw wallet errors, JSON output, and plain-text guidance.

**Architecture:** Introduce a dedicated route-conflict config error, thread structured diagnostics through `scripts/wallet.py`, and enrich valid status/deposit payloads with explicit route metadata. Preserve the current pure `mandated-vault` vs `predict-account + ERC_MANDATED_*` execution split.

**Tech Stack:** Python 3.11+, pytest, existing PredictClaw CLI, current config/wallet/funding services.

---

### Task 1: Lock the conflict contract in tests

**Files:**
- Modify: `tests/test_config.py`
- Modify: `tests/test_cli_router.py`

**Step 1: Write the failing config test**

- Add a test that `PREDICT_WALLET_MODE=mandated-vault` plus valid Predict Account credentials raises a route-aware config error.
- Assert the error text mentions the active route, the recommended mode, and the next step.

**Step 2: Run the config test to verify RED**

Run: `uv run pytest tests/test_config.py -q`

**Step 3: Write the failing CLI JSON/text tests**

- Add `wallet status --json` and `wallet deposit --json` tests for the same conflict configuration.
- Assert the JSON error payload includes route guidance fields.
- Add a plain-text assertion that the output tells the user to switch to `PREDICT_WALLET_MODE=predict-account` for Predict Account funding.

**Step 4: Run the CLI tests to verify RED**

Run: `uv run pytest tests/test_cli_router.py -q`

### Task 2: Add a dedicated route-conflict error type

**Files:**
- Modify: `lib/config.py`
- Test: `tests/test_config.py`

**Step 1: Introduce a route-aware config error**

- Add a small error subclass or structured payload helper for route-conflict diagnostics.
- Include fields for active mode/route, recommended mode/route, detected capabilities, and next step.

**Step 2: Use it in mandated-vault + Predict Account conflicts**

- Replace the generic `does not allow Predict Account credentials` path with the route-aware error for this exact mixed configuration.

**Step 3: Run focused tests to verify GREEN**

Run: `uv run pytest tests/test_config.py -q`

### Task 3: Surface structured wallet command errors

**Files:**
- Modify: `scripts/wallet.py`
- Test: `tests/test_cli_router.py`

**Step 1: Add a shared wallet error emitter**

- In JSON mode, emit a stable object with `success=false`, error class, message, and any route guidance fields from the config error.
- In text mode, print the message and the route guidance lines in a readable order.

**Step 2: Wire the emitter into wallet handlers**

- Apply it to `status`, `deposit`, `bootstrap-vault`, and other wallet handlers that already catch `ConfigError`.

**Step 3: Run focused CLI tests to verify GREEN**

Run: `uv run pytest tests/test_cli_router.py -q`

### Task 4: Add explicit active-route metadata to successful status output

**Files:**
- Modify: `lib/wallet_manager.py`
- Test: `tests/test_wallet.py`

**Step 1: Extend status payloads**

- Add explicit route fields for pure mandated-vault snapshots such as `activeRoute` and `routePurpose`.
- Add matching route metadata for overlay snapshots without changing existing `fundingRoute` semantics.

**Step 2: Update plain-text status rendering**

- Show the route intent clearly for pure `mandated-vault` and overlay outputs.

**Step 3: Run focused wallet tests to verify GREEN**

Run: `uv run pytest tests/test_wallet.py -q`

### Task 5: Add explicit active-route metadata to successful deposit output

**Files:**
- Modify: `lib/funding_service.py`
- Modify: `scripts/wallet.py`
- Test: `tests/test_funding.py`
- Test: `tests/test_cli_router.py`

**Step 1: Extend deposit payloads**

- Add route metadata for pure `mandated-vault` deposit details.
- Preserve existing overlay deposit fields while adding the same route-purpose vocabulary.

**Step 2: Update plain-text deposit rendering**

- Add a line for pure `mandated-vault` clarifying that the current route funds the vault itself, not the Predict Account.

**Step 3: Run focused tests to verify GREEN**

Run: `uv run pytest tests/test_funding.py tests/test_cli_router.py -q`

### Task 6: Final verification

**Files:**
- Modify only if verification reveals small gaps

**Step 1: Run targeted regression suite**

Run: `uv run pytest tests/test_config.py tests/test_cli_router.py tests/test_wallet.py tests/test_funding.py -q`

**Step 2: Run full tests**

Run: `uv run pytest -q`

**Step 3: Run CLI checks**

Run:
- `uv run python scripts/predictclaw.py --help`
- `uv run python scripts/wallet.py status --help`
- `uv run python scripts/wallet.py deposit --help`

**Step 4: Commit in small pieces**

- route-conflict error contract
- wallet CLI error/output plumbing
- status/deposit route metadata


path: docs/plans/2026-03-26-predictclaw-clawhub-risk-reduction-implementation.md
content: # PredictClaw ClawHub Risk Reduction Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Reduce the likelihood that PredictClaw is flagged as suspicious on ClawHub by removing default auto-install and auto-write behavior, making the setup path safe by default, and documenting the external MCP runtime explicitly.

**Architecture:** Keep mandated-vault and overlay runtime support intact, but change setup and documentation so the default path only detects the external `erc-mandated-mcp` launcher and prints manual instructions. Remove runtime code paths that perform global npm install or write `.env` automatically from the published skill package.

**Tech Stack:** Python 3.11+, pytest, PredictClaw CLI, ClawHub-published skill docs.

---

### Task 1: Lock the new safe-default setup contract in tests

**Files:**
- Modify: `tests/test_mandated_mcp_setup.py`
- Modify: `tests/test_cli_router.py`
- Modify: `tests/test_docs_examples.py`

**Step 1: Write failing setup tests**

- Replace tests that expect auto-install and auto `.env` backfill with tests that expect detection-only behavior.
- Assert setup help no longer advertises `--install` / `--write-env`.
- Assert docs no longer present the one-click install/write-env path as the normal workflow.

**Step 2: Run tests to verify RED**

Run: `uv run pytest tests/test_mandated_mcp_setup.py tests/test_cli_router.py tests/test_docs_examples.py -q`

### Task 2: Remove auto-install and auto-write behavior from setup

**Files:**
- Modify: `lib/mandated_mcp_setup.py`
- Modify: `scripts/setup.py`
- Delete: `lib/env_backfill.py` if it is unused
- Test: `tests/test_mandated_mcp_setup.py`
- Test: `tests/test_cli_router.py`

**Step 1: Make configure_mandated_mcp detection-only**

- Remove runtime `npm install -g` behavior from the published code path.
- Remove automatic `.env` writes from setup.
- Return a manual instruction message when the launcher is missing.

**Step 2: Simplify setup CLI**

- Remove `--install` and `--write-env` flags from the public setup command.
- Keep the command as a safe detector that prints what runtime is required and what env var the user must set manually.

**Step 3: Run focused tests to verify GREEN**

Run: `uv run pytest tests/test_mandated_mcp_setup.py tests/test_cli_router.py -q`

### Task 3: Make the external MCP runtime explicitly declared in docs

**Files:**
- Modify: `README.md`
- Modify: `README.zh-CN.md`
- Modify: `SKILL.md`
- Test: `tests/test_docs_examples.py`

**Step 1: Update main-path docs**

- Rewrite mandated MCP setup docs so the default path says:
  - PredictClaw depends on an external `erc-mandated-mcp` runtime
  - users must install/configure it separately
  - PredictClaw does not globally install packages or auto-edit `.env` as the default path

**Step 2: Keep advanced behavior out of the normal path**

- If any advanced recovery behavior remains documented, clearly mark it as advanced/manual and not the recommended path.
- Remove one-click auto-install framing from README/SKILL examples and notes.

**Step 3: Run focused docs tests to verify GREEN**

Run: `uv run pytest tests/test_docs_examples.py tests/test_skill_manifest.py -q`

### Task 4: Final verification and publication readiness

**Files:**
- Modify only if verification reveals gaps

**Step 1: Run targeted regression suite**

Run: `uv run pytest tests/test_mandated_mcp_setup.py tests/test_cli_router.py tests/test_docs_examples.py tests/test_skill_manifest.py -q`

**Step 2: Run full suite**

Run: `uv run pytest -q`

**Step 3: Run CLI help checks**

Run:
- `uv run python scripts/predictclaw.py --help`
- `uv run python scripts/setup.py --help`

**Step 4: Publish follow-up**

- Bump version
- Sync to GitHub
- Publish to ClawHub
- Re-check the ClawHub page state


path: docs/plans/2026-03-31-vault-deposit-default-design.md
content: # Vault Deposit Default Design

## Goal

Align PredictClaw's `predict-account + vault` funding semantics so the default user-facing funding entry is the Vault deposit flow, while Predict Account remains the trading identity and downstream recipient of vault-driven funding.

## Problem

Current product messaging and CLI output are split:

- Some output fields still suggest the user should manually top up the Predict Account directly.
- The intended product model is that `predict-account + vault` defaults to funding through the Vault deposit flow.
- OpenClaw needs stable fields that distinguish:
  - default funding ingress
  - trading identity
  - downstream funding target

## Design

### User-facing semantics

- `fundingAddress` should point to the Vault address for `predict-account + vault`.
- `manualTopUpAddress` should also point to the Vault, because it represents the default funding ingress.
- `predictAccountAddress` and `tradingIdentityAddress` should continue to identify the Predict Account.
- Human-readable output should explain that users fund the Vault first, then Vault-driven orchestration tops up the Predict Account.

### Internal semantics

- The internal funding route remains `vault-to-predict-account`.
- The internal orchestration target can still be the Predict Account.
- We are changing the *user-facing* funding entry semantics, not the underlying orchestration identifier.

## Success Criteria

- `wallet deposit --json` and `wallet status --json` point user-facing funding entry fields at the Vault.
- CLI text says Vault deposit flow is the default funding entry.
- Docs and tests describe Predict Account as the trading identity while Vault is the default funding ingress.


path: docs/plans/2026-03-31-vault-deposit-default-implementation.md
content: # Vault Deposit Default Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Make `predict-account + vault` default funding guidance point at the Vault deposit flow instead of the Predict Account address.

**Architecture:** Keep the internal route identifier `vault-to-predict-account`, but rewrite user-facing guidance fields, CLI text, and docs so funding ingress = Vault and trading identity = Predict Account.

**Tech Stack:** Python CLI/output shaping, pytest, Markdown docs.

---

### Task 1: Lock user-facing funding semantics in tests

**Files:**
- Modify: `tests/test_funding.py`
- Modify: `tests/test_cli_router.py`
- Modify: `tests/test_docs_examples.py`

**Step 1: Write failing tests**
- Assert overlay `fundingAddress` and `manualTopUpAddress` are the Vault.
- Assert human-readable wallet deposit output labels Vault as the default funding entry.
- Assert docs describe Vault deposit flow as the default ingress.

**Step 2: Run focused tests to verify RED**
- `uv run pytest tests/test_funding.py tests/test_cli_router.py tests/test_docs_examples.py -q`

### Task 2: Change JSON/output semantics

**Files:**
- Modify: `lib/wallet_manager.py`
- Modify: `lib/funding_service.py`
- Modify: `scripts/wallet.py`

**Step 1: Update guidance fields**
- Point overlay `fundingAddress` to the Vault.
- Point `manualTopUpAddress` to the Vault.
- Rewrite guidance string to explain vault-first funding.

**Step 2: Update CLI labels**
- Replace the old manual top-up phrasing with Vault deposit flow wording.

**Step 3: Run focused tests to verify GREEN**
- `uv run pytest tests/test_funding.py tests/test_cli_router.py -q`

### Task 3: Rewrite docs/help text

**Files:**
- Modify: `README.md`
- Modify: `README.zh-CN.md`
- Modify: `SKILL.md`
- Modify: `scripts/predictclaw.py`

**Step 1: Rewrite funding-address guidance**
- Make Vault deposit flow the default answer for `predict-account + vault`.
- Keep Predict Account as trading identity.

**Step 2: Run docs tests to verify GREEN**
- `uv run pytest tests/test_docs_examples.py -q`

### Task 4: Final verification and release

**Step 1: Run full tests**
- `uv run pytest -q`

**Step 2: If publishing is requested, bump version and release**


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_EOA_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_EOA_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_EOA_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."""

    def to_dict(self) -> dict[str, object]:
        return {"message": str(self)}

    def format_lines(self) -> list[str]:
        return [str(self)]


class RouteConflictConfigError(ConfigError):
    def __init__(
        self,
        *,
        active_mode: str,
        active_route: str,
        recommended_mode: str,
        recommended_route: str,
        route_conflict_reason: str,
        detected_capabilities: dict[str, bool],
        next_step: str,
    ) -> None:
        self.error_code = "route-mode-conflict"
        self.active_mode = active_mode
        self.active_route = active_route
        self.recommended_mode = recommended_mode
        self.recommended_route = recommended_route
        self.route_conflict_reason = route_conflict_reason
        self.detected_capabilities = detected_capabilities
        self.next_step = next_step
        super().__init__(
            "Current active route is "
            f"{active_route} because PREDICT_WALLET_MODE={active_mode}, "
            "but Predict Account credentials are also configured. "
            f"If your goal is funding Predict Account, switch to PREDICT_WALLET_MODE={recommended_mode} "
            f"to use the {recommended_route} route."
        )

    def to_dict(self) -> dict[str, object]:
        return {
            "errorCode": self.error_code,
            "message": str(self),
            "activeMode": self.active_mode,
            "activeRoute": self.active_route,
            "recommendedMode": self.recommended_mode,
            "recommendedRoute": self.recommended_route,
            "routeConflictReason": self.route_conflict_reason,
            "detectedCapabilities": self.detected_capabilities,
            "nextStep": self.next_step,
        }

    def format_lines(self) -> list[str]:
        return [
            str(self),
            f"Active Mode: {self.active_mode}",
            f"Active Route: {self.active_route}",
            f"Recommended Mode: {self.recommended_mode}",
            f"Recommended Route: {self.recommended_route}",
            f"Reason: {self.route_conflict_reason}",
            f"Next Step: {self.next_step}",
        ]


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_FACTORY_ADDRESS_DEFAULT = [REDACTED_SECRET]
MANDATED_VAULT_NAME_DEFAULT = "PredictClaw Vault"
MANDATED_VAULT_SYMBOL_DEFAULT = "pCLAW"
MANDATED_VAULT_SALT_DEFAULT = (
    "0x515efd8b3fb262cd01675c249fcdf91ce513efff6fed6c1e97d2a6d7f526c7f9"
)
MANDATED_VAULT_ASSET_ADDRESS_BY_CHAIN_ID = {
    56: [REDACTED_SECRET],
    97: [REDACTED_SECRET],
}
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_BOOTSTRAP_PRIVATE_KEY",
    "ERC_MANDATED_ENABLE_BROADCAST",
    "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",
)
MANDATED_DERIVATION_CORE_ENV_NAMES = (
    "ERC_MANDATED_VAULT_ASSET_ADDRESS",
    "ERC_MANDATED_VAULT_NAME",
    "ERC_MANDATED_VAULT_SYMBOL",
    "ERC_MANDATED_VAULT_AUTHORITY",
    "ERC_MANDATED_VAULT_SALT",
)


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 _build_predict_account_overlay_route_conflict() -> RouteConflictConfigError:
    return RouteConflictConfigError(
        active_mode=WalletMode.MANDATED_VAULT.value,
        active_route="vault-control-plane",
        recommended_mode=WalletMode.PREDICT_ACCOUNT.value,
        recommended_route="vault-to-predict-account",
        route_conflict_reason=(
            "mandated-vault mode selects the pure vault control-plane path, "
            "while the configured Predict Account credentials indicate an overlay funding workflow"
        ),
        detected_capabilities={
            "predictAccountCredentials": True,
            "mandatedVaultConfig": True,
            "predictAccountOverlayCandidate": True,
        },
        next_step=(
            "Set PREDICT_WALLET_MODE=predict-account and keep the current ERC_MANDATED_* settings "
            "if your goal is topping up Predict Account."
        ),
    )


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


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

    env: RuntimeEnv = RuntimeEnv.MAINNET
    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_bootstrap_private_key: SecretStr | None = None
    mandated_enable_broadcast: bool | 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
    has_mandated_explicit_vault_input: bool = False
    has_any_mandated_derivation_input: bool = False
    has_all_mandated_derivation_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. "
                "Use test-fixture for secret-free verification, or set a non-mainnet "
                "environment explicitly if you intentionally need it."
            )

        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
        has_bootstrap_signer = has_eoa
        has_mandated_vault_address = self.has_mandated_explicit_vault_input
        has_any_mandated_derivation = self.has_any_mandated_derivation_input
        has_all_mandated_derivation = self.has_all_mandated_derivation_input

        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_EOA_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_EOA_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_predict_account_credentials:
            raise ValueError(
                "mandated-vault mode does not allow Predict Account credentials."
            )
        if has_any_mandated_derivation and not has_all_mandated_derivation:
            raise ValueError(
                "mandated-vault mode requires PREDICT_EOA_PRIVATE_KEY, ERC_MANDATED_VAULT_ADDRESS, or full derivation inputs."
            )
        if (
            not has_bootstrap_signer
            and not has_mandated_vault_address
            and not has_all_mandated_derivation
        ):
            raise ValueError(
                "mandated-vault mode requires PREDICT_EOA_PRIVATE_KEY, ERC_MANDATED_VAULT_ADDRESS, or full derivation 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:
        if self.mandated_authority_private_key is not None:
            return self.mandated_authority_private_key.get_secret_value()
        if self.wallet_mode == WalletMode.MANDATED_VAULT:
            return self.private_key_value
        return 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_bootstrap_private_key_value(self) -> str | None:
        if self.mandated_bootstrap_private_key is not None:
            return self.mandated_bootstrap_private_key.get_secret_value()
        if (
            self.wallet_mode == WalletMode.MANDATED_VAULT
            and self.private_key_value is not None
        ):
            return self.private_key_value
        return self.mandated_authority_private_key_value

    @property
    def mandated_bootstrap_signer_address(self) -> str | None:
        if self.mandated_bootstrap_private_key_value is not None:
            return Account.from_key(self.mandated_bootstrap_private_key_value).address
        return self.mandated_vault_authority

    @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.MAINNET.value))
        wallet_mode_override = _wallet_mode_or_none(source.get("PREDICT_WALLET_MODE"))
        private_key = _secret_or_none(source.get("PREDICT_EOA_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_chain_id = _int_or_none(source.get("ERC_MANDATED_CHAIN_ID"))
        mandated_vault_address = _value_or_none(
            source.get("ERC_MANDATED_VAULT_ADDRESS")
        )
        mandated_factory_address = (
            _value_or_none(source.get("ERC_MANDATED_FACTORY_ADDRESS"))
            or MANDATED_FACTORY_ADDRESS_DEFAULT
        )
        raw_mandated_vault_asset_address = _value_or_none(
            source.get("ERC_MANDATED_VAULT_ASSET_ADDRESS")
        )
        raw_mandated_vault_name = _value_or_none(source.get("ERC_MANDATED_VAULT_NAME"))
        raw_mandated_vault_symbol = _value_or_none(
            source.get("ERC_MANDATED_VAULT_SYMBOL")
        )
        raw_mandated_vault_authority = _value_or_none(
            source.get("ERC_MANDATED_VAULT_AUTHORITY")
        )
        raw_mandated_vault_salt = _value_or_none(source.get("ERC_MANDATED_VAULT_SALT"))

        mandated_vault_asset_address = raw_mandated_vault_asset_address
        mandated_vault_name = raw_mandated_vault_name
        mandated_vault_symbol = raw_mandated_vault_symbol
        mandated_vault_authority = raw_mandated_vault_authority
        mandated_vault_salt = raw_mandated_vault_salt
        has_mandated_config_input = any(
            _value_or_none(source.get(env_name)) is not None
            for env_name in MANDATED_INPUT_ENV_NAMES
        )

        if (
            wallet_mode_override == WalletMode.MANDATED_VAULT
            and predict_account_address is not None
            and privy_private_key is not None
            and has_mandated_config_input
        ):
            raise _build_predict_account_overlay_route_conflict()

        if (
            wallet_mode_override == WalletMode.MANDATED_VAULT
            and mandated_vault_address is None
        ):
            if mandated_vault_asset_address is None:
                mandated_vault_asset_address = _default_mandated_vault_asset_address(
                    runtime_env, mandated_chain_id
                )
            if mandated_vault_name is None:
                mandated_vault_name = MANDATED_VAULT_NAME_DEFAULT
            if mandated_vault_symbol is None:
                mandated_vault_symbol = MANDATED_VAULT_SYMBOL_DEFAULT
            if mandated_vault_salt is None:
                mandated_vault_salt = MANDATED_VAULT_SALT_DEFAULT
            if mandated_vault_authority is None and private_key is not None:
                mandated_vault_authority = Account.from_key(
                    private_key.get_secret_value()
                ).address

        try:
            return cls(
                env=runtime_env,
                storage_dir=Path(
                    source.get("PREDICT_STORAGE_DIR", "~/.openclaw/predict")
                ).expanduser(),
                wallet_mode_override=wallet_mode_override,
                [REDACTED_SECRET]"PREDICT_API_KEY")),
                private_key=private_key,
                predict_account_address=predict_account_address,
                privy_private_key=privy_private_key,
                mandated_vault_address=mandated_vault_address,
                mandated_factory_address=mandated_factory_address,
                mandated_vault_asset_address=mandated_vault_asset_address,
                mandated_vault_name=mandated_vault_name,
                mandated_vault_symbol=mandated_vault_symbol,
                mandated_vault_authority=mandated_vault_authority,
                mandated_vault_salt=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_bootstrap_private_key=_secret_or_none(
                    source.get("ERC_MANDATED_BOOTSTRAP_PRIVATE_KEY")
                ),
                mandated_enable_broadcast=_bool_or_none(
                    source.get("ERC_MANDATED_ENABLE_BROADCAST")
                ),
                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=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=has_mandated_config_input,
                has_mandated_explicit_vault_input=mandated_vault_address is not None,
                has_any_mandated_derivation_input=any(
                    _value_or_none(source.get(env_name)) is not None
                    for env_name in MANDATED_DERIVATION_CORE_ENV_NAMES
                ),
                has_all_mandated_derivation_input=all(
                    _value_or_none(source.get(env_name)) is not None
                    for env_name in MANDATED_DERIVATION_CORE_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_EOA_PRIVATE_KEY"),
                        source.get("PREDICT_PRIVY_PRIVATE_KEY"),
                        source.get("ERC_MANDATED_AUTHORITY_PRIVATE_KEY"),
                        source.get("ERC_MANDATED_EXECUTOR_PRIVATE_KEY"),
                        source.get("ERC_MANDATED_BOOTSTRAP_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 _bool_or_none(raw: str | None) -> bool | None:
    value = _value_or_none(raw)
    if value is None:
        return None
    normalized = value.lower()
    if normalized in {"1", "true", "yes", "on"}:
        return True
    if normalized in {"0", "false", "no", "off"}:
        return False
    raise ValueError(
        "ERC_MANDATED_ENABLE_BROADCAST must be one of: 1, 0, true, false, yes, no, on, off."
    )


def _default_mandated_vault_asset_address(
    runtime_env: RuntimeEnv, mandated_chain_id: int | None
) -> str:
    selected_chain_id = mandated_chain_id
    if selected_chain_id is None:
        selected_chain_id = int(
            ChainId.BNB_MAINNET
            if runtime_env == RuntimeEnv.MAINNET
            else ChainId.BNB_TESTNET
        )
    asset_address = MANDATED_VAULT_ASSET_ADDRESS_BY_CHAIN_ID.get(selected_chain_id)
    if asset_address is None:
        raise ValueError(
            "No default ERC_MANDATED_VAULT_ASSET_ADDRESS is configured for the selected chain."
        )
    return asset_address


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
import os
import re
from dataclasses import dataclass
from datetime import UTC, datetime
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 .session_storage import FundAndActionSessionRecord, SessionStorage
from .wallet_manager import (
    FixtureWalletSdk,
    MandatedVaultBridgeProtocol,
    PredictSdkWallet,
    VaultPermissionSummary,
    _overlay_address_guidance_payload,
    _build_mandated_permission_summary,
    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
RPC_ENV_CANDIDATES = {
    56: ("BSC_MAINNET_RPC_URL", "BSC_RPC_URL", "ERC_MANDATED_RPC_URL"),
    97: ("BSC_TESTNET_RPC_URL", "BSC_RPC_URL", "ERC_MANDATED_RPC_URL"),
}
PUBLIC_RPC_FALLBACKS = {
    56: "https://bsc-dataseed.bnbchain.org/",
    97: "https://data-seed-prebsc-1-s1.bnbchain.org:8545/",
}
CUSTOM_ERROR_DATA_RE = re.compile(r"0x[a-fA-F0-9]{8,}")


def _utc_timestamp() -> str:
    return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def _model_dump(value: Any) -> dict[str, Any]:
    if hasattr(value, "model_dump"):
        return cast(dict[str, Any], value.model_dump(by_alias=True))
    return cast(dict[str, Any], value)


ERC20_METADATA_ABI = [
    {
        "stateMutability": "view",
        "type": "function",
        "name": "name",
        "inputs": [],
        "outputs": [{"name": "", "type": "string"}],
    },
    {
        "stateMutability": "view",
        "type": "function",
        "name": "symbol",
        "inputs": [],
        "outputs": [{"name": "", "type": "string"}],
    },
    {
        "stateMutability": "view",
        "type": "function",
        "name": "decimals",
        "inputs": [],
        "outputs": [{"name": "", "type": "uint8"}],
    },
    {
        "stateMutability": "view",
        "type": "function",
        "name": "balanceOf",
        "inputs": [{"name": "owner", "type": "address"}],
        "outputs": [{"name": "", "type": "uint256"}],
    },
]

ERC4626_PREVIEW_ABI = ERC20_METADATA_ABI + [
    {
        "stateMutability": "view",
        "type": "function",
        "name": "asset",
        "inputs": [],
        "outputs": [{"name": "", "type": "address"}],
    },
    {
        "stateMutability": "view",
        "type": "function",
        "name": "totalAssets",
        "inputs": [],
        "outputs": [{"name": "", "type": "uint256"}],
    },
    {
        "stateMutability": "view",
        "type": "function",
        "name": "maxRedeem",
        "inputs": [{"name": "owner", "type": "address"}],
        "outputs": [{"name": "", "type": "uint256"}],
    },
    {
        "stateMutability": "view",
        "type": "function",
        "name": "maxWithdraw",
        "inputs": [{"name": "owner", "type": "address"}],
        "outputs": [{"name": "", "type": "uint256"}],
    },
    {
        "stateMutability": "view",
        "type": "function",
        "name": "previewRedeem",
        "inputs": [{"name": "shares", "type": "uint256"}],
        "outputs": [{"name": "", "type": "uint256"}],
    },
    {
        "stateMutability": "nonpayable",
        "type": "function",
        "name": "redeem",
        "inputs": [
            {"name": "shares", "type": "uint256"},
            {"name": "receiver", "type": "address"},
            {"name": "owner", "type": "address"},
        ],
        "outputs": [{"name": "", "type": "uint256"}],
    },
]


@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
    active_route: str | None = None
    route_purpose: str | None = None
    vault_address_source: str | None = None
    vault_exists: bool | None = None
    create_vault_preparation: dict[str, object] | None = None
    bootstrap_preview: dict[str, object] | None = None
    permission_summary: dict[str, object] | None = None
    session_scope: str | None = None
    session_binding: 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.active_route is not None:
            payload["activeRoute"] = self.active_route
        if self.route_purpose is not None:
            payload["routePurpose"] = self.route_purpose
        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.bootstrap_preview is not None:
            payload["bootstrapPreview"] = self.bootstrap_preview
        if self.permission_summary is not None:
            payload["permissionSummary"] = self.permission_summary
        if self.session_scope is not None:
            payload["sessionScope"] = self.session_scope
        if self.session_binding is not None:
            payload["sessionBinding"] = self.session_binding
        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
            payload.update(
                _overlay_address_guidance_payload(
                    predict_account_address=self.predict_account_address,
                    vault_address=self.vault_address,
                )
            )
        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,
        }


@dataclass
class VaultRedeemPreview:
    chain_id: int
    chain: str
    share_token: str
    holder: str
    share_token_name: str | None
    share_token_symbol: str | None
    share_token_decimals: int | None
    underlying_asset: str | None
    underlying_symbol: str | None
    underlying_decimals: int | None
    share_balance_wei: int
    requested_shares_wei: int
    total_assets_wei: int | None
    preview_redeem_wei: int | None
    max_redeem_wei: int | None
    max_withdraw_wei: int | None
    redeemable_now: bool
    blocking_reason: str | None
    contract_error: dict[str, object] | None
    recommended_next_action: str
    configured_roles: dict[str, str | None]
    holder_role_matches: dict[str, bool]

    def to_dict(self) -> dict[str, object]:
        payload: dict[str, object] = {
            "chainId": self.chain_id,
            "chain": self.chain,
            "shareToken": self.share_token,
            "holder": self.holder,
            "shareBalanceWei": self.share_balance_wei,
            "requestedSharesWei": self.requested_shares_wei,
            "redeemableNow": self.redeemable_now,
            "recommendedNextAction": self.recommended_next_action,
            "configuredRoles": self.configured_roles,
            "holderRoleMatches": self.holder_role_matches,
            "shareTokenMetadata": {
                "name": self.share_token_name,
                "symbol": self.share_token_symbol,
                "decimals": self.share_token_decimals,
            },
        }
        if self.underlying_asset is not None:
            payload["underlyingAsset"] = {
                "address": self.underlying_asset,
                "symbol": self.underlying_symbol,
                "decimals": self.underlying_decimals,
            }
        if self.total_assets_wei is not None:
            payload["totalAssetsWei"] = self.total_assets_wei
        if self.preview_redeem_wei is not None:
            payload["previewRedeemWei"] = self.preview_redeem_wei
        if self.max_redeem_wei is not None:
            payload["maxRedeemWei"] = self.max_redeem_wei
        if self.max_withdraw_wei is not None:
            payload["maxWithdrawWei"] = self.max_withdraw_wei
        if self.blocking_reason is not None:
            payload["blockingReason"] = self.blocking_reason
        if self.contract_error is not None:
            payload["contractError"] = self.contract_error
        return payload


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 continue_funding(
        self,
        *,
        tx_hash: str,
        confirmations: int = 1,
        block_number: str | None = None,
        block_hash: str | None = None,
    ) -> dict[str, object]:
        record = self._require_active_session()
        task = cast(dict[str, Any], record.funding_next_step.get("task", {}))
        funding_plan = cast(
            dict[str, Any] | None,
            task.get("fundingPlan")
            or record.funding_plan
            or cast(
                dict[str, Any], record.funding_session.get("fundAndActionPlan", {})
            ).get("fundingPlan"),
        )
        if task.get("kind") not in {"submitFunding", "pollFundingResult"}:
            raise ConfigError(
                "Active session is not waiting for a funding continuation step."
            )
        if not isinstance(funding_plan, dict):
            raise ConfigError(
                "Active session did not expose a funding plan for continuation."
            )

        bridge = self._bridge_factory(self._config)

        async def _run_and_close() -> dict[str, object]:
            await bridge.connect()
            try:
                updated_at = _utc_timestamp()
                transfer = await bridge.create_vault_asset_transfer_result(
                    asset_transfer_plan=funding_plan,
                    status="confirmed",
                    updated_at=updated_at,
                    submitted_at=updated_at,
                    completed_at=updated_at,
                    attempt=1,
                    chain_id=int(self._config.chain_id),
                    tx_hash=tx_hash,
                    receipt={
                        "blockNumber": block_number,
                        "blockHash": block_hash,
                        "confirmations": confirmations,
                    },
                    output={"status": "ok"},
                )
                transfer_payload = _model_dump(transfer.assetTransferResult)
                applied = await bridge.apply_agent_fund_and_action_session_event(
                    session=record.funding_session,
                    event={
                        "type": "fundingConfirmed",
                        "updatedAt": updated_at,
                        "assetTransferResult": transfer_payload,
                    },
                )
                session_payload = _model_dump(applied.session)
                next_step = await bridge.next_agent_fund_and_action_session_step(
                    session=session_payload
                )
                next_step_payload = _model_dump(next_step)
                record.funding_session = session_payload
                record.funding_next_step = next_step_payload
                record.updated_at = updated_at
                SessionStorage(self._config.storage_dir).upsert(record)
                return {
                    "session": session_payload,
                    "assetTransferResult": transfer_payload,
                    "nextStep": next_step_payload,
                    "sessionBinding": record.binding_payload(),
                }
            finally:
                await bridge.close()

        return asyncio.run(_run_and_close())

    def continue_follow_up(
        self,
        *,
        reference_type: str,
        reference_value: str,
        status: str = "succeeded",
        output: dict[str, Any] | None = None,
    ) -> dict[str, object]:
        record = self._require_active_session()
        task = cast(dict[str, Any], record.funding_next_step.get("task", {}))
        follow_up_action_plan = cast(
            dict[str, Any] | None,
            task.get("followUpActionPlan")
            or cast(
                dict[str, Any], record.funding_session.get("fundAndActionPlan", {})
            ).get("followUpActionPlan"),
        )
        if task.get("kind") not in {"submitFollowUp", "pollFollowUpResult"}:
            raise ConfigError(
                "Active session is not waiting for a follow-up continuation step."
            )
        if not isinstance(follow_up_action_plan, dict):
            raise ConfigError(
                "Active session did not expose a follow-up action plan for continuation."
            )

        bridge = self._bridge_factory(self._config)

        async def _run_and_close() -> dict[str, object]:
            await bridge.connect()
            try:
                updated_at = _utc_timestamp()
                follow_up_result = await bridge.create_agent_follow_up_action_result(
                    follow_up_action_plan=follow_up_action_plan,
                    status=status,
                    updated_at=updated_at,
                    completed_at=updated_at,
                    attempt=1,
                    reference={"type": reference_type, "value": reference_value},
                    output=output,
                )
                follow_up_payload = _model_dump(follow_up_result.followUpActionResult)
                applied = await bridge.apply_agent_fund_and_action_session_event(
                    session=record.funding_session,
                    event={
                        "type": "followUpResultReceived",
                        "followUpActionResult": follow_up_payload,
                    },
                )
                session_payload = _model_dump(applied.session)
                record.funding_session = session_payload
                record.funding_next_step = {"task": {"kind": "completed"}}
                record.updated_at = updated_at
                SessionStorage(self._config.storage_dir).upsert(record)
                return {
                    "session": session_payload,
                    "followUpActionResult": follow_up_payload,
                    "sessionBinding": record.binding_payload(),
                }
            finally:
                await bridge.close()

        return asyncio.run(_run_and_close())

    def _require_active_session(self) -> FundAndActionSessionRecord:
        predict_account_address = self._config.predict_account_address
        record = SessionStorage(self._config.storage_dir).get_active_session(
            predict_account_address=predict_account_address
        )
        if record is None:
            raise ConfigError("No active overlay funding session is available.")
        return record

    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(
                    cast(WalletSdkProtocol, sdk)
                )
                usdt_balance = await load_wallet_usdt_balance_wei(
                    cast(WalletSdkProtocol, 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,
                )
                resolved_authority = cast(
                    str | None,
                    orchestration.account_context.get("authority"),
                )
                resolved_asset = cast(
                    str | None,
                    orchestration.funding_target.get("tokenAddress"),
                )
                session_record = SessionStorage(
                    self._config.storage_dir
                ).get_active_session(predict_account_address=sdk.funding_address)
                orchestration_payload = orchestration.to_dict()
                session_scope: str | None = None
                session_binding: dict[str, object] | None = None
                if session_record is not None:
                    session_scope = session_record.session_scope
                    session_binding = session_record.binding_payload()
                    orchestration_payload["fundingPlan"] = session_record.funding_plan
                    orchestration_payload["fundingSession"] = (
                        session_record.funding_session
                    )
                    orchestration_payload["fundingNextStep"] = (
                        session_record.funding_next_step
                    )
                return DepositDetails(
                    mode=str(getattr(sdk.mode, "value", sdk.mode)),
                    funding_address=orchestration.vault_address,
                    signer_address=sdk.signer_address,
                    chain=sdk.chain_name,
                    accepted_assets=["BNB", "USDT"],
                    bnb_balance_wei=bnb_balance,
                    usdt_balance_wei=usdt_balance,
                    active_route="vault-to-predict-account",
                    route_purpose="predict-account-top-up-and-trading",
                    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_payload,
                    permission_summary=_build_mandated_permission_summary(
                        self._config,
                        permission_model="vault-to-predict-account-overlay",
                        vault_authority=resolved_authority,
                        underlying_asset=resolved_asset,
                        allowed_token_addresses=cast(
                            list[str] | None,
                            orchestration.funding_policy.get("allowedTokenAddresses"),
                        ),
                        allowed_recipients=cast(
                            list[str] | None,
                            orchestration.funding_policy.get("allowedRecipients"),
                        ),
                        max_amount_per_tx=cast(
                            str | None,
                            orchestration.funding_policy.get("maxAmountPerTx"),
                        ),
                        max_amount_per_window=cast(
                            str | None,
                            orchestration.funding_policy.get("maxAmountPerWindow"),
                        ),
                        window_seconds=cast(
                            int | None,
                            orchestration.funding_policy.get("windowSeconds"),
                        ),
                    ).to_dict(),
                    session_scope=session_scope,
                    session_binding=session_binding,
                )
            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
                    and resolution.bootstrap_preview is 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",
                    }
                bootstrap_preview = (
                    resolution.bootstrap_preview.to_dict()
                    if resolution.bootstrap_preview is not None
                    else None
                )

                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,
                    active_route="vault-control-plane",
                    route_purpose="bootstrap-or-direct-vault-ops",
                    vault_address_source=resolution.vault_address_source,
                    vault_exists=resolution.vault_deployed,
                    create_vault_preparation=preparation,
                    bootstrap_preview=bootstrap_preview,
                    permission_summary=_build_mandated_permission_summary(
                        self._config,
                        permission_model="mandated-vault-v1",
                    ).to_dict(),
                )
            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 preview_vault_redeem(
        self,
        *,
        share_token: str,
        holder: str | None = None,
        redeem_all: bool = False,
    ) -> VaultRedeemPreview:
        checksum_share_token = Web3.to_checksum_address(share_token)
        resolved_holder = _resolve_redeem_holder(self._config, holder)
        checksum_holder = Web3.to_checksum_address(resolved_holder)
        chain_id = self._config.mandated_chain_id or int(self._config.chain_id)
        chain_name = "BNB Mainnet" if chain_id == 56 else "BNB Testnet"
        rpc_url = _resolve_preview_rpc_url(chain_id)
        web3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 20}))
        share_contract = web3.eth.contract(
            address=checksum_share_token, abi=ERC4626_PREVIEW_ABI
        )

        share_token_name = cast(
            str | None, _safe_contract_call(share_contract.functions.name)
        )
        share_token_symbol = cast(
            str | None, _safe_contract_call(share_contract.functions.symbol)
        )
        share_token_decimals = cast(
            int | None, _safe_contract_call(share_contract.functions.decimals)
        )
        share_balance_wei = int(
            cast(int, share_contract.functions.balanceOf(checksum_holder).call())
        )
        underlying_asset = cast(
            str | None, _safe_contract_call(share_contract.functions.asset)
        )
        total_assets_wei = cast(
            int | None, _safe_contract_call(share_contract.functions.totalAssets)
        )
        max_redeem_wei = cast(
            int | None,
            _safe_contract_call(share_contract.functions.maxRedeem, checksum_holder),
        )
        max_withdraw_wei = cast(
            int | None,
            _safe_contract_call(share_contract.functions.maxWithdraw, checksum_holder),
        )
        requested_shares_wei = (
            share_balance_wei
            if redeem_all or max_redeem_wei is None
            else max_redeem_wei
        )
        preview_redeem_wei: int | None = None
        if requested_shares_wei > 0:
            preview_redeem_wei = cast(
                int | None,
                _safe_contract_call(
                    share_contract.functions.previewRedeem, requested_shares_wei
                ),
            )
        contract_error = None
        if requested_shares_wei > 0:
            contract_error = _simulate_redeem_call(
                web3=web3,
                share_contract=share_contract,
                holder=checksum_holder,
                requested_shares_wei=requested_shares_wei,
            )

        underlying_symbol = None
        underlying_decimals = None
        if underlying_asset is not None:
            underlying_contract = web3.eth.contract(
                address=Web3.to_checksum_address(underlying_asset),
                abi=ERC20_METADATA_ABI,
            )
            underlying_symbol = cast(
                str | None, _safe_contract_call(underlying_contract.functions.symbol)
            )
            underlying_decimals = cast(
                int | None, _safe_contract_call(underlying_contract.functions.decimals)
            )

        blocking_reason = _resolve_redeem_blocking_reason(
            share_balance_wei=share_balance_wei,
            requested_shares_wei=requested_shares_wei,
            max_redeem_wei=max_redeem_wei,
            contract_error=contract_error,
        )
        redeemable_now = blocking_reason is None
        recommended_next_action = _build_redeem_next_action(
            redeemable_now=redeemable_now,
            contract_error=contract_error,
        )
        configured_roles = {
            "vaultAuthority": self._config.mandated_vault_authority,
            "vaultExecutor": self._config.mandated_executor_address,
            "bootstrapSigner": self._config.mandated_bootstrap_signer_address,
        }
        holder_role_matches = {
            "vaultAuthority": checksum_holder.lower()
            == (self._config.mandated_vault_authority or "").lower(),
            "vaultExecutor": checksum_holder.lower()
            == (self._config.mandated_executor_address or "").lower(),
            "bootstrapSigner": checksum_holder.lower()
            == (self._config.mandated_bootstrap_signer_address or "").lower(),
        }
        return VaultRedeemPreview(
            chain_id=chain_id,
            chain=chain_name,
            share_token=checksum_share_token,
            holder=checksum_holder,
            share_token_name=share_token_name,
            share_token_symbol=share_token_symbol,
            share_token_decimals=share_token_decimals,
            underlying_asset=underlying_asset,
            underlying_symbol=underlying_symbol,
            underlying_decimals=underlying_decimals,
            share_balance_wei=share_balance_wei,
            requested_shares_wei=requested_shares_wei,
            total_assets_wei=total_assets_wei,
            preview_redeem_wei=preview_redeem_wei,
            max_redeem_wei=max_redeem_wei,
            max_withdraw_wei=max_withdraw_wei,
            redeemable_now=redeemable_now,
            blocking_reason=blocking_reason,
            contract_error=contract_error,
            recommended_next_action=recommended_next_action,
            configured_roles=configured_roles,
            holder_role_matches=holder_role_matches,
        )

    def _require_sdk(self) -> TransferCapableWallet:
        if self._config.auth_signer_address is None:
            raise ConfigError(
                "Wallet actions require signer configuration. Set PREDICT_EOA_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 _resolve_preview_rpc_url(chain_id: int) -> str:
    for env_name in RPC_ENV_CANDIDATES.get(chain_id, ("ERC_MANDATED_RPC_URL",)):
        value = os.getenv(env_name)
        if value:
            return value
    fallback = PUBLIC_RPC_FALLBACKS.get(chain_id)
    if fallback is None:
        raise ConfigError(f"No RPC URL configured for chainId {chain_id}.")
    return fallback


def _resolve_redeem_holder(config: PredictConfig, holder: str | None) -> str:
    if holder is not None:
        return holder
    for candidate in (
        config.mandated_executor_address,
        config.mandated_bootstrap_signer_address,
        config.auth_signer_address,
    ):
        if candidate:
            return candidate
    raise ConfigError(
        "Redeem preview requires --holder or a configured executor/bootstrap/auth signer address."
    )


def _safe_contract_call(function_factory: Any, *args: Any) -> Any:
    try:
        if args:
            return function_factory(*args).call()
        return function_factory().call()
    except Exception:
        return None


def _extract_custom_error_data(error: Exception) -> str | None:
    matches = CUSTOM_ERROR_DATA_RE.findall(str(error))
    for match in matches:
        if len(match) >= 10:
            return match
    return None


def _decode_custom_error(error_data: str | None) -> dict[str, object] | None:
    if not error_data or len(error_data) < 10:
        return None
    selector = error_data[2:10].lower()
    body = bytes.fromhex(error_data[10:])
    if selector == "b94abeec":
        payload: dict[str, object] = {"code": "ERC4626ExceededMaxRedeem"}
        if len(body) >= 96:
            owner = "0x" + body[12:32].hex()
            shares = int.from_bytes(body[32:64], "big")
            max_redeem = int.from_bytes(body[64:96], "big")
            payload.update(
                {
                    "owner": Web3.to_checksum_address(owner),
                    "requestedSharesWei": shares,
                    "maxRedeemWei": max_redeem,
                }
            )
        return payload
    if selector == "fe9cceec":
        payload = {"code": "ERC4626ExceededMaxWithdraw"}
        if len(body) >= 96:
            owner = "0x" + body[12:32].hex()
            assets = int.from_bytes(body[32:64], "big")
            max_withdraw = int.from_bytes(body[64:96], "big")
            payload.update(
                {
                    "owner": Web3.to_checksum_address(owner),
                    "requestedAssetsWei": assets,
                    "maxWithdrawWei": max_withdraw,
                }
            )
        return payload
    return {"code": "UNKNOWN_CUSTOM_ERROR", "data": error_data}


def _simulate_redeem_call(
    *,
    web3: Web3,
    share_contract: Any,
    holder: str,
    requested_shares_wei: int,
) -> dict[str, object] | None:
    if requested_shares_wei <= 0:
        return None
    try:
        share_contract.functions.redeem(
            requested_shares_wei,
            holder,
            holder,
        ).call({"from": holder})
        return None
    except Exception as error:
        decoded = _decode_custom_error(
            _extract_custom_error_data(cast(Exception, error))
        )
        if decoded is not None:
            return decoded
        return {
            "code": type(error).__name__,
            "message": str(error),
        }


def _resolve_redeem_blocking_reason(
    *,
    share_balance_wei: int,
    requested_shares_wei: int,
    max_redeem_wei: int | None,
    contract_error: dict[str, object] | None,
) -> str | None:
    if share_balance_wei <= 0:
        return "no-shares"
    if requested_shares_wei <= 0:
        return "no-requested-shares"
    if contract_error is not None:
        code = contract_error.get("code")
        if code == "ERC4626ExceededMaxRedeem":
            return "erc4626-max-redeem-blocked"
        if code == "ERC4626ExceededMaxWithdraw":
            return "erc4626-max-withdraw-blocked"
        return "redeem-call-blocked"
    if max_redeem_wei is not None and requested_shares_wei > max_redeem_wei:
        return "requested-shares-exceed-max-redeem"
    return None


def _build_redeem_next_action(
    *, redeemable_now: bool, contract_error: dict[str, object] | None
) -> str:
    if redeemable_now:
        return "Redeem preview succeeded; a future confirm path can use this amount if execution support is added."
    code = contract_error.get("code") if contract_error is not None else None
    if code == "ERC4626ExceededMaxRedeem":
        return "Vault shares are currently not redeemable through a direct full redeem call. Check cooldown, queue, unlock, or vault-specific withdrawal rules before attempting a real transaction."
    if code == "ERC4626ExceededMaxWithdraw":
        return "Vault withdrawals are currently capped below the requested amount. Inspect vault-specific withdrawal limits before attempting a real transaction."
    return "Redeem preview is blocked. Inspect the contract error and vault rules before attempting any real redeem transaction."


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/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/local_env.py
content: from __future__ import annotations

import os
from pathlib import Path

from dotenv import dotenv_values


def resolve_local_env_path(base_dir: Path) -> Path:
    override = os.getenv("PREDICTCLAW_ENV_FILE")
    if override:
        return Path(override).expanduser()
    return base_dir / ".env"


def load_local_env(base_dir: Path) -> Path:
    env_path = resolve_local_env_path(base_dir)
    if not env_path.exists():
        return env_path

    for key, value in dotenv_values(env_path).items():
        if value is None:
            continue
        os.environ.setdefault(key, value)

    return env_path


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

from dataclasses import dataclass

from predict_sdk.types import Book

from .config import ConfigError
from .models import MarketRecord, OrderBookRecord, OutcomeRecord


@dataclass
class ResolvedOutcome:
    label: str
    token_id: str


def orderbook_record_to_sdk_book(orderbook: OrderBookRecord) -> Book:
    market_id = int(orderbook.marketId) if orderbook.marketId is not None else 0
    asks = [(float(level[0]), float(level[1])) for level in (orderbook.asks or [])]
    bids = [(float(level[0]), float(level[1])) for level in (orderbook.bids or [])]
    return Book(
        market_id=market_id,
        update_timestamp_ms=orderbook.updateTimestampMs or 0,
        asks=asks,
        bids=bids,
    )


def resolve_outcome(market: MarketRecord, label: str) -> ResolvedOutcome:
    normalized = label.upper()
    for outcome in market.outcomes or []:
        if _matches_outcome(outcome, normalized):
            token_id = outcome.tokenId or outcome.onChainId
            if not token_id:
                raise ConfigError(
                    f"Outcome {label} is missing a token id in market {market.id}."
                )
            return ResolvedOutcome(label=normalized, token_id=token_id)
    raise ConfigError(f"Outcome {label} is not available for market {market.id}.")


def _matches_outcome(outcome: OutcomeRecord, normalized_label: str) -> bool:
    return (outcome.name or "").upper() == normalized_label


path: scripts/__init__.py
content: """PredictClaw CLI scripts package."""


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