diff --git a/package.json b/package.json index f0c4e9e..6346efc 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@typechain/ethers-v6": "^0.5.1", + "@types/bluebird": "^3.5.42", "@types/chai": "^4.3.16", "@types/eventsource": "^1.1.11", "@types/mocha": "^10.0.7", @@ -38,13 +39,15 @@ "chai": "^4.4.1", "dotenv": "^16.3.1", "mocha": "^10.6.0", - "sinon": "^18.0.0" + "sinon": "^18.0.0", + "typechain": "^8.3.2" }, "scripts": { "dev": "nodemon", "build": "tsc", "test": " mocha", "start": "node ./out/index.js", - "lint": "prettier './**/*.ts' --write" + "lint": "prettier './**/*.ts' --write", + "generate-contract-types": "rm -rf src/contract-types && mkdir -p src/contract-types && typechain --target ethers-v6 --out-dir src/contract-types 'src/abi/*.json'" } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index fe44629..84bf125 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,8 @@ import { isEthSendBundleParams, sendBundle, verifyBundleSignature, + getOvalRefundConfig, + OvalDiscovery } from "./lib"; const app = express(); @@ -51,6 +53,10 @@ const { ovalConfigs, ovalConfigsShared } = env; const walletManager = WalletManager.getInstance(provider); walletManager.initialize(ovalConfigs, ovalConfigsShared); +// Initialize Oval discovery +const ovalDiscovery = OvalDiscovery.getInstance(); +ovalDiscovery.initialize(provider); + // Start restful API server to listen for root inbound post requests. app.post("/", async (req, res, next) => { try { @@ -167,7 +173,7 @@ app.post("/", async (req, res, next) => { // Construct the inner bundle with call to Oval to unlock the latest value. const unlockBundle = createUnlockLatestValueBundle( unlock.signedUnlockTx, - ovalConfigs[unlock.ovalAddress].refundAddress, + getOvalRefundConfig(unlock.ovalAddress).refundAddress, targetBlock, ); diff --git a/src/lib/bundleUtils.ts b/src/lib/bundleUtils.ts index 36da710..c3d2e89 100644 --- a/src/lib/bundleUtils.ts +++ b/src/lib/bundleUtils.ts @@ -1,13 +1,14 @@ import { Interface, Transaction, TransactionRequest, Wallet } from "ethers"; import express from "express"; import { FlashbotsBundleProvider } from "flashbots-ethers-v6-provider-bundle"; -import { getBaseFee, getMaxBlockByChainId, getProvider } from "./helpers"; +import { getBaseFee, getMaxBlockByChainId, getOvalRefundConfig, getProvider } from "./helpers"; import { WalletManager } from "./walletManager"; import MevShareClient, { BundleParams } from "@flashbots/mev-share-client"; import { JSONRPCID, createJSONRPCSuccessResponse } from "json-rpc-2.0"; import { ovalAbi } from "../abi"; +import { OvalDiscovery } from "./"; import { env } from "./env"; import { Logger } from "./logging"; import { Refund } from "./types"; @@ -97,7 +98,7 @@ export const getUnlockBundlesFromOvalAddresses = async ( // Construct the inner bundle with call to Oval to unlock the latest value. const unlockBundle = createUnlockLatestValueBundle( unlock.signedUnlockTx, - ovalConfigs[ovalAddress].refundAddress, + getOvalRefundConfig(ovalAddress).refundAddress, targetBlock, ); @@ -116,8 +117,10 @@ export const findUnlock = async ( targetBlock: number, req: express.Request, ) => { + const factoryInstances = OvalDiscovery.getInstance().getOvalFactoryInstances(); + const unlocks = await Promise.all( - Object.keys(ovalConfigs).map(async (ovalAddress) => + [...factoryInstances, ...Object.keys(ovalConfigs)].map(async (ovalAddress) => prepareUnlockTransaction(flashbotsBundleProvider, backrunTxs, targetBlock, ovalAddress, req), ), ); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index d45b6d7..b7f578b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -64,3 +64,5 @@ export const flashbotsSupportedNetworks: { export const FLASHBOTS_SIGNATURE_HEADER = "x-flashbots-signature"; export const OVAL_ADDRESSES_HEADER = "x-oval-addresses"; + +export const FACTORIES_GENESIS_BLOCK = 20268518; \ No newline at end of file diff --git a/src/lib/env.ts b/src/lib/env.ts index a2b896a..b90b396 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -50,6 +50,13 @@ type EnvironmentVariables = { [key: number]: number; }; sharedWalletUsageCleanupInterval: number; + standardCoinbaseFactory: string; + standardChainlinkFactory: string; + standardChronicleFactory: string; + standardPythFactory: string; + defaultRefundAddress: string; + defaultRefundPercent: number; + ovalDiscoveryInterval: number; }; export const env: EnvironmentVariables = { @@ -73,4 +80,11 @@ export const env: EnvironmentVariables = { [SEPOLIA_CHAIN_ID]: getInt(getEnvVar("SEPOLIA_BLOCK_OFFSET", "24")), }, sharedWalletUsageCleanupInterval: getInt(getEnvVar("SHARED_WALLET_USAGE_CLEANUP_INTERVAL", "60")), + ovalDiscoveryInterval: getInt(getEnvVar("OVAL_DISCOVERY_INTERVAL", "180")), + standardCoinbaseFactory: getAddress(getEnvVar("STANDARD_COINBASE_FACTORY", "0x0e3d2b8220C0f74A287B85690a8cfeE5b45C2D44")), + standardChainlinkFactory: getAddress(getEnvVar("STANDARD_CHAINLINK_FACTORY", "0x6d0cbebdeBc5060E6264fcC497d5A277B5748Cf9")), + standardChronicleFactory: getAddress(getEnvVar("STANDARD_CHRONICLE_FACTORY", "0xE0225B5224512868814D9b10A14F705d99Ba0EdF")), + standardPythFactory: getAddress(getEnvVar("STANDARD_PYTH_FACTORY", "0x53A2a7C0cBb76B20782C6842A25876C5377B64e8")), + defaultRefundAddress: getAddress(getEnvVar("DEFAULT_REFUND_ADDRESS", "0x9Cc5b1bc0E1970D44B5Adc7ba51d76a5DD375434")), + defaultRefundPercent: getFloat(getEnvVar("DEFAULT_REFUND_PERCENT", fallback.refundPercent)), }; \ No newline at end of file diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index db7581b..976e3a0 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -13,10 +13,11 @@ import { import { Request } from "express"; import { FlashbotsBundleProvider } from "flashbots-ethers-v6-provider-bundle"; import { JSONRPCRequest } from "json-rpc-2.0"; +import { OvalDiscovery } from "./"; import { flashbotsSupportedNetworks, supportedNetworks } from "./constants"; import { env } from "./env"; import { Logger } from "./logging"; -import { OvalAddressConfigList, OvalConfig, OvalConfigShared, OvalConfigs, OvalConfigsShared } from "./types"; +import { OvalAddressConfigList, OvalConfig, OvalConfigShared, OvalConfigs, OvalConfigsShared, RefundConfig } from "./types"; export function getProvider() { const network = new Network(supportedNetworks[env.chainId], env.chainId); @@ -278,6 +279,21 @@ export function getOvalConfigsShared(input: string): OvalConfigsShared { throw new Error(`Value "${input}" is valid JSON but is not OvalConfigsShared records`); } +export const getOvalAddresses = (): string[] => { + const factoryInstances = OvalDiscovery.getInstance().getOvalFactoryInstances(); + return [...factoryInstances, ...Object.keys(env.ovalConfigs)].map(getAddress); +}; + +export const getOvalRefundConfig = (ovalAddress: string): RefundConfig => { + if (env.ovalConfigs[ovalAddress]) { + return env.ovalConfigs[ovalAddress]; + } + return { + refundAddress: getAddress(env.defaultRefundAddress), + refundPercent: env.defaultRefundPercent, + }; +}; + // Get OvalAddressConfigList from the header string or throw an error if the header is invalid. export const getOvalHeaderConfigs = ( header: string | string[] | undefined, @@ -292,10 +308,10 @@ export const getOvalHeaderConfigs = ( } // Normalise addresses and check if they are valid Oval instances. const normalisedAddresses = ovalAddresses.map(getAddress); - if (normalisedAddresses.some((ovalAddress) => !ovalConfigs[ovalAddress])) { + if (normalisedAddresses.some((ovalAddress) => !getOvalAddresses().includes(ovalAddress))) { throw new Error(`Some addresses in "${header}" are not valid Oval instances`); } - const uniqueRefundAddresses = new Set(normalisedAddresses.map((address) => ovalConfigs[address].refundAddress)); + const uniqueRefundAddresses = new Set(normalisedAddresses.map((address) => getOvalRefundConfig(address).refundAddress)); if (uniqueRefundAddresses.size > 1) { throw new Error(`Value "${header}" only supports a single refund address`); } diff --git a/src/lib/index.ts b/src/lib/index.ts index beaaf32..dc6c586 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -5,3 +5,4 @@ export * from "./logging"; export * from "./bundleUtils"; export * from "./constants"; export * from "./walletManager"; +export * from "./ovalDiscovery"; diff --git a/src/lib/ovalDiscovery.ts b/src/lib/ovalDiscovery.ts index 2fff4d9..07f548e 100644 --- a/src/lib/ovalDiscovery.ts +++ b/src/lib/ovalDiscovery.ts @@ -1,167 +1,81 @@ -import { JsonRpcProvider, Wallet, getAddress } from "ethers"; -import { OvalConfigs, OvalConfigsShared } from "./types"; -import { retrieveGckmsKey } from "./gckms"; +import { EventLog, JsonRpcProvider, getAddress } from "ethers"; import { env } from "./env"; -import { isMochaTest } from "./helpers"; - - - -// WalletManager class to handle wallet operations. -export class WalletManager { - private static instance: WalletManager; - private wallets: Record = {}; - private sharedWallets: Map = new Map(); - private sharedWalletUsage: Map> = new Map(); - private provider: JsonRpcProvider; - - private constructor(provider: JsonRpcProvider) { - this.provider = provider; - this.setupCleanupInterval(); +import { EventSearchConfig, paginatedEventQuery } from "./events"; + +import { StandardCoinbaseFactory } from "../contract-types/StandardCoinbaseFactory"; +import { StandardCoinbaseFactory__factory } from "../contract-types/factories/StandardCoinbaseFactory__factory"; + +import { StandardChainlinkFactory } from "../contract-types/StandardChainlinkFactory"; +import { StandardChainlinkFactory__factory } from "../contract-types/factories/StandardChainlinkFactory__factory"; + +import { StandardChronicleFactory } from "../contract-types/StandardChronicleFactory"; +import { StandardChronicleFactory__factory } from "../contract-types/factories/StandardChronicleFactory__factory"; + +import { StandardPythFactory } from "../contract-types/StandardPythFactory"; +import { StandardPythFactory__factory } from "../contract-types/factories/StandardPythFactory__factory"; +import { FACTORIES_GENESIS_BLOCK } from "./constants"; + +// Singleton class to discover Oval instances +export class OvalDiscovery { + private static instance: OvalDiscovery; + private provider: JsonRpcProvider | undefined; + private standardCoinbaseFactory: StandardCoinbaseFactory; + private standardChainlinkFactory: StandardChainlinkFactory; + private standardChronicleFactory: StandardChronicleFactory; + private standardPythFactory: StandardPythFactory; + private ovalInstances: Set = new Set(); + + private constructor() { + this.standardCoinbaseFactory = StandardCoinbaseFactory__factory.connect(env.standardCoinbaseFactory); + this.standardChainlinkFactory = StandardChainlinkFactory__factory.connect(env.standardChainlinkFactory); + this.standardChronicleFactory = StandardChronicleFactory__factory.connect(env.standardChronicleFactory); + this.standardPythFactory = StandardPythFactory__factory.connect(env.standardPythFactory); } // Singleton pattern to get an instance of WalletManager - public static getInstance(provider: JsonRpcProvider): WalletManager { - if (!WalletManager.instance) { - WalletManager.instance = new WalletManager(provider); - } - return WalletManager.instance; - } - - // Initialize wallets with configurations - public async initialize(ovalConfigs: OvalConfigs, sharedConfigs?: OvalConfigsShared): Promise { - await this.initializeWallets(ovalConfigs); - if (sharedConfigs) { - await this.initializeSharedWallets(sharedConfigs); + public static getInstance(): OvalDiscovery { + if (!OvalDiscovery.instance) { + OvalDiscovery.instance = new OvalDiscovery(); + OvalDiscovery.instance.findOval(FACTORIES_GENESIS_BLOCK); } + return OvalDiscovery.instance; } - // Get a wallet for a given address - public getWallet(address: string, targetBlock: number): Wallet { - const checkSummedAddress = getAddress(address); - const wallet = this.wallets[checkSummedAddress]; - if (!wallet) { - return this.getSharedWallet(address, targetBlock); - } - return wallet.connect(this.provider); + public async initialize(provider: JsonRpcProvider) { + if (this.provider) return; + this.provider = provider; + this.findOval(FACTORIES_GENESIS_BLOCK); } - // Get a shared wallet for a given Oval instance and target block - private getSharedWallet(ovalInstance: string, targetBlock: number): Wallet { - // Check if a wallet has already been assigned to this Oval instance - if (this.sharedWalletUsage.has(ovalInstance)) { - const previousAssignments = this.sharedWalletUsage.get(ovalInstance); - if (previousAssignments) { - const existingAssignment = previousAssignments.find(assignment => assignment.walletPubKey); - if (existingAssignment) { - return this.sharedWallets.get(existingAssignment.walletPubKey)!.connect(this.provider); - } - } - } - - // If no wallet has been assigned, find the least used wallet - const selectedWallet = this.findLeastUsedWallet(); - if (selectedWallet) { - this.updateWalletUsage(ovalInstance, selectedWallet, targetBlock); - const selectedWalletPubKey = selectedWallet.address; - this.sharedWallets.set(selectedWalletPubKey, selectedWallet); - return selectedWallet.connect(this.provider); - } + public async findOval(fromBlock: number) { + if (!this.provider) return; + const lastBlock = await this.provider.getBlockNumber(); - throw new Error(`No available shared wallets for Oval instance ${ovalInstance} at block ${targetBlock}`); - } + const factories = [this.standardCoinbaseFactory, this.standardChainlinkFactory, this.standardChronicleFactory, this.standardPythFactory]; - // Private helper methods - private setupCleanupInterval(): void { - if (isMochaTest()) return; - setInterval(async () => { - const currentBlock = await this.provider.getBlockNumber(); - this.cleanupOldRecords(currentBlock); - }, env.sharedWalletUsageCleanupInterval * 1000); - } + for (const factory of factories) { + const searchConfig: EventSearchConfig = { + fromBlock, + toBlock: lastBlock, + maxBlockLookBack: 20000 + }; + const ovalDeployments = await paginatedEventQuery(factory.connect(this.provider), factory.filters.OvalDeployed(undefined, undefined, undefined, undefined, undefined, undefined), searchConfig); - private async initializeWallets(configs: OvalConfigs): Promise { - for (const [address, config] of Object.entries(configs)) { - this.wallets[address] = await this.createWallet(config); - } - } - - private async initializeSharedWallets(configs: OvalConfigsShared): Promise { - for (const config of configs) { - const wallet = await this.createWallet(config); - if (wallet) { - const walletPubKey = await wallet.getAddress(); - this.sharedWallets.set(walletPubKey, wallet); - this.sharedWalletUsage.set(walletPubKey, []); - } - } - } - - private async createWallet(config: WalletConfig): Promise { - if (config.unlockerKey) { - return new Wallet(config.unlockerKey); - } - if (config.gckmsKeyId) { - const gckmsKey = await retrieveGckmsKey({ - ...JSON.parse(env.gckmsConfig), - cryptoKeyId: config.gckmsKeyId, - ciphertextFilename: `${config.gckmsKeyId}.enc`, + ovalDeployments.forEach((ovalDeployment: EventLog) => { + this.ovalInstances.add(getAddress(ovalDeployment.args[1])); }); - return new Wallet(gckmsKey); } - throw new Error('Invalid wallet configuration'); - } - - private findLeastUsedWallet(): Wallet | undefined { - let selectedWallet: Wallet | undefined; - const usageCount = new Map() - // Initialize usage counts for each wallet - this.sharedWallets.forEach((_, walletPubKey) => { - usageCount.set(walletPubKey, 0); - }); - - // Sum usage counts for each wallet - this.sharedWalletUsage.forEach((usageRecords, _) => { - usageRecords.forEach((record) => { - const count = usageCount.get(record.walletPubKey) || 0; - usageCount.set(record.walletPubKey, count + record.count); - }); - }); - - // Find the wallet with the least usage - let minUsage = Infinity; - usageCount.forEach((count, walletPubKey) => { - if (count < minUsage) { - minUsage = count; - selectedWallet = this.sharedWallets.get(walletPubKey); - } - }); - - return selectedWallet; + setTimeout(() => { + this.findOval(lastBlock); + }, env.ovalDiscoveryInterval * 1000); } - private async updateWalletUsage(ovalInstance: string, wallet: Wallet, targetBlock: number): Promise { - const walletPubKey = await wallet.getAddress(); - const usageRecords = this.sharedWalletUsage.get(ovalInstance) || []; - const existingRecord = usageRecords.find(record => record.walletPubKey === walletPubKey && record.targetBlock === targetBlock); - - if (existingRecord) { - existingRecord.count += 1; - } else { - usageRecords.push({ walletPubKey, targetBlock, count: 1 }); - } - - this.sharedWalletUsage.set(ovalInstance, usageRecords); + public getOvalFactoryInstances(): Array { + return Array.from(this.ovalInstances); } - private cleanupOldRecords(currentBlock: number): void { - this.sharedWalletUsage.forEach((usageRecords, walletPubKey) => { - const filteredRecords = usageRecords.filter(record => record.targetBlock >= currentBlock - 1); - if (filteredRecords.length === 0) { - this.sharedWalletUsage.delete(walletPubKey); - } else { - this.sharedWalletUsage.set(walletPubKey, filteredRecords); - } - }); + public isOval(address: string): boolean { + return this.ovalInstances.has(address); } } \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts index 7094334..9418707 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,4 @@ -export interface OvalConfig { - unlockerKey?: string; - gckmsKeyId?: string; +export interface RefundConfig { refundAddress: string; refundPercent: number; } @@ -10,6 +8,8 @@ export interface OvalConfigShared { gckmsKeyId?: string; } +export interface OvalConfig extends OvalConfigShared, RefundConfig { } + // Records to store supported Oval instances and their configs. export type OvalConfigs = Record; diff --git a/src/lib/walletManager.ts b/src/lib/walletManager.ts index 90d9fb4..755e110 100644 --- a/src/lib/walletManager.ts +++ b/src/lib/walletManager.ts @@ -1,8 +1,9 @@ import { JsonRpcProvider, Wallet, getAddress } from "ethers"; -import { OvalConfigs, OvalConfigsShared } from "./types"; -import { retrieveGckmsKey } from "./gckms"; +import { OvalDiscovery } from "./"; import { env } from "./env"; +import { retrieveGckmsKey } from "./gckms"; import { isMochaTest } from "./helpers"; +import { OvalConfigs, OvalConfigsShared } from "./types"; type WalletConfig = { unlockerKey?: string; @@ -18,6 +19,7 @@ type WalletUsed = { // WalletManager class to handle wallet operations. export class WalletManager { private static instance: WalletManager; + private ovalDiscovery: OvalDiscovery; private wallets: Record = {}; private sharedWallets: Map = new Map(); private sharedWalletUsage: Map> = new Map(); @@ -25,6 +27,7 @@ export class WalletManager { private constructor(provider: JsonRpcProvider) { this.provider = provider; + this.ovalDiscovery = OvalDiscovery.getInstance(); this.setupCleanupInterval(); } @@ -56,6 +59,9 @@ export class WalletManager { // Get a shared wallet for a given Oval instance and target block private getSharedWallet(ovalInstance: string, targetBlock: number): Wallet { + if (!this.ovalDiscovery.isOval(ovalInstance)) { + throw new Error(`Oval instance ${ovalInstance} is not found`); + } // Check if a wallet has already been assigned to this Oval instance if (this.sharedWalletUsage.has(ovalInstance)) { const previousAssignments = this.sharedWalletUsage.get(ovalInstance); diff --git a/test/walletManager.ts b/test/walletManager.ts index a9dbbeb..c62db67 100644 --- a/test/walletManager.ts +++ b/test/walletManager.ts @@ -4,8 +4,10 @@ import { WalletManager } from '../src/lib/walletManager'; import { JsonRpcProvider, Wallet } from 'ethers'; import "../src/lib/express-extensions"; import * as gckms from '../src/lib/gckms'; +import * as ovalDiscovery from '../src/lib/ovalDiscovery'; import { OvalConfigs, OvalConfigsShared } from '../src/lib/types'; + const mockProvider = new JsonRpcProvider(); const getRandomAddressAndKey = () => { @@ -18,6 +20,14 @@ const getRandomAddressAndKey = () => { describe('WalletManager Tests', () => { + beforeEach(() => { + const ovalDiscoveryInstance = { + isOval: sinon.stub().resolves(true), + findOval: sinon.stub().resolves() + }; + sinon.stub(ovalDiscovery.OvalDiscovery, 'getInstance').returns(ovalDiscoveryInstance as any); + }); + afterEach(() => { sinon.restore(); }); diff --git a/yarn.lock b/yarn.lock index 26ab52f..4a7f585 100644 --- a/yarn.lock +++ b/yarn.lock @@ -960,6 +960,11 @@ dependencies: axios "*" +"@types/bluebird@^3.5.42": + version "3.5.42" + resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.42.tgz#7ec05f1ce9986d920313c1377a5662b1b563d366" + integrity sha512-Jhy+MWRlro6UjVi578V/4ZGNfeCOcNCp0YaFNIUGFKlImowqwb1O/22wDVk3FDGMLqxdpOV3qQHD5fPEH4hK6A== + "@types/bn.js@^5.1.0", "@types/bn.js@^5.1.1": version "5.1.5" resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-5.1.5.tgz#2e0dacdcce2c0f16b905d20ff87aedbc6f7b4bf0"