diff --git a/packages/automated-dispute/src/eboActor.ts b/packages/automated-dispute/src/eboActor.ts index 0388858..4731385 100644 --- a/packages/automated-dispute/src/eboActor.ts +++ b/packages/automated-dispute/src/eboActor.ts @@ -24,11 +24,12 @@ import { AddDispute, AddRequest, AddResponse, - Noop, + FinalizeRequest, UpdateDisputeStatus, } from "./services/index.js"; import { Dispute, + DisputeStatus, EboEvent, EboEventName, Request, @@ -205,7 +206,10 @@ export class EboActor { ); case "RequestFinalized": - return Noop.buildFromEvent(); + return FinalizeRequest.buildFromEvent( + event as EboEvent<"RequestFinalized">, + this.registry, + ); default: throw new UnknownEvent(event.name); @@ -412,7 +416,7 @@ export class EboActor { const request = this.getActorRequest(); const dispute = this.registry.getResponseDispute(response); const disputeWindow = - response.createdAt + request.prophetData.responseModuleData.disputeWindow; + response.createdAt + request.prophetData.disputeModuleData.disputeWindow; // Response is still able to be disputed if (blockNumber <= disputeWindow) return false; @@ -428,11 +432,41 @@ export class EboActor { * * Be aware that a request can be finalized but some of its disputes can still be pending resolution. * + * @param blockNumber block number to check entities at * @returns `true` if all entities are settled, otherwise `false` */ - public canBeTerminated(): boolean { - // TODO - throw new Error("Implement me"); + public canBeTerminated(blockNumber: bigint): boolean { + const request = this.getActorRequest(); + const isRequestFinalized = request.status === "Finalized"; + const nonSettledProposals = this.activeProposals(blockNumber); + + return isRequestFinalized && nonSettledProposals.length === 0; + } + + /** + * Check for any active proposals at a specific block number. + * + * @param blockNumber block number to check proposals' status against + * @returns an array of `Response` instances + */ + private activeProposals(blockNumber: bigint): Response[] { + const responses = this.registry.getResponses(); + + return responses.filter((response) => { + if (this.isResponseAccepted(response, blockNumber)) return false; + + const dispute = this.registry.getResponseDispute(response); + + // Response has not been disputed but is not accepted yet, so it's active. + if (!dispute) return true; + + // The rest of the status (ie "Escalated" | "Won" | "Lost" | "NoResolution") + // cannot be changed by the EBO agent once they've been reached so they make + // the proposal non-active. + const activeStatus: DisputeStatus[] = ["None", "Active"]; + + return activeStatus.includes(dispute.status); + }); } /** diff --git a/packages/automated-dispute/src/exceptions/eboRegistry/index.ts b/packages/automated-dispute/src/exceptions/eboRegistry/index.ts index a16f691..95eb578 100644 --- a/packages/automated-dispute/src/exceptions/eboRegistry/index.ts +++ b/packages/automated-dispute/src/exceptions/eboRegistry/index.ts @@ -1,3 +1,4 @@ export * from "./commandAlreadyRun.js"; export * from "./commandNotRun.js"; export * from "./disputeNotFound.js"; +export * from "./requestNotFound.js"; diff --git a/packages/automated-dispute/src/exceptions/eboRegistry/requestNotFound.ts b/packages/automated-dispute/src/exceptions/eboRegistry/requestNotFound.ts new file mode 100644 index 0000000..cbca12a --- /dev/null +++ b/packages/automated-dispute/src/exceptions/eboRegistry/requestNotFound.ts @@ -0,0 +1,7 @@ +export class RequestNotFound extends Error { + constructor(requestId: string) { + super(`Request ${requestId} was not found.`); + + this.name = "RequestNotFound"; + } +} diff --git a/packages/automated-dispute/src/interfaces/eboRegistry.ts b/packages/automated-dispute/src/interfaces/eboRegistry.ts index 50e06aa..e0b3dad 100644 --- a/packages/automated-dispute/src/interfaces/eboRegistry.ts +++ b/packages/automated-dispute/src/interfaces/eboRegistry.ts @@ -1,4 +1,11 @@ -import { Dispute, DisputeStatus, Request, RequestId, Response } from "../types/prophet.js"; +import { + Dispute, + DisputeStatus, + Request, + RequestId, + RequestStatus, + Response, +} from "../types/index.js"; /** Registry that stores Prophet entities (ie. requests, responses and disputes) */ export interface EboRegistry { @@ -17,6 +24,14 @@ export interface EboRegistry { */ getRequest(requestId: RequestId): Request | undefined; + /** + * Update the request status based on its ID. + * + * @param requestId the ID of the `Request` + * @param status the `Request` status + */ + updateRequestStatus(requestId: string, status: RequestStatus): void; + /** * Remove a `Request` by its ID. * diff --git a/packages/automated-dispute/src/services/eboProcessor.ts b/packages/automated-dispute/src/services/eboProcessor.ts index 08d7205..aea44fb 100644 --- a/packages/automated-dispute/src/services/eboProcessor.ts +++ b/packages/automated-dispute/src/services/eboProcessor.ts @@ -144,7 +144,7 @@ export class EboProcessor { await actor.processEvents(); await actor.onLastBlockUpdated(lastBlock); - if (actor.canBeTerminated()) { + if (actor.canBeTerminated(lastBlock)) { this.terminateActor(requestId); } } diff --git a/packages/automated-dispute/src/services/eboRegistry/commands/addRequest.ts b/packages/automated-dispute/src/services/eboRegistry/commands/addRequest.ts index 3517b2b..5d57fcc 100644 --- a/packages/automated-dispute/src/services/eboRegistry/commands/addRequest.ts +++ b/packages/automated-dispute/src/services/eboRegistry/commands/addRequest.ts @@ -20,6 +20,7 @@ export class AddRequest implements EboRegistryCommand { epoch: event.metadata.epoch, createdAt: event.blockNumber, prophetData: event.metadata.request, + status: "Active", }; return new AddRequest(registry, request); diff --git a/packages/automated-dispute/src/services/eboRegistry/commands/finalizeRequest.ts b/packages/automated-dispute/src/services/eboRegistry/commands/finalizeRequest.ts new file mode 100644 index 0000000..a84f21f --- /dev/null +++ b/packages/automated-dispute/src/services/eboRegistry/commands/finalizeRequest.ts @@ -0,0 +1,41 @@ +import { CommandAlreadyRun, CommandNotRun, RequestNotFound } from "../../../exceptions/index.js"; +import { EboRegistry, EboRegistryCommand } from "../../../interfaces/index.js"; +import { EboEvent, Request, RequestStatus } from "../../../types/index.js"; + +export class FinalizeRequest implements EboRegistryCommand { + private wasRun: boolean = false; + private previousStatus?: RequestStatus; + + private constructor( + private readonly registry: EboRegistry, + private readonly request: Request, + ) {} + + public static buildFromEvent( + event: EboEvent<"RequestFinalized">, + registry: EboRegistry, + ): FinalizeRequest { + const requestId = event.metadata.requestId; + const request = registry.getRequest(requestId); + + if (!request) throw new RequestNotFound(requestId); + + return new FinalizeRequest(registry, request); + } + + run(): void { + if (this.wasRun) throw new CommandAlreadyRun(FinalizeRequest.name); + + this.previousStatus = this.request.status; + + this.registry.updateRequestStatus(this.request.id, "Finalized"); + + this.wasRun = true; + } + + undo(): void { + if (!this.wasRun || !this.previousStatus) throw new CommandNotRun(FinalizeRequest.name); + + this.registry.updateRequestStatus(this.request.id, this.previousStatus); + } +} diff --git a/packages/automated-dispute/src/services/eboRegistry/commands/index.ts b/packages/automated-dispute/src/services/eboRegistry/commands/index.ts index c05c58f..aa77846 100644 --- a/packages/automated-dispute/src/services/eboRegistry/commands/index.ts +++ b/packages/automated-dispute/src/services/eboRegistry/commands/index.ts @@ -1,5 +1,5 @@ export * from "./addDispute.js"; export * from "./addRequest.js"; export * from "./addResponse.js"; -export * from "./noop.js"; +export * from "./finalizeRequest.js"; export * from "./updateDisputeStatus.js"; diff --git a/packages/automated-dispute/src/services/eboRegistry/commands/noop.ts b/packages/automated-dispute/src/services/eboRegistry/commands/noop.ts deleted file mode 100644 index a15af24..0000000 --- a/packages/automated-dispute/src/services/eboRegistry/commands/noop.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { CommandAlreadyRun, CommandNotRun } from "../../../exceptions/index.js"; -import { EboRegistryCommand } from "../../../interfaces/index.js"; - -export class Noop implements EboRegistryCommand { - private wasRun: boolean = false; - - private constructor() {} - - public static buildFromEvent(): Noop { - return new Noop(); - } - - run(): void { - if (this.wasRun) throw new CommandAlreadyRun(Noop.name); - - this.wasRun = true; - } - - undo(): void { - if (!this.wasRun) throw new CommandNotRun(Noop.name); - } -} diff --git a/packages/automated-dispute/src/services/eboRegistry/eboMemoryRegistry.ts b/packages/automated-dispute/src/services/eboRegistry/eboMemoryRegistry.ts index 0321e69..8456719 100644 --- a/packages/automated-dispute/src/services/eboRegistry/eboMemoryRegistry.ts +++ b/packages/automated-dispute/src/services/eboRegistry/eboMemoryRegistry.ts @@ -1,6 +1,13 @@ -import { DisputeNotFound } from "../../exceptions/index.js"; +import { DisputeNotFound, RequestNotFound } from "../../exceptions/index.js"; import { EboRegistry } from "../../interfaces/index.js"; -import { Dispute, DisputeStatus, Request, RequestId, Response } from "../../types/index.js"; +import { + Dispute, + DisputeStatus, + Request, + RequestId, + RequestStatus, + Response, +} from "../../types/index.js"; export class EboMemoryRegistry implements EboRegistry { constructor( @@ -20,6 +27,17 @@ export class EboMemoryRegistry implements EboRegistry { return this.requests.get(requestId); } + public updateRequestStatus(requestId: RequestId, status: RequestStatus): void { + const request = this.getRequest(requestId); + + if (request === undefined) throw new RequestNotFound(requestId); + + this.requests.set(requestId, { + ...request, + status: status, + }); + } + /** @inheritdoc */ public removeRequest(requestId: RequestId): boolean { return this.requests.delete(requestId); diff --git a/packages/automated-dispute/src/types/events.ts b/packages/automated-dispute/src/types/events.ts index 0709879..89e6e50 100644 --- a/packages/automated-dispute/src/types/events.ts +++ b/packages/automated-dispute/src/types/events.ts @@ -44,7 +44,7 @@ export interface DisputeEscalated { } export interface RequestFinalized { - requestId: string; + requestId: RequestId; responseId: string; caller: string; blockNumber: bigint; diff --git a/packages/automated-dispute/src/types/prophet.ts b/packages/automated-dispute/src/types/prophet.ts index d0ded26..9776991 100644 --- a/packages/automated-dispute/src/types/prophet.ts +++ b/packages/automated-dispute/src/types/prophet.ts @@ -3,12 +3,14 @@ import { NormalizedAddress } from "@ebo-agent/shared"; import { Address } from "viem"; export type RequestId = NormalizedAddress; +export type RequestStatus = "Active" | "Finalized"; export interface Request { id: RequestId; chainId: Caip2ChainId; epoch: bigint; createdAt: bigint; + status: RequestStatus; prophetData: Readonly<{ requester: Address; diff --git a/packages/automated-dispute/tests/eboActor/fixtures.ts b/packages/automated-dispute/tests/eboActor/fixtures.ts index 634f154..e7c7ab7 100644 --- a/packages/automated-dispute/tests/eboActor/fixtures.ts +++ b/packages/automated-dispute/tests/eboActor/fixtures.ts @@ -16,6 +16,7 @@ export const DEFAULT_MOCKED_REQUEST_CREATED_DATA: Request = { chainId: "eip155:1", epoch: 1n, createdAt: 1n, + status: "Active", prophetData: { disputeModule: "0x01" as Address, finalityModule: "0x02" as Address, diff --git a/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts b/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts index 6f716eb..68c7d5a 100644 --- a/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts @@ -1,6 +1,7 @@ import { ILogger } from "@ebo-agent/shared"; import { describe, expect, it, vi } from "vitest"; +import { FinalizeRequest } from "../../src/services/index.js"; import { EboEvent } from "../../src/types/index.js"; import mocks from "../mocks/index.js"; import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.js"; @@ -8,36 +9,60 @@ import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.js"; const logger: ILogger = mocks.mockLogger(); describe("EboActor", () => { - describe("onRequestFinalized", () => { - const actorRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; - - const event: EboEvent<"RequestFinalized"> = { - name: "RequestFinalized", - requestId: actorRequest.id, - blockNumber: 1n, - logIndex: 1, - metadata: { - blockNumber: 1n, - caller: "0x01", + describe("processEvents", () => { + describe("when RequestFinalized is enqueued", () => { + const actorRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + + const event: EboEvent<"RequestFinalized"> = { + name: "RequestFinalized", requestId: actorRequest.id, - responseId: "0x02", - }, - }; + blockNumber: 1n, + logIndex: 1, + metadata: { + blockNumber: 1n, + caller: "0x01", + requestId: actorRequest.id, + responseId: "0x02", + }, + }; + + it("logs a message during request finalization", async () => { + const { actor, registry } = mocks.buildEboActor(actorRequest, logger); + + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + + const mockInfo = vi.spyOn(logger, "info"); + + actor.enqueue(event); + + await actor.processEvents(); + + expect(mockInfo).toHaveBeenCalledWith( + expect.stringMatching(`Request ${actorRequest.id} has been finalized.`), + ); + }); + + it("uses the FinalizeRequest registry command", async () => { + const { actor, registry } = mocks.buildEboActor(actorRequest, logger); - it("logs a message during request finalization", async () => { - const { actor, registry } = mocks.buildEboActor(actorRequest, logger); + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); - vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + const mockFinalizeRequest = { + run: vi.fn(), + undo: vi.fn(), + } as unknown as FinalizeRequest; - const mockInfo = vi.spyOn(logger, "info"); + const mockBuildFromEvent = vi + .spyOn(FinalizeRequest, "buildFromEvent") + .mockReturnValue(mockFinalizeRequest); - actor.enqueue(event); + actor.enqueue(event); - await actor.processEvents(); + await actor.processEvents(); - expect(mockInfo).toHaveBeenCalledWith( - expect.stringMatching(`Request ${actorRequest.id} has been finalized.`), - ); + expect(mockBuildFromEvent).toHaveBeenCalledWith(event, registry); + expect(mockFinalizeRequest.run).toHaveBeenCalled(); + }); }); }); }); diff --git a/packages/automated-dispute/tests/services/eboActor.spec.ts b/packages/automated-dispute/tests/services/eboActor.spec.ts index 6b1bc32..a416ef9 100644 --- a/packages/automated-dispute/tests/services/eboActor.spec.ts +++ b/packages/automated-dispute/tests/services/eboActor.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PastEventEnqueueError, RequestMismatch } from "../../src/exceptions/index.js"; -import { EboEvent, RequestId } from "../../src/types/index.js"; +import { EboEvent, Request, RequestId } from "../../src/types/index.js"; import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "../eboActor/fixtures.js"; import mocks from "../mocks/index.js"; @@ -9,6 +9,7 @@ const logger = mocks.mockLogger(); describe("EboActor", () => { const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const event: EboEvent<"RequestCreated"> = { name: "RequestCreated", blockNumber: 2n, @@ -243,4 +244,95 @@ describe("EboActor", () => { expect(callOrder).not.toEqual([1, 2, 2, 1]); // Case with no mutexes }); }); + + describe("canBeTerminated", () => { + it("returns false if the request has not been finalized yet", () => { + const { actor, registry } = mocks.buildEboActor(request, logger); + const currentBlockNumber = request.prophetData.responseModuleData.disputeWindow - 1n; + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponses").mockReturnValue([]); + + expect(actor.canBeTerminated(currentBlockNumber)).toBe(false); + }); + + it("returns false if there's one disputable response", () => { + const response = mocks.buildResponse(request); + const { actor, registry } = mocks.buildEboActor(request, logger); + const currentBlockNumber = + response.createdAt + request.prophetData.disputeModuleData.disputeWindow - 1n; + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponses").mockReturnValue([response]); + vi.spyOn(registry, "getResponseDispute").mockReturnValue(undefined); + + expect(actor.canBeTerminated(currentBlockNumber)).toBe(false); + }); + + it("returns false if the request is finalized but there's one active dispute", () => { + const request: Request = { + ...DEFAULT_MOCKED_REQUEST_CREATED_DATA, + status: "Finalized", + }; + + const response = mocks.buildResponse(request); + const dispute = mocks.buildDispute(request, response, { status: "Active" }); + + const { actor, registry } = mocks.buildEboActor(request, logger); + const currentBlockNumber = + response.createdAt + request.prophetData.disputeModuleData.disputeWindow - 1n; + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponses").mockReturnValue([response]); + vi.spyOn(registry, "getResponseDispute").mockReturnValue(dispute); + + const canBeTerminated = actor.canBeTerminated(currentBlockNumber); + + expect(canBeTerminated).toBe(false); + }); + + it("returns true once everything is settled", () => { + const request: Request = { + ...DEFAULT_MOCKED_REQUEST_CREATED_DATA, + status: "Finalized", + }; + + const disputedResponse = mocks.buildResponse(request, { id: "0x01" }); + const undisputedResponse = mocks.buildResponse(request, { + id: "0x02", + createdAt: request.prophetData.responseModuleData.deadline - 1n, + }); + + const escalatedDispute = mocks.buildDispute(request, disputedResponse, { + status: "Escalated", + }); + + const { actor, registry } = mocks.buildEboActor(request, logger); + const currentBlockNumber = + undisputedResponse.createdAt + + request.prophetData.disputeModuleData.disputeWindow + + 1n; + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + + vi.spyOn(registry, "getResponses").mockReturnValue([ + disputedResponse, + undisputedResponse, + ]); + + vi.spyOn(registry, "getResponseDispute").mockImplementation((response) => { + switch (response.id) { + case disputedResponse.id: + return escalatedDispute; + + case undisputedResponse.id: + return undefined; + } + }); + + const canBeTerminated = actor.canBeTerminated(currentBlockNumber); + + expect(canBeTerminated).toBe(true); + }); + }); }); diff --git a/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/finalizeRequest.spec.ts b/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/finalizeRequest.spec.ts new file mode 100644 index 0000000..b381b43 --- /dev/null +++ b/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/finalizeRequest.spec.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { CommandAlreadyRun, CommandNotRun } from "../../../../src/exceptions/index.js"; +import { EboRegistry } from "../../../../src/interfaces/index.js"; +import { FinalizeRequest } from "../../../../src/services/index.js"; +import { EboEvent } from "../../../../src/types/index.js"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "../../../eboActor/fixtures.js"; +import mocks from "../../../mocks/index.js"; + +describe("FinalizeRequest", () => { + let registry: EboRegistry; + + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const response = mocks.buildResponse(request); + + const event: EboEvent<"RequestFinalized"> = { + name: "RequestFinalized", + blockNumber: response.createdAt + 1n, + logIndex: 1, + requestId: request.id, + metadata: { + requestId: request.id, + responseId: response.id, + blockNumber: response.createdAt + 1n, + caller: "0x01", + }, + }; + + beforeEach(() => { + registry = { + getRequest: vi.fn().mockReturnValue(request), + updateRequestStatus: vi.fn(), + } as unknown as EboRegistry; + }); + + describe("run", () => { + it("changes the request state", () => { + const command = FinalizeRequest.buildFromEvent(event, registry); + + command.run(); + + expect(registry.updateRequestStatus).toHaveBeenCalledWith(request.id, "Finalized"); + }); + + it("throws if the command was already run", () => { + const command = FinalizeRequest.buildFromEvent(event, registry); + + command.run(); + + expect(() => command.run()).toThrow(CommandAlreadyRun); + }); + }); + + describe("undo", () => { + it("reverts the request status", () => { + const command = FinalizeRequest.buildFromEvent(event, registry); + const firstStatus = request.status; + + command.run(); + command.undo(); + + expect(registry.updateRequestStatus).toHaveBeenNthCalledWith( + 1, + request.id, + "Finalized", + ); + + expect(registry.updateRequestStatus).toHaveBeenNthCalledWith( + 2, + request.id, + firstStatus, + ); + }); + + it("throws if undoing the command before being run", () => { + const command = FinalizeRequest.buildFromEvent(event, registry); + + expect(() => command.undo()).toThrow(CommandNotRun); + }); + }); +}); diff --git a/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/noop.spec.ts b/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/noop.spec.ts deleted file mode 100644 index fbd6c31..0000000 --- a/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/noop.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { CommandAlreadyRun, CommandNotRun } from "../../../../src/exceptions/index.js"; -import { Noop } from "../../../../src/services/index.js"; - -describe("Noop", () => { - describe("run", () => { - it("throws if the command was already run", () => { - const command = Noop.buildFromEvent(); - - command.run(); - - expect(() => command.run()).toThrow(CommandAlreadyRun); - }); - }); - - describe("undo", () => { - it("throws if undoing the command before being run", () => { - const command = Noop.buildFromEvent(); - - expect(() => command.undo()).toThrow(CommandNotRun); - }); - }); -});