diff --git a/package.json b/package.json index e533de5..79f4bef 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@types/express": "^4.17.18", "@types/jest": "^29.5.3", "@types/node": "^18.16.3", + "@types/node-fetch": "^2.6.9", "@types/node-slack": "^0.0.31", "@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/parser": "^5.51.0", diff --git a/src/commands/runMulti.ts b/src/commands/runMulti.ts index 9b1c353..2ee2250 100644 --- a/src/commands/runMulti.ts +++ b/src/commands/runMulti.ts @@ -14,6 +14,7 @@ export async function runMulti(options: RunMultiOptions) { deploymentBlocks, watchdogTimeouts, orderBookApis, + filterPolicyConfigFiles, oneShot, disableApi, apiPort, @@ -51,6 +52,7 @@ export async function runMulti(options: RunMultiOptions) { deploymentBlock: deploymentBlocks[index], watchdogTimeout: watchdogTimeouts[index], orderBookApi: orderBookApis[index], + filterPolicyConfig: filterPolicyConfigFiles[index], }, storage ); diff --git a/src/domain/chainContext.ts b/src/domain/chainContext.ts index 71d3010..e5c23d0 100644 --- a/src/domain/chainContext.ts +++ b/src/domain/chainContext.ts @@ -32,6 +32,7 @@ import { reorgsTotal, } from "../utils/metrics"; import { hexZeroPad } from "ethers/lib/utils"; +import { FilterPolicy } from "../utils/filterPolicy"; const WATCHDOG_FREQUENCY = 5 * 1000; // 5 seconds @@ -67,6 +68,11 @@ export interface ChainWatcherHealth { }; } +export interface FilterPolicyConfig { + baseUrl: string; + // authToken: string; // TODO: Implement authToken +} + /** * The chain context handles watching a single chain for new conditional orders * and executing them. @@ -85,6 +91,7 @@ export class ChainContext { chainId: SupportedChainId; registry: Registry; orderBook: OrderBookApi; + filterPolicy: FilterPolicy | undefined; contract: ComposableCoW; multicall: Multicall3; @@ -101,6 +108,7 @@ export class ChainContext { watchdogTimeout, owners, orderBookApi, + filterPolicyConfig, } = options; this.deploymentBlock = deploymentBlock; this.pageSize = pageSize; @@ -126,12 +134,19 @@ export class ChainContext { }, }); + this.filterPolicy = filterPolicyConfig + ? new FilterPolicy({ + configBaseUrl: filterPolicyConfig, + // configAuthToken: filterPolicyConfigAuthToken, // TODO: Implement authToken + }) + : undefined; this.contract = composableCowContract(this.provider, this.chainId); this.multicall = Multicall3__factory.connect(MULTICALL3, this.provider); } /** - * Initialise a chain context. + * Initialize a chain context. + * * @param options as parsed by commander from the command line arguments. * @param storage the db singleton that provides persistence. * @returns A chain context that is monitoring for orders on the chain. @@ -444,7 +459,7 @@ async function processBlock( blockNumberOverride?: number, blockTimestampOverride?: number ) { - const { provider, chainId } = context; + const { provider, chainId, filterPolicy } = context; const timer = processBlockDurationSeconds .labels(context.chainId.toString()) .startTimer(); @@ -454,6 +469,14 @@ async function processBlock( block.number.toString() ); + // Get the latest filter policy + if (filterPolicy) { + filterPolicy.reloadPolicies().catch((error) => { + console.log(`Error fetching the filter policy config for chain `, error); + return null; + }); + } + // Transaction watcher for adding new contracts let hasErrors = false; for (const event of events) { diff --git a/src/domain/checkForAndPlaceOrder.ts b/src/domain/checkForAndPlaceOrder.ts index 69cbe04..9be3956 100644 --- a/src/domain/checkForAndPlaceOrder.ts +++ b/src/domain/checkForAndPlaceOrder.ts @@ -25,6 +25,7 @@ import { PollResultCode, PollResultErrors, PollResultSuccess, + SigningScheme, SupportedChainId, formatEpoch, } from "@cowprotocol/cow-sdk"; @@ -41,6 +42,7 @@ import { pollingOnChainEthersErrorsTotal, measureTime, } from "../utils/metrics"; +import { FilterAction } from "../utils/filterPolicy"; const GPV2SETTLEMENT = "0x9008D19f58AAbD9eD0D60971565AA8510560ab41"; @@ -83,7 +85,7 @@ export async function checkForAndPlaceOrder( blockNumberOverride?: number, blockTimestampOverride?: number ) { - const { chainId, registry } = context; + const { chainId, registry, filterPolicy } = context; const { ownerOrders, numOrders } = registry; const blockNumber = blockNumberOverride || block.number; @@ -110,9 +112,7 @@ export async function checkForAndPlaceOrder( ); const ordersPendingDelete = []; // enumerate all the `ConditionalOrder`s for a given owner - log.debug( - `Process owner ${owner} (${conditionalOrders.size} orders): ${registry.numOrders}` - ); + log.debug(`Process owner ${owner} (${conditionalOrders.size} orders)`); for (const conditionalOrder of conditionalOrders) { orderCounter++; const ownerRef = `${ownerCounter}.${orderCounter}`; @@ -127,13 +127,32 @@ export async function checkForAndPlaceOrder( const { result: lastHint } = conditionalOrder.pollResult || {}; + // Apply filtering policy + if (filterPolicy) { + const filterResult = filterPolicy.preFilter({ + owner, + conditionalOrderParams: conditionalOrder.params, + }); + + switch (filterResult) { + case FilterAction.DROP: + log.debug("Dropping conditional order. Reason: AcceptPolicy: DROP"); + ordersPendingDelete.push(conditionalOrder); + + continue; + case FilterAction.SKIP: + log.debug("Skipping conditional order. Reason: AcceptPolicy: SKIP"); + continue; + } + } + // Check if the order is due (by epoch) if ( lastHint?.result === PollResultCode.TRY_AT_EPOCH && blockTimestamp < lastHint.epoch ) { log.debug( - `Skipping conditional. Reason: Not due yet (TRY_AT_EPOCH=${ + `Skipping conditional order. Reason: Not due yet (TRY_AT_EPOCH=${ lastHint.epoch }, ${formatEpoch(lastHint.epoch)}). ${logOrderDetails}`, conditionalOrder.params @@ -147,7 +166,7 @@ export async function checkForAndPlaceOrder( blockNumber < lastHint.blockNumber ) { log.debug( - `Skipping conditional. Reason: Not due yet (TRY_ON_BLOCK=${ + `Skipping conditional order. Reason: Not due yet (TRY_ON_BLOCK=${ lastHint.blockNumber }, in ${ lastHint.blockNumber - blockNumber @@ -442,16 +461,26 @@ async function _placeOrder(params: { const { chainId } = orderBook.context; try { const postOrder: OrderCreation = { - ...order, + kind: order.kind, + from: order.from, + sellToken: order.sellToken, + buyToken: order.buyToken, sellAmount: order.sellAmount.toString(), buyAmount: order.buyAmount.toString(), + receiver: order.receiver, feeAmount: order.feeAmount.toString(), - signingScheme: "eip1271", + validTo: order.validTo, + appData: order.appData, + partiallyFillable: order.partiallyFillable, + sellTokenBalance: order.sellTokenBalance, + buyTokenBalance: order.buyTokenBalance, + signingScheme: SigningScheme.EIP1271, + signature: order.signature, }; // If the operation is a dry run, don't post to the API log.info(`Post order ${orderUid} to OrderBook on chain ${chainId}`); - log.debug(`Order`, postOrder); + log.debug(`Post order details`, postOrder); if (!dryRun) { const orderUid = await orderBook.sendOrder(postOrder); orderBookDiscreteOrdersTotal.labels(...metricLabels).inc(); diff --git a/src/index.ts b/src/index.ts index 4ef39be..51544be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,7 +71,7 @@ const databasePathOption = new Option( .default(DEFAULT_DATABASE_PATH) .env("DATABASE_PATH"); -const chainConfigHelp = `Chain configuration in the format of ,[,,], e.g. http://erigon.dappnode:8545,12345678,30,https://api.cow.fi/mainnet`; +const chainConfigHelp = `Chain configuration in the format of ,[,,,], e.g. http://erigon.dappnode:8545,12345678,30,https://api.cow.fi/mainnet,https://raw.githubusercontent.com/cowprotocol/watch-tower/config/filter-policy-1.json`; const multiChainConfigOption = new Option( "--chain-config ", chainConfigHelp @@ -231,7 +231,7 @@ function parseChainConfigOption(option: string): ChainConfigOptions { // Ensure there are at least two parts (rpc and deploymentBlock) if (parts.length < 2) { throw new InvalidArgumentError( - `Chain configuration must be in the format of ,[,,], e.g. http://erigon.dappnode:8545,12345678,30,https://api.cow.fi/mainnet` + `Chain configuration must be in the format of ,[,,,], e.g. http://erigon.dappnode:8545,12345678,30,https://api.cow.fi/mainnet,https://raw.githubusercontent.com/cowprotocol/watch-tower/config/filter-policy-1.json` ); } @@ -255,7 +255,8 @@ function parseChainConfigOption(option: string): ChainConfigOptions { const rawWatchdogTimeout = parts[2]; // If there is a third part, it is the watchdogTimeout - const watchdogTimeout = parts.length > 2 ? Number(rawWatchdogTimeout) : 30; + const watchdogTimeout = + parts.length > 2 && rawWatchdogTimeout ? Number(rawWatchdogTimeout) : 30; // Ensure that the watchdogTimeout is a positive number if (isNaN(watchdogTimeout) || watchdogTimeout < 0) { throw new InvalidArgumentError( @@ -264,7 +265,7 @@ function parseChainConfigOption(option: string): ChainConfigOptions { } // If there is a fourth part, it is the orderBookApi - const orderBookApi = parts.length > 3 ? parts[3] : undefined; + const orderBookApi = parts.length > 3 && parts[3] ? parts[3] : undefined; // Ensure that the orderBookApi is a valid URL if (orderBookApi && !isValidUrl(orderBookApi)) { throw new InvalidArgumentError( @@ -272,7 +273,23 @@ function parseChainConfigOption(option: string): ChainConfigOptions { ); } - return { rpc, deploymentBlock, watchdogTimeout, orderBookApi }; + // If there is a fifth part, it is the filterPolicyConfig + const filterPolicyConfig = + parts.length > 4 && parts[4] ? parts[4] : undefined; + // Ensure that the orderBookApi is a valid URL + if (filterPolicyConfig && !isValidUrl(filterPolicyConfig)) { + throw new InvalidArgumentError( + `${filterPolicyConfig} must be a valid URL (orderBookApi)` + ); + } + + return { + rpc, + deploymentBlock, + watchdogTimeout, + orderBookApi, + filterPolicyConfig, + }; } function parseChainConfigOptions( @@ -282,15 +299,23 @@ function parseChainConfigOptions( deploymentBlocks: [], watchdogTimeouts: [], orderBookApis: [], + filterPolicyConfigFiles: [], } ): MultiChainConfigOptions { const parsedOption = parseChainConfigOption(option); - const { rpc, deploymentBlock, watchdogTimeout, orderBookApi } = parsedOption; + const { + rpc, + deploymentBlock, + watchdogTimeout, + orderBookApi, + filterPolicyConfig, + } = parsedOption; previous.rpcs.push(rpc); previous.deploymentBlocks.push(deploymentBlock); previous.watchdogTimeouts.push(watchdogTimeout); previous.orderBookApis.push(orderBookApi); + previous.filterPolicyConfigFiles.push(filterPolicyConfig); return previous; } diff --git a/src/types/index.ts b/src/types/index.ts index 99e1cf6..56e9689 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -27,14 +27,17 @@ export type ChainConfigOptions = { rpc: string; deploymentBlock: number; watchdogTimeout: number; - orderBookApi: OrderBookApi; + orderBookApi?: string; + filterPolicyConfig?: string; + // filterPolicyConfigAuthToken?: string; // TODO: Implement authToken }; export type MultiChainConfigOptions = { rpcs: string[]; deploymentBlocks: number[]; watchdogTimeouts: number[]; - orderBookApis: OrderBookApi[]; + orderBookApis: (string | undefined)[]; + filterPolicyConfigFiles: (string | undefined)[]; }; export type RunSingleOptions = RunOptions & ChainConfigOptions; diff --git a/src/utils/filterPolicy.ts b/src/utils/filterPolicy.ts new file mode 100644 index 0000000..656e0b0 --- /dev/null +++ b/src/utils/filterPolicy.ts @@ -0,0 +1,84 @@ +import { ConditionalOrderParams } from "@cowprotocol/cow-sdk"; + +export enum FilterAction { + DROP = "DROP", + SKIP = "SKIP", + ACCEPT = "ACCEPT", +} + +interface PolicyConfig { + owners: Map; + handlers: Map; +} + +export interface FilterParams { + owner: string; + conditionalOrderParams: ConditionalOrderParams; +} + +export interface FilterPolicyParams { + configBaseUrl: string; + // configAuthToken: string; // TODO: Implement authToken +} +export class FilterPolicy { + protected configUrl: string; + protected config: PolicyConfig | undefined; + + constructor({ configBaseUrl }: FilterPolicyParams) { + this.configUrl = configBaseUrl; + } + + /** + * Decide if a conditional order should be processed, ignored, or dropped base in some filtering rules + * + * @param filterParams params required for the pre-filtering, including the conditional order params, chainId and the owner contract + * @returns The action that should be performed with the conditional order + */ + preFilter({ owner, conditionalOrderParams }: FilterParams): FilterAction { + if (!this.config) { + return FilterAction.ACCEPT; + } + + const { owners, handlers } = this.config; + + const action = + owners.get(owner) || handlers.get(conditionalOrderParams.handler); + + if (action) { + return action; + } + + return FilterAction.ACCEPT; + } + + /** + * Reloads the policies with their latest version + */ + async reloadPolicies() { + const policyConfig = await this.getConfig(); + + if (policyConfig) { + this.config = policyConfig; + } + } + + protected async getConfig(): Promise { + if (!this.configUrl) { + throw new Error("configUrl must be defined"); + } + const configResponse = await fetch(this.configUrl); // TODO: Implement authToken + + if (!configResponse.ok) { + throw new Error( + `Failed to fetch policy. Error ${ + configResponse.status + }: ${await configResponse.text().catch(() => "")}` + ); + } + const config = await configResponse.json(); + return { + owners: new Map(Object.entries(config.owners)), + handlers: new Map(Object.entries(config.handlers)), + }; + } +} diff --git a/yarn.lock b/yarn.lock index 922f8e2..5dd8874 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1405,6 +1405,14 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== +"@types/node-fetch@^2.6.9": + version "2.6.9" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.9.tgz#15f529d247f1ede1824f7e7acdaa192d5f28071e" + integrity sha512-bQVlnMLFJ2d35DkPNjEPmd9ueO/rh5EiaZt2bhqiSarPjZIuIV6bPQVqcrEyvNo+AfTrRGVazle1tl597w3gfA== + dependencies: + "@types/node" "*" + form-data "^4.0.0" + "@types/node-slack@^0.0.31": version "0.0.31" resolved "https://registry.yarnpkg.com/@types/node-slack/-/node-slack-0.0.31.tgz#0ea777e16bb48edf4d0028682daebc4f8b85f377" @@ -3077,6 +3085,15 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"