From 811bb8bf7dbfe6de1b096de965981b7c72af2b36 Mon Sep 17 00:00:00 2001 From: Beebs <47253537+jahabeebs@users.noreply.github.com> Date: Wed, 20 Nov 2024 09:24:19 -0500 Subject: [PATCH] feat: handle requests with no responses after deadline (#91) --- .../src/services/eboActor.ts | 183 ++++++++++++++---- .../automated-dispute/tests/guards.spec.ts | 14 +- .../tests/mocks/eboActor.mocks.ts | 31 +-- .../tests/services/eboActor.spec.ts | 16 +- .../eboActor/onDisputeEscalated.spec.ts | 41 +++- .../eboActor/onLastBlockupdated.spec.ts | 173 ++++++++++++++++- .../eboActor/onRequestFinalized.spec.ts | 7 +- .../eboActor/onResponseDisputed.spec.ts | 1 + .../commands/addRequest.spec.ts | 14 +- .../tests/services/eboProcessor.spec.ts | 106 +--------- 10 files changed, 401 insertions(+), 185 deletions(-) diff --git a/packages/automated-dispute/src/services/eboActor.ts b/packages/automated-dispute/src/services/eboActor.ts index fc235245..bd4da40a 100644 --- a/packages/automated-dispute/src/services/eboActor.ts +++ b/packages/automated-dispute/src/services/eboActor.ts @@ -9,7 +9,7 @@ import { } from "@ebo-agent/shared"; import { Mutex } from "async-mutex"; import { Heap } from "heap-js"; -import { ContractFunctionRevertedError } from "viem"; +import { ContractFunctionRevertedError, Hex } from "viem"; import type { Dispute, @@ -19,6 +19,7 @@ import type { Epoch, ErrorContext, Request, + RequestId, Response, ResponseBody, } from "../types/index.js"; @@ -118,7 +119,9 @@ export class EboActor { */ public enqueue(event: EboEvent): void { if (!this.shouldHandleRequest(event.requestId)) { - this.logger.error(`The request ${event.requestId} is not handled by this actor.`); + this.logger.error(`The request ${event.requestId} is not handled by this actor.`, { + requestId: event.requestId, + }); throw new RequestMismatch(this.actorRequest.id, event.requestId); } @@ -163,12 +166,13 @@ export class EboActor { try { updateStateCommand = this.buildUpdateStateCommand(event); - this.logger.debug("Running command..."); - this.logger.debug(stringify({ command: updateStateCommand.name() })); + this.logger.debug("Running command...", { command: updateStateCommand.name() }); updateStateCommand.run(); - this.logger.debug("Command run successfully."); + this.logger.debug("Command run successfully.", { + command: updateStateCommand.name(), + }); } catch (err) { if (err instanceof ProphetDecodingError) { // Skipping malformed entities @@ -177,6 +181,7 @@ export class EboActor { reason: err.err?.name, message: err.message, }), + { eventName: event.name }, ); continue; @@ -211,7 +216,9 @@ export class EboActor { return; } else { - this.logger.error(`Error processing event ${event.name}: ${err}`); + this.logger.error(`Error processing event ${event.name}: ${err}`, { + eventName: event.name, + }); throw err; } @@ -336,6 +343,46 @@ export class EboActor { } } + /** + * Finalize a request with no response after the deadline + * + * @param request The request to finalize + */ + private async finalizeRequestWithNoResponse(request: Request): Promise { + this.logger.info(`Request finalized with no response`, { requestId: request.id }); + + const nullResponseProphetData: Readonly<{ + proposer: `0x${string}`; + requestId: RequestId; + response: Hex; + }> = { + proposer: this.protocolProvider.getAccountAddress(), + requestId: "0x0000000000000000000000000000000000000000" as RequestId, // Must be 0x0 address + response: "0x0000000000000000000000000000000000000000" as Hex, // Can be any value + }; + + try { + await this.protocolProvider.finalize(request.prophetData, nullResponseProphetData); + this.logger.info(`Request ${request.id} finalized with no response.`, { + requestId: request.id, + }); + } catch (err) { + if (err instanceof CustomContractError) { + this.logger.warn(`Finalizing request with no response reverted: ${err.name}`); + this.logger.debug(stringify({ request, error: err })); + + err.setContext({ + request, + registry: this.registry, + }); + + await this.errorHandler.handle(err); + } else { + throw err; + } + } + } + /** * Triggers time-based interactions with smart contracts. This handles window-based * checks like proposal windows to close requests, or dispute windows to accept responses. @@ -346,15 +393,40 @@ export class EboActor { await this.settleDisputes(atTimestamp); const request = this.getActorRequest(); + const response = this.getFinalizableResponse(request, atTimestamp); if (response) { await this.finalizeRequest(request, response); - return; } else { - // TODO: check for responseModuleData.deadline, if no answer has been accepted after the deadline - // notify and (TBD) finalize with no response + // Check if the response deadline has passed with no accepted responses + const activeDisputes = this.getActiveDisputes(); + + if (activeDisputes.length > 0) { + this.logger.info( + `There are active disputes for request ${request.id}, not finalizing yet.`, + { requestId: request.id }, + ); + return; + } + + const proposalDeadline = + request.createdAt.timestamp + request.decodedData.responseModuleData.deadline; + + if (atTimestamp > proposalDeadline) { + const responses = this.registry.getResponses(); + + if (responses.length === 0) { + this.logger.info( + `No responses found for request ${request.id} after deadline`, + { + requestId: request.id, + }, + ); + await this.finalizeRequestWithNoResponse(request); + } + } } } @@ -371,7 +443,9 @@ export class EboActor { this.canBeSettled(request, dispute, atTimestamp), ); - this.logger.info(`Settling ${settleableDisputes.length} disputes...`); + this.logger.info(`Settling ${settleableDisputes.length} disputes...`, { + requestId: request.id, + }); for (const dispute of settleableDisputes) { const responseId = dispute.prophetData.responseId; @@ -381,6 +455,7 @@ export class EboActor { this.logger.error( `While trying to settle dispute ${dispute.id}, its response with ` + `id ${dispute.prophetData.responseId} was not found in the registry.`, + { disputeId: dispute.id, responseId: dispute.prophetData.responseId }, ); throw new DisputeWithoutResponse(dispute); @@ -398,7 +473,7 @@ export class EboActor { * @returns a `Response` if can be used to finalize `request. Otherwise undefined. */ private getFinalizableResponse(request: Request, atTimestamp: UnixTimestamp) { - this.logger.info("Getting finalizable requests..."); + this.logger.info("Getting finalizable requests...", { requestId: request.id }); const proposalDeadline = request.createdAt.timestamp + request.decodedData.responseModuleData.deadline; @@ -406,7 +481,9 @@ export class EboActor { const isProposalWindowOpen = atTimestamp <= proposalDeadline; if (isProposalWindowOpen) { - this.logger.info(`Proposal window for request ${request.id} not closed yet.`); + this.logger.info(`Proposal window for request ${request.id} not closed yet.`, { + requestId: request.id, + }); return undefined; } @@ -421,15 +498,20 @@ export class EboActor { * @param response a `Response` */ private async finalizeRequest(request: Request, response: Response) { - this.logger.info(`Finalizing request.`); - this.logger.debug(stringify({ request: request, response: response })); + this.logger.info(`Finalizing request.`, { requestId: request.id, responseId: response.id }); + this.logger.debug(stringify({ request: request, response: response }), { + requestId: request.id, + responseId: response.id, + }); try { await this.protocolProvider.finalize(request.prophetData, response.prophetData); } catch (err) { if (err instanceof CustomContractError) { - this.logger.warn(`Finalizing request reverted: ${err.name}`); - this.logger.debug(stringify({ request, error: err })); + this.logger.warn(`Finalizing request reverted: ${err.name}`, { + requestId: request.id, + responseId: response.id, + }); err.setContext({ request, @@ -480,6 +562,7 @@ export class EboActor { } catch (err: unknown) { this.logger.error( `Failed to fetch escalation data for request ${request.id}: ${isNativeError(err) ? err.message : err}`, + { requestId: request.id }, ); return; } @@ -495,7 +578,7 @@ export class EboActor { dispute.prophetData, ); - this.logger.info(`Dispute ${dispute.id} settled.`); + this.logger.info(`Dispute ${dispute.id} settled.`, { disputeId: dispute.id }); } else if (amountFor === amountAgainst) { await this.protocolProvider.escalateDispute( request.prophetData, @@ -503,11 +586,13 @@ export class EboActor { dispute.prophetData, ); - this.logger.info(`Dispute ${dispute.id} escalated.`); + this.logger.info(`Dispute ${dispute.id} escalated.`, { disputeId: dispute.id }); } } catch (err) { if (err instanceof CustomContractError) { - this.logger.warn(`Call reverted for dispute ${dispute.id} due to: ${err.name}`); + this.logger.warn(`Call reverted for dispute ${dispute.id} due to: ${err.name}`, { + disputeId: dispute.id, + }); err.setContext({ request, @@ -516,7 +601,9 @@ export class EboActor { registry: this.registry, }); } else { - this.logger.error(`Failed to settle dispute ${dispute.id}: ${err}`); + this.logger.error(`Failed to settle dispute ${dispute.id}: ${err}`, { + disputeId: dispute.id, + }); throw err; } } @@ -663,6 +750,7 @@ export class EboActor { if (this.equalResponses(newResponse, proposedResponse)) { this.logger.info( `Block ${blockNumber} for epoch ${epoch} and chain ${chainId} already proposed on response ${responseId}. Skipping...`, + { requestId: request.id, responseId: responseId }, ); return true; @@ -700,13 +788,15 @@ export class EboActor { * @param chainId the CAIP-2 compliant chain ID */ private async proposeResponse(chainId: Caip2ChainId): Promise { - this.logger.info(`Proposing response for ${chainId}`); + this.logger.info(`Proposing response for ${chainId}`, { chainId: chainId }); const responseBody = await this.buildResponseBody(chainId); const request = this.getActorRequest(); if (this.alreadyProposed(responseBody.block)) { - this.logger.warn(`Block ${responseBody.block} already proposed`); + this.logger.warn(`Block ${responseBody.block} already proposed`, { + blockNumber: responseBody.block, + }); throw new ResponseAlreadyProposed(request, responseBody); } @@ -722,7 +812,9 @@ export class EboActor { try { await this.protocolProvider.proposeResponse(request.prophetData, response); - this.logger.info(`Block ${responseBody.block} proposed`); + this.logger.info(`Block ${responseBody.block} proposed`, { + blockNumber: responseBody.block, + }); } catch (err) { if (err instanceof ContractFunctionRevertedError) { const { epoch } = request.decodedData.requestModuleData; @@ -736,12 +828,13 @@ export class EboActor { await this.errorHandler.handle(customError); this.logger.warn( - `Block ${responseBody.block} for epoch ${epoch} and ` + - `chain ${chainId} was not proposed. Skipping proposal...`, + `Block ${responseBody.block} for epoch ${epoch} and chain ${chainId} was not proposed. Skipping proposal...`, + { blockNumber: responseBody.block, epoch: epoch, chainId: chainId }, ); } else { this.logger.error( `Actor handling request ${this.actorRequest.id} is not able to continue.`, + { requestId: this.actorRequest.id }, ); throw err; @@ -774,7 +867,9 @@ export class EboActor { }; if (this.equalResponses(actorResponse, proposedResponse)) { - this.logger.info(`Response ${event.metadata.responseId} was validated. Skipping...`); + this.logger.info(`Response ${event.metadata.responseId} was validated. Skipping...`, { + responseId: event.metadata.responseId, + }); return; } @@ -864,7 +959,9 @@ export class EboActor { ); if (dispute.decodedData.status === "Escalated") { - this.logger.warn(`Skipping dispute ${dispute.id} as it's already been escalated`); + this.logger.warn(`Skipping dispute ${dispute.id} as it's already been escalated`, { + disputeId: dispute.id, + }); return; } @@ -924,8 +1021,15 @@ export class EboActor { : escalation.amountOfPledgesAgainstDispute; if (sidePledges < maxNumberOfEscalations) { - this.logger.info(`Pledging ${side} dispute...`); - this.logger.debug(stringify({ request, response, dispute })); + this.logger.info(`Pledging ${side} dispute...`, { + side: side, + disputeId: dispute.id, + }); + this.logger.debug(stringify({ request, response, dispute }), { + requestId: request.id, + responseId: response.id, + disputeId: dispute.id, + }); if (side === "for") { await this.protocolProvider.pledgeForDispute( @@ -941,6 +1045,7 @@ export class EboActor { } else { this.logger.warn( `Skipping pledge ${side} dispute. Max number of escalations reached`, + { side: side, disputeId: dispute.id }, ); this.logger.debug( stringify({ @@ -949,6 +1054,7 @@ export class EboActor { dispute, escalation, }), + { requestId: request.id, responseId: response.id, disputeId: dispute.id }, ); } } catch (err) { @@ -956,6 +1062,7 @@ export class EboActor { const errorName = err.data?.errorName || err.name; this.logger.warn( `Pledge ${side} dispute ${dispute.id} reverted due to: ${errorName}`, + { side: side, disputeId: dispute.id, errorName: errorName }, ); const customError = ErrorFactory.createError(errorName); @@ -994,10 +1101,11 @@ export class EboActor { await this.proposeResponse(chainId); } catch (err) { if (err instanceof ResponseAlreadyProposed) { - this.logger.warn(err.message); + this.logger.warn(err.message, { requestId: request.id, responseId: response.id }); } else { this.logger.error( `Could not propose a new response after response ${response.id} disputal.`, + { requestId: request.id, responseId: response.id }, ); throw err; @@ -1015,12 +1123,16 @@ export class EboActor { const disputeId = event.metadata.disputeId; const disputeStatus = ProphetCodec.decodeDisputeStatus(event.metadata.status); - this.logger.info(`Dispute ${disputeId} status changed to ${disputeStatus}.`); + this.logger.info(`Dispute ${disputeId} status changed to ${disputeStatus}.`, { + disputeId: disputeId, + status: disputeStatus, + }); switch (disputeStatus) { case "None": this.logger.warn( `Agent does not know how to handle status changing to 'None' on dispute ${disputeId}.`, + { disputeId: disputeId }, ); break; @@ -1046,6 +1158,7 @@ export class EboActor { this.logger.info( `Dispute ${event.metadata.disputeId} for request ${request.id} has been escalated.`, + { disputeId: event.metadata.disputeId, requestId: request.id }, ); } @@ -1061,10 +1174,14 @@ export class EboActor { // with no valid response. // // This actor will just wait until the proposal window ends. - this.logger.warn(err.message); + this.logger.warn(err.message, { + requestId: request.id, + chainId: request.decodedData.requestModuleData.chainId, + }); } else { this.logger.error( `Could not handle dispute ${disputeId} changing to NoResolution status.`, + { disputeId: disputeId, requestId: request.id }, ); throw err; @@ -1080,6 +1197,6 @@ export class EboActor { private async onRequestFinalized(_event: EboEvent<"OracleRequestFinalized">): Promise { const request = this.getActorRequest(); - this.logger.info(`Request ${request.id} has been finalized.`); + this.logger.info(`Request ${request.id} has been finalized.`, { requestId: request.id }); } } diff --git a/packages/automated-dispute/tests/guards.spec.ts b/packages/automated-dispute/tests/guards.spec.ts index c19ae142..a9785a67 100644 --- a/packages/automated-dispute/tests/guards.spec.ts +++ b/packages/automated-dispute/tests/guards.spec.ts @@ -1,4 +1,5 @@ -import { Caip2ChainId } from "@ebo-agent/shared"; +import { UnixTimestamp } from "@ebo-agent/shared"; +import { Hex } from "viem"; import { describe, expect, it } from "vitest"; import { isRequestCreatedEvent } from "../src/guards.js"; @@ -8,18 +9,16 @@ import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./services/eboActor/fixture describe("isRequestCreatedEvent", () => { it("returns true when passing a RequestCreatedd event", () => { - const id: RequestId = "0x01"; - const event: EboEvent<"RequestCreated"> = { name: "RequestCreated", blockNumber: 1n, logIndex: 1, - requestId: id, + timestamp: BigInt(Date.now()) as UnixTimestamp, + requestId: "0x01" as RequestId, metadata: { - chainId: "eip155:1" as Caip2ChainId, - epoch: 1n, - requestId: id, + requestId: "0x01" as RequestId, request: DEFAULT_MOCKED_REQUEST_CREATED_DATA.prophetData, + ipfsHash: "0x01" as Hex, }, }; @@ -34,6 +33,7 @@ describe("isRequestCreatedEvent", () => { name: "ResponseProposed", blockNumber: 1n, logIndex: 1, + timestamp: BigInt(Date.now()) as UnixTimestamp, requestId: request.id, metadata: { requestId: request.id, diff --git a/packages/automated-dispute/tests/mocks/eboActor.mocks.ts b/packages/automated-dispute/tests/mocks/eboActor.mocks.ts index 262d9463..06036cda 100644 --- a/packages/automated-dispute/tests/mocks/eboActor.mocks.ts +++ b/packages/automated-dispute/tests/mocks/eboActor.mocks.ts @@ -72,10 +72,26 @@ export function buildEboActor(request: Request, logger: ILogger) { timestamp: BigInt(Date.now()) as UnixTimestamp, } as unknown as Block); + vi.spyOn(protocolProvider, "finalize").mockResolvedValue(undefined); + const blockNumberRpcUrls = new Map([ [chainId, ["http://localhost:8539"]], ]); + const notificationService: NotificationService = { + send: vi.fn().mockResolvedValue(undefined), + sendOrThrow: vi.fn().mockResolvedValue(undefined), + createErrorMessage: vi + .fn() + .mockImplementation((defaultMessage: string, context?: unknown, err?: unknown) => { + return { + title: defaultMessage, + description: err instanceof Error ? err.message : undefined, + }; + }), + sendError: vi.fn().mockResolvedValue(undefined), + }; + const blockNumberService = new BlockNumberService( blockNumberRpcUrls, { @@ -88,6 +104,7 @@ export function buildEboActor(request: Request, logger: ILogger) { }, }, logger, + notificationService, ); vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue(BigInt(12345)); @@ -96,20 +113,6 @@ export function buildEboActor(request: Request, logger: ILogger) { const eventProcessingMutex = new Mutex(); - const notificationService: NotificationService = { - send: vi.fn().mockResolvedValue(undefined), - sendOrThrow: vi.fn().mockResolvedValue(undefined), - createErrorMessage: vi - .fn() - .mockImplementation((defaultMessage: string, context?: unknown, err?: unknown) => { - return { - title: defaultMessage, - description: err instanceof Error ? err.message : undefined, - }; - }), - sendError: vi.fn().mockResolvedValue(undefined), - }; - const actor = new EboActor( { id, epoch, chainId }, protocolProvider, diff --git a/packages/automated-dispute/tests/services/eboActor.spec.ts b/packages/automated-dispute/tests/services/eboActor.spec.ts index d954686d..91b4b8e6 100644 --- a/packages/automated-dispute/tests/services/eboActor.spec.ts +++ b/packages/automated-dispute/tests/services/eboActor.spec.ts @@ -10,7 +10,7 @@ import { } from "../../src/exceptions/index.js"; import { EboEvent, Request, RequestId, ResponseId } from "../../src/types/index.js"; import mocks from "../mocks/index.js"; -import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "../services/eboActor/fixtures.js"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./eboActor/fixtures.js"; const logger = mocks.mockLogger(); @@ -499,7 +499,7 @@ describe("EboActor", () => { actor["proposeResponse"] = vi.fn().mockRejectedValue(contractError); - const customError = new CustomContractError("SomeError", { + const customError = new CustomContractError("UnknownError", { shouldNotify: false, shouldReenqueue: true, shouldTerminate: false, @@ -542,7 +542,9 @@ describe("EboActor", () => { dispute.prophetData, ); - expect(logger.info).toHaveBeenCalledWith(`Dispute ${dispute.id} escalated.`); + expect(logger.info).toHaveBeenCalledWith(`Dispute ${dispute.id} escalated.`, { + disputeId: dispute.id, + }); }); it("rethrows error when settleDispute fails", async () => { @@ -591,7 +593,9 @@ describe("EboActor", () => { ); expect(escalateDisputeMock).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith(`Dispute ${dispute.id} settled.`); + expect(logger.info).toHaveBeenCalledWith(`Dispute ${dispute.id} settled.`, { + disputeId: dispute.id, + }); }); it("settles dispute when amountOfPledgesForDispute < amountOfPledgesAgainstDispute", async () => { @@ -622,7 +626,9 @@ describe("EboActor", () => { ); expect(escalateDisputeMock).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledWith(`Dispute ${dispute.id} settled.`); + expect(logger.info).toHaveBeenCalledWith(`Dispute ${dispute.id} settled.`, { + disputeId: dispute.id, + }); }); }); }); diff --git a/packages/automated-dispute/tests/services/eboActor/onDisputeEscalated.spec.ts b/packages/automated-dispute/tests/services/eboActor/onDisputeEscalated.spec.ts index cc7d8ebb..c8de1193 100644 --- a/packages/automated-dispute/tests/services/eboActor/onDisputeEscalated.spec.ts +++ b/packages/automated-dispute/tests/services/eboActor/onDisputeEscalated.spec.ts @@ -1,10 +1,10 @@ -import { ILogger } from "@ebo-agent/shared"; +import { ILogger, UnixTimestamp } from "@ebo-agent/shared"; import { Address } from "viem"; import { describe, expect, it, vi } from "vitest"; import { EboEvent } from "../../../src/types/index.js"; -import mocks from "../../mocks/index.ts"; -import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.ts"; +import mocks from "../../mocks/index.js"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.js"; const logger: ILogger = { info: vi.fn(), @@ -19,7 +19,7 @@ describe("onDisputeEscalated", () => { it("creates the dispute if it does not exist", async () => { const dispute = mocks.buildDispute(actorRequest, response, { - decodedData: { status: "Active" }, + decodedData: { status: "Escalated" }, }); const event: EboEvent<"DisputeEscalated"> = { @@ -27,6 +27,7 @@ describe("onDisputeEscalated", () => { requestId: actorRequest.id, blockNumber: 1n, logIndex: 1, + timestamp: BigInt(Date.now()) as UnixTimestamp, metadata: { caller: "0x01" as Address, disputeId: dispute.id, @@ -39,7 +40,15 @@ describe("onDisputeEscalated", () => { vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); vi.spyOn(registry, "getDispute").mockReturnValue(undefined); - const mockAddDispute = vi.spyOn(registry, "addDispute"); + const disputesMap: Record = {}; + + const mockAddDispute = vi.spyOn(registry, "addDispute").mockImplementation((newDispute) => { + disputesMap[newDispute.id] = newDispute; + }); + + vi.spyOn(registry, "getDispute").mockImplementation((id: string) => { + return disputesMap[id]; + }); actor.enqueue(event); @@ -53,6 +62,15 @@ describe("onDisputeEscalated", () => { }, }), ); + + expect(registry.getDispute(dispute.id)).toEqual( + expect.objectContaining({ + id: dispute.id, + decodedData: { + status: "Escalated", + }, + }), + ); }); it("logs even when there is a ResponseDisputed event after", async () => { @@ -65,6 +83,7 @@ describe("onDisputeEscalated", () => { requestId: actorRequest.id, blockNumber: 1n, logIndex: 1, + timestamp: BigInt(Date.now()) as UnixTimestamp, metadata: { caller: "0x01" as Address, disputeId: dispute.id, @@ -77,6 +96,7 @@ describe("onDisputeEscalated", () => { requestId: actorRequest.id, blockNumber: disputeEscalatedEvent.blockNumber, logIndex: disputeEscalatedEvent.logIndex + 1, + timestamp: BigInt(Date.now()) as UnixTimestamp, metadata: { disputeId: dispute.id, responseId: response.id, @@ -90,7 +110,15 @@ describe("onDisputeEscalated", () => { vi.spyOn(registry, "getResponse").mockReturnValue(response); vi.spyOn(registry, "getDispute").mockReturnValueOnce(undefined).mockReturnValue(dispute); - vi.spyOn(registry, "addDispute").mockImplementation(() => {}); + const disputesMap: Record = {}; + + vi.spyOn(registry, "addDispute").mockImplementation((newDispute) => { + disputesMap[newDispute.id] = newDispute; + }); + + vi.spyOn(registry, "getDispute").mockImplementation((id: string) => { + return disputesMap[id]; + }); // Simulating the DisputeEscalated event coming before the ResponseDisputed event actor.enqueue(disputeEscalatedEvent); @@ -100,6 +128,7 @@ describe("onDisputeEscalated", () => { expect(logger.info).toHaveBeenCalledWith( `Dispute ${dispute.id} for request ${actorRequest.id} has been escalated.`, + { disputeId: dispute.id, requestId: actorRequest.id }, ); }); diff --git a/packages/automated-dispute/tests/services/eboActor/onLastBlockupdated.spec.ts b/packages/automated-dispute/tests/services/eboActor/onLastBlockupdated.spec.ts index 4f5a56ad..20af9fc4 100644 --- a/packages/automated-dispute/tests/services/eboActor/onLastBlockupdated.spec.ts +++ b/packages/automated-dispute/tests/services/eboActor/onLastBlockupdated.spec.ts @@ -2,10 +2,10 @@ import { UnixTimestamp } from "@ebo-agent/shared"; import { pad } from "viem"; import { describe, expect, it, vi } from "vitest"; -import { DisputeWithoutResponse } from "../../../src/exceptions/index.js"; -import { DisputeId, ResponseId } from "../../../src/types/prophet.js"; -import mocks from "../../mocks"; -import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures"; +import { CustomContractError, DisputeWithoutResponse } from "../../../src/exceptions/index.js"; +import { ResponseId } from "../../../src/types/prophet.js"; +import mocks from "../../mocks/index.js"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.js"; const logger = mocks.mockLogger(); @@ -34,7 +34,6 @@ describe("EboActor", () => { const responseToNotSettle = mocks.buildResponse(request, { id: "0x11" as ResponseId }); const disputeToNotSettle = mocks.buildDispute(request, responseToNotSettle, { createdAt: { - // Should be settled 1 second after the other dispute deadline timestamp: (disputeToSettle.createdAt.timestamp + 1n) as UnixTimestamp, blockNumber: 1000n, logIndex: 0, @@ -53,7 +52,6 @@ describe("EboActor", () => { return responseToNotSettle; } }); - // Skipping finalize flow with this mock vi.spyOn(registry, "getResponses").mockReturnValue([]); vi.spyOn(registry, "getDisputes").mockReturnValue([ disputeToSettle, @@ -67,7 +65,7 @@ describe("EboActor", () => { const newBlockNumber = disputeDeadline + 1n; vi.spyOn(protocolProvider.read, "getEscalation").mockResolvedValue({ - disputeId: pad("0x03") as DisputeId, + disputeId: disputeToSettle.id, status: "Active", amountOfPledgesForDispute: BigInt(10), amountOfPledgesAgainstDispute: BigInt(5), @@ -144,7 +142,6 @@ describe("EboActor", () => { vi.spyOn(registry, "getRequest").mockReturnValue(request); vi.spyOn(registry, "getResponse").mockReturnValue(undefined); - // Skipping finalize flow with this mock vi.spyOn(registry, "getResponses").mockReturnValue([]); vi.spyOn(registry, "getDisputes").mockReturnValue([dispute]); actor["canBeSettled"] = vi.fn().mockReturnValue(true); @@ -180,8 +177,9 @@ describe("EboActor", () => { await actor.onLastBlockUpdated(newBlockNumber as UnixTimestamp); - expect(logger.info).toBeCalledWith( - expect.stringMatching(`Proposal window for request ${request.id} not closed yet.`), + expect(logger.info).toHaveBeenCalledWith( + `Proposal window for request ${request.id} not closed yet.`, + { requestId: request.id }, ); expect(mockFinalize).not.toHaveBeenCalled(); @@ -253,5 +251,160 @@ describe("EboActor", () => { firstResponse.prophetData, ); }); + + it("does not finalize the request when there are active disputes and no responses", async () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + + const response = mocks.buildResponse(request, { id: "0x03" as ResponseId }); + const activeDispute = mocks.buildDispute(request, response, { + decodedData: { status: "Active" }, + createdAt: { + timestamp: 1n as UnixTimestamp, + blockNumber: 1000n, + logIndex: 0, + }, + }); + + const { actor, registry } = mocks.buildEboActor(request, logger); + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponse").mockImplementation((id) => { + if (id === ("0x03" as ResponseId)) return undefined; + return undefined; + }); + vi.spyOn(registry, "getResponses").mockReturnValue([]); // No responses + vi.spyOn(registry, "getDisputes").mockReturnValue([activeDispute]); + + actor["canBeSettled"] = vi.fn().mockReturnValue(true); + + const proposalDeadline = + request.decodedData.disputeModuleData.bondEscalationDeadline + + request.decodedData.disputeModuleData.tyingBuffer; + const newBlockTimestamp = (request.createdAt.timestamp + + proposalDeadline + + 1n) as UnixTimestamp; + + await expect(actor.onLastBlockUpdated(newBlockTimestamp)).rejects.toThrow( + DisputeWithoutResponse, + ); + }); + + it("finalizes the request with no response when deadline passes and no active disputes", async () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const { responseModuleData } = request.decodedData; + + const { actor, registry } = mocks.buildEboActor(request, logger); + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponses").mockReturnValue([]); // No responses + vi.spyOn(registry, "getDisputes").mockReturnValue([]); // No active disputes + + const mockFinalizeRequestWithNoResponse = vi + .spyOn(actor as any, "finalizeRequestWithNoResponse") + .mockImplementation(() => Promise.resolve()); + + const newBlockTimestamp = (request.createdAt.timestamp + + responseModuleData.deadline + + 1n) as UnixTimestamp; + + await actor.onLastBlockUpdated(newBlockTimestamp); + + expect(mockFinalizeRequestWithNoResponse).toHaveBeenCalledWith(request); + }); + }); + + it("finalizes request with no response when deadline passes", async () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const { responseModuleData } = request.decodedData; + + const { actor, registry } = mocks.buildEboActor(request, logger); + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponses").mockReturnValue([]); // No responses + + const mockFinalizeRequestWithNoResponse = vi + .spyOn(actor as any, "finalizeRequestWithNoResponse") + .mockImplementation(() => Promise.resolve()); + + const newBlockTimestamp = (request.createdAt.timestamp + + responseModuleData.deadline + + 1n) as UnixTimestamp; + + await actor.onLastBlockUpdated(newBlockTimestamp); + + expect(mockFinalizeRequestWithNoResponse).toHaveBeenCalledWith(request); + }); + + it("does not finalize request with no response before deadline", async () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const { responseModuleData } = request.decodedData; + + const { actor, registry } = mocks.buildEboActor(request, logger); + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponses").mockReturnValue([]); // No responses + + const mockFinalizeRequestWithNoResponse = vi + .spyOn(actor as any, "finalizeRequestWithNoResponse") + .mockImplementation(() => Promise.resolve()); + + const newBlockTimestamp = (request.createdAt.timestamp + + responseModuleData.deadline - + 1n) as UnixTimestamp; + + await actor.onLastBlockUpdated(newBlockTimestamp); + + expect(mockFinalizeRequestWithNoResponse).not.toHaveBeenCalled(); + }); + + it("does not finalize request when responses exist", async () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const { responseModuleData } = request.decodedData; + + const response = mocks.buildResponse(request); + + const { actor, registry } = mocks.buildEboActor(request, logger); + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponses").mockReturnValue([response]); + + const mockFinalizeRequestWithNoResponse = vi + .spyOn(actor as any, "finalizeRequestWithNoResponse") + .mockImplementation(() => Promise.resolve()); + + const newBlockTimestamp = (request.createdAt.timestamp + + responseModuleData.deadline + + 1n) as UnixTimestamp; + + await actor.onLastBlockUpdated(newBlockTimestamp); + + expect(mockFinalizeRequestWithNoResponse).not.toHaveBeenCalled(); + }); + + it("handles errors when finalizing request with no response", async () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const { responseModuleData } = request.decodedData; + + const { actor, registry, protocolProvider } = mocks.buildEboActor(request, logger); + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponses").mockReturnValue([]); // No responses + + const finalizeError = new CustomContractError("UnknownError", { + shouldNotify: false, + shouldReenqueue: true, + shouldTerminate: false, + }); + vi.spyOn(protocolProvider, "finalize").mockRejectedValue(finalizeError); + + const errorHandlerSpy = vi.spyOn(actor["errorHandler"], "handle"); + + const newBlockTimestamp = (request.createdAt.timestamp + + responseModuleData.deadline + + 1n) as UnixTimestamp; + + await actor.onLastBlockUpdated(newBlockTimestamp); + + expect(errorHandlerSpy).toHaveBeenCalledWith(finalizeError); }); }); diff --git a/packages/automated-dispute/tests/services/eboActor/onRequestFinalized.spec.ts b/packages/automated-dispute/tests/services/eboActor/onRequestFinalized.spec.ts index 7257d972..10a55723 100644 --- a/packages/automated-dispute/tests/services/eboActor/onRequestFinalized.spec.ts +++ b/packages/automated-dispute/tests/services/eboActor/onRequestFinalized.spec.ts @@ -1,4 +1,4 @@ -import { ILogger } from "@ebo-agent/shared"; +import { ILogger, UnixTimestamp } from "@ebo-agent/shared"; import { describe, expect, it, vi } from "vitest"; import { FinalizeRequest } from "../../../src/services/index.js"; @@ -18,8 +18,8 @@ describe("EboActor", () => { requestId: actorRequest.id, blockNumber: 1n, logIndex: 1, + timestamp: BigInt(Date.now()) as UnixTimestamp, metadata: { - blockNumber: 1n, caller: "0x01", requestId: actorRequest.id, responseId: "0x02" as ResponseId, @@ -38,7 +38,8 @@ describe("EboActor", () => { await actor.processEvents(); expect(mockInfo).toHaveBeenCalledWith( - expect.stringMatching(`Request ${actorRequest.id} has been finalized.`), + `Request ${actorRequest.id} has been finalized.`, + { requestId: actorRequest.id }, ); }); diff --git a/packages/automated-dispute/tests/services/eboActor/onResponseDisputed.spec.ts b/packages/automated-dispute/tests/services/eboActor/onResponseDisputed.spec.ts index 9c3622b3..038f8dbf 100644 --- a/packages/automated-dispute/tests/services/eboActor/onResponseDisputed.spec.ts +++ b/packages/automated-dispute/tests/services/eboActor/onResponseDisputed.spec.ts @@ -152,6 +152,7 @@ describe("onResponseDisputed", () => { expect(logger.warn).toHaveBeenCalledWith( `Skipping dispute ${dispute.id} as it's already been escalated`, + { disputeId: dispute.id }, ); expect(mockPledgeFor).not.toHaveBeenCalled(); diff --git a/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/addRequest.spec.ts b/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/addRequest.spec.ts index b1659ddd..154add87 100644 --- a/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/addRequest.spec.ts +++ b/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/addRequest.spec.ts @@ -13,7 +13,7 @@ import { EboRegistry } from "../../../../src/interfaces/index.js"; import { AddRequest } from "../../../../src/services/index.js"; import { EboEvent } from "../../../../src/types/index.js"; import { buildRequest } from "../../../mocks/eboActor.mocks.js"; -import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "../../../services/eboActor/fixtures.js"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "../../eboActor/fixtures.js"; describe("AddRequest", () => { let registry: EboRegistry; @@ -93,7 +93,9 @@ describe("AddRequest", () => { fail("Expecting error"); } catch (err) { expect(err).toBeInstanceOf(ProphetDecodingError); - expect(err.message).toMatch("request.requestModuleData"); + if (err instanceof Error) { + expect(err.message).toMatch("request.requestModuleData"); + } } }); @@ -115,7 +117,9 @@ describe("AddRequest", () => { fail("Expecting error"); } catch (err) { expect(err).toBeInstanceOf(ProphetDecodingError); - expect(err.message).toMatch("request.responseModuleData"); + if (err instanceof Error) { + expect(err.message).toMatch("request.responseModuleData"); + } } }); @@ -137,7 +141,9 @@ describe("AddRequest", () => { fail("Expecting error"); } catch (err) { expect(err).toBeInstanceOf(ProphetDecodingError); - expect(err.message).toMatch("request.disputeModuleData"); + if (err instanceof Error) { + expect(err.message).toMatch("request.disputeModuleData"); + } } }); }); diff --git a/packages/automated-dispute/tests/services/eboProcessor.spec.ts b/packages/automated-dispute/tests/services/eboProcessor.spec.ts index 2918e939..98c88018 100644 --- a/packages/automated-dispute/tests/services/eboProcessor.spec.ts +++ b/packages/automated-dispute/tests/services/eboProcessor.spec.ts @@ -868,16 +868,6 @@ describe("EboProcessor", () => { const epoch = 1n; const chainId = "eip155:1" as Caip2ChainId; - const decodedRequestModuleData = { - epoch: epoch, - chainId: chainId, - accountingExtension: "0x0000000000000000000000000000000000000000" as Hex, - paymentAmount: 1000n, - }; - - const encodedRequestModuleData = - ProphetCodec.encodeRequestRequestModuleData(decodedRequestModuleData); - const firstEvent: EboEvent<"RequestCreated"> = { name: "RequestCreated", requestId, @@ -885,9 +875,9 @@ describe("EboProcessor", () => { logIndex: 1, timestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)) as UnixTimestamp, metadata: { - request: { - requestModuleData: encodedRequestModuleData, - }, + requestId: "0x01" as RequestId, + request: DEFAULT_MOCKED_REQUEST_CREATED_DATA.prophetData, + ipfsHash: "0x01" as Hex, }, }; @@ -978,95 +968,5 @@ describe("EboProcessor", () => { expect(createRequestSpy).toHaveBeenCalledTimes(1); expect(createRequestSpy).toHaveBeenCalledWith(currentEpoch.number, "eip155:42161"); }); - - it("adds handled chain IDs when actors are created", async () => { - const { processor, actorsManager } = mocks.buildEboProcessor( - logger, - accountingModules, - notifier, - ); - - const requestId = "0x01" as RequestId; - const epoch = 1n; - const chainId = "eip155:1" as Caip2ChainId; - - const decodedRequestModuleData = { - epoch: epoch, - chainId: chainId, - accountingExtension: "0x0000000000000000000000000000000000000000" as Hex, - paymentAmount: 1000n, - }; - - const encodedRequestModuleData = - ProphetCodec.encodeRequestRequestModuleData(decodedRequestModuleData); - - const firstEvent: EboEvent<"RequestCreated"> = { - name: "RequestCreated", - requestId, - blockNumber: 1n, - logIndex: 1, - timestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)) as UnixTimestamp, - metadata: { - request: { - requestModuleData: encodedRequestModuleData, - }, - }, - }; - - vi.spyOn(actorsManager, "getActor").mockReturnValue(undefined); - vi.spyOn(actorsManager, "createActor").mockImplementation(() => { - const mockRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; - return mocks.buildEboActor(mockRequest, logger).actor; - }); - - await processor["getOrCreateActor"](requestId, firstEvent); - - const handledChainIds = processor["getHandledChainIds"](epoch); - - expect(handledChainIds).toBeDefined(); - expect(handledChainIds!.has(chainId)).toBe(true); - }); - - it("retains handled chain IDs after actors are terminated", async () => { - const { processor, actorsManager } = mocks.buildEboProcessor( - logger, - accountingModules, - notifier, - ); - - const requestId = "0x01" as RequestId; - const epoch = 1n; - const chainId = "eip155:1" as Caip2ChainId; - - const mockRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; - - const actor = mocks.buildEboActor(mockRequest, logger).actor; - - vi.spyOn(actorsManager, "getActor").mockReturnValue(actor); - vi.spyOn(actorsManager, "deleteActor").mockReturnValue(true); - - processor["addHandledChainId"](epoch, chainId); - - await processor["terminateActor"](requestId); - - const handledChainIds = processor["getHandledChainIds"](epoch); - - expect(handledChainIds).toBeDefined(); - expect(handledChainIds!.has(chainId)).toBe(true); - }); - - it("cleans up epochs less than currentEpoch - 1", () => { - const { processor } = mocks.buildEboProcessor(logger, accountingModules, notifier); - - processor["handledChainIdsPerEpoch"].set(1n, new Set(["eip155:1"])); - processor["handledChainIdsPerEpoch"].set(2n, new Set(["eip155:1"])); - processor["handledChainIdsPerEpoch"].set(3n, new Set(["eip155:1"])); - - processor["cleanupOldEpochs"](3n); - - expect(processor["handledChainIdsPerEpoch"].has(1n)).toBe(false); - expect(processor["handledChainIdsPerEpoch"].has(2n)).toBe(true); - expect(processor["handledChainIdsPerEpoch"].has(3n)).toBe(true); - }); }); });