Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Minor fixes and refactorings #12

Merged
merged 2 commits into from
Jul 8, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ LOG_COLORIZE=true
LOG_LEVEL=debug
LOG_FORMAT=pretty

# Bot variables
MNEMONIC="test test test test test test test test test test test junk"

# Optional: PERSIST_ACCOUNTS_TO_WATCH=true
# Optional: ETHER_LIQUIDATOR_ADDRESS=0xAb6702A6Fd7f0F2596f70c273376036B44a10709
# The mnemonic for the bot that will be placing transaction on the OEV and Blast network
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
MNEMONIC=test test test test test test test test test test test junk
# The address of the OrbitLiquidator contract (obtained by running a deployment script)
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
ORBIT_LIQUIDATOR_ADDRESS=
58 changes: 40 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
# OEV Orbit Bot Example

This repository contains an example OEV Searcher bot implementation targeting [Orbit Lending](https://orbitlending.io/).
This repository contains an example OEV searcher bot implementation targeting [Orbit Lending](https://orbitlending.io/).
To understand how OEV works, visit
[the OEV documentation](https://oev-docs--pr12-new-oev-docs-0y2wddya.web.app/reference/oev-network/overview/oev-network.html).

Before running this application, be sure to read and understand the code.

## Process Overview

The OEV Bot follows this flow to extract OEV from Orbit Lending:
The OEV bot follows this flow to extract OEV from Orbit Lending:

1. **Initialisation**

- Get log events from the target chain and build a list of accounts to watch for possible liquidation opportunities
- Get log events from the Blast chain and build a list of accounts to watch for possible liquidation opportunities
- Get log events from the OEV Network to determine awarded/live/lost bids

2. **Main Loop**
Expand All @@ -28,7 +28,7 @@ Given a list of accounts to watch, the app does the following: (Refer to `findOe

- Simulate liquidation potential by [transmuting](#transmutation) the oracle's value for a feed
- Refer to the [Transmutation section of this README](#transmutation) for more information.
- Find Orbit's Price Oracle: `orbitSpaceStation.oracle`
- Find Orbit's Price Oracle: `orbitSpaceStation.oracle()`
- Read the current value of the oracle for the target feed: `priceOracle.getUnderlyingPrice(oEtherV2)`
- Apply a transmutation value to the read price: `getPercentageValue(currentEthUsdPrice, 100.2)`
- Create a set of calls that can be used to transmute the value of the oracle temporarily
Expand Down Expand Up @@ -58,17 +58,18 @@ Given a list of accounts to watch, the app does the following: (Refer to `findOe

- Store the active bid's parameters

### Attempt to Exploit the OEV Liquidation Opportunity
### Attempt to Capture the OEV Liquidation Opportunity

Refer to `attemptLiquidation()`

- [Listen for the award, expiry or loss of the active bid](https://oev-docs--pr12-new-oev-docs-0y2wddya.web.app/reference/oev-network/searchers/submit-bids.html#checking-bid-status-and-listening-for-awarded-bids)
- If the bid is awarded, encode a multicall call set containing
- Call #1: Call to the API3 Server with the awarded bid details as call data
- If the bid is awarded, encode a multicall transaction containing
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
- Call #1: Call to the API3 Server with the awarded bid details as call data with value corresponding to the bid
amount
- Call #2: Call the Orbit Ether Liquidator contract with the liquidation parameters
- Simulate the liquidation multicall and determine the profitability - bail if the profit is below the minimum
- [Execute the liquidation transaction](https://oev-docs--pr12-new-oev-docs-0y2wddya.web.app/reference/oev-network/searchers/submit-bids.html#performing-the-oracle-update-using-the-awarded-bid)
- Report the fulfilment on the OEV Network #TODO there's no page for this in the OEV docs
- Report the fulfillment on the OEV Network #TODO there's no page for this in the OEV docs
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
https://github.com/api3dao/oev-docs/pull/12#issuecomment-2186092191

### Transmutation
Expand All @@ -77,11 +78,11 @@ In order to bid on an OEV update an application will need to determine
[the bid's parameters](https://oev-docs--pr12-new-oev-docs-0y2wddya.web.app/reference/oev-network/searchers/submit-bids.html#arguments-for-placebid),
and in particular:

- The value of the bid (what will be paid for the bid in the target chain's native token)
- The value of the bid (what will be paid for the bid in the Blast chain's native token)
- The conditions under which the bid will be considered (less-than or greater-than a specific dAPI value)

Determining these values would generally require re-implementing the mathematical logic of the dApp being targeted,
something which is often very onerous. To make integrating into a target dApp easier, API3 has built a contract that
Determining these values would generally require re-implementing the business logic of the dApp being targeted,
something which is often very onerous. To make target dApp integration easier, API3 has built a contract that
facilitates the "transmutation" of a dAPI (from the concept of transmuting silver to gold).

**Purpose of Transmutation:**
Expand Down Expand Up @@ -147,19 +148,26 @@ Copy `.env.example` to `.env` and populate it
cp .env.example .env
```

3. **Ensure Wallet is Funded:**
3. **Generate Wallet for the bot:**
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
Siegrift marked this conversation as resolved.
Show resolved Hide resolved

Make sure the account on Blast associated with your `MNEMONIC` has sufficient funds on the OEV Network and Blast. A new
mnemonic can be generated using the
A new mnemonic can be generated using the
[API3 Airnode CLI](https://docs.api3.org/reference/airnode/latest/packages/admin-cli.html#generate-mnemonic) if required

```sh
pnpm dlx @api3/airnode-admin generate-mnemonic
```

3. **Deploy and Fund the OrbitLiquidator Contract (First-time setup):**
4. **Fund the wallet with Blast ETH**

The wallet will be capturing liquidations on the Blast network, so it needs to have some small ETH balance. The
liquidations happen through a helper contract, which also requires some ETH deposit.

5. **Deploy and Fund the OrbitLiquidator Contract (First-time setup):**
Siegrift marked this conversation as resolved.
Show resolved Hide resolved

```sh
# Install the dependencies
pnpm i
Siegrift marked this conversation as resolved.
Show resolved Hide resolved

# Build the project and contract
pnpm build

Expand All @@ -170,11 +178,25 @@ pnpm orbit-bot:cli-utils deploy
4. **Fund the OrbitLiquidator contract**

```sh
# REQUIRED: Update `.env` with the `ETHER_LIQUIDATOR_ADDRESS` with the output of the deploy command
pnpm orbit-bot:cli-utils deposit 1 # for 1 ETH
pnpm orbit-bot:cli-utils deposit 0.01 # for 0.01 ETH
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
```

5. **Run the Bot:**
5. **Bridge funds to the OEV network**

The wallet will be interacting with the
[OEV network](https://oev-docs--pr12-new-oev-docs-0y2wddya.web.app/reference/oev-network/overview/oev-network.html) for
which it needs to have ETH balance. You can use the official
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
[OEV bridge](https://oev-docs--pr12-new-oev-docs-0y2wddya.web.app/reference/oev-network/bridge.html) to bridge funds
from the Ethereum network to the OEV network.

6. **Deposit funds to the OevAuctionHouse**

You can use the
[OEV network explorer](https://oev.explorer.api3.org/address/0x34f13A5C0AD750d212267bcBc230c87AEFD35CC5?tab=write_contract)
to call `deposit` with your wallet. Be sure to leave some ETH in the wallet as well to cover gas costs for OEV network
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
transactions.

7. **Run the Bot:**
Siegrift marked this conversation as resolved.
Show resolved Hide resolved

```sh
pnpm orbit-bot
Expand Down
5 changes: 3 additions & 2 deletions src/accounts-to-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export const getBorrowersFromLogs = async (startBlockNumber?: number | null) =>
export const getAccountDetails = async (borrowerBatch: string[]) => {
console.info('Fetching accounts with borrowed ETH', { count: borrowerBatch.length });
const getAccountDetailsCalls = borrowerBatch.map((borrower) => ({
target: contractAddresses.OrbitLiquidator,
target: contractAddresses.orbitLiquidator,
callData: OrbitLiquidator.interface.encodeFunctionData('getAccountDetails', [borrower]),
}));
const [_blockNumber1, getAccountDetailsEncoded] = await multicall3.aggregate!.staticCall(getAccountDetailsCalls);
Expand Down Expand Up @@ -122,7 +122,7 @@ export const checkLiquidationPotentialOfAccounts = async (

// Now we determine the profitability per borrower
for (let i = 0; i < accountDetails.length; i++) {
const [oTokens, borrowBalances, tokenBalances, [shortfallValue, liquidityValue]] = accountDetails[i]!;
const [oTokens, borrowBalances, tokenBalances, [liquidityValue, shortfallValue]] = accountDetails[i]!;
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
const borrower = borrowers[i]!;
let usdBorrowBalance = 0n;

Expand Down Expand Up @@ -154,6 +154,7 @@ export const checkLiquidationPotentialOfAccounts = async (
borrower,
usdBorrowBalance: formatEther(usdBorrowBalance),
liquidity: formatEther(liquidityValue),
shortfall: formatEther(shortfallValue),
});
accountsToWatch.push(borrower);
await sleep(MIN_RPC_DELAY_MS);
Expand Down
74 changes: 39 additions & 35 deletions src/cli-utils.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,17 @@
import { Contract, ContractFactory, formatEther, parseEther } from 'ethers';
import { Contract, ContractFactory, ethers, formatEther, parseEther } from 'ethers';

import { blastProvider, oEtherV2, oUsdb, wallet } from './commons';
import { blastProvider, oEtherV2, oUsdb, oevAuctionHouse, oevNetworkProvider, wallet } from './commons';
import { getOrbitLiquidatorArtifact, OrbitLiquidatorInterface } from './interfaces';
import { contractAddresses } from './constants';

const OrbitLiquidatorAddress = contractAddresses.OrbitLiquidator;

const main = async () => {
// Print the wallet and the liquidator contract balances.
Siegrift marked this conversation as resolved.
Show resolved Hide resolved
console.info(`Wallet ETH balance (${wallet.address}) `, {
eth: formatEther(await blastProvider.getBalance(wallet.address)),
oEth: formatEther(await oEtherV2.balanceOf!(wallet.address)),
ethInOEth: formatEther(await oEtherV2.balanceOfUnderlying!.staticCall(wallet.address)),
});
console.info('Wallet USDB balance', {
oUsdb: formatEther(await oUsdb.balanceOf(wallet.address)),
usdbInOUsdb: formatEther(await oUsdb.balanceOfUnderlying.staticCall(wallet.address)),
});
console.info('OrbitLiquidator ETH balance', {
eth: formatEther(await blastProvider.getBalance(contractAddresses.OrbitLiquidator)),
oEth: formatEther(await oEtherV2.balanceOf!(contractAddresses.OrbitLiquidator)),
ethInOEth: formatEther(await oEtherV2.balanceOfUnderlying!.staticCall(contractAddresses.OrbitLiquidator)),
});
console.info('OrbitLiquidator USDB balance', {
oUsdb: formatEther(await oUsdb.balanceOf(contractAddresses.OrbitLiquidator)),
usdbInOUsdb: formatEther(await oUsdb.balanceOfUnderlying.staticCall(contractAddresses.OrbitLiquidator)),
});

const { bytecode } = getOrbitLiquidatorArtifact();

const OrbitLiquidator = new Contract(OrbitLiquidatorAddress, OrbitLiquidatorInterface, wallet.connect(blastProvider));
const orbitLiquidator = new Contract(
contractAddresses.orbitLiquidator,
OrbitLiquidatorInterface,
wallet.connect(blastProvider)
);

// Expected usage is to call this script with the type of command to perform.
const command = process.argv[2];
Expand All @@ -49,21 +31,21 @@ const main = async () => {
txHash: deployTx.deploymentTransaction()!.hash,
address,
});
process.stdout.write([`Add the following to your .env:`, `ETHER_LIQUIDATOR_ADDRESS=${address} `, ``].join('\n'));
process.stdout.write([`Add the following to your .env:`, `ORBIT_LIQUIDATOR_ADDRESS=${address} `, ``].join('\n'));

return;
}
case 'deposit': {
const ethToSend = process.argv[3]!;
if (!ethToSend) throw new Error('ETH amount to deposit is required (e.g. 0.05)');
console.info('Depositing ETH to OrbitLiquidator contract', {
address: await OrbitLiquidator.getAddress(),
ethToSend: parseEther(ethToSend),
address: await orbitLiquidator.getAddress(),
ethToSend,
});

const depositTx = await wallet.connect(blastProvider).sendTransaction({
value: parseEther(ethToSend),
to: await OrbitLiquidator.getAddress(),
to: await orbitLiquidator.getAddress(),
});
await depositTx.wait(1);
console.info('Deposited', { txHash: depositTx.hash });
Expand All @@ -72,27 +54,49 @@ const main = async () => {
}
case 'withdraw-all-eth': {
console.info('Withdrawing all ETH from OrbitLiquidator contract', {
address: await OrbitLiquidator.getAddress(),
address: await orbitLiquidator.getAddress(),
});

const withdrawalTx = await OrbitLiquidator.withdrawAllEth!();
const withdrawalTx = await orbitLiquidator.withdrawAllEth!();
await withdrawalTx.wait(1);
console.info('Withdrew', { txHash: withdrawalTx.hash });
return;
}
case 'withdraw-all-tokens':
case 'withdraw-all-token': {
case 'withdraw-all-tokens': {
const tokenAddress = process.argv[3]!;
if (!tokenAddress) throw new Error('Token address to withdraw is required');
console.info('Withdrawing all tokens from OrbitLiquidator contract', {
address: await OrbitLiquidator.getAddress(),
address: await orbitLiquidator.getAddress(),
});

const withdrawalTx = await OrbitLiquidator.withdrawAllToken!(tokenAddress);
const withdrawalTx = await orbitLiquidator.withdrawAllTokens!(tokenAddress);
await withdrawalTx.wait(1);
console.info('Withdrew', { txHash: withdrawalTx.hash });
return;
}
case 'wallet-balances': {
// Print the wallet and the liquidator contract balances.
console.info(`Wallet balance`, {
address: wallet.address,
eth: formatEther(await blastProvider.getBalance(wallet.address)),
oEth: formatEther(await oEtherV2.balanceOf!(wallet.address)),
ethInOEth: formatEther(await oEtherV2.balanceOfUnderlying!.staticCall(wallet.address)),
oUsdb: formatEther(await oUsdb.balanceOf(wallet.address)),
usdbInOUsdb: formatEther(await oUsdb.balanceOfUnderlying.staticCall(wallet.address)),
oevNetworkEth: formatEther(await oevNetworkProvider.getBalance(wallet.address)),
oevAuctionHouseEth: formatEther(await oevAuctionHouse.bidderToBalance(wallet.address)),
});
if (contractAddresses.orbitLiquidator !== ethers.ZeroAddress) {
console.info('OrbitLiquidator balance', {
eth: formatEther(await blastProvider.getBalance(contractAddresses.orbitLiquidator)),
oEth: formatEther(await oEtherV2.balanceOf!(contractAddresses.orbitLiquidator)),
ethInOEth: formatEther(await oEtherV2.balanceOfUnderlying!.staticCall(contractAddresses.orbitLiquidator)),
oUsdb: formatEther(await oUsdb.balanceOf(contractAddresses.orbitLiquidator)),
usdbInOUsdb: formatEther(await oUsdb.balanceOfUnderlying.staticCall(contractAddresses.orbitLiquidator)),
});
}
return;
}
default: {
console.error('Unknown action', { command });
return;
Expand Down
2 changes: 1 addition & 1 deletion src/commons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export const externalMulticallSimulator = new Contract(
externalMulticallSimulatorInterface,
blastProvider
);
export const OrbitLiquidator = new Contract(contractAddresses.OrbitLiquidator, OrbitLiquidatorInterface, blastProvider);
export const OrbitLiquidator = new Contract(contractAddresses.orbitLiquidator, OrbitLiquidatorInterface, blastProvider);
export const api3ServerV1 = Api3ServerV1Factory.connect(contractAddresses.api3ServerV1, blastProvider);

export const getPercentageValue = (value: bigint, percent: number) => {
Expand Down
6 changes: 3 additions & 3 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { parseEther } from 'ethers';
import { ethers, parseEther } from 'ethers';

// The percentage to use when simulating a transmutation.
// For example, 0.1 here would test if liquidations are profitable above 100.1% of the current data feed value
export const SIMULATION_PERCENTAGE = 0.5;
export const SIMULATION_PERCENTAGE = 0.2;
Siegrift marked this conversation as resolved.
Show resolved Hide resolved

export const contractAddresses = {
// Blast network
api3OevEthUsdProxy: '0xCBE95Ba8fF327a1E3e6Bdade4C598277450145B3',
api3ServerV1: '0x709944a48cAf83535e43471680fDA4905FB3920a',
externalMulticallSimulator: '0xb45fe2838F47DCCEe00F635785EAF0c723F742E5',
multicall3: '0xcA11bde05977b3631167028862bE2a173976CA11',
OrbitLiquidator: process.env.ETHER_LIQUIDATOR_ADDRESS!,
orbitLiquidator: process.env.ORBIT_LIQUIDATOR_ADDRESS || ethers.ZeroAddress,
orbitSpaceStation: '0x1E18C3cb491D908241D0db14b081B51be7B6e652',

// OEV network
Expand Down
22 changes: 11 additions & 11 deletions src/oev-bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
* The bot's main coordinator function.
*
* The function starts with initialisation calls:
* - Initialise the target chain related data (acquires accounts to watch)
* - Initialise the Blast chain related data (acquires accounts to watch)
* - Refer to getAccountsToWatch() and getAccountsFromFile()
* - Initialise the OEV Network chain data
* - Fetches OEV Network logs - this allows the app to determine won/lost bids
Expand Down Expand Up @@ -365,7 +365,7 @@ const attemptLiquidation = async () => {
callData: awardDetails,
},
{
target: contractAddresses.OrbitLiquidator,
target: contractAddresses.orbitLiquidator,
allowFailure: false,
value: 0,
callData: OrbitLiquidator.interface.encodeFunctionData('liquidate', [
Expand Down Expand Up @@ -434,13 +434,13 @@ const findOevLiquidation = async () => {
usdbInOUsdb: formatEther(await oUsdb.balanceOfUnderlying.staticCall(wallet.address)),
});
console.info('OrbitLiquidator ETH balance', {
eth: formatEther(await blastProvider.getBalance(contractAddresses.OrbitLiquidator)),
oEth: formatEther(await oEtherV2.balanceOf!(contractAddresses.OrbitLiquidator)),
ethInOEth: formatEther(await oEtherV2.balanceOfUnderlying!.staticCall(contractAddresses.OrbitLiquidator)),
eth: formatEther(await blastProvider.getBalance(contractAddresses.orbitLiquidator)),
oEth: formatEther(await oEtherV2.balanceOf!(contractAddresses.orbitLiquidator)),
ethInOEth: formatEther(await oEtherV2.balanceOfUnderlying!.staticCall(contractAddresses.orbitLiquidator)),
});
console.info('OrbitLiquidator USDB balance', {
oUsdb: formatEther(await oUsdb.balanceOf(contractAddresses.OrbitLiquidator)),
usdbInOUsdb: formatEther(await oUsdb.balanceOfUnderlying.staticCall(contractAddresses.OrbitLiquidator)),
oUsdb: formatEther(await oUsdb.balanceOf(contractAddresses.orbitLiquidator)),
usdbInOUsdb: formatEther(await oUsdb.balanceOfUnderlying.staticCall(contractAddresses.orbitLiquidator)),
});

// Print out the close factor. Currently, the value is set to 0.5, so we can only liquidate 50% of the borrowed asset.
Expand All @@ -465,7 +465,7 @@ const findOevLiquidation = async () => {
);

const { targetChainData } = storage;
if (!targetChainData) throw new Error('Target chain data not initialized.');
if (!targetChainData) throw new Error('Blast chain data not initialized.');
const { borrowers } = targetChainData;
const getAccountLiquidityCalls = borrowers.map((borrower) => {
return {
Expand Down Expand Up @@ -518,7 +518,7 @@ const findOevLiquidation = async () => {
const transmutationCalls = [
...dapiTransmutationCalls,
{
target: contractAddresses.OrbitLiquidator,
target: contractAddresses.orbitLiquidator,
data: OrbitLiquidator.interface.encodeFunctionData('getAccountDetails', [borrower]),
},
];
Expand All @@ -541,7 +541,7 @@ const findOevLiquidation = async () => {
acc.tokenBalance > curr.tokenBalance ? acc : curr
);

const orbitLiquidatorBalance = await blastProvider.getBalance(contractAddresses.OrbitLiquidator);
const orbitLiquidatorBalance = await blastProvider.getBalance(contractAddresses.orbitLiquidator);
const maxBorrowRepay = min(
(((ethBorrowAsset.borrowBalance * 10n ** 18n) / transmutationValue) * closeFactor) / 10n ** 18n,
orbitLiquidatorBalance,
Expand All @@ -563,7 +563,7 @@ const findOevLiquidation = async () => {
const liquidateBorrowCalls = [
...dapiTransmutationCalls,
{
target: contractAddresses.OrbitLiquidator,
target: contractAddresses.orbitLiquidator,
data: OrbitLiquidator.interface.encodeFunctionData('liquidate', [
ethBorrowAsset.oToken,
borrower,
Expand Down