Solver Guide
End-to-end guide to writing solvers against the Solving Engine — IntentSolver ABC, data types, screening, benchmarking, and the JSON-over-stdio protocol.
Miners on Minotaur compete by writing the best Solving Engine — code that generates optimal execution plans for App Intents. The Solving Engine is a single engine that handles all Apps across the entire network. Validators run the winning solver in sandboxed Docker containers, benchmark it against active intents, and adopt the highest-scoring version.
The competition surface is the IntentSolver abstract base class. Miners extend it, package their code in a Docker image, and submit it. Validators screen the submission (3 stages), benchmark it against the current champion, and adopt it if it scores better.
IntentSolver ABC
Module · minotaur_subnet.sdk.intent_solverThe IntentSolver is the core competition surface. Miners extend this class to build solving strategies.
from minotaur_subnet.sdk.intent_solver import IntentSolver, MarketSnapshot, SolverMetadata
from minotaur_subnet.shared.types import AppIntentDefinition, ExecutionPlan, IntentState
class MySolver(IntentSolver):
def initialize(self, config):
self.rpc_urls = config.get("rpc_urls", {})
def generate_plan(self, intent, state, snapshot=None):
# Build an execution plan for this intent
...
def metadata(self):
return SolverMetadata(
name="my-solver",
version="1.0.0",
author="5Grwva...",
supported_intent_types=["swap"],
)
# Required: tells the harness which class to instantiate
SOLVER_CLASS = MySolver Lifecycle
The validator runs your solver through this lifecycle for each benchmark round:
-
initialize(config)
Called once when the solver is loaded. The
configdict containschain_ids,rpc_urls(per-chain RPC URL map),timeout_per_plan_ms(default 30000), andsupported_protocols. -
restore_state(data)
Called if serialized state from a prior epoch exists.
-
on_benchmark_start(intent_count)
Before the benchmark batch begins. Use this for pre-computing shared data structures or warming caches.
-
generate_plan(intent, state, snapshot)
Called per intent. This is the core competition surface.
-
check_trigger(intent, state, snapshot)
Called for auto-triggered intents to check if conditions are met.
-
on_benchmark_end(results)
After the batch completes. Receives a list of
{intent_id, score, elapsed_ms}dicts. -
serialize_state()
Persist learned state for the next epoch (max 50MB).
Core Methods
initialize(config: dict) -> None Required
One-time setup. Store RPC URLs, build routing tables, load ML models.
def initialize(self, config):
self.rpc_urls = config.get("rpc_urls", {})
self.chain_ids = config.get("chain_ids", [1])
# Create Web3 instances, build routing tables, etc. Any exception during initialize() causes the solver to fail screening (Stage 2).
generate_plan(intent, state, snapshot) -> ExecutionPlan Required
Generate an execution plan for the given intent. Prefer querying on-chain state via RPC URLs from initialize(). Fall back to snapshot data when RPC is unavailable.
def generate_plan(self, intent, state, snapshot=None):
if getattr(state, "typed_context", None) is not None:
params = state.typed_context.raw_params
else:
params = state.raw_params
# Query live pool states via RPC
pools = self.query_pools(state.chain_id)
route = self.find_best_route(pools, params)
return ExecutionPlan(
intent_id=intent.app_id,
interactions=route.to_interactions(),
deadline=int(time.time()) + 300,
nonce=state.nonce,
) Any exception results in a score of 0.0 for this intent. The process is killed if execution exceeds the per-plan timeout (30s default).
quote(intent, state, snapshot) -> QuoteResult Optional
Compute a quote without generating a full execution plan. Override for fast quoting support.
check_trigger(intent, state, snapshot) -> bool Optional
For auto-triggered (perpetual) intents: return True when conditions are met and the intent should execute. Default returns False.
metadata() -> SolverMetadata Required
Return solver identification and capabilities. Used for logging, benchmarking reports, and miner attribution.
serialize_state() / restore_state(data) Optional
Persist and restore learned state across epochs. Use this for ML model weights, routing tables, or parameter tuning data.
Data Types
ExecutionPlan
Module · minotaur_subnet.shared.typesThe output of generate_plan(). Defines the exact on-chain calls to execute.
@dataclass
class ExecutionPlan:
intent_id: str # Which intent this plan fulfills (app_id)
interactions: list[Interaction] # Ordered calls to execute
deadline: int # Unix timestamp — plan expires after this
nonce: int # Replay protection
metadata: dict[str, Any] = {} # App-specific data (plan_type, route info, etc.) Interaction
A single on-chain call in an execution plan.
@dataclass
class Interaction:
target: str # Contract address (0x-prefixed, 42 chars)
value: str # Wei value as decimal string ("0" for no ETH)
call_data: str # ABI-encoded calldata (0x-prefixed hex)
chain_id: int # Target chain (default: 1) MarketSnapshot
Point-in-time market data for plan generation. Used primarily for benchmarking and as a fallback when RPC access is unavailable. Production solvers should prefer querying on-chain state directly via RPC URLs provided in initialize().
@dataclass
class MarketSnapshot:
chain_id: int # Target chain ID
block_number: int # Block at which this snapshot was taken
timestamp: int # Unix timestamp of the snapshot block
prices: dict[str, float] = {} # Token price feeds
pool_states: dict[str, dict] = {} # DEX pool states keyed by pool address
balances: dict[str, str] = {} # Token balances keyed by token address
dex_config: dict[str, Any] = {} # DEX router/factory addresses
raw_state: dict[str, Any] = {} # Additional contract storage Pool states contain protocol-specific data:
- Uniswap V3:
token0,token1,fee,sqrtPriceX96,liquidity - Uniswap V2:
token0,token1,reserve0,reserve1
IntentState
Current on-chain state of an App Intent contract.
@dataclass
class IntentState:
contract_address: str
chain_id: int
nonce: int
owner: str
raw_params: dict[str, Any] = {} # Canonical raw app/runtime params
control: dict[str, Any] = {} # Runtime control metadata
extra: dict[str, Any] = {} # Derived compatibility payload
typed_context: Any | None = None # Preferred typed runtime view Prefer typed_context when present — it exposes manifest-driven typed fields like SwapIntentContext, TwapIntentContext, and RebalanceIntentContext. New solver code should read untyped values from raw_params and runtime metadata such as _intent_function from control.
SolverMetadata
@dataclass
class SolverMetadata:
name: str # Human-readable name
version: str # Semantic version (e.g., "2.1.0")
author: str # Miner hotkey or identifier
description: str = ""
supported_chains: list[int] = [1]
supported_intent_types: list[str] = ["swap"] Strategy ABC
Module · minotaur_subnet.sdk.strategyA Strategy is a lightweight, app-specific plan generator. Unlike IntentSolver (which handles lifecycle, serialization, benchmarking), Strategy focuses on one thing: generating plans for a specific app.
from minotaur_subnet.sdk.strategy import Strategy
class MyVaultStrategy(Strategy):
APP_ID = "vault-abc123"
INTENT_FUNCTIONS = ["buyDip"] # Empty list = handle all functions
def generate_plan(self, intent, state, snapshot=None):
return ExecutionPlan(
intent_id=intent.app_id,
interactions=[...],
deadline=int(time.time()) + 300,
nonce=state.nonce,
)
def check_trigger(self, intent, state, snapshot=None):
# For perpetual orders: check if conditions are met
return self.should_buy(state)
STRATEGY_CLASS = MyVaultStrategy Key attributes:
APP_ID— The app_id this strategy handles. Must be set.INTENT_FUNCTIONS— List of intent function names. Empty list means handle all functions.accepts(app_id, intent_function)— Checks if this strategy handles the given app/function.
RoutingSolver
Module · minotaur_subnet.sdk.routing_solverThe RoutingSolver is an IntentSolver that dispatches generate_plan() calls to registered Strategy instances based on app_id. This is the recommended pattern for solvers that handle multiple apps.
from minotaur_subnet.sdk.routing_solver import RoutingSolver
solver = RoutingSolver()
solver.register_strategy(MySwapStrategy())
solver.register_strategy(MyVaultStrategy())
solver.initialize({"chain_ids": [1], "rpc_urls": {1: "http://localhost:8545"}})
# Dispatches to MySwapStrategy or MyVaultStrategy based on intent.app_id
plan = solver.generate_plan(intent, state, snapshot) When no strategy matches, the RoutingSolver generates a minimal fallback plan (WETH deposit). This plan scores low but passes structural validation, ensuring the solver never crashes on unknown apps.
The RoutingSolver class is exported as SOLVER_CLASS in the module, making it the default submission target.
Reference Solver + Helpers
This SDK ships only the abstract interfaces — IntentSolver, IntentProcessor, Strategy, RoutingSolver — plus the data types (ExecutionPlan, Interaction, etc.) and a thin selectors-only abi_utils shim.
The reference DEX-aggregator solver, the Uniswap V3 / Aerodrome routing math, the V3 calldata encoders, and the per-app strategy modules all live in a separate repository that miners fork:
subnet112/minotaur-solverKey directories there:
| Path | What’s in it |
|---|---|
solver.py | The MinerSolver entry that exports SOLVER_CLASS. Fork target. |
common/abi_utils.py | encode_approve (generic ERC-20). |
common/parsing.py | App-agnostic input normalisation. |
strategies/dex_aggregator/baseline_solver.py | The reference DEX baseline miners are trying to beat — RPC-first pool discovery, V3 math, multi-hop routing across Uniswap V3 + Aerodrome Slipstream on Base. |
strategies/dex_aggregator/aerodrome.py | Aerodrome Slipstream pool discovery + calldata. |
strategies/dex_aggregator/pool_math.py | Uniswap V3 single-tick math + best-pool / best-route finder. |
strategies/dex_aggregator/swap_solver.py | Single-hop V3 plan emitter. |
strategies/dex_aggregator/v3_codec.py | Uniswap V3 SwapRouter calldata encoders (V1/V2 auto-select, multi-hop path). |
strategies/dex_aggregator/uniswap_v3.py, token_math.py | Per-strategy helpers. |
strategies/<other_app>/ | Miner-defined per-app strategy modules. |
The split is intentional — miners own and improve everything in strategies/, while the SDK in this repo just ships the contracts (ABCs + types) the validator harness needs in order to load and run any solver.
pool_math, v3_codec, BaselineSwapSolver, VaultDipSolver, and the rich abi_utils helpers (encode_exact_input_single, encode_exact_input, encode_swap_path) are not in minotaur_subnet.sdk — importing them from there will raise ModuleNotFoundError. Pull them from the subnet112/minotaur-solver repo above.
Example — building a single-hop swap interaction using the solver-repo helpers:
from common.abi_utils import encode_approve
from strategies.dex_aggregator.v3_codec import encode_exact_input_single
from minotaur_subnet.shared.types import Interaction
USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
ROUTER = "0xE592427A0AEce92De3Edee1F18E0157C05861564"
interactions = [
# 1. Approve the router to spend USDC.
Interaction(
target=USDC,
value="0",
call_data=encode_approve(ROUTER, 1_000_000_000),
),
# 2. Swap USDC -> WETH via the 0.3% pool.
Interaction(
target=ROUTER,
value="0",
call_data=encode_exact_input_single(
token_in=USDC,
token_out=WETH,
fee=3000,
recipient=contract_address,
deadline=int(time.time()) + 300,
amount_in=1_000_000_000,
amount_out_minimum=0,
chain_id=1,
),
),
] Docker Requirements
Solver submissions are packaged as Docker images. The requirements are strict.
Required Files
- ·Dockerfile// must use official base
- ·solver.py// must export SOLVER_CLASS
- ·README.md// description of approach
- ·requirements.txt
- ▾models
- ·...
Dockerfile Rules
FROM ghcr.io/subnet112/solver-base:v1
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt No CMD or ENTRYPOINT directives — the harness manages the entry point. The Docker image must use FROM ghcr.io/subnet112/solver-base:v1.
Size Limits
| Limit | Value |
|---|---|
| Maximum repo size | 100 MB |
Maximum single binary (outside models/) | 10 MB |
| Maximum serialized state | 50 MB |
solver.py Entry Point
Your solver.py must export a SOLVER_CLASS variable pointing to your IntentSolver subclass:
from minotaur_subnet.sdk.intent_solver import IntentSolver, SolverMetadata
class MySolver(IntentSolver):
# ... implementation ...
SOLVER_CLASS = MySolver Screening Pipeline
Before a solver reaches benchmarking, it passes through a 3-stage screening pipeline that filters broken, malformed, or malicious submissions.
-
Stage 1 — Static Checks (~10s)
No Docker required. Validates:
- Required files exist (
Dockerfile,solver.py,README.md) - Dockerfile uses
FROM ghcr.io/subnet112/solver-base - No
CMDorENTRYPOINTin Dockerfile - Repo size is within limits (100 MB)
- No suspicious binaries larger than 10 MB (outside
models/)
- Required files exist (
-
Stage 2 — Build Check (~2 min)
Builds the Docker image and verifies the solver can be imported and initialized:
docker build --network=none --memory=4g- Import check:
from solver import SOLVER_CLASS - Init check:
SOLVER_CLASS().initialize({"chain_ids": [1]}) - Metadata validation:
nameandversionmust be non-empty - Must be a subclass of
IntentSolver
-
Stage 3 — Smoke Test (~5 min)
Runs 3 synthetic intents and verifies valid plans:
- Plans must have non-empty
interactions intent_idmust match the intent’sapp_iddeadlinemust be in the future (relative to snapshot timestamp)- All interaction
targetaddresses must be valid (0x-prefixed, 42 chars) - All
call_datamust be 0x-prefixed hex - For auto-triggered intents:
check_trigger()must return a boolean
- Plans must have non-empty
Benchmarking
After passing screening, solvers enter the benchmarking phase where they compete against the current champion.
Champion / Challenger Model
- The champion is the currently active solver used for live order processing
- A new submission is the challenger
- Both are benchmarked against the same set of active intents
- The challenger must beat the champion’s average score by a 0.5% dethrone margin to be adopted
- Scores come from the JS scoring engine:
score(plan, state, context)returns 0.0-1.0
Scoring Pipeline
For each intent in the benchmark set:
-
Generate plan
Solver generates an
ExecutionPlanviagenerate_plan(). -
Simulate on Anvil fork
Plan is simulated on an Anvil fork (captures token transfers, gas usage, state changes).
-
Score via JS engine
JS scoring engine evaluates
score(plan, state, context)wherecontextincludescontext.simulation,context.state, andcontext.oracle. -
Record score
Score is recorded (0.0-1.0).
Timeouts
Per-command timeouts enforced by the harness:
| Command | Timeout |
|---|---|
initialize | 60s |
generate_plan | 30s |
check_trigger | 10s |
on_benchmark_start | 10s |
on_benchmark_end | 30s |
serialize_state | 30s |
restore_state | 30s |
metadata | 5s |
Total container lifetime: 10 minutes maximum.
JSON-over-stdio Protocol
Communication between the host-side orchestrator and the in-container runner uses JSON-over-stdin/stdout (newline-delimited JSON).
{"command": "generate_plan", "intent": {...}, "state": {...}, "snapshot": {...}} {"success": true, "result": {...}} {"success": false, "error": "Something went wrong", "error_type": "ValueError"} Commands
| Command | Description | Params |
|---|---|---|
initialize | One-time setup | config dict |
generate_plan | Generate execution plan | intent, state, snapshot |
check_trigger | Check auto-trigger condition | intent, state, snapshot |
on_benchmark_start | Before benchmark batch | intent_count |
on_benchmark_end | After benchmark batch | results list |
serialize_state | Persist state | (none) |
restore_state | Restore state | state_b64 (base64-encoded) |
metadata | Get solver info | (none) |
shutdown | Graceful exit | (none) |
Each command gets exactly one response. stderr is captured for logging but does not affect scoring.
Security
Solver containers run in a locked-down environment.
--network=none— No network access during benchmarking (RPC URLs are provided via the orchestrator for live execution)--read-only— Read-only filesystem (with/tmptmpfs for scratch space)--cap-drop=ALL— All Linux capabilities dropped--memory=2g(screening) /--memory=4g(build) — Memory limits enforced--cpus=1.0— CPU limit during screening
The harness manages the container entry point. Solvers cannot override it because CMD and ENTRYPOINT are forbidden in the Dockerfile.
Agentic Solver Development
The miner includes an agent subcommand that uses an LLM (Claude) to automatically develop and improve solver strategies.
How It Works
The agent loop (minotaur_subnet.miner.agent) runs continuously:
-
Discovers active apps
Fetches all deployed App Intents and their JS scoring modules from the validator.
-
Monitors per-app scores
Tracks how the current solver performs on each app.
-
Identifies underperformers
Apps where the solver scores below threshold.
-
Generates strategies via Claude CLI
The LLM reads the app’s JS scoring module, Solidity contract, and current strategy code, then writes an improved
Strategyclass. -
Tests strategies locally
Validates the generated code compiles and produces valid plans.
-
Bundles into RoutingSolver
Combines all per-app strategies into a single solver.
-
Submits to validator
Pushes the updated solver for screening and benchmarking.
Running the Agent
python -m minotaur_subnet.miner.main agent \
--validator-url http://localhost:8080 \
--interval 300 Submissions land on the api service at :8080, not on the validator daemon at :9100. Setting --validator-url http://localhost:9100 is a common mistake and will fail.
The agent generates Strategy subclasses (one per app) and registers them with a RoutingSolver. Each strategy targets a specific APP_ID and set of INTENT_FUNCTIONS.
When to Use
The agentic approach is most useful for:
- Bootstrapping strategies for newly deployed apps
- Iterating on strategies for apps with complex scoring logic
- Miners who want to compete without deep DeFi expertise
For maximum performance, experienced miners will typically write custom strategies by hand and use the agent as a starting point or supplement.
View on GitHub