From 496fdfabb20a3b165ba174c007d05b577828cc2c Mon Sep 17 00:00:00 2001 From: Yaco 0x Date: Thu, 15 Aug 2024 16:56:17 -0300 Subject: [PATCH 01/11] feat: implement EboActor.onRequestFinalized handler --- packages/automated-dispute/src/eboActor.ts | 29 +++++++- .../automated-dispute/src/types/events.ts | 13 +--- .../tests/eboActor/mocks/index.ts | 5 ++ .../tests/eboActor/onRequestCreated.spec.ts | 6 ++ .../tests/eboActor/onRequestFinalized.spec.ts | 69 +++++++++++++++++++ 5 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts diff --git a/packages/automated-dispute/src/eboActor.ts b/packages/automated-dispute/src/eboActor.ts index 8298a97..8be489a 100644 --- a/packages/automated-dispute/src/eboActor.ts +++ b/packages/automated-dispute/src/eboActor.ts @@ -10,17 +10,32 @@ import { ProtocolProvider } from "./protocolProvider.js"; import { EboEvent } from "./types/events.js"; import { Dispute, Request, Response, ResponseBody } from "./types/prophet.js"; +type OnTerminateActorCallback = (request: Request) => Promise; + /** * Actor that handles a singular Prophet's request asking for the block number that corresponds * to an instant on an indexed chain. */ export class EboActor { + /** + * Creates an `EboActor` instance. + * + * @param actorRequest.id request ID this actor will handle + * @param actorRequest.epoch requested epoch + * @param actorRequest.epoch requested epoch's timestamp + * @param onTerminate callback to be run when this instance is being terminated + * @param protocolProvider a `ProtocolProvider` instance + * @param blockNumberService a `BlockNumberService` instance + * @param registry an `EboRegistry` instance + * @param logger an `ILogger` instance + */ constructor( private readonly actorRequest: { id: string; epoch: bigint; epochTimestamp: bigint; }, + private readonly onTerminate: OnTerminateActorCallback, private readonly protocolProvider: ProtocolProvider, private readonly blockNumberService: BlockNumberService, private readonly registry: EboRegistry, @@ -332,9 +347,17 @@ export class EboActor { } } - public async onFinalizeRequest(_event: EboEvent<"RequestFinalizable">): Promise { - // TODO: implement - return; + /** + * Handle the `ResponseFinalized` event. + * + * @param event `ResponseFinalized` event + */ + public async onRequestFinalized(event: EboEvent<"RequestFinalized">): Promise { + this.shouldHandleRequest(event.metadata.requestId); + + const request = this.getActorRequest(); + + await this.onTerminate(request); } public async onDisputeStatusChanged(_event: EboEvent<"DisputeStatusChanged">): Promise { diff --git a/packages/automated-dispute/src/types/events.ts b/packages/automated-dispute/src/types/events.ts index 85f4f6f..40451d8 100644 --- a/packages/automated-dispute/src/types/events.ts +++ b/packages/automated-dispute/src/types/events.ts @@ -10,7 +10,6 @@ export type EboEventName = | "ResponseDisputed" | "DisputeStatusChanged" | "DisputeEscalated" - | "RequestFinalizable" | "RequestFinalized"; export interface NewEpoch { @@ -49,10 +48,6 @@ export interface DisputeEscalated { blockNumber: bigint; } -export interface RequestFinalizable { - requestId: string; -} - export interface RequestFinalized { requestId: string; responseId: string; @@ -72,11 +67,9 @@ export type EboEventData = E extends "NewEpoch" ? DisputeStatusChanged : E extends "DisputeEscalated" ? DisputeEscalated - : E extends "RequestFinalizable" - ? RequestFinalizable - : E extends "RequestFinalized" - ? RequestFinalized - : never; + : E extends "RequestFinalized" + ? RequestFinalized + : never; export type EboEvent = { name: T; diff --git a/packages/automated-dispute/tests/eboActor/mocks/index.ts b/packages/automated-dispute/tests/eboActor/mocks/index.ts index 2f85b11..47d5039 100644 --- a/packages/automated-dispute/tests/eboActor/mocks/index.ts +++ b/packages/automated-dispute/tests/eboActor/mocks/index.ts @@ -1,6 +1,7 @@ import { BlockNumberService } from "@ebo-agent/blocknumber"; import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types"; import { ILogger } from "@ebo-agent/shared"; +import { vi } from "vitest"; import { EboActor } from "../../../src/eboActor"; import { EboMemoryRegistry } from "../../../src/eboMemoryRegistry"; @@ -18,6 +19,8 @@ import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS } from "../fixtures"; function buildEboActor(request: Request, logger: ILogger) { const { id, chainId, epoch, epochTimestamp } = request; + const onTerminate = vi.fn(); + const protocolProviderRpcUrls = ["http://localhost:8538"]; const protocolProvider = new ProtocolProvider( protocolProviderRpcUrls, @@ -33,6 +36,7 @@ function buildEboActor(request: Request, logger: ILogger) { const actor = new EboActor( { id, epoch, epochTimestamp }, + onTerminate, protocolProvider, blockNumberService, registry, @@ -41,6 +45,7 @@ function buildEboActor(request: Request, logger: ILogger) { return { actor, + onTerminate, protocolProvider, blockNumberService, registry, diff --git a/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts b/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts index 1f40dc8..5303301 100644 --- a/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts @@ -45,6 +45,8 @@ describe("EboActor", () => { }, }; + const onTerminate = vi.fn(); + let protocolProvider: ProtocolProvider; let blockNumberService: BlockNumberService; let registry: EboMemoryRegistry; @@ -88,6 +90,7 @@ describe("EboActor", () => { const actor = new EboActor( requestConfig, + onTerminate, protocolProvider, blockNumberService, registry, @@ -130,6 +133,7 @@ describe("EboActor", () => { const actor = new EboActor( requestConfig, + onTerminate, protocolProvider, blockNumberService, registry, @@ -179,6 +183,7 @@ describe("EboActor", () => { const actor = new EboActor( requestConfig, + onTerminate, protocolProvider, blockNumberService, registry, @@ -201,6 +206,7 @@ describe("EboActor", () => { const actor = new EboActor( requestConfig, + onTerminate, protocolProvider, blockNumberService, registry, diff --git a/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts b/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts new file mode 100644 index 0000000..0c40792 --- /dev/null +++ b/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts @@ -0,0 +1,69 @@ +import { ILogger } from "@ebo-agent/shared"; +import { describe, expect, it, vi } from "vitest"; + +import { InvalidActorState } from "../../src/exceptions/invalidActorState.exception.js"; +import { EboEvent } from "../../src/types/events.js"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.js"; +import mocks from "./mocks/index.js"; + +const logger: ILogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}; + +describe("EboActor", () => { + describe("onRequestFinalized", () => { + const actorRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + + const event: EboEvent<"RequestFinalized"> = { + name: "RequestFinalized", + blockNumber: 1n, + logIndex: 1, + metadata: { + blockNumber: 1n, + caller: "0x01", + requestId: actorRequest.id, + responseId: "0x02", + }, + }; + + it("executes the actor's callback during termination", async () => { + const { actor, onTerminate, registry } = mocks.buildEboActor(actorRequest, logger); + + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + + onTerminate.mockImplementation(() => Promise.resolve()); + + await actor.onRequestFinalized(event); + + expect(onTerminate).toHaveBeenCalledWith(actorRequest); + }); + + it("throws if the event's request is not handled by actor", () => { + const { actor } = mocks.buildEboActor(actorRequest, logger); + + const otherRequestEvent = { + ...event, + metadata: { + ...event.metadata, + requestId: actorRequest.id + "123", + }, + }; + + expect(actor.onRequestFinalized(otherRequestEvent)).rejects.toThrow(InvalidActorState); + }); + + // The one who defines the callback is responsible for handling callback errors + it("throws if the callback throws", () => { + const { actor, onTerminate } = mocks.buildEboActor(actorRequest, logger); + + onTerminate.mockImplementation(() => { + throw new Error(); + }); + + expect(actor.onRequestFinalized(event)).rejects.toThrow(InvalidActorState); + }); + }); +}); From 8e53d2fe34a62e19ff1367d69048fcc2e5668193 Mon Sep 17 00:00:00 2001 From: Yaco 0x Date: Fri, 16 Aug 2024 15:45:22 -0300 Subject: [PATCH 02/11] feat: implement EboActorsManager --- .../automated-dispute/src/eboActorsManager.ts | 24 ++++++ .../automated-dispute/src/exceptions/index.ts | 1 + .../requestAlreadyHandled.exception.ts | 7 ++ .../tests/eboActorsManager.spec.ts | 79 +++++++++++++++++++ .../mocks/index.ts => mocks/eboActor.ts} | 16 ++-- .../automated-dispute/tests/mocks/index.ts | 4 + .../automated-dispute/tests/mocks/logger.ts | 9 +++ 7 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 packages/automated-dispute/src/eboActorsManager.ts create mode 100644 packages/automated-dispute/src/exceptions/requestAlreadyHandled.exception.ts create mode 100644 packages/automated-dispute/tests/eboActorsManager.spec.ts rename packages/automated-dispute/tests/{eboActor/mocks/index.ts => mocks/eboActor.ts} (78%) create mode 100644 packages/automated-dispute/tests/mocks/index.ts create mode 100644 packages/automated-dispute/tests/mocks/logger.ts diff --git a/packages/automated-dispute/src/eboActorsManager.ts b/packages/automated-dispute/src/eboActorsManager.ts new file mode 100644 index 0000000..b1a2dd9 --- /dev/null +++ b/packages/automated-dispute/src/eboActorsManager.ts @@ -0,0 +1,24 @@ +import { EboActor } from "./eboActor.js"; +import { RequestAlreadyHandled } from "./exceptions/index.js"; + +export class EboActorsManager { + private readonly requestActorMap: Map; + + constructor() { + this.requestActorMap = new Map(); + } + + public registerActor(requestId: string, actor: EboActor): void { + if (this.requestActorMap.has(requestId)) throw new RequestAlreadyHandled(requestId); + + this.requestActorMap.set(requestId, actor); + } + + public getActor(requestId: string): EboActor | undefined { + return this.requestActorMap.get(requestId); + } + + public deleteActor(requestId: string): boolean { + return this.requestActorMap.delete(requestId); + } +} diff --git a/packages/automated-dispute/src/exceptions/index.ts b/packages/automated-dispute/src/exceptions/index.ts index 4050512..e94ced4 100644 --- a/packages/automated-dispute/src/exceptions/index.ts +++ b/packages/automated-dispute/src/exceptions/index.ts @@ -1,2 +1,3 @@ export * from "./rpcUrlsEmpty.exception.js"; export * from "./invalidActorState.exception.js"; +export * from "./requestAlreadyHandled.exception.js"; diff --git a/packages/automated-dispute/src/exceptions/requestAlreadyHandled.exception.ts b/packages/automated-dispute/src/exceptions/requestAlreadyHandled.exception.ts new file mode 100644 index 0000000..d3fb0b7 --- /dev/null +++ b/packages/automated-dispute/src/exceptions/requestAlreadyHandled.exception.ts @@ -0,0 +1,7 @@ +export class RequestAlreadyHandled extends Error { + constructor(requestId: string) { + super(`Request ${requestId} is already being handled by another actor.`); + + this.name = "RequestAlreadyHandled"; + } +} diff --git a/packages/automated-dispute/tests/eboActorsManager.spec.ts b/packages/automated-dispute/tests/eboActorsManager.spec.ts new file mode 100644 index 0000000..ff17f94 --- /dev/null +++ b/packages/automated-dispute/tests/eboActorsManager.spec.ts @@ -0,0 +1,79 @@ +import { ILogger } from "@ebo-agent/shared"; +import { describe, expect, it, vi } from "vitest"; + +import { EboActorsManager } from "../src/eboActorsManager.js"; +import { RequestAlreadyHandled } from "../src/exceptions/requestAlreadyHandled.exception.js"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./eboActor/fixtures.js"; +import mocks from "./mocks/index.js"; + +const logger: ILogger = mocks.mockLogger(); + +describe("EboActorsManager", () => { + describe("registerActor", () => { + it("registers the actor correctly", () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const { actor } = mocks.buildEboActor(request, logger); + const actorsManager = new EboActorsManager(); + const mockSetRequestActorMap = vi.spyOn(actorsManager["requestActorMap"], "set"); + + actorsManager.registerActor(request.id, actor); + + expect(mockSetRequestActorMap).toHaveBeenCalledWith(request.id, actor); + }); + + it("throws if the request has already an actor linked to it", () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const { actor: firstActor } = mocks.buildEboActor(request, logger); + const { actor: secondActor } = mocks.buildEboActor(request, logger); + const actorsManager = new EboActorsManager(); + + actorsManager.registerActor(request.id, firstActor); + + expect(() => actorsManager.registerActor(request.id, secondActor)).toThrowError( + RequestAlreadyHandled, + ); + }); + }); + + describe("getActor", () => { + it("returns undefined if the request is not linked to any actor", () => { + const actorsManager = new EboActorsManager(); + + expect(actorsManager.getActor("0x9999")).toBeUndefined(); + }); + + it("returns the request's linked actor", () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const { actor } = mocks.buildEboActor(request, logger); + const actorsManager = new EboActorsManager(); + + actorsManager.registerActor(request.id, actor); + + expect(actorsManager.getActor(request.id)).toBe(actor); + }); + }); + + describe("deleteActor", () => { + it("deletes the actor linked to the request", () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const { actor } = mocks.buildEboActor(request, logger); + const actorsManager = new EboActorsManager(); + + actorsManager.registerActor(request.id, actor); + + expect(actorsManager.getActor(request.id)).toBe(actor); + + actorsManager.deleteActor(request.id); + + expect(actorsManager.getActor(request.id)).toBeUndefined(); + }); + + it("returns false if the request has no actors linked", () => { + const requestId = "0x01"; + const actorsManager = new EboActorsManager(); + + expect(actorsManager.getActor(requestId)).toBeUndefined(); + expect(actorsManager.deleteActor(requestId)).toEqual(false); + }); + }); +}); diff --git a/packages/automated-dispute/tests/eboActor/mocks/index.ts b/packages/automated-dispute/tests/mocks/eboActor.ts similarity index 78% rename from packages/automated-dispute/tests/eboActor/mocks/index.ts rename to packages/automated-dispute/tests/mocks/eboActor.ts index 47d5039..b18b857 100644 --- a/packages/automated-dispute/tests/eboActor/mocks/index.ts +++ b/packages/automated-dispute/tests/mocks/eboActor.ts @@ -3,11 +3,11 @@ import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types"; import { ILogger } from "@ebo-agent/shared"; import { vi } from "vitest"; -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"; +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 "../eboActor/fixtures"; /** * Builds a base `EboActor` scaffolded with all its dependencies. @@ -16,7 +16,7 @@ import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS } from "../fixtures"; * @param logger logger * @returns */ -function buildEboActor(request: Request, logger: ILogger) { +export function buildEboActor(request: Request, logger: ILogger) { const { id, chainId, epoch, epochTimestamp } = request; const onTerminate = vi.fn(); @@ -60,7 +60,7 @@ function buildEboActor(request: Request, logger: ILogger) { * @param attributes custom attributes to set into the response to build * @returns a `Response` */ -function buildResponse(request: Request, attributes: Partial = {}): Response { +export function buildResponse(request: Request, attributes: Partial = {}): Response { const baseResponse: Response = { id: "0x01", wasDisputed: false, @@ -80,5 +80,3 @@ function buildResponse(request: Request, attributes: Partial = {}): Re ...attributes, }; } - -export default { buildEboActor, buildResponse }; diff --git a/packages/automated-dispute/tests/mocks/index.ts b/packages/automated-dispute/tests/mocks/index.ts new file mode 100644 index 0000000..0aa2842 --- /dev/null +++ b/packages/automated-dispute/tests/mocks/index.ts @@ -0,0 +1,4 @@ +import { buildEboActor, buildResponse } from "./eboActor.js"; +import { mockLogger } from "./logger.js"; + +export default { buildEboActor, buildResponse, mockLogger }; diff --git a/packages/automated-dispute/tests/mocks/logger.ts b/packages/automated-dispute/tests/mocks/logger.ts new file mode 100644 index 0000000..18743c7 --- /dev/null +++ b/packages/automated-dispute/tests/mocks/logger.ts @@ -0,0 +1,9 @@ +import { ILogger } from "@ebo-agent/shared"; +import { vi } from "vitest"; + +export const mockLogger: () => ILogger = () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}); From 1a58060ecf5898e33a6ee31fac9d9fdc0220f847 Mon Sep 17 00:00:00 2001 From: Yaco 0x Date: Fri, 16 Aug 2024 15:51:14 -0300 Subject: [PATCH 03/11] refactor: use centralized mocked logger --- .../tests/eboActor/onRequestCreated.spec.ts | 8 ++------ .../tests/eboActor/onRequestFinalized.spec.ts | 9 ++------- .../tests/eboActor/onResponseDisputed.spec.ts | 11 +++-------- .../tests/eboActor/onResponseProposed.spec.ts | 11 +++-------- 4 files changed, 10 insertions(+), 29 deletions(-) diff --git a/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts b/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts index 5303301..b1f6ad7 100644 --- a/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts @@ -10,17 +10,13 @@ import { RequestMismatch } from "../../src/exceptions/requestMismatch.js"; import { ProtocolProvider } from "../../src/protocolProvider.js"; import { EboEvent } from "../../src/types/events.js"; import { Response } from "../../src/types/prophet.js"; +import mocks from "../mocks/index.js"; import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS, DEFAULT_MOCKED_REQUEST_CREATED_DATA, } from "./fixtures.js"; -const logger: ILogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), -}; +const logger: ILogger = mocks.mockLogger(); describe("EboActor", () => { describe("onRequestCreated", () => { diff --git a/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts b/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts index 0c40792..6955743 100644 --- a/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts @@ -3,15 +3,10 @@ import { describe, expect, it, vi } from "vitest"; import { InvalidActorState } from "../../src/exceptions/invalidActorState.exception.js"; import { EboEvent } from "../../src/types/events.js"; +import mocks from "../mocks/index.js"; import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.js"; -import mocks from "./mocks/index.js"; -const logger: ILogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), -}; +const logger: ILogger = mocks.mockLogger(); describe("EboActor", () => { describe("onRequestFinalized", () => { diff --git a/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts b/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts index 4246348..5c66e78 100644 --- a/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts @@ -5,15 +5,10 @@ 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 mocks from "../mocks/index.js"; 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(), -}; + +const logger: ILogger = mocks.mockLogger(); describe("onResponseDisputed", () => { const actorRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; diff --git a/packages/automated-dispute/tests/eboActor/onResponseProposed.spec.ts b/packages/automated-dispute/tests/eboActor/onResponseProposed.spec.ts index 6ebac86..25bbc99 100644 --- a/packages/automated-dispute/tests/eboActor/onResponseProposed.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onResponseProposed.spec.ts @@ -3,15 +3,10 @@ import { describe, expect, it, vi } from "vitest"; import { InvalidActorState } from "../../src/exceptions/invalidActorState.exception"; import { EboEvent } from "../../src/types/events"; +import mocks from "../mocks/index.ts"; import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.ts"; -import mocks from "./mocks/index.ts"; - -const logger: ILogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), -}; + +const logger: ILogger = mocks.mockLogger(); describe("onResponseProposed", () => { const actorRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; From c03c26d884b72c0133988d6c7b94ed8cadcfa97a Mon Sep 17 00:00:00 2001 From: Yaco 0x Date: Fri, 16 Aug 2024 19:53:01 -0300 Subject: [PATCH 04/11] docs: document EboActorsManager --- .../automated-dispute/src/eboActorsManager.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/automated-dispute/src/eboActorsManager.ts b/packages/automated-dispute/src/eboActorsManager.ts index b1a2dd9..6e6701d 100644 --- a/packages/automated-dispute/src/eboActorsManager.ts +++ b/packages/automated-dispute/src/eboActorsManager.ts @@ -8,16 +8,34 @@ export class EboActorsManager { this.requestActorMap = new Map(); } + /** + * Registers the link between a request ID and the actor handling the respective request. + * + * @param requestId request ID + * @param actor an `EboActor` instance that handles the request + */ public registerActor(requestId: string, actor: EboActor): void { if (this.requestActorMap.has(requestId)) throw new RequestAlreadyHandled(requestId); this.requestActorMap.set(requestId, actor); } + /** + * Get the `EboActor` instance linked with the `requestId`. + * + * @param requestId request ID + * @returns an `EboActor` instance if found by `requestId`, otherwise `undefined` + */ public getActor(requestId: string): EboActor | undefined { return this.requestActorMap.get(requestId); } + /** + * Deletes an actor from the manager, based on its linked request. + * + * @param requestId request ID + * @returns `true` if there was a linked actor for the request ID and it was removed, or `false` if the request was not linked to any actor. + */ public deleteActor(requestId: string): boolean { return this.requestActorMap.delete(requestId); } From 7a439a277896c0dea2f40f3eb2660f296f5a1ff7 Mon Sep 17 00:00:00 2001 From: Yaco 0x Date: Fri, 16 Aug 2024 20:00:20 -0300 Subject: [PATCH 05/11] refactor: do not require request id on actor register --- packages/automated-dispute/src/eboActor.ts | 9 +++++++++ packages/automated-dispute/src/eboActorsManager.ts | 7 ++++--- .../automated-dispute/tests/eboActorsManager.spec.ts | 11 ++++++----- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/automated-dispute/src/eboActor.ts b/packages/automated-dispute/src/eboActor.ts index 8be489a..d7189ae 100644 --- a/packages/automated-dispute/src/eboActor.ts +++ b/packages/automated-dispute/src/eboActor.ts @@ -42,6 +42,15 @@ export class EboActor { private readonly logger: ILogger, ) {} + /** + * Get the request ID this actor is handling. + * + * @returns request ID + */ + public getRequestId(): string { + return this.actorRequest.id; + } + /** * Handle `RequestCreated` event. * diff --git a/packages/automated-dispute/src/eboActorsManager.ts b/packages/automated-dispute/src/eboActorsManager.ts index 6e6701d..fa4fe75 100644 --- a/packages/automated-dispute/src/eboActorsManager.ts +++ b/packages/automated-dispute/src/eboActorsManager.ts @@ -9,12 +9,13 @@ export class EboActorsManager { } /** - * Registers the link between a request ID and the actor handling the respective request. + * Registers the actor and makes it fetchable by the ID of the request the actor is handling. * - * @param requestId request ID * @param actor an `EboActor` instance that handles the request */ - public registerActor(requestId: string, actor: EboActor): void { + public registerActor(actor: EboActor): void { + const requestId = actor.getRequestId(); + if (this.requestActorMap.has(requestId)) throw new RequestAlreadyHandled(requestId); this.requestActorMap.set(requestId, actor); diff --git a/packages/automated-dispute/tests/eboActorsManager.spec.ts b/packages/automated-dispute/tests/eboActorsManager.spec.ts index ff17f94..8df066e 100644 --- a/packages/automated-dispute/tests/eboActorsManager.spec.ts +++ b/packages/automated-dispute/tests/eboActorsManager.spec.ts @@ -16,9 +16,10 @@ describe("EboActorsManager", () => { const actorsManager = new EboActorsManager(); const mockSetRequestActorMap = vi.spyOn(actorsManager["requestActorMap"], "set"); - actorsManager.registerActor(request.id, actor); + actorsManager.registerActor(actor); expect(mockSetRequestActorMap).toHaveBeenCalledWith(request.id, actor); + expect(actorsManager.getActor(actor.getRequestId())).toBe(actor); }); it("throws if the request has already an actor linked to it", () => { @@ -27,9 +28,9 @@ describe("EboActorsManager", () => { const { actor: secondActor } = mocks.buildEboActor(request, logger); const actorsManager = new EboActorsManager(); - actorsManager.registerActor(request.id, firstActor); + actorsManager.registerActor(firstActor); - expect(() => actorsManager.registerActor(request.id, secondActor)).toThrowError( + expect(() => actorsManager.registerActor(secondActor)).toThrowError( RequestAlreadyHandled, ); }); @@ -47,7 +48,7 @@ describe("EboActorsManager", () => { const { actor } = mocks.buildEboActor(request, logger); const actorsManager = new EboActorsManager(); - actorsManager.registerActor(request.id, actor); + actorsManager.registerActor(actor); expect(actorsManager.getActor(request.id)).toBe(actor); }); @@ -59,7 +60,7 @@ describe("EboActorsManager", () => { const { actor } = mocks.buildEboActor(request, logger); const actorsManager = new EboActorsManager(); - actorsManager.registerActor(request.id, actor); + actorsManager.registerActor(actor); expect(actorsManager.getActor(request.id)).toBe(actor); From fdddf82ddc3b72af79a718ceab88c4029b4cb8d7 Mon Sep 17 00:00:00 2001 From: Yaco 0x Date: Fri, 16 Aug 2024 20:09:51 -0300 Subject: [PATCH 06/11] refactor: group some imports using index.js --- packages/automated-dispute/src/exceptions/index.ts | 1 + .../tests/eboActor/onRequestCreated.spec.ts | 5 ++--- packages/automated-dispute/tests/eboActorsManager.spec.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/automated-dispute/src/exceptions/index.ts b/packages/automated-dispute/src/exceptions/index.ts index e94ced4..a0a14e0 100644 --- a/packages/automated-dispute/src/exceptions/index.ts +++ b/packages/automated-dispute/src/exceptions/index.ts @@ -1,3 +1,4 @@ export * from "./rpcUrlsEmpty.exception.js"; export * from "./invalidActorState.exception.js"; export * from "./requestAlreadyHandled.exception.js"; +export * from "./requestMismatch.js"; diff --git a/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts b/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts index b1f6ad7..fc58645 100644 --- a/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts @@ -6,10 +6,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { EboActor } from "../../src/eboActor.js"; import { EboMemoryRegistry } from "../../src/eboMemoryRegistry.js"; -import { RequestMismatch } from "../../src/exceptions/requestMismatch.js"; +import { RequestMismatch } from "../../src/exceptions/index.js"; import { ProtocolProvider } from "../../src/protocolProvider.js"; -import { EboEvent } from "../../src/types/events.js"; -import { Response } from "../../src/types/prophet.js"; +import { EboEvent, Response } from "../../src/types/index.js"; import mocks from "../mocks/index.js"; import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS, diff --git a/packages/automated-dispute/tests/eboActorsManager.spec.ts b/packages/automated-dispute/tests/eboActorsManager.spec.ts index 8df066e..a55c261 100644 --- a/packages/automated-dispute/tests/eboActorsManager.spec.ts +++ b/packages/automated-dispute/tests/eboActorsManager.spec.ts @@ -2,7 +2,7 @@ import { ILogger } from "@ebo-agent/shared"; import { describe, expect, it, vi } from "vitest"; import { EboActorsManager } from "../src/eboActorsManager.js"; -import { RequestAlreadyHandled } from "../src/exceptions/requestAlreadyHandled.exception.js"; +import { RequestAlreadyHandled } from "../src/exceptions/index.js"; import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./eboActor/fixtures.js"; import mocks from "./mocks/index.js"; From 746af83ca4f2c1ce24ea773afe4a27327f4a413d Mon Sep 17 00:00:00 2001 From: Yaco 0x Date: Wed, 21 Aug 2024 12:07:02 -0300 Subject: [PATCH 07/11] feat: base EboProcessor class --- .../automated-dispute/src/protocolProvider.ts | 4 ++ .../src/services/eboProcessor.ts | 40 +++++++++++++++++++ .../automated-dispute/src/services/index.ts | 1 + .../automated-dispute/src/types/events.ts | 1 + .../automated-dispute/src/types/prophet.ts | 8 ++-- .../tests/services/eboProcessor.spec.ts | 13 ++++++ 6 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 packages/automated-dispute/src/services/eboProcessor.ts create mode 100644 packages/automated-dispute/src/services/index.ts create mode 100644 packages/automated-dispute/tests/services/eboProcessor.spec.ts diff --git a/packages/automated-dispute/src/protocolProvider.ts b/packages/automated-dispute/src/protocolProvider.ts index 76a178d..7d35550 100644 --- a/packages/automated-dispute/src/protocolProvider.ts +++ b/packages/automated-dispute/src/protocolProvider.ts @@ -90,6 +90,7 @@ export class ProtocolProvider { name: "RequestCreated", blockNumber: 1n, logIndex: 1, + requestId: "0x01", metadata: { requestId: "0x01", chainId: "eip155:1", @@ -111,6 +112,7 @@ export class ProtocolProvider { name: "ResponseProposed", blockNumber: 2n, logIndex: 1, + requestId: "0x01", metadata: { requestId: "0x01", responseId: "0x02", @@ -129,6 +131,7 @@ export class ProtocolProvider { name: "ResponseDisputed", blockNumber: 3n, logIndex: 1, + requestId: "0x01", metadata: { requestId: "0x01", responseId: "0x02", @@ -145,6 +148,7 @@ export class ProtocolProvider { name: "DisputeStatusChanged", blockNumber: 4n, logIndex: 20, + requestId: "0x01", metadata: { disputeId: "0x03", status: "Won", blockNumber: 4n }, } as EboEvent<"DisputeStatusChanged">, ]; diff --git a/packages/automated-dispute/src/services/eboProcessor.ts b/packages/automated-dispute/src/services/eboProcessor.ts new file mode 100644 index 0000000..da0e6a3 --- /dev/null +++ b/packages/automated-dispute/src/services/eboProcessor.ts @@ -0,0 +1,40 @@ +import { ILogger } from "@ebo-agent/shared"; + +import { EboActor } from "../eboActor.js"; +import { EboActorsManager } from "../eboActorsManager.js"; +import { ProtocolProvider } from "../protocolProvider.js"; + +const DEFAULT_MS_BETWEEN_CHECKS = 10 * 60 * 1000; // 10 minutes + +export class EboProcessor { + private eventsInterval?: NodeJS.Timeout; + private lastCheckedBlock?: bigint; + + constructor( + private readonly protocolProvider: ProtocolProvider, + private readonly actorsManager: EboActorsManager, + private readonly logger: ILogger, + ) {} + + public async start(msBetweenChecks: number = DEFAULT_MS_BETWEEN_CHECKS) { + this.bootstrap(); // Bootstrapping + + this.eventsInterval = setInterval(this.eventLoop, msBetweenChecks); + } + + private async bootstrap() { + // TODO + } + + private async eventLoop() { + // TODO + } + + private async onActorError(_actor: EboActor, _error: Error) { + // TODO + } + + private async notifyError(_error: Error) { + // TODO + } +} diff --git a/packages/automated-dispute/src/services/index.ts b/packages/automated-dispute/src/services/index.ts new file mode 100644 index 0000000..448cab7 --- /dev/null +++ b/packages/automated-dispute/src/services/index.ts @@ -0,0 +1 @@ +export * from "./eboProcessor.js"; diff --git a/packages/automated-dispute/src/types/events.ts b/packages/automated-dispute/src/types/events.ts index 40451d8..5d07d09 100644 --- a/packages/automated-dispute/src/types/events.ts +++ b/packages/automated-dispute/src/types/events.ts @@ -76,5 +76,6 @@ export type EboEvent = { blockNumber: bigint; logIndex: number; rawLog?: Log; + requestId: string; // Field to use to route events to actors metadata: EboEventData; }; diff --git a/packages/automated-dispute/src/types/prophet.ts b/packages/automated-dispute/src/types/prophet.ts index 97195ac..d8564ff 100644 --- a/packages/automated-dispute/src/types/prophet.ts +++ b/packages/automated-dispute/src/types/prophet.ts @@ -2,8 +2,10 @@ import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types.js"; import { Timestamp } from "@ebo-agent/shared"; import { Address } from "viem"; +export type RequestId = string; + export interface Request { - id: string; + id: RequestId; chainId: Caip2ChainId; epoch: bigint; epochTimestamp: Timestamp; @@ -25,7 +27,7 @@ export interface Response { prophetData: Readonly<{ proposer: Address; - requestId: string; + requestId: RequestId; // To be byte-encode when sending it to Prophet response: { @@ -48,6 +50,6 @@ export interface Dispute { disputer: Address; proposer: Address; responseId: string; - requestId: string; + requestId: RequestId; }; } diff --git a/packages/automated-dispute/tests/services/eboProcessor.spec.ts b/packages/automated-dispute/tests/services/eboProcessor.spec.ts new file mode 100644 index 0000000..b64b0fd --- /dev/null +++ b/packages/automated-dispute/tests/services/eboProcessor.spec.ts @@ -0,0 +1,13 @@ +import { describe, it } from "vitest"; + +describe("EboProcessor", () => { + describe.skip("start", () => { + it.skip("bootstraps actors with onchain active requests when starting"); + it.skip("fetches events since epoch start when starting"); + it.skip("fetches events since last block checked after first events fetch"); + it.skip("registers new actor when a new request id is detected on events"); + it.skip("forwards events to corresponding actors"); + it.skip("notifies if an actor throws while handling events"); + it.skip("removes the actor when processing onFinalizeRequest event"); + }); +}); From edaa317c0b88ccee30f8a559d227c99f422e2ff1 Mon Sep 17 00:00:00 2001 From: 0xyaco Date: Fri, 23 Aug 2024 18:14:26 -0300 Subject: [PATCH 08/11] refactor: actor manager create (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 🤖 Linear Part of GRT-58 ## Description * Removes `onTerminate` callback from `EboActor` as this functionality will be handled by the `EboProcessor`. * Allows the `EboActorsManager` to build new actors while registering them inside its registry. --- packages/automated-dispute/src/eboActor.ts | 8 +- .../automated-dispute/src/eboActorsManager.ts | 34 +++++- .../eboActor/onDisputeStatusChanged.spec.ts | 24 ---- .../tests/eboActor/onRequestCreated.spec.ts | 6 - .../tests/eboActor/onRequestFinalized.spec.ts | 21 +--- .../tests/eboActorsManager.spec.ts | 105 +++++++++++++----- .../mocks/{eboActor.ts => eboActor.mocks.ts} | 13 +-- .../automated-dispute/tests/mocks/index.ts | 11 +- .../mocks/{logger.ts => logger.mocks.ts} | 0 9 files changed, 130 insertions(+), 92 deletions(-) rename packages/automated-dispute/tests/mocks/{eboActor.ts => eboActor.mocks.ts} (89%) rename packages/automated-dispute/tests/mocks/{logger.ts => logger.mocks.ts} (100%) diff --git a/packages/automated-dispute/src/eboActor.ts b/packages/automated-dispute/src/eboActor.ts index 050c677..1fc032e 100644 --- a/packages/automated-dispute/src/eboActor.ts +++ b/packages/automated-dispute/src/eboActor.ts @@ -14,8 +14,6 @@ import { ProtocolProvider } from "./protocolProvider.js"; import { EboEvent } from "./types/events.js"; import { Dispute, Request, Response, ResponseBody } from "./types/prophet.js"; -type OnTerminateActorCallback = (request: Request) => Promise; - /** * Actor that handles a singular Prophet's request asking for the block number that corresponds * to an instant on an indexed chain. @@ -39,7 +37,6 @@ export class EboActor { epoch: bigint; epochTimestamp: bigint; }, - private readonly onTerminate: OnTerminateActorCallback, private readonly protocolProvider: ProtocolProvider, private readonly blockNumberService: BlockNumberService, private readonly registry: EboRegistry, @@ -425,8 +422,7 @@ export class EboActor { private async onDisputeEscalated(disputeId: string, request: Request) { // TODO: notify - - await this.onTerminate(request); + this.logger.info(`Dispute ${disputeId} for request ${request.id} has been escalated.`); } private async onDisputeWithNoResolution(disputeId: string, request: Request) { @@ -462,6 +458,6 @@ export class EboActor { const request = this.getActorRequest(); - await this.onTerminate(request); + this.logger.info(`Request ${request.id} has been finalized.`); } } diff --git a/packages/automated-dispute/src/eboActorsManager.ts b/packages/automated-dispute/src/eboActorsManager.ts index fa4fe75..6998f3f 100644 --- a/packages/automated-dispute/src/eboActorsManager.ts +++ b/packages/automated-dispute/src/eboActorsManager.ts @@ -1,5 +1,11 @@ +import { BlockNumberService } from "@ebo-agent/blocknumber"; +import { ILogger } from "@ebo-agent/shared"; + import { EboActor } from "./eboActor.js"; +import { EboMemoryRegistry } from "./eboMemoryRegistry.js"; import { RequestAlreadyHandled } from "./exceptions/index.js"; +import { ProtocolProvider } from "./protocolProvider.js"; +import { RequestId } from "./types/prophet.js"; export class EboActorsManager { private readonly requestActorMap: Map; @@ -9,16 +15,36 @@ export class EboActorsManager { } /** - * Registers the actor and makes it fetchable by the ID of the request the actor is handling. + * Creates and registers the actor by its request ID. * - * @param actor an `EboActor` instance that handles the request + * @param actor an `EboActor` instance that handles a request. */ - public registerActor(actor: EboActor): void { - const requestId = actor.getRequestId(); + public createActor( + actorRequest: { + id: RequestId; + epoch: bigint; + epochTimestamp: bigint; + }, + protocolProvider: ProtocolProvider, + blockNumberService: BlockNumberService, + logger: ILogger, + ): EboActor { + const requestId = actorRequest.id; if (this.requestActorMap.has(requestId)) throw new RequestAlreadyHandled(requestId); + const registry = new EboMemoryRegistry(); + const actor = new EboActor( + actorRequest, + protocolProvider, + blockNumberService, + registry, + logger, + ); + this.requestActorMap.set(requestId, actor); + + return actor; } /** diff --git a/packages/automated-dispute/tests/eboActor/onDisputeStatusChanged.spec.ts b/packages/automated-dispute/tests/eboActor/onDisputeStatusChanged.spec.ts index 559eca5..33a6f6c 100644 --- a/packages/automated-dispute/tests/eboActor/onDisputeStatusChanged.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onDisputeStatusChanged.spec.ts @@ -42,30 +42,6 @@ describe("onDisputeStatusChanged", () => { expect(mockUpdateDisputeStatus).toHaveBeenCalledWith(dispute.id, "Lost"); }); - it("executes the onTerminate callback when dispute has been escalated", async () => { - const dispute = mocks.buildDispute(actorRequest, response, { status: "Won" }); - const event: EboEvent<"DisputeStatusChanged"> = { - name: "DisputeStatusChanged", - blockNumber: 1n, - logIndex: 1, - metadata: { - disputeId: "0x01", - status: "Escalated", - dispute: dispute.prophetData, - blockNumber: 1n, - }, - }; - - const { actor, registry, onTerminate } = mocks.buildEboActor(actorRequest, logger); - - vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); - vi.spyOn(registry, "getDispute").mockReturnValue(dispute); - - await actor.onDisputeStatusChanged(event); - - expect(onTerminate).toHaveBeenCalledWith(actorRequest); - }); - it.skip("notifies when dispute has been escalated"); it("proposes a new response when dispute status goes into NoResolution", async () => { diff --git a/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts b/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts index fc58645..633d99d 100644 --- a/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts @@ -40,8 +40,6 @@ describe("EboActor", () => { }, }; - const onTerminate = vi.fn(); - let protocolProvider: ProtocolProvider; let blockNumberService: BlockNumberService; let registry: EboMemoryRegistry; @@ -85,7 +83,6 @@ describe("EboActor", () => { const actor = new EboActor( requestConfig, - onTerminate, protocolProvider, blockNumberService, registry, @@ -128,7 +125,6 @@ describe("EboActor", () => { const actor = new EboActor( requestConfig, - onTerminate, protocolProvider, blockNumberService, registry, @@ -178,7 +174,6 @@ describe("EboActor", () => { const actor = new EboActor( requestConfig, - onTerminate, protocolProvider, blockNumberService, registry, @@ -201,7 +196,6 @@ describe("EboActor", () => { const actor = new EboActor( requestConfig, - onTerminate, protocolProvider, blockNumberService, registry, diff --git a/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts b/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts index 6955743..0e68732 100644 --- a/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts @@ -24,16 +24,18 @@ describe("EboActor", () => { }, }; - it("executes the actor's callback during termination", async () => { - const { actor, onTerminate, 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); - onTerminate.mockImplementation(() => Promise.resolve()); + const mockInfo = vi.spyOn(logger, "info"); await actor.onRequestFinalized(event); - expect(onTerminate).toHaveBeenCalledWith(actorRequest); + expect(mockInfo).toHaveBeenCalledWith( + expect.stringMatching(`Request ${actorRequest.id} has been finalized.`), + ); }); it("throws if the event's request is not handled by actor", () => { @@ -49,16 +51,5 @@ describe("EboActor", () => { expect(actor.onRequestFinalized(otherRequestEvent)).rejects.toThrow(InvalidActorState); }); - - // The one who defines the callback is responsible for handling callback errors - it("throws if the callback throws", () => { - const { actor, onTerminate } = mocks.buildEboActor(actorRequest, logger); - - onTerminate.mockImplementation(() => { - throw new Error(); - }); - - expect(actor.onRequestFinalized(event)).rejects.toThrow(InvalidActorState); - }); }); }); diff --git a/packages/automated-dispute/tests/eboActorsManager.spec.ts b/packages/automated-dispute/tests/eboActorsManager.spec.ts index a55c261..9a13ac8 100644 --- a/packages/automated-dispute/tests/eboActorsManager.spec.ts +++ b/packages/automated-dispute/tests/eboActorsManager.spec.ts @@ -1,38 +1,89 @@ +import { beforeEach } from "node:test"; +import { BlockNumberService } from "@ebo-agent/blocknumber"; +import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types.js"; import { ILogger } from "@ebo-agent/shared"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { EboActorsManager } from "../src/eboActorsManager.js"; import { RequestAlreadyHandled } from "../src/exceptions/index.js"; -import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./eboActor/fixtures.js"; +import { ProtocolProvider } from "../src/protocolProvider.js"; +import { + DEFAULT_MOCKED_PROTOCOL_CONTRACTS, + DEFAULT_MOCKED_REQUEST_CREATED_DATA, +} from "./eboActor/fixtures.js"; import mocks from "./mocks/index.js"; const logger: ILogger = mocks.mockLogger(); describe("EboActorsManager", () => { - describe("registerActor", () => { - it("registers the actor correctly", () => { - const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; - const { actor } = mocks.buildEboActor(request, logger); + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const actorRequest = { + id: request.id, + epoch: request.epoch, + epochTimestamp: request.epochTimestamp, + }; + const chainId = request.chainId; + + let protocolProvider: ProtocolProvider; + let blockNumberService: BlockNumberService; + + beforeEach(() => { + const protocolProviderRpcUrls = ["http://localhost:8538"]; + protocolProvider = new ProtocolProvider( + protocolProviderRpcUrls, + DEFAULT_MOCKED_PROTOCOL_CONTRACTS, + ); + + const blockNumberRpcUrls = new Map([ + [chainId, ["http://localhost:8539"]], + ]); + blockNumberService = new BlockNumberService(blockNumberRpcUrls, logger); + }); + + describe("createActor", () => { + it("creates the actor", () => { + const actorsManager = new EboActorsManager(); + const actor = actorsManager.createActor( + actorRequest, + protocolProvider, + blockNumberService, + logger, + ); + + expect(actor).toMatchObject({ + actorRequest: expect.objectContaining({ + id: request.id, + epoch: request.epoch, + epochTimestamp: request.epochTimestamp, + }), + }); + }); + + it("registers the actor to be fetchable by id", () => { const actorsManager = new EboActorsManager(); - const mockSetRequestActorMap = vi.spyOn(actorsManager["requestActorMap"], "set"); - actorsManager.registerActor(actor); + expect(actorsManager.getActor(request.id)).toBeUndefined(); + + actorsManager.createActor(actorRequest, protocolProvider, blockNumberService, logger); - expect(mockSetRequestActorMap).toHaveBeenCalledWith(request.id, actor); - expect(actorsManager.getActor(actor.getRequestId())).toBe(actor); + const actor = actorsManager.getActor(request.id); + + expect(actor).toBeDefined(); }); it("throws if the request has already an actor linked to it", () => { - const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; - const { actor: firstActor } = mocks.buildEboActor(request, logger); - const { actor: secondActor } = mocks.buildEboActor(request, logger); const actorsManager = new EboActorsManager(); - actorsManager.registerActor(firstActor); + actorsManager.createActor(actorRequest, protocolProvider, blockNumberService, logger); - expect(() => actorsManager.registerActor(secondActor)).toThrowError( - RequestAlreadyHandled, - ); + expect(() => { + actorsManager.createActor( + actorRequest, + protocolProvider, + blockNumberService, + logger, + ); + }).toThrowError(RequestAlreadyHandled); }); }); @@ -44,25 +95,29 @@ describe("EboActorsManager", () => { }); it("returns the request's linked actor", () => { - const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; - const { actor } = mocks.buildEboActor(request, logger); const actorsManager = new EboActorsManager(); - actorsManager.registerActor(actor); + actorsManager.createActor(actorRequest, protocolProvider, blockNumberService, logger); + + const actor = actorsManager.getActor(request.id); - expect(actorsManager.getActor(request.id)).toBe(actor); + expect(actor).toMatchObject({ + actorRequest: expect.objectContaining({ + id: request.id, + epoch: request.epoch, + epochTimestamp: request.epochTimestamp, + }), + }); }); }); describe("deleteActor", () => { it("deletes the actor linked to the request", () => { - const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; - const { actor } = mocks.buildEboActor(request, logger); const actorsManager = new EboActorsManager(); - actorsManager.registerActor(actor); + actorsManager.createActor(actorRequest, protocolProvider, blockNumberService, logger); - expect(actorsManager.getActor(request.id)).toBe(actor); + expect(actorsManager.getActor(request.id)).toBeDefined(); actorsManager.deleteActor(request.id); diff --git a/packages/automated-dispute/tests/mocks/eboActor.ts b/packages/automated-dispute/tests/mocks/eboActor.mocks.ts similarity index 89% rename from packages/automated-dispute/tests/mocks/eboActor.ts rename to packages/automated-dispute/tests/mocks/eboActor.mocks.ts index 12cfe64..5c7ac0b 100644 --- a/packages/automated-dispute/tests/mocks/eboActor.ts +++ b/packages/automated-dispute/tests/mocks/eboActor.mocks.ts @@ -1,13 +1,12 @@ import { BlockNumberService } from "@ebo-agent/blocknumber"; import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types"; import { ILogger } from "@ebo-agent/shared"; -import { vi } from "vitest"; -import { EboActor } from "../../src/eboActor"; -import { EboMemoryRegistry } from "../../src/eboMemoryRegistry"; -import { ProtocolProvider } from "../../src/protocolProvider"; +import { EboActor } from "../../src/eboActor.js"; +import { EboMemoryRegistry } from "../../src/eboMemoryRegistry.js"; +import { ProtocolProvider } from "../../src/protocolProvider.js"; import { Dispute, Request, Response } from "../../src/types/index.js"; -import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS } from "../eboActor/fixtures"; +import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS } from "../eboActor/fixtures.js"; /** * Builds a base `EboActor` scaffolded with all its dependencies. @@ -19,8 +18,6 @@ import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS } from "../eboActor/fixtures"; export function buildEboActor(request: Request, logger: ILogger) { const { id, chainId, epoch, epochTimestamp } = request; - const onTerminate = vi.fn(); - const protocolProviderRpcUrls = ["http://localhost:8538"]; const protocolProvider = new ProtocolProvider( protocolProviderRpcUrls, @@ -36,7 +33,6 @@ export function buildEboActor(request: Request, logger: ILogger) { const actor = new EboActor( { id, epoch, epochTimestamp }, - onTerminate, protocolProvider, blockNumberService, registry, @@ -45,7 +41,6 @@ export function buildEboActor(request: Request, logger: ILogger) { return { actor, - onTerminate, protocolProvider, blockNumberService, registry, diff --git a/packages/automated-dispute/tests/mocks/index.ts b/packages/automated-dispute/tests/mocks/index.ts index 257bb96..d746fab 100644 --- a/packages/automated-dispute/tests/mocks/index.ts +++ b/packages/automated-dispute/tests/mocks/index.ts @@ -1,4 +1,9 @@ -import { buildDispute, buildEboActor, buildResponse } from "./eboActor.js"; -import { mockLogger } from "./logger.js"; +import { buildDispute, buildEboActor, buildResponse } from "./eboActor.mocks.js"; +import { mockLogger } from "./logger.mocks.js"; -export default { buildEboActor, buildResponse, buildDispute, mockLogger }; +export default { + buildDispute, + buildEboActor, + buildResponse, + mockLogger, +}; diff --git a/packages/automated-dispute/tests/mocks/logger.ts b/packages/automated-dispute/tests/mocks/logger.mocks.ts similarity index 100% rename from packages/automated-dispute/tests/mocks/logger.ts rename to packages/automated-dispute/tests/mocks/logger.mocks.ts From bf4dcac468cf73642d827ca18ee064e087e71a2c Mon Sep 17 00:00:00 2001 From: 0xyaco Date: Fri, 30 Aug 2024 19:26:51 -0300 Subject: [PATCH 09/11] feat: EboProcessor event loop (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 🤖 Linear Part of GRT-58 ## Description Periodic interval inside `EboProcessor` to process new events and forward them to actors. --- package.json | 4 +- packages/automated-dispute/src/eboActor.ts | 49 +- .../automated-dispute/src/eboActorsManager.ts | 17 +- .../src/exceptions/eboProcessor/index.ts | 1 + .../processorAlreadyStarted.exception.ts | 7 + .../automated-dispute/src/exceptions/index.ts | 2 + .../automated-dispute/src/protocolProvider.ts | 6 + .../src/services/eboProcessor.ts | 232 +++++++++- .../automated-dispute/src/templates/index.ts | 13 + .../automated-dispute/src/types/prophet.ts | 4 +- .../tests/eboActor/fixtures.ts | 4 +- .../tests/mocks/eboProcessor.mocks.ts | 32 ++ .../automated-dispute/tests/mocks/index.ts | 2 + .../tests/services/eboProcessor.spec.ts | 422 +++++++++++++++++- packages/shared/src/exceptions/index.ts | 1 + .../shared/src/exceptions/invalidAddress.ts | 5 + packages/shared/src/index.ts | 3 +- packages/shared/src/services/address.ts | 22 + packages/shared/src/services/index.ts | 2 + packages/shared/src/{ => services}/logger.ts | 2 +- packages/shared/src/types/address.ts | 1 + packages/shared/src/types/index.ts | 1 + .../shared/tests/services/address.spec.ts | 50 +++ .../tests/{ => services}/logger.spec.ts | 2 +- pnpm-lock.yaml | 411 ++++++++--------- 25 files changed, 1059 insertions(+), 236 deletions(-) create mode 100644 packages/automated-dispute/src/exceptions/eboProcessor/index.ts create mode 100644 packages/automated-dispute/src/exceptions/eboProcessor/processorAlreadyStarted.exception.ts create mode 100644 packages/automated-dispute/src/templates/index.ts create mode 100644 packages/automated-dispute/tests/mocks/eboProcessor.mocks.ts create mode 100644 packages/shared/src/exceptions/index.ts create mode 100644 packages/shared/src/exceptions/invalidAddress.ts create mode 100644 packages/shared/src/services/address.ts create mode 100644 packages/shared/src/services/index.ts rename packages/shared/src/{ => services}/logger.ts (97%) create mode 100644 packages/shared/src/types/address.ts create mode 100644 packages/shared/tests/services/address.spec.ts rename packages/shared/tests/{ => services}/logger.spec.ts (94%) diff --git a/package.json b/package.json index 31c7f69..6354c6a 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,15 @@ "@types/node": "20.14.12", "@typescript-eslint/eslint-plugin": "7.16.1", "@typescript-eslint/parser": "7.16.1", - "@vitest/coverage-v8": "^2.0.3", + "@vitest/coverage-v8": "2.0.5", "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-prettier": "5.2.1", "husky": "9.1.0", "lint-staged": "15.2.7", "prettier": "3.3.3", - "turbo": "2.0.7", "ts-node": "10.9.2", + "turbo": "2.0.7", "typescript": "5.5.3", "vitest": "2.0.3" }, diff --git a/packages/automated-dispute/src/eboActor.ts b/packages/automated-dispute/src/eboActor.ts index 1fc032e..e0939e6 100644 --- a/packages/automated-dispute/src/eboActor.ts +++ b/packages/automated-dispute/src/eboActor.ts @@ -11,7 +11,7 @@ import { } from "./exceptions/index.js"; import { EboRegistry } from "./interfaces/eboRegistry.js"; import { ProtocolProvider } from "./protocolProvider.js"; -import { EboEvent } from "./types/events.js"; +import { EboEvent, EboEventName } from "./types/events.js"; import { Dispute, Request, Response, ResponseBody } from "./types/prophet.js"; /** @@ -44,12 +44,51 @@ export class EboActor { ) {} /** - * Get the request ID this actor is handling. + * Update internal state for Request, Response and Dispute instances. * - * @returns request ID + * @param _event EBO event */ - public getRequestId(): string { - return this.actorRequest.id; + public updateState(_event: EboEvent) { + // TODO + throw new Error("Implement me"); + } + + /** + * Handle a new event and triggers reactive interactions with smart contracts. + * + * A basic example would be reacting to a new request by proposing a response. + * + * @param _event EBO event + */ + public onNewEvent(_event: EboEvent) { + // TODO + throw new Error("Implement me"); + } + + /** + * Triggers time-based interactions with smart contracts. This handles window-based + * checks like proposal windows to close requests, or dispute windows to accept responses. + * + * @param _blockNumber block number to check open/closed windows + */ + public onLastBlockUpdated(_blockNumber: bigint) { + // TODO + throw new Error("Implement me"); + } + + /** + * Check for all entities to be settled (ie their status is not changeable by this system), e.g.: + * * Dispute status is `Lost`, `Won` or `NoResolution` + * * Response cannot be disputed anymore and its disputes have been settled + * * Request has at least one accepted response + * + * Be aware that a request can be finalized but some of its disputes can still be pending resolution. + * + * @returns `true` if all entities are settled, otherwise `false` + */ + public canBeTerminated(): boolean { + // TODO + throw new Error("Implement me"); } /** diff --git a/packages/automated-dispute/src/eboActorsManager.ts b/packages/automated-dispute/src/eboActorsManager.ts index 6998f3f..f3e1833 100644 --- a/packages/automated-dispute/src/eboActorsManager.ts +++ b/packages/automated-dispute/src/eboActorsManager.ts @@ -1,5 +1,5 @@ import { BlockNumberService } from "@ebo-agent/blocknumber"; -import { ILogger } from "@ebo-agent/shared"; +import { Address, ILogger } from "@ebo-agent/shared"; import { EboActor } from "./eboActor.js"; import { EboMemoryRegistry } from "./eboMemoryRegistry.js"; @@ -8,12 +8,21 @@ import { ProtocolProvider } from "./protocolProvider.js"; import { RequestId } from "./types/prophet.js"; export class EboActorsManager { - private readonly requestActorMap: Map; + private readonly requestActorMap: Map; constructor() { this.requestActorMap = new Map(); } + /** + * Return an array of normalized request IDs this instance is handling. + * + * @returns array of normalized request IDs + */ + public getRequestIds(): RequestId[] { + return [...this.requestActorMap.entries()].map((entry) => Address.normalize(entry[0])); + } + /** * Creates and registers the actor by its request ID. * @@ -53,7 +62,7 @@ export class EboActorsManager { * @param requestId request ID * @returns an `EboActor` instance if found by `requestId`, otherwise `undefined` */ - public getActor(requestId: string): EboActor | undefined { + public getActor(requestId: RequestId): EboActor | undefined { return this.requestActorMap.get(requestId); } @@ -63,7 +72,7 @@ export class EboActorsManager { * @param requestId request ID * @returns `true` if there was a linked actor for the request ID and it was removed, or `false` if the request was not linked to any actor. */ - public deleteActor(requestId: string): boolean { + public deleteActor(requestId: RequestId): boolean { return this.requestActorMap.delete(requestId); } } diff --git a/packages/automated-dispute/src/exceptions/eboProcessor/index.ts b/packages/automated-dispute/src/exceptions/eboProcessor/index.ts new file mode 100644 index 0000000..ee485fe --- /dev/null +++ b/packages/automated-dispute/src/exceptions/eboProcessor/index.ts @@ -0,0 +1 @@ +export * from "./processorAlreadyStarted.exception.js"; diff --git a/packages/automated-dispute/src/exceptions/eboProcessor/processorAlreadyStarted.exception.ts b/packages/automated-dispute/src/exceptions/eboProcessor/processorAlreadyStarted.exception.ts new file mode 100644 index 0000000..1dcce92 --- /dev/null +++ b/packages/automated-dispute/src/exceptions/eboProcessor/processorAlreadyStarted.exception.ts @@ -0,0 +1,7 @@ +export class ProcessorAlreadyStarted extends Error { + constructor() { + super(`Processor was already started.`); + + this.name = "ProcessorAlreadyStarted"; + } +} diff --git a/packages/automated-dispute/src/exceptions/index.ts b/packages/automated-dispute/src/exceptions/index.ts index 840498a..7a3d0ac 100644 --- a/packages/automated-dispute/src/exceptions/index.ts +++ b/packages/automated-dispute/src/exceptions/index.ts @@ -1,3 +1,5 @@ +export * from "./eboProcessor/index.js"; + export * from "./invalidActorState.exception.js"; export * from "./invalidDisputeStatus.exception.js"; export * from "./requestAlreadyHandled.exception.js"; diff --git a/packages/automated-dispute/src/protocolProvider.ts b/packages/automated-dispute/src/protocolProvider.ts index 7d35550..a9afd87 100644 --- a/packages/automated-dispute/src/protocolProvider.ts +++ b/packages/automated-dispute/src/protocolProvider.ts @@ -80,6 +80,12 @@ export class ProtocolProvider { }; } + async getLastFinalizedBlock(): Promise { + const { number } = await this.client.getBlock({ blockTag: "finalized" }); + + return number; + } + async getEvents(_fromBlock: bigint, _toBlock: bigint): Promise[]> { // TODO: implement actual method. // diff --git a/packages/automated-dispute/src/services/eboProcessor.ts b/packages/automated-dispute/src/services/eboProcessor.ts index da0e6a3..21059e4 100644 --- a/packages/automated-dispute/src/services/eboProcessor.ts +++ b/packages/automated-dispute/src/services/eboProcessor.ts @@ -1,33 +1,251 @@ -import { ILogger } from "@ebo-agent/shared"; +import { BlockNumberService } from "@ebo-agent/blocknumber"; +import { Address, ILogger } from "@ebo-agent/shared"; import { EboActor } from "../eboActor.js"; import { EboActorsManager } from "../eboActorsManager.js"; +import { ProcessorAlreadyStarted } from "../exceptions/index.js"; import { ProtocolProvider } from "../protocolProvider.js"; +import { alreadyDeletedActorWarning, droppingUnhandledEventsWarning } from "../templates/index.js"; +import { EboEvent, EboEventName } from "../types/events.js"; +import { RequestId } from "../types/prophet.js"; const DEFAULT_MS_BETWEEN_CHECKS = 10 * 60 * 1000; // 10 minutes +type EboEventStream = EboEvent[]; + export class EboProcessor { private eventsInterval?: NodeJS.Timeout; private lastCheckedBlock?: bigint; constructor( private readonly protocolProvider: ProtocolProvider, + private readonly blockNumberService: BlockNumberService, private readonly actorsManager: EboActorsManager, private readonly logger: ILogger, ) {} + /** + * Start syncing blocks and events + * + * @param msBetweenChecks milliseconds between each sync + */ public async start(msBetweenChecks: number = DEFAULT_MS_BETWEEN_CHECKS) { - this.bootstrap(); // Bootstrapping + if (this.eventsInterval) throw new ProcessorAlreadyStarted(); + + await this.sync(); // Bootstrapping + + this.eventsInterval = setInterval(async () => { + try { + await this.sync(); + } catch (err) { + this.logger.error(`Unhandled error during the event loop: ${err}`); + + clearInterval(this.eventsInterval); - this.eventsInterval = setInterval(this.eventLoop, msBetweenChecks); + throw err; + } + }, msBetweenChecks); } - private async bootstrap() { - // TODO + /** Sync new blocks and their events with their corresponding actors. */ + private async sync() { + // TODO: detect new epoch by comparing subgraph's data with EpochManager's current epoch + // and trigger a request creation. + + if (!this.lastCheckedBlock) { + this.lastCheckedBlock = await this.getEpochStartBlock(); + } + + const lastBlock = await this.protocolProvider.getLastFinalizedBlock(); + const events = await this.protocolProvider.getEvents(this.lastCheckedBlock, lastBlock); + const eventsByRequestId = this.groupEventsByRequest(events); + + const synchableRequests = this.calculateSynchableRequests([...eventsByRequestId.keys()]); + const synchedRequests = [...synchableRequests].map(async (requestId: RequestId) => { + try { + const events = eventsByRequestId.get(requestId) ?? []; + + await this.syncRequest(requestId, events, lastBlock); + } catch (err) { + // FIXME: to avoid one request bringing down the whole agent if an error is thrown, + // the failing request's actor (if any) will be silently removed. + // + // On the enhancements phase, the processor will try to recover that particular actor, + // if possible, by recreating the actor again and trying to handle all request events. + // + // Consider also the possibility to use Promise.allSettled per nigiri's suggestion. + this.logger.error(`Handling events for ${requestId} caused an error: ${err}`); + + // TODO: notify + + this.actorsManager.deleteActor(requestId); + } + }); + + await Promise.all(synchedRequests); + + this.lastCheckedBlock = lastBlock; } - private async eventLoop() { - // TODO + /** + * Fetches the first block of the current epoch. + * + * @returns the first block of the current epoch + */ + private async getEpochStartBlock() { + const { currentEpochBlockNumber } = await this.protocolProvider.getCurrentEpoch(); + + return currentEpochBlockNumber; + } + + /** + * Group events by its normalized request ID. + * . + * + * @param events a raw stream of events for, potentially, several requests + * @returns a map with normalized request ID as a key and an array of the request's events as value. + */ + private groupEventsByRequest(events: EboEventStream) { + const groupedEvents = new Map(); + + for (const event of events) { + const requestId = Address.normalize(event.requestId); + const requestEvents = groupedEvents.get(requestId) || []; + + groupedEvents.set(requestId, [...requestEvents, event]); + } + + return groupedEvents; + } + + /** + * Calculate the request IDs that should be considered for sync by merging the + * request IDs read from events and the request IDs already being handled by an actor. + * + * @param eventsRequestIds request IDs observed in an events batch + * @returns request IDS to sync + */ + private calculateSynchableRequests(eventsRequestIds: RequestId[]) { + const actorsRequestIds = this.actorsManager.getRequestIds(); + const uniqueRequestIds = new Set([...eventsRequestIds, ...actorsRequestIds]); + + return [...uniqueRequestIds].map((requestId) => Address.normalize(requestId)); + } + + /** + * Sync the actor with new events and update the state based on the last block. + * + * @param requestId the ID of the `Request` + * @param events a stream of consumed events + * @param lastBlock the last block checked + */ + private async syncRequest(requestId: RequestId, events: EboEventStream, lastBlock: bigint) { + const firstEvent = events[0]; + const actor = await this.getOrCreateActor(requestId, firstEvent); + + if (!actor) { + this.logger.warn(droppingUnhandledEventsWarning(requestId)); + + return; + } + + const sortedEvents = events.sort(this.compareByBlockAndLogIndex); + + sortedEvents.forEach((event, idx) => { + // NOTE: forEach preserves events' order, DO NOT use a for loop + actor.updateState(event); + + const isLastEvent = idx === events.length - 1; + if (isLastEvent) actor.onNewEvent(event); + }); + + actor.onLastBlockUpdated(lastBlock); + + if (actor.canBeTerminated()) { + this.terminateActor(requestId); + } + } + + /** + * Compare function to sort events chronologically in ascending order by block number + * and log index. + * + * @param e1 EBO event + * @param e2 EBO event + * @returns 1 if `e2` is older than `e1`, -1 if `e1` is older than `e2`, 0 otherwise + */ + private compareByBlockAndLogIndex(e1: EboEvent, e2: EboEvent) { + if (e1.blockNumber > e2.blockNumber) return 1; + if (e1.blockNumber < e2.blockNumber) return -1; + + return e1.logIndex - e2.logIndex; + } + + /** + * Get the actor handling a specific request. If there's no actor created yet, it's created. + * + * @param requestId the ID of the request the returned actor is handling + * @param firstEvent an event to create an actor if it does not exist + * @returns the actor handling the specified request + */ + private async getOrCreateActor(requestId: RequestId, firstEvent?: EboEvent) { + const actor = this.actorsManager.getActor(requestId); + + if (actor) return actor; + + if (firstEvent && firstEvent.name === "RequestCreated") { + this.logger.info(`Creating a new EboActor to handle request ${requestId}...`); + + return this.createNewActor(firstEvent as EboEvent<"RequestCreated">); + } else { + return null; + } + } + + /** + * Create a new actor based on the data provided by a `RequestCreated` event. + * + * @param event a `RequestCreated` event + * @returns a new `EboActor` instance + */ + private async createNewActor(event: EboEvent<"RequestCreated">) { + // FIXME: this is one of the places where we should change + // the processor's behavior if we want to support non-current epochs + const { currentEpochTimestamp } = await this.protocolProvider.getCurrentEpoch(); + + const actorRequest = { + id: Address.normalize(event.requestId), + epoch: event.metadata.epoch, + epochTimestamp: currentEpochTimestamp, + }; + + const actor = this.actorsManager.createActor( + actorRequest, + this.protocolProvider, + this.blockNumberService, + this.logger, + ); + + return actor; + } + + /** + * Removes the actor from tracking the request. + * + * @param requestId the ID of the request the actor is handling + */ + private terminateActor(requestId: RequestId) { + this.logger.info(`Terminating actor handling request ${requestId}...`); + + const deletedActor = this.actorsManager.deleteActor(requestId); + + if (deletedActor) { + this.logger.info(`Actor handling request ${requestId} was terminated.`); + } else { + this.logger.warn(alreadyDeletedActorWarning(requestId)); + + // TODO: notify + } } private async onActorError(_actor: EboActor, _error: Error) { diff --git a/packages/automated-dispute/src/templates/index.ts b/packages/automated-dispute/src/templates/index.ts new file mode 100644 index 0000000..b3e63b2 --- /dev/null +++ b/packages/automated-dispute/src/templates/index.ts @@ -0,0 +1,13 @@ +import { RequestId } from "../types/prophet.js"; + +export const alreadyDeletedActorWarning = (requestId: RequestId) => ` +Actor handling request ${requestId} was already deleted. + +It is strongly suggested to check request status on-chain to be sure its responses and disputes have been correctly settled., +`; + +export const droppingUnhandledEventsWarning = (requestId: RequestId) => ` +Dropping events for request ${requestId} because no actor is handling it and the first event the agent read is not a \`RequestCreated\` event. + +The request likely started before the current epoch's first block, which will not be handled by the agent. +`; diff --git a/packages/automated-dispute/src/types/prophet.ts b/packages/automated-dispute/src/types/prophet.ts index d8564ff..3d330ce 100644 --- a/packages/automated-dispute/src/types/prophet.ts +++ b/packages/automated-dispute/src/types/prophet.ts @@ -1,8 +1,8 @@ import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types.js"; -import { Timestamp } from "@ebo-agent/shared"; +import { NormalizedAddress, Timestamp } from "@ebo-agent/shared"; import { Address } from "viem"; -export type RequestId = string; +export type RequestId = NormalizedAddress; export interface Request { id: RequestId; diff --git a/packages/automated-dispute/tests/eboActor/fixtures.ts b/packages/automated-dispute/tests/eboActor/fixtures.ts index 70a08d1..12ec0fc 100644 --- a/packages/automated-dispute/tests/eboActor/fixtures.ts +++ b/packages/automated-dispute/tests/eboActor/fixtures.ts @@ -1,6 +1,6 @@ import { Address } from "viem"; -import { Request } from "../../src/types/prophet"; +import { Request, RequestId } from "../../src/types/prophet"; export const DEFAULT_MOCKED_PROTOCOL_CONTRACTS = { oracle: "0x123456" as Address, @@ -8,7 +8,7 @@ export const DEFAULT_MOCKED_PROTOCOL_CONTRACTS = { }; export const DEFAULT_MOCKED_REQUEST_CREATED_DATA: Request = { - id: "0x01", + id: "0x01" as RequestId, chainId: "eip155:1", epoch: 1n, epochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), diff --git a/packages/automated-dispute/tests/mocks/eboProcessor.mocks.ts b/packages/automated-dispute/tests/mocks/eboProcessor.mocks.ts new file mode 100644 index 0000000..e1191dd --- /dev/null +++ b/packages/automated-dispute/tests/mocks/eboProcessor.mocks.ts @@ -0,0 +1,32 @@ +import { BlockNumberService } from "@ebo-agent/blocknumber"; +import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types"; +import { ILogger } from "@ebo-agent/shared"; + +import { EboActorsManager } from "../../src/eboActorsManager"; +import { ProtocolProvider } from "../../src/protocolProvider"; +import { EboProcessor } from "../../src/services"; +import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS } from "../eboActor/fixtures"; + +export function buildEboProcessor(logger: ILogger) { + const protocolProviderRpcUrls = ["http://localhost:8538"]; + const protocolProvider = new ProtocolProvider( + protocolProviderRpcUrls, + DEFAULT_MOCKED_PROTOCOL_CONTRACTS, + ); + + const blockNumberRpcUrls = new Map([ + ["eip155:1" as Caip2ChainId, ["http://localhost:8539"]], + ]); + const blockNumberService = new BlockNumberService(blockNumberRpcUrls, logger); + + const actorsManager = new EboActorsManager(); + const processor = new EboProcessor(protocolProvider, blockNumberService, actorsManager, logger); + + return { + processor, + protocolProvider, + blockNumberService, + actorsManager, + logger, + }; +} diff --git a/packages/automated-dispute/tests/mocks/index.ts b/packages/automated-dispute/tests/mocks/index.ts index d746fab..5274342 100644 --- a/packages/automated-dispute/tests/mocks/index.ts +++ b/packages/automated-dispute/tests/mocks/index.ts @@ -1,9 +1,11 @@ import { buildDispute, buildEboActor, buildResponse } from "./eboActor.mocks.js"; +import { buildEboProcessor } from "./eboProcessor.mocks.js"; import { mockLogger } from "./logger.mocks.js"; export default { buildDispute, buildEboActor, + buildEboProcessor, buildResponse, mockLogger, }; diff --git a/packages/automated-dispute/tests/services/eboProcessor.spec.ts b/packages/automated-dispute/tests/services/eboProcessor.spec.ts index b64b0fd..ae0dfc7 100644 --- a/packages/automated-dispute/tests/services/eboProcessor.spec.ts +++ b/packages/automated-dispute/tests/services/eboProcessor.spec.ts @@ -1,13 +1,419 @@ -import { describe, it } from "vitest"; +import { BlockNumberService } from "@ebo-agent/blocknumber"; +import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ProcessorAlreadyStarted } from "../../src/exceptions/index.js"; +import { ProtocolProvider } from "../../src/protocolProvider.js"; +import { EboEvent, EboEventName } from "../../src/types/events.js"; +import { RequestId } from "../../src/types/prophet.js"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "../eboActor/fixtures.js"; +import mocks from "../mocks/index.js"; + +const logger = mocks.mockLogger(); +const msBetweenChecks = 1; describe("EboProcessor", () => { - describe.skip("start", () => { - it.skip("bootstraps actors with onchain active requests when starting"); - it.skip("fetches events since epoch start when starting"); - it.skip("fetches events since last block checked after first events fetch"); - it.skip("registers new actor when a new request id is detected on events"); - it.skip("forwards events to corresponding actors"); + describe("start", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("bootstraps actors with onchain active requests when starting", async () => { + const { processor, actorsManager, protocolProvider } = mocks.buildEboProcessor(logger); + + const currentEpoch = { + currentEpoch: 1n, + currentEpochBlockNumber: 1n, + currentEpochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), + }; + + const requestCreatedEvent: EboEvent<"RequestCreated"> = { + name: "RequestCreated", + blockNumber: 1n, + logIndex: 1, + requestId: DEFAULT_MOCKED_REQUEST_CREATED_DATA.id, + metadata: { + requestId: DEFAULT_MOCKED_REQUEST_CREATED_DATA.id, + epoch: DEFAULT_MOCKED_REQUEST_CREATED_DATA.epoch, + chainId: DEFAULT_MOCKED_REQUEST_CREATED_DATA.chainId, + request: DEFAULT_MOCKED_REQUEST_CREATED_DATA["prophetData"], + }, + }; + + vi.spyOn(protocolProvider, "getCurrentEpoch").mockResolvedValue(currentEpoch); + vi.spyOn(protocolProvider, "getLastFinalizedBlock").mockResolvedValue( + currentEpoch.currentEpochBlockNumber + 10n, + ); + vi.spyOn(protocolProvider, "getEvents").mockResolvedValue([requestCreatedEvent]); + + const mockCreateActor = vi.spyOn(actorsManager, "createActor"); + + await processor.start(msBetweenChecks); + + const expectedNewActor = expect.objectContaining({ + id: requestCreatedEvent.requestId, + epoch: currentEpoch.currentEpoch, + epochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), + }); + + expect(mockCreateActor).toHaveBeenCalledWith( + expectedNewActor, + expect.any(ProtocolProvider), + expect.any(BlockNumberService), + logger, + ); + }); + + it("throws if called more than once", async () => { + const { processor, protocolProvider } = mocks.buildEboProcessor(logger); + + const currentEpoch = { + currentEpoch: 1n, + currentEpochBlockNumber: 1n, + currentEpochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), + }; + + vi.spyOn(protocolProvider, "getCurrentEpoch").mockResolvedValue(currentEpoch); + vi.spyOn(protocolProvider, "getLastFinalizedBlock").mockResolvedValue( + currentEpoch.currentEpochBlockNumber + 10n, + ); + vi.spyOn(protocolProvider, "getEvents").mockResolvedValue([]); + + await processor.start(1); + expect(processor.start(1)).rejects.toThrow(ProcessorAlreadyStarted); + }); + + it("fetches events since epoch start when starting", async () => { + const { processor, protocolProvider } = mocks.buildEboProcessor(logger); + + const currentEpoch = { + currentEpoch: 1n, + currentEpochBlockNumber: 1n, + currentEpochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), + }; + + const currentBlock = currentEpoch.currentEpochBlockNumber + 10n; + + const requestCreatedEvent: EboEvent<"RequestCreated"> = { + name: "RequestCreated", + blockNumber: 1n, + logIndex: 1, + requestId: DEFAULT_MOCKED_REQUEST_CREATED_DATA.id, + metadata: { + requestId: DEFAULT_MOCKED_REQUEST_CREATED_DATA.id, + epoch: DEFAULT_MOCKED_REQUEST_CREATED_DATA.epoch, + chainId: DEFAULT_MOCKED_REQUEST_CREATED_DATA.chainId, + request: DEFAULT_MOCKED_REQUEST_CREATED_DATA["prophetData"], + }, + }; + + vi.spyOn(protocolProvider, "getCurrentEpoch").mockResolvedValue(currentEpoch); + vi.spyOn(protocolProvider, "getLastFinalizedBlock").mockResolvedValue(currentBlock); + + const mockGetEvents = vi.spyOn(protocolProvider, "getEvents"); + mockGetEvents.mockResolvedValue([requestCreatedEvent]); + + await processor.start(msBetweenChecks); + + expect(mockGetEvents).toHaveBeenCalledWith( + currentEpoch.currentEpochBlockNumber, + currentBlock, + ); + }); + + it("fetches events since last block checked after first events fetch", async () => { + const { processor, protocolProvider } = mocks.buildEboProcessor(logger); + + const mockLastCheckedBlock = 5n; + processor["lastCheckedBlock"] = mockLastCheckedBlock; + + const currentEpoch = { + currentEpoch: 1n, + currentEpochBlockNumber: 1n, + currentEpochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), + }; + + const currentBlock = currentEpoch.currentEpochBlockNumber + 10n; + + const requestCreatedEvent: EboEvent<"RequestCreated"> = { + name: "RequestCreated", + blockNumber: 6n, + logIndex: 1, + requestId: DEFAULT_MOCKED_REQUEST_CREATED_DATA.id, + metadata: { + requestId: DEFAULT_MOCKED_REQUEST_CREATED_DATA.id, + epoch: DEFAULT_MOCKED_REQUEST_CREATED_DATA.epoch, + chainId: DEFAULT_MOCKED_REQUEST_CREATED_DATA.chainId, + request: DEFAULT_MOCKED_REQUEST_CREATED_DATA["prophetData"], + }, + }; + + vi.spyOn(protocolProvider, "getCurrentEpoch").mockResolvedValue(currentEpoch); + vi.spyOn(protocolProvider, "getLastFinalizedBlock").mockResolvedValue(currentBlock); + + const mockGetEvents = vi.spyOn(protocolProvider, "getEvents"); + mockGetEvents.mockResolvedValue([requestCreatedEvent]); + + processor["lastCheckedBlock"] = mockLastCheckedBlock; + + await processor.start(msBetweenChecks); + + expect(mockGetEvents).toHaveBeenCalledWith(mockLastCheckedBlock, currentBlock); + }); + + it("causes actor to execute RPCs only during the last event", async () => { + const { processor, protocolProvider, actorsManager } = mocks.buildEboProcessor(logger); + + const currentEpoch = { + currentEpoch: 1n, + currentEpochBlockNumber: 1n, + currentEpochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), + }; + + const currentBlock = currentEpoch.currentEpochBlockNumber + 10n; + + vi.spyOn(protocolProvider, "getCurrentEpoch").mockResolvedValue(currentEpoch); + vi.spyOn(protocolProvider, "getLastFinalizedBlock").mockResolvedValue(currentBlock); + + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const response = mocks.buildResponse(request); + + const eventStream: EboEvent[] = [ + { + name: "RequestCreated", + blockNumber: 6n, + logIndex: 1, + requestId: request.id, + metadata: { + requestId: request.id, + epoch: request.epoch, + chainId: request.chainId, + request: request["prophetData"], + }, + }, + { + name: "ResponseProposed", + blockNumber: 7n, + logIndex: 1, + requestId: request.id, + metadata: { + requestId: request.id, + responseId: response.id, + response: response.prophetData, + }, + }, + ]; + + vi.spyOn(protocolProvider, "getEvents").mockResolvedValue(eventStream); + + const { actor } = mocks.buildEboActor(request, logger); + + const mockActorUpdateState = vi.spyOn(actor, "updateState"); + const mockActorOnNewEvent = vi.spyOn(actor, "onNewEvent"); + + mockActorUpdateState.mockImplementation(() => {}); + mockActorOnNewEvent.mockImplementation(() => {}); + + vi.spyOn(actor, "onLastBlockUpdated").mockImplementation(() => {}); + + vi.spyOn(actorsManager, "createActor").mockResolvedValue(actor); + vi.spyOn(actorsManager, "getActor").mockResolvedValue(actor); + + await processor.start(msBetweenChecks); + + expect(mockActorUpdateState).toHaveBeenCalledTimes(eventStream.length); + expect(mockActorOnNewEvent).toHaveBeenCalledOnce(); + }); + + it("forwards events in block and log index order", async () => { + const { processor, protocolProvider, actorsManager } = mocks.buildEboProcessor(logger); + + const currentEpoch = { + currentEpoch: 1n, + currentEpochBlockNumber: 1n, + currentEpochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), + }; + + const currentBlock = currentEpoch.currentEpochBlockNumber + 10n; + + vi.spyOn(protocolProvider, "getCurrentEpoch").mockResolvedValue(currentEpoch); + vi.spyOn(protocolProvider, "getLastFinalizedBlock").mockResolvedValue(currentBlock); + + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const response = mocks.buildResponse(request); + + const eventStream: EboEvent[] = [ + { + name: "ResponseProposed", + blockNumber: 7n, + logIndex: 1, + requestId: request.id, + metadata: { + requestId: request.id, + responseId: response.id, + response: response.prophetData, + }, + }, + { + name: "RequestCreated", + blockNumber: 6n, + logIndex: 1, + requestId: request.id, + metadata: { + requestId: request.id, + epoch: request.epoch, + chainId: request.chainId, + request: request["prophetData"], + }, + }, + ]; + + vi.spyOn(protocolProvider, "getEvents").mockResolvedValue(eventStream); + + const { actor } = mocks.buildEboActor(request, logger); + + const mockActorUpdateState = vi.spyOn(actor, "updateState"); + + mockActorUpdateState.mockImplementation(() => {}); + + vi.spyOn(actor, "onNewEvent").mockImplementation(() => {}); + vi.spyOn(actor, "onLastBlockUpdated").mockImplementation(() => {}); + + vi.spyOn(actorsManager, "createActor").mockResolvedValue(actor); + vi.spyOn(actorsManager, "getActor").mockResolvedValue(actor); + + await processor.start(msBetweenChecks); + + expect(mockActorUpdateState).toHaveBeenNthCalledWith(1, eventStream[1]); + expect(mockActorUpdateState).toHaveBeenNthCalledWith(2, eventStream[0]); + }); + + it("forwards events to corresponding actors", async () => { + const { processor, protocolProvider, actorsManager } = mocks.buildEboProcessor(logger); + + const currentEpoch = { + currentEpoch: 1n, + currentEpochBlockNumber: 1n, + currentEpochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), + }; + + const currentBlock = currentEpoch.currentEpochBlockNumber + 10n; + + vi.spyOn(protocolProvider, "getCurrentEpoch").mockResolvedValue(currentEpoch); + vi.spyOn(protocolProvider, "getLastFinalizedBlock").mockResolvedValue(currentBlock); + + const request1 = { + ...DEFAULT_MOCKED_REQUEST_CREATED_DATA, + id: "0x01" as RequestId, + chainId: "eip155:1" as Caip2ChainId, + }; + const response1 = mocks.buildResponse(request1); + + const request2 = { + ...DEFAULT_MOCKED_REQUEST_CREATED_DATA, + id: "0x02" as RequestId, + chainId: "eip155:17" as Caip2ChainId, + }; + const response2 = mocks.buildResponse(request2); + + const eventStream: EboEvent[] = [ + { + name: "ResponseProposed", + blockNumber: 7n, + logIndex: 1, + requestId: request1.id, + metadata: { + requestId: request1.id, + responseId: response1.id, + response: response1.prophetData, + }, + }, + { + name: "ResponseProposed", + blockNumber: 7n, + logIndex: 2, + requestId: request2.id, + metadata: { + requestId: request2.id, + responseId: response2.id, + response: response2.prophetData, + }, + }, + ]; + + vi.spyOn(protocolProvider, "getEvents").mockResolvedValue(eventStream); + + const { actor: actor1 } = mocks.buildEboActor(request1, logger); + const { actor: actor2 } = mocks.buildEboActor(request1, logger); + + const mockActor1UpdateState = vi.spyOn(actor1, "updateState"); + const mockActor2UpdateState = vi.spyOn(actor2, "updateState"); + + mockActor1UpdateState.mockImplementation(() => {}); + mockActor2UpdateState.mockImplementation(() => {}); + + vi.spyOn(actor1, "onNewEvent").mockImplementation(() => {}); + vi.spyOn(actor1, "onLastBlockUpdated").mockImplementation(() => {}); + vi.spyOn(actor2, "onNewEvent").mockImplementation(() => {}); + vi.spyOn(actor2, "onLastBlockUpdated").mockImplementation(() => {}); + + vi.spyOn(actorsManager, "getActor").mockImplementation((requestId: RequestId) => { + switch (requestId) { + case request1.id: + return actor1; + + case request2.id: + return actor2; + + default: + return undefined; + } + }); + + await processor.start(msBetweenChecks); + + expect(mockActor1UpdateState).toHaveBeenCalledWith(eventStream[0]); + expect(mockActor2UpdateState).toHaveBeenCalledWith(eventStream[1]); + }); + it.skip("notifies if an actor throws while handling events"); - it.skip("removes the actor when processing onFinalizeRequest event"); + + it("removes the actor from registry when terminating", async () => { + const { processor, protocolProvider, actorsManager } = mocks.buildEboProcessor(logger); + + const currentEpoch = { + currentEpoch: 1n, + currentEpochBlockNumber: 1n, + currentEpochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), + }; + + const currentBlock = currentEpoch.currentEpochBlockNumber + 10n; + + vi.spyOn(protocolProvider, "getCurrentEpoch").mockResolvedValue(currentEpoch); + vi.spyOn(protocolProvider, "getLastFinalizedBlock").mockResolvedValue(currentBlock); + + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + + vi.spyOn(protocolProvider, "getEvents").mockResolvedValue([]); + + const { actor } = mocks.buildEboActor(request, logger); + + vi.spyOn(actor, "onLastBlockUpdated").mockImplementation(() => {}); + vi.spyOn(actor, "canBeTerminated").mockReturnValue(true); + + vi.spyOn(actorsManager, "createActor").mockResolvedValue(actor); + vi.spyOn(actorsManager, "getActor").mockResolvedValue(actor); + vi.spyOn(actorsManager, "getRequestIds").mockReturnValue([request.id]); + + const mockActorManagerDeleteActor = vi.spyOn(actorsManager, "deleteActor"); + + await processor.start(msBetweenChecks); + + expect(mockActorManagerDeleteActor).toHaveBeenCalledWith(request.id); + }); }); }); diff --git a/packages/shared/src/exceptions/index.ts b/packages/shared/src/exceptions/index.ts new file mode 100644 index 0000000..54c427c --- /dev/null +++ b/packages/shared/src/exceptions/index.ts @@ -0,0 +1 @@ +export * from "./invalidAddress.js"; diff --git a/packages/shared/src/exceptions/invalidAddress.ts b/packages/shared/src/exceptions/invalidAddress.ts new file mode 100644 index 0000000..8013a6f --- /dev/null +++ b/packages/shared/src/exceptions/invalidAddress.ts @@ -0,0 +1,5 @@ +export class InvalidAddress extends Error { + constructor(str: string) { + super(`Invalid address: ${str}`); + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7f3d504..90dde44 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from "./constants.js"; -export * from "./logger.js"; +export * from "./exceptions/index.js"; +export * from "./services/index.js"; export * from "./types/index.js"; diff --git a/packages/shared/src/services/address.ts b/packages/shared/src/services/address.ts new file mode 100644 index 0000000..6e3db95 --- /dev/null +++ b/packages/shared/src/services/address.ts @@ -0,0 +1,22 @@ +import { InvalidAddress } from "../exceptions/index.js"; +import { NormalizedAddress } from "../types/index.js"; + +const ADDRESS_REGEX = /^0x[A-Fa-f0-9]+$/; + +export class Address { + public static normalize(address: string): NormalizedAddress { + if (!ADDRESS_REGEX.test(address)) throw new InvalidAddress(address); + + return address.toLowerCase() as NormalizedAddress; + } + + public static isNormalized(address: string): boolean { + try { + return address == Address.normalize(address); + } catch (err) { + if (err instanceof InvalidAddress) return false; + + throw err; + } + } +} diff --git a/packages/shared/src/services/index.ts b/packages/shared/src/services/index.ts new file mode 100644 index 0000000..8ec1389 --- /dev/null +++ b/packages/shared/src/services/index.ts @@ -0,0 +1,2 @@ +export * from "./address.js"; +export * from "./logger.js"; diff --git a/packages/shared/src/logger.ts b/packages/shared/src/services/logger.ts similarity index 97% rename from packages/shared/src/logger.ts rename to packages/shared/src/services/logger.ts index 57cd9ad..5457eee 100644 --- a/packages/shared/src/logger.ts +++ b/packages/shared/src/services/logger.ts @@ -1,6 +1,6 @@ import { createLogger, format, transports, Logger as WinstonLogger } from "winston"; -import { ILogger } from "./index.js"; +import { ILogger } from "../index.js"; type LogLevel = "error" | "warn" | "info" | "debug"; diff --git a/packages/shared/src/types/address.ts b/packages/shared/src/types/address.ts new file mode 100644 index 0000000..7055ff0 --- /dev/null +++ b/packages/shared/src/types/address.ts @@ -0,0 +1 @@ +export type NormalizedAddress = `0x${string}`; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 4dcf4e8..2ba46d9 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,2 +1,3 @@ export * from "./logger.js"; export * from "./timestamp.js"; +export * from "./address.js"; diff --git a/packages/shared/tests/services/address.spec.ts b/packages/shared/tests/services/address.spec.ts new file mode 100644 index 0000000..7806fec --- /dev/null +++ b/packages/shared/tests/services/address.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; + +import { Address, InvalidAddress } from "../../src/index.js"; + +describe("address", () => { + describe("normalize", () => { + it("normalizes an address", () => { + const address = "0xABCDEABCDEABCDEABCDEABCDEABCDEABCDEABCDEAB"; + const normalizedAddress = Address.normalize(address); + + expect(normalizedAddress).toEqual("0xabcdeabcdeabcdeabcdeabcdeabcdeabcdeabcdeab"); + }); + + it("throws if input is not an address", () => { + const notAnAddress = "foobar"; + + expect(() => Address.normalize(notAnAddress)).toThrow(InvalidAddress); + }); + }); + + describe("isNormalized", () => { + it("returns true if is a normalized address", () => { + const address = "0xabcdeabcdeabcdeabcdeabcdeabcdeabcdeabcdeab"; + + expect(Address.isNormalized(address)).toBe(true); + }); + + it("returns false if is not a normalized address", () => { + const address = "0xABCDEABCDEABCDEABCDEABCDEABCDEABCDEABCDEAB"; + + expect(Address.isNormalized(address)).toBe(false); + }); + + it("returns false if input is not an address", () => { + const notAnAddress = "foobar"; + + expect(Address.isNormalized(notAnAddress)).toBe(false); + }); + + it("throws if there is an unhandled error", () => { + const normalizeMock = vi.spyOn(Address, "normalize").mockImplementation(() => { + throw new Error(); + }); + + expect(() => Address.isNormalized("abc")).toThrow(Error); + + normalizeMock.mockRestore(); + }); + }); +}); diff --git a/packages/shared/tests/logger.spec.ts b/packages/shared/tests/services/logger.spec.ts similarity index 94% rename from packages/shared/tests/logger.spec.ts rename to packages/shared/tests/services/logger.spec.ts index 5a7a86a..f8ac1df 100644 --- a/packages/shared/tests/logger.spec.ts +++ b/packages/shared/tests/services/logger.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { Logger } from "../src/logger.js"; +import { Logger } from "../../src/services/index.js"; describe("Logger Singleton", () => { it("creates a logger instance with the given log level", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eebb37e..0d628da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: 7.16.1 version: 7.16.1(eslint@8.57.0)(typescript@5.5.3) "@vitest/coverage-v8": - specifier: ^2.0.3 - version: 2.0.4(vitest@2.0.3(@types/node@20.14.12)) + specifier: ^2.0.5 + version: 2.0.5(vitest@2.0.3(@types/node@20.14.12)) eslint: specifier: 8.57.0 version: 8.57.0 @@ -111,31 +111,31 @@ packages: } engines: { node: ">=6.9.0" } - "@babel/compat-data@7.25.0": + "@babel/compat-data@7.25.4": resolution: { - integrity: sha512-P4fwKI2mjEb3ZU5cnMJzvRsRKGBUcs8jvxIoRmr6ufAY9Xk2Bz7JubRTTivkw55c7WQJfTECeqYVa+HZ0FzREg==, + integrity: sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==, } engines: { node: ">=6.9.0" } - "@babel/core@7.24.9": + "@babel/core@7.25.2": resolution: { - integrity: sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==, + integrity: sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==, } engines: { node: ">=6.9.0" } - "@babel/generator@7.25.0": + "@babel/generator@7.25.5": resolution: { - integrity: sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==, + integrity: sha512-abd43wyLfbWoxC6ahM8xTkqLpGB2iWBVyuKC9/srhFunCd1SDNrV1s72bBpK4hLj8KLzHBBcOblvLQZBNw9r3w==, } engines: { node: ">=6.9.0" } - "@babel/helper-compilation-targets@7.24.8": + "@babel/helper-compilation-targets@7.25.2": resolution: { - integrity: sha512-oU+UoqCHdp+nWVDkpldqIQL/i/bvAv53tRqLG/s+cOXxe66zOYLU7ar/Xs3LdmBihrUMEUhwu6dMZwbNOYDwvw==, + integrity: sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==, } engines: { node: ">=6.9.0" } @@ -146,10 +146,10 @@ packages: } engines: { node: ">=6.9.0" } - "@babel/helper-module-transforms@7.25.0": + "@babel/helper-module-transforms@7.25.2": resolution: { - integrity: sha512-bIkOa2ZJYn7FHnepzr5iX9Kmz8FjIz4UKzJ9zhX3dnYuVW0xul9RuR3skBfoLu+FPTQw90EHW9rJsSZhyLQ3fQ==, + integrity: sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==, } engines: { node: ">=6.9.0" } peerDependencies: @@ -197,10 +197,10 @@ packages: } engines: { node: ">=6.9.0" } - "@babel/parser@7.25.0": + "@babel/parser@7.25.4": resolution: { - integrity: sha512-CzdIU9jdP0dg7HdyB+bHvDJGagUv+qtzZt5rYCWwW6tITNqV9odjp6Qu41gkG0ca5UfdDUWrKkiAnHHdGRnOrA==, + integrity: sha512-nq+eWrOgdtu3jG5Os4TQP3x3cLA8hR8TvJNjD8vnPa20WGycimcparWnLK4jJhElTK6SDyuJo1weMKO/5LpmLA==, } engines: { node: ">=6.0.0" } hasBin: true @@ -212,17 +212,17 @@ packages: } engines: { node: ">=6.9.0" } - "@babel/traverse@7.25.1": + "@babel/traverse@7.25.4": resolution: { - integrity: sha512-LrHHoWq08ZpmmFqBAzN+hUdWwy5zt7FGa/hVwMcOqW6OVtwqaoD5utfuGYU87JYxdZgLUvktAsn37j/sYR9siA==, + integrity: sha512-VJ4XsrD+nOvlXyLzmLzUs/0qjFS4sK30te5yEFlvbbUNEgKaVb2BHZUpAL+ttLPQAHNrsI3zZisbfha5Cvr8vg==, } engines: { node: ">=6.9.0" } - "@babel/types@7.25.0": + "@babel/types@7.25.4": resolution: { - integrity: sha512-LcnxQSsd9aXOIgmmSpvZ/1yo46ra2ESYyqLcryaBZOghxy5qqOBjvCWP5JfkI8yl9rlxRgdLTTMCQQRcN2hdCg==, + integrity: sha512-zQ1ijeeCXVEh+aNL0RlmkPkG8HUiDcU2pzQQFjtbntgAczRASFzj4H+6+bV+dy1ntKR14I/DypeuRG1uma98iQ==, } engines: { node: ">=6.9.0" } @@ -296,10 +296,10 @@ packages: } engines: { node: ">=v18" } - "@commitlint/load@19.2.0": + "@commitlint/load@19.4.0": resolution: { - integrity: sha512-XvxxLJTKqZojCxaBQ7u92qQLFMMZc4+p9qrIq/9kJDy8DOrEa7P1yx7Tjdc2u2JxIalqT4KOGraVgCE7eCYJyQ==, + integrity: sha512-I4lCWaEZYQJ1y+Y+gdvbGAx9pYPavqZAZ3/7/8BpWh+QjscAn8AjsUpLV2PycBsEx7gupq5gM4BViV9xwTIJuw==, } engines: { node: ">=v18" } @@ -317,10 +317,10 @@ packages: } engines: { node: ">=v18" } - "@commitlint/read@19.2.1": + "@commitlint/read@19.4.0": resolution: { - integrity: sha512-qETc4+PL0EUv7Q36lJbPG+NJiBOGg7SSC7B5BsPWOmei+Dyif80ErfWQ0qXoW9oCh7GTpTNRoaVhiI8RbhuaNw==, + integrity: sha512-r95jLOEZzKDakXtnQub+zR3xjdnrl2XzerPwm7ch1/cc5JGq04tyaNpa6ty0CRCWdVrk4CZHhqHozb8yZwy2+g==, } engines: { node: ">=v18" } @@ -744,130 +744,130 @@ packages: } engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } - "@rollup/rollup-android-arm-eabi@4.19.1": + "@rollup/rollup-android-arm-eabi@4.21.1": resolution: { - integrity: sha512-XzqSg714++M+FXhHfXpS1tDnNZNpgxxuGZWlRG/jSj+VEPmZ0yg6jV4E0AL3uyBKxO8mO3xtOsP5mQ+XLfrlww==, + integrity: sha512-2thheikVEuU7ZxFXubPDOtspKn1x0yqaYQwvALVtEcvFhMifPADBrgRPyHV0TF3b+9BgvgjgagVyvA/UqPZHmg==, } cpu: [arm] os: [android] - "@rollup/rollup-android-arm64@4.19.1": + "@rollup/rollup-android-arm64@4.21.1": resolution: { - integrity: sha512-thFUbkHteM20BGShD6P08aungq4irbIZKUNbG70LN8RkO7YztcGPiKTTGZS7Kw+x5h8hOXs0i4OaHwFxlpQN6A==, + integrity: sha512-t1lLYn4V9WgnIFHXy1d2Di/7gyzBWS8G5pQSXdZqfrdCGTwi1VasRMSS81DTYb+avDs/Zz4A6dzERki5oRYz1g==, } cpu: [arm64] os: [android] - "@rollup/rollup-darwin-arm64@4.19.1": + "@rollup/rollup-darwin-arm64@4.21.1": resolution: { - integrity: sha512-8o6eqeFZzVLia2hKPUZk4jdE3zW7LCcZr+MD18tXkgBBid3lssGVAYuox8x6YHoEPDdDa9ixTaStcmx88lio5Q==, + integrity: sha512-AH/wNWSEEHvs6t4iJ3RANxW5ZCK3fUnmf0gyMxWCesY1AlUj8jY7GC+rQE4wd3gwmZ9XDOpL0kcFnCjtN7FXlA==, } cpu: [arm64] os: [darwin] - "@rollup/rollup-darwin-x64@4.19.1": + "@rollup/rollup-darwin-x64@4.21.1": resolution: { - integrity: sha512-4T42heKsnbjkn7ovYiAdDVRRWZLU9Kmhdt6HafZxFcUdpjlBlxj4wDrt1yFWLk7G4+E+8p2C9tcmSu0KA6auGA==, + integrity: sha512-dO0BIz/+5ZdkLZrVgQrDdW7m2RkrLwYTh2YMFG9IpBtlC1x1NPNSXkfczhZieOlOLEqgXOFH3wYHB7PmBtf+Bg==, } cpu: [x64] os: [darwin] - "@rollup/rollup-linux-arm-gnueabihf@4.19.1": + "@rollup/rollup-linux-arm-gnueabihf@4.21.1": resolution: { - integrity: sha512-MXg1xp+e5GhZ3Vit1gGEyoC+dyQUBy2JgVQ+3hUrD9wZMkUw/ywgkpK7oZgnB6kPpGrxJ41clkPPnsknuD6M2Q==, + integrity: sha512-sWWgdQ1fq+XKrlda8PsMCfut8caFwZBmhYeoehJ05FdI0YZXk6ZyUjWLrIgbR/VgiGycrFKMMgp7eJ69HOF2pQ==, } cpu: [arm] os: [linux] - "@rollup/rollup-linux-arm-musleabihf@4.19.1": + "@rollup/rollup-linux-arm-musleabihf@4.21.1": resolution: { - integrity: sha512-DZNLwIY4ftPSRVkJEaxYkq7u2zel7aah57HESuNkUnz+3bZHxwkCUkrfS2IWC1sxK6F2QNIR0Qr/YXw7nkF3Pw==, + integrity: sha512-9OIiSuj5EsYQlmwhmFRA0LRO0dRRjdCVZA3hnmZe1rEwRk11Jy3ECGGq3a7RrVEZ0/pCsYWx8jG3IvcrJ6RCew==, } cpu: [arm] os: [linux] - "@rollup/rollup-linux-arm64-gnu@4.19.1": + "@rollup/rollup-linux-arm64-gnu@4.21.1": resolution: { - integrity: sha512-C7evongnjyxdngSDRRSQv5GvyfISizgtk9RM+z2biV5kY6S/NF/wta7K+DanmktC5DkuaJQgoKGf7KUDmA7RUw==, + integrity: sha512-0kuAkRK4MeIUbzQYu63NrJmfoUVicajoRAL1bpwdYIYRcs57iyIV9NLcuyDyDXE2GiZCL4uhKSYAnyWpjZkWow==, } cpu: [arm64] os: [linux] - "@rollup/rollup-linux-arm64-musl@4.19.1": + "@rollup/rollup-linux-arm64-musl@4.21.1": resolution: { - integrity: sha512-89tFWqxfxLLHkAthAcrTs9etAoBFRduNfWdl2xUs/yLV+7XDrJ5yuXMHptNqf1Zw0UCA3cAutkAiAokYCkaPtw==, + integrity: sha512-/6dYC9fZtfEY0vozpc5bx1RP4VrtEOhNQGb0HwvYNwXD1BBbwQ5cKIbUVVU7G2d5WRE90NfB922elN8ASXAJEA==, } cpu: [arm64] os: [linux] - "@rollup/rollup-linux-powerpc64le-gnu@4.19.1": + "@rollup/rollup-linux-powerpc64le-gnu@4.21.1": resolution: { - integrity: sha512-PromGeV50sq+YfaisG8W3fd+Cl6mnOOiNv2qKKqKCpiiEke2KiKVyDqG/Mb9GWKbYMHj5a01fq/qlUR28PFhCQ==, + integrity: sha512-ltUWy+sHeAh3YZ91NUsV4Xg3uBXAlscQe8ZOXRCVAKLsivGuJsrkawYPUEyCV3DYa9urgJugMLn8Z3Z/6CeyRQ==, } cpu: [ppc64] os: [linux] - "@rollup/rollup-linux-riscv64-gnu@4.19.1": + "@rollup/rollup-linux-riscv64-gnu@4.21.1": resolution: { - integrity: sha512-/1BmHYh+iz0cNCP0oHCuF8CSiNj0JOGf0jRlSo3L/FAyZyG2rGBuKpkZVH9YF+x58r1jgWxvm1aRg3DHrLDt6A==, + integrity: sha512-BggMndzI7Tlv4/abrgLwa/dxNEMn2gC61DCLrTzw8LkpSKel4o+O+gtjbnkevZ18SKkeN3ihRGPuBxjaetWzWg==, } cpu: [riscv64] os: [linux] - "@rollup/rollup-linux-s390x-gnu@4.19.1": + "@rollup/rollup-linux-s390x-gnu@4.21.1": resolution: { - integrity: sha512-0cYP5rGkQWRZKy9/HtsWVStLXzCF3cCBTRI+qRL8Z+wkYlqN7zrSYm6FuY5Kd5ysS5aH0q5lVgb/WbG4jqXN1Q==, + integrity: sha512-z/9rtlGd/OMv+gb1mNSjElasMf9yXusAxnRDrBaYB+eS1shFm6/4/xDH1SAISO5729fFKUkJ88TkGPRUh8WSAA==, } cpu: [s390x] os: [linux] - "@rollup/rollup-linux-x64-gnu@4.19.1": + "@rollup/rollup-linux-x64-gnu@4.21.1": resolution: { - integrity: sha512-XUXeI9eM8rMP8aGvii/aOOiMvTs7xlCosq9xCjcqI9+5hBxtjDpD+7Abm1ZhVIFE1J2h2VIg0t2DX/gjespC2Q==, + integrity: sha512-kXQVcWqDcDKw0S2E0TmhlTLlUgAmMVqPrJZR+KpH/1ZaZhLSl23GZpQVmawBQGVhyP5WXIsIQ/zqbDBBYmxm5w==, } cpu: [x64] os: [linux] - "@rollup/rollup-linux-x64-musl@4.19.1": + "@rollup/rollup-linux-x64-musl@4.21.1": resolution: { - integrity: sha512-V7cBw/cKXMfEVhpSvVZhC+iGifD6U1zJ4tbibjjN+Xi3blSXaj/rJynAkCFFQfoG6VZrAiP7uGVzL440Q6Me2Q==, + integrity: sha512-CbFv/WMQsSdl+bpX6rVbzR4kAjSSBuDgCqb1l4J68UYsQNalz5wOqLGYj4ZI0thGpyX5kc+LLZ9CL+kpqDovZA==, } cpu: [x64] os: [linux] - "@rollup/rollup-win32-arm64-msvc@4.19.1": + "@rollup/rollup-win32-arm64-msvc@4.21.1": resolution: { - integrity: sha512-88brja2vldW/76jWATlBqHEoGjJLRnP0WOEKAUbMcXaAZnemNhlAHSyj4jIwMoP2T750LE9lblvD4e2jXleZsA==, + integrity: sha512-3Q3brDgA86gHXWHklrwdREKIrIbxC0ZgU8lwpj0eEKGBQH+31uPqr0P2v11pn0tSIxHvcdOWxa4j+YvLNx1i6g==, } cpu: [arm64] os: [win32] - "@rollup/rollup-win32-ia32-msvc@4.19.1": + "@rollup/rollup-win32-ia32-msvc@4.21.1": resolution: { - integrity: sha512-LdxxcqRVSXi6k6JUrTah1rHuaupoeuiv38du8Mt4r4IPer3kwlTo+RuvfE8KzZ/tL6BhaPlzJ3835i6CxrFIRQ==, + integrity: sha512-tNg+jJcKR3Uwe4L0/wY3Ro0H+u3nrb04+tcq1GSYzBEmKLeOQF2emk1whxlzNqb6MMrQ2JOcQEpuuiPLyRcSIw==, } cpu: [ia32] os: [win32] - "@rollup/rollup-win32-x64-msvc@4.19.1": + "@rollup/rollup-win32-x64-msvc@4.21.1": resolution: { - integrity: sha512-2bIrL28PcK3YCqD9anGxDxamxdiJAxA+l7fWIwM5o8UqNy1t3d1NdAweO2XhA0KTDJ5aH1FsuiT5+7VhtHliXg==, + integrity: sha512-xGiIH95H1zU7naUyTKEyOA/I0aexNMUdO9qRv0bLKN3qu25bBdrxZHqA3PTJ24YNN/GdMzG4xkDcd/GvjuhfLg==, } cpu: [x64] os: [win32] @@ -1026,13 +1026,13 @@ packages: integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==, } - "@vitest/coverage-v8@2.0.4": + "@vitest/coverage-v8@2.0.5": resolution: { - integrity: sha512-i4lx/Wpg5zF1h2op7j0wdwuEQxaL/YTwwQaKuKMHYj7MMh8c7I4W7PNfOptZBCSBZI0z1qwn64o0pM/pA8Tz1g==, + integrity: sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==, } peerDependencies: - vitest: 2.0.4 + vitest: 2.0.5 "@vitest/expect@2.0.3": resolution: @@ -1046,10 +1046,10 @@ packages: integrity: sha512-URM4GLsB2xD37nnTyvf6kfObFafxmycCL8un3OC9gaCs5cti2u+5rJdIflZ2fUJUen4NbvF6jCufwViAFLvz1g==, } - "@vitest/pretty-format@2.0.4": + "@vitest/pretty-format@2.0.5": resolution: { - integrity: sha512-RYZl31STbNGqf4l2eQM1nvKPXE0NhC6Eq0suTTePc4mtMQ1Fn8qZmjV4emZdEdG2NOWGKSCrHZjmTqDCDoeFBw==, + integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==, } "@vitest/runner@2.0.3": @@ -1206,10 +1206,10 @@ packages: } engines: { node: ">=12" } - async@3.2.5: + async@3.2.6: resolution: { - integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==, + integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==, } balanced-match@1.0.2: @@ -1237,10 +1237,10 @@ packages: } engines: { node: ">=8" } - browserslist@4.23.2: + browserslist@4.23.3: resolution: { - integrity: sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==, + integrity: sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==, } engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } hasBin: true @@ -1259,10 +1259,10 @@ packages: } engines: { node: ">=6" } - caniuse-lite@1.0.30001643: + caniuse-lite@1.0.30001653: resolution: { - integrity: sha512-ERgWGNleEilSrHM6iUz/zJNSQTP8Mr21wDWpdgvRwcTXGAq6jMtOUPP4dqFPTdKqZ2wKTdtB+uucZ3MRpAUSmg==, + integrity: sha512-XGWQVB8wFQ2+9NZwZ10GxTYC5hk0Fa+q8cSkr0tgvMhYhMHP/QC+WTgrePMDBWiWc/pV+1ik82Al20XOK25Gcw==, } chai@5.1.1: @@ -1519,16 +1519,16 @@ packages: integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, } - electron-to-chromium@1.5.2: + electron-to-chromium@1.5.13: resolution: { - integrity: sha512-kc4r3U3V3WLaaZqThjYz/Y6z8tJe+7K0bbjUVo3i+LWIypVdMx5nXCkwRe6SWbY6ILqLdc1rKcKmr3HoH7wjSQ==, + integrity: sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==, } - emoji-regex@10.3.0: + emoji-regex@10.4.0: resolution: { - integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==, + integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==, } emoji-regex@8.0.0: @@ -1796,10 +1796,10 @@ packages: integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==, } - foreground-child@3.2.1: + foreground-child@3.3.0: resolution: { - integrity: sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==, + integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==, } engines: { node: ">=14" } @@ -1956,10 +1956,10 @@ packages: engines: { node: ">=18" } hasBin: true - ignore@5.3.1: + ignore@5.3.2: resolution: { - integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==, + integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, } engines: { node: ">= 4" } @@ -2405,10 +2405,10 @@ packages: } engines: { node: ">= 8" } - micromatch@4.0.7: + micromatch@4.0.8: resolution: { - integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==, + integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, } engines: { node: ">=8.6" } @@ -2655,10 +2655,10 @@ packages: engines: { node: ">=0.10" } hasBin: true - postcss@8.4.40: + postcss@8.4.41: resolution: { - integrity: sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==, + integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==, } engines: { node: ^10 || ^12 || >=14 } @@ -2760,10 +2760,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rollup@4.19.1: + rollup@4.21.1: resolution: { - integrity: sha512-K5vziVlg7hTpYfFBI+91zHBEMo6jafYXpkMlqZjg7/zhIG9iHqazBf4xz9AVdjS9BruRn280ROqLI7G3OFRIlw==, + integrity: sha512-ZnYyKvscThhgd3M5+Qt3pmhO4jIRR5RGzaSovB6Q7rGNrK5cUncrtLmcTTJVSdcKXyZjW8X8MB0JMSuH9bcAJg==, } engines: { node: ">=18.0.0", npm: ">=8.0.0" } hasBin: true @@ -2780,10 +2780,10 @@ packages: integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, } - safe-stable-stringify@2.4.3: + safe-stable-stringify@2.5.0: resolution: { - integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==, + integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==, } engines: { node: ">=10" } @@ -3003,16 +3003,16 @@ packages: integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==, } - tinybench@2.8.0: + tinybench@2.9.0: resolution: { - integrity: sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==, + integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==, } - tinypool@1.0.0: + tinypool@1.0.1: resolution: { - integrity: sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==, + integrity: sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==, } engines: { node: ^18.0.0 || >=20.0.0 } @@ -3077,10 +3077,10 @@ packages: "@swc/wasm": optional: true - tslib@2.6.3: + tslib@2.7.0: resolution: { - integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==, + integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==, } turbo-darwin-64@2.0.7: @@ -3230,10 +3230,10 @@ packages: engines: { node: ^18.0.0 || >=20.0.0 } hasBin: true - vite@5.3.5: + vite@5.4.2: resolution: { - integrity: sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==, + integrity: sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==, } engines: { node: ^18.0.0 || >=20.0.0 } hasBin: true @@ -3242,6 +3242,7 @@ packages: less: "*" lightningcss: ^1.21.0 sass: "*" + sass-embedded: "*" stylus: "*" sugarss: "*" terser: ^5.4.0 @@ -3254,6 +3255,8 @@ packages: optional: true sass: optional: true + sass-embedded: + optional: true stylus: optional: true sugarss: @@ -3437,20 +3440,20 @@ snapshots: "@babel/highlight": 7.24.7 picocolors: 1.0.1 - "@babel/compat-data@7.25.0": {} + "@babel/compat-data@7.25.4": {} - "@babel/core@7.24.9": + "@babel/core@7.25.2": dependencies: "@ampproject/remapping": 2.3.0 "@babel/code-frame": 7.24.7 - "@babel/generator": 7.25.0 - "@babel/helper-compilation-targets": 7.24.8 - "@babel/helper-module-transforms": 7.25.0(@babel/core@7.24.9) + "@babel/generator": 7.25.5 + "@babel/helper-compilation-targets": 7.25.2 + "@babel/helper-module-transforms": 7.25.2(@babel/core@7.25.2) "@babel/helpers": 7.25.0 - "@babel/parser": 7.25.0 + "@babel/parser": 7.25.4 "@babel/template": 7.25.0 - "@babel/traverse": 7.25.1 - "@babel/types": 7.25.0 + "@babel/traverse": 7.25.4 + "@babel/types": 7.25.4 convert-source-map: 2.0.0 debug: 4.3.6 gensync: 1.0.0-beta.2 @@ -3459,42 +3462,42 @@ snapshots: transitivePeerDependencies: - supports-color - "@babel/generator@7.25.0": + "@babel/generator@7.25.5": dependencies: - "@babel/types": 7.25.0 + "@babel/types": 7.25.4 "@jridgewell/gen-mapping": 0.3.5 "@jridgewell/trace-mapping": 0.3.25 jsesc: 2.5.2 - "@babel/helper-compilation-targets@7.24.8": + "@babel/helper-compilation-targets@7.25.2": dependencies: - "@babel/compat-data": 7.25.0 + "@babel/compat-data": 7.25.4 "@babel/helper-validator-option": 7.24.8 - browserslist: 4.23.2 + browserslist: 4.23.3 lru-cache: 5.1.1 semver: 6.3.1 "@babel/helper-module-imports@7.24.7": dependencies: - "@babel/traverse": 7.25.1 - "@babel/types": 7.25.0 + "@babel/traverse": 7.25.4 + "@babel/types": 7.25.4 transitivePeerDependencies: - supports-color - "@babel/helper-module-transforms@7.25.0(@babel/core@7.24.9)": + "@babel/helper-module-transforms@7.25.2(@babel/core@7.25.2)": dependencies: - "@babel/core": 7.24.9 + "@babel/core": 7.25.2 "@babel/helper-module-imports": 7.24.7 "@babel/helper-simple-access": 7.24.7 "@babel/helper-validator-identifier": 7.24.7 - "@babel/traverse": 7.25.1 + "@babel/traverse": 7.25.4 transitivePeerDependencies: - supports-color "@babel/helper-simple-access@7.24.7": dependencies: - "@babel/traverse": 7.25.1 - "@babel/types": 7.25.0 + "@babel/traverse": 7.25.4 + "@babel/types": 7.25.4 transitivePeerDependencies: - supports-color @@ -3507,7 +3510,7 @@ snapshots: "@babel/helpers@7.25.0": dependencies: "@babel/template": 7.25.0 - "@babel/types": 7.25.0 + "@babel/types": 7.25.4 "@babel/highlight@7.24.7": dependencies: @@ -3516,29 +3519,29 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.0.1 - "@babel/parser@7.25.0": + "@babel/parser@7.25.4": dependencies: - "@babel/types": 7.25.0 + "@babel/types": 7.25.4 "@babel/template@7.25.0": dependencies: "@babel/code-frame": 7.24.7 - "@babel/parser": 7.25.0 - "@babel/types": 7.25.0 + "@babel/parser": 7.25.4 + "@babel/types": 7.25.4 - "@babel/traverse@7.25.1": + "@babel/traverse@7.25.4": dependencies: "@babel/code-frame": 7.24.7 - "@babel/generator": 7.25.0 - "@babel/parser": 7.25.0 + "@babel/generator": 7.25.5 + "@babel/parser": 7.25.4 "@babel/template": 7.25.0 - "@babel/types": 7.25.0 + "@babel/types": 7.25.4 debug: 4.3.6 globals: 11.12.0 transitivePeerDependencies: - supports-color - "@babel/types@7.25.0": + "@babel/types@7.25.4": dependencies: "@babel/helper-string-parser": 7.24.8 "@babel/helper-validator-identifier": 7.24.7 @@ -3552,8 +3555,8 @@ snapshots: dependencies: "@commitlint/format": 19.3.0 "@commitlint/lint": 19.2.2 - "@commitlint/load": 19.2.0(@types/node@20.14.12)(typescript@5.5.3) - "@commitlint/read": 19.2.1 + "@commitlint/load": 19.4.0(@types/node@20.14.12)(typescript@5.5.3) + "@commitlint/read": 19.4.0 "@commitlint/types": 19.0.3 execa: 8.0.1 yargs: 17.7.2 @@ -3599,7 +3602,7 @@ snapshots: "@commitlint/rules": 19.0.3 "@commitlint/types": 19.0.3 - "@commitlint/load@19.2.0(@types/node@20.14.12)(typescript@5.5.3)": + "@commitlint/load@19.4.0(@types/node@20.14.12)(typescript@5.5.3)": dependencies: "@commitlint/config-validator": 19.0.3 "@commitlint/execute-rule": 19.0.0 @@ -3623,7 +3626,7 @@ snapshots: conventional-changelog-angular: 7.0.0 conventional-commits-parser: 5.0.0 - "@commitlint/read@19.2.1": + "@commitlint/read@19.4.0": dependencies: "@commitlint/top-level": 19.0.0 "@commitlint/types": 19.0.3 @@ -3751,7 +3754,7 @@ snapshots: debug: 4.3.6 espree: 9.6.1 globals: 13.24.0 - ignore: 5.3.1 + ignore: 5.3.2 import-fresh: 3.3.0 js-yaml: 4.1.0 minimatch: 3.1.2 @@ -3775,11 +3778,11 @@ snapshots: "@ianvs/prettier-plugin-sort-imports@4.3.1(prettier@3.3.3)": dependencies: - "@babel/core": 7.24.9 - "@babel/generator": 7.25.0 - "@babel/parser": 7.25.0 - "@babel/traverse": 7.25.1 - "@babel/types": 7.25.0 + "@babel/core": 7.25.2 + "@babel/generator": 7.25.5 + "@babel/parser": 7.25.4 + "@babel/traverse": 7.25.4 + "@babel/types": 7.25.4 prettier: 3.3.3 semver: 7.6.3 transitivePeerDependencies: @@ -3841,52 +3844,52 @@ snapshots: "@pkgr/core@0.1.1": {} - "@rollup/rollup-android-arm-eabi@4.19.1": + "@rollup/rollup-android-arm-eabi@4.21.1": optional: true - "@rollup/rollup-android-arm64@4.19.1": + "@rollup/rollup-android-arm64@4.21.1": optional: true - "@rollup/rollup-darwin-arm64@4.19.1": + "@rollup/rollup-darwin-arm64@4.21.1": optional: true - "@rollup/rollup-darwin-x64@4.19.1": + "@rollup/rollup-darwin-x64@4.21.1": optional: true - "@rollup/rollup-linux-arm-gnueabihf@4.19.1": + "@rollup/rollup-linux-arm-gnueabihf@4.21.1": optional: true - "@rollup/rollup-linux-arm-musleabihf@4.19.1": + "@rollup/rollup-linux-arm-musleabihf@4.21.1": optional: true - "@rollup/rollup-linux-arm64-gnu@4.19.1": + "@rollup/rollup-linux-arm64-gnu@4.21.1": optional: true - "@rollup/rollup-linux-arm64-musl@4.19.1": + "@rollup/rollup-linux-arm64-musl@4.21.1": optional: true - "@rollup/rollup-linux-powerpc64le-gnu@4.19.1": + "@rollup/rollup-linux-powerpc64le-gnu@4.21.1": optional: true - "@rollup/rollup-linux-riscv64-gnu@4.19.1": + "@rollup/rollup-linux-riscv64-gnu@4.21.1": optional: true - "@rollup/rollup-linux-s390x-gnu@4.19.1": + "@rollup/rollup-linux-s390x-gnu@4.21.1": optional: true - "@rollup/rollup-linux-x64-gnu@4.19.1": + "@rollup/rollup-linux-x64-gnu@4.21.1": optional: true - "@rollup/rollup-linux-x64-musl@4.19.1": + "@rollup/rollup-linux-x64-musl@4.21.1": optional: true - "@rollup/rollup-win32-arm64-msvc@4.19.1": + "@rollup/rollup-win32-arm64-msvc@4.21.1": optional: true - "@rollup/rollup-win32-ia32-msvc@4.19.1": + "@rollup/rollup-win32-ia32-msvc@4.21.1": optional: true - "@rollup/rollup-win32-x64-msvc@4.19.1": + "@rollup/rollup-win32-x64-msvc@4.21.1": optional: true "@scure/base@1.1.7": {} @@ -3932,7 +3935,7 @@ snapshots: "@typescript-eslint/visitor-keys": 7.16.1 eslint: 8.57.0 graphemer: 1.4.0 - ignore: 5.3.1 + ignore: 5.3.2 natural-compare: 1.4.0 ts-api-utils: 1.3.0(typescript@5.5.3) optionalDependencies: @@ -4005,7 +4008,7 @@ snapshots: "@ungap/structured-clone@1.2.0": {} - "@vitest/coverage-v8@2.0.4(vitest@2.0.3(@types/node@20.14.12))": + "@vitest/coverage-v8@2.0.5(vitest@2.0.3(@types/node@20.14.12))": dependencies: "@ampproject/remapping": 2.3.0 "@bcoe/v8-coverage": 0.2.3 @@ -4034,7 +4037,7 @@ snapshots: dependencies: tinyrainbow: 1.2.0 - "@vitest/pretty-format@2.0.4": + "@vitest/pretty-format@2.0.5": dependencies: tinyrainbow: 1.2.0 @@ -4121,7 +4124,7 @@ snapshots: assertion-error@2.0.1: {} - async@3.2.5: {} + async@3.2.6: {} balanced-match@1.0.2: {} @@ -4138,18 +4141,18 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.23.2: + browserslist@4.23.3: dependencies: - caniuse-lite: 1.0.30001643 - electron-to-chromium: 1.5.2 + caniuse-lite: 1.0.30001653 + electron-to-chromium: 1.5.13 node-releases: 2.0.18 - update-browserslist-db: 1.1.0(browserslist@4.23.2) + update-browserslist-db: 1.1.0(browserslist@4.23.3) cac@6.7.14: {} callsites@3.1.0: {} - caniuse-lite@1.0.30001643: {} + caniuse-lite@1.0.30001653: {} chai@5.1.1: dependencies: @@ -4294,9 +4297,9 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.2: {} + electron-to-chromium@1.5.13: {} - emoji-regex@10.3.0: {} + emoji-regex@10.4.0: {} emoji-regex@8.0.0: {} @@ -4391,7 +4394,7 @@ snapshots: glob-parent: 6.0.2 globals: 13.24.0 graphemer: 1.4.0 - ignore: 5.3.1 + ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 @@ -4453,7 +4456,7 @@ snapshots: "@nodelib/fs.walk": 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.7 + micromatch: 4.0.8 fast-json-stable-stringify@2.1.0: {} @@ -4496,7 +4499,7 @@ snapshots: fn.name@1.1.0: {} - foreground-child@3.2.1: + foreground-child@3.3.0: dependencies: cross-spawn: 7.0.3 signal-exit: 4.1.0 @@ -4532,7 +4535,7 @@ snapshots: glob@10.4.5: dependencies: - foreground-child: 3.2.1 + foreground-child: 3.3.0 jackspeak: 3.4.3 minimatch: 9.0.5 minipass: 7.1.2 @@ -4563,7 +4566,7 @@ snapshots: array-union: 2.1.0 dir-glob: 3.0.1 fast-glob: 3.3.2 - ignore: 5.3.1 + ignore: 5.3.2 merge2: 1.4.1 slash: 3.0.0 @@ -4579,7 +4582,7 @@ snapshots: husky@9.1.0: {} - ignore@5.3.1: {} + ignore@5.3.2: {} import-fresh@3.3.0: dependencies: @@ -4711,7 +4714,7 @@ snapshots: execa: 8.0.1 lilconfig: 3.1.2 listr2: 8.2.4 - micromatch: 4.0.7 + micromatch: 4.0.8 pidtree: 0.6.0 string-argv: 0.3.2 yaml: 2.4.5 @@ -4767,7 +4770,7 @@ snapshots: "@types/triple-beam": 1.3.5 fecha: 4.2.3 ms: 2.1.3 - safe-stable-stringify: 2.4.3 + safe-stable-stringify: 2.5.0 triple-beam: 1.4.1 loupe@3.1.1: @@ -4786,8 +4789,8 @@ snapshots: magicast@0.3.4: dependencies: - "@babel/parser": 7.25.0 - "@babel/types": 7.25.0 + "@babel/parser": 7.25.4 + "@babel/types": 7.25.4 source-map-js: 1.2.0 make-dir@4.0.0: @@ -4802,7 +4805,7 @@ snapshots: merge2@1.4.1: {} - micromatch@4.0.7: + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 @@ -4918,7 +4921,7 @@ snapshots: pidtree@0.6.0: {} - postcss@8.4.40: + postcss@8.4.41: dependencies: nanoid: 3.3.7 picocolors: 1.0.1 @@ -4963,26 +4966,26 @@ snapshots: dependencies: glob: 7.2.3 - rollup@4.19.1: + rollup@4.21.1: dependencies: "@types/estree": 1.0.5 optionalDependencies: - "@rollup/rollup-android-arm-eabi": 4.19.1 - "@rollup/rollup-android-arm64": 4.19.1 - "@rollup/rollup-darwin-arm64": 4.19.1 - "@rollup/rollup-darwin-x64": 4.19.1 - "@rollup/rollup-linux-arm-gnueabihf": 4.19.1 - "@rollup/rollup-linux-arm-musleabihf": 4.19.1 - "@rollup/rollup-linux-arm64-gnu": 4.19.1 - "@rollup/rollup-linux-arm64-musl": 4.19.1 - "@rollup/rollup-linux-powerpc64le-gnu": 4.19.1 - "@rollup/rollup-linux-riscv64-gnu": 4.19.1 - "@rollup/rollup-linux-s390x-gnu": 4.19.1 - "@rollup/rollup-linux-x64-gnu": 4.19.1 - "@rollup/rollup-linux-x64-musl": 4.19.1 - "@rollup/rollup-win32-arm64-msvc": 4.19.1 - "@rollup/rollup-win32-ia32-msvc": 4.19.1 - "@rollup/rollup-win32-x64-msvc": 4.19.1 + "@rollup/rollup-android-arm-eabi": 4.21.1 + "@rollup/rollup-android-arm64": 4.21.1 + "@rollup/rollup-darwin-arm64": 4.21.1 + "@rollup/rollup-darwin-x64": 4.21.1 + "@rollup/rollup-linux-arm-gnueabihf": 4.21.1 + "@rollup/rollup-linux-arm-musleabihf": 4.21.1 + "@rollup/rollup-linux-arm64-gnu": 4.21.1 + "@rollup/rollup-linux-arm64-musl": 4.21.1 + "@rollup/rollup-linux-powerpc64le-gnu": 4.21.1 + "@rollup/rollup-linux-riscv64-gnu": 4.21.1 + "@rollup/rollup-linux-s390x-gnu": 4.21.1 + "@rollup/rollup-linux-x64-gnu": 4.21.1 + "@rollup/rollup-linux-x64-musl": 4.21.1 + "@rollup/rollup-win32-arm64-msvc": 4.21.1 + "@rollup/rollup-win32-ia32-msvc": 4.21.1 + "@rollup/rollup-win32-x64-msvc": 4.21.1 fsevents: 2.3.3 run-parallel@1.2.0: @@ -4991,7 +4994,7 @@ snapshots: safe-buffer@5.2.1: {} - safe-stable-stringify@2.4.3: {} + safe-stable-stringify@2.5.0: {} semver@6.3.1: {} @@ -5049,7 +5052,7 @@ snapshots: string-width@7.2.0: dependencies: - emoji-regex: 10.3.0 + emoji-regex: 10.4.0 get-east-asian-width: 1.2.0 strip-ansi: 7.1.0 @@ -5080,7 +5083,7 @@ snapshots: synckit@0.9.1: dependencies: "@pkgr/core": 0.1.1 - tslib: 2.6.3 + tslib: 2.7.0 test-exclude@7.0.1: dependencies: @@ -5096,9 +5099,9 @@ snapshots: through@2.3.8: {} - tinybench@2.8.0: {} + tinybench@2.9.0: {} - tinypool@1.0.0: {} + tinypool@1.0.1: {} tinyrainbow@1.2.0: {} @@ -5134,7 +5137,7 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - tslib@2.6.3: {} + tslib@2.7.0: {} turbo-darwin-64@2.0.7: optional: true @@ -5175,9 +5178,9 @@ snapshots: unicorn-magic@0.1.0: {} - update-browserslist-db@1.1.0(browserslist@4.23.2): + update-browserslist-db@1.1.0(browserslist@4.23.3): dependencies: - browserslist: 4.23.2 + browserslist: 4.23.3 escalade: 3.1.2 picocolors: 1.0.1 @@ -5229,22 +5232,23 @@ snapshots: debug: 4.3.6 pathe: 1.1.2 tinyrainbow: 1.2.0 - vite: 5.3.5(@types/node@20.14.12) + vite: 5.4.2(@types/node@20.14.12) transitivePeerDependencies: - "@types/node" - less - lightningcss - sass + - sass-embedded - stylus - sugarss - supports-color - terser - vite@5.3.5(@types/node@20.14.12): + vite@5.4.2(@types/node@20.14.12): dependencies: esbuild: 0.21.5 - postcss: 8.4.40 - rollup: 4.19.1 + postcss: 8.4.41 + rollup: 4.21.1 optionalDependencies: "@types/node": 20.14.12 fsevents: 2.3.3 @@ -5253,7 +5257,7 @@ snapshots: dependencies: "@ampproject/remapping": 2.3.0 "@vitest/expect": 2.0.3 - "@vitest/pretty-format": 2.0.4 + "@vitest/pretty-format": 2.0.5 "@vitest/runner": 2.0.3 "@vitest/snapshot": 2.0.3 "@vitest/spy": 2.0.3 @@ -5264,10 +5268,10 @@ snapshots: magic-string: 0.30.11 pathe: 1.1.2 std-env: 3.7.0 - tinybench: 2.8.0 - tinypool: 1.0.0 + tinybench: 2.9.0 + tinypool: 1.0.1 tinyrainbow: 1.2.0 - vite: 5.3.5(@types/node@20.14.12) + vite: 5.4.2(@types/node@20.14.12) vite-node: 2.0.3(@types/node@20.14.12) why-is-node-running: 2.3.0 optionalDependencies: @@ -5276,6 +5280,7 @@ snapshots: - less - lightningcss - sass + - sass-embedded - stylus - sugarss - supports-color @@ -5300,12 +5305,12 @@ snapshots: dependencies: "@colors/colors": 1.6.0 "@dabh/diagnostics": 2.0.3 - async: 3.2.5 + async: 3.2.6 is-stream: 2.0.1 logform: 2.6.1 one-time: 1.0.0 readable-stream: 3.6.2 - safe-stable-stringify: 2.4.3 + safe-stable-stringify: 2.5.0 stack-trace: 0.0.10 triple-beam: 1.4.1 winston-transport: 4.7.1 From 0a5fdab881ea45de9465fc50a84cbee9a05d238e Mon Sep 17 00:00:00 2001 From: 0xyaco Date: Mon, 2 Sep 2024 19:56:14 -0300 Subject: [PATCH 10/11] feat: periodic checks (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 🤖 Linear Part of GRT-58 ## Description * Check for finalizable request and _settleable_ disputes * Adds `createdAt` to all Prophet entities * Refactor `EboRegistry` getters to return arrays of entities instead of maps (we were always doing `.values()` or `.keys()`) **NOTE**: we might end up extracting some of the functionality added here into another service (some `TODO` comments were left there marking those places), but for the moment I prefer to have this working to reach the E2E stage with the core logic already developed. :angel: --- packages/automated-dispute/src/eboActor.ts | 174 +++++++++++++++- .../src/eboMemoryRegistry.ts | 28 ++- .../disputeWithoutResponse.exception.ts | 7 + .../src/exceptions/eboActor/index.ts | 1 + .../src/interfaces/eboRegistry.ts | 25 ++- .../automated-dispute/src/protocolProvider.ts | 45 ++--- .../automated-dispute/src/services/index.ts | 1 + .../automated-dispute/src/types/prophet.ts | 20 +- .../tests/eboActor/fixtures.ts | 16 ++ .../tests/eboActor/onLastBlockupdated.spec.ts | 185 ++++++++++++++++++ .../tests/eboActor/onRequestCreated.spec.ts | 31 ++- .../tests/eboActor/onResponseDisputed.spec.ts | 8 +- .../tests/mocks/eboActor.mocks.ts | 3 +- 13 files changed, 479 insertions(+), 65 deletions(-) create mode 100644 packages/automated-dispute/src/exceptions/eboActor/disputeWithoutResponse.exception.ts create mode 100644 packages/automated-dispute/src/exceptions/eboActor/index.ts create mode 100644 packages/automated-dispute/tests/eboActor/onLastBlockupdated.spec.ts diff --git a/packages/automated-dispute/src/eboActor.ts b/packages/automated-dispute/src/eboActor.ts index e0939e6..3589ba0 100644 --- a/packages/automated-dispute/src/eboActor.ts +++ b/packages/automated-dispute/src/eboActor.ts @@ -3,6 +3,7 @@ import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types.js"; import { ILogger } from "@ebo-agent/shared"; import { ContractFunctionRevertedError } from "viem"; +import { DisputeWithoutResponse } from "./exceptions/eboActor/disputeWithoutResponse.exception.js"; import { InvalidActorState, InvalidDisputeStatus, @@ -12,7 +13,7 @@ import { import { EboRegistry } from "./interfaces/eboRegistry.js"; import { ProtocolProvider } from "./protocolProvider.js"; import { EboEvent, EboEventName } from "./types/events.js"; -import { Dispute, Request, Response, ResponseBody } from "./types/prophet.js"; +import { Dispute, Request, RequestId, Response, ResponseBody } from "./types/prophet.js"; /** * Actor that handles a singular Prophet's request asking for the block number that corresponds @@ -33,7 +34,7 @@ export class EboActor { */ constructor( private readonly actorRequest: { - id: string; + id: RequestId; epoch: bigint; epochTimestamp: bigint; }, @@ -69,11 +70,164 @@ export class EboActor { * Triggers time-based interactions with smart contracts. This handles window-based * checks like proposal windows to close requests, or dispute windows to accept responses. * - * @param _blockNumber block number to check open/closed windows + * @param blockNumber block number to check open/closed windows */ - public onLastBlockUpdated(_blockNumber: bigint) { - // TODO - throw new Error("Implement me"); + public async onLastBlockUpdated(blockNumber: bigint): Promise { + await this.settleDisputes(blockNumber); + + const request = this.getActorRequest(); + const proposalDeadline = request.prophetData.responseModuleData.deadline; + const isProposalWindowOpen = blockNumber <= proposalDeadline; + + if (isProposalWindowOpen) { + this.logger.debug(`Proposal window for request ${request.id} not closed yet.`); + + return; + } + + const acceptedResponse = this.getAcceptedResponse(blockNumber); + + if (acceptedResponse) { + this.logger.info(`Finalizing request ${request.id}...`); + + await this.protocolProvider.finalize(request.prophetData, acceptedResponse.prophetData); + } + + // TODO: check for responseModuleData.deadline, if no answer has been accepted after the deadline + // notify and (TBD) finalize with no response + } + + /** + * Try to settle all active disputes if settling is needed. + * + * @param blockNumber block number to check if the dispute is to be settled + */ + private async settleDisputes(blockNumber: bigint): Promise { + const request = this.getActorRequest(); + const disputes: Dispute[] = this.getActiveDisputes(); + + const settledDisputes = disputes.map(async (dispute) => { + const responseId = dispute.prophetData.responseId; + const response = this.registry.getResponse(responseId); + + if (!response) { + this.logger.error( + `While trying to settle dispute ${dispute.id} its response with` + + `id ${dispute.prophetData.responseId} was not found in the registry.`, + ); + + throw new DisputeWithoutResponse(dispute); + } + + if (this.canBeSettled(request, dispute, blockNumber)) { + await this.settleDispute(request, response, dispute); + } + }); + + // Any of the disputes not being handled correctly should make the actor fail + await Promise.all(settledDisputes); + } + + private getActiveDisputes(): Dispute[] { + const disputes = this.registry.getDisputes(); + + return disputes.filter((dispute) => dispute.status === "Active"); + } + + // TODO: extract this into another service + private canBeSettled(request: Request, dispute: Dispute, blockNumber: bigint): boolean { + if (dispute.status !== "Active") return false; + + const { bondEscalationDeadline, tyingBuffer } = request.prophetData.disputeModuleData; + const deadline = bondEscalationDeadline + tyingBuffer; + + return blockNumber > deadline; + } + + /** + * Try to settle a dispute. If the dispute should be escalated, it escalates it. + * + * @param request the dispute's request + * @param response the dispute's response + * @param dispute the dispute + */ + private settleDispute(request: Request, response: Response, dispute: Dispute): Promise { + return Promise.resolve() + .then(async () => { + this.logger.info(`Settling dispute ${dispute.id}...`); + + // OPTIMIZE: check for pledges to potentially save the ShouldBeEscalated error + + await this.protocolProvider.settleDispute( + request.prophetData, + response.prophetData, + dispute.prophetData, + ); + + this.logger.info(`Dispute ${dispute.id} settled.`); + }) + .catch(async (err) => { + this.logger.warn(`Dispute ${dispute.id} was not settled.`); + + // TODO: use custom errors to be developed while implementing ProtocolProvider + if (!(err instanceof ContractFunctionRevertedError)) throw err; + + this.logger.warn(`Call reverted for ${dispute.id} due to: ${err.data?.errorName}`); + + if (err.data?.errorName === "BondEscalationModule_ShouldBeEscalated") { + this.logger.warn(`Escalating dispute ${dispute.id}...`); + + await this.protocolProvider.escalateDispute( + request.prophetData, + response.prophetData, + dispute.prophetData, + ); + + // TODO: notify + + this.logger.warn(`Dispute ${dispute.id} was escalated.`); + } + }) + .catch((err) => { + this.logger.error(`Failed to escalate dispute ${dispute.id}.`); + + // TODO: notify + + throw err; + }); + } + + /** + * Gets the first accepted response based on its creation timestamp + * + * @param blockNumber current block number + * @returns a `Response` instance if any accepted, otherwise `undefined` + */ + private getAcceptedResponse(blockNumber: bigint): Response | undefined { + const responses = this.registry.getResponses(); + const acceptedResponses = responses.filter((response) => + this.isResponseAccepted(response, blockNumber), + ); + + return acceptedResponses.sort((a, b) => { + if (a.createdAt < b.createdAt) return -1; + if (a.createdAt > b.createdAt) return 1; + + return 0; + })[0]; + } + + // TODO: refactor outside this module + private isResponseAccepted(response: Response, blockNumber: bigint) { + const request = this.getActorRequest(); + const dispute = this.registry.getResponseDispute(response); + const disputeWindow = + response.createdAt + request.prophetData.responseModuleData.disputeWindow; + + // Response is still able to be disputed + if (blockNumber <= disputeWindow) return false; + + return dispute ? dispute.status === "Lost" : true; } /** @@ -117,7 +271,7 @@ export class EboActor { prophetData: event.metadata.request, }; - this.registry.addRequest(event.metadata.requestId, request); + this.registry.addRequest(request); if (this.anyActiveProposal()) { // Skipping new proposal until the actor receives a ResponseDisputed event; @@ -165,7 +319,8 @@ export class EboActor { block: blockNumber, }; - for (const [responseId, proposedResponse] of responses) { + for (const proposedResponse of responses) { + const responseId = proposedResponse.id; const proposedBody = proposedResponse.prophetData.response; if (this.equalResponses(proposedBody, newResponse)) { @@ -245,7 +400,7 @@ export class EboActor { const response: Response = { id: event.metadata.responseId, - wasDisputed: false, // All responses are created undisputed + createdAt: event.blockNumber, prophetData: event.metadata.response, }; @@ -321,6 +476,7 @@ export class EboActor { const dispute: Dispute = { id: event.metadata.disputeId, + createdAt: event.blockNumber, status: "Active", prophetData: event.metadata.dispute, }; diff --git a/packages/automated-dispute/src/eboMemoryRegistry.ts b/packages/automated-dispute/src/eboMemoryRegistry.ts index a33de9a..a2ea426 100644 --- a/packages/automated-dispute/src/eboMemoryRegistry.ts +++ b/packages/automated-dispute/src/eboMemoryRegistry.ts @@ -1,21 +1,22 @@ import { DisputeNotFound } from "./exceptions/eboRegistry/disputeNotFound.js"; import { EboRegistry } from "./interfaces/eboRegistry.js"; -import { Dispute, DisputeStatus, Request, Response } from "./types/prophet.js"; +import { Dispute, DisputeStatus, Request, RequestId, Response } from "./types/prophet.js"; export class EboMemoryRegistry implements EboRegistry { constructor( - private requests: Map = new Map(), + private requests: Map = new Map(), private responses: Map = new Map(), + private responsesDisputes: Map = new Map(), private disputes: Map = new Map(), ) {} /** @inheritdoc */ - public addRequest(requestId: string, request: Request) { - this.requests.set(requestId, request); + public addRequest(request: Request) { + this.requests.set(request.id, request); } /** @inheritdoc */ - public getRequest(requestId: string) { + public getRequest(requestId: RequestId) { return this.requests.get(requestId); } @@ -26,7 +27,7 @@ export class EboMemoryRegistry implements EboRegistry { /** @inheritdoc */ public getResponses() { - return this.responses; + return [...this.responses.values()]; } /** @inheritdoc */ @@ -37,6 +38,12 @@ export class EboMemoryRegistry implements EboRegistry { /** @inheritdoc */ public addDispute(disputeId: string, dispute: Dispute): void { this.disputes.set(disputeId, dispute); + this.responsesDisputes.set(dispute.prophetData.responseId, dispute.id); + } + + /** @inheritdoc */ + public getDisputes(): Dispute[] { + return [...this.disputes.values()]; } /** @inheritdoc */ @@ -44,6 +51,15 @@ export class EboMemoryRegistry implements EboRegistry { return this.disputes.get(disputeId); } + /** @inheritdoc */ + public getResponseDispute(response: Response): Dispute | undefined { + const disputeId = this.responsesDisputes.get(response.id); + + if (!disputeId) return undefined; + + return this.getDispute(disputeId); + } + /** @inheritdoc */ public updateDisputeStatus(disputeId: string, status: DisputeStatus): void { const dispute = this.getDispute(disputeId); diff --git a/packages/automated-dispute/src/exceptions/eboActor/disputeWithoutResponse.exception.ts b/packages/automated-dispute/src/exceptions/eboActor/disputeWithoutResponse.exception.ts new file mode 100644 index 0000000..d21b328 --- /dev/null +++ b/packages/automated-dispute/src/exceptions/eboActor/disputeWithoutResponse.exception.ts @@ -0,0 +1,7 @@ +import { Dispute } from "../../types/prophet.js"; + +export class DisputeWithoutResponse extends Error { + constructor(dispute: Dispute) { + super(`Response not found for dispute ${dispute.id}.`); + } +} diff --git a/packages/automated-dispute/src/exceptions/eboActor/index.ts b/packages/automated-dispute/src/exceptions/eboActor/index.ts new file mode 100644 index 0000000..0997bf2 --- /dev/null +++ b/packages/automated-dispute/src/exceptions/eboActor/index.ts @@ -0,0 +1 @@ +export * from "./disputeWithoutResponse.exception.js"; diff --git a/packages/automated-dispute/src/interfaces/eboRegistry.ts b/packages/automated-dispute/src/interfaces/eboRegistry.ts index 39048af..a7a5213 100644 --- a/packages/automated-dispute/src/interfaces/eboRegistry.ts +++ b/packages/automated-dispute/src/interfaces/eboRegistry.ts @@ -1,14 +1,13 @@ -import { Dispute, DisputeStatus, Request, Response } from "../types/prophet.js"; +import { Dispute, DisputeStatus, Request, RequestId, Response } from "../types/prophet.js"; /** Registry that stores Prophet entities (ie. requests, responses and disputes) */ export interface EboRegistry { /** * Add a `Request` by ID. * - * @param requestId the ID of the `Request` to use as index * @param request the `Request` */ - addRequest(requestId: string, request: Request): void; + addRequest(request: Request): void; /** * Get a `Request` by ID. @@ -16,7 +15,7 @@ export interface EboRegistry { * @param requestId request ID * @returns the request if already added into registry, `undefined` otherwise */ - getRequest(requestId: string): Request | undefined; + getRequest(requestId: RequestId): Request | undefined; /** * Add a `Response` by ID. @@ -29,9 +28,16 @@ export interface EboRegistry { /** * Return all responses * - * @returns responses map + * @returns responses array */ - getResponses(): Map; + getResponses(): Response[]; + + /** + * Return the dispute of a response + * + * @returns a dispute if the response has been disputed, `undefined` otherwise + */ + getResponseDispute(response: Response): Dispute | undefined; /** * Get a `Response` by ID. @@ -49,6 +55,13 @@ export interface EboRegistry { */ addDispute(disputeId: string, dispute: Dispute): void; + /** + * Get all disputes + * + * @returns an array of `Dispute` instances + */ + getDisputes(): Dispute[]; + /** * Get a `Dispute` by ID. * diff --git a/packages/automated-dispute/src/protocolProvider.ts b/packages/automated-dispute/src/protocolProvider.ts index a9afd87..b54bcaa 100644 --- a/packages/automated-dispute/src/protocolProvider.ts +++ b/packages/automated-dispute/src/protocolProvider.ts @@ -91,27 +91,7 @@ export class ProtocolProvider { // // We should decode events using the corresponding ABI and also "fabricate" new events // if for some triggers there are no events (e.g. dispute window ended) - const eboRequestCreatorEvents = [ - { - name: "RequestCreated", - blockNumber: 1n, - logIndex: 1, - requestId: "0x01", - metadata: { - requestId: "0x01", - chainId: "eip155:1", - epoch: 1n, - request: { - requester: "0x12345678901234567890123456789012", - requestModule: "0x12345678901234567890123456789012", - responseModule: "0x12345678901234567890123456789012", - disputeModule: "0x12345678901234567890123456789012", - resolutionModule: "0x12345678901234567890123456789012", - finalityModule: "0x12345678901234567890123456789012", - }, - }, - } as EboEvent<"RequestCreated">, - ]; + const eboRequestCreatorEvents: EboEvent[] = []; const oracleEvents = [ { @@ -237,10 +217,31 @@ export class ProtocolProvider { return; } + async settleDispute( + _request: Request["prophetData"], + _response: Response["prophetData"], + _dispute: Dispute["prophetData"], + ): Promise { + // TODO: implement actual method + return; + } + + async escalateDispute( + _request: Request["prophetData"], + _response: Response["prophetData"], + _dispute: Dispute["prophetData"], + ): Promise { + // TODO: implement actual method + return; + } + // Pending confirmation from onchain team // releasePledge(args):void; - async finalize(_request: Request, _response: Response): Promise { + async finalize( + _request: Request["prophetData"], + _response: Response["prophetData"], + ): Promise { //TODO: implement actual method return; } diff --git a/packages/automated-dispute/src/services/index.ts b/packages/automated-dispute/src/services/index.ts index 448cab7..3eeb0b8 100644 --- a/packages/automated-dispute/src/services/index.ts +++ b/packages/automated-dispute/src/services/index.ts @@ -1 +1,2 @@ +export * from "../eboActor.js"; export * from "./eboProcessor.js"; diff --git a/packages/automated-dispute/src/types/prophet.ts b/packages/automated-dispute/src/types/prophet.ts index 3d330ce..ff96bc2 100644 --- a/packages/automated-dispute/src/types/prophet.ts +++ b/packages/automated-dispute/src/types/prophet.ts @@ -18,12 +18,29 @@ export interface Request { disputeModule: Address; resolutionModule: Address; finalityModule: Address; + // Modules' data + responseModuleData: { + accountingExtension: Address; + bondToken: Address; + bondSize: bigint; + deadline: bigint; + disputeWindow: bigint; + }; + disputeModuleData: { + accountingExtension: Address; + bondToken: Address; + bondSize: bigint; + maxNumberOfEscalations: bigint; + bondEscalationDeadline: bigint; + tyingBuffer: bigint; + disputeWindow: bigint; + }; }>; } export interface Response { id: string; - wasDisputed: boolean; + createdAt: bigint; prophetData: Readonly<{ proposer: Address; @@ -44,6 +61,7 @@ export type DisputeStatus = "None" | "Active" | "Escalated" | "Won" | "Lost" | " export interface Dispute { id: string; + createdAt: bigint; status: DisputeStatus; prophetData: { diff --git a/packages/automated-dispute/tests/eboActor/fixtures.ts b/packages/automated-dispute/tests/eboActor/fixtures.ts index 12ec0fc..ca6c0d0 100644 --- a/packages/automated-dispute/tests/eboActor/fixtures.ts +++ b/packages/automated-dispute/tests/eboActor/fixtures.ts @@ -20,5 +20,21 @@ export const DEFAULT_MOCKED_REQUEST_CREATED_DATA: Request = { resolutionModule: "0x04" as Address, responseModule: "0x05" as Address, requester: "0x10" as Address, + responseModuleData: { + accountingExtension: "0x01" as Address, + bondToken: "0x02" as Address, + bondSize: 1n, + deadline: 10n, + disputeWindow: 1n, + }, + disputeModuleData: { + accountingExtension: "0x01" as Address, + bondToken: "0x01" as Address, + bondEscalationDeadline: 5n, + bondSize: 1n, + disputeWindow: 1n, + maxNumberOfEscalations: 5n, + tyingBuffer: 1n, + }, }, }; diff --git a/packages/automated-dispute/tests/eboActor/onLastBlockupdated.spec.ts b/packages/automated-dispute/tests/eboActor/onLastBlockupdated.spec.ts new file mode 100644 index 0000000..40f3588 --- /dev/null +++ b/packages/automated-dispute/tests/eboActor/onLastBlockupdated.spec.ts @@ -0,0 +1,185 @@ +import { ContractFunctionRevertedError } from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import { DisputeWithoutResponse } from "../../src/exceptions/eboActor/disputeWithoutResponse.exception"; +import mocks from "../mocks"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures"; + +const logger = mocks.mockLogger(); + +describe("EboActor", () => { + describe("onLastBlockUpdated", () => { + it("settles all disputes when escalation deadline and tying buffer passed", async () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const { disputeModuleData } = request.prophetData; + + const response = mocks.buildResponse(request, { id: "0x10" }); + const dispute = mocks.buildDispute(request, response, { createdAt: 1n }); + const disputeDeadline = + disputeModuleData.bondEscalationDeadline + disputeModuleData.tyingBuffer; + + const { actor, registry, protocolProvider } = mocks.buildEboActor(request, logger); + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponse").mockImplementation((id) => { + switch (id) { + case response.id: + return response; + } + }); + // Skipping finalize flow with this mock + vi.spyOn(registry, "getResponses").mockReturnValue([]); + vi.spyOn(registry, "getDisputes").mockReturnValue([dispute]); + + const mockSettleDispute = vi + .spyOn(protocolProvider, "settleDispute") + .mockImplementation(() => Promise.resolve()); + + const newBlockNumber = disputeDeadline + 1n; + + await actor.onLastBlockUpdated(newBlockNumber); + + expect(mockSettleDispute).toHaveBeenCalledWith( + request.prophetData, + response.prophetData, + dispute.prophetData, + ); + }); + + it("escalates dispute if cannot settle", async () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const { disputeModuleData } = request.prophetData; + + const response = mocks.buildResponse(request, { id: "0x10" }); + const dispute = mocks.buildDispute(request, response, { createdAt: 1n }); + const disputeDeadline = + disputeModuleData.bondEscalationDeadline + disputeModuleData.tyingBuffer; + + const { actor, registry, protocolProvider } = mocks.buildEboActor(request, logger); + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponse").mockImplementation((id) => { + switch (id) { + case response.id: + return response; + } + }); + // Skipping finalize flow with this mock + vi.spyOn(registry, "getResponses").mockReturnValue([]); + vi.spyOn(registry, "getDisputes").mockReturnValue([dispute]); + + const error = Object.create(ContractFunctionRevertedError.prototype); + error.data = { errorName: "BondEscalationModule_ShouldBeEscalated" }; + + const mockSettleDispute = vi + .spyOn(protocolProvider, "settleDispute") + .mockImplementation(async () => { + throw error; + }); + + const mockEscalateDispute = vi + .spyOn(protocolProvider, "escalateDispute") + .mockImplementation(() => Promise.resolve()); + + const newBlockNumber = disputeDeadline + 1n; + + await actor.onLastBlockUpdated(newBlockNumber); + + expect(mockSettleDispute).toHaveBeenCalledWith( + request.prophetData, + response.prophetData, + dispute.prophetData, + ); + + expect(mockEscalateDispute).toHaveBeenCalledWith( + request.prophetData, + response.prophetData, + dispute.prophetData, + ); + }); + + it("throws if the dispute has no response in registry", async () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const { disputeModuleData } = request.prophetData; + + const response = mocks.buildResponse(request, { id: "0x10" }); + const dispute = mocks.buildDispute(request, response, { createdAt: 1n }); + const disputeDeadline = + disputeModuleData.bondEscalationDeadline + disputeModuleData.tyingBuffer; + + const { actor, registry } = mocks.buildEboActor(request, logger); + + 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]); + + const newBlockNumber = disputeDeadline + 1n; + + expect(actor.onLastBlockUpdated(newBlockNumber)).rejects.toThrow( + DisputeWithoutResponse, + ); + }); + + it.skip("notifies dispute escalation"); + + it("logs and returns when response deadline has not been reached", async () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const response = mocks.buildResponse(request, { id: "0x10" }); + + const { responseModuleData } = request.prophetData; + const deadline = responseModuleData.deadline; + + const { actor, registry, protocolProvider } = mocks.buildEboActor(request, logger); + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponse").mockImplementation((id) => { + switch (id) { + case response.id: + return response; + } + }); + + const newBlockNumber = deadline - 1n; + const mockFinalize = vi.spyOn(protocolProvider, "finalize"); + + await actor.onLastBlockUpdated(newBlockNumber); + + expect(logger.debug).toBeCalledWith( + expect.stringMatching(`Proposal window for request ${request.id} not closed yet.`), + ); + + expect(mockFinalize).not.toHaveBeenCalled(); + }); + + it("finalizes the request using the first accepted response", async () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const firstResponse = mocks.buildResponse(request, { id: "0x10", createdAt: 5n }); + const firstResponseDispute = mocks.buildDispute(request, firstResponse, { + status: "Lost", + }); + const secondResponse = mocks.buildResponse(request, { id: "0x11", createdAt: 10n }); + + const { actor, registry, protocolProvider } = mocks.buildEboActor(request, logger); + + const reverseResponses = [secondResponse, firstResponse]; + + vi.spyOn(registry, "getRequest").mockReturnValue(request); + vi.spyOn(registry, "getResponses").mockReturnValue(reverseResponses); + vi.spyOn(registry, "getDispute").mockReturnValue(firstResponseDispute); + + const mockFinalize = vi.spyOn(protocolProvider, "finalize"); + + const newBlock = + secondResponse.createdAt + request.prophetData.responseModuleData.disputeWindow; + + await actor.onLastBlockUpdated(newBlock + 1n); + + expect(mockFinalize).toHaveBeenCalledWith( + request.prophetData, + firstResponse.prophetData, + ); + }); + }); +}); diff --git a/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts b/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts index 633d99d..5341021 100644 --- a/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts @@ -4,11 +4,11 @@ import { ILogger } from "@ebo-agent/shared"; import { Address } from "viem"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { EboActor } from "../../src/eboActor.js"; import { EboMemoryRegistry } from "../../src/eboMemoryRegistry.js"; import { RequestMismatch } from "../../src/exceptions/index.js"; import { ProtocolProvider } from "../../src/protocolProvider.js"; -import { EboEvent, Response } from "../../src/types/index.js"; +import { EboActor } from "../../src/services/index.js"; +import { EboEvent } from "../../src/types/index.js"; import mocks from "../mocks/index.js"; import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS, @@ -131,22 +131,21 @@ describe("EboActor", () => { logger, ); - const previousResponses = new Map(); - previousResponses.set("0x01", { - id: "0x01", - wasDisputed: false, - prophetData: { - proposer: "0x02", - requestId: requestId, - response: { - block: indexedEpochBlockNumber, - chainId: requestCreatedEvent.metadata.chainId, - epoch: protocolEpoch.currentEpoch, + vi.spyOn(registry, "getResponses").mockReturnValue([ + { + id: "0x01", + createdAt: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), + prophetData: { + proposer: "0x02", + requestId: requestId, + response: { + block: indexedEpochBlockNumber, + chainId: requestCreatedEvent.metadata.chainId, + epoch: protocolEpoch.currentEpoch, + }, }, }, - }); - - vi.spyOn(registry, "getResponses").mockReturnValue(previousResponses); + ]); await actor.onRequestCreated(requestCreatedEvent); diff --git a/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts b/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts index 5c66e78..d602660 100644 --- a/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts @@ -2,11 +2,11 @@ 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 { InvalidActorState } from "../../src/exceptions/invalidActorState.exception.js"; +import { EboEvent } from "../../src/types/events.js"; +import { Response } from "../../src/types/prophet.js"; import mocks from "../mocks/index.js"; -import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.js"; const logger: ILogger = mocks.mockLogger(); diff --git a/packages/automated-dispute/tests/mocks/eboActor.mocks.ts b/packages/automated-dispute/tests/mocks/eboActor.mocks.ts index 5c7ac0b..a1e1f1f 100644 --- a/packages/automated-dispute/tests/mocks/eboActor.mocks.ts +++ b/packages/automated-dispute/tests/mocks/eboActor.mocks.ts @@ -58,7 +58,7 @@ export function buildEboActor(request: Request, logger: ILogger) { export function buildResponse(request: Request, attributes: Partial = {}): Response { const baseResponse: Response = { id: "0x01", - wasDisputed: false, + createdAt: request.createdAt + 1n, prophetData: { proposer: "0x01", requestId: request.id, @@ -84,6 +84,7 @@ export function buildDispute( const baseDispute: Dispute = { id: "0x01", status: "Active", + createdAt: response.createdAt + 1n, prophetData: { disputer: "0x01", proposer: response.prophetData.proposer, From d86434d6fb97dd44361868490d9fb8f8a52752fa Mon Sep 17 00:00:00 2001 From: 0xyaco Date: Tue, 3 Sep 2024 17:05:33 -0300 Subject: [PATCH 11/11] feat: actor as event queues (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 🤖 Linear Closes GRT-58 ## Description * Changes `EboActor` public methods to only `enqueue` and `processEvents` * Enqueues corresponding events into a heap inside the `EboActor` instances to process them later * Creates a new interface `EboRegistryCommand` implementing the _Command_ design pattern to be able to rollback internal `EboActor` state if an RPC call fails after updating the state, simulating a "transaction". * Refactor actor creation to remove dependency with `protocolProvider` during this method ### EboActor processEvents flow ```mermaid flowchart LR start(((start))) peekNextEvent[Peek next event] noMoreEvents?{No more events?} updateActorState[Update actor state] rpcCall[Execute RPC] rpcFailed?{RPC failed?} rollbackStateUpdate[Rollback state update] popProcessedEvent[Pop processed event] _end(((end))) start-->peekNextEvent peekNextEvent-->noMoreEvents? noMoreEvents?-->|true|_end noMoreEvents?-->|false|updateActorState updateActorState-->rpcCall rpcCall-->rpcFailed? rpcFailed?-->|yes|rollbackStateUpdate rollbackStateUpdate-- Will retry during next periodic check -->_end rpcFailed?-->|no|popProcessedEvent popProcessedEvent-->peekNextEvent ``` --- packages/automated-dispute/package.json | 2 + packages/automated-dispute/src/eboActor.ts | 169 +++++++- .../automated-dispute/src/eboActorsManager.ts | 8 +- .../src/exceptions/eboActor/index.ts | 2 + .../pastEventEnqueueError.exception.ts | 10 + .../eboActor/unknownEvent.exception.ts | 7 + .../eboRegistry/commandAlreadyRun.ts | 7 + .../exceptions/eboRegistry/commandNotRun.ts | 7 + .../src/exceptions/eboRegistry/index.ts | 2 + .../automated-dispute/src/exceptions/index.ts | 2 + .../src/interfaces/eboRegistry.ts | 19 +- .../src/interfaces/eboRegistryCommand.ts | 11 + .../automated-dispute/src/interfaces/index.ts | 2 + .../automated-dispute/src/protocolProvider.ts | 7 - .../src/services/eboProcessor.ts | 75 ++-- .../eboRegistry/commands/addRequest.ts | 40 ++ .../eboRegistry/commands/addResponse.ts | 35 ++ .../services/eboRegistry/commands/index.ts | 4 + .../eboRegistry}/eboMemoryRegistry.ts | 20 +- .../src/services/eboRegistry/index.ts | 2 + .../automated-dispute/src/services/index.ts | 1 + .../automated-dispute/src/types/events.ts | 4 +- .../automated-dispute/src/types/prophet.ts | 3 +- .../tests/eboActor/fixtures.ts | 1 - .../eboActor/onDisputeStatusChanged.spec.ts | 2 +- .../tests/eboActor/onRequestCreated.spec.ts | 380 ++++++++++-------- .../tests/eboActor/onRequestFinalized.spec.ts | 2 +- .../tests/eboActor/onResponseDisputed.spec.ts | 2 +- .../tests/eboActor/onResponseProposed.spec.ts | 136 ++++--- .../tests/eboActorsManager.spec.ts | 3 - .../tests/mocks/eboActor.mocks.ts | 11 +- .../tests/services/eboActor.spec.ts | 244 +++++++++++ .../commands/addRequest.spec.ts | 75 ++++ .../commands/addResponse.spec.ts | 72 ++++ .../tests/services/eboProcessor.spec.ts | 106 +---- pnpm-lock.yaml | 283 +++++++------ 36 files changed, 1214 insertions(+), 542 deletions(-) create mode 100644 packages/automated-dispute/src/exceptions/eboActor/pastEventEnqueueError.exception.ts create mode 100644 packages/automated-dispute/src/exceptions/eboActor/unknownEvent.exception.ts create mode 100644 packages/automated-dispute/src/exceptions/eboRegistry/commandAlreadyRun.ts create mode 100644 packages/automated-dispute/src/exceptions/eboRegistry/commandNotRun.ts create mode 100644 packages/automated-dispute/src/interfaces/eboRegistryCommand.ts create mode 100644 packages/automated-dispute/src/interfaces/index.ts create mode 100644 packages/automated-dispute/src/services/eboRegistry/commands/addRequest.ts create mode 100644 packages/automated-dispute/src/services/eboRegistry/commands/addResponse.ts create mode 100644 packages/automated-dispute/src/services/eboRegistry/commands/index.ts rename packages/automated-dispute/src/{ => services/eboRegistry}/eboMemoryRegistry.ts (79%) create mode 100644 packages/automated-dispute/src/services/eboRegistry/index.ts create mode 100644 packages/automated-dispute/tests/services/eboActor.spec.ts create mode 100644 packages/automated-dispute/tests/services/eboMemoryRegistry/commands/addRequest.spec.ts create mode 100644 packages/automated-dispute/tests/services/eboMemoryRegistry/commands/addResponse.spec.ts diff --git a/packages/automated-dispute/package.json b/packages/automated-dispute/package.json index a23558c..1a50a4b 100644 --- a/packages/automated-dispute/package.json +++ b/packages/automated-dispute/package.json @@ -19,6 +19,8 @@ "dependencies": { "@ebo-agent/blocknumber": "workspace:*", "@ebo-agent/shared": "workspace:*", + "async-mutex": "0.5.0", + "heap-js": "2.5.0", "viem": "2.17.11" } } diff --git a/packages/automated-dispute/src/eboActor.ts b/packages/automated-dispute/src/eboActor.ts index 3589ba0..5e8c1ee 100644 --- a/packages/automated-dispute/src/eboActor.ts +++ b/packages/automated-dispute/src/eboActor.ts @@ -1,32 +1,66 @@ import { BlockNumberService } from "@ebo-agent/blocknumber"; import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types.js"; import { ILogger } from "@ebo-agent/shared"; +import { Mutex } from "async-mutex"; +import { Heap } from "heap-js"; import { ContractFunctionRevertedError } from "viem"; import { DisputeWithoutResponse } from "./exceptions/eboActor/disputeWithoutResponse.exception.js"; import { InvalidActorState, InvalidDisputeStatus, + PastEventEnqueueError, RequestMismatch, ResponseAlreadyProposed, + UnknownEvent, } from "./exceptions/index.js"; -import { EboRegistry } from "./interfaces/eboRegistry.js"; +import { EboRegistry, EboRegistryCommand } from "./interfaces/index.js"; import { ProtocolProvider } from "./protocolProvider.js"; -import { EboEvent, EboEventName } from "./types/events.js"; -import { Dispute, Request, RequestId, Response, ResponseBody } from "./types/prophet.js"; +import { AddRequest, AddResponse } from "./services/index.js"; +import { + Dispute, + EboEvent, + EboEventName, + Request, + RequestId, + Response, + ResponseBody, +} from "./types/index.js"; + +/** + * Compare function to sort events chronologically in ascending order by block number + * and log index. + * + * @param e1 EBO event + * @param e2 EBO event + * @returns 1 if `e2` is older than `e1`, -1 if `e1` is older than `e2`, 0 otherwise + */ +const EBO_EVENT_COMPARATOR = (e1: EboEvent, e2: EboEvent) => { + if (e1.blockNumber > e2.blockNumber) return 1; + if (e1.blockNumber < e2.blockNumber) return -1; + + return e1.logIndex - e2.logIndex; +}; /** * Actor that handles a singular Prophet's request asking for the block number that corresponds * to an instant on an indexed chain. */ export class EboActor { + /** + * Events queue that keeps the pending events to be processed. + * + * **NOTE**: the only place where `pop` should be called for the queue is during + * `processEvents` + */ + private readonly eventsQueue: Heap>; + private lastEventProcessed: EboEvent | undefined; + /** * Creates an `EboActor` instance. * * @param actorRequest.id request ID this actor will handle * @param actorRequest.epoch requested epoch - * @param actorRequest.epoch requested epoch's timestamp - * @param onTerminate callback to be run when this instance is being terminated * @param protocolProvider a `ProtocolProvider` instance * @param blockNumberService a `BlockNumberService` instance * @param registry an `EboRegistry` instance @@ -36,22 +70,115 @@ export class EboActor { private readonly actorRequest: { id: RequestId; epoch: bigint; - epochTimestamp: bigint; }, private readonly protocolProvider: ProtocolProvider, private readonly blockNumberService: BlockNumberService, private readonly registry: EboRegistry, + private readonly eventProcessingMutex: Mutex, private readonly logger: ILogger, - ) {} + ) { + this.eventsQueue = new Heap(EBO_EVENT_COMPARATOR); + } + + /** + * Enqueue events to be processed by the actor. + * + * @param event EBO event + */ + public enqueue(event: EboEvent): void { + if (this.shouldHandleRequest(event.requestId)) { + this.logger.error(`The request ${event.requestId} is not handled by this actor.`); + + throw new RequestMismatch(this.actorRequest.id, event.requestId); + } + + if (this.lastEventProcessed) { + const isPastEvent = EBO_EVENT_COMPARATOR(this.lastEventProcessed, event) >= 0; + + if (isPastEvent) throw new PastEventEnqueueError(this.lastEventProcessed, event); + } + + this.eventsQueue.push(event); + } + + /** + * Process all enqueued events synchronously and sequentially, based on their block numbers. + * + * The processing will update the internal state of the actor and, if the event is the most + * recent one, it will try to react to it by interacting with the protocol smart contracts. + * + * An error thrown after updating the internal state will cause a rollback for the internal + * state update and will keep the not-processed yet events, so those can be retried in the + * future, where there are two scenarios: + * + * 1) New events were fetched, the failing event was handled by another agent and event + * processing resumes. + * 2) No new events were fetched, the failing event processing will be retried until it + * succeeds, new events are fetched or the actor expires. + * + * The actor is supposed to process the events until the request expires somehow. + * + * @throws {RequestMismatch} when an event from another request was enqueued in this actor + */ + public processEvents(): Promise { + // TODO: check for actor expiration (ie if it makes no sense to still handle the request events) + + return this.eventProcessingMutex.runExclusive(async () => { + let event: EboEvent | undefined; + + while ((event = this.eventsQueue.pop())) { + this.lastEventProcessed = event; + + const updateStateCommand = this.buildUpdateStateCommand(event); + + updateStateCommand.run(); + + try { + if (this.eventsQueue.isEmpty()) { + // `event` is the last and most recent event thus + // it needs to run some RPCs to keep Prophet's flow going on + await this.onNewEvent(event); + } + } catch (err) { + this.logger.error(`Error processing event ${event.name}: ${err}`); + + // Enqueue the event again as it's supposed to be reprocessed + this.eventsQueue.push(event); + + // Undo last state update + updateStateCommand.undo(); + + return; + } + } + }); + } /** * Update internal state for Request, Response and Dispute instances. * + * TODO: move to a Factory and use it as EboActor dependency, right now we have to mock + * this private method which is eww + * * @param _event EBO event */ - public updateState(_event: EboEvent) { - // TODO - throw new Error("Implement me"); + private buildUpdateStateCommand(event: EboEvent): EboRegistryCommand { + switch (event.name) { + case "RequestCreated": + return AddRequest.buildFromEvent( + event as EboEvent<"RequestCreated">, + this.registry, + ); + + case "ResponseProposed": + return AddResponse.buildFromEvent( + event as EboEvent<"ResponseProposed">, + this.registry, + ); + + default: + throw new UnknownEvent(event.name); + } } /** @@ -61,9 +188,9 @@ export class EboActor { * * @param _event EBO event */ - public onNewEvent(_event: EboEvent) { + private async onNewEvent(_event: EboEvent) { // TODO - throw new Error("Implement me"); + return; } /** @@ -266,7 +393,6 @@ export class EboActor { id: this.actorRequest.id, chainId: event.metadata.chainId, epoch: this.actorRequest.epoch, - epochTimestamp: this.actorRequest.epochTimestamp, createdAt: event.blockNumber, prophetData: event.metadata.request, }; @@ -342,8 +468,12 @@ export class EboActor { * @returns a response body */ private async buildResponse(chainId: Caip2ChainId): Promise { + // FIXME(non-current epochs): adapt this code to fetch timestamps corresponding + // to the first block of any epoch, not just the current epoch + const { currentEpochTimestamp } = await this.protocolProvider.getCurrentEpoch(); + const epochBlockNumber = await this.blockNumberService.getEpochBlockNumber( - this.actorRequest.epochTimestamp, + currentEpochTimestamp, chainId, ); @@ -404,7 +534,7 @@ export class EboActor { prophetData: event.metadata.response, }; - this.registry.addResponse(event.metadata.responseId, response); + this.registry.addResponse(response); const eventResponse = event.metadata.response; const actorResponse = await this.buildResponse(eventResponse.response.chainId); @@ -441,15 +571,10 @@ export class EboActor { * Validate that the actor should handle the request by its ID. * * @param requestId request ID + * @returns `true` if the actor is handling the request, `false` otherwise */ private shouldHandleRequest(requestId: string) { - if (this.actorRequest.id.toLowerCase() !== requestId.toLowerCase()) { - this.logger.error(`The request ${requestId} is not handled by this actor.`); - - // We want to fail the actor as receiving events from other requests - // will most likely cause a corrupted state. - throw new InvalidActorState(); - } + return this.actorRequest.id.toLowerCase() !== requestId.toLowerCase(); } /** diff --git a/packages/automated-dispute/src/eboActorsManager.ts b/packages/automated-dispute/src/eboActorsManager.ts index f3e1833..95361ba 100644 --- a/packages/automated-dispute/src/eboActorsManager.ts +++ b/packages/automated-dispute/src/eboActorsManager.ts @@ -1,10 +1,11 @@ import { BlockNumberService } from "@ebo-agent/blocknumber"; import { Address, ILogger } from "@ebo-agent/shared"; +import { Mutex } from "async-mutex"; import { EboActor } from "./eboActor.js"; -import { EboMemoryRegistry } from "./eboMemoryRegistry.js"; import { RequestAlreadyHandled } from "./exceptions/index.js"; import { ProtocolProvider } from "./protocolProvider.js"; +import { EboMemoryRegistry } from "./services/eboRegistry/eboMemoryRegistry.js"; import { RequestId } from "./types/prophet.js"; export class EboActorsManager { @@ -32,7 +33,6 @@ export class EboActorsManager { actorRequest: { id: RequestId; epoch: bigint; - epochTimestamp: bigint; }, protocolProvider: ProtocolProvider, blockNumberService: BlockNumberService, @@ -43,11 +43,15 @@ export class EboActorsManager { if (this.requestActorMap.has(requestId)) throw new RequestAlreadyHandled(requestId); const registry = new EboMemoryRegistry(); + + const eventProcessingMutex = new Mutex(); + const actor = new EboActor( actorRequest, protocolProvider, blockNumberService, registry, + eventProcessingMutex, logger, ); diff --git a/packages/automated-dispute/src/exceptions/eboActor/index.ts b/packages/automated-dispute/src/exceptions/eboActor/index.ts index 0997bf2..21eb485 100644 --- a/packages/automated-dispute/src/exceptions/eboActor/index.ts +++ b/packages/automated-dispute/src/exceptions/eboActor/index.ts @@ -1 +1,3 @@ export * from "./disputeWithoutResponse.exception.js"; +export * from "./pastEventEnqueueError.exception.js"; +export * from "./unknownEvent.exception.js"; diff --git a/packages/automated-dispute/src/exceptions/eboActor/pastEventEnqueueError.exception.ts b/packages/automated-dispute/src/exceptions/eboActor/pastEventEnqueueError.exception.ts new file mode 100644 index 0000000..75ea507 --- /dev/null +++ b/packages/automated-dispute/src/exceptions/eboActor/pastEventEnqueueError.exception.ts @@ -0,0 +1,10 @@ +import { EboEvent, EboEventName } from "../../types/index.js"; + +export class PastEventEnqueueError extends Error { + constructor(lastEvent: EboEvent, enqueuedEvent: EboEvent) { + super( + `Cannot enqueue event ${enqueuedEvent.name} at block ${enqueuedEvent.blockNumber} ` + + `as it's older than the last processed event ${lastEvent.name} at block ${lastEvent.blockNumber}.`, + ); + } +} diff --git a/packages/automated-dispute/src/exceptions/eboActor/unknownEvent.exception.ts b/packages/automated-dispute/src/exceptions/eboActor/unknownEvent.exception.ts new file mode 100644 index 0000000..0919b13 --- /dev/null +++ b/packages/automated-dispute/src/exceptions/eboActor/unknownEvent.exception.ts @@ -0,0 +1,7 @@ +export class UnknownEvent extends Error { + constructor(eventName: string) { + super(`Unknown event: ${eventName}`); + + this.name = "UnknownEvent"; + } +} diff --git a/packages/automated-dispute/src/exceptions/eboRegistry/commandAlreadyRun.ts b/packages/automated-dispute/src/exceptions/eboRegistry/commandAlreadyRun.ts new file mode 100644 index 0000000..26b9f2d --- /dev/null +++ b/packages/automated-dispute/src/exceptions/eboRegistry/commandAlreadyRun.ts @@ -0,0 +1,7 @@ +export class CommandAlreadyRun extends Error { + constructor(commandName: string) { + super(`Command ${commandName} can only be run once.`); + + this.name = "CommandAlreadyRun"; + } +} diff --git a/packages/automated-dispute/src/exceptions/eboRegistry/commandNotRun.ts b/packages/automated-dispute/src/exceptions/eboRegistry/commandNotRun.ts new file mode 100644 index 0000000..f55e027 --- /dev/null +++ b/packages/automated-dispute/src/exceptions/eboRegistry/commandNotRun.ts @@ -0,0 +1,7 @@ +export class CommandNotRun extends Error { + constructor(commandName: string) { + super(`Cannot undo ${commandName} as it was not run yet.`); + + this.name = "CommandNotRun"; + } +} diff --git a/packages/automated-dispute/src/exceptions/eboRegistry/index.ts b/packages/automated-dispute/src/exceptions/eboRegistry/index.ts index ee4060e..a16f691 100644 --- a/packages/automated-dispute/src/exceptions/eboRegistry/index.ts +++ b/packages/automated-dispute/src/exceptions/eboRegistry/index.ts @@ -1 +1,3 @@ +export * from "./commandAlreadyRun.js"; +export * from "./commandNotRun.js"; export * from "./disputeNotFound.js"; diff --git a/packages/automated-dispute/src/exceptions/index.ts b/packages/automated-dispute/src/exceptions/index.ts index 7a3d0ac..f01857f 100644 --- a/packages/automated-dispute/src/exceptions/index.ts +++ b/packages/automated-dispute/src/exceptions/index.ts @@ -1,4 +1,6 @@ +export * from "./eboActor/index.js"; export * from "./eboProcessor/index.js"; +export * from "./eboRegistry/index.js"; export * from "./invalidActorState.exception.js"; export * from "./invalidDisputeStatus.exception.js"; diff --git a/packages/automated-dispute/src/interfaces/eboRegistry.ts b/packages/automated-dispute/src/interfaces/eboRegistry.ts index a7a5213..5b37946 100644 --- a/packages/automated-dispute/src/interfaces/eboRegistry.ts +++ b/packages/automated-dispute/src/interfaces/eboRegistry.ts @@ -17,13 +17,20 @@ export interface EboRegistry { */ getRequest(requestId: RequestId): Request | undefined; + /** + * Remove a `Request` by its ID. + * + * @param requestId request ID + * @returns `true` if the request in the registry existed and has been removed, or `false` if the request does not exist + */ + removeRequest(requestId: string): boolean; + /** * Add a `Response` by ID. * - * @param responseId the ID of the `Response` to use as index * @param response the `Response` */ - addResponse(responseId: string, response: Response): void; + addResponse(response: Response): void; /** * Return all responses @@ -47,6 +54,14 @@ export interface EboRegistry { */ getResponse(responseId: string): Response | undefined; + /** + * Remove a `Response` by its ID. + * + * @param responseId response ID + * @returns `true` if the response in the registry existed and has been removed, or `false` if the response does not exist + */ + removeResponse(responseId: string): boolean; + /** * Add a dispute by ID. * diff --git a/packages/automated-dispute/src/interfaces/eboRegistryCommand.ts b/packages/automated-dispute/src/interfaces/eboRegistryCommand.ts new file mode 100644 index 0000000..0108e3a --- /dev/null +++ b/packages/automated-dispute/src/interfaces/eboRegistryCommand.ts @@ -0,0 +1,11 @@ +export interface EboRegistryCommand { + /** + * Run a command to update the registry + */ + run(): void; + + /** + * Undo a command that has been run + */ + undo(): void; +} diff --git a/packages/automated-dispute/src/interfaces/index.ts b/packages/automated-dispute/src/interfaces/index.ts new file mode 100644 index 0000000..38d57ab --- /dev/null +++ b/packages/automated-dispute/src/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from "./eboRegistry.js"; +export * from "./eboRegistryCommand.js"; diff --git a/packages/automated-dispute/src/protocolProvider.ts b/packages/automated-dispute/src/protocolProvider.ts index b54bcaa..68608ea 100644 --- a/packages/automated-dispute/src/protocolProvider.ts +++ b/packages/automated-dispute/src/protocolProvider.ts @@ -130,13 +130,6 @@ export class ProtocolProvider { }, }, } as EboEvent<"ResponseDisputed">, - { - name: "DisputeStatusChanged", - blockNumber: 4n, - logIndex: 20, - requestId: "0x01", - metadata: { disputeId: "0x03", status: "Won", blockNumber: 4n }, - } as EboEvent<"DisputeStatusChanged">, ]; return this.mergeEventStreams(eboRequestCreatorEvents, oracleEvents); diff --git a/packages/automated-dispute/src/services/eboProcessor.ts b/packages/automated-dispute/src/services/eboProcessor.ts index 21059e4..08d7205 100644 --- a/packages/automated-dispute/src/services/eboProcessor.ts +++ b/packages/automated-dispute/src/services/eboProcessor.ts @@ -1,7 +1,6 @@ import { BlockNumberService } from "@ebo-agent/blocknumber"; import { Address, ILogger } from "@ebo-agent/shared"; -import { EboActor } from "../eboActor.js"; import { EboActorsManager } from "../eboActorsManager.js"; import { ProcessorAlreadyStarted } from "../exceptions/index.js"; import { ProtocolProvider } from "../protocolProvider.js"; @@ -50,7 +49,9 @@ export class EboProcessor { /** Sync new blocks and their events with their corresponding actors. */ private async sync() { // TODO: detect new epoch by comparing subgraph's data with EpochManager's current epoch - // and trigger a request creation. + // and trigger a request creation if there's no actor handling an request. + // This process should somehow check if there's already a request created for the epoch + // and chain that has no agent assigned and create it if that's the case. if (!this.lastCheckedBlock) { this.lastCheckedBlock = await this.getEpochStartBlock(); @@ -67,18 +68,7 @@ export class EboProcessor { await this.syncRequest(requestId, events, lastBlock); } catch (err) { - // FIXME: to avoid one request bringing down the whole agent if an error is thrown, - // the failing request's actor (if any) will be silently removed. - // - // On the enhancements phase, the processor will try to recover that particular actor, - // if possible, by recreating the actor again and trying to handle all request events. - // - // Consider also the possibility to use Promise.allSettled per nigiri's suggestion. - this.logger.error(`Handling events for ${requestId} caused an error: ${err}`); - - // TODO: notify - - this.actorsManager.deleteActor(requestId); + this.onActorError(requestId, err as Error); } }); @@ -141,7 +131,7 @@ export class EboProcessor { */ private async syncRequest(requestId: RequestId, events: EboEventStream, lastBlock: bigint) { const firstEvent = events[0]; - const actor = await this.getOrCreateActor(requestId, firstEvent); + const actor = this.getOrCreateActor(requestId, firstEvent); if (!actor) { this.logger.warn(droppingUnhandledEventsWarning(requestId)); @@ -149,38 +139,16 @@ export class EboProcessor { return; } - const sortedEvents = events.sort(this.compareByBlockAndLogIndex); + events.forEach((event) => actor.enqueue(event)); - sortedEvents.forEach((event, idx) => { - // NOTE: forEach preserves events' order, DO NOT use a for loop - actor.updateState(event); - - const isLastEvent = idx === events.length - 1; - if (isLastEvent) actor.onNewEvent(event); - }); - - actor.onLastBlockUpdated(lastBlock); + await actor.processEvents(); + await actor.onLastBlockUpdated(lastBlock); if (actor.canBeTerminated()) { this.terminateActor(requestId); } } - /** - * Compare function to sort events chronologically in ascending order by block number - * and log index. - * - * @param e1 EBO event - * @param e2 EBO event - * @returns 1 if `e2` is older than `e1`, -1 if `e1` is older than `e2`, 0 otherwise - */ - private compareByBlockAndLogIndex(e1: EboEvent, e2: EboEvent) { - if (e1.blockNumber > e2.blockNumber) return 1; - if (e1.blockNumber < e2.blockNumber) return -1; - - return e1.logIndex - e2.logIndex; - } - /** * Get the actor handling a specific request. If there's no actor created yet, it's created. * @@ -188,7 +156,7 @@ export class EboProcessor { * @param firstEvent an event to create an actor if it does not exist * @returns the actor handling the specified request */ - private async getOrCreateActor(requestId: RequestId, firstEvent?: EboEvent) { + private getOrCreateActor(requestId: RequestId, firstEvent?: EboEvent) { const actor = this.actorsManager.getActor(requestId); if (actor) return actor; @@ -206,17 +174,12 @@ export class EboProcessor { * Create a new actor based on the data provided by a `RequestCreated` event. * * @param event a `RequestCreated` event - * @returns a new `EboActor` instance + * @returns a new `EboActor` instance, `null` if the actor was not created */ - private async createNewActor(event: EboEvent<"RequestCreated">) { - // FIXME: this is one of the places where we should change - // the processor's behavior if we want to support non-current epochs - const { currentEpochTimestamp } = await this.protocolProvider.getCurrentEpoch(); - + private createNewActor(event: EboEvent<"RequestCreated">) { const actorRequest = { id: Address.normalize(event.requestId), epoch: event.metadata.epoch, - epochTimestamp: currentEpochTimestamp, }; const actor = this.actorsManager.createActor( @@ -229,6 +192,18 @@ export class EboProcessor { return actor; } + private onActorError(requestId: RequestId, error: Error) { + this.logger.error( + `Critical error. Actor event handling request ${requestId} ` + + `threw a non-recoverable error: ${error.message}\n\n` + + `The request ${requestId} will stop being tracked by the system.`, + ); + + // TODO: notify + + this.terminateActor(requestId); + } + /** * Removes the actor from tracking the request. * @@ -248,10 +223,6 @@ export class EboProcessor { } } - private async onActorError(_actor: EboActor, _error: Error) { - // TODO - } - private async notifyError(_error: Error) { // TODO } diff --git a/packages/automated-dispute/src/services/eboRegistry/commands/addRequest.ts b/packages/automated-dispute/src/services/eboRegistry/commands/addRequest.ts new file mode 100644 index 0000000..3517b2b --- /dev/null +++ b/packages/automated-dispute/src/services/eboRegistry/commands/addRequest.ts @@ -0,0 +1,40 @@ +import { CommandAlreadyRun, CommandNotRun } from "../../../exceptions/index.js"; +import { EboRegistry, EboRegistryCommand } from "../../../interfaces/index.js"; +import { EboEvent, Request } from "../../../types/index.js"; + +export class AddRequest implements EboRegistryCommand { + private wasRun: boolean = false; + + private constructor( + private readonly registry: EboRegistry, + private readonly request: Request, + ) {} + + public static buildFromEvent( + event: EboEvent<"RequestCreated">, + registry: EboRegistry, + ): AddRequest { + const request: Request = { + id: event.requestId, + chainId: event.metadata.chainId, + epoch: event.metadata.epoch, + createdAt: event.blockNumber, + prophetData: event.metadata.request, + }; + + return new AddRequest(registry, request); + } + + run(): void { + if (this.wasRun) throw new CommandAlreadyRun(AddRequest.name); + + this.registry.addRequest(this.request); + this.wasRun = true; + } + + undo(): void { + if (!this.wasRun) throw new CommandNotRun(AddRequest.name); + + this.registry.removeRequest(this.request.id); + } +} diff --git a/packages/automated-dispute/src/services/eboRegistry/commands/addResponse.ts b/packages/automated-dispute/src/services/eboRegistry/commands/addResponse.ts new file mode 100644 index 0000000..4beb2ea --- /dev/null +++ b/packages/automated-dispute/src/services/eboRegistry/commands/addResponse.ts @@ -0,0 +1,35 @@ +import { CommandAlreadyRun, CommandNotRun } from "../../../exceptions/index.js"; +import { EboRegistry, EboRegistryCommand } from "../../../interfaces/index.js"; +import { EboEvent, Response } from "../../../types/index.js"; + +export class AddResponse implements EboRegistryCommand { + private wasRun: boolean = false; + + private constructor( + private readonly registry: EboRegistry, + private readonly response: Response, + ) {} + + static buildFromEvent(event: EboEvent<"ResponseProposed">, registry: EboRegistry) { + const response: Response = { + id: event.metadata.responseId, + createdAt: event.blockNumber, + prophetData: event.metadata.response, + }; + + return new AddResponse(registry, response); + } + + run(): void { + if (this.wasRun) throw new CommandAlreadyRun(AddResponse.name); + + this.registry.addResponse(this.response); + this.wasRun = true; + } + + undo(): void { + if (!this.wasRun) throw new CommandNotRun(AddResponse.name); + + this.registry.removeResponse(this.response.id); + } +} diff --git a/packages/automated-dispute/src/services/eboRegistry/commands/index.ts b/packages/automated-dispute/src/services/eboRegistry/commands/index.ts new file mode 100644 index 0000000..8afc923 --- /dev/null +++ b/packages/automated-dispute/src/services/eboRegistry/commands/index.ts @@ -0,0 +1,4 @@ +export * from "./addRequest.js"; +export * from "./addResponse.js"; + +// TODO: add the rest of the commands diff --git a/packages/automated-dispute/src/eboMemoryRegistry.ts b/packages/automated-dispute/src/services/eboRegistry/eboMemoryRegistry.ts similarity index 79% rename from packages/automated-dispute/src/eboMemoryRegistry.ts rename to packages/automated-dispute/src/services/eboRegistry/eboMemoryRegistry.ts index a2ea426..84d14c7 100644 --- a/packages/automated-dispute/src/eboMemoryRegistry.ts +++ b/packages/automated-dispute/src/services/eboRegistry/eboMemoryRegistry.ts @@ -1,6 +1,6 @@ -import { DisputeNotFound } from "./exceptions/eboRegistry/disputeNotFound.js"; -import { EboRegistry } from "./interfaces/eboRegistry.js"; -import { Dispute, DisputeStatus, Request, RequestId, Response } from "./types/prophet.js"; +import { DisputeNotFound } from "../../exceptions/index.js"; +import { EboRegistry } from "../../interfaces/index.js"; +import { Dispute, DisputeStatus, Request, RequestId, Response } from "../../types/index.js"; export class EboMemoryRegistry implements EboRegistry { constructor( @@ -21,8 +21,13 @@ export class EboMemoryRegistry implements EboRegistry { } /** @inheritdoc */ - public addResponse(responseId: string, response: Response): void { - this.responses.set(responseId, response); + public removeRequest(requestId: RequestId): boolean { + return this.requests.delete(requestId); + } + + /** @inheritdoc */ + public addResponse(response: Response): void { + this.responses.set(response.id, response); } /** @inheritdoc */ @@ -35,6 +40,11 @@ export class EboMemoryRegistry implements EboRegistry { return this.responses.get(responseId); } + /** @inheritdoc */ + removeResponse(responseId: string): boolean { + return this.responses.delete(responseId); + } + /** @inheritdoc */ public addDispute(disputeId: string, dispute: Dispute): void { this.disputes.set(disputeId, dispute); diff --git a/packages/automated-dispute/src/services/eboRegistry/index.ts b/packages/automated-dispute/src/services/eboRegistry/index.ts new file mode 100644 index 0000000..7b324b3 --- /dev/null +++ b/packages/automated-dispute/src/services/eboRegistry/index.ts @@ -0,0 +1,2 @@ +export * from "./commands/index.js"; +export * from "./eboMemoryRegistry.js"; diff --git a/packages/automated-dispute/src/services/index.ts b/packages/automated-dispute/src/services/index.ts index 3eeb0b8..e0aa884 100644 --- a/packages/automated-dispute/src/services/index.ts +++ b/packages/automated-dispute/src/services/index.ts @@ -1,2 +1,3 @@ export * from "../eboActor.js"; export * from "./eboProcessor.js"; +export * from "./eboRegistry/index.js"; diff --git a/packages/automated-dispute/src/types/events.ts b/packages/automated-dispute/src/types/events.ts index 62df660..c7218bc 100644 --- a/packages/automated-dispute/src/types/events.ts +++ b/packages/automated-dispute/src/types/events.ts @@ -1,7 +1,7 @@ import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types.js"; import { Log } from "viem"; -import { Dispute, DisputeStatus, Request, Response } from "./prophet.js"; +import { Dispute, DisputeStatus, Request, RequestId, Response } from "./prophet.js"; export type EboEventName = | "NewEpoch" @@ -69,6 +69,6 @@ export type EboEvent = { blockNumber: bigint; logIndex: number; rawLog?: Log; - requestId: string; // Field to use to route events to actors + requestId: RequestId; // Field to use to route events to actors metadata: EboEventData; }; diff --git a/packages/automated-dispute/src/types/prophet.ts b/packages/automated-dispute/src/types/prophet.ts index ff96bc2..d0ded26 100644 --- a/packages/automated-dispute/src/types/prophet.ts +++ b/packages/automated-dispute/src/types/prophet.ts @@ -1,5 +1,5 @@ import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types.js"; -import { NormalizedAddress, Timestamp } from "@ebo-agent/shared"; +import { NormalizedAddress } from "@ebo-agent/shared"; import { Address } from "viem"; export type RequestId = NormalizedAddress; @@ -8,7 +8,6 @@ export interface Request { id: RequestId; chainId: Caip2ChainId; epoch: bigint; - epochTimestamp: Timestamp; createdAt: bigint; prophetData: Readonly<{ diff --git a/packages/automated-dispute/tests/eboActor/fixtures.ts b/packages/automated-dispute/tests/eboActor/fixtures.ts index ca6c0d0..f6aeb65 100644 --- a/packages/automated-dispute/tests/eboActor/fixtures.ts +++ b/packages/automated-dispute/tests/eboActor/fixtures.ts @@ -11,7 +11,6 @@ export const DEFAULT_MOCKED_REQUEST_CREATED_DATA: Request = { id: "0x01" as RequestId, chainId: "eip155:1", epoch: 1n, - epochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), createdAt: 1n, prophetData: { disputeModule: "0x01" as Address, diff --git a/packages/automated-dispute/tests/eboActor/onDisputeStatusChanged.spec.ts b/packages/automated-dispute/tests/eboActor/onDisputeStatusChanged.spec.ts index 33a6f6c..b353df7 100644 --- a/packages/automated-dispute/tests/eboActor/onDisputeStatusChanged.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onDisputeStatusChanged.spec.ts @@ -12,7 +12,7 @@ const logger: ILogger = { debug: vi.fn(), }; -describe("onDisputeStatusChanged", () => { +describe.skip("onDisputeStatusChanged", () => { const actorRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; const response = mocks.buildResponse(actorRequest); diff --git a/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts b/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts index 5341021..f80dee1 100644 --- a/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onRequestCreated.spec.ts @@ -1,14 +1,15 @@ import { BlockNumberService } from "@ebo-agent/blocknumber"; import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types.js"; import { ILogger } from "@ebo-agent/shared"; +import { Mutex } from "async-mutex"; import { Address } from "viem"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { EboMemoryRegistry } from "../../src/eboMemoryRegistry.js"; +import { EboActor } from "../../src/eboActor.js"; import { RequestMismatch } from "../../src/exceptions/index.js"; import { ProtocolProvider } from "../../src/protocolProvider.js"; -import { EboActor } from "../../src/services/index.js"; -import { EboEvent } from "../../src/types/index.js"; +import { EboMemoryRegistry } from "../../src/services/index.js"; +import { EboEvent, Response } from "../../src/types/index.js"; import mocks from "../mocks/index.js"; import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS, @@ -18,123 +19,174 @@ import { const logger: ILogger = mocks.mockLogger(); describe("EboActor", () => { - describe("onRequestCreated", () => { - const requestId: Address = "0x12345"; - const indexedChainId: Caip2ChainId = "eip155:137"; - - const protocolEpoch = { - currentEpoch: 1n, - currentEpochBlockNumber: 1n, - currentEpochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), - }; - - const requestCreatedEvent: EboEvent<"RequestCreated"> = { - blockNumber: 34n, - logIndex: 1, - name: "RequestCreated", - metadata: { - chainId: indexedChainId, - epoch: protocolEpoch.currentEpoch, - requestId: requestId, - request: DEFAULT_MOCKED_REQUEST_CREATED_DATA.prophetData, - }, - }; - - let protocolProvider: ProtocolProvider; - let blockNumberService: BlockNumberService; - let registry: EboMemoryRegistry; - - beforeEach(() => { - protocolProvider = new ProtocolProvider( - ["http://localhost:8538"], - DEFAULT_MOCKED_PROTOCOL_CONTRACTS, - ); - - const chainRpcUrls = new Map(); - chainRpcUrls.set(indexedChainId, ["http://localhost:8539"]); - - blockNumberService = new BlockNumberService(chainRpcUrls, logger); - registry = new EboMemoryRegistry(); - }); - - it("proposes a response", async () => { - const indexedEpochBlockNumber = 48n; - - vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( - indexedEpochBlockNumber, - ); - - const proposeResponseMock = vi.spyOn(protocolProvider, "proposeResponse"); - - proposeResponseMock.mockImplementation( - ( - _requestId: string, - _epoch: bigint, - _chainId: Caip2ChainId, - _blockNumbre: bigint, - ) => Promise.resolve(), - ); + describe("processEvents", () => { + describe("when RequestCreated is enqueued", () => { + const requestId: Address = "0x12345"; + const indexedChainId: Caip2ChainId = "eip155:137"; + + const protocolEpoch = { + currentEpoch: 1n, + currentEpochBlockNumber: 1n, + currentEpochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), + }; - const requestConfig = { - id: requestId, - epoch: protocolEpoch.currentEpoch, - epochTimestamp: protocolEpoch.currentEpochTimestamp, + const requestCreatedEvent: EboEvent<"RequestCreated"> = { + blockNumber: 34n, + requestId: requestId, + logIndex: 1, + name: "RequestCreated", + metadata: { + chainId: indexedChainId, + epoch: protocolEpoch.currentEpoch, + requestId: requestId, + request: DEFAULT_MOCKED_REQUEST_CREATED_DATA.prophetData, + }, }; - const actor = new EboActor( - requestConfig, - protocolProvider, - blockNumberService, - registry, - logger, - ); - - await actor.onRequestCreated(requestCreatedEvent); - - expect(proposeResponseMock).toHaveBeenCalledWith( - requestCreatedEvent.metadata.requestId, - protocolEpoch.currentEpoch, - requestCreatedEvent.metadata.chainId, - indexedEpochBlockNumber, - ); - }); + let protocolProvider: ProtocolProvider; + let blockNumberService: BlockNumberService; + let registry: EboMemoryRegistry; + let eventProcessingMutex: Mutex; - it("does not propose when already proposed the same block", async () => { - const indexedEpochBlockNumber = 48n; + beforeEach(() => { + protocolProvider = new ProtocolProvider( + ["http://localhost:8538"], + DEFAULT_MOCKED_PROTOCOL_CONTRACTS, + ); - vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( - indexedEpochBlockNumber, - ); + const chainRpcUrls = new Map(); + chainRpcUrls.set(indexedChainId, ["http://localhost:8539"]); - const proposeResponseMock = vi.spyOn(protocolProvider, "proposeResponse"); + blockNumberService = new BlockNumberService(chainRpcUrls, logger); + registry = new EboMemoryRegistry(); + eventProcessingMutex = new Mutex(); + }); - proposeResponseMock.mockImplementation( - ( - _requestId: string, - _epoch: bigint, - _chainId: Caip2ChainId, - _blockNumbre: bigint, - ) => Promise.resolve(), - ); + it("stores the new request", async () => { + const indexedEpochBlockNumber = 48n; - const requestConfig = { - id: requestId, - epoch: protocolEpoch.currentEpoch, - epochTimestamp: protocolEpoch.currentEpochTimestamp, - }; + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( + indexedEpochBlockNumber, + ); - const actor = new EboActor( - requestConfig, - protocolProvider, - blockNumberService, - registry, - logger, - ); + vi.spyOn(protocolProvider, "proposeResponse").mockImplementation(() => + Promise.resolve(), + ); - vi.spyOn(registry, "getResponses").mockReturnValue([ - { + const requestConfig = { + id: requestId, + epoch: protocolEpoch.currentEpoch, + epochTimestamp: protocolEpoch.currentEpochTimestamp, + }; + + const actor = new EboActor( + requestConfig, + protocolProvider, + blockNumberService, + registry, + eventProcessingMutex, + logger, + ); + + const mockRegistryAddRequest = vi + .spyOn(registry, "addRequest") + .mockImplementation(() => {}); + + actor.enqueue(requestCreatedEvent); + + await actor.processEvents(); + + expect(mockRegistryAddRequest).toHaveBeenCalledWith( + expect.objectContaining({ + id: requestId, + }), + ); + }); + + it.skip("rollbacks state updates if the rpc call fails"); + + it.skip("proposes a response", async () => { + const indexedEpochBlockNumber = 48n; + + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( + indexedEpochBlockNumber, + ); + + const proposeResponseMock = vi.spyOn(protocolProvider, "proposeResponse"); + + proposeResponseMock.mockImplementation( + ( + _requestId: string, + _epoch: bigint, + _chainId: Caip2ChainId, + _blockNumbre: bigint, + ) => Promise.resolve(), + ); + + const requestConfig = { + id: requestId, + epoch: protocolEpoch.currentEpoch, + epochTimestamp: protocolEpoch.currentEpochTimestamp, + }; + + const actor = new EboActor( + requestConfig, + protocolProvider, + blockNumberService, + registry, + eventProcessingMutex, + logger, + ); + + actor.enqueue(requestCreatedEvent); + + await actor.processEvents(); + + expect(proposeResponseMock).toHaveBeenCalledWith( + requestCreatedEvent.metadata.requestId, + protocolEpoch.currentEpoch, + requestCreatedEvent.metadata.chainId, + indexedEpochBlockNumber, + ); + }); + + it.skip("does not propose when already proposed the same block", async () => { + const indexedEpochBlockNumber = 48n; + + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( + indexedEpochBlockNumber, + ); + + const proposeResponseMock = vi.spyOn(protocolProvider, "proposeResponse"); + + proposeResponseMock.mockImplementation( + ( + _requestId: string, + _epoch: bigint, + _chainId: Caip2ChainId, + _blockNumbre: bigint, + ) => Promise.resolve(), + ); + + const requestConfig = { + id: requestId, + epoch: protocolEpoch.currentEpoch, + epochTimestamp: protocolEpoch.currentEpochTimestamp, + }; + + const actor = new EboActor( + requestConfig, + protocolProvider, + blockNumberService, + registry, + eventProcessingMutex, + logger, + ); + + const previousResponses = new Map(); + previousResponses.set("0x01", { id: "0x01", - createdAt: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), + wasDisputed: false, prophetData: { proposer: "0x02", requestId: requestId, @@ -144,64 +196,68 @@ describe("EboActor", () => { epoch: protocolEpoch.currentEpoch, }, }, - }, - ]); - - await actor.onRequestCreated(requestCreatedEvent); - - expect(proposeResponseMock).not.toHaveBeenCalled(); - }); - - it("throws if the event's request id does not match with actor's", () => { - const noMatchRequestCreatedEvent: EboEvent<"RequestCreated"> = { - blockNumber: 34n, - logIndex: 1, - name: "RequestCreated", - metadata: { - chainId: "eip155:10", - epoch: protocolEpoch.currentEpoch, - requestId: "0x000000" as Address, - request: DEFAULT_MOCKED_REQUEST_CREATED_DATA.prophetData, - }, - }; + }); - const requestConfig = { - id: requestId, - epoch: protocolEpoch.currentEpoch, - epochTimestamp: protocolEpoch.currentEpochTimestamp, - }; + vi.spyOn(registry, "getResponses").mockReturnValue(previousResponses); - const actor = new EboActor( - requestConfig, - protocolProvider, - blockNumberService, - registry, - logger, - ); - - expect(actor.onRequestCreated(noMatchRequestCreatedEvent)).rejects.toThrowError( - RequestMismatch, - ); - }); + await actor.onRequestCreated(requestCreatedEvent); - it("throws if the indexed chain block number cannot be fetched", () => { - vi.spyOn(blockNumberService, "getEpochBlockNumber").mockRejectedValue(new Error()); + expect(proposeResponseMock).not.toHaveBeenCalled(); + }); - const requestConfig = { - id: requestId, - epoch: protocolEpoch.currentEpoch, - epochTimestamp: protocolEpoch.currentEpochTimestamp, - }; - - const actor = new EboActor( - requestConfig, - protocolProvider, - blockNumberService, - registry, - logger, - ); + it.skip("throws if the event's request id does not match with actor's", () => { + const noMatchRequestCreatedEvent: EboEvent<"RequestCreated"> = { + blockNumber: 34n, + logIndex: 1, + name: "RequestCreated", + metadata: { + chainId: "eip155:10", + epoch: protocolEpoch.currentEpoch, + requestId: "0x000000" as Address, + request: DEFAULT_MOCKED_REQUEST_CREATED_DATA.prophetData, + }, + }; - expect(actor.onRequestCreated(requestCreatedEvent)).rejects.toBeDefined(); + const requestConfig = { + id: requestId, + epoch: protocolEpoch.currentEpoch, + epochTimestamp: protocolEpoch.currentEpochTimestamp, + }; + + const actor = new EboActor( + requestConfig, + protocolProvider, + blockNumberService, + registry, + eventProcessingMutex, + logger, + ); + + expect(actor.onRequestCreated(noMatchRequestCreatedEvent)).rejects.toThrowError( + RequestMismatch, + ); + }); + + it.skip("throws if the indexed chain block number cannot be fetched", () => { + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockRejectedValue(new Error()); + + const requestConfig = { + id: requestId, + epoch: protocolEpoch.currentEpoch, + epochTimestamp: protocolEpoch.currentEpochTimestamp, + }; + + const actor = new EboActor( + requestConfig, + protocolProvider, + blockNumberService, + registry, + eventProcessingMutex, + logger, + ); + + expect(actor.onRequestCreated(requestCreatedEvent)).rejects.toBeDefined(); + }); }); }); }); diff --git a/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts b/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts index 0e68732..690b2f4 100644 --- a/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onRequestFinalized.spec.ts @@ -8,7 +8,7 @@ import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.js"; const logger: ILogger = mocks.mockLogger(); -describe("EboActor", () => { +describe.skip("EboActor", () => { describe("onRequestFinalized", () => { const actorRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; diff --git a/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts b/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts index d602660..cc40912 100644 --- a/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onResponseDisputed.spec.ts @@ -10,7 +10,7 @@ import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.js"; const logger: ILogger = mocks.mockLogger(); -describe("onResponseDisputed", () => { +describe.skip("onResponseDisputed", () => { const actorRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; const response: Response = mocks.buildResponse(actorRequest); diff --git a/packages/automated-dispute/tests/eboActor/onResponseProposed.spec.ts b/packages/automated-dispute/tests/eboActor/onResponseProposed.spec.ts index 25bbc99..9a803b7 100644 --- a/packages/automated-dispute/tests/eboActor/onResponseProposed.spec.ts +++ b/packages/automated-dispute/tests/eboActor/onResponseProposed.spec.ts @@ -8,91 +8,103 @@ import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "./fixtures.ts"; const logger: ILogger = mocks.mockLogger(); -describe("onResponseProposed", () => { - const actorRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; - - const responseProposedEvent: EboEvent<"ResponseProposed"> = { - name: "ResponseProposed", - blockNumber: 1n, - logIndex: 2, - metadata: { - requestId: actorRequest.id, - responseId: "0x02", - response: { - proposer: "0x03", +describe("EboActor", () => { + describe("processEvents", () => { + describe("when ResponseProposed is enqueued", () => { + const actorRequest = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + + const responseProposedEvent: EboEvent<"ResponseProposed"> = { + name: "ResponseProposed", requestId: actorRequest.id, - response: { - block: 1n, - chainId: actorRequest.chainId, - epoch: 1n, + blockNumber: 1n, + logIndex: 2, + metadata: { + requestId: actorRequest.id, + responseId: "0x02", + response: { + proposer: "0x03", + requestId: actorRequest.id, + response: { + block: 1n, + chainId: actorRequest.chainId, + epoch: 1n, + }, + }, }, - }, - }, - }; + }; - const proposeData = responseProposedEvent.metadata.response.response; + const proposeData = responseProposedEvent.metadata.response.response; - it("adds the response to the registry", async () => { - const { actor, registry, blockNumberService } = mocks.buildEboActor(actorRequest, logger); + it("adds the response to the registry", async () => { + const { actor, registry, blockNumberService } = mocks.buildEboActor( + actorRequest, + logger, + ); - vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); - vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue(proposeData.block); + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( + proposeData.block, + ); - const addResponseMock = vi.spyOn(registry, "addResponse"); + const addResponseMock = vi.spyOn(registry, "addResponse"); - await actor.onResponseProposed(responseProposedEvent); + actor.enqueue(responseProposedEvent); - expect(addResponseMock).toHaveBeenCalled(); - }); + await actor.processEvents(); - it("throws if the response's request is not handled by actor", () => { - const { actor } = mocks.buildEboActor(actorRequest, logger); + expect(addResponseMock).toHaveBeenCalled(); + }); - const otherRequestEvent = { - ...responseProposedEvent, - metadata: { - ...responseProposedEvent.metadata, - requestId: responseProposedEvent.metadata.requestId + "123", - }, - }; + it.skip("throws if the response's request is not handled by actor", () => { + const { actor } = mocks.buildEboActor(actorRequest, logger); - expect(actor.onResponseProposed(otherRequestEvent)).rejects.toThrowError(InvalidActorState); - }); + const otherRequestEvent = { + ...responseProposedEvent, + metadata: { + ...responseProposedEvent.metadata, + requestId: responseProposedEvent.metadata.requestId + "123", + }, + }; - it("does not dispute the response if seems valid", async () => { - const { actor, registry, blockNumberService, protocolProvider } = mocks.buildEboActor( - actorRequest, - logger, - ); + expect(actor.onResponseProposed(otherRequestEvent)).rejects.toThrowError( + InvalidActorState, + ); + }); - vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + it.skip("does not dispute the response if seems valid", async () => { + const { actor, registry, blockNumberService, protocolProvider } = + mocks.buildEboActor(actorRequest, logger); - vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue(proposeData.block); + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); - const mockDisputeResponse = vi.spyOn(protocolProvider, "disputeResponse"); + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( + proposeData.block, + ); - await actor.onResponseProposed(responseProposedEvent); + const mockDisputeResponse = vi.spyOn(protocolProvider, "disputeResponse"); - expect(mockDisputeResponse).not.toHaveBeenCalled(); - }); + await actor.onResponseProposed(responseProposedEvent); + + expect(mockDisputeResponse).not.toHaveBeenCalled(); + }); - it("dispute the response if it should be different", async () => { - const { actor, registry, blockNumberService, protocolProvider } = mocks.buildEboActor( - actorRequest, - logger, - ); + it.skip("dispute the response if it should be different", async () => { + const { actor, registry, blockNumberService, protocolProvider } = + mocks.buildEboActor(actorRequest, logger); - vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); + vi.spyOn(registry, "getRequest").mockReturnValue(actorRequest); - vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( - proposeData.block + 1n, - ); + vi.spyOn(blockNumberService, "getEpochBlockNumber").mockResolvedValue( + proposeData.block + 1n, + ); - const mockDisputeResponse = vi.spyOn(protocolProvider, "disputeResponse"); + const mockDisputeResponse = vi.spyOn(protocolProvider, "disputeResponse"); - await actor.onResponseProposed(responseProposedEvent); + await actor.onResponseProposed(responseProposedEvent); - expect(mockDisputeResponse).toHaveBeenCalled(); + expect(mockDisputeResponse).toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/automated-dispute/tests/eboActorsManager.spec.ts b/packages/automated-dispute/tests/eboActorsManager.spec.ts index 9a13ac8..7760bad 100644 --- a/packages/automated-dispute/tests/eboActorsManager.spec.ts +++ b/packages/automated-dispute/tests/eboActorsManager.spec.ts @@ -20,7 +20,6 @@ describe("EboActorsManager", () => { const actorRequest = { id: request.id, epoch: request.epoch, - epochTimestamp: request.epochTimestamp, }; const chainId = request.chainId; @@ -54,7 +53,6 @@ describe("EboActorsManager", () => { actorRequest: expect.objectContaining({ id: request.id, epoch: request.epoch, - epochTimestamp: request.epochTimestamp, }), }); }); @@ -105,7 +103,6 @@ describe("EboActorsManager", () => { actorRequest: expect.objectContaining({ id: request.id, epoch: request.epoch, - epochTimestamp: request.epochTimestamp, }), }); }); diff --git a/packages/automated-dispute/tests/mocks/eboActor.mocks.ts b/packages/automated-dispute/tests/mocks/eboActor.mocks.ts index a1e1f1f..7bfb3a0 100644 --- a/packages/automated-dispute/tests/mocks/eboActor.mocks.ts +++ b/packages/automated-dispute/tests/mocks/eboActor.mocks.ts @@ -1,10 +1,11 @@ import { BlockNumberService } from "@ebo-agent/blocknumber"; import { Caip2ChainId } from "@ebo-agent/blocknumber/dist/types"; import { ILogger } from "@ebo-agent/shared"; +import { Mutex } from "async-mutex"; import { EboActor } from "../../src/eboActor.js"; -import { EboMemoryRegistry } from "../../src/eboMemoryRegistry.js"; import { ProtocolProvider } from "../../src/protocolProvider.js"; +import { EboMemoryRegistry } from "../../src/services/index.js"; import { Dispute, Request, Response } from "../../src/types/index.js"; import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS } from "../eboActor/fixtures.js"; @@ -16,7 +17,7 @@ import { DEFAULT_MOCKED_PROTOCOL_CONTRACTS } from "../eboActor/fixtures.js"; * @returns */ export function buildEboActor(request: Request, logger: ILogger) { - const { id, chainId, epoch, epochTimestamp } = request; + const { id, chainId, epoch } = request; const protocolProviderRpcUrls = ["http://localhost:8538"]; const protocolProvider = new ProtocolProvider( @@ -31,11 +32,14 @@ export function buildEboActor(request: Request, logger: ILogger) { const registry = new EboMemoryRegistry(); + const eventProcessingMutex = new Mutex(); + const actor = new EboActor( - { id, epoch, epochTimestamp }, + { id, epoch }, protocolProvider, blockNumberService, registry, + eventProcessingMutex, logger, ); @@ -44,6 +48,7 @@ export function buildEboActor(request: Request, logger: ILogger) { protocolProvider, blockNumberService, registry, + eventProcessingMutex, logger, }; } diff --git a/packages/automated-dispute/tests/services/eboActor.spec.ts b/packages/automated-dispute/tests/services/eboActor.spec.ts new file mode 100644 index 0000000..c58cf58 --- /dev/null +++ b/packages/automated-dispute/tests/services/eboActor.spec.ts @@ -0,0 +1,244 @@ +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 { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "../eboActor/fixtures.js"; +import mocks from "../mocks/index.js"; + +const logger = mocks.mockLogger(); + +describe("EboActor", () => { + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const event: EboEvent<"RequestCreated"> = { + name: "RequestCreated", + blockNumber: 2n, + logIndex: 1, + requestId: request.id, + metadata: { + chainId: request.chainId, + epoch: request.epoch, + requestId: request.id, + request: request.prophetData, + }, + }; + + describe("enqueue", () => { + it("enqueues an event", () => { + const { actor } = mocks.buildEboActor(request, logger); + + const mockEventsQueuePush = vi.spyOn(actor["eventsQueue"], "push"); + + actor.enqueue(event); + + expect(mockEventsQueuePush).toHaveBeenCalledWith(event); + }); + + it("throws when the event's request does not match with actor's request", () => { + const { actor } = mocks.buildEboActor(request, logger); + + const otherRequestEvent = { + ...event, + requestId: (request.id === "0x01" ? "0x02" : "0x01") as RequestId, + }; + + expect(() => actor.enqueue(otherRequestEvent)).toThrow(RequestMismatch); + }); + + it("throws if an old event is enqueued after processing newer events", async () => { + const processedEvent: EboEvent<"RequestCreated"> = { ...event }; + const oldEvent: EboEvent<"RequestCreated"> = { + ...processedEvent, + blockNumber: processedEvent.blockNumber - 1n, + }; + + const { actor } = mocks.buildEboActor(request, logger); + + // TODO: mock the procol provider instead + actor["onNewEvent"] = vi.fn().mockImplementation(() => Promise.resolve()); + + actor.enqueue(processedEvent); + + await actor.processEvents(); + + expect(() => actor.enqueue(oldEvent)).toThrow(PastEventEnqueueError); + }); + }); + + describe("processEvents", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("consumes all events when processing", async () => { + const { actor } = mocks.buildEboActor(request, logger); + const queue = actor["eventsQueue"]; + + actor.enqueue(event); + + expect(queue.size()).toEqual(1); + + await actor.processEvents(); + + expect(queue.size()).toEqual(0); + }); + + it("enqueues again an event if its processing throws", async () => { + const { actor } = mocks.buildEboActor(request, logger); + const queue = actor["eventsQueue"]; + + const mockEventsQueuePush = vi.spyOn(queue, "push"); + + actor["onNewEvent"] = vi.fn().mockImplementation(() => Promise.reject()); + + actor.enqueue(event); + + await actor.processEvents(); + + expect(mockEventsQueuePush).toHaveBeenNthCalledWith(1, event); + expect(mockEventsQueuePush).toHaveBeenNthCalledWith(2, event); + }); + + it("enqueues again an event at the top if its processing throws", async () => { + const { actor } = mocks.buildEboActor(request, logger); + const queue = actor["eventsQueue"]; + + const firstEvent = { ...event }; + const secondEvent = { ...firstEvent, blockNumber: firstEvent.blockNumber + 1n }; + + actor["onNewEvent"] = vi.fn().mockImplementation(() => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(); + }, 10); + }); + }); + + setTimeout(async () => { + actor.enqueue(firstEvent); + + await actor.processEvents(); + }, 5); + + setTimeout(() => { + actor.enqueue(secondEvent); + }, 10); + + // First enqueue + await vi.advanceTimersByTimeAsync(5); + + expect(queue.size()).toEqual(0); + + // Second enqueue + await vi.advanceTimersByTimeAsync(5); + + expect(queue.size()).toEqual(1); + expect(queue.peek()).toEqual(secondEvent); + + // processEvents throws and re-enqueues first event + await vi.advanceTimersByTime(10); + + expect(queue.size()).toEqual(2); + expect(queue.peek()).toEqual(firstEvent); + }); + + it("does not allow interleaved event processing", async () => { + /** + * This case aims to cover the scenario in which the first call keeps awaiting to + * resolve its internal promises while a second call to `processEvents` with + * a new batch of events is kicked off. + * + * We want the second call to wait for the first one to finish. + * + * To illustrate this case, without mutexes this would happen: + * | Interval 1 | Interval 2 + * t=5 | processEvents() | + * t=10 | | processEvents() + * t=11 | | resolve() + * t=25 | resolve() | + * + * With mutexes, we aim for this: + * | Interval 1 | Interval 2 + * t=5 | processEvents() | + * t=25 | resolve() | processEvents() + * t=26 | | resolve() + * + */ + const callOrder: number[] = []; + + const response = mocks.buildResponse(request); + + const firstEvent: EboEvent<"RequestCreated"> = { ...event }; + const secondEvent: EboEvent<"ResponseProposed"> = { + name: "ResponseProposed", + blockNumber: firstEvent.blockNumber + 1n, + logIndex: 1, + requestId: firstEvent.requestId, + metadata: { + requestId: firstEvent.requestId, + responseId: response.id, + response: response.prophetData, + }, + }; + + const { actor } = mocks.buildEboActor(request, logger); + + const onNewEventDelay20 = () => { + callOrder.push(1); + + return new Promise((resolve) => { + setTimeout(() => { + callOrder.push(1); + + resolve(null); + }, 20); + }); + }; + + const onNewEventDelay1 = () => { + callOrder.push(2); + + return new Promise((resolve) => { + setTimeout(() => { + callOrder.push(2); + + resolve(null); + }, 1); + }); + }; + + actor["onNewEvent"] = vi + .fn() + .mockImplementationOnce(onNewEventDelay20) + .mockImplementationOnce(onNewEventDelay1); + + setTimeout(() => { + actor.enqueue(firstEvent); + + actor.processEvents(); + }, 5); + + setTimeout(() => { + actor.enqueue(secondEvent); + + actor.processEvents(); + }, 10); + + await vi.advanceTimersByTimeAsync(5); + + expect(callOrder).toEqual([1]); + + await vi.advanceTimersByTimeAsync(20); + + expect(callOrder).toEqual([1, 1, 2]); + + await vi.advanceTimersByTimeAsync(1); + + expect(callOrder).toEqual([1, 1, 2, 2]); + expect(callOrder).not.toEqual([1, 2, 2, 1]); // Case with no mutexes + }); + }); +}); diff --git a/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/addRequest.spec.ts b/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/addRequest.spec.ts new file mode 100644 index 0000000..f85f64b --- /dev/null +++ b/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/addRequest.spec.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; + +import { CommandAlreadyRun, CommandNotRun } from "../../../../src/exceptions/index.js"; +import { EboRegistry } from "../../../../src/interfaces/index.js"; +import { AddRequest } from "../../../../src/services/index.js"; +import { EboEvent } from "../../../../src/types/index.js"; +import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "../../../eboActor/fixtures.js"; + +describe("AddRequest", () => { + let registry: EboRegistry; + + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const event: EboEvent<"RequestCreated"> = { + name: "RequestCreated", + blockNumber: 1n, + logIndex: 1, + requestId: request.id, + metadata: { + chainId: request.chainId, + epoch: request.epoch, + request: request.prophetData, + requestId: request.id, + }, + }; + + beforeEach(() => { + registry = { + addRequest: vi.fn(), + removeRequest: vi.fn(), + } as unknown as EboRegistry; + }); + + describe("run", () => { + it("adds the request to the registry", () => { + const command = AddRequest.buildFromEvent(event, registry); + + const mockAddRequest = registry.addRequest as Mock; + + command.run(); + + expect(mockAddRequest).toHaveBeenCalledWith( + expect.objectContaining({ + id: request.id, + }), + ); + }); + + it("throws if the command was already run", () => { + const command = AddRequest.buildFromEvent(event, registry); + + command.run(); + + expect(() => command.run()).toThrow(CommandAlreadyRun); + }); + }); + + describe("undo", () => { + it("removes the added request", () => { + const command = AddRequest.buildFromEvent(event, registry); + + const mockRemoveRequest = registry.removeRequest as Mock; + + command.run(); + command.undo(); + + expect(mockRemoveRequest).toHaveBeenCalledWith(request.id); + }); + + it("throws if undoing the command before being run", () => { + const command = AddRequest.buildFromEvent(event, registry); + + expect(() => command.undo()).toThrow(CommandNotRun); + }); + }); +}); diff --git a/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/addResponse.spec.ts b/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/addResponse.spec.ts new file mode 100644 index 0000000..2ba2f21 --- /dev/null +++ b/packages/automated-dispute/tests/services/eboMemoryRegistry/commands/addResponse.spec.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; + +import { CommandAlreadyRun, CommandNotRun } from "../../../../src/exceptions/index.js"; +import { EboRegistry } from "../../../../src/interfaces/index.js"; +import { AddResponse } 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("AddResponse", () => { + let registry: EboRegistry; + + const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; + const response = mocks.buildResponse(request); + const event: EboEvent<"ResponseProposed"> = { + name: "ResponseProposed", + blockNumber: response.createdAt, + logIndex: 1, + requestId: request.id, + metadata: { + requestId: request.id, + responseId: response.id, + response: response.prophetData, + }, + }; + + beforeEach(() => { + registry = { + addResponse: vi.fn(), + removeResponse: vi.fn(), + } as unknown as EboRegistry; + }); + + describe("run", () => { + it("adds the response to the registry", () => { + const command = AddResponse.buildFromEvent(event, registry); + + const mockAddRequest = registry.addResponse as Mock; + + command.run(); + + expect(mockAddRequest).toHaveBeenCalledWith(response); + }); + + it("throws if the command was already run", () => { + const command = AddResponse.buildFromEvent(event, registry); + + command.run(); + + expect(() => command.run()).toThrow(CommandAlreadyRun); + }); + }); + + describe("undo", () => { + it("removes the response request", () => { + const command = AddResponse.buildFromEvent(event, registry); + + const mockRemoveRequest = registry.removeResponse as Mock; + + command.run(); + command.undo(); + + expect(mockRemoveRequest).toHaveBeenCalledWith(response.id); + }); + + it("throws if undoing the command before being run", () => { + const command = AddResponse.buildFromEvent(event, registry); + + expect(() => command.undo()).toThrow(CommandNotRun); + }); + }); +}); diff --git a/packages/automated-dispute/tests/services/eboProcessor.spec.ts b/packages/automated-dispute/tests/services/eboProcessor.spec.ts index ae0dfc7..8439226 100644 --- a/packages/automated-dispute/tests/services/eboProcessor.spec.ts +++ b/packages/automated-dispute/tests/services/eboProcessor.spec.ts @@ -57,7 +57,6 @@ describe("EboProcessor", () => { const expectedNewActor = expect.objectContaining({ id: requestCreatedEvent.requestId, epoch: currentEpoch.currentEpoch, - epochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), }); expect(mockCreateActor).toHaveBeenCalledWith( @@ -165,7 +164,7 @@ describe("EboProcessor", () => { expect(mockGetEvents).toHaveBeenCalledWith(mockLastCheckedBlock, currentBlock); }); - it("causes actor to execute RPCs only during the last event", async () => { + it("enqueues and process every new event into the actor", async () => { const { processor, protocolProvider, actorsManager } = mocks.buildEboProcessor(logger); const currentEpoch = { @@ -212,87 +211,23 @@ describe("EboProcessor", () => { const { actor } = mocks.buildEboActor(request, logger); - const mockActorUpdateState = vi.spyOn(actor, "updateState"); - const mockActorOnNewEvent = vi.spyOn(actor, "onNewEvent"); + const mockActorEnqueue = vi.spyOn(actor, "enqueue"); + const mockActorProcessEvents = vi + .spyOn(actor, "processEvents") + .mockImplementation(() => Promise.resolve()); - mockActorUpdateState.mockImplementation(() => {}); - mockActorOnNewEvent.mockImplementation(() => {}); - - vi.spyOn(actor, "onLastBlockUpdated").mockImplementation(() => {}); - - vi.spyOn(actorsManager, "createActor").mockResolvedValue(actor); - vi.spyOn(actorsManager, "getActor").mockResolvedValue(actor); - - await processor.start(msBetweenChecks); - - expect(mockActorUpdateState).toHaveBeenCalledTimes(eventStream.length); - expect(mockActorOnNewEvent).toHaveBeenCalledOnce(); - }); - - it("forwards events in block and log index order", async () => { - const { processor, protocolProvider, actorsManager } = mocks.buildEboProcessor(logger); - - const currentEpoch = { - currentEpoch: 1n, - currentEpochBlockNumber: 1n, - currentEpochTimestamp: BigInt(Date.UTC(2024, 1, 1, 0, 0, 0, 0)), - }; - - const currentBlock = currentEpoch.currentEpochBlockNumber + 10n; - - vi.spyOn(protocolProvider, "getCurrentEpoch").mockResolvedValue(currentEpoch); - vi.spyOn(protocolProvider, "getLastFinalizedBlock").mockResolvedValue(currentBlock); - - const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; - const response = mocks.buildResponse(request); - - const eventStream: EboEvent[] = [ - { - name: "ResponseProposed", - blockNumber: 7n, - logIndex: 1, - requestId: request.id, - metadata: { - requestId: request.id, - responseId: response.id, - response: response.prophetData, - }, - }, - { - name: "RequestCreated", - blockNumber: 6n, - logIndex: 1, - requestId: request.id, - metadata: { - requestId: request.id, - epoch: request.epoch, - chainId: request.chainId, - request: request["prophetData"], - }, - }, - ]; - - vi.spyOn(protocolProvider, "getEvents").mockResolvedValue(eventStream); - - const { actor } = mocks.buildEboActor(request, logger); - - const mockActorUpdateState = vi.spyOn(actor, "updateState"); - - mockActorUpdateState.mockImplementation(() => {}); - - vi.spyOn(actor, "onNewEvent").mockImplementation(() => {}); vi.spyOn(actor, "onLastBlockUpdated").mockImplementation(() => {}); vi.spyOn(actorsManager, "createActor").mockResolvedValue(actor); - vi.spyOn(actorsManager, "getActor").mockResolvedValue(actor); + vi.spyOn(actorsManager, "getActor").mockReturnValue(actor); await processor.start(msBetweenChecks); - expect(mockActorUpdateState).toHaveBeenNthCalledWith(1, eventStream[1]); - expect(mockActorUpdateState).toHaveBeenNthCalledWith(2, eventStream[0]); + expect(mockActorProcessEvents).toHaveBeenCalledOnce(); + expect(mockActorEnqueue).toHaveBeenCalledTimes(2); }); - it("forwards events to corresponding actors", async () => { + it("enqueues events into corresponding actors", async () => { const { processor, protocolProvider, actorsManager } = mocks.buildEboProcessor(logger); const currentEpoch = { @@ -316,7 +251,7 @@ describe("EboProcessor", () => { const request2 = { ...DEFAULT_MOCKED_REQUEST_CREATED_DATA, id: "0x02" as RequestId, - chainId: "eip155:17" as Caip2ChainId, + chainId: "eip155:137" as Caip2ChainId, }; const response2 = mocks.buildResponse(request2); @@ -348,17 +283,18 @@ describe("EboProcessor", () => { vi.spyOn(protocolProvider, "getEvents").mockResolvedValue(eventStream); const { actor: actor1 } = mocks.buildEboActor(request1, logger); - const { actor: actor2 } = mocks.buildEboActor(request1, logger); + const { actor: actor2 } = mocks.buildEboActor(request2, logger); + + const mockActor1Enqueue = vi.spyOn(actor1, "enqueue"); + const mockActor2Enqueue = vi.spyOn(actor2, "enqueue"); - const mockActor1UpdateState = vi.spyOn(actor1, "updateState"); - const mockActor2UpdateState = vi.spyOn(actor2, "updateState"); + mockActor1Enqueue.mockImplementation(() => {}); + mockActor2Enqueue.mockImplementation(() => {}); - mockActor1UpdateState.mockImplementation(() => {}); - mockActor2UpdateState.mockImplementation(() => {}); + vi.spyOn(actor1, "processEvents").mockImplementation(() => Promise.resolve()); + vi.spyOn(actor2, "processEvents").mockImplementation(() => Promise.resolve()); - vi.spyOn(actor1, "onNewEvent").mockImplementation(() => {}); vi.spyOn(actor1, "onLastBlockUpdated").mockImplementation(() => {}); - vi.spyOn(actor2, "onNewEvent").mockImplementation(() => {}); vi.spyOn(actor2, "onLastBlockUpdated").mockImplementation(() => {}); vi.spyOn(actorsManager, "getActor").mockImplementation((requestId: RequestId) => { @@ -376,8 +312,8 @@ describe("EboProcessor", () => { await processor.start(msBetweenChecks); - expect(mockActor1UpdateState).toHaveBeenCalledWith(eventStream[0]); - expect(mockActor2UpdateState).toHaveBeenCalledWith(eventStream[1]); + expect(mockActor1Enqueue).toHaveBeenCalledWith(eventStream[0]); + expect(mockActor2Enqueue).toHaveBeenCalledWith(eventStream[1]); }); it.skip("notifies if an actor throws while handling events"); @@ -406,7 +342,7 @@ describe("EboProcessor", () => { vi.spyOn(actor, "canBeTerminated").mockReturnValue(true); vi.spyOn(actorsManager, "createActor").mockResolvedValue(actor); - vi.spyOn(actorsManager, "getActor").mockResolvedValue(actor); + vi.spyOn(actorsManager, "getActor").mockReturnValue(actor); vi.spyOn(actorsManager, "getRequestIds").mockReturnValue([request.id]); const mockActorManagerDeleteActor = vi.spyOn(actorsManager, "deleteActor"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d628da..dada3bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,7 +30,7 @@ importers: specifier: 7.16.1 version: 7.16.1(eslint@8.57.0)(typescript@5.5.3) "@vitest/coverage-v8": - specifier: ^2.0.5 + specifier: 2.0.5 version: 2.0.5(vitest@2.0.3(@types/node@20.14.12)) eslint: specifier: 8.57.0 @@ -71,6 +71,12 @@ importers: "@ebo-agent/shared": specifier: workspace:* version: link:../shared + async-mutex: + specifier: 0.5.0 + version: 0.5.0 + heap-js: + specifier: 2.5.0 + version: 2.5.0 viem: specifier: 2.17.11 version: 2.17.11(typescript@5.5.3) @@ -125,10 +131,10 @@ packages: } engines: { node: ">=6.9.0" } - "@babel/generator@7.25.5": + "@babel/generator@7.25.6": resolution: { - integrity: sha512-abd43wyLfbWoxC6ahM8xTkqLpGB2iWBVyuKC9/srhFunCd1SDNrV1s72bBpK4hLj8KLzHBBcOblvLQZBNw9r3w==, + integrity: sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==, } engines: { node: ">=6.9.0" } @@ -183,10 +189,10 @@ packages: } engines: { node: ">=6.9.0" } - "@babel/helpers@7.25.0": + "@babel/helpers@7.25.6": resolution: { - integrity: sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==, + integrity: sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==, } engines: { node: ">=6.9.0" } @@ -197,10 +203,10 @@ packages: } engines: { node: ">=6.9.0" } - "@babel/parser@7.25.4": + "@babel/parser@7.25.6": resolution: { - integrity: sha512-nq+eWrOgdtu3jG5Os4TQP3x3cLA8hR8TvJNjD8vnPa20WGycimcparWnLK4jJhElTK6SDyuJo1weMKO/5LpmLA==, + integrity: sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==, } engines: { node: ">=6.0.0" } hasBin: true @@ -212,17 +218,17 @@ packages: } engines: { node: ">=6.9.0" } - "@babel/traverse@7.25.4": + "@babel/traverse@7.25.6": resolution: { - integrity: sha512-VJ4XsrD+nOvlXyLzmLzUs/0qjFS4sK30te5yEFlvbbUNEgKaVb2BHZUpAL+ttLPQAHNrsI3zZisbfha5Cvr8vg==, + integrity: sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==, } engines: { node: ">=6.9.0" } - "@babel/types@7.25.4": + "@babel/types@7.25.6": resolution: { - integrity: sha512-zQ1ijeeCXVEh+aNL0RlmkPkG8HUiDcU2pzQQFjtbntgAczRASFzj4H+6+bV+dy1ntKR14I/DypeuRG1uma98iQ==, + integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==, } engines: { node: ">=6.9.0" } @@ -289,10 +295,10 @@ packages: } engines: { node: ">=v18" } - "@commitlint/lint@19.2.2": + "@commitlint/lint@19.4.1": resolution: { - integrity: sha512-xrzMmz4JqwGyKQKTpFzlN0dx0TAiT7Ran1fqEBgEmEj+PU98crOFtysJgY+QdeSagx6EDRigQIXJVnfrI0ratA==, + integrity: sha512-Ws4YVAZ0jACTv6VThumITC1I5AG0UyXMGua3qcf55JmXIXm/ejfaVKykrqx7RyZOACKVAs8uDRIsEsi87JZ3+Q==, } engines: { node: ">=v18" } @@ -331,10 +337,10 @@ packages: } engines: { node: ">=v18" } - "@commitlint/rules@19.0.3": + "@commitlint/rules@19.4.1": resolution: { - integrity: sha512-TspKb9VB6svklxNCKKwxhELn7qhtY1rFF8ls58DcFd0F97XoG07xugPjjbVnLqmMkRjZDbDIwBKt9bddOfLaPw==, + integrity: sha512-AgctfzAONoVxmxOXRyxXIq7xEPrd7lK/60h2egp9bgGUMZK9v0+YqLOA+TH+KqCa63ZoCr8owP2YxoSSu7IgnQ==, } engines: { node: ">=v18" } @@ -744,130 +750,130 @@ packages: } engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } - "@rollup/rollup-android-arm-eabi@4.21.1": + "@rollup/rollup-android-arm-eabi@4.21.2": resolution: { - integrity: sha512-2thheikVEuU7ZxFXubPDOtspKn1x0yqaYQwvALVtEcvFhMifPADBrgRPyHV0TF3b+9BgvgjgagVyvA/UqPZHmg==, + integrity: sha512-fSuPrt0ZO8uXeS+xP3b+yYTCBUd05MoSp2N/MFOgjhhUhMmchXlpTQrTpI8T+YAwAQuK7MafsCOxW7VrPMrJcg==, } cpu: [arm] os: [android] - "@rollup/rollup-android-arm64@4.21.1": + "@rollup/rollup-android-arm64@4.21.2": resolution: { - integrity: sha512-t1lLYn4V9WgnIFHXy1d2Di/7gyzBWS8G5pQSXdZqfrdCGTwi1VasRMSS81DTYb+avDs/Zz4A6dzERki5oRYz1g==, + integrity: sha512-xGU5ZQmPlsjQS6tzTTGwMsnKUtu0WVbl0hYpTPauvbRAnmIvpInhJtgjj3mcuJpEiuUw4v1s4BimkdfDWlh7gA==, } cpu: [arm64] os: [android] - "@rollup/rollup-darwin-arm64@4.21.1": + "@rollup/rollup-darwin-arm64@4.21.2": resolution: { - integrity: sha512-AH/wNWSEEHvs6t4iJ3RANxW5ZCK3fUnmf0gyMxWCesY1AlUj8jY7GC+rQE4wd3gwmZ9XDOpL0kcFnCjtN7FXlA==, + integrity: sha512-99AhQ3/ZMxU7jw34Sq8brzXqWH/bMnf7ZVhvLk9QU2cOepbQSVTns6qoErJmSiAvU3InRqC2RRZ5ovh1KN0d0Q==, } cpu: [arm64] os: [darwin] - "@rollup/rollup-darwin-x64@4.21.1": + "@rollup/rollup-darwin-x64@4.21.2": resolution: { - integrity: sha512-dO0BIz/+5ZdkLZrVgQrDdW7m2RkrLwYTh2YMFG9IpBtlC1x1NPNSXkfczhZieOlOLEqgXOFH3wYHB7PmBtf+Bg==, + integrity: sha512-ZbRaUvw2iN/y37x6dY50D8m2BnDbBjlnMPotDi/qITMJ4sIxNY33HArjikDyakhSv0+ybdUxhWxE6kTI4oX26w==, } cpu: [x64] os: [darwin] - "@rollup/rollup-linux-arm-gnueabihf@4.21.1": + "@rollup/rollup-linux-arm-gnueabihf@4.21.2": resolution: { - integrity: sha512-sWWgdQ1fq+XKrlda8PsMCfut8caFwZBmhYeoehJ05FdI0YZXk6ZyUjWLrIgbR/VgiGycrFKMMgp7eJ69HOF2pQ==, + integrity: sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==, } cpu: [arm] os: [linux] - "@rollup/rollup-linux-arm-musleabihf@4.21.1": + "@rollup/rollup-linux-arm-musleabihf@4.21.2": resolution: { - integrity: sha512-9OIiSuj5EsYQlmwhmFRA0LRO0dRRjdCVZA3hnmZe1rEwRk11Jy3ECGGq3a7RrVEZ0/pCsYWx8jG3IvcrJ6RCew==, + integrity: sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==, } cpu: [arm] os: [linux] - "@rollup/rollup-linux-arm64-gnu@4.21.1": + "@rollup/rollup-linux-arm64-gnu@4.21.2": resolution: { - integrity: sha512-0kuAkRK4MeIUbzQYu63NrJmfoUVicajoRAL1bpwdYIYRcs57iyIV9NLcuyDyDXE2GiZCL4uhKSYAnyWpjZkWow==, + integrity: sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==, } cpu: [arm64] os: [linux] - "@rollup/rollup-linux-arm64-musl@4.21.1": + "@rollup/rollup-linux-arm64-musl@4.21.2": resolution: { - integrity: sha512-/6dYC9fZtfEY0vozpc5bx1RP4VrtEOhNQGb0HwvYNwXD1BBbwQ5cKIbUVVU7G2d5WRE90NfB922elN8ASXAJEA==, + integrity: sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==, } cpu: [arm64] os: [linux] - "@rollup/rollup-linux-powerpc64le-gnu@4.21.1": + "@rollup/rollup-linux-powerpc64le-gnu@4.21.2": resolution: { - integrity: sha512-ltUWy+sHeAh3YZ91NUsV4Xg3uBXAlscQe8ZOXRCVAKLsivGuJsrkawYPUEyCV3DYa9urgJugMLn8Z3Z/6CeyRQ==, + integrity: sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==, } cpu: [ppc64] os: [linux] - "@rollup/rollup-linux-riscv64-gnu@4.21.1": + "@rollup/rollup-linux-riscv64-gnu@4.21.2": resolution: { - integrity: sha512-BggMndzI7Tlv4/abrgLwa/dxNEMn2gC61DCLrTzw8LkpSKel4o+O+gtjbnkevZ18SKkeN3ihRGPuBxjaetWzWg==, + integrity: sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==, } cpu: [riscv64] os: [linux] - "@rollup/rollup-linux-s390x-gnu@4.21.1": + "@rollup/rollup-linux-s390x-gnu@4.21.2": resolution: { - integrity: sha512-z/9rtlGd/OMv+gb1mNSjElasMf9yXusAxnRDrBaYB+eS1shFm6/4/xDH1SAISO5729fFKUkJ88TkGPRUh8WSAA==, + integrity: sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==, } cpu: [s390x] os: [linux] - "@rollup/rollup-linux-x64-gnu@4.21.1": + "@rollup/rollup-linux-x64-gnu@4.21.2": resolution: { - integrity: sha512-kXQVcWqDcDKw0S2E0TmhlTLlUgAmMVqPrJZR+KpH/1ZaZhLSl23GZpQVmawBQGVhyP5WXIsIQ/zqbDBBYmxm5w==, + integrity: sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==, } cpu: [x64] os: [linux] - "@rollup/rollup-linux-x64-musl@4.21.1": + "@rollup/rollup-linux-x64-musl@4.21.2": resolution: { - integrity: sha512-CbFv/WMQsSdl+bpX6rVbzR4kAjSSBuDgCqb1l4J68UYsQNalz5wOqLGYj4ZI0thGpyX5kc+LLZ9CL+kpqDovZA==, + integrity: sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==, } cpu: [x64] os: [linux] - "@rollup/rollup-win32-arm64-msvc@4.21.1": + "@rollup/rollup-win32-arm64-msvc@4.21.2": resolution: { - integrity: sha512-3Q3brDgA86gHXWHklrwdREKIrIbxC0ZgU8lwpj0eEKGBQH+31uPqr0P2v11pn0tSIxHvcdOWxa4j+YvLNx1i6g==, + integrity: sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==, } cpu: [arm64] os: [win32] - "@rollup/rollup-win32-ia32-msvc@4.21.1": + "@rollup/rollup-win32-ia32-msvc@4.21.2": resolution: { - integrity: sha512-tNg+jJcKR3Uwe4L0/wY3Ro0H+u3nrb04+tcq1GSYzBEmKLeOQF2emk1whxlzNqb6MMrQ2JOcQEpuuiPLyRcSIw==, + integrity: sha512-5rA4vjlqgrpbFVVHX3qkrCo/fZTj1q0Xxpg+Z7yIo3J2AilW7t2+n6Q8Jrx+4MrYpAnjttTYF8rr7bP46BPzRw==, } cpu: [ia32] os: [win32] - "@rollup/rollup-win32-x64-msvc@4.21.1": + "@rollup/rollup-win32-x64-msvc@4.21.2": resolution: { - integrity: sha512-xGiIH95H1zU7naUyTKEyOA/I0aexNMUdO9qRv0bLKN3qu25bBdrxZHqA3PTJ24YNN/GdMzG4xkDcd/GvjuhfLg==, + integrity: sha512-6UUxd0+SKomjdzuAcp+HAmxw1FlGBnl1v2yEPSabtx4lBfdXHDVsW7+lQkgz9cNFJGY3AWR7+V8P5BqkD9L9nA==, } cpu: [x64] os: [win32] @@ -1206,6 +1212,12 @@ packages: } engines: { node: ">=12" } + async-mutex@0.5.0: + resolution: + { + integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==, + } + async@3.2.6: resolution: { @@ -1259,10 +1271,10 @@ packages: } engines: { node: ">=6" } - caniuse-lite@1.0.30001653: + caniuse-lite@1.0.30001655: resolution: { - integrity: sha512-XGWQVB8wFQ2+9NZwZ10GxTYC5hk0Fa+q8cSkr0tgvMhYhMHP/QC+WTgrePMDBWiWc/pV+1ik82Al20XOK25Gcw==, + integrity: sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==, } chai@5.1.1: @@ -1577,10 +1589,10 @@ packages: engines: { node: ">=12" } hasBin: true - escalade@3.1.2: + escalade@3.2.0: resolution: { - integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==, + integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==, } engines: { node: ">=6" } @@ -1935,6 +1947,13 @@ packages: } engines: { node: ">=8" } + heap-js@2.5.0: + resolution: + { + integrity: sha512-kUGoI3p7u6B41z/dp33G6OaL7J4DRqRYwVmeIlwLClx7yaaAy7hoDExnuejTKtuDwfcatGmddHDEOjf6EyIxtQ==, + } + engines: { node: ">=10.0.0" } + html-escaper@2.0.2: resolution: { @@ -2366,10 +2385,10 @@ packages: integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==, } - magicast@0.3.4: + magicast@0.3.5: resolution: { - integrity: sha512-TyDF/Pn36bBji9rWKHlZe+PZb6Mx5V8IHCSxk7X4aljM4e/vyDvZZYwHewdVaqiA0nb3ghfHU/6AUpDxWoER2Q==, + integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==, } make-dir@4.0.0: @@ -2760,10 +2779,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rollup@4.21.1: + rollup@4.21.2: resolution: { - integrity: sha512-ZnYyKvscThhgd3M5+Qt3pmhO4jIRR5RGzaSovB6Q7rGNrK5cUncrtLmcTTJVSdcKXyZjW8X8MB0JMSuH9bcAJg==, + integrity: sha512-e3TapAgYf9xjdLvKQCkQTnbTKd4a6jwlpQSJJFokHGaX2IVjoEqkIIhiQfqsi0cdwlOD+tQGuOd5AJkc5RngBw==, } engines: { node: ">=18.0.0", npm: ">=8.0.0" } hasBin: true @@ -3446,14 +3465,14 @@ snapshots: dependencies: "@ampproject/remapping": 2.3.0 "@babel/code-frame": 7.24.7 - "@babel/generator": 7.25.5 + "@babel/generator": 7.25.6 "@babel/helper-compilation-targets": 7.25.2 "@babel/helper-module-transforms": 7.25.2(@babel/core@7.25.2) - "@babel/helpers": 7.25.0 - "@babel/parser": 7.25.4 + "@babel/helpers": 7.25.6 + "@babel/parser": 7.25.6 "@babel/template": 7.25.0 - "@babel/traverse": 7.25.4 - "@babel/types": 7.25.4 + "@babel/traverse": 7.25.6 + "@babel/types": 7.25.6 convert-source-map: 2.0.0 debug: 4.3.6 gensync: 1.0.0-beta.2 @@ -3462,9 +3481,9 @@ snapshots: transitivePeerDependencies: - supports-color - "@babel/generator@7.25.5": + "@babel/generator@7.25.6": dependencies: - "@babel/types": 7.25.4 + "@babel/types": 7.25.6 "@jridgewell/gen-mapping": 0.3.5 "@jridgewell/trace-mapping": 0.3.25 jsesc: 2.5.2 @@ -3479,8 +3498,8 @@ snapshots: "@babel/helper-module-imports@7.24.7": dependencies: - "@babel/traverse": 7.25.4 - "@babel/types": 7.25.4 + "@babel/traverse": 7.25.6 + "@babel/types": 7.25.6 transitivePeerDependencies: - supports-color @@ -3490,14 +3509,14 @@ snapshots: "@babel/helper-module-imports": 7.24.7 "@babel/helper-simple-access": 7.24.7 "@babel/helper-validator-identifier": 7.24.7 - "@babel/traverse": 7.25.4 + "@babel/traverse": 7.25.6 transitivePeerDependencies: - supports-color "@babel/helper-simple-access@7.24.7": dependencies: - "@babel/traverse": 7.25.4 - "@babel/types": 7.25.4 + "@babel/traverse": 7.25.6 + "@babel/types": 7.25.6 transitivePeerDependencies: - supports-color @@ -3507,10 +3526,10 @@ snapshots: "@babel/helper-validator-option@7.24.8": {} - "@babel/helpers@7.25.0": + "@babel/helpers@7.25.6": dependencies: "@babel/template": 7.25.0 - "@babel/types": 7.25.4 + "@babel/types": 7.25.6 "@babel/highlight@7.24.7": dependencies: @@ -3519,29 +3538,29 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.0.1 - "@babel/parser@7.25.4": + "@babel/parser@7.25.6": dependencies: - "@babel/types": 7.25.4 + "@babel/types": 7.25.6 "@babel/template@7.25.0": dependencies: "@babel/code-frame": 7.24.7 - "@babel/parser": 7.25.4 - "@babel/types": 7.25.4 + "@babel/parser": 7.25.6 + "@babel/types": 7.25.6 - "@babel/traverse@7.25.4": + "@babel/traverse@7.25.6": dependencies: "@babel/code-frame": 7.24.7 - "@babel/generator": 7.25.5 - "@babel/parser": 7.25.4 + "@babel/generator": 7.25.6 + "@babel/parser": 7.25.6 "@babel/template": 7.25.0 - "@babel/types": 7.25.4 + "@babel/types": 7.25.6 debug: 4.3.6 globals: 11.12.0 transitivePeerDependencies: - supports-color - "@babel/types@7.25.4": + "@babel/types@7.25.6": dependencies: "@babel/helper-string-parser": 7.24.8 "@babel/helper-validator-identifier": 7.24.7 @@ -3554,7 +3573,7 @@ snapshots: "@commitlint/cli@19.3.0(@types/node@20.14.12)(typescript@5.5.3)": dependencies: "@commitlint/format": 19.3.0 - "@commitlint/lint": 19.2.2 + "@commitlint/lint": 19.4.1 "@commitlint/load": 19.4.0(@types/node@20.14.12)(typescript@5.5.3) "@commitlint/read": 19.4.0 "@commitlint/types": 19.0.3 @@ -3595,11 +3614,11 @@ snapshots: "@commitlint/types": 19.0.3 semver: 7.6.3 - "@commitlint/lint@19.2.2": + "@commitlint/lint@19.4.1": dependencies: "@commitlint/is-ignored": 19.2.2 "@commitlint/parse": 19.0.3 - "@commitlint/rules": 19.0.3 + "@commitlint/rules": 19.4.1 "@commitlint/types": 19.0.3 "@commitlint/load@19.4.0(@types/node@20.14.12)(typescript@5.5.3)": @@ -3643,7 +3662,7 @@ snapshots: lodash.mergewith: 4.6.2 resolve-from: 5.0.0 - "@commitlint/rules@19.0.3": + "@commitlint/rules@19.4.1": dependencies: "@commitlint/ensure": 19.0.3 "@commitlint/message": 19.0.0 @@ -3779,10 +3798,10 @@ snapshots: "@ianvs/prettier-plugin-sort-imports@4.3.1(prettier@3.3.3)": dependencies: "@babel/core": 7.25.2 - "@babel/generator": 7.25.5 - "@babel/parser": 7.25.4 - "@babel/traverse": 7.25.4 - "@babel/types": 7.25.4 + "@babel/generator": 7.25.6 + "@babel/parser": 7.25.6 + "@babel/traverse": 7.25.6 + "@babel/types": 7.25.6 prettier: 3.3.3 semver: 7.6.3 transitivePeerDependencies: @@ -3844,52 +3863,52 @@ snapshots: "@pkgr/core@0.1.1": {} - "@rollup/rollup-android-arm-eabi@4.21.1": + "@rollup/rollup-android-arm-eabi@4.21.2": optional: true - "@rollup/rollup-android-arm64@4.21.1": + "@rollup/rollup-android-arm64@4.21.2": optional: true - "@rollup/rollup-darwin-arm64@4.21.1": + "@rollup/rollup-darwin-arm64@4.21.2": optional: true - "@rollup/rollup-darwin-x64@4.21.1": + "@rollup/rollup-darwin-x64@4.21.2": optional: true - "@rollup/rollup-linux-arm-gnueabihf@4.21.1": + "@rollup/rollup-linux-arm-gnueabihf@4.21.2": optional: true - "@rollup/rollup-linux-arm-musleabihf@4.21.1": + "@rollup/rollup-linux-arm-musleabihf@4.21.2": optional: true - "@rollup/rollup-linux-arm64-gnu@4.21.1": + "@rollup/rollup-linux-arm64-gnu@4.21.2": optional: true - "@rollup/rollup-linux-arm64-musl@4.21.1": + "@rollup/rollup-linux-arm64-musl@4.21.2": optional: true - "@rollup/rollup-linux-powerpc64le-gnu@4.21.1": + "@rollup/rollup-linux-powerpc64le-gnu@4.21.2": optional: true - "@rollup/rollup-linux-riscv64-gnu@4.21.1": + "@rollup/rollup-linux-riscv64-gnu@4.21.2": optional: true - "@rollup/rollup-linux-s390x-gnu@4.21.1": + "@rollup/rollup-linux-s390x-gnu@4.21.2": optional: true - "@rollup/rollup-linux-x64-gnu@4.21.1": + "@rollup/rollup-linux-x64-gnu@4.21.2": optional: true - "@rollup/rollup-linux-x64-musl@4.21.1": + "@rollup/rollup-linux-x64-musl@4.21.2": optional: true - "@rollup/rollup-win32-arm64-msvc@4.21.1": + "@rollup/rollup-win32-arm64-msvc@4.21.2": optional: true - "@rollup/rollup-win32-ia32-msvc@4.21.1": + "@rollup/rollup-win32-ia32-msvc@4.21.2": optional: true - "@rollup/rollup-win32-x64-msvc@4.21.1": + "@rollup/rollup-win32-x64-msvc@4.21.2": optional: true "@scure/base@1.1.7": {} @@ -4018,7 +4037,7 @@ snapshots: istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.1.7 magic-string: 0.30.11 - magicast: 0.3.4 + magicast: 0.3.5 std-env: 3.7.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 @@ -4124,6 +4143,10 @@ snapshots: assertion-error@2.0.1: {} + async-mutex@0.5.0: + dependencies: + tslib: 2.7.0 + async@3.2.6: {} balanced-match@1.0.2: {} @@ -4143,7 +4166,7 @@ snapshots: browserslist@4.23.3: dependencies: - caniuse-lite: 1.0.30001653 + caniuse-lite: 1.0.30001655 electron-to-chromium: 1.5.13 node-releases: 2.0.18 update-browserslist-db: 1.1.0(browserslist@4.23.3) @@ -4152,7 +4175,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001653: {} + caniuse-lite@1.0.30001655: {} chai@5.1.1: dependencies: @@ -4341,7 +4364,7 @@ snapshots: "@esbuild/win32-ia32": 0.21.5 "@esbuild/win32-x64": 0.21.5 - escalade@3.1.2: {} + escalade@3.2.0: {} escape-string-regexp@1.0.5: {} @@ -4576,6 +4599,8 @@ snapshots: has-flag@4.0.0: {} + heap-js@2.5.0: {} + html-escaper@2.0.2: {} human-signals@5.0.0: {} @@ -4787,10 +4812,10 @@ snapshots: dependencies: "@jridgewell/sourcemap-codec": 1.5.0 - magicast@0.3.4: + magicast@0.3.5: dependencies: - "@babel/parser": 7.25.4 - "@babel/types": 7.25.4 + "@babel/parser": 7.25.6 + "@babel/types": 7.25.6 source-map-js: 1.2.0 make-dir@4.0.0: @@ -4966,26 +4991,26 @@ snapshots: dependencies: glob: 7.2.3 - rollup@4.21.1: + rollup@4.21.2: dependencies: "@types/estree": 1.0.5 optionalDependencies: - "@rollup/rollup-android-arm-eabi": 4.21.1 - "@rollup/rollup-android-arm64": 4.21.1 - "@rollup/rollup-darwin-arm64": 4.21.1 - "@rollup/rollup-darwin-x64": 4.21.1 - "@rollup/rollup-linux-arm-gnueabihf": 4.21.1 - "@rollup/rollup-linux-arm-musleabihf": 4.21.1 - "@rollup/rollup-linux-arm64-gnu": 4.21.1 - "@rollup/rollup-linux-arm64-musl": 4.21.1 - "@rollup/rollup-linux-powerpc64le-gnu": 4.21.1 - "@rollup/rollup-linux-riscv64-gnu": 4.21.1 - "@rollup/rollup-linux-s390x-gnu": 4.21.1 - "@rollup/rollup-linux-x64-gnu": 4.21.1 - "@rollup/rollup-linux-x64-musl": 4.21.1 - "@rollup/rollup-win32-arm64-msvc": 4.21.1 - "@rollup/rollup-win32-ia32-msvc": 4.21.1 - "@rollup/rollup-win32-x64-msvc": 4.21.1 + "@rollup/rollup-android-arm-eabi": 4.21.2 + "@rollup/rollup-android-arm64": 4.21.2 + "@rollup/rollup-darwin-arm64": 4.21.2 + "@rollup/rollup-darwin-x64": 4.21.2 + "@rollup/rollup-linux-arm-gnueabihf": 4.21.2 + "@rollup/rollup-linux-arm-musleabihf": 4.21.2 + "@rollup/rollup-linux-arm64-gnu": 4.21.2 + "@rollup/rollup-linux-arm64-musl": 4.21.2 + "@rollup/rollup-linux-powerpc64le-gnu": 4.21.2 + "@rollup/rollup-linux-riscv64-gnu": 4.21.2 + "@rollup/rollup-linux-s390x-gnu": 4.21.2 + "@rollup/rollup-linux-x64-gnu": 4.21.2 + "@rollup/rollup-linux-x64-musl": 4.21.2 + "@rollup/rollup-win32-arm64-msvc": 4.21.2 + "@rollup/rollup-win32-ia32-msvc": 4.21.2 + "@rollup/rollup-win32-x64-msvc": 4.21.2 fsevents: 2.3.3 run-parallel@1.2.0: @@ -5181,7 +5206,7 @@ snapshots: update-browserslist-db@1.1.0(browserslist@4.23.3): dependencies: browserslist: 4.23.3 - escalade: 3.1.2 + escalade: 3.2.0 picocolors: 1.0.1 uri-js@4.4.1: @@ -5248,7 +5273,7 @@ snapshots: dependencies: esbuild: 0.21.5 postcss: 8.4.41 - rollup: 4.21.1 + rollup: 4.21.2 optionalDependencies: "@types/node": 20.14.12 fsevents: 2.3.3 @@ -5350,7 +5375,7 @@ snapshots: yargs@17.7.2: dependencies: cliui: 8.0.1 - escalade: 3.1.2 + escalade: 3.2.0 get-caller-file: 2.0.5 require-directory: 2.1.1 string-width: 4.2.3