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

Add foundry/anvil harness for simulating and testing #24

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 3 additions & 2 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"main": "index.ts",
"license": "MIT",
"engines": {
"node": "16.x.x",
"node": "18.x.x",
"yarn": ">=1.0.0 <2.0.0"
},
"devDependencies": {
Expand All @@ -15,7 +15,8 @@
"build": "npx tsc",
"gcp-build": "npx tsc",
"start": "node lib/index.js",
"clean": "rm -rf lib"
"clean": "rm -rf lib",
"sim": "node lib/index-sim.js"
},
"dependencies": {
"@types/big.js": "^6.1.2",
Expand Down
11 changes: 9 additions & 2 deletions app/src/Liquidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import Bottleneck from "bottleneck";

const FACTORY_ADDRESS: string = process.env.FACTORY_ADDRESS!;
const CREATE_ACCOUNT_TOPIC_ID: string = process.env.CREATE_ACCOUNT_TOPIC_ID!;
const WALLET_ADDRESS = process.env.WALLET_ADDRESS!;
const ALOE_INITIAL_DEPLOY = 0;
const POLLING_INTERVAL_MS = 150_000; // 2.5 minutes
const CLIENT_KEEPALIVE_INTERVAL_MS = 60_000;
Expand Down Expand Up @@ -274,13 +273,21 @@ export default class Liquidator {
) {
throw new Error(`Invalid strain: ${strain}`);
}
const data = this.web3.eth.abi.encodeParameter("address", WALLET_ADDRESS);
const data = this.web3.eth.abi.encodeParameter("address", this.txManager.address());
try {
const estimatedGasLimit: number = await this.liquidatorContract.methods
.liquidate(borrower, data, integerStrain)
.estimateGas({
gasLimit: Liquidator.GAS_LIMIT,
});
if (process.env.SIM && estimatedGasLimit < 23000) {
return {
success: false,
estimatedGas: estimatedGasLimit,
error: LiquidationError.Unknown,
errorMsg: "Anvil doesn't bubble-up reverts when estimating gas, so we have no clue what's happening"
}
}
return {
success: true,
estimatedGas: estimatedGasLimit,
Expand Down
11 changes: 7 additions & 4 deletions app/src/TxManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ config();
const MAX_RETRIES_ALLOWED: number = 10;
const GAS_INCREASE_FACTOR: number = 1.10;
const MAX_ACCEPTABLE_ERRORS = 10;
const WALLET_ADDRESS = process.env.WALLET_ADDRESS!;

type LiquidationTxInfo = {
borrower: string;
Expand Down Expand Up @@ -59,6 +58,10 @@ export default class TXManager {
this.gasPriceMaximum = TXManager.getMaxGasPriceForChain(chainId);
}

public address() {
return this.account?.address;
}

public addLiquidatableAccount(address: string) {
this.queue.push(address);
this.processLiquidatableCandidates();
Expand Down Expand Up @@ -108,8 +111,8 @@ export default class TXManager {
log("debug", `Exceeded maximum amount of retries when attempting to liquidate borrower: ${borrower}`);
continue;
}
const encodedAddress = this.client.eth.abi.encodeParameter("address", WALLET_ADDRESS);
const currentNonce = await this.client.eth.getTransactionCount(WALLET_ADDRESS, "pending");
const encodedAddress = this.client.eth.abi.encodeParameter("address", this.account.address);
const currentNonce = await this.client.eth.getTransactionCount(this.account.address, "pending");
const estimatedGasLimit: number = await this.liquidatorContract.methods
.liquidate(borrower, encodedAddress, liquidationTxInfo.currentStrain)
.estimateGas({
Expand All @@ -118,7 +121,7 @@ export default class TXManager {
const updatedGasLimit = Math.ceil(estimatedGasLimit * GAS_INCREASE_FACTOR);
const encodedData = this.liquidatorContract.methods.liquidate(borrower, encodedAddress, liquidationTxInfo.currentStrain).encodeABI();
const transactionConfig: TransactionConfig = {
from: WALLET_ADDRESS,
from: this.account.address,
to: this.liquidatorContract.options.address,
gasPrice: liquidationTxInfo.gasPrice,
gas: updatedGasLimit,
Expand Down
44 changes: 44 additions & 0 deletions app/src/index-sim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { spawn } from "node:child_process";

import dotenv from "dotenv";
dotenv.config();

import {
web3WithWebsocketProvider,
nextStdoutMsg,
startAnvil,
} from "./sim/Utils";

const alchemy_key = process.env.ALCHEMY_API_KEY;
const anvil = startAnvil({
forkUrl: `https://opt-mainnet.g.alchemy.com/v2/${alchemy_key}`,
forkBlockNumber: Number(process.argv[2]), // e.g. 92975261
blockTime: 5,
baseFee: 1,
});

nextStdoutMsg(anvil).then(async () => {
// `await` this to make sure things are good to go
const web3 = await web3WithWebsocketProvider("ws://127.0.0.1:8545");

// NOTE: We can do all the usual things with this `web3` instance, e.g.:
/*
web3.eth.subscribe("newBlockHeaders", (err, res) => {
console.log(err, res);
});
process.on("SIGINT", () => {
web3.eth.clearSubscriptions(() => {});
});
*/

const bot = spawn("node", ["lib/index.js"], {
cwd: process.cwd(),
env: { ...process.env, SIM: "true" },
stdio: "pipe",
});
bot.stdout.on("data", (data) => console.info(String(data)));
bot.stderr.on("data", (data) => console.error(String(data)));
process.on("beforeExit", () => {
bot.kill("SIGINT");
});
});
10 changes: 6 additions & 4 deletions app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ const limiter = new Bottleneck({
minTime: MS_BETWEEN_REQUESTS,
});
const LIQUIDATOR_ADDRESS = process.env.LIQUIDATOR_ADDRESS!;
const liquidators: Liquidator[] = [
new Liquidator(OPTIMISM_ALCHEMY_URL, LIQUIDATOR_ADDRESS, limiter),
new Liquidator(ARBITRUM_ALCHEMY_URL, LIQUIDATOR_ADDRESS, limiter),
];
const liquidators: Liquidator[] = process.env.SIM === 'true'
? [new Liquidator("ws://127.0.0.1:8545", LIQUIDATOR_ADDRESS, limiter)]
: [
new Liquidator(OPTIMISM_ALCHEMY_URL, LIQUIDATOR_ADDRESS, limiter),
new Liquidator(ARBITRUM_ALCHEMY_URL, LIQUIDATOR_ADDRESS, limiter),
];

app.get("/liquidator_liveness_check", (req, res) => {
res.status(STATUS_OK).send({ status: "ok" });
Expand Down
118 changes: 118 additions & 0 deletions app/src/sim/Utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";

import Web3 from "web3";
import winston from "winston";

function createLogger(filename: string) {
return winston.createLogger({
level: "info",
format: winston.format.simple(),
transports: [
new winston.transports.File({
filename: filename,
level: "debug",
}),
],
exitOnError: false,
});
}

export function sleep(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}

export function nextStdoutMsg(proc: ChildProcessWithoutNullStreams) {
return new Promise<void>((resolve) => {
proc.stdout.once("data", (_) => resolve());
});
}

export type AnvilOptions = {
/**
* RPC URL from which to pull real chain state
*/
forkUrl?: string;
/**
* Block number at which to fork away from real RPC state
*/
forkBlockNumber?: number;
/**
* Time between mined blocks in seconds, e.g. `2` would mean a fake block is mined every other second.
*/
blockTime?: number;
/**
* Port on localhost for HTTP and WS providers. Default is 8545.
*/
port?: number;
/**
* Path to IPC connection file, e.g. "./anvil.ipc". If unspecified, IPC provider is disabled.
*/
ipc?: string;
/**
* Minimum fee charged for a transaction
*/
baseFee?: number;
};

export function startAnvil(
options: AnvilOptions,
logging = true
): ChildProcessWithoutNullStreams {
const argPairs = Object.entries(options);
const args: string[] = [];

for (const argPair of argPairs) {
// Split argPair[0] string at every capital letter
const name = argPair[0]
.split(/(?=[A-Z])/)
.map((s) => s.toLowerCase())
.join("-");
const value = String(argPair[1]);
args.push(`--${name}`, value);
}

console.info("\nStarting anvil with args:");
console.info(args);
console.info("");

const anvil = spawn("anvil", args, {
cwd: process.cwd(),
env: process.env,
stdio: "pipe",
});

process.on("beforeExit", () => {
anvil.kill("SIGINT");
});

if (logging) {
const logger = createLogger("anvil-debug.log");
anvil.stdout.on("data", (data) => logger.info(data));
anvil.stderr.on("data", (data) => logger.error(data));
}

return anvil;
}

export async function web3WithWebsocketProvider(
url: string,
connectionAttempts = 10
) {
for (let i = 0; i < connectionAttempts; i += 1) {
try {
const provider = new Web3.providers.WebsocketProvider(url);
const web3 = new Web3(provider);

await web3.eth.getBlockNumber();
return web3;
} catch (e) {
console.error(e);
await sleep(1000);
}
}
throw new Error(
`Couldn't connect to ${url} despite ${connectionAttempts} tries`
);
}