Skip to main content
The Protocol trait is the core interface that allows ANK to interact with DeFi protocols in a uniform way. Every protocol—whether Aave, Lido, Uniswap, or a custom implementation—exposes the same methods to the engine.

Protocol Trait Definition

From core/protocol/src/lib.rs:core/protocol/src/lib.rs:117-161:
pub trait Protocol: Send + Sync {
    /// Stable identifier for the protocol (e.g., "aave-v3", "uniswap-v3")
    fn id(&self) -> &'static str;

    /// Execute an Action at `ts` on behalf of `user`, returning an ExecOutcome
    fn execute(&mut self, ts: Timestamp, user: UserId, action: Action) -> Result<ExecOutcome>;

    /// Optional per-user read-only view (free-shape JSON)
    fn view_user(&self, user: UserId) -> serde_json::Value {
        serde_json::json!({})
    }

    /// Optional market-wide read-only view (free-shape JSON)
    fn view_market(&self) -> serde_json::Value {
        serde_json::json!({})
    }

    /// Apply historical data (e.g., chain events, exogenous updates)
    fn apply_historical(
        &mut self,
        ts: Timestamp,
        market_hint: Option<String>,
        payload: serde_json::Value,
    ) -> Result<()> {
        Ok(())
    }

    /// Per-tick callback to advance protocol state (funding, interest, decay, etc.)
    fn on_tick(&mut self, ts: Timestamp) -> Result<()> {
        Ok(())
    }
}

Required Methods

id()

Returns a static string identifier for the protocol:
fn id(&self) -> &'static str {
    "my-protocol"
}
This is used by the engine to route transactions (Tx.to field).

execute()

The core execution method that:
  1. Receives an Action (user’s instruction)
  2. Mutates internal protocol state
  3. Returns an ExecOutcome with balance deltas, gas cost, and events
fn execute(&mut self, ts: Timestamp, user: UserId, action: Action) -> Result<ExecOutcome>

Optional Methods

view_user()

Returns a JSON snapshot of user-specific state (deposits, borrows, positions):
fn view_user(&self, user: UserId) -> serde_json::Value {
    json!({
        "deposits": self.deposits.get(&user),
        "borrows": self.borrows.get(&user),
    })
}
Strategies call this to inspect account state before deciding actions.

view_market()

Returns a JSON snapshot of global market state (prices, rates, liquidity):
fn view_market(&self) -> serde_json::Value {
    json!({
        "total_deposits": self.total_deposits,
        "borrow_rate": self.borrow_rate,
        "prices": self.prices,
    })
}

on_tick()

Called by the engine once per tick to update time-dependent state:
fn on_tick(&mut self, ts: Timestamp) -> Result<()> {
    // Accrue interest
    self.variable_debt_index *= self.borrow_rate_per_tick;
    Ok(())
}
on_tick() is called before the strategy planner runs, so updated rates/prices are visible.

apply_historical()

Allows replaying historical events (e.g., from on-chain logs) to mutate protocol state:
fn apply_historical(
    &mut self,
    ts: Timestamp,
    market_hint: Option<String>,
    payload: serde_json::Value,
) -> Result<()> {
    // Parse and apply event
    Ok(())
}

Action and ExecOutcome

Action

An instruction sent to a protocol. From core/protocol/src/lib.rs:core/protocol/src/lib.rs:56-68:
pub enum Action {
    Noop,  // No operation
    Custom(serde_json::Value),  // Protocol-specific JSON payload
}
Protocols typically use Action::Custom with a structured payload:
{
  "kind": "deposit",
  "token": 3,
  "amount": "1000000000000000000000"
}

ExecOutcome

The result of executing an action. From core/protocol/src/lib.rs:core/protocol/src/lib.rs:90-107:
pub struct ExecOutcome {
    /// Balance updates in e18 units: token → i128 (positive = credit, negative = debit)
    pub delta: BalancesDelta,
    /// Gas consumed (protocol's estimate)
    pub gas_used: U64S,
    /// Optional events emitted during execution
    pub events: Vec<Event>,
}
Example: Deposit 1000 wstETH
let mut delta = BalancesDelta::default();
delta.0.insert(TokenId(3), -1000_000_000_000_000_000_000i128); // Debit user

Ok(ExecOutcome {
    delta,
    gas_used: 180_000u64.into(),
    events: vec![Event::Info("Deposited 1000 wstETH".into())],
})

Implementing a Custom Protocol

Here’s a minimal lending protocol example:
use ank_accounting::{Balances, BalancesDelta, Timestamp, UserId, TokenId};
use ank_protocol::{Action, Event, ExecOutcome, Protocol};
use anyhow::{anyhow, Result};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum LendingAction {
    Deposit { token: u32, amount: String },
    Withdraw { token: u32, amount: String },
}

pub struct SimpleLending {
    deposits: IndexMap<UserId, IndexMap<TokenId, u128>>,
    interest_rate_per_tick: u128,  // e.g., 1.0001e27 (Ray)
}

impl SimpleLending {
    pub fn new(interest_rate_per_tick: u128) -> Self {
        Self {
            deposits: IndexMap::new(),
            interest_rate_per_tick,
        }
    }
}

impl Protocol for SimpleLending {
    fn id(&self) -> &'static str {
        "simple-lending"
    }

    fn execute(&mut self, _ts: Timestamp, user: UserId, action: Action) -> Result<ExecOutcome> {
        let Action::Custom(payload) = action else {
            return Ok(ExecOutcome::default());
        };

        let act: LendingAction = serde_json::from_value(payload)?;
        let mut delta = BalancesDelta::default();

        match act {
            LendingAction::Deposit { token, amount } => {
                let amt: u128 = amount.parse()?;
                let token_id = TokenId(token);

                // Debit user's wallet
                delta.0.insert(token_id, -(amt as i128));

                // Credit internal ledger
                self.deposits
                    .entry(user)
                    .or_default()
                    .entry(token_id)
                    .and_modify(|bal| *bal = bal.saturating_add(amt))
                    .or_insert(amt);

                Ok(ExecOutcome {
                    delta,
                    gas_used: 150_000u64.into(),
                    events: vec![Event::Info(format!("Deposited {} of token {}", amt, token))],
                })
            }
            LendingAction::Withdraw { token, amount } => {
                let amt: u128 = amount.parse()?;
                let token_id = TokenId(token);

                // Check balance
                let user_deposits = self.deposits.get_mut(&user)
                    .ok_or_else(|| anyhow!("No deposits for user"))?;
                let bal = user_deposits.get_mut(&token_id)
                    .ok_or_else(|| anyhow!("No deposit for token"))?;

                if *bal < amt {
                    return Err(anyhow!("Insufficient deposit"));
                }

                // Debit internal ledger
                *bal = bal.saturating_sub(amt);

                // Credit user's wallet
                delta.0.insert(token_id, amt as i128);

                Ok(ExecOutcome {
                    delta,
                    gas_used: 120_000u64.into(),
                    events: vec![Event::Info(format!("Withdrew {} of token {}", amt, token))],
                })
            }
        }
    }

    fn view_user(&self, user: UserId) -> serde_json::Value {
        serde_json::json!({
            "deposits": self.deposits.get(&user).cloned().unwrap_or_default()
        })
    }

    fn view_market(&self) -> serde_json::Value {
        let total: u128 = self.deposits.values()
            .flat_map(|user_deps| user_deps.values())
            .sum();
        serde_json::json!({
            "total_deposits": total.to_string(),
            "interest_rate": self.interest_rate_per_tick.to_string(),
        })
    }

    fn on_tick(&mut self, _ts: Timestamp) -> Result<()> {
        // Accrue interest on all deposits
        for user_deposits in self.deposits.values_mut() {
            for balance in user_deposits.values_mut() {
                // Multiply by interest rate (assuming Ray math: 1e27 = 1.0)
                *balance = (*balance as u128)
                    .saturating_mul(self.interest_rate_per_tick)
                    / 1_000_000_000_000_000_000_000_000_000u128;
            }
        }
        Ok(())
    }
}

Registering the Protocol

let mut protocols: IndexMap<String, Box<dyn Protocol>> = IndexMap::new();
protocols.insert(
    "simple-lending".into(),
    Box::new(SimpleLending::new(1_000_100_000_000_000_000_000_000_000u128))
);

let engine = Engine::new(protocols, 1725000000);

View Methods Best Practices

view_user() Design

Return only the data needed by strategies:
fn view_user(&self, user: UserId) -> serde_json::Value {
    let pos = self.positions.get(&user);
    json!({
        "collateral": pos.map(|p| p.collateral).unwrap_or(0).to_string(),
        "debt": pos.map(|p| p.debt).unwrap_or(0).to_string(),
        "health_factor": self.compute_hf(user).to_string(),
    })
}
Serialize large numbers as strings to avoid precision loss in JSON.

view_market() Design

Include global parameters that affect user decisions:
fn view_market(&self) -> serde_json::Value {
    json!({
        "prices": self.prices.iter()
            .map(|(k, v)| (k.0.to_string(), v.to_string()))
            .collect::<HashMap<_, _>>(),
        "borrow_rate": self.borrow_rate.to_string(),
        "supply_rate": self.supply_rate.to_string(),
        "utilization": self.compute_utilization().to_string(),
    })
}

on_tick() Patterns

Interest Accrual

fn on_tick(&mut self, ts: Timestamp) -> Result<()> {
    // Compound interest on debt
    self.debt_index = self.debt_index
        .saturating_mul(self.rate_per_tick)
        / RAY;  // Ray = 1e27
    Ok(())
}

Price Updates

fn on_tick(&mut self, ts: Timestamp) -> Result<()> {
    // Update exchange rate (e.g., Lido wstETH)
    self.exchange_rate_ray = self.exchange_rate_ray
        .saturating_mul(self.reward_rate_per_tick)
        / RAY;
    Ok(())
}

Conditional Logic

fn on_tick(&mut self, ts: Timestamp) -> Result<()> {
    // Only update every 100 ticks
    if self.last_update_ts + 100 <= ts {
        self.recompute_rates();
        self.last_update_ts = ts;
    }
    Ok(())
}

Action Parsing Patterns

Tagged Enums with Serde

#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum MyAction {
    Swap { amount_in: String, token_in: u32, token_out: u32 },
    AddLiquidity { amount_a: String, amount_b: String },
}

let act: MyAction = serde_json::from_value(payload)?;
match act {
    MyAction::Swap { amount_in, token_in, token_out } => { /* ... */ }
    MyAction::AddLiquidity { amount_a, amount_b } => { /* ... */ }
}

Manual Parsing

let kind = payload["kind"].as_str().ok_or_else(|| anyhow!("missing kind"))?;
match kind {
    "deposit" => {
        let token = payload["token"].as_u64().unwrap() as u32;
        let amount: u128 = payload["amount"].as_str().unwrap().parse()?;
        // ...
    }
    _ => return Err(anyhow!("unknown action: {}", kind)),
}

Error Handling

Protocols should return descriptive errors:
if user_balance < amount {
    return Err(anyhow!("Insufficient balance: have {}, need {}", user_balance, amount));
}

if health_factor < MIN_HF {
    return Err(anyhow!("Health factor too low: {}", health_factor));
}
Errors abort the transaction but do not revert prior txs in the same bundle. Use best-effort execution carefully.

Real-World Protocol Examples

ANK includes production-grade protocol implementations:

Aave V3

Location: protocols/aave-v3/ Features:
  • Multi-asset lending markets
  • Dynamic interest rate models
  • Health factor enforcement
  • Liquidation (fractional)
  • set_price for external oracle updates

Lido (wstETH)

Location: protocols/lido/ Features:
  • Stake/unstake with exchange rate growth
  • on_tick() compounds exchange rate
  • Wrapped stETH (wstETH) representation

Pendle

Location: protocols/pendle/ Features:
  • SY wrapper for underlying assets
  • PT/YT minting and redemption
  • PT ↔ SY AMM with LP shares
  • Maturity and rate modeling

Testing Protocols

Use the engine to write integration tests:
#[test]
fn test_deposit_withdraw() {
    let mut protocols: IndexMap<String, Box<dyn Protocol>> = IndexMap::new();
    protocols.insert("test".into(), Box::new(MyProtocol::new()));

    let mut engine = Engine::new(protocols, 0);
    let user = UserId(1);
    engine.balances_mut(user).set(TokenId(1), 1000e18 as u128);

    // Deposit
    let outcomes = engine.tick_with_bundles(user, vec![TxBundle {
        txs: vec![Tx {
            to: "test".into(),
            action: Action::Custom(json!({"kind": "deposit", "token": 1, "amount": "500"})),
            gas_limit: None,
        }]
    }]).unwrap();

    assert_eq!(engine.balances(user).get(TokenId(1)), 500);

    // Withdraw
    engine.tick_with_bundles(user, vec![TxBundle {
        txs: vec![Tx {
            to: "test".into(),
            action: Action::Custom(json!({"kind": "withdraw", "token": 1, "amount": "200"})),
            gas_limit: None,
        }]
    }]).unwrap();

    assert_eq!(engine.balances(user).get(TokenId(1)), 700);
}

Engine

See how the engine orchestrates protocol execution

Strategies

Build strategies that call your protocols

Accounting

Understand balance deltas and token IDs

Build docs developers (and LLMs) love