Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: fetch pledges for a dispute before settling or escalating #82

Merged
merged 6 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/automated-dispute/src/interfaces/protocolProvider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { Caip2ChainId } from "@ebo-agent/shared";
import { Address, Block } from "viem";

import type { Dispute, EboEvent, EboEventName, Epoch, Request, Response } from "../types/index.js";
import type {
BondEscalation,
Dispute,
EboEvent,
EboEventName,
Epoch,
Request,
RequestId,
Response,
} from "../types/index.js";
import { ProtocolContractsNames } from "../constants.js";

export type ProtocolContract = (typeof ProtocolContractsNames)[number];
Expand Down Expand Up @@ -55,6 +64,14 @@ export interface IReadProvider {
* @returns A promise that resolves with an array of approved modules for the user.
*/
getApprovedModules(user: Address): Promise<readonly Address[]>;

/**
* Fetches the escalation data for a given request ID.
*
* @param requestId - The ID of the request.
* @returns A Promise that resolves to the BondEscalation data.
*/
getEscalation(requestId: RequestId): Promise<BondEscalation>;
}

/**
Expand Down
22 changes: 22 additions & 0 deletions packages/automated-dispute/src/providers/protocolProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { privateKeyToAccount } from "viem/accounts";
import { arbitrum, arbitrumSepolia, mainnet, sepolia } from "viem/chains";

import type {
BondEscalation,
Dispute,
DisputeId,
EboEvent,
Expand Down Expand Up @@ -55,6 +56,7 @@ import {
IWriteProvider,
ProtocolContractsAddresses,
} from "../interfaces/index.js";
import { ProphetCodec } from "../services/index.js";

type RpcConfig = {
chainId: Caip2ChainId;
Expand Down Expand Up @@ -188,6 +190,7 @@ export class ProtocolProvider implements IProtocolProvider {
getAvailableChains: this.getAvailableChains.bind(this),
getAccountingModuleAddress: this.getAccountingModuleAddress.bind(this),
getApprovedModules: this.getApprovedModules.bind(this),
getEscalation: this.getEscalation.bind(this),
};

private createReadClient(
Expand Down Expand Up @@ -1099,4 +1102,23 @@ export class ProtocolProvider implements IProtocolProvider {
throw new TransactionExecutionError("finalize transaction failed");
}
}

/**
* Fetches the escalation data for a given request ID.
*
* @param requestId - The ID of the request.
* @returns A Promise that resolves to the BondEscalation data.
*/
async getEscalation(requestId: RequestId): Promise<BondEscalation> {
const result = await this.bondEscalationContract.read.getEscalation([requestId]);

const bondEscalation: BondEscalation = {
disputeId: result.disputeId,
status: ProphetCodec.decodeBondEscalationStatus(result.disputeId, result.status),
amountOfPledgesForDispute: result.amountOfPledgesForDispute,
amountOfPledgesAgainstDispute: result.amountOfPledgesAgainstDispute,
};

return bondEscalation;
}
}
59 changes: 29 additions & 30 deletions packages/automated-dispute/src/services/eboActor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isNativeError } from "util/types";
import { BlockNumberService } from "@ebo-agent/blocknumber";
import { Caip2ChainId, ILogger, stringify, UnixTimestamp } from "@ebo-agent/shared";
import { Mutex } from "async-mutex";
Expand Down Expand Up @@ -467,18 +468,37 @@ export class EboActor {
response: Response,
dispute: Dispute,
): Promise<void> {
let escalationData;
try {
this.logger.info(`Settling dispute ${dispute.id}...`);
escalationData = await this.protocolProvider.read.getEscalation(request.id);
} catch (err: unknown) {
this.logger.error(
`Failed to fetch escalation data for request ${request.id}: ${isNativeError(err) ? err.message : err}`,
);
return;
}

// OPTIMIZE: check for pledges to potentially save the ShouldBeEscalated error
try {
const amountFor = escalationData.amountOfPledgesForDispute;
const amountAgainst = escalationData.amountOfPledgesAgainstDispute;

if (amountFor !== amountAgainst) {
await this.protocolProvider.settleDispute(
request.prophetData,
response.prophetData,
dispute.prophetData,
);

await this.protocolProvider.settleDispute(
request.prophetData,
response.prophetData,
dispute.prophetData,
);
this.logger.info(`Dispute ${dispute.id} settled.`);
} else if (amountFor === amountAgainst) {
await this.protocolProvider.escalateDispute(
request.prophetData,
response.prophetData,
dispute.prophetData,
);

this.logger.info(`Dispute ${dispute.id} settled.`);
this.logger.info(`Dispute ${dispute.id} escalated.`);
}
} catch (err) {
if (err instanceof CustomContractError) {
this.logger.warn(`Call reverted for dispute ${dispute.id} due to: ${err.name}`);
Expand All @@ -489,29 +509,8 @@ export class EboActor {
dispute,
registry: this.registry,
});

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(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}`);

this.logger.error(`Failed to settle dispute ${dispute.id}: ${err}`);
throw err;
}
}
Expand Down
23 changes: 23 additions & 0 deletions packages/automated-dispute/src/services/prophetCodec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
toHex,
} from "viem";

import type { BondEscalationStatus } from "../types/prophet.js";
import { ProphetDecodingError } from "../exceptions/index.js";
import { DisputeStatus, Request, Response } from "../types/prophet.js";

Expand Down Expand Up @@ -256,4 +257,26 @@ export class ProphetCodec {
// TODO: throw ProphetEncodingError
return index;
}

/**
* Decodes the BondEscalationStatus enum from the contract.
*
* @param id - The ID of the request.
* @param status - The numeric status from the contract.
* @returns The corresponding BondEscalationStatus string.
*/
static decodeBondEscalationStatus(id: Hex, status: number): BondEscalationStatus {
switch (status) {
case 0:
return "Active";
case 1:
return "Resolved";
case 2:
return "Escalated";
case 3:
return "NoResolution";
default:
throw new ProphetDecodingError(id, toHex(status.toString()));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ID is to help us identify the property that was decoded!

Suggested change
throw new ProphetDecodingError(id, toHex(status.toString()));
throw new ProphetDecodingError("escalation.status", toHex(status.toString()));

You can remove the id param from this method too.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
}
}
9 changes: 9 additions & 0 deletions packages/automated-dispute/src/types/prophet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ export interface Response {

export type DisputeStatus = "None" | "Active" | "Escalated" | "Won" | "Lost" | "NoResolution";

export type BondEscalationStatus = "Active" | "Resolved" | "Escalated" | "NoResolution";

export interface BondEscalation {
disputeId: string;
status: BondEscalationStatus;
amountOfPledgesForDispute: bigint;
amountOfPledgesAgainstDispute: bigint;
}

export interface Dispute {
id: DisputeId;
createdAt: {
Expand Down
14 changes: 11 additions & 3 deletions packages/automated-dispute/tests/mocks/eboActor.mocks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BlockNumberService } from "@ebo-agent/blocknumber";
import { Caip2ChainId, ILogger, UnixTimestamp } from "@ebo-agent/shared";
import { Mutex } from "async-mutex";
import { Block } from "viem";
import { Block, pad } from "viem";
import { vi } from "vitest";

import { ProtocolProvider } from "../../src/providers/index.js";
Expand Down Expand Up @@ -57,6 +57,13 @@ export function buildEboActor(request: Request, logger: ILogger) {
mockedPrivateKey,
);

vi.spyOn(protocolProvider.read, "getEscalation").mockResolvedValue({
disputeId: ("0x" + "03".repeat(32)) as DisputeId,
status: "Active",
amountOfPledgesForDispute: BigInt(10),
amountOfPledgesAgainstDispute: BigInt(5),
});

vi.spyOn(protocolProvider, "getCurrentEpoch").mockResolvedValue({
number: BigInt(1),
firstBlockNumber: BigInt(100),
Expand Down Expand Up @@ -164,7 +171,7 @@ export function buildResponse(request: Request, attributes: Partial<Response> =
};

const baseResponse: Response = {
id: "0x0111111111111111111111111111111111111111" as ResponseId,
id: attributes.id || (pad("0x02") as ResponseId),
createdAt: {
timestamp: (request.createdAt.timestamp + 1n) as UnixTimestamp,
blockNumber: request.createdAt.blockNumber + 1n,
Expand Down Expand Up @@ -192,7 +199,8 @@ export function buildDispute(
attributes: Partial<Dispute> = {},
): Dispute {
const baseDispute: Dispute = {
id: "0x01" as DisputeId,
id: attributes.id || (pad("0x03") as DisputeId),
status: "Active",
createdAt: {
timestamp: (response.createdAt.timestamp + 1n) as UnixTimestamp,
blockNumber: response.createdAt.blockNumber + 1n,
Expand Down
72 changes: 46 additions & 26 deletions packages/automated-dispute/tests/services/eboActor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
PastEventEnqueueError,
RequestMismatch,
} from "../../src/exceptions/index.js";
import { EboEvent, ErrorContext, Request, RequestId, ResponseId } from "../../src/types/index.js";
import { EboEvent, Request, RequestId, ResponseId } from "../../src/types/index.js";
import mocks from "../mocks/index.js";
import { DEFAULT_MOCKED_REQUEST_CREATED_DATA } from "../services/eboActor/fixtures.js";

Expand Down Expand Up @@ -518,42 +518,30 @@ describe("EboActor", () => {
});

describe("settleDispute", () => {
it("escalates dispute when BondEscalationModule_ShouldBeEscalated error occurs", async () => {
it("escalates dispute when amountOfPledgesForDispute === amountOfPledgesAgainstDispute", async () => {
const { actor, protocolProvider } = mocks.buildEboActor(request, logger);
const response = mocks.buildResponse(request);
const dispute = mocks.buildDispute(request, response);

const shouldBeEscalatedName = "BondEscalationModule_ShouldBeEscalated";

const customError = new CustomContractError(shouldBeEscalatedName, {
shouldNotify: false,
shouldReenqueue: false,
shouldTerminate: false,
vi.spyOn(protocolProvider.read, "getEscalation").mockResolvedValue({
disputeId: dispute.id,
status: "Active",
amountOfPledgesForDispute: BigInt(5),
amountOfPledgesAgainstDispute: BigInt(5),
});

vi.spyOn(protocolProvider, "settleDispute").mockRejectedValue(customError);

const escalateDisputeMock = vi
.spyOn(protocolProvider, "escalateDispute")
.mockResolvedValue();

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,
);

return customError;
});

await actor["settleDispute"](request, response, dispute);

expect(escalateDisputeMock).toHaveBeenCalledWith(
request.prophetData,
response.prophetData,
dispute.prophetData,
);

expect(logger.info).toHaveBeenCalledWith(`Dispute ${dispute.id} escalated.`);
});

Expand All @@ -563,12 +551,44 @@ describe("EboActor", () => {
const dispute = mocks.buildDispute(request, response);

const settleError = new Error("SettleDispute failed");
vi.spyOn(protocolProvider.read, "getEscalation").mockResolvedValue({
disputeId: dispute.id,
status: "Active",
amountOfPledgesForDispute: BigInt(10),
amountOfPledgesAgainstDispute: BigInt(5),
});

vi.spyOn(protocolProvider, "settleDispute").mockRejectedValue(settleError);

await expect(actor["settleDispute"](request, response, dispute)).rejects.toThrow(
settleError,
);
});

it("settles dispute when amountOfPledgesForDispute !== amountOfPledgesAgainstDispute", async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this is an extremely core case, let's explicitly test both cases here: > and <

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ I split this into two test cases

const { actor, protocolProvider } = mocks.buildEboActor(request, logger);
const response = mocks.buildResponse(request);
const dispute = mocks.buildDispute(request, response);

const settleDisputeMock = vi
.spyOn(protocolProvider, "settleDispute")
.mockResolvedValue();

vi.spyOn(protocolProvider.read, "getEscalation").mockResolvedValue({
disputeId: dispute.id,
status: "Active",
amountOfPledgesForDispute: BigInt(10),
amountOfPledgesAgainstDispute: BigInt(5),
});

await actor["settleDispute"](request, response, dispute);

expect(settleDisputeMock).toHaveBeenCalledWith(
request.prophetData,
response.prophetData,
dispute.prophetData,
);

expect(logger.info).toHaveBeenCalledWith(`Dispute ${dispute.id} settled.`);
});
});
});
Loading