diff --git a/.cspell.json b/.cspell.json index d54182ac..b0185233 100644 --- a/.cspell.json +++ b/.cspell.json @@ -5,6 +5,8 @@ "useGitignore": true, "language": "en", "words": [ + "funder", + "Funder", "binkey", "binsec", "chainlist", diff --git a/cypress/scripts/anvil.ts b/cypress/scripts/anvil.ts index 54c532f8..efbb52b7 100644 --- a/cypress/scripts/anvil.ts +++ b/cypress/scripts/anvil.ts @@ -1,80 +1,63 @@ -/* eslint-disable sonarjs/no-duplicate-string */ -import { spawn } from "child_process"; +import { spawnSync } from "child_process"; +import { useHandler } from "../../static/scripts/rewards/web3/use-rpc-handler"; +// @ts-expect-error - Missing types +import { RPCHandler } from "@ubiquity-dao/rpc-handler"; -const url = "http://localhost:8545"; +class Anvil { + rpcs: string[] = []; + rpcHandler: RPCHandler | null = null; -const anvil = spawn("anvil", ["--chain-id", "31337", "--fork-url", "https://gnosis-pokt.nodies.app", "--host", "127.0.0.1", "--port", "8545"], { - stdio: "inherit", -}); + async init() { + this.rpcHandler = await useHandler(100); + console.log(`[RPCHandler] Fetching RPCs...`); + await this.rpcHandler.testRpcPerformance(); + const latencies: Record = this.rpcHandler.getLatencies(); + const sorted = Object.entries(latencies).sort(([, a], [, b]) => a - b); + console.log( + `Fetched ${sorted.length} RPCs.\nFastest: ${sorted[0][0]} (${sorted[0][1]}ms)\nSlowest: ${sorted[sorted.length - 1][0]} (${sorted[sorted.length - 1][1]}ms)` + ); -setTimeout(() => { - console.log(`\n\n Anvil setup complete \n\n`); -}, 5000); + this.rpcs = sorted.map(([rpc]) => rpc.split("__")[1]); + } -// anvil --chain-id 31337 --fork-url https://eth.llamarpc.com --host 127.0.0.1 --port 8546 + async run() { + await this.init(); + console.log(`Starting Anvil...`); + const isSuccess = await this.spawner(this.rpcs.shift()); -spawn("cast", ["rpc", "--rpc-url", url, "anvil_impersonateAccount", "0xba12222222228d8ba445958a75a0704d566bf2c8"], { - stdio: "inherit", -}); -spawn( - "cast", - [ - "send", - "--rpc-url", - url, - "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", - "--unlocked", - "--from", - "0xba12222222228d8ba445958a75a0704d566bf2c8", - "transfer(address,uint256)(bool)", - "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - "337888400000000000000000", - ], - { - stdio: "inherit", + if (!isSuccess) { + throw new Error(`Anvil failed to start`); + } } -); -spawn( - "cast", - [ - "send", - "--rpc-url", - url, - "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", - "--unlocked", - "--from", - "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "approve(address,uint256)(bool)", - "0x000000000022D473030F116dDEE9F6B43aC78BA3", - "9999999999999991111111119999999999999999", - ], - { - stdio: "inherit", - } -); -spawn( - "cast", - [ - "send", - "--rpc-url", - url, - "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d", - "--unlocked", - "--from", - "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - "approve(address,uint256)(bool)", - "0x000000000022D473030F116dDEE9F6B43aC78BA3", - "999999999999999111119999999999999999", - ], - { - stdio: "inherit", + + async spawner(rpc?: string): Promise { + if (!rpc) { + console.log(`No RPCs left to try`); + return false; + } + + console.log(`Forking with RPC: ${rpc}`); + + const anvil = spawnSync("anvil", ["--chain-id", "31337", "--fork-url", rpc, "--host", "127.0.0.1", "--port", "8545"], { + stdio: "inherit", + }); + + if (anvil.status !== 0) { + console.log(`Anvil failed to start with RPC: ${rpc}`); + console.log(`Retrying with next RPC...`); + return this.spawner(this.rpcs.shift()); + } + + return true; } -); +} -anvil.on("close", (code) => { - console.log(`Anvil exited with code ${code}`); -}); +async function main() { + const anvil = new Anvil(); + await anvil.run(); +} -anvil.on("error", (err) => { - console.error("Failed to start Anvil", err); +main().catch((error) => { + console.error(error); + process.exit(1); }); diff --git a/cypress/scripts/funding.ts b/cypress/scripts/funding.ts new file mode 100644 index 00000000..0144607d --- /dev/null +++ b/cypress/scripts/funding.ts @@ -0,0 +1,273 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { SpawnSyncOptionsWithStringEncoding, spawnSync } from "child_process"; + +/** + * Handles the async funding of the testing environment + * specifically for use within a GitHub Action. + * + * Attempts to make tests more reliable by ensuring that the + * correct allowances and balances are set before running tests. + * + * Will attempt to retry a failed action up to 5 times max. + */ + +class TestFunder { + anvilRPC = "http://localhost:8545"; + fundingWallet = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + beneficiary = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + permit2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; + WXDAI = "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d"; + whale = "0xba12222222228d8ba445958a75a0704d566bf2c8"; + expected = { + allowance: "999999999999999111119999999999999999", + balance: "337888400000000000000000", + }; + + async execute() { + const loader = this.loader(); + + let isMoving = true; + + const steps = { + impersonate: async () => await this._impersonateAccount(this.whale), + approveFunding: async () => await this._approvePayload(this.fundingWallet), + transfer: async () => await this._transferPayload(), + }; + + while (isMoving) { + console.log(`Attempting to fund the testing environment`); + + for (const [step, fn] of Object.entries(steps)) { + console.log(`Running step: ${step}`); + if (!(await fn())) { + console.log(`Failed to run step: ${step} -> retrying...`); + const isSuccess = await this.retry(fn); + + if (!isSuccess) { + console.log(`Failed to run step: ${step} -> exiting...`); + isMoving = false; + } + } + } + + if (isMoving) { + console.log(`Funding complete`); + break; + } + } + + await this.validate(); + clearInterval(loader); + } + + async retry(fn: () => Promise, retries = 5) { + let i = 0; + let isSuccess = false; + while (i < retries) { + try { + isSuccess = await fn(); + } catch (error) { + console.error(error); + } + + if (isSuccess) return isSuccess; + i++; + } + throw new Error("Failed to execute function"); + } + + async validate() { + const allowance = await this._fundingAllowanceCheck(); + const balance = await this._fundingBalanceCheck(); + + if (parseInt(allowance) < parseInt(this.expected.allowance)) { + throw new Error("Allowance is not set correctly"); + } + + if (parseInt(balance) < parseInt(this.expected.balance)) { + throw new Error("Balance is not set correctly"); + } + + console.log(`Funding wallet is ready for testing\n`); + console.log(`Allowance: ${allowance}\nBalance: ${balance}`); + } + + private async _exec(payload: { command: string; args: string[]; options: SpawnSyncOptionsWithStringEncoding }) { + const { command, args, options } = payload; + const result = spawnSync(command, args, options); + if (result.error) { + throw result.error; + } + return result; + } + + // pretend to be the whale + private async _impersonateAccount(address: string) { + const impersonate = await this._exec({ + command: "cast", + args: ["rpc", "--rpc-url", this.anvilRPC, "anvil_impersonateAccount", address], + options: { encoding: "utf8", stdio: "inherit" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + return impersonate.status === 0; + } + + private async _fundingAllowanceCheck() { + const allowance = await this._exec({ + command: "cast", + args: ["call", this.WXDAI, "allowance(address,address)(uint256)", this.fundingWallet, this.permit2, "--rpc-url", this.anvilRPC], + options: { encoding: "utf8" }, + }); + + return allowance.stdout; + } + + private async _fundingBalanceCheck() { + const balance = await this._exec({ + command: "cast", + args: ["call", this.WXDAI, "balanceOf(address)(uint256)", this.fundingWallet, "--rpc-url", this.anvilRPC], + options: { encoding: "utf8" }, + }); + + return balance.stdout; + } + + private async _approvePayload(address: string) { + const approve = await this._exec({ + command: "cast", + args: [ + "send", + "--rpc-url", + this.anvilRPC, + this.WXDAI, + "--unlocked", + "--from", + address, + "approve(address,uint256)(bool)", + this.permit2, + this.expected.allowance, + ], + options: { encoding: "utf8", stdio: "inherit" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + return approve.status === 0; + } + + private async _transferPayload() { + const balance = parseInt(await this._fundingBalanceCheck()); + const expected = parseInt(this.expected.balance); + + if (balance === expected) { + return true; + } else if (balance > expected) { + await this.retry(() => this._clearBalance()); + } + + const transfer = await this._exec({ + command: "cast", + args: [ + "send", + "--rpc-url", + this.anvilRPC, + this.WXDAI, + "--unlocked", + "--from", + this.whale, + "transfer(address,uint256)(bool)", + this.fundingWallet, + this.expected.balance, + ], + options: { encoding: "utf8", stdio: "inherit" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + return transfer.status === 0; + } + + private async _clearBalance() { + console.log(`Funder was over-funded, clearing excess funds`); + const balance = parseInt(await this._fundingBalanceCheck()); + const difference = BigInt(balance - parseInt(this.expected.balance)); + + const clear = await this._exec({ + command: "cast", + args: [ + "send", + "--rpc-url", + this.anvilRPC, + this.WXDAI, + "--unlocked", + "--from", + this.fundingWallet, + "transfer(address,uint256)(bool)", + this.whale, + difference.toString(), + ], + options: { encoding: "utf8", stdio: "inherit" }, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + return clear.status === 0; + } + + async pingAnvil() { + try { + const resp = await fetch("http://localhost:8545", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: "eth_blockNumber", params: [], id: 1, jsonrpc: "2.0" }), + }); + + const { result } = await resp.json(); + + if (parseInt(result) > 0) { + return true; + } + } catch { + // + } + return false; + } + loader() { + const steps = ["|", "/", "-", "\\"]; + let i = 0; + return setInterval(() => { + process.stdout.write(`\r${steps[i++]}`); + i = i % steps.length; + }, 100); + } +} + +async function main() { + const funder = new TestFunder(); + let isAnvilReady = false; + let retries = 5; + + while (!isAnvilReady && retries > 0) { + isAnvilReady = await funder.pingAnvil(); + retries--; + console.log(`Waiting for Anvil to ready up...`); + await new Promise((resolve) => setTimeout(resolve, 5000)); + + if (retries === 0) { + throw new Error(`Could not connect to Anvil`); + } + } + + await funder.execute(); +} + +main() + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(() => { + process.exit(0); + }); diff --git a/package.json b/package.json index e0479771..55eb48e6 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:start": "yarn start", "test:run": "cypress run", "test:open": "cypress open", - "test:fund": "cast rpc --rpc-url http://127.0.0.1:8545 anvil_impersonateAccount 0xba12222222228d8ba445958a75a0704d566bf2c8 & cast send --rpc-url http://127.0.0.1:8545 0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d --unlocked --from 0xba12222222228d8ba445958a75a0704d566bf2c8 \"transfer(address,uint256)(bool)\" 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 337888400000000000000000 & cast send --rpc-url http://127.0.0.1:8545 0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d --unlocked --from 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 \"approve(address,uint256)(bool)\" 0x000000000022D473030F116dDEE9F6B43aC78BA3 9999999999999991111111119999999999999999 & cast send --rpc-url http://127.0.0.1:8545 0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d --unlocked --from 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \"approve(address,uint256)(bool)\" 0x000000000022D473030F116dDEE9F6B43aC78BA3 999999999999999111119999999999999999 & cast send --rpc-url http://127.0.0.1:8545 0xfB9124a8Bd01c250942de7beeE1E50aB9Ce36493 --unlocked --from 0xba12222222228d8ba445958a75a0704d566bf2c8 --value 0.1ether" + "test:fund": "tsx cypress/scripts/funding.ts" }, "keywords": [ "typescript", diff --git a/static/scripts/rewards/render-transaction/read-claim-data-from-url.ts b/static/scripts/rewards/render-transaction/read-claim-data-from-url.ts index fefdcc15..fb911825 100644 --- a/static/scripts/rewards/render-transaction/read-claim-data-from-url.ts +++ b/static/scripts/rewards/render-transaction/read-claim-data-from-url.ts @@ -2,7 +2,6 @@ import { createClient } from "@supabase/supabase-js"; import { decodePermits } from "@ubiquibot/permit-generation/handlers"; import { Permit } from "@ubiquibot/permit-generation/types"; import { app, AppState } from "../app-state"; -import { useFastestRpc } from "../rpc-optimization/get-optimal-provider"; import { toaster } from "../toaster"; import { connectWallet } from "../web3/connect-wallet"; import { checkRenderInvalidatePermitAdminControl, checkRenderMakeClaimControl } from "../web3/erc20-permit"; diff --git a/static/scripts/rewards/web3/use-rpc-handler.ts b/static/scripts/rewards/web3/use-rpc-handler.ts index 007b6b67..7659f64d 100644 --- a/static/scripts/rewards/web3/use-rpc-handler.ts +++ b/static/scripts/rewards/web3/use-rpc-handler.ts @@ -2,7 +2,7 @@ import { RPCHandler } from "@ubiquity-dao/rpc-handler"; import { AppState } from "../app-state"; import { ethers } from "ethers"; -async function useHandler(networkId: number) { +export async function useHandler(networkId: number) { const config = { networkId: networkId, autoStorage: true,