A low-risk arbitrage without an upfront capital: flash loans on Starknet
If you’re interested in exploring MEV opportunities on Starknet, join our community of MEV (re)searchers where all the industry updates including articles and tools are published and active discussions happen.
In this article we consider creating an augmented version of an atomic arbitrage bot for Ekubo AMM on Starknet described in the series:
- How to create an atomic arbitrage bot in Starknet: part 1 (basics) — introduction to Starknet, account setup
- How to create an atomic arbitrage bot in Starknet: part 2 (the foggy desert) — a triangular atomic arbitrage strategy with the real example on Starknet Ekubo DEX
Besides, these two articles I assume a sound experience with Rust (I am not going to spend any time here for basic programming things) and Cairo (a smart contract programming language for Starknet). On the latter I can suggest the following resources:
- Cairo for Rust devs series of articles (I’m going to finish it soon) — for Cairo language in a general setting;
- chapter 13.1 of Cairo book — for a general introduction to smart contracts;
During our journey I will also try to introduce other concepts while providing some links to Starknet by example (an awesome resource on practical smart contract programming in Starknet).
DISCLAIMER: This article and related articles are for educational purposes only and cannot be considered as a financial/investment advice. Please do your own research and make sure to fully understand the code before using it or putting your money in it.
Let’s quickly recap the atomic arbitrage strategy of the created bot . We estimate a profit off-chain via obtaining quotes for different amounts from Ekubo API and then in case of a positive profit with a fee estimation we send a transaction which wraps three calls:
- Transfer an amount of a target token (ETH) — called on ERC20 token smart contract
- Swap via several hops (and perhaps splits) for the same token — called on Ekubo Router contract
- Withdraw at least the amount invested — called on Ekubo Router contract
On submitting the transaction we call three above-mentioned methods on two different contracts and all the three being wrapped in a single transaction are executed by Starknet Sequencer atomically (i.e. either all pass or all are reverted including chain storage changes).
We use only our own funds at every attempt. Thus our investment is limited by the amount of funds at our account. The more amount is invested, the more we expect to get in return. Would it be possible to leverage additional investment funds via borrowing?
Atomic arbitrage strategy with flash loans
In TradFi there is a margin trading — when you borrow additional funds from a broker while providing a part of a collateral (as a percentage of a loan amount). The other part of a loan amount is covered by the assets (usually, securities) purchased with the loan. The lender (your broker) is exposed to two kind of risks:
- a market risk (when the price of purchased assets decreases and so the collateral decreases) is managed by requesting you to add more funds (making so called margin call — hence the headline image) — so it is basically delegated to the trader;
- a credit risk (when you are not able to pay back the loan amount) is managed by tracking the assets price and your funds deposited as a collateral.
A trade on margin is usually considered a risky trading strategy as the trader is exposed to a market risk and as a consequence can loose not only the assets purchased but the also their own funds.
DeFi introduces a novel lending instrument called a flash loan. A flash loan allows you to borrow an amount of a specified token and return it back with lending fees (sometimes even at a zero fee) within the same transaction, i.e. being atomic it carries no credit risk. First introduced and standardized on Ethereum with EIP-3156 and EIP-7399, the concept was adopted by other chains including Starknet.
So there are at least 2 possible ways to leverage flash loans in our bot:
- Use Ekubo platform-specific flash accounting feature, i.e. borrow the target token, make an arbitrage, pay back the token loaned (compare with Uniswap) — we use this mechanism in the article;
- Use an external lending platform on Starknet, e.g. zkLend or Vesu (let’s consider this strategy in the next follow-up article).
Both of them require writing our own smart contract which will make subcalls to flash loan contracts and swap methods of Ekubo. So the strategy looks as follows:
- Get quotes from Ekubo API
- Check arbitrage opportunities off-chain
- Make a loan for the chosen amount in our contract
- Make an arbitrage in our contract
- Get back the token and check that the received amount is greater than the invested one + loan fee
- Pay back the loan and loan fees in our contract
Setting up an environment
Smart contracts on Starknet are Cairo programs compiled and executed by Cairo VM (native execution is also planned) integrated into a blockchain node (strictly speaking, the sequencer but with ongoing decentralization of Starknet the border between a node and a sequencer is blurring).
Developing and testing smart contracts requires a local node with Cairo VM which would execute smart contracts. But manual setup could be bothersome so like in Ethereum, there is a smart contract development multitool/IDE called Starknet Foundry. After the installation (be sure to install version 0.27, you can check with snforge --version
) you will have two CLI utilities at your disposal:
snforge
(build, test, deploy smart contracts) — similar tocargo
sncast
(interact with a node via RPC calls with CLI)
Let’s create a new project (choosing a default Starknet Foundry test runner on prompt):
$ snforge init ekubo_flash_loan
We’re going to work with the latest version of Cairo (august 2024) — 2.7.1. Be sure to use a compatible version of scarb
(scarb --version
).
You can easily install the required version of scarb
with
$ asdf install scarb 2.7.1
$ asdf global scarb 2.7.1
Also make sure to have edition = “2024_07”
in Scarb.toml
. All the relevant smart contract code is available in the repository.
Implementation overview
Let’s think over what we’re going to get at the end. We want to have the same logic applied to the opportunities search but we do not want to be limited by the amount. So we can achieve a high code reuse if we just re-implement Router
interface in our contract while providing flash-loan logic under the hood. Then in the off-chain part little has to be changed: the address of the contract and the transaction calls.
Ekubo abis repository contains a stripped implementation of Router (let’s refer to it as RouterLite
)— we use it as a basement.
Router contract interacts with Core
contract via series of swap calls along the swap route for every swap provided, and balance differences are cleared after the every swap. All interactions with Core
are within locked
callback to enable the flash accounting.
We take the swap-routes logic from RouterLite
but as we make an arbitrage (i.e. we start and end up with the same token) and thanks to the flash accounting we can first make swaps (while not having enough funds to invest) and withdraw
profits (if any) at the end.
Starknet smart contracts crash course
Interface
As we mentioned earlier, we mimic the interface of Router but for only interested methods. We also would like to reuse types already defined for Ekubo so let’s add its ABIs as a dependency in Scarb.toml
:
[dependencies]
ekubo = { git = "https://github.com/EkuboProtocol/abis" }
Scarb.toml
(generated with snforge
) also already contains starknet
library which is an extension library to Cairo core
library and it provides blockchain-specific types and functions.
So let’s start with defining the interface (in the file lib.cairo
):
use ekubo::router_lite::{RouteNode, TokenAmount, Swap};
use starknet::ContractAddress;
#[starknet::interface]
pub trait IArbitrageur<TContractState> {
// Does a multihop swap, where the output/input of each hop is passed as input/output of the
// next swap Note to do exact output swaps, the route must be given in reverse
fn multihop_swap(ref self: TContractState, route: Array<RouteNode>, token_amount: TokenAmount);
// Does multiple multihop swaps
fn multi_multihop_swap(ref self: TContractState, swaps: Array<Swap>);
// Get the owner of the bot, read-only (view) function
fn get_owner(self: @TContractState) -> ContractAddress;
}
We can quickly check (without compilation) that everything passes type checking with scarb check
(compare to cargo check
). If you see the error:
error: Version solving failed:
- ekubo v0.1.0 (git+https://github.com/EkuboProtocol/abis#05bca296a02f2ae4e38bd64acc6a68e6de72c424) cannot use starknet v2.7.0 (std), because ekubo requires starknet =2.7.1
then check the scarb
version with scarb --version
and install the required one (see the section Setting up an environment above).
We take multihop_swap
and multi_multihop_swap
write calls signatures from Router and also introduce read-only (“view”) method get_owner
. Read-only methods can be called via RPC without any transactions (and therefore accounts) as they don’t change the chain state. Every public method in Starknet smart contract serves as an entrypoint (aka main function) for Cairo VM to run. Notice also that the trait is generic over the contract state.
#[starknet::interface]
macro (actually implemented as a plugin to cairo compiler for a code generation) generates a dispatcher (named by adding Dispatcher
to the trait name, i.e. IArbitrageurDispatcher
in our case). Why do we need a dispatcher? Remember from our previous discussions that all transaction data for an interaction with a node should be encoded (and decoded to read) into felt252
. Dispatcher handles encoding/decoding for us (wrapping a contract ABI) so that we can use convenient types instead of just arrays of felts (we will see such an example soon). For any type to be encoded/decoded, it should implement Serde
trait (usually derived).
Interface implementation
Now on an actual trait implementation. An implementation should be wrapped into a module with a macro #[starknet::contract]
and declaring a struct (even empty one) with #[storage]
macro is required. All interactions with Core
Ekubo contract should happen within locked
callback with a predefined signature (we have to implement ILocker
trait):
fn locked(ref self: TContractState, id: u32, data: Span<felt252>) -> Span<felt252>;
Here is an example of raw undecoded data Span<felt252>
input from a user transaction (an array of routes of swaps) and the same raw serialized output (an array of deltas of changes in Core
balances).
As we need to interact with a Core
contract inside locked
, we either should pass a contract address of Core
as an argument inside data
(but it breaks a deserialization compatibility with Router) or take it from our contract state (i.e. we need to put it there at the contract instantiation).
We take the second option and use a constructor which is a special entrypoint that is called only once at a deployment of the contract. We can put in the storage only those types which implement starknet::Store
trait (#[starknet::interface]
generates derive-implementations of starknet::Store
so ekubo_core::ICoreDispatcher
can be stored in the state without any help from our side).
#[starknet::contract]
pub mod arbitrage {
use ekubo::types::delta::Delta;
use ekubo::router_lite::{RouteNode, TokenAmount, Swap};
use starknet::ContractAddress;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use core::num::traits::Zero;
use ekubo::interfaces::core as ekubo_core;
use ekubo::interfaces::core::ICoreDispatcherTrait;
use ekubo::components::shared_locker;
#[storage]
struct Storage {
core: ekubo_core::ICoreDispatcher,
owner: ContractAddress,
}
// we have to specify owner explicitly
// as in Starknet a contract is deployed by Universal Deployer Contract
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress, core: ContractAddress) {
let core = ekubo_core::ICoreDispatcher { contract_address: core };
// we save a Core dispatcher to the contract state
// to write to the state we have to import
// StoragePointerWriteAccess trait
self.core.write(core);
assert(!owner.is_zero(), 'owner is the zero address');
self.owner.write(owner);
}
#[abi(embed_v0)] // required for trait implementations
impl LockerImpl of ekubo_core::ILocker<ContractState> {
fn locked(ref self: ContractState, id: u32, data: Span<felt252>) -> Span<felt252> {
// let's make a stub for now
array![].span()
}
}
#[abi(embed_v0)]
impl ArbitrageImpl of super::IArbitrageur<ContractState> {
// an implementation of multihop_swap and multi_multihop_swap
// is taken from RouterLite
fn multihop_swap(
ref self: ContractState, route: Array<RouteNode>, token_amount: TokenAmount
) {
self.multi_multihop_swap(array![Swap { route, token_amount }]);
}
fn multi_multihop_swap(ref self: ContractState, swaps: Array<Swap>) {
let _arr: Span<felt252> = shared_locker::call_core_with_callback(
self.core.read(), @swaps
);
}
// a view method to obtain a contract owner
fn get_owner(self: @ContractState) -> ContractAddress {
self.owner.read()
}
}
}
Testing
This implementation already allows us to write our first test. In tests/test_contract.cairo
:
use snforge_std::{
declare, ContractClassTrait, test_address,
};
use ekubo_flash_loan_example::{arbitrage, IArbitrageurDispatcher, IArbitrageurDispatcherTrait};
// https://docs.ekubo.org/integration-guides/reference/contract-addresses
const EKUBO_CORE_ADDRESS: felt252 =
0x0444a09d96389aa7148f1aada508e30b71299ffe650d9c97fdaae38cb9a23384;
fn declare_and_deploy() -> IArbitrageurDispatcher {
// First declare and deploy a contract
// (the name of the contract is the contract module name)
let contract = declare("arbitrage").unwrap();
// deploy function accepts a snap of an array of contract arguments serialized as felt252
let (contract_address, _) = contract.deploy(
@array![test_address().into(), EKUBO_CORE_ADDRESS]).unwrap();
// Create a Dispatcher object that will allow interacting with the deployed contract
IArbitrageurDispatcher { contract_address }
}
#[test]
fn test_get_owner() {
// a test is a smart contract itself
// so a test account declares and deploy
// our arbitrage contract
let dispatcher = declare_and_deploy();
let owner = dispatcher.get_owner();
// test_address function returns the address of the current test
// which is a contract itself
assert_eq!(owner, test_address());
}
And run it with snforge test
.
Swapping
Here is a commented implementation of locked
:
// deserialize the raw input into defined data structures
let mut swaps = shared_locker::consume_callback_data::<Array<Swap>>(core, data);
let mut total_profit: i129 = Zero::zero();
let mut token: ContractAddress = Zero::zero();
// for every swap (when an input amount is splitted between several swaps)
while let Option::Some(swap) = swaps.pop_front() {
let mut route = swap.route;
// if token amount is positive it is an exact input
// otherwise it is an exact output
let mut token_amount = swap.token_amount;
token = swap.token_amount.token;
// flash loan: we have not yet enough funds but we can make a swap in advance
// we don't care here either it is an exact input or an exact output
// for the exact input - we loan the whole investment
// for the exact output - we loan the investment (yet unknown) + profit (if any)
// In both cases the loan should cover the investment
let loaned_amount = swap.token_amount;
//we unwind the swap route - a path of interrelated token exchanges
while let Option::Some(node) = route.pop_front() {
// is_token1 indicates whether the amount is in terms of token0 or token1.
// it used to setup the proper direction of a swap
let is_token1 = token_amount.token == node.pool_key.token1;
// [Delta](https://github.com/EkuboProtocol/abis/blob/main/src/types/delta.cairo)
// represents a change in balances of Core contract
// e.g swap 20 token0 for 30 token1 if is_token1 == false results
// in delta (20, -30)
// so we as a trader get 30 units of token1 and lose 20 units of token0
let delta = core
.swap(
node.pool_key,
ekubo_core::SwapParameters {
amount: token_amount.amount,
is_token1: is_token1,
sqrt_ratio_limit: node.sqrt_ratio_limit,
skip_ahead: node.skip_ahead,
}
);
// the delta from the current swap serves as an input for the next swap
// from the trader's (ours) perspective
token_amount =
if (is_token1) {
// we swapped token1 for token0
// as a trader we have now token0
// in this case delta is (-token0, token1)
// (change in the balances of Core)
// so for the next swap the input is token0 and
// we should flip the sign (-(-token0))
TokenAmount { amount: -delta.amount0, token: node.pool_key.token0 }
} else {
// we swapped token0 for token1
// as a trader we have now token1
// core balances delta (token0, -token1)
// so we for the next swap we offer -(-token1)
// as the exact input amount
// for the case when we specified -token0 as an exact output amount
// delta is (-token0, token1)
// we provide -token1 as an exact output for the previous(!) hop - for
// exact output swaps, the route must be given in reverse
TokenAmount { amount: -delta.amount1, token: node.pool_key.token1 }
};
};
assert(token_amount.token == loaned_amount.token, 'the same token');
// Exact input case: `token_amount` (contains the last output) and `loaned_amount`
// (contains the first input) are positive
// Exact output case: `token_amount` (contains the last input) and `loaned_amount`
// (contains the last output) are negative
// In both cases the difference is our actual net profit
total_profit += token_amount.amount - loaned_amount.amount;
};
// The most important check we have
assert(total_profit > Zero::zero(), 'Unprofitable swap');
// Withdraw profits
core.withdraw(token, recipient, total_profit.try_into().unwrap());
// as we don't care of the actual deltas
// just return an empty array to reduce gas costs
let mut serialized: Array<felt252> = array![];
let mut outputs: Array<Array<Delta>> = ArrayTrait::new();
Serde::serialize(@outputs, ref serialized);
serialized.span()
And let’s try a very basic test (snforge test test_empty_swap
to run the specific test):
#[should_panic(expected: ('Unprofitable swap',))]
#[test]
fn test_empty_swap() {
let dispatcher = declare_and_deploy();
dispatcher.multi_multihop_swap(array![]);
}
Output:
[FAIL] tests::test_contract::test_empty_swap
Failure data:
Got an exception while executing a hint: Hint Error: Error at pc=0:3138:
Got an exception while executing a hint: Requested contract address
ContractAddress(PatriciaKey(StarkFelt("0x0444a09d96389aa7148f1aada508e30b71299ffe650d9c97fdaae38cb9a23384")))
is not deployed.
Our contract interacts with Ekubo Core
contract which is not deployed in the local node spawned by Starknet Foundry. We could try to declare and deploy Ekubo contract within the test but it may lead to deploying more external dependencies and so on. Actually, for such kind of integration tests a more convenient solution exists.
Fork testing
Fork testing allows to fork a chain (with external contracts already deployed and having an actual state), declare and deploy our contract to the fork and run tests. Let’s add to Scarb.toml
:
[[tool.snforge.fork]]
name = "SEPOLIA_FORK"
url = "https://free-rpc.nethermind.io/sepolia-juno/v0_7"
block_id.number = "104487" # we use a specific block for reproducibility
We use Sepolia testnet as on moment of writing (august 2024) mainnet hasn’t yet migrated to Cairo 2.7.0 and also for another reason which will be highlighted later.
And add an attribute macro to our test:
#[test]
#[fork("SEPOLIA_FORK")]
fn test_empty_swap() {
...
}
Now it passes.
How forking works in Starknet Foundry
Starknet Foundry extends scarb
with a plugin to support additional configuration in Scarb.toml
(fork specification) and #[fork]
attribute macro to mark tests to use a forked state. When we run snforge test
subcommand, a multi-threaded tokio runtime is created and run_for_workspace
is the run inside it. This function:
- builds test artifacts (
scarb build --test
) and contracts (scarb build
) with scarb; - runs test for every package in the workspace, in turn for every test target in every package and
run_test_case
for every test case; - collects test summaries.
We’re interested in the run_test_case
and more specifically in initialization of ExtendedStateReader
which implements StateReader
trait from blockifier
(Starknet block building library) and takes as inputs:
DictStateReader
(extended with a test account) — it is a basic state reader for any test (implementsStateReader
trait as well);ForkStateReader
which is applied for tests using forks and initialized with a cache directory (.snfoundry_cache
) and a configuration of the fork inScarb.toml
.
Let’s inspect how ForkStateReader
loads or creates a fork cache (you can clean up previous cache with snforge clean-cache
). Before a test runs without any cache, nothing is created. Why? We can look up an answer in the design document. The state is read from the cache first and in the case of a cache miss the state is pulled from RPC provider (configured for the fork in Scarb.toml
). In case of write requests, state changes are written to the cache file and the subsequent read requests use the cached state.
For example, if we call get_block_info
of ExtendedStateReader
(which is delegated to ForkStateReader
) , in the cache we could see the following:
{
"cache_version": 3,
"storage_at": {},
"nonce_at": {},
"class_hash_at": {},
"compiled_contract_class": {},
"block_info": {
"block_number": 658442,
"block_timestamp": 1721056318,
"sequencer_address": "0x1176a1bd84444c89232ec27754698e5d2e7e1a7f1539f12027f28b23ec9f3d8",
"gas_prices": {
"eth_l1_gas_price": 100000000000,
"strk_l1_gas_price": 100000000000,
"eth_l1_data_gas_price": 1000000,
"strk_l1_data_gas_price": 1000000000
},
"use_kzg_da": true
}
}
Subsequent calls get_block_info
will use this cached data.
Testing a profitable strategy
We need to find a profitable arbitrage opportunity. But if we even try some profitable arbitrage transaction we looked up in the past (check my previous article for an example), we could hardly replay it. Why? Because the arbitrage opportunity emerges and caught within the block. So if we fork some block with an arbitrage opportunity observed in it, then it is highly probable that the same block contains the transaction which caught it. But it may be not the case for the testnet. Let’s try to get quotes:
$ curl 'https://sepolia-api.ekubo.org/quote/10000000000000000/0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7/0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7'| jq
And it seems that it outputs a simple arbitrage route:
{
"specifiedAmount": "10000000000000000",
"amount": "11678380679671722",
"route": [
{
"pool_key": {
"token0": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
"token1": "0x7ab0b8855a61f480b4423c46c32fa7c553f0aac3531bbddaa282d86244f7a23",
"fee": "0xccccccccccccccccccccccccccccccc",
"tick_spacing": 354892,
"extension": "0x73ec792c33b52d5f96940c2860d512b3884f2127d25e023eb9d44a678e4b971"
},
"sqrt_ratio_limit": "0x1000003f7f1380b76",
"skip_ahead": "0x0"
},
{
"pool_key": {
"token0": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
"token1": "0x7ab0b8855a61f480b4423c46c32fa7c553f0aac3531bbddaa282d86244f7a23",
"fee": "0x20c49ba5e353f80000000000000000",
"tick_spacing": 354892,
"extension": "0x73ec792c33b52d5f96940c2860d512b3884f2127d25e023eb9d44a678e4b971"
},
"sqrt_ratio_limit": "0x7ea4d9526482a9577ead999cd4fa76f2ba8dfdca5b3f2f",
"skip_ahead": "0x0"
}
]
}
There is an unknown ERC20 token which was added to Ekubo on testnet. This is a perfect test case for us! As the swap is profitable, let’s first try to call Router with the route to confirm (as our contract interface is compatible with Router. As our test account may have not enough funds to make the swap, we just take a rich account (I’ve just took the first one from ERC20 ETH contract), use a cheatcode to impersonate the caller and transfer some funds to our test account (a note for gold seekers — it is only relevant to local state, no hope to make it on the mainnet :)).
// ... previous imports
use starknet::{ContractAddress, contract_address_const};
use snforge_std::{start_cheat_caller_address, stop_cheat_caller_address};
use ekubo::interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait};
use ekubo::types::keys::PoolKey;
use ekubo::types::i129::i129;
use ekubo::types::delta::Delta;
const EKUBO_ROUTER_ADDRESS: felt252 =
0x0045f933adf0607292468ad1c1dedaa74d5ad166392590e72676a34d01d7b763;
#[starknet::interface]
pub trait IRouter<TContractState> {
fn multihop_swap(
ref self: TContractState, route: Array<RouteNode>, token_amount: TokenAmount
) -> Array<Delta>;
}
#[test]
#[fork("SEPOLIA_FORK")]
fn test_swap() {
let first_node = RouteNode {
pool_key: PoolKey {
token0: contract_address_const::<
0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7
>(),
token1: contract_address_const::<
0x7ab0b8855a61f480b4423c46c32fa7c553f0aac3531bbddaa282d86244f7a23
>(),
fee: 0xccccccccccccccccccccccccccccccc,
tick_spacing: 354892,
// TWAMM Extension
// https://docs.ekubo.org/integration-guides/reference/contract-addresses
extension: contract_address_const::<
0x73ec792c33b52d5f96940c2860d512b3884f2127d25e023eb9d44a678e4b971
>()
},
sqrt_ratio_limit: 0x1000003f7f1380b76,
skip_ahead: 0x0
};
let second_node = RouteNode {
pool_key: PoolKey {
token0: contract_address_const::<
0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7
>(),
token1: contract_address_const::<
0x7ab0b8855a61f480b4423c46c32fa7c553f0aac3531bbddaa282d86244f7a23
>(),
fee: 0x20c49ba5e353f80000000000000000,
tick_spacing: 354892,
// TWAMM Extension
extension: contract_address_const::<
0x73ec792c33b52d5f96940c2860d512b3884f2127d25e023eb9d44a678e4b971
>()
},
sqrt_ratio_limit: 0x7ea4d9526482a9577ead999cd4fa76f2ba8dfdca5b3f2f,
skip_ahead: 0x0
};
let token_address = contract_address_const::<
0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7
>();
let amount: u128 = 0x2386f26fc10000; // 10000000000000000
let token_amount = TokenAmount {
token: token_address, amount: i129 { mag: amount, sign: false }
};
let token = IERC20Dispatcher { contract_address: token_address };
let rich_account = contract_address_const::<
0x061fa009f87866652b6fcf4d8ea4b87a12f85e8cb682b912b0a79dafdbb7f362
>();
// Change the caller address to another
start_cheat_caller_address(token_address, rich_account);
token.transfer(test_address(), amount.into());
stop_cheat_caller_address(token_address);
assert(token.balanceOf(test_address()) >= amount.into(), 'trader has enough funds');
let router = IRouterDispatcher { contract_address: contract_address_const::<
EKUBO_ROUTER_ADDRESS
>() };
let result = router.multihop_swap(array![first_node, second_node], token_amount);
assert_eq!(starknet::get_contract_address(), test_address());
assert_eq!(*result[0].amount0.mag, amount);
assert_eq!(*result[1].amount0.mag, 0x2ab89317ae54c0); // 12024890918851776
}
Nice! (make sure to run the test). Now let swap (pun) the Router with our contract and run the test (snforge test test_profitable_arbitrage
):
#[test]
#[fork("SEPOLIA_FORK")]
fn test_profitable_arbitrage() {
let first_node = RouteNode {
// same as in the previous test
};
let second_node = RouteNode {
// same as in the previous test
};
let token_address = contract_address_const::<
0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7
>();
let amount: u128 = 0x2386f26fc10000; // 10000000000000000
let token_amount = TokenAmount {
token: token_address, amount: i129 { mag: amount, sign: false }
};
let token = IERC20Dispatcher { contract_address: token_address };
// We test a flash loan so the trader shouldn't have enough funds
let balance_before = token.balanceOf(test_address());
assert_eq!(balance_before, 0);
let dispatcher = declare_and_deploy();
assert_eq!(dispatcher.get_owner(), test_address());
let route = array![first_node, second_node];
dispatcher.multihop_swap(route, token_amount);
let balance_after = token.balanceOf(test_address());
let earned = balance_after - balance_before;
assert_eq!(earned, (0x2ab89317ae54c0 - amount).into());
}
Ownable
During the implementation we forgot one very important thing. Since all the data on Starknet (as well as many other chains) is public, anyone can call multihop_swap/multi_multihop_swap
methods of the contract (locked
method is protected thanks to function which checks that the caller should be Core contract). And if there is a bug in the contract implementation, then the bot owner is under the risk.
#[test]
#[fork("SEPOLIA_FORK")]
fn test_access() {
let dispatcher = declare_and_deploy();
let other_address = contract_address_const::<
0x061fa009f87866652b6fcf4d8ea4b87a12f85e8cb682b912b0a79dafdbb7f362
>();
start_cheat_caller_address(dispatcher.contract_address, other_address);
assert_ne!(other_address, test_address());
assert_eq!(dispatcher.get_owner(), test_address());
dispatcher.multi_multihop_swap(array![]);
}
This test fails with CORE_ONLY
error code from Ekubo’s consume_callback_data
used in our contract. It means that our implementation isn’t protected from being called by others. Necessary changes are made in the repository, feel free to explore.
Deployment to network
Now we can try to deploy our contract to Sepolia testnet. First of all, we should add our account (you can read about accounts in my previous article) so that sncast
could use it. Note a space before the first command (in order to exclude it from bash history).
$ sncast --url https://free-rpc.nethermind.io/sepolia-juno/v0_7 \
account add --name test --type argent \
--address 0x05c5e5Dd0A7C1c9F1A28202f6468bfa738172045c146bf8427EB81e238f7e520 \
--class-hash 0x029927c8af6bccf3f6fda035981e765a7bdbf18a2dc0d630494f8758aa908e2b \
--private-key <..>
$ sncast --url https://free-rpc.nethermind.io/sepolia-juno/v0_7 account list
Now let’s declare our contract class:
$ sncast --url https://free-rpc.nethermind.io/sepolia-juno/v0_7 \
--account test declare --contract-name arbitrage --fee-token strk
command: declare
class_hash: 0x6f9785bf61b7d1f1e1a0feacca5200d43a5009f1e0d12e316a514b8f5bcf290
transaction_hash: 0x72c9dab3770ac76d2642c02d99c5398b51fc9c0f9d6de4f750092843d0b8eca
And deploy an instance of the contract (note: we need to supply Ekubo Core
contract address for the constructor):
$ sncast --url https://free-rpc.nethermind.io/sepolia-juno/v0_7 \
--account test deploy --fee-token strk \
--class-hash 0x6f9785bf61b7d1f1e1a0feacca5200d43a5009f1e0d12e316a514b8f5bcf290 \
--constructor-calldata 0x05c5e5Dd0A7C1c9F1A28202f6468bfa738172045c146bf8427EB81e238f7e520 0x0444a09d96389aa7148f1aada508e30b71299ffe650d9c97fdaae38cb9a23384
command: deploy
contract_address: 0x5601e1c44f32f0ace600ffb64cd13e43cdcce0643a5fa282e806940ec2bccc2
transaction_hash: 0x57c44c823d14f2e26aafaf425b575c97232183c7174ab4d92f7b0dc02306aea
Let’s make a call (read-only):
$ sncast --url https://free-rpc.nethermind.io/sepolia-juno/v0_7 \
--account test call --function get_owner \
--contract-address 0x5601e1c44f32f0ace600ffb64cd13e43cdcce0643a5fa282e806940ec2bccc2
command: call
response: [0x5c5e5dd0a7c1c9f1a28202f6468bfa738172045c146bf8427eb81e238f7e520]
Let’s try to execute an arbitrage manually (transaction):
$ sncast --url https://free-rpc.nethermind.io/sepolia-juno/v0_7 \
--account test invoke --fee-token strk --function multihop_swap \
--contract-address 0x5601e1c44f32f0ace600ffb64cd13e43cdcce0643a5fa282e806940ec2bccc2 \
--calldata 0x2 0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 0x7ab0b8855a61f480b4423c46c32fa7c553f0aac3531bbddaa282d86244f7a23 0xccccccccccccccccccccccccccccccc 0x56a4c 0x73ec792c33b52d5f96940c2860d512b3884f2127d25e023eb9d44a678e4b971 0x1000003f7f1380b76 0x0 0x0 0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 0x7ab0b8855a61f480b4423c46c32fa7c553f0aac3531bbddaa282d86244f7a23 0x20c49ba5e353f80000000000000000 0x56a4c 0x73ec792c33b52d5f96940c2860d512b3884f2127d25e023eb9d44a678e4b971 0x577ead999cd4fa76f2ba8dfdca5b3f2f 0x7ea4d9526482a9 0x0 0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 0x2386f26fc10000 0x0
command: invoke
error: Transaction execution error = TransactionExecutionErrorData {
...
Failure reason: 0x756e70726f66697461626c652073776170 ('unprofitable swap').\n" }
I’ve also made necessary changes to the previous version of the bot. Now it can be run in two modes: using only our own funds (the version implemented in my previous articles) and using a flash loan (we use the contract defined in the article instead of Ekubo Router contract).
Conclusion
In this long-read article we learnt (hopefully) how to write smart contracts for Starknet and use a flash accounting feature in Ekubo for leveraging flash loans in arbitrage strategies. In the next article we’re going to further explore different MEV strategies in Starknet. Stay tuned and join our Starknet MEV community to learn more!
References
- https://github.com/ExtropyIO/defi-bot (accessed in August 2024)
- https://coinsbench.com/triangular-arbitrage-using-solidity-and-javascript-a-brief-explanation-21fd958f2556 (accessed in August 2024)
- https://github.com/yuichiroaoki/flash-swap-example (accessed in August 2024)
- https://github.com/flashbots/simple-arbitrage/ (accessed in August 2024)
- Margin Trading Guide (accessed in August 2024)
- https://book.cairo-lang.org/ (accessed in August 2024)
- https://starknet-by-example.voyager.online/ (accessed in August 2024)
- https://foundry-rs.github.io/starknet-foundry/ (accessed in August 2024)
- https://docs.uniswap.org/contracts/v4/concepts/lock-mechanism (accessed in August 2024)