An arbitrage with a flash loan in Starknet: a lending platform + DEX

Maksim Ryndin
8 min readAug 26, 2024

--

If you’re interested in learning more about MEV opportunities on Starknet, feel free to join an open Starknet MEV community in Telegram.

This article is a sequel to my previous one where we discussed how to use Ekubo builtin flash accounting feature to leverage an additional capital for an arbitrage for no interest fees. But say, we’re going to arbitrage on another Starknet DEX, e.g. JediSwap. While it has Uniswap v2/v3-like flash swaps but the fee is slightly higher than in case of a pre-funded swap. In that case, we can use a lending platform like ZkLend (has a small fee though) or Vesu (has zero-interest flash loans) which provides flash loans.

ZkLend in the context of liquidation bots (another decent MEV strategy to consider) is very well described in the articles of Kristian Aristizabal — I highly recommend them for the reference.

In this article we’re going to utilize Vesu for flash loans.

Vesu looks similar to Ekubo in a design sense: it also uses pools, a singleton core contract and extensions (which, being supplied by protocol users, add new features to pools via hooks). We’re not going to describe the lending protocol here as it is irrelevant for our case but refer to the excellent documentation.

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.

Setup

As I’ve already described in detail the process in the previous article, let me only provide necessary commands:

$ snforge init vesu_flash_loan

In Scarb.toml

// ...
edition = "2024_07"

// ...
[[tool.snforge.fork]]
name = "MAINNET_FORK"
url = "https://free-rpc.nethermind.io/mainnet-juno/v0_7"
block_id.number = "671358" # we use a specific block for reproducibility

All the code related to the smart contract you can find in the repository.

Flash loan

Vesu implements (see flash_loan method) flash loans close to EIP-3156. As Vesu uses old snforge and starknet versions (as of August 2024), instead of pulling it as dependency, we’re going to add required interfaces manually as we use only tiny parts of them.

pub mod erc20;
use starknet::ContractAddress;

#[starknet::interface]
pub trait IArbitrageur<TContractState> {
// Flash loan callback
fn on_flash_loan(
ref self: TContractState,
sender: ContractAddress,
asset: ContractAddress,
amount: u256,
data: Span<felt252>
);

// Does a multihop swap, where the output of each hop is passed as input of the
// next swap
fn multihop_swap(ref self: TContractState, path: Array<felt252>, amount: u256);

// Get the owner of the bot, read-only (view) function
fn get_owner(self: @TContractState) -> ContractAddress;
}

// https://github.com/vesuxyz/vesu-v1/blob/main/src/singleton.cairo
#[starknet::interface]
pub trait IVesu<TContractState> {
fn flash_loan(
ref self: TContractState,
receiver: ContractAddress,
asset: ContractAddress,
amount: u256,
is_legacy: bool,
data: Span<felt252>
);
}

The flash loan callback follows IFlashloanReceiver trait so we just introduce it directly in our contract interface. Let’s implement a basic contract template:

#[starknet::contract]
pub mod arbitrage {
use super::{IVesuDispatcherTrait, IVesuDispatcher};
use super::erc20::{IERC20Dispatcher, IERC20DispatcherTrait};
use core::num::traits::Zero;
use starknet::{ContractAddress, get_caller_address, get_contract_address};
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};

#[storage]
struct Storage {
vesu: IVesuDispatcher,
owner: ContractAddress,
}

#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress, vesu: ContractAddress) {
let vesu = IVesuDispatcher { contract_address: vesu };
self.vesu.write(vesu);
assert(!owner.is_zero(), 'owner is the zero address');
self.owner.write(owner);
}

#[abi(embed_v0)]
impl ArbitrageImpl of super::IArbitrageur<ContractState> {
fn on_flash_loan(
ref self: ContractState,
sender: ContractAddress,
asset: ContractAddress,
amount: u256,
data: Span<felt252>
) {
let vesu = self.vesu.read();
assert(get_caller_address() == vesu.contract_address, 'unauthorized');
assert(get_contract_address() == sender, 'unauthorized');

// for testing purposes only
let token_address = starknet::contract_address_const::<
0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7
>();
let token = IERC20Dispatcher { contract_address: token_address };
let balance = token.balanceOf(get_contract_address());
assert(balance == amount, 'loan received');
}

// https://book.cairo-lang.org/ch11-06-inlining-in-cairo.html
#[inline(always)]
fn multihop_swap(ref self: ContractState, path: Array<felt252>, amount: u256) {
let owner = self.owner.read();
assert(owner == get_caller_address(), 'unauthorized');
let token = IERC20Dispatcher { contract_address: (*path.at(0)).try_into().unwrap() };
let vesu = self.vesu.read();
// Allow Vesu to take back the loan
token.approve(vesu.contract_address, amount);
vesu
.flash_loan(
get_contract_address(), token.contract_address, amount, false, array![].span()
);
}

fn get_owner(self: @ContractState) -> ContractAddress {
self.owner.read()
}
}
}

Note that as the flash loan receiver is the caller of flash_loan method of Vesu Singleton (if we look at the implementation), we should borrow on behalf of our contract (get_contract_address()) and not the owner.

And the following test passes:

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(), VESU_SINGLETON_ADDRESS])
.unwrap();

// Create a Dispatcher object that will allow interacting with the deployed contract
IArbitrageurDispatcher { contract_address }
}

#[test]
#[fork("MAINNET_FORK")]
fn test_flash_loan() {
// we take one of the tokens supported by Vesu on Sepolia
// https://github.com/vesuxyz/changelog/blob/main/deployments/deployment_sn_main.json
// ETH
let token_address = contract_address_const::<
0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7
>();

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 amount: u256 = 1000;
// We can check the balance of Vesu in the explorer
// https://voyager.online/contract/0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7#readContract
let balance = token.balanceOf(contract_address_const::<VESU_SINGLETON_ADDRESS>());
assert(balance > amount.into(), 'amount fits');
dispatcher.multihop_swap(array![token.contract_address.into()], amount);
let balance_after = token.balanceOf(test_address());
assert_eq!(balance_after, 0);
}

If we would try to use owner as a flash loan receiver, we would see the error:

$ snforge test
[FAIL] tests::test_contract::test_flash_loan

Failure data:
Got an exception while executing a hint: Hint Error: Error at pc=0:1903:
Got an exception while executing a hint: Error at pc=0:56680:
Got an exception while executing a hint: Entry point
EntryPointSelector(
StarkFelt(
"0x004f32fc9f350e6a05fd4df19e6e457ec9d34b03c1197116794986b6482f2962"
)
)
not found in contract.
Cairo traceback (most recent call last):
Unknown location (pc=0:10380)
Unknown location (pc=0:38870)

Cairo traceback (most recent call last):
Unknown location (pc=0:791)

You can use starkli to guess (as it’s a hash, we cannot get a selector from hash) the selector name:

$ starkli selector on_flash_loan
0x004f32fc9f350e6a05fd4df19e6e457ec9d34b03c1197116794986b6482f2962

(Function selectors are encoded only by names in Starknet as opposed to Ethereum where the full signature is used). So this error is due to that the account contract calling the flash_loan doesn’t implement on_flash_loan callback. That’s why we borrow on behalf of the contract and not the owner.

Swap

JediSwap v2 is a clone of Uniswap v3 but in Starknet. So it features concentrated liquidity (i.e. when liquidity providers supply tokens in pre-defined price ranges). We’re going to interact with JediSwapV2SwapRouter contract which provides convenient high-level methods. Again, due to frozen old snforge and starknet dependencies, we just re-recreate necessary interfaces in our repository:

use starknet::ContractAddress;

#[starknet::interface]
pub trait IJediSwapV2SwapRouter<TContractState> {
fn exact_input(ref self: TContractState, params: ExactInputParams) -> u256;
}

#[derive(Drop, Serde)]
pub struct ExactInputParams {
pub path: Array<felt252>, //[token1, token2, fee1, token2, token3, fee2]
pub recipient: ContractAddress,
pub deadline: u64,
pub amount_in: u256,
pub amount_out_minimum: u256
}

A correct path format can be looked up in the implementation of JediSwapV2SwapRouter: _exact_input_internal method deserializes a path slice of length 3 into PathData struct which sets the order of each hop: token_in, token_out, fee .

Let’s change the implementation of key methods:

#[starknet::contract]
pub mod arbitrage {
use super::{IVesuDispatcherTrait, IVesuDispatcher};
use super::erc20::{IERC20Dispatcher, IERC20DispatcherTrait};
use super::jediswap::{
IJediSwapV2SwapRouterDispatcher, IJediSwapV2SwapRouterDispatcherTrait, ExactInputParams
};
use core::num::traits::Zero;
use starknet::{ContractAddress, get_caller_address, get_contract_address, get_block_timestamp};
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};

#[storage]
struct Storage {
vesu: IVesuDispatcher,
jediswap: IJediSwapV2SwapRouterDispatcher,
owner: ContractAddress,
}

#[constructor]
fn constructor(
ref self: ContractState,
owner: ContractAddress,
vesu: ContractAddress,
jediswap: ContractAddress
) {
assert(!owner.is_zero(), 'owner is the zero address');
let vesu = IVesuDispatcher { contract_address: vesu };
self.vesu.write(vesu);
let jediswap = IJediSwapV2SwapRouterDispatcher { contract_address: jediswap };
self.jediswap.write(jediswap);
self.owner.write(owner);
}

#[abi(embed_v0)]
impl ArbitrageImpl of super::IArbitrageur<ContractState> {
fn on_flash_loan(
ref self: ContractState,
sender: ContractAddress,
asset: ContractAddress,
amount: u256,
mut data: Span<felt252>
) {
assert(get_contract_address() == sender, 'unauthorized');
let vesu = self.vesu.read();
assert(get_caller_address() == vesu.contract_address, 'unauthorized');
let jediswap = self.jediswap.read();

let params: ExactInputParams = Serde::deserialize(ref data)
.expect('deserialize swap params');
// Approve the router to spend token.
// https://docs.jediswap.xyz/for-developers/jediswap-v1/smart-contract-integration/implement-a-swap#id-2.-approve
let token = IERC20Dispatcher { contract_address: asset };
token.approve(jediswap.contract_address, amount);
let swapped = jediswap.exact_input(params);

assert(swapped > amount, 'unprofitable swap');
let owner = self.owner.read();

// take profit to the owner
token.transfer(owner, swapped - amount);
}

// https://book.cairo-lang.org/ch11-06-inlining-in-cairo.html
#[inline(always)]
fn multihop_swap(ref self: ContractState, path: Array<felt252>, amount: u256) {
let owner = self.owner.read();
assert(owner == get_caller_address(), 'unauthorized');
assert(*path.at(0) == *path.at(path.len() - 2), 'the same token');

let token = IERC20Dispatcher {
contract_address: (*path.at(0)).try_into().expect('first token')
};
let vesu = self.vesu.read();
// Allow Vesu to take back the loan
token.approve(vesu.contract_address, amount);

let args = ExactInputParams {
path,
recipient: owner,
deadline: get_block_timestamp(),
amount_in: amount,
amount_out_minimum: amount,
};

let mut serialized: Array<felt252> = array![];

Serde::serialize(@args, ref serialized);

vesu
.flash_loan(
get_contract_address(), token.contract_address, amount, false, serialized.span()
);
}

fn get_owner(self: @ContractState) -> ContractAddress {
self.owner.read()
}
}
}

Note also that we added a JediSwap router address as a constructor argument.

Now let’s think how we are going to test it. We should obtain some quotes (like we did offchain with Ekubo API). But the only thing I know is this indexer repo which we could run ourselves.

For the purposes of the test we can look up a pool at the app page. I’ve just chosen ETH-STRK pool with fee 0.3% (or 0.3 * 100 * 100 to transform into input u32).

And we are ready to modify the test test_flash_loan into the following:

#[should_panic(expected: ('Too little received',))]
#[test]
#[fork("MAINNET_FORK")]
fn test_arbitrage() {
// we take one of the tokens supported by Vesu on Sepolia
// https://github.com/vesuxyz/changelog/blob/main/deployments/deployment_sn_sepolia.json
let (dispatcher, token, amount) = setup();
// it is important to choose the first token among those supported by Vesu for flash loans
// Swap via STRK
dispatcher
.multihop_swap(
array![
token.contract_address.into(),
0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d,
3000,
0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d,
token.contract_address.into(),
3000
],
amount
);
let balance_after = token.balanceOf(test_address());
assert_eq!(balance_after, 0);
}

We have our profitability assertion (assert(swapped > amount, ‘unprofitable swap’);) but the router assertion (assert(amount_out >= params.amount_out_minimum, ‘Too little received’);) triggers earlier as we set the minimum output amount that’s why we set it for the test expected panic. In any case we preserve our own condition as a guard against any code changes of the router.

As you may try another route, you can encounter an error:

[FAIL] tests::test_contract::test_arbitrage

Failure data:
0x4153 ('AS')

This error code is in the assertion assert(amount_specified.is_non_zero(), ‘AS’); of the pool implementation and it means (despite the non-zero input amount) that along the route we didn’t get an interim amount to proceed. It means that the pool may not have enough liquidity.

Conclusion

In this article we learnt how to use flash loans on Starknet to leverage our arbitrage operations with an additional capital. We also touched another AMM called JediSwap v2 which is a clone of Uniswap v3.

In the next article we will learn how to create an indexer for JediSwap and calculate quotes for arbitrage opportunities search in a similar vein as Ekubo API does.

References

  1. https://docs.vesu.xyz/ (accessed in August 2024)
  2. https://docs.uniswap.org (accessed in August 2024)
  3. https://docs.jediswap.xyz/ (accessed in August 2024)

--

--