diff --git a/typescript/solver/NonceKeeperWallet.ts b/typescript/solver/NonceKeeperWallet.ts index 94313a25..782b197e 100644 --- a/typescript/solver/NonceKeeperWallet.ts +++ b/typescript/solver/NonceKeeperWallet.ts @@ -1,3 +1,4 @@ +import type { BigNumber } from "@ethersproject/bignumber"; import { defaultPath, HDNode } from "@ethersproject/hdnode"; import type { Deferrable } from "@ethersproject/properties"; import type { @@ -36,7 +37,7 @@ export class NonceKeeperWallet extends Wallet { // this check is necessary in order to not generate new nonces when a tx is going to fail await super.estimateGas(transaction); } catch (error) { - checkError(error, {transaction}); + checkError(error, { transaction }); } if (transaction.nonce == null) { @@ -48,6 +49,14 @@ export class NonceKeeperWallet extends Wallet { return super.sendTransaction(transaction); } + async estimateGas( + transaction: Deferrable, + ): Promise { + return super + .estimateGas(transaction) + .catch((error) => checkError(error, { transaction })); + } + static override fromMnemonic( mnemonic: string, path?: string, @@ -67,47 +76,84 @@ function checkError(error: any, params: any): any { const transaction = params.transaction || params.signedTransaction; let message = error.message; - if (error.code === Logger.errors.SERVER_ERROR && error.error && typeof(error.error.message) === "string") { - message = error.error.message; - } else if (typeof(error.body) === "string") { - message = error.body; - } else if (typeof(error.responseText) === "string") { - message = error.responseText; + if ( + error.code === Logger.errors.SERVER_ERROR && + error.error && + typeof error.error.message === "string" + ) { + message = error.error.message; + } else if (typeof error.body === "string") { + message = error.body; + } else if (typeof error.responseText === "string") { + message = error.responseText; } message = (message || "").toLowerCase(); // "insufficient funds for gas * price + value + cost(data)" if (message.match(/insufficient funds|base fee exceeds gas limit/i)) { - ethersLogger.throwError("insufficient funds for intrinsic transaction cost", Logger.errors.INSUFFICIENT_FUNDS, { - error, transaction - }); + ethersLogger.throwError( + "insufficient funds for intrinsic transaction cost", + Logger.errors.INSUFFICIENT_FUNDS, + { + error, + transaction, + }, + ); } // "nonce too low" if (message.match(/nonce (is )?too low/i)) { - ethersLogger.throwError("nonce has already been used", Logger.errors.NONCE_EXPIRED, { - error, transaction - }); + ethersLogger.throwError( + "nonce has already been used", + Logger.errors.NONCE_EXPIRED, + { + error, + transaction, + }, + ); } // "replacement transaction underpriced" - if (message.match(/replacement transaction underpriced|transaction gas price.*too low/i)) { - ethersLogger.throwError("replacement fee too low", Logger.errors.REPLACEMENT_UNDERPRICED, { - error, transaction - }); + if ( + message.match( + /replacement transaction underpriced|transaction gas price.*too low/i, + ) + ) { + ethersLogger.throwError( + "replacement fee too low", + Logger.errors.REPLACEMENT_UNDERPRICED, + { + error, + transaction, + }, + ); } // "replacement transaction underpriced" if (message.match(/only replay-protected/i)) { - ethersLogger.throwError("legacy pre-eip-155 transactions not supported", Logger.errors.UNSUPPORTED_OPERATION, { - error, transaction - }); + ethersLogger.throwError( + "legacy pre-eip-155 transactions not supported", + Logger.errors.UNSUPPORTED_OPERATION, + { + error, + transaction, + }, + ); } - if (message.match(/gas required exceeds allowance|always failing transaction|execution reverted/)) { - ethersLogger.throwError("cannot estimate gas; transaction may fail or may require manual gas limit", Logger.errors.UNPREDICTABLE_GAS_LIMIT, { - error, transaction - }); + if ( + message.match( + /gas required exceeds allowance|always failing transaction|execution reverted/, + ) + ) { + ethersLogger.throwError( + "cannot estimate gas; transaction may fail or may require manual gas limit", + Logger.errors.UNPREDICTABLE_GAS_LIMIT, + { + error, + transaction, + }, + ); } throw error; diff --git a/typescript/solver/config/solvers.json b/typescript/solver/config/solvers.json index 24b34bbb..93b26091 100644 --- a/typescript/solver/config/solvers.json +++ b/typescript/solver/config/solvers.json @@ -4,5 +4,8 @@ }, "hyperlane7683": { "enabled": true + }, + "compactX": { + "enabled": true } } diff --git a/typescript/solver/index.ts b/typescript/solver/index.ts index ca033069..4fa5b6cb 100644 --- a/typescript/solver/index.ts +++ b/typescript/solver/index.ts @@ -9,7 +9,7 @@ import { getMultiProvider } from "./solvers/utils.js"; const main = async () => { const multiProvider = await getMultiProvider(chainMetadata).catch( - (error) => (log.error(error.reason ?? error.message), process.exit(1)) + (error) => (log.error(error.reason ?? error.message), process.exit(1)), ); log.info("🙍 Intent Solver 📝"); diff --git a/typescript/solver/package.json b/typescript/solver/package.json index 2c9f2a40..b1b79daf 100644 --- a/typescript/solver/package.json +++ b/typescript/solver/package.json @@ -26,6 +26,7 @@ "@inquirer/prompts": "^7.2.0", "@typechain/ethers-v5": "^11.1.2", "@types/copyfiles": "^2", + "@types/ws": "^8", "copyfiles": "^2.4.1", "prettier": "^3.3.3", "tsx": "^4.19.2", @@ -45,6 +46,7 @@ "pino": "^9.5.0", "pino-pretty": "^13.0.0", "pino-socket": "^7.4.0", - "uniqolor": "^1.1.1" + "uniqolor": "^1.1.1", + "ws": "^8.18.1" } } diff --git a/typescript/solver/solvers/BaseFiller.ts b/typescript/solver/solvers/BaseFiller.ts index 2f197c3d..36c2d06c 100644 --- a/typescript/solver/solvers/BaseFiller.ts +++ b/typescript/solver/solvers/BaseFiller.ts @@ -1,3 +1,4 @@ +import { BigNumber } from "@ethersproject/bignumber"; import type { MultiProvider } from "@hyperlane-xyz/sdk"; import type { Result } from "@hyperlane-xyz/utils"; import { @@ -82,11 +83,19 @@ export abstract class BaseFiller< await this.fill(parsedArgs, data, originChainName, blockNumber); await this.settleOrder(parsedArgs, data, originChainName); - } catch (error) { + } catch (error: any) { this.log.error({ msg: `Failed processing intent`, intent: `${this.metadata.protocolName}-${parsedArgs.orderId}`, - error: JSON.stringify(error), + error: { + stack: error.stack, + details: JSON.stringify(error, (_, value) => { + if (value instanceof BigNumber || typeof value === "bigint") { + return value.toString(); + } + return value; + }), + }, }); } }; diff --git a/typescript/solver/solvers/BaseListener.ts b/typescript/solver/solvers/BaseListener.ts index 28c6a5fe..37d8c40e 100644 --- a/typescript/solver/solvers/BaseListener.ts +++ b/typescript/solver/solvers/BaseListener.ts @@ -97,7 +97,7 @@ export abstract class BaseListener< confirmationBlocks, ), pollInterval ?? this.defaultPollInterval, - ) + ), ); contract.provider.getNetwork().then((network) => { @@ -118,9 +118,8 @@ export abstract class BaseListener< clearInterval(this.pollIntervals[i]); } this.pollIntervals = []; - } + }; }; - } protected async pollEvents( diff --git a/typescript/solver/solvers/SolverManager.ts b/typescript/solver/solvers/SolverManager.ts index ff55bb24..8791513e 100644 --- a/typescript/solver/solvers/SolverManager.ts +++ b/typescript/solver/solvers/SolverManager.ts @@ -8,16 +8,13 @@ type SolverModule = { create: () => Promise; }; filler: { - create: ( - multiProvider: MultiProvider, - rules?: any - ) => FillerFn; + create: (multiProvider: MultiProvider, rules?: any) => FillerFn; }; rules?: any; }; type ListenerFn = ( - handler: (args: T, originChainName: string, blockNumber: number) => void + handler: (args: T, originChainName: string, blockNumber: number) => void, ) => ShutdownFn; type ShutdownFn = () => void; @@ -25,7 +22,7 @@ type ShutdownFn = () => void; type FillerFn = ( args: T, originChainName: string, - blockNumber: number + blockNumber: number, ) => Promise; export class SolverManager { @@ -33,7 +30,7 @@ export class SolverManager { constructor( private readonly multiProvider: MultiProvider, - private readonly log: Logger + private readonly log: Logger, ) {} async initializeSolvers() { @@ -47,7 +44,7 @@ export class SolverManager { await this.initializeSolver(solverName as SolverName); } catch (error: any) { this.log.error( - `Failed to initialize solver ${solverName}: ${error.message}` + `Failed to initialize solver ${solverName}: ${error.message}`, ); throw error; } diff --git a/typescript/solver/solvers/WebSocketListener.ts b/typescript/solver/solvers/WebSocketListener.ts new file mode 100644 index 00000000..62f3e16a --- /dev/null +++ b/typescript/solver/solvers/WebSocketListener.ts @@ -0,0 +1,188 @@ +import type { Logger } from "../logger.js"; +import type { ParsedArgs } from "./BaseFiller.js"; + +import { WebSocket } from "ws"; +import { WSClientOptions } from "./types.js"; + +export interface ConnectionUpdate { + type: "connected"; + data: { + clientCount: number; + }; + timestamp: string; +} + +export type WebSocketMessage = ConnectionUpdate; + +export abstract class WebSocketListener { + private ws: WebSocket | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1_000; + private pingInterval: NodeJS.Timeout | null = null; + private pongTimeout: NodeJS.Timeout | null = null; + private lastPingTime = 0; + private wsUrl: string; + + protected onOpen() {} + protected onClose() {} + protected onError() {} + + protected constructor( + private readonly metadata: { + webSocket: { + url: string; + clientOptions?: WSClientOptions; + options?: { + maxReconnectAttempts?: number; + reconnectDelay?: number; + }; + }; + protocolName: string; + }, + private readonly log: Logger, + ) { + this.maxReconnectAttempts = + this.metadata.webSocket.options?.maxReconnectAttempts || + this.maxReconnectAttempts; + this.reconnectDelay = + this.metadata.webSocket.options?.reconnectDelay || this.reconnectDelay; + + this.wsUrl = this.metadata.webSocket.url; + } + + private connect(): void { + try { + this.ws = new WebSocket( + this.wsUrl, + this.metadata.webSocket.clientOptions as WebSocket.ClientOptions, + ); + this.setupEventListeners(); + } catch (error) { + this.log.error({ + msg: "Failed to create WebSocket connection", + error: JSON.stringify(error), + }); + this.handleReconnect(); + } + } + + private setupEventListeners(): void { + if (!this.ws) return; + + this.ws.on("open", (): void => { + this.log.info({ msg: "WebSocket connection established" }); + this.reconnectAttempts = 0; + this.reconnectDelay = 1000; + this.setupPingInterval(); + this.onOpen?.(); + }); + + this.ws.on("close", (): void => { + this.log.info({ msg: "WebSocket connection closed" }); + this.cleanupPingInterval(); + this.onClose?.(); + this.handleReconnect(); + }); + + this.ws.on("error", (error: Error): void => { + this.log.error({ + msg: "WebSocket error occurred", + error: JSON.stringify(error), + }); + this.cleanupPingInterval(); + }); + + this.ws.on("pong", () => { + if (this.pongTimeout) { + clearTimeout(this.pongTimeout); + this.pongTimeout = null; + } + + const latency = Date.now() - this.lastPingTime; + this.log.debug({ msg: "WebSocket pong", latency }); + }); + } + + private handleReconnect(): void { + this.cleanupPingInterval(); + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.log.info({ + msg: `Attempting to reconnect... (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})`, + }); + + setTimeout(() => { + this.reconnectAttempts++; + this.reconnectDelay *= 2; // Exponential backoff + this.connect(); + }, this.reconnectDelay); + } else { + this.log.error({ msg: "Max reconnection attempts reached" }); + } + } + + private setupPingInterval(): void { + // Send a ping every 15 seconds + this.pingInterval = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.lastPingTime = Date.now(); + + this.ws.ping(); + + // Set a timeout to close and reconnect if we don't receive a pong within 5 seconds + this.pongTimeout = setTimeout(() => { + this.log.warn({ + msg: "No pong received within timeout, closing connection...", + }); + if (this.ws) { + this.ws.close(); + } + }, 5_000); + } + }, 15_000); + } + + private cleanupPingInterval(): void { + if (this.pingInterval) { + clearInterval(this.pingInterval); + this.pingInterval = null; + } + if (this.pongTimeout) { + clearTimeout(this.pongTimeout); + this.pongTimeout = null; + } + } + + create() { + return ( + handler: ( + args: TParsedArgs, + originChainName: string, + blockNumber: number, + ) => void, + ) => { + this.connect(); + + if (!this.ws) { + this.log.debug({ msg: "WebSocket connection not yet established" }); + return; + } + + this.ws.on("message", (data: Buffer): void => { + try { + const args = this.parseEventArgs(data); + handler(args, "", -1); + } catch (error) { + this.log.error("Error parsing message:", error); + } + }); + + return () => { + this.cleanupPingInterval(); + this.ws?.close(); + }; + }; + } + + protected abstract parseEventArgs(args: Buffer): TParsedArgs; +} diff --git a/typescript/solver/solvers/compactX/config/allowBlockLists.ts b/typescript/solver/solvers/compactX/config/allowBlockLists.ts new file mode 100644 index 00000000..e06b7782 --- /dev/null +++ b/typescript/solver/solvers/compactX/config/allowBlockLists.ts @@ -0,0 +1,32 @@ +import { + type AllowBlockLists, + AllowBlockListsSchema, +} from "../../../config/types.js"; + +// Example config +// [ +// { +// senderAddress: "*", +// destinationDomain: ["1"], +// recipientAddress: "*" +// }, +// { +// senderAddress: ["0xca7f632e91B592178D83A70B404f398c0a51581F"], +// destinationDomain: ["42220", "43114"], +// recipientAddress: "*" +// }, +// { +// senderAddress: "*", +// destinationDomain: ["42161", "420"], +// recipientAddress: ["0xca7f632e91B592178D83A70B404f398c0a51581F"] +// } +// ] + +const allowBlockLists: AllowBlockLists = { + allowList: [], + blockList: [], +}; + +AllowBlockListsSchema.parse(allowBlockLists); + +export default allowBlockLists; diff --git a/typescript/solver/solvers/compactX/config/index.ts b/typescript/solver/solvers/compactX/config/index.ts new file mode 100644 index 00000000..43446c14 --- /dev/null +++ b/typescript/solver/solvers/compactX/config/index.ts @@ -0,0 +1,4 @@ +import allowBlockLists from "./allowBlockLists.js"; +import metadata from "./metadata.js"; + +export { allowBlockLists, metadata }; diff --git a/typescript/solver/solvers/compactX/config/metadata.ts b/typescript/solver/solvers/compactX/config/metadata.ts new file mode 100644 index 00000000..800f1ab5 --- /dev/null +++ b/typescript/solver/solvers/compactX/config/metadata.ts @@ -0,0 +1,160 @@ +import { type CompactXMetadata, CompactXMetadataSchema } from "../types.js"; + +const metadata: CompactXMetadata = { + protocolName: "CompactX", + intentSources: { + webSockets: [ + { + url: "ws://localhost:3000/ws", + // url: "wss://compactx-disseminator.com/ws", + }, + ], + }, + chainInfo: { + 1: { + arbiter: "0xDfd41e6E2e08e752f464084F5C11619A3c950237", + tribunal: "0xDfd41e6E2e08e752f464084F5C11619A3c950237", + compactX: "0x00000000000018DF021Ff2467dF97ff846E09f48", + prefix: + "0x1901afbd5f3d34c216b31ba8b82d0b32ae91e4edea92dd5bbf4c1ad028f72364a211", + priorityFee: 1n, + tokens: { + ETH: { + address: "0x0000000000000000000000000000000000000000", + decimals: 18, + symbol: "ETH", + coingeckoId: "ethereum", + }, + WETH: { + address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + decimals: 18, + symbol: "WETH", + coingeckoId: "weth", + }, + USDC: { + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + decimals: 6, + symbol: "USDC", + coingeckoId: "usd-coin", + }, + }, + }, + 10: { + arbiter: "0x2602D9f66ec17F2dc770063F7B91821DD741F626", + tribunal: "0x2602D9f66ec17F2dc770063F7B91821DD741F626", + compactX: "0x00000000000018DF021Ff2467dF97ff846E09f48", + prefix: + "0x1901ea25de9c16847077fe9d95916c29598dc64f4850ba02c5dbe7800d2e2ecb338e", + priorityFee: 1n, + tokens: { + ETH: { + address: "0x0000000000000000000000000000000000000000", + decimals: 18, + symbol: "ETH", + coingeckoId: "ethereum", + }, + WETH: { + address: "0x4200000000000000000000000000000000000006", + decimals: 18, + symbol: "WETH", + coingeckoId: "weth", + }, + USDC: { + address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + decimals: 6, + symbol: "USDC", + coingeckoId: "usd-coin", + }, + }, + }, + 130: { + arbiter: "0x81fC1d90C5fae0f15FC91B5592177B594011C576", + tribunal: "0x81fC1d90C5fae0f15FC91B5592177B594011C576", + compactX: "0x00000000000018DF021Ff2467dF97ff846E09f48", + prefix: + "0x190150e2b173e1ac2eac4e4995e45458f4cd549c256c423a041bf17d0c0a4a736d2c", + priorityFee: 1n, + tokens: { + ETH: { + address: "0x0000000000000000000000000000000000000000", + decimals: 18, + symbol: "ETH", + coingeckoId: "ethereum", + }, + WETH: { + address: "0x4200000000000000000000000000000000000006", + decimals: 18, + symbol: "WETH", + coingeckoId: "weth", + }, + USDC: { + address: "0x078d782b760474a361dda0af3839290b0ef57ad6", + decimals: 6, + symbol: "USDC", + coingeckoId: "usd-coin", + }, + }, + }, + 8453: { + arbiter: "0xfaBE453252ca8337b091ba01BB168030E2FE6c1F", + tribunal: "0xfaBE453252ca8337b091ba01BB168030E2FE6c1F", + compactX: "0x00000000000018DF021Ff2467dF97ff846E09f48", + prefix: + "0x1901a1324f3bfe91ee592367ae7552e9348145e65b410335d72e4507dcedeb41bf52", + priorityFee: 50n, + tokens: { + ETH: { + address: "0x0000000000000000000000000000000000000000", + decimals: 18, + symbol: "ETH", + coingeckoId: "ethereum", + }, + WETH: { + address: "0x4200000000000000000000000000000000000006", + decimals: 18, + symbol: "WETH", + coingeckoId: "weth", + }, + USDC: { + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + decimals: 6, + symbol: "USDC", + coingeckoId: "usd-coin", + }, + }, + }, + }, + allocators: { + AUTOCATOR: { + id: "1730150456036417775412616585", + signingAddress: "0x4491fB95F2d51416688D4862f0cAeFE5281Fa3d9", + url: "https://autocator.org", + }, + SMALLOCATOR: { + id: "1223867955028248789127899354", + signingAddress: "0x51044301738Ba2a27bd9332510565eBE9F03546b", + url: "https://smallocator.xyz", + }, + }, + customRules: { + rules: [ + { + name: "validateChainsAndTokens", + }, + { + name: "verifySignatures", + }, + { + name: "checkExpirations", + }, + { + name: "validateArbiterAndTribunal", + }, + { + name: "verifyNonce", + }, + ], + }, +}; + +export default CompactXMetadataSchema.parse(metadata); diff --git a/typescript/solver/solvers/compactX/contracts/TheCompact.json b/typescript/solver/solvers/compactX/contracts/TheCompact.json new file mode 100644 index 00000000..ff6dae73 --- /dev/null +++ b/typescript/solver/solvers/compactX/contracts/TheCompact.json @@ -0,0 +1,50 @@ +{ + "abi": [ + { + "type": "function", + "name": "getForcedWithdrawalStatus", + "inputs": [ + { "name": "account", "type": "address", "internalType": "address" }, + { "name": "id", "type": "uint256", "internalType": "uint256" } + ], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "enum ForcedWithdrawalStatus" + }, + { "name": "", "type": "uint256", "internalType": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRegistrationStatus", + "inputs": [ + { "name": "sponsor", "type": "address", "internalType": "address" }, + { "name": "claimHash", "type": "bytes32", "internalType": "bytes32" }, + { "name": "typehash", "type": "bytes32", "internalType": "bytes32" } + ], + "outputs": [ + { "name": "isActive", "type": "bool", "internalType": "bool" }, + { "name": "expires", "type": "uint256", "internalType": "uint256" } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "hasConsumedAllocatorNonce", + "inputs": [ + { "name": "nonce", "type": "uint256", "internalType": "uint256" }, + { "name": "allocator", "type": "address", "internalType": "address" } + ], + "outputs": [{ "name": "", "type": "bool", "internalType": "bool" }], + "stateMutability": "view" + } + ], + "methodIdentifiers": { + "getForcedWithdrawalStatus(address,uint256)": "144bd5b5", + "getRegistrationStatus(address,bytes32,bytes32)": "440a0ec3", + "hasConsumedAllocatorNonce(uint256,address)": "da2f268b" + } +} diff --git a/typescript/solver/solvers/compactX/contracts/Tribunal.json b/typescript/solver/solvers/compactX/contracts/Tribunal.json new file mode 100644 index 00000000..fa714fe6 --- /dev/null +++ b/typescript/solver/solvers/compactX/contracts/Tribunal.json @@ -0,0 +1,51 @@ +{ + "abi": [ + { + "name": "fill", + "type": "function", + "stateMutability": "payable", + "inputs": [ + { + "name": "claim", + "type": "tuple", + "components": [ + { "name": "chainId", "type": "uint256" }, + { + "name": "compact", + "type": "tuple", + "components": [ + { "name": "arbiter", "type": "address" }, + { "name": "sponsor", "type": "address" }, + { "name": "nonce", "type": "uint256" }, + { "name": "expires", "type": "uint256" }, + { "name": "id", "type": "uint256" }, + { "name": "amount", "type": "uint256" } + ] + }, + { "name": "sponsorSignature", "type": "bytes" }, + { "name": "allocatorSignature", "type": "bytes" } + ] + }, + { + "name": "mandate", + "type": "tuple", + "components": [ + { "name": "recipient", "type": "address" }, + { "name": "expires", "type": "uint256" }, + { "name": "token", "type": "address" }, + { "name": "minimumAmount", "type": "uint256" }, + { "name": "baselinePriorityFee", "type": "uint256" }, + { "name": "scalingFactor", "type": "uint256" }, + { "name": "salt", "type": "bytes32" } + ] + }, + { "name": "claimant", "type": "address" } + ], + "outputs": [ + { "name": "mandateHash", "type": "bytes32" }, + { "name": "settlementAmount", "type": "uint256" }, + { "name": "claimAmount", "type": "uint256" } + ] + } + ] +} diff --git a/typescript/solver/solvers/compactX/filler.ts b/typescript/solver/solvers/compactX/filler.ts new file mode 100644 index 00000000..9ff257e8 --- /dev/null +++ b/typescript/solver/solvers/compactX/filler.ts @@ -0,0 +1,287 @@ +import { type MultiProvider } from "@hyperlane-xyz/sdk"; +import type { Result } from "@hyperlane-xyz/utils"; + +import { formatEther } from "@ethersproject/units"; +import { Tribunal__factory } from "../../typechain/factories/compactX/contracts/Tribunal__factory.js"; +import { BaseFiller } from "../BaseFiller.js"; +import { BuildRules, RulesMap } from "../types.js"; +import { retrieveTokenBalance } from "../utils.js"; +import { allowBlockLists, metadata } from "./config/index.js"; +import { PriceService } from "./services/price/PriceService.js"; +import type { CompactXMetadata, CompactXParsedArgs } from "./types.js"; +import { calculateFillValue, getMaxSettlementAmount, log } from "./utils.js"; + +export type CompactXRule = CompactXFiller["rules"][number]; + +export class CompactXFiller extends BaseFiller< + CompactXMetadata, + CompactXParsedArgs, + {} +> { + private priceService: PriceService; + + constructor(multiProvider: MultiProvider, rules?: BuildRules) { + super(multiProvider, allowBlockLists, metadata, log, rules); + this.priceService = new PriceService(); + this.priceService.start(); + } + + protected retrieveOriginInfo( + parsedArgs: CompactXParsedArgs, + chainName: string, + ) { + return Promise.reject("Method not implemented."); + } + + protected retrieveTargetInfo(parsedArgs: CompactXParsedArgs) { + return Promise.reject("Method not implemented."); + } + + protected async prepareIntent( + parsedArgs: CompactXParsedArgs, + ): Promise> { + try { + await super.prepareIntent(parsedArgs); + + return { data: "", success: true }; + } catch (error: any) { + return { + error: + error.message ?? `Failed to prepare ${metadata.protocolName} Intent.`, + success: false, + }; + } + } + + protected async fill(parsedArgs: CompactXParsedArgs) { + const request = parsedArgs.context; + + this.log.info({ + msg: "Filling Intent", + intent: `${this.metadata.protocolName}-${request.compact.id}`, + }); + + // Process the broadcast transaction + const mandateChainId = request.compact.mandate.chainId; + + const provider = this.multiProvider.getProvider(mandateChainId); + const signer = this.multiProvider.getSigner(mandateChainId); + const fillerAddress = await signer.getAddress(); + + // Calculate simulation values + const minimumAmount = BigInt(request.compact.mandate.minimumAmount); + const bufferedMinimumAmount = (minimumAmount * 101n) / 100n; + + // Calculate settlement amount based on mandate token (ETH/WETH check) + const mandateTokenAddress = request.compact.mandate.token; + + // Get the relevant token balance based on mandate token + const mandateTokenBalance = ( + await retrieveTokenBalance(mandateTokenAddress, fillerAddress, provider) + ).toBigInt(); + + // Check if we have sufficient token balance for simulation settlement + if (mandateTokenBalance < bufferedMinimumAmount) { + throw new Error( + `Token balance (${mandateTokenBalance}) is less than simulation settlement amount (${bufferedMinimumAmount})`, + ); + } + + // Get current base fee from latest block using mandate chain + const block = await provider.getBlock("latest"); + const baseFeePerGas = block.baseFeePerGas?.toBigInt(); + + if (!baseFeePerGas) { + throw new Error("Could not get base fee from latest block"); + } + + // Calculate simulation priority fee + const maxPriorityFeePerGas = metadata.chainInfo[mandateChainId].priorityFee; + const bufferedBaseFeePerGas = (baseFeePerGas * 120n) / 100n; // Base fee + 20% buffer + const maxFeePerGas = maxPriorityFeePerGas + bufferedBaseFeePerGas; + + // Calculate simulation value + const simulationValue = calculateFillValue(request, bufferedMinimumAmount); + const ethBalance = (await signer.getBalance()).toBigInt(); + + // Check if we have sufficient ETH for simulation value + if (ethBalance < simulationValue) { + throw new Error( + `ETH balance (${ethBalance}) is less than simulation value (${simulationValue})`, + ); + } + + const tribunal = Tribunal__factory.connect( + request.compact.mandate.tribunal, + signer, + ); + + // Encode simulation data with proper ABI + const { mandate, ...compact } = request.compact; + const data = tribunal.interface.encodeFunctionData("fill", [ + { + chainId: request.chainId, + compact: { + arbiter: compact.arbiter, + sponsor: compact.sponsor, + nonce: compact.nonce, + expires: compact.expires, + id: compact.id, + amount: compact.amount, + }, + sponsorSignature: + !request.sponsorSignature || request.sponsorSignature === "0x" + ? `0x${"0".repeat(128)}` + : request.sponsorSignature, + allocatorSignature: request.allocatorSignature, + }, + { + recipient: mandate.recipient, + expires: mandate.expires, + token: mandate.token, + minimumAmount: mandate.minimumAmount, + baselinePriorityFee: mandate.baselinePriorityFee, + scalingFactor: mandate.scalingFactor, + salt: mandate.salt, + }, + fillerAddress, + ]); + + // Estimate gas using simulation values and add 25% buffer + this.log.debug("Performing initial simulation to get gas estimate"); + const estimatedGas = ( + await signer.estimateGas({ + to: request.compact.mandate.tribunal, + value: simulationValue, + data, + maxFeePerGas, + maxPriorityFeePerGas, + }) + ).toBigInt(); + + // Get current ETH price for the chain from memory + const ethPrice = this.priceService.getPrice(mandateChainId); + + const maxSettlementAmount = getMaxSettlementAmount({ + estimatedGas, + ethPrice, + maxFeePerGas, + request, + }); + + this.log.debug({ + msg: "Settlement", + amount: maxSettlementAmount, + minimum: minimumAmount, + }); + + // Check if we have sufficient token balance for settlement amount + if (mandateTokenBalance < maxSettlementAmount) { + throw new Error( + `Token balance (${mandateTokenBalance}) is less than settlement amount (${maxSettlementAmount})`, + ); + } + + // Check if profitable (settlement amount > minimum amount) + if (maxSettlementAmount <= minimumAmount) { + throw new Error( + `Fill estimated to be unprofitable after execution costs (${maxSettlementAmount} <= ${minimumAmount})`, + ); + } + + // Calculate final value based on mandate token (using chain-specific ETH address) + const value = calculateFillValue(request, maxSettlementAmount); + + // Check if we have sufficient ETH for value + if (ethBalance < value) { + throw new Error( + `ETH balance (${ethBalance}) is less than settlement value (${value})`, + ); + } + + // Do final gas estimation with actual values + const finalEstimatedGas = ( + await signer.estimateGas({ + to: request.compact.mandate.tribunal, + value, + data, + maxFeePerGas, + maxPriorityFeePerGas, + }) + ).toBigInt(); + + const bufferedGasLimit = (finalEstimatedGas * 125n) / 100n; + + this.log.debug({ + msg: "Got final gas estimate", + finalEstimatedGas, + finalGasWithBuffer: bufferedGasLimit, + }); + + // Check if we have enough ETH for value + gas using cached balance + const requiredBalance = value + maxFeePerGas * bufferedGasLimit; + + if (ethBalance < requiredBalance) { + const shortageWei = requiredBalance - ethBalance; + const shortageEth = +formatEther(shortageWei); + + throw new Error( + `Insufficient ETH balance. Need ${formatEther(requiredBalance)} ETH but only have ${formatEther(ethBalance)} ETH (short ${shortageEth.toFixed( + 6, + )} ETH)`, + ); + } + + this.log.debug({ + msg: "Account balance exceeds required balance. Submitting transaction!", + ethBalance, + requiredBalance, + }); + + // Submit transaction with the wallet client + const response = await signer.sendTransaction({ + to: request.compact.mandate.tribunal, + value, + maxFeePerGas, + chainId: mandateChainId, + maxPriorityFeePerGas, + gasLimit: bufferedGasLimit, + data, + }); + + const receipt = await response.wait(); + + // Calculate final costs and profit + const finalGasCostWei = maxFeePerGas * bufferedGasLimit; + const finalGasCostEth = +formatEther(finalGasCostWei); + const finalGasCostUSD = finalGasCostEth * ethPrice; + + this.log.info({ + msg: "Transaction submitted", + hash: receipt.transactionHash, + txInfo: + this.multiProvider.tryGetExplorerTxUrl(mandateChainId, { + hash: receipt.transactionHash, + }) ?? receipt.transactionHash, + }); + this.log.debug({ + msg: "Settlement amount", + settlementAmount: maxSettlementAmount, + minimumAmount, + }); + this.log.debug({ + msg: "Final gas cost", + finalGasCostUSD: finalGasCostUSD.toFixed(2), + finalGasCostWei: `${formatEther(finalGasCostWei)} ETH)`, + }); + } +} + +export const create = ( + multiProvider: MultiProvider, + customRules?: RulesMap, +) => { + return new CompactXFiller(multiProvider, { + custom: customRules, + }).create(); +}; diff --git a/typescript/solver/solvers/compactX/index.ts b/typescript/solver/solvers/compactX/index.ts new file mode 100644 index 00000000..e578421c --- /dev/null +++ b/typescript/solver/solvers/compactX/index.ts @@ -0,0 +1,3 @@ +export * as filler from "./filler.js"; +export * as listener from "./listener.js"; +export * as rules from "./rules/index.js"; diff --git a/typescript/solver/solvers/compactX/listener.ts b/typescript/solver/solvers/compactX/listener.ts new file mode 100644 index 00000000..1b66bcc0 --- /dev/null +++ b/typescript/solver/solvers/compactX/listener.ts @@ -0,0 +1,44 @@ +import { chainIdsToName } from "../../config/index.js"; +import type { BaseWebSocketSource } from "../types.js"; +import { WebSocketListener } from "../WebSocketListener.js"; +import { metadata } from "./config/index.js"; +import { BroadcastRequestSchema, type CompactXParsedArgs } from "./types.js"; +import { log } from "./utils.js"; + +type CompactXClassMetadata = { + webSocket: BaseWebSocketSource; + protocolName: string; +}; + +export class CompactXListener extends WebSocketListener { + constructor(metadata: CompactXClassMetadata) { + super(metadata, log); + } + + protected parseEventArgs(args: Buffer): CompactXParsedArgs { + const context: CompactXParsedArgs["context"] = BroadcastRequestSchema.parse( + JSON.parse(args.toString()), + ); + + return { + orderId: context.compact.id, + senderAddress: context.compact.sponsor, + recipients: [ + { + destinationChainName: chainIdsToName[context.compact.mandate.chainId], + recipientAddress: context.compact.mandate.recipient, + }, + ], + context, + }; + } +} + +export const create = () => { + const { intentSources, protocolName } = metadata; + const _metadata = { + webSocket: intentSources.webSockets[0], + protocolName, + }; + return new CompactXListener(_metadata).create(); +}; diff --git a/typescript/solver/solvers/compactX/rules/checkExpirations.ts b/typescript/solver/solvers/compactX/rules/checkExpirations.ts new file mode 100644 index 00000000..d923c7fe --- /dev/null +++ b/typescript/solver/solvers/compactX/rules/checkExpirations.ts @@ -0,0 +1,33 @@ +import { metadata } from "../config/index.js"; +import { CompactXRule } from "../filler.js"; + +export function checkExpirations(): CompactXRule { + return async (parsedArgs) => { + // Check if either compact or mandate has expired or is close to expiring + const currentTimestamp = BigInt(Math.floor(Date.now() / 1000)); + const { compactExpirationBuffer, mandateExpirationBuffer } = + metadata.chainInfo[parsedArgs.context.chainId]; + + if ( + BigInt(parsedArgs.context.compact.expires) <= + currentTimestamp + compactExpirationBuffer + ) { + return { + error: `Compact must have at least ${compactExpirationBuffer} seconds until expiration`, + success: false, + }; + } + + if ( + BigInt(parsedArgs.context.compact.mandate.expires) <= + currentTimestamp + mandateExpirationBuffer + ) { + return { + error: `Mandate must have at least ${mandateExpirationBuffer} seconds until expiration`, + success: false, + }; + } + + return { data: "Intent is not expired", success: true }; + }; +} diff --git a/typescript/solver/solvers/compactX/rules/index.ts b/typescript/solver/solvers/compactX/rules/index.ts new file mode 100644 index 00000000..d2e0b825 --- /dev/null +++ b/typescript/solver/solvers/compactX/rules/index.ts @@ -0,0 +1,5 @@ +export { checkExpirations } from "./checkExpirations.js"; +export { validateArbiterAndTribunal } from "./validateArbiterAndTribunal.js"; +export { validateChainsAndTokens } from "./validateChainsAndTokens.js"; +export { verifyNonce } from "./verifyNonce.js"; +export { verifySignatures } from "./verifySignatures.js"; diff --git a/typescript/solver/solvers/compactX/rules/validateArbiterAndTribunal.ts b/typescript/solver/solvers/compactX/rules/validateArbiterAndTribunal.ts new file mode 100644 index 00000000..9d25e617 --- /dev/null +++ b/typescript/solver/solvers/compactX/rules/validateArbiterAndTribunal.ts @@ -0,0 +1,31 @@ +import { metadata } from "../config/index.js"; +import type { CompactXRule } from "../filler.js"; +import type { CompactXParsedArgs } from "../types.js"; + +export function validateArbiterAndTribunal(): CompactXRule { + return async (parsedArgs: CompactXParsedArgs) => { + // Validate arbiter address + if ( + parsedArgs.context.compact.arbiter !== + metadata.chainInfo[parsedArgs.context.chainId].arbiter + ) { + return { + error: `Unsupported arbiter address ${parsedArgs.context.compact.arbiter}, on chain ${parsedArgs.context.chainId}`, + success: false, + }; + } + + // Validate tribunal addresses + if ( + parsedArgs.context.compact.mandate.tribunal !== + metadata.chainInfo[parsedArgs.context.compact.mandate.chainId].tribunal + ) { + return { + error: `Unsupported tribunal address ${parsedArgs.context.compact.mandate.tribunal}, on chain ${parsedArgs.context.compact.mandate.chainId}`, + success: false, + }; + } + + return { data: "Arbiter and Tribunal are Ok", success: true }; + }; +} diff --git a/typescript/solver/solvers/compactX/rules/validateChainsAndTokens.ts b/typescript/solver/solvers/compactX/rules/validateChainsAndTokens.ts new file mode 100644 index 00000000..3d0141fa --- /dev/null +++ b/typescript/solver/solvers/compactX/rules/validateChainsAndTokens.ts @@ -0,0 +1,82 @@ +import { metadata } from "../config/index.js"; +import type { CompactXRule } from "../filler.js"; +import type { CompactXParsedArgs } from "../types.js"; +import { getChainSupportedTokens } from "../utils.js"; + +export function validateChainsAndTokens(): CompactXRule { + return async (parsedArgs: CompactXParsedArgs) => { + // Validate origin chain ID + if (!(parsedArgs.context.chainId in metadata.chainInfo)) { + return { + error: `Origin ${parsedArgs.context.chainId} is not supported`, + success: false, + }; + } + + // Validate destination chain ID + if (!(parsedArgs.context.compact.mandate.chainId in metadata.chainInfo)) { + return { + error: `Destination ${parsedArgs.context.compact.mandate.chainId} is not supported`, + success: false, + }; + } + + // Validate claim token + const claimToken = `0x${BigInt(parsedArgs.context.compact.id).toString(16).slice(-40)}`; + const originChainTokens = getChainSupportedTokens( + parsedArgs.context.chainId, + ); + + if ( + !Object.entries(originChainTokens).some( + ([, { address }]) => claimToken === address, + ) + ) { + return { + error: `Claim token not supported ${claimToken}, on chain ${parsedArgs.context.chainId}`, + success: false, + }; + } + + // Validate destination token + const mandateToken = parsedArgs.context.compact.mandate.token; + const mandateChainTokens = getChainSupportedTokens( + parsedArgs.context.compact.mandate.chainId, + ); + + if ( + !Object.entries(mandateChainTokens).some( + ([, { address }]) => mandateToken === address, + ) + ) { + return { + error: `Destination token not supported ${mandateToken}, on chain ${parsedArgs.context.compact.mandate.chainId}`, + success: false, + }; + } + + // Validate arbiter address + if ( + parsedArgs.context.compact.arbiter !== + metadata.chainInfo[parsedArgs.context.chainId].arbiter + ) { + return { + error: `Unsupported arbiter address ${parsedArgs.context.compact.arbiter}, on chain ${parsedArgs.context.chainId}`, + success: false, + }; + } + + // Validate tribunal addresses + if ( + parsedArgs.context.compact.mandate.tribunal !== + metadata.chainInfo[parsedArgs.context.compact.mandate.chainId].tribunal + ) { + return { + error: `Unsupported tribunal address ${parsedArgs.context.compact.mandate.tribunal}, on chain ${parsedArgs.context.compact.mandate.chainId}`, + success: false, + }; + } + + return { data: "Chains and tokens are Ok", success: true }; + }; +} diff --git a/typescript/solver/solvers/compactX/rules/verifyNonce.ts b/typescript/solver/solvers/compactX/rules/verifyNonce.ts new file mode 100644 index 00000000..9ee0d8a5 --- /dev/null +++ b/typescript/solver/solvers/compactX/rules/verifyNonce.ts @@ -0,0 +1,24 @@ +import type { CompactXRule } from "../filler.js"; +import { TheCompactService } from "../services/TheCompactService.js"; +import { log } from "../utils.js"; + +export function verifyNonce(): CompactXRule { + return async (parsedArgs, context) => { + const theCompactService = new TheCompactService(context.multiProvider, log); + + const nonceConsumed = await theCompactService.hasConsumedAllocatorNonce( + +parsedArgs.context.chainId, + BigInt(parsedArgs.context.compact.nonce), + parsedArgs.context.compact.arbiter, + ); + + if (nonceConsumed) { + return { + error: "Nonce has already been consumed", + success: false, + }; + } + + return { data: "Nonce is Ok", success: true }; + }; +} diff --git a/typescript/solver/solvers/compactX/rules/verifySignatures.ts b/typescript/solver/solvers/compactX/rules/verifySignatures.ts new file mode 100644 index 00000000..2d5c590f --- /dev/null +++ b/typescript/solver/solvers/compactX/rules/verifySignatures.ts @@ -0,0 +1,27 @@ +import type { CompactXRule } from "../filler.js"; +import { TheCompactService } from "../services/TheCompactService.js"; +import { deriveClaimHash, log } from "../utils.js"; +import { verifyBroadcastRequest } from "../validation/signature.js"; + +export function verifySignatures(): CompactXRule { + return async (parsedArgs, context) => { + const theCompactService = new TheCompactService(context.multiProvider, log); + + // Derive and log claim hash + const claimHash = deriveClaimHash(parsedArgs.context.compact); + + // Set the claim hash before verification + parsedArgs.context.claimHash = claimHash; + + const { isValid, error } = await verifyBroadcastRequest( + parsedArgs.context, + theCompactService, + ); + + if (!isValid) { + return { error: error ?? "Could not verify signatures", success: false }; + } + + return { data: "Signatures are Ok", success: true }; + }; +} diff --git a/typescript/solver/solvers/compactX/services/TheCompactService.ts b/typescript/solver/solvers/compactX/services/TheCompactService.ts new file mode 100644 index 00000000..b315fd95 --- /dev/null +++ b/typescript/solver/solvers/compactX/services/TheCompactService.ts @@ -0,0 +1,130 @@ +import type { Logger } from "../../../logger.js"; + +import type { BigNumber } from "@ethersproject/bignumber"; +import type { MultiProvider } from "@hyperlane-xyz/sdk"; +import type { Address } from "@hyperlane-xyz/utils"; +import { TheCompact__factory } from "../../../typechain/factories/compactX/contracts/TheCompact__factory.js"; +import { metadata } from "../config/index.js"; + +/** + * @notice Status of a forced withdrawal + * @dev Maps to the contract's ForcedWithdrawalStatus enum + */ +export enum ForcedWithdrawalStatus { + Disabled = 0, // Not pending or enabled for forced withdrawal + Pending = 1, // Not yet available, but initiated + Enabled = 2, // Available for forced withdrawal on demand +} + +export interface RegistrationStatus { + isActive: boolean; + expires: BigNumber; +} + +export interface ForcedWithdrawalInfo { + status: keyof typeof ForcedWithdrawalStatus; + availableAt: number; +} + +export class TheCompactService { + constructor( + readonly multiProvider: MultiProvider, + readonly log: Logger, + ) {} + + private getReadOnlyCompactInstance(chainId: number) { + const provider = this.multiProvider.getProvider(chainId); + + return TheCompact__factory.connect( + metadata.chainInfo[chainId].compactX, + provider, + ); + } + + async hasConsumedAllocatorNonce( + chainId: number, + nonce: bigint, + allocator: Address, + ): Promise { + const theCompact = this.getReadOnlyCompactInstance(chainId); + const result = await theCompact.hasConsumedAllocatorNonce(nonce, allocator); + + return result as boolean; + } + + async getRegistrationStatus( + chainId: number, + sponsor: string, + claimHash: string, + typehash: string, + ): Promise { + try { + this.log.debug({ + msg: "Fetching registration status for sponsor", + sponsor, + claimHash, + typehash, + chainId, + }); + + const theCompact = this.getReadOnlyCompactInstance(chainId); + + // Use explicit type assertion for the contract call result + const { isActive, expires } = await theCompact.getRegistrationStatus( + sponsor, + claimHash, + typehash, + ); + + this.log.debug({ msg: "Registration status", isActive, expires }); + + return { isActive, expires } as RegistrationStatus; + } catch (error) { + const errorInfo = { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + name: error instanceof Error ? error.name : undefined, + // For viem errors, they often have a cause property + cause: (error as { cause?: unknown })?.cause, + // Some errors might have a data property with more details + data: (error as { data?: unknown })?.data, + // Convert the whole error to string to capture anything else + toString: String(error), + }; + + this.log.debug({ + msg: "Error in getRegistrationStatus:", + errorInfo, + errorMessage: errorInfo.message, + chainId, + sponsor, + claimHash, + typehash, + }); + + throw error; + } + } + + async getForcedWithdrawalStatus( + chainId: number, + account: Address, + lockId: bigint, + ): Promise { + const theCompact = this.getReadOnlyCompactInstance(chainId); + + const result = await theCompact.getForcedWithdrawalStatus(account, lockId); + + const [status, availableAt] = result as [number, BigNumber]; + + // Map numeric status to enum key + const statusKey = ForcedWithdrawalStatus[ + status + ] as keyof typeof ForcedWithdrawalStatus; + + return { + status: statusKey, + availableAt: Number(availableAt), + }; + } +} diff --git a/typescript/solver/solvers/compactX/services/price/CoinGeckoProvider.ts b/typescript/solver/solvers/compactX/services/price/CoinGeckoProvider.ts new file mode 100644 index 00000000..b8ef64a2 --- /dev/null +++ b/typescript/solver/solvers/compactX/services/price/CoinGeckoProvider.ts @@ -0,0 +1,157 @@ +import type { Logger } from "../../../../logger.js"; +import { log } from "../../utils.js"; + +// Map chain IDs to CoinGecko platform IDs and their native token IDs +const CHAIN_TO_PLATFORM: Record< + number, + { platform: string; nativeToken: string } +> = { + 1: { platform: "ethereum", nativeToken: "ethereum" }, + 10: { platform: "optimistic-ethereum", nativeToken: "ethereum" }, + 130: { platform: "unichain", nativeToken: "ethereum" }, + 8453: { platform: "base", nativeToken: "ethereum" }, +}; + +interface PriceData { + price: number; + timestamp: number; + source: string; +} + +class CoinGeckoError extends Error { + constructor(message: string) { + super(message); + this.name = "CoinGeckoError"; + } +} + +export class CoinGeckoProvider { + private cache: Map; + private log: Logger; + private baseUrl: string; + private headers: Record; + private readonly CACHE_TTL = 120_000; + + constructor(apiKey?: string) { + this.cache = new Map(); + this.log = log; + this.baseUrl = apiKey + ? "https://pro-api.coingecko.com/api/v3" + : "https://api.coingecko.com/api/v3"; + this.headers = { + accept: "application/json", + ...(apiKey && { "x-cg-pro-api-key": apiKey }), + }; + } + + private async makeRequest(url: string, errorContext: string): Promise { + try { + // Use global fetch + const response = await fetch(url, { headers: this.headers }); + + if (!response.ok) { + let errorMessage: string; + try { + const errorData = (await response.json()) as { error?: string }; + errorMessage = errorData?.error || response.statusText; + } catch { + errorMessage = response.statusText; + } + throw new CoinGeckoError(`${errorContext}: ${errorMessage}`); + } + + const data = await response.json(); + if (!data) { + throw new CoinGeckoError(`${errorContext}: Empty response`); + } + + return data as T; + } catch (error) { + if (error instanceof CoinGeckoError) throw error; + throw new CoinGeckoError( + `${errorContext}: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + private validateEthPriceResponse( + data: unknown, + nativeToken: string, + ): asserts data is { [key: string]: { usd: number } } { + if (!data || typeof data !== "object") { + throw new CoinGeckoError( + "Invalid native token price response format: not an object", + ); + } + + const priceObj = data as { [key: string]: { usd?: unknown } }; + if ( + !priceObj[nativeToken]?.usd || + typeof priceObj[nativeToken].usd !== "number" + ) { + throw new CoinGeckoError( + "Invalid native token price response format: missing or invalid price", + ); + } + } + + private getPlatformInfo(chainId: number): { + platform: string; + nativeToken: string; + } { + const info = CHAIN_TO_PLATFORM[chainId]; + if (!info) { + throw new CoinGeckoError(`Unsupported chain ID: ${chainId}`); + } + return info; + } + + async getEthPrice(chainId: number): Promise { + const cached = this.cache.get(chainId); + + if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { + return cached.data; + } + + try { + const { nativeToken } = this.getPlatformInfo(chainId); + const url = `${this.baseUrl}/simple/price?ids=${nativeToken}&vs_currencies=usd`; + + this.log.debug({ + name: "CoinGeckoProvider", + msg: "Fetching native token price", + chainId, + url, + }); + + const data = await this.makeRequest( + url, + "Failed to fetch ETH price", + ); + this.validateEthPriceResponse(data, nativeToken); + + this.log.debug({ + name: "CoinGeckoProvider", + msg: "Received native token price data", + data: JSON.stringify(data), + }); + + const timestamp = Date.now(); + const priceData: PriceData = { + price: data[nativeToken].usd, + timestamp, + source: "coingecko", + }; + + this.cache.set(chainId, { data: priceData, timestamp }); + return priceData; + } catch (error) { + this.log.error({ + name: "CoinGeckoProvider", + msg: "Failed to fetch ETH price", + error, + }); + throw error; + } + } +} diff --git a/typescript/solver/solvers/compactX/services/price/PriceService.ts b/typescript/solver/solvers/compactX/services/price/PriceService.ts new file mode 100644 index 00000000..4c573176 --- /dev/null +++ b/typescript/solver/solvers/compactX/services/price/PriceService.ts @@ -0,0 +1,108 @@ +import EventEmitter from "node:events"; +import type { Logger } from "../../../../logger.js"; +import { metadata } from "../../config/index.js"; +import { log } from "../../utils.js"; +import { CoinGeckoProvider } from "./CoinGeckoProvider.js"; + +interface PriceData { + price: number; + lastUpdated: number; +} + +export class PriceService extends EventEmitter { + private prices: Map; + private log: Logger; + private provider: CoinGeckoProvider; + private updateInterval: NodeJS.Timeout | null; + private readonly UPDATE_INTERVAL = 60_000; + + constructor(apiKey?: string) { + super(); + this.prices = new Map(); + this.log = log; + this.provider = new CoinGeckoProvider(apiKey); + this.updateInterval = null; + } + + public start(): void { + // Initial price fetch + this.updatePrices().catch((error) => { + this.log.error({ + name: "PriceService", + msg: "Failed to fetch initial prices", + error, + }); + }); + + // Set up periodic updates + this.updateInterval = setInterval(() => { + this.updatePrices().catch((error) => { + this.log.error({ + name: "PriceService", + msg: "Failed to update prices", + error, + }); + }); + }, this.UPDATE_INTERVAL); + } + + public stop(): void { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + } + + public getPrice(chainId: number): number { + const priceData = this.prices.get(chainId); + if (!priceData) { + this.log.error({ + name: "PriceService", + msg: "No price data available", + chainId, + }); + return 0; + } + + // Check if price is stale + const stalePriceThreshold = 120_000; + if (Date.now() - priceData.lastUpdated > stalePriceThreshold) { + this.log.warn({ + name: "PriceService", + msg: "Price data is stale", + chainId, + }); + } + + return priceData.price; + } + + private async updatePrices(): Promise { + for (const chainId in metadata.chainInfo) { + try { + const { price } = await this.provider.getEthPrice(+chainId); + this.prices.set(+chainId, { + price, + lastUpdated: Date.now(), + }); + this.log.debug({ + name: "PriceService", + msg: "Updated ETH price", + chainId, + price, + }); + + // Emit the price update + this.emit("price_update", chainId, price); + } catch (error) { + this.log.error({ + name: "PriceService", + msg: "Failed to update price", + chainId, + error, + }); + // Don't update the price if there's an error, keep using the old one + } + } + } +} diff --git a/typescript/solver/solvers/compactX/types.ts b/typescript/solver/solvers/compactX/types.ts new file mode 100644 index 00000000..4a547c0e --- /dev/null +++ b/typescript/solver/solvers/compactX/types.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; + +import { ParsedArgs } from "../BaseFiller.js"; +import { BaseMetadataSchema, BaseWebSocketSourceSchema } from "../types.js"; + +// Custom validators and constants +const isHexString = (str: string) => /^0x[0-9a-fA-F]*$/.test(str); +const isAddress = (str: string) => isHexString(str) && str.length === 42; // 0x + 40 chars (20 bytes) +const isHash = (str: string) => isHexString(str) && str.length === 66; // 0x + 64 chars (32 bytes) +const is64ByteHex = (str: string) => isHexString(str) && str.length === 130; // 0x + 128 chars (64 bytes) +const isEmptyHex = (str: string) => str === "0x"; +const isNumericString = (str: string) => /^-?\d+$/.test(str); +const isNumericOrHexString = (str: string) => + isNumericString(str) || isHexString(str); +const UINT32_MAX = 4294967295; // 2^32 - 1 + +const numericOrHexSchema = z.string().refine(isNumericOrHexString, { + message: "Must be either a numeric string or a hex string with 0x prefix", +}); + +const addressSchema = z + .string() + .refine(isAddress, { + message: "Must be a valid Ethereum address (0x prefix + 20 bytes)", + }) + .transform((addr) => addr.toLowerCase()); + +const hashSchema = z.string().refine(isHash, { + message: "Must be a valid hash (0x prefix + 32 bytes)", +}); + +// Type definitions +export const MandateSchema = z.object({ + chainId: z + .number() + .int() + .min(1) + .max(UINT32_MAX) + .refine( + (n) => n >= 1 && n <= UINT32_MAX, + `Chain ID must be between 1 and ${UINT32_MAX}`, + ), + tribunal: addressSchema, + recipient: addressSchema, + expires: numericOrHexSchema, + token: addressSchema, + minimumAmount: numericOrHexSchema, + baselinePriorityFee: numericOrHexSchema, + scalingFactor: numericOrHexSchema, + salt: hashSchema, +}); + +export const CompactMessageSchema = z.object({ + arbiter: addressSchema, + sponsor: addressSchema, + nonce: hashSchema, + expires: numericOrHexSchema, + id: numericOrHexSchema, + amount: numericOrHexSchema, + mandate: MandateSchema, +}); + +export const ContextSchema = z.object({ + dispensation: numericOrHexSchema, + dispensationUSD: z.string(), + spotOutputAmount: numericOrHexSchema, + quoteOutputAmountDirect: numericOrHexSchema, + quoteOutputAmountNet: numericOrHexSchema, + deltaAmount: numericOrHexSchema.optional(), + slippageBips: z + .number() + .int() + .min(0) + .max(10000) + .refine( + (n) => n >= 0 && n <= 10000, + "Slippage must be between 0 and 10000 basis points", + ) + .optional(), + witnessTypeString: z.string(), + witnessHash: hashSchema, + claimHash: hashSchema.optional(), +}); + +export const BroadcastRequestSchema = z.object({ + chainId: numericOrHexSchema, + compact: CompactMessageSchema, + sponsorSignature: z + .string() + .refine( + (str) => str === null || isEmptyHex(str) || is64ByteHex(str), + "Sponsor signature must be null, 0x, or a 64-byte hex string", + ) + .nullable(), + allocatorSignature: z + .string() + .refine(is64ByteHex, "Allocator signature must be a 64-byte hex string"), + context: ContextSchema, + claimHash: hashSchema.optional(), +}); + +export type BroadcastRequest = z.infer; + +export const CompactXMetadataSchema = BaseMetadataSchema.extend({ + intentSources: z + .object({ + webSockets: z.array(BaseWebSocketSourceSchema), + }) + .strict(), + chainInfo: z.record( + z.string(), + z.object({ + arbiter: addressSchema, + tribunal: addressSchema, + compactX: addressSchema, + prefix: z.string(), + priorityFee: z.bigint(), + compactExpirationBuffer: z.bigint().default(60n), + mandateExpirationBuffer: z.bigint().default(10n), + tokens: z.record( + z.string(), + z.object({ + address: addressSchema, + decimals: z.number(), + symbol: z.string(), + coingeckoId: z.string(), + }), + ), + }), + ), + allocators: z.record( + z.string(), + z.object({ + id: z.string(), + signingAddress: addressSchema, + url: z.string().url(), + }), + ), +}); + +export type CompactXMetadata = z.input; + +export type CompactXParsedArgs = ParsedArgs & { + context: BroadcastRequest; +}; diff --git a/typescript/solver/solvers/compactX/utils.ts b/typescript/solver/solvers/compactX/utils.ts new file mode 100644 index 00000000..8e3d80f5 --- /dev/null +++ b/typescript/solver/solvers/compactX/utils.ts @@ -0,0 +1,177 @@ +import { createLogger } from "../../logger.js"; +import { metadata } from "./config/index.js"; + +import { AbiCoder } from "@ethersproject/abi"; +import { keccak256 } from "@ethersproject/keccak256"; +import { toUtf8Bytes } from "@ethersproject/strings"; +import { formatEther, parseEther } from "@ethersproject/units"; +import { BroadcastRequest } from "./types.js"; + +export const log = createLogger(metadata.protocolName); + +/** + * Derives the claim hash using EIP-712 typed data hashing + */ +export function deriveClaimHash(compact: BroadcastRequest["compact"]) { + // Calculate COMPACT_TYPEHASH to match Solidity's EIP-712 typed data + const COMPACT_TYPESTRING = + "Compact(address arbiter,address sponsor,uint256 nonce,uint256 expires,uint256 id,uint256 amount,Mandate mandate)Mandate(uint256 chainId,address tribunal,address recipient,uint256 expires,address token,uint256 minimumAmount,uint256 baselinePriorityFee,uint256 scalingFactor,bytes32 salt)"; + const COMPACT_TYPEHASH = keccak256(toUtf8Bytes(COMPACT_TYPESTRING)); + + // Calculate MANDATE_TYPEHASH to match Solidity's EIP-712 typed data + const MANDATE_TYPESTRING = + "Mandate(uint256 chainId,address tribunal,address recipient,uint256 expires,address token,uint256 minimumAmount,uint256 baselinePriorityFee,uint256 scalingFactor,bytes32 salt)"; + const MANDATE_TYPEHASH = keccak256(toUtf8Bytes(MANDATE_TYPESTRING)); + + const abiCoder = new AbiCoder(); + // Now encode all the mandate parameters with the mandate typehash + const encodedMandateData = abiCoder.encode( + [ + "bytes32", // MANDATE_TYPEHASH + "uint256", // mandate.chainId + "address", // mandate.tribunal + "address", // mandate.recipient + "uint256", // mandate.expires + "address", // mandate.token + "uint256", // mandate.minimumAmount + "uint256", // mandate.baselinePriorityFee + "uint256", // mandate.scalingFactor + "bytes32", // mandate.salt + ], + [ + MANDATE_TYPEHASH, + BigInt(compact.mandate.chainId), + compact.mandate.tribunal, + compact.mandate.recipient, + BigInt(compact.mandate.expires), + compact.mandate.token, + BigInt(compact.mandate.minimumAmount), + BigInt(compact.mandate.baselinePriorityFee), + BigInt(compact.mandate.scalingFactor), + compact.mandate.salt, + ], + ); + + // derive the "witness hash" using the mandate data + const witnessHash = keccak256(encodedMandateData); + + // Now encode all the parameters with the typehash, matching the contract's abi.encode + const encodedData = abiCoder.encode( + [ + "bytes32", // COMPACT_TYPEHASH + "address", // arbiter + "address", // sponsor + "uint256", // nonce + "uint256", // expires + "uint256", // id + "uint256", // amount + "bytes32", // witnessHash + ], + [ + COMPACT_TYPEHASH, + compact.arbiter, + compact.sponsor, + BigInt(compact.nonce), + BigInt(compact.expires), + BigInt(compact.id), + BigInt(compact.amount), + witnessHash, + ], + ); + + // Return the final hash + return keccak256(encodedData); +} + +export function getChainSupportedTokens(chainId: string | number) { + return metadata.chainInfo[chainId].tokens; +} + +export function isNativeOrWrappedNative( + chainId: string | number, + token: string, +): boolean { + const { ETH, WETH } = getChainSupportedTokens(chainId); + + return token.toLowerCase() === ETH.address || token === WETH.address; +} + +export function calculateFillValue( + request: BroadcastRequest, + settlementAmount: bigint, +) { + const { ETH } = getChainSupportedTokens(request.compact.mandate.chainId); + const mandateTokenAddress = request.compact.mandate.token; + const bufferedDispensation = + (BigInt(request.context.dispensation) * 125n) / 100n; + + return mandateTokenAddress === ETH.address + ? settlementAmount + bufferedDispensation + : bufferedDispensation; +} + +export function getMaxSettlementAmount({ + estimatedGas, + ethPrice, + maxFeePerGas, + request, +}: { + estimatedGas: bigint; + ethPrice: number; + maxFeePerGas: bigint; + request: BroadcastRequest; +}) { + // Extract the dispensation amount in USD from the request and add 25% buffer + const dispensationUSD = +request.context.dispensationUSD.replace("$", ""); + const dispensation = BigInt(request.context.dispensation); + const bufferedDispensation = (dispensation * 125n) / 100n; + + const bufferedEstimatedGas = (estimatedGas * 125n) / 100n; + log.debug({ + msg: "Got gas estimate", + estimatedGas, + bufferedEstimatedGas, + }); + + // Calculate max fee and total gas cost + const gasCostWei = maxFeePerGas * bufferedEstimatedGas; + const gasCostEth = +formatEther(gasCostWei); + const gasCostUSD = gasCostEth * ethPrice; + + // Calculate execution costs + const executionCostWei = gasCostWei + bufferedDispensation; + const executionCostUSD = gasCostUSD + dispensationUSD; + + // Get claim token from compact ID and check if it's ETH/WETH across all chains + const claimToken = `0x${BigInt(request.compact.id).toString(16).slice(-40)}`; + + // Check if token is ETH/WETH in any supported chain + const isClaimETHorWETH = isNativeOrWrappedNative(request.chainId, claimToken); + const isSettlementTokenETHorWETH = isNativeOrWrappedNative( + request.compact.mandate.chainId, + request.compact.mandate.token, + ); + + // Calculate claim amount less execution costs + let claimAmountLessExecutionCostsWei: bigint; + let claimAmountLessExecutionCostsUSD: number; + + if (isClaimETHorWETH) { + claimAmountLessExecutionCostsWei = + BigInt(request.compact.amount) - executionCostWei; + claimAmountLessExecutionCostsUSD = + +formatEther(claimAmountLessExecutionCostsWei) * ethPrice; + } else { + // Assume USDC with 6 decimals + // TODO-1: refactor this to allow any non-ETH/WETH token, not only USDC + claimAmountLessExecutionCostsUSD = + Number(request.compact.amount) / 1e6 - executionCostUSD; + claimAmountLessExecutionCostsWei = parseEther( + (claimAmountLessExecutionCostsUSD / ethPrice).toFixed(18), + ).toBigInt(); + } + + return isSettlementTokenETHorWETH + ? claimAmountLessExecutionCostsWei + : BigInt(Math.floor(claimAmountLessExecutionCostsUSD * 1e6)); // Scale up USDC amount +} diff --git a/typescript/solver/solvers/compactX/validation/signature.ts b/typescript/solver/solvers/compactX/validation/signature.ts new file mode 100644 index 00000000..9d91a590 --- /dev/null +++ b/typescript/solver/solvers/compactX/validation/signature.ts @@ -0,0 +1,254 @@ +import ethers from "ethers"; + +import { metadata } from "../config/index.js"; +import type { + RegistrationStatus, + TheCompactService, +} from "../services/TheCompactService.js"; +import type { BroadcastRequest } from "../types.js"; +import { log } from "../utils.js"; + +// Extract allocator ID from compact.id +const extractAllocatorId = (compactId: string): string => { + const compactIdBigInt = BigInt(compactId); + + // Shift right by 160 bits to remove the input token part + const shiftedBigInt = compactIdBigInt >> 160n; + + // Then mask to get only the allocator ID bits (92 bits) + const mask = (1n << 92n) - 1n; + const allocatorIdBigInt = shiftedBigInt & mask; + + return allocatorIdBigInt.toString(); +}; + +// The Compact typehash for registration checks +const COMPACT_REGISTRATION_TYPEHASH = + "0x27f09e0bb8ce2ae63380578af7af85055d3ada248c502e2378b85bc3d05ee0b0" as const; + +async function verifySignature( + claimHash: string, + signature: string, + expectedSigner: string, + chainPrefix: string, +): Promise { + try { + // Ensure hex values have 0x prefix + const normalizedClaimHash = claimHash.startsWith("0x") + ? claimHash + : `0x${claimHash}`; + const normalizedPrefix = chainPrefix.startsWith("0x") + ? chainPrefix + : `0x${chainPrefix}`; + const normalizedSignature = signature.startsWith("0x") + ? signature + : `0x${signature}`; + + log.debug({ + msg: "Verifying signature", + normalizedClaimHash, + normalizedPrefix, + normalizedSignature, + expectedSigner, + }); + + // Convert hex strings to bytes and concatenate + const prefixBytes = ethers.utils.arrayify(normalizedPrefix); + const claimHashBytes = ethers.utils.arrayify(normalizedClaimHash); + + // Concatenate bytes + const messageBytes = new Uint8Array( + prefixBytes.length + claimHashBytes.length, + ); + messageBytes.set(prefixBytes); + messageBytes.set(claimHashBytes, prefixBytes.length); + + // Get the digest + const digest = ethers.utils.keccak256(messageBytes); + log.debug({ msg: "Generated digest", digest }); + + // Convert compact signature to full signature + const parsedCompactSig = ethers.utils.splitSignature(normalizedSignature); + const serializedSig = ethers.utils.joinSignature(parsedCompactSig); + log.debug({ msg: "Parsed signature", serializedSig }); + + // Recover the signer address + const recoveredAddress = ethers.utils.recoverAddress(digest, serializedSig); + const match = + recoveredAddress.toLowerCase() === expectedSigner.toLowerCase(); + + log.debug({ msg: "Recovered address", recoveredAddress }); + log.debug({ msg: "Expected signer", expectedSigner }); + log.debug({ msg: "Match?", match }); + + // Compare recovered address with expected signer + return match; + } catch (error) { + return false; + } +} + +export async function verifyBroadcastRequest( + request: BroadcastRequest, + theCompactService: TheCompactService, +): Promise<{ + isValid: boolean; + isOnchainRegistration: boolean; + error?: string; +}> { + const chainId = request.chainId; + + log.info({ + msg: "Verifying broadcast request", + chainId, + sponsor: request.compact.sponsor, + arbiter: request.compact.arbiter, + nonce: request.compact.nonce, + expires: request.compact.expires, + id: request.compact.id, + amount: request.compact.amount, + sponsorSignature: request.sponsorSignature, + allocatorSignature: request.allocatorSignature, + }); + + // Get chain prefix based on chainId + const chainPrefix = metadata.chainInfo[chainId].prefix; + + // Get the claim hash from the request + const claimHash = request.claimHash; + if (!claimHash) { + throw new Error("Claim hash is required for signature verification"); + } + + // Try to verify sponsor signature first + let isSponsorValid = false; + let registrationStatus: RegistrationStatus | null = null; + let isOnchainRegistration = false; + let error: string | undefined; + + try { + log.debug({ + msg: "Attempting to verify sponsor signature", + claimHash, + sponsorSignature: request.sponsorSignature, + sponsor: request.compact.sponsor, + chainPrefix, + }); + + if (request.sponsorSignature && request.sponsorSignature !== "0x") { + isSponsorValid = await verifySignature( + claimHash, + request.sponsorSignature, + request.compact.sponsor, + chainPrefix, + ); + + if (!isSponsorValid) { + error = "Invalid sponsor signature provided"; + } + } else { + // Check registration status if no valid signature provided + log.debug( + "No sponsor signature provided, checking onchain registration...", + ); + try { + registrationStatus = await theCompactService.getRegistrationStatus( + +chainId, + request.compact.sponsor, + claimHash, + COMPACT_REGISTRATION_TYPEHASH, + ); + + log.debug({ + msg: "Registration status check result", + isActive: registrationStatus.isActive, + expires: registrationStatus.expires?.toString(), + compactExpires: request.compact.expires, + }); + + if (registrationStatus.isActive) { + isSponsorValid = true; + isOnchainRegistration = true; + } else { + error = + "No sponsor signature provided (0x) and no active onchain registration found"; + } + } catch (err) { + log.error({ + msg: "Registration status check failed", + error: err, + chainId, + sponsor: request.compact.sponsor, + claimHash, + }); + error = "Failed to check onchain registration status"; + } + } + } catch (err) { + error = "Sponsor signature verification failed"; + log.error({ msg: error, err }); + } + + if (!isSponsorValid) { + log.error({ + msg: "Verification failed: Invalid sponsor signature and no active registration found", + sponsorSignaturePresent: !!request.sponsorSignature, + registrationStatus: registrationStatus + ? { + isActive: registrationStatus.isActive, + expires: registrationStatus.expires?.toString(), + } + : null, + }); + return { isValid: false, isOnchainRegistration, error }; + } + + // Extract allocator ID from compact.id + const allocatorId = extractAllocatorId(request.compact.id); + log.debug({ msg: "Extracted allocator ID", allocatorId }); + + // Find the matching allocator + let allocatorAddress: string | undefined; + for (const [name, allocator] of Object.entries(metadata.allocators)) { + if (allocator.id === allocatorId) { + allocatorAddress = allocator.signingAddress; + log.debug({ + msg: "Found matching allocator", + allocatorName: name, + allocatorAddress, + }); + break; + } + } + + if (!allocatorAddress) { + const error = `No allocator found for ID: ${allocatorId}`; + log.error(error); + + return { + isValid: false, + isOnchainRegistration, + error: error, + }; + } + + // Verify allocator signature + const isAllocatorValid = await verifySignature( + claimHash, + request.allocatorSignature, + allocatorAddress, + chainPrefix, + ); + if (!isAllocatorValid) { + const error = "Invalid allocator signature"; + log.error(error); + + return { + isValid: false, + isOnchainRegistration, + error, + }; + } + + return { isValid: true, isOnchainRegistration }; +} diff --git a/typescript/solver/solvers/eco/config/metadata.ts b/typescript/solver/solvers/eco/config/metadata.ts index 6bfc4c4d..fc42fc36 100644 --- a/typescript/solver/solvers/eco/config/metadata.ts +++ b/typescript/solver/solvers/eco/config/metadata.ts @@ -2,12 +2,14 @@ import { type EcoMetadata, EcoMetadataSchema } from "../types.js"; const metadata: EcoMetadata = { protocolName: "Eco", - intentSources: [ - { - address: "0x734a3d5a8D691d9b911674E682De5f06517c79ec", - chainName: "optimismsepolia", - }, - ], + intentSources: { + blockchainEvents: [ + { + address: "0x734a3d5a8D691d9b911674E682De5f06517c79ec", + chainName: "optimismsepolia", + }, + ], + }, adapters: { basesepolia: "0x218FB5210d4eE248f046F3EC8B5Dd1c7Bc0756e5", }, diff --git a/typescript/solver/solvers/eco/listener.ts b/typescript/solver/solvers/eco/listener.ts index 1a58f200..110e5d2a 100644 --- a/typescript/solver/solvers/eco/listener.ts +++ b/typescript/solver/solvers/eco/listener.ts @@ -18,7 +18,10 @@ export class EcoListener extends BaseListener< > { constructor() { const { intentSources, protocolName } = metadata; - const ecoMetadata = { contracts: intentSources, protocolName }; + const ecoMetadata = { + contracts: intentSources.blockchainEvents, + protocolName, + }; super(IntentSource__factory, "IntentCreated", ecoMetadata, log); } diff --git a/typescript/solver/solvers/eco/types.ts b/typescript/solver/solvers/eco/types.ts index 7948bf0b..09c95c08 100644 --- a/typescript/solver/solvers/eco/types.ts +++ b/typescript/solver/solvers/eco/types.ts @@ -2,9 +2,17 @@ import z from "zod"; import { chainNames } from "../../config/index.js"; import { addressSchema } from "../../config/types.js"; import type { IntentCreatedEventObject } from "../../typechain/eco/contracts/IntentSource.js"; -import { BaseMetadataSchema } from "../types.js"; +import { + BaseBlockchainEventSourceSchema, + BaseMetadataSchema, +} from "../types.js"; export const EcoMetadataSchema = BaseMetadataSchema.extend({ + intentSources: z + .object({ + blockchainEvents: z.array(BaseBlockchainEventSourceSchema), + }) + .strict(), adapters: z.record( z.string().refine((name) => chainNames.includes(name), { message: "Invalid chainName", diff --git a/typescript/solver/solvers/eco/utils.ts b/typescript/solver/solvers/eco/utils.ts index 95c9f066..e0ec628c 100644 --- a/typescript/solver/solvers/eco/utils.ts +++ b/typescript/solver/solvers/eco/utils.ts @@ -31,7 +31,7 @@ export async function withdrawRewards( log.debug(`${protocolName} - Intent proven: ${_hash}`); const settler = IntentSource__factory.connect( - metadata.intentSources.find( + metadata.intentSources.blockchainEvents.find( (source) => source.chainName == originChainName, )!.address, signer, diff --git a/typescript/solver/solvers/hyperlane7683/config/metadata.ts b/typescript/solver/solvers/hyperlane7683/config/metadata.ts index 91eecf67..6228b068 100644 --- a/typescript/solver/solvers/hyperlane7683/config/metadata.ts +++ b/typescript/solver/solvers/hyperlane7683/config/metadata.ts @@ -7,66 +7,68 @@ import { const metadata: Hyperlane7683Metadata = { protocolName: "Hyperlane7683", - intentSources: [ - // mainnet - // { - // address: "0x5F69f9aeEB44e713fBFBeb136d712b22ce49eb88", - // chainName: "ethereum", - // }, - // { - // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", - // chainName: "optimism", - // }, - // { - // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", - // chainName: "arbitrum", - // }, - // { - // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", - // chainName: "base", - // }, - // { - // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", - // chainName: "gnosis", - // }, - // { - // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", - // chainName: "berachain", - // }, - // { - // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", - // chainName: "form", - // }, - // { - // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", - // chainName: "unichain", - // }, - // { - // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", - // chainName: "artela", - // }, + intentSources: { + blockchainEvents: [ + // mainnet + // { + // address: "0x5F69f9aeEB44e713fBFBeb136d712b22ce49eb88", + // chainName: "ethereum", + // }, + // { + // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", + // chainName: "optimism", + // }, + // { + // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", + // chainName: "arbitrum", + // }, + // { + // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", + // chainName: "base", + // }, + // { + // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", + // chainName: "gnosis", + // }, + // { + // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", + // chainName: "berachain", + // }, + // { + // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", + // chainName: "form", + // }, + // { + // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", + // chainName: "unichain", + // }, + // { + // address: "0x9245A985d2055CeA7576B293Da8649bb6C5af9D0", + // chainName: "artela", + // }, - // testnet - { - address: "0xf614c6bF94b022E16BEF7dBecF7614FFD2b201d3", - chainName: "optimismsepolia", - }, - { - address: "0xf614c6bF94b022E16BEF7dBecF7614FFD2b201d3", - chainName: "arbitrumsepolia", - }, - { - address: "0xf614c6bF94b022E16BEF7dBecF7614FFD2b201d3", - chainName: "sepolia", - }, - { - address: "0xf614c6bF94b022E16BEF7dBecF7614FFD2b201d3", - chainName: "basesepolia", - initialBlock: 21491220, - pollInterval: 1000, - confirmationBlocks: 2, - }, - ], + // testnet + { + address: "0xf614c6bF94b022E16BEF7dBecF7614FFD2b201d3", + chainName: "optimismsepolia", + }, + { + address: "0xf614c6bF94b022E16BEF7dBecF7614FFD2b201d3", + chainName: "arbitrumsepolia", + }, + { + address: "0xf614c6bF94b022E16BEF7dBecF7614FFD2b201d3", + chainName: "sepolia", + }, + { + address: "0xf614c6bF94b022E16BEF7dBecF7614FFD2b201d3", + chainName: "basesepolia", + initialBlock: 21491220, + pollInterval: 1000, + confirmationBlocks: 2, + }, + ], + }, customRules: { rules: [ { diff --git a/typescript/solver/solvers/hyperlane7683/listener.ts b/typescript/solver/solvers/hyperlane7683/listener.ts index 8f88df35..4ba948da 100644 --- a/typescript/solver/solvers/hyperlane7683/listener.ts +++ b/typescript/solver/solvers/hyperlane7683/listener.ts @@ -7,9 +7,9 @@ import type { } from "../../typechain/hyperlane7683/contracts/Hyperlane7683.js"; import { BaseListener } from "../BaseListener.js"; import { metadata } from "./config/index.js"; -import type { OpenEventArgs, Hyperlane7683Metadata } from "./types.js"; -import { log } from "./utils.js"; import { getLastIndexedBlocks } from "./db.js"; +import type { Hyperlane7683Metadata, OpenEventArgs } from "./types.js"; +import { log } from "./utils.js"; export class Hyperlane7683Listener extends BaseListener< Hyperlane7683, @@ -18,7 +18,10 @@ export class Hyperlane7683Listener extends BaseListener< > { constructor(metadata: Hyperlane7683Metadata) { const { intentSources, protocolName } = metadata; - const hyperlane7683Metadata = { contracts: intentSources, protocolName }; + const hyperlane7683Metadata = { + contracts: intentSources.blockchainEvents, + protocolName, + }; super(Hyperlane7683__factory, "Open", hyperlane7683Metadata, log); } @@ -43,22 +46,24 @@ export const create = async () => { const { intentSources } = metadata; const blocksByChain = await getLastIndexedBlocks(); - metadata.intentSources = intentSources.map((intentSource) => { - const chainBlockNumber = - blocksByChain[intentSource.chainName]?.blockNumber; + metadata.intentSources.blockchainEvents = intentSources.blockchainEvents.map( + (intentSource) => { + const chainBlockNumber = + blocksByChain[intentSource.chainName]?.blockNumber; - if ( - chainBlockNumber && - chainBlockNumber >= (intentSource.initialBlock ?? 0) - ) { - return { - ...intentSource, - initialBlock: blocksByChain[intentSource.chainName].blockNumber, - processedIds: blocksByChain[intentSource.chainName].processedIds, - }; - } - return intentSource; - }); + if ( + chainBlockNumber && + chainBlockNumber >= (intentSource.initialBlock ?? 0) + ) { + return { + ...intentSource, + initialBlock: blocksByChain[intentSource.chainName].blockNumber, + processedIds: blocksByChain[intentSource.chainName].processedIds, + }; + } + return intentSource; + }, + ); return new Hyperlane7683Listener(metadata).create(); }; diff --git a/typescript/solver/solvers/hyperlane7683/rules/filterByTokenAndAmount.ts b/typescript/solver/solvers/hyperlane7683/rules/filterByTokenAndAmount.ts index fd76ec4a..d46d3ecc 100644 --- a/typescript/solver/solvers/hyperlane7683/rules/filterByTokenAndAmount.ts +++ b/typescript/solver/solvers/hyperlane7683/rules/filterByTokenAndAmount.ts @@ -21,7 +21,7 @@ export function filterByTokenAndAmount( FilterByTokenAndAmountArgs.parse(args); const allowedTokens: Record = {}; - + for (const [chainId, tokens] of Object.entries(args)) { allowedTokens[chainId] = []; @@ -68,9 +68,12 @@ export function filterByTokenAndAmount( if (amountIn.lte(amountOut)) { return { error: "Intent is not profitable", success: false }; } - + if (amountOut.gt(maxAmount.toString())) { - return { error: "Output amount exceeds the maximum allowed", success: false }; + return { + error: "Output amount exceeds the maximum allowed", + success: false, + }; } return { data: "Amounts and tokens are Ok", success: true }; diff --git a/typescript/solver/solvers/hyperlane7683/types.ts b/typescript/solver/solvers/hyperlane7683/types.ts index 3cde2e8b..5909df8c 100644 --- a/typescript/solver/solvers/hyperlane7683/types.ts +++ b/typescript/solver/solvers/hyperlane7683/types.ts @@ -2,7 +2,10 @@ import type { BigNumber } from "ethers"; import z from "zod"; import type { OpenEventObject } from "../../typechain/hyperlane7683/contracts/Hyperlane7683.js"; -import { BaseMetadataSchema } from "../types.js"; +import { + BaseBlockchainEventSourceSchema, + BaseMetadataSchema, +} from "../types.js"; export type ExtractStruct = T extends (infer U & K)[] ? U[] @@ -41,6 +44,12 @@ export type IntentData = { maxSpent: ResolvedCrossChainOrder["maxSpent"]; }; -export const Hyperlane7683MetadataSchema = BaseMetadataSchema.extend({}); +export const Hyperlane7683MetadataSchema = BaseMetadataSchema.extend({ + intentSources: z + .object({ + blockchainEvents: z.array(BaseBlockchainEventSourceSchema), + }) + .strict(), +}); export type Hyperlane7683Metadata = z.infer; diff --git a/typescript/solver/solvers/index.ts b/typescript/solver/solvers/index.ts index 4c1ad5c7..6c790749 100644 --- a/typescript/solver/solvers/index.ts +++ b/typescript/solver/solvers/index.ts @@ -1,2 +1,3 @@ export * as eco from "./eco/index.js"; export * as hyperlane7683 from "./hyperlane7683/index.js"; +export * as compactX from "./compactX/index.js"; diff --git a/typescript/solver/solvers/types.ts b/typescript/solver/solvers/types.ts index e4bf3821..2b8503c4 100644 --- a/typescript/solver/solvers/types.ts +++ b/typescript/solver/solvers/types.ts @@ -9,20 +9,121 @@ export const addressSchema = z message: "Invalid address", }); +const WSClientOptionsSchema = z.object({ + ALPNCallback: z.function().optional(), + allowPartialTrustChain: z.boolean().optional(), + ca: z + .union([ + z.string(), + z.instanceof(Buffer), + z.array(z.union([z.string(), z.instanceof(Buffer)])), + ]) + .optional(), + cert: z + .union([ + z.string(), + z.instanceof(Buffer), + z.array(z.union([z.string(), z.instanceof(Buffer)])), + ]) + .optional(), + sigalgs: z.string().optional(), + ciphers: z.string().optional(), + clientCertEngine: z.string().optional(), + crl: z + .union([ + z.string(), + z.instanceof(Buffer), + z.array(z.union([z.string(), z.instanceof(Buffer)])), + ]) + .optional(), + dhparam: z.union([z.string(), z.instanceof(Buffer)]).optional(), + ecdhCurve: z.string().optional(), + honorCipherOrder: z.boolean().optional(), + key: z + .union([ + z.string(), + z.instanceof(Buffer), + z.array(z.union([z.string(), z.instanceof(Buffer)])), + ]) + .optional(), + privateKeyEngine: z.string().optional(), + privateKeyIdentifier: z.string().optional(), + maxVersion: z.enum(["TLSv1.3", "TLSv1.2", "TLSv1.1", "TLSv1"]).optional(), + minVersion: z.enum(["TLSv1.3", "TLSv1.2", "TLSv1.1", "TLSv1"]).optional(), + passphrase: z.string().optional(), + pfx: z + .union([ + z.string(), + z.instanceof(Buffer), + z.array(z.union([z.string(), z.instanceof(Buffer)])), + ]) + .optional(), + secureOptions: z.number().optional(), + secureProtocol: z.string().optional(), + sessionIdContext: z.string().optional(), + ticketKeys: z.instanceof(Buffer).optional(), + sessionTimeout: z.number().optional(), + protocol: z.string().optional(), + followRedirects: z.boolean().optional(), + generateMask: z.function().optional(), + handshakeTimeout: z.number().optional(), + maxRedirects: z.number().optional(), + perMessageDeflate: z + .union([z.boolean(), z.object({}).passthrough()]) + .optional(), + localAddress: z.string().optional(), + protocolVersion: z.number().optional(), + headers: z.record(z.string()).optional(), + origin: z.string().optional(), + agent: z.any().optional(), + host: z.string().optional(), + family: z.number().optional(), + checkServerIdentity: z.function().optional(), + rejectUnauthorized: z.boolean().optional(), + allowSynchronousEvents: z.boolean().optional(), + autoPong: z.boolean().optional(), + maxPayload: z.number().optional(), + skipUTF8Validation: z.boolean().optional(), + createConnection: z.function().optional(), + finishRequest: z.function().optional(), +}); + +export type WSClientOptions = z.infer; + +export const BaseWebSocketSourceSchema = z.object({ + url: z.string().url({ message: "Invalid WebSocket URL" }), + clientOptions: WSClientOptionsSchema.optional(), + options: z + .object({ + maxReconnectAttempts: z.number().optional(), + reconnectDelay: z.number().optional(), + }) + .optional(), +}); + +export type BaseWebSocketSource = z.infer; + +export const BaseBlockchainEventSourceSchema = z.object({ + address: addressSchema, + chainName: z.string().refine((name) => chainNames.includes(name), { + message: "Invalid chainName", + }), + pollInterval: z.number().optional(), + confirmationBlocks: z.number().optional(), + initialBlock: z.number().optional(), + processedIds: z.array(z.string()).optional(), +}); + +export type BaseBlockchainEventSource = z.infer< + typeof BaseBlockchainEventSourceSchema +>; + export const BaseMetadataSchema = z.object({ protocolName: z.string(), - intentSources: z.array( - z.object({ - address: addressSchema, - chainName: z.string().refine((name) => chainNames.includes(name), { - message: "Invalid chainName", - }), - pollInterval: z.number().optional(), - confirmationBlocks: z.number().optional(), - initialBlock: z.number().optional(), - processedIds: z.array(z.string()).optional(), - }), - ), + intentSources: z.object({ + blockchainEvents: z.array(BaseBlockchainEventSourceSchema).optional(), + webSockets: z.array(BaseWebSocketSourceSchema).optional(), + }), customRules: z .object({ rules: z.array( diff --git a/typescript/solver/solvers/utils.ts b/typescript/solver/solvers/utils.ts index a2e13dbb..9bb151fa 100644 --- a/typescript/solver/solvers/utils.ts +++ b/typescript/solver/solvers/utils.ts @@ -5,7 +5,7 @@ import { AddressZero } from "@ethersproject/constants"; import { formatUnits } from "@ethersproject/units"; import type { ChainMap, ChainMetadata } from "@hyperlane-xyz/sdk"; import { MultiProvider } from "@hyperlane-xyz/sdk"; -import { ensure0x } from "@hyperlane-xyz/utils"; +import { ensure0x, isZeroishAddress } from "@hyperlane-xyz/utils"; import { password } from "@inquirer/prompts"; import { MNEMONIC, PRIVATE_KEY } from "../config/index.js"; @@ -140,7 +140,7 @@ export function retrieveTokenBalance( ownerAddress: string, provider: Provider, ) { - if (tokenAddress === AddressZero) { + if (isZeroishAddress(tokenAddress)) { return provider.getBalance(ownerAddress); } diff --git a/yarn.lock b/yarn.lock index 085efc6f..13b16c55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4386,6 +4386,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^8": + version: 8.18.0 + resolution: "@types/ws@npm:8.18.0" + dependencies: + "@types/node": "npm:*" + checksum: 10/2a3bbf27690532627bfde8a215c0cf3a56680f339f972785b30d0b4665528275b9270c0a0839244610b0a3f2da4218c6dd741ceba1d173fde5c5091f2034b823 + languageName: node + linkType: hard + "@types/ws@npm:^8.2.2, @types/ws@npm:^8.5.4": version: 8.5.14 resolution: "@types/ws@npm:8.5.14" @@ -10790,6 +10799,7 @@ __metadata: "@libsql/client": "npm:^0.14.0" "@typechain/ethers-v5": "npm:^11.1.2" "@types/copyfiles": "npm:^2" + "@types/ws": "npm:^8" chalk: "npm:^5.3.0" copyfiles: "npm:^2.4.1" dotenv-flow: "npm:^4.1.0" @@ -10803,6 +10813,7 @@ __metadata: typescript: "npm:^5.6.3" uniqolor: "npm:^1.1.1" vitest: "npm:^2.1.8" + ws: "npm:^8.18.1" zod: "npm:^3.23.8" languageName: unknown linkType: soft @@ -12450,6 +12461,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.1": + version: 8.18.1 + resolution: "ws@npm:8.18.1" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/3f38e9594f2af5b6324138e86b74df7d77bbb8e310bf8188679dd80bac0d1f47e51536a1923ac3365f31f3d8b25ea0b03e4ade466aa8292a86cd5defca64b19b + languageName: node + linkType: hard + "xhr-request-promise@npm:^0.1.2": version: 0.1.3 resolution: "xhr-request-promise@npm:0.1.3"