diff --git a/src/arch/svm/SpokeUtils.ts b/src/arch/svm/SpokeUtils.ts index 929706e78..0504d3cc2 100644 --- a/src/arch/svm/SpokeUtils.ts +++ b/src/arch/svm/SpokeUtils.ts @@ -49,8 +49,8 @@ import { toAddress, unwrapEventData, } from "./"; +import { EventWithData, SVMEventNames, SVMProvider } from "./types"; import { CHAIN_IDs } from "../../constants"; -import { SVMEventNames, SVMProvider } from "./types"; /** * @note: Average Solana slot duration is about 400-500ms. We can be conservative @@ -94,7 +94,41 @@ export function getDepositIdAtBlock(_contract: unknown, _blockTag: number): Prom } /** - * Finds deposit events within a 2-day window ending at the specified slot. + * Helper function to query deposit events within a time window. + * @param eventClient - SvmCpiEventsClient instance + * @param depositId - The deposit ID to search for + * @param slot - The slot to search up to (defaults to current slot) + * @param secondsLookback - The number of seconds to look back for deposits (defaults to 2 days) + * @returns Array of deposit events within the slot window + */ +async function queryDepositEventsInWindow( + eventClient: SvmCpiEventsClient, + depositId: BigNumber, + slot?: bigint, + secondsLookback = 2 * 24 * 60 * 60 // 2 days +): Promise { + // We can only perform this search when we have a safe deposit ID. + if (isUnsafeDepositId(depositId)) { + throw new Error(`Cannot find historical deposit for unsafe deposit ID ${depositId}.`); + } + + const provider = eventClient.getRpc(); + const currentSlot = await provider.getSlot({ commitment: "confirmed" }).send(); + + // If no slot is provided, use the current slot + // If a slot is provided, ensure it's not in the future + const endSlot = slot !== undefined ? BigInt(Math.min(Number(slot), Number(currentSlot))) : currentSlot; + + // Calculate start slot (approximately secondsLookback seconds earlier) + const slotsInElapsed = BigInt(Math.round((secondsLookback * 1000) / SLOT_DURATION_MS)); + const startSlot = endSlot - slotsInElapsed; + + // Query for the deposit events with this limited slot range + return eventClient.queryEvents("FundsDeposited", startSlot, endSlot); +} + +/** + * Finds deposit events within a time window (default 2 days) ending at the specified slot. * * @remarks * This implementation uses a slot-limited search approach because Solana PDA state has @@ -114,7 +148,8 @@ export function getDepositIdAtBlock(_contract: unknown, _blockTag: number): Prom * @important * This function may return `undefined` for valid deposit IDs that are older than the search * window (approximately 2 days before the specified slot). This is an acceptable limitation - * as deposits this old are typically not relevant to current operations. + * as deposits this old are typically not relevant to current operations. This can be an issue + * if no proposal was made for a chain over a period of > 1.5 days. * * @param eventClient - SvmCpiEventsClient instance * @param depositId - The deposit ID to search for @@ -129,24 +164,10 @@ export async function findDeposit( slot?: bigint, secondsLookback = 2 * 24 * 60 * 60 // 2 days ): Promise { - // We can only perform this search when we have a safe deposit ID. - if (isUnsafeDepositId(depositId)) { - throw new Error(`Cannot binary search for depositId ${depositId}`); - } - - const provider = eventClient.getRpc(); - const currentSlot = await provider.getSlot({ commitment: "confirmed" }).send(); - - // If no slot is provided, use the current slot - // If a slot is provided, ensure it's not in the future - const endSlot = slot !== undefined ? BigInt(Math.min(Number(slot), Number(currentSlot))) : currentSlot; - - // Calculate start slot (approximately secondsLookback seconds earlier) - const slotsInElapsed = BigInt(Math.round((secondsLookback * 1000) / SLOT_DURATION_MS)); - const startSlot = endSlot - slotsInElapsed; + const depositEvents = await queryDepositEventsInWindow(eventClient, depositId, slot, secondsLookback); - // Query for the deposit events with this limited slot range. Filter by deposit id. - const depositEvent = (await eventClient.queryEvents("FundsDeposited", startSlot, endSlot))?.find((event) => + // Find the first matching deposit event + const depositEvent = depositEvents.find((event) => depositId.eq((event.data as unknown as { depositId: BigNumber }).depositId) ); @@ -172,6 +193,60 @@ export async function findDeposit( } as DepositWithBlock; } +/** + * Finds all deposit events within a time window (default 2 days) ending at the specified slot. + * + * @remarks + * This implementation uses a slot-limited search approach because Solana PDA state has + * limitations that prevent directly referencing old deposit IDs. Unlike EVM chains where + * we might use binary search across the entire chain history, in Solana we must query within + * a constrained slot range. + * + * The search window is calculated by: + * 1. Using the provided slot (or current confirmed slot if none is provided) + * 2. Looking back 2 days worth of slots from that point + * + * We use a 2-day window because: + * 1. Most valid deposits that need to be processed will be recent + * 2. This covers multiple bundle submission periods + * 3. It balances performance with practical deposit age + * + * @important + * This function may return an empty array for valid deposit IDs that are older than the search + * window (approximately 2 days before the specified slot). This is an acceptable limitation + * as deposits this old are typically not relevant to current operations. This can be an issue + * if no proposal was made for a chain over a period of > 1.5 days. + * + * @param eventClient - SvmCpiEventsClient instance + * @param depositId - The deposit ID to search for + * @param slot - The slot to search up to (defaults to current slot). The search will look + * for deposits between (slot - secondsLookback) and slot. + * @param secondsLookback - The number of seconds to look back for deposits (defaults to 2 days). + * @returns Array of deposits if found within the slot window, empty array otherwise + */ +export async function findAllDeposits( + eventClient: SvmCpiEventsClient, + depositId: BigNumber, + slot?: bigint, + secondsLookback = 2 * 24 * 60 * 60 // 2 days +): Promise { + const depositEvents = await queryDepositEventsInWindow(eventClient, depositId, slot, secondsLookback); + + // Filter for all matching deposit events + const matchingEvents = depositEvents.filter((event) => + depositId.eq((event.data as unknown as { depositId: BigNumber }).depositId) + ); + + // Return all deposit events with block info + return matchingEvents.map((event) => ({ + txnRef: event.signature.toString(), + blockNumber: Number(event.slot), + txnIndex: 0, + logIndex: 0, + ...(unwrapEventData(event.data) as Record), + })) as DepositWithBlock[]; +} + /** * Resolves the fill status of a deposit at a specific slot or at the current confirmed one. * diff --git a/src/clients/SpokePoolClient/EVMSpokePoolClient.ts b/src/clients/SpokePoolClient/EVMSpokePoolClient.ts index effdbe951..26870cf45 100644 --- a/src/clients/SpokePoolClient/EVMSpokePoolClient.ts +++ b/src/clients/SpokePoolClient/EVMSpokePoolClient.ts @@ -7,7 +7,7 @@ import { relayFillStatus, getTimestampForBlock as _getTimestampForBlock, } from "../../arch/evm"; -import { DepositWithBlock, FillStatus, RelayData } from "../../interfaces"; +import { DepositWithBlock, FillStatus, Log, RelayData } from "../../interfaces"; import { BigNumber, DepositSearchResult, @@ -16,6 +16,7 @@ import { MakeOptional, toBN, EvmAddress, + MultipleDepositSearchResult, toAddressType, } from "../../utils"; import { @@ -146,28 +147,25 @@ export class EVMSpokePoolClient extends SpokePoolClient { return _getTimeAt(this.spokePool, blockNumber); } - public override async findDeposit(depositId: BigNumber): Promise { - let deposit = this.getDeposit(depositId); - if (deposit) { - return { found: true, deposit }; - } - - // No deposit found; revert to searching for it. - const upperBound = this.latestHeightSearched || undefined; // Don't permit block 0 as the high block. + private async queryDepositEvents( + depositId: BigNumber + ): Promise<{ events: Log[]; from: number; elapsedMs: number } | { reason: string }> { + const tStart = Date.now(); + const upperBound = this.latestHeightSearched || undefined; const from = await findDepositBlock(this.spokePool, depositId, this.deploymentBlock, upperBound); const chain = getNetworkName(this.chainId); + if (!from) { - const reason = - `Unable to find ${chain} depositId ${depositId}` + - ` within blocks [${this.deploymentBlock}, ${upperBound ?? "latest"}].`; - return { found: false, code: InvalidFill.DepositIdNotFound, reason }; + return { + reason: `Unable to find ${chain} depositId ${depositId} within blocks [${this.deploymentBlock}, ${ + upperBound ?? "latest" + }].`, + }; } const to = from; - const tStart = Date.now(); - // Check both V3FundsDeposited and FundsDeposited events to look for a specified depositId. const { maxLookBack } = this.eventSearchConfig; - const query = ( + const events = ( await Promise.all([ paginatedEventQuery( this.spokePool, @@ -180,15 +178,35 @@ export class EVMSpokePoolClient extends SpokePoolClient { { from, to, maxLookBack } ), ]) - ).flat(); + ) + .flat() + .filter(({ args }) => args["depositId"].eq(depositId)); + const tStop = Date.now(); + return { events, from, elapsedMs: tStop - tStart }; + } + + public override async findDeposit(depositId: BigNumber): Promise { + let deposit = this.getDeposit(depositId); + if (deposit) { + return { found: true, deposit }; + } + + // No deposit found; revert to searching for it. + const result = await this.queryDepositEvents(depositId); + + if ("reason" in result) { + return { found: false, code: InvalidFill.DepositIdNotFound, reason: result.reason }; + } + + const { events: query, from, elapsedMs } = result; const event = query.find(({ args }) => args["depositId"].eq(depositId)); if (event === undefined) { return { found: false, code: InvalidFill.DepositIdNotFound, - reason: `${chain} depositId ${depositId} not found at block ${from}.`, + reason: `${getNetworkName(this.chainId)} depositId ${depositId} not found at block ${from}.`, }; } @@ -215,12 +233,76 @@ export class EVMSpokePoolClient extends SpokePoolClient { at: "SpokePoolClient#findDeposit", message: "Located deposit outside of SpokePoolClient's search range", deposit, - elapsedMs: tStop - tStart, + elapsedMs, }); return { found: true, deposit }; } + public override async findAllDeposits(depositId: BigNumber): Promise { + // First check memory for deposits + let deposits = this.getDepositsForDepositId(depositId); + if (deposits.length > 0) { + return { found: true, deposits }; + } + + // If no deposits found in memory, try to find on-chain + const result = await this.queryDepositEvents(depositId); + if ("reason" in result) { + return { found: false, code: InvalidFill.DepositIdNotFound, reason: result.reason }; + } + + const { events, elapsedMs } = result; + + if (events.length === 0) { + return { + found: false, + code: InvalidFill.DepositIdNotFound, + reason: `${getNetworkName(this.chainId)} depositId ${depositId} not found at block ${result.from}.`, + }; + } + + // First do all synchronous operations + deposits = events.map((event) => { + const deposit = { + ...spreadEventWithBlockNumber(event), + inputToken: toAddressType(event.args.inputToken, event.args.originChainId), + outputToken: toAddressType(event.args.outputToken, event.args.destinationChainId), + depositor: toAddressType(event.args.depositor, this.chainId), + recipient: toAddressType(event.args.recipient, event.args.destinationChainId), + exclusiveRelayer: toAddressType(event.args.exclusiveRelayer, event.args.destinationChainId), + originChainId: this.chainId, + fromLiteChain: true, // To be updated immediately afterwards. + toLiteChain: true, // To be updated immediately afterwards. + } as DepositWithBlock; + + if (deposit.outputToken.isZeroAddress()) { + deposit.outputToken = this.getDestinationTokenForDeposit(deposit); + } + deposit.fromLiteChain = this.isOriginLiteChain(deposit); + deposit.toLiteChain = this.isDestinationLiteChain(deposit); + + return deposit; + }); + + // Then do all async operations in parallel + deposits = await Promise.all( + deposits.map(async (deposit) => ({ + ...deposit, + quoteBlockNumber: await this.getBlockNumber(Number(deposit.quoteTimestamp)), + })) + ); + + this.logger.debug({ + at: "SpokePoolClient#findAllDeposits", + message: "Located deposits outside of SpokePoolClient's search range", + deposits: deposits, + elapsedMs, + }); + + return { found: true, deposits }; + } + public override getTimestampForBlock(blockNumber: number): Promise { return _getTimestampForBlock(this.spokePool.provider, blockNumber); } diff --git a/src/clients/SpokePoolClient/SVMSpokePoolClient.ts b/src/clients/SpokePoolClient/SVMSpokePoolClient.ts index 2322403fa..79b3674d4 100644 --- a/src/clients/SpokePoolClient/SVMSpokePoolClient.ts +++ b/src/clients/SpokePoolClient/SVMSpokePoolClient.ts @@ -11,19 +11,22 @@ import { relayFillStatus, fillStatusArray, } from "../../arch/svm"; -import { FillStatus, RelayData, SortableEvent } from "../../interfaces"; +import { DepositWithBlock, FillStatus, RelayData, SortableEvent } from "../../interfaces"; import { BigNumber, DepositSearchResult, EventSearchConfig, + getNetworkName, InvalidFill, MakeOptional, + MultipleDepositSearchResult, sortEventsAscendingInPlace, SvmAddress, } from "../../utils"; import { isUpdateFailureReason } from "../BaseAbstractClient"; import { HubPoolClient } from "../HubPoolClient"; import { knownEventNames, SpokePoolClient, SpokePoolUpdate } from "./SpokePoolClient"; +import { findAllDeposits } from "../../arch/svm/SpokeUtils"; /** * SvmSpokePoolClient is a client for the SVM SpokePool program. It extends the base SpokePoolClient @@ -228,6 +231,41 @@ export class SVMSpokePoolClient extends SpokePoolClient { }; } + public override async findAllDeposits(depositId: BigNumber): Promise { + // TODO: Should we have something like this? In findDeposit we don't look in memory. + // // First check memory for deposits + // const memoryDeposits = this.getDepositsForDepositId(depositId); + // if (memoryDeposits.length > 0) { + // return { found: true, deposits: memoryDeposits }; + // } + + // If no deposits found in memory, try to find on-chain + const deposits = await findAllDeposits(this.svmEventsClient, depositId); + if (!deposits || deposits.length === 0) { + return { + found: false, + code: InvalidFill.DepositIdNotFound, + reason: `${getNetworkName(this.chainId)} deposit with ID ${depositId} not found`, + }; + } + + // Enrich all deposits with additional information + const enrichedDeposits = await Promise.all( + deposits.map(async (deposit: DepositWithBlock) => ({ + ...deposit, + quoteBlockNumber: await this.getBlockNumber(Number(deposit.quoteTimestamp)), + originChainId: this.chainId, + fromLiteChain: this.isOriginLiteChain(deposit), + toLiteChain: this.isDestinationLiteChain(deposit), + outputToken: deposit.outputToken.isZeroAddress() + ? this.getDestinationTokenForDeposit(deposit) + : deposit.outputToken, + })) + ); + + return { found: true, deposits: enrichedDeposits }; + } + /** * Retrieves the fill status for a given relay data from the SVM chain. */ diff --git a/src/clients/SpokePoolClient/SpokePoolClient.ts b/src/clients/SpokePoolClient/SpokePoolClient.ts index f7aa0b126..b895ffb9b 100644 --- a/src/clients/SpokePoolClient/SpokePoolClient.ts +++ b/src/clients/SpokePoolClient/SpokePoolClient.ts @@ -18,6 +18,7 @@ import { validateFillForDeposit, chainIsProd, Address, + MultipleDepositSearchResult, toAddressType, } from "../../utils"; import { duplicateEvent, sortEventsAscendingInPlace } from "../../utils/EventUtils"; @@ -892,6 +893,30 @@ export abstract class SpokePoolClient extends BaseAbstractClient { ); } + /** + * Find all deposits (including duplicates) based on its deposit ID. + * @param depositId The unique ID of the deposit being queried. + * @returns Array of all deposits with the given depositId, including duplicates. + */ + public getDepositsForDepositId(depositId: BigNumber): DepositWithBlock[] { + const deposit = this.getDeposit(depositId); + if (!deposit) { + return []; + } + const depositHash = getRelayEventKey(deposit); + const duplicates = this.duplicateDepositHashes[depositHash] ?? []; + return [deposit, ...duplicates]; + } + + /** + * Find all deposits for a given depositId, both in memory and on-chain. + * This method will first check memory for deposits, and if none are found, + * it will search on-chain for the deposit. + * @param depositId The unique ID of the deposit being queried. + * @returns Array of all deposits with the given depositId, including duplicates and on-chain deposits. + */ + public abstract findAllDeposits(depositId: BigNumber): Promise; + // /////////////////////// // // ABSTRACT METHODS // // /////////////////////// diff --git a/src/interfaces/SpokePool.ts b/src/interfaces/SpokePool.ts index 1fa4c3ef8..220e5958c 100644 --- a/src/interfaces/SpokePool.ts +++ b/src/interfaces/SpokePool.ts @@ -78,6 +78,14 @@ export interface Fill extends Omit { relayExecutionInfo: RelayExecutionEventInfo; } +export interface InvalidFill { + fill: FillWithBlock; + validationResults: Array<{ + reason: string; + deposit?: DepositWithBlock; + }>; +} + export interface ConvertedFill extends Omit< Fill, diff --git a/src/utils/DepositUtils.ts b/src/utils/DepositUtils.ts index c0a54111f..c735aa5e9 100644 --- a/src/utils/DepositUtils.ts +++ b/src/utils/DepositUtils.ts @@ -34,6 +34,10 @@ export type DepositSearchResult = | { found: true; deposit: DepositWithBlock } | { found: false; code: InvalidFill; reason: string }; +export type MultipleDepositSearchResult = + | { found: true; deposits: DepositWithBlock[] } + | { found: false; code: InvalidFill.DepositIdNotFound; reason: string }; + /** * Attempts to resolve a deposit for a fill. If the fill's deposit Id is within the spoke pool client's search range, * the deposit is returned immediately. Otherwise, the deposit is queried first from the provided cache, and if it is diff --git a/src/utils/SpokeUtils.ts b/src/utils/SpokeUtils.ts index 9a6ad64b9..857de7977 100644 --- a/src/utils/SpokeUtils.ts +++ b/src/utils/SpokeUtils.ts @@ -1,11 +1,12 @@ import { encodeAbiParameters, Hex, keccak256 } from "viem"; import { fixedPointAdjustment as fixedPoint } from "./common"; import { MAX_SAFE_DEPOSIT_ID, ZERO_BYTES } from "../constants"; -import { Fill, FillType, RelayData, SlowFillLeaf } from "../interfaces"; +import { DepositWithBlock, Fill, FillType, InvalidFill, RelayData, SlowFillLeaf } from "../interfaces"; import { BigNumber } from "./BigNumberUtils"; -import { isMessageEmpty } from "./DepositUtils"; -import { chainIsSvm } from "./NetworkUtils"; +import { isMessageEmpty, validateFillForDeposit } from "./DepositUtils"; +import { chainIsSvm, getNetworkName } from "./NetworkUtils"; import { svm } from "../arch"; +import { SpokePoolClient } from "../clients"; export function isSlowFill(fill: Fill): boolean { return fill.relayExecutionInfo.fillType === FillType.SlowFill; @@ -74,3 +75,67 @@ export function isUnsafeDepositId(depositId: BigNumber): boolean { export function getMessageHash(message: string): string { return isMessageEmpty(message) ? ZERO_BYTES : keccak256(message as Hex); } + +export async function findInvalidFills(spokePoolClients: { + [chainId: number]: SpokePoolClient; +}): Promise { + const invalidFills: InvalidFill[] = []; + + // Iterate through each spoke pool client + for (const spokePoolClient of Object.values(spokePoolClients)) { + // Get all fills for this client + const fills = spokePoolClient.getFills(); + + // Process each fill + for (const fill of fills) { + // Skip fills with unsafe deposit IDs + // @TODO Deposits with unsafe depositIds should be processed after some time + if (isUnsafeDepositId(fill.depositId)) { + continue; + } + + // Get all deposits (including duplicates) for this fill's depositId, both in memory and on-chain + const depositResult = await spokePoolClients[fill.originChainId]?.findAllDeposits(fill.depositId); + + // If no deposits found at all + if (!depositResult?.found) { + invalidFills.push({ + fill, + validationResults: [ + { + reason: `No ${getNetworkName(fill.originChainId)} deposit with depositId ${fill.depositId} found`, + }, + ], + }); + continue; + } + + // Try to find a valid deposit for this fill + let foundValidDeposit = false; + const validationResults: Array<{ reason: string; deposit: DepositWithBlock }> = []; + + for (const deposit of depositResult.deposits) { + // Validate the fill against the deposit + const validationResult = validateFillForDeposit(fill, deposit); + if (validationResult.valid) { + foundValidDeposit = true; + break; + } + validationResults.push({ + reason: validationResult.reason, + deposit, + }); + } + + // If no valid deposit was found, add to invalid fills with all validation results + if (!foundValidDeposit) { + invalidFills.push({ + fill, + validationResults, + }); + } + } + } + + return invalidFills; +} diff --git a/test/SpokePoolClient.FindDeposits.ts b/test/SpokePoolClient.FindDeposits.ts new file mode 100644 index 000000000..9ddc2ae78 --- /dev/null +++ b/test/SpokePoolClient.FindDeposits.ts @@ -0,0 +1,213 @@ +import { EVMSpokePoolClient, SpokePoolClient } from "../src/clients"; +import { + bnOne, + toBN, + InvalidFill, + deploy as deployMulticall, + getRelayEventKey, + toAddressType, + Address, +} from "../src/utils"; +import { CHAIN_ID_TEST_LIST, originChainId, destinationChainId, repaymentChainId } from "./constants"; +import { + expect, + BigNumber, + toBNWei, + ethers, + SignerWithAddress, + deposit, + setupTokensForWallet, + deploySpokePoolWithToken, + Contract, + createSpyLogger, + deployAndConfigureHubPool, + enableRoutesOnHubPool, + deployConfigStore, + getLastBlockTime, + winston, +} from "./utils"; +import { MockConfigStoreClient, MockHubPoolClient } from "./mocks"; +import sinon from "sinon"; + +describe("SpokePoolClient: Find Deposits", function () { + let spokePool_1: Contract, erc20_1: Contract, spokePool_2: Contract, erc20_2: Contract, hubPool: Contract; + let owner: SignerWithAddress, depositor: SignerWithAddress, relayer: SignerWithAddress; + let spokePool1DeploymentBlock: number; + let l1Token: Contract, configStore: Contract; + let spyLogger: winston.Logger; + let spokePoolClient1: SpokePoolClient, configStoreClient: MockConfigStoreClient; + let inputToken: Address, outputToken: Address; + let inputAmount: BigNumber, outputAmount: BigNumber; + let hubPoolClient: MockHubPoolClient; + + beforeEach(async function () { + [owner, depositor, relayer] = await ethers.getSigners(); + await deployMulticall(owner); + ({ + spokePool: spokePool_1, + erc20: erc20_1, + deploymentBlock: spokePool1DeploymentBlock, + } = await deploySpokePoolWithToken(originChainId)); + ({ spokePool: spokePool_2, erc20: erc20_2 } = await deploySpokePoolWithToken(destinationChainId)); + ({ hubPool, l1Token_1: l1Token } = await deployAndConfigureHubPool(owner, [ + { l2ChainId: destinationChainId, spokePool: spokePool_2 }, + { l2ChainId: originChainId, spokePool: spokePool_1 }, + { l2ChainId: repaymentChainId, spokePool: spokePool_1 }, + { l2ChainId: 1, spokePool: spokePool_1 }, + ])); + await enableRoutesOnHubPool(hubPool, [ + { destinationChainId: originChainId, l1Token, destinationToken: erc20_1 }, + { destinationChainId: destinationChainId, l1Token, destinationToken: erc20_2 }, + ]); + ({ spyLogger } = createSpyLogger()); + ({ configStore } = await deployConfigStore(owner, [l1Token])); + configStoreClient = new MockConfigStoreClient(spyLogger, configStore, undefined, undefined, CHAIN_ID_TEST_LIST); + await configStoreClient.update(); + hubPoolClient = new MockHubPoolClient(spyLogger, hubPool, configStoreClient); + hubPoolClient.setTokenMapping(l1Token.address, originChainId, erc20_1.address); + hubPoolClient.setTokenMapping(l1Token.address, destinationChainId, erc20_2.address); + await hubPoolClient.update(); + spokePoolClient1 = new EVMSpokePoolClient( + spyLogger, + spokePool_1, + hubPoolClient, + originChainId, + spokePool1DeploymentBlock + ); + await setupTokensForWallet(spokePool_1, depositor, [erc20_1], undefined, 10); + await setupTokensForWallet(spokePool_2, relayer, [erc20_2], undefined, 10); + await spokePool_1.setCurrentTime(await getLastBlockTime(spokePool_1.provider)); + inputToken = toAddressType(erc20_1.address, originChainId); + inputAmount = toBNWei(1); + outputToken = toAddressType(erc20_2.address, destinationChainId); + outputAmount = inputAmount.sub(bnOne); + }); + + describe("findAllDeposits", function () { + it("finds deposits in memory and on-chain", async function () { + const depositEvent = await deposit( + spokePool_1, + destinationChainId, + depositor, + inputToken, + inputAmount, + outputToken, + outputAmount + ); + await spokePoolClient1.update(); + const result = await spokePoolClient1.findAllDeposits(depositEvent.depositId); + expect(result.found).to.be.true; + if (result.found) { + expect(result.deposits).to.have.lengthOf(1); + const foundDeposit = result.deposits[0]; + expect(foundDeposit.depositId).to.equal(depositEvent.depositId); + expect(foundDeposit.originChainId).to.equal(depositEvent.originChainId); + expect(foundDeposit.destinationChainId).to.equal(depositEvent.destinationChainId); + expect(foundDeposit.depositor.eq(depositEvent.depositor)).to.be.true; + expect(foundDeposit.recipient.eq(depositEvent.recipient)).to.be.true; + expect(foundDeposit.inputToken.eq(depositEvent.inputToken)).to.be.true; + expect(foundDeposit.outputToken.eq(depositEvent.outputToken)).to.be.true; + expect(foundDeposit.inputAmount).to.equal(depositEvent.inputAmount); + expect(foundDeposit.outputAmount).to.equal(depositEvent.outputAmount); + } + }); + + it("returns empty result for non-existent deposit ID", async function () { + await spokePoolClient1.update(); + const nonExistentId = toBN(999999); + const result = await spokePoolClient1.findAllDeposits(nonExistentId); + expect(result.found).to.be.false; + if (!result.found) { + expect(result.code).to.equal(InvalidFill.DepositIdNotFound); + expect(result.reason).to.be.a("string"); + } + }); + + it("finds a single deposit for a given ID", async function () { + const depositEvent = await deposit( + spokePool_1, + destinationChainId, + depositor, + inputToken, + inputAmount, + outputToken, + outputAmount + ); + await spokePoolClient1.update(); + const result = await spokePoolClient1.findAllDeposits(depositEvent.depositId); + expect(result.found).to.be.true; + if (result.found) { + expect(result.deposits).to.have.lengthOf(1); + const foundDeposit = result.deposits[0]; + expect(foundDeposit.depositId).to.equal(depositEvent.depositId); + expect(foundDeposit.originChainId).to.equal(depositEvent.originChainId); + expect(foundDeposit.destinationChainId).to.equal(depositEvent.destinationChainId); + expect(foundDeposit.depositor.eq(depositEvent.depositor)).to.be.true; + expect(foundDeposit.recipient.eq(depositEvent.recipient)).to.be.true; + expect(foundDeposit.inputToken.eq(depositEvent.inputToken)).to.be.true; + expect(foundDeposit.outputToken.eq(depositEvent.outputToken)).to.be.true; + expect(foundDeposit.inputAmount).to.equal(depositEvent.inputAmount); + expect(foundDeposit.outputAmount).to.equal(depositEvent.outputAmount); + } + }); + + it("simulates fetching a deposit from chain during update", async function () { + const depositEvent = await deposit( + spokePool_1, + destinationChainId, + depositor, + inputToken, + inputAmount, + outputToken, + outputAmount + ); + await spokePoolClient1.update(); + const depositHash = getRelayEventKey(depositEvent); + delete spokePoolClient1["depositHashes"][depositHash]; + const fakeEvent = { + args: { + depositId: depositEvent.depositId, + originChainId: depositEvent.originChainId, + destinationChainId: depositEvent.destinationChainId, + // These are bytes32 strings, as emitted by the contract event + depositor: depositEvent.depositor.toBytes32(), + recipient: depositEvent.recipient.toBytes32(), + inputToken: depositEvent.inputToken.toBytes32(), + inputAmount: depositEvent.inputAmount, + outputToken: depositEvent.outputToken.toBytes32(), + outputAmount: depositEvent.outputAmount, + quoteTimestamp: depositEvent.quoteTimestamp, + message: depositEvent.message, + fillDeadline: depositEvent.fillDeadline, + exclusivityDeadline: depositEvent.exclusivityDeadline, + exclusiveRelayer: depositEvent.exclusiveRelayer.toBytes32(), + }, + blockNumber: depositEvent.blockNumber, + transactionHash: depositEvent.txnRef, + transactionIndex: depositEvent.txnIndex, + logIndex: depositEvent.logIndex, + }; + // Note: This matches the contract event output, and the client will convert these to Address objects internally. + const queryFilterStub = sinon.stub(spokePool_1, "queryFilter"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryFilterStub.resolves([fakeEvent as any]); + await spokePoolClient1.update(); + const result = await spokePoolClient1.findAllDeposits(depositEvent.depositId); + expect(result.found).to.be.true; + if (result.found) { + expect(result.deposits).to.have.lengthOf(2); + const foundDeposit = result.deposits[0]; + expect(foundDeposit.depositId).to.equal(depositEvent.depositId); + expect(foundDeposit.originChainId).to.equal(depositEvent.originChainId); + expect(foundDeposit.destinationChainId).to.equal(depositEvent.destinationChainId); + expect(foundDeposit.depositor.eq(depositEvent.depositor)).to.be.true; + expect(foundDeposit.recipient.eq(depositEvent.recipient)).to.be.true; + expect(foundDeposit.inputToken.eq(depositEvent.inputToken)).to.be.true; + expect(foundDeposit.outputToken.eq(depositEvent.outputToken)).to.be.true; + expect(foundDeposit.inputAmount).to.equal(depositEvent.inputAmount); + expect(foundDeposit.outputAmount).to.equal(depositEvent.outputAmount); + } + queryFilterStub.restore(); + }); + }); +}); diff --git a/test/SpokeUtils.ts b/test/SpokeUtils.ts index bbab28cda..b936b033a 100644 --- a/test/SpokeUtils.ts +++ b/test/SpokeUtils.ts @@ -1,10 +1,42 @@ import { utils as ethersUtils } from "ethers"; -import { UNDEFINED_MESSAGE_HASH, ZERO_BYTES } from "../src/constants"; -import { getMessageHash, getRelayEventKey, keccak256, randomAddress, toBN, validateFillForDeposit } from "../src/utils"; -import { expect } from "./utils"; +import { MAX_SAFE_DEPOSIT_ID, UNDEFINED_MESSAGE_HASH, ZERO_BYTES } from "../src/constants"; +import { + findInvalidFills, + getMessageHash, + getRelayEventKey, + keccak256, + randomAddress, + toBN, + validateFillForDeposit, + toAddressType, + InvalidFill, +} from "../src/utils"; +import { expect, deploySpokePoolWithToken, Contract } from "./utils"; +import { MockSpokePoolClient } from "./mocks"; +import winston from "winston"; const random = () => Math.round(Math.random() * 1e8); const randomBytes = () => `0x${ethersUtils.randomBytes(48).join("").slice(0, 64)}`; +const dummyLogger = winston.createLogger({ transports: [new winston.transports.Console()] }); + +const dummyFillProps = { + relayer: toAddressType(randomAddress(), 1), + repaymentChainId: random(), + relayExecutionInfo: { + updatedRecipient: toAddressType(randomAddress(), 1), + updatedOutputAmount: toBN(random()), + updatedMessageHash: ZERO_BYTES, + fillType: 0, + }, + blockNumber: random(), + txnRef: randomBytes(), + txnIndex: random(), + logIndex: random(), + quoteTimestamp: random(), + quoteBlockNumber: random(), + fromLiteChain: false, + toLiteChain: false, +}; describe("SpokeUtils", function () { const message = randomBytes(); @@ -12,20 +44,28 @@ describe("SpokeUtils", function () { const sampleData = { originChainId: random(), destinationChainId: random(), - depositor: randomAddress(), - recipient: randomAddress(), - inputToken: randomAddress(), + depositor: toAddressType(randomAddress(), 1), + recipient: toAddressType(randomAddress(), 1), + inputToken: toAddressType(randomAddress(), 1), inputAmount: toBN(random()), - outputToken: randomAddress(), + outputToken: toAddressType(randomAddress(), 1), outputAmount: toBN(random()), message, messageHash, depositId: toBN(random()), fillDeadline: random(), - exclusiveRelayer: randomAddress(), + exclusiveRelayer: toAddressType(randomAddress(), 1), exclusivityDeadline: random(), + ...dummyFillProps, }; + let spokePool: Contract; + let deploymentBlock: number; + + beforeEach(async function () { + ({ spokePool, deploymentBlock } = await deploySpokePoolWithToken(sampleData.originChainId)); + }); + it("getRelayEventKey correctly concatenates an event key", function () { const eventKey = getRelayEventKey(sampleData); const expectedKey = @@ -89,4 +129,167 @@ describe("SpokeUtils", function () { const message = randomBytes(); expect(getMessageHash(message)).to.equal(keccak256(message)); }); + + describe("findInvalidFills", function () { + let mockSpokePoolClient: MockSpokePoolClient; + let mockSpokePoolClients: { [chainId: number]: MockSpokePoolClient }; + + beforeEach(function () { + mockSpokePoolClient = new MockSpokePoolClient(dummyLogger, spokePool, sampleData.originChainId, deploymentBlock); + mockSpokePoolClient.getFills = () => []; + mockSpokePoolClient.findAllDeposits = async () => { + await Promise.resolve(); + return { found: false, code: InvalidFill.DepositIdNotFound, reason: "Deposit not found" }; + }; + + mockSpokePoolClients = { + [sampleData.originChainId]: mockSpokePoolClient, + }; + }); + + it("returns empty array when no fills exist", async function () { + const invalidFills = await findInvalidFills(mockSpokePoolClients); + expect(invalidFills).to.be.an("array").that.is.empty; + }); + + it("skips fills with unsafe deposit IDs", async function () { + const unsafeDepositId = toBN(MAX_SAFE_DEPOSIT_ID).add(1); + mockSpokePoolClient.getFills = () => [ + { + ...sampleData, + depositId: unsafeDepositId, + messageHash, + ...dummyFillProps, + }, + ]; + + const invalidFills = await findInvalidFills(mockSpokePoolClients); + expect(invalidFills).to.be.an("array").that.is.empty; + }); + + it("detects fills with no matching deposits", async function () { + mockSpokePoolClient.getFills = () => [ + { + ...sampleData, + depositId: toBN(random()), + messageHash, + ...dummyFillProps, + }, + ]; + + const invalidFills = await findInvalidFills(mockSpokePoolClients); + expect(invalidFills).to.have.lengthOf(1); + expect(invalidFills[0].validationResults).to.have.lengthOf(1); + expect(invalidFills[0].validationResults[0].reason).to.include("deposit with depositId"); + }); + + it("detects fills with mismatched deposit attributes", async function () { + const deposit = { + ...sampleData, + blockNumber: random(), + txnRef: randomBytes(), + txnIndex: random(), + logIndex: random(), + quoteTimestamp: random(), + quoteBlockNumber: random(), + fromLiteChain: false, + toLiteChain: false, + relayer: toAddressType(randomAddress(), 1), + repaymentChainId: random(), + relayExecutionInfo: { + updatedRecipient: toAddressType(randomAddress(), 1), + updatedOutputAmount: sampleData.outputAmount, + updatedMessageHash: sampleData.messageHash, + fillType: 0, + }, + }; + + const fill = { + ...deposit, + recipient: toAddressType(randomAddress(), 1), + relayer: toAddressType(randomAddress(), 1), + repaymentChainId: random(), + relayExecutionInfo: { + updatedRecipient: toAddressType(randomAddress(), 1), + updatedOutputAmount: deposit.outputAmount, + updatedMessageHash: deposit.messageHash, + fillType: 0, + }, + }; + + mockSpokePoolClient.getFills = () => [fill]; + mockSpokePoolClient.findAllDeposits = async () => { + await Promise.resolve(); + return { + found: true, + deposits: [deposit], + }; + }; + + const invalidFills = await findInvalidFills(mockSpokePoolClients); + expect(invalidFills).to.have.lengthOf(1); + expect(invalidFills[0].validationResults).to.have.lengthOf(1); + expect(invalidFills[0].validationResults[0].reason).to.include("recipient mismatch"); + }); + + it("handles multiple fills with different validation results", async function () { + const validDeposit = { + ...sampleData, + blockNumber: random(), + txnRef: randomBytes(), + txnIndex: random(), + logIndex: random(), + quoteTimestamp: random(), + quoteBlockNumber: random(), + fromLiteChain: false, + toLiteChain: false, + relayer: toAddressType(randomAddress(), 1), + repaymentChainId: random(), + relayExecutionInfo: { + updatedRecipient: sampleData.recipient, + updatedOutputAmount: sampleData.outputAmount, + updatedMessageHash: sampleData.messageHash, + fillType: 0, + }, + }; + + const validFill = { + ...validDeposit, + relayer: toAddressType(randomAddress(), 1), + repaymentChainId: random(), + relayExecutionInfo: { + updatedRecipient: validDeposit.recipient, + updatedOutputAmount: validDeposit.outputAmount, + updatedMessageHash: validDeposit.messageHash, + fillType: 0, + }, + }; + + const invalidFill = { + ...validDeposit, + recipient: toAddressType(randomAddress(), 1), + relayer: toAddressType(randomAddress(), 1), + repaymentChainId: random(), + relayExecutionInfo: { + updatedRecipient: toAddressType(randomAddress(), 1), + updatedOutputAmount: validDeposit.outputAmount, + updatedMessageHash: validDeposit.messageHash, + fillType: 0, + }, + }; + + mockSpokePoolClient.getFills = () => [validFill, invalidFill]; + mockSpokePoolClient.findAllDeposits = async () => { + await Promise.resolve(); + return { + found: true, + deposits: [validDeposit], + }; + }; + + const invalidFills = await findInvalidFills(mockSpokePoolClients); + expect(invalidFills).to.have.lengthOf(1); + expect(invalidFills[0].fill).to.deep.equal(invalidFill); + }); + }); });