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:
Receives an Action (user’s instruction)
Mutates internal protocol state
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_000 i128 ); // Debit user
Ok ( ExecOutcome {
delta ,
gas_used : 180_000 u64 . 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_000 u64 . 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_000 u64 . 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_000 u128 ;
}
}
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_000 u128 ))
);
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 ), 1000 e 18 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