Skip to content
Minotaur
+ 03 · Solver

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_solver

The IntentSolver is the core competition surface. Miners extend this class to build solving strategies.

solver.py PYTHON
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:

  1. initialize(config)

    Called once when the solver is loaded. The config dict contains chain_ids, rpc_urls (per-chain RPC URL map), timeout_per_plan_ms (default 30000), and supported_protocols.

  2. restore_state(data)

    Called if serialized state from a prior epoch exists.

  3. on_benchmark_start(intent_count)

    Before the benchmark batch begins. Use this for pre-computing shared data structures or warming caches.

  4. generate_plan(intent, state, snapshot)

    Called per intent. This is the core competition surface.

  5. check_trigger(intent, state, snapshot)

    Called for auto-triggered intents to check if conditions are met.

  6. on_benchmark_end(results)

    After the batch completes. Receives a list of {intent_id, score, elapsed_ms} dicts.

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

PYTHON
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.

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.

PYTHON
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,
  )

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.types

The output of generate_plan(). Defines the exact on-chain calls to execute.

PYTHON
@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.

PYTHON
@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().

PYTHON
@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.

PYTHON
@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

SolverMetadata

PYTHON
@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.strategy

A 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.

vault_strategy.py PYTHON
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_solver

The 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.

PYTHON
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-solver

Key directories there:

PathWhat’s in it
solver.pyThe MinerSolver entry that exports SOLVER_CLASS. Fork target.
common/abi_utils.pyencode_approve (generic ERC-20).
common/parsing.pyApp-agnostic input normalisation.
strategies/dex_aggregator/baseline_solver.pyThe 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.pyAerodrome Slipstream pool discovery + calldata.
strategies/dex_aggregator/pool_math.pyUniswap V3 single-tick math + best-pool / best-route finder.
strategies/dex_aggregator/swap_solver.pySingle-hop V3 plan emitter.
strategies/dex_aggregator/v3_codec.pyUniswap V3 SwapRouter calldata encoders (V1/V2 auto-select, multi-hop path).
strategies/dex_aggregator/uniswap_v3.py, token_math.pyPer-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.

Example — building a single-hop swap interaction using the solver-repo helpers:

example.py PYTHON
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

my-solver/ TREE
  • ·Dockerfile// must use official base
  • ·solver.py// must export SOLVER_CLASS
  • ·README.md// description of approach
  • ·requirements.txt
  • models
    • ·...

Dockerfile Rules

Dockerfile DOCKERFILE
FROM ghcr.io/subnet112/solver-base:v1

COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt

Size Limits

LimitValue
Maximum repo size100 MB
Maximum single binary (outside models/)10 MB
Maximum serialized state50 MB

solver.py Entry Point

Your solver.py must export a SOLVER_CLASS variable pointing to your IntentSolver subclass:

solver.py PYTHON
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.

  1. 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 CMD or ENTRYPOINT in Dockerfile
    • Repo size is within limits (100 MB)
    • No suspicious binaries larger than 10 MB (outside models/)
  2. Stage 2 — Build Check (~2 min)

    Builds the Docker image and verifies the solver can be imported and initialized:

    1. docker build --network=none --memory=4g
    2. Import check: from solver import SOLVER_CLASS
    3. Init check: SOLVER_CLASS().initialize({"chain_ids": [1]})
    4. Metadata validation: name and version must be non-empty
    5. Must be a subclass of IntentSolver
  3. Stage 3 — Smoke Test (~5 min)

    Runs 3 synthetic intents and verifies valid plans:

    • Plans must have non-empty interactions
    • intent_id must match the intent’s app_id
    • deadline must be in the future (relative to snapshot timestamp)
    • All interaction target addresses must be valid (0x-prefixed, 42 chars)
    • All call_data must be 0x-prefixed hex
    • For auto-triggered intents: check_trigger() must return a boolean

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:

  1. Generate plan

    Solver generates an ExecutionPlan via generate_plan().

  2. Simulate on Anvil fork

    Plan is simulated on an Anvil fork (captures token transfers, gas usage, state changes).

  3. Score via JS engine

    JS scoring engine evaluates score(plan, state, context) where context includes context.simulation, context.state, and context.oracle.

  4. Record score

    Score is recorded (0.0-1.0).

Timeouts

Per-command timeouts enforced by the harness:

CommandTimeout
initialize60s
generate_plan30s
check_trigger10s
on_benchmark_start10s
on_benchmark_end30s
serialize_state30s
restore_state30s
metadata5s

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).

host → container JSON
{"command": "generate_plan", "intent": {...}, "state": {...}, "snapshot": {...}}
container → host JSON
{"success": true, "result": {...}}
container → host JSON
{"success": false, "error": "Something went wrong", "error_type": "ValueError"}

Commands

CommandDescriptionParams
initializeOne-time setupconfig dict
generate_planGenerate execution planintent, state, snapshot
check_triggerCheck auto-trigger conditionintent, state, snapshot
on_benchmark_startBefore benchmark batchintent_count
on_benchmark_endAfter benchmark batchresults list
serialize_statePersist state(none)
restore_stateRestore statestate_b64 (base64-encoded)
metadataGet solver info(none)
shutdownGraceful 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.

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:

  1. Discovers active apps

    Fetches all deployed App Intents and their JS scoring modules from the validator.

  2. Monitors per-app scores

    Tracks how the current solver performs on each app.

  3. Identifies underperformers

    Apps where the solver scores below threshold.

  4. Generates strategies via Claude CLI

    The LLM reads the app’s JS scoring module, Solidity contract, and current strategy code, then writes an improved Strategy class.

  5. Tests strategies locally

    Validates the generated code compiles and produces valid plans.

  6. Bundles into RoutingSolver

    Combines all per-app strategies into a single solver.

  7. Submits to validator

    Pushes the updated solver for screening and benchmarking.

Running the Agent

terminal SH
python -m minotaur_subnet.miner.main agent \
  --validator-url http://localhost:8080 \
  --interval 300

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