diff --git a/packages/automated-dispute/src/eboActor.ts b/packages/automated-dispute/src/eboActor.ts index e537f83..8298a97 100644 --- a/packages/automated-dispute/src/eboActor.ts +++ b/packages/automated-dispute/src/eboActor.ts @@ -1,7 +1,7 @@ import { BlockNumberService } from "@ebo-agent/blocknumber"; import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types.js"; import { ILogger } from "@ebo-agent/shared"; -import { ContractFunctionRevertedError, Hex } from "viem"; +import { ContractFunctionRevertedError } from "viem"; import { InvalidActorState } from "./exceptions/invalidActorState.exception.js"; import { RequestMismatch } from "./exceptions/requestMismatch.js"; @@ -17,7 +17,7 @@ import { Dispute, Request, Response, ResponseBody } from "./types/prophet.js"; export class EboActor { constructor( private readonly actorRequest: { - id: Hex; + id: string; epoch: bigint; epochTimestamp: bigint; }, @@ -216,19 +216,120 @@ export class EboActor { } } - public async onResponseDisputed(_event: EboEvent<"ResponseDisputed">): Promise { - // TODO: implement - return; + /** + * Returns the active actor request. + * + * @throws {InvalidActorState} when the request has not been added to the registry yet. + * @returns the actor `Request` + */ + private getActorRequest() { + const request = this.registry.getRequest(this.actorRequest.id); + + if (request === undefined) throw new InvalidActorState(); + + return request; } - private async disputeProposal(_dispute: Dispute): Promise { - // TODO: implement - return; + /** + * Handle the `ResponseDisputed` event. + * + * @param event `ResponseDisputed` event. + */ + public async onResponseDisputed(event: EboEvent<"ResponseDisputed">): Promise { + this.shouldHandleRequest(event.metadata.dispute.requestId); + + const dispute: Dispute = { + id: event.metadata.disputeId, + status: "Active", + prophetData: event.metadata.dispute, + }; + + this.registry.addDispute(event.metadata.disputeId, dispute); + + const request = this.getActorRequest(); + const proposedResponse = this.registry.getResponse(event.metadata.responseId); + + if (!proposedResponse) throw new InvalidActorState(); + + const isValidDispute = await this.isValidDispute(proposedResponse); + + if (isValidDispute) await this.pledgeFor(request, dispute); + else await this.pledgeAgainst(request, dispute); } - private async isValidDispute(_dispute: Dispute): Promise { - // TODO: implement - return true; + /** + * Check if a dispute is valid, comparing the already submitted and disputed proposal with + * the response this actor would propose. + * + * @param proposedResponse the already submitted response + * @returns true if the hypothetical proposal is different that the submitted one, false otherwise + */ + private async isValidDispute(proposedResponse: Response) { + const actorResponse = await this.buildResponse( + proposedResponse.prophetData.response.chainId, + ); + + const equalResponses = this.equalResponses( + actorResponse, + proposedResponse.prophetData.response, + ); + + return !equalResponses; + } + + /** + * Pledge in favor of the dispute. + * + * @param request the dispute's `Request` + * @param dispute the `Dispute` + */ + private async pledgeFor(request: Request, dispute: Dispute) { + try { + this.logger.info(`Pledging against dispute ${dispute.id}`); + + await this.protocolProvider.pledgeForDispute(request.prophetData, dispute.prophetData); + } catch (err) { + if (err instanceof ContractFunctionRevertedError) { + // TODO: handle each error appropriately + this.logger.warn(`Pledging for dispute ${dispute.id} was reverted. Skipping...`); + } else { + // TODO: handle each error appropriately + this.logger.error( + `Actor handling request ${this.actorRequest.id} is not able to continue.`, + ); + + throw err; + } + } + } + + /** + * Pledge against the dispute. + * + * @param request the dispute's `Request` + * @param dispute the `Dispute` + */ + private async pledgeAgainst(request: Request, dispute: Dispute) { + try { + this.logger.info(`Pledging for dispute ${dispute.id}`); + + await this.protocolProvider.pledgeAgainstDispute( + request.prophetData, + dispute.prophetData, + ); + } catch (err) { + if (err instanceof ContractFunctionRevertedError) { + // TODO: handle each error appropriately + this.logger.warn(`Pledging on dispute ${dispute.id} was reverted. Skipping...`); + } else { + // TODO: handle each error appropriately + this.logger.error( + `Actor handling request ${this.actorRequest.id} is not able to continue.`, + ); + + throw err; + } + } } public async onFinalizeRequest(_event: EboEvent<"RequestFinalizable">): Promise { diff --git a/packages/automated-dispute/src/eboMemoryRegistry.ts b/packages/automated-dispute/src/eboMemoryRegistry.ts index 5da93a2..24640f2 100644 --- a/packages/automated-dispute/src/eboMemoryRegistry.ts +++ b/packages/automated-dispute/src/eboMemoryRegistry.ts @@ -5,7 +5,7 @@ export class EboMemoryRegistry implements EboRegistry { constructor( private requests: Map = new Map(), private responses: Map = new Map(), - private dispute: Map = new Map(), + private disputes: Map = new Map(), ) {} /** @inheritdoc */ @@ -27,4 +27,14 @@ export class EboMemoryRegistry implements EboRegistry { public getResponses() { return this.responses; } + + /** @inheritdoc */ + public getResponse(responseId: string): Response | undefined { + return this.responses.get(responseId); + } + + /** @inheritdoc */ + public addDispute(disputeId: string, dispute: Dispute): void { + this.disputes.set(disputeId, dispute); + } } diff --git a/packages/automated-dispute/src/interfaces/eboRegistry.ts b/packages/automated-dispute/src/interfaces/eboRegistry.ts index 8d32e46..997cc23 100644 --- a/packages/automated-dispute/src/interfaces/eboRegistry.ts +++ b/packages/automated-dispute/src/interfaces/eboRegistry.ts @@ -1,4 +1,4 @@ -import { Request, Response } from "../types/prophet.js"; +import { Dispute, Request, Response } from "../types/prophet.js"; /** Registry that stores Prophet entities (ie. requests, responses and disputes) */ export interface EboRegistry { @@ -22,7 +22,7 @@ export interface EboRegistry { * Add a `Response` by ID. * * @param responseId the ID of the `Response` to use as index - * @param response the `Resopnse` + * @param response the `Response` */ addResponse(responseId: string, response: Response): void; @@ -32,4 +32,20 @@ export interface EboRegistry { * @returns responses map */ getResponses(): Map; + + /** + * Get a `Response` by ID. + * + * @param responseId response ID + * @returns the `Response` if already added into registry, `undefined` otherwise + */ + getResponse(responseId: string): Response | undefined; + + /** + * Add a dispute by ID. + * + * @param disputeId the ID of the `Dispute` to use as index + * @param dispute the `Dispute` + */ + addDispute(disputeId: string, dispute: Dispute): void; } diff --git a/packages/automated-dispute/src/protocolProvider.ts b/packages/automated-dispute/src/protocolProvider.ts index b6c80ab..76a178d 100644 --- a/packages/automated-dispute/src/protocolProvider.ts +++ b/packages/automated-dispute/src/protocolProvider.ts @@ -211,12 +211,18 @@ export class ProtocolProvider { return; } - async pledgeForDispute(_request: Request, _dispute: Dispute): Promise { + async pledgeForDispute( + _request: Request["prophetData"], + _dispute: Dispute["prophetData"], + ): Promise { // TODO: implement actual method return; } - async pledgeAgaintsDispute(_request: Request, _dispute: Dispute): Promise { + async pledgeAgainstDispute( + _request: Request["prophetData"], + _dispute: Dispute["prophetData"], + ): Promise { // TODO: implement actual method return; } diff --git a/packages/automated-dispute/src/types/events.ts b/packages/automated-dispute/src/types/events.ts index 1e4eb53..85f4f6f 100644 --- a/packages/automated-dispute/src/types/events.ts +++ b/packages/automated-dispute/src/types/events.ts @@ -32,8 +32,8 @@ export interface RequestCreated { } export interface ResponseDisputed { - requestId: string; responseId: string; + disputeId: string; dispute: Dispute["prophetData"]; } diff --git a/packages/automated-dispute/src/types/prophet.ts b/packages/automated-dispute/src/types/prophet.ts index 2b13ed8..97195ac 100644 --- a/packages/automated-dispute/src/types/prophet.ts +++ b/packages/automated-dispute/src/types/prophet.ts @@ -38,9 +38,11 @@ export interface Response { export type ResponseBody = Response["prophetData"]["response"]; +export type DisputeStatus = "None" | "Active" | "Escalated" | "Won" | "Lost" | "NoResolution"; + export interface Dispute { id: string; - status: string; + status: DisputeStatus; prophetData: { disputer: Address; diff --git a/packages/automated-dispute/tests/eboActor/mocks/index.ts b/packages/automated-dispute/tests/eboActor/mocks/index.ts new file mode 100644 index 0000000..2f85b11 --- /dev/null +++ b/packages/automated-dispute/tests/eboActor/mocks/index.ts @@ -0,0 +1,79 @@ +import { BlockNumberService } from "@ebo-agent/blocknumber"; +import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types"; +import { ILogger } from "@ebo-agent/shared"; + +import { EboActor } from "../../../src/eboActor"; +import { EboMemoryRegistry } from "../../../src/eboMemoryRegistry"; +import { ProtocolProvider } from "../../../src/protocolProvider"; +import { Request, Response } from "../../../src/types/prophet"; +import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS } from "../fixtures"; + +/** + * Builds a base `EboActor` scaffolded with all its dependencies. + * + * @param request a `Request` to populate the `EboActor` with + * @param logger logger + * @returns + */ +function buildEboActor(request: Request, logger: ILogger) { + const { id, chainId, epoch, epochTimestamp } = request; + + const protocolProviderRpcUrls = ["http://localhost:8538"]; + const protocolProvider = new ProtocolProvider( + protocolProviderRpcUrls, + DEFAULT_MOCKED_PROTOCOL_CONTRACTS, + ); + + const blockNumberRpcUrls = new Map([ + [chainId, ["http://localhost:8539"]], + ]); + const blockNumberService = new BlockNumberService(blockNumberRpcUrls, logger); + + const registry = new EboMemoryRegistry(); + + const actor = new EboActor( + { id, epoch, epochTimestamp }, + protocolProvider, + blockNumberService, + registry, + logger, + ); + + return { + actor, + protocolProvider, + blockNumberService, + registry, + logger, + }; +} + +/** + * Helper function to build a response based on a request. + * + * @param request the `Request` to base the response on + * @param attributes custom attributes to set into the response to build + * @returns a `Response` + */ +function buildResponse(request: Request, attributes: Partial = {}): Response { + const baseResponse: Response = { + id: "0x01", + wasDisputed: false, + prophetData: { + proposer: "0x01", + requestId: request.id, + response: { + chainId: request.chainId, + block: 1n, + epoch: request.epoch, + }, + }, + }; + + return { + ...baseResponse, + ...attributes, + }; +} + +export default { buildEboActor, buildResponse }; diff --git a/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts b/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts new file mode 100644 index 0000000..4246348 --- /dev/null +++ b/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts @@ -0,0 +1,154 @@ +import { ILogger } from "@ebo-agent/shared"; +import { ContractFunctionRevertedError } from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import { InvalidActorState } from "../../src/exceptions/invalidActorState.exception"; +import { EboEvent } from "../../src/types/events"; +import { Response } from "../../src/types/prophet"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures"; +import mocks from "./mocks/index.js"; + +const logger: ILogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}; + +describe("onResponseDisputed", () => { + const actorRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const response: Response = mocks.buildResponse(actorRequest); + + const event: EboEvent<"ResponseDisputed"> = { + name: "ResponseDisputed", + blockNumber: 1n, + logIndex: 1, + metadata: { + disputeId: "0x03", + responseId: response.id, + dispute: { + requestId: actorRequest.id, + responseId: response.id, + disputer: "0x11", + proposer: "0x12", + }, + }, + }; + + it("pledges for dispute if proposal should be different", async () => { + const { actor, registry, blockNumberService, protocolProvider } = mocks.buildEboActor( + actorRequest, + logger, + ); + + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + vi.spyOn(registry, "getResponse").mockReturnValue(response); + + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( + response.prophetData.response.block + 1n, + ); + + const mockPledgeForDispute = vi.spyOn(protocolProvider, "pledgeForDispute"); + + await actor.onResponseDisputed(event); + + expect(mockPledgeForDispute).toHaveBeenCalled(); + }); + + it("pledges against dispute if proposal is ok", async () => { + const { actor, registry, blockNumberService, protocolProvider } = mocks.buildEboActor( + actorRequest, + logger, + ); + + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + vi.spyOn(registry, "getResponse").mockReturnValue(response); + + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( + response.prophetData.response.block, + ); + + const mockPledgeAgainstDispute = vi.spyOn(protocolProvider, "pledgeAgainstDispute"); + + await actor.onResponseDisputed(event); + + expect(mockPledgeAgainstDispute).toHaveBeenCalled(); + }); + + it("adds dispute to registry", async () => { + const { actor, registry, blockNumberService, protocolProvider } = mocks.buildEboActor( + actorRequest, + logger, + ); + + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + vi.spyOn(registry, "getResponse").mockReturnValue(response); + + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( + response.prophetData.response.block, + ); + + vi.spyOn(protocolProvider, "pledgeAgainstDispute").mockResolvedValue(); + + const addResponseMock = vi.spyOn(registry, "addDispute"); + + await actor.onResponseDisputed(event); + + expect(addResponseMock).toHaveBeenCalled(); + }); + + it("resolves if the pledge is reverted", async () => { + const { actor, blockNumberService, protocolProvider, registry } = mocks.buildEboActor( + actorRequest, + logger, + ); + + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + vi.spyOn(registry, "getResponse").mockReturnValue(response); + + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( + response.prophetData.response.block + 1n, + ); + + vi.spyOn(protocolProvider, "pledgeForDispute").mockRejectedValue( + Object.create(ContractFunctionRevertedError.prototype), + ); + + expect(actor.onResponseDisputed(event)).resolves.toBeUndefined(); + }); + + it("throws if protocol provider cannot complete pledge", () => { + const { actor, blockNumberService, protocolProvider, registry } = mocks.buildEboActor( + actorRequest, + logger, + ); + + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + vi.spyOn(registry, "getResponse").mockReturnValue(response); + + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( + response.prophetData.response.block + 1n, + ); + + vi.spyOn(protocolProvider, "pledgeForDispute").mockRejectedValue(new Error()); + + expect(actor.onResponseDisputed(event)).rejects.toThrow(); + }); + + it("throws if the response's request is not handled by actor", () => { + const { actor } = mocks.buildEboActor(actorRequest, logger); + + const otherRequestEvent = { + ...event, + metadata: { + ...event.metadata, + dispute: { + ...event.metadata.dispute, + requestId: "0x02", + }, + }, + }; + + expect(actor.onResponseDisputed(otherRequestEvent)).rejects.toThrow(InvalidActorState); + }); +}); diff --git a/packages/automated-dispute/tests/eboActor/onResponseProposed.spec.ts b/packages/automated-dispute/tests/eboActor/onResponseProposed.spec.ts index 1b5e162..6ebac86 100644 --- a/packages/automated-dispute/tests/eboActor/onResponseProposed.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onResponseProposed.spec.ts @@ -1,14 +1,10 @@ -import { BlockNumberService } from "@ebo-agent/blocknumber"; -import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types"; import { ILogger } from "@ebo-agent/shared"; import { describe, expect, it, vi } from "vitest"; -import { EboActor } from "../../src/eboActor"; -import { EboMemoryRegistry } from "../../src/eboMemoryRegistry"; import { InvalidActorState } from "../../src/exceptions/invalidActorState.exception"; -import { ProtocolProvider } from "../../src/protocolProvider"; import { EboEvent } from "../../src/types/events"; -import { DEFAULT_MOCKED_PROPHET_REQUEST, DEFAULT_MOCKED_PROTOCOL_CONTRACTS } from "./fixtures"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.ts"; +import mocks from "./mocks/index.ts"; const logger: ILogger = { info: vi.fn(), @@ -18,29 +14,22 @@ const logger: ILogger = { }; describe("onResponseProposed", () => { - const requestId = "0x01"; - const indexedChainId: Caip2ChainId = "eip155:137"; - - const protocolEpoch = { - currentEpoch: 1n, - currentEpochBlockNumber: 1n, - currentEpochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), - }; + const actorRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; const responseProposedEvent: EboEvent<"ResponseProposed"> = { name: "ResponseProposed", blockNumber: 1n, logIndex: 2, metadata: { - requestId: requestId, + requestId: actorRequest.id, responseId: "0x02", response: { proposer: "0x03", - requestId: requestId, + requestId: actorRequest.id, response: { - block: protocolEpoch.currentEpochBlockNumber, - chainId: indexedChainId, - epoch: protocolEpoch.currentEpoch, + block: 1n, + chainId: actorRequest.chainId, + epoch: 1n, }, }, }, @@ -49,14 +38,11 @@ describe("onResponseProposed", () => { const proposeData = responseProposedEvent.metadata.response.response; it("adds the response to the registry", async () => { - const { actor, registry } = mockEboActor({ - requestId, - indexedChainId, - mockActorResponse: { - mockBlockNumber: proposeData.block, - mockEpoch: proposeData.epoch, - }, - }); + const { actor, registry, blockNumberService } = mocks.buildEboActor(actorRequest, logger); + + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue(proposeData.block); const addResponseMock = vi.spyOn(registry, "addResponse"); @@ -66,14 +52,7 @@ describe("onResponseProposed", () => { }); it("throws if the response's request is not handled by actor", () => { - const { actor } = mockEboActor({ - requestId, - indexedChainId, - mockActorResponse: { - mockBlockNumber: proposeData.block, - mockEpoch: proposeData.epoch, - }, - }); + const { actor } = mocks.buildEboActor(actorRequest, logger); const otherRequestEvent = { ...responseProposedEvent, @@ -87,14 +66,14 @@ describe("onResponseProposed", () => { }); it("does not dispute the response if seems valid", async () => { - const { actor, protocolProvider } = mockEboActor({ - requestId, - indexedChainId, - mockActorResponse: { - mockBlockNumber: proposeData.block, - mockEpoch: proposeData.epoch, - }, - }); + const { actor, registry, blockNumberService, protocolProvider } = mocks.buildEboActor( + actorRequest, + logger, + ); + + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue(proposeData.block); const mockDisputeResponse = vi.spyOn(protocolProvider, "disputeResponse"); @@ -104,14 +83,16 @@ describe("onResponseProposed", () => { }); it("dispute the response if it should be different", async () => { - const { actor, protocolProvider } = mockEboActor({ - requestId, - indexedChainId, - mockActorResponse: { - mockBlockNumber: proposeData.block + 1n, - mockEpoch: proposeData.epoch, - }, - }); + const { actor, registry, blockNumberService, protocolProvider } = mocks.buildEboActor( + actorRequest, + logger, + ); + + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( + proposeData.block + 1n, + ); const mockDisputeResponse = vi.spyOn(protocolProvider, "disputeResponse"); @@ -120,54 +101,3 @@ describe("onResponseProposed", () => { expect(mockDisputeResponse).toHaveBeenCalled(); }); }); - -// Mocks the basic dependencies behavior of EboActor instance on onResponseProposed -// to validate the proposal without disputing it -function mockEboActor(mockedValues: { - requestId: string; - indexedChainId: Caip2ChainId; - mockActorResponse: { - mockBlockNumber: bigint; - mockEpoch: bigint; - }; -}) { - const { requestId, indexedChainId, mockActorResponse } = mockedValues; - const { mockBlockNumber, mockEpoch } = mockActorResponse; - - const protocolProvider = new ProtocolProvider( - ["http://localhost:8538"], - DEFAULT_MOCKED_PROTOCOL_CONTRACTS, - ); - - const chainRpcUrls = new Map(); - chainRpcUrls.set(indexedChainId, ["http://localhost:8539"]); - - const blockNumberService = new BlockNumberService(chainRpcUrls, logger); - const registry = new EboMemoryRegistry(); - - const requestConfig = { - id: requestId, - epoch: mockEpoch, - epochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), - }; - - const actor = new EboActor( - requestConfig, - protocolProvider, - blockNumberService, - registry, - logger, - ); - - vi.spyOn(registry, "getRequest").mockReturnValue(DEFAULT_MOCKED_PROPHET_REQUEST); - - vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue(mockBlockNumber); - - return { - actor, - protocolProvider, - blockNumberService, - registry, - logger, - }; -}