diff --git a/.gitignore b/.gitignore index 0a98701f6b..68c03aacc2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ dist # Address lists addresses.json +# Inventory config +inventoryConfig.json + # AI context files CLAUDE.md diff --git a/CODEOWNERS b/CODEOWNERS index 531ac6ea4f..e4b6395da7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -6,4 +6,4 @@ # These owners will be the default owners for everything in # the repo unless a later match takes precedence. -* @mrice32 @nicholaspai @pxrl @james-a-morris @dohaki @bmzig +* @mrice32 @nicholaspai @pxrl @dohaki @bmzig diff --git a/Dockerfile b/Dockerfile index d27b0ff012..6b3e19d35e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG NODEJS_VERSION=20 +ARG NODEJS_VERSION=22.18 ##### Stage 1 diff --git a/index.ts b/index.ts index d7ca4633a7..9241b82068 100644 --- a/index.ts +++ b/index.ts @@ -10,24 +10,28 @@ import { waitForLogger, stringifyThrownValue, } from "./src/utils"; -import { runRelayer, runRebalancer } from "./src/relayer"; -import { runDataworker } from "./src/dataworker"; +import { runRelayer, runRebalancer, runInventoryManager } from "./src/relayer"; +import { runDataworker, runDisputerWatchdog } from "./src/dataworker"; import { runMonitor } from "./src/monitor"; import { runFinalizer } from "./src/finalizer"; import { version } from "./package.json"; import { runRefiller } from "./src/refiller"; +import { runHyperliquidExecutor } from "./src/hyperliquid"; let logger: typeof Logger; let cmd: string; const CMDS = { dataworker: runDataworker, + "disputer-watchdog": runDisputerWatchdog, finalizer: runFinalizer, help: help, monitor: runMonitor, refiller: runRefiller, relayer: runRelayer, rebalancer: runRebalancer, + hlExecutor: runHyperliquidExecutor, + inventoryManager: runInventoryManager, }; export async function run(args: { [k: string]: boolean | string }): Promise { diff --git a/package.json b/package.json index 6d26fb875d..fa948b93a4 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,13 @@ "license": "AGPL-3.0-only", "private": true, "engines": { - "node": ">=20.19.2" + "node": ">=22.18.0" }, "dependencies": { - "@across-protocol/constants": "^3.1.79", - "@across-protocol/contracts": "^4.1.9", - "@across-protocol/sdk": "4.3.74", + "@across-protocol/constants": "^3.1.89", + "@across-protocol/contracts": "^4.1.14", + "@across-protocol/contracts-beta": "npm:@across-protocol/contracts@4.1.12-beta.7", + "@across-protocol/sdk": "4.3.97", "@arbitrum/sdk": "^4.0.2", "@aws-sdk/client-kms": "^3.592.0", "@aws-sdk/client-s3": "^3.592.0", @@ -28,14 +29,15 @@ "@google-cloud/storage": "^6.10.1", "@maticnetwork/maticjs": "^3.6.0", "@maticnetwork/maticjs-ethers": "^1.0.3", + "@nktkas/hyperliquid": "^0.25.9", "@openzeppelin/hardhat-upgrades": "^1.28.0", "@solana-program/address-lookup-table": "^0.7.0", "@solana-program/compute-budget": "^0.8.0", "@solana-program/system": "^0.7.0", "@solana-program/token": "^0.5.0", "@solana/kit": "^2.1.0", - "@uma/logger": "^1.3.3", - "@uma/serverless-orchestration": "^2.17.2", + "@risk-labs/logger": "^1.3.7", + "@risk-labs/serverless-orchestration": "^1.0.5", "axios": "^1.7.4", "binance-api-node": "0.12.7", "dotenv": "^16.3.1", @@ -51,7 +53,7 @@ "superstruct": "^1.0.3", "ts-node": "^10.9.1", "viem": "^2.33.1", - "winston": "^3.10.0", + "winston": "^3.17.0", "zksync-ethers": "^5.7.2" }, "files": [ @@ -81,11 +83,14 @@ "run-monitor": "node ./dist/index.js --monitor", "run-refiller": "node ./dist/index.js --refiller", "run-finalizer": "node ./dist/index.js --finalizer", + "run-hlExecutor": "node ./dist/index.js --hlExecutor", + "run-inventoryManager": "node ./dist/index.js --inventoryManager", "relay": "node ./dist/index.js --relayer", "deposit": "yarn ts-node ./scripts/spokepool.ts deposit", "dispute": "yarn ts-node ./scripts/hubpool.ts dispute", "update": "git pull && yarn reinstall && yarn version --non-interactive && git show --quiet", "update-addresses": "yarn ts-node ./scripts/fetch-addresses.ts", + "update-inventory-config": "yarn ts-node ./scripts/fetchInventoryConfig.ts", "validate-root-bundle": "yarn ts-node src/scripts/validateRootBundle.ts" }, "devDependencies": { diff --git a/scripts/eventListener.ts b/scripts/eventListener.ts new file mode 100644 index 0000000000..340711a847 --- /dev/null +++ b/scripts/eventListener.ts @@ -0,0 +1,34 @@ +// import { Block } from "viem"; +import { utils as ethersUtils } from "ethers"; +import { Log } from "../src/interfaces"; +import { EventListener } from "../src/clients"; +import { getDeployedContract, Logger as logger } from "../src/utils"; + +async function run() { + const chainId = 8453; + const spokePool = getDeployedContract("SpokePool", chainId); + const listener = new EventListener(chainId, logger, 1); + const newBlock = (blockNumber: number, timestamp: number) => { + console.log(`xxx got block ${blockNumber} @ ${timestamp}`); + }; + const newFill = (event: Log) => { + console.log(`xxx got event ${event.event}`); + }; + + const eventDescriptor = spokePool.interface.getEvent("FilledRelay"); + const eventFormatted = eventDescriptor.format(ethersUtils.FormatTypes.full); + + listener.onBlock(newBlock); + listener.onEvent(spokePool.address, eventFormatted, newFill); +} + +if (require.main === module) { + run() + .then(async () => { + process.exitCode = 0; + }) + .catch(async (error) => { + console.error("Process exited with", error); + process.exitCode = 128; + }); +} diff --git a/scripts/fetchInventoryConfig.ts b/scripts/fetchInventoryConfig.ts new file mode 100644 index 0000000000..89ea83c9ac --- /dev/null +++ b/scripts/fetchInventoryConfig.ts @@ -0,0 +1,119 @@ +import { writeFile } from "node:fs/promises"; +import { config } from "dotenv"; +import axios from "axios"; + +const OUTPUT_PATH = "inventoryConfig.json"; + +interface GitHubFileResponse { + content: string; + encoding: string; + sha: string; + size: number; + url: string; +} + +async function fetchWithRetry( + url: string, + headers: { Authorization: string; Accept: string }, + retries = 3, + delayMs = 1000 +): Promise { + for (let i = 0; i < retries; i++) { + try { + const response = await axios.get(url, { headers }); + return response.data; + } catch (error) { + if (i === retries - 1) { + throw error; + } + console.log(`⚠️ Request failed (attempt ${i + 1}/${retries}). Retrying in ${delayMs}ms...`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + throw new Error("Failed to fetch file from GitHub"); +} + +async function fetchFileFromGitHub( + owner: string, + repo: string, + path: string, + token: string, + branch = "master" +): Promise { + const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`; + const headers = { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }; + + const response = await fetchWithRetry(url, headers); + + // GitHub API returns base64 encoded content + const content = Buffer.from(response.content, "base64").toString("utf-8"); + return content; +} + +async function run(): Promise { + config(); // Load .env file + + // Get configuration from environment variables + const githubToken = process.env.GITHUB_TOKEN; + const githubOwner = process.env.GITHUB_REPO_OWNER; + const githubRepo = process.env.GITHUB_REPO_NAME; + const githubFilePath = process.env.GITHUB_FILE_PATH; + const githubBranch = process.env.GITHUB_BRANCH || "master"; + + // Validate required environment variables + if (!githubToken) { + throw new Error("GITHUB_TOKEN environment variable is required"); + } + if (!githubOwner) { + throw new Error("GITHUB_REPO_OWNER environment variable is required"); + } + if (!githubRepo) { + throw new Error("GITHUB_REPO_NAME environment variable is required"); + } + if (!githubFilePath) { + throw new Error("GITHUB_FILE_PATH environment variable is required"); + } + + console.log(`Fetching ${githubFilePath} from ${githubOwner}/${githubRepo} (branch: ${githubBranch})...`); + + try { + // Fetch the file content from GitHub + const fileContent = await fetchFileFromGitHub(githubOwner, githubRepo, githubFilePath, githubToken, githubBranch); + + // Parse JSON to validate it's valid JSON + const jsonData = JSON.parse(fileContent); + + // Write to output file + await writeFile(OUTPUT_PATH, JSON.stringify(jsonData, null, 2)); + + console.log(`✅ Successfully fetched and saved to ${OUTPUT_PATH}`); + return 0; + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 404) { + throw new Error(`File not found: ${githubFilePath} in ${githubOwner}/${githubRepo}`); + } else if (error.response?.status === 401 || error.response?.status === 403) { + throw new Error( + `Authentication failed. Check your GITHUB_TOKEN and ensure it has access to ${githubOwner}/${githubRepo}` + ); + } else { + throw new Error(`GitHub API error: ${error.response?.status} - ${error.message}`); + } + } + throw error; + } +} + +if (require.main === module) { + run() + .then((result: number) => { + process.exitCode = result; + }) + .catch((error) => { + console.error("❌ Process exited with error:", error.message); + process.exitCode = 127; + }); +} diff --git a/scripts/hyperliquidExchangeApi.ts b/scripts/hyperliquidExchangeApi.ts new file mode 100644 index 0000000000..bd085d49ab --- /dev/null +++ b/scripts/hyperliquidExchangeApi.ts @@ -0,0 +1,51 @@ +import * as hl from "@nktkas/hyperliquid"; +import { SymbolConverter } from "@nktkas/hyperliquid/script/src/utils/mod"; + +async function run() { + const subsClient = new hl.SubscriptionClient({ + transport: new hl.WebSocketTransport(), + }); + + const httpTransport = new hl.HttpTransport(); // or `WebSocketTransport` + const converter = await SymbolConverter.create({ transport: httpTransport }); + const spotPairId = converter.getSpotPairId("USDT0/USDC"); + console.log(`Printing fills for spot pair ${spotPairId}`); + // Log out all historical fills for user and also log + // for each new one. + await subsClient.userFills({ user: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D" }, (data) => { + for (const fill of data.fills) { + if (fill.coin === spotPairId) { + console.log(fill); + // e.g. USDT-USDC sell limit order fill (sell USDT for USDC) + // { + // coin: '@166', + // px: '0.99987', + // sz: '12.0', + // side: 'A', + // time: 1762442654786, + // startPosition: '20.75879498', + // dir: 'Sell', + // closedPnl: '-0.00266345', + // hash: '0x12bac8ba533cb3641434042ef5f8990207c6009fee3fd236b683740d12308d4e', + // oid: 225184674691, + // crossed: false, + // fee: '0.00095987', + // tid: 854069469806140, + // feeToken: 'USDC', + // twapId: null + // } + } + } + }); +} + +if (require.main === module) { + run() + .then(async () => { + process.exitCode = 0; + }) + .catch(async (error) => { + console.error("Process exited with", error); + process.exitCode = 128; + }); +} diff --git a/src/adapter/BaseChainAdapter.ts b/src/adapter/BaseChainAdapter.ts index 6122a5648d..919d91f18f 100644 --- a/src/adapter/BaseChainAdapter.ts +++ b/src/adapter/BaseChainAdapter.ts @@ -34,6 +34,9 @@ import { ZERO_BYTES, isEVMSpokePoolClient, EvmAddress, + chainIsEvm, + sendAndConfirmSolanaTransaction, + getSvmProvider, } from "../utils"; import { AugmentedTransaction, TransactionClient } from "../clients/TransactionClient"; import { @@ -291,7 +294,7 @@ export class BaseChainAdapter { if (!this.isSupportedL2Bridge(l1Token)) { return []; } - let txnsToSend: AugmentedTransaction[]; + let txnsToSend; try { txnsToSend = await this.l2Bridges[l1Token.toNative()].constructWithdrawToL1Txns( address, @@ -317,10 +320,17 @@ export class BaseChainAdapter { ); return []; } - const multicallerClient = new MultiCallerClient(this.logger); - txnsToSend.forEach((txn) => multicallerClient.enqueueTransaction(txn)); - const txnReceipts = await multicallerClient.executeTxnQueues(simMode); - return txnReceipts[this.chainId]; + if (chainIsEvm(this.chainId)) { + const multicallerClient = new MultiCallerClient(this.logger); + txnsToSend.forEach((txn) => multicallerClient.enqueueTransaction(txn)); + const txnReceipts = await multicallerClient.executeTxnQueues(simMode, [this.chainId]); + return txnReceipts[this.chainId]; + } + const txnSignatures = []; + for (const solanaTransaction of txnsToSend) { + txnSignatures.push(await sendAndConfirmSolanaTransaction(solanaTransaction, getSvmProvider())); + } + return txnSignatures; } async getL2PendingWithdrawalAmount( @@ -453,8 +463,8 @@ export class BaseChainAdapter { // Permit bypass if simMode is set in order to permit tests to pass. if (simMode === false) { const symbol = await contract.symbol(); - const { BSC, HYPEREVM, MAINNET, PLASMA } = CHAIN_IDs; - const nativeTokenChains = [BSC, HYPEREVM, MAINNET, PLASMA]; + const { BSC, HYPEREVM, MAINNET, PLASMA, MONAD } = CHAIN_IDs; + const nativeTokenChains = [BSC, HYPEREVM, MAINNET, PLASMA, MONAD]; const prependW = nativeTokenChains.some((chainId) => PUBLIC_NETWORKS[chainId].nativeToken === nativeTokenSymbol); const expectedTokenSymbol = prependW ? `W${nativeTokenSymbol}` : nativeTokenSymbol; assert( diff --git a/src/adapter/bridges/UsdcCCTPBridge.ts b/src/adapter/bridges/UsdcCCTPBridge.ts index 9fe576c97c..fe9a788410 100644 --- a/src/adapter/bridges/UsdcCCTPBridge.ts +++ b/src/adapter/bridges/UsdcCCTPBridge.ts @@ -18,9 +18,13 @@ import { bnZero, } from "../../utils"; import { processEvent, TransferTokenParams } from "../utils"; -import { getCctpV2TokenMessenger, getV2DepositForBurnMaxFee } from "../../utils/CCTPUtils"; +import { + getCctpV2TokenMessenger, + getV2DepositForBurnMaxFee, + CCTPV2_FINALITY_THRESHOLD_STANDARD, +} from "../../utils/CCTPUtils"; import { CCTP_NO_DOMAIN } from "@across-protocol/constants"; -import { CCTP_MAX_SEND_AMOUNT, CCTPV2_FINALITY_THRESHOLD_STANDARD } from "../../common"; +import { CCTP_MAX_SEND_AMOUNT } from "../../common"; import { SortableEvent } from "../../interfaces"; export class UsdcCCTPBridge extends BaseBridgeAdapter { diff --git a/src/adapter/l2Bridges/ArbitrumOrbitBridge.ts b/src/adapter/l2Bridges/ArbitrumOrbitBridge.ts index cbc2e0341b..a56ac227ef 100644 --- a/src/adapter/l2Bridges/ArbitrumOrbitBridge.ts +++ b/src/adapter/l2Bridges/ArbitrumOrbitBridge.ts @@ -9,7 +9,6 @@ import { getNetworkName, isDefined, paginatedEventQuery, - Provider, Signer, toBN, EvmAddress, @@ -22,14 +21,8 @@ import ARBITRUM_ERC20_GATEWAY_L2_ABI from "../../common/abi/ArbitrumErc20Gateway export class ArbitrumOrbitBridge extends BaseL2BridgeAdapter { protected l2GatewayRouter: Contract; - constructor( - l2chainId: number, - hubChainId: number, - l2Signer: Signer, - l1Provider: Provider | Signer, - l1Token: EvmAddress - ) { - super(l2chainId, hubChainId, l2Signer, l1Provider, l1Token); + constructor(l2chainId: number, hubChainId: number, l2Signer: Signer, l1Signer: Signer, l1Token: EvmAddress) { + super(l2chainId, hubChainId, l2Signer, l1Signer, l1Token); const { address, abi } = CONTRACT_ADDRESSES[l2chainId].erc20GatewayRouter; this.l2GatewayRouter = new Contract(address, abi, l2Signer); @@ -38,7 +31,7 @@ export class ArbitrumOrbitBridge extends BaseL2BridgeAdapter { CUSTOM_ARBITRUM_GATEWAYS[this.l2chainId]?.[l1Token.toNative()] ?? DEFAULT_ARBITRUM_GATEWAY[this.l2chainId]; const l2GatewayContract = new Contract(l2Address, ARBITRUM_ERC20_GATEWAY_L2_ABI, this.l2Signer); const l1GatewayContractAbi = CONTRACT_ADDRESSES[this.hubChainId][`orbitErc20Gateway_${this.l2chainId}`].abi; - const l1GatewayContract = new Contract(l1Address, l1GatewayContractAbi, this.l1Provider); + const l1GatewayContract = new Contract(l1Address, l1GatewayContractAbi, this.l1Signer); this.l2Bridge = l2GatewayContract; this.l1Bridge = l1GatewayContract; } diff --git a/src/adapter/l2Bridges/BaseL2BridgeAdapter.ts b/src/adapter/l2Bridges/BaseL2BridgeAdapter.ts index 2b00d2fddd..77557b319f 100644 --- a/src/adapter/l2Bridges/BaseL2BridgeAdapter.ts +++ b/src/adapter/l2Bridges/BaseL2BridgeAdapter.ts @@ -3,12 +3,13 @@ import { BigNumber, Contract, EventSearchConfig, - Provider, Signer, EvmAddress, Address, getHubPoolAddress, getSpokePoolAddress, + SVMProvider, + SolanaTransaction, } from "../../utils"; import { TransferTokenParams } from "../utils"; @@ -17,16 +18,25 @@ export abstract class BaseL2BridgeAdapter { protected l1Bridge: Contract; protected readonly hubPoolAddress: EvmAddress; protected readonly spokePoolAddress: Address; + // Whether either of these two are defined is determined at construction. + // The solana bridge defines `svmProvider` while the EVM bridges define `l2Signer`. + protected readonly l2Signer: Signer | undefined; + protected readonly svmProvider: SVMProvider | undefined; constructor( protected l2chainId: number, protected hubChainId: number, - protected l2Signer: Signer, - protected l1Provider: Provider | Signer, + protected l2SignerOrSvmProvider: Signer | SVMProvider, + protected l1Signer: Signer, protected l1Token: EvmAddress ) { this.hubPoolAddress = getHubPoolAddress(hubChainId); this.spokePoolAddress = getSpokePoolAddress(l2chainId); + if (l2SignerOrSvmProvider instanceof Signer) { + this.l2Signer = l2SignerOrSvmProvider satisfies Signer; + } else { + this.svmProvider = l2SignerOrSvmProvider satisfies SVMProvider; + } } abstract constructWithdrawToL1Txns( @@ -35,7 +45,7 @@ export abstract class BaseL2BridgeAdapter { l1Token: EvmAddress, amount: BigNumber, optionalParams?: TransferTokenParams - ): Promise; + ): Promise; abstract getL2PendingWithdrawalAmount( l2EventSearchConfig: EventSearchConfig, diff --git a/src/adapter/l2Bridges/BinanceCEXBridge.ts b/src/adapter/l2Bridges/BinanceCEXBridge.ts index b6d2738f0b..4ece6bdeae 100644 --- a/src/adapter/l2Bridges/BinanceCEXBridge.ts +++ b/src/adapter/l2Bridges/BinanceCEXBridge.ts @@ -6,7 +6,6 @@ import { EventSearchConfig, getNetworkName, isDefined, - Provider, Signer, EvmAddress, getBinanceApiClient, @@ -35,17 +34,11 @@ export class BinanceCEXBridge extends BaseL2BridgeAdapter { // The deposit network corresponding to the L2. protected depositNetwork: string; - constructor( - l2chainId: number, - hubChainId: number, - l2Signer: Signer, - l1Provider: Provider | Signer, - l1Token: EvmAddress - ) { + constructor(l2chainId: number, hubChainId: number, l2Signer: Signer, l1Signer: Signer, l1Token: EvmAddress) { if (hubChainId !== CHAIN_IDs.MAINNET) { throw new Error("Cannot define a Binance CEX bridge for a non-production network"); } - super(l2chainId, hubChainId, l2Signer, l1Provider, l1Token); + super(l2chainId, hubChainId, l2Signer, l1Signer, l1Token); const l2Token = getTranslatedTokenAddress(l1Token, hubChainId, l2chainId); this.l2Bridge = new Contract(l2Token.toNative(), ERC20_ABI, l2Signer); diff --git a/src/adapter/l2Bridges/BinanceCEXNativeBridge.ts b/src/adapter/l2Bridges/BinanceCEXNativeBridge.ts index 33ed49248e..9054ca29e0 100644 --- a/src/adapter/l2Bridges/BinanceCEXNativeBridge.ts +++ b/src/adapter/l2Bridges/BinanceCEXNativeBridge.ts @@ -2,7 +2,6 @@ import { BigNumber, createFormatFunction, getNetworkName, - Provider, Signer, Contract, EvmAddress, @@ -13,14 +12,8 @@ import WETH_ABI from "../../common/abi/Weth.json"; import { BinanceCEXBridge } from "./"; export class BinanceCEXNativeBridge extends BinanceCEXBridge { - constructor( - l2chainId: number, - hubChainId: number, - l2Signer: Signer, - l1Provider: Provider | Signer, - l1Token: EvmAddress - ) { - super(l2chainId, hubChainId, l2Signer, l1Provider, l1Token); + constructor(l2chainId: number, hubChainId: number, l2Signer: Signer, l1Signer: Signer, l1Token: EvmAddress) { + super(l2chainId, hubChainId, l2Signer, l1Signer, l1Token); } async constructWithdrawToL1Txns( diff --git a/src/adapter/l2Bridges/HyperlaneXERC20Bridge.ts b/src/adapter/l2Bridges/HyperlaneXERC20Bridge.ts index b4683d8032..04bfb69e1f 100644 --- a/src/adapter/l2Bridges/HyperlaneXERC20Bridge.ts +++ b/src/adapter/l2Bridges/HyperlaneXERC20Bridge.ts @@ -2,7 +2,6 @@ import { BigNumber, Contract, Signer, - Provider, EvmAddress, assert, isDefined, @@ -28,14 +27,8 @@ export class HyperlaneXERC20BridgeL2 extends BaseL2BridgeAdapter { private readonly originDomainId: number; private readonly destinationDomainId: number; - constructor( - l2chainId: number, - hubChainId: number, - l2Signer: Signer, - l1Provider: Provider | Signer, - l1Token: EvmAddress - ) { - super(l2chainId, hubChainId, l2Signer, l1Provider, l1Token); + constructor(l2chainId: number, hubChainId: number, l2Signer: Signer, l1Signer: Signer, l1Token: EvmAddress) { + super(l2chainId, hubChainId, l2Signer, l1Signer, l1Token); const l2Token = getTranslatedTokenAddress(l1Token, hubChainId, l2chainId); assert(l2Token.isEVM()); @@ -54,7 +47,7 @@ export class HyperlaneXERC20BridgeL2 extends BaseL2BridgeAdapter { ); this.l2Bridge = new Contract(l2RouterAddressStr, HYPERLANE_ROUTER_ABI, l2Signer); - this.l1Bridge = new Contract(l1RouterAddressStr, HYPERLANE_ROUTER_ABI, l1Provider); + this.l1Bridge = new Contract(l1RouterAddressStr, HYPERLANE_ROUTER_ABI, l1Signer); this.originDomainId = PUBLIC_NETWORKS[l2chainId].hypDomainId; assert( diff --git a/src/adapter/l2Bridges/OFTL2Bridge.ts b/src/adapter/l2Bridges/OFTL2Bridge.ts index c415419b1b..e0e3a9567f 100644 --- a/src/adapter/l2Bridges/OFTL2Bridge.ts +++ b/src/adapter/l2Bridges/OFTL2Bridge.ts @@ -3,7 +3,6 @@ import { AugmentedTransaction } from "../../clients"; import { Address, EvmAddress, - Provider, assert, createFormatFunction, ConvertDecimals, @@ -30,14 +29,8 @@ export class OFTL2Bridge extends BaseL2BridgeAdapter { private l2ToL1AmountConverter: (amount: BigNumber) => BigNumber; private readonly feePct: BigNumber = BigNumber.from(5 * 10 ** 15); // Default fee percent of 0.5% - constructor( - l2chainId: number, - hubChainId: number, - l2Signer: Signer, - l1Provider: Provider | Signer, - l1Token: EvmAddress - ) { - super(l2chainId, hubChainId, l2Signer, l1Provider, l1Token); + constructor(l2chainId: number, hubChainId: number, l2Signer: Signer, l1Signer: Signer, l1Token: EvmAddress) { + super(l2chainId, hubChainId, l2Signer, l1Signer, l1Token); const translatedL2Token = getTranslatedTokenAddress(l1Token, hubChainId, l2chainId); assert(translatedL2Token.isEVM()); @@ -48,7 +41,7 @@ export class OFTL2Bridge extends BaseL2BridgeAdapter { this.nativeFeeCap = OFT_FEE_CAP_OVERRIDES[this.l2chainId] ?? OFT_DEFAULT_FEE_CAP; - this.l1Bridge = new Contract(l1OftMessenger.toNative(), IOFT_ABI_FULL, l1Provider); + this.l1Bridge = new Contract(l1OftMessenger.toNative(), IOFT_ABI_FULL, l1Signer); this.l2Bridge = new Contract(l2OftMessenger.toNative(), IOFT_ABI_FULL, l2Signer); this.l2ChainEid = OFT.getEndpointId(l2chainId); diff --git a/src/adapter/l2Bridges/OpStackBridge.ts b/src/adapter/l2Bridges/OpStackBridge.ts index 8f54fee646..c59cbbf777 100644 --- a/src/adapter/l2Bridges/OpStackBridge.ts +++ b/src/adapter/l2Bridges/OpStackBridge.ts @@ -8,7 +8,6 @@ import { getNetworkName, isDefined, paginatedEventQuery, - Provider, Signer, toBN, EvmAddress, @@ -18,19 +17,13 @@ import { BaseL2BridgeAdapter } from "./BaseL2BridgeAdapter"; import { AugmentedTransaction } from "../../clients/TransactionClient"; export class OpStackBridge extends BaseL2BridgeAdapter { - constructor( - l2chainId: number, - hubChainId: number, - l2Signer: Signer, - l1Provider: Provider | Signer, - l1Token: EvmAddress - ) { - super(l2chainId, hubChainId, l2Signer, l1Provider, l1Token); + constructor(l2chainId: number, hubChainId: number, l2Signer: Signer, l1Signer: Signer, l1Token: EvmAddress) { + super(l2chainId, hubChainId, l2Signer, l1Signer, l1Token); const { address, abi } = CONTRACT_ADDRESSES[l2chainId].ovmStandardBridge; this.l2Bridge = new Contract(address, abi, l2Signer); const { address: l1Address, abi: l1Abi } = CONTRACT_ADDRESSES[hubChainId][`ovmStandardBridge_${l2chainId}`]; - this.l1Bridge = new Contract(l1Address, l1Abi, l1Provider); + this.l1Bridge = new Contract(l1Address, l1Abi, l1Signer); } constructWithdrawToL1Txns( diff --git a/src/adapter/l2Bridges/OpStackUSDCBridge.ts b/src/adapter/l2Bridges/OpStackUSDCBridge.ts index 9cbb76012a..fad1d93553 100644 --- a/src/adapter/l2Bridges/OpStackUSDCBridge.ts +++ b/src/adapter/l2Bridges/OpStackUSDCBridge.ts @@ -11,7 +11,6 @@ import { isDefined, MAX_SAFE_ALLOWANCE, paginatedEventQuery, - Provider, Signer, EvmAddress, getTokenInfo, @@ -22,20 +21,14 @@ import { BaseL2BridgeAdapter } from "./BaseL2BridgeAdapter"; export class OpStackUSDCBridge extends BaseL2BridgeAdapter { readonly minGasLimit = 200_000; - constructor( - l2chainId: number, - hubChainId: number, - l2Signer: Signer, - l1Provider: Provider | Signer, - l1Token: EvmAddress - ) { - super(l2chainId, hubChainId, l2Signer, l1Provider, l1Token); + constructor(l2chainId: number, hubChainId: number, l2Signer: Signer, l1Signer: Signer, l1Token: EvmAddress) { + super(l2chainId, hubChainId, l2Signer, l1Signer, l1Token); const { address: l2Address, abi: l2ABI } = CONTRACT_ADDRESSES[l2chainId].opUSDCBridge; this.l2Bridge = new Contract(l2Address, l2ABI, l2Signer); const { address: l1Address, abi: l1ABI } = CONTRACT_ADDRESSES[hubChainId][`opUSDCBridge_${l2chainId}`]; - this.l1Bridge = new Contract(l1Address, l1ABI, l1Provider); + this.l1Bridge = new Contract(l1Address, l1ABI, l1Signer); } async constructWithdrawToL1Txns( diff --git a/src/adapter/l2Bridges/OpStackWethBridge.ts b/src/adapter/l2Bridges/OpStackWethBridge.ts index 5f45774236..3ab2cceb7f 100644 --- a/src/adapter/l2Bridges/OpStackWethBridge.ts +++ b/src/adapter/l2Bridges/OpStackWethBridge.ts @@ -8,7 +8,6 @@ import { getNetworkName, isDefined, paginatedEventQuery, - Provider, Signer, toBN, EvmAddress, @@ -19,19 +18,13 @@ import WETH_ABI from "../../common/abi/Weth.json"; import { AugmentedTransaction } from "../../clients/TransactionClient"; export class OpStackWethBridge extends BaseL2BridgeAdapter { - constructor( - l2chainId: number, - hubChainId: number, - l2Signer: Signer, - l1Provider: Provider | Signer, - l1Token: EvmAddress - ) { - super(l2chainId, hubChainId, l2Signer, l1Provider, l1Token); + constructor(l2chainId: number, hubChainId: number, l2Signer: Signer, l1Signer: Signer, l1Token: EvmAddress) { + super(l2chainId, hubChainId, l2Signer, l1Signer, l1Token); const { address, abi } = CONTRACT_ADDRESSES[l2chainId].ovmStandardBridge; this.l2Bridge = new Contract(address, abi, l2Signer); const { address: l1Address, abi: l1Abi } = CONTRACT_ADDRESSES[hubChainId][`ovmStandardBridge_${l2chainId}`]; - this.l1Bridge = new Contract(l1Address, l1Abi, l1Provider); + this.l1Bridge = new Contract(l1Address, l1Abi, l1Signer); } constructWithdrawToL1Txns( diff --git a/src/adapter/l2Bridges/SolanaUsdcCCTPBridge.ts b/src/adapter/l2Bridges/SolanaUsdcCCTPBridge.ts new file mode 100644 index 0000000000..8272d284a8 --- /dev/null +++ b/src/adapter/l2Bridges/SolanaUsdcCCTPBridge.ts @@ -0,0 +1,190 @@ +import { + BigNumber, + bnZero, + Contract, + EventSearchConfig, + paginatedEventQuery, + Signer, + EvmAddress, + getCctpDomainForChainId, + TOKEN_SYMBOLS_MAP, + assert, + getCctpV1TokenMessenger, + getCctpV1MessageTransmitter, + SvmAddress, + getAssociatedTokenAddress, + getKitKeypairFromEvmSigner, + getCCTPDepositAccounts, + SVMProvider, + createDefaultTransaction, + isDefined, + SolanaTransaction, +} from "../../utils"; +import { BaseL2BridgeAdapter } from "./BaseL2BridgeAdapter"; +import { CCTP_MAX_SEND_AMOUNT } from "../../common"; +import { arch } from "@across-protocol/sdk"; +import { TokenMessengerMinterIdl, TokenMessengerMinterClient } from "@across-protocol/contracts"; +import { + Address, + generateKeyPairSigner, + address, + appendTransactionMessageInstruction, + pipe, + type KeyPairSigner, +} from "@solana/kit"; + +type DepositForBurnEvent = { + nonce: BigInt; + burnToken: string; + amount: BigInt; + depositor: string; + mintRecipient: string; + destinationDomain: number; + destinationTokenMessenger: string; + destinationCaller: string; +}; + +export class SolanaUsdcCCTPBridge extends BaseL2BridgeAdapter { + private IS_CCTP_V2 = false; + private readonly l1UsdcTokenAddress: EvmAddress; + private readonly l2UsdcTokenAddress: SvmAddress; + private readonly solanaEventsClientPromise: Promise; + private readonly tokenMessengerMinter: Address; + private readonly messageTransmitter: Address; + private readonly svmSignerPromise: Promise; + private svmSigner: KeyPairSigner; + private solanaEventsClient: arch.svm.SvmCpiEventsClient; + + constructor(l2chainId: number, hubChainId: number, l2Provider: SVMProvider, l1Signer: Signer, l1Token: EvmAddress) { + super(l2chainId, hubChainId, l2Provider, l1Signer, l1Token); + + const { address: l1TokenMessengerAddress, abi: l1Abi } = getCctpV1TokenMessenger(hubChainId); + this.l1Bridge = new Contract(l1TokenMessengerAddress, l1Abi, l1Signer); + + this.l1UsdcTokenAddress = EvmAddress.from(TOKEN_SYMBOLS_MAP.USDC.addresses[this.hubChainId]); + this.l2UsdcTokenAddress = SvmAddress.from(TOKEN_SYMBOLS_MAP.USDC.addresses[this.l2chainId]); + + const { address: l2TokenMessengerAddress } = getCctpV1TokenMessenger(this.l2chainId); + const { address: l2MessageTransmitterAddress } = getCctpV1MessageTransmitter(this.l2chainId); + + this.tokenMessengerMinter = address(l2TokenMessengerAddress); + this.messageTransmitter = address(l2MessageTransmitterAddress); + + this.solanaEventsClientPromise = arch.svm.SvmCpiEventsClient.createFor( + l2Provider, + this.tokenMessengerMinter, + TokenMessengerMinterIdl + ); + + this.svmSignerPromise = getKitKeypairFromEvmSigner(this.l1Signer); + } + + private get l1DestinationDomain(): number { + return getCctpDomainForChainId(this.hubChainId); + } + + async constructWithdrawToL1Txns( + toAddress: EvmAddress, + l2Token: SvmAddress, + l1Token: EvmAddress, + amount: BigNumber + ): Promise { + assert(l1Token.eq(this.l1UsdcTokenAddress)); + assert(l2Token.eq(this.l2UsdcTokenAddress)); + this.svmSigner ??= await this.svmSignerPromise; + + amount = amount.gt(CCTP_MAX_SEND_AMOUNT) ? CCTP_MAX_SEND_AMOUNT : amount; + const [cctpDepositAccounts, burnTokenAccount, messageSentEventData] = await Promise.all([ + getCCTPDepositAccounts( + this.hubChainId, + this.l1DestinationDomain, + this.tokenMessengerMinter, + this.messageTransmitter + ), + this._getAssociatedTokenAddress(this.svmSigner.address), + generateKeyPairSigner(), + ]); + const { + tokenMessenger, + messageTransmitter, + localToken, + remoteTokenMessenger, + tokenMinter, + cctpEventAuthority: eventAuthority, + tokenMessengerMinterSenderAuthority: senderAuthorityPda, + } = cctpDepositAccounts; + const depositForBurnIx = TokenMessengerMinterClient.getDepositForBurnInstruction({ + owner: this.svmSigner, + eventRentPayer: this.svmSigner, + senderAuthorityPda, + burnTokenAccount, + messageTransmitter, + tokenMessenger, + remoteTokenMessenger, + tokenMinter, + localToken, + burnTokenMint: arch.svm.toAddress(this.l2UsdcTokenAddress), + messageSentEventData, + messageTransmitterProgram: this.messageTransmitter, + tokenMessengerMinterProgram: this.tokenMessengerMinter, + eventAuthority, + program: this.tokenMessengerMinter, + amount: amount.toBigInt(), + destinationDomain: this.l1DestinationDomain, + mintRecipient: address(toAddress.toBase58()), + }); + const depositForBurnTx = pipe(await createDefaultTransaction(this.svmProvider, this.svmSigner), (tx) => + appendTransactionMessageInstruction(depositForBurnIx, tx) + ); + return [depositForBurnTx]; + } + + async getL2PendingWithdrawalAmount( + l2EventConfig: EventSearchConfig, + l1EventConfig: EventSearchConfig, + fromAddress: EvmAddress, + l2Token: EvmAddress + ): Promise { + if (!l2Token.eq(this.l2UsdcTokenAddress)) { + return bnZero; + } + this.svmSigner ??= await this.svmSignerPromise; + this.solanaEventsClient ??= await this.solanaEventsClientPromise; + + // @dev: First parameter in MintAndWithdraw is mintRecipient, this should be the same as the fromAddress + // for all use cases of this adapter. + const l1EventFilterArgs = [fromAddress.toNative(), undefined, this.l1UsdcTokenAddress.toNative()]; + const [withdrawalInitiatedEvents, withdrawalFinalizedEvents] = await Promise.all([ + this.solanaEventsClient.queryDerivedAddressEvents( + "DepositForBurn", + this.tokenMessengerMinter, + BigInt(l2EventConfig.from), + BigInt(l2EventConfig.to) + ), + paginatedEventQuery(this.l1Bridge, this.l1Bridge.filters.MintAndWithdraw(...l1EventFilterArgs), l1EventConfig), + ]); + const counted = new Set(); + const withdrawalAmount = withdrawalInitiatedEvents.reduce((totalAmount, _l2Args) => { + const l2Args = _l2Args.data as DepositForBurnEvent; + const l2MintRecipient = l2Args.mintRecipient; + // Exit early if the event parsed was initiated by an address we are not tracking. + if (l2MintRecipient !== fromAddress.toBase58()) { + return totalAmount; + } + const matchingFinalizedEvent = withdrawalFinalizedEvents.find(({ args: l1Args }, idx) => { + const l1MintRecipient = EvmAddress.from(l1Args.mintRecipient); + if (counted.has(idx) || !l1Args.amount.eq(l2Args.amount) || l2MintRecipient !== l1MintRecipient.toBase58()) { + return false; + } + counted.add(idx); + return true; + }); + return isDefined(matchingFinalizedEvent) ? totalAmount : totalAmount.add(Number(l2Args.amount)); + }, bnZero); + return withdrawalAmount; + } + + async _getAssociatedTokenAddress(address: Address): Promise
{ + return await getAssociatedTokenAddress(SvmAddress.from(address as string), this.l2UsdcTokenAddress); + } +} diff --git a/src/adapter/l2Bridges/UsdcCCTPBridge.ts b/src/adapter/l2Bridges/UsdcCCTPBridge.ts index 6312df0348..d60f2598bf 100644 --- a/src/adapter/l2Bridges/UsdcCCTPBridge.ts +++ b/src/adapter/l2Bridges/UsdcCCTPBridge.ts @@ -6,7 +6,6 @@ import { getNetworkName, isDefined, paginatedEventQuery, - Provider, Signer, toBN, EvmAddress, @@ -18,31 +17,28 @@ import { getTokenInfo, getV2DepositForBurnMaxFee, getCctpV2TokenMessenger, + CCTPV2_FINALITY_THRESHOLD_STANDARD, } from "../../utils"; import { BaseL2BridgeAdapter } from "./BaseL2BridgeAdapter"; import { AugmentedTransaction } from "../../clients/TransactionClient"; -import { CCTP_MAX_SEND_AMOUNT, CCTPV2_FINALITY_THRESHOLD_STANDARD } from "../../common"; +import { CCTP_MAX_SEND_AMOUNT } from "../../common"; import { TransferTokenParams } from "../utils"; +/** + * This adapter uses CCTP V2 to bridge USDC between L2's. + */ export class UsdcCCTPBridge extends BaseL2BridgeAdapter { - private IS_CCTP_V2 = false; private readonly l1UsdcTokenAddress: EvmAddress; private readonly l2UsdcTokenAddress: EvmAddress; - constructor( - l2chainId: number, - hubChainId: number, - l2Signer: Signer, - l1Provider: Provider | Signer, - l1Token: EvmAddress - ) { - super(l2chainId, hubChainId, l2Signer, l1Provider, l1Token); + constructor(l2chainId: number, hubChainId: number, l2Signer: Signer, l1Signer: Signer, l1Token: EvmAddress) { + super(l2chainId, hubChainId, l2Signer, l1Signer, l1Token); const { address: l2TokenMessengerAddress, abi: l2TokenMessengerAbi } = getCctpV2TokenMessenger(l2chainId); this.l2Bridge = new Contract(l2TokenMessengerAddress, l2TokenMessengerAbi, l2Signer); const { address: l1TokenMessengerAddress, abi: l1Abi } = getCctpV2TokenMessenger(hubChainId); - this.l1Bridge = new Contract(l1TokenMessengerAddress, l1Abi, l1Provider); + this.l1Bridge = new Contract(l1TokenMessengerAddress, l1Abi, l1Signer); this.l1UsdcTokenAddress = EvmAddress.from(TOKEN_SYMBOLS_MAP.USDC.addresses[this.hubChainId]); this.l2UsdcTokenAddress = EvmAddress.from(TOKEN_SYMBOLS_MAP.USDC.addresses[this.l2chainId]); diff --git a/src/adapter/l2Bridges/index.ts b/src/adapter/l2Bridges/index.ts index 59e63affd1..ce85cf1257 100644 --- a/src/adapter/l2Bridges/index.ts +++ b/src/adapter/l2Bridges/index.ts @@ -6,3 +6,4 @@ export * from "./OpStackBridge"; export * from "./OpStackUSDCBridge"; export * from "./OpStackWethBridge"; export * from "./UsdcCCTPBridge"; +export * from "./SolanaUsdcCCTPBridge"; diff --git a/src/caching/RedisCache.ts b/src/caching/RedisCache.ts index b9089aa1e4..f10c6832e8 100644 --- a/src/caching/RedisCache.ts +++ b/src/caching/RedisCache.ts @@ -1,5 +1,17 @@ +import assert from "assert"; import { interfaces, constants } from "@across-protocol/sdk"; -import { RedisClient, setRedisKey } from "../utils"; +import { isDefined } from "../utils"; +import { createClient } from "redis4"; +import winston from "winston"; + +export type RedisClient = ReturnType; + +export interface RedisCacheInterface extends interfaces.CachingMechanismInterface, interfaces.PubSubMechanismInterface { + decr(key: string): Promise; + decrBy(key: string, amount: number): Promise; + incr(key: string): Promise; + incrBy(key: string, amount: number): Promise; +} /** * RedisCache is a caching mechanism that uses Redis as the backing store. It is used by the @@ -7,42 +19,107 @@ import { RedisClient, setRedisKey } from "../utils"; * is designed to use the `CachingMechanismInterface` interface so that it can be used as a * drop-in in the SDK without the SDK needing to reason about the implementation details. */ -export class RedisCache implements interfaces.CachingMechanismInterface { - /** - * The redisClient is the redis client that is used to communicate with the redis server. - * It is instantiated lazily when the `instantiate` method is called. - */ - private redisClient: RedisClient | undefined; +export class RedisCache implements RedisCacheInterface { + constructor( + private readonly client: RedisClient, + private readonly namespace?: string, + private readonly logger?: winston.Logger + ) { + this.logger?.debug({ + at: "RedisCache#constructor", + message: isDefined(namespace) ? `Created redis client with namespace ${namespace}` : "Created redis client.", + }); + } + + private getNamespacedKey(key: string): string { + return isDefined(this.namespace) ? `${this.namespace}:${key}` : key; + } + + get url(): string { + return this.client.options.url; + } + + async get(key: string): Promise { + return this.client.get(this.getNamespacedKey(key)) as T; + } + + async ttl(key: string): Promise { + return this.client.ttl(this.getNamespacedKey(key)); + } - /** - * The constructor takes in the redisClient. - * @param redisClient The redis client to use for caching. - */ - constructor(redisClient: RedisClient) { - this.redisClient = redisClient; + async set(key: string, val: T, expirySeconds = constants.DEFAULT_CACHING_TTL): Promise { + // Apply namespace to key. + key = this.getNamespacedKey(key); + if (expirySeconds === Number.POSITIVE_INFINITY) { + // No TTL + return await this.client.set(key, String(val)); + } else if (expirySeconds > 0) { + // EX: Expire key after expirySeconds. + return await this.client.set(key, String(val), { EX: expirySeconds }); + } else { + if (expirySeconds <= 0) { + this.logger?.warn({ + at: "RedisCache#set", + message: `Tried to set key ${key} with expirySeconds = ${expirySeconds}. This shouldn't be allowed.`, + }); + } + return await this.client.set(key, String(val)); + } } - public async get(key: string): Promise { - // Get the value from redis. - return this.redisClient.get(key) as T; + decr(key: string): Promise { + return this.decrBy(key, 1); } - public async ttl(key: string): Promise { - return this.redisClient.ttl(key); + decrBy(key: string, amount: number): Promise { + assert(amount >= 0); + return this.client.decrBy(key, amount); } - public async set(key: string, value: T, ttl: number = constants.DEFAULT_CACHING_TTL): Promise { - // Call the setRedisKey function to set the value in redis. - await setRedisKey(key, String(value), this.redisClient, ttl); - // Return key to indicate that the value was set successfully. - return key; + incr(key: string): Promise { + return this.incrBy(key, 1); } - public async pub(channel: string, message: string): Promise { - return await this.redisClient.pub(channel, message); + incrBy(key: string, amount: number): Promise { + assert(amount >= 0); + return this.client.incrBy(key, amount); } - public async sub(channel: string, listener: (message: string, channel: string) => void): Promise { - return await this.redisClient.sub(channel, listener); + pub(channel: string, message: string): Promise { + return this.client.publish(channel, message); + } + + async sub(channel: string, listener: (message: string, channel: string) => void): Promise { + await this.client.subscribe(channel, listener); + } + + async duplicate(): Promise { + const newClient = this.client.duplicate(); + await newClient.connect(); + return new RedisCache(newClient, this.namespace, this.logger); + } + + async disconnect(): Promise { + await disconnectRedisClient(this.client, this.logger); + } +} + +/** + * An internal function to disconnect from a redis client. This function is designed to NOT throw an error if the + * disconnect fails. + * @param client The redis client to disconnect from. + * @param logger An optional logger to use to log the disconnect. + */ +export async function disconnectRedisClient(client: RedisClient, logger?: winston.Logger): Promise { + let disconnectSuccessful = true; + try { + await client.disconnect(); + } catch (_e) { + disconnectSuccessful = false; } + const url = client.options.url ?? "unknown"; + logger?.debug({ + at: "RedisCache#disconnect", + message: `Disconnected from redis server at ${url} successfully? ${disconnectSuccessful}`, + }); } diff --git a/src/caching/RedisPubSub.ts b/src/caching/RedisPubSub.ts deleted file mode 100644 index 3a874f2262..0000000000 --- a/src/caching/RedisPubSub.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { interfaces } from "@across-protocol/sdk"; -import { RedisClient } from "../utils"; - -export class RedisPubSub implements interfaces.PubSubMechanismInterface { - constructor(private readonly redisClient: RedisClient) { - this.redisClient = redisClient; - } - - async sub(channel: string, listener: (message: string, channel: string) => void): Promise { - await this.redisClient.sub(channel, listener); - } - - async pub(channel: string, message: string): Promise { - return this.redisClient.pub(channel, message); - } -} diff --git a/src/cctp-finalizer/app.ts b/src/cctp-finalizer/app.ts new file mode 100644 index 0000000000..0d21602a6d --- /dev/null +++ b/src/cctp-finalizer/app.ts @@ -0,0 +1,99 @@ +// eslint-disable-next-line node/no-extraneous-import -- express is a transitive dependency from @uma/serverless-orchestration +import express, { Request, Response } from "express"; +import { CCTPService } from "./services/cctpService"; +import { winston } from "../utils"; +import { PubSubMessage } from "./types"; + +const app = express(); + +// Create logger instance +const logger = winston.createLogger({ + level: "info", + format: winston.format.combine(winston.format.timestamp(), winston.format.json()), + transports: [new winston.transports.Console()], +}); + +let cctpService: CCTPService; + +try { + cctpService = new CCTPService(logger); + logger.info({ at: "CCTPFinalizer#init", message: "Application initialized successfully" }); +} catch (error) { + logger.error({ at: "CCTPFinalizer#init", message: "Failed to initialize application", error }); + // eslint-disable-next-line no-process-exit -- Initialization failure is fatal, app cannot function + process.exit(1); +} + +app.use(express.json()); + +app.get("/health", (req: Request, res: Response) => { + res.status(200).json({ + status: "healthy", + timestamp: new Date().toISOString(), + environment: process.env.NODE_ENV || "development", + }); +}); + +app.post("/", async (req: Request, res: Response) => { + try { + const pubSubMessage = req.body.message; + + if (!pubSubMessage) { + logger.error({ at: "CCTPFinalizer#processMessage", message: "Invalid Pub/Sub message format" }); + res.status(400).send("Bad Request: Invalid Pub/Sub message format"); + return; + } + + const data = Buffer.from(pubSubMessage.data, "base64").toString().trim(); + logger.info({ + at: "CCTPFinalizer#processMessage", + message: "Received Pub/Sub message", + data, + attributes: pubSubMessage.attributes, + }); + + await processMessage(data, pubSubMessage.attributes); + + res.status(204).send(); + } catch (error) { + logger.error({ at: "CCTPFinalizer#processMessage", message: "Error processing message", error }); + res.status(500).send("Internal Server Error"); + } +}); + +async function processMessage(data: string, attributes?: { [key: string]: string }): Promise { + try { + const messageData: PubSubMessage = JSON.parse(data); + + logger.info({ + at: "CCTPFinalizer#processMessage", + message: "Processing CCTP burn transaction", + burnTransactionHash: messageData.burnTransactionHash, + sourceChainId: messageData.sourceChainId, + attributes: attributes || {}, + }); + + const response = await cctpService.processBurnTransaction(messageData); + + if (response.success) { + logger.info({ + at: "CCTPFinalizer#processMessage", + message: "Burn transaction processed successfully", + mintTxHash: response.mintTxHash, + burnTransactionHash: messageData.burnTransactionHash, + }); + } else { + logger.error({ + at: "CCTPFinalizer#processMessage", + message: "Burn transaction processing failed", + error: response.error, + burnTransactionHash: messageData.burnTransactionHash, + }); + } + } catch (parseError) { + logger.error({ at: "CCTPFinalizer#processMessage", message: "Error parsing message data", error: parseError }); + throw parseError; + } +} + +export default app; diff --git a/src/cctp-finalizer/index.ts b/src/cctp-finalizer/index.ts new file mode 100644 index 0000000000..3503ba21c7 --- /dev/null +++ b/src/cctp-finalizer/index.ts @@ -0,0 +1,28 @@ +import app from "./app"; +import { winston } from "../utils"; + +const PORT = parseInt(process.env.PORT || "8080", 10); + +// Create logger instance +const logger = winston.createLogger({ + level: "info", + format: winston.format.combine(winston.format.timestamp(), winston.format.json()), + transports: [new winston.transports.Console()], +}); + +process.on("uncaughtException", (error) => { + logger.error({ at: "CCTPFinalizer#uncaughtException", message: "Uncaught Exception", error }); + // eslint-disable-next-line no-process-exit -- Uncaught exceptions are fatal, must exit to prevent undefined state + process.exit(1); +}); + +process.on("unhandledRejection", (reason, promise) => { + logger.error({ at: "CCTPFinalizer#unhandledRejection", message: "Unhandled Rejection", reason, promise }); + // eslint-disable-next-line no-process-exit -- Unhandled rejections are fatal, must exit to prevent undefined state + process.exit(1); +}); + +app.listen(PORT, "0.0.0.0", () => { + logger.info({ at: "CCTPFinalizer#startup", message: `Server is running on port ${PORT}` }); + logger.info({ at: "CCTPFinalizer#startup", message: `Health check available at http://localhost:${PORT}/health` }); +}); diff --git a/src/cctp-finalizer/services/cctpService.ts b/src/cctp-finalizer/services/cctpService.ts new file mode 100644 index 0000000000..049b77f649 --- /dev/null +++ b/src/cctp-finalizer/services/cctpService.ts @@ -0,0 +1,269 @@ +import { ethers } from "ethers"; +import { utils } from "@across-protocol/sdk"; +import { ProcessBurnTransactionResponse, PubSubMessage } from "../types"; +import { winston, getCctpDestinationChainFromDomain, PUBLIC_NETWORKS, chainIsProd, chainIsSvm } from "../../utils"; +import { checkIfAlreadyProcessedEvm, processMintEvm, getEvmProvider } from "../utils/evmUtils"; +import { checkIfAlreadyProcessedSvm, processMintSvm, getSvmProvider } from "../utils/svmUtils"; + +export class CCTPService { + private privateKey: string; + private svmPrivateKey?: Uint8Array; + private logger: winston.Logger; + + constructor(logger?: winston.Logger) { + this.privateKey = process.env.PRIVATE_KEY!; + + this.logger = + logger || + winston.createLogger({ + level: "info", + format: winston.format.combine(winston.format.timestamp(), winston.format.json()), + transports: [new winston.transports.Console()], + }); + + if (process.env.SVM_PRIVATE_KEY) { + try { + this.svmPrivateKey = Uint8Array.from(JSON.parse(process.env.SVM_PRIVATE_KEY)); + } catch (error) { + this.logger.warn({ at: "CCTPService#constructor", message: "Failed to parse SVM private key", error }); + } + } + } + + async processBurnTransaction(message: PubSubMessage): Promise { + try { + const { + burnTransactionHash, + sourceChainId, + message: cctpMessage, + attestation: cctpAttestation, + destinationChainId: providedDestinationChainId, + signature, + } = message; + + this.logger.info({ + at: "CCTPService#processBurnTransaction", + message: "Processing burn transaction", + burnTransactionHash, + sourceChainId, + hasProvidedMessage: !!cctpMessage, + hasProvidedAttestation: !!cctpAttestation, + hasProvidedDestinationChainId: !!providedDestinationChainId, + }); + + let attestation: { message: string; attestation: string; status?: string }; + let destinationChainId: number; + + // If message and attestation are provided, use them directly + if (cctpMessage && cctpAttestation) { + this.logger.info({ + at: "CCTPService#processBurnTransaction", + message: "Using provided message and attestation, skipping attestation fetch", + }); + + attestation = { + message: cctpMessage, + attestation: cctpAttestation, + }; + + if (providedDestinationChainId) { + this.logger.info({ + at: "CCTPService#processBurnTransaction", + message: "Using provided destination chain ID", + destinationChainId: providedDestinationChainId, + }); + destinationChainId = providedDestinationChainId; + } else { + destinationChainId = this.getDestinationChainIdFromMessage(cctpMessage, sourceChainId); + } + } else { + this.logger.info({ + at: "CCTPService#processBurnTransaction", + message: "Fetching attestation from API", + }); + + // Fetch attestation + const attestationResponse = await utils.fetchCctpV2Attestations([burnTransactionHash], sourceChainId); + + const attestations = attestationResponse[burnTransactionHash]; + + if (!attestations?.messages?.length) { + return { + success: false, + error: "No attestation found for the burn transaction", + }; + } + + attestation = attestations.messages[0]; + + if (!this.isAttestationReady(attestation.status!)) { + return { + success: false, + error: `Attestation not ready. Status: ${attestation.status}`, + }; + } + + if (providedDestinationChainId) { + destinationChainId = providedDestinationChainId; + } else { + destinationChainId = this.getDestinationChainIdFromMessage(attestation.message, sourceChainId); + } + } + + // Check if already processed + const isAlreadyProcessed = await this.checkIfAlreadyProcessed(destinationChainId, attestation.message); + + if (isAlreadyProcessed) { + return { + success: true, + mintTxHash: "ALREADY_PROCESSED", + error: "Message has already been processed on-chain", + }; + } + + // Process the mint + return await this.processMint(destinationChainId, attestation, signature); + } catch (error) { + this.logger.error({ + at: "CCTPService#processBurnTransaction", + message: "Error processing burn transaction", + error, + }); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error occurred", + }; + } + } + + private getDestinationChainIdFromMessage(message: string, sourceChainId: number): number { + // Bytes 0-3: version, Bytes 4-7: source domain, Bytes 8-11: destination domain (uint32) + const messageBytes = ethers.utils.arrayify(message); + const destinationDomainBytes = messageBytes.slice(8, 12); + const destinationDomainId = ethers.BigNumber.from(destinationDomainBytes).toNumber(); + + this.logger.info({ + at: "CCTPService#getDestinationChainIdFromMessage", + message: "Decoded destination domain from CCTP message", + destinationDomainId, + }); + + // Map domain to chain ID + const destinationChainId = getCctpDestinationChainFromDomain(destinationDomainId, chainIsProd(sourceChainId)); + + return destinationChainId; + } + + private async checkIfAlreadyProcessed(chainId: number, message: string): Promise { + try { + if (chainIsSvm(chainId)) { + if (!this.svmPrivateKey) { + this.logger.warn({ + at: "CCTPService#checkIfAlreadyProcessed", + message: "SVM private key not available, cannot check if message processed on Solana", + }); + return false; + } + + const rpcUrl = this.getRpcUrlForChain(chainId); + const svmProvider = getSvmProvider(rpcUrl); + return await checkIfAlreadyProcessedSvm(message, this.svmPrivateKey, svmProvider, this.logger); + } else { + const rpcUrl = this.getRpcUrlForChain(chainId); + const provider = getEvmProvider(rpcUrl); + return await checkIfAlreadyProcessedEvm(chainId, message, provider); + } + } catch (error) { + this.logger.warn({ + at: "CCTPService#checkIfAlreadyProcessed", + message: "Could not check used nonces, proceeding anyway", + error, + }); + return false; + } + } + + private async processMint( + chainId: number, + attestation: any, + signature?: string + ): Promise { + const chainName = PUBLIC_NETWORKS[chainId]?.name || `Chain ${chainId}`; + this.logger.info({ + at: "CCTPService#processMint", + message: `Calling receiveMessage on ${chainName} (${chainId})`, + }); + + try { + if (chainIsSvm(chainId)) { + if (!this.svmPrivateKey) { + return { + success: false, + error: "SVM private key not configured for Solana CCTP finalization", + }; + } + + const rpcUrl = this.getRpcUrlForChain(chainId); + const svmProvider = getSvmProvider(rpcUrl); + const result = await processMintSvm(attestation, this.svmPrivateKey, svmProvider, this.logger); + return { + success: true, + mintTxHash: result.txHash, + }; + } else { + const rpcUrl = this.getRpcUrlForChain(chainId); + const provider = getEvmProvider(rpcUrl); + const result = await processMintEvm(chainId, attestation, provider, this.privateKey, this.logger, signature); + return { + success: true, + mintTxHash: result.txHash, + }; + } + } catch (error) { + this.logger.error({ + at: "CCTPService#processMint", + message: "Mint transaction failed", + error, + }); + return { + success: false, + error: error instanceof Error ? error.message : "Mint transaction failed", + }; + } + } + + private isAttestationReady(status: string): boolean { + return ["complete", "done", "succeeded"].includes(status); + } + + private getRpcUrlForChain(chainId: number): string { + const rpcUrlMap: { [chainId: number]: string } = { + // Production networks + 1: process.env.ETHEREUM_RPC_URL!, + 10: process.env.OPTIMISM_RPC_URL!, + 137: process.env.POLYGON_RPC_URL!, + 42161: process.env.ARBITRUM_RPC_URL!, + 8453: process.env.BASE_RPC_URL!, + 130: process.env.ARBITRUM_NOVA_RPC_URL!, + 59144: process.env.LINEA_RPC_URL!, + 480: process.env.ZKSYNC_RPC_URL!, + 999: process.env.HYPEREVM_RPC_URL!, + 34268394551451: process.env.SOLANA_RPC_URL!, + // Test networks + 11155111: process.env.SEPOLIA_RPC_URL!, + 11155420: process.env.OPTIMISM_SEPOLIA_RPC_URL!, + 421614: process.env.ARBITRUM_SEPOLIA_RPC_URL!, + 84532: process.env.BASE_SEPOLIA_RPC_URL!, + 80002: process.env.POLYGON_AMOY_RPC_URL!, + 1301: process.env.ARBITRUM_NOVA_SEPOLIA_RPC_URL!, + 998: process.env.HYPEREVM_TESTNET_RPC_URL!, + 133268194659241: process.env.SOLANA_DEVNET_RPC_URL!, + }; + + const rpcUrl = rpcUrlMap[chainId]; + if (!rpcUrl) { + throw new Error(`No RPC URL configured for chain ID ${chainId}`); + } + return rpcUrl; + } +} diff --git a/src/cctp-finalizer/types/index.ts b/src/cctp-finalizer/types/index.ts new file mode 100644 index 0000000000..d1e683d414 --- /dev/null +++ b/src/cctp-finalizer/types/index.ts @@ -0,0 +1,23 @@ +export interface ProcessBurnTransactionResponse { + success: boolean; + mintTxHash?: string; + error?: string; +} + +export interface PubSubMessage { + burnTransactionHash: string; + sourceChainId: number; + message?: string; + attestation?: string; + destinationChainId?: number; + signature?: string; +} + +export interface ChainConfig { + chainId: number; + name: string; + rpcUrl: string; + messageTransmitterAddress: string; + tokenMessengerAddress: string; + cctpDomain: number; +} diff --git a/src/cctp-finalizer/utils/evmUtils.ts b/src/cctp-finalizer/utils/evmUtils.ts new file mode 100644 index 0000000000..c4279668a4 --- /dev/null +++ b/src/cctp-finalizer/utils/evmUtils.ts @@ -0,0 +1,89 @@ +import { ethers } from "ethers"; +import { utils } from "@across-protocol/sdk"; +import { winston, runTransaction, getCctpV2MessageTransmitter, CHAIN_IDs } from "../../utils"; +import { CONTRACT_ADDRESSES } from "../../common/ContractAddresses"; + +/** + * Gets EVM provider from RPC URL + */ +export function getEvmProvider(rpcUrl: string): ethers.providers.JsonRpcProvider { + return new ethers.providers.JsonRpcProvider(rpcUrl); +} + +/** + * Checks if a CCTP message has already been processed on EVM chain (CCTP V2) + */ +export async function checkIfAlreadyProcessedEvm( + chainId: number, + message: string, + provider: ethers.providers.JsonRpcProvider +): Promise { + const { address, abi } = getCctpV2MessageTransmitter(chainId); + const contract = new ethers.Contract(address!, abi, provider); + + const messageBytes = ethers.utils.arrayify(message); + const nonceBytes = messageBytes.slice(12, 44); + const nonce = ethers.utils.hexlify(nonceBytes); + + return await utils.hasCCTPMessageBeenProcessedEvm(nonce, contract); +} + +/** + * Processes a CCTP mint transaction on EVM chain (CCTP V2) + */ +export async function processMintEvm( + chainId: number, + attestation: { message: string; attestation: string }, + provider: ethers.providers.JsonRpcProvider, + privateKey: string, + logger: winston.Logger, + signature?: string +): Promise<{ txHash: string }> { + const signer = new ethers.Wallet(privateKey, provider); + + const isHyperEVM = chainId === CHAIN_IDs.HYPEREVM || chainId === CHAIN_IDs.HYPEREVM_TESTNET; + + let contract: ethers.Contract; + let receiveMessageArgs: unknown[]; + + const isHyperCoreDestination = isHyperEVM && signature; + + if (isHyperCoreDestination) { + // Use SponsoredCCTPDstPeriphery for HyperCore destinations (both sponsored and non-sponsored flows) + const { address, abi } = CONTRACT_ADDRESSES[chainId].sponsoredCCTPDstPeriphery; + if (!address) { + throw new Error(`SponsoredCCTPDstPeriphery address not configured for chain ${chainId}`); + } + contract = new ethers.Contract(address, abi, signer); + receiveMessageArgs = [attestation.message, attestation.attestation, signature]; + logger.info({ + at: "evmUtils#processMintEvm", + message: "Using SponsoredCCTPDstPeriphery contract for HyperCore destination", + chainId, + contractAddress: address, + }); + } else { + // Use standard MessageTransmitter for non-HyperCore destinations + const { address, abi } = getCctpV2MessageTransmitter(chainId); + contract = new ethers.Contract(address!, abi, signer); + receiveMessageArgs = [attestation.message, attestation.attestation]; + logger.info({ + at: "evmUtils#processMintEvm", + message: "Using standard MessageTransmitter contract", + chainId, + contractAddress: address, + }); + } + + const mintTx = await runTransaction(logger, contract, "receiveMessage", receiveMessageArgs); + + const mintTxReceipt = await mintTx.wait(); + + logger.info({ + at: "evmUtils#processMintEvm", + message: "Mint transaction confirmed", + txHash: mintTxReceipt.transactionHash, + }); + + return { txHash: mintTxReceipt.transactionHash }; +} diff --git a/src/cctp-finalizer/utils/index.ts b/src/cctp-finalizer/utils/index.ts new file mode 100644 index 0000000000..681d35e15a --- /dev/null +++ b/src/cctp-finalizer/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./evmUtils"; +export * from "./svmUtils"; diff --git a/src/cctp-finalizer/utils/svmUtils.ts b/src/cctp-finalizer/utils/svmUtils.ts new file mode 100644 index 0000000000..d271bab269 --- /dev/null +++ b/src/cctp-finalizer/utils/svmUtils.ts @@ -0,0 +1,139 @@ +import { ethers } from "ethers"; +import { arch } from "@across-protocol/sdk"; +import { createKeyPairSignerFromBytes, createDefaultRpcTransport, createSolanaRpcFromTransport } from "@solana/kit"; +import { winston, SvmAddress, sendAndConfirmSolanaTransaction, SVMProvider } from "../../utils"; + +/** + * Gets SVM provider from RPC URL + */ +export function getSvmProvider(rpcUrl: string): SVMProvider { + const transport = createDefaultRpcTransport({ url: rpcUrl }); + return createSolanaRpcFromTransport(transport); +} + +/** + * PLACEHOLDER: Checks if a CCTP V2 message has been processed on Solana + * TODO: Implement this once SDK supports V2 + */ +async function hasCCTPV2MessageBeenProcessed(): Promise { + throw new Error( + "CCTP V2 for Solana is not yet implemented. Need to integrate with MessageTransmitterV2 program (CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC)" + ); +} + +/** + * PLACEHOLDER: Gets CCTP V2 receiveMessage transaction for Solana + * TODO: Implement this once SDK supports V2 + */ +async function getCCTPV2ReceiveMessageTx(): Promise { + throw new Error( + "CCTP V2 for Solana is not yet implemented. Need to integrate with MessageTransmitterV2 program (CCTPV2Sm4AdWt5296sk4P66VBZ7bEhcARwFaaS9YPbeC)" + ); +} + +/** + * Checks if a CCTP message has already been processed on Solana + * Detects message version and uses appropriate V1 or V2 function + */ +export async function checkIfAlreadyProcessedSvm( + message: string, + svmPrivateKey: Uint8Array, + svmProvider: SVMProvider, + logger: winston.Logger +): Promise { + const svmSigner = await createKeyPairSignerFromBytes(svmPrivateKey); + const latestBlockhash = await svmProvider.getLatestBlockhash().send(); + + const messageBytes = ethers.utils.arrayify(message); + const messageBytesArray = ethers.utils.arrayify(messageBytes); + + const version = Number(ethers.utils.hexlify(messageBytesArray.slice(0, 4))); + const sourceDomain = Number(ethers.utils.hexlify(messageBytesArray.slice(4, 8))); + const nonce = Number(ethers.utils.hexlify(messageBytesArray.slice(12, 20))); + + logger.info({ + at: "svmUtils#checkIfAlreadyProcessedSvm", + message: "Checking CCTP message version", + version, + sourceDomain, + nonce, + }); + + // Version 0 = V1, Version 1 = V2 + if (version === 0) { + return await arch.svm.hasCCTPV1MessageBeenProcessed( + svmProvider, + svmSigner, + nonce, + sourceDomain, + latestBlockhash.value + ); + } else { + // V2 (version 1) + return await hasCCTPV2MessageBeenProcessed(); + } +} + +/** + * Processes a CCTP mint transaction on Solana + * Detects message version and uses appropriate V1 or V2 function + */ +export async function processMintSvm( + attestation: { message: string; attestation: string }, + svmPrivateKey: Uint8Array, + svmProvider: SVMProvider, + logger: winston.Logger +): Promise<{ txHash: string }> { + const svmSigner = await createKeyPairSignerFromBytes(svmPrivateKey); + + const messageBytes = ethers.utils.arrayify(attestation.message); + const messageBytesArray = ethers.utils.arrayify(messageBytes); + + const version = Number(ethers.utils.hexlify(messageBytesArray.slice(0, 4))); + const sourceDomain = Number(ethers.utils.hexlify(messageBytesArray.slice(4, 8))); + const nonce = Number(ethers.utils.hexlify(messageBytesArray.slice(12, 20))); + const recipient = ethers.utils.hexlify(messageBytesArray.slice(52, 84)); + + logger.info({ + at: "svmUtils#processMintSvm", + message: "Processing CCTP message", + version, + sourceDomain, + nonce, + }); + + const attestedMessage: arch.svm.AttestedCCTPMessage = { + nonce, + sourceDomain, + messageBytes: attestation.message, + attestation: attestation.attestation, + type: "transfer", + }; + + let receiveMessageTx = undefined; + + // Version 0 = V1, Version 1 = V2 + if (version === 0) { + receiveMessageTx = await arch.svm.getCCTPV1ReceiveMessageTx( + svmProvider, + svmSigner, + attestedMessage, + 1, // hubChainId + SvmAddress.from(recipient) + ); + } else { + // V2 (version 1) + receiveMessageTx = await getCCTPV2ReceiveMessageTx(); + } + + const txSignature = await sendAndConfirmSolanaTransaction(receiveMessageTx, svmProvider); + + logger.info({ + at: "svmUtils#processMintSvm", + message: "Mint transaction confirmed on Solana", + version, + txHash: txSignature, + }); + + return { txHash: txSignature }; +} diff --git a/src/clients/BundleDataApproxClient.ts b/src/clients/BundleDataApproxClient.ts index 8a8a10f822..50ec7e072b 100644 --- a/src/clients/BundleDataApproxClient.ts +++ b/src/clients/BundleDataApproxClient.ts @@ -9,6 +9,11 @@ import { assert, BigNumber, isDefined, winston } from "../utils"; import { Address, bnZero, getL1TokenAddress } from "../utils/SDKUtils"; import { HubPoolClient } from "./HubPoolClient"; +export type BundleDataState = { + upcomingDeposits: { [l1Token: string]: { [chainId: number]: BigNumber } }; + upcomingRefunds: { [l1Token: string]: { [chainId: number]: { [relayer: string]: BigNumber } } }; +}; + export class BundleDataApproxClient { private upcomingRefunds: { [l1Token: string]: { [chainId: number]: { [relayer: string]: BigNumber } } } = undefined; private upcomingDeposits: { [l1Token: string]: { [chainId: number]: BigNumber } } = undefined; @@ -24,6 +29,26 @@ export class BundleDataApproxClient { this.spokePoolManager = new SpokePoolManager(logger, spokePoolClients); } + /** + * Export current BundleData(Approx)Client state. + * @returns BundleData(Approx)Client state. This can be subsequently ingested by BundleDataApproxClient.import(). + */ + export(): BundleDataState { + const { upcomingDeposits, upcomingRefunds } = this; + return { upcomingDeposits, upcomingRefunds }; + } + + /** + * Import BundleData(Approx)Client state. + * @params state BundleData(Approx)Client state, previously exported. + * @returns void + */ + import(state: BundleDataState): void { + const { upcomingDeposits, upcomingRefunds } = state; + this.upcomingDeposits = upcomingDeposits; + this.upcomingRefunds = upcomingRefunds; + } + // Return sum of refunds for all fills sent after the fromBlocks. // Makes a simple assumption that all fills that were sent after the last executed bundle // are valid and will be refunded on the repayment chain selected. Assume additionally that the repayment chain diff --git a/src/clients/EventListener.ts b/src/clients/EventListener.ts new file mode 100644 index 0000000000..a5c85a0a13 --- /dev/null +++ b/src/clients/EventListener.ts @@ -0,0 +1,117 @@ +import assert from "assert"; +import { EventEmitter } from "node:events"; +import * as chains from "viem/chains"; +import { AbiEvent, BaseError, Block, createPublicClient, http, Log as viemLog, parseAbiItem, webSocket } from "viem"; +import { Log } from "../interfaces"; +import { EventManager, getNetworkName, getNodeUrlList, getOriginFromURL, getProviderHeaders, winston } from "../utils"; + +function resolveProviders(chainId: number, quorum = 1) { + const protocol = process.env[`RPC_PROVIDERS_TRANSPORT_${chainId}`] ?? "wss"; + assert(protocol === "wss" || protocol === "https"); + + const urls = Object.values(getNodeUrlList(chainId, quorum, protocol)); + const nProviders = urls.length; + const chain = getNetworkName(chainId); + assert(nProviders >= quorum, `Insufficient providers for ${chain} (minimum ${quorum} required by quorum)`); + + const viemChain = Object.values(chains).find(({ id }) => id === chainId); + const providers = Object.entries(urls).map(([provider, url]) => { + const headers = getProviderHeaders(provider, chainId); + const transport = protocol === "wss" ? webSocket(url) : http(url, { fetchOptions: { headers } }); + + return createPublicClient({ + chain: viemChain, + transport, + name: getOriginFromURL(url), + }); + }); + + return providers; +} + +export class EventListener extends EventEmitter { + public readonly chain: string; + private readonly eventMgr: EventManager; + private readonly providers: ReturnType; + // private readonly abortController: AbortController; + + constructor(public readonly chainId: number, private readonly logger: winston.Logger, public readonly quorum = 1) { + super(); + this.chain = getNetworkName(chainId); + // this.abortController = new AbortController(); + this.eventMgr = new EventManager(logger, chainId, quorum); + this.providers = resolveProviders(chainId, quorum); + } + + onBlock(handler: (blockNumber: number, timestamp: number) => void) { + const at = "EventListener::onBlock"; + const { chain, logger } = this; + + const newBlock = (block: Block, provider: string) => { + // Transient error that sometimes occurs. Catch it here and try to flush out the provider. + if (!block) { + logger.debug({ at, message: `Received empty ${chain} block from ${provider}.` }); + return; + } + const [blockNumber, currentTime] = [parseInt(block.number.toString()), parseInt(block.timestamp.toString())]; + this.emit("block", blockNumber, currentTime); + }; + + const blockError = (error: Error, provider: string) => { + const message = `Caught ${chain} provider error.`; + const { message: errorMessage, details, shortMessage, metaMessages } = error as BaseError; + logger.warn({ at, message, errorMessage, shortMessage, provider, details, metaMessages }); + }; + + this.on("block", handler); + const [provider] = this.providers; + provider.watchBlocks({ + emitOnBegin: true, + onBlock: (block: Block) => newBlock(block, provider.name), + onError: (error: Error) => blockError(error, provider.name), + }); + } + + onEvent(address: string, event: string, handler: (log: Log) => void): void { + this.onEvents(address, [event], handler); + } + + onEvents(address: string, events: string[], handler: (log: Log) => void): void { + const { eventMgr, providers } = this; + events.forEach((eventDescriptor) => { + // Viem is unhappy with "tuple" in the event descriptor; sub it out. + // Viem also complains about the return type of parseAbiItem() (@todo: why), so coerce it. + const event = parseAbiItem(eventDescriptor.replace("tuple", "")) as AbiEvent; + this.on(event.name, handler); + + providers.forEach((provider) => { + const onLogs = (logs: viemLog[]) => { + logs.forEach((log) => { + const event = { + ...log, + args: log["args"], + blockNumber: Number(log.blockNumber), + event: log["eventName"], + topics: [], // Not supplied by viem, but not actually used by the relayer. + }; + + if (log.removed) { + eventMgr.remove(event, provider.name); + this.emit(event.event, event); + } else { + // @todo: eventMgr.add should determine whether to emit the event. + eventMgr.add(event, provider.name); + this.emit(event.event, event); + } + }); + }; + + provider.watchEvent({ + address: address as `0x${string}`, + event, + onLogs, + }); + }); + }); + } +} diff --git a/src/clients/InventoryClient.ts b/src/clients/InventoryClient.ts index b30a415980..1af2592cd4 100644 --- a/src/clients/InventoryClient.ts +++ b/src/clients/InventoryClient.ts @@ -38,7 +38,8 @@ import { forEachAsync, max, } from "../utils"; -import { BundleDataApproxClient, HubPoolClient, TokenClient } from "."; +import { BundleDataApproxClient, BundleDataState } from "./BundleDataApproxClient"; +import { HubPoolClient, TokenClient } from "."; import { Deposit, ProposedRootBundle } from "../interfaces"; import { InventoryConfig, isAliasConfig, TokenBalanceConfig } from "../interfaces/InventoryManagement"; import lodash from "lodash"; @@ -62,6 +63,12 @@ export type Rebalance = { const DEFAULT_TOKEN_OVERAGE = toBNWei("1.5"); +export type InventoryClientState = { + bundleDataState: BundleDataState; + pendingL2Withdrawals: { [l1Token: string]: { [chainId: number]: BigNumber } }; + inventoryConfig: InventoryConfig; +}; + export class InventoryClient { private logDisabledManagement = false; private readonly scalar: BigNumber; @@ -69,12 +76,13 @@ export class InventoryClient { private excessRunningBalancePromises: { [l1Token: string]: Promise<{ [chainId: number]: BigNumber }> } = {}; private profiler: InstanceType; private bundleDataApproxClient: BundleDataApproxClient; + private inventoryConfig: InventoryConfig; private pendingL2Withdrawals: { [l1Token: string]: { [chainId: number]: BigNumber } } = {}; constructor( readonly relayer: EvmAddress, readonly logger: winston.Logger, - readonly inventoryConfig: InventoryConfig, + inventoryConfig: InventoryConfig, readonly tokenClient: TokenClient, readonly chainIdList: number[], readonly hubPoolClient: HubPoolClient, @@ -83,6 +91,7 @@ export class InventoryClient { readonly simMode = false, readonly prioritizeLpUtilization = true ) { + this.inventoryConfig = inventoryConfig; this.scalar = sdkUtils.fixedPointAdjustment; this.formatWei = createFormatFunction(2, 4, false, 18); this.profiler = new Profiler({ @@ -104,6 +113,44 @@ export class InventoryClient { ); } + /** + * Export current InventoryClient state. + * @returns InventoryClient state. This can be subsequently ingested by InventoryClient.import(). + */ + export(): InventoryClientState { + const { upcomingDeposits, upcomingRefunds } = this.bundleDataApproxClient.export(); + const state = { + inventoryConfig: this.inventoryConfig, + bundleDataState: { + upcomingDeposits, + upcomingRefunds, + }, + pendingL2Withdrawals: this.pendingL2Withdrawals, + }; + + this.logger.debug({ at: "InventoryClient::export", message: "Exported inventory client state." }); + return state; + } + + /** + * Import InventoryClient state. + * @returns void + */ + import(state: InventoryClientState) { + const { bundleDataState, pendingL2Withdrawals } = state; + this.inventoryConfig = state.inventoryConfig; + this.bundleDataApproxClient.import(bundleDataState); + this.pendingL2Withdrawals = pendingL2Withdrawals; + this.logger.debug({ at: "InventoryClient::import", message: "Imported inventory client state." }); + } + + /** + * Get the cache key for the InventoryClient state. + * @returns Cache key for the InventoryClient state + */ + getInventoryCacheKey(inventoryTopic: string): string { + return `${inventoryTopic}-${this.relayer}`; + } /** * Resolve the token balance configuration for `l1Token` on `chainId`. If `l1Token` maps to multiple tokens on * `chainId` then `l2Token` must be supplied. @@ -125,6 +172,16 @@ export class InventoryClient { } } + isSwapSupported(inputToken: Address, outputToken: Address, inputChainId: number, outputChainId: number): boolean { + return (this.inventoryConfig?.allowedSwapRoutes ?? []).some( + (swapRoute) => + swapRoute.fromChain === inputChainId && + swapRoute.fromToken === inputToken.toNative() && + swapRoute.toChain === outputChainId && + swapRoute.toToken === outputToken.toNative() + ); + } + /* * Get the total balance for an L1 token across all chains, considering any outstanding cross chain transfers as a * virtual balance on that chain. @@ -426,6 +483,11 @@ export class InventoryClient { return true; } + // Return true if the input and output tokens are defined as equivalent according to a user-defined swap config. + if (this.isSwapSupported(inputToken, outputToken, originChainId, destinationChainId)) { + return true; + } + // Return true if the input and output token are defined as equivalent according to a hardcoded mapping // of equivalent tokens. This should allow relayer to define which tokens it should be able to fill despite them // not being linked via a PoolRebalanceRoute. @@ -1405,7 +1467,7 @@ export class InventoryClient { }`, { l1Token: l1Token.toEvmAddress(), - l2Token: l2Token.toEvmAddress(), + l2Token: l2Token.toNative(), cumulativeBalance: formatter(cumulativeBalance), currentAllocPct: formatUnits(currentAllocPct, 18), excessWithdrawThresholdPct: formatUnits(excessWithdrawThresholdPct, 18), @@ -1496,7 +1558,7 @@ export class InventoryClient { withdrawals.map((withdrawal) => { return { ...withdrawal, - l2Token: withdrawal.l2Token.toEvmAddress(), + l2Token: withdrawal.l2Token.toNative(), }; }), ]; diff --git a/src/clients/ProfitClient.ts b/src/clients/ProfitClient.ts index c615314136..5ec176ed4e 100644 --- a/src/clients/ProfitClient.ts +++ b/src/clients/ProfitClient.ts @@ -178,7 +178,8 @@ export class ProfitClient { } const { decimals, addresses } = token; - const address = addresses[1]; // Mainnet tokens are always used for price lookups. + + const address = addresses[this.hubPoolClient.chainId] ?? addresses[chainId]; // Mainnet tokens have priority for price lookups. return { symbol, address, decimals }; } @@ -562,19 +563,18 @@ export class ProfitClient { lpFeePct: BigNumber, l1Token: EvmAddress, repaymentChainId: number - ): Promise> { + ): Promise< + Pick + > { let profitable = false; let netRelayerFeePct = bnZero; let nativeGasCost = uint256Max; let tokenGasCost = uint256Max; let gasPrice = uint256Max; + let totalFeePct = bnZero; try { - ({ profitable, netRelayerFeePct, nativeGasCost, tokenGasCost, gasPrice } = await this.getFillProfitability( - deposit, - lpFeePct, - l1Token, - repaymentChainId - )); + ({ profitable, netRelayerFeePct, nativeGasCost, tokenGasCost, gasPrice, totalFeePct } = + await this.getFillProfitability(deposit, lpFeePct, l1Token, repaymentChainId)); } catch (err) { this.logger.debug({ at: "ProfitClient#isFillProfitable", @@ -590,6 +590,7 @@ export class ProfitClient { tokenGasCost, gasPrice, netRelayerFeePct, + totalFeePct, }; } @@ -691,7 +692,9 @@ export class ProfitClient { try { const tokenAddrs = Array.from(new Set(Object.values(tokens))); const tokenPrices = await this.priceClient.getPricesByAddress(tokenAddrs, "usd"); - tokenPrices.forEach(({ address, price }) => (this.tokenPrices[address] = toBNWei(price))); + tokenPrices.forEach(({ address, price }) => { + this.tokenPrices[address] = toBNWei(price); + }); this.logger.debug({ at: "ProfitClient", message: "Updated token prices", tokenPrices: this.tokenPrices }); } catch (err) { const errMsg = `Failed to update token prices (${err})`; diff --git a/src/clients/SpokePoolClient.ts b/src/clients/SpokePoolClient.ts index 42edcb92db..77ea07b48c 100644 --- a/src/clients/SpokePoolClient.ts +++ b/src/clients/SpokePoolClient.ts @@ -12,19 +12,6 @@ export type IndexerOpts = { path?: string; }; -type SpokePoolEventRemoved = { - event: string; -}; - -type SpokePoolEventsAdded = { - blockNumber: number; - currentTime: number; - nEvents: number; // Number of events. - data: string; -}; - -export type SpokePoolClientMessage = SpokePoolEventsAdded | SpokePoolEventRemoved; - /** * Apply Typescript Mixins to permit a single class to generically extend a SpokePoolClient-ish instance. * The SDK exports both the EVMSpokePoolClient and SVMSpokePoolClient types. They have different properties @@ -192,29 +179,20 @@ export function SpokeListener>( const at = "SpokePoolClient#removeEvent"; const eventIdx = this._queryableEventNames().indexOf(event.event); const pendingEvents = this.#pendingEvents[eventIdx]; - const { event: eventName, blockNumber, blockHash, transactionHash, transactionIndex, logIndex } = event; + const { event: eventName, blockNumber, blockHash, transactionHash } = event; // First check for removal from any pending events. - const pendingEventIdx = pendingEvents.findIndex( - (pending) => - pending.logIndex === logIndex && - pending.transactionIndex === transactionIndex && - pending.transactionHash === transactionHash && - pending.blockHash === blockHash - ); - - let handled = false; - if (pendingEventIdx !== -1) { - // Drop the relevant event. - pendingEvents.splice(pendingEventIdx, 1); - handled = true; - - this.logger.debug({ - at: "SpokePoolClient#removeEvent", - message: `Removed 1 pre-ingested ${this.#chain} ${eventName} event for block ${blockNumber}.`, - event, - }); - } + let removed = false; + let removedEventIdx: number; + do { + removedEventIdx = pendingEvents.findIndex(({ blockHash }) => blockHash === event.blockHash); + if (removedEventIdx !== -1) { + removed = true; + pendingEvents.splice(removedEventIdx, 1); // Drop the relevant event. + const message = `Removed pre-ingested ${this.#chain} ${eventName} event for block ${blockNumber}.`; + this.logger.debug({ at, message, event }); + } + } while (removedEventIdx !== -1); // Back out any events that were previously ingested via update(). This is best-effort and may help to save the // relayer from filling a deposit where it must wait for additional deposit confirmations. Note that this is @@ -224,12 +202,13 @@ export function SpokeListener>( const { depositId } = event.args; assert(isDefined(depositId)); - const result = Object.entries(this.depositHashes).find(([, deposit]) => deposit.txnRef === transactionHash); - if (isDefined(result)) { - const [depositKey, deposit] = result; - delete this.depositHashes[depositKey]; - handled = true; - this.logger.warn({ at, message: `Removed 1 ${this.#chain} ${eventName} event.`, deposit }); + const deposits = Object.entries(this.depositHashes).filter(([, deposit]) => deposit.txnRef === transactionHash); + if (deposits.length > 0) { + deposits.forEach(([depositKey, deposit]) => { + delete this.depositHashes[depositKey]; + removed = true; + this.logger.warn({ at, message: `Removed 1 ${this.#chain} ${eventName} event.`, deposit }); + }); } else { this.logger.warn({ at, @@ -245,12 +224,12 @@ export function SpokeListener>( } else { // Retaining any remaining event types should be non-critical for relayer operation. They may // produce sub-optimal decisions, but should not affect the correctness of relayer operation. - handled = true; + removed = true; const message = `Detected re-org affecting pre-ingested ${this.#chain} ${eventName} events. Ignoring.`; this.logger.debug({ at, message, transactionHash, blockHash }); } - return handled; + return removed; } async _update(eventsToQuery: string[]): Promise { diff --git a/src/clients/bridges/AdapterManager.ts b/src/clients/bridges/AdapterManager.ts index 8b4d16c7cb..8e1ea61739 100644 --- a/src/clients/bridges/AdapterManager.ts +++ b/src/clients/bridges/AdapterManager.ts @@ -99,9 +99,11 @@ export class AdapterManager { return {}; } const spokePoolClient = this.spokePoolManager.getClient(chainId); - let l2Signer; + let l2SignerOrSvmProvider; if (isEVMSpokePoolClient(spokePoolClient)) { - l2Signer = spokePoolClient.spokePool.signer; + l2SignerOrSvmProvider = spokePoolClient.spokePool.signer; + } else if (isSVMSpokePoolClient(spokePoolClient)) { + l2SignerOrSvmProvider = spokePoolClient.svmEventsClient.getRpc(); } return Object.fromEntries( SUPPORTED_TOKENS[chainId] @@ -111,7 +113,13 @@ export class AdapterManager { if (!isDefined(bridgeConstructor)) { return undefined; } - const bridge = new bridgeConstructor(chainId, hubChainId, l2Signer, l1Signer, EvmAddress.from(l1Token)); + const bridge = new bridgeConstructor( + chainId, + hubChainId, + l2SignerOrSvmProvider, + l1Signer, + EvmAddress.from(l1Token) + ); return [l1Token, bridge]; }) .filter(isDefined) ?? [] diff --git a/src/clients/index.ts b/src/clients/index.ts index 998ed2d03a..e600d57006 100644 --- a/src/clients/index.ts +++ b/src/clients/index.ts @@ -7,10 +7,11 @@ export type SVMSpokePoolClient = clients.SVMSpokePoolClient; export type SpokePoolUpdate = clients.SpokePoolUpdate; export const { EVMSpokePoolClient, SpokePoolClient, SVMSpokePoolClient, SpokePoolManager } = clients; -export { SpokeListener, SpokePoolClientMessage } from "./SpokePoolClient"; +export { SpokeListener } from "./SpokePoolClient"; export class BundleDataClient extends clients.BundleDataClient.BundleDataClient {} export * from "./BalanceAllocator"; +export * from "./EventListener"; export * from "./HubPoolClient"; export * from "./ConfigStoreClient"; export * from "./MultiCallerClient"; diff --git a/src/common/ClientHelper.ts b/src/common/ClientHelper.ts index 04e8611e96..4da369b5f7 100644 --- a/src/common/ClientHelper.ts +++ b/src/common/ClientHelper.ts @@ -126,7 +126,7 @@ export async function constructSpokePoolClientsWithLookback( // disputing valid bundles. if (!hubPoolClient.isUpdated) { - throw new Error("Config store client must be updated before constructing spoke pool clients"); + throw new Error("HubPoolClient must be updated before constructing spoke pool clients"); } const hubPoolChainId = hubPoolClient.chainId; diff --git a/src/common/Constants.ts b/src/common/Constants.ts index ebd3511b1f..fc851cb383 100644 --- a/src/common/Constants.ts +++ b/src/common/Constants.ts @@ -7,7 +7,6 @@ import { CHAIN_IDs, TOKEN_SYMBOLS_MAP, Signer, - Provider, ZERO_ADDRESS, bnUint32Max, EvmAddress, @@ -49,6 +48,7 @@ import { BinanceCEXBridge as L2BinanceCEXBridge, UsdcCCTPBridge as L2UsdcCCTPBridge, BinanceCEXNativeBridge as L2BinanceCEXNativeBridge, + SolanaUsdcCCTPBridge as L2SolanaUsdcCCTPBridge, } from "../adapter/l2Bridges"; import { CONTRACT_ADDRESSES } from "./ContractAddresses"; import { HyperlaneXERC20Bridge } from "../adapter/bridges/HyperlaneXERC20Bridge"; @@ -80,7 +80,7 @@ export const MAX_RELAYER_DEPOSIT_LOOK_BACK = 4 * 60 * 60; export const FINALIZER_TOKENBRIDGE_LOOKBACK = 14 * 24 * 60 * 60; // Chain IDs using the Succinct/Helios SP1 messaging bridge. -export const UNIVERSAL_CHAINS = [CHAIN_IDs.BSC, CHAIN_IDs.HYPEREVM, CHAIN_IDs.PLASMA]; +export const UNIVERSAL_CHAINS = [CHAIN_IDs.BSC, CHAIN_IDs.HYPEREVM, CHAIN_IDs.PLASMA, CHAIN_IDs.MONAD]; // Reorgs are anticipated on Ethereum and Polygon. We use different following distances when processing deposit // events based on the USD amount of the deposit. This protects the relayer from the worst case situation where it fills @@ -151,6 +151,7 @@ const resolveRpcConfig = () => { [CHAIN_IDs.ALEPH_ZERO]: 0, [CHAIN_IDs.BOBA]: 0, [CHAIN_IDs.HYPEREVM]: 1_000, // QuickNode constraint. + [CHAIN_IDs.MONAD]: 1_000, // Alchemy constraint [CHAIN_IDs.SOLANA]: 1_000, [CHAIN_IDs.SOLANA_DEVNET]: 1000, }; @@ -177,6 +178,7 @@ export const BUNDLE_END_BLOCK_BUFFERS = { [CHAIN_IDs.INK]: 120, // 1s/block gives 2 mins buffer time [CHAIN_IDs.MAINNET]: 5, // 12s/block [CHAIN_IDs.MODE]: 60, // 2s/block. Same finality profile as Optimism + [CHAIN_IDs.MONAD]: 150, // ~400ms/block. 0.8s finality [CHAIN_IDs.OPTIMISM]: 60, // 2s/block [CHAIN_IDs.PLASMA]: 180, // ~1s/block variable. Finality guarantees are less certain, be a bit more conservative. [CHAIN_IDs.POLYGON]: 128, // 2s/block. Polygon reorgs often so this number is set larger than the largest observed reorg. @@ -197,6 +199,7 @@ export const BUNDLE_END_BLOCK_BUFFERS = { [CHAIN_IDs.LENS_SEPOLIA]: 0, [CHAIN_IDs.LISK_SEPOLIA]: 0, [CHAIN_IDs.MODE_SEPOLIA]: 0, + [CHAIN_IDs.MONAD_TESTNET]: 0, [CHAIN_IDs.OPTIMISM_SEPOLIA]: 0, [CHAIN_IDs.PLASMA_TESTNET]: 0, [CHAIN_IDs.POLYGON_AMOY]: 0, @@ -237,6 +240,7 @@ export const CHAIN_CACHE_FOLLOW_DISTANCE: { [chainId: number]: number } = { [CHAIN_IDs.LINEA]: 100, // Linea has a soft-finality of 1 block. This value is padded - but at 3s/block the padding is 5 minutes [CHAIN_IDs.MAINNET]: 128, [CHAIN_IDs.MODE]: 120, + [CHAIN_IDs.MONAD]: 150, [CHAIN_IDs.OPTIMISM]: 120, [CHAIN_IDs.PLASMA]: 300, [CHAIN_IDs.POLYGON]: 256, @@ -281,6 +285,7 @@ export const DEFAULT_NO_TTL_DISTANCE: { [chainId: number]: number } = { [CHAIN_IDs.LISK]: 86400, [CHAIN_IDs.MAINNET]: 14400, [CHAIN_IDs.MODE]: 86400, + [CHAIN_IDs.MONAD]: 432000, [CHAIN_IDs.OPTIMISM]: 86400, [CHAIN_IDs.PLASMA]: 172800, [CHAIN_IDs.POLYGON]: 86400, @@ -342,6 +347,7 @@ export const SUPPORTED_TOKENS: { [chainId: number]: string[] } = { [CHAIN_IDs.LINEA]: ["USDC", "USDT", "WETH", "WBTC", "DAI", "ezETH"], [CHAIN_IDs.LISK]: ["WETH", "USDC", "USDT", "LSK", "WBTC"], [CHAIN_IDs.MODE]: ["ETH", "WETH", "USDC", "USDT", "WBTC", "ezETH"], + [CHAIN_IDs.MONAD]: ["USDC"], // @TODO: Add USDT and WBTC after its added to the chain token list [CHAIN_IDs.OPTIMISM]: [ "DAI", "SNX", @@ -378,6 +384,7 @@ export const SUPPORTED_TOKENS: { [chainId: number]: string[] } = { [CHAIN_IDs.TATARA]: ["TATARA-USDC", "WETH"], [CHAIN_IDs.UNICHAIN_SEPOLIA]: ["WETH", "USDC"], [CHAIN_IDs.MODE_SEPOLIA]: ["WETH"], + [CHAIN_IDs.MONAD_TESTNET]: ["USDC", "USDT", "WBTC"], [CHAIN_IDs.OPTIMISM_SEPOLIA]: ["WETH", "USDC"], [CHAIN_IDs.BOB_SEPOLIA]: ["WETH", "WBTC"], }; @@ -408,6 +415,14 @@ type L1BridgeConstructor = new ( logger: winston.Logger ) => T; +type L2BridgeConstructor = new ( + l2chainId: number, + hubChainId: number, + l2SignerOrProvider: any, + l1Signer: Signer, + l1Token: EvmAddress +) => T; + // Map of chain IDs to all "canonical bridges" for the given chain. Canonical is loosely defined -- in this // case, it is the default bridge for the given chain. const resolveCanonicalBridges = (): Record> => { @@ -444,17 +459,7 @@ const resolveCanonicalBridges = (): Record> = { [CHAIN_IDs.BSC]: L2BinanceCEXBridge, [CHAIN_IDs.LISK]: L2OpStackBridge, [CHAIN_IDs.REDSTONE]: L2OpStackBridge, @@ -507,6 +512,12 @@ export const CUSTOM_BRIDGE: Record>> = { [CHAIN_IDs.LISK]: { [TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET]]: L2OpStackUSDCBridge, [TOKEN_SYMBOLS_MAP.WETH.addresses[CHAIN_IDs.MAINNET]]: L2OpStackWethBridge, @@ -633,6 +637,11 @@ export const CUSTOM_L2_BRIDGE: { [CHAIN_IDs.MODE]: { [TOKEN_SYMBOLS_MAP.ezETH.addresses[CHAIN_IDs.MAINNET]]: HyperlaneXERC20BridgeL2, }, + [CHAIN_IDs.MONAD]: { + [TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET]]: L2UsdcCCTPBridge, + // [TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.MAINNET]]: OFTL2Bridge, + // [TOKEN_SYMBOLS_MAP.WBTC.addresses[CHAIN_IDs.MAINNET]]: OFTL2Bridge, + }, [CHAIN_IDs.LINEA]: { [TOKEN_SYMBOLS_MAP.ezETH.addresses[CHAIN_IDs.MAINNET]]: HyperlaneXERC20BridgeL2, [TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET]]: L2UsdcCCTPBridge, @@ -660,6 +669,9 @@ export const CUSTOM_L2_BRIDGE: { [CHAIN_IDs.BSC]: { [TOKEN_SYMBOLS_MAP.ezETH.addresses[CHAIN_IDs.MAINNET]]: HyperlaneXERC20BridgeL2, }, + [CHAIN_IDs.SOLANA]: { + [TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET]]: L2SolanaUsdcCCTPBridge, + }, [CHAIN_IDs.WORLD_CHAIN]: { [TOKEN_SYMBOLS_MAP.ezETH.addresses[CHAIN_IDs.MAINNET]]: HyperlaneXERC20BridgeL2, [TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET]]: L2UsdcCCTPBridge, @@ -741,27 +753,29 @@ export const SCROLL_CUSTOM_GATEWAY: { [chainId: number]: { l1: string; l2: strin }; // Expected worst-case time for message from L1 to propagate to L2 in seconds -export const EXPECTED_L1_TO_L2_MESSAGE_TIME = { - [CHAIN_IDs.ARBITRUM]: 20 * 60, - [CHAIN_IDs.BASE]: 20 * 60, - [CHAIN_IDs.BLAST]: 20 * 60, - [CHAIN_IDs.UNICHAIN]: 20 * 60, - [CHAIN_IDs.INK]: 20 * 60, - [CHAIN_IDs.LENS]: 60 * 60, - [CHAIN_IDs.LINEA]: 60 * 60, - [CHAIN_IDs.LISK]: 20 * 60, - [CHAIN_IDs.MODE]: 20 * 60, - [CHAIN_IDs.OPTIMISM]: 20 * 60, - [CHAIN_IDs.PLASMA]: 60 * 60, - [CHAIN_IDs.POLYGON]: 60 * 60, - [CHAIN_IDs.REDSTONE]: 20 * 60, - [CHAIN_IDs.SCROLL]: 60 * 60, - [CHAIN_IDs.SOLANA]: 60 * 30, - [CHAIN_IDs.SONEIUM]: 20 * 60, - [CHAIN_IDs.WORLD_CHAIN]: 20 * 60, - [CHAIN_IDs.ZK_SYNC]: 60 * 60, - [CHAIN_IDs.ZORA]: 20 * 60, +const resolveBridgeDelay = () => { + const defaultBridgeDelay = 60 * 60; + + const bridgeFamilies = { + [ChainFamily.OP_STACK]: 20 * 60, + [ChainFamily.ORBIT]: 40 * 60, + [ChainFamily.ZK_STACK]: 60 * 60, + }; + + const bridges = { + [CHAIN_IDs.ARBITRUM]: bridgeFamilies[ChainFamily.ORBIT], + [CHAIN_IDs.ZK_SYNC]: bridgeFamilies[ChainFamily.ZK_STACK], + }; + + return Object.fromEntries( + Object.entries(PUBLIC_NETWORKS).map(([_chainId, { family }]) => { + const chainId = Number(_chainId); + const bridgeDelay = bridges[chainId] ?? bridgeFamilies[family] ?? defaultBridgeDelay; + return [chainId, bridgeDelay]; + }) + ); }; +export const EXPECTED_L1_TO_L2_MESSAGE_TIME = resolveBridgeDelay(); export const OPSTACK_CONTRACT_OVERRIDES = { [CHAIN_IDs.BASE]: { @@ -918,6 +932,7 @@ export const DEFAULT_GAS_MULTIPLIER: { [chainId: number]: number } = { [CHAIN_IDs.INK]: 1.5, [CHAIN_IDs.LISK]: 1.5, [CHAIN_IDs.MODE]: 1.5, + [CHAIN_IDs.MONAD]: 1.1, [CHAIN_IDs.PLASMA]: 1.5, [CHAIN_IDs.REDSTONE]: 1.5, [CHAIN_IDs.SONEIUM]: 1.5, @@ -991,6 +1006,7 @@ export const HYPERLANE_FEE_CAP_OVERRIDES: { [chainId: number]: BigNumber } = { // 0.4 BNB fee cap on BSC [CHAIN_IDs.BSC]: toWei("0.4"), [CHAIN_IDs.HYPEREVM]: toWei("8"), + [CHAIN_IDs.MONAD]: toWei("8"), // @TODO: Re-evaluate this after token launch [CHAIN_IDs.PLASMA]: toWei("8"), }; @@ -1003,6 +1019,8 @@ export const EVM_OFT_MESSENGERS: Map> = new Map( TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.MAINNET], new Map([ [CHAIN_IDs.MAINNET, EvmAddress.from("0x6C96dE32CEa08842dcc4058c14d3aaAD7Fa41dee")], + // @TODO: Add this after enabling USDT0 on Monad + // [CHAIN_IDs.MONAD, EvmAddress.from("0x9151434b16b9763660705744891fa906f660ecc5")], // @TODO: Check with Monad team if this is correct [CHAIN_IDs.ARBITRUM, EvmAddress.from("0x14E4A1B13bf7F943c8ff7C51fb60FA964A298D92")], [CHAIN_IDs.HYPEREVM, EvmAddress.from("0x904861a24F30EC96ea7CFC3bE9EA4B476d237e98")], [CHAIN_IDs.INK, EvmAddress.from("0x1cB6De532588fCA4a21B7209DE7C456AF8434A65")], @@ -1028,6 +1046,7 @@ export const OFT_FEE_CAP_OVERRIDES: { [chainId: number]: BigNumber } = { // 0.4 BNB fee cap on BSC [CHAIN_IDs.BSC]: toWei("0.4"), [CHAIN_IDs.HYPEREVM]: toWei("8"), + [CHAIN_IDs.MONAD]: toWei("8"), // @TODO: Re-evaluate this after token launch [CHAIN_IDs.PLASMA]: toWei("600"), // 1600 MATIC/POL cap on Polygon [CHAIN_IDs.POLYGON]: toWei("1600"), @@ -1052,6 +1071,13 @@ export const SWAP_ROUTES: { [chainId: number]: SwapRoute } = { destinationChainId: CHAIN_IDs.BSC, tradeType: "exactOutput", }, + [CHAIN_IDs.MONAD]: { + inputToken: EvmAddress.from(TOKEN_SYMBOLS_MAP.WETH.addresses[CHAIN_IDs.ARBITRUM]), + outputToken: EvmAddress.from(ZERO_ADDRESS), + originChainId: CHAIN_IDs.ARBITRUM, + destinationChainId: CHAIN_IDs.MONAD, + tradeType: "exactOutput", + }, [CHAIN_IDs.POLYGON]: { inputToken: EvmAddress.from(ZERO_ADDRESS), outputToken: EvmAddress.from(ZERO_ADDRESS), @@ -1074,7 +1100,3 @@ export const SWAP_ROUTES: { [chainId: number]: SwapRoute } = { tradeType: "exactOutput", }, }; - -export type CCTPMessageStatus = "finalized" | "ready" | "pending"; -export const CCTPV2_FINALITY_THRESHOLD_STANDARD = 2000; -export const CCTPV2_FINALITY_THRESHOLD_FAST = 1000; diff --git a/src/common/ContractAddresses.ts b/src/common/ContractAddresses.ts index 1f3c90b003..d9da46aac8 100644 --- a/src/common/ContractAddresses.ts +++ b/src/common/ContractAddresses.ts @@ -8,6 +8,7 @@ import ATOMIC_DEPOSITOR_TRANSFER_PROXY_ABI from "./abi/AtomicDepositorTransferPr import HUB_POOL_ABI from "./abi/HubPool.json"; import VOTING_V2_ABI from "./abi/VotingV2.json"; import OP_USDC_BRIDGE_ABI from "./abi/OpStackUSDCBridge.json"; +import SPONSORED_CCTP_DST_PERIPHERY_ABI from "./abi/SponsoredCCTPDstPeriphery.json"; import OVM_L1_STANDARD_BRIDGE_ABI from "./abi/OpStackStandardBridgeL1.json"; import OVM_L2_STANDARD_BRIDGE_ABI from "./abi/OpStackStandardBridgeL2.json"; import SNX_OPTIMISM_BRIDGE_L1_ABI from "./abi/SnxOptimismBridgeL1.json"; @@ -234,6 +235,16 @@ export const CONTRACT_ADDRESSES: { abi: ATOMIC_DEPOSITOR_TRANSFER_PROXY_ABI, }, }, + [CHAIN_IDs.MONAD]: { + cctpV2MessageTransmitter: { + address: "0x81D40F21F12A8F0E3252Bccb954D722d4c464B64", + abi: CCTP_MESSAGE_TRANSMITTER_ABI, + }, + cctpV2TokenMessenger: { + address: "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d", + abi: CCTP_V2_TOKEN_MESSENGER_ABI, + }, + }, [CHAIN_IDs.OPTIMISM]: { daiOptimismBridge: { address: "0x467194771dae2967aef3ecbedd3bf9a310c76c65", @@ -300,6 +311,10 @@ export const CONTRACT_ADDRESSES: { address: "0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d", abi: CCTP_V2_TOKEN_MESSENGER_ABI, }, + sponsoredCCTPDstPeriphery: { + address: undefined, // TODO: Deploy and add mainnet address + abi: SPONSORED_CCTP_DST_PERIPHERY_ABI, + }, }, [CHAIN_IDs.ZK_SYNC]: { nativeTokenVault: { @@ -468,6 +483,10 @@ export const CONTRACT_ADDRESSES: { address: "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA", abi: CCTP_V2_TOKEN_MESSENGER_ABI, }, + sponsoredCCTPDstPeriphery: { + address: "0x06C61D54958a0772Ee8aF41789466d39FfeaeB13", + abi: SPONSORED_CCTP_DST_PERIPHERY_ABI, + }, }, [CHAIN_IDs.ARBITRUM]: { cctpMessageTransmitter: { diff --git a/src/common/abi/MinimalERC20.json b/src/common/abi/MinimalERC20.json index 9160e94094..e60475f341 100644 --- a/src/common/abi/MinimalERC20.json +++ b/src/common/abi/MinimalERC20.json @@ -48,6 +48,15 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "constant": true, + "inputs": [{ "name": "", "type": "address" }], + "name": "balanceOf", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/src/common/abi/SponsoredCCTPDstPeriphery.json b/src/common/abi/SponsoredCCTPDstPeriphery.json new file mode 100644 index 0000000000..8edd0058bd --- /dev/null +++ b/src/common/abi/SponsoredCCTPDstPeriphery.json @@ -0,0 +1,25 @@ +[ + { + "inputs": [ + { + "internalType": "bytes", + "name": "message", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "attestation", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + } + ], + "name": "receiveMessage", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/src/dataworker/Dataworker.ts b/src/dataworker/Dataworker.ts index 12e578a05c..63e520b778 100644 --- a/src/dataworker/Dataworker.ts +++ b/src/dataworker/Dataworker.ts @@ -28,6 +28,7 @@ import { toAddressType, getKitKeypairFromEvmSigner, getEventAuthority, + getRedisCache, getStatePda, getFillStatusPda, LatestBlockhash, @@ -84,7 +85,7 @@ import { l2TokensToCountTowardsSpokePoolLeafExecutionCapital, persistDataToArweave, } from "../dataworker/DataworkerUtils"; -import { _buildRelayerRefundRoot, _buildSlowRelayRoot } from "./DataworkerUtils"; +import { _buildRelayerRefundRoot, _buildSlowRelayRoot, generateValidationKey } from "./DataworkerUtils"; import _ from "lodash"; import { ARBITRUM_ORBIT_L1L2_MESSAGE_FEE_DATA, @@ -646,7 +647,16 @@ export class Dataworker { spokePoolClients, earliestBlocksInSpokePoolClients ); - if (!valid) { + + if (valid) { + const rootBundleKey = generateValidationKey(pendingRootBundle); + const redis = await getRedisCache(this.logger); + if (isDefined(redis)) { + const validations = await redis.incr(rootBundleKey); + const message = "Registered successful validation."; + this.logger.debug({ at: "Dataworker#validate", message, rootBundleKey, validations }); + } + } else { // In the case where the Dataworker config is improperly configured, emit an error level alert so bot runner // can get dataworker running ASAP. if (ERROR_DISPUTE_REASONS.has(reason)) { @@ -2755,8 +2765,10 @@ export class Dataworker { // msg.value, we can drop this mapping and instead use response from `oftMessengers` call to decide whether a spoke // supports withdrawals via OFT const CHAINS_SUPPORTING_MSG_VALUE_ON_OFT_WITHDRAWAL = new Set([ + CHAIN_IDs.ARBITRUM, CHAIN_IDs.BSC, CHAIN_IDs.HYPEREVM, + CHAIN_IDs.MONAD, CHAIN_IDs.PLASMA, CHAIN_IDs.POLYGON, ]); @@ -2923,7 +2935,7 @@ export class Dataworker { (tx) => setTransactionMessageLifetimeUsingBlockhash(recentBlockhash.value, tx), (tx) => appendTransactionMessageInstructions([closeInstructionParamsIx], tx) ); - const closeSig = await sendAndConfirmSolanaTransaction(closeInstructionParamsTx, kitKeypair, provider); + const closeSig = await sendAndConfirmSolanaTransaction(closeInstructionParamsTx, provider); this.logger.debug({ at: "Dataworker#executeRelayerRefundLeafSvm", message: "Closed instruction params PDA", @@ -2950,7 +2962,7 @@ export class Dataworker { (tx) => appendTransactionMessageInstructions([initializeInstructionParamsIx], tx) ); let txSignature; - txSignature = await sendAndConfirmSolanaTransaction(initInstructionParamsTx, kitKeypair, provider); + txSignature = await sendAndConfirmSolanaTransaction(initInstructionParamsTx, provider); this.logger.debug({ at: "Dataworker#executeRelayerRefundLeafSvm", message: "Initialized instruction params account", @@ -2976,7 +2988,7 @@ export class Dataworker { (tx) => setTransactionMessageLifetimeUsingBlockhash(recentBlockhash.value, tx), (tx) => appendTransactionMessageInstructions([writeInstructionParamsIx], tx) ); - txSignature = await sendAndConfirmSolanaTransaction(writeInstructionParamsTx, kitKeypair, provider); + txSignature = await sendAndConfirmSolanaTransaction(writeInstructionParamsTx, provider); this.logger.debug({ at: "Dataworker#executeRelayerRefundLeafSvm", message: "Wrote relayer refund leaf data to instruction params account", @@ -3004,7 +3016,7 @@ export class Dataworker { (tx) => setTransactionMessageLifetimeUsingBlockhash(recentBlockhash.value, tx), (tx) => appendTransactionMessageInstructions([lookupTableIx], tx) ); - txSignature = await sendAndConfirmSolanaTransaction(createLookupTableTx, kitKeypair, provider); + txSignature = await sendAndConfirmSolanaTransaction(createLookupTableTx, provider); await waitForNewSolanaBlock(provider, 1); this.logger.debug({ @@ -3027,7 +3039,7 @@ export class Dataworker { (tx) => setTransactionMessageLifetimeUsingBlockhash(recentBlockhash.value, tx), (tx) => appendTransactionMessageInstructions([deactivateLutIx], tx) ); - const deactivateLutSignature = await sendAndConfirmSolanaTransaction(deactivateLutTx, kitKeypair, provider); + const deactivateLutSignature = await sendAndConfirmSolanaTransaction(deactivateLutTx, provider); this.logger.debug({ at: "Dataworker#executeRelayerRefundLeafSvm", message: "Deactivated address lookup table", @@ -3083,7 +3095,7 @@ export class Dataworker { (tx) => setTransactionMessageLifetimeUsingBlockhash(recentBlockhash.value, tx), (tx) => appendTransactionMessageInstructions([extendLookupTableIx], tx) ); - await sendAndConfirmSolanaTransaction(extendLutTx, kitKeypair, provider); + await sendAndConfirmSolanaTransaction(extendLutTx, provider); // @dev Every time we extend an ALT, we need to wait for a new solana block so that the table has // sufficient time to "activate." https://solana.com/developers/courses/program-optimization/lookup-tables#6-modify-main-to-use-lookup-tables await waitForNewSolanaBlock(provider, 1); @@ -3096,7 +3108,7 @@ export class Dataworker { (tx) => appendTransactionMessageInstructions([executeRelayerRefundLeafIx], tx), (tx) => compressTransactionMessageUsingAddressLookupTables(tx, addressLookupTableDefinitions.lookupTableMap) ); - refundLeafSignature = await sendAndConfirmSolanaTransaction(executeRelayerRefundLeafTx, kitKeypair, provider); + refundLeafSignature = await sendAndConfirmSolanaTransaction(executeRelayerRefundLeafTx, provider); } else { // This is case 2. Some refundAddresses do not have ATAs for the l2TokenAddress. this.logger.warn({ @@ -3152,7 +3164,7 @@ export class Dataworker { (tx) => setTransactionMessageLifetimeUsingBlockhash(recentBlockhash.value, tx), (tx) => appendTransactionMessageInstructions([initializeClaimAccountIx], tx) ); - txSignature = await sendAndConfirmSolanaTransaction(initializeClaimAccountTx, kitKeypair, provider); + txSignature = await sendAndConfirmSolanaTransaction(initializeClaimAccountTx, provider); this.logger.debug({ at: "Dataworker#executeRelayerRefundLeafSvm", message: "Initialized claim account", @@ -3180,7 +3192,7 @@ export class Dataworker { (tx) => setTransactionMessageLifetimeUsingBlockhash(recentBlockhash.value, tx), (tx) => appendTransactionMessageInstructions([extendLookupTableIx], tx) ); - await sendAndConfirmSolanaTransaction(extendLutTx, kitKeypair, provider); + await sendAndConfirmSolanaTransaction(extendLutTx, provider); // @dev Every time we extend an ALT, we need to wait for a new solana block so that the table has // sufficient time to "activate." https://solana.com/developers/courses/program-optimization/lookup-tables#6-modify-main-to-use-lookup-tables await waitForNewSolanaBlock(provider, 1); @@ -3193,11 +3205,7 @@ export class Dataworker { (tx) => appendTransactionMessageInstructions([executeRelayerRefundLeafDeferredIx], tx), (tx) => compressTransactionMessageUsingAddressLookupTables(tx, addressLookupTableDefinitions.lookupTableMap) ); - refundLeafSignature = await sendAndConfirmSolanaTransaction( - executeRelayerRefundLeafDeferredTx, - kitKeypair, - provider - ); + refundLeafSignature = await sendAndConfirmSolanaTransaction(executeRelayerRefundLeafDeferredTx, provider); const claimRelayerRefund = async ( refundAddress: KitAddress, claimAccount: KitAddress, @@ -3226,7 +3234,7 @@ export class Dataworker { const claimRelayerRefundTx = pipe(await createDefaultTransaction(provider, kitKeypair), (tx) => appendTransactionMessageInstructions([claimRelayerRefundIx], tx) ); - return await sendAndConfirmSolanaTransaction(claimRelayerRefundTx, kitKeypair, provider); + return await sendAndConfirmSolanaTransaction(claimRelayerRefundTx, provider); }; // Zip the claimAccounts with the recipient ATA and then claim all refunds corresponding to refund accounts with ATAs. const recipientTokenAccounts = claimAccounts.map((claimAccount, idx) => { @@ -3361,7 +3369,7 @@ export class Dataworker { : tx, (tx) => appendTransactionMessageInstructions([executeSlowFillIx], tx) ); - return sendAndConfirmSolanaTransaction(executeSlowFillTx, kitKeypair, provider); + return sendAndConfirmSolanaTransaction(executeSlowFillTx, provider); } async _getKitKeypair(): Promise { diff --git a/src/dataworker/DataworkerUtils.ts b/src/dataworker/DataworkerUtils.ts index 6ad21e7ee4..7a840ad976 100644 --- a/src/dataworker/DataworkerUtils.ts +++ b/src/dataworker/DataworkerUtils.ts @@ -9,6 +9,7 @@ import { import { CONTRACT_ADDRESSES } from "../common/ContractAddresses"; import { PoolRebalanceLeaf, + ProposedRootBundle, Refund, RelayerRefundLeaf, RelayerRefundLeafWithGroup, @@ -394,6 +395,16 @@ export function _getRefundLeaves( return relayerRefundLeaves; } +export function generateValidationKey( + proposal: Pick< + ProposedRootBundle, + "proposer" | "poolRebalanceRoot" | "relayerRefundRoot" | "slowRelayRoot" | "challengePeriodEndTimestamp" + > +): string { + const fields = ["proposer", "poolRebalanceRoot", "relayerRefundRoot", "slowRelayRoot", "challengePeriodEndTimestamp"]; + return "across-validations-" + fields.map((field) => proposal[field.toString()]).join("-"); +} + /** * @notice Returns WETH and ETH token addresses for chain if defined, or throws an error if they're not * in the hardcoded dictionary. diff --git a/src/dataworker/Disputer.ts b/src/dataworker/Disputer.ts new file mode 100644 index 0000000000..26686d1d56 --- /dev/null +++ b/src/dataworker/Disputer.ts @@ -0,0 +1,175 @@ +import { Contract, Signer } from "ethers"; +import { WETH9__factory as WETH9 } from "@across-protocol/contracts"; +import { AugmentedTransaction, TransactionClient } from "../clients"; +import { + BigNumber, + bnUint256Max, + bnZero, + formatEther, + getNetworkName, + isDefined, + Provider, + TransactionReceipt, + winston, +} from "../utils"; + +export class Disputer { + protected bondToken: Contract; + protected bondAmount: BigNumber; + protected bondMultiplier: { min: number; target: number }; + protected provider: Provider; + protected txnClient: TransactionClient; + protected chain: string; + private initPromise: Promise; + + constructor( + protected readonly chainId: number, + protected readonly logger: winston.Logger, + protected readonly hubPool: Contract, + readonly signer: Signer, + protected readonly simulate = true + ) { + this.chain = getNetworkName(chainId); + this.provider = hubPool.provider; + // signer.connect() is unsupported in test. + this.signer = signer.provider ? signer : signer.connect(hubPool.provider); + this.bondMultiplier = { + min: 4, + target: 8, + }; + this.txnClient = new TransactionClient(this.logger); + + const initPromise = async () => { + // @todo: Optimise all calls here by using Multicall3 to query: + // - bondToken + // - bondAmount + // - native balance + const [bondToken, bondAmount] = await Promise.all([this.hubPool.bondToken(), this.hubPool.bondAmount()]); + this.bondToken = WETH9.connect(bondToken, this.signer); + this.bondAmount = bondAmount; + }; + this.initPromise = initPromise(); + } + + async validate(): Promise { + await this.initPromise; + + const { bondAmount, logger } = this; + const minBondAmount = bondAmount.mul(this.bondMultiplier.min); + + // Balance checks. + const balance = await this.balance(); + const mintAmount = minBondAmount.sub(balance); + if (mintAmount.gt(bnZero)) { + const nativeBalance = await this.provider.getBalance(await this.signer.getAddress()); + if (nativeBalance.gt(mintAmount)) { + await this.mintBond(mintAmount); + } else { + const fmtAmount = formatEther(mintAmount); + const message = `Insufficient native token balance to mint ${fmtAmount} bond tokens.`; + if (!this.simulate && balance.lt(bondAmount)) { + throw new Error(message); + } + logger.warn({ at: "Disputer::validate", message, nativeBalance, mintAmount }); + } + } + + // Ensure allowances are in place. + const allowance = await this.allowance(); + const minAllowance = bondAmount.mul(this.bondMultiplier.target); + if (allowance.lt(minAllowance)) { + await this.approve(); + } + } + + async balance(): Promise { + const disputer = await this.signer.getAddress(); + return this.bondToken.balanceOf(disputer); + } + + async allowance(): Promise { + const signer = await this.signer.getAddress(); + return this.bondToken.allowance(signer, this.hubPool.address); + } + + async approve(amount = bnUint256Max): Promise { + const { chainId, bondToken, hubPool } = this; + const txn = { + chainId, + contract: bondToken, + method: "approve", + args: [hubPool.address, amount], + message: "Approved HubPool to spend bondToken.", + amount, + unpermissioned: false, + canFailInSimulation: false, + nonMulticall: true, + }; + + return this.submit(txn); + } + + async mintBond(amount: BigNumber): Promise { + const { chainId, bondToken } = this; + const txn = { + chainId, + contract: bondToken, + method: "deposit", + args: [], + message: `Minted ${amount} HubPool bondToken.`, + value: amount, + unpermissioned: false, + canFailInSimulation: false, + nonMulticall: true, + }; + + return this.submit(txn); + } + + dispute(): Promise { + const { chainId, hubPool } = this; + const txn = { + chainId, + contract: hubPool.connect(this.signer), + method: "disputeRootBundle", + args: [], + message: "Disputed HubPool root bundle proposal.", + unpermissioned: false, + canFailInSimulation: false, + nonMulticall: true, + }; + + try { + return this.submit(txn); + } catch (err) { + this.logger.error({ at: "Disputer::dispute", message: "Failed to submit HubPool dispute.", err }); + } + + return Promise.resolve(undefined); + } + + protected async submit(txn: AugmentedTransaction, maxTries = 3): Promise { + const { chainId, logger, txnClient } = this; + + if (this.simulate) { + logger.warn({ at: "Disputer::submit", message: `Suppressing ${txn.method} transaction.` }); + return Promise.resolve(undefined); + } + + let txnReceipt: TransactionReceipt; + let cause: unknown; + let tries = 0; + + do { + try { + const [txnResponse] = await txnClient.submit(chainId, [txn]); + txnReceipt = await txnResponse.wait(); + return txnReceipt; + } catch (err: unknown) { + cause = err; + } + } while (!isDefined(txnReceipt) && ++tries < maxTries); + + throw new Error(`Unable to submit transaction on ${this.chain}`, { cause }); + } +} diff --git a/src/dataworker/index.ts b/src/dataworker/index.ts index d759ced722..e27a3a39bc 100644 --- a/src/dataworker/index.ts +++ b/src/dataworker/index.ts @@ -1,5 +1,7 @@ import { TokenClient } from "../clients"; import { + blockExplorerLink, + CHAIN_IDs, EvmAddress, SvmAddress, winston, @@ -13,11 +15,14 @@ import { getSvmSignerFromEvmSigner, waitForPubSub, averageBlockTime, + getRedisCache, getRedisPubSub, + Provider, } from "../utils"; import { spokePoolClientsToProviders } from "../common"; import { Dataworker } from "./Dataworker"; import { DataworkerConfig } from "./DataworkerConfig"; +import { generateValidationKey } from "./DataworkerUtils"; import { constructDataworkerClients, constructSpokePoolClientsForFastDataworker, @@ -25,7 +30,8 @@ import { DataworkerClients, } from "./DataworkerClientHelper"; import { BalanceAllocator } from "../clients/BalanceAllocator"; -import { PendingRootBundle, BundleData } from "../interfaces"; +import { PendingRootBundle, ProposedRootBundle, BundleData } from "../interfaces"; +import { Disputer } from "./Disputer"; config(); let logger: winston.Logger; @@ -80,23 +86,47 @@ function resolvePersonality(config: DataworkerConfig): string { return "Dataworker"; // unknown } +/** + * Query the details of a proposal at a given block/tag. + * @param provider Ethers provider instance. + * @param chainId HubPool chain ID. + * @param blockTag Block/tag to query at. + */ +async function getProposal( + provider: Provider, + chainId = CHAIN_IDs.MAINNET, + blockTag: number | "latest" = "latest" +): Promise< + Pick< + ProposedRootBundle, + "poolRebalanceRoot" | "relayerRefundRoot" | "slowRelayRoot" | "challengePeriodEndTimestamp" | "proposer" + > & { currentTime: number; currentBlock: number } +> { + const { number: currentBlock, timestamp: currentTime } = await provider.getBlock(blockTag); + const hubPool = getDeployedContract("HubPool", chainId).connect(provider); + + const { poolRebalanceRoot, relayerRefundRoot, slowRelayRoot, proposer, challengePeriodEndTimestamp } = + await hubPool.rootBundleProposal({ blockTag: currentBlock }); + + return { + currentTime, + currentBlock, + poolRebalanceRoot, + relayerRefundRoot, + slowRelayRoot, + challengePeriodEndTimestamp, + proposer: EvmAddress.from(proposer), + }; +} + async function getChallengeRemaining( chainId: number, challengeBuffer: number, logger: winston.Logger ): Promise { const provider = await getProvider(chainId); - const latestBlock = await provider.getBlockNumber(); - const hubPool = getDeployedContract("HubPool", chainId).connect(provider); + const { currentBlock, currentTime, ...proposal } = await getProposal(provider, chainId); - const [proposal, currentTime] = await Promise.all([ - hubPool.rootBundleProposal({ - blockTag: latestBlock, - }), - hubPool.getCurrentTime({ - blockTag: latestBlock, - }), - ]); const { challengePeriodEndTimestamp } = proposal; const challengeRemaining = Math.max(challengePeriodEndTimestamp + challengeBuffer - currentTime, 0); logger.debug({ @@ -105,8 +135,8 @@ async function getChallengeRemaining( challengeRemaining, challengeBuffer, challengePeriodEndTimestamp, + currentBlock, currentTime, - blockTag: latestBlock, }); return challengeRemaining; @@ -375,3 +405,60 @@ export async function runDataworker(_logger: winston.Logger, baseSigner: Signer) await disconnectRedisClients(logger); } } + +export async function runDisputerWatchdog(logger: winston.Logger, signer: Signer): Promise { + const personality = "Disputer Watchdog"; + const at = "runDisputerWatchDog"; + const config = new DataworkerConfig(process.env); + const { hubPoolChainId: hubChainId, sendingTransactionsEnabled: enabled } = config; + + const { DISPUTER_WATCHDOG_MIN_ATTESTATIONS = "3", DISPUTER_WATCHDOG_CHALLENGE_LIMIT = "600" } = process.env; // @todo Watchdog config. + const minValidations = Number(DISPUTER_WATCHDOG_MIN_ATTESTATIONS); + const challengeLimit = Number(DISPUTER_WATCHDOG_CHALLENGE_LIMIT); + + const provider = await getProvider(hubChainId, logger); + const hubPool = getDeployedContract("HubPool", hubChainId).connect(provider); + const disputer = new Disputer(hubChainId, logger, hubPool, signer, !enabled); + + logger.debug({ at, message: "Starting Disputer Watchdog." }); + + try { + await disputer.validate(); + const redis = await getRedisCache(logger); + + const { currentTime, currentBlock, ...proposal } = await getProposal(provider, hubChainId); + + const getValidations = async () => { + const key = generateValidationKey(proposal); + const result = await redis.get(key); + return Number(result) || 0; // Revert to 0 on isNaN(result) + }; + + // @todo Validate that currentTime is not too different from host time. + const challengeRemaining = proposal.challengePeriodEndTimestamp - currentTime; + if (challengeRemaining <= 0) { + logger.debug({ at, message: "Proposal challenge window has elapsed, nothing to do..." }); + return; + } + + const validations = await getValidations(); + if (challengeRemaining <= challengeLimit && validations < minValidations) { + const dispute = await disputer.dispute(); + const message = enabled + ? "Submitted HubPool root bundle dispute." + : "Suppressed HubPool root bundle dispute due to configuration."; + const txn = isDefined(dispute) ? blockExplorerLink(dispute.transactionHash, hubChainId) : undefined; + logger.error({ at, message, proposal, txn }); + } else { + const waiting = challengeRemaining - challengeLimit; + const message = + waiting > 0 + ? `Must wait an additional ${waiting} seconds before evaluating validator attestations.` + : "Current proposal has sufficient validator attestations."; + logger.debug({ at, message, challengeLimit, validations, minValidations }); + } + } finally { + await disconnectRedisClients(logger); + logger.debug({ at, message: `Completed ${personality} run.` }); + } +} diff --git a/src/finalizer/config.ts b/src/finalizer/config.ts index 306970767a..7813a063ae 100644 --- a/src/finalizer/config.ts +++ b/src/finalizer/config.ts @@ -1,7 +1,7 @@ import assert from "assert"; import { assert as ssAssert, enums } from "superstruct"; import { CommonConfig, FINALIZER_TOKENBRIDGE_LOOKBACK, ProcessEnv } from "../common"; -import { Address, EvmAddress } from "../utils"; +import { Address, EvmAddress, SvmAddress, ethers } from "../utils"; /** * The finalization type is used to determine the direction of the finalization. @@ -28,7 +28,11 @@ export class FinalizerConfig extends CommonConfig { const userAddresses: { [address: string]: string[] } = JSON.parse(FINALIZER_WITHDRAWAL_TO_ADDRESSES); this.userAddresses = new Map(); Object.entries(userAddresses).forEach(([address, tokensToFinalize]) => { - this.userAddresses.set(EvmAddress.from(address), tokensToFinalize); + if (ethers.utils.isHexString(address)) { + this.userAddresses.set(EvmAddress.from(address), tokensToFinalize); + } else { + this.userAddresses.set(SvmAddress.from(address), tokensToFinalize); + } }); this.chainsToFinalize = JSON.parse(FINALIZER_CHAINS); diff --git a/src/finalizer/index.ts b/src/finalizer/index.ts index 53746df618..5001572a4a 100644 --- a/src/finalizer/index.ts +++ b/src/finalizer/index.ts @@ -46,6 +46,7 @@ import { polygonFinalizer, scrollFinalizer, zkSyncFinalizer, + oftRetryFinalizer, } from "./utils"; import { FinalizerConfig } from "./config"; @@ -125,6 +126,11 @@ function generateChainConfig(): void { if (cctpDomain !== CCTP_NO_DOMAIN && family !== ChainFamily.SVM) { config.finalizeOnAny.push(cctpV2Finalizer); } + + // @todo Once contracts are linked, change this to add all chains w/ OFT enabled. + if (chainId === CHAIN_IDs.ARBITRUM) { + config.finalizeOnAny.push(oftRetryFinalizer); + } }); } diff --git a/src/finalizer/utils/arbStack.ts b/src/finalizer/utils/arbStack.ts index db7776e98f..d03370256b 100644 --- a/src/finalizer/utils/arbStack.ts +++ b/src/finalizer/utils/arbStack.ts @@ -64,7 +64,9 @@ export async function arbStackFinalizer( ): Promise { assert(isEVMSpokePoolClient(spokePoolClient)); // Recipient addresses are just used to query events. - const recipientAddresses = Array.from(_recipientAddresses.keys()).map((address) => address.toNative()); + const recipientAddresses = Array.from(_recipientAddresses.keys()) + .filter((address) => address.isEVM()) + .map((address) => address.toNative()); LATEST_MAINNET_BLOCK = hubPoolClient.latestHeightSearched; const hubPoolProvider = await getProvider(hubPoolClient.chainId, logger); MAINNET_BLOCK_TIME = (await arch.evm.averageBlockTime(hubPoolProvider)).average; diff --git a/src/finalizer/utils/cctp/Svml2ToL1.ts b/src/finalizer/utils/cctp/Svml2ToL1.ts index 549a95a63e..a6dd7aef89 100644 --- a/src/finalizer/utils/cctp/Svml2ToL1.ts +++ b/src/finalizer/utils/cctp/Svml2ToL1.ts @@ -22,7 +22,7 @@ import { import { AttestedCCTPDeposit, getCCTPV1Deposits, getCctpReceiveMessageCallData } from "../../../utils/CCTPUtils"; import { bridgeTokensToHubPool } from "./svmUtils"; import { FinalizerPromise, CrossChainMessage, AddressesToFinalize } from "../../types"; -import { CCTPMessageStatus } from "../../../common"; +import { utils } from "@across-protocol/sdk"; /** * Finalizes CCTP V1 token and message relays originating on an SVM L2 and destined to Ethereum, where the L2 is indicated @@ -74,7 +74,7 @@ export async function cctpV1SvmL2toL1Finalizer( ); const statusesGrouped = groupObjectCountsByProp( outstandingDeposits, - (message: { status: CCTPMessageStatus }) => message.status + (message: { status: utils.CCTPMessageStatus }) => message.status ); const pending = outstandingDeposits .filter(({ status }) => status === "pending") diff --git a/src/finalizer/utils/cctp/anyToAny.ts b/src/finalizer/utils/cctp/anyToAny.ts index bc7803a224..ef232b068c 100644 --- a/src/finalizer/utils/cctp/anyToAny.ts +++ b/src/finalizer/utils/cctp/anyToAny.ts @@ -39,7 +39,7 @@ export async function cctpV2Finalizer( const sourceChainId = spokePoolClient.chainId; assert(!chainIsSvm(sourceChainId), "cctpFinalizer only supports EVM chains"); - const senderAndRecipientAddresses = Array.from(addressesToFinalize.keys()); + const senderAndRecipientAddresses = Array.from(addressesToFinalize.keys()).filter((address) => address.isEVM()); // Get all DepositForBurn events from source chain sent from one of the addresses in addressesToFinalize. const sourceProvider = getCachedProvider(sourceChainId); diff --git a/src/finalizer/utils/cctp/l1ToSvmL2.ts b/src/finalizer/utils/cctp/l1ToSvmL2.ts index 452cef607d..00443ad964 100644 --- a/src/finalizer/utils/cctp/l1ToSvmL2.ts +++ b/src/finalizer/utils/cctp/l1ToSvmL2.ts @@ -14,7 +14,7 @@ import { getCctpV1Messages, isDepositForBurnEvent } from "../../../utils/CCTPUti import { FinalizerPromise, AddressesToFinalize } from "../../types"; import { KeyPairSigner } from "@solana/kit"; import { finalizeCCTPV1MessagesSVM } from "./svmUtils/l1Tol2"; -import { CCTPMessageStatus } from "../../../common/Constants"; +import { utils } from "@across-protocol/sdk"; /** * Finalizes CCTP V1 token and message relays originating on Ethereum and destined to the input L2, as indicated @@ -43,7 +43,7 @@ export async function cctpV1L1toSvmL2Finalizer( }; const signer: KeyPairSigner = await getKitKeypairFromEvmSigner(_signer); const outstandingMessages = await getCctpV1Messages( - Array.from(senderAddresses.keys()), + Array.from(senderAddresses.keys()).filter((address) => address.isEVM()), hubPoolClient.chainId, l2SpokePoolClient.chainId, searchConfig, @@ -54,7 +54,7 @@ export async function cctpV1L1toSvmL2Finalizer( ); const statusesGrouped = groupObjectCountsByProp( outstandingMessages, - (message: { status: CCTPMessageStatus }) => message.status + (message: { status: utils.CCTPMessageStatus }) => message.status ); logger.debug({ at: `Finalizer#CCTPV1L1ToL2Finalizer:${l2SpokePoolClient.chainId}`, diff --git a/src/finalizer/utils/cctp/svmUtils/l2Tol1.ts b/src/finalizer/utils/cctp/svmUtils/l2Tol1.ts index 766f7e8820..bb132c3ba5 100644 --- a/src/finalizer/utils/cctp/svmUtils/l2Tol1.ts +++ b/src/finalizer/utils/cctp/svmUtils/l2Tol1.ts @@ -106,7 +106,7 @@ export async function bridgeTokensToHubPool( ); const formatUsdc = createFormatFunction(2, 4, false, 6); if (process.env.SEND_TRANSACTIONS === "true") { - const withdrawSignature = await sendAndConfirmSolanaTransaction(bridgeTokensToHubPoolTx, signer, svmProvider); + const withdrawSignature = await sendAndConfirmSolanaTransaction(bridgeTokensToHubPoolTx, svmProvider); return { message: `Withdrew ${formatUsdc(pendingWithdrawAmount.toString())} USDC from Solana to the hub pool.`, signature: withdrawSignature, diff --git a/src/finalizer/utils/index.ts b/src/finalizer/utils/index.ts index 4f04d411df..724cd87f95 100644 --- a/src/finalizer/utils/index.ts +++ b/src/finalizer/utils/index.ts @@ -7,3 +7,4 @@ export * from "./cctp"; export * from "./binance"; export * from "./linea"; export * from "./helios"; +export * from "./oftRetry"; diff --git a/src/finalizer/utils/linea/l1ToL2.ts b/src/finalizer/utils/linea/l1ToL2.ts index c4730da380..6aed6421f7 100644 --- a/src/finalizer/utils/linea/l1ToL2.ts +++ b/src/finalizer/utils/linea/l1ToL2.ts @@ -55,7 +55,9 @@ export async function lineaL1ToL2Finalizer( _senderAddresses: AddressesToFinalize ): Promise { assert(isEVMSpokePoolClient(l1SpokePoolClient) && isEVMSpokePoolClient(l2SpokePoolClient)); - const senderAddresses = Array.from(_senderAddresses.keys()).map((address) => address.toEvmAddress()); + const senderAddresses = Array.from(_senderAddresses.keys()) + .filter((address) => address.isEVM()) + .map((address) => address.toEvmAddress()); const [l1ChainId] = [hubPoolClient.chainId, hubPoolClient.hubPool.address]; if (l1ChainId !== CHAIN_IDs.MAINNET) { throw new Error("Finalizations for Linea testnet is not supported."); diff --git a/src/finalizer/utils/linea/l2ToL1.ts b/src/finalizer/utils/linea/l2ToL1.ts index 5663ec73b9..ed184ad9a2 100644 --- a/src/finalizer/utils/linea/l2ToL1.ts +++ b/src/finalizer/utils/linea/l2ToL1.ts @@ -230,6 +230,7 @@ export async function lineaL2ToL1Finalizer( // Append raw MessageSent events for custom sender addresses to TokensBridged events const senderAddresses = Array.from(_senderAddresses.keys()) + .filter((address) => address.isEVM()) .map((address) => address.toEvmAddress()) .filter((sender) => sender !== spokePoolClient.spokePool.address && sender !== hubPoolClient.hubPool.address); const l2TokenBridge = new Contract( diff --git a/src/finalizer/utils/oftRetry.ts b/src/finalizer/utils/oftRetry.ts new file mode 100644 index 0000000000..35280da8e3 --- /dev/null +++ b/src/finalizer/utils/oftRetry.ts @@ -0,0 +1,82 @@ +import { SpokePoolClient } from "../../clients"; +import { + EventSearchConfig, + assert, + groupObjectCountsByProp, + winston, + isEVMSpokePoolClient, + chunk, + getSrcOftMessages, + getLzTransactionDetails, + mapAsync, + getChainIdFromEndpointId, +} from "../../utils"; +import { FinalizerPromise, CrossChainMessage } from "../types"; + +/** + * Finalizes failed lzCompose messages on destination by checking for failed transactions and resubmitting its calldata.. + * @param logger Logger instance. + * @param spokePoolClient Origin SpokePool client instance. + * @returns FinalizerPromise instance. + */ +export async function oftRetryFinalizer( + logger: winston.Logger, + spokePoolClient: SpokePoolClient +): Promise { + assert(isEVMSpokePoolClient(spokePoolClient), "Cannot retry LZ messages on non-EVM networks."); + const srcProvider = spokePoolClient.spokePool.provider; + const searchConfig: EventSearchConfig = { + from: spokePoolClient.eventSearchConfig.from, + to: spokePoolClient.latestHeightSearched, + maxLookBack: spokePoolClient.eventSearchConfig.maxLookBack, + }; + const depositInitiatedMessages = await getSrcOftMessages(spokePoolClient.chainId, searchConfig, srcProvider); + const _outstandingMessages = []; + // To avoid rate-limiting, chunk API queries. + const chunkSize = Number(process.env["LZ_API_CHUNK_SIZE"] ?? 8); + for (const depositInitiatedMessageChunk of chunk(depositInitiatedMessages, chunkSize)) { + _outstandingMessages.push( + ...(await mapAsync(depositInitiatedMessageChunk, async ({ txnRef }) => { + return await getLzTransactionDetails(txnRef); + })) + ); + } + const outstandingMessages = _outstandingMessages.map(({ data }) => data.flat()).flat(); + + // Lz messages are executed automatically and must be retried only if their execution reverts on chain. + const unprocessedMessages = outstandingMessages.filter(({ destination }) => destination?.status !== "SUCCEEDED"); + const statusesGrouped = groupObjectCountsByProp( + outstandingMessages.map(({ destination }) => destination), + (message: { status: string }) => message.status + ); + logger.debug({ + at: `Finalizer#OftRetryFinalizer:${spokePoolClient.chainId}`, + message: `Detected ${unprocessedMessages.length} LZ retryable messages for origin ${spokePoolClient.chainId}`, + statusesGrouped, + }); + + const destinationTransactions = await mapAsync(unprocessedMessages, async ({ source }) => { + return await srcProvider.getTransaction(source.tx); + }); + + const callData = destinationTransactions.map((txData) => { + return { + target: txData.to, + callData: txData.data, + }; + }); + + const crossChainMessages = unprocessedMessages.map((unprocessedMessage) => { + return { + originationChainId: spokePoolClient.chainId, + destinationChainId: getChainIdFromEndpointId(unprocessedMessage.pathway.dstEid), + type: "misc", + miscReason: "oftRetry", + } as CrossChainMessage; + }); + + return { + crossChainMessages, + callData, + }; +} diff --git a/src/finalizer/utils/opStack.ts b/src/finalizer/utils/opStack.ts index 9e1809c803..e722481691 100644 --- a/src/finalizer/utils/opStack.ts +++ b/src/finalizer/utils/opStack.ts @@ -44,7 +44,7 @@ import { EvmAddress, ZERO_ADDRESS, } from "../../utils"; -import { CONTRACT_ADDRESSES, OPSTACK_CONTRACT_OVERRIDES } from "../../common"; +import { CONTRACT_ADDRESSES, OPSTACK_CONTRACT_OVERRIDES, CHAIN_MAX_BLOCK_LOOKBACK } from "../../common"; import OPStackPortalL1 from "../../common/abi/OpStackPortalL1.json"; import { FinalizerPromise, CrossChainMessage, AddressesToFinalize } from "../types"; const { utils } = ethers; @@ -145,6 +145,7 @@ export async function opStackFinalizer( // the lite chain to Ethereum via the canonical OVM standard bridge. // Filter out SpokePool as sender since we query for it previously using the TokensBridged event query. const ovmFromAddresses = Array.from(senderAddresses.keys()) + .filter((address) => address.isEVM()) .map((sender) => sender.toEvmAddress()) .filter((sender) => sender !== spokePool.address); const searchConfig = { ...spokePoolClient.eventSearchConfig, to }; @@ -778,9 +779,23 @@ async function multicallOptimismFinalizations( ); // Reduce the query by only querying events that were emitted after the earliest TokenBridged event we saw. This // is an easy optimization as we know that WithdrawalRequested events are only emitted after the TokenBridged event. - const fromBlock = tokensBridgedEvents[0].blockNumber; + const l2FromBlock = tokensBridgedEvents[0].blockNumber; + const l2Provider = Signer.isSigner(crossChainMessenger.l2SignerOrProvider) + ? crossChainMessenger.l2SignerOrProvider.provider + : crossChainMessenger.l2SignerOrProvider; + const [l2Block, latestL1Block] = await Promise.all([ + l2Provider.getBlock(l2FromBlock), + usdYieldManager.provider.getBlockNumber(), + ]); + const l1FromBlock = await getBlockForTimestamp(logger, hubPoolClient.chainId, l2Block.timestamp); + const l1SearchConfig = { + from: l1FromBlock, + to: latestL1Block, + maxLookBack: CHAIN_MAX_BLOCK_LOOKBACK[chainId], + }; const [_withdrawalRequests, lastCheckpointId, lastFinalizedRequestId] = await Promise.all([ - usdYieldManager.queryFilter( + paginatedEventQuery( + usdYieldManager, usdYieldManager.filters.WithdrawalRequested( null, null, @@ -793,7 +808,7 @@ async function multicallOptimismFinalizations( // stops querying TokensBridged/L2 Withdrawal events that have the HubPool as the recipient. [hubPoolClient.hubPool.address, blastDaiRetriever.address] ), - fromBlock + l1SearchConfig ), usdYieldManager.getLastCheckpointId(), // We fetch the lastFinalizedRequestId to filter out any withdrawal requests to give more @@ -835,7 +850,7 @@ async function multicallOptimismFinalizations( ), Promise.all( claimableWithdrawalRequests.map(({ requestId }) => - usdYieldManager.queryFilter(usdYieldManager.filters.WithdrawalClaimed(requestId), fromBlock) + paginatedEventQuery(usdYieldManager, usdYieldManager.filters.WithdrawalClaimed(requestId), l1SearchConfig) ) ), ]); diff --git a/src/hyperliquid/HyperliquidExecutor.ts b/src/hyperliquid/HyperliquidExecutor.ts new file mode 100644 index 0000000000..d7b03f3e66 --- /dev/null +++ b/src/hyperliquid/HyperliquidExecutor.ts @@ -0,0 +1,343 @@ +import winston from "winston"; +import { utils as ethersUtils } from "ethers"; +import { HyperliquidExecutorConfig } from "./HyperliquidExecutorConfig"; +import { + Contract, + Provider, + isDefined, + CHAIN_IDs, + getTokenInfo, + EvmAddress, + getHlInfoClient, + getSpotMeta, + getDstOftHandler, + getL2Book, + getDstCctpHandler, + getOpenOrders, + TOKEN_SYMBOLS_MAP, + bnZero, + BigNumber, + forEachAsync, + ConvertDecimals, + getChainQuorum, +} from "../utils"; +import { Log, SwapFlowInitialized } from "../interfaces"; +import { MultiCallerClient, EventListener, HubPoolClient } from "../clients"; + +export interface HyperliquidExecutorClients { + // We can further constrain the HubPoolClient type since we don't call any functions on it. + hubPoolClient: HubPoolClient; + multiCallerClient: MultiCallerClient; + dstProvider: Provider; +} + +// Type describing the result of a task created by the HyperliquidExecutor. +// An actionable task resulted in a transaction being sent, while a non-actionable task +// is informational. +export type TaskResult = { + actionable: boolean; + mrkdwn: string; +}; + +// Minimal type describing a pair. Each pair has its own unique swap handler, but there may be +// pairs with the same name. E.g. a USDT0-USDC pair may have different swap handlers (depending on +// if the token the bot is trying to buy is USDC or USDT), but will have the same name (i.e. will use the +// same HL orderbook). +export type Pair = { + name: string; + swapHandler: EvmAddress; + baseToken: EvmAddress; + finalToken: EvmAddress; +}; + +const BASE_TOKENS = ["USDT0", "USDC"]; +const abortController = new AbortController(); + +/** + * Class which operates on HyperEVM handler contracts. This supports placing orders on Hypercore and transferring tokens from + * swap handler contracts to users on Hypercore. + */ +export class HyperliquidExecutor { + private dstOftMessenger: Contract; + private dstCctpMessenger: Contract; + private pairs: { [pair: string]: Pair } = {}; + private pairUpdates: { [pairName: string]: number } = {}; + private eventListener: EventListener; + private infoClient; + private handledEvents: Set = new Set(); + + private tasks: Promise[] = []; + private taskResolver; + + public initialized = false; + private chainId = CHAIN_IDs.HYPEREVM; + + public constructor( + readonly logger: winston.Logger, + readonly config: HyperliquidExecutorConfig, + readonly clients: HyperliquidExecutorClients + ) { + // These must be defined. + const signer = clients.hubPoolClient.hubPool.signer; + this.dstOftMessenger = getDstOftHandler().connect(signer.connect(this.clients.dstProvider)); + this.dstCctpMessenger = getDstCctpHandler().connect(signer.connect(this.clients.dstProvider)); + + this.infoClient = getHlInfoClient(); + this.eventListener = new EventListener(this.chainId, this.logger, getChainQuorum(this.chainId)); + } + + public async initialize(): Promise { + const spotMeta = await getSpotMeta(this.infoClient); + await forEachAsync(BASE_TOKENS, async (supportedToken) => { + const counterpartTokens = this.config.supportedTokens.filter((token) => token !== supportedToken); + await forEachAsync(counterpartTokens, async (counterpartToken) => { + const baseToken = spotMeta.tokens.find((token) => token.name === supportedToken); + const finalToken = spotMeta.tokens.find((token) => token.name === counterpartToken); + // The token does not exist in the spot list, so no pair will exist. + if (!isDefined(baseToken) || !isDefined(finalToken)) { + return; + } + const pair = spotMeta.universe.find( + (_pair) => _pair.tokens.includes(baseToken.index) && _pair.tokens.includes(finalToken.index) + ); + if (!isDefined(pair)) { + return; + } + const baseTokenAddress = + baseToken.evmContract?.address ?? TOKEN_SYMBOLS_MAP[baseToken.name].addresses[this.chainId]; + const finalTokenAddress = + finalToken.evmContract?.address ?? TOKEN_SYMBOLS_MAP[finalToken.name].addresses[this.chainId]; + + const dstHandler = baseToken.name === "USDT0" ? this.dstOftMessenger : this.dstCctpMessenger; + const swapHandler = await dstHandler.predictSwapHandler(finalTokenAddress); + + const pairId = `${baseToken.name}-${finalToken.name}`; + this.pairs[pairId] = { + name: pair.name, + swapHandler: EvmAddress.from(swapHandler), + baseToken: EvmAddress.from(baseTokenAddress), + finalToken: EvmAddress.from(finalTokenAddress), + }; + this.pairUpdates[pairId] = 0; + }); + }); + + process.on("SIGHUP", () => { + this.logger.debug({ + at: "HyperliquidExecutor#initialize", + message: "Received SIGHUP on HyperliquidExecutor. stopping...", + }); + abortController.abort(); + }); + + process.on("disconnect", () => { + this.logger.debug({ + at: "HyperliquidExecutor#initialize", + message: "HyperliquidExecutor disconnected, stopping...", + }); + abortController.abort(); + }); + this.initialized = true; + } + + public startListeners(): void { + const handleSwapFlowInitiated = (log: Log) => { + const swapFlowInitiated = log.args as SwapFlowInitialized; + // If we handled the event already, then ignore it. + if (this.handledEvents.has(swapFlowInitiated.quoteNonce)) { + return; + } + this.handledEvents.add(swapFlowInitiated.quoteNonce); + + this.logger.debug({ + at: "HyperliquidExecutor#handleSwapFlowInitiated", + message: "Observed new order event", + swapFlowInitiated, + }); + const baseToken = this.dstOftMessenger.address === log.address ? "USDT0" : "USDC"; + const finalToken = getTokenInfo(EvmAddress.from(swapFlowInitiated.finalToken), this.chainId).symbol; + const pairId = `${baseToken}-${finalToken}`; + const pair = this.pairs[pairId]; + + this.pairUpdates[pairId] = log.blockNumber; + + // Update tasks to place a new limit order. + this.updateTasks(this.updateOrderAmount(pair, BigNumber.from(swapFlowInitiated.evmAmountIn))); + }; + + // Handle events coming from all destination handlers. + for (const handler of [this.dstOftMessenger, this.dstCctpMessenger]) { + const eventDescriptor = handler.interface.getEvent("SwapFlowInitialized"); + const eventFormatted = eventDescriptor.format(ethersUtils.FormatTypes.full); + this.eventListener.onEvent(handler.address, eventFormatted, handleSwapFlowInitiated); + } + + // Handle new block events. + const handleNewBlock = (blockNumber: number) => { + Object.entries(this.pairUpdates).forEach(([pairId, updateBlock]) => { + if (blockNumber - updateBlock < this.config.reviewInterval) { + return; + } + this.logger.debug({ + at: "HyperliquidExecutor#handleNewBlock", + message: "Reviewing active orders", + lastReview: updateBlock, + currentReviewBlock: blockNumber, + pairId, + }); + + // Update tasks to refresh limit orders. + this.updateTasks(this.updateOrderAmount(this.pairs[pairId], bnZero)); // Placing an order with an order amount of zero just refreshes the order at the bestAsk. + this.pairUpdates[pairId] = blockNumber; + }); + }; + this.eventListener.onBlock(handleNewBlock); + } + + public async processTasks(): Promise { + for await (const taskResult of this.resolveNextTask()) { + this.logger.debug({ + at: "HyperliquidExecutor#processTasks", + message: "Finished processing task", + taskResult, + }); + if (this.clients.multiCallerClient.transactionCount() !== 0) { + await this.clients.multiCallerClient.executeTxnQueues(); + } + } + } + + private async updateOrderAmount(pair: Pair, orderAmount: BigNumber): Promise { + // Get the existing orders on the pair and the l2Book. + const [openOrders, l2Book] = await Promise.all([ + getOpenOrders(this.infoClient, { user: pair.swapHandler.toNative() }), + getL2Book(this.infoClient, { coin: pair.name }), + ]); + const { baseToken, finalToken } = pair; + const { decimals } = this._getTokenInfo(baseToken, this.chainId); + + const existingOrder = openOrders[0]; + const bestAsk = Number(l2Book.levels[1][0].px); + const priceXe8 = Math.floor(bestAsk * 10 ** 8); + // The swap handler should only have orders placed on the associated pair. + // `sz` represents the remaining, unfilled quantity. + const existingAmountPlaced = BigNumber.from(openOrders[0]?.sz ?? 0); + const existingAmountPlacedQuantums = existingAmountPlaced.mul(10 ** decimals); + const newAmountToPlace = orderAmount.add(existingAmountPlacedQuantums); + const sizeXe8 = ConvertDecimals(decimals, 8)(newAmountToPlace); + + const oid = Math.floor(Date.now() / 1000); // Set the new order id to the current time in seconds. + // Atomically replace the existing order with the new order by first cancelling the existing order and then placing a new one. + if (isDefined(existingOrder)) { + this.cancelLimitOrderByCloid(baseToken, finalToken, existingOrder.oid); + } else if (newAmountToPlace.eq(bnZero)) { + // If the existing order is undefined and there is no new amount to place, then there is nothing actionable for us to do on this update. + return { actionable: false, mrkdwn: "No order updates required" }; + } + this.placeOrder(baseToken, finalToken, priceXe8, sizeXe8, oid); + return { actionable: true, mrkdwn: `Placed new limit order at px: ${bestAsk} and sz: ${newAmountToPlace}.` }; + } + + // Onchain function wrappers. + private placeOrder(baseToken: EvmAddress, finalToken: EvmAddress, price: number, size: BigNumber, oid: number) { + const l2TokenInfo = this._getTokenInfo(baseToken, this.chainId); + const finalTokenInfo = this._getTokenInfo(finalToken, this.chainId); + const dstHandler = l2TokenInfo.symbol === "USDC" ? this.dstCctpMessenger : this.dstOftMessenger; + + const mrkdwn = `baseToken: ${l2TokenInfo.symbol}\n finalToken: ${finalTokenInfo.symbol}\n price: ${price}\n size: ${size}\n oid: ${oid}`; + this.clients.multiCallerClient.enqueueTransaction({ + contract: dstHandler, + chainId: this.chainId, + method: "submitLimitOrderFromBot", + args: [finalToken.toNative(), price, size, oid], + message: `Submitted limit order of ${l2TokenInfo.symbol} -> ${finalTokenInfo.symbol} to Hypercore.`, + mrkdwn, + nonMulticall: true, // Cannot multicall this since it is a permissioned action. + }); + } + + private sendSponsorshipFundsToSwapHandler(baseToken: EvmAddress, amount: BigNumber) { + const l2TokenInfo = this._getTokenInfo(baseToken, this.chainId); + const dstHandler = l2TokenInfo.symbol === "USDC" ? this.dstCctpMessenger : this.dstOftMessenger; + + const mrkdwn = `baseToken: ${l2TokenInfo.symbol}\n amount: ${amount}`; + this.clients.multiCallerClient.enqueueTransaction({ + contract: dstHandler, + chainId: this.chainId, + method: "sendSponsorshipFundsToSwapHandler", + args: [baseToken.toNative(), amount], + message: "Sent sponsored funds to the swap handler.", + mrkdwn, + nonMulticall: true, // Cannot multicall this since it is a permissioned action. + unpermissioned: false, + }); + } + + private cancelLimitOrderByCloid(baseToken: EvmAddress, finalToken: EvmAddress, oid: number) { + const l2TokenInfo = this._getTokenInfo(baseToken, this.chainId); + const finalTokenInfo = this._getTokenInfo(finalToken, this.chainId); + const dstHandler = l2TokenInfo.symbol === "USDC" ? this.dstCctpMessenger : this.dstOftMessenger; + + const mrkdwn = `baseToken: ${l2TokenInfo}\n finalToken: ${finalTokenInfo.symbol}\n oid: ${oid}`; + this.clients.multiCallerClient.enqueueTransaction({ + contract: dstHandler, + chainId: this.chainId, + method: "cancelLimitOrderByCloid", + args: [finalToken.toNative(), oid], + message: `Cancelled limit order ${oid}.`, + mrkdwn, + nonMulticall: true, // Cannot multicall this since it is a permissioned action. + }); + } + + private async finalizeLimitOrders(finalToken: EvmAddress, quoteNonces: string[], limitOrderOutputs: BigNumber[]) { + // limitOrderOutputs is the amount of final tokens received for each limit order associated with a quoteNonce. + const l2TokenInfo = this._getTokenInfo(finalToken, this.chainId); + const dstHandler = l2TokenInfo.symbol === "USDC" ? this.dstCctpMessenger : this.dstOftMessenger; + + const mrkdwn = `finalToken: ${l2TokenInfo.symbol}\n quoteNonces: ${quoteNonces}\n limitOrderOuts: ${limitOrderOutputs}`; + this.clients.multiCallerClient.enqueueTransaction({ + contract: dstHandler, + chainId: this.chainId, + method: "finalizeSwapFlows", + args: [finalToken.toNative(), quoteNonces, limitOrderOutputs], + message: `Finalized ${quoteNonces.length} limit orders and sending output tokens to the user.`, + mrkdwn, + nonMulticall: true, // Cannot multicall this since it is a permissioned action. + }); + } + + private _getTokenInfo(token: EvmAddress, chainId: number) { + const tokenInfo = getTokenInfo(token, chainId); + const updatedSymbol = tokenInfo.symbol === "USDT" ? "USDT0" : tokenInfo.symbol; + return { + ...tokenInfo, + symbol: updatedSymbol, + }; + } + + // Task utilities + private async waitForNextTask(): Promise { + if (this.tasks.length > 0) { + return; + } + await new Promise((resolve) => (this.taskResolver = resolve)); + } + + private async *resolveNextTask() { + while (!abortController.signal.aborted) { + await this.waitForNextTask(); + + // Yield the first task to complete in the array of tasks and remove that promise from the list of outstanding tasks. + const taskResult = Promise.race(this.tasks); + this.tasks.splice(this.tasks.indexOf(Promise.resolve(taskResult)), 1); + + yield taskResult; + } + } + + private updateTasks(task: Promise) { + this.tasks.push(task); + this.taskResolver(); + } +} diff --git a/src/hyperliquid/HyperliquidExecutorClientHelper.ts b/src/hyperliquid/HyperliquidExecutorClientHelper.ts new file mode 100644 index 0000000000..992fd00a19 --- /dev/null +++ b/src/hyperliquid/HyperliquidExecutorClientHelper.ts @@ -0,0 +1,21 @@ +import winston from "winston"; +import { HyperliquidExecutorClients } from "./HyperliquidExecutor"; +import { constructClients, updateClients } from "../common"; +import { Signer, getProvider, CHAIN_IDs } from "../utils"; +import { HyperliquidExecutorConfig } from "./HyperliquidExecutorConfig"; + +export async function constructHyperliquidExecutorClients( + config: HyperliquidExecutorConfig, + logger: winston.Logger, + baseSigner: Signer +): Promise { + // hubPoolLookback can be very short since its not used for anything in the HyperliquidExecutor. + const hubPoolLookback = 7200; + const commonClients = await constructClients(logger, config, baseSigner, hubPoolLookback); + + // @dev No need to update the hub pool client since we don't call any functions on it. We only need the hubPool + // Contract instance, signer, and chain ID. + await updateClients(commonClients, config, logger); + const dstProvider = await getProvider(CHAIN_IDs.HYPEREVM); + return { ...commonClients, dstProvider }; +} diff --git a/src/hyperliquid/HyperliquidExecutorConfig.ts b/src/hyperliquid/HyperliquidExecutorConfig.ts new file mode 100644 index 0000000000..abf8b52a10 --- /dev/null +++ b/src/hyperliquid/HyperliquidExecutorConfig.ts @@ -0,0 +1,14 @@ +import { CommonConfig, ProcessEnv } from "../common"; + +export class HyperliquidExecutorConfig extends CommonConfig { + public readonly supportedTokens: string[]; + public readonly reviewInterval: number; + + constructor(env: ProcessEnv) { + super(env); + + const { HYPERLIQUID_SUPPORTED_TOKENS, HYPERLIQUID_REPLACE_ORDER_BLOCK_TIMEOUT } = env; + this.supportedTokens = JSON.parse(HYPERLIQUID_SUPPORTED_TOKENS ?? "[]"); + this.reviewInterval = Number(HYPERLIQUID_REPLACE_ORDER_BLOCK_TIMEOUT ?? 20); + } +} diff --git a/src/hyperliquid/index.ts b/src/hyperliquid/index.ts new file mode 100644 index 0000000000..53e83bed67 --- /dev/null +++ b/src/hyperliquid/index.ts @@ -0,0 +1,30 @@ +import { winston, config, startupLogLevel, Signer, disconnectRedisClients } from "../utils"; +import { HyperliquidExecutor } from "./HyperliquidExecutor"; +import { constructHyperliquidExecutorClients } from "./HyperliquidExecutorClientHelper"; +import { HyperliquidExecutorConfig } from "./HyperliquidExecutorConfig"; +config(); +let logger: winston.Logger; + +export async function runHyperliquidExecutor(_logger: winston.Logger, baseSigner: Signer): Promise { + logger = _logger; + const config = new HyperliquidExecutorConfig(process.env); + const clients = await constructHyperliquidExecutorClients(config, logger, baseSigner); + const executor = new HyperliquidExecutor(logger, config, clients); + await executor.initialize(); + + try { + logger[startupLogLevel(config)]({ + at: "HyperliquidExecutor#index", + message: "HyperliquidExecutor started", + config, + }); + const start = Date.now(); + + executor.startListeners(); + await executor.processTasks(); + + logger.debug({ at: "HyperliquidExecutor#index", message: `Time to run: ${(Date.now() - start) / 1000}s` }); + } finally { + await disconnectRedisClients(logger); + } +} diff --git a/src/interfaces/Hyperliquid.ts b/src/interfaces/Hyperliquid.ts new file mode 100644 index 0000000000..d9c5b416a1 --- /dev/null +++ b/src/interfaces/Hyperliquid.ts @@ -0,0 +1,14 @@ +import { BigNumber } from "../utils"; + +export interface SwapFlowInitialized { + quoteNonce: string; + finalRecipient: string; + finalToken: string; + // In baseToken + evmAmountIn: BigNumber; + bridgingFeesIncurred: BigNumber; + // In finalToken + coreAmountIn: BigNumber; + minAmountToSend: BigNumber; + maxAmountToSend: BigNumber; +} diff --git a/src/interfaces/InventoryManagement.ts b/src/interfaces/InventoryManagement.ts index 28919e909b..c6f3208aeb 100644 --- a/src/interfaces/InventoryManagement.ts +++ b/src/interfaces/InventoryManagement.ts @@ -19,6 +19,14 @@ export type ChainTokenInventory = { [symbol: string]: ChainTokenConfig; }; +export type SwapRoute = { + fromChain: number; + fromToken: string; + toChain: number; + toToken: string; + bidirectional: boolean; +}; + /** * Example configuration: * - DAI on chains 10 & 42161. @@ -64,6 +72,9 @@ export interface InventoryConfig { [chainId: number]: BigNumber; }; wrapEtherThreshold: BigNumber; + + // Allows caller to specify specific swap routes eligible for filling. + allowedSwapRoutes: SwapRoute[]; } export function isAliasConfig(config: ChainTokenConfig | ChainTokenInventory): config is ChainTokenInventory { diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 13963a0db4..8be25e43db 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -8,6 +8,7 @@ export * from "./Error"; export * from "./Report"; export * from "./Arweave"; export * from "./BundleData"; +export * from "./Hyperliquid"; // Bridge interfaces export interface OutstandingTransfers { diff --git a/src/libexec/RelayerSpokePoolListener.ts b/src/libexec/RelayerSpokePoolListener.ts index caa8be50a2..839675b10d 100644 --- a/src/libexec/RelayerSpokePoolListener.ts +++ b/src/libexec/RelayerSpokePoolListener.ts @@ -1,12 +1,10 @@ import assert from "assert"; import minimist from "minimist"; import { Contract, utils as ethersUtils } from "ethers"; -import { BaseError, Block, createPublicClient, Log as viemLog, webSocket } from "viem"; +import { BaseError, Block, createPublicClient, http, Log as viemLog, webSocket } from "viem"; import * as chains from "viem/chains"; -import { utils as sdkUtils } from "@across-protocol/sdk"; import * as utils from "../../scripts/utils"; import { - CHAIN_IDs, disconnectRedisClients, EventManager, exit, @@ -18,120 +16,143 @@ import { getNodeUrlList, getOriginFromURL, getProvider, + getProviderHeaders, getSpokePool, getRedisCache, Logger, + Provider, winston, } from "../utils"; import { ScraperOpts } from "./types"; import { postEvents, removeEvent } from "./util/ipc"; -import { getEventFilterArgs, scrapeEvents as _scrapeEvents } from "./util/evm"; +import { scrapeEvents as _scrapeEvents } from "./util/evm"; const { NODE_SUCCESS, NODE_APP_ERR } = utils; -const INDEXER_POLLING_PERIOD = 2_000; // ms; time to sleep between checking for exit request via SIGHUP. +const PROGRAM = "RelayerSpokePoolListener"; +const abortController = new AbortController(); +let providers: ReturnType; +let spokePool: Contract; let logger: winston.Logger; let chainId: number; let chain: string; -let stop = false; - -// This mapping is necessary because viem imposes extremely narrow type inference. @todo: Improve? -const _chains = { - [CHAIN_IDs.ARBITRUM]: chains.arbitrum, - [CHAIN_IDs.BASE]: chains.base, - [CHAIN_IDs.BLAST]: chains.blast, - [CHAIN_IDs.LINEA]: chains.linea, - [CHAIN_IDs.LISK]: chains.lisk, - [CHAIN_IDs.MAINNET]: chains.mainnet, - [CHAIN_IDs.MODE]: chains.mode, - [CHAIN_IDs.OPTIMISM]: chains.optimism, - [CHAIN_IDs.POLYGON]: chains.polygon, - [CHAIN_IDs.REDSTONE]: chains.redstone, - [CHAIN_IDs.SCROLL]: chains.scroll, - [CHAIN_IDs.WORLD_CHAIN]: chains.worldchain, - [CHAIN_IDs.ZK_SYNC]: chains.zksync, - [CHAIN_IDs.ZORA]: chains.zora, -} as const; // Teach BigInt how to be represented as JSON. (BigInt.prototype as any).toJSON = function () { return this.toString(); }; +/** + * Instantiate websocket providers. + * @param chainId Chain ID of network. + * @param quorum Minimum number of providers required. + * @returns An array of websocket providers. + */ +function resolveProviders(chainId: number, quorum = 1) { + const protocol = process.env[`RPC_PROVIDERS_TRANSPORT_${chainId}`] ?? "wss"; + assert(protocol === "wss" || protocol === "https"); + + const urls = Object.values(getNodeUrlList(chainId, quorum, protocol)); + const nProviders = urls.length; + assert(nProviders >= quorum, `Insufficient providers for ${chain} (minimum ${quorum} required by quorum)`); + + const viemChain = Object.values(chains).find(({ id }) => id === chainId); + const providers = Object.entries(urls).map(([provider, url]) => { + const headers = getProviderHeaders(provider, chainId); + const transport = protocol === "wss" ? webSocket(url) : http(url, { fetchOptions: { headers } }); + + return createPublicClient({ + chain: viemChain, + transport, + name: getOriginFromURL(url), + }); + }); + + return providers; +} + /** * Aggregate utils/scrapeEvents for a series of event names. - * @param spokePool Ethers Contract instance. - * @param eventNames The array of events to be queried. + * @param address A contract address to query. + * @param eventSignatures An array of event signatures to be queried. + * @param provider An Ethers provider instance. * @param opts Options to configure event scraping behaviour. * @returns void */ -export async function scrapeEvents(spokePool: Contract, eventNames: string[], opts: ScraperOpts): Promise { - const { number: toBlock, timestamp: currentTime } = await spokePool.provider.getBlock("latest"); +async function scrapeEvents( + address: string, + eventSignatures: string[], + provider: Provider, + opts: ScraperOpts +): Promise { + const at = `${PROGRAM}::scrapeEvents`; + const { number: toBlock, timestamp: currentTime } = await provider.getBlock("latest"); + const events = await Promise.all( - eventNames.map((eventName) => _scrapeEvents(spokePool, eventName, { ...opts, toBlock }, logger)) + eventSignatures.map(async (sig) => { + try { + return await _scrapeEvents(provider, address, sig, { ...opts, toBlock }, logger); + } catch { + logger.warn({ at, message: `Failed to scrape ${chain} events.`, event: sig }); + return Promise.resolve([]); + } + }) ); - if (!stop) { + if (!abortController.signal.aborted) { postEvents(toBlock, currentTime, events.flat()); } } /** - * Given a SpokePool contract instance and an array of event names, subscribe to all future event emissions. - * Periodically transmit received events to the parent process (if defined). - * @param eventMgr Ethers Contract instance. - * @param eventName The name of the event to be filtered. - * @param opts Options to configure event scraping behaviour. + * Setup a newHeads subscription. + * @param eventMgr Event Manager instance. * @returns void */ -async function listen(eventMgr: EventManager, spokePool: Contract, eventNames: string[], quorum = 1): Promise { - const urls = Object.values(getNodeUrlList(chainId, quorum, "wss")); - const nProviders = urls.length; - assert(nProviders >= quorum, `Insufficient providers for ${chain} (required ${quorum} by quorum)`); - - const providers = urls.map((url) => - createPublicClient({ - chain: _chains[chainId], - transport: webSocket(url), - name: getOriginFromURL(url), - }) - ); +function subNewHeads(eventMgr: EventManager): void { + const at = `${PROGRAM}::newHeads`; // On each new block, submit any "finalised" events. const newBlock = (block: Block, provider: string) => { // Transient error that sometimes occurs in production. Catch it here and try to flush out the provider. if (!block) { - logger.debug({ - at: "RelayerSpokePoolListener::run", - message: `Received empty ${chain} block from ${provider}.`, - }); + logger.debug({ at, message: `Received empty ${chain} block from ${provider}.` }); return; } const [blockNumber, currentTime] = [parseInt(block.number.toString()), parseInt(block.timestamp.toString())]; const events = eventMgr.tick(); if (!postEvents(blockNumber, currentTime, events)) { - stop = true; + abortController.abort(); } }; const blockError = (error: Error, provider: string) => { - const at = "RelayerSpokePoolListener::run"; const message = `Caught ${chain} provider error.`; const { message: errorMessage, details, shortMessage, metaMessages } = error as BaseError; logger.debug({ at, message, errorMessage, shortMessage, provider, details, metaMessages }); }; - providers.forEach((provider, idx) => { - if (idx === 0) { - provider.watchBlocks({ - emitOnBegin: true, - onBlock: (block: Block) => newBlock(block, provider.name), - onError: (error: Error) => blockError(error, provider.name), - }); - } + const [provider] = providers; + provider.watchBlocks({ + emitOnBegin: true, + onBlock: (block: Block) => newBlock(block, provider.name), + onError: (error: Error) => blockError(error, provider.name), + }); +} - const abi = JSON.parse(spokePool.interface.format(ethersUtils.FormatTypes.json) as string); +/** + * Given a SpokePool contract instance and an array of event names, subscribe to all future event emissions. + * Periodically transmit received events to the parent process (if defined). + * @param eventMgr Ethers Contract instance. + * @param spokePool ethers SpokePool contract instances. + * @param eventName The name of the event to be filtered. + * @returns void + */ +function subEvents(eventMgr: EventManager, spokePool: Contract, eventNames: string[]): void { + const abi = JSON.parse(spokePool.interface.format(ethersUtils.FormatTypes.json) as string); + + providers.forEach((provider) => { eventNames.forEach((eventName) => { provider.watchContractEvent({ address: spokePool.address as `0x${string}`, @@ -156,26 +177,23 @@ async function listen(eventMgr: EventManager, spokePool: Contract, eventNames: s }); }); }); - - do { - await sdkUtils.delay(INDEXER_POLLING_PERIOD); - } while (!stop); } /** * Main entry point. */ async function run(argv: string[]): Promise { + const at = `${PROGRAM}::run`; + const minimistOpts = { - string: ["lookback", "relayer", "spokepool"], + string: ["lookback", "spokepool"], }; const args = minimist(argv, minimistOpts); ({ chainid: chainId } = args); - const { lookback, relayer = null, blockrange: maxBlockRange = 10_000 } = args; + const { lookback, blockrange: maxBlockRange = 10_000 } = args; assert(Number.isInteger(chainId), "chainId must be numeric "); assert(Number.isInteger(maxBlockRange), "maxBlockRange must be numeric"); - assert(!isDefined(relayer) || ethersUtils.isAddress(relayer), `relayer address is invalid (${relayer})`); const { quorum = getChainQuorum(chainId) } = args; assert(Number.isInteger(quorum), "quorum must be numeric "); @@ -206,10 +224,10 @@ async function run(argv: string[]): Promise { await getBlockForTimestamp(logger, chainId, latestBlock.timestamp - lookback, blockFinder, cache) ); } else { - logger.debug({ at: "RelayerSpokePoolListener::run", message: `Skipping lookback on ${chain}.` }); + logger.debug({ at, message: `Skipping lookback on ${chain}.` }); } - const spokePool = getSpokePool(chainId, spokePoolAddr); + spokePool = getSpokePool(chainId, spokePoolAddr); if (!isDefined(spokePoolAddr)) { ({ address: spokePoolAddr } = spokePool); } @@ -219,24 +237,23 @@ async function run(argv: string[]): Promise { deploymentBlock, lookback: latestBlock.number - startBlock, maxBlockRange, - filterArgs: getEventFilterArgs(relayer), quorum, }; - logger.debug({ at: "RelayerSpokePoolListener::run", message: `Starting ${chain} SpokePool Indexer.`, opts }); + logger.debug({ at, message: `Starting ${chain} SpokePool Indexer.`, opts }); process.on("SIGHUP", () => { - logger.debug({ at: "Relayer#run", message: `Received SIGHUP in ${chain} listener, stopping...` }); - stop = true; + logger.debug({ at, message: `Received SIGHUP in ${chain} listener, stopping...` }); + abortController.abort(); }); process.on("disconnect", () => { - logger.debug({ at: "Relayer::run", message: `${chain} parent disconnected, stopping...` }); - stop = true; + logger.debug({ at, message: `${chain} parent disconnected, stopping...` }); + abortController.abort(); }); // Note: An event emitted between scrapeEvents() and listen(). @todo: Ensure that there is overlap and deduplication. - logger.debug({ at: "RelayerSpokePoolListener::run", message: `Scraping previous ${chain} events.`, opts }); + logger.debug({ at, message: `Scraping previous ${chain} events.`, opts }); if (latestBlock.number > startBlock) { const events = [ @@ -246,19 +263,28 @@ async function run(argv: string[]): Promise { "RelayedRootBundle", "ExecutedRelayerRefundRoot", ]; + const _spokePool = spokePool.connect(quorumProvider); - await scrapeEvents(_spokePool, events, opts); + const { address, interface: abi, provider } = _spokePool; + const signatures = events.map((event) => abi.getEvent(event).format(ethersUtils.FormatTypes.full)); + await scrapeEvents(address, signatures, provider, opts); } // Events to listen for. const events = ["FundsDeposited", "FilledRelay"]; const eventMgr = new EventManager(logger, chainId, quorum); + providers = resolveProviders(chainId, quorum); + + logger.debug({ at, message: `Starting ${chain} listener.`, events, opts }); + + subNewHeads(eventMgr); + subEvents(eventMgr, spokePool, events); - logger.debug({ at: "RelayerSpokePoolListener::run", message: `Starting ${chain} listener.`, events, opts }); - await listen(eventMgr, spokePool, events, quorum); + return new Promise((resolve) => abortController.signal.addEventListener("abort", () => resolve())); } if (require.main === module) { + const at = PROGRAM; logger = Logger; run(process.argv.slice(2)) @@ -266,12 +292,12 @@ if (require.main === module) { process.exitCode = NODE_SUCCESS; }) .catch((error) => { - logger.error({ at: "RelayerSpokePoolListener", message: `${chain} listener exited with error.`, error }); + logger.error({ at, message: `${chain} listener exited with error.`, error }); process.exitCode = NODE_APP_ERR; }) .finally(async () => { await disconnectRedisClients(); - logger.debug({ at: "RelayerSpokePoolListener", message: `Exiting ${chain} listener.` }); + logger.debug({ at, message: `Exiting ${chain} listener.` }); exit(Number(process.exitCode)); }); } diff --git a/src/libexec/RelayerSpokePoolListenerHTTPS.ts b/src/libexec/RelayerSpokePoolListenerHTTPS.ts deleted file mode 100644 index 9f63193833..0000000000 --- a/src/libexec/RelayerSpokePoolListenerHTTPS.ts +++ /dev/null @@ -1,255 +0,0 @@ -import assert from "assert"; -import minimist from "minimist"; -import { Contract, utils as ethersUtils } from "ethers"; -import { createPublicClient, Log as viemLog, http } from "viem"; -import * as chains from "viem/chains"; -import { utils as sdkUtils } from "@across-protocol/sdk"; -import * as utils from "../../scripts/utils"; -import { - CHAIN_IDs, - disconnectRedisClients, - EventManager, - exit, - isDefined, - getBlockForTimestamp, - getChainQuorum, - getCurrentTime, - getDeploymentBlockNumber, - getNetworkName, - getNodeUrlList, - getOriginFromURL, - getProvider, - getProviderHeaders, - getSpokePool, - getRedisCache, - Logger, - winston, -} from "../utils"; -import { ScraperOpts } from "./types"; -import { postEvents, removeEvent } from "./util/ipc"; -import { getEventFilterArgs, scrapeEvents as _scrapeEvents } from "./util/evm"; - -const { NODE_SUCCESS, NODE_APP_ERR } = utils; - -const INDEXER_POLLING_PERIOD = 2_000; // ms; time to sleep between checking for exit request via SIGHUP. - -let logger: winston.Logger; -let chainId: number; -let chain: string; -let stop = false; - -// This mapping is necessary because viem imposes extremely narrow type inference. @todo: Improve? -const _chains = { - [CHAIN_IDs.ARBITRUM]: chains.arbitrum, - [CHAIN_IDs.BASE]: chains.base, - [CHAIN_IDs.BLAST]: chains.blast, - [CHAIN_IDs.LINEA]: chains.linea, - [CHAIN_IDs.LISK]: chains.lisk, - [CHAIN_IDs.MAINNET]: chains.mainnet, - [CHAIN_IDs.MODE]: chains.mode, - [CHAIN_IDs.OPTIMISM]: chains.optimism, - [CHAIN_IDs.POLYGON]: chains.polygon, - [CHAIN_IDs.REDSTONE]: chains.redstone, - [CHAIN_IDs.SCROLL]: chains.scroll, - [CHAIN_IDs.WORLD_CHAIN]: chains.worldchain, - [CHAIN_IDs.ZK_SYNC]: chains.zksync, - [CHAIN_IDs.ZORA]: chains.zora, -} as const; - -// Teach BigInt how to be represented as JSON. -(BigInt.prototype as any).toJSON = function () { - return this.toString(); -}; - -/** - * Aggregate utils/scrapeEvents for a series of event names. - * @param spokePool Ethers Contract instance. - * @param eventNames The array of events to be queried. - * @param opts Options to configure event scraping behaviour. - * @returns void - */ -export async function scrapeEvents(spokePool: Contract, eventNames: string[], opts: ScraperOpts): Promise { - const { number: toBlock, timestamp: currentTime } = await spokePool.provider.getBlock("latest"); - const events = await Promise.all( - eventNames.map((eventName) => _scrapeEvents(spokePool, eventName, { ...opts, toBlock }, logger)) - ); - - if (!stop) { - postEvents(toBlock, currentTime, events.flat()); - } -} - -/** - * Given a SpokePool contract instance and an array of event names, subscribe to all future event emissions. - * Periodically transmit received events to the parent process (if defined). - * @param eventMgr Ethers Contract instance. - * @param eventName The name of the event to be filtered. - * @param opts Options to configure event scraping behaviour. - * @returns void - */ -async function listen(eventMgr: EventManager, spokePool: Contract, eventNames: string[], quorum = 1): Promise { - const urls = getNodeUrlList(chainId, quorum); - const nProviders = Object.values(urls).length; - assert(nProviders >= quorum, `Insufficient providers for ${chain} (required ${quorum} by quorum)`); - - const providers = Object.entries(urls).map(([provider, url]) => { - const headers = getProviderHeaders(provider, chainId); - return createPublicClient({ - chain: _chains[chainId], - transport: http(url, { fetchOptions: { headers } }), - name: getOriginFromURL(url), - }); - }); - - providers.forEach((provider) => { - const abi = JSON.parse(spokePool.interface.format(ethersUtils.FormatTypes.json) as string); - eventNames.forEach((eventName) => { - provider.watchContractEvent({ - address: spokePool.address as `0x${string}`, - abi, - eventName, - onLogs: (logs: viemLog[]) => { - logs.forEach((log) => { - const event = { - ...log, - args: log["args"], - blockNumber: Number(log.blockNumber), - event: log["eventName"], - topics: [], // Not supplied by viem, but not actually used by the relayer. - }; - if (log.removed) { - eventMgr.remove(event, provider.name); - removeEvent(event); - } else { - eventMgr.add(event, provider.name); - } - }); - - const events = eventMgr.tick(); - const { blockNumber } = events.at(-1); - if (!postEvents(blockNumber, getCurrentTime(), events)) { - stop = true; - } - }, - }); - }); - }); - - do { - await sdkUtils.delay(INDEXER_POLLING_PERIOD); - } while (!stop); -} - -/** - * Main entry point. - */ -async function run(argv: string[]): Promise { - const minimistOpts = { - string: ["lookback", "relayer", "spokepool"], - }; - const args = minimist(argv, minimistOpts); - - ({ chainid: chainId } = args); - const { lookback, relayer = null, blockrange: maxBlockRange = 10_000 } = args; - assert(Number.isInteger(chainId), "chainId must be numeric "); - assert(Number.isInteger(maxBlockRange), "maxBlockRange must be numeric"); - assert(!isDefined(relayer) || ethersUtils.isAddress(relayer), `relayer address is invalid (${relayer})`); - - const { quorum = getChainQuorum(chainId) } = args; - assert(Number.isInteger(quorum), "quorum must be numeric "); - - let { spokepool: spokePoolAddr } = args; - assert( - !isDefined(spokePoolAddr) || ethersUtils.isAddress(spokePoolAddr), - `Invalid SpokePool address (${spokePoolAddr})` - ); - - chain = getNetworkName(chainId); - - const quorumProvider = await getProvider(chainId); - const blockFinder = undefined; - const cache = await getRedisCache(); - const latestBlock = await quorumProvider.getBlock("latest"); - - const deploymentBlock = getDeploymentBlockNumber("SpokePool", chainId); - let startBlock = latestBlock.number; - if (/^@[0-9]+$/.test(lookback)) { - // Lookback to a specific block (lookback = @). - startBlock = Number(lookback.slice(1)); - } else if (isDefined(lookback)) { - // Resolve `lookback` seconds from head to a specific block. - assert(Number.isInteger(Number(lookback)), `Invalid lookback (${lookback})`); - startBlock = Math.max( - deploymentBlock, - await getBlockForTimestamp(logger, chainId, latestBlock.timestamp - lookback, blockFinder, cache) - ); - } else { - logger.debug({ at: "RelayerSpokePoolListener::run", message: `Skipping lookback on ${chain}.` }); - } - - const spokePool = getSpokePool(chainId, spokePoolAddr); - if (!isDefined(spokePoolAddr)) { - ({ address: spokePoolAddr } = spokePool); - } - - const opts = { - spokePool: spokePoolAddr, - deploymentBlock, - lookback: latestBlock.number - startBlock, - maxBlockRange, - filterArgs: getEventFilterArgs(relayer), - quorum, - }; - - logger.debug({ at: "RelayerSpokePoolListener::run", message: `Starting ${chain} SpokePool Indexer.`, opts }); - - process.on("SIGHUP", () => { - logger.debug({ at: "Relayer#run", message: `Received SIGHUP in ${chain} listener, stopping...` }); - stop = true; - }); - - process.on("disconnect", () => { - logger.debug({ at: "Relayer::run", message: `${chain} parent disconnected, stopping...` }); - stop = true; - }); - - // Note: An event emitted between scrapeEvents() and listen(). @todo: Ensure that there is overlap and deduplication. - logger.debug({ at: "RelayerSpokePoolListener::run", message: `Scraping previous ${chain} events.`, opts }); - - if (latestBlock.number > startBlock) { - const events = [ - "FundsDeposited", - "FilledRelay", - "RequestedSpeedUpDeposit", - "RelayedRootBundle", - "ExecutedRelayerRefundRoot", - ]; - const _spokePool = spokePool.connect(quorumProvider); - await scrapeEvents(_spokePool, events, opts); - } - - // Events to listen for. - const events = ["FundsDeposited", "FilledRelay"]; - const eventMgr = new EventManager(logger, chainId, quorum); - - logger.debug({ at: "RelayerSpokePoolListener::run", message: `Starting ${chain} listener.`, events, opts }); - await listen(eventMgr, spokePool, events, quorum); -} - -if (require.main === module) { - logger = Logger; - - run(process.argv.slice(2)) - .then(() => { - process.exitCode = NODE_SUCCESS; - }) - .catch((error) => { - logger.error({ at: "RelayerSpokePoolListener", message: `${chain} listener exited with error.`, error }); - process.exitCode = NODE_APP_ERR; - }) - .finally(async () => { - await disconnectRedisClients(); - logger.debug({ at: "RelayerSpokePoolListener", message: `Exiting ${chain} listener.` }); - exit(Number(process.exitCode)); - }); -} diff --git a/src/libexec/RelayerSpokePoolListenerSVM.ts b/src/libexec/RelayerSpokePoolListenerSVM.ts index c6165b2e26..6eff005589 100644 --- a/src/libexec/RelayerSpokePoolListenerSVM.ts +++ b/src/libexec/RelayerSpokePoolListenerSVM.ts @@ -32,6 +32,7 @@ type EventWithData = arch.svm.EventWithData; const { NODE_SUCCESS, NODE_APP_ERR } = utils; const abortController = new AbortController(); +const PROGRAM = "RelayerSpokePoolListenerSVM"; let logger: winston.Logger; let chainId: number; let chain: string; @@ -70,10 +71,10 @@ function logFromEvent(event: Pick { + const at = `${PROGRAM}::run`; const minimistOpts = { string: ["lookback", "spokepool"], }; @@ -232,7 +234,7 @@ async function run(argv: string[]): Promise { ) ); } else { - logger.debug({ at: "RelayerSpokePoolListener::run", message: `Skipping lookback on ${chain}.` }); + logger.debug({ at, message: `Skipping lookback on ${chain}.` }); } const opts = { @@ -241,15 +243,15 @@ async function run(argv: string[]): Promise { lookback: Number(latestSlot - startSlot), }; - logger.debug({ at: "RelayerSpokePoolListener::run", message: `Starting ${chain} SpokePool Indexer.`, opts }); + logger.debug({ at, message: `Starting ${chain} SpokePool Indexer.`, opts }); process.on("SIGHUP", () => { - logger.debug({ at: "Relayer#run", message: `Received SIGHUP in ${chain} listener, stopping...` }); + logger.debug({ at, message: `Received SIGHUP in ${chain} listener, stopping...` }); abortController.abort(); }); process.on("disconnect", () => { - logger.debug({ at: "Relayer::run", message: `${chain} parent disconnected, stopping...` }); + logger.debug({ at, message: `${chain} parent disconnected, stopping...` }); abortController.abort(); }); @@ -260,13 +262,14 @@ async function run(argv: string[]): Promise { } const events = ["FundsDeposited", "FilledRelay"]; - logger.debug({ at: "RelayerSpokePoolListener::run", message: `Starting ${chain} listener.`, events, opts }); + logger.debug({ at, message: `Starting ${chain} listener.`, events, opts }); const eventMgr = new EventManager(logger, chainId, quorum); await listen(eventMgr, eventsClient, events, quorum); } if (require.main === module) { + const at = PROGRAM; logger = Logger; run(process.argv.slice(2)) @@ -274,12 +277,12 @@ if (require.main === module) { process.exitCode = NODE_SUCCESS; }) .catch((error) => { - logger.error({ at: "RelayerSpokePoolListener", message: `${chain} listener exited with error.`, error }); + logger.error({ at, message: `${chain} listener exited with error.`, error }); process.exitCode = NODE_APP_ERR; }) .finally(async () => { await disconnectRedisClients(); - logger.debug({ at: "RelayerSpokePoolListener", message: `Exiting ${chain} listener.` }); + logger.debug({ at, message: `Exiting ${chain} listener.` }); exit(Number(process.exitCode)); }); } diff --git a/src/libexec/types.ts b/src/libexec/types.ts index c1986dfb04..f13e0d4524 100644 --- a/src/libexec/types.ts +++ b/src/libexec/types.ts @@ -1,5 +1,4 @@ export { Log } from "../interfaces"; -export { SpokePoolClientMessage } from "../clients"; export type ScraperOpts = { lookback?: number; // Event lookback (in seconds). @@ -7,3 +6,16 @@ export type ScraperOpts = { maxBlockRange?: number; // Maximum block range for paginated getLogs queries. filterArgs?: { [event: string]: string[] }; // Event-specific filter criteria to apply. }; + +type EventRemoved = { + event: string; +}; + +type EventsAdded = { + blockNumber: number; + currentTime: number; + nEvents: number; // Number of events. + data: string; +}; + +export type ListenerMessage = EventsAdded | EventRemoved; diff --git a/src/libexec/util/evm/util.ts b/src/libexec/util/evm/util.ts index 086234f7cb..e525468b87 100644 --- a/src/libexec/util/evm/util.ts +++ b/src/libexec/util/evm/util.ts @@ -1,49 +1,21 @@ import assert from "assert"; -import { Contract, EventFilter } from "ethers"; -import { getNetworkName, isDefined, paginatedEventQuery, Profiler, winston } from "../../../utils"; +import { Contract, utils as ethersUtils } from "ethers"; +import { getNetworkName, paginatedEventQuery, Profiler, Provider, winston } from "../../../utils"; import { Log, ScraperOpts } from "../../types"; /** - * Given an event name and contract, return the corresponding Ethers EventFilter object. - * @param contract Ethers Contract instance. - * @param eventName The name of the event to be filtered. - * @param filterArgs Optional filter arguments to be applied. - * @returns An Ethers EventFilter instance. - */ -export function getEventFilter(contract: Contract, eventName: string, filterArgs?: string[]): EventFilter { - const filter = contract.filters[eventName]; - if (!isDefined(filter)) { - throw new Error(`Event ${eventName} not defined for contract`); - } - - return isDefined(filterArgs) ? filter(...filterArgs) : filter(); -} - -/** - * Get a general event filter mapping to be used for filtering SpokePool contract events. - * This is currently only useful for filtering the relayer address on FilledRelay events. - * @param relayer Optional relayer address to filter on. - * @returns An argument array for input to an Ethers EventFilter. - */ -export function getEventFilterArgs(relayer?: string): { [event: string]: (null | string)[] } { - const FilledRelay = !isDefined(relayer) - ? undefined - : [null, null, null, null, null, null, null, null, null, null, relayer]; - - return { FilledRelay }; -} - -/** - * Given a SpokePool contract instance and an event name, scrape all corresponding events and submit them to the - * parent process (if defined). - * @param spokePool Ethers Contract instance. - * @param eventName The name of the event to be filtered. - * @param opts Options to configure event scraping behaviour. - * @returns void + * Scrape events from a contract for a given event signature within a specified block range. + * @param provider Ethers RPC provider instance. + * @param address Contract address to filter on. + * @param event The event descriptor to filter for. + * @param opts Options to configure event scraping behaviour, including the target block number. + * @param logger Optional Winston logger instance. + * @returns Promise resolving to an array of log entries. */ export async function scrapeEvents( - spokePool: Contract, - eventName: string, + provider: Provider, + address: string, + event: string, opts: ScraperOpts & { toBlock: number }, logger?: winston.Logger ): Promise { @@ -51,22 +23,25 @@ export async function scrapeEvents( logger, at: "scrapeEvents", }); - const { lookback, deploymentBlock, filterArgs, maxBlockRange, toBlock } = opts; - const { chainId } = await spokePool.provider.getNetwork(); + const { lookback, deploymentBlock, maxBlockRange, toBlock } = opts; + const { chainId } = await provider.getNetwork(); const chain = getNetworkName(chainId); const fromBlock = Math.max(toBlock - (lookback ?? deploymentBlock), deploymentBlock); assert(toBlock > fromBlock, `${toBlock} > ${fromBlock}`); const searchConfig = { from: fromBlock, to: toBlock, maxLookBack: maxBlockRange }; + const eventFrag = ethersUtils.Fragment.from(event); + assert(ethersUtils.EventFragment.isEventFragment(eventFrag), `Invalid event descriptor (${event})`); + + const abi = new ethersUtils.Interface([event]); + const contract = new Contract(address, abi); + const [filter] = Object.values(contract.filters); + const mark = profiler.start("paginatedEventQuery"); - const filter = getEventFilter(spokePool, eventName, filterArgs[eventName]); - const events = await paginatedEventQuery(spokePool, filter, searchConfig); + const events = await paginatedEventQuery(contract.connect(provider), filter(), searchConfig); mark.stop({ - message: `Scraped ${events.length} ${chain} ${eventName} events.`, - numEvents: events.length, - chain, - eventName, + message: `Scraped ${events.length} ${chain} ${eventFrag.name} events.`, searchConfig, }); diff --git a/src/libexec/util/ipc.ts b/src/libexec/util/ipc.ts index d2ba3058b0..2dfc2045d1 100644 --- a/src/libexec/util/ipc.ts +++ b/src/libexec/util/ipc.ts @@ -1,6 +1,6 @@ import { utils as sdkUtils } from "@across-protocol/sdk"; import { isDefined, sortEventsAscending } from "../../utils"; -import { Log, SpokePoolClientMessage } from "./../types"; +import { Log, ListenerMessage } from "./../types"; /** * Given the inputs for a SpokePoolClient update, consolidate the inputs into a message and submit it to the parent @@ -18,7 +18,7 @@ export function postEvents(blockNumber: number, currentTime: number, events: Log } events = sortEventsAscending(events); - const message: SpokePoolClientMessage = { + const message: ListenerMessage = { blockNumber, currentTime, nEvents: events.length, @@ -45,7 +45,7 @@ export function removeEvent(event: Log): void { return; } - const message: SpokePoolClientMessage = { + const message: ListenerMessage = { event: JSON.stringify(event, sdkUtils.jsonReplacerWithBigNumbers), }; process.send(JSON.stringify(message)); diff --git a/src/libexec/util/svm/util.ts b/src/libexec/util/svm/util.ts index 5698d3af86..6e00ccf435 100644 --- a/src/libexec/util/svm/util.ts +++ b/src/libexec/util/svm/util.ts @@ -4,12 +4,13 @@ import { Profiler, winston } from "../../../utils"; import { ScraperOpts } from "../../types"; /** - * Given a SpokePool contract instance and an event name, scrape all corresponding events and submit them to the - * parent process (if defined). - * @param spokePool Ethers Contract instance. + * Scrape events from a SVM CPI events client for a given event name within a specified block range. + * @param chain Chain name identifier. + * @param eventsClient SVM CPI events client instance. * @param eventName The name of the event to be filtered. - * @param opts Options to configure event scraping behaviour. - * @returns void + * @param opts Options to configure event scraping behaviour, including the target block number. + * @param logger Winston logger instance. + * @returns Promise resolving to an array of events with data. */ export async function scrapeEvents( chain: string, diff --git a/src/monitor/Monitor.ts b/src/monitor/Monitor.ts index 68a74094eb..96daf303fb 100644 --- a/src/monitor/Monitor.ts +++ b/src/monitor/Monitor.ts @@ -184,7 +184,7 @@ export class Monitor { const mrkdwn = `${l1TokenUtilization.poolCollateralSymbol} pool token at \ ${blockExplorerLink(l1TokenUtilization.l1Token.toEvmAddress(), l1TokenUtilization.chainId)} on \ ${getNetworkName(l1TokenUtilization.chainId)} is at \ - ${createFormatFunction(0, 2)(utilizationString)}% utilization!"`; + ${createFormatFunction(0, 2)(utilizationString)}% utilization!`; this.logger.debug({ at: "Monitor#checkUtilization", message: "High pool utilization warning 🏊", mrkdwn }); } } diff --git a/src/monitor/MonitorClientHelper.ts b/src/monitor/MonitorClientHelper.ts index 7874e834f7..96e095538f 100644 --- a/src/monitor/MonitorClientHelper.ts +++ b/src/monitor/MonitorClientHelper.ts @@ -26,7 +26,7 @@ export async function constructMonitorClients( ): Promise { const signerAddr = await baseSigner.getAddress(); // Set hubPoolLookback conservatively to be equal to one month of blocks. If the config.maxRelayerLookBack - // exceeds half a month, then we'll just use the gensis block since in that case, this monitor is being used + // exceeds half a month, then we'll just use the genesis block since in that case, this monitor is being used // for non-production circumstances. const hubPoolLookback = config.maxRelayerLookBack > 3600 * 24 * 15 ? undefined : 3600 * 24 * 30; const commonClients = await constructClients(logger, config, baseSigner, hubPoolLookback); diff --git a/src/refiller/Refiller.ts b/src/refiller/Refiller.ts index 9d05e63de9..045f3990e9 100644 --- a/src/refiller/Refiller.ts +++ b/src/refiller/Refiller.ts @@ -1,5 +1,6 @@ import winston from "winston"; -import { RefillerConfig } from "./RefillerConfig"; +import axios from "axios"; +import { RefillerConfig, RefillBalanceData } from "./RefillerConfig"; import { Address, assert, @@ -29,8 +30,14 @@ import { TransactionReceipt, WETH9, isDefined, + chainIsL1, + Provider, + bnZero, + getL1TokenAddress, + CHAIN_IDs, } from "../utils"; -import { SWAP_ROUTES, SwapRoute } from "../common"; +import { SWAP_ROUTES, SwapRoute, CUSTOM_BRIDGE, CANONICAL_BRIDGE } from "../common"; +import ERC20_ABI from "../common/abi/MinimalERC20.json"; import { arch } from "@across-protocol/sdk"; import { AcrossSwapApiClient, BalanceAllocator, MultiCallerClient } from "../clients"; import { RedisCache } from "../caching/RedisCache"; @@ -74,7 +81,7 @@ export class Refiller { this.initialized = true; } - async refillNativeTokenBalances(): Promise { + async refillBalances(): Promise { assert(this.initialized, "not initialized, call initialize() first"); const { refillEnabledBalances } = this.config; @@ -82,186 +89,278 @@ export class Refiller { const currentBalances = await this._getBalances(refillEnabledBalances); const decimalValues = await this._getDecimals(refillEnabledBalances); this.logger.debug({ - at: "Refiller#refillNativeTokenBalances", - message: "Checking native token balances", + at: "Refiller#refillBalances", + message: "Checking token balances", currentBalances: refillEnabledBalances.map(({ chainId, token, account, target }, i) => { return { chainId, - token: token.toEvmAddress(), - account: account.toEvmAddress(), + token: token.toNative(), + account: account.toNative(), currentBalance: currentBalances[i].toString(), target: parseUnits(target.toString(), decimalValues[i]), }; }), }); - - // Compare current balances with triggers and send tokens if signer has enough balance. - await mapAsync(refillEnabledBalances, async ({ chainId, isHubPool, token, account, target, trigger }, i) => { - assert(token.eq(getNativeTokenAddressForChain(chainId)), "Token must be the native token"); + await mapAsync(refillEnabledBalances, async (refillBalanceData, i) => { + const { token, chainId } = refillBalanceData; const l2Provider = this.clients.balanceAllocator.providers[chainId]; assert(l2Provider, `No L2 provider found for chain ${chainId}, have you overridden the spoke pool chains?`); + let refillHandler; + switch (token.toNative()) { + case getNativeTokenAddressForChain(chainId).toNative(): + refillHandler = this.refillNativeTokenBalances; + break; + case TOKEN_SYMBOLS_MAP.USDH.addresses[CHAIN_IDs.HYPEREVM]: + refillHandler = this.refillUsdh; + break; + default: + refillHandler = this.refillTokenBalances; + } + return refillHandler.bind(this)(currentBalances[i], decimalValues[i], refillBalanceData, l2Provider); + }); + } + + private async refillNativeTokenBalances( + currentBalance: BigNumber, + decimals: number, + refillBalances: RefillBalanceData, + l2Provider: Provider + ): Promise { + const { chainId, isHubPool, token, account, target, trigger } = refillBalances; + // Compare current balances with triggers and send tokens if signer has enough balance. - const currentBalance = currentBalances[i]; - const decimals = decimalValues[i]; - const balanceTrigger = parseUnits(trigger.toString(), decimals); - const isBelowTrigger = currentBalance.lte(balanceTrigger); - if (!isBelowTrigger) { + const balanceTrigger = parseUnits(trigger.toString(), decimals); + const isBelowTrigger = currentBalance.lte(balanceTrigger); + if (!isBelowTrigger) { + this.logger.debug({ + at: "Refiller#refillBalances", + message: "Balance is above trigger", + account, + balanceTrigger, + currentBalance: currentBalance.toString(), + token, + chainId, + }); + return; + } + // Fill balance back to target, not trigger. + const balanceTarget = parseUnits(target.toString(), decimals); + // Deficit amount is in units of the output token. + const deficit = balanceTarget.sub(currentBalance); + const selfRefill = account.eq(this.baseSignerAddress); + // If the account and the signer are the same, we can't transfer to ourselves so we need to try to go through + // one of the `canRefill` paths below, which tries to source the `token` from other means, like unwrapping + // WETH or sending a cross chain swap. + let canRefill = selfRefill + ? false + : await this.clients.balanceAllocator.requestBalanceAllocation(chainId, [token], this.baseSignerAddress, deficit); + if (!canRefill && chainIsEvm(chainId)) { + const nativeTokenSymbol = getNativeTokenSymbol(chainId); + // If token is the native token and the native token is ETH, try unwrapping some WETH into ETH first before + // transferring ETH to the account. + if (nativeTokenSymbol === "ETH") { this.logger.debug({ - at: "Refiller#refillBalances", - message: "Balance is above trigger", - account, - balanceTrigger, - currentBalance: currentBalance.toString(), - token, + at: "Refiller#refillNativeTokenBalances", + message: `Balance of ETH below trigger on chain ${chainId} and account has insufficient ETH to transfer on chain, now trying to unwrap WETH to refill ETH`, chainId, + currentBalance: currentBalance.toString(), + trigger: balanceTrigger.toString(), + balanceTarget: balanceTarget.toString(), + deficit, + account: this.baseSignerAddress, }); - return; - } - // Fill balance back to target, not trigger. - const balanceTarget = parseUnits(target.toString(), decimals); - const deficit = balanceTarget.sub(currentBalance); - const selfRefill = account.eq(this.baseSignerAddress); - // If the account and the signer are the same, we can't transfer to ourselves so we need to try to go through - // one of the `canRefill` paths below, which tries to source the `token` from other means, like unwrapping - // WETH or sending a cross chain swap. - let canRefill = selfRefill - ? false - : await this.clients.balanceAllocator.requestBalanceAllocation( - chainId, - [token], - this.baseSignerAddress, - deficit - ); - if (!canRefill && chainIsEvm(chainId)) { - const nativeTokenSymbol = getNativeTokenSymbol(chainId); - // If token is the native token and the native token is ETH, try unwrapping some WETH into ETH first before - // transferring ETH to the account. - if (nativeTokenSymbol === "ETH") { - this.logger.debug({ + const txn = await this._unwrapWethToRefill(chainId, deficit); + if (txn) { + this.logger.info({ at: "Refiller#refillNativeTokenBalances", - message: `Balance of ETH below trigger on chain ${chainId} and account has insufficient ETH to transfer on chain, now trying to unwrap WETH to refill ETH`, + message: `Unwrapped WETH from ${this.baseSignerAddress} to refill ETH in ${account} 🎁!`, chainId, - currentBalance: currentBalance.toString(), + requiredUnwrapAmount: deficit.toString(), + ethBalance: currentBalance.toString(), trigger: balanceTrigger.toString(), balanceTarget: balanceTarget.toString(), - deficit, - account: this.baseSignerAddress, - }); - const txn = await this._unwrapWethToRefill(chainId, deficit); - if (txn) { - this.logger.info({ - at: "Refiller#refillNativeTokenBalances", - message: `Unwrapped WETH from ${this.baseSignerAddress} to refill ETH in ${account} 🎁!`, - chainId, - requiredUnwrapAmount: deficit.toString(), - ethBalance: currentBalance.toString(), - trigger: balanceTrigger.toString(), - balanceTarget: balanceTarget.toString(), - transactionHash: blockExplorerLink(txn.transactionHash, chainId), - }); - // Don't return early and flip canRefill to true because we now have enough ETH to transfer to the account - canRefill = true; - } else { - return; - } - } else if (isDefined(SWAP_ROUTES[chainId])) { - // To refill other native tokens, we will first submit an async cross chain swap to receive the native token, and then on the next - // run, the refill should be successful. Actual swap routes are currently determined by a hardcoded map from destination chain ID to - // swap strategy. - const swapRoute: SwapRoute = SWAP_ROUTES[chainId]; - this.logger.debug({ - at: "Refiller#refillNativeTokenBalances", - message: `Balance of ${nativeTokenSymbol} below trigger on chain ${chainId} and account has insufficient ${nativeTokenSymbol} to transfer on chain, now trying to swap ${ - swapRoute.inputToken - } from ${getNetworkName(swapRoute.originChainId)} to ${nativeTokenSymbol}`, - swapRoute, - currentBalance: currentBalance.toString(), - trigger: balanceTrigger.toString(), - balanceTarget: balanceTarget.toString(), - deficit, - account: this.baseSignerAddress, + transactionHash: blockExplorerLink(txn.transactionHash, chainId), }); - const txn = await this._swapToRefill(swapRoute, deficit, EvmAddress.from(account.toEvmAddress())); - if (txn) { - this.logger.info({ - at: "Monitor#refillBalances", - message: `Swapped ${swapRoute.inputToken} on ${getNetworkName( - swapRoute.originChainId - )} to ${nativeTokenSymbol} on ${getNetworkName(chainId)} for ${account} 🎁!`, - transactionHash: blockExplorerLink(txn.transactionHash, swapRoute.originChainId), - currentBalance: currentBalance.toString(), - trigger: balanceTrigger.toString(), - balanceTarget: balanceTarget.toString(), - amount: deficit.toString(), - }); - } - // Return early now because the cross chain swap, successful or not, is async and we won't be able to - // refill balances via a transfer() until the next bot iteration. + // Don't return early and flip canRefill to true because we now have enough ETH to transfer to the account + canRefill = true; + } else { return; } - } - - // Now try to transfer native tokens from the base account to the target account on the same chain. - if (canRefill && chainIsEvm(chainId)) { + } else if (isDefined(SWAP_ROUTES[chainId])) { + // To refill other native tokens, we will first submit an async cross chain swap to receive the native token, and then on the next + // run, the refill should be successful. Actual swap routes are currently determined by a hardcoded map from destination chain ID to + // swap strategy. + const swapRoute: SwapRoute = SWAP_ROUTES[chainId]; this.logger.debug({ - at: "Monitor#refillBalances", - message: "Balance below trigger and can refill to target", - from: this.baseSignerAddress, - to: account.toEvmAddress(), - balanceTrigger, - balanceTarget, + at: "Refiller#refillNativeTokenBalances", + message: `Balance of ${nativeTokenSymbol} below trigger on chain ${chainId} and account has insufficient ${nativeTokenSymbol} to transfer on chain, now trying to swap ${ + swapRoute.inputToken + } from ${getNetworkName(swapRoute.originChainId)} to ${nativeTokenSymbol}`, + swapRoute, + currentBalance: currentBalance.toString(), + trigger: balanceTrigger.toString(), + balanceTarget: balanceTarget.toString(), deficit, - token: token.toEvmAddress(), - chainId, - isHubPool, + account: this.baseSignerAddress, }); - // There are three cases: - // 1. The account is the HubPool. In which case we need to call a special function to load ETH into it. - // 2. The account is not a HubPool and we want to load ETH. - if (isHubPool) { - // Note: We ignore the `token` if the account is HubPool because we can't call the method with other tokens. - this.clients.multiCallerClient.enqueueTransaction({ - contract: this.clients.hubPoolClient.hubPool, - chainId: this.clients.hubPoolClient.chainId, - method: "loadEthForL2Calls", - args: [], - message: "Reloaded ETH in HubPool 🫡!", - mrkdwn: `Loaded ${formatUnits(deficit, decimals)} ETH from ${this.baseSignerAddress}.`, - value: deficit, - }); - } else { - const nativeSymbolForChain = getNativeTokenSymbol(chainId); - // To send a raw transaction, we need to create a fake Contract instance at the recipient address and - // set the method param to be an empty string. - const sendRawTransactionContract = new Contract( - account.toEvmAddress(), - [], - this.baseSigner.connect(l2Provider) - ); - const txn = await (await sendRawTransaction(this.logger, sendRawTransactionContract, deficit)).wait(); + const txn = await this._swapToRefill(swapRoute, deficit, EvmAddress.from(account.toEvmAddress())); + if (txn) { this.logger.info({ - at: "Refiller#refillBalances", - message: `Reloaded ${formatUnits(deficit, decimals)} ${nativeSymbolForChain} for ${account} from ${ - this.baseSignerAddress - } 🫡!`, - transactionHash: blockExplorerLink(txn.transactionHash, chainId), + at: "Monitor#refillBalances", + message: `Swapped ${swapRoute.inputToken} on ${getNetworkName( + swapRoute.originChainId + )} to ${nativeTokenSymbol} on ${getNetworkName(chainId)} for ${account} 🎁!`, + transactionHash: blockExplorerLink(txn.transactionHash, swapRoute.originChainId), + currentBalance: currentBalance.toString(), + trigger: balanceTrigger.toString(), + balanceTarget: balanceTarget.toString(), + amount: deficit.toString(), }); } + // Return early now because the cross chain swap, successful or not, is async and we won't be able to + // refill balances via a transfer() until the next bot iteration. + return; + } + } + + // Now try to transfer native tokens from the base account to the target account on the same chain. + if (canRefill && chainIsEvm(chainId)) { + this.logger.debug({ + at: "Monitor#refillBalances", + message: "Balance below trigger and can refill to target", + from: this.baseSignerAddress, + to: account.toEvmAddress(), + balanceTrigger, + balanceTarget, + deficit, + token: token.toEvmAddress(), + chainId, + isHubPool, + }); + // There are three cases: + // 1. The account is the HubPool. In which case we need to call a special function to load ETH into it. + // 2. The account is not a HubPool and we want to load ETH. + if (isHubPool) { + // Note: We ignore the `token` if the account is HubPool because we can't call the method with other tokens. + this.clients.multiCallerClient.enqueueTransaction({ + contract: this.clients.hubPoolClient.hubPool, + chainId: this.clients.hubPoolClient.chainId, + method: "loadEthForL2Calls", + args: [], + message: "Reloaded ETH in HubPool 🫡!", + mrkdwn: `Loaded ${formatUnits(deficit, decimals)} ETH from ${this.baseSignerAddress}.`, + value: deficit, + }); } else { - this.logger.warn({ + const nativeSymbolForChain = getNativeTokenSymbol(chainId); + // To send a raw transaction, we need to create a fake Contract instance at the recipient address and + // set the method param to be an empty string. + const sendRawTransactionContract = new Contract( + account.toEvmAddress(), + [], + this.baseSigner.connect(l2Provider) + ); + const txn = await (await sendRawTransaction(this.logger, sendRawTransactionContract, deficit)).wait(); + this.logger.info({ at: "Refiller#refillBalances", - message: "Cannot refill balance to target", - from: this.baseSignerAddress, - to: account, - currentBalance: currentBalance.toString(), - balanceTrigger, - balanceTarget, - deficit, - token, - chainId, + message: `Reloaded ${formatUnits(deficit, decimals)} ${nativeSymbolForChain} for ${account} from ${ + this.baseSignerAddress + } 🫡!`, + transactionHash: blockExplorerLink(txn.transactionHash, chainId), }); } + } else { + this.logger.warn({ + at: "Refiller#refillBalances", + message: "Cannot refill balance to target", + from: this.baseSignerAddress, + to: account, + currentBalance: currentBalance.toString(), + balanceTrigger, + balanceTarget, + deficit, + token, + chainId, + }); + } + } + + private async refillTokenBalances( + currentBalance: BigNumber, + decimals: number, + refillBalanceData: RefillBalanceData, + l2Provider: Provider + ): Promise { + const { chainId, token, account, target, trigger, refillPeriod = 10 * 60 } = refillBalanceData; + assert(!chainIsL1(chainId), "Cannot refill mainnet non-native token balance."); + const redisKey = `refill:${account.toNative()}->${chainId}-${token.toNative()}`; + const isRefillProcessed = await this._isRefillProcessed(redisKey); + if (isRefillProcessed) { + return; + } + + // Determine the amounts to bridge. + const balanceTrigger = parseUnits(trigger.toString(), decimals); + const isBelowTrigger = currentBalance.lte(balanceTrigger); + if (!isBelowTrigger) { + this.logger.debug({ + at: "Refiller#refillBalances", + message: "Balance is above trigger", + account, + balanceTrigger, + currentBalance: currentBalance.toString(), + token, + chainId, + }); + return; + } + // Fill balance back to target, not trigger. + const balanceTarget = parseUnits(target.toString(), decimals); + // Deficit amount is in the output token, but in this case, input token == output token. + const amount = balanceTarget.sub(currentBalance); + + // Construct a token bridge for the target token and build a transaction to send the l1 token to l2. + const l1Token = getL1TokenAddress(token, chainId); + const l2TokenInfo = getTokenInfo(token, chainId); + const tokenBridgeConstructor = CUSTOM_BRIDGE[chainId]?.[l1Token.toNative()] ?? CANONICAL_BRIDGE[chainId]; + const tokenBridge = new tokenBridgeConstructor( + chainId, + this.clients.hubPoolClient.chainId, + this.baseSigner, + l2Provider, + l1Token, + this.logger + ); + const { + contract, + method, + args, + value = bnZero, + } = await tokenBridge.constructL1ToL2Txn(account, l1Token, token, amount); + + // Execute the l1 to l2 rebalance. + let txn; + try { + txn = await (await runTransaction(this.logger, contract, method, args, value)).wait(); + } catch (error) { + // Log the error and do not retry. + this.logger.warn({ + at: "Refiller#refillBalances", + message: `Failed to refill ${formatUnits(amount, decimals)} ${l2TokenInfo.symbol} for ${account} from ${ + this.baseSignerAddress + }`, + error, + }); + return; + } + this.logger.info({ + at: "Refiller#refillBalances", + message: `Reloaded ${formatUnits(amount, decimals)} ${l2TokenInfo.symbol} for ${account} from ${ + this.baseSignerAddress + } 🫡!`, + transactionHash: blockExplorerLink(txn.transactionHash, chainId), }); + await this.redisCache.set(redisKey, "true", refillPeriod); } private async _unwrapWethToRefill(chainId: number, amount: BigNumber): Promise { @@ -290,6 +389,116 @@ export class Refiller { } } + private async refillUsdh(currentBalance: BigNumber, decimals: number): Promise { + // If either the apiUrl or apiKey is undefined, then return, since we can't do anything. + if (!isDefined(this.config.nativeMarketsApiConfig)) { + this.logger.warn({ + at: "Refiller#refillUsdh", + message: "Native markets api key and base URL are missing from .env. Unable to rebalance USDH.", + }); + return; + } + const { apiUrl: nativeMarketsApiUrl, apiKey: nativeMarketsApiKey } = this.config.nativeMarketsApiConfig; + const headers = { + "Api-Version": "2025-11-01", + "Content-Type": "application/json", + Authorization: `Bearer ${nativeMarketsApiKey}`, + }; + const day = 24 * 60 * 60; + + // First, get the address ID of the base signer, which is used to determine the deposit address for Arb -> HyperEVM transfers. + let addressId; + // If we have the address ID for the base signer and token combo in cache, then do not request it from native markets. + const addressIdCacheKey = `nativeMarketsAddressId:${this.baseSignerAddress.toNative()}`; + const addressIdCache = await this.redisCache.get(addressIdCacheKey); + if (isDefined(addressIdCache)) { + addressId = addressIdCache; + } else { + const { data: registeredAddresses } = await axios.get(`${nativeMarketsApiUrl}/addresses`, { headers }); + addressId = registeredAddresses.items.find( + ({ chain, token, address_hex }) => + chain === "hyper_evm" && token === "usdh" && address_hex === this.baseSignerAddress.toNative() + )?.id; + // In the event the address is not currently available, create a new one by posting to the native markets API. + if (!isDefined(addressId)) { + const newAddressIdData = { + address: this.baseSignerAddress.toNative(), + chain: "hyper_evm", + name: "across-refiller-test", + token: "usdh", + }; + + this.logger.info({ + at: "Refiller#refillNativeTokenBalances", + message: `Address ${this.baseSignerAddress.toNative()} is not registered in the native markets API. Creating new address ID.`, + address: this.baseSignerAddress.toNative(), + }); + const { data: _addressId } = await axios.post(`${nativeMarketsApiUrl}/addresses`, newAddressIdData, { + headers, + }); + addressId = _addressId.id; + } + await this.redisCache.set(addressIdCacheKey, addressId, 7 * day); + } + + // Next, get the transfer route deposit address on Arbitrum. + const { data: transferRoutes } = await axios.get(`${nativeMarketsApiUrl}/transfer_routes`, { headers }); + let availableTransferRoute = transferRoutes.items + .filter((route) => isDefined(route.source_address)) + .find( + ({ source_address, destination_address }) => + source_address.chain === "arbitrum" && + source_address.token === "usdc" && + destination_address.address_hex === this.baseSignerAddress.toNative() + ); + // Once again, if the transfer route is not defined, then create a new one by querying the native markets API. + if (!isDefined(availableTransferRoute)) { + const newTransferRouteData = { + destination_address_id: addressId, + name: "Arbitrum USDC -> HyperEVM USDH", + source_currency: "usdc", + source_payment_rail: "arbitrum", + }; + + this.logger.info({ + at: "Refiller#refillNativeTokenBalances", + message: `Address ID ${addressId} does not have an Arbitrum USDC -> HyperEVM USDH transfer route configured. Creating a new route.`, + address: this.baseSignerAddress.toNative(), + addressId, + }); + const { data: _availableTransferRoute } = await axios.post( + `${nativeMarketsApiUrl}/transfer_routes`, + newTransferRouteData, + { + headers, + } + ); + availableTransferRoute = _availableTransferRoute; + } + + // Create the transfer transaction. + const srcProvider = this.clients.balanceAllocator.providers[CHAIN_IDs.ARBITRUM]; + const usdc = new Contract( + TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], + ERC20_ABI, + this.baseSigner.connect(srcProvider) + ); + // By default, sweep the entire USDC balance of the base signer to HyperEVM USDH. + const amountToTransfer = await usdc.balanceOf(this.baseSignerAddress.toNative()); + + if (amountToTransfer.gt(bnZero)) { + this.clients.multiCallerClient.enqueueTransaction({ + contract: usdc, + chainId: CHAIN_IDs.ARBITRUM, + method: "transfer", + args: [availableTransferRoute.source_address.address_hex, amountToTransfer], + message: "Rebalanced Arbitrum USDC to HyperEVM USDH", + nonMulticall: true, + mrkdwn: `Sent ${formatUnits(amountToTransfer, decimals)} USDC from Arbitrum to HyperEVM.`, + }); + } + } + private async _swapToRefill( swapRoute: SwapRoute, amount: BigNumber, @@ -298,14 +507,8 @@ export class Refiller { const redisKey = `acrossSwap:${swapRoute.originChainId}-${swapRoute.inputToken.toNative()}->${ swapRoute.destinationChainId }-${swapRoute.outputToken.toNative()}`; - const pendingSwap = await this.redisCache.get(redisKey); - if (pendingSwap) { - const ttl = await this.redisCache.ttl(redisKey); - this.logger.debug({ - at: "Refiller#refillNativeTokenBalances", - message: `Found pending swap in cache with key ${redisKey}, avoiding new swap`, - ttl, - }); + const isRefillProcessed = await this._isRefillProcessed(redisKey); + if (isRefillProcessed) { return; } assert( @@ -408,6 +611,20 @@ export class Refiller { }) ); } + + private async _isRefillProcessed(redisKey: string): Promise { + const pendingSwap = await this.redisCache.get(redisKey); + if (pendingSwap) { + const ttl = await this.redisCache.ttl(redisKey); + this.logger.debug({ + at: "Refiller#refillNativeTokenBalances", + message: `Found pending swap in cache with key ${redisKey}, avoiding new swap`, + ttl, + }); + return true; + } + return false; + } } type BalanceRequest = { chainId: number; token: Address; account: Address }; diff --git a/src/refiller/RefillerConfig.ts b/src/refiller/RefillerConfig.ts index c7b4db8fc0..83a6d1ac1b 100644 --- a/src/refiller/RefillerConfig.ts +++ b/src/refiller/RefillerConfig.ts @@ -1,20 +1,24 @@ import { CommonConfig, ProcessEnv } from "../common"; import { getNativeTokenAddressForChain, Address, toAddressType, isDefined } from "../utils"; +export type RefillBalanceData = { + chainId: number; + isHubPool: boolean; + account: Address; + token: Address; + target: number; + trigger: number; + refillPeriod?: number; +}; + export class RefillerConfig extends CommonConfig { - readonly refillEnabledBalances: { - chainId: number; - isHubPool: boolean; - account: Address; - token: Address; - target: number; - trigger: number; - }[] = []; + readonly refillEnabledBalances: RefillBalanceData[] = []; + readonly nativeMarketsApiConfig: { apiKey: string; apiUrl: string }; constructor(env: ProcessEnv) { super(env); - const { REFILL_BALANCES } = env; + const { REFILL_BALANCES, NATIVE_MARKETS_API_KEY, NATIVE_MARKETS_API_BASE } = env; // Used to send tokens if available in wallet to balances under target balances. if (REFILL_BALANCES) { @@ -43,6 +47,10 @@ export class RefillerConfig extends CommonConfig { ); } + if (isDefined(NATIVE_MARKETS_API_KEY) && isDefined(NATIVE_MARKETS_API_BASE)) { + this.nativeMarketsApiConfig = { apiKey: NATIVE_MARKETS_API_KEY, apiUrl: NATIVE_MARKETS_API_BASE }; + } + // Should only have 1 HubPool. if (Object.values(this.refillEnabledBalances).filter((x) => x.isHubPool).length > 1) { throw new Error("REFILL_BALANCES should only have 1 account marked isHubPool as true"); diff --git a/src/refiller/index.ts b/src/refiller/index.ts index dc95de3f30..7d290464b3 100644 --- a/src/refiller/index.ts +++ b/src/refiller/index.ts @@ -15,7 +15,7 @@ export async function runRefiller(_logger: winston.Logger, baseSigner: Signer): try { logger[startupLogLevel(config)]({ at: "Refiller#index", message: "Refiller started ⛽️", config }); const start = Date.now(); - await refiller.refillNativeTokenBalances(); + await refiller.refillBalances(); await clients.multiCallerClient.executeTxnQueues(); diff --git a/src/relayer/Relayer.ts b/src/relayer/Relayer.ts index c8b754f57c..8194e53b39 100644 --- a/src/relayer/Relayer.ts +++ b/src/relayer/Relayer.ts @@ -16,7 +16,6 @@ import { getUnfilledDeposits, isDefined, winston, - fixedPointAdjustment, Profiler, formatGwei, depositForcesOriginChainRepayment, @@ -47,6 +46,7 @@ type RepaymentChainProfitability = { gasCost: BigNumber; gasPrice: BigNumber; relayerFeePct: BigNumber; + totalUserFeePct: BigNumber; lpFeePct: BigNumber; }; @@ -193,7 +193,14 @@ export class Relayer { filterDeposit({ deposit, version: depositVersion, invalidFills }: RelayerUnfilledDeposit): boolean { const { depositId, originChainId, destinationChainId, depositor, recipient, inputToken, blockNumber } = deposit; const { acrossApiClient, configStoreClient, hubPoolClient, profitClient, spokePoolClients } = this.clients; - const { addressFilter, ignoreLimits, relayerTokens, acceptInvalidFills, minDepositConfirmations } = this.config; + const { + addressFilter, + ignoreLimits, + relayerTokens, + acceptInvalidFills, + minDepositConfirmations, + relayerDestinationTokens, + } = this.config; const [srcChain, dstChain] = [getNetworkName(originChainId), getNetworkName(destinationChainId)]; const relayKey = sdkUtils.getRelayEventKey(deposit); @@ -279,13 +286,26 @@ export class Relayer { if (relayerTokens.length > 0 && !relayerTokens.some((token) => token.eq(l1Token))) { this.logger.debug({ at: "Relayer::filterDeposit", - message: "Skipping deposit for unsupported token.", + message: "Skipping deposit for unsupported input token.", deposit, l1Token, }); return ignoreDeposit(); } + if ( + relayerDestinationTokens[destinationChainId] && + !relayerDestinationTokens[destinationChainId].some((token) => token.eq(deposit.outputToken)) + ) { + this.logger.debug({ + at: "Relayer::filterDeposit", + message: "Skipping deposit for unsupported output token.", + deposit, + outputToken: deposit.outputToken, + }); + return ignoreDeposit(); + } + // Skip deposits with unmatching input/output tokens. if (!this.clients.inventoryClient.validateOutputToken(deposit)) { this.logger.debug({ @@ -741,6 +761,7 @@ export class Relayer { gasCost, gasLimit: _gasLimit, lpFeePct: realizedLpFeePct, + totalUserFeePct, gasPrice, } = repaymentChainProfitability; @@ -798,7 +819,7 @@ export class Relayer { tokenClient.decrementLocalBalance(destinationChainId, outputToken, outputAmount); const gasLimit = isMessageEmpty(resolveDepositMessage(deposit)) ? undefined : _gasLimit; - this.fillRelay(deposit, repaymentChainId, realizedLpFeePct, gasPrice, gasLimit); + this.fillRelay(deposit, repaymentChainId, realizedLpFeePct, totalUserFeePct, gasPrice, gasLimit); } /** @@ -1083,6 +1104,7 @@ export class Relayer { deposit: Deposit, repaymentChainId: number, realizedLpFeePct: BigNumber, + totalUserFeePct: BigNumber, gasPrice: BigNumber, gasLimit?: BigNumber ): void { @@ -1137,7 +1159,13 @@ export class Relayer { ]; const message = `Filled v3 deposit ${messageModifier}🚀`; - const mrkdwn = this.constructRelayFilledMrkdwn(deposit, repaymentChainId, realizedLpFeePct, gasPrice); + const mrkdwn = this.constructRelayFilledMrkdwn( + deposit, + repaymentChainId, + realizedLpFeePct, + totalUserFeePct, + gasPrice + ); const contract = spokePoolClient.spokePool; const chainId = deposit.destinationChainId; @@ -1152,7 +1180,13 @@ export class Relayer { assert(deposit.outputToken.isSVM()); const message = "Filled v3 deposit on SVM 🚀"; - const mrkdwn = this.constructRelayFilledMrkdwn(deposit, repaymentChainId, realizedLpFeePct, gasPrice); + const mrkdwn = this.constructRelayFilledMrkdwn( + deposit, + repaymentChainId, + realizedLpFeePct, + totalUserFeePct, + gasPrice + ); this.clients.svmFillerClient.enqueueFill( spokePoolClient.spokePoolAddress, @@ -1217,6 +1251,7 @@ export class Relayer { gasCost: bnUint256Max, gasPrice: bnUint256Max, relayerFeePct: bnZero, + totalUserFeePct: bnZero, lpFeePct: bnUint256Max, }, }; @@ -1251,13 +1286,15 @@ export class Relayer { gasCost: BigNumber; gasPrice: BigNumber; relayerFeePct: BigNumber; + totalUserFeePct: BigNumber; }> => { const { profitable, nativeGasCost: gasLimit, tokenGasCost: gasCost, gasPrice, - netRelayerFeePct: relayerFeePct, // net relayer fee is equal to total fee minus the lp fee. + netRelayerFeePct: relayerFeePct, + totalFeePct: totalUserFeePct, } = await profitClient.isFillProfitable(deposit, lpFeePct, hubPoolToken, preferredChainId); return { profitable, @@ -1265,6 +1302,7 @@ export class Relayer { gasCost, gasPrice, relayerFeePct, + totalUserFeePct, }; }; @@ -1283,12 +1321,14 @@ export class Relayer { // @dev The following internal function should be the only one used to set `preferredChain` above. const getProfitabilityDataForPreferredChainIndex = (preferredChainIndex: number): RepaymentChainProfitability => { const lpFeePct = lpFeePcts[preferredChainIndex]; - const { gasLimit, gasCost, relayerFeePct, gasPrice } = repaymentChainProfitabilities[preferredChainIndex]; + const { gasLimit, gasCost, relayerFeePct, gasPrice, totalUserFeePct } = + repaymentChainProfitabilities[preferredChainIndex]; return { gasLimit, gasCost, gasPrice, relayerFeePct, + totalUserFeePct, lpFeePct, }; }; @@ -1545,10 +1585,11 @@ export class Relayer { deposit: Deposit, repaymentChainId: number, realizedLpFeePct: BigNumber, + totalUserFeePct: BigNumber, gasPrice: BigNumber ): string { let mrkdwn = - this.constructBaseFillMarkdown(deposit, realizedLpFeePct, gasPrice) + + this.constructBaseFillMarkdown(deposit, realizedLpFeePct, totalUserFeePct, gasPrice) + ` Relayer repayment: ${getNetworkName(repaymentChainId)}.`; if (isDepositSpedUp(deposit)) { @@ -1563,7 +1604,12 @@ export class Relayer { return mrkdwn; } - private constructBaseFillMarkdown(deposit: Deposit, _realizedLpFeePct: BigNumber, _gasPriceGwei: BigNumber): string { + private constructBaseFillMarkdown( + deposit: Deposit, + _realizedLpFeePct: BigNumber, + _totalUserFeePct: BigNumber, + _gasPriceGwei: BigNumber + ): string { const { symbol, decimals } = this.clients.hubPoolClient.getTokenInfoForAddress( deposit.inputToken, deposit.originChainId @@ -1578,17 +1624,12 @@ export class Relayer { let msg = `Relayed depositId ${deposit.depositId.toString()} from ${srcChain} to ${dstChain} of ${inputAmount} ${symbol}`; const realizedLpFeePct = formatFeePct(_realizedLpFeePct); - const adjustedInputAmount = sdkUtils.ConvertDecimals(decimals, outputTokenDecimals)(deposit.inputAmount); - const _totalFeePct = adjustedInputAmount - .sub(deposit.outputAmount) - .mul(fixedPointAdjustment) - .div(adjustedInputAmount); - const totalFeePct = formatFeePct(_totalFeePct); + const totalUserFeePct = formatFeePct(_totalUserFeePct); const _outputAmount = createFormatFunction(2, 4, false, outputTokenDecimals)(deposit.outputAmount.toString()); msg += ` and output ${_outputAmount} ${outputTokenSymbol}, with depositor ${depositor}.` + - ` Realized LP fee: ${realizedLpFeePct}%, total fee: ${totalFeePct}%. Gas price used in profit calc: ${formatGwei( + ` Realized LP fee: ${realizedLpFeePct}%, total user fee: ${totalUserFeePct}%. Gas price used in profit calc: ${formatGwei( _gasPriceGwei.toString() )} Gwei.`; diff --git a/src/relayer/RelayerConfig.ts b/src/relayer/RelayerConfig.ts index d3c8f74b36..7d8a7e78ca 100644 --- a/src/relayer/RelayerConfig.ts +++ b/src/relayer/RelayerConfig.ts @@ -22,7 +22,7 @@ import { } from "../utils"; import { CommonConfig, ProcessEnv } from "../common"; import * as Constants from "../common/Constants"; -import { InventoryConfig, TokenBalanceConfig, isAliasConfig } from "../interfaces/InventoryManagement"; +import { InventoryConfig, TokenBalanceConfig, isAliasConfig, SwapRoute } from "../interfaces/InventoryManagement"; type DepositConfirmationConfig = { usdThreshold: BigNumber; @@ -38,6 +38,7 @@ export class RelayerConfig extends CommonConfig { readonly sendingMessageRelaysEnabled: { [chainId: number]: boolean } = {}; readonly sendingSlowRelaysEnabled: boolean; readonly relayerTokens: EvmAddress[]; + readonly relayerDestinationTokens: { [chainId: number]: Address[] }; readonly relayerOriginChains: number[] = []; readonly relayerDestinationChains: number[] = []; readonly relayerGasPadding: BigNumber; @@ -46,6 +47,8 @@ export class RelayerConfig extends CommonConfig { readonly minRelayerFeePct: BigNumber; readonly minFillTime: { [chainId: number]: number } = {}; readonly acceptInvalidFills: boolean; + readonly relayerUseInventoryManager: boolean; + readonly inventoryTopic: string; // List of depositors we only want to send slow fills for. readonly slowDepositors: Address[]; // Following distances in blocks to guarantee finality on each chain. @@ -81,6 +84,7 @@ export class RelayerConfig extends CommonConfig { RELAYER_EXTERNAL_INVENTORY_CONFIG, RELAYER_INVENTORY_CONFIG, RELAYER_TOKENS, + RELAYER_DESTINATION_TOKENS, SEND_RELAYS, SEND_SLOW_RELAYS, MIN_RELAYER_FEE_PCT, @@ -91,6 +95,8 @@ export class RelayerConfig extends CommonConfig { RELAYER_TRY_MULTICALL_CHAINS, RELAYER_LOGGING_INTERVAL = "30", RELAYER_MAINTENANCE_INTERVAL = "60", + INVENTORY_TOPIC = "across-relayer-inventory", + RELAYER_USE_INVENTORY_MANAGER = "false", } = env; super(env); @@ -105,6 +111,15 @@ export class RelayerConfig extends CommonConfig { this.relayerTokens = JSON.parse(RELAYER_TOKENS ?? "[]").map((token) => toAddressType(ethers.utils.getAddress(token), CHAIN_IDs.MAINNET) ); + // An empty array for a defined destination chain means that all tokens are supported. To support no tokens + // for a destination chain, map the chain to an empty array. For example, to fill only token A on chain C + // and fill nothing on chain D, set relayerDestinationTokens: { C: [A], D: [] } + this.relayerDestinationTokens = Object.fromEntries( + Object.entries(JSON.parse(RELAYER_DESTINATION_TOKENS ?? "{}")).map(([_chainId, tokens]) => { + const chainId = Number(_chainId); + return [chainId, ((tokens as string[]) ?? []).map((token) => toAddressType(token, Number(chainId)))]; + }) + ); // SLOW_DEPOSITORS can exist on any network, so their origin network must be inferred based on the structure of the address. this.slowDepositors = JSON.parse(SLOW_DEPOSITORS ?? "[]").map((depositor) => { @@ -118,6 +133,9 @@ export class RelayerConfig extends CommonConfig { this.loggingInterval = Number(RELAYER_LOGGING_INTERVAL); this.maintenanceInterval = Number(RELAYER_MAINTENANCE_INTERVAL); + this.inventoryTopic = INVENTORY_TOPIC; + this.relayerUseInventoryManager = RELAYER_USE_INVENTORY_MANAGER === "true"; + assert( !isDefined(RELAYER_EXTERNAL_INVENTORY_CONFIG) || !isDefined(RELAYER_INVENTORY_CONFIG), "Concurrent inventory management configurations detected." @@ -263,6 +281,38 @@ export class RelayerConfig extends CommonConfig { }); } }); + + // Replace symbols in allowed swap routes with addresses. + const rawSwapRoutes = inventoryConfig?.allowedSwapRoutes ?? ([] as SwapRoute[]); + const swapRoutes = (inventoryConfig.allowedSwapRoutes = [] as SwapRoute[]); + rawSwapRoutes.forEach((rawSwapRoute) => { + const swapRoute = { + ...rawSwapRoute, + fromToken: ethersUtils.isAddress(rawSwapRoute.fromToken) + ? ethersUtils.getAddress(rawSwapRoute.fromToken) + : TOKEN_SYMBOLS_MAP[rawSwapRoute.fromToken].addresses[rawSwapRoute.fromChain], + toToken: ethersUtils.isAddress(rawSwapRoute.toToken) + ? ethersUtils.getAddress(rawSwapRoute.toToken) + : TOKEN_SYMBOLS_MAP[rawSwapRoute.toToken].addresses[rawSwapRoute.toChain], + }; + assert( + swapRoute.fromToken !== undefined, + `allowedSwapRoutes: No from token identified for ${rawSwapRoute.fromToken} on chain ${rawSwapRoute.fromChain}` + ); + assert( + swapRoute.toToken !== undefined, + `allowedSwapRoutes: No to token identified for ${rawSwapRoute.toToken} on chain ${rawSwapRoute.toChain}` + ); + swapRoutes.push(swapRoute); + if (rawSwapRoute.bidirectional) { + const bidirectionalSwapRoute = { + ...swapRoute, + fromToken: swapRoute.toToken, + toToken: swapRoute.fromToken, + }; + swapRoutes.push(bidirectionalSwapRoute); + } + }); } this.debugProfitability = DEBUG_PROFITABILITY === "true"; diff --git a/src/relayer/index.ts b/src/relayer/index.ts index 7664fd233a..1acf4c947b 100644 --- a/src/relayer/index.ts +++ b/src/relayer/index.ts @@ -5,6 +5,7 @@ import { disconnectRedisClients, getNetworkName, getRedisCache, + isDefined, Profiler, Signer, winston, @@ -13,6 +14,9 @@ import { Relayer } from "./Relayer"; import { RelayerConfig } from "./RelayerConfig"; import { constructRelayerClients } from "./RelayerClientHelper"; import { runAPIServer } from "../api"; +import { InventoryClientState } from "../clients"; +import { updateSpokePoolClients } from "../common"; +import { RedisCacheInterface } from "../caching/RedisCache"; config(); let logger: winston.Logger; @@ -91,6 +95,17 @@ export async function runRelayer(_logger: winston.Logger, baseSigner: Signer): P logger.warn({ at: "Relayer#run", message: "Assuming active relayer role in degraded state", degraded }); } + if (!inventoryInit && config.relayerUseInventoryManager) { + const key = inventoryClient.getInventoryCacheKey(config.inventoryTopic); + const inventoryState = await getInventoryState(redis, key); + if (inventoryState) { + inventoryClient.import(inventoryState); + inventoryInit = true; + } else { + logger.error({ at: "Relayer#run", message: "No inventory state found in cache", key }); + } + } + // One time initialization of functions that handle lots of events only after all spokePoolClients are updated. if (!inventoryInit && inventoryManagement) { inventoryClient.setBundleData(); @@ -205,3 +220,55 @@ export async function runRebalancer(_logger: winston.Logger, baseSigner: Signer) logger.debug({ at, message: `${personality} instance completed.` }); } } + +export async function runInventoryManager(_logger: winston.Logger, baseSigner: Signer): Promise { + const personality = "InventoryManager"; + const at = `${personality}::run`; + const config = new RelayerConfig(process.env); + logger = _logger; + + try { + const redis = await getRedisCache(logger); + + const clients = await constructRelayerClients(logger, config, baseSigner); + const { spokePoolClients, inventoryClient } = clients; + + await updateSpokePoolClients(spokePoolClients, [ + "FundsDeposited", + "FilledRelay", + "RelayedRootBundle", + "ExecutedRelayerRefundRoot", + ]); + + inventoryClient.setBundleData(); + await inventoryClient.update(config.spokePoolChainsOverride); + + const inventory = inventoryClient.export(); + await setInventoryState(redis, inventoryClient.getInventoryCacheKey(config.inventoryTopic), inventory); + } finally { + await disconnectRedisClients(logger); + logger.debug({ at, message: `${personality} instance completed.` }); + } +} + +async function setInventoryState( + redis: RedisCacheInterface, + topic: string, + state: InventoryClientState, + ttl = 900 // 15 minutes +): Promise { + const value = JSON.stringify(state, sdkUtils.jsonReplacerWithBigNumbers); + await redis.set(topic, value, ttl); +} + +async function getInventoryState(redis: RedisCacheInterface, topic: string): Promise { + const state = await redis.get(topic); + if (!isDefined(state)) { + return undefined; + } + + const processedState = JSON.parse(state, sdkUtils.jsonReviverWithBigNumbers); + return processedState.inventoryConfig && processedState.bundleDataState && processedState.pendingL2Withdrawals + ? processedState + : undefined; +} diff --git a/src/scripts/validateRunningBalances.ts b/src/scripts/validateRunningBalances.ts index 006de3df10..02adbff3bc 100644 --- a/src/scripts/validateRunningBalances.ts +++ b/src/scripts/validateRunningBalances.ts @@ -60,7 +60,7 @@ import { createDataworker } from "../dataworker"; import { _buildSlowRelayRoot, getBlockForChain } from "../dataworker/DataworkerUtils"; import { Log, ProposedRootBundle, SpokePoolClientsByChain, BundleData } from "../interfaces"; import { CONTRACT_ADDRESSES, constructSpokePoolClientsWithStartBlocks, updateSpokePoolClients } from "../common"; -import { createConsoleTransport } from "@uma/logger"; +import { createConsoleTransport } from "@risk-labs/logger"; import { interfaces as sdkInterfaces } from "@across-protocol/sdk"; import _ from "lodash"; import { SpokePoolClient } from "../clients/SpokePoolClient"; diff --git a/src/utils/CCTPUtils.ts b/src/utils/CCTPUtils.ts index d4feda634d..2ccc986c70 100644 --- a/src/utils/CCTPUtils.ts +++ b/src/utils/CCTPUtils.ts @@ -1,21 +1,9 @@ import { arch, utils } from "@across-protocol/sdk"; import { TokenMessengerMinterIdl } from "@across-protocol/contracts"; -import { - PUBLIC_NETWORKS, - CHAIN_IDs, - TOKEN_SYMBOLS_MAP, - CCTP_NO_DOMAIN, - PRODUCTION_NETWORKS, - TEST_NETWORKS, -} from "@across-protocol/constants"; +import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "@across-protocol/constants"; import axios from "axios"; import { Contract, ethers } from "ethers"; -import { - CCTPMessageStatus, - CCTPV2_FINALITY_THRESHOLD_FAST, - CCTPV2_FINALITY_THRESHOLD_STANDARD, - CONTRACT_ADDRESSES, -} from "../common"; +import { CONTRACT_ADDRESSES } from "../common"; import { BigNumber } from "./BNUtils"; import { bnZero, @@ -57,31 +45,6 @@ type CCTPV1APIGetMessagesResponse = { messages: CCTPV1APIMessageAttestation[] }; // CCTP V1 /attestations/{messageHash} response type CCTPV1APIGetAttestationResponse = { status: string; attestation: string }; -// CCTP V2 /burn/USDC/fees/{sourceDomainId}/{destDomainId} response -type CCTPV2APIGetFeesResponse = { finalityThreshold: number; minimumFee: number }[]; - -// CCTP V2 /fastBurn/USDC/allowance response -type CCTPV2APIGetFastBurnAllowanceResponse = { allowance: number }; - -// CCTP V2 /messages/{sourceDomainId} response -type CCTPV2APIAttestation = { - status: string; - attestation: string; - message: string; - eventNonce: string; - cctpVersion: number; - decodedMessage: { - recipient: string; - destinationDomain: number; - decodedMessageBody: { - amount: string; - mintRecipient: string; - messageSender: string; - }; - }; -}; -type CCTPV2APIGetAttestationResponse = { messages: CCTPV2APIAttestation[] }; - /** ******************************************************************************************************************** * * CCTP SMART CONTRACT EVENT TYPES @@ -107,12 +70,6 @@ type DepositForBurnMessageEvent = DepositForBurnMessageData & { log: Log }; type CCTPMessageEvent = CommonMessageEvent | DepositForBurnMessageEvent; const CCTP_MESSAGE_SENT_TOPIC_HASH = ethers.utils.id("MessageSent(bytes)"); -const CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V1 = ethers.utils.id( - "DepositForBurn(uint64,address,uint256,address,bytes32,uint32,bytes32,bytes32)" -); -const CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V2 = ethers.utils.id( - "DepositForBurn(address,uint256,address,bytes32,uint32,bytes32,bytes32,uint256,uint32,bytes)" -); /** ******************************************************************************************************************** * @@ -120,76 +77,17 @@ const CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V2 = ethers.utils.id( * ******************************************************************************************************************* **/ -export type AttestedCCTPMessage = CCTPMessageEvent & { status: CCTPMessageStatus; attestation?: string }; -export type AttestedCCTPDeposit = DepositForBurnMessageEvent & { status: CCTPMessageStatus; attestation?: string }; - -/** - * @notice Converts an ETH Address string to a 32-byte hex string. - * @param address The address to convert. - * @returns The 32-byte hex string representation of the address - required for CCTP messages. - */ -export function cctpAddressToBytes32(address: string): string { - return ethers.utils.hexZeroPad(address, 32); -} - -/** - * Converts a 32-byte hex string with padding to a standard ETH address. - * @param bytes32 The 32-byte hex string to convert. - * @returns The ETH address representation of the 32-byte hex string. - */ -export function cctpBytes32ToAddress(bytes32: string): string { - // Grab the last 20 bytes of the 32-byte hex string - return ethers.utils.getAddress(ethers.utils.hexDataSlice(bytes32, 12)); -} - -/** - * @notice Returns the CCTP domain for a given chain ID. Throws if the chain ID is not a CCTP domain. - * @param chainId - * @returns CCTP Domain ID - */ -export function getCctpDomainForChainId(chainId: number): number { - const cctpDomain = PUBLIC_NETWORKS[chainId]?.cctpDomain; - if (!isDefined(cctpDomain) || cctpDomain === CCTP_NO_DOMAIN) { - throw new Error(`No CCTP domain found for chainId: ${chainId}`); - } - return cctpDomain; -} +export const { CCTPV2_FINALITY_THRESHOLD_FAST, CCTPV2_FINALITY_THRESHOLD_STANDARD } = utils; +export type AttestedCCTPMessage = utils.AttestedCCTPMessage; +export type AttestedCCTPDeposit = utils.AttestedCCTPDeposit; -/** - * @notice Returns the chain ID for a given CCTP domain. Inverse functionof `getCctpDomainForChainId()`. However, - * since CCTP Domains are shared between production and test networks, we need to use the `productionNetworks` flag - * to determine whether to return the production or test network chain ID. - * @param domain CCTP domain ID. - * @param productionNetworks Whether to return the production or test network chain ID. - * @returns Chain ID. - */ -export function getCctpDestinationChainFromDomain(domain: number, productionNetworks: boolean): number { - if (domain === CCTP_NO_DOMAIN) { - throw new Error("Cannot input CCTP_NO_DOMAIN to getCctpDestinationChainFromDomain"); - } - // Test and Production networks use the same CCTP domain, so we need to use the flag passed in to - // determine whether to use the Test or Production networks. - const networks = productionNetworks ? PRODUCTION_NETWORKS : TEST_NETWORKS; - const otherNetworks = productionNetworks ? TEST_NETWORKS : PRODUCTION_NETWORKS; - const chainId = Object.keys(networks).find((key) => networks[key].cctpDomain.toString() === domain); - if (!isDefined(chainId)) { - const chainId = Object.keys(otherNetworks).find((key) => otherNetworks[key].cctpDomain.toString() === domain); - if (!isDefined(chainId)) { - throw new Error(`No chainId found for domain: ${domain}`); - } - return parseInt(chainId); - } - return parseInt(chainId); -} - -/** - * @notice Typeguard. Returns whether the event is a CCTP deposit for burn event. Should work for V1 and V2 - * @param event CCTP message event. - * @returns True if the event is a CCTP V1 deposit for burn event. - */ -export function isDepositForBurnEvent(event: CCTPMessageEvent): event is DepositForBurnMessageEvent { - return "amount" in event && "mintRecipient" in event && "burnToken" in event; -} +export const { + cctpAddressToBytes32, + cctpBytes32ToAddress, + getCctpDomainForChainId, + getCctpDestinationChainFromDomain, + isDepositForBurnEvent, +} = utils; /** * Return all deposit for burn transaction hashes that were created on the source chain. @@ -242,7 +140,7 @@ export async function getCctpV2DepositForBurnStatuses( // Fetch attestations for all deposit burn event transaction hashes. Note, some events might share the same // transaction hash, so only fetch attestations for unique transaction hashes. const uniqueTxHashes = Array.from(new Set(depositForBurnHashes)); - const attestationResponses = await _fetchCctpV2Attestations(uniqueTxHashes, sourceChainId); + const attestationResponses = await utils.fetchCctpV2Attestations(uniqueTxHashes, sourceChainId); // Categorize deposits based on status: const pendingDepositTxnHashes: string[] = []; @@ -258,7 +156,7 @@ export async function getCctpV2DepositForBurnStatuses( return; } // API has not produced an attestation for this deposit yet: - if (_getPendingAttestationStatus(attestation) === "pending") { + if (utils.getPendingAttestationStatus(attestation) === "pending") { pendingDepositTxnHashes.push(txnHash); return; } @@ -280,7 +178,10 @@ export async function getCctpV2DepositForBurnStatuses( chainIsProd(sourceChainId) ); const destinationMessageTransmitter = await _getDestinationMessageTransmitterContract(destinationChainId, false); - const processed = await _hasCCTPMessageBeenProcessedEvm(attestation.eventNonce, destinationMessageTransmitter); + const processed = await utils.hasCCTPMessageBeenProcessedEvm( + attestation.eventNonce, + destinationMessageTransmitter + ); if (processed) { finalizedDepositTxnHashes.push({ txnHash, destinationChainId }); } else { @@ -385,7 +286,7 @@ export async function getCctpV1Messages( return { ...message, attestation: attestation?.attestation, // Will be undefined if status is "pending" - status: _getPendingAttestationStatus(attestation), + status: utils.getPendingAttestationStatus(attestation), }; }) ); @@ -408,8 +309,8 @@ export async function getV2DepositForBurnMaxFee( amount: BigNumber ): Promise<{ maxFee: BigNumber; finalityThreshold: number }> { const [_fastBurnAllowance, transferFees] = await Promise.all([ - _getV2FastBurnAllowance(chainIsProd(destinationChainId)), - _getV2MinTransferFees(originChainId, destinationChainId), + utils.getV2FastBurnAllowance(chainIsProd(destinationChainId)), + utils.getV2MinTransferFees(originChainId, destinationChainId), ]); const expectedMaxFastTransferFee = getV2MaxExpectedTransferFee(originChainId); // If we are using CCTP V2 then try to use the fast transfer if the amount is under the @@ -699,7 +600,7 @@ function _getCCTPV1EventsFromReceipt( const depositIndexPairs = []; receipt.logs.forEach((log, i) => { // Attempt to parse as `MessageSent` - const messageSentVersion = _getMessageSentVersion(log); + const messageSentVersion = utils.getMessageSentVersion(log); const isV1MessageSentEvent = messageSentVersion === 0; if (isV1MessageSentEvent) { @@ -708,7 +609,7 @@ function _getCCTPV1EventsFromReceipt( } // Attempt to parse as `DepositForBurn` - const depositForBurnVersion = _getDepositForBurnVersion(log); + const depositForBurnVersion = utils.getDepositForBurnVersion(log); const isV1DepositForBurnEvent = depositForBurnVersion === 0; if (!isV1DepositForBurnEvent) { @@ -787,7 +688,7 @@ async function _getCCTPV1MessagesWithStatus( latestBlockhash!.value ); } else { - processed = await _hasCCTPMessageBeenProcessedEvm(messageEvent.nonceHash, messageTransmitterContract); + processed = await utils.hasCCTPMessageBeenProcessedEvm(messageEvent.nonceHash, messageTransmitterContract); } if (!processed) { @@ -805,56 +706,6 @@ async function _getCCTPV1MessagesWithStatus( ); } -async function _fetchCctpV2Attestations( - depositForBurnTxnHashes: string[], - sourceChainId: number -): Promise<{ [sourceTxnHash: string]: CCTPV2APIGetAttestationResponse }> { - // For v2, we fetch an API response for every txn hash we have. API returns an array of both v1 and v2 attestations - const sourceDomainId = getCctpDomainForChainId(sourceChainId); - const isMainnet = utils.chainIsProd(sourceChainId); - - // Circle rate limit is 35 requests / second. To avoid getting banned, batch calls into chunks with 1 second delay between chunks - // For v2, this is actually required because we don't know if message is finalized or not before hitting the API. Therefore as our - // CCTP v2 list of chains grows, we might require more than 35 calls here to fetch all attestations - const attestationResponses = {}; - const chunkSize = process.env.CCTP_API_REQUEST_CHUNK_SIZE ? parseInt(process.env.CCTP_API_REQUEST_CHUNK_SIZE) : 8; - for (let i = 0; i < depositForBurnTxnHashes.length; i += chunkSize) { - const chunk = depositForBurnTxnHashes.slice(i, i + chunkSize); - - await Promise.all( - chunk.map(async (txHash) => { - const attestations = await _fetchAttestationsForTxn(sourceDomainId, txHash, isMainnet); - - // If multiple deposit for burn events, there will be multiple attestations. - attestationResponses[txHash] = attestations; - }) - ); - - if (i + chunkSize < depositForBurnTxnHashes.length) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - } - return attestationResponses; -} - -function _getPendingAttestationStatus( - attestation: CCTPV2APIAttestation | CCTPV1APIGetAttestationResponse -): CCTPMessageStatus { - if (!isDefined(attestation.attestation)) { - return "pending"; - } else { - return attestation.status === "pending_confirmations" || attestation.attestation === "PENDING" - ? "pending" - : "ready"; - } -} - -async function _hasCCTPMessageBeenProcessedEvm(nonceHash: string, contract: ethers.Contract): Promise { - const resultingCall: BigNumber = await contract.callStatic.usedNonces(nonceHash); - // If the resulting call is 1, the message has been processed. If it is 0, the message has not been processed. - return (resultingCall ?? bnZero).toNumber() === 1; -} - async function _getCCTPV1DepositEventsSvm( senderAddresses: Address[], sourceChainId: number, @@ -906,7 +757,7 @@ async function _getCCTPV1DepositEventsSvm( decodedMessage.messageHash, chainIsProd(sourceChainId) ); - const alreadyProcessed = await _hasCCTPMessageBeenProcessedEvm( + const alreadyProcessed = await utils.hasCCTPMessageBeenProcessedEvm( decodedMessage.nonceHash, destinationMessageTransmitter ); @@ -914,8 +765,8 @@ async function _getCCTPV1DepositEventsSvm( ...decodedMessage, attestation: data.attestation, status: alreadyProcessed - ? ("finalized" as CCTPMessageStatus) - : _getPendingAttestationStatus(attestationStatusObject), + ? ("finalized" as utils.CCTPMessageStatus) + : utils.getPendingAttestationStatus(attestationStatusObject), log: undefined, }; }); @@ -973,52 +824,6 @@ function _decodeDepositForBurnMessageDataV1(message: { data: string }, isSvm = f }; } -/** - * @notice The maximum amount of USDC that can be sent using a fast transfer. - * @param isMainnet Toggles whether to call CCTP API on mainnet or sandbox environment. - * @returns USDC amount in units of USDC. - * @link https://developers.circle.com/api-reference/cctp/all/get-fast-burn-usdc-allowance - */ -async function _getV2FastBurnAllowance(isMainnet: boolean): Promise { - const httpResponse = await axios.get( - `https://iris-api${isMainnet ? "" : "-sandbox"}.circle.com/v2/fastBurn/USDC/allowance` - ); - return httpResponse.data.allowance.toString(); -} - -/** - * Returns the minimum transfer fees required for a transfer to be relayed. When calling depositForBurn(), the maxFee - * parameter must be greater than or equal to the minimum fee. - * @param sourceChainId The source chain ID of the transfer. - * @param destinationChainId The destination chain ID of the transfer. - * @param isMainnet Toggles whether to call CCTP API on mainnet or sandbox environment. - * @returns The standard and fast transfer fees for the given source and destination chains. - * @link https://developers.circle.com/api-reference/cctp/all/get-burn-usdc-fees - */ -async function _getV2MinTransferFees( - sourceChainId: number, - destinationChainId: number -): Promise<{ standard: BigNumber; fast: BigNumber }> { - const isMainnet = chainIsProd(destinationChainId); - const sourceDomain = getCctpDomainForChainId(sourceChainId); - const destinationDomain = getCctpDomainForChainId(destinationChainId); - const endpoint = `https://iris-api${ - isMainnet ? "" : "-sandbox" - }.circle.com/v2/burn/USDC/fees/${sourceDomain}/${destinationDomain}`; - const httpResponse = await axios.get(endpoint); - const standardFee = httpResponse.data.find((fee) => fee.finalityThreshold === CCTPV2_FINALITY_THRESHOLD_STANDARD); - assert( - isDefined(standardFee?.minimumFee), - `CCTPUtils#getTransferFees: Standard fee not found in API response: ${endpoint}` - ); - const fastFee = httpResponse.data.find((fee) => fee.finalityThreshold === CCTPV2_FINALITY_THRESHOLD_FAST); - assert(isDefined(fastFee?.minimumFee), `CCTPUtils#getTransferFees: Fast fee not found in API response: ${endpoint}`); - return { - standard: BigNumber.from(standardFee.minimumFee), - fast: BigNumber.from(fastFee.minimumFee), - }; -} - /** * Generates an attestation proof for a given message hash. This is required to finalize a CCTP message. * @param messageHash The message hash to generate an attestation proof for. This is generated by taking the keccak256 hash of the message bytes of the initial transaction log. @@ -1038,22 +843,6 @@ async function _fetchCctpV1Attestation( return attestationResponse; } -// @todo: We can pass in a nonceHash here once we know how to recreate the nonceHash. -// Returns both v1 and v2 attestations -async function _fetchAttestationsForTxn( - sourceDomainId: number, - transactionHash: string, - isMainnet: boolean -): Promise { - const httpResponse = await axios.get( - `https://iris-api${ - isMainnet ? "" : "-sandbox" - }.circle.com/v2/messages/${sourceDomainId}?transactionHash=${transactionHash}` - ); - - return httpResponse.data; -} - async function _fetchCCTPSvmAttestationProof(transactionHash: string): Promise { const httpResponse = await axios.get( `https://iris-api.circle.com/messages/${getCctpDomainForChainId(CHAIN_IDs.SOLANA)}/${transactionHash}` @@ -1061,28 +850,3 @@ async function _fetchCCTPSvmAttestationProof(transactionHash: string): Promise { - if (log.topics[0] !== CCTP_MESSAGE_SENT_TOPIC_HASH) { - return -1; - } - // v1 and v2 have the same topic hash, so we have to do a bit of decoding here to understand the version - const messageBytes = ethers.utils.defaultAbiCoder.decode(["bytes"], log.data)[0]; - // Source: https://developers.circle.com/stablecoins/message-format - const version = parseInt(messageBytes.slice(2, 10), 16); // read version: first 4 bytes (skipping '0x') - return version; -}; - -// returns 0 for v1 `DepositForBurn` event, 1 for v2, -1 for other events -const _getDepositForBurnVersion = (log: ethers.providers.Log): number => { - const topic = log.topics[0]; - switch (topic) { - case CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V1: - return 0; - case CCTP_DEPOSIT_FOR_BURN_TOPIC_HASH_V2: - return 1; - default: - return -1; - } -}; diff --git a/src/utils/ContractUtils.ts b/src/utils/ContractUtils.ts index 1baf031bc8..971a6f4f2c 100644 --- a/src/utils/ContractUtils.ts +++ b/src/utils/ContractUtils.ts @@ -1,4 +1,5 @@ import * as typechain from "@across-protocol/contracts"; // TODO: refactor once we've fixed export from contract repo +import * as beta from "@across-protocol/contracts-beta"; import { CHAIN_IDs, getNetworkName, @@ -38,6 +39,7 @@ export function castSpokePoolName(networkId: number): string { case CHAIN_IDs.BSC: case CHAIN_IDs.HYPEREVM: case CHAIN_IDs.PLASMA: + case CHAIN_IDs.MONAD: return "Universal_SpokePool"; case CHAIN_IDs.ZK_SYNC: return "ZkSync_SpokePool"; @@ -46,8 +48,12 @@ export function castSpokePoolName(networkId: number): string { return "SvmSpoke"; case CHAIN_IDs.SONEIUM: return "Cher_SpokePool"; - case CHAIN_IDs.UNICHAIN || CHAIN_IDs.UNICHAIN_SEPOLIA: - return "DoctorWho_SpokePool"; + case CHAIN_IDs.REDSTONE: + case CHAIN_IDs.UNICHAIN: + case CHAIN_IDs.ZORA: + case CHAIN_IDs.BASE: + case CHAIN_IDs.MODE: + return "OP_SpokePool"; default: networkName = getNetworkName(networkId); } @@ -85,3 +91,22 @@ export function getDeploymentBlockNumber(contractName: string, networkId: number throw new Error(`Could not find deployment block for contract ${contractName} on ${networkId}`); } } + +// The DstOft/Cctp handler contracts only exist on HyperEVM. +export function getDstOftHandler(): Contract { + const factoryName = "DstOFTHandler"; + const artifact = beta["HyperCoreFlowExecutor__factory"]; + return new Contract(beta.getDeployedAddress(factoryName, CHAIN_IDs.HYPEREVM), artifact.abi); +} + +export function getDstCctpHandler(): Contract { + const factoryName = "SponsoredCCTPDstPeriphery"; + const artifact = beta["HyperCoreFlowExecutor__factory"]; + return new Contract(beta.getDeployedAddress(factoryName, CHAIN_IDs.HYPEREVM), artifact.abi); +} + +export function getSrcOftPeriphery(chainId: number): Contract { + const factoryName = "SponsoredOFTSrcPeriphery"; + const artifact = beta[`${factoryName}__factory`]; + return new Contract(beta.getDeployedAddress(factoryName, chainId), artifact.abi); +} diff --git a/src/utils/HyperliquidUtils.ts b/src/utils/HyperliquidUtils.ts new file mode 100644 index 0000000000..9544867bf6 --- /dev/null +++ b/src/utils/HyperliquidUtils.ts @@ -0,0 +1,84 @@ +import { Signer, isSignerWallet, assert, delay } from "./"; +import * as hl from "@nktkas/hyperliquid"; + +export function getDefaultHlTransport(extraOptions: ConstructorParameters = []) { + return new hl.HttpTransport(...extraOptions); +} + +export function getHlExchangeClient( + signer: Signer, + transport: hl.HttpTransport | hl.WebSocketTransport = getDefaultHlTransport() +) { + assert( + isSignerWallet(signer), + "HyperliquidUtils#getHlExchangeClient: Cannot define an exchange client without a wallet." + ); + return new hl.ExchangeClient({ wallet: signer, transport }); +} + +export function getHlInfoClient(transport: hl.HttpTransport | hl.WebSocketTransport = getDefaultHlTransport()) { + return new hl.InfoClient({ transport }); +} + +export async function getL2Book( + infoClient: hl.InfoClient, + params: hl.L2BookParameters, + nRetries = 0, + maxRetries = 3 +): Promise { + return await _callWithRetry(infoClient.l2Book.bind(infoClient), [params], nRetries, maxRetries); +} + +export async function getSpotMeta( + infoClient: hl.InfoClient, + nRetries = 0, + maxRetries = 3 +): Promise { + return await _callWithRetry(infoClient.spotMeta.bind(infoClient), [], nRetries, maxRetries); +} + +export async function getOpenOrders( + infoClient: hl.InfoClient, + params: hl.OpenOrdersParameters, + nRetries = 0, + maxRetries = 3 +): Promise { + return _callWithRetry(infoClient.openOrders.bind(infoClient), [params], nRetries, maxRetries); +} + +export async function getHistoricalOrders( + infoClient: hl.InfoClient, + params: hl.HistoricalOrdersParameters, + nRetries = 0, + maxRetries = 3 +): Promise { + return _callWithRetry(infoClient.historicalOrders.bind(infoClient), [params], nRetries, maxRetries); +} + +export async function getOrderStatus( + infoClient: hl.InfoClient, + params: hl.OrderStatusParameters, + nRetries = 0, + maxRetries = 3 +): Promise { + return _callWithRetry(infoClient.orderStatus.bind(infoClient), [params], nRetries, maxRetries); +} + +async function _callWithRetry( + apiCall: (...args: A) => Promise, + args: A, + nRetries: number, + maxRetries: number +): Promise { + try { + return await apiCall(...args); + } catch (e) { + if (nRetries > maxRetries) { + throw new Error(`Max retries exceeded when querying the hyperliquid API: ${e}`); + } + const delaySeconds = 2 ** nRetries + Math.random(); + await delay(delaySeconds); + // @todo Once we have a better idea on the types of errors we can suppress/retry on, then choose appropriate action based on the error thrown. + return await _callWithRetry(apiCall, args, ++nRetries, maxRetries); + } +} diff --git a/src/utils/LogUtils.ts b/src/utils/LogUtils.ts index b2db46833e..e0e409f5c9 100644 --- a/src/utils/LogUtils.ts +++ b/src/utils/LogUtils.ts @@ -1,6 +1,6 @@ // Fix to console log in single line, and add error message to log. import winston from "winston"; -import { createNewLogger, createConsoleTransport } from "@uma/logger"; +import { createNewLogger, createConsoleTransport } from "@risk-labs/logger"; const transports = process.env.DEBUG_LOG === "true" ? [createConsoleTransport()] : [new winston.transports.Console()]; const config = process.env.DEBUG_LOG === "true" ? {} : { createConsoleTransport: false }; diff --git a/src/utils/OFTUtils.ts b/src/utils/OFTUtils.ts index 8880a223d8..b6e7213648 100644 --- a/src/utils/OFTUtils.ts +++ b/src/utils/OFTUtils.ts @@ -1,7 +1,22 @@ import { OFT_NO_EID } from "@across-protocol/constants"; -import { BigNumber, BigNumberish, EvmAddress, PUBLIC_NETWORKS, assert, isDefined, CHAIN_IDs } from "."; +import { + BigNumber, + BigNumberish, + EvmAddress, + PUBLIC_NETWORKS, + assert, + isDefined, + CHAIN_IDs, + EventSearchConfig, + Provider, + paginatedEventQuery, + spreadEventWithBlockNumber, + getSrcOftPeriphery, +} from "."; import { BytesLike } from "ethers"; -import { EVM_OFT_MESSENGERS } from "../common/Constants"; +import axios from "axios"; +import { EVM_OFT_MESSENGERS } from "../common"; +import { SortableEvent } from "../interfaces"; export type SendParamStruct = { dstEid: BigNumberish; @@ -18,6 +33,25 @@ export type MessagingFeeStruct = { lzTokenFee: BigNumberish; }; +export type LzTransactionDetails = { destination: LzDestinationTransactionDetails; pathway: Pathway }; + +export type LzDestinationTransactionDetails = { status: string; failedTx: TransactionOutcome[] }; + +export type LzBridgeEvent = SortableEvent; + +type TransactionOutcome = { + txHash: string; + txError: string; + blockHash: string; + blockNumber: string; + revertReason: string; +}; + +type Pathway = { + srcEid: number; + dstEid: number; +}; + /** * @returns Endpoint Id for a given chain * @throws If oftEid is not defined for a chain or equal to OFT_NO_EID @@ -30,6 +64,16 @@ export function getEndpointId(chainId: number): number { return eid; } +/** + * @param endpoint ID The OFT endpoint ID for the given chain. + * @returns The endpoint's corresponding chain ID. + * @throws If oftEid is not defined for a chain or equal to OFT_NO_EID. + */ +export function getChainIdFromEndpointId(eid: number): number { + const [chainId] = Object.entries(PUBLIC_NETWORKS).find(([, network]) => network.oftEid === eid); + return Number(chainId); +} + /** * @returns IOFT messenger for a given chain. Only supports EVM chains for now * @throws If EVM_OFT_MESSENGERS mapping doesn't have an entry for the l1Token - chainId combination @@ -92,3 +136,32 @@ export function buildSimpleSendParamEvm(to: EvmAddress, dstEid: number, roundedA oftCmd: "0x", }; } + +/** + * @notice Fetches destination chain transaction details for a outbound message. + * @param txHash Transaction hash of the outbound message on the origin chain. + * @returns Message data as outlined in these docs: https://docs.layerzero.network/v2/concepts/troubleshooting/debugging-messages#response-shape. + */ +export async function getLzTransactionDetails(txHash: string): Promise { + const httpResponse = await axios.get(`https://scan.layerzero-api.com/v1/messages/tx/${txHash}`); + const txDetails = httpResponse.data; + return txDetails; +} + +/** + * @notice Fetches OFT messages initiated from a srcOft contract + * @param srcChainId Chain ID corresponding to the deployed srcOftMessenger. + * @param searchConfig Event search config to use on srcChainId. + * @param srcProvider ethers Provider instance for the srcChainId. + * @returns A list of SortableEvents corresponding to bridge events on srcChainId. + */ +export async function getSrcOftMessages( + srcChainId: number, + searchConfig: EventSearchConfig, + srcProvider: Provider +): Promise { + const srcOft = getSrcOftPeriphery(srcChainId).connect(srcProvider); + + const messageInitiatedEvents = await paginatedEventQuery(srcOft, srcOft.filters.SponsoredOFTSend(), searchConfig); + return messageInitiatedEvents.map(spreadEventWithBlockNumber); +} diff --git a/src/utils/RedisUtils.ts b/src/utils/RedisUtils.ts index 46e679ae1f..2f0ae15166 100644 --- a/src/utils/RedisUtils.ts +++ b/src/utils/RedisUtils.ts @@ -2,87 +2,15 @@ import { assert, toBN, BigNumberish, isDefined } from "./"; import { REDIS_URL_DEFAULT } from "../common/Constants"; import { createClient } from "redis4"; import winston from "winston"; -import { Deposit, Fill, CachingMechanismInterface, PubSubMechanismInterface } from "../interfaces"; +import { Deposit, Fill, PubSubMechanismInterface } from "../interfaces"; import dotenv from "dotenv"; -import { RedisCache } from "../caching/RedisCache"; -import { constants } from "@across-protocol/sdk"; -import { RedisPubSub } from "../caching/RedisPubSub"; +import { disconnectRedisClient, RedisCache, RedisCacheInterface, RedisClient } from "../caching/RedisCache"; dotenv.config(); const globalNamespace: string | undefined = process.env.GLOBAL_CACHE_NAMESPACE ? String(process.env.GLOBAL_CACHE_NAMESPACE) : undefined; -export type _RedisClient = ReturnType; - -export class RedisClient { - constructor( - private readonly client: _RedisClient, - private readonly namespace?: string, - private readonly logger?: winston.Logger - ) { - this.logger?.debug({ - at: "RedisClient#constructor", - message: isDefined(namespace) ? `Created redis client with namespace ${namespace}` : "Created redis client.", - }); - } - - private getNamespacedKey(key: string): string { - return isDefined(this.namespace) ? `${this.namespace}:${key}` : key; - } - - get url(): string { - return this.client.options.url; - } - - async get(key: string): Promise { - return this.client.get(this.getNamespacedKey(key)); - } - - async ttl(key: string): Promise { - return this.client.ttl(this.getNamespacedKey(key)); - } - - async set(key: string, val: string, expirySeconds = constants.DEFAULT_CACHING_TTL): Promise { - // Apply namespace to key. - key = this.getNamespacedKey(key); - if (expirySeconds === Number.POSITIVE_INFINITY) { - // No TTL - await this.client.set(key, val); - } else if (expirySeconds > 0) { - // EX: Expire key after expirySeconds. - await this.client.set(key, val, { EX: expirySeconds }); - } else { - if (expirySeconds <= 0) { - this.logger?.warn({ - at: "RedisClient#setRedisKey", - message: `Tried to set key ${key} with expirySeconds = ${expirySeconds}. This shouldn't be allowed.`, - }); - } - await this.client.set(key, val); - } - } - - pub(channel: string, message: string): Promise { - return this.client.publish(channel, message); - } - - async sub(channel: string, listener: (message: string, channel: string) => void): Promise { - await this.client.subscribe(channel, listener); - return 1; - } - - async duplicate(): Promise { - const newClient = this.client.duplicate(); - await newClient.connect(); - return new RedisClient(newClient, this.namespace, this.logger); - } - - async disconnect(): Promise { - await disconnectRedisClient(this.client, this.logger); - } -} - // Avoid caching calls that are recent enough to be affected by things like reorgs. // Current time must be >= 15 minutes past the event timestamp for it to be stable enough to cache. export const REDIS_CACHEABLE_AGE = 15 * 60; @@ -90,11 +18,11 @@ export const REDIS_CACHEABLE_AGE = 15 * 60; export const REDIS_URL = process.env.REDIS_URL || REDIS_URL_DEFAULT; // Make the redis client for a particular url essentially a singleton. -const redisClients: { [url: string]: RedisClient } = {}; +const redisClients: { [url: string]: RedisCache } = {}; -export async function getRedis(logger?: winston.Logger, url = REDIS_URL): Promise { +async function _getRedis(logger?: winston.Logger, url = REDIS_URL): Promise { if (!redisClients[url]) { - let redisClient: _RedisClient | undefined = undefined; + let redisClient: RedisClient | undefined = undefined; const reconnectStrategy = (retries: number): number | Error => { // Set a maximum retry limit to prevent infinite reconnection attempts const MAX_RETRIES = 10; @@ -127,7 +55,7 @@ export async function getRedis(logger?: winston.Logger, url = REDIS_URL): Promis message: `Connected to redis server at ${url} successfully!`, dbSize: await redisClient.dbSize(), }); - redisClients[url] = new RedisClient(redisClient, globalNamespace); + redisClients[url] = new RedisCache(redisClient, globalNamespace); } catch (err) { delete redisClients[url]; await disconnectRedisClient(redisClient, logger); @@ -143,19 +71,13 @@ export async function getRedis(logger?: winston.Logger, url = REDIS_URL): Promis return redisClients[url]; } -export async function getRedisCache( - logger?: winston.Logger, - url?: string -): Promise { +export async function getRedisCache(logger?: winston.Logger, url?: string): Promise { // Don't permit redis to be used in test. if (isDefined(process.env.RELAYER_TEST)) { return undefined; } - const client = await getRedis(logger, url); - if (client) { - return new RedisCache(client); - } + return await _getRedis(logger, url); } export async function getRedisPubSub( @@ -167,23 +89,14 @@ export async function getRedisPubSub( return undefined; } - const client = await getRedis(logger, url); + const client = await _getRedis(logger, url); if (client) { // since getRedis returns the same client instance for the same url, // we need to duplicate it before creating a new RedisPubSub instance - return new RedisPubSub(await client.duplicate()); + return await client.duplicate(); } } -export async function setRedisKey( - key: string, - val: string, - redisClient: RedisClient, - expirySeconds = constants.DEFAULT_CACHING_TTL -): Promise { - await redisClient.set(key, val, expirySeconds); -} - export function getRedisDepositKey(depositOrFill: Deposit | Fill): string { return `deposit_${depositOrFill.originChainId}_${depositOrFill.depositId.toString()}`; } @@ -191,16 +104,16 @@ export function getRedisDepositKey(depositOrFill: Deposit | Fill): string { export async function setDeposit( deposit: Deposit, currentChainTime: number, - redisClient: RedisClient, + redisClient: RedisCache, expirySeconds = 0 ): Promise { if (shouldCache(deposit.quoteTimestamp, currentChainTime)) { - await setRedisKey(getRedisDepositKey(deposit), JSON.stringify(deposit), redisClient, expirySeconds); + await redisClient.set(getRedisDepositKey(deposit), JSON.stringify(deposit), expirySeconds); } } -export async function getDeposit(key: string, redisClient: RedisClient): Promise { - const depositRaw = await redisClient.get(key); +export async function getDeposit(key: string, redisClient: RedisCache): Promise { + const depositRaw = await redisClient.get(key); if (depositRaw) { return JSON.parse(depositRaw, objectWithBigNumberReviver); } @@ -273,23 +186,3 @@ export function objectWithBigNumberReviver(_: string, value: { type: string; hex } return toBN(value.hex); } - -/** - * An internal function to disconnect from a redis client. This function is designed to NOT throw an error if the - * disconnect fails. - * @param client The redis client to disconnect from. - * @param logger An optional logger to use to log the disconnect. - */ -async function disconnectRedisClient(client: _RedisClient, logger?: winston.Logger): Promise { - let disconnectSuccessful = true; - try { - await client.disconnect(); - } catch (_e) { - disconnectSuccessful = false; - } - const url = client.options.url ?? "unknown"; - logger?.debug({ - at: "RedisClient#disconnect", - message: `Disconnected from redis server at ${url} successfully? ${disconnectSuccessful}`, - }); -} diff --git a/src/utils/SDKUtils.ts b/src/utils/SDKUtils.ts index 4d44ef2f4d..b1c51eb3c0 100644 --- a/src/utils/SDKUtils.ts +++ b/src/utils/SDKUtils.ts @@ -103,6 +103,7 @@ export const { convertRelayDataParamsToNative, convertFillParamsToNative, chunk, + chainIsL1, } = sdk.utils; export const { diff --git a/src/utils/TransactionUtils.ts b/src/utils/TransactionUtils.ts index 75387e1331..81285e0b03 100644 --- a/src/utils/TransactionUtils.ts +++ b/src/utils/TransactionUtils.ts @@ -23,7 +23,6 @@ import { } from "../utils"; import { CompilableTransactionMessage, - KeyPairSigner, getBase64EncodedWireTransaction, signTransactionMessageWithSigners, type MicroLamports, @@ -41,6 +40,8 @@ const MIN_GAS_RETRY_SCALER_DEFAULT = 1.1; const MAX_GAS_RETRY_SCALER_DEFAULT = 3; const TRANSACTION_SUBMISSION_RETRIES_DEFAULT = 3; +export type SolanaTransaction = CompilableTransactionMessage; + export type TransactionSimulationResult = { transaction: AugmentedTransaction; succeed: boolean; @@ -222,8 +223,7 @@ export async function sendRawTransaction( } export async function sendAndConfirmSolanaTransaction( - _unsignedTransaction: CompilableTransactionMessage, - signer: KeyPairSigner, + _unsignedTransaction: SolanaTransaction, provider: SVMProvider, cycles = 25, pollingDelay = 600 // 1.5 slots on Solana. diff --git a/src/utils/index.ts b/src/utils/index.ts index 4249e1258d..0114e8065d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -25,7 +25,7 @@ export type { Block, TransactionResponse, TransactionReceipt, Provider } from "@ export { config } from "dotenv"; -export { waitForLogger } from "@uma/logger"; +export { waitForLogger } from "@risk-labs/logger"; export { CHAIN_IDs, @@ -78,3 +78,5 @@ export * from "./BNUtils"; export * from "./CCTPUtils"; export * from "./RetryUtils"; export * from "./BinanceUtils"; +export * from "./OFTUtils"; +export * from "./HyperliquidUtils"; diff --git a/tasks/integration-tests.ts b/tasks/integration-tests.ts index d4410be65a..77dead7ba1 100644 --- a/tasks/integration-tests.ts +++ b/tasks/integration-tests.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { task } from "hardhat/config"; import { getSigner, winston } from "../src/utils"; -import { SpyTransport, bigNumberFormatter } from "@uma/logger"; +import { SpyTransport, bigNumberFormatter } from "@risk-labs/logger"; import { runDataworker } from "../src/dataworker"; import { runRelayer } from "../src/relayer"; import { runFinalizer } from "../src/finalizer"; diff --git a/test/InventoryClient.RefundChain.ts b/test/InventoryClient.RefundChain.ts index 72a170f974..91ded84aff 100644 --- a/test/InventoryClient.RefundChain.ts +++ b/test/InventoryClient.RefundChain.ts @@ -18,7 +18,7 @@ import { import { ConfigStoreClient, InventoryClient } from "../src/clients"; // Tested import { CrossChainTransferClient } from "../src/clients/bridges"; -import { Deposit, InventoryConfig } from "../src/interfaces"; +import { Deposit, InventoryConfig, SwapRoute } from "../src/interfaces"; import { CHAIN_IDs, ZERO_ADDRESS, @@ -283,6 +283,44 @@ describe("InventoryClient: Refund chain selection", async function () { ); }); + it("Allows user to define swap routes", async function () { + const swapRoutes: SwapRoute[] = [ + { + fromChain: POLYGON, + fromToken: l2TokensForWeth[POLYGON], + toChain: OPTIMISM, + toToken: l2TokensForUsdc[OPTIMISM], + bidirectional: false, + }, + ]; + const _inventoryClient = new MockInventoryClient( + toAddressType(owner.address, MAINNET), + spyLogger, + { + ...inventoryConfig, + allowedSwapRoutes: swapRoutes, + }, + tokenClient, + enabledChainIds, + hubPoolClient, + adapterManager, + crossChainTransferClient, + false, // simMode + false // prioritizeUtilization + ); + + expect( + await _inventoryClient.isSwapSupported( + toAddressType(l2TokensForWeth[POLYGON], POLYGON), + toAddressType(l2TokensForUsdc[OPTIMISM], OPTIMISM), + POLYGON, + OPTIMISM + ) + ).to.be.true; + // Should not throw: + await _inventoryClient.determineRefundChainId(sampleDepositData); + }); + it("token config is not defined", async function () { // Defaults to destination chain. const _inventoryClient = new MockInventoryClient( diff --git a/test/ProfitClient.ConsiderProfitability.ts b/test/ProfitClient.ConsiderProfitability.ts index cdaab7e5d5..55283d9a85 100644 --- a/test/ProfitClient.ConsiderProfitability.ts +++ b/test/ProfitClient.ConsiderProfitability.ts @@ -92,7 +92,7 @@ describe("ProfitClient: Consider relay profit", () => { }; const tokens = Object.fromEntries( - ["MATIC", "USDC", "WBTC", "WETH"].map((symbol) => { + ["POL", "USDC", "WBTC", "WETH"].map((symbol) => { const { decimals, addresses } = TOKEN_SYMBOLS_MAP[symbol]; return [symbol, { symbol, decimals, address: addresses[CHAIN_IDs.MAINNET] }]; }) diff --git a/test/ProfitClient.PriceRetrieval.ts b/test/ProfitClient.PriceRetrieval.ts index 0d32d9fc3f..acc5f21e57 100644 --- a/test/ProfitClient.PriceRetrieval.ts +++ b/test/ProfitClient.PriceRetrieval.ts @@ -4,7 +4,7 @@ import { bnZero, TOKEN_SYMBOLS_MAP, toAddressType, toAddressType } from "../src/ import { expect, ethers, createSpyLogger, hubPoolFixture, deployConfigStore, randomAddress, toBNWei } from "./utils"; import { MockHubPoolClient } from "./mocks"; -const mainnetTokens = ["WETH", "WBTC", "DAI", "USDC", "USDT", "BAL", "MATIC"].map((symbol) => { +const mainnetTokens = ["WETH", "WBTC", "DAI", "USDC", "USDT", "BAL", "POL"].map((symbol) => { const { decimals, addresses } = TOKEN_SYMBOLS_MAP[symbol]; const address = addresses[1]; return { symbol, decimals, address }; @@ -73,7 +73,7 @@ describe("ProfitClient: Price Retrieval", async () => { it("Correctly resolves addresses for gas token symbols", async () => { await profitClient.update(); - ["ETH", "MATIC"].forEach((gasToken) => expect(profitClient.resolveTokenAddress(gasToken)).to.not.be.undefined); + ["ETH", "POL"].forEach((gasToken) => expect(profitClient.resolveTokenAddress(gasToken)).to.not.be.undefined); }); it("Remaps token symbols to equivalent token symbols", async () => { diff --git a/test/Refiller.ts b/test/Refiller.ts index 5ab58e9962..813c2db848 100644 --- a/test/Refiller.ts +++ b/test/Refiller.ts @@ -47,7 +47,7 @@ describe("Refiller", function () { }; const refiller = new Refiller(spyLogger, config, clients); await refiller.initialize(); - await refiller.refillNativeTokenBalances(); + await refiller.refillBalances(); expect(multiCallerClient.transactionCount()).to.equal(1); await multiCallerClient.executeTxnQueues(); diff --git a/test/Relayer.BasicFill.ts b/test/Relayer.BasicFill.ts index ffa57257f2..af586b60fc 100644 --- a/test/Relayer.BasicFill.ts +++ b/test/Relayer.BasicFill.ts @@ -209,6 +209,7 @@ describe("Relayer: Check for Unfilled Deposits and Fill", async function () { }, { relayerTokens: [], + relayerDestinationTokens: {}, minDepositConfirmations: defaultMinDepositConfirmations, sendingRelaysEnabled: true, tryMulticallChains: [], @@ -422,6 +423,7 @@ describe("Relayer: Check for Unfilled Deposits and Fill", async function () { }, { relayerTokens: [], + relayerDestinationTokens: {}, relayerOriginChains: [destinationChainId], relayerDestinationChains: [originChainId], minDepositConfirmations: defaultMinDepositConfirmations, @@ -448,6 +450,43 @@ describe("Relayer: Check for Unfilled Deposits and Fill", async function () { } }); + it("Filters on custom destination tokens", async function () { + relayerInstance = new Relayer( + relayer.address, + spyLogger, + { + spokePoolClients, + hubPoolClient, + configStoreClient, + tokenClient, + profitClient, + multiCallerClient, + inventoryClient, + acrossApiClient: new AcrossApiClient(spyLogger, hubPoolClient, chainIds), + tryMulticallClient, + }, + { + relayerTokens: [], + relayerDestinationTokens: { + [destinationChainId]: [], // Explicitly do not include output token here + }, + minDepositConfirmations: defaultMinDepositConfirmations, + sendingRelaysEnabled: true, + tryMulticallChains: [], + sendingMessageRelaysEnabled: {}, + loggingInterval: -1, + } as unknown as RelayerConfig + ); + + // Deposit for output token should be ignored. + await depositV3(spokePool_1, destinationChainId, depositor, inputToken, inputAmount, outputToken, outputAmount); + await updateAllClients(); + const txnReceipts = await relayerInstance.checkForUnfilledDepositsAndFill(); + for (const receipts of Object.values(txnReceipts)) { + expect((await receipts).length).to.equal(0); + } + }); + it("Ignores expired deposits", async function () { const spokePoolTime = (await spokePool_2.getCurrentTime()).toNumber(); const fillDeadline = spokePoolTime + 60; @@ -537,6 +576,7 @@ describe("Relayer: Check for Unfilled Deposits and Fill", async function () { }, { relayerTokens: [], + relayerDestinationTokens: {}, minDepositConfirmations: { [originChainId]: [{ usdThreshold: bnUint256Max, minConfirmations: 3 }], }, @@ -577,6 +617,7 @@ describe("Relayer: Check for Unfilled Deposits and Fill", async function () { { minFillTime: { [destinationChainId]: minFillTime }, relayerTokens: [], + relayerDestinationTokens: {}, minDepositConfirmations: defaultMinDepositConfirmations, sendingRelaysEnabled: true, sendingMessageRelaysEnabled: {}, @@ -648,6 +689,7 @@ describe("Relayer: Check for Unfilled Deposits and Fill", async function () { }, { relayerTokens: [], + relayerDestinationTokens: {}, minDepositConfirmations: { [originChainId]: originChainConfirmations, [destinationChainId]: [{ usdThreshold: bnUint256Max, minConfirmations: 1 }], @@ -774,6 +816,7 @@ describe("Relayer: Check for Unfilled Deposits and Fill", async function () { }, { relayerTokens: [], + relayerDestinationTokens: {}, minDepositConfirmations: { [originChainId]: originChainConfirmations, [destinationChainId]: [{ usdThreshold: bnUint256Max, minConfirmations: 1 }], diff --git a/test/Relayer.IndexedSpokePoolClient.ts b/test/Relayer.IndexedSpokePoolClient.ts index 35bea38827..aca2b70e92 100644 --- a/test/Relayer.IndexedSpokePoolClient.ts +++ b/test/Relayer.IndexedSpokePoolClient.ts @@ -6,7 +6,7 @@ import { constants, utils as sdkUtils } from "@across-protocol/sdk"; import { SpokeListener, EVMSpokePoolClient } from "../src/clients"; import { Log } from "../src/interfaces"; import { EventSearchConfig, sortEventsAscending, sortEventsAscendingInPlace } from "../src/utils"; -import { SpokePoolClientMessage } from "../src/clients/SpokePoolClient"; +import { ListenerMessage } from "../src/libexec/types"; import { assertPromiseError, createSpyLogger, deploySpokePoolWithToken, expect, randomAddress } from "./utils"; type Constructor = new (...args: any[]) => T; @@ -35,30 +35,31 @@ describe("IndexedSpokePoolClient: Update", async function () { const chainId = CHAIN_IDs.MAINNET; const randomNumber = (ceil = 1_000_000) => Math.floor(Math.random() * ceil); - const makeHash = () => ethersUtils.id(randomNumber().toString()); - const makeTopic = () => ethersUtils.id(randomNumber().toString()).slice(0, 40); + const makeHash = (seed?: number) => ethersUtils.id((seed ?? randomNumber()).toString()); + const makeTopic = (signature?: string) => ethersUtils.id(signature ?? randomNumber().toString()).slice(0, 40); let blockNumber = 100; - const generateEvent = (event: string, blockNumber: number): Log => { + const generateEvent = (event: string, blockNumber: number, transactionHash?: string): Log => { + transactionHash ??= makeHash(); return { blockNumber, transactionIndex: randomNumber(100), logIndex: randomNumber(100), - transactionHash: makeHash(), + transactionHash, removed: false, address: randomAddress(), data: ethersUtils.id(`EventManager-random-txndata-${randomNumber()}`), topics: [makeTopic()], args: [] as Result, - blockHash: makeHash(), + blockHash: makeHash(blockNumber), event, }; }; let depositId: number; - const getDepositEvent = (blockNumber: number): Log => { - const event = generateEvent("FundsDeposited", blockNumber); + const getDepositEvent = (blockNumber: number, transactionHash?: string): Log => { + const event = generateEvent("FundsDeposited", blockNumber, transactionHash); const args = { depositor: randomAddress(), recipient: randomAddress(), @@ -98,7 +99,7 @@ describe("IndexedSpokePoolClient: Update", async function () { * instance is immediately accessible and the message handler callback is called directly. */ const postEvents = (blockNumber: number, currentTime: number, events: Log[]): void => { - const message: SpokePoolClientMessage = { + const message: ListenerMessage = { blockNumber, currentTime, nEvents: events.length, @@ -179,6 +180,24 @@ describe("IndexedSpokePoolClient: Update", async function () { expect(droppedDeposit).to.not.exist; }); + it("Correctly removes all pending events for a given blockHash", async function () { + const events: Log[] = []; + for (let i = 0; i < 25; ++i) { + events.push(getDepositEvent(blockNumber)); + } + + postEvents(blockNumber, currentTime, events); + + const [droppedEvent] = events; + removeEvent(droppedEvent); + + await spokePoolClient.update(); + + // All events should have been dropped before SpokePoolClient update. + const deposits = spokePoolClient.getDeposits(); + expect(deposits.length).to.equal(0); + }); + it("Correctly removes pending events that are dropped after update", async function () { const events: Log[] = []; for (let i = 0; i < 25; ++i) { @@ -202,6 +221,29 @@ describe("IndexedSpokePoolClient: Update", async function () { expect(droppedDeposit).to.not.exist; }); + it("Correctly removes multiple deposits within the same transactionHash after update", async function () { + const events: Log[] = []; + const deposit = getDepositEvent(blockNumber); + const { transactionHash } = deposit; + for (let i = 0; i < 25; ++i) { + events.push(getDepositEvent(blockNumber, transactionHash)); + } + sortEventsAscendingInPlace(events); + + postEvents(blockNumber, currentTime, events); + await spokePoolClient.update(); + + let deposits = spokePoolClient.getDeposits(); + expect(deposits.length).to.equal(events.length); + + // Drop a single event and verify that all related events w/ same transactionHash are also dropped. + removeEvent(deposit); + + await spokePoolClient.update(); + deposits = spokePoolClient.getDeposits(); + expect(deposits.length).to.equal(0); + }); + it("Throws on post-ingested dropped EnabledDepositRoute events", async function () { const events: Log[] = []; for (let i = 0; i < 25; ++i) { diff --git a/test/Relayer.SlowFill.ts b/test/Relayer.SlowFill.ts index 65cbc80e6e..5ca6f5373d 100644 --- a/test/Relayer.SlowFill.ts +++ b/test/Relayer.SlowFill.ts @@ -180,6 +180,7 @@ describe("Relayer: Initiates slow fill requests", async function () { }, { relayerTokens: [], + relayerDestinationTokens: {}, slowDepositors: [], minDepositConfirmations: defaultMinDepositConfirmations, tryMulticallChains: [], @@ -241,7 +242,7 @@ describe("Relayer: Initiates slow fill requests", async function () { spyLogIncludes( spy, -10, - "Taking repayment for deposit 0 with preferred chains [1] on destination chain 1337 would also not be profitable." + "Taking repayment for deposit 0 with preferred chains [1] on destination chain 1342 would also not be profitable." ) ).to.be.true; expect(lastSpyLogIncludes(spy, "Requested slow fill for deposit.")).to.be.true; diff --git a/test/Relayer.TokenShortfall.ts b/test/Relayer.TokenShortfall.ts index 46bd16131e..54c5db7428 100644 --- a/test/Relayer.TokenShortfall.ts +++ b/test/Relayer.TokenShortfall.ts @@ -180,6 +180,7 @@ describe("Relayer: Token balance shortfall", async function () { }, { relayerTokens: [], + relayerDestinationTokens: {}, slowDepositors: [], minDepositConfirmations: defaultMinDepositConfirmations, tryMulticallChains: [], diff --git a/test/Relayer.UnfilledDeposits.ts b/test/Relayer.UnfilledDeposits.ts index d0b39bbba7..6900ccfce5 100644 --- a/test/Relayer.UnfilledDeposits.ts +++ b/test/Relayer.UnfilledDeposits.ts @@ -182,6 +182,7 @@ describe("Relayer: Unfilled Deposits", async function () { }, { relayerTokens: [], + relayerDestinationTokens: {}, minDepositConfirmations: defaultMinDepositConfirmations, acceptInvalidFills: false, sendingMessageRelaysEnabled: {}, diff --git a/test/disputer.watchdog.ts b/test/disputer.watchdog.ts new file mode 100644 index 0000000000..9615c0702f --- /dev/null +++ b/test/disputer.watchdog.ts @@ -0,0 +1,98 @@ +import { Disputer } from "../src/dataworker/Disputer"; +import { bnUint256Max, bnZero, bnOne, toBNWei, ZERO_BYTES } from "../src/utils"; +import { setupUmaEcosystem } from "./fixtures/UmaEcosystemFixture"; +import { + Contract, + createSpyLogger, + deployAndConfigureHubPool, + ethers, + expect, + getContractFactory, + SignerWithAddress, + winston, +} from "./utils"; + +describe("Disputer: Watchdog", async function () { + let chainId: number; + const simulate = false; + const bondAmount = toBNWei(1); + + let hubPool: Contract, bondToken: Contract; + let owner: SignerWithAddress, signer: SignerWithAddress; + let logger: winston.Logger; + let disputer: Disputer; + let signerAddr: string; + + beforeEach(async function () { + ({ spyLogger: logger } = createSpyLogger()); + [owner, signer] = await ethers.getSigners(); + signerAddr = await signer.getAddress(); + + const umaEcosystem = await setupUmaEcosystem(owner); + ({ hubPool } = await deployAndConfigureHubPool(owner, [], umaEcosystem.finder.address, umaEcosystem.timer.address)); + + ({ chainId } = await hubPool.provider.getNetwork()); + + bondToken = await (await getContractFactory("BondToken", owner)).deploy(hubPool.address); + await bondToken.setProposer(signerAddr, true); + await umaEcosystem.collateralWhitelist.addToWhitelist(bondToken.address); + await umaEcosystem.store.setFinalFee(bondToken.address, { rawValue: toBNWei("0.1") }); + await hubPool.setBond(bondToken.address, bondAmount); + + disputer = new Disputer(chainId, logger, hubPool, signer, simulate); + await disputer.validate(); + }); + + it("Disputer::mintBond", async function () { + let balance = await bondToken.balanceOf(signerAddr); + expect(balance.gt(bnZero)).to.be.true; + + await bondToken.connect(signer).transfer(await owner.getAddress(), balance); + balance = await bondToken.balanceOf(signerAddr); + expect(balance.eq(bnZero)).to.be.true; + + await disputer.mintBond(bnOne); + balance = await bondToken.balanceOf(signerAddr); + expect(balance.eq(bnOne)).to.be.true; + }); + + it("Disputer::allowance", async function () { + let allowance = await bondToken.allowance(signerAddr, hubPool.address); + expect(allowance.eq(bnUint256Max)).to.be.true; + + allowance = await disputer.allowance(); + expect(allowance.eq(bnUint256Max)).to.be.true; + + await disputer.approve(bnZero); + + allowance = await disputer.allowance(); + expect(allowance.eq(bnZero)).to.be.true; + }); + + it("Disputer::approve", async function () { + let allowance = await bondToken.allowance(signerAddr, hubPool.address); + expect(allowance.gt(bnZero)).to.be.true; + + await bondToken.connect(signer).approve(hubPool.address, bnZero); + allowance = await bondToken.allowance(signerAddr, hubPool.address); + expect(allowance.eq(bnZero)).to.be.true; + + await disputer.approve(bnOne); + allowance = await bondToken.allowance(signerAddr, hubPool.address); + expect(allowance.eq(bnOne)).to.be.true; + }); + + it("Disputer::dispute", async function () { + const balance = await bondToken.balanceOf(signerAddr); + expect(balance.gte(bondAmount.mul(2))).to.be.true; + + const allowance = await bondToken.allowance(signerAddr, hubPool.address); + expect(allowance.gte(bondAmount.mul(2))).to.be.true; + + // Propose an empty bundle. + await hubPool.connect(signer).proposeRootBundle([], 1, ZERO_BYTES, ZERO_BYTES, ZERO_BYTES); + + const txnReceipt = await disputer.dispute(); + expect(txnReceipt).to.exist; + }); +}); diff --git a/test/finalizeCCTPV1Messages.test.ts b/test/finalizeCCTPV1Messages.test.ts index 23da63ed42..3dafa61661 100644 --- a/test/finalizeCCTPV1Messages.test.ts +++ b/test/finalizeCCTPV1Messages.test.ts @@ -8,7 +8,7 @@ import { createDefaultSolanaClient, encodePauseDepositsMessageBody } from "./uti import { signer } from "./Solana.setup"; import { finalizeCCTPV1MessagesSVM } from "../src/finalizer/utils/cctp/svmUtils"; import { cctpV1L1toSvmL2Finalizer } from "../src/finalizer/utils/cctp/l1ToSvmL2"; -import { AttestedCCTPMessage, ZERO_ADDRESS } from "../src/utils"; +import { AttestedCCTPMessage, ZERO_ADDRESS, EvmAddress } from "../src/utils"; import { FinalizerPromise } from "../src/finalizer/types"; import { createSpyLogger, ethers, getContractFactory } from "./utils"; import { setupDataworker } from "./fixtures/Dataworker.Fixture"; @@ -21,6 +21,9 @@ interface ExtendedSolanaClient extends ReturnType(); +addressesToFinalize.set(EvmAddress.from("0x1234567890123456789012345678901234567890"), []); + // Helper function for the new tests const getAttestedMessage = async ( messageBody: Buffer, @@ -351,7 +354,7 @@ describe("finalizeCCTPV1Messages", () => { hubPoolClient as any, // Cast to local HubPoolClient type l2SpokePoolClient, evmSpokePoolClient, - ["0x1234567890123456789012345678901234567890"] as any + addressesToFinalize ); // Verify the result structure @@ -416,7 +419,7 @@ describe("finalizeCCTPV1Messages", () => { hubPoolClient as any, // Cast to local HubPoolClient type l2SpokePoolClient, evmSpokePoolClient, - ["0x1234567890123456789012345678901234567890"] as any + addressesToFinalize ); // Verify the result structure @@ -479,7 +482,7 @@ describe("finalizeCCTPV1Messages", () => { hubPoolClient as any, // Cast to local HubPoolClient type l2SpokePoolClient, evmSpokePoolClient, - ["0x1234567890123456789012345678901234567890"] as any + addressesToFinalize ); // Verify the result structure @@ -548,7 +551,7 @@ describe("finalizeCCTPV1Messages", () => { hubPoolClient as any, // Cast to local HubPoolClient type l2SpokePoolClient, evmSpokePoolClient, - ["0x1234567890123456789012345678901234567890"] as any + addressesToFinalize ); // Verify the result structure diff --git a/test/utils/index.ts b/test/utils/index.ts index 19bbc0baef..af6802efe7 100644 --- a/test/utils/index.ts +++ b/test/utils/index.ts @@ -1,5 +1,5 @@ export * as contracts from "@across-protocol/contracts/dist/test-utils"; -export * as uma from "@uma/logger"; +export * as uma from "@risk-labs/logger"; export * from "./utils"; export * from "./BlockchainUtils"; diff --git a/test/utils/utils.ts b/test/utils/utils.ts index 8e76de8d9a..32f78863b6 100644 --- a/test/utils/utils.ts +++ b/test/utils/utils.ts @@ -1,5 +1,5 @@ import * as utils from "@across-protocol/contracts/dist/test-utils"; -import { SpyTransport, bigNumberFormatter } from "@uma/logger"; +import { SpyTransport, bigNumberFormatter } from "@risk-labs/logger"; import { AcrossConfigStore, FakeContract } from "@across-protocol/contracts"; import { constants, utils as sdkUtils } from "@across-protocol/sdk"; import { Contract, providers } from "ethers"; @@ -50,7 +50,7 @@ export { lastSpyLogLevel, spyLogIncludes, spyLogLevel, -} from "@uma/logger"; +} from "@risk-labs/logger"; export { MAX_SAFE_ALLOWANCE, MAX_UINT_VAL } from "../../src/utils"; export const { ethers, diff --git a/yarn.lock b/yarn.lock index b7b85206f3..d79bbcaf8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16,15 +16,49 @@ resolved "https://registry.yarnpkg.com/@across-protocol/constants/-/constants-3.1.77.tgz#0d57db8676a7eaf4541b2cc9143d95e8d753c0af" integrity sha512-zRRTFAKhLgAA1QBJsbG5KautFczW0NV0N2hPW8dIn3cyC+pvYBU8FBb3gQ2Vp9b++kg1Gk1rSpMVMKKnjUBtOA== -"@across-protocol/constants@^3.1.78": - version "3.1.78" - resolved "https://registry.yarnpkg.com/@across-protocol/constants/-/constants-3.1.78.tgz#421c3692388f14bf9432cf4b947234d83a34d699" - integrity sha512-0+dTcSV/afBcO0HmIXC24DEBNEqVzNbg7sLePEO40/Q0i5ERw9QPTOEDWdkaYN4rv+yISLqjt5hYN7mmX1Smpw== +"@across-protocol/constants@^3.1.88": + version "3.1.88" + resolved "https://registry.yarnpkg.com/@across-protocol/constants/-/constants-3.1.88.tgz#1ba756921a06bd5bd2d3750a934b1ceb624a6b66" + integrity sha512-pwvbDpaMm25VCsjc9G3gsIyWxY/mak+AGg0NHQszhS1nbKFFZxgJe6Qs5v9IIND7mlxTAXFmXNLOsjwC9U5Kkg== -"@across-protocol/constants@^3.1.79": - version "3.1.79" - resolved "https://registry.yarnpkg.com/@across-protocol/constants/-/constants-3.1.79.tgz#674363af1e5ee177ad8962c7dec075b21c14e222" - integrity sha512-7CnAkskCXCcEWNJrOC6xDzO6bfJXdNrzVfNsBsVvUGMPBn144yuBYwnLb0uIaYdZB6fX1O8bHqWsLxPxVTMx8A== +"@across-protocol/constants@^3.1.89": + version "3.1.89" + resolved "https://registry.yarnpkg.com/@across-protocol/constants/-/constants-3.1.89.tgz#c35b9052cc456474e13a8c39ff071d85177596eb" + integrity sha512-KNVEnvX9Ygs9G5GSZB9NvN/HGquHfHJipmKS7kOY35UqA2yrZlzgKFKnL6//BdXlukE+eru6t+v/7lhPtYUi0A== + +"@across-protocol/contracts-beta@npm:@across-protocol/contracts@4.1.12-beta.7": + version "4.1.12-beta.7" + resolved "https://registry.yarnpkg.com/@across-protocol/contracts/-/contracts-4.1.12-beta.7.tgz#f8312777d584ec84ff277e242157bafd7475a380" + integrity sha512-ly05NCo/cgn5+hJAeS/sAS9qcJ3egXG4AF6nBNzxpKZqV/t/SBrjejLpbi7ybrQMHlW7zc/xDFCI0aWgtw1BDQ== + dependencies: + "@across-protocol/constants" "^3.1.77" + "@coral-xyz/anchor" "^0.31.1" + "@defi-wonderland/smock" "^2.3.4" + "@eth-optimism/contracts" "^0.5.40" + "@ethersproject/abstract-provider" "5.7.0" + "@ethersproject/abstract-signer" "5.7.0" + "@ethersproject/bignumber" "5.7.0" + "@openzeppelin/contracts" "5.5.0" + "@openzeppelin/contracts-upgradeable" "5.5.0" + "@openzeppelin/contracts-upgradeable-v4" "npm:@openzeppelin/contracts-upgradeable@4.9.6" + "@openzeppelin/contracts-v4" "npm:@openzeppelin/contracts@4.9.6" + "@openzeppelin/foundry-upgrades" "^0.4.0" + "@safe-global/protocol-kit" "^6.1.1" + "@scroll-tech/contracts" "^0.1.0" + "@solana-developers/helpers" "^2.4.0" + "@solana-program/address-lookup-table" "^0.7.0" + "@solana-program/token" "^0.5.1" + "@solana/kit" "^2.1.0" + "@solana/spl-token" "^0.4.6" + "@solana/web3.js" "1.98.2" + "@types/yargs" "^17.0.33" + "@uma/common" "^2.37.3" + "@uma/contracts-node" "^0.4.17" + axios "^1.7.4" + bs58 "^6.0.0" + prettier-plugin-rust "^0.1.9" + yargs "^17.7.2" + zksync-web3 "^0.14.3" "@across-protocol/contracts@^0.1.4": version "0.1.4" @@ -35,21 +69,24 @@ "@openzeppelin/contracts" "4.1.0" "@uma/core" "^2.18.0" -"@across-protocol/contracts@^4.1.9": - version "4.1.9" - resolved "https://registry.yarnpkg.com/@across-protocol/contracts/-/contracts-4.1.9.tgz#2f8a6680e899d1b26b96dda599463ba3292977a5" - integrity sha512-R0DASCEhxW2MnhXxs7abrSavFJByaVlEjA5gO5e6i58EGJmZcxZbi0EIR2WH+WRniGIYFNlAZNbRxMN16bmKhQ== +"@across-protocol/contracts@^4.1.14": + version "4.1.14" + resolved "https://registry.yarnpkg.com/@across-protocol/contracts/-/contracts-4.1.14.tgz#abedc67f8d5515d799da0d3a73fac40e8edc659f" + integrity sha512-8Akpqj4sGxdceQI+QSzNh2O2/sIX99j3TTSFMCaXH7W0MzZxsMytvHZJKgZJOgps5Aged+X61pyCC08xhWIOVQ== dependencies: - "@across-protocol/constants" "^3.1.77" + "@across-protocol/constants" "^3.1.88" "@coral-xyz/anchor" "^0.31.1" "@defi-wonderland/smock" "^2.3.4" "@eth-optimism/contracts" "^0.5.40" "@ethersproject/abstract-provider" "5.7.0" "@ethersproject/abstract-signer" "5.7.0" "@ethersproject/bignumber" "5.7.0" - "@openzeppelin/contracts" "4.9.6" - "@openzeppelin/contracts-upgradeable" "4.9.6" + "@openzeppelin/contracts" "5.5.0" + "@openzeppelin/contracts-upgradeable" "5.5.0" + "@openzeppelin/contracts-upgradeable-v4" "npm:@openzeppelin/contracts-upgradeable@4.9.6" + "@openzeppelin/contracts-v4" "npm:@openzeppelin/contracts@4.9.6" "@openzeppelin/foundry-upgrades" "^0.4.0" + "@safe-global/protocol-kit" "^6.1.1" "@scroll-tech/contracts" "^0.1.0" "@solana-developers/helpers" "^2.4.0" "@solana-program/address-lookup-table" "^0.7.0" @@ -60,21 +97,20 @@ "@types/yargs" "^17.0.33" "@uma/common" "^2.37.3" "@uma/contracts-node" "^0.4.17" - "@uma/core" "^2.61.0" axios "^1.7.4" bs58 "^6.0.0" prettier-plugin-rust "^0.1.9" yargs "^17.7.2" zksync-web3 "^0.14.3" -"@across-protocol/sdk@4.3.74": - version "4.3.74" - resolved "https://registry.yarnpkg.com/@across-protocol/sdk/-/sdk-4.3.74.tgz#06502c88651a22b44f730174e272dbfe1c10a452" - integrity sha512-7chlfO1J+POl+mDc7FjUGMVbYeAAtGC7rAfcmeZQA5kSNJqgr1wqfa8xT6UAPyFux5QdcmhIn4riDGSAB0zluQ== +"@across-protocol/sdk@4.3.97": + version "4.3.97" + resolved "https://registry.yarnpkg.com/@across-protocol/sdk/-/sdk-4.3.97.tgz#da7b17e80da7eb38b04c45e5419856ae1267f834" + integrity sha512-mNc6c+rlWbhP4lGWUqjOlv1/YuoiO4eVrbEnZrkgG0pXAunBH4o2xB1n/Js4EmhYk37DWRNAdZHbbbcYznrZug== dependencies: "@across-protocol/across-token" "^1.0.0" - "@across-protocol/constants" "^3.1.78" - "@across-protocol/contracts" "^4.1.9" + "@across-protocol/constants" "^3.1.89" + "@across-protocol/contracts" "^4.1.14" "@coral-xyz/anchor" "^0.30.1" "@eth-optimism/sdk" "^3.3.1" "@ethersproject/bignumber" "^5.7.0" @@ -1150,6 +1186,15 @@ enabled "2.0.x" kuler "^2.0.0" +"@dabh/diagnostics@^2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.8.tgz#ead97e72ca312cf0e6dd7af0d300b58993a31a5e" + integrity sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q== + dependencies: + "@so-ric/colorspace" "^1.1.6" + enabled "2.0.x" + kuler "^2.0.0" + "@defi-wonderland/smock@^2.3.4", "@defi-wonderland/smock@^2.3.5": version "2.3.5" resolved "https://registry.yarnpkg.com/@defi-wonderland/smock/-/smock-2.3.5.tgz#2f4daa10b4f4c69627200128778f61c51f4b32cc" @@ -2131,6 +2176,11 @@ protobufjs "^7.2.4" yargs "^17.7.2" +"@henrygd/semaphore@0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@henrygd/semaphore/-/semaphore-0.1.0.tgz#92edf8e441b4f565d046d5de8c8d92437b5df26d" + integrity sha512-5wRPsfP9iWnOTFRd7QKdHlWTgY9xw9GAHSkQP/Q05ZTKUBdfTj44BhsnADPfvBoUQVGDtP2Nd0UShVdeTLuLwA== + "@humanwhocodes/config-array@^0.5.0": version "0.5.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" @@ -2251,11 +2301,28 @@ tweetnacl "^1.0.3" tweetnacl-util "^0.15.1" +"@msgpack/msgpack@^3.1.2": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@msgpack/msgpack/-/msgpack-3.1.2.tgz#fdd25cc2202297519798bbaf4689152ad9609e19" + integrity sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ== + "@multiformats/base-x@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@multiformats/base-x/-/base-x-4.0.1.tgz#95ff0fa58711789d53aefb2590a8b7a4e715d121" integrity sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw== +"@nktkas/hyperliquid@^0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@nktkas/hyperliquid/-/hyperliquid-0.25.9.tgz#69b8bef3065322ea5346f67067a43077e64f89e1" + integrity sha512-rNqRG4LWeCmnWUNV5XQAl+XLH7uzShvwXj3Z7JsWC45QgU8RlSv/MODUP9CE3Xv/avXWkEEhwy/BkLQkN85s+g== + dependencies: + "@henrygd/semaphore" "0.1.0" + "@msgpack/msgpack" "^3.1.2" + "@noble/hashes" "^2.0.1" + "@noble/secp256k1" "^3.0.0" + typescript-event-target "1.1.1" + valibot "1.1.0" + "@noble/ciphers@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-1.3.0.tgz#f64b8ff886c240e644e5573c097f86e5b43676dc" @@ -2333,11 +2400,21 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== +"@noble/hashes@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" + integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== + "@noble/secp256k1@1.5.5", "@noble/secp256k1@~1.5.2": version "1.5.5" resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.5.5.tgz#315ab5745509d1a8c8e90d0bdf59823ccf9bcfc3" integrity sha512-sZ1W6gQzYnu45wPrWx8D3kwI2/U29VYTx9OjbDAd7jwRItJ0cSTMPRL/C8AWZFn9kWFLQGqEXVEE86w4Z8LpIQ== +"@noble/secp256k1@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-3.0.0.tgz#29711361db8f37b1b7e0b8d80c933013fc887475" + integrity sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -2929,11 +3006,26 @@ hex2dec "^1.0.1" uuid "^8.0.0" -"@openzeppelin/contracts-upgradeable@4.9.6", "@openzeppelin/contracts-upgradeable@^4.8.1": +"@openzeppelin/contracts-upgradeable-v4@npm:@openzeppelin/contracts-upgradeable@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.9.6.tgz#38b21708a719da647de4bb0e4802ee235a0d24df" integrity sha512-m4iHazOsOCv1DgM7eD7GupTJ+NFVujRZt1wzddDPSVGpWdKq1SKkla5htKG7+IS4d2XOCtzkUNwRZ7Vq5aEUMA== +"@openzeppelin/contracts-upgradeable@5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-5.5.0.tgz#e35b3ededa5ccc13205c2b42e2058cd5cb7d424a" + integrity sha512-Va5hKG5oaK0EE5bXTVWugcGimMHazxL+SL523dH6WVbGiuLXwuWr9oxtLyPHQSVGtgmlIgtKNR5V+OUpCIUwFQ== + +"@openzeppelin/contracts-upgradeable@^4.8.1": + version "4.9.6" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.9.6.tgz#38b21708a719da647de4bb0e4802ee235a0d24df" + integrity sha512-m4iHazOsOCv1DgM7eD7GupTJ+NFVujRZt1wzddDPSVGpWdKq1SKkla5htKG7+IS4d2XOCtzkUNwRZ7Vq5aEUMA== + +"@openzeppelin/contracts-v4@npm:@openzeppelin/contracts@4.9.6": + version "4.9.6" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.6.tgz#2a880a24eb19b4f8b25adc2a5095f2aa27f39677" + integrity sha512-xSmezSupL+y9VkHZJGDoCBpmnB2ogM13ccaYDWqJTfS3dbuHkgjuwDFUmaFauBCboQMGB/S5UqUl2y54X99BmA== + "@openzeppelin/contracts@3.4.1-solc-0.7-2": version "3.4.1-solc-0.7-2" resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-3.4.1-solc-0.7-2.tgz#371c67ebffe50f551c3146a9eec5fe6ffe862e92" @@ -2949,10 +3041,10 @@ resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.4.2.tgz#4e889c9c66e736f7de189a53f8ba5b8d789425c2" integrity sha512-NyJV7sJgoGYqbtNUWgzzOGW4T6rR19FmX1IJgXGdapGPWsuMelGJn9h03nos0iqfforCbCB0iYIR0MtIuIFLLw== -"@openzeppelin/contracts@4.9.6": - version "4.9.6" - resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.6.tgz#2a880a24eb19b4f8b25adc2a5095f2aa27f39677" - integrity sha512-xSmezSupL+y9VkHZJGDoCBpmnB2ogM13ccaYDWqJTfS3dbuHkgjuwDFUmaFauBCboQMGB/S5UqUl2y54X99BmA== +"@openzeppelin/contracts@5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-5.5.0.tgz#24e8a2f9598de484dcb223512af656edf52bc0e8" + integrity sha512-R8hq4zmKKWP2c7OxeRgAcjZwvF5W0Qq2OIX7degrtdM52Q9xYr4MLJdUAVPKGUewNJ1qo+M6YiZLLnNUnjP/gg== "@openzeppelin/contracts@^4.2.0", "@openzeppelin/contracts@^4.7.3", "@openzeppelin/contracts@^4.8.1": version "4.9.3" @@ -3020,6 +3112,15 @@ browser-or-node "^2.0.0" cross-fetch "^3.0.6" +"@peculiar/asn1-schema@^2.3.13": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.5.0.tgz#4e58d7c3087c4259cebf5363e092f85b9cbf0ca1" + integrity sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ== + dependencies: + asn1js "^3.0.6" + pvtsutils "^1.3.6" + tslib "^2.8.1" + "@pinata/sdk@^2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@pinata/sdk/-/sdk-2.1.0.tgz#d61aa8f21ec1206e867f4b65996db52b70316945" @@ -3193,6 +3294,73 @@ path-browserify "^1.0.0" url "^0.11.0" +"@risk-labs/logger@^1.3.7": + version "1.3.7" + resolved "https://registry.yarnpkg.com/@risk-labs/logger/-/logger-1.3.7.tgz#2dc38e73aee8d009283d581fbde438f33fb2f118" + integrity sha512-fQMKaP/6hcUe9ocEBcA0LLraKO+RO3giJ1RSXfeOcCllFIHWavJeMEmCAej1EurSFeqr1BsucCm2tqRizMhjGg== + dependencies: + "@google-cloud/logging-winston" "^4.1.1" + "@google-cloud/trace-agent" "^5.1.6" + "@pagerduty/pdjs" "^2.2.4" + axios "^1.12.2" + discord.js "^14.11.0" + dotenv "^9.0.0" + minimist "^1.2.0" + node-pagerduty "^1.2.0" + redis "^4.6.13" + superstruct "^2.0.2" + winston "^3.17.0" + winston-transport "^4.9.0" + +"@risk-labs/serverless-orchestration@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@risk-labs/serverless-orchestration/-/serverless-orchestration-1.0.5.tgz#2da3601ef1d1418a54160036bbc655d45603744d" + integrity sha512-Y75JStykqhPupKDzfWWt5cJI7ksADcCTYJY1N+amXxT2X419NvE9TZVrUyHCUN+07MpQ2GW+rD2tilDJqfRJRA== + dependencies: + "@awaitjs/express" "^0.3.0" + "@google-cloud/datastore" "^7.0.0" + "@google-cloud/storage" "^6.4.2" + "@risk-labs/logger" "^1.3.7" + express "^4.17.1" + fetch-retry "^5.0.3" + google-auth-library "^8.5.1" + node-fetch "^2.3.0" + viem "^2.37.2" + +"@safe-global/protocol-kit@^6.1.1": + version "6.1.1" + resolved "https://registry.yarnpkg.com/@safe-global/protocol-kit/-/protocol-kit-6.1.1.tgz#f9c262f7b9fbafa0f58e2e9185b3e4cb28d43e26" + integrity sha512-SlRosKB52h1CV2gMlKG4UOvh2j4tXuzz1GZ/yQ1HD0Zvm5azUlaytFwKzHun9xNVvfe+vvSNHUEGX2Umy+rQ9A== + dependencies: + "@safe-global/safe-deployments" "^1.37.42" + "@safe-global/safe-modules-deployments" "^2.2.14" + "@safe-global/types-kit" "^3.0.0" + abitype "^1.0.2" + semver "^7.7.2" + viem "^2.21.8" + optionalDependencies: + "@noble/curves" "^1.6.0" + "@peculiar/asn1-schema" "^2.3.13" + +"@safe-global/safe-deployments@^1.37.42": + version "1.37.47" + resolved "https://registry.yarnpkg.com/@safe-global/safe-deployments/-/safe-deployments-1.37.47.tgz#9343451979d8ec53676ec71eb6ee422732ca1cd8" + integrity sha512-abxu9nmvjfDahCIFdrHw4ENZ1CD60z/bgrv5cV3+sygADU1vuh96jFumebo+6PR/Q5qz5glrQuLwEtZ8K/lvJQ== + dependencies: + semver "^7.6.2" + +"@safe-global/safe-modules-deployments@^2.2.14": + version "2.2.18" + resolved "https://registry.yarnpkg.com/@safe-global/safe-modules-deployments/-/safe-modules-deployments-2.2.18.tgz#51cf689da2b979d9b0bbccd7d2dbf4741b33b872" + integrity sha512-OQwoguGdano/XPMSbzc9cMuRXW84KCSAoG9avKRo5O4AcGLMNDqXTYG993a3k6QeWGpPwpl9LFEpwT0oAZ6UwQ== + +"@safe-global/types-kit@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@safe-global/types-kit/-/types-kit-3.0.0.tgz#b35826af0e417fa02a540b874c109b5ddb5ed086" + integrity sha512-AZWIlR5MguDPdGiOj7BB4JQPY2afqmWQww1mu8m8Oi16HHBW99G01kFOu4NEHBwEU1cgwWOMY19hsI5KyL4W2w== + dependencies: + abitype "^1.0.2" + "@sapphire/async-queue@^1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@sapphire/async-queue/-/async-queue-1.5.0.tgz#2f255a3f186635c4fb5a2381e375d3dfbc5312d8" @@ -3922,6 +4090,14 @@ "@smithy/types" "^3.7.2" tslib "^2.6.2" +"@so-ric/colorspace@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@so-ric/colorspace/-/colorspace-1.1.6.tgz#62515d8b9f27746b76950a83bde1af812d91923b" + integrity sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw== + dependencies: + color "^5.0.2" + text-hex "1.0.x" + "@solana-developers/helpers@^2.4.0": version "2.8.1" resolved "https://registry.yarnpkg.com/@solana-developers/helpers/-/helpers-2.8.1.tgz#73618effb22073546e06c7a7875640d46c93a7fc" @@ -5327,41 +5503,6 @@ "@uniswap/v3-core" "^1.0.0-rc.2" "@uniswap/v3-periphery" "^1.0.0-beta.23" -"@uma/core@^2.61.0": - version "2.61.0" - resolved "https://registry.yarnpkg.com/@uma/core/-/core-2.61.0.tgz#29580736349a47af8fb10beb4bb3b50bfcf912f5" - integrity sha512-bnk+CWW+uWpRilrgUny/gDXHKomG+h1Ug84OXdx+AAvj1/BtlMDOCNNt1OX8LSAz+a0hkiN9s24/zgHclTC/sg== - dependencies: - "@gnosis.pm/safe-contracts" "^1.3.0" - "@gnosis.pm/zodiac" "3.2.0" - "@maticnetwork/fx-portal" "^1.0.4" - "@openzeppelin/contracts" "4.9.6" - "@uma/common" "^2.37.3" - "@uniswap/lib" "4.0.1-alpha" - "@uniswap/v2-core" "1.0.0" - "@uniswap/v2-periphery" "1.1.0-beta.0" - "@uniswap/v3-core" "^1.0.0-rc.2" - "@uniswap/v3-periphery" "^1.0.0-beta.23" - -"@uma/logger@^1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@uma/logger/-/logger-1.3.3.tgz#93a6139629fdb92001dfcc4b2ab651f8934ee8ca" - integrity sha512-Yc7byg42K8f2kFaRti2djdp+Pi0WA0gs5HogFRrxN5FVC7tdsv+R7p4sVJvv8QbB2mmyh4qnITTBPOK5K0EZHw== - dependencies: - "@google-cloud/logging-winston" "^4.1.1" - "@google-cloud/trace-agent" "^5.1.6" - "@pagerduty/pdjs" "^2.2.4" - bn.js "^4.11.9" - discord.js "^14.11.0" - dotenv "^9.0.0" - minimist "^1.2.0" - node-pagerduty "^1.2.0" - redis "^4.6.13" - superstruct "^1.0.3" - web3 "^1.6.0" - winston "^3.2.1" - winston-transport "^4.3.0" - "@uma/merkle-distributor@^1.3.38": version "1.3.40" resolved "https://registry.yarnpkg.com/@uma/merkle-distributor/-/merkle-distributor-1.3.40.tgz#70ba0ebf0fe923851786396bcb9a59a5a931127b" @@ -5376,21 +5517,6 @@ mocha "^8.3.0" node-fetch "^2.6.1" -"@uma/serverless-orchestration@^2.17.2": - version "2.17.2" - resolved "https://registry.yarnpkg.com/@uma/serverless-orchestration/-/serverless-orchestration-2.17.2.tgz#e75a7d170d48d3a3c165d728f62c47a6eed3f832" - integrity sha512-6RXl1Geb3lOaloQqpQna3SvjyMf7+txG/UMVp1VGgQKCbD0jTOERfnzRkJZ26y/2gU15zSfHYjVOARl+cGWBFw== - dependencies: - "@awaitjs/express" "^0.3.0" - "@google-cloud/datastore" "^7.0.0" - "@google-cloud/storage" "^6.4.2" - "@uma/logger" "^1.3.3" - express "^4.17.1" - fetch-retry "^5.0.3" - google-auth-library "^8.5.1" - node-fetch "^2.3.0" - viem "^2.37.2" - "@ungap/promise-all-settled@1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" @@ -5506,16 +5632,16 @@ abitype@1.1.0: resolved "https://registry.yarnpkg.com/abitype/-/abitype-1.1.0.tgz#510c5b3f92901877977af5e864841f443bf55406" integrity sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A== +abitype@^1.0.2, abitype@^1.0.9: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abitype/-/abitype-1.1.1.tgz#b50ed400f8bfca5452eb4033445c309d3e1117c8" + integrity sha512-Loe5/6tAgsBukY95eGaPSDmQHIjRZYQq8PB1MpsNccDIK8WiV+Uw6WzaIXipvaxTEL2yEB0OpEaQv3gs8pkS9Q== + abitype@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/abitype/-/abitype-1.0.6.tgz#76410903e1d88e34f1362746e2d407513c38565b" integrity sha512-MMSqYh4+C/aVqI2RQaWqbvI4Kxo5cQV40WQ4QFtDnNzCkqChm8MuENhElmynZlO0qUy/ObkEUaXtKqYnx1Kp3A== -abitype@^1.0.9: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abitype/-/abitype-1.1.1.tgz#b50ed400f8bfca5452eb4033445c309d3e1117c8" - integrity sha512-Loe5/6tAgsBukY95eGaPSDmQHIjRZYQq8PB1MpsNccDIK8WiV+Uw6WzaIXipvaxTEL2yEB0OpEaQv3gs8pkS9Q== - abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -5994,6 +6120,15 @@ asn1@~0.2.3: dependencies: safer-buffer "~2.1.0" +asn1js@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.6.tgz#53e002ebe00c5f7fd77c1c047c3557d7c04dce25" + integrity sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA== + dependencies: + pvtsutils "^1.3.6" + pvutils "^1.1.3" + tslib "^2.8.1" + assert-plus@1.0.0, assert-plus@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" @@ -6115,6 +6250,15 @@ axios@^0.27.2: follow-redirects "^1.14.9" form-data "^4.0.0" +axios@^1.12.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.2.tgz#6c307390136cf7a2278d09cec63b136dfc6e6da7" + integrity sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.4" + proxy-from-env "^1.1.0" + axios@^1.6.1: version "1.7.9" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a" @@ -7257,6 +7401,13 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" +color-convert@^3.0.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-3.1.2.tgz#cef9e0fd4cb90b07c14697b3fa70af9d7f4870f1" + integrity sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg== + dependencies: + color-name "^2.0.0" + color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" @@ -7267,6 +7418,11 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-name@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-2.0.2.tgz#85054825a23e6d6f81d3503f660c4c4a2a15f04f" + integrity sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A== + color-string@^1.6.0: version "1.9.0" resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.0.tgz#63b6ebd1bec11999d1df3a79a7569451ac2be8aa" @@ -7275,6 +7431,13 @@ color-string@^1.6.0: color-name "^1.0.0" simple-swizzle "^0.2.2" +color-string@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-2.1.2.tgz#db1dd52414cc9037ada8fa7d936b8e9f6c3366c9" + integrity sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA== + dependencies: + color-name "^2.0.0" + color-support@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" @@ -7288,6 +7451,14 @@ color@^3.1.3: color-convert "^1.9.3" color-string "^1.6.0" +color@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/color/-/color-5.0.2.tgz#712ec894007ab27b37207732d182784e001b4a3d" + integrity sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA== + dependencies: + color-convert "^3.0.1" + color-string "^2.0.0" + colors@1.4.0, colors@^1.1.2: version "1.4.0" resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" @@ -8396,6 +8567,16 @@ es-set-tostringtag@^2.0.1: has "^1.0.3" has-tostringtag "^1.0.0" +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + es-shim-unscopables@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" @@ -9663,6 +9844,17 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" + integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + 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" @@ -9976,7 +10168,7 @@ get-intrinsic@^1.2.4: has-symbols "^1.0.3" hasown "^2.0.0" -get-intrinsic@^1.3.0: +get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== @@ -10683,6 +10875,13 @@ has-tostringtag@^1.0.0: dependencies: has-symbols "^1.0.2" +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + has-unicode@^2.0.0, has-unicode@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" @@ -15123,6 +15322,18 @@ pure-rand@^5.0.1: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-5.0.1.tgz#97a287b4b4960b2a3448c0932bf28f2405cac51d" integrity sha512-ksWccjmXOHU2gJBnH0cK1lSYdvSZ0zLoCMSz/nTGh6hDvCSgcRxDyIcOBD6KNxFz3xhMPm/T267Tbe2JRymKEQ== +pvtsutils@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.6.tgz#ec46e34db7422b9e4fdc5490578c1883657d6001" + integrity sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg== + dependencies: + tslib "^2.8.1" + +pvutils@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" + integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== + qrcode-terminal@^0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" @@ -15934,6 +16145,11 @@ semver@^7.6.0: dependencies: lru-cache "^6.0.0" +semver@^7.6.2, semver@^7.7.2: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + semver@~5.4.1: version "5.4.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e" @@ -17342,6 +17558,11 @@ typeorm@^0.3.20: uuid "^11.1.0" yargs "^17.7.2" +typescript-event-target@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/typescript-event-target/-/typescript-event-target-1.1.1.tgz#20a6d491b77d2e37dc432c5394ab74c0d7065539" + integrity sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg== + typescript@^5.9: version "5.9.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6" @@ -17630,6 +17851,11 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== +valibot@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/valibot/-/valibot-1.1.0.tgz#873bb1af9e1577391690307bfe0520bd1360ec2d" + integrity sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw== + validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" @@ -17689,6 +17915,20 @@ viem@^2.21.15: webauthn-p256 "0.0.10" ws "8.18.0" +viem@^2.21.8: + version "2.38.3" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.38.3.tgz#316905584d60188d3a921fc3960e55e9e46cb3f4" + integrity sha512-By2TutLv07iNHHtWqHHzjGipevYsfGqT7KQbGEmqLco1qTJxKnvBbSviqiu6/v/9REV6Q/FpmIxf2Z7/l5AbcQ== + dependencies: + "@noble/curves" "1.9.1" + "@noble/hashes" "1.8.0" + "@scure/bip32" "1.7.0" + "@scure/bip39" "1.6.0" + abitype "1.1.0" + isows "1.0.7" + ox "0.9.6" + ws "8.18.3" + viem@^2.33.1: version "2.33.1" resolved "https://registry.yarnpkg.com/viem/-/viem-2.33.1.tgz#47ae1de711a1052f03f0f585be224383de812b03" @@ -18343,30 +18583,30 @@ winston-transport@^4.9.0: readable-stream "^3.6.2" triple-beam "^1.3.0" -winston@^3.10.0, winston@^3.2.1: - version "3.10.0" - resolved "https://registry.yarnpkg.com/winston/-/winston-3.10.0.tgz#d033cb7bd3ced026fed13bf9d92c55b903116803" - integrity sha512-nT6SIDaE9B7ZRO0u3UvdrimG0HkB7dSTAgInQnNR2SOPJ4bvq5q79+pXLftKmP52lJGW15+H5MCK0nM9D3KB/g== +winston@^3.12.0: + version "3.17.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.17.0.tgz#74b8665ce9b4ea7b29d0922cfccf852a08a11423" + integrity sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw== dependencies: - "@colors/colors" "1.5.0" + "@colors/colors" "^1.6.0" "@dabh/diagnostics" "^2.0.2" async "^3.2.3" is-stream "^2.0.0" - logform "^2.4.0" + logform "^2.7.0" one-time "^1.0.0" readable-stream "^3.4.0" safe-stable-stringify "^2.3.1" stack-trace "0.0.x" triple-beam "^1.3.0" - winston-transport "^4.5.0" + winston-transport "^4.9.0" -winston@^3.12.0: - version "3.17.0" - resolved "https://registry.yarnpkg.com/winston/-/winston-3.17.0.tgz#74b8665ce9b4ea7b29d0922cfccf852a08a11423" - integrity sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw== +winston@^3.17.0: + version "3.18.3" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.18.3.tgz#93ac10808c8e1081d723bc8811cd2f445ddfdcd1" + integrity sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww== dependencies: "@colors/colors" "^1.6.0" - "@dabh/diagnostics" "^2.0.2" + "@dabh/diagnostics" "^2.0.8" async "^3.2.3" is-stream "^2.0.0" logform "^2.7.0" @@ -18377,6 +18617,23 @@ winston@^3.12.0: triple-beam "^1.3.0" winston-transport "^4.9.0" +winston@^3.2.1: + version "3.10.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.10.0.tgz#d033cb7bd3ced026fed13bf9d92c55b903116803" + integrity sha512-nT6SIDaE9B7ZRO0u3UvdrimG0HkB7dSTAgInQnNR2SOPJ4bvq5q79+pXLftKmP52lJGW15+H5MCK0nM9D3KB/g== + dependencies: + "@colors/colors" "1.5.0" + "@dabh/diagnostics" "^2.0.2" + async "^3.2.3" + is-stream "^2.0.0" + logform "^2.4.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + safe-stable-stringify "^2.3.1" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.5.0" + word-wrap@^1.2.3, word-wrap@~1.2.3: version "1.2.4" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f"