Loom book

Documentation for the Loom bot framework

Telegram Chat

Loom is a modular framework designed to streamline the development of automated strategies for decentralized exchanges (DEXs) or other blockchain applications. Whether you're building a trading bot, arbitrage bot, block builder, solver or like to interact with any other custom DeFi protocol, the Loom framework provides essential building blocks for managing blockchain interactions, DEX calculations, event handling, and state management.

High level architecture

The Loom framework is using the alloy type system and has a deep integration with reth to receive events.

High level architecture

Getting started

Checkout the repository

Clone the repository:

git clone git@github.com:dexloom/loom.git

Setting up topology

Copy config-example.toml to config.toml and configure according to your setup.

Updating private key encryption password

Private key encryption password is individual secret key that is generated automatically but can be replaced

It is located in ./crates/defi-entities/private.rs and looks like


#![allow(unused)]
fn main() {
pub const KEY_ENCRYPTION_PWD: [u8; 16] = [35, 48, 129, 101, 133, 220, 104, 197, 183, 159, 203, 89, 168, 201, 91, 130];
}

To change key encryption password run and replace content of KEY_ENCRYPTION_PWD

cargo run --bin keys generate-password  

To get encrypted key run:

cargo run --bin keys encrypt --key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

Setup database

Install postgresql and create database and user.

Create user and db:

su - postgres
createuser loom
createdb loom

Run psql and update user and privileges:

alter user loom with encrypted password 'loom';
grant all privileges on database loom to loom;
create schema loom;
grant usage on schema loom to loom;
grant create on schema loom to loom;
\q

Starting loom

DATA=<ENCRYPTED_PRIVATE_KEY> cargo run --bin loom

Makefile

Makefile is shipped with following important commands:

  • build - builds all binaries
  • fmt - formats loom with rustfmt
  • pre-release - check code with rustfmt and clippy
  • clippy - check code with clippy

Testing

Testing Loom requires two environment variables pointing at archive node:

  • MAINNET_WS - websocket url of archive node
  • MAINNET_HTTP - http url of archive node

To run tests:

make test

Examples

Fetch Uniswap Resveres

Here is a basic example of how to fetch pool data from various Uniswap liquidity pools and their forks.

use std::env;

use alloy::{
    network::Ethereum,
    primitives::{address, Address, BlockNumber, U256},
    transports::BoxTransport,
};
use alloy_provider::RootProvider;
use alloy_rpc_types::BlockId;
use dotenv::dotenv;
use loom_defi_abi::uniswap2::IUniswapV2Pair;
use loom_node_debug_provider::{AnvilDebugProvider, AnvilDebugProviderFactory};
use std::result::Result;

async fn fetch_pools(
    node_url: String,
    block_number: u64,
) -> Result<(), Box<dyn std::error::Error>> {
    const POOL_ADDRESSES: [Address; 4] = [
        address!("322BBA387c825180ebfB62bD8E6969EBe5b5e52d"), // ITO/WETH pool
        address!("b4e16d0168e52d35cacd2c6185b44281ec28c9dc"), // USDC/WETH pool
        address!("0d4a11d5eeaac28ec3f61d100daf4d40471f1852"), // WETH/USDT pool
        address!("ddd23787a6b80a794d952f5fb036d0b31a8e6aff"), // PEPE/WETH pool
    ];

    let client: AnvilDebugProvider<
        RootProvider<BoxTransport>,
        RootProvider<BoxTransport>,
        BoxTransport,
        BoxTransport,
        Ethereum,
    > = AnvilDebugProviderFactory::from_node_on_block(node_url, BlockNumber::from(block_number))
        .await
        .unwrap();
    for pool_address in POOL_ADDRESSES {
        let pool_contract = IUniswapV2Pair::new(pool_address, client.clone());
        let contract_reserves = pool_contract
            .getReserves()
            .call()
            .block(BlockId::from(block_number))
            .await?;
        let reserves_0_original = U256::from(contract_reserves.reserve0);
        let reserves_1_original = U256::from(contract_reserves.reserve1);

        println!("Reserve0: {}", reserves_0_original);
        println!("Reserve1: {}", reserves_1_original);
    }
    Ok(())
}

#[tokio::main]
async fn main() {
    dotenv().ok();
    let block_number = 21077209u64; // set the latest block number
    let node_url: String = env::var("WSS_RPC_URL").unwrap(); //add a provider which is supported like tenderly
    let _ = fetch_pools(node_url, block_number).await;
}

Architecture

Actor

An actor can run as one or multiple workers as tokio tasks. Such an actor can receive messages and send messages to other actors.

stateDiagram-v2
    direction LR
    [*] --> running: start
    running --> [*]: shutdown

Actor types:

  • Actor: Implements a start function.
  • One-shot actor: Implements a start function and runs only once.
  • One-shot blocking actor: Implements a start_and_wait function and runs only once, blocking all other actors from execution.

Macros

A macro generates function to allow to initialize the fields of an actor.

There are three types of macros:

  • #[accessor]: A field to access a "global" state as Option<SharedState<MarketState>>
  • #[consumer]: A field that provides an access to consume event by Option<Broadcaster<...>>
  • #[producer]: A field that allows to produce new event using Option<Broadcaster<...>>

Example

Define a struct with the following fields and add the macros as you need:

#[derive(Accessor, Consumer, Producer)]
pub struct ExampleActor {
    #[accessor]
    market: Option<SharedState<Market>>,
    #[consumer]
    mempool_events_rx: Option<Broadcaster<MempoolEvents>>,
    #[producer]
    compose_channel_tx: Option<Broadcaster<MessageTxCompose>>,
}

The macro generates the following functions:

fn init_example_actor() -> ExampleActor {
    let market_state = SharedState::new(MarketState::new());
    let mempool_events_channel = Broadcaster::new(10);
    let compose_channel = Broadcaster::new(10);
    
    ExampleActor::new()
        .access(market_state.clone()) // <-- these functions are generated by the macro
        .consume(mempool_events_channel.clone())
        .produce(compose_channel.clone());
}

State management

Loom provides multiple ways to fetch the state and keep it up to date. The state can be fetched using different methods as described in the following section.

Receiving new state

Loom is allow to fetch the state using three different methods:

  • WS/IPC based: Subscribing to new events using WebSocket or IPC. For each new event a debug_trace_block call is made to get the state diff.
  • Direct DB: Subscribing to new events like before using WebSocket or IPC, but fetching the state diff directly from the DB.
  • ExEx: Subscribing to new ExEx events and reading the execution outcome from reth.

Receiving new state

Adding new state to the DB

Loom keeps all required state in-memory and optionally fetches missing state from an external database provider. The LoomDB is split in three parts to be efficient cloneable. The first part is mutable where every new or changed state will be added.

With each new block a background task will be spawned that merges all state to the inner read-only LoomDB. This inner LoomDB lives inside an Arc. The motivation is here to not wait for the merge and save costs for not cloning the whole state all the time.

The third part in a DatabaseRef to an external database provider. This is used to fetch missing state that was not prefetched. Both parts are optional e.g. for testing if the prefetched state is working correct.

Receiving new state

Custom messages

If you like to add new messages without modifying Loom you can easily add a custom struct like Blockchain to keep the references for states and channels.

Custom Blockchain

pub struct CustomBlockchain {
    custom_channel: Broadcaster<CustomMessage>,
}

impl CustomBlockchain {
    pub fn new() -> Self {
        Self {
            custom_channel: Broadcaster::new(10),
        }
    }
    pub fn custom_channel(&self) -> Broadcaster<CustomMessage> {
        self.custom_channel.clone()
    }
}

Custom Actor

Allow to set custom struct in your Actor:

#[derive(Consumer)]
pub struct ExampleActor {
    #[consumer]
    custom_channel_rx: Option<Broadcaster<CustomMessage>>,
}

impl Actor for ExampleActor {
    pub fn on_custom_bc(self, custom_bc: &CustomBlockchain) -> Self {
        Self { custom_channel_tx: Some(custom_bc.custom_channel()), ..self }
    }
}

Start custom actor

When loading your custom actor, you can set the custom blockchain:

let custom_bc = CustomBlockchain::new();
let mut bc_actors = BlockchainActors::new(provider.clone(), bc.clone(), relays);
bc_actors.start(ExampleActor::new().on_custom_bc(&custom_bc))?;

Address book

The address book contain ofter used addresses to have a convenient way to access them. It is less error-prone and easier to read.

Address types

Right now you will find TokenAddress, FactoryAddress, PeripheryAddress and other more specific address clusters for different protocols like UniswapV2PoolAddress.

Example

Just import is using the loom or the dedicated defi-address-book crate.

use loom::eth::address_book::TokenAddress;

let weth_address = TokenAddress::WETH;