How to create an atomic arbitrage bot in Starknet: part 1 (basics)
Recently I’ve become interested in MEV (Maximum Extractable Value) in L2 context (it is actively researched). Besides, I am fascinated by ZK stuff so trying Starknet was an obvious choice. There are not so many beginner-friendly practical resources on the topic so I’ve decided to come up with my own pet projects and articles to learn it more. I’ve found this repository and decided to understand the source.
MEV in the broader setting refers to searching profitable opportunities in the crypto space. The topic of MEV is huge and here we will focus only on one very specific MEV strategy.
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.
In this article we will learn/refresh Starknet basics and in the following one we will create an atomic arbitrage bot in Rust for Ekubo decentralized exchange (it is among the top in Starknet by trading volume). I assume that you have some basic knowledge of blockchains and cryptocurrencies, block explorers and you know Rust. While trying to introduce necessary concepts along the way (very briefly though), I provide code snippets only to illustrate concepts and do not describe a general setup of a Rust project assuming that the reader has enough experience in Rust to lookup necessary details in the repository.
Starknet
Starknet is a Layer 2 (L2) Zero-knowledge (ZK) rollup which aims to scale Ethereum (L1) by batching transactions (rolling them up), “compressing” them and submitting the compacted result (ZK Proof) to the L1 thus increasing a throughput and decreasing gas fees.
You can check current TPS (transactions per second) and other network statistics here.
To make the article reasonably sized, I won’t go deeper into Starknet architecture — you can check the official docs. For our purposes from a user and applied developer perspective it is enough to assume that Starknet works roughly the same as other blockchains (e.g. Ethereum). There are nodes which allow to interact with the blockchain via Remote Procedure Call (RPC) interface. For ease of use RPC usually abstracted into client libraries in different languages. For this article we use https://github.com/xJonathanLEI/starknet-rs/ — a widely accepted Rust client library for Starknet.
The RPC interface allows to query the state of the blockchain (read-only calls which do not require paying gas fees). The usually named abstraction A read-only RPC client is conventionally called an rpc provider. You can run your own node or use one of the free services. For example, we can fetch the chain id
let rpc_transport = HttpTransport::new(Url::parse("https://free-rpc.nethermind.io/sepolia-juno/v0_7")?);
let provider = JsonRpcClient::new(rpc_transport);
let chain_id = provider.chain_id().await?;
chain_id’s were introduced by Ethereum community to protect from replay attacks and since then have been introduced in many other blockchains. For Starknet we have chain ids for Mainnet (production) and Sepolia (testnet, named after Ethereum testnet to which it is corresponding).
Any user’s interaction which changes the chain’s state consumes computational and storage resources and so must be paid (as gas fees) by the user from its account. So the state is changed with transactions packed in blocks. And blocks are hashed one after another to make a hashchain. Transactions are specifically encoded messages which are cryptographically signed by a user (with a private key) and sent to a blockchain node. An RPC client with write abilities is conventionally called a signer. Signer is associated with an account which reflects user’s balance in the blockchain. We will return to transaction signing a little bit later.
Transactions are atomic, i.e. they either pass or revert. In both cases the user pays gas fees for usage of computational and storage resources of the blockchain. The atomicity of transactions allows very interesting trading strategies. But before we are going to have a look at it, let’s discuss the technological breakthroughs underneath.
The state of the chain can also be queried with publicly available services called block explorers which index the whole history of blockchain state changes and allow you to make different read and write queries against it. One of the popular block explorers in Starknet is https://voyager.online/ which we use in this article.
Smart contracts and ERC20 tokens
Assets on chain are usually represented as tokens. Technically these tokens are implemented as smart contracts (a concept pioneered by Ethereum) — programs submitted via transactions to the chain and executed by nodes. For Starknet these smart contracts are written in Cairo (compare with Solidity in Ethereum). Besides tokens, smart contracts allow to implement arbitrary logic which is executed by blockchain nodes (”onchain”). Execution of contracts consumes computation and storage resources and so requires paying gas fees.
Along the evolution of different DeFi products the need for the single token standard (basically smart contract API called ABI — Application Binary Interface) rose. So Ethereum community came with ERC20 to standardize the interface of interaction with such token contracts.
Starknet (as many other chains) also adopted ERC20 standard. You can check the available ERC20 token contracts in the explorer (for Sepolia and for mainnet).
Accounts
As we mentioned, to submit a transaction to Starknet (as in many other blockchains) a user should have an account which is associated with a private key to sign the transaction.
The easiest way to create a Starknet account with a private key is to use a wallet (see https://www.starknet.io/blog/getting-started-using-starknet-setting-up-a-starknet-wallet/).
You can get some free testnet tokens with the faucet. At the faucet you will see ETH and STRK options — Starknet allows both tokens to be used for payment of transaction fees. As we use transactions of version 1 here, then you should choose ETH. Then you will see funds associated with the account in the wallet interface.
Even when you get some tokens to your account, it is still considered unitialized. Copy an account address from the interface of your wallet and check your account with the explorer https://sepolia.voyager.online/contract/{account address}
.
At the explorer you will see the following picture (it is important to fund your account to see it in the block explorer).
It is a notable UX difference from Ethereum.
In Starknet any new smart contract (including a user account) is first declared as a contract class which contains the logic of the contract. But to actually use any contract you have to deploy an instance of the contract class (it may seem as an unnecessary complication but it actually allows for a contract upgrade — when the logic of the contract is updated with a new class but the storage (state) is preserved). The address of the account is pre-computed so you can fund it to pay gas fees for the deployment transaction (transfer tokens to it from somewhere — a faucet for testnet, a CEX via buying tokens for fiat money for mainnet, an existing Ethereum account using Ethereum -Starknet bridge etc). Many Starknet wallets do account deployment either with the first transaction or in the interface. In other cases you can do it yourself.
So we learned that every smart contract in Starknet is actually a deployed instance of some declared contract class (the latter acting as a mold to produce smart contracts).
Remember, every token is a smart contract and has publicly available methods. From the list of ERC20 tokens we can choose the deployed ETH contract and follow its contract address.
Here we see the Class hash of the declared contract Class. Let’s follow the link and on the line 573 of ABI we can the `balanceOf` method which takes an account address as its argument (arguments for smart contract methods are named calldata). There is also a nice summary of all methods just above the ABI in Voyager interface. But to query the ETH balance of our account we need to call a corresponding `balanceOf` method of the deployed ETH ERC20 smart contract on Starknet. Let’s call this method with our account address (no worries, read-only queries do not require any fees and we use the testnet in this article).
ERC20 standard suggests that `balanceOf` method returns an available token balance of the account as u256. But here we see an array of 2 numbers. The reason is that Starknet operates with numbers in Felt (Field ELemenT) which are 252 bit numbers. So we can construct a u256 from two felts (both should be no more than 2**128 to provide low and high limbs). If you choose Formatted Response and Response Format “Decimal” in Voyager, you will receive the expected balance (the same is displayed in the wallet — 10000000000000000 WEI or 0.01 ETH). There are convenient conversion utilities in Voyager for other ad-hoc calculations.
Permissionless blockchains are public so anyone can lookup a balance of your account and all your activity.
Programmatically we can get the same result with the code:
async fn get_account_balance(token_contract: Felt, account_address: Felt, provider: &JsonRpcClient<HttpTransport>) -> Result<U256> {
let felts = provider
.call(
FunctionCall {
contract_address: token_contract,
entry_point_selector: get_selector_from_name("balanceOf")?,
calldata: vec![
account_address
],
},
BlockId::Tag(BlockTag::Latest),
)
.await.map_err(|e|
eyre!("Error when fetching account balance:\n{e:#?}")
)?;
let low = u128::from_le_bytes(felts[0].to_bytes_le()[0..16].try_into()?);
let high = u128::from_le_bytes(felts[1].to_bytes_le()[0..16].try_into()?);
// the unit of data in Cairo is Felt (u252) but ERC20 standard suggests to return u256 from balanceOf
// So the token contract returns a pair of low (128 bits) and high (128 bits) to construct a u256
Ok(U256::from_words(low, high))
}
Note: we send a read-only call (type FunctionalCall in starknet-rs) with an rpc provider. starknet-rs hasn’t yet offered an ABI parsing so we should explicitly encode function selector and convert required arguments into felts.
Sending transactions
Danger zone: be sure to keep your private key secret (do not commit it with git, don’t accidentally confuse it with your account address and paste on some website, be aware of malicious software and especially browser extensions and so on). A private key for an account is the same for both Mainnet and Sepolia. So any leak is harmful.
Remember, that for the write access to the blockchain we need a private key to sign transactions. If you used a wallet to create your account, you can copy your private key from your account menu.
This is a practical exercise that will also introduce other important things.
- Create a second Starknet account, fund it with Sepolia faucet.
- Transfer some amount of tokens from your second account to your first one with the following code (reminder: you can look up basic building blocks and dependencies in the repository).
let rpc_transport = HttpTransport::new(Url::parse(
"https://free-rpc.nethermind.io/sepolia-juno/v0_7",
)?);
let provider = JsonRpcClient::new(rpc_transport);
let signer = LocalWallet::from(SigningKey::from_secret_scalar(Felt::from_hex(&env::var(
"ACCOUNT2_PRIVATE_KEY",
)?)?));
let account_address = Felt::from_hex(&env::var("ACCOUNT2_ADDRESS")?)?;
let recepient_address = Felt::from_hex(&env::var("ACCOUNT1_ADDRESS")?)?;
let token_address =
Felt::from_hex("0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7")?; // ETH ERC20
let account_balance = get_account_balance(token_address, account_address, &provider).await?;
let recepient_balance =
get_account_balance(token_address, recepient_address, &provider).await?;
info!("Account balance: {account_balance} WEI, recepient balance: {recepient_balance} WEI");
let account = SingleOwnerAccount::new(
provider,
signer,
account_address,
chain_id::SEPOLIA,
ExecutionEncoding::New,
);
// 0.01 ETH
let amount = Felt::from_dec_str("10000000000000000")?;
let transfer_call = Call {
to: token_address,
selector: get_selector_from_name("transfer")?,
calldata: vec![recepient_address, amount, Felt::ZERO],
};
let fee = account
.execute_v1(vec![transfer_call.clone()])
.estimate_fee()
.await
.map_err(|e| eyre!("Error while estimating fee:\n{e:#?}"))?;
info!("fee etimation: {} WEI", fee.overall_fee);
let tx = account
.execute_v1(vec![transfer_call])
.max_fee(fee.overall_fee + Felt::TWO)
.send()
.await
.map_err(|e| eyre!("Error while estimating fee:\n{e:#?}"))?;
info!(
"sent transaction, check status:\nhttps://sepolia.voyager.online/tx/{:#x}",
tx.transaction_hash
);
Important things to pay attention to:
- there is an `execute_v1` method of the account which prepares a transaction in a version 1 format (fees are paid in ETH, to be deprecated). there is also a version 3 format (fees are paid in STRK)
- Method arguments are only felts and Rust types should be converted into Felt properly. Here is a canonical example. An ABI signature of
transfer
takes an amount as u256 so we have to provide two felts (compare with the return value ofbalanceOf
). But many scalar types just fit Felt (252 bits) and convenient conversions are provided. Non-scalar types serialization is also straightforward. - when we have sent a transaction, we wait for it to be either accepted (first at L2, then at L1) or rejected. In other words, it is not finalized immediately. In practice you can consider a transaction successful if it has an
ACCEPTED_ON_L2
status.
Programmatically we can get a status of a transaction and its receipt with the following code:
// Wait for the transaction to be accepted
async fn wait_for_transaction(
provider: &JsonRpcClient<HttpTransport>,
tx_hash: Felt,
) -> Result<TransactionReceiptWithBlockInfo> {
let mut retries = 200;
let retry_interval = Duration::from_millis(3000);
while retries >= 0 {
tokio::time::sleep(retry_interval).await; // sleep before the tx status to give some time for a tx get to the provider node
let status = provider
.get_transaction_status(tx_hash)
.await
.map_err(|e| eyre!("failed to get tx status: {e:#?}"))?;
retries -= 1;
match status {
TransactionStatus::Received => continue,
TransactionStatus::Rejected => bail!("transaction is rejected"),
TransactionStatus::AcceptedOnL2(_) | TransactionStatus::AcceptedOnL1(_) => {
match provider.get_transaction_receipt(tx_hash).await {
Ok(receipt) => return Ok(receipt),
// For some nodes even though the transaction has execution status SUCCEEDED finality status ACCEPTED_ON_L2,
// get_transaction_receipt returns "Transaction hash not found"
// see https://github.com/starknet-io/starknet.js/blob/v6.7.0/src/channel/rpc_0_7.ts#L248
Err(_) => continue,
}
}
}
}
bail!("maximum retries attempts")
}
Conclusion
In this article we briefly introduced/refreshed necessary basic concepts, learnt some Starknet specific features, accounts basics, read-write calls via JSON RPC api and starknet-rs.
In the next article we will use Ekubo DEX to investigate MEV opportunities in Starknet. Stay tuned!
References
- https://www.starknet.io/blog/getting-started-using-starknet-setting-up-a-starknet-wallet/ (accessed in July 2024)
- https://docs.starknet.io/ (accessed in July 2024)
- https://www.starknetjs.com/docs/guides/intro (accessed in July 2024)
- https://starknet-by-example.voyager.online/ (accessed in July 2024)