From f6379bfbaec08cacb0f24d5890a1cab51641294b Mon Sep 17 00:00:00 2001 From: 0xyaco Date: Fri, 25 Oct 2024 19:13:26 +0200 Subject: [PATCH] test: e2e dispute (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 🤖 Linear Closes GRT-192, GRT-216 ## Description * Test agent reaction to wrong response Miscelanous fixes: * Improve `waitForEvent` E2E testing util to return the found event * Move dispute status mapping into `ProphetCodec` class * Normalize all IDs read from events as soon as they are fetched * Fix dispute settling errors not being handled correctly (thus not escalating if needed) * Fix proposing a new response when the active response has been disputed --- .../e2e/scenarios/01_happy_path/index.spec.ts | 269 +++++++++++++++++- .../prophet-e2e-scaffold/waitForEvent.ts | 16 +- .../test/approveAccountingModules.spec.ts | 1 + .../src/exceptions/errorFactory.ts | 4 +- .../src/exceptions/errorHandler.ts | 2 + .../src/providers/protocolProvider.ts | 86 ++---- .../src/services/eboActor.ts | 144 +++++++--- .../src/services/eboActorsManager.ts | 6 +- .../src/services/eboProcessor.ts | 24 +- .../eboRegistry/commands/addResponse.ts | 6 +- .../services/eboRegistry/eboMemoryRegistry.ts | 2 + .../src/services/prophetCodec.ts | 30 +- .../tests/services/eboActor.spec.ts | 55 ++-- .../eboActor/onLastBlockupdated.spec.ts | 2 +- .../tests/services/prophetCodec.spec.ts | 32 ++- .../tests/services/protocolProvider.spec.ts | 51 +--- packages/shared/src/external.ts | 2 +- packages/shared/src/services/index.ts | 1 + packages/shared/src/services/stringify.ts | 2 + 19 files changed, 529 insertions(+), 206 deletions(-) create mode 100644 packages/shared/src/services/stringify.ts diff --git a/apps/agent/test/e2e/scenarios/01_happy_path/index.spec.ts b/apps/agent/test/e2e/scenarios/01_happy_path/index.spec.ts index 1cbcdc7..0681efb 100644 --- a/apps/agent/test/e2e/scenarios/01_happy_path/index.spec.ts +++ b/apps/agent/test/e2e/scenarios/01_happy_path/index.spec.ts @@ -6,6 +6,7 @@ import { ProphetCodec, ProtocolProvider, } from "@ebo-agent/automated-dispute"; +import { RequestId } from "@ebo-agent/automated-dispute/dist/types/prophet.js"; import { BlockNumberService } from "@ebo-agent/blocknumber"; import { Caip2ChainId, Logger } from "@ebo-agent/shared"; import { CreateServerReturnType } from "prool"; @@ -78,6 +79,10 @@ const FORK_L2_URL = "https://arbitrum-sepolia.gateway.tenderly.co"; const PROTOCOL_L2_LOCAL_URL = `http://${PROTOCOL_L2_LOCAL_RPC_HOST}:${PROTOCOL_L2_LOCAL_RPC_PORT}/1`; +const newEventAbi = parseAbiItem( + "event NewEpoch(uint256 indexed _epoch, string indexed _chainId, uint256 _blockNumber)", +); + describe.sequential("single agent", () => { let l2ProtocolAnvil: CreateServerReturnType; @@ -258,7 +263,7 @@ describe.sequential("single agent", () => { blockTimeout: initBlock + 1000n, }); - expect(requestCreatedEvent).toBe(true); + expect(requestCreatedEvent).toBeDefined(); const responseProposedAbi = getAbiItem({ abi: oracleAbi, name: "ResponseProposed" }); @@ -277,7 +282,7 @@ describe.sequential("single agent", () => { blockTimeout: initBlock + 1000n, }); - expect(responseProposedEvent).toBe(true); + expect(responseProposedEvent).toBeDefined(); await anvilClient.increaseTime({ seconds: 60 * 60 * 24 * 7 * 4 }); @@ -306,9 +311,7 @@ describe.sequential("single agent", () => { filter: { address: protocolContracts["EBOFinalityModule"], fromBlock: initBlock, - event: parseAbiItem( - "event NewEpoch(uint256 indexed _epoch, string indexed _chainId, uint256 _blockNumber)", - ), + event: newEventAbi, strict: true, }, matcher: (log) => { @@ -325,4 +328,260 @@ describe.sequential("single agent", () => { expect(oracleRequestFinalizedEvent).toBeDefined(); expect(newEpochEvent).toBeDefined(); }); + + /** + * Given: + * - A single agent A1 operating for chain CHAIN1 + * - A request REQ1 for E1, a wrong response RESP1(REQ1) + * - Within the RESP1 dispute window + * + * When: + * - A1 detects RESP1 is wrong + * + * Then: + * - A1 disputes RESP1 with DISP1 + * - `ResponseDisputed(RESP1.id, DISP1.id, DISP1)` + * - A1 proposes RESP2 + * - `ResponseProposed(REQ1.id, RESP2.id, RESP2)` + * - A1 settles DISP1 and it ends with status `Won` + * - `DisputeStatusUpdated(DISP1.id, DISP1, "Won")` + * - A1 finalizes REQ1 with RESP2 + * - `EBOFinalityModule.newEpoch(E1, CHAIN1, RESP2.response)` + * - `OracleRequestFinalized(REQ1.id, RESP2.id, A1.address)` + */ + test.skip("dispute response and propose a new one", { timeout: E2E_TEST_TIMEOUT }, async () => { + const logger = Logger.getInstance(); + + const protocolProvider = new ProtocolProvider( + { + l1: { + chainId: PROTOCOL_L2_CHAIN_ID, + // Using the same RPC due to Anvil's arbitrum block number bug + urls: [PROTOCOL_L2_LOCAL_URL], + transactionReceiptConfirmations: 1, + timeout: 1_000, + retryInterval: 500, + }, + l2: { + chainId: PROTOCOL_L2_CHAIN_ID, + urls: [PROTOCOL_L2_LOCAL_URL], + transactionReceiptConfirmations: 1, + timeout: 1_000, + retryInterval: 500, + }, + }, + { + bondEscalationModule: protocolContracts["BondEscalationModule"], + eboRequestCreator: protocolContracts["EBORequestCreator"], + epochManager: EPOCH_MANAGER_ADDRESS, + oracle: protocolContracts["Oracle"], + horizonAccountingExtension: protocolContracts["HorizonAccountingExtension"], + }, + accounts[0].privateKey, + ); + + vi.spyOn(protocolProvider, "getAccountingApprovedModules").mockResolvedValue([ + protocolContracts["EBORequestModule"], + protocolContracts["BondedResponseModule"], + protocolContracts["BondEscalationModule"], + ]); + + const blockNumberService = new BlockNumberService( + new Map([[PROTOCOL_L2_CHAIN_ID, [PROTOCOL_L2_LOCAL_URL]]]), + { + baseUrl: new URL("http://not.needed/"), + bearerToken: "not.needed", + bearerTokenExpirationWindow: 1000, + servicePaths: { + block: "/block", + blockByTime: "/blockByTime", + }, + }, + logger, + ); + + const actorsManager = new EboActorsManager(); + + const processor = new EboProcessor( + { + requestModule: protocolContracts["EBORequestModule"], + responseModule: protocolContracts["BondedResponseModule"], + escalationModule: protocolContracts["BondEscalationModule"], + }, + protocolProvider, + blockNumberService, + actorsManager, + logger, + { + notifyError: vi.fn(), + } as unknown as NotificationService, + ); + + const anvilClient = createTestClient({ + mode: "anvil", + account: GRT_HOLDER, + chain: PROTOCOL_L2_CHAIN, + transport: http(PROTOCOL_L2_LOCAL_URL), + }) + .extend(publicActions) + .extend(walletActions); + + // Set epoch length to a big enough epoch length as in sepolia is way too short at the moment + await setEpochLength({ + length: 100_000n, + client: anvilClient, + epochManagerAddress: EPOCH_MANAGER_ADDRESS, + governorAddress: GOVERNOR_ADDRESS, + }); + + const initBlock = await anvilClient.getBlockNumber(); + const currentEpoch = await protocolProvider.getCurrentEpoch(); + + const correctResponse = await blockNumberService.getEpochBlockNumber( + currentEpoch.startTimestamp, + PROTOCOL_L2_CHAIN_ID, + ); + + await protocolProvider.createRequest(currentEpoch.number, PROTOCOL_L2_CHAIN_ID); + + const requestCreatedEvent = await waitForEvent({ + client: anvilClient, + filter: { + address: protocolContracts["Oracle"], + fromBlock: initBlock, + event: getAbiItem({ abi: oracleAbi, name: "RequestCreated" }), + strict: true, + }, + pollingIntervalMs: 100, + blockTimeout: initBlock + 1000n, + }); + + expect(requestCreatedEvent).toBeDefined(); + + await protocolProvider.proposeResponse(requestCreatedEvent.args._request, { + proposer: accounts[0].account.address, + requestId: requestCreatedEvent.args._requestId as RequestId, + response: ProphetCodec.encodeResponse({ block: correctResponse - 1n }), + }); + + const badResponseProposedEvent = await waitForEvent({ + client: anvilClient, + filter: { + address: protocolContracts["Oracle"], + fromBlock: initBlock, + event: getAbiItem({ abi: oracleAbi, name: "ResponseProposed" }), + strict: true, + }, + pollingIntervalMs: 100, + blockTimeout: initBlock + 1000n, + }); + + processor.start(3000); + + const badResponseDisputedEvent = await waitForEvent({ + client: anvilClient, + filter: { + address: protocolContracts["Oracle"], + fromBlock: initBlock, + event: getAbiItem({ abi: oracleAbi, name: "ResponseDisputed" }), + strict: true, + }, + matcher: (log) => { + return log.args._responseId === badResponseProposedEvent.args._responseId; + }, + pollingIntervalMs: 100, + blockTimeout: initBlock + 1000n, + }); + + expect(badResponseDisputedEvent).toBeDefined(); + + const correctResponseProposedEvent = await waitForEvent({ + client: anvilClient, + filter: { + address: protocolContracts["Oracle"], + fromBlock: initBlock, + event: getAbiItem({ abi: oracleAbi, name: "ResponseProposed" }), + strict: true, + }, + matcher: (log) => { + const responseBlock = ProphetCodec.decodeResponse( + log.args._response.response, + ).block; + + return ( + log.args._requestId === requestCreatedEvent.args._requestId && + log.args._responseId !== badResponseProposedEvent.args._responseId && + responseBlock === correctResponse + ); + }, + pollingIntervalMs: 100, + blockTimeout: initBlock + 1000n, + }); + + expect(correctResponseProposedEvent).toBeDefined(); + + await anvilClient.increaseTime({ seconds: 60 * 60 * 24 * 7 * 4 }); + + const disputeSettledEvent = await waitForEvent({ + client: anvilClient, + filter: { + address: protocolContracts["Oracle"], + fromBlock: initBlock, + event: getAbiItem({ abi: oracleAbi, name: "DisputeStatusUpdated" }), + strict: true, + }, + matcher: (log) => { + const status = ProphetCodec.decodeDisputeStatus(log.args._status); + + return ( + log.args._disputeId === badResponseDisputedEvent.args._disputeId && + status === "Won" + ); + }, + pollingIntervalMs: 100, + blockTimeout: initBlock + 1000n, + }); + + expect(disputeSettledEvent).toBeDefined(); + + const [requestFinalizedEvent, newEpochEvent] = await Promise.all([ + waitForEvent({ + client: anvilClient, + filter: { + address: protocolContracts["Oracle"], + fromBlock: initBlock, + event: getAbiItem({ abi: oracleAbi, name: "OracleRequestFinalized" }), + strict: true, + }, + matcher: (log) => { + return ( + log.args._requestId === requestCreatedEvent.args._requestId && + log.args._responseId === correctResponseProposedEvent.args._responseId + ); + }, + pollingIntervalMs: 100, + blockTimeout: initBlock + 1000n, + }), + waitForEvent({ + client: anvilClient, + filter: { + address: protocolContracts["EBOFinalityModule"], + fromBlock: initBlock, + event: newEventAbi, + strict: true, + }, + matcher: (log) => { + return ( + log.args._chainId === keccak256(toHex(ARBITRUM_SEPOLIA_ID)) && + log.args._epoch === currentEpoch.number + ); + }, + pollingIntervalMs: 100, + blockTimeout: initBlock + 1000n, + }), + ]); + + expect(requestFinalizedEvent).toBeDefined(); + expect(newEpochEvent).toBeDefined(); + }); }); diff --git a/apps/agent/test/e2e/utils/prophet-e2e-scaffold/waitForEvent.ts b/apps/agent/test/e2e/utils/prophet-e2e-scaffold/waitForEvent.ts index 46a0dff..318f9a3 100644 --- a/apps/agent/test/e2e/utils/prophet-e2e-scaffold/waitForEvent.ts +++ b/apps/agent/test/e2e/utils/prophet-e2e-scaffold/waitForEvent.ts @@ -1,4 +1,4 @@ -import { AbiEvent, GetLogsParameters, Log } from "viem"; +import { AbiEvent, GetLogsParameters, Log, MaybeAbiEventName } from "viem"; import { AnvilClient } from "./anvil.js"; @@ -6,9 +6,12 @@ interface WaitForEventInput { /** Client to use for event polling */ client: client; /** Event filtering */ - filter: GetLogsParameters; + filter: GetLogsParameters; /** Matcher to apply to filtered events */ - matcher: (log: Log) => boolean; + matcher?: ( + log: Log>, + ) => boolean; + /** Event polling interval in milliseconds */ pollingIntervalMs: number; /** Block number to time out after the polled chain has reached the specified block */ @@ -31,12 +34,13 @@ export async function waitForEvent(filter); + const logs = await client.getLogs(filter); + const matchingLogs = matcher ? logs.filter(matcher) : logs; - if (logs.some(matcher)) return true; + if (matchingLogs && matchingLogs.length > 0) return matchingLogs[0]; await new Promise((r) => setTimeout(r, pollingInterval)); } while (currentBlock < blockTimeout); - return false; + throw new Error(`Event ${filter.event?.name} not found.`); } diff --git a/apps/scripts/test/approveAccountingModules.spec.ts b/apps/scripts/test/approveAccountingModules.spec.ts index 1dc55b8..b3a4a12 100644 --- a/apps/scripts/test/approveAccountingModules.spec.ts +++ b/apps/scripts/test/approveAccountingModules.spec.ts @@ -45,6 +45,7 @@ describe("approveModules script", () => { "Approved module: Bonded Response Module at address 0xBondedResponseModule", ), ); + expect(console.log).toHaveBeenCalledWith( expect.stringContaining( "Approved module: Bond Escalation Module at address 0xBondEscalationModule", diff --git a/packages/automated-dispute/src/exceptions/errorFactory.ts b/packages/automated-dispute/src/exceptions/errorFactory.ts index cfa6029..1ea62a7 100644 --- a/packages/automated-dispute/src/exceptions/errorFactory.ts +++ b/packages/automated-dispute/src/exceptions/errorFactory.ts @@ -307,7 +307,7 @@ const errorStrategiesEntries: [ErrorName, ErrorHandlingStrategy][] = [ { shouldNotify: false, shouldTerminate: false, - shouldReenqueue: true, + shouldReenqueue: false, }, ], [ @@ -315,7 +315,7 @@ const errorStrategiesEntries: [ErrorName, ErrorHandlingStrategy][] = [ { shouldNotify: false, shouldTerminate: false, - shouldReenqueue: true, + shouldReenqueue: false, }, ], [ diff --git a/packages/automated-dispute/src/exceptions/errorHandler.ts b/packages/automated-dispute/src/exceptions/errorHandler.ts index 7730e62..532f714 100644 --- a/packages/automated-dispute/src/exceptions/errorHandler.ts +++ b/packages/automated-dispute/src/exceptions/errorHandler.ts @@ -33,6 +33,8 @@ export class ErrorHandler { if (strategy.shouldTerminate && context.terminateActor) { context.terminateActor(); + } else { + this.logger.warn(`Event handling caused an error`); } } } diff --git a/packages/automated-dispute/src/providers/protocolProvider.ts b/packages/automated-dispute/src/providers/protocolProvider.ts index fd5748d..40f9d9b 100644 --- a/packages/automated-dispute/src/providers/protocolProvider.ts +++ b/packages/automated-dispute/src/providers/protocolProvider.ts @@ -1,5 +1,5 @@ import { UnsupportedChain } from "@ebo-agent/blocknumber"; -import { Caip2ChainId, UnixTimestamp } from "@ebo-agent/shared"; +import { Caip2ChainId, HexUtils, UnixTimestamp } from "@ebo-agent/shared"; import { Address, BaseError, @@ -25,7 +25,6 @@ import { arbitrum, arbitrumSepolia, mainnet, sepolia } from "viem/chains"; import type { Dispute, DisputeId, - DisputeStatus, EboEvent, EboEventName, Epoch, @@ -48,8 +47,8 @@ import { InvalidBlockRangeError, RpcUrlsEmpty, TransactionExecutionError, - UnknownDisputeStatus, } from "../exceptions/index.js"; +import { ProphetCodec } from "../external.js"; import { IProtocolProvider, IReadProvider, @@ -249,36 +248,6 @@ export class ProtocolProvider implements IProtocolProvider { } } - /** - * Maps a uint8 value to the corresponding DisputeStatus string. - * - * The mapping corresponds to the DisputeStatus enum in the Prophet's IOracle.sol: - * https://github.com/defi-wonderland/prophet-core/blob/dev/solidity/interfaces/IOracle.sol#L178-L186 - * - * Enums use 0-based indexes (None has index 0, Active has index 1, etc.). - * - * @param status - The uint8 value representing the dispute status. - * @returns The DisputeStatus string corresponding to the input value. - */ - private mapDisputeStatus(status: number): DisputeStatus { - switch (status) { - case 0: - return "None"; - case 1: - return "Active"; - case 2: - return "Escalated"; - case 3: - return "Won"; - case 4: - return "Lost"; - case 5: - return "NoResolution"; - default: - throw new UnknownDisputeStatus(status); - } - } - /** * Returns the address of the account used for transactions. * @@ -373,13 +342,13 @@ export class ProtocolProvider implements IProtocolProvider { timestamp: timestamp, rawLog: event, - requestId: _requestId as RequestId, + requestId: HexUtils.normalize(_requestId) as RequestId, metadata: { - responseId: _responseId as ResponseId, - requestId: _requestId as RequestId, + responseId: HexUtils.normalize(_responseId) as ResponseId, + requestId: HexUtils.normalize(_requestId) as RequestId, response: { proposer: _response.proposer, - requestId: _response.requestId as RequestId, + requestId: HexUtils.normalize(_response.requestId) as RequestId, response: _response.response, }, }, @@ -423,15 +392,15 @@ export class ProtocolProvider implements IProtocolProvider { timestamp: timestamp, rawLog: event, - requestId: _dispute.requestId as RequestId, + requestId: HexUtils.normalize(_dispute.requestId) as RequestId, metadata: { - responseId: _responseId as ResponseId, - disputeId: _disputeId as DisputeId, + responseId: HexUtils.normalize(_responseId) as ResponseId, + disputeId: HexUtils.normalize(_disputeId) as DisputeId, dispute: { disputer: _dispute.disputer, proposer: _dispute.proposer, - responseId: _dispute.responseId as ResponseId, - requestId: _dispute.requestId as RequestId, + responseId: HexUtils.normalize(_dispute.responseId) as ResponseId, + requestId: HexUtils.normalize(_dispute.requestId) as RequestId, }, }, } as EboEvent<"ResponseDisputed">; @@ -474,16 +443,16 @@ export class ProtocolProvider implements IProtocolProvider { timestamp: timestamp, rawLog: event, - requestId: _dispute.requestId as RequestId, + requestId: HexUtils.normalize(_dispute.requestId) as RequestId, metadata: { - disputeId: _disputeId as DisputeId, + disputeId: HexUtils.normalize(_disputeId) as DisputeId, dispute: { disputer: _dispute.disputer, proposer: _dispute.proposer, - responseId: _dispute.responseId as ResponseId, - requestId: _dispute.requestId as RequestId, + responseId: HexUtils.normalize(_dispute.responseId) as ResponseId, + requestId: HexUtils.normalize(_dispute.requestId) as RequestId, }, - status: this.mapDisputeStatus(_status), + status: ProphetCodec.decodeDisputeStatus(_status), blockNumber: event.blockNumber, }, } as EboEvent<"DisputeStatusUpdated">; @@ -531,14 +500,14 @@ export class ProtocolProvider implements IProtocolProvider { timestamp: timestamp, rawLog: event, - requestId: _dispute.requestId as RequestId, + requestId: HexUtils.normalize(_dispute.requestId) as RequestId, metadata: { - disputeId: _disputeId as DisputeId, + disputeId: HexUtils.normalize(_disputeId) as DisputeId, dispute: { disputer: _dispute.disputer, proposer: _dispute.proposer, - responseId: _dispute.responseId as ResponseId, - requestId: _dispute.requestId as RequestId, + responseId: HexUtils.normalize(_dispute.responseId) as ResponseId, + requestId: HexUtils.normalize(_dispute.requestId) as RequestId, }, caller: _caller as Address, blockNumber: event.blockNumber, @@ -584,10 +553,10 @@ export class ProtocolProvider implements IProtocolProvider { timestamp: timestamp, rawLog: event, - requestId: _requestId as RequestId, + requestId: HexUtils.normalize(_requestId) as RequestId, metadata: { - requestId: _requestId as RequestId, - responseId: _responseId as ResponseId, + requestId: HexUtils.normalize(_requestId) as RequestId, + responseId: HexUtils.normalize(_responseId) as ResponseId, caller: _caller as Address, blockNumber: event.blockNumber, }, @@ -679,9 +648,9 @@ export class ProtocolProvider implements IProtocolProvider { timestamp: timestamp, rawLog: event, - requestId: event.args._requestId as RequestId, + requestId: HexUtils.normalize(event.args._requestId) as RequestId, metadata: { - requestId: event.args._requestId as RequestId, + requestId: HexUtils.normalize(event.args._requestId) as RequestId, request: event.args._request, ipfsHash: event.args._ipfsHash, }, @@ -977,8 +946,10 @@ export class ProtocolProvider implements IProtocolProvider { const revertError = error.walk( (err) => err instanceof ContractFunctionRevertedError, ); + if (revertError instanceof ContractFunctionRevertedError) { const errorName = revertError.data?.errorName ?? ""; + throw ErrorFactory.createError(errorName); } } @@ -1026,11 +997,14 @@ export class ProtocolProvider implements IProtocolProvider { const revertError = error.walk( (err) => err instanceof ContractFunctionRevertedError, ); + if (revertError instanceof ContractFunctionRevertedError) { const errorName = revertError.data?.errorName ?? ""; + throw ErrorFactory.createError(errorName); } } + throw error; } } diff --git a/packages/automated-dispute/src/services/eboActor.ts b/packages/automated-dispute/src/services/eboActor.ts index cf6a20b..db2c4d3 100644 --- a/packages/automated-dispute/src/services/eboActor.ts +++ b/packages/automated-dispute/src/services/eboActor.ts @@ -1,5 +1,5 @@ import { BlockNumberService } from "@ebo-agent/blocknumber"; -import { Caip2ChainId, HexUtils, ILogger, UnixTimestamp } from "@ebo-agent/shared"; +import { Caip2ChainId, ILogger, stringify, UnixTimestamp } from "@ebo-agent/shared"; import { Mutex } from "async-mutex"; import { Heap } from "heap-js"; import { ContractFunctionRevertedError } from "viem"; @@ -14,7 +14,6 @@ import type { Request, Response, ResponseBody, - ResponseId, } from "../types/index.js"; import { ErrorHandler } from "../exceptions/errorHandler.js"; import { @@ -161,8 +160,6 @@ export class EboActor { await this.onLastEvent(event); } } catch (err) { - this.logger.error(`Error processing event ${event.name}: ${err}`); - if (err instanceof CustomContractError) { err.setProcessEventsContext( event, @@ -176,8 +173,11 @@ export class EboActor { ); await this.errorHandler.handle(err); + return; } else { + this.logger.error(`Error processing event ${event.name}: ${err}`); + throw err; } } @@ -290,26 +290,16 @@ export class EboActor { await this.settleDisputes(atTimestamp); const request = this.getActorRequest(); - const proposalDeadline = - request.createdAt.timestamp + request.decodedData.responseModuleData.deadline; - const isProposalWindowOpen = atTimestamp <= proposalDeadline; + const response = this.getFinalizableResponse(request, atTimestamp); - if (isProposalWindowOpen) { - this.logger.debug(`Proposal window for request ${request.id} not closed yet.`); + if (response) { + await this.finalizeRequest(request, response); return; + } else { + // TODO: check for responseModuleData.deadline, if no answer has been accepted after the deadline + // notify and (TBD) finalize with no response } - - const acceptedResponse = this.getAcceptedResponse(atTimestamp); - - 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 } /** @@ -343,6 +333,60 @@ export class EboActor { await Promise.all(settledDisputes); } + /** + * Get the first response to be used to finalize a request. + * + * @param request request to search responses for + * @param atTimestamp timestamp to validate Prophet's time windows + * @returns a `Response` if can be used to finalize `request. Otherwise undefined. + */ + private getFinalizableResponse(request: Request, atTimestamp: UnixTimestamp) { + this.logger.info("Getting finalizable requests..."); + + const proposalDeadline = + request.createdAt.timestamp + request.decodedData.responseModuleData.deadline; + + const isProposalWindowOpen = atTimestamp <= proposalDeadline; + + if (isProposalWindowOpen) { + this.logger.info(`Proposal window for request ${request.id} not closed yet.`); + + return undefined; + } + + return this.getAcceptedResponse(atTimestamp); + } + + /** + * Finalize `request` with `response`. + * + * @param request a `Request` + * @param response a `Response` + */ + private async finalizeRequest(request: Request, response: Response) { + this.logger.info(`Finalizing request.`); + this.logger.debug(stringify({ request: request, response: response })); + + try { + await this.protocolProvider.finalize(request.prophetData, response.prophetData); + } catch (err) { + if (err instanceof CustomContractError) { + this.logger.warn(`Finalizing request reverted: ${err.name}`); + this.logger.debug(stringify({ request, error: err })); + + err.setContext({ + request, + response, + registry: this.registry, + }); + + this.errorHandler.handle(err); + } else { + throw err; + } + } + } + private getActiveDisputes(): Dispute[] { const disputes = this.registry.getDisputes(); @@ -386,36 +430,38 @@ export class EboActor { this.logger.info(`Dispute ${dispute.id} settled.`); } catch (err) { - if (err instanceof ContractFunctionRevertedError) { - const errorName = err.data?.errorName || err.name; - this.logger.warn(`Call reverted for dispute ${dispute.id} due to: ${errorName}`); + if (err instanceof CustomContractError) { + this.logger.warn(`Call reverted for dispute ${dispute.id} due to: ${err.name}`); - const customError = ErrorFactory.createError(errorName); - customError.setContext({ + err.setContext({ request, response, dispute, registry: this.registry, }); - customError.on("BondEscalationModule_ShouldBeEscalated", async () => { + err.on("BondEscalationModule_ShouldBeEscalated", async () => { try { await this.protocolProvider.escalateDispute( request.prophetData, response.prophetData, dispute.prophetData, ); + this.logger.info(`Dispute ${dispute.id} escalated.`); - await this.errorHandler.handle(customError); + + await this.errorHandler.handle(err); } catch (escalationError) { this.logger.error( `Failed to escalate dispute ${dispute.id}: ${escalationError}`, ); + throw escalationError; } }); } else { this.logger.error(`Failed to escalate dispute ${dispute.id}: ${err}`); + throw err; } } @@ -445,13 +491,14 @@ export class EboActor { private isResponseAccepted(response: Response, atTimestamp: UnixTimestamp) { const request = this.getActorRequest(); const dispute = this.registry.getResponseDispute(response); + const disputeWindow = response.createdAt.timestamp + request.decodedData.disputeModuleData.disputeWindow; // Response is still able to be disputed if (atTimestamp <= disputeWindow) return false; - return dispute ? dispute.status === "Lost" : true; + return dispute ? ["Lost", "None"].includes(dispute.status) : true; } /** @@ -598,10 +645,14 @@ export class EboActor { * @param chainId the CAIP-2 compliant chain ID */ private async proposeResponse(chainId: Caip2ChainId): Promise { + this.logger.info(`Proposing response for ${chainId}`); + const responseBody = await this.buildResponseBody(chainId); const request = this.getActorRequest(); if (this.alreadyProposed(responseBody.block)) { + this.logger.warn(`Block ${responseBody.block} already proposed`); + throw new ResponseAlreadyProposed(request, responseBody); } @@ -615,6 +666,8 @@ export class EboActor { try { await this.protocolProvider.proposeResponse(request.prophetData, response); + + this.logger.info(`Block ${responseBody.block} proposed`); } catch (err) { if (err instanceof ContractFunctionRevertedError) { const { epoch } = request.decodedData.requestModuleData; @@ -674,9 +727,10 @@ export class EboActor { const dispute: Dispute["prophetData"] = { disputer: disputer, proposer: proposedResponse.prophetData.proposer, - responseId: HexUtils.normalize(event.metadata.responseId) as ResponseId, + responseId: event.metadata.responseId, requestId: request.id, }; + try { await this.protocolProvider.disputeResponse( request.prophetData, @@ -761,8 +815,28 @@ export class EboActor { const isValidDispute = await this.isValidDispute(request, proposedResponse); - if (isValidDispute) await this.pledgeFor(request, dispute); - else await this.pledgeAgainst(request, dispute); + if (isValidDispute) { + await Promise.all([ + this.pledgeFor(request, dispute), + (async () => { + try { + const { chainId } = request.decodedData.requestModuleData; + + await this.proposeResponse(chainId); + } catch (err) { + if (err instanceof ResponseAlreadyProposed) { + this.logger.warn(err.message); + } else { + this.logger.error( + `Could not propose a new response after response ${proposedResponse.id} disputal.`, + ); + + throw err; + } + } + })(), + ]); + } else await this.pledgeAgainst(request, dispute); } /** @@ -777,12 +851,8 @@ export class EboActor { const { chainId } = request.decodedData.requestModuleData; const actorResponse = { - prophetData: { - requestId: request.id, - }, - decodedData: { - response: await this.buildResponseBody(chainId), - }, + prophetData: { requestId: request.id }, + decodedData: { response: await this.buildResponseBody(chainId) }, }; const equalResponses = this.equalResponses(actorResponse, proposedResponse); diff --git a/packages/automated-dispute/src/services/eboActorsManager.ts b/packages/automated-dispute/src/services/eboActorsManager.ts index 86e2700..71cc230 100644 --- a/packages/automated-dispute/src/services/eboActorsManager.ts +++ b/packages/automated-dispute/src/services/eboActorsManager.ts @@ -1,5 +1,5 @@ import { BlockNumberService } from "@ebo-agent/blocknumber"; -import { HexUtils, ILogger } from "@ebo-agent/shared"; +import { ILogger } from "@ebo-agent/shared"; import { Mutex } from "async-mutex"; import { RequestAlreadyHandled } from "../exceptions/index.js"; @@ -22,9 +22,7 @@ export class EboActorsManager { * @returns array of normalized request IDs */ public getRequestIds(): RequestId[] { - return [...this.requestActorMap.keys()].map( - (requestId) => HexUtils.normalize(requestId) as RequestId, - ); + return [...this.requestActorMap.keys()].map((requestId) => requestId); } public getActorsRequests(): ActorRequest[] { diff --git a/packages/automated-dispute/src/services/eboProcessor.ts b/packages/automated-dispute/src/services/eboProcessor.ts index f6f105c..2f435da 100644 --- a/packages/automated-dispute/src/services/eboProcessor.ts +++ b/packages/automated-dispute/src/services/eboProcessor.ts @@ -1,6 +1,13 @@ import { isNativeError } from "util/types"; import { BlockNumberService } from "@ebo-agent/blocknumber"; -import { Caip2ChainId, Caip2Utils, HexUtils, ILogger, UnixTimestamp } from "@ebo-agent/shared"; +import { + Caip2ChainId, + Caip2Utils, + HexUtils, + ILogger, + stringify, + UnixTimestamp, +} from "@ebo-agent/shared"; import { Block, ContractFunctionRevertedError } from "viem"; import { @@ -61,6 +68,7 @@ export class EboProcessor { await this.sync(); } catch (err) { this.logger.error(`Unhandled error during the event loop: ${err}`); + await this.notifier.notifyError(err as Error, { message: "Unhandled error during the event loop", }); @@ -121,7 +129,7 @@ export class EboProcessor { const lastBlock = await this.getLastFinalizedBlock(); // Events will sync starting from the block after the last checked one, - // making the block interval exclusive on its lower bound: + // making the block interval exclusive of its lower bound: // (last checked block, last block] const events = await this.getEvents(this.lastCheckedBlock + 1n, lastBlock.number); @@ -207,9 +215,8 @@ export class EboProcessor { const events = await this.protocolProvider.getEvents(fromBlock, toBlock); - // TODO: add a logger.debug of all the fetched events, super useful during debugging - this.logger.info(`${events.length} events fetched.`); + this.logger.debug(stringify(events)); return events; } @@ -225,7 +232,7 @@ export class EboProcessor { const groupedEvents = new Map(); for (const event of events) { - const requestId = HexUtils.normalize(event.requestId) as RequestId; + const requestId = event.requestId; const requestEvents = groupedEvents.get(requestId) || []; groupedEvents.set(requestId, [...requestEvents, event]); @@ -245,7 +252,7 @@ export class EboProcessor { const actorsRequestIds = this.actorsManager.getRequestIds(); const uniqueRequestIds = new Set([...eventsRequestIds, ...actorsRequestIds]); - return [...uniqueRequestIds].map((requestId) => HexUtils.normalize(requestId) as RequestId); + return [...uniqueRequestIds].map((requestId) => requestId); } /** @@ -318,7 +325,7 @@ export class EboProcessor { const isChainSupported = Caip2Utils.isSupported(chainId); if (isChainSupported) { - const requestId = HexUtils.normalize(firstEvent.requestId) as RequestId; + const requestId = firstEvent.requestId; this.logger.info(`Creating a new EboActor to handle request ${requestId}...`); @@ -381,6 +388,8 @@ export class EboProcessor { */ private async createMissingRequests(epoch: Epoch["number"]): Promise { try { + // TODO: keep requests even when their actors are finalized to avoid + // trying to create requests again when they've been already solved const handledEpochChains = this.actorsManager .getActorsRequests() .reduce((actorRequestMap, actorRequest: ActorRequest) => { @@ -423,7 +432,6 @@ export class EboProcessor { } catch (err) { // Request creation must be notified but it's not critical, as it will be // retried during next sync. - if (err instanceof ContractFunctionRevertedError) { if (err.name === "EBORequestCreator_RequestAlreadyCreated") { this.logger.info( diff --git a/packages/automated-dispute/src/services/eboRegistry/commands/addResponse.ts b/packages/automated-dispute/src/services/eboRegistry/commands/addResponse.ts index d782d9e..a348793 100644 --- a/packages/automated-dispute/src/services/eboRegistry/commands/addResponse.ts +++ b/packages/automated-dispute/src/services/eboRegistry/commands/addResponse.ts @@ -1,8 +1,6 @@ -import { HexUtils } from "@ebo-agent/shared"; - import { CommandAlreadyRun, CommandNotRun } from "../../../exceptions/index.js"; import { EboRegistry, EboRegistryCommand } from "../../../interfaces/index.js"; -import { EboEvent, Response, ResponseBody, ResponseId } from "../../../types/index.js"; +import { EboEvent, Response, ResponseBody } from "../../../types/index.js"; import { ProphetCodec } from "../../prophetCodec.js"; export class AddResponse implements EboRegistryCommand { @@ -18,7 +16,7 @@ export class AddResponse implements EboRegistryCommand { const responseBody: ResponseBody = ProphetCodec.decodeResponse(encodedResponse); const response: Response = { - id: HexUtils.normalize(event.metadata.responseId) as ResponseId, + id: event.metadata.responseId, createdAt: { timestamp: event.timestamp, blockNumber: event.blockNumber, diff --git a/packages/automated-dispute/src/services/eboRegistry/eboMemoryRegistry.ts b/packages/automated-dispute/src/services/eboRegistry/eboMemoryRegistry.ts index 8456719..d558dd9 100644 --- a/packages/automated-dispute/src/services/eboRegistry/eboMemoryRegistry.ts +++ b/packages/automated-dispute/src/services/eboRegistry/eboMemoryRegistry.ts @@ -102,6 +102,8 @@ export class EboMemoryRegistry implements EboRegistry { /** @inheritdoc */ removeDispute(disputeId: string): boolean { + // FIXME: remove also all records in responseDispute (probably adding a reverse map, + // as response<>dispute is a 1 to 1 relationship would be a nice approach) return this.disputes.delete(disputeId); } } diff --git a/packages/automated-dispute/src/services/prophetCodec.ts b/packages/automated-dispute/src/services/prophetCodec.ts index 69512ef..f000de5 100644 --- a/packages/automated-dispute/src/services/prophetCodec.ts +++ b/packages/automated-dispute/src/services/prophetCodec.ts @@ -1,7 +1,8 @@ import { Caip2ChainId } from "@ebo-agent/shared"; import { Address, decodeAbiParameters, encodeAbiParameters } from "viem"; -import { Request, Response } from "../types/prophet.js"; +import { UnknownDisputeStatus } from "../exceptions/unknownDisputeStatus.exception.js"; +import { DisputeStatus, Request, Response } from "../types/prophet.js"; const REQUEST_MODULE_DATA_REQUEST_ABI_FIELDS = [ { @@ -48,6 +49,15 @@ const DISPUTE_MODULE_DATA_REQUEST_ABI_FIELDS = [ const RESPONSE_RESPONSE_ABI_FIELDS = [{ name: "block", type: "uint256" }] as const; +const DISPUTE_STATUS_ENUM: DisputeStatus[] = [ + "None", + "Active", + "Escalated", + "Won", + "Lost", + "NoResolution", +]; + /** Class to encode/decode Prophet's structs into/from a byte array */ export class ProphetCodec { /** @@ -178,4 +188,22 @@ export class ProphetCodec { block: decodedParameters[0], }; } + + /** + * Maps a uint8 value to the corresponding DisputeStatus string. + * + * The mapping corresponds to the DisputeStatus enum in the Prophet's IOracle.sol: + * https://github.com/defi-wonderland/prophet-core/blob/dev/solidity/interfaces/IOracle.sol#L178-L186 + * + * Enums use 0-based indexes (None has index 0, Active has index 1, etc.). + * + * @param status - The uint8 value representing the dispute status. + * @returns The DisputeStatus string corresponding to the input value. + */ + static decodeDisputeStatus(status: number): DisputeStatus { + const disputeStatus = DISPUTE_STATUS_ENUM[status]; + + if (!disputeStatus) throw new UnknownDisputeStatus(status); + else return disputeStatus; + } } diff --git a/packages/automated-dispute/tests/services/eboActor.spec.ts b/packages/automated-dispute/tests/services/eboActor.spec.ts index ea23274..7efa305 100644 --- a/packages/automated-dispute/tests/services/eboActor.spec.ts +++ b/packages/automated-dispute/tests/services/eboActor.spec.ts @@ -8,7 +8,7 @@ import { PastEventEnqueueError, RequestMismatch, } from "../../src/exceptions/index.js"; -import { EboEvent, Request, RequestId, ResponseId } from "../../src/types/index.js"; +import { EboEvent, ErrorContext, Request, RequestId, ResponseId } from "../../src/types/index.js"; import mocks from "../mocks/index.js"; import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "../services/eboActor/fixtures.js"; @@ -494,56 +494,37 @@ describe("EboActor", () => { const response = mocks.buildResponse(request); const dispute = mocks.buildDispute(request, response); - const abi: Abi = [ - { - type: "error", - name: "BondEscalationModule_ShouldBeEscalated", - inputs: [], - }, - ]; + const shouldBeEscalatedName = "BondEscalationModule_ShouldBeEscalated"; - const errorName = "BondEscalationModule_ShouldBeEscalated"; - const data = encodeErrorResult({ - abi, - errorName, - args: [], + const customError = new CustomContractError(shouldBeEscalatedName, { + shouldNotify: false, + shouldReenqueue: false, + shouldTerminate: false, }); - const contractError = new ContractFunctionRevertedError({ - abi, - data, - functionName: "settleDispute", - }); + vi.spyOn(protocolProvider, "settleDispute").mockRejectedValue(customError); - vi.spyOn(protocolProvider, "settleDispute").mockRejectedValue(contractError); const escalateDisputeMock = vi .spyOn(protocolProvider, "escalateDispute") .mockResolvedValue(); - const customError = new CustomContractError(errorName, { - shouldNotify: false, - shouldReenqueue: false, - shouldTerminate: false, - }); + vi.spyOn(customError, "on").mockImplementation((err, errorHandler) => { + expect(err).toMatch(shouldBeEscalatedName); + expect(errorHandler).toBeTypeOf("function"); + + errorHandler({} as ErrorContext); + + expect(escalateDisputeMock).toHaveBeenCalledWith( + request.prophetData, + response.prophetData, + dispute.prophetData, + ); - const onSpy = vi.spyOn(customError, "on").mockImplementation((eventName, handler) => { - if (eventName === errorName) { - handler(); - } return customError; }); - vi.spyOn(ErrorFactory, "createError").mockReturnValue(customError); - await actor["settleDispute"](request, response, dispute); - expect(onSpy).toHaveBeenCalledWith(errorName, expect.any(Function)); - - expect(escalateDisputeMock).toHaveBeenCalledWith( - request.prophetData, - response.prophetData, - dispute.prophetData, - ); expect(logger.info).toHaveBeenCalledWith(`Dispute ${dispute.id} escalated.`); }); diff --git a/packages/automated-dispute/tests/services/eboActor/onLastBlockupdated.spec.ts b/packages/automated-dispute/tests/services/eboActor/onLastBlockupdated.spec.ts index 3e2fdf5..2213077 100644 --- a/packages/automated-dispute/tests/services/eboActor/onLastBlockupdated.spec.ts +++ b/packages/automated-dispute/tests/services/eboActor/onLastBlockupdated.spec.ts @@ -170,7 +170,7 @@ describe("EboActor", () => { await actor.onLastBlockUpdated(newBlockNumber as UnixTimestamp); - expect(logger.debug).toBeCalledWith( + expect(logger.info).toBeCalledWith( expect.stringMatching(`Proposal window for request ${request.id} not closed yet.`), ); diff --git a/packages/automated-dispute/tests/services/prophetCodec.spec.ts b/packages/automated-dispute/tests/services/prophetCodec.spec.ts index fa6172d..b976700 100644 --- a/packages/automated-dispute/tests/services/prophetCodec.spec.ts +++ b/packages/automated-dispute/tests/services/prophetCodec.spec.ts @@ -1,8 +1,9 @@ import { isHex } from "viem"; import { describe, expect, it } from "vitest"; +import { UnknownDisputeStatus } from "../../src/exceptions"; import { ProphetCodec } from "../../src/services"; -import { Response } from "../../src/types"; +import { DisputeStatus, Response } from "../../src/types"; describe("ProphetCodec", () => { describe("encodeResponse", () => { @@ -24,6 +25,35 @@ describe("ProphetCodec", () => { }); }); + describe("decodeDisputeStatus", () => { + const testCases: Array<{ input: number; expected: DisputeStatus }> = [ + { input: 0, expected: "None" }, + { input: 1, expected: "Active" }, + { input: 2, expected: "Escalated" }, + { input: 3, expected: "Won" }, + { input: 4, expected: "Lost" }, + { input: 5, expected: "NoResolution" }, + ]; + + testCases.forEach(({ input, expected }) => { + it(`maps status ${input} to '${expected}'`, () => { + const result = ProphetCodec.decodeDisputeStatus(input); + + expect(result).toBe(expected); + }); + }); + + it("throws UnknownDisputeStatus for invalid status", () => { + const invalidStatuses = [-1, 6, 999]; + + invalidStatuses.forEach((status) => { + expect(() => { + ProphetCodec.decodeDisputeStatus(status); + }).toThrow(UnknownDisputeStatus); + }); + }); + }); + describe.todo("decodeResponse"); describe.todo("decodeRequestRequestModuleData"); diff --git a/packages/automated-dispute/tests/services/protocolProvider.spec.ts b/packages/automated-dispute/tests/services/protocolProvider.spec.ts index 5a175a6..ef4326a 100644 --- a/packages/automated-dispute/tests/services/protocolProvider.spec.ts +++ b/packages/automated-dispute/tests/services/protocolProvider.spec.ts @@ -1,4 +1,4 @@ -import { Caip2ChainId } from "@ebo-agent/shared"; +import { Caip2ChainId, HexUtils } from "@ebo-agent/shared"; import { Address, ContractFunctionRevertedError, @@ -25,11 +25,10 @@ import { InvalidAccountOnClient, RpcUrlsEmpty, TransactionExecutionError, - UnknownDisputeStatus, } from "../../src/exceptions/index.js"; import { ProtocolContractsAddresses } from "../../src/interfaces/index.js"; import { ProtocolProvider } from "../../src/providers/index.js"; -import { DisputeStatus, EboEvent } from "../../src/types/index.js"; +import { EboEvent } from "../../src/types/index.js"; import { DEFAULT_MOCKED_DISPUTE_DATA, DEFAULT_MOCKED_REQUEST_CREATED_DATA, @@ -890,6 +889,10 @@ describe("ProtocolProvider", () => { }); describe("getEvents", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("successfully merges and sorts events from all sources", async () => { const request = DEFAULT_MOCKED_REQUEST_CREATED_DATA; @@ -899,6 +902,8 @@ describe("ProtocolProvider", () => { mockedPrivateKey, ); + vi.spyOn(HexUtils, "normalize").mockReturnValue("0x01"); + // FIXME: types are sketchy here const mockRequestCreatorEvents: EboEvent<"RequestCreated">[] = [ { @@ -1037,44 +1042,4 @@ describe("ProtocolProvider", () => { }); }); }); - - describe("mapDisputeStatus", () => { - const testCases: Array<{ input: number; expected: DisputeStatus }> = [ - { input: 0, expected: "None" }, - { input: 1, expected: "Active" }, - { input: 2, expected: "Escalated" }, - { input: 3, expected: "Won" }, - { input: 4, expected: "Lost" }, - { input: 5, expected: "NoResolution" }, - ]; - - testCases.forEach(({ input, expected }) => { - it(`maps status ${input} to '${expected}'`, () => { - const protocolProvider = new ProtocolProvider( - mockRpcConfig, - mockContractAddress, - mockedPrivateKey, - ); - - const result = (protocolProvider as any).mapDisputeStatus(input); - expect(result).toBe(expected); - }); - }); - - it("throws UnknownDisputeStatus for invalid status", () => { - const protocolProvider = new ProtocolProvider( - mockRpcConfig, - mockContractAddress, - mockedPrivateKey, - ); - - const invalidStatuses = [-1, 6, 999]; - - invalidStatuses.forEach((status) => { - expect(() => { - (protocolProvider as any).mapDisputeStatus(status); - }).toThrow(UnknownDisputeStatus); - }); - }); - }); }); diff --git a/packages/shared/src/external.ts b/packages/shared/src/external.ts index bc87529..11e37f6 100644 --- a/packages/shared/src/external.ts +++ b/packages/shared/src/external.ts @@ -1,3 +1,3 @@ export type * from "./types/index.js"; -export { HexUtils, Caip2Utils, Logger } from "./services/index.js"; +export { HexUtils, Caip2Utils, Logger, stringify } from "./services/index.js"; export { InvalidHex, InvalidChainId } from "./exceptions/index.js"; diff --git a/packages/shared/src/services/index.ts b/packages/shared/src/services/index.ts index c7ff42a..9ccb0e9 100644 --- a/packages/shared/src/services/index.ts +++ b/packages/shared/src/services/index.ts @@ -1,3 +1,4 @@ export * from "./hexUtils.js"; export * from "./caip2Utils.js"; export * from "./logger.js"; +export * from "./stringify.js"; diff --git a/packages/shared/src/services/stringify.ts b/packages/shared/src/services/stringify.ts new file mode 100644 index 0000000..cb327c5 --- /dev/null +++ b/packages/shared/src/services/stringify.ts @@ -0,0 +1,2 @@ +export const stringify = (a: unknown) => + JSON.stringify(a, (_k, val) => (typeof val === "bigint" ? val.toString() : val));