How to create an atomic arbitrage bot in Starknet: part 2 (the foggy desert)

Maksim Ryndin
13 min readJul 16, 2024

--

L2 MEV draws more and more attention. Especially as Ethereum becomes DA-layer for L2 and a transactional activity shifts to L2s due to reduced gas fees and higher tps (transactions per second). As more DeFi products are created at L2, there are more opportunities for searchers. In this article we are going to build a basic atomic arbitrage bot for Ekubo DEX.

The first part is devoted to Starknet basics. Rust knowledge is assumed, the focus of the article is the atomic arbitrage strategy. Code snippets are provided only to illustrate concepts, all other tooling and setup can be looked up at the repository.

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.

What are CEXes and DEXes?

Liquidity is one of the cornerstone pillars of financial systems. Without liquidity there is no market. If you’re going to buy 1 dollar but nobody sells 1 dollar, there is no liquidity. If you’re going to sell your own hackaton-created token but struggling to find any buyer, then you have an illiquid asset.

In traditional finance (aka TradFi) there are exchanges which are centralized entities allowing a trader to exchange (that’s why the name) or swap a pair of assets (or make a sequence of interrelated swaps — a multi-hop swap). But small and medium traders have no direct access to exchanges — they make their trades via brokers (sometimes even large players use broker services to fulfill large orders in a time-distributed fashion to prevent severe price impacts). In a case a trader wants to buy an asset and there is no supply side, a broker can sell the asset (gaining some incentivizing rebates from the exchange besides a sale price) thus providing liquidity and being a market maker.

In crypto there are both CEXes (centralized exchanges) and DEXes (decentralized exchanges). DEXes are DeFi (decentralized finance) entities which allow traders to swap assets without an intermediary (a broker). The traders and other users are liquidity providers (LPs) themselves. Liquidity providers submit a pair of assets to the DEX pool (roughly a market for a pair of tokens) thus expressing their willingness to sell one asset for another to traders. Technically, DEXes are either on-chain smart contracts (like Ekubo in Starknet or Uniswap in Ethereum) or chains themselves (like dYdX v4 in Cosmos ecosystem).

Smart contracts DEXes have a built-in mechanism of liquidity regulation — usually it is a mathematical relation between reserves of tokens in the pool. Trades change the reserves in the pool and the price is changed accordingly thus automatically adjusting the pair market. Hence the name — Automatic Market Makers (AMMs).

There are many great resources on Automatic Market Makers (this article is highly recommended) and the visualization of a constant product AMM.

An atomic arbitrage

The onchain nature of DEXes opens a whole bunch of new trading opportunities (and risks as well due to a permissionless nature of most distributed ledgers) because of unique features of blockchains. One of such opportunities is an atomic arbitrage. But let’s first start with an arbitrage in general.

An arbitrage opportunity emerges when there is a price difference for the same asset at different markets. Then the strategy is to buy at a cheaper price and sell at a higher price and gain a profit (accounting for buy and sell fees at both markets). In practice though, due to short time windows and volatile assets when the price may change (“slip”) during an execution — so called an execution risk, — traders have some asset bought earlier at the second market with higher price where they would sell.

A popular form of an arbitrage is triangular: for example, when we buy STRK token for USDC, then sell STRK token for ETH, and finally sell ETH token for USDC (we will see an example of such an arbitrage below) — 3 swaps involved, hence the name.

The atomicity of transactions allows an atomic arbitrage to happen when a trader buys and sells an asset within one transaction. Usually an atomic arbitrage is associated with two DEXes on the same chain but there are cases when it is possible to seize an atomic arbitrage opportunity within a single DEX.

An atomic arbitrage is considered much less riskier (or even risk-free) compared to CEX-DEX arbitrage and has much lower entry capital requirements. As a consequence, returns on an atomic arbitrage are lower than those on CEX-DEX arbitrage and so atomic arbitrages are more often searched on markets with illiquid tokens where a cross-venue arbitrage being non-atomic would expose a trader to significant risks. The article A Tale of Two Arbitrages is highly recommended in this context.

On the technical side, an atomic arbitrage can be implemented as a smart contract — the so called blind arbitrage — we rely on the fact that a transaction is atomic and can reverted by providing the assertion that an arbitrage smart contract must grow its balance. Despite being implemented as a smart contract and considered of low risk, a blind arbitrage bot can be a victim of attack itself.

The second option is to rely on a node RPC to submit an arbitrage transaction via calling a DEX smart contract while identifying arbitrage opportunities off-chain via DEX indexer API (our approach here).

The third option is a combined approach when arbitrage opportunities are identified off-chain but instead of a DEX contract, the bot calls its own smart contract wrapping the arbitrage strategy.

In general for learning purposes an atomic arbitrage is a good starting point in MEV exploration due its relative simplicity and low risks (hopefully).

Ekubo

Ekubo is an Automatic Market Maker, implemented as a set of smart contracts on Starknet. In its design it is quite similar to Uniswap v4 featuring concentrated liquidity (one of the core developers of Uniswap — Moody Salem — is the creator of Ekubo). Again, we don’t go deep into the math behind, the references are provided.

The main contracts are Core (the core logic and state), Positions (a user-friendly interface in front of the Core for LPs — market makers) and Router (a user-friendly interface in front of Core for traders/swappers). Router builds routes for swaps (and that is what we need for an arbitrage).

For our purposes it is enough to understand that for every token pair there can be created multiple liquidity pools (differentiated by fee paid for a swap and price tick spacing basically). It means within the same DEX there can co-exist several markets for the same trading pair. Thus, there are even more arbitrage opportunities.

A Pool is uniquely identified by the following parameters comprising a PoolKey

  • a pair (token0 and token1)
  • a fee (a percentage of the amount to be paid to LPs)
  • a tick-spacing (a distance between two price ticks in this liquidity pool)
  • an extension

Extensions extend the functionality of the pool (compare with hooks in Uniswap v4), extension id 0 means there is no extension.

You can read more here and here (basically, DCA-enabled pools allow to swap huge amounts without significant price impact via dissecting the large order into many small ones which are executed over a specified time period). Extensions can potentially front-run swaps with their own swaps.

The official docs list the contracts addresses. We need a Router contract — let’s take the latest version V3.0.13 (the deployed contract address is 0x0199741822c2dc722f6f605204f35e56dbc23bceed54818168c4c49e4fb8737e). As the state is stored in Core contract, we can easily use latest Router contracts without any transfer of balances.

The basic workhorse method of Router contract class is swap (to trade against one pool). Let's dig deeper into its args. There are pool_key.-prefixed args to identify a pool against which we are going to trade, and:

  • sqrt_ratio_limit - this is a quantity related to the price - Ekubo uses fixed point Q64.128 numbers for price ratios. ERC20 standard specifies that a token should have decimals field to enable human-readable represenations. So for ETH decimals equals 18. But Ekubo (as well as Uniswap) ignores decimals in a price ratio so it should be taken into account.
  • token_amount (comprised of three felts — a token contract address, an amount, a sign) — it is either an input amount (if the sign is 0), or an output amount (when the sign is 1, i.e. negative) — when you want an exact output amount of token.

Playing with the API

As Ekubo smart contracts emit events during transactions, these events are indexed, stored and exposed by Ekubo team under HTTP API ( it is possible to run your own instance of indexer).

It is important to note that events may be emitted at a transaction validation stage even in the case the transaction is reverted.

API mirrors smart contracts functionality. Let’s now try to make the API call to get swap quotes. We would like to find price differences between pools where ETH is one of the tokens in a pair.

We ask for a quote (a recent price reflecting the current market conditions) to input 0.01 ETH (10**16 WEI) via 2 hops to get some ETH back. At first it sounds strange but our goal is to find an arbitrage opportunity — when we get back more tokens than we provided as an input due to price differences between liquidity pools.

curl 'https://mainnet-api.ekubo.org/quote/10000000000000000/0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7/0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7' | jq

Here 0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7 is a ERC20 contract address for ETH token on Starknet.

The response is

{
"specifiedAmount": "10000000000000000",
"amount": "9999778823633700",
"route": [
{
"pool_key": {
"token0": "0x42b8f0484674ca266ac5d08e4ac6a3fe65bd3129795def2dca5c34ecc5f96d2",
"token1": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
"fee": "0x68db8bac710cb4000000000000000",
"tick_spacing": 200,
"extension": "0x0"
},
"sqrt_ratio_limit": "0x11c4a8f60e33bd3c5d7878ce0958b6c3c",
"skip_ahead": "0x0"
},
{
"pool_key": {
"token0": "0x42b8f0484674ca266ac5d08e4ac6a3fe65bd3129795def2dca5c34ecc5f96d2",
"token1": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
"fee": "0xa7c5ac471b478666666666666666",
"tick_spacing": 20,
"extension": "0x0"
},
"sqrt_ratio_limit": "0x1147861dcdfffda7fd6fd09ac9cdff7f8",
"skip_ahead": "0x0"
}
]
}

Here we see that for our input amount specifiedAmount = 0.01 ETH (10000000000000000 WEI) we are offered amount = 0.0099997788236337 ETH (9999778823633700 WEI) and this doesn’t account for gas. Definitely, it is not an opportunity we dreamt of. But let’s dissect the fields further. We have a field route which shows us pools to go through to obtain the decrease in our wealth. The first pool is a pair ETH / wstETH (note that both token contract addresses have no leading 0s in their hex representations so it is important to work with Felt values in code). The first pool takes fee = 0x68db8bac710cb4000000000000000 or 0x68db8bac710cb4000000000000000 / 2**128 (Ekubo uses fixed point Q0.128 numbers for fees) or 0.0001 or 0.01%. The same calculation for the second pool gives a fee of 0.001%. Let see these pools in the app

Multiple pools for a single tokens pair

And there is another moment we should be aware of. Let’s try to swap 1 ETH (10**18 WEI):

curl 'https://mainnet-api.ekubo.org/quote/1000000000000000000/0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7/0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7' | jq

The amount returned is 999977133697446463 WEI or 0.9999771336974465 ETH (compare with 0.0099997788236337 ETH for 0.01 ETH) so the conversion rate function is not linear — the more amount we want to swap, the worse the exchange rate. An automatic market making prevents us from draining the pools.

sqrt_ratio_limit = 0x11c4a8f60e33bd3c5d7878ce0958b6c3cmeans (0x11c4a8f60e33bd3c5d7878ce0958b6c3c / 2**128) ** 2 (see the docs on price calculation) or 1.2332384488949992 — a limit on how far the price can move as part of the swap. So we potentially can buy 1 wstETH for no more than 1.2323… ETH.

Since token1 (a quote token, the numerator) is ETH and token0 (a base token, the denominator) is wstETH, then the limit price is 1.2332384488949992 ETH/wstETH.

For the second pool sqrt_ratio_limit = 0x1147861dcdfffda7fd6fd09ac9cdff7f8 means 1.1663176790238867 ETH for 1 wstETH.

And we also should remember about a price slippage — between the time we fetch quotes and the moment our transaction is received there is a chance that the price will change in an unfavorable direction.

Ekubo multihop swaps in the wild

Now let’s see the swaps in the explorer. We take amultihop_swap call (a generalization of swap allowing multiple hops in a route) and check its transaction. In the calldata section we can see that the multihop_swap method of the contract 0x0199…737e (its a Router V3.0.13) was called with the following arguments. The input amount token_amount

{
"token": {
// USDC token, 6 decimals
"value": "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8",
"type": "core::starknet::contract_address::ContractAddress"
},
"amount": {
"value": {
"mag": {
// 640 015 424 without taking into account decimals
// or 640.015424 USDC
"value": "0x2625dc40",
"type": "core::integer::u128"
},
// sign is not specified
// so this is an exact input amount
// if the sign was negative (0x01) it would be
// an exact output amount
"sign": {
"value": "0x00",
"type": "core::bool"
}
},
"type": "ekubo::types::i129::i129"
}
}

Theroute argument shows the path of swaps (for calculating price limits please refer to the docs).

[
{
"pool_key": {
"value": {
"token0": {
// STRK token, decimals 18
// https://voyager.online/contract/0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d
"value": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d",
"type": "core::starknet::contract_address::ContractAddress"
},
"token1": {
// USDC token, decimals 6
// https://voyager.online/contract/0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8
"value": "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8",
"type": "core::starknet::contract_address::ContractAddress"
},
"fee": {
// 0.01%
"value": "0x068db8bac710cb4000000000000000",
"type": "core::integer::u128"
},
"tick_spacing": {
// 200
"value": "0xc8",
"type": "core::integer::u128"
},
"extension": {
"value": "0x00",
"type": "core::starknet::contract_address::ContractAddress"
}
},
"type": "ekubo::types::keys::PoolKey"
},
"sqrt_ratio_limit": {
// Price limit
// ratio_limit = (int("0xfffffc080ed7b4556f3528fe26840249f4b191ef6dff7928", 16) / 2**128) ** 2
// price_limit = ratio_limit * 10**12 (to adjust for decimals)
// 340282205938518420846375217872744239629034703552512 USDC/STRK
"value": "0xfffffc080ed7b4556f3528fe26840249f4b191ef6dff7928",
"type": "core::integer::u256"
},
"skip_ahead": {
"value": "0x00",
"type": "core::integer::u128"
}
},
{
"pool_key": {
"value": {
"token0": {
// STRK token, decimals 18
// https://voyager.online/contract/0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d
"value": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d",
"type": "core::starknet::contract_address::ContractAddress"
},
"token1": {
// ETH token, decimals 18
// https://voyager.online/contract/0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7
"value": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
"type": "core::starknet::contract_address::ContractAddress"
},
"fee": {
// 0.05%
"value": "0x20c49ba5e353f80000000000000000",
"type": "core::integer::u128"
},
"tick_spacing": {
// 1000
"value": "0x03e8",
"type": "core::integer::u128"
},
"extension": {
"value": "0x00",
"type": "core::starknet::contract_address::ContractAddress"
}
},
"type": "ekubo::types::keys::PoolKey"
},
"sqrt_ratio_limit": {
// price_limit 2.938737267327691e-39 ETH/STRK
"value": "0x01000003f7f1380b75",
"type": "core::integer::u256"
},
"skip_ahead": {
"value": "0x00",
"type": "core::integer::u128"
}
},
{
"pool_key": {
"value": {
"token0": {
// ETH token, decimals 18
// https://voyager.online/contract/0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7
"value": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7",
"type": "core::starknet::contract_address::ContractAddress"
},
"token1": {
// USDC token, decimals 6
// https://voyager.online/contract/0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8
"value": "0x053c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8",
"type": "core::starknet::contract_address::ContractAddress"
},
"fee": {
// 0.05%
"value": "0x20c49ba5e353f80000000000000000",
"type": "core::integer::u128"
},
"tick_spacing": {
// 1000
"value": "0x03e8",
"type": "core::integer::u128"
},
"extension": {
"value": "0x00",
"type": "core::starknet::contract_address::ContractAddress"
}
},
"type": "ekubo::types::keys::PoolKey"
},
"sqrt_ratio_limit": {
// price_limit 2.938737267327691e-27
"value": "0x01000003f7f1380b75",
"type": "core::integer::u256"
},
"skip_ahead": {
"value": "0x00",
"type": "core::integer::u128"
}
}
]

The response below is an array of deltas of changes in the balances of Core contract. So the positive amount 640.015424 USDC means that Core received USDC in the first hop and lost 1077.2345205584716 STRK (the negative amount in the first hop).

[
{
"amount0": {
// -1077.2345205584716 STRK
"value": {
"mag": {
"value": "0x3a65a1d103cbeda6ee",
"type": "core::integer::u128"
},
"sign": {
"value": "0x01",
"type": "core::bool"
}
},
"type": "ekubo::types::i129::i129"
},
"amount1": {
// 640.015424 USDC
"value": {
"mag": {
"value": "0x2625dc40",
"type": "core::integer::u128"
},
"sign": {
"value": "0x00",
"type": "core::bool"
}
},
"type": "ekubo::types::i129::i129"
}
},
{
"amount0": {
// 1077.2345205584716 STRK
"value": {
"mag": {
"value": "0x3a65a1d103cbeda6ee",
"type": "core::integer::u128"
},
"sign": {
"value": "0x00",
"type": "core::bool"
}
},
"type": "ekubo::types::i129::i129"
},
"amount1": {
// -0.19019801153590166 ETH
"value": {
"mag": {
"value": "0x02a3b815729543d2",
"type": "core::integer::u128"
},
"sign": {
"value": "0x01",
"type": "core::bool"
}
},
"type": "ekubo::types::i129::i129"
}
},
{
"amount0": {
// 0.19019801153590166 ETH
"value": {
"mag": {
"value": "0x02a3b815729543d2",
"type": "core::integer::u128"
},
"sign": {
"value": "0x00",
"type": "core::bool"
}
},
"type": "ekubo::types::i129::i129"
},
"amount1": {
// -640.708278 USDC
"value": {
"mag": {
"value": "0x26306eb6",
"type": "core::integer::u128"
},
"sign": {
"value": "0x01",
"type": "core::bool"
}
},
"type": "ekubo::types::i129::i129"
}
}
]

This transaction is an example of an atomic triangular arbitrage resulting in a profit of 0.692854 USDC (without transaction fees).

640.015424 USDC — 1077.2345205584716 STRK — 0.19019801153590166 ETH — 640.708278 USDC

We can look up an actual fee paid in the explorer — it is 0.000003940630801152 ETH or $0.013287. So the net profit was around $0.679566 or 0.11% of the investment.

In the same transaction we see that besides multihop_swap call there is also a clear call to USDC ERC20 token — the trader takes the profit. Tokens transferred in the transaction details summarizes flows:

Tokens transfer during the arbitrage

The input (640.015424 USDC) was transferred from Ekubo Router (0x0199…737e) to Ekubo Core, the multihop swap happened inside Core, the output (640.708278 USDC) was transferred to Ekubo Router, and then the trader transferred the profit (0.692854 USDC) from Router to his/her account. And everything happened within the single transaction.

You may wonder why the profit (0.692854 USDC) and not the output (640.708278 USDC) is withdrawn — the answer is flash accounting — only the resulting difference can be transferred. There is also another Router method clear_minimum which allows to set the lower bound on the output to be withdrawn — so the input has to be transferred first to Router.

An implementation

Let’s summarize what we’ve learnt so far:

  • an atomicity of a transaction — either it passes or is reverted — allows for low-risk strategies
  • multiple pair markets within the same DEX allow to build multi-hop routes to swap tokens
  • the swap price depends on the amount due to an automatic market making

So among our input parameters which we can vary to identify an arbitrage can be (the list isn’t exhaustive):

  • an amount
  • a token
  • a number of hops

Our exploration suggests the following algorithm.

  1. specify different amounts of the target token to swap up to the limit of our account balance
  2. get quotes for every specified amount and identify the top profit arbitrage opportunity (the greatest positive difference between an amount to receive and the specified amount)
  3. execute a multihop_swap/multi_multihop_swap transaction with transfer and clear_minimum calls to Router to provide a protection against price slippage (as all 3 calls are executed inside a single transaction which is atomic). multi_multihop_swap is a further generalization of swap when a specified amount can be split between several multi-hop swaps.

The proposed arbitrage strategy is implemented in the repository.

Conclusion

In this article we discussed general notions of liquidity pools and DEXes, played with Ekubo DEX API and explored a basic atomic arbitrage strategy in Starknet.

I am going to further explore different MEV strategies in Starknet so stay tuned! Thank you!

PS: we’re building a Starknet MEV community — feel free to join.

References

  1. https://bennyattar.substack.com/p/the-evolution-of-amms (accessed in July 2024)
  2. https://docs.ekubo.org/ (accessed in July 2024)
  3. https://www.paradigm.xyz/2021/07/twamm (accessed in July 2024)
  4. https://frontier.tech/a-tale-of-two-arbitrages (accessed in July 2024)
  5. https://book.cairo-lang.org/ (accessed in July 2024)
  6. https://docs.uniswap.org/contracts/v4/concepts/intro-to-v4 (accessed in July 2024)
  7. https://uniswap.org/whitepaper-v3.pdf (accessed in July 2024)
  8. https://rekt.news/ (accessed in July 2024)

--

--