Skip to main content

Overview

The ank-agents module provides scaffolding for agent-based market (ABM) simulations in ANK. Key features:
  • Pure planning architecture (agents propose, engine executes)
  • Deterministic tie-breaking for reproducible results
  • Built-in block builder (MEV-style transaction ordering)
  • Pre-built agent types: noise traders, arbitrageurs, sandwich bots
Design principles:
  • Pure planning: Agents only propose transactions. State changes happen when the engine executes the produced bundles.
  • Determinism: Plans are stamped with (agent_idx, seq) for consistent tie-breaking across runs.
  • Safety: A per-agent cap (MAX_PLANS_PER_AGENT = 64) prevents flooding a tick.

Core Types

WorldView

A read-only snapshot of the world for agents to reason over.

Fields

markets
IndexMap<String, serde_json::Value>
Protocol id → protocol-level market view (free-shape JSON).Each protocol’s view_market() returns a custom JSON structure describing market state.
portfolios
IndexMap<UserId, Balances>
UserId → on-engine wallet balances (e18).Maps user IDs to their token balances (all amounts in 1e18 scale).

Methods

from_engine
fn(e: &Engine) -> Self
Build a WorldView from the current engine state (cheap clone of views and portfolios).

PlannedTx

A planned transaction with scheduling metadata used by block builders.

Fields

user
UserId
The user on whose behalf this tx will execute.
tx
Tx
The concrete tx (target protocol + action).
priority
i64
Higher priority executes earlier (after gas bid).
gas_bid
u64
Simple stand-in for a “gas bid” used by builders to sort.

Methods

new
fn(user: UserId, tx: Tx, priority: i64, gas_bid: u64) -> Self
Construct a new planned transaction.

Traits

Agent

Trait implemented by any agent that can observe the world and produce a set of planned transactions each ABM tick.

Methods

name
fn(&self) -> &'static str
required
A short, stable name for logging/metrics.
init
fn(&mut self, engine: &Engine) -> Result<()>
One-time initialization hook (e.g., pre-compute handles).Default: Does nothing (returns Ok(()))
stride
fn(&self) -> u64
Run once every N ticks.Default: 1 (every tick)
step
fn(&mut self, ctx: EngineCtx, world: &WorldView) -> Vec<PlannedTx>
required
Plan transactions for the current tick. The engine executes the returned txs if they pass fees/risk policies.

BlockBuilder

Block builder abstraction: takes a pool of planned txs and returns per-user bundles. Builders decide ordering (e.g., by gas bid / priority) and how to group txs into bundles submitted to the engine.

Methods

build
fn(&mut self, ctx: EngineCtx, plans: Vec<PlannedTx>) -> Vec<(UserId, TxBundle)>
required
Build bundles for this tick from a set of planned transactions.

AbmRunner

Coordinates all agents with the engine. One “global tick”:
  1. All agents observe the same WorldView
  2. Produce planned txs
  3. Builder assembles per-user bundles
  4. Engine executes all bundles at the same timestamp

Fields

agents
Vec<Box<dyn Agent>>
Registered agents participating in the market.
builder
Box<dyn BlockBuilder>
Current block builder implementation (MEV policy).

Methods

new
fn() -> Self
Create an empty runner with MevBlockBuilder as default builder.
with_builder
fn(self, builder: Box<dyn BlockBuilder>) -> Self
Replace the block builder.
register
fn(&mut self, a: Box<dyn Agent>)
Register an agent.
init
fn(&mut self, engine: &Engine) -> Result<()>
Initialize all agents once (called before the first tick).
tick
fn(&mut self, engine: &mut Engine) -> Result<()>
Run one ABM tick: all agents act at the same timestamp.

Built-in Block Builders

MevBlockBuilder

A simple builder that sorts by: (gas_bid DESC, priority DESC, agent_idx ASC, seq ASC), then preserves per-user order when forming bundles. Sorting logic:
  1. Higher gas_bid first (MEV auction)
  2. Higher priority within same gas bid
  3. Lower agent_idx for deterministic tie-breaking
  4. Lower seq for transaction order within agent

Built-in Agents

NoiseTraderAgent

A lightweight “noise trader” agent that submits periodic swaps to a DEX.

Fields

user
UserId
User on whose behalf the trades are executed.
target
String
Target DEX protocol id (e.g., "uniswap-v3").
mean_notional_e18
u128
Mean notional (1e18) for each swap.

Behavior

  • Alternates trade direction each tick (deterministic)
  • Creates one swap per tick
  • Priority: 0, Gas bid: 1

DexArbAgent

Try to close price gaps across two DEX markets (very simplified).

Fields

user
UserId
User executing the arbitrage.
dex_a
String
First DEX id (e.g., "uniswap-v3").
dex_b
String
Second DEX id.
threshold_bps
u32
If abs(rel_gap) exceeds this threshold in bps, attempt a two-leg trade.

Behavior

  • Reads mid_e18 price from both DEX market views
  • Calculates relative price gap in basis points
  • If gap exceeds threshold, submits buy + sell trades
  • Priority: 5, Gas bid: 5

SandwichBot

A super-light “sandwich bot” stub: front-run + back-run with higher gas.

Fields

user
UserId
User on whose behalf the sandwich is executed.
target
String
Target DEX protocol id.

Behavior

  • Creates two transactions per tick:
    1. Front-run: Priority 10, Gas bid 100
    2. Back-run: Priority -10, Gas bid 100
  • Amount: 0.5e18 per leg

DEX Action Helpers

Strongly-typed helpers for building common DEX actions (avoid free-form JSON typos).

swap_exact_in

Create a swap_exact_in action. Signature:
pub fn swap_exact_in(
    zero_for_one: bool,
    amount_in_e18: u128,
    min_out_e18: u128,
) -> Action
Parameters:
zero_for_one
bool
Trade direction flag (token0 → token1).
amount_in_e18
u128
Input amount (1e18 scale).
min_out_e18
u128
Minimum acceptable output amount (slippage guard).

Usage Examples

Basic ABM setup

use ank_agents::*;
use ank_engine::Engine;

let mut engine = Engine::default();
// ... initialize engine with protocols, users, etc. ...

let mut runner = AbmRunner::new();

// Register agents
runner.register(Box::new(NoiseTraderAgent {
    user: 1,
    target: "uniswap-v3".into(),
    mean_notional_e18: 1_000_000_000_000_000_000u128, // 1 token
}));

runner.register(Box::new(DexArbAgent {
    user: 2,
    dex_a: "uniswap-v3".into(),
    dex_b: "sushiswap".into(),
    threshold_bps: 50, // 0.5% gap triggers arb
}));

// Initialize agents
runner.init(&engine)?;

// Run simulation
for _ in 0..100 {
    runner.tick(&mut engine)?;
}

Custom agent implementation

use ank_agents::*;

struct MarketMaker {
    user: UserId,
    target: String,
    spread_bps: u32,
}

impl Agent for MarketMaker {
    fn name(&self) -> &'static str {
        "market_maker"
    }
    
    fn stride(&self) -> u64 {
        10 // Run every 10 ticks
    }
    
    fn step(&mut self, ctx: EngineCtx, world: &WorldView) -> Vec<PlannedTx> {
        let mut plans = vec![];
        
        // Read market state
        if let Some(market) = world.markets.get(&self.target) {
            let mid_price = market.get("mid_e18")
                .and_then(|v| v.as_str())
                .and_then(|s| s.parse::<u128>().ok())
                .unwrap_or(0);
            
            if mid_price > 0 {
                // Place bid and ask around mid
                let spread = (mid_price * self.spread_bps as u128) / 10_000;
                
                // Buy side
                plans.push(PlannedTx::new(
                    self.user,
                    Tx {
                        to: self.target.clone(),
                        action: dex_actions::swap_exact_in(
                            true,
                            1_000_000_000_000_000_000u128,
                            0,
                        ),
                        gas_limit: Some(200_000),
                    },
                    1,
                    10,
                ));
                
                // Sell side
                plans.push(PlannedTx::new(
                    self.user,
                    Tx {
                        to: self.target.clone(),
                        action: dex_actions::swap_exact_in(
                            false,
                            1_000_000_000_000_000_000u128,
                            0,
                        ),
                        gas_limit: Some(200_000),
                    },
                    1,
                    10,
                ));
            }
        }
        
        plans
    }
}

Custom block builder

use ank_agents::*;

/// First-come, first-served builder (no MEV)
struct FcfsBuilder;

impl BlockBuilder for FcfsBuilder {
    fn build(&mut self, _ctx: EngineCtx, mut plans: Vec<PlannedTx>) -> Vec<(UserId, TxBundle)> {
        // Sort by agent_idx and seq only (arrival order)
        plans.sort_by_key(|p| (p.agent_idx, p.seq));
        
        let mut grouped: IndexMap<UserId, Vec<Tx>> = IndexMap::new();
        for p in plans {
            grouped.entry(p.user).or_default().push(p.tx);
        }
        
        grouped
            .into_iter()
            .map(|(u, txs)| (u, TxBundle { txs }))
            .collect()
    }
}

// Use custom builder
let runner = AbmRunner::new()
    .with_builder(Box::new(FcfsBuilder));

Reading portfolio state in agents

impl Agent for MyAgent {
    fn step(&mut self, ctx: EngineCtx, world: &WorldView) -> Vec<PlannedTx> {
        // Check user balance
        if let Some(balances) = world.portfolios.get(&self.user) {
            if let Some(eth_balance) = balances.get(&TOKEN_ETH) {
                let eth_float = *eth_balance as f64 / 1e18;
                println!("ETH balance: {:.4}", eth_float);
                
                // Only trade if sufficient balance
                if *eth_balance > 1_000_000_000_000_000_000u128 {
                    // ... create trades ...
                }
            }
        }
        
        vec![]
    }
}

Multi-agent coordination

// Create multiple noise traders with different behaviors
for i in 0..10 {
    runner.register(Box::new(NoiseTraderAgent {
        user: 100 + i,
        target: "uniswap-v3".into(),
        mean_notional_e18: (1_000 + i * 100) * 1_000_000_000_000_000u128,
    }));
}

// Add arbitrageurs
for i in 0..3 {
    runner.register(Box::new(DexArbAgent {
        user: 200 + i,
        dex_a: "uniswap-v3".into(),
        dex_b: "sushiswap".into(),
        threshold_bps: 25 + i * 25, // Different sensitivity
    }));
}

// Add MEV bots
runner.register(Box::new(SandwichBot {
    user: 300,
    target: "uniswap-v3".into(),
}));

Design Notes

Determinism

All agent logic is deterministic when:
  • Using deterministic RNG (seeded from ctx.step_idx)
  • Avoiding system time / external I/O
  • Reading from immutable WorldView
This ensures reproducible simulations across runs.

Per-Agent Cap

MAX_PLANS_PER_AGENT = 64 prevents any single agent from flooding the mempool. If an agent returns more than 64 planned transactions, the list is truncated.

Execution Model

  1. Observation: All agents receive the same WorldView snapshot
  2. Planning: Agents independently produce PlannedTx vectors
  3. Building: Block builder sorts/groups transactions
  4. Execution: Engine executes all bundles at the same timestamp
This simulates simultaneous decision-making in a competitive market.

Block Builder Semantics

MevBlockBuilder sort order:
(gas_bid DESC, priority DESC, agent_idx ASC, seq ASC)
  • gas_bid: Simulates MEV auction (highest bidder first)
  • priority: User-defined urgency
  • agent_idx: Deterministic tie-breaker
  • seq: Preserves transaction order within agent

Gas Limits

Agents can set gas_limit on transactions. The engine may reject transactions exceeding available gas (if gas accounting is enabled).

Future Extensions

The ank-agents module is designed for extensibility:
  • More agent types: Liquidators, rebalancers, governance bots
  • Advanced builders: PBS-style separation, private order flow
  • Machine learning: RL agents trained on historical data
  • Multi-market: Agents operating across multiple protocols

Build docs developers (and LLMs) love