From a1b4008e9120394b997fc543ef917adcad89a3af Mon Sep 17 00:00:00 2001 From: Mani Brar Date: Tue, 17 Dec 2024 13:58:53 +0700 Subject: [PATCH 1/9] feat: transaction handler & custom logging,errors --- .../src/ArbToEth/transactionHandler.test.ts | 345 ++++++++++++++++++ .../src/ArbToEth/transactionHandler.ts | 208 +++++++++++ validator-cli/src/utils/arbMsgExecutor.ts | 16 +- validator-cli/src/utils/botEvents.ts | 26 ++ validator-cli/src/utils/emitter.ts | 14 + validator-cli/src/utils/errors.ts | 25 ++ validator-cli/src/utils/logger.ts | 91 +++++ 7 files changed, 717 insertions(+), 8 deletions(-) create mode 100644 validator-cli/src/ArbToEth/transactionHandler.test.ts create mode 100644 validator-cli/src/ArbToEth/transactionHandler.ts create mode 100644 validator-cli/src/utils/botEvents.ts create mode 100644 validator-cli/src/utils/emitter.ts create mode 100644 validator-cli/src/utils/errors.ts create mode 100644 validator-cli/src/utils/logger.ts diff --git a/validator-cli/src/ArbToEth/transactionHandler.test.ts b/validator-cli/src/ArbToEth/transactionHandler.test.ts new file mode 100644 index 00000000..18fd8a27 --- /dev/null +++ b/validator-cli/src/ArbToEth/transactionHandler.test.ts @@ -0,0 +1,345 @@ +import { ArbToEthTransactionHandler, ContractType } from "./transactionHandler"; +import { MockEmitter, defaultEmitter } from "../utils/emitter"; +import { BotEvents } from "../utils/botEvents"; +import { ClaimNotSetError, ContractNotSupportedError } from "../utils/errors"; +import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; +import { getMessageStatus } from "../utils/arbMsgExecutor"; + +describe("ArbToEthTransactionHandler", () => { + let epoch: number = 100; + let deposit: bigint = BigInt(100000); + let veaInbox: any; + let veaOutbox: any; + let veaInboxProvider: any; + let veaOutboxProvider: any; + let claim: ClaimStruct = null; + + beforeEach(() => { + veaInboxProvider = { + getTransactionReceipt: jest.fn(), + getBlock: jest.fn(), + }; + veaOutbox = { + estimateGas: jest.fn(), + withdrawChallengeDeposit: jest.fn(), + ["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"]: jest.fn(), + }; + veaInbox = { + sendSnapshot: jest.fn(), + }; + claim = { + stateRoot: "0x1234", + claimer: "0x1234", + timestampClaimed: 1234, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: "0x1234", + }; + }); + describe("constructor", () => { + it("should create a new TransactionHandler without claim", () => { + const transactionHandler = new ArbToEthTransactionHandler( + epoch, + deposit, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider + ); + expect(transactionHandler).toBeDefined(); + expect(transactionHandler.epoch).toEqual(epoch); + expect(transactionHandler.veaOutbox).toEqual(veaOutbox); + expect(transactionHandler.emitter).toEqual(defaultEmitter); + }); + + it("should create a new TransactionHandler with claim", () => { + const transactionHandler = new ArbToEthTransactionHandler( + epoch, + deposit, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + defaultEmitter, + claim + ); + expect(transactionHandler).toBeDefined(); + expect(transactionHandler.epoch).toEqual(epoch); + expect(transactionHandler.veaOutbox).toEqual(veaOutbox); + expect(transactionHandler.claim).toEqual(claim); + expect(transactionHandler.emitter).toEqual(defaultEmitter); + }); + }); + + describe("checkTransactionStatus", () => { + let transactionHandler: ArbToEthTransactionHandler; + let finalityBlock: number = 100; + const mockEmitter = new MockEmitter(); + beforeEach(() => { + transactionHandler = new ArbToEthTransactionHandler( + epoch, + deposit, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + veaInboxProvider.getBlock.mockResolvedValue({ number: finalityBlock }); + }); + + it("should return false if transaction is not final", async () => { + jest.spyOn(mockEmitter, "emit"); + veaInboxProvider.getTransactionReceipt.mockResolvedValue({ + blockNumber: finalityBlock - (transactionHandler.requiredConfirmations - 1), + }); + const trnxHash = "0x123456"; + const status = await transactionHandler.checkTransactionStatus(trnxHash, ContractType.INBOX); + expect(status).toBeTruthy(); + expect(mockEmitter.emit).toHaveBeenCalledWith( + BotEvents.TXN_NOT_FINAL, + trnxHash, + transactionHandler.requiredConfirmations - 1 + ); + }); + + it("should return true if transaction is pending", async () => { + jest.spyOn(mockEmitter, "emit"); + veaInboxProvider.getTransactionReceipt.mockResolvedValue(null); + const trnxHash = "0x123456"; + const status = await transactionHandler.checkTransactionStatus(trnxHash, ContractType.INBOX); + expect(status).toBeTruthy(); + expect(mockEmitter.emit).toHaveBeenCalledWith(BotEvents.TXN_PENDING, trnxHash); + }); + + it("should return false if transaction is final", async () => { + jest.spyOn(mockEmitter, "emit"); + veaInboxProvider.getTransactionReceipt.mockResolvedValue({ + blockNumber: finalityBlock - transactionHandler.requiredConfirmations, + }); + const trnxHash = "0x123456"; + const status = await transactionHandler.checkTransactionStatus(trnxHash, ContractType.INBOX); + expect(status).toBeFalsy(); + expect(mockEmitter.emit).toHaveBeenCalledWith( + BotEvents.TXN_FINAL, + trnxHash, + transactionHandler.requiredConfirmations + ); + }); + + it("should throw an error if contract type is not supported", async () => { + const trnxHash = "0x123456"; + await expect(transactionHandler.checkTransactionStatus(trnxHash, ContractType.ROUTER)).rejects.toThrow( + new ContractNotSupportedError(ContractType.ROUTER) + ); + }); + + it("should return false if transaction hash is null", async () => { + const trnxHash = null; + const status = await transactionHandler.checkTransactionStatus(trnxHash, ContractType.INBOX); + expect(status).toBeFalsy(); + }); + }); + + describe("challengeClaim", () => { + let transactionHandler: ArbToEthTransactionHandler; + const mockEmitter = new MockEmitter(); + beforeEach(() => { + transactionHandler = new ArbToEthTransactionHandler( + epoch, + deposit, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + transactionHandler.claim = claim; + }); + + it("should emit CHALLENGING event and throw error if claim is not set", async () => { + jest.spyOn(mockEmitter, "emit"); + transactionHandler.claim = null; + await expect(transactionHandler.challengeClaim()).rejects.toThrow(ClaimNotSetError); + expect(mockEmitter.emit).toHaveBeenCalledWith(BotEvents.CHALLENGING); + }); + + it("should not challenge claim if txn is pending", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(true); + transactionHandler.transactions.challengeTxn = "0x1234"; + await transactionHandler.challengeClaim(); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( + transactionHandler.transactions.challengeTxn, + ContractType.OUTBOX + ); + expect(veaOutbox.estimateGas).not.toHaveBeenCalled(); + }); + + it("should challenge claim", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(false); + const mockChallenge = jest.fn().mockResolvedValue({ hash: "0x1234" }) as any; + (mockChallenge as any).estimateGas = jest.fn().mockResolvedValue(BigInt(100000)); + veaOutbox["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"] = mockChallenge; + await transactionHandler.challengeClaim(); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith(null, ContractType.OUTBOX); + expect(transactionHandler.transactions.challengeTxn).toEqual("0x1234"); + }); + + it.todo("should set challengeTxn as completed when txn is final"); + }); + + describe("withdrawDeposit", () => { + let transactionHandler: ArbToEthTransactionHandler; + const mockEmitter = new MockEmitter(); + beforeEach(() => { + transactionHandler = new ArbToEthTransactionHandler( + epoch, + deposit, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + veaOutbox.withdrawChallengeDeposit.mockResolvedValue("0x1234"); + transactionHandler.claim = claim; + }); + + it("should withdraw deposit", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(false); + veaOutbox.withdrawChallengeDeposit.mockResolvedValue({ hash: "0x1234" }); + await transactionHandler.withdrawChallengeDeposit(); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith(null, ContractType.OUTBOX); + expect(transactionHandler.transactions.withdrawChallengeDepositTxn).toEqual("0x1234"); + }); + + it("should not withdraw deposit if txn is pending", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(true); + transactionHandler.transactions.withdrawChallengeDepositTxn = "0x1234"; + await transactionHandler.withdrawChallengeDeposit(); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( + transactionHandler.transactions.withdrawChallengeDepositTxn, + ContractType.OUTBOX + ); + }); + + it("should throw an error if claim is not set", async () => { + transactionHandler.claim = null; + await expect(transactionHandler.withdrawChallengeDeposit()).rejects.toThrow(ClaimNotSetError); + }); + + it("should emit WITHDRAWING event", async () => { + jest.spyOn(mockEmitter, "emit"); + await transactionHandler.withdrawChallengeDeposit(); + expect(mockEmitter.emit).toHaveBeenCalledWith(BotEvents.WITHDRAWING); + }); + }); + + describe("sendSnapshot", () => { + let transactionHandler: ArbToEthTransactionHandler; + const mockEmitter = new MockEmitter(); + beforeEach(() => { + transactionHandler = new ArbToEthTransactionHandler( + epoch, + deposit, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + transactionHandler.claim = claim; + }); + + it("should send snapshot", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(false); + veaInbox.sendSnapshot.mockResolvedValue({ hash: "0x1234" }); + await transactionHandler.sendSnapshot(); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith(null, ContractType.INBOX); + expect(transactionHandler.transactions.sendSnapshotTxn).toEqual("0x1234"); + }); + + it("should not send snapshot if txn is pending", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(true); + transactionHandler.transactions.sendSnapshotTxn = "0x1234"; + await transactionHandler.sendSnapshot(); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( + transactionHandler.transactions.sendSnapshotTxn, + ContractType.INBOX + ); + expect(veaInbox.sendSnapshot).not.toHaveBeenCalled(); + }); + + it("should throw an error if claim is not set", async () => { + jest.spyOn(mockEmitter, "emit"); + transactionHandler.claim = null; + await expect(transactionHandler.sendSnapshot()).rejects.toThrow(ClaimNotSetError); + expect(mockEmitter.emit).toHaveBeenCalledWith(BotEvents.SENDING_SNAPSHOT, epoch); + }); + }); + + describe("resolveChallengedClaim", () => { + let mockGetMessageStatus: any; + let mockMessageExecutor: any; + let transactionHandler: ArbToEthTransactionHandler; + const mockEmitter = new MockEmitter(); + beforeEach(() => { + mockGetMessageStatus = jest.fn(); + mockMessageExecutor = jest.fn(); + transactionHandler = new ArbToEthTransactionHandler( + epoch, + deposit, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + }); + it("should resolve challenged claim", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(false); + mockGetMessageStatus.mockResolvedValue(1); + transactionHandler.transactions.sendSnapshotTxn = "0x1234"; + mockMessageExecutor.mockResolvedValue({ hash: "0x1234" }); + await transactionHandler.resolveChallengedClaim(mockMessageExecutor, mockGetMessageStatus); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( + transactionHandler.transactions.sendSnapshotTxn, + ContractType.OUTBOX + ); + expect(mockGetMessageStatus).toHaveBeenCalledWith( + transactionHandler.transactions.sendSnapshotTxn, + veaInboxProvider, + veaOutboxProvider + ); + expect(transactionHandler.transactions.executeSnapshotTxn).toEqual("0x1234"); + }); + + it("should not resolve challenged claim if txn is pending", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(true); + transactionHandler.transactions.sendSnapshotTxn = "0x1234"; + await transactionHandler.resolveChallengedClaim(mockMessageExecutor, mockGetMessageStatus); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( + transactionHandler.transactions.sendSnapshotTxn, + ContractType.OUTBOX + ); + }); + + it("should not claim if snapshot txn is not ready to execute", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(false); + mockGetMessageStatus.mockResolvedValue(0); + transactionHandler.transactions.sendSnapshotTxn = "0x1234"; + await transactionHandler.resolveChallengedClaim(mockMessageExecutor, mockGetMessageStatus); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( + transactionHandler.transactions.sendSnapshotTxn, + ContractType.OUTBOX + ); + expect(mockGetMessageStatus).toHaveBeenCalledWith( + transactionHandler.transactions.sendSnapshotTxn, + veaInboxProvider, + veaOutboxProvider + ); + expect(mockMessageExecutor).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/validator-cli/src/ArbToEth/transactionHandler.ts b/validator-cli/src/ArbToEth/transactionHandler.ts new file mode 100644 index 00000000..f6e874c4 --- /dev/null +++ b/validator-cli/src/ArbToEth/transactionHandler.ts @@ -0,0 +1,208 @@ +import { VeaInboxArbToEth, VeaOutboxArbToEth } from "@kleros/vea-contracts/typechain-types"; +import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; +import { JsonRpcProvider } from "@ethersproject/providers"; +import { getMessageStatus, messageExecutor } from "../utils/arbMsgExecutor"; +import { defaultEmitter } from "../utils/emitter"; +import { BotEvents } from "../utils/botEvents"; +import { ClaimNotSetError, ContractNotSupportedError } from "../utils/errors"; + +/** + * @file This file contains the logic for handling transactions from Arbitrum to Ethereum. + * It is responsible for: + * challenge() - Challenge a claim on VeaOutbox(ETH). + * withdrawChallengeDeposit() - Withdraw the challenge deposit. + * sendSnapshot() - Send a snapshot from the VeaInbox(ARB) to the VeaOutox(ETH). + * executeSnapshot() - Execute a sent snapshot to resolve dispute in VeaOutbox (ETH). + */ + +type Transactions = { + challengeTxn: string | null; + withdrawChallengeDepositTxn: string | null; + sendSnapshotTxn: string | null; + executeSnapshotTxn: string | null; +}; + +export enum ContractType { + INBOX = "inbox", + OUTBOX = "outbox", + ROUTER = "router", +} + +export class ArbToEthTransactionHandler { + public requiredConfirmations = 10; + public claim: ClaimStruct | null = null; + public deposit: bigint; + + public veaInbox: VeaInboxArbToEth; + public veaOutbox: VeaOutboxArbToEth; + public veaInboxProvider: JsonRpcProvider; + public veaOutboxProvider: JsonRpcProvider; + public epoch: number; + public emitter: typeof defaultEmitter; + + public transactions: Transactions = { + challengeTxn: null, + withdrawChallengeDepositTxn: null, + sendSnapshotTxn: null, + executeSnapshotTxn: null, + }; + + constructor( + epoch: number, + deposit: bigint, + veaInbox: VeaInboxArbToEth, + veaOutbox: VeaOutboxArbToEth, + veaInboxProvider: JsonRpcProvider, + veaOutboxProvider: JsonRpcProvider, + emitter: typeof defaultEmitter = defaultEmitter, + claim: ClaimStruct | null = null + ) { + this.epoch = epoch; + this.deposit = deposit; + this.veaInbox = veaInbox; + this.veaOutbox = veaOutbox; + this.veaInboxProvider = veaInboxProvider; + this.veaOutboxProvider = veaOutboxProvider; + this.emitter = emitter; + this.claim = claim; + } + + /** + * Check the status of a transaction. + * + * @param trnxHash Transaction hash to check the status of. + * @param contract Contract type to check the transaction status in. + * + * @returns False if transaction is pending || not final || not made, else True. + */ + public async checkTransactionStatus(trnxHash: string | null, contract: ContractType): Promise { + let provider: JsonRpcProvider; + if (contract === ContractType.INBOX) { + provider = this.veaInboxProvider; + } else if (contract === ContractType.OUTBOX) { + provider = this.veaOutboxProvider; + } else { + throw new ContractNotSupportedError(contract); + } + + if (trnxHash == null) { + return false; + } + + const receipt = await provider.getTransactionReceipt(trnxHash); + + if (!receipt) { + this.emitter.emit(BotEvents.TXN_PENDING, trnxHash); + return true; + } + + const currentBlock = await provider.getBlock("latest"); + const confirmations = currentBlock.number - receipt.blockNumber; + + if (confirmations >= this.requiredConfirmations) { + this.emitter.emit(BotEvents.TXN_FINAL, trnxHash, confirmations); + return false; + } else { + this.emitter.emit(BotEvents.TXN_NOT_FINAL, trnxHash, confirmations); + return true; + } + } + + /** + * Challenge claim for this.epoch in VeaOutbox(ETH). + * + */ + public async challengeClaim() { + // TODO: Add a check for finality of l2 transaction of saveSnapshot() + this.emitter.emit(BotEvents.CHALLENGING); + if (!this.claim) { + throw new ClaimNotSetError(); + } + if (await this.checkTransactionStatus(this.transactions.challengeTxn, ContractType.OUTBOX)) { + return; + } + const gasEstimate: bigint = await this.veaOutbox[ + "challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))" + ].estimateGas(this.epoch, this.claim, { value: this.deposit }); + const maxFeePerGasProfitable = this.deposit / (gasEstimate * BigInt(6)); + + // Set a reasonable maxPriorityFeePerGas but ensure it's lower than maxFeePerGas + let maxPriorityFeePerGasMEV = BigInt(6667000000000); // 6667 gwei + + // Ensure maxPriorityFeePerGas <= maxFeePerGas + if (maxPriorityFeePerGasMEV > maxFeePerGasProfitable) { + maxPriorityFeePerGasMEV = maxFeePerGasProfitable; + } + + const challengeTxn = await this.veaOutbox[ + "challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))" + ](this.epoch, this.claim, { + maxFeePerGas: maxFeePerGasProfitable, + maxPriorityFeePerGas: maxPriorityFeePerGasMEV, + value: this.deposit, + gasLimit: gasEstimate, + }); + this.emitter.emit(BotEvents.TXN_MADE, challengeTxn.hash, this.epoch, "Challenge"); + this.transactions.challengeTxn = challengeTxn.hash; + } + + /** + * Withdraw the challenge deposit. + * + */ + public async withdrawChallengeDeposit() { + this.emitter.emit(BotEvents.WITHDRAWING); + if (!this.claim) { + throw new ClaimNotSetError(); + } + if (await this.checkTransactionStatus(this.transactions.withdrawChallengeDepositTxn, ContractType.OUTBOX)) { + return; + } + const withdrawDepositTxn = await this.veaOutbox.withdrawChallengeDeposit(this.epoch, this.claim); + this.emitter.emit(BotEvents.TXN_MADE, withdrawDepositTxn.hash, this.epoch, "Withdraw"); + this.transactions.withdrawChallengeDepositTxn = withdrawDepositTxn.hash; + } + + /** + * Send a snapshot from the VeaInbox(ARB) to the VeaOutox(ETH). + */ + public async sendSnapshot() { + this.emitter.emit(BotEvents.SENDING_SNAPSHOT, this.epoch); + if (!this.claim) { + throw new ClaimNotSetError(); + } + if (await this.checkTransactionStatus(this.transactions.sendSnapshotTxn, ContractType.INBOX)) { + return; + } + const sendSnapshotTxn = await this.veaInbox.sendSnapshot(this.epoch, this.claim); + this.emitter.emit(BotEvents.TXN_MADE, sendSnapshotTxn.hash, this.epoch, "Send Snapshot"); + this.transactions.sendSnapshotTxn = sendSnapshotTxn.hash; + } + + /** + * Execute a sent snapshot to resolve dispute in VeaOutbox (ETH). + */ + public async resolveChallengedClaim( + executeMsg: typeof messageExecutor = messageExecutor, + msgStatus: typeof getMessageStatus = getMessageStatus + ) { + this.emitter.emit(BotEvents.EXECUTING_SNAPSHOT, this.epoch); + if (await this.checkTransactionStatus(this.transactions.sendSnapshotTxn, ContractType.OUTBOX)) { + return; + } + // TODO: Add check for the status of the snapshot txn + const status = await msgStatus(this.transactions.sendSnapshotTxn, this.veaInboxProvider, this.veaOutboxProvider); + // 1 : ready to execute, 0 : cant execute yet + if (status == 1) { + const msgExecuteTrnx = await executeMsg( + this.transactions.sendSnapshotTxn, + this.veaInboxProvider, + this.veaOutboxProvider + ); + this.emitter.emit(BotEvents.TXN_MADE, msgExecuteTrnx.hash, this.epoch, "Execute Snapshot"); + this.transactions.executeSnapshotTxn = msgExecuteTrnx.hash; + } else { + this.emitter.emit(BotEvents.CANT_EXECUTE_SNAPSHOT); + } + } +} diff --git a/validator-cli/src/utils/arbMsgExecutor.ts b/validator-cli/src/utils/arbMsgExecutor.ts index e2d8c8bf..0e9a7e47 100644 --- a/validator-cli/src/utils/arbMsgExecutor.ts +++ b/validator-cli/src/utils/arbMsgExecutor.ts @@ -10,11 +10,13 @@ import { Signer } from "@ethersproject/abstract-signer"; import { ContractTransaction } from "@ethersproject/contracts"; // Execute the child-to-parent (arbitrum-to-ethereum) message, for reference see: https://docs.arbitrum.io/sdk/reference/message/ChildToParentMessage -async function messageExecutor(trnxHash: string, childRpc: string, parentRpc: string): Promise { +async function messageExecutor( + trnxHash: string, + childJsonRpc: JsonRpcProvider, + parentProvider: JsonRpcProvider +): Promise { const PRIVATE_KEY = process.env.PRIVATE_KEY; - const childJsonRpc = new JsonRpcProvider(childRpc); const childProvider = new ArbitrumProvider(childJsonRpc); - const parentProvider = new JsonRpcProvider(parentRpc); const childReceipt: TransactionReceipt = await childProvider.getTransactionReceipt(trnxHash); if (!childReceipt) { @@ -37,13 +39,11 @@ async function messageExecutor(trnxHash: string, childRpc: string, parentRpc: st async function getMessageStatus( trnxHash: string, - childRpc: string, - parentRpc: string + childJsonRpc: JsonRpcProvider, + parentJsonRpc: JsonRpcProvider ): Promise { const PRIVATE_KEY = process.env.PRIVATE_KEY; - const childJsonRpc = new JsonRpcProvider(childRpc); const childProvider = new ArbitrumProvider(childJsonRpc); - const parentProvider = new JsonRpcProvider(parentRpc); let childReceipt: TransactionReceipt | null; @@ -52,7 +52,7 @@ async function getMessageStatus( throw new Error(`Transaction receipt not found for hash: ${trnxHash}`); } const messageReceipt = new ChildTransactionReceipt(childReceipt); - const parentSigner: Signer = new Wallet(PRIVATE_KEY, parentProvider); + const parentSigner: Signer = new Wallet(PRIVATE_KEY, parentJsonRpc); const messages = await messageReceipt.getChildToParentMessages(parentSigner); const childToParentMessage = messages[0]; if (!childToParentMessage) { diff --git a/validator-cli/src/utils/botEvents.ts b/validator-cli/src/utils/botEvents.ts new file mode 100644 index 00000000..7049ffdd --- /dev/null +++ b/validator-cli/src/utils/botEvents.ts @@ -0,0 +1,26 @@ +export enum BotEvents { + // Bridger state + STARTED = "started", + CHECKING = "checking", + WAITING = "waiting", + + // Epoch state + NO_NEW_MESSAGES = "no_new_messages", + NO_SNAPSHOT = "no_snapshot", + EPOCH_PASSED = "epoch_passed", + + // Claim state + CHALLENGING = "challenging", + CHALLENGER_WON_CLAIM = "challenger_won_claim", + SENDING_SNAPSHOT = "sending_snapshot", + EXECUTING_SNAPSHOT = "executing_snapshot", + CANT_EXECUTE_SNAPSHOT = "CANT_EXECUTE_SNAPSHOT", + WITHDRAWING = "withdrawing", + + // Transaction state + TXN_MADE = "txn_made", + TXN_PENDING = "txn_pending", + TXN_PENDING_CONFIRMATIONS = "txn_pending_confirmations", + TXN_FINAL = "txn_final", + TXN_NOT_FINAL = "txn_not_final", +} diff --git a/validator-cli/src/utils/emitter.ts b/validator-cli/src/utils/emitter.ts new file mode 100644 index 00000000..530f8345 --- /dev/null +++ b/validator-cli/src/utils/emitter.ts @@ -0,0 +1,14 @@ +import { EventEmitter } from "node:events"; +import { BotEvents } from "./botEvents"; + +export const defaultEmitter = new EventEmitter(); + +export class MockEmitter extends EventEmitter { + emit(event: string | symbol, ...args: any[]): boolean { + // Prevent console logs for BotEvents during tests + if (Object.values(BotEvents).includes(event as BotEvents)) { + return true; + } + return super.emit(event, ...args); + } +} diff --git a/validator-cli/src/utils/errors.ts b/validator-cli/src/utils/errors.ts new file mode 100644 index 00000000..c50753b3 --- /dev/null +++ b/validator-cli/src/utils/errors.ts @@ -0,0 +1,25 @@ +class ClaimNotFoundError extends Error { + constructor(epoch: number) { + super(); + this.name = "ClaimNotFoundError"; + this.message = `No claim was found for ${epoch}`; + } +} + +class ClaimNotSetError extends Error { + constructor() { + super(); + this.name = "NoClaimSetError"; + this.message = "Claim is not set"; + } +} + +class ContractNotSupportedError extends Error { + constructor(contract: string) { + super(); + this.name = "ContractNotSupportedError"; + this.message = `Unsupported contract type: ${contract}`; + } +} + +export { ClaimNotFoundError, ClaimNotSetError, ContractNotSupportedError }; diff --git a/validator-cli/src/utils/logger.ts b/validator-cli/src/utils/logger.ts new file mode 100644 index 00000000..bb573adf --- /dev/null +++ b/validator-cli/src/utils/logger.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from "node:events"; +import { BotEvents } from "./botEvents"; + +/** + * Listens to relevant events of an EventEmitter instance and issues log lines + * + * @param emitter - The event emitter instance that issues the relevant events + * + * @example + * + * const emitter = new EventEmitter(); + * initialize(emitter); + */ + +export const initialize = (emitter: EventEmitter) => { + return configurableInitialize(emitter); +}; + +export const configurableInitialize = (emitter: EventEmitter) => { + // Bridger state logs + emitter.on(BotEvents.STARTED, () => { + console.log("Bridger started"); + }); + + emitter.on(BotEvents.CHECKING, (epoch: number) => { + console.log(`Running checks for epoch ${epoch}`); + }); + + emitter.on(BotEvents.WAITING, (epoch: number) => { + console.log(`Waiting for next verifiable epoch after ${epoch}`); + }); + + emitter.on(BotEvents.NO_NEW_MESSAGES, () => { + console.log("No new messages found"); + }); + + emitter.on(BotEvents.NO_SNAPSHOT, () => { + console.log("No snapshot saved for epoch"); + }); + + emitter.on(BotEvents.EPOCH_PASSED, (epoch: number) => { + console.log(`Epoch ${epoch} has passed`); + }); + + emitter.on(BotEvents.CHALLENGER_WON_CLAIM, () => { + console.log("Challenger won claim"); + }); + + // Transaction state logs + emitter.on(BotEvents.TXN_MADE, (transaction: string, epoch: number, state: string) => { + console.log(`${state} transaction for ${epoch} made with hash: ${transaction}`); + }); + emitter.on(BotEvents.TXN_PENDING, (transaction: string) => { + console.log(`Transaction is still pending with hash: ${transaction}`); + }); + + emitter.on(BotEvents.TXN_FINAL, (transaction: string, confirmations: number) => { + console.log(`Transaction(${transaction}) is final with ${confirmations} confirmations`); + }); + + emitter.on(BotEvents.TXN_NOT_FINAL, (transaction: string, confirmations: number) => { + console.log(`Transaction(${transaction}) is not final yet, ${confirmations} confirmations left.`); + }); + emitter.on(BotEvents.TXN_PENDING_CONFIRMATIONS, (transaction: string, confirmations: number) => { + console.log(`Transaction(${transaction}) is pending with ${confirmations} confirmations`); + }); + + // Claim state logs + // makeClaim() + emitter.on(BotEvents.CHALLENGING, (epoch: number) => { + console.log(`Claim can be challenged, challenging for epoch ${epoch}`); + }); + + // startVerification() + emitter.on(BotEvents.SENDING_SNAPSHOT, (epoch: number) => { + console.log(`Sending snapshot for ${epoch}`); + }); + emitter.on(BotEvents.EXECUTING_SNAPSHOT, (epoch) => { + console.log(`Executing snapshot to resolve dispute for epoch ${epoch}`); + }); + + // verifySnapshot() + emitter.on(BotEvents.CANT_EXECUTE_SNAPSHOT, () => { + console.log("Cant execute snapshot, waiting l2 challenge period to pass"); + }); + + // withdrawClaimDeposit() + emitter.on(BotEvents.WITHDRAWING, () => { + console.log(`Withdrawing challenge deposit for epoch`); + }); +}; From 295a2e738f0db10d5ea649616bed7ca1dccc97ef Mon Sep 17 00:00:00 2001 From: Mani Brar Date: Tue, 17 Dec 2024 13:59:38 +0700 Subject: [PATCH 2/9] feat: test init --- validator-cli/jest.config.ts | 10 ++++++++++ validator-cli/package.json | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 validator-cli/jest.config.ts diff --git a/validator-cli/jest.config.ts b/validator-cli/jest.config.ts new file mode 100644 index 00000000..1927a555 --- /dev/null +++ b/validator-cli/jest.config.ts @@ -0,0 +1,10 @@ +import type { Config } from "jest"; + +const config: Config = { + preset: "ts-jest", + testEnvironment: "node", + collectCoverage: true, + collectCoverageFrom: ["**/*.ts"], +}; + +export default config; diff --git a/validator-cli/package.json b/validator-cli/package.json index 9139a3e0..55ab7a6a 100644 --- a/validator-cli/package.json +++ b/validator-cli/package.json @@ -14,7 +14,8 @@ "start-chiado-devnet": "npx ts-node ./src/devnet/arbToChiado/happyPath.ts", "start-sepolia-devnet": "npx ts-node ./src/devnet/arbToSepolia/happyPath.ts", "start-sepolia-testnet": "npx ts-node ./src/ArbToEth/watcherArbToEth.ts", - "start-arbitrum-to-gnosis": "npx ts-node ./src/ArbToEth/watcherArbToGnosis.ts" + "start-arbitrum-to-gnosis": "npx ts-node ./src/ArbToEth/watcherArbToGnosis.ts", + "test": "jest --coverage" }, "dependencies": { "@arbitrum/sdk": "4.0.1", @@ -28,6 +29,9 @@ "web3-batched-send": "^1.0.3" }, "devDependencies": { + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", "ts-node": "^10.9.2" } } From 2d2c8503f5876a74f65729587c78e09a39a193b9 Mon Sep 17 00:00:00 2001 From: Mani Brar Date: Tue, 17 Dec 2024 15:27:12 +0700 Subject: [PATCH 3/9] feat: epoch handler --- validator-cli/src/consts/bridgeRoutes.ts | 30 +++++++++++ validator-cli/src/utils/epochHandler.test.ts | 38 ++++++++++++++ validator-cli/src/utils/epochHandler.ts | 52 ++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 validator-cli/src/consts/bridgeRoutes.ts create mode 100644 validator-cli/src/utils/epochHandler.test.ts create mode 100644 validator-cli/src/utils/epochHandler.ts diff --git a/validator-cli/src/consts/bridgeRoutes.ts b/validator-cli/src/consts/bridgeRoutes.ts new file mode 100644 index 00000000..4726f29b --- /dev/null +++ b/validator-cli/src/consts/bridgeRoutes.ts @@ -0,0 +1,30 @@ +interface IBridge { + chain: string; + epochPeriod: number; + deposit: bigint; + minChallengePeriod: number; + sequencerDelayLimit: number; +} + +const bridges: { [chainId: number]: IBridge } = { + 11155111: { + chain: "sepolia", + epochPeriod: 7200, + deposit: BigInt("1000000000000000000"), + minChallengePeriod: 10800, + sequencerDelayLimit: 86400, + }, + 10200: { + chain: "chiado", + epochPeriod: 3600, + deposit: BigInt("1000000000000000000"), + minChallengePeriod: 10800, + sequencerDelayLimit: 86400, + }, +}; + +const getBridgeConfig = (chainId: number): IBridge | undefined => { + return bridges[chainId]; +}; + +export { getBridgeConfig }; diff --git a/validator-cli/src/utils/epochHandler.test.ts b/validator-cli/src/utils/epochHandler.test.ts new file mode 100644 index 00000000..33ab1955 --- /dev/null +++ b/validator-cli/src/utils/epochHandler.test.ts @@ -0,0 +1,38 @@ +import { setEpochRange, getLatestVerifiableEpoch } from "./epochHandler"; + +describe("epochHandler", () => { + describe("setEpochRange", () => { + const startEpoch = 1000; + const mockedEpochPeriod = 600; + const mockedSeqDelayLimit = 600; + const startCoolDown = 7 * 24 * 60 * 60; + const currentTimestamp = (startEpoch + 3) * mockedEpochPeriod + startCoolDown + mockedSeqDelayLimit; + it("should return the correct epoch range", () => { + const chainId = 1; + const fetchBridgeConfig = jest.fn(() => ({ + sequencerDelayLimit: mockedSeqDelayLimit, + epochPeriod: mockedEpochPeriod, + })); + + const mockNow = 1626325500; + const currentClaimableEpoch = Math.floor(mockNow / (1000 * mockedEpochPeriod)) - 1; + const result = setEpochRange(currentTimestamp, chainId, mockNow, fetchBridgeConfig as any); + + expect(result[0]).toEqual(startEpoch); + expect(result[result.length - 1]).toEqual(currentClaimableEpoch); + expect(result.length).toEqual(currentClaimableEpoch + 1 - startEpoch); + }); + }); + + describe("getLatestVerifiableEpoch", () => { + it("should return the correct epoch number", () => { + const chainId = 1; + const now = 1626325200000; + const fetchBridgeConfig = jest.fn(() => ({ + epochPeriod: 600, + })); + const result = getLatestVerifiableEpoch(chainId, now, fetchBridgeConfig as any); + expect(result).toEqual(now / (600 * 1000) - 1); + }); + }); +}); diff --git a/validator-cli/src/utils/epochHandler.ts b/validator-cli/src/utils/epochHandler.ts new file mode 100644 index 00000000..3f185d3f --- /dev/null +++ b/validator-cli/src/utils/epochHandler.ts @@ -0,0 +1,52 @@ +import { getBridgeConfig } from "../consts/bridgeRoutes"; + +const setEpochRange = ( + currentTimestamp: number, + chainId: number, + now: number = Date.now(), + fetchBridgeConfig: typeof getBridgeConfig = getBridgeConfig +): Array => { + const { sequencerDelayLimit, epochPeriod } = fetchBridgeConfig(chainId); + const coldStartBacklog = 7 * 24 * 60 * 60; // when starting the watcher, specify an extra backlog to check + + // When Sequencer is malicious, even when L1 is finalized, L2 state might be unknown for up to sequencerDelayLimit + epochPeriod. + const L2SyncPeriod = sequencerDelayLimit + epochPeriod; + // When we start the watcher, we need to go back far enough to check for claims which may have been pending L2 state finalization. + const veaEpochOutboxWatchLowerBound = + Math.floor((currentTimestamp - L2SyncPeriod - coldStartBacklog) / epochPeriod) - 2; + // ETH / Gnosis POS assumes synchronized clocks + // using local time as a proxy for true "latest" L1 time + const timeLocal = Math.floor(now / 1000); + + let veaEpochOutboxClaimableNow = Math.floor(timeLocal / epochPeriod) - 1; + // only past epochs are claimable, hence shift by one here + const veaEpochOutboxRange = veaEpochOutboxClaimableNow - veaEpochOutboxWatchLowerBound; + const veaEpochOutboxCheckClaimsRangeArray: number[] = new Array(veaEpochOutboxRange + 1) + .fill(veaEpochOutboxWatchLowerBound) + .map((el, i) => el + i); + + return veaEpochOutboxCheckClaimsRangeArray; +}; + +/** + * Checks if a new epoch has started. + * + * @param currentVerifiableEpoch - The current verifiable epoch number + * @param epochPeriod - The epoch period in seconds + * @param now - The current time in milliseconds (optional, defaults to Date.now()) + * + * @returns The updated epoch number + * + * @example + * currentEpoch = checkForNewEpoch(currentEpoch, 7200); + */ +const getLatestVerifiableEpoch = ( + chainId: number, + now: number = Date.now(), + fetchBridgeConfig: typeof getBridgeConfig = getBridgeConfig +): number => { + const { epochPeriod } = fetchBridgeConfig(chainId); + return Math.floor(now / 1000 / epochPeriod) - 1; +}; + +export { setEpochRange, getLatestVerifiableEpoch }; From 25b88b67bbed2b08d773d7cafc13422d720fb08f Mon Sep 17 00:00:00 2001 From: Mani Brar Date: Mon, 23 Dec 2024 14:27:09 +0530 Subject: [PATCH 4/9] feat: claim helper --- validator-cli/src/utils/claim.test.ts | 184 ++++++++++++++++++++++++++ validator-cli/src/utils/claim.ts | 68 ++++++++++ 2 files changed, 252 insertions(+) create mode 100644 validator-cli/src/utils/claim.test.ts create mode 100644 validator-cli/src/utils/claim.ts diff --git a/validator-cli/src/utils/claim.test.ts b/validator-cli/src/utils/claim.test.ts new file mode 100644 index 00000000..59da60f9 --- /dev/null +++ b/validator-cli/src/utils/claim.test.ts @@ -0,0 +1,184 @@ +import { ethers } from "ethers"; +import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; +import { fetchClaim, hashClaim } from "./claim"; +import { ClaimNotFoundError } from "./errors"; + +let mockClaim: ClaimStruct; +// Pre calculated from the deployed contracts +const hashedMockClaim = "0xfee47661ef0432da320c3b4706ff7d412f421b9d1531c33ce8f2e03bfe5dcfa2"; + +describe("snapshotClaim", () => { + describe("fetchClaim", () => { + let veaOutbox: any; + const epoch = 1; + beforeEach(() => { + mockClaim = { + stateRoot: "0xeac817ed5c5b3d1c2c548f231b7cf9a0dfd174059f450ec6f0805acf6a16a551", + claimer: "0xFa00D29d378EDC57AA1006946F0fc6230a5E3288", + timestampClaimed: 1730276784, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress, + }; + veaOutbox = { + queryFilter: jest.fn(), + provider: { + getBlock: jest.fn().mockResolvedValueOnce({ timestamp: mockClaim.timestampClaimed, number: 1234 }), + }, + filters: { + VerificationStarted: jest.fn(), + Challenged: jest.fn(), + Claimed: jest.fn(), + }, + claimHashes: jest.fn(), + }; + }); + + it("should return a valid claim", async () => { + veaOutbox.claimHashes.mockResolvedValueOnce(hashedMockClaim); + veaOutbox.queryFilter + .mockImplementationOnce(() => + Promise.resolve([ + { + data: mockClaim.stateRoot, + topics: [null, `0x000000000000000000000000${mockClaim.claimer.toString().slice(2)}`], + blockNumber: 1234, + }, + ]) + ) // For Claimed + .mockImplementationOnce(() => []) // For Challenged + .mockImplementationOnce(() => Promise.resolve([])); // For VerificationStarted + + const claim = await fetchClaim(veaOutbox, epoch); + + expect(claim).toBeDefined(); + expect(claim).toEqual(mockClaim); + }); + + it("should return a valid claim with challenger", async () => { + mockClaim.challenger = "0xFa00D29d378EDC57AA1006946F0fc6230a5E3288"; + veaOutbox.claimHashes.mockResolvedValueOnce(hashClaim(mockClaim)); + veaOutbox.queryFilter + .mockImplementationOnce(() => + Promise.resolve([ + { + data: mockClaim.stateRoot, + topics: [null, `0x000000000000000000000000${mockClaim.claimer.toString().slice(2)}`], + blockNumber: 1234, + }, + ]) + ) // For Claimed + .mockImplementationOnce(() => + Promise.resolve([ + { + blockHash: "0x1234", + topics: [null, null, `0x000000000000000000000000${mockClaim.challenger.toString().slice(2)}`], + }, + ]) + ) // For Challenged + .mockImplementationOnce(() => Promise.resolve([])); // For VerificationStartedß + + const claim = await fetchClaim(veaOutbox, epoch); + expect(claim).toBeDefined(); + expect(claim).toEqual(mockClaim); + }); + + it("should return a valid claim with verification", async () => { + mockClaim.timestampVerification = 1234; + mockClaim.blocknumberVerification = 1234; + veaOutbox.claimHashes.mockResolvedValueOnce(hashClaim(mockClaim)); + veaOutbox.provider.getBlock.mockResolvedValueOnce({ timestamp: mockClaim.timestampVerification }); + veaOutbox.queryFilter + .mockImplementationOnce(() => + Promise.resolve([ + { + data: mockClaim.stateRoot, + topics: [null, `0x000000000000000000000000${mockClaim.claimer.toString().slice(2)}`], + blockNumber: 1234, + }, + ]) + ) // For Claimed + .mockImplementationOnce(() => Promise.resolve([])) // For Challenged + .mockImplementationOnce(() => + Promise.resolve([ + { + blockNumber: 1234, + }, + ]) + ); // For VerificationStarted + + const claim = await fetchClaim(veaOutbox, epoch); + + expect(claim).toBeDefined(); + expect(claim).toEqual(mockClaim); + }); + + it("should return null if no claim is found", async () => { + veaOutbox.claimHashes.mockResolvedValueOnce(ethers.ZeroHash); + + const claim = await fetchClaim(veaOutbox, epoch); + expect(claim).toBeNull(); + expect(veaOutbox.queryFilter).toHaveBeenCalledTimes(0); + }); + + it("should throw an error if no claim is found", async () => { + veaOutbox.claimHashes.mockResolvedValueOnce(hashedMockClaim); + veaOutbox.queryFilter + .mockImplementationOnce(() => Promise.resolve([])) + .mockImplementationOnce(() => Promise.resolve([])) + .mockImplementationOnce(() => Promise.resolve([])); + + await expect(async () => { + await fetchClaim(veaOutbox, epoch); + }).rejects.toThrow(new ClaimNotFoundError(epoch)); + }); + + it("should throw an error if the claim is not valid", async () => { + mockClaim.honest = 1; + veaOutbox.claimHashes.mockResolvedValueOnce(hashClaim(mockClaim)); + veaOutbox.queryFilter + .mockImplementationOnce(() => + Promise.resolve([ + { + data: mockClaim.stateRoot, + topics: [null, `0x000000000000000000000000${mockClaim.claimer.toString().slice(2)}`], + blockNumber: 1234, + }, + ]) + ) // For Claimed + .mockImplementationOnce(() => []) // For Challenged + .mockImplementationOnce(() => Promise.resolve([])); // For VerificationStarted + + await expect(async () => { + await fetchClaim(veaOutbox, epoch); + }).rejects.toThrow(new ClaimNotFoundError(epoch)); + }); + }); + + describe("hashClaim", () => { + beforeEach(() => { + mockClaim = { + stateRoot: "0xeac817ed5c5b3d1c2c548f231b7cf9a0dfd174059f450ec6f0805acf6a16a551", + claimer: "0xFa00D29d378EDC57AA1006946F0fc6230a5E3288", + timestampClaimed: 1730276784, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress, + }; + }); + it("should return a valid hash", () => { + const hash = hashClaim(mockClaim); + expect(hash).toBeDefined(); + expect(hash).toEqual(hashedMockClaim); + }); + + it("should not return a valid hash", () => { + mockClaim.honest = 1; + const hash = hashClaim(mockClaim); + expect(hash).toBeDefined(); + expect(hash).not.toEqual(hashedMockClaim); + }); + }); +}); diff --git a/validator-cli/src/utils/claim.ts b/validator-cli/src/utils/claim.ts new file mode 100644 index 00000000..0857a54f --- /dev/null +++ b/validator-cli/src/utils/claim.ts @@ -0,0 +1,68 @@ +import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; +import { ethers } from "ethers"; +import { ClaimNotFoundError } from "./errors"; + +const fetchClaim = async (veaOutbox: any, epoch: number): Promise => { + let claim: ClaimStruct = { + stateRoot: ethers.ZeroHash, + claimer: ethers.ZeroAddress, + timestampClaimed: 0, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress, + }; + const claimHash = await veaOutbox.claimHashes(epoch); + if (claimHash === ethers.ZeroHash) return null; + + const [claimLogs, challengeLogs, verificationLogs] = await Promise.all([ + veaOutbox.queryFilter(veaOutbox.filters.Claimed(null, epoch, null)), + veaOutbox.queryFilter(veaOutbox.filters.Challenged(epoch, null)), + veaOutbox.queryFilter(veaOutbox.filters.VerificationStarted(epoch)), + ]); + if (claimLogs.length === 0) throw new ClaimNotFoundError(epoch); + claim.stateRoot = claimLogs[0].data; + claim.claimer = `0x${claimLogs[0].topics[1].slice(26)}`; + claim.timestampClaimed = (await veaOutbox.provider.getBlock(claimLogs[0].blockNumber)).timestamp; + + if (verificationLogs.length > 0) { + claim.timestampVerification = (await veaOutbox.provider.getBlock(verificationLogs[0].blockNumber)).timestamp; + claim.blocknumberVerification = verificationLogs[0].blockNumber; + } + if (challengeLogs.length > 0) { + claim.challenger = "0x" + challengeLogs[0].topics[2].substring(26); + } + + if (hashClaim(claim) == claimHash) { + return claim; + } else { + throw new ClaimNotFoundError(epoch); + } +}; + +/** + * Hashes the claim data. + * + * @param claim - The claim data to be hashed + * + * @returns The hash of the claim data + * + * @example + * const claimHash = hashClaim(claim); + */ +const hashClaim = (claim: ClaimStruct) => { + return ethers.solidityPackedKeccak256( + ["bytes32", "address", "uint32", "uint32", "uint32", "uint8", "address"], + [ + claim.stateRoot, + claim.claimer, + claim.timestampClaimed, + claim.timestampVerification, + claim.blocknumberVerification, + claim.honest, + claim.challenger, + ] + ); +}; + +export { fetchClaim, hashClaim }; From 759fd1f480d6b074e69b168710c12b5637f49263 Mon Sep 17 00:00:00 2001 From: Mani Brar Date: Mon, 23 Dec 2024 14:48:50 +0530 Subject: [PATCH 5/9] fix: epoch tests --- validator-cli/src/utils/epochHandler.test.ts | 35 ++++++++++---------- validator-cli/src/utils/epochHandler.ts | 10 +++--- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/validator-cli/src/utils/epochHandler.test.ts b/validator-cli/src/utils/epochHandler.test.ts index 33ab1955..23cb1ed9 100644 --- a/validator-cli/src/utils/epochHandler.test.ts +++ b/validator-cli/src/utils/epochHandler.test.ts @@ -1,38 +1,37 @@ -import { setEpochRange, getLatestVerifiableEpoch } from "./epochHandler"; +import { setEpochRange, getLatestChallengeableEpoch } from "./epochHandler"; describe("epochHandler", () => { describe("setEpochRange", () => { - const startEpoch = 1000; - const mockedEpochPeriod = 600; - const mockedSeqDelayLimit = 600; + const currentEpoch = 1000000; + + const mockedEpochPeriod = 1000; + const mockedSeqDelayLimit = 1000; const startCoolDown = 7 * 24 * 60 * 60; - const currentTimestamp = (startEpoch + 3) * mockedEpochPeriod + startCoolDown + mockedSeqDelayLimit; + const currentTimestamp = currentEpoch * mockedEpochPeriod; + const now = (currentTimestamp + mockedEpochPeriod + 1) * 1000; // In ms + const startEpoch = + Math.floor((currentTimestamp - (mockedSeqDelayLimit + mockedEpochPeriod + startCoolDown)) / mockedEpochPeriod) - + 2; it("should return the correct epoch range", () => { - const chainId = 1; - const fetchBridgeConfig = jest.fn(() => ({ - sequencerDelayLimit: mockedSeqDelayLimit, + const mockedFetchBridgeConfig = jest.fn(() => ({ epochPeriod: mockedEpochPeriod, + sequencerDelayLimit: mockedSeqDelayLimit, })); - - const mockNow = 1626325500; - const currentClaimableEpoch = Math.floor(mockNow / (1000 * mockedEpochPeriod)) - 1; - const result = setEpochRange(currentTimestamp, chainId, mockNow, fetchBridgeConfig as any); - + const result = setEpochRange(currentEpoch * mockedEpochPeriod, 1, now, mockedFetchBridgeConfig as any); + expect(result[result.length - 1]).toEqual(currentEpoch - 1); expect(result[0]).toEqual(startEpoch); - expect(result[result.length - 1]).toEqual(currentClaimableEpoch); - expect(result.length).toEqual(currentClaimableEpoch + 1 - startEpoch); }); }); - describe("getLatestVerifiableEpoch", () => { + describe("getLatestChallengeableEpoch", () => { it("should return the correct epoch number", () => { const chainId = 1; const now = 1626325200000; const fetchBridgeConfig = jest.fn(() => ({ epochPeriod: 600, })); - const result = getLatestVerifiableEpoch(chainId, now, fetchBridgeConfig as any); - expect(result).toEqual(now / (600 * 1000) - 1); + const result = getLatestChallengeableEpoch(chainId, now, fetchBridgeConfig as any); + expect(result).toEqual(now / (600 * 1000) - 2); }); }); }); diff --git a/validator-cli/src/utils/epochHandler.ts b/validator-cli/src/utils/epochHandler.ts index 3f185d3f..d6842a40 100644 --- a/validator-cli/src/utils/epochHandler.ts +++ b/validator-cli/src/utils/epochHandler.ts @@ -19,9 +19,10 @@ const setEpochRange = ( const timeLocal = Math.floor(now / 1000); let veaEpochOutboxClaimableNow = Math.floor(timeLocal / epochPeriod) - 1; + // only past epochs are claimable, hence shift by one here const veaEpochOutboxRange = veaEpochOutboxClaimableNow - veaEpochOutboxWatchLowerBound; - const veaEpochOutboxCheckClaimsRangeArray: number[] = new Array(veaEpochOutboxRange + 1) + const veaEpochOutboxCheckClaimsRangeArray: number[] = new Array(veaEpochOutboxRange) .fill(veaEpochOutboxWatchLowerBound) .map((el, i) => el + i); @@ -40,13 +41,14 @@ const setEpochRange = ( * @example * currentEpoch = checkForNewEpoch(currentEpoch, 7200); */ -const getLatestVerifiableEpoch = ( +const getLatestChallengeableEpoch = ( chainId: number, now: number = Date.now(), fetchBridgeConfig: typeof getBridgeConfig = getBridgeConfig ): number => { + // NOTE: Add logic to check if claim was made here or in main function? const { epochPeriod } = fetchBridgeConfig(chainId); - return Math.floor(now / 1000 / epochPeriod) - 1; + return Math.floor(now / 1000 / epochPeriod) - 2; }; -export { setEpochRange, getLatestVerifiableEpoch }; +export { setEpochRange, getLatestChallengeableEpoch }; From a38d3ea457cd90d9edebb10d4540587ee3d342ef Mon Sep 17 00:00:00 2001 From: Mani Brar Date: Mon, 30 Dec 2024 13:30:23 +0530 Subject: [PATCH 6/9] feat: watcher & validator --- validator-cli/.env.dist | 17 +- validator-cli/package.json | 2 +- .../src/ArbToEth/transactionHandler.test.ts | 50 +- .../src/ArbToEth/transactionHandler.ts | 42 +- validator-cli/src/ArbToEth/validator.test.ts | 220 ++++++ validator-cli/src/ArbToEth/validator.ts | 92 +++ validator-cli/src/ArbToEth/watcherArbToEth.ts | 740 ------------------ .../watcherArbToGnosis.ts | 0 validator-cli/src/consts/bridgeRoutes.ts | 26 +- validator-cli/src/utils/arbToEthState.ts | 208 +++++ validator-cli/src/utils/botEvents.ts | 3 + validator-cli/src/utils/claim.test.ts | 32 +- validator-cli/src/utils/claim.ts | 79 +- validator-cli/src/utils/ethers.ts | 67 +- validator-cli/src/utils/logger.ts | 18 +- validator-cli/src/utils/shutdown.ts | 18 + validator-cli/src/watcher.ts | 71 ++ .../{ArbToEth/watcher.ts => watcherDevnet.ts} | 0 18 files changed, 805 insertions(+), 880 deletions(-) create mode 100644 validator-cli/src/ArbToEth/validator.test.ts create mode 100644 validator-cli/src/ArbToEth/validator.ts delete mode 100644 validator-cli/src/ArbToEth/watcherArbToEth.ts rename validator-cli/src/{ArbToEth => ArbToGnosis}/watcherArbToGnosis.ts (100%) create mode 100644 validator-cli/src/utils/arbToEthState.ts create mode 100644 validator-cli/src/utils/shutdown.ts create mode 100644 validator-cli/src/watcher.ts rename validator-cli/src/{ArbToEth/watcher.ts => watcherDevnet.ts} (100%) diff --git a/validator-cli/.env.dist b/validator-cli/.env.dist index ffa74e1c..c152544b 100644 --- a/validator-cli/.env.dist +++ b/validator-cli/.env.dist @@ -3,28 +3,29 @@ PRIVATE_KEY= # Devnet RPCs RPC_CHIADO=https://rpc.chiadochain.net RPC_ARB_SEPOLIA=https://sepolia-rollup.arbitrum.io/rpc -RPC_GNOSIS=https://rpc.chiadochain.net -RPC_ARB=https://sepolia-rollup.arbitrum.io/rpc RPC_SEPOLIA= # Testnet or Mainnet RPCs -RPC_ARB= +RPC_ARB=https://sepolia-rollup.arbitrum.io/rpc RPC_ETH= +RPC_GNOSIS=https://rpc.chiadochain.net # Testnet or Mainnet Addresses +# VEA Arbitrum to Ethereum VEAINBOX_ARB_TO_ETH_ADDRESS=0xE12daFE59Bc3A996362d54b37DFd2BA9279cAd06 VEAOUTBOX_ARB_TO_ETH_ADDRESS=0x209BFdC6B7c66b63A8382196Ba3d06619d0F12c9 +# VEA Arbitrum to GNOSIS +VEAINBOX_ARB_TO_GNOSIS_ADDRESS=0x854374483572FFcD4d0225290346279d0718240b +VEAOUTBOX_ARB_TO_GNOSIS_ADDRESS=0x2f1788F7B74e01c4C85578748290467A5f063B0b +VEAROUTER_ARB_TO_GNOSIS_ADDRESS=0x5BE03fDE7794Bc188416ba16932510Ed1277b193 +GNOSIS_AMB_ADDRESS=0x8448E15d0e706C0298dECA99F0b4744030e59d7d +VEAOUTBOX_CHAIN_ID=421611 # Devnet Addresses VEAINBOX_ARBSEPOLIA_TO_SEPOLIA_ADDRESS=0x906dE43dBef27639b1688Ac46532a16dc07Ce410 VEAOUTBOX_ARBSEPOLIA_TO_SEPOLIA_ADDRESS=0x906dE43dBef27639b1688Ac46532a16dc07Ce410 -#For arbToGnosis bridge -VEAINBOX_ARB_TO_GNOSIS_ADDRESS=0x854374483572FFcD4d0225290346279d0718240b -VEAOUTBOX_ARB_TO_GNOSIS_ADDRESS=0x2f1788F7B74e01c4C85578748290467A5f063B0b -VEAROUTER_ARB_TO_GNOSIS_ADDRESS=0x5BE03fDE7794Bc188416ba16932510Ed1277b193 -GNOSIS_AMB_ADDRESS=0x8448E15d0e706C0298dECA99F0b4744030e59d7d TRANSACTION_BATCHER_CONTRACT_ADDRESS_SEPOLIA=0xe7953da7751063d0a41ba727c32c762d3523ade8 TRANSACTION_BATCHER_CONTRACT_ADDRESS_CHIADO=0xcC0a08D4BCC5f91ee9a1587608f7a2975EA75d73 \ No newline at end of file diff --git a/validator-cli/package.json b/validator-cli/package.json index 55ab7a6a..e9deeb03 100644 --- a/validator-cli/package.json +++ b/validator-cli/package.json @@ -10,7 +10,7 @@ "yarn": "4.2.2" }, "scripts": { - "start": "npx ts-node ./src/ArbToEth/watcher.ts", + "start": "npx ts-node ./src/watcher.ts", "start-chiado-devnet": "npx ts-node ./src/devnet/arbToChiado/happyPath.ts", "start-sepolia-devnet": "npx ts-node ./src/devnet/arbToSepolia/happyPath.ts", "start-sepolia-testnet": "npx ts-node ./src/ArbToEth/watcherArbToEth.ts", diff --git a/validator-cli/src/ArbToEth/transactionHandler.test.ts b/validator-cli/src/ArbToEth/transactionHandler.test.ts index 18fd8a27..29a81d39 100644 --- a/validator-cli/src/ArbToEth/transactionHandler.test.ts +++ b/validator-cli/src/ArbToEth/transactionHandler.test.ts @@ -1,13 +1,11 @@ import { ArbToEthTransactionHandler, ContractType } from "./transactionHandler"; import { MockEmitter, defaultEmitter } from "../utils/emitter"; import { BotEvents } from "../utils/botEvents"; -import { ClaimNotSetError, ContractNotSupportedError } from "../utils/errors"; +import { ClaimNotSetError } from "../utils/errors"; import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; -import { getMessageStatus } from "../utils/arbMsgExecutor"; describe("ArbToEthTransactionHandler", () => { let epoch: number = 100; - let deposit: bigint = BigInt(100000); let veaInbox: any; let veaOutbox: any; let veaInboxProvider: any; @@ -41,7 +39,6 @@ describe("ArbToEthTransactionHandler", () => { it("should create a new TransactionHandler without claim", () => { const transactionHandler = new ArbToEthTransactionHandler( epoch, - deposit, veaInbox, veaOutbox, veaInboxProvider, @@ -56,7 +53,6 @@ describe("ArbToEthTransactionHandler", () => { it("should create a new TransactionHandler with claim", () => { const transactionHandler = new ArbToEthTransactionHandler( epoch, - deposit, veaInbox, veaOutbox, veaInboxProvider, @@ -79,7 +75,6 @@ describe("ArbToEthTransactionHandler", () => { beforeEach(() => { transactionHandler = new ArbToEthTransactionHandler( epoch, - deposit, veaInbox, veaOutbox, veaInboxProvider, @@ -128,13 +123,6 @@ describe("ArbToEthTransactionHandler", () => { ); }); - it("should throw an error if contract type is not supported", async () => { - const trnxHash = "0x123456"; - await expect(transactionHandler.checkTransactionStatus(trnxHash, ContractType.ROUTER)).rejects.toThrow( - new ContractNotSupportedError(ContractType.ROUTER) - ); - }); - it("should return false if transaction hash is null", async () => { const trnxHash = null; const status = await transactionHandler.checkTransactionStatus(trnxHash, ContractType.INBOX); @@ -148,7 +136,6 @@ describe("ArbToEthTransactionHandler", () => { beforeEach(() => { transactionHandler = new ArbToEthTransactionHandler( epoch, - deposit, veaInbox, veaOutbox, veaInboxProvider, @@ -195,7 +182,6 @@ describe("ArbToEthTransactionHandler", () => { beforeEach(() => { transactionHandler = new ArbToEthTransactionHandler( epoch, - deposit, veaInbox, veaOutbox, veaInboxProvider, @@ -242,7 +228,6 @@ describe("ArbToEthTransactionHandler", () => { beforeEach(() => { transactionHandler = new ArbToEthTransactionHandler( epoch, - deposit, veaInbox, veaOutbox, veaInboxProvider, @@ -280,16 +265,13 @@ describe("ArbToEthTransactionHandler", () => { }); describe("resolveChallengedClaim", () => { - let mockGetMessageStatus: any; let mockMessageExecutor: any; let transactionHandler: ArbToEthTransactionHandler; const mockEmitter = new MockEmitter(); beforeEach(() => { - mockGetMessageStatus = jest.fn(); mockMessageExecutor = jest.fn(); transactionHandler = new ArbToEthTransactionHandler( epoch, - deposit, veaInbox, veaOutbox, veaInboxProvider, @@ -299,18 +281,15 @@ describe("ArbToEthTransactionHandler", () => { }); it("should resolve challenged claim", async () => { jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(false); - mockGetMessageStatus.mockResolvedValue(1); transactionHandler.transactions.sendSnapshotTxn = "0x1234"; mockMessageExecutor.mockResolvedValue({ hash: "0x1234" }); - await transactionHandler.resolveChallengedClaim(mockMessageExecutor, mockGetMessageStatus); - expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( + await transactionHandler.resolveChallengedClaim( transactionHandler.transactions.sendSnapshotTxn, - ContractType.OUTBOX + mockMessageExecutor ); - expect(mockGetMessageStatus).toHaveBeenCalledWith( + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( transactionHandler.transactions.sendSnapshotTxn, - veaInboxProvider, - veaOutboxProvider + ContractType.OUTBOX ); expect(transactionHandler.transactions.executeSnapshotTxn).toEqual("0x1234"); }); @@ -318,28 +297,11 @@ describe("ArbToEthTransactionHandler", () => { it("should not resolve challenged claim if txn is pending", async () => { jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(true); transactionHandler.transactions.sendSnapshotTxn = "0x1234"; - await transactionHandler.resolveChallengedClaim(mockMessageExecutor, mockGetMessageStatus); - expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( - transactionHandler.transactions.sendSnapshotTxn, - ContractType.OUTBOX - ); - }); - - it("should not claim if snapshot txn is not ready to execute", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(false); - mockGetMessageStatus.mockResolvedValue(0); - transactionHandler.transactions.sendSnapshotTxn = "0x1234"; - await transactionHandler.resolveChallengedClaim(mockMessageExecutor, mockGetMessageStatus); + await transactionHandler.resolveChallengedClaim(mockMessageExecutor); expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( transactionHandler.transactions.sendSnapshotTxn, ContractType.OUTBOX ); - expect(mockGetMessageStatus).toHaveBeenCalledWith( - transactionHandler.transactions.sendSnapshotTxn, - veaInboxProvider, - veaOutboxProvider - ); - expect(mockMessageExecutor).not.toHaveBeenCalled(); }); }); }); diff --git a/validator-cli/src/ArbToEth/transactionHandler.ts b/validator-cli/src/ArbToEth/transactionHandler.ts index f6e874c4..cb23bf45 100644 --- a/validator-cli/src/ArbToEth/transactionHandler.ts +++ b/validator-cli/src/ArbToEth/transactionHandler.ts @@ -1,10 +1,11 @@ import { VeaInboxArbToEth, VeaOutboxArbToEth } from "@kleros/vea-contracts/typechain-types"; import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; import { JsonRpcProvider } from "@ethersproject/providers"; -import { getMessageStatus, messageExecutor } from "../utils/arbMsgExecutor"; +import { messageExecutor } from "../utils/arbMsgExecutor"; import { defaultEmitter } from "../utils/emitter"; import { BotEvents } from "../utils/botEvents"; -import { ClaimNotSetError, ContractNotSupportedError } from "../utils/errors"; +import { ClaimNotSetError } from "../utils/errors"; +import { getBridgeConfig } from "../consts/bridgeRoutes"; /** * @file This file contains the logic for handling transactions from Arbitrum to Ethereum. @@ -25,13 +26,12 @@ type Transactions = { export enum ContractType { INBOX = "inbox", OUTBOX = "outbox", - ROUTER = "router", } export class ArbToEthTransactionHandler { public requiredConfirmations = 10; public claim: ClaimStruct | null = null; - public deposit: bigint; + public chainId = 11155111; public veaInbox: VeaInboxArbToEth; public veaOutbox: VeaOutboxArbToEth; @@ -49,7 +49,6 @@ export class ArbToEthTransactionHandler { constructor( epoch: number, - deposit: bigint, veaInbox: VeaInboxArbToEth, veaOutbox: VeaOutboxArbToEth, veaInboxProvider: JsonRpcProvider, @@ -58,7 +57,6 @@ export class ArbToEthTransactionHandler { claim: ClaimStruct | null = null ) { this.epoch = epoch; - this.deposit = deposit; this.veaInbox = veaInbox; this.veaOutbox = veaOutbox; this.veaInboxProvider = veaInboxProvider; @@ -81,8 +79,6 @@ export class ArbToEthTransactionHandler { provider = this.veaInboxProvider; } else if (contract === ContractType.OUTBOX) { provider = this.veaOutboxProvider; - } else { - throw new ContractNotSupportedError(contract); } if (trnxHash == null) { @@ -113,7 +109,6 @@ export class ArbToEthTransactionHandler { * */ public async challengeClaim() { - // TODO: Add a check for finality of l2 transaction of saveSnapshot() this.emitter.emit(BotEvents.CHALLENGING); if (!this.claim) { throw new ClaimNotSetError(); @@ -121,10 +116,11 @@ export class ArbToEthTransactionHandler { if (await this.checkTransactionStatus(this.transactions.challengeTxn, ContractType.OUTBOX)) { return; } + const { deposit } = getBridgeConfig(this.chainId); const gasEstimate: bigint = await this.veaOutbox[ "challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))" - ].estimateGas(this.epoch, this.claim, { value: this.deposit }); - const maxFeePerGasProfitable = this.deposit / (gasEstimate * BigInt(6)); + ].estimateGas(this.epoch, this.claim, { value: deposit }); + const maxFeePerGasProfitable = deposit / (gasEstimate * BigInt(6)); // Set a reasonable maxPriorityFeePerGas but ensure it's lower than maxFeePerGas let maxPriorityFeePerGasMEV = BigInt(6667000000000); // 6667 gwei @@ -139,7 +135,7 @@ export class ArbToEthTransactionHandler { ](this.epoch, this.claim, { maxFeePerGas: maxFeePerGasProfitable, maxPriorityFeePerGas: maxPriorityFeePerGasMEV, - value: this.deposit, + value: deposit, gasLimit: gasEstimate, }); this.emitter.emit(BotEvents.TXN_MADE, challengeTxn.hash, this.epoch, "Challenge"); @@ -182,27 +178,13 @@ export class ArbToEthTransactionHandler { /** * Execute a sent snapshot to resolve dispute in VeaOutbox (ETH). */ - public async resolveChallengedClaim( - executeMsg: typeof messageExecutor = messageExecutor, - msgStatus: typeof getMessageStatus = getMessageStatus - ) { + public async resolveChallengedClaim(sendSnapshotTxn: string, executeMsg: typeof messageExecutor = messageExecutor) { this.emitter.emit(BotEvents.EXECUTING_SNAPSHOT, this.epoch); if (await this.checkTransactionStatus(this.transactions.sendSnapshotTxn, ContractType.OUTBOX)) { return; } - // TODO: Add check for the status of the snapshot txn - const status = await msgStatus(this.transactions.sendSnapshotTxn, this.veaInboxProvider, this.veaOutboxProvider); - // 1 : ready to execute, 0 : cant execute yet - if (status == 1) { - const msgExecuteTrnx = await executeMsg( - this.transactions.sendSnapshotTxn, - this.veaInboxProvider, - this.veaOutboxProvider - ); - this.emitter.emit(BotEvents.TXN_MADE, msgExecuteTrnx.hash, this.epoch, "Execute Snapshot"); - this.transactions.executeSnapshotTxn = msgExecuteTrnx.hash; - } else { - this.emitter.emit(BotEvents.CANT_EXECUTE_SNAPSHOT); - } + const msgExecuteTrnx = await executeMsg(sendSnapshotTxn, this.veaInboxProvider, this.veaOutboxProvider); + this.emitter.emit(BotEvents.TXN_MADE, msgExecuteTrnx.hash, this.epoch, "Execute Snapshot"); + this.transactions.executeSnapshotTxn = msgExecuteTrnx.hash; } } diff --git a/validator-cli/src/ArbToEth/validator.test.ts b/validator-cli/src/ArbToEth/validator.test.ts new file mode 100644 index 00000000..7ff2d1d1 --- /dev/null +++ b/validator-cli/src/ArbToEth/validator.test.ts @@ -0,0 +1,220 @@ +import { ethers } from "ethers"; +import { challengeAndResolveClaim } from "./validator"; +import { BotEvents } from "../utils/botEvents"; + +describe("validator", () => { + let veaOutbox: any; + let veaInbox: any; + let veaInboxProvider: any; + let veaOutboxProvider: any; + let emitter: any; + let mockGetClaim: any; + let mockClaim: any; + let mockGetClaimState: any; + let mockGetBlockFinality: any; + + beforeEach(() => { + veaInbox = { + snapshots: jest.fn(), + provider: { + getBlock: jest.fn(), + }, + }; + veaOutbox = { + claimHashes: jest.fn(), + queryFilter: jest.fn(), + provider: { + getBlock: jest.fn(), + }, + }; + emitter = { + emit: jest.fn(), + }; + mockClaim = { + stateRoot: "0x1234", + claimer: "0xFa00D29d378EDC57AA1006946F0fc6230a5E3288", + timestampClaimed: 1234, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress, + }; + mockGetClaim = jest.fn(); + mockGetBlockFinality = jest.fn().mockResolvedValue([{ number: 0 }, { number: 0, timestamp: 100 }, false]); + }); + describe("challengeAndResolveClaim", () => { + it("should return null if no claim is made", async () => { + const transactionHandler = null; + mockGetClaim = jest.fn().mockReturnValue(null); + const result = await challengeAndResolveClaim( + 0, + 10, + veaInbox, + veaInboxProvider, + veaOutbox, + veaOutboxProvider, + transactionHandler, + emitter, + mockGetClaim, + mockGetClaimState, + mockGetBlockFinality + ); + + expect(result).toBeNull(); + expect(emitter.emit).toHaveBeenCalledWith(BotEvents.NO_CLAIM, 0); + }); + + it("should challenge if claim is invalid and not challenged", async () => { + const challengeTxn = "0x123"; + const mockTransactionHandler = { + challengeClaim: jest.fn().mockImplementation(() => { + mockTransactionHandler.transactions.challengeTxn = challengeTxn; + return Promise.resolve(); + }), + transactions: { + challengeTxn: "0x0", + }, + }; + veaInbox.snapshots = jest.fn().mockReturnValue("0x321"); + mockGetClaim = jest.fn().mockReturnValue(mockClaim); + const updatedTransactionHandler = await challengeAndResolveClaim( + 0, + 10, + veaInbox, + veaInboxProvider, + veaOutbox, + veaOutboxProvider, + mockTransactionHandler as any, + emitter, + mockGetClaim, + mockGetClaimState, + mockGetBlockFinality + ); + expect(updatedTransactionHandler.transactions.challengeTxn).toBe(challengeTxn); + expect(mockTransactionHandler.challengeClaim).toHaveBeenCalled(); + }); + + it("should not challenge if claim is valid", async () => { + mockClaim.challenger = mockClaim.claimer; + mockGetClaim = jest.fn().mockReturnValue(mockClaim); + veaInbox.snapshots = jest.fn().mockReturnValue(mockClaim.stateRoot); + const updatedTransactionHandler = await challengeAndResolveClaim( + 0, + 10, + veaInbox, + veaInboxProvider, + veaOutbox, + veaOutboxProvider, + null, + emitter, + mockGetClaim, + mockGetClaimState, + mockGetBlockFinality + ); + expect(updatedTransactionHandler).toBeNull(); + }); + + it("send snapshot if snapshot not sent", async () => { + mockClaim.challenger = mockClaim.claimer; + mockGetClaim = jest.fn().mockReturnValue(mockClaim); + mockGetClaimState = jest + .fn() + .mockReturnValue({ sendSnapshot: { status: false, txnHash: "" }, execution: { status: 0, txnHash: "" } }); + veaInbox.snapshots = jest.fn().mockReturnValue("0x0"); + const mockTransactionHandler = { + sendSnapshot: jest.fn().mockImplementation(() => { + mockTransactionHandler.transactions.sendSnapshotTxn = "0x123"; + return Promise.resolve(); + }), + transactions: { + sendSnapshotTxn: "0x0", + }, + }; + const updatedTransactionHandler = await challengeAndResolveClaim( + 0, + 10, + veaInbox, + veaInboxProvider, + veaOutbox, + veaOutboxProvider, + mockTransactionHandler as any, + emitter, + mockGetClaim, + mockGetClaimState, + mockGetBlockFinality + ); + expect(updatedTransactionHandler.transactions.sendSnapshotTxn).toEqual("0x123"); + expect(mockTransactionHandler.sendSnapshot).toHaveBeenCalled(); + expect(updatedTransactionHandler.claim).toEqual(mockClaim); + }); + + it("resolve challenged claim if snapshot sent but not executed", async () => { + mockClaim.challenger = mockClaim.claimer; + mockGetClaim = jest.fn().mockReturnValue(mockClaim); + mockGetClaimState = jest + .fn() + .mockReturnValue({ sendSnapshot: { status: true, txnHash: "0x123" }, execution: { status: 1, txnHash: "" } }); + veaInbox.snapshots = jest.fn().mockReturnValue("0x0"); + const mockTransactionHandler = { + resolveChallengedClaim: jest.fn().mockImplementation(() => { + mockTransactionHandler.transactions.executeSnapshotTxn = "0x123"; + return Promise.resolve(); + }), + transactions: { + executeSnapshotTxn: "0x0", + }, + }; + const updatedTransactionHandler = await challengeAndResolveClaim( + 0, + 10, + veaInbox, + veaInboxProvider, + veaOutbox, + veaOutboxProvider, + mockTransactionHandler as any, + emitter, + mockGetClaim, + mockGetClaimState, + mockGetBlockFinality + ); + expect(updatedTransactionHandler.transactions.executeSnapshotTxn).toEqual("0x123"); + expect(mockTransactionHandler.resolveChallengedClaim).toHaveBeenCalled(); + expect(updatedTransactionHandler.claim).toEqual(mockClaim); + }); + + it("withdraw challenge deposit if snapshot sent and executed", async () => { + mockClaim.challenger = mockClaim.claimer; + mockGetClaim = jest.fn().mockReturnValue(mockClaim); + mockGetClaimState = jest.fn().mockReturnValue({ + sendSnapshot: { status: true, txnHash: "0x123" }, + execution: { status: 2, txnHash: "0x321" }, + }); + veaInbox.snapshots = jest.fn().mockReturnValue("0x0"); + const mockTransactionHandler = { + withdrawChallengeDeposit: jest.fn().mockImplementation(() => { + mockTransactionHandler.transactions.withdrawChallengeDepositTxn = "0x1234"; + return Promise.resolve(); + }), + transactions: { + withdrawChallengeDepositTxn: "0x0", + }, + }; + const updatedTransactionHandler = await challengeAndResolveClaim( + 0, + 10, + veaInbox, + veaInboxProvider, + veaOutbox, + veaOutboxProvider, + mockTransactionHandler as any, + emitter, + mockGetClaim, + mockGetClaimState, + mockGetBlockFinality + ); + expect(updatedTransactionHandler.transactions.withdrawChallengeDepositTxn).toEqual("0x1234"); + expect(mockTransactionHandler.withdrawChallengeDeposit).toHaveBeenCalled(); + expect(updatedTransactionHandler.claim).toEqual(mockClaim); + }); + }); +}); diff --git a/validator-cli/src/ArbToEth/validator.ts b/validator-cli/src/ArbToEth/validator.ts new file mode 100644 index 00000000..5033c45b --- /dev/null +++ b/validator-cli/src/ArbToEth/validator.ts @@ -0,0 +1,92 @@ +import { VeaInboxArbToEth, VeaOutboxArbToEth } from "@kleros/vea-contracts/typechain-types"; +import { JsonRpcProvider } from "@ethersproject/providers"; +import { ethers } from "ethers"; +import { ArbToEthTransactionHandler } from "./transactionHandler"; +import { getClaim, getClaimResolveState } from "../utils/claim"; +import { defaultEmitter } from "../utils/emitter"; +import { BotEvents } from "../utils/botEvents"; +import { getBlocksAndCheckFinality } from "../utils/arbToEthState"; + +// https://github.com/prysmaticlabs/prysm/blob/493905ee9e33a64293b66823e69704f012b39627/config/params/mainnet_config.go#L103 +const secondsPerSlotEth = 12; + +export async function challengeAndResolveClaim( + epoch: number, + epochPeriod: number, + veaInbox: VeaInboxArbToEth, + veaInboxProvider: JsonRpcProvider, + veaOutboxProvider: JsonRpcProvider, + veaOutbox: VeaOutboxArbToEth, + transactionHandler: ArbToEthTransactionHandler | null, + emitter: typeof defaultEmitter = defaultEmitter, + fetchClaim: typeof getClaim = getClaim, + fetchClaimResolveState: typeof getClaimResolveState = getClaimResolveState, + fetchBlocksAndCheckFinality: typeof getBlocksAndCheckFinality = getBlocksAndCheckFinality +): Promise { + const [arbitrumBlock, ethFinalizedBlock, finalityIssueFlagEth] = await fetchBlocksAndCheckFinality( + veaOutboxProvider, + veaInboxProvider, + epoch, + epochPeriod + ); + let blockNumberOutboxLowerBound: number; + const epochClaimableFinalized = Math.floor(ethFinalizedBlock.timestamp / epochPeriod) - 2; + // to query event performantly, we limit the block range with the heuristic that. delta blocknumber <= delta timestamp / secondsPerSlot + if (epoch <= epochClaimableFinalized) { + blockNumberOutboxLowerBound = + ethFinalizedBlock.number - Math.ceil(((epochClaimableFinalized - epoch + 2) * epochPeriod) / secondsPerSlotEth); + } else { + blockNumberOutboxLowerBound = ethFinalizedBlock.number - Math.ceil(epochPeriod / secondsPerSlotEth); + } + const ethBlockTag = finalityIssueFlagEth ? "finalized" : "latest"; + const claim = await fetchClaim(veaOutbox, veaOutboxProvider, epoch, blockNumberOutboxLowerBound, ethBlockTag); + if (!claim) { + emitter.emit(BotEvents.NO_CLAIM, epoch); + return null; + } + if (!transactionHandler) { + transactionHandler = new ArbToEthTransactionHandler( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + defaultEmitter, + claim + ); + } else { + transactionHandler.claim = claim; + } + + const claimSnapshot = await veaInbox.snapshots(epoch, { blockTag: arbitrumBlock.number }); + + if (claimSnapshot != claim.stateRoot && claim.challenger == ethers.ZeroAddress) { + await transactionHandler.challengeClaim(); + } else { + if (claimSnapshot == claim.stateRoot) { + emitter.emit(BotEvents.VALID_CLAIM, epoch); + return null; + } else { + const claimResolveState = await fetchClaimResolveState( + veaInbox, + veaInboxProvider, + veaOutboxProvider, + epoch, + blockNumberOutboxLowerBound, + ethBlockTag + ); + + if (!claimResolveState.sendSnapshot.status) { + await transactionHandler.sendSnapshot(); + } else if (claimResolveState.execution.status == 1) { + await transactionHandler.resolveChallengedClaim(claimResolveState.sendSnapshot.txHash); + } else if (claimResolveState.execution.status == 2) { + await transactionHandler.withdrawChallengeDeposit(); + } else { + emitter.emit(BotEvents.WAITING_ARB_TIMEOUT, epoch); + } + } + } + + return transactionHandler; +} diff --git a/validator-cli/src/ArbToEth/watcherArbToEth.ts b/validator-cli/src/ArbToEth/watcherArbToEth.ts deleted file mode 100644 index e68e6562..00000000 --- a/validator-cli/src/ArbToEth/watcherArbToEth.ts +++ /dev/null @@ -1,740 +0,0 @@ -import { getVeaOutboxArbToEth, getVeaInboxArbToEth } from "../utils/ethers"; -import { JsonRpcProvider } from "@ethersproject/providers"; -import { getArbitrumNetwork } from "@arbitrum/sdk"; -import { NODE_INTERFACE_ADDRESS } from "@arbitrum/sdk/dist/lib/dataEntities/constants"; -import { NodeInterface__factory } from "@arbitrum/sdk/dist/lib/abi/factories/NodeInterface__factory"; -import { SequencerInbox__factory } from "@arbitrum/sdk/dist/lib/abi/factories/SequencerInbox__factory"; -import { ContractTransaction, ContractTransactionResponse, ethers } from "ethers"; -import { Block, Log, TransactionReceipt } from "@ethersproject/abstract-provider"; -import { SequencerInbox } from "@arbitrum/sdk/dist/lib/abi/SequencerInbox"; -import { NodeInterface } from "@arbitrum/sdk/dist/lib/abi/NodeInterface"; -import { getMessageStatus, messageExecutor } from "../utils/arbMsgExecutor"; - -require("dotenv").config(); - -// https://github.com/prysmaticlabs/prysm/blob/493905ee9e33a64293b66823e69704f012b39627/config/params/mainnet_config.go#L103 -const slotsPerEpochEth = 32; -const secondsPerSlotEth = 12; - -// This script monitors claims made on VeaOutbox and initiates challenges if required. -// The core flow includes: -// 1. `challenge(veaOutbox)`: Check claims and challenge if necassary. -// 2. `sendSnapshot(veaInbox)`: Send the snapshot from veaInbox for a challenged epoch. -// 3. `resolveDisputeClaim(arbitrumBridge)`: Execute the sent snapshot to resolve the dispute. -// 4. `withdrawChallengeDeposit(veaOutbox)`: Withdraw the deposit if the challenge is successful. - -const watch = async () => { - // connect to RPCs - const providerEth = new JsonRpcProvider(process.env.RPC_ETH); - const providerArb = new JsonRpcProvider(process.env.RPC_ARB); - - // use typechain generated contract factories for vea outbox and inbox - const veaOutbox = getVeaOutboxArbToEth( - process.env.VEAOUTBOX_ARB_TO_ETH_ADDRESS, - process.env.PRIVATE_KEY, - process.env.RPC_ETH - ); - const veaInbox = getVeaInboxArbToEth( - process.env.VEAINBOX_ARB_TO_ETH_ADDRESS, - process.env.PRIVATE_KEY, - process.env.RPC_ARB - ); - - // get Arb sequencer params - const l2Network = await getArbitrumNetwork(providerArb); - const sequencer = SequencerInbox__factory.connect(l2Network.ethBridge.sequencerInbox, providerEth); - const maxDelaySeconds = Number((await retryOperation(() => sequencer.maxTimeVariation(), 1000, 10))[1]); - - // get vea outbox params - const deposit = BigInt((await retryOperation(() => veaOutbox.deposit(), 1000, 10)) as any); - const epochPeriod = Number(await retryOperation(() => veaOutbox.epochPeriod(), 1000, 10)); - const sequencerDelayLimit = Number(await retryOperation(() => veaOutbox.sequencerDelayLimit(), 1000, 10)); - - // * - // calculate epoch range to check claims on Eth - // * - - // Finalized Eth block provides an 'anchor point' for the vea epochs in the outbox that are claimable - const blockFinalizedEth: Block = (await retryOperation(() => providerEth.getBlock("finalized"), 1000, 10)) as Block; - - const coldStartBacklog = 7 * 24 * 60 * 60; // when starting the watcher, specify an extra backlog to check - - // When Sequencer is malicious, even when L1 is finalized, L2 state might be unknown for up to sequencerDelayLimit + epochPeriod. - const L2SyncPeriod = sequencerDelayLimit + epochPeriod; - // When we start the watcher, we need to go back far enough to check for claims which may have been pending L2 state finalization. - const veaEpochOutboxWatchLowerBound = - Math.floor((blockFinalizedEth.timestamp - L2SyncPeriod - coldStartBacklog) / epochPeriod) - 2; - - // ETH / Gnosis POS assumes synchronized clocks - // using local time as a proxy for true "latest" L1 time - const timeLocal = Math.floor(Date.now() / 1000); - - let veaEpochOutboxClaimableNow = Math.floor(timeLocal / epochPeriod) - 1; - - // only past epochs are claimable, hence shift by one here - const veaEpochOutboxRange = veaEpochOutboxClaimableNow - veaEpochOutboxWatchLowerBound + 1; - const veaEpochOutboxCheckClaimsRangeArray: number[] = new Array(veaEpochOutboxRange) - .fill(veaEpochOutboxWatchLowerBound) - .map((el, i) => el + i); - - console.log( - "cold start: checking past claim history from epoch " + - veaEpochOutboxCheckClaimsRangeArray[0] + - " to the current claimable epoch " + - veaEpochOutboxCheckClaimsRangeArray[veaEpochOutboxCheckClaimsRangeArray.length - 1] - ); - - const challengeTxnHashes = new Map(); - - while (true) { - // returns the most recent finalized arbBlock found on Ethereum and info about finality issues on Eth. - // if L1 is experiencing finalization problems, returns the latest arbBlock found in the latest L1 block - const [blockArbFoundOnL1, blockFinalizedEth, finalityIssueFlagEth] = await getBlocksAndCheckFinality( - providerEth, - providerArb, - sequencer, - maxDelaySeconds - ); - - if (!blockArbFoundOnL1) { - console.error("Critical Error: Arbitrum block is not found on L1."); - return; - } - - // claims can be made for the previous epoch, hence - // if an epoch is 2 or more epochs behind the L1 finalized epoch, no further claims can be made, we call this 'veaEpochOutboxFinalized' - const veaEpochOutboxClaimableFinalized = Math.floor(blockFinalizedEth.timestamp / epochPeriod) - 2; - - const timeLocal = Math.floor(Date.now() / 1000); - const timeEth = finalityIssueFlagEth ? timeLocal : blockFinalizedEth.timestamp; - - // if the sequencer is offline for maxDelaySeconds, the l2 timestamp in the next block is clamp to the current L1 timestamp - maxDelaySeconds - const l2Time = Math.max(blockArbFoundOnL1.timestamp, blockFinalizedEth.timestamp - maxDelaySeconds); - - // the latest epoch that is finalized from the L2 POV - // this depends on the L2 clock - const veaEpochInboxFinalized = Math.floor(l2Time / epochPeriod) - 1; - const veaEpochOutboxClaimableNowOld = veaEpochOutboxClaimableNow; - veaEpochOutboxClaimableNow = Math.floor(timeEth / epochPeriod) - 1; - if (veaEpochOutboxClaimableNow > veaEpochOutboxClaimableNowOld) { - const veaEpochsOutboxClaimableNew: number[] = new Array( - veaEpochOutboxClaimableNow - veaEpochOutboxClaimableNowOld - ) - .fill(veaEpochOutboxClaimableNowOld + 1) - .map((el, i) => el + i); - veaEpochOutboxCheckClaimsRangeArray.push(...veaEpochsOutboxClaimableNew); - } - - if (veaEpochOutboxCheckClaimsRangeArray.length == 0) { - console.log("no claims to check"); - const timeToNextEpoch = epochPeriod - (Math.floor(Date.now() / 1000) % epochPeriod); - console.log("waiting till next epoch in " + timeToNextEpoch + " seconds. . ."); - continue; - } - - for (let index = 0; index < veaEpochOutboxCheckClaimsRangeArray.length; index++) { - console.log("Checking claim for epoch " + veaEpochOutboxCheckClaimsRangeArray[index]); - const challenge = challengeTxnHashes.get(index); - const veaEpochOutboxCheck = veaEpochOutboxCheckClaimsRangeArray[index]; - - // if L1 experiences finality failure, we use the latest block - const blockTagEth = finalityIssueFlagEth ? "latest" : "finalized"; - const claimHash = (await retryOperation( - () => veaOutbox.claimHashes(veaEpochOutboxCheck, { blockTag: blockTagEth }), - 1000, - 10 - )) as string; - - // no claim - if (claimHash == "0x0000000000000000000000000000000000000000000000000000000000000000") { - // if epoch is not claimable anymore, remove from array - if (veaEpochOutboxCheck <= veaEpochOutboxClaimableFinalized) { - console.log( - "no claim for epoch " + - veaEpochOutboxCheck + - " and the vea epoch in the outbox is finalized (can no longer be claimed)." - ); - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - index--; - continue; - } else { - console.log( - "no claim for epoch " + - veaEpochOutboxCheck + - " and the vea epoch in the outbox is not finalized (can still be claimed)." - ); - } - } else { - // claim exists - let blockNumberOutboxLowerBound: number; - - // to query event performantly, we limit the block range with the heuristic that. delta blocknumber <= delta timestamp / secondsPerSlot - if (veaEpochOutboxCheck <= veaEpochOutboxClaimableFinalized) { - blockNumberOutboxLowerBound = - blockFinalizedEth.number - - Math.ceil(((veaEpochOutboxClaimableFinalized - veaEpochOutboxCheck + 2) * epochPeriod) / secondsPerSlotEth); - } else { - blockNumberOutboxLowerBound = blockFinalizedEth.number - Math.ceil(epochPeriod / secondsPerSlotEth); - } - - // get claim data - const logClaimed = ( - await retryOperation( - () => - veaOutbox.queryFilter( - veaOutbox.filters.Claimed(null, veaEpochOutboxCheck, null), - blockNumberOutboxLowerBound, - blockTagEth - ), - 1000, - 10 - ) - )[0] as Log; - // check the snapshot on the inbox on Arbitrum - // only check the state from L1 POV, don't trust the sequencer feed. - // arbBlock is a recent (finalized or latest if there are finality problems) block found posted on L1 - const claimSnapshot = (await retryOperation( - () => veaInbox.snapshots(veaEpochOutboxCheck, { blockTag: blockArbFoundOnL1.number }), - 1000, - 10 - )) as string; - - // claim differs from snapshot - if (logClaimed.data != claimSnapshot) { - console.log("!! Claimed merkle root mismatch for epoch " + veaEpochOutboxCheck); - - // if Eth is finalizing but sequencer is malfunctioning, we can wait until the snapshot is considered finalized (L2 time is in the next epoch) - if (!finalityIssueFlagEth && veaEpochInboxFinalized < veaEpochOutboxCheck) { - // note as long as L1 does not have finalization probelms, sequencer could still be malfunctioning - console.log("L2 snapshot is not yet finalized, waiting for finalization to determine challengable status"); - } else { - const timestampClaimed = ( - (await retryOperation(() => providerEth.getBlock(logClaimed.blockNumber), 1000, 10)) as Block - ).timestamp; - - /* - - we want to constrcut the struct below from events, since only the hash is stored onchain - - struct Claim { - bytes32 stateRoot; - address claimer; - uint32 timestampClaimed; - uint32 timestampVerification; - uint32 blocknumberVerification; - Party honest; - address challenger; - } - - */ - var claim = { - stateRoot: logClaimed.data, - claimer: "0x" + logClaimed.topics[1].substring(26), - timestampClaimed: timestampClaimed, - timestampVerification: 0, - blocknumberVerification: 0, - honest: 0, - challenger: "0x0000000000000000000000000000000000000000", - }; - - // check if the claim is in verification or verified - const logVerficiationStarted = (await retryOperation( - () => - veaOutbox.queryFilter( - veaOutbox.filters.VerificationStarted(veaEpochOutboxCheck), - blockNumberOutboxLowerBound, - blockTagEth - ), - 1000, - 10 - )) as Log[]; - - if (logVerficiationStarted.length > 0) { - const timestampVerification = ( - (await retryOperation( - () => providerEth.getBlock(logVerficiationStarted[logVerficiationStarted.length - 1].blockNumber), - 1000, - 10 - )) as Block - ).timestamp; - - // Update the claim struct with verification details - claim.timestampVerification = timestampVerification; - claim.blocknumberVerification = logVerficiationStarted[logVerficiationStarted.length - 1].blockNumber; - - const claimHashCalculated = hashClaim(claim); - - // The hash should match if there is no challenge made and no honest party yet - if (claimHashCalculated != claimHash) { - // Either challenge is made or honest party is set with or without a challenge - claim.honest = 1; - const claimerHonestHash = hashClaim(claim); - if (claimerHonestHash == claimHash) { - console.log("Claim is honest for epoch " + veaEpochOutboxCheck); - // As the claim is honest, remove the epoch from the local array - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - challengeTxnHashes.delete(index); - continue; - } - // The claim is challenged and anyone can be the honest party - } - } - - const logChallenges = (await retryOperation( - () => - veaOutbox.queryFilter( - veaOutbox.filters.Challenged(veaEpochOutboxCheck, null), - blockNumberOutboxLowerBound, - blockTagEth - ), - 1000, - 10 - )) as Log[]; - - // if not challenged, keep checking all claim struct variables - if (logChallenges.length == 0 && challengeTxnHashes[index] == undefined) { - console.log("Claim is challengeable for epoch " + veaEpochOutboxCheck); - } else if (logChallenges.length > 0) { - // Claim is challenged, we check if the snapShot is sent and if the dispute is resolved - console.log("Claim is already challenged for epoch " + veaEpochOutboxCheck); - claim.challenger = "0x" + logChallenges[0].topics[2].substring(26); - - // if claim hash with challenger as winner matches the claimHash, then the challenge is over and challenger won - const challengerWinClaim = { ...claim }; - challengerWinClaim.honest = 2; // challenger wins - - const claimerWinClaim = { ...claim }; - claimerWinClaim.honest = 1; // claimer wins - if (hashClaim(challengerWinClaim) == claimHash) { - // The challenge is over and challenger won - console.log("Challenger won the challenge for epoch " + veaEpochOutboxCheck); - const withdrawChlngDepositTxn = (await retryOperation( - () => veaOutbox.withdrawChallengeDeposit(veaEpochOutboxCheck, challengerWinClaim), - 1000, - 10 - )) as ContractTransactionResponse; - console.log( - "Deposit withdrawn by challenger for " + - veaEpochOutboxCheck + - " with txn hash " + - withdrawChlngDepositTxn.hash - ); - // As the challenge is over, remove the epoch from the local array - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - challengeTxnHashes.delete(index); - continue; - } else if (hashClaim(claimerWinClaim) == claimHash) { - // The challenge is over and claimer won - console.log("Claimer won the challenge for epoch " + veaEpochOutboxCheck); - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - challengeTxnHashes.delete(index); - continue; - } - - // Claim is challenged, no honest party yet - if (logChallenges[0].blockNumber < blockFinalizedEth.number) { - // Send the "stateRoot" snapshot from Arbitrum to the Eth inbox if not sent already - const claimTimestamp = veaEpochOutboxCheckClaimsRangeArray[index] * epochPeriod; - - let blockLatestArb = (await retryOperation(() => providerArb.getBlock("latest"), 1000, 10)) as Block; - let blockoldArb = (await retryOperation( - () => providerArb.getBlock(blockLatestArb.number - 100), - 1000, - 10 - )) as Block; - - const arbAverageBlockTime = (blockLatestArb.timestamp - blockoldArb.timestamp) / 100; - - const fromClaimEpochBlock = Math.ceil( - blockLatestArb.number - (blockLatestArb.timestamp - claimTimestamp) / arbAverageBlockTime - ); - - const sendSnapshotLogs = (await retryOperation( - () => - veaInbox.queryFilter( - veaInbox.filters.SnapshotSent(veaEpochOutboxCheck, null), - fromClaimEpochBlock, - blockTagEth - ), - 1000, - 10 - )) as Log[]; - if (sendSnapshotLogs.length == 0) { - // No snapshot sent so, send snapshot - try { - const gasEstimate = await retryOperation( - () => veaInbox.sendSnapshot.estimateGas(veaEpochOutboxCheck, claim), - 1000, - 10 - ); - - const txnSendSnapshot = (await retryOperation( - () => - veaInbox["sendSnapshot(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"]( - veaEpochOutboxCheck, - claim, // the claim struct has to be updated with the correct challenger - { - gasLimit: gasEstimate, - } - ), - 1000, - 10 - )) as ContractTransactionResponse; - console.log( - "Snapshot message sent for epoch " + - veaEpochOutboxCheck + - " with txn hash " + - txnSendSnapshot.hash - ); - } catch (error) { - console.error("Error sending snapshot for epoch " + veaEpochOutboxCheck + " with error " + error); - } - } else { - // snapshot already sent, check if the snapshot can be relayed to veaOutbox - console.log("Snapshot already sent for epoch " + veaEpochOutboxCheck); - const msgStatus = await getMessageStatus( - sendSnapshotLogs[0].transactionHash, - process.env.RPC_ARB, - process.env.RPC_ETH - ); - if (msgStatus === 1) { - // msg waiting for execution - const msgExecuteTrnx = await messageExecutor( - sendSnapshotLogs[0].transactionHash, - process.env.RPC_ARB, - process.env.RPC_ETH - ); - if (msgExecuteTrnx) { - // msg executed successfully - console.log("Snapshot message relayed to veaOutbox for epoch " + veaEpochOutboxCheck); - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - challengeTxnHashes.delete(index); - } else { - // msg failed to execute - console.error("Error sending snapshot to veaOutbox for epoch " + veaEpochOutboxCheck); - } - } - } - continue; - } - continue; - } - - if (challengeTxnHashes[index] != undefined) { - const txnReceipt = (await retryOperation( - () => providerEth.getTransactionReceipt(challengeTxnHashes[index]), - 10, - 1000 - )) as TransactionReceipt; - if (!txnReceipt) { - console.log("challenge txn " + challenge[index] + " not mined yet"); - continue; - } - const blockNumber = txnReceipt.blockNumber; - const challengeBlock = (await retryOperation(() => providerEth.getBlock(blockNumber), 1000, 10)) as Block; - if (challengeBlock.number < blockFinalizedEth.number) { - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - index--; - challengeTxnHashes.delete(index); - // the challenge is finalized, no further action needed - console.log("challenge is finalized"); - continue; - } - } - const gasEstimate = (await retryOperation( - () => - veaOutbox["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"].estimateGas( - veaEpochOutboxCheck, - claim, - { value: deposit } - ), - 1000, - 10 - )) as bigint; - - // Adjust the calculation to ensure maxFeePerGas is reasonable - const maxFeePerGasProfitable = deposit / (gasEstimate * BigInt(6)); - - // Set a reasonable maxPriorityFeePerGas but ensure it's lower than maxFeePerGas - let maxPriorityFeePerGasMEV = BigInt(6667000000000); // 6667 gwei - console.log("Transaction Challenge Gas Estimate", gasEstimate.toString()); - - // Ensure maxPriorityFeePerGas <= maxFeePerGas - if (maxPriorityFeePerGasMEV > maxFeePerGasProfitable) { - console.warn( - "maxPriorityFeePerGas is higher than maxFeePerGasProfitable, adjusting maxPriorityFeePerGas" - ); - maxPriorityFeePerGasMEV = maxFeePerGasProfitable; // adjust to be equal or less - } - try { - const txnChallenge = (await retryOperation( - () => - veaOutbox["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"]( - veaEpochOutboxCheck, - claim, - { - maxFeePerGas: maxFeePerGasProfitable, - maxPriorityFeePerGas: maxPriorityFeePerGasMEV, - value: deposit, - gasLimit: gasEstimate, - } - ), - 1000, - 10 - )) as ContractTransactionResponse; - // Make wait for receipt and check if the challenge is finalized - console.log("Transaction Challenge Hash", txnChallenge.hash); - // Update local var with the challenge txn hash - challengeTxnHashes.set(index, txnChallenge.hash); - console.log("challenging claim for epoch " + veaEpochOutboxCheck + " with txn hash " + txnChallenge.hash); - } catch (error) { - console.error("Error challenging claim for epoch " + veaEpochOutboxCheck + " with error " + error); - } - } - } else { - console.log("claim hash matches snapshot for epoch " + veaEpochOutboxCheck); - if ( - veaEpochOutboxCheck <= veaEpochOutboxClaimableFinalized && - veaEpochOutboxCheck >= veaEpochInboxFinalized - ) { - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - index--; - continue; - } - } - } - } - - // 3 second delay for potential block and attestation propogation - console.log("waiting 3 seconds for potential block and attestation propogation. . ."); - await wait(1000 * 3); - } -}; - -const wait = (ms) => new Promise((r) => setTimeout(r, ms)); - -const retryOperation = (operation, delay, retries) => - new Promise((resolve, reject) => { - return operation() - .then(resolve) - .catch((reason) => { - if (retries > 0) { - // log retry - console.log("retrying", retries); - return wait(delay) - .then(retryOperation.bind(null, operation, delay, retries - 1)) - .then(resolve) - .catch(reject); - } - return reject(reason); - }); - }); - -const getBlocksAndCheckFinality = async ( - EthProvider: JsonRpcProvider, - ArbProvider: JsonRpcProvider, - sequencer: SequencerInbox, - maxDelaySeconds: number -): Promise<[Block, Block, Boolean] | undefined> => { - const blockFinalizedArb = (await retryOperation(() => ArbProvider.getBlock("finalized"), 1000, 10)) as Block; - const blockFinalizedEth = (await retryOperation(() => EthProvider.getBlock("finalized"), 1000, 10)) as Block; - - const finalityBuffer = 300; // 5 minutes, allows for network delays - const maxFinalityTimeSecondsEth = (slotsPerEpochEth * 3 - 1) * secondsPerSlotEth; // finalization after 2 justified epochs - - let finalityIssueFlagArb = false; - let finalityIssueFlagEth = false; - - // check latest arb block to see if there are any sequencer issues - let blockLatestArb = (await retryOperation(() => ArbProvider.getBlock("latest"), 1000, 10)) as Block; - - const maxDelayInSeconds = 7 * 24 * 60 * 60; // 7 days - let blockoldArb = (await retryOperation(() => ArbProvider.getBlock(blockLatestArb.number - 100), 1000, 10)) as Block; - const arbAverageBlockTime = (blockLatestArb.timestamp - blockoldArb.timestamp) / 100; - const fromBlockArbFinalized = blockFinalizedArb.number - Math.ceil(maxDelayInSeconds / arbAverageBlockTime); - // to performantly query the sequencerInbox's SequencerBatchDelivered event on Eth, we limit the block range - // we use the heuristic that. delta blocknumber <= delta timestamp / secondsPerSlot - // Arb: -----------x <-- Finalized - // || - // \/ - // Eth: -------------------------x <-- Finalized - // /\ - // ||<----------------> <-- Math.floor((timeDiffBlockFinalizedArbL1 + maxDelaySeconds) / secondsPerSlotEth) - // fromBlockEth - - const timeDiffBlockFinalizedArbL1 = blockFinalizedEth.timestamp - blockFinalizedArb.timestamp; - const fromBlockEthFinalized = - blockFinalizedEth.number - Math.floor((timeDiffBlockFinalizedArbL1 + maxDelaySeconds) / secondsPerSlotEth); - - let blockFinalizedArbToL1Block = await ArbBlockToL1Block( - ArbProvider, - sequencer, - blockFinalizedArb, - fromBlockEthFinalized, - fromBlockArbFinalized, - false - ); - - if (!blockFinalizedArbToL1Block) { - console.error("Arbitrum finalized block is not found on L1."); - finalityIssueFlagArb = true; - } else if (Math.abs(blockFinalizedArbToL1Block[0].timestamp - blockFinalizedArb.timestamp) > 1800) { - // The L2 timestamp is drifted from the L1 timestamp in which the L2 block is posted. - console.error("Finalized L2 block time is more than 30 min drifted from L1 clock."); - } - - // blockLatestArbToL1Block[0] is the L1 block, blockLatestArbToL1Block[1] is the L2 block (fallsback on latest L2 block if L2 block is not found on L1) - let blockLatestArbToL1Block = await ArbBlockToL1Block( - ArbProvider, - sequencer, - blockLatestArb, - fromBlockEthFinalized, - fromBlockArbFinalized, - true - ); - - if (finalityIssueFlagArb && !blockLatestArbToL1Block) { - console.error("Arbitrum latest block is not found on L1."); - // this means some issue in the arbitrum node implementation (very bad) - return undefined; - } - - // is blockLatestArb is not found on L1, ArbBlockToL1Block fallsback on the latest L2 block found on L1 - if (blockLatestArbToL1Block[1] != blockLatestArb.number) { - blockLatestArb = (await retryOperation(() => ArbProvider.getBlock(blockLatestArbToL1Block[1]), 1000, 10)) as Block; - } - - // ETH POS assumes synchronized clocks - // using local time as a proxy for true "latest" L1 time - const localTimeSeconds = Math.floor(Date.now() / 1000); - - // The sequencer is completely offline - // Not necessarily a problem, but we should know about it - if (localTimeSeconds - blockLatestArbToL1Block[0].timestamp > 1800) { - console.error("Arbitrum sequencer is offline (from L1 'latest' POV) for atleast 30 minutes."); - } - - // The L2 timestamp is drifted from the L1 timestamp in which the L2 block is posted. - // Not necessarily a problem, but we should know about it - if (Math.abs(blockLatestArbToL1Block[0].timestamp - blockLatestArb.timestamp) > 1800) { - console.error("Latest L2 block time is more than 30 min drifted from L1 clock."); - console.error("L2 block time: " + blockLatestArb.timestamp); - console.error("L1 block time: " + blockLatestArbToL1Block[0].timestamp); - console.error("L2 block number: " + blockLatestArb.number); - } - - // Note: Using last finalized block as a proxy for the latest finalized epoch - // Using a BeaconChain RPC would be more accurate - if (localTimeSeconds - blockFinalizedEth.timestamp > maxFinalityTimeSecondsEth + finalityBuffer) { - console.error("Ethereum mainnet is not finalizing"); - finalityIssueFlagEth = true; - } - - if (blockFinalizedEth.number < blockFinalizedArbToL1Block[0].number) { - console.error( - "Arbitrum 'finalized' block is posted in an L1 block which is not finalized. Arbitrum node is out of sync with L1 node. It's recommended to use the same L1 RPC as the L1 node used by the Arbitrum node." - ); - finalityIssueFlagArb = true; - } - - // if L1 is experiencing finalization problems, we use the latest L2 block - // we could - const blockArbitrum = finalityIssueFlagArb || finalityIssueFlagEth ? blockLatestArb : blockFinalizedArb; - - return [blockArbitrum, blockFinalizedEth, finalityIssueFlagEth]; -}; - -const ArbBlockToL1Block = async ( - L2Provider: JsonRpcProvider, - sequencer: SequencerInbox, - L2Block: Block, - fromBlockEth: number, - fromArbBlock: number, - fallbackLatest: boolean -): Promise<[Block, number] | undefined> => { - const nodeInterface = NodeInterface__factory.connect(NODE_INTERFACE_ADDRESS, L2Provider); - - let latestL2batchOnEth: number; - let latestL2BlockNumberOnEth: number; - let result = (await nodeInterface.functions - .findBatchContainingBlock(L2Block.number, { blockTag: "latest" }) - .catch((e) => { - // If the L2Block is the latest ArbBlock this will always throw an error - console.log("Error finding batch containing block, searching heuristically..."); - })) as any; - - if (!result) { - if (!fallbackLatest) { - return undefined; - } else { - [latestL2batchOnEth, latestL2BlockNumberOnEth] = await findLatestL2BatchAndBlock( - nodeInterface, - fromArbBlock, - L2Block.number - ); - } - } - - const batch = result?.batch?.toNumber() ?? latestL2batchOnEth; - const L2BlockNumberFallback = latestL2BlockNumberOnEth ?? L2Block.number; - /** - * We use the batch number to query the L1 sequencerInbox's SequencerBatchDelivered event - * then, we get its emitted transaction hash. - */ - const queryBatch = sequencer.filters.SequencerBatchDelivered(batch); - - const emittedEvent = (await retryOperation( - () => sequencer.queryFilter(queryBatch, fromBlockEth, "latest"), - 1000, - 10 - )) as any; - if (emittedEvent.length == 0) { - return undefined; - } - - const L1Block = (await retryOperation(() => emittedEvent[0].getBlock(), 1000, 10)) as Block; - return [L1Block, L2BlockNumberFallback]; -}; - -const findLatestL2BatchAndBlock = async ( - nodeInterface: NodeInterface, - fromArbBlock: number, - latestBlockNumber: number -): Promise<[number, number]> => { - let low = fromArbBlock; - let high = latestBlockNumber; - - while (low <= high) { - const mid = Math.floor((low + high) / 2); - try { - (await nodeInterface.functions.findBatchContainingBlock(mid, { blockTag: "latest" })) as any; - low = mid + 1; - } catch (e) { - high = mid - 1; - } - } - if (high < low) return [undefined, undefined]; - // high is now the latest L2 block number that has a corresponding batch on L1 - const result = (await nodeInterface.functions.findBatchContainingBlock(high, { blockTag: "latest" })) as any; - return [result.batch.toNumber(), high]; -}; - -const hashClaim = (claim): any => { - return ethers.solidityPackedKeccak256( - ["bytes32", "address", "uint32", "uint32", "uint32", "uint8", "address"], - [ - claim.stateRoot, - claim.claimer, - claim.timestampClaimed, - claim.timestampVerification, - claim.blocknumberVerification, - claim.honest, - claim.challenger, - ] - ); -}; - -(async () => { - await watch(); -})(); -export default watch; diff --git a/validator-cli/src/ArbToEth/watcherArbToGnosis.ts b/validator-cli/src/ArbToGnosis/watcherArbToGnosis.ts similarity index 100% rename from validator-cli/src/ArbToEth/watcherArbToGnosis.ts rename to validator-cli/src/ArbToGnosis/watcherArbToGnosis.ts diff --git a/validator-cli/src/consts/bridgeRoutes.ts b/validator-cli/src/consts/bridgeRoutes.ts index 4726f29b..b2f6eb00 100644 --- a/validator-cli/src/consts/bridgeRoutes.ts +++ b/validator-cli/src/consts/bridgeRoutes.ts @@ -1,18 +1,30 @@ -interface IBridge { +require("dotenv").config(); + +interface Bridge { chain: string; epochPeriod: number; deposit: bigint; minChallengePeriod: number; sequencerDelayLimit: number; + inboxRPC: string; + outboxRPC: string; + inboxAddress: string; + outboxAddress: string; + routerAddress?: string; + roueterProvider?: string; } -const bridges: { [chainId: number]: IBridge } = { +const bridges: { [chainId: number]: Bridge } = { 11155111: { chain: "sepolia", epochPeriod: 7200, deposit: BigInt("1000000000000000000"), minChallengePeriod: 10800, sequencerDelayLimit: 86400, + inboxRPC: process.env.RPC_ARB, + outboxRPC: process.env.RPC_ETH, + inboxAddress: process.env.VEAINBOX_ARB_TO_ETH_ADDRESS, + outboxAddress: process.env.VEAOUTBOX_ARB_TO_ETH_ADDRESS, }, 10200: { chain: "chiado", @@ -20,11 +32,17 @@ const bridges: { [chainId: number]: IBridge } = { deposit: BigInt("1000000000000000000"), minChallengePeriod: 10800, sequencerDelayLimit: 86400, + inboxRPC: process.env.RPC_ARB, + outboxRPC: process.env.RPC_GNOSIS, + roueterProvider: process.env.RPC_ETH, + inboxAddress: process.env.VEAINBOX_ARB_TO_GNOSIS_ADDRESS, + routerAddress: process.env.VEA_ROUTER_ARB_TO_GNOSIS_ADDRESS, + outboxAddress: process.env.VEAOUTBOX_ARB_TO_GNOSIS_ADDRESS, }, }; -const getBridgeConfig = (chainId: number): IBridge | undefined => { +const getBridgeConfig = (chainId: number): Bridge | undefined => { return bridges[chainId]; }; -export { getBridgeConfig }; +export { getBridgeConfig, Bridge }; diff --git a/validator-cli/src/utils/arbToEthState.ts b/validator-cli/src/utils/arbToEthState.ts new file mode 100644 index 00000000..2e3774d5 --- /dev/null +++ b/validator-cli/src/utils/arbToEthState.ts @@ -0,0 +1,208 @@ +import { JsonRpcProvider, Block } from "@ethersproject/providers"; +import { SequencerInbox } from "@arbitrum/sdk/dist/lib/abi/SequencerInbox"; +import { NodeInterface__factory } from "@arbitrum/sdk/dist/lib/abi/factories/NodeInterface__factory"; +import { NodeInterface } from "@arbitrum/sdk/dist/lib/abi/NodeInterface"; +import { NODE_INTERFACE_ADDRESS } from "@arbitrum/sdk/dist/lib/dataEntities/constants"; +import { getArbitrumNetwork } from "@arbitrum/sdk"; +import { SequencerInbox__factory } from "@arbitrum/sdk/dist/lib/abi/factories/SequencerInbox__factory"; + +// https://github.com/prysmaticlabs/prysm/blob/493905ee9e33a64293b66823e69704f012b39627/config/params/mainnet_config.go#L103 +const slotsPerEpochEth = 32; +const secondsPerSlotEth = 12; + +const getBlocksAndCheckFinality = async ( + EthProvider: JsonRpcProvider, + ArbProvider: JsonRpcProvider, + veaEpoch: number, + veaEpochPeriod: number +): Promise<[Block, Block, Boolean] | undefined> => { + const currentEpoch = Math.floor(Date.now() / 1000 / veaEpochPeriod); + + const l2Network = await getArbitrumNetwork(ArbProvider); + const sequencer = SequencerInbox__factory.connect(l2Network.ethBridge.sequencerInbox, EthProvider); + const maxDelaySeconds = Number(await sequencer.maxTimeVariation()); + const blockFinalizedArb = (await ArbProvider.getBlock("finalized")) as Block; + const blockFinalizedEth = (await EthProvider.getBlock("finalized")) as Block; + if ( + currentEpoch - veaEpoch > 2 && + blockFinalizedArb.timestamp > veaEpoch * veaEpochPeriod && + blockFinalizedEth.timestamp > veaEpoch * veaEpochPeriod + ) { + return [blockFinalizedArb, blockFinalizedEth, false]; + } + const finalityBuffer = 300; // 5 minutes, allows for network delays + const maxFinalityTimeSecondsEth = slotsPerEpochEth * 2 * secondsPerSlotEth; // finalization after 2 justified epochs + + let finalityIssueFlagArb = false; + let finalityIssueFlagEth = false; + + // check latest arb block to see if there are any sequencer issues + let blockLatestArb = (await ArbProvider.getBlock("latest")) as Block; + + const maxDelayInSeconds = 7 * 24 * 60 * 60; // 7 days + let blockoldArb = (await ArbProvider.getBlock(blockLatestArb.number - 100)) as Block; + const arbAverageBlockTime = (blockLatestArb.timestamp - blockoldArb.timestamp) / 100; + const fromBlockArbFinalized = blockFinalizedArb.number - Math.ceil(maxDelayInSeconds / arbAverageBlockTime); + // to performantly query the sequencerInbox's SequencerBatchDelivered event on Eth, we limit the block range + // we use the heuristic that. delta blocknumber <= delta timestamp / secondsPerSlot + // Arb: -----------x <-- Finalized + // || + // \/ + // Eth: -------------------------x <-- Finalized + // /\ + // ||<----------------> <-- Math.floor((timeDiffBlockFinalizedArbL1 + maxDelaySeconds) / secondsPerSlotEth) + // fromBlockEth + + const timeDiffBlockFinalizedArbL1 = blockFinalizedEth.timestamp - blockFinalizedArb.timestamp; + const fromBlockEthFinalized = + blockFinalizedEth.number - Math.floor((timeDiffBlockFinalizedArbL1 + maxDelaySeconds) / secondsPerSlotEth); + + let blockFinalizedArbToL1Block = await ArbBlockToL1Block( + ArbProvider, + sequencer, + blockFinalizedArb, + fromBlockEthFinalized, + fromBlockArbFinalized, + false + ); + + if (!blockFinalizedArbToL1Block) { + console.error("Arbitrum finalized block is not found on L1."); + finalityIssueFlagArb = true; + } else if (Math.abs(blockFinalizedArbToL1Block[0].timestamp - blockFinalizedArb.timestamp) > 1800) { + // The L2 timestamp is drifted from the L1 timestamp in which the L2 block is posted. + console.error("Finalized L2 block time is more than 30 min drifted from L1 clock."); + } + + // blockLatestArbToL1Block[0] is the L1 block, blockLatestArbToL1Block[1] is the L2 block (fallsback on latest L2 block if L2 block is not found on L1) + let blockLatestArbToL1Block = await ArbBlockToL1Block( + ArbProvider, + sequencer, + blockLatestArb, + fromBlockEthFinalized, + fromBlockArbFinalized, + true + ); + + if (finalityIssueFlagArb && !blockLatestArbToL1Block) { + console.error("Arbitrum latest block is not found on L1."); + // this means some issue in the arbitrum node implementation (very bad) + return undefined; + } + + // is blockLatestArb is not found on L1, ArbBlockToL1Block fallsback on the latest L2 block found on L1 + if (blockLatestArbToL1Block[1] != blockLatestArb.number) { + blockLatestArb = (await ArbProvider.getBlock(blockLatestArbToL1Block[1])) as Block; + } + + // ETH POS assumes synchronized clocks + // using local time as a proxy for true "latest" L1 time + const localTimeSeconds = Math.floor(Date.now() / 1000); + + // The sequencer is completely offline + // Not necessarily a problem, but we should know about it + if (localTimeSeconds - blockLatestArbToL1Block[0].timestamp > 1800) { + console.error("Arbitrum sequencer is offline (from L1 'latest' POV) for atleast 30 minutes."); + } + + // The L2 timestamp is drifted from the L1 timestamp in which the L2 block is posted. + // Not necessarily a problem, but we should know about it + if (Math.abs(blockLatestArbToL1Block[0].timestamp - blockLatestArb.timestamp) > 1800) { + console.error("Latest L2 block time is more than 30 min drifted from L1 clock."); + console.error("L2 block time: " + blockLatestArb.timestamp); + console.error("L1 block time: " + blockLatestArbToL1Block[0].timestamp); + console.error("L2 block number: " + blockLatestArb.number); + } + + // Note: Using last finalized block as a proxy for the latest finalized epoch + // Using a BeaconChain RPC would be more accurate + if (localTimeSeconds - blockFinalizedEth.timestamp > maxFinalityTimeSecondsEth + finalityBuffer) { + console.error("Ethereum mainnet is not finalizing"); + finalityIssueFlagEth = true; + } + + if (blockFinalizedEth.number < blockFinalizedArbToL1Block[0].number) { + console.error( + "Arbitrum 'finalized' block is posted in an L1 block which is not finalized. Arbitrum node is out of sync with L1 node. It's recommended to use the same L1 RPC as the L1 node used by the Arbitrum node." + ); + finalityIssueFlagArb = true; + } + // if L1 is experiencing finalization problems, we use the latest L2 block + // we could + const blockArbitrum = finalityIssueFlagArb || finalityIssueFlagEth ? blockFinalizedArb : blockLatestArb; + + return [blockArbitrum, blockFinalizedEth, finalityIssueFlagEth]; +}; + +const ArbBlockToL1Block = async ( + L2Provider: JsonRpcProvider, + sequencer: SequencerInbox, + L2Block: Block, + fromBlockEth: number, + fromArbBlock: number, + fallbackLatest: boolean +): Promise<[Block, number] | undefined> => { + const nodeInterface = NodeInterface__factory.connect(NODE_INTERFACE_ADDRESS, L2Provider); + + let latestL2batchOnEth: number; + let latestL2BlockNumberOnEth: number; + let result = (await nodeInterface.functions + .findBatchContainingBlock(L2Block.number, { blockTag: "latest" }) + .catch((e) => { + // If the L2Block is the latest ArbBlock this will always throw an error + console.log("Error finding batch containing block, searching heuristically..."); + })) as any; + + if (!result) { + if (!fallbackLatest) { + return undefined; + } else { + [latestL2batchOnEth, latestL2BlockNumberOnEth] = await findLatestL2BatchAndBlock( + nodeInterface, + fromArbBlock, + L2Block.number + ); + } + } + + const batch = result?.batch?.toNumber() ?? latestL2batchOnEth; + const L2BlockNumberFallback = latestL2BlockNumberOnEth ?? L2Block.number; + /** + * We use the batch number to query the L1 sequencerInbox's SequencerBatchDelivered event + * then, we get its emitted transaction hash. + */ + const queryBatch = sequencer.filters.SequencerBatchDelivered(batch); + + const emittedEvent = await sequencer.queryFilter(queryBatch, fromBlockEth, "latest"); + if (emittedEvent.length == 0) { + return undefined; + } + + const L1Block = (await emittedEvent[0].getBlock()) as Block; + return [L1Block, L2BlockNumberFallback]; +}; + +const findLatestL2BatchAndBlock = async ( + nodeInterface: NodeInterface, + fromArbBlock: number, + latestBlockNumber: number +): Promise<[number, number]> => { + let low = fromArbBlock; + let high = latestBlockNumber; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + try { + (await nodeInterface.functions.findBatchContainingBlock(mid)) as any; + low = mid + 1; + } catch (e) { + high = mid - 1; + } + } + + // high is now the latest L2 block number that has a corresponding batch on L1 + const result = (await nodeInterface.functions.findBatchContainingBlock(high)) as any; + return [result.batch.toNumber(), high]; +}; + +export { getBlocksAndCheckFinality }; diff --git a/validator-cli/src/utils/botEvents.ts b/validator-cli/src/utils/botEvents.ts index 7049ffdd..fef5ed5b 100644 --- a/validator-cli/src/utils/botEvents.ts +++ b/validator-cli/src/utils/botEvents.ts @@ -3,6 +3,8 @@ export enum BotEvents { STARTED = "started", CHECKING = "checking", WAITING = "waiting", + NO_CLAIM = "no_claim", + VALID_CLAIM = "valid_claim", // Epoch state NO_NEW_MESSAGES = "no_new_messages", @@ -16,6 +18,7 @@ export enum BotEvents { EXECUTING_SNAPSHOT = "executing_snapshot", CANT_EXECUTE_SNAPSHOT = "CANT_EXECUTE_SNAPSHOT", WITHDRAWING = "withdrawing", + WAITING_ARB_TIMEOUT = "waiting_arb_timeout", // Transaction state TXN_MADE = "txn_made", diff --git a/validator-cli/src/utils/claim.test.ts b/validator-cli/src/utils/claim.test.ts index 59da60f9..4a22ec2e 100644 --- a/validator-cli/src/utils/claim.test.ts +++ b/validator-cli/src/utils/claim.test.ts @@ -1,15 +1,19 @@ import { ethers } from "ethers"; import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; -import { fetchClaim, hashClaim } from "./claim"; +import { getClaim, hashClaim } from "./claim"; import { ClaimNotFoundError } from "./errors"; +import { getBlock } from "web3/lib/commonjs/eth.exports"; let mockClaim: ClaimStruct; // Pre calculated from the deployed contracts const hashedMockClaim = "0xfee47661ef0432da320c3b4706ff7d412f421b9d1531c33ce8f2e03bfe5dcfa2"; +const mockBlockTag = "latest"; +const mockFromBlock = 0; describe("snapshotClaim", () => { - describe("fetchClaim", () => { + describe("getClaim", () => { let veaOutbox: any; + let veaOutboxProvider: any; const epoch = 1; beforeEach(() => { mockClaim = { @@ -23,9 +27,6 @@ describe("snapshotClaim", () => { }; veaOutbox = { queryFilter: jest.fn(), - provider: { - getBlock: jest.fn().mockResolvedValueOnce({ timestamp: mockClaim.timestampClaimed, number: 1234 }), - }, filters: { VerificationStarted: jest.fn(), Challenged: jest.fn(), @@ -33,6 +34,9 @@ describe("snapshotClaim", () => { }, claimHashes: jest.fn(), }; + veaOutboxProvider = { + getBlock: jest.fn().mockResolvedValueOnce({ timestamp: mockClaim.timestampClaimed, number: 1234 }), + }; }); it("should return a valid claim", async () => { @@ -50,7 +54,7 @@ describe("snapshotClaim", () => { .mockImplementationOnce(() => []) // For Challenged .mockImplementationOnce(() => Promise.resolve([])); // For VerificationStarted - const claim = await fetchClaim(veaOutbox, epoch); + const claim = await getClaim(veaOutbox, veaOutboxProvider, epoch, mockFromBlock, mockBlockTag); expect(claim).toBeDefined(); expect(claim).toEqual(mockClaim); @@ -79,7 +83,7 @@ describe("snapshotClaim", () => { ) // For Challenged .mockImplementationOnce(() => Promise.resolve([])); // For VerificationStartedß - const claim = await fetchClaim(veaOutbox, epoch); + const claim = await getClaim(veaOutbox, veaOutboxProvider, epoch, mockFromBlock, mockBlockTag); expect(claim).toBeDefined(); expect(claim).toEqual(mockClaim); }); @@ -88,7 +92,7 @@ describe("snapshotClaim", () => { mockClaim.timestampVerification = 1234; mockClaim.blocknumberVerification = 1234; veaOutbox.claimHashes.mockResolvedValueOnce(hashClaim(mockClaim)); - veaOutbox.provider.getBlock.mockResolvedValueOnce({ timestamp: mockClaim.timestampVerification }); + veaOutboxProvider.getBlock.mockResolvedValueOnce({ timestamp: mockClaim.timestampVerification }); veaOutbox.queryFilter .mockImplementationOnce(() => Promise.resolve([ @@ -108,7 +112,7 @@ describe("snapshotClaim", () => { ]) ); // For VerificationStarted - const claim = await fetchClaim(veaOutbox, epoch); + const claim = await getClaim(veaOutbox, veaOutboxProvider, epoch, mockFromBlock, mockBlockTag); expect(claim).toBeDefined(); expect(claim).toEqual(mockClaim); @@ -117,7 +121,7 @@ describe("snapshotClaim", () => { it("should return null if no claim is found", async () => { veaOutbox.claimHashes.mockResolvedValueOnce(ethers.ZeroHash); - const claim = await fetchClaim(veaOutbox, epoch); + const claim = await getClaim(veaOutbox, veaOutboxProvider, epoch, mockFromBlock, mockBlockTag); expect(claim).toBeNull(); expect(veaOutbox.queryFilter).toHaveBeenCalledTimes(0); }); @@ -130,19 +134,19 @@ describe("snapshotClaim", () => { .mockImplementationOnce(() => Promise.resolve([])); await expect(async () => { - await fetchClaim(veaOutbox, epoch); + await getClaim(veaOutbox, veaOutboxProvider, epoch, mockFromBlock, mockBlockTag); }).rejects.toThrow(new ClaimNotFoundError(epoch)); }); it("should throw an error if the claim is not valid", async () => { - mockClaim.honest = 1; veaOutbox.claimHashes.mockResolvedValueOnce(hashClaim(mockClaim)); + mockClaim.honest = 1; veaOutbox.queryFilter .mockImplementationOnce(() => Promise.resolve([ { data: mockClaim.stateRoot, - topics: [null, `0x000000000000000000000000${mockClaim.claimer.toString().slice(2)}`], + topics: [null, `0x000000000000000000000000${ethers.ZeroAddress.toString().slice(2)}`], blockNumber: 1234, }, ]) @@ -151,7 +155,7 @@ describe("snapshotClaim", () => { .mockImplementationOnce(() => Promise.resolve([])); // For VerificationStarted await expect(async () => { - await fetchClaim(veaOutbox, epoch); + await getClaim(veaOutbox, veaOutboxProvider, epoch, mockFromBlock, mockBlockTag); }).rejects.toThrow(new ClaimNotFoundError(epoch)); }); }); diff --git a/validator-cli/src/utils/claim.ts b/validator-cli/src/utils/claim.ts index 0857a54f..d06c794c 100644 --- a/validator-cli/src/utils/claim.ts +++ b/validator-cli/src/utils/claim.ts @@ -1,8 +1,22 @@ import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; +import { JsonRpcProvider } from "@ethersproject/providers"; import { ethers } from "ethers"; import { ClaimNotFoundError } from "./errors"; +import { getMessageStatus } from "./arbMsgExecutor"; -const fetchClaim = async (veaOutbox: any, epoch: number): Promise => { +/** + * + * @param veaOutbox VeaOutbox contract instance + * @param epoch epoch number of the claim to be fetched + * @returns claim type of ClaimStruct + */ +const getClaim = async ( + veaOutbox: any, + veaOutboxProvider: JsonRpcProvider, + epoch: number, + fromBlock: number, + toBlock: number | string +): Promise => { let claim: ClaimStruct = { stateRoot: ethers.ZeroHash, claimer: ethers.ZeroAddress, @@ -16,28 +30,73 @@ const fetchClaim = async (veaOutbox: any, epoch: number): Promise 0) { - claim.timestampVerification = (await veaOutbox.provider.getBlock(verificationLogs[0].blockNumber)).timestamp; + claim.timestampVerification = (await veaOutboxProvider.getBlock(verificationLogs[0].blockNumber)).timestamp; claim.blocknumberVerification = verificationLogs[0].blockNumber; } if (challengeLogs.length > 0) { + console.log(challengeLogs); claim.challenger = "0x" + challengeLogs[0].topics[2].substring(26); } if (hashClaim(claim) == claimHash) { return claim; - } else { - throw new ClaimNotFoundError(epoch); } + claim.honest = 1; // Assuming claimer is honest + if (hashClaim(claim) == claimHash) { + return claim; + } + claim.honest = 2; // Assuming challenger is honest + if (hashClaim(claim) == claimHash) { + return claim; + } + throw new ClaimNotFoundError(epoch); +}; + +type ClaimResolveState = { + sendSnapshot: { + status: boolean; + txHash: string; + }; + execution: { + status: number; // 0: not ready, 1: ready, 2: executed + txHash: string; + }; +}; + +const getClaimResolveState = async ( + veaInbox: any, + veaInboxProvider: JsonRpcProvider, + veaOutboxProvider: JsonRpcProvider, + epoch: number, + fromBlock: number, + toBlock: number | string +): Promise => { + const sentSnapshotLogs = await veaInbox.queryFilter(veaInbox.filters.SnapshotSent(epoch, null), fromBlock, toBlock); + + const claimResolveState: ClaimResolveState = { + sendSnapshot: { status: false, txHash: "" }, + execution: { status: 0, txHash: "" }, + }; + + if (sentSnapshotLogs.length === 0) return claimResolveState; + else claimResolveState.sendSnapshot.status = true; + + const status = await getMessageStatus(sentSnapshotLogs[0].transactionHash, veaInboxProvider, veaOutboxProvider); + claimResolveState.execution.status = status; + + return claimResolveState; }; /** @@ -47,8 +106,6 @@ const fetchClaim = async (veaOutbox: any, epoch: number): Promise { return ethers.solidityPackedKeccak256( @@ -65,4 +122,4 @@ const hashClaim = (claim: ClaimStruct) => { ); }; -export { fetchClaim, hashClaim }; +export { getClaim, hashClaim, getClaimResolveState }; diff --git a/validator-cli/src/utils/ethers.ts b/validator-cli/src/utils/ethers.ts index 48b542e0..45ada19b 100644 --- a/validator-cli/src/utils/ethers.ts +++ b/validator-cli/src/utils/ethers.ts @@ -9,6 +9,8 @@ import { RouterArbToGnosis__factory, IAMB__factory, } from "@kleros/vea-contracts/typechain-types"; +import { challengeAndResolveClaim as challengeAndResolveClaimArbToEth } from "../ArbToEth/validator"; +import { ArbToEthTransactionHandler } from "../ArbToEth/transactionHandler"; function getWallet(privateKey: string, web3ProviderURL: string) { return new Wallet(privateKey, new JsonRpcProvider(web3ProviderURL)); @@ -18,46 +20,65 @@ function getWalletRPC(privateKey: string, rpc: JsonRpcProvider) { return new Wallet(privateKey, rpc); } -function getVeaInboxArbToEth(veaInboxAddress: string, privateKey: string, web3ProviderURL: string) { - return VeaInboxArbToEth__factory.connect(veaInboxAddress, getWallet(privateKey, web3ProviderURL)); +function getVeaInbox(veaInboxAddress: string, privateKey: string, web3ProviderURL: string, chainId: number) { + switch (chainId) { + case 11155111: + return VeaInboxArbToEth__factory.connect(veaInboxAddress, getWallet(privateKey, web3ProviderURL)); + case 10200: + return VeaInboxArbToGnosis__factory.connect(veaInboxAddress, getWallet(privateKey, web3ProviderURL)); + } } -function getWETH(WETH: string, privateKey: string, web3ProviderURL: string) { - return IWETH__factory.connect(WETH, getWallet(privateKey, web3ProviderURL)); -} - -function getVeaOutboxArbToEth(veaOutboxAddress: string, privateKey: string, web3ProviderURL: string) { - return VeaOutboxArbToEth__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); -} - -function getVeaOutboxArbToEthDevnet(veaOutboxAddress: string, privateKey: string, web3ProviderURL: string) { - return VeaOutboxArbToEthDevnet__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); +function getVeaOutbox(veaOutboxAddress: string, privateKey: string, web3ProviderURL: string, chainId: number) { + switch (chainId) { + case 11155111: + return VeaOutboxArbToEth__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); + case 10200: + return VeaOutboxArbToGnosis__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); + } } -function getVeaOutboxArbToGnosis(veaOutboxAddress: string, privateKey: string, web3ProviderURL: string) { - return VeaOutboxArbToGnosis__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); +function getVeaRouter(veaRouterAddress: string, privateKey: string, web3ProviderURL: string, chainId: number) { + switch (chainId) { + case 10200: + return RouterArbToGnosis__factory.connect(veaRouterAddress, getWallet(privateKey, web3ProviderURL)); + } } -function getVeaInboxArbToGnosis(veaInboxAddress: string, privateKey: string, web3ProviderURL: string) { - return VeaInboxArbToGnosis__factory.connect(veaInboxAddress, getWallet(privateKey, web3ProviderURL)); +function getWETH(WETH: string, privateKey: string, web3ProviderURL: string) { + return IWETH__factory.connect(WETH, getWallet(privateKey, web3ProviderURL)); } -function getVeaRouterArbToGnosis(veaRouterAddress: string, privateKey: string, web3ProviderURL: string) { - return RouterArbToGnosis__factory.connect(veaRouterAddress, getWallet(privateKey, web3ProviderURL)); +function getVeaOutboxArbToEthDevnet(veaOutboxAddress: string, privateKey: string, web3ProviderURL: string) { + return VeaOutboxArbToEthDevnet__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); } function getAMB(ambAddress: string, privateKey: string, web3ProviderURL: string) { return IAMB__factory.connect(ambAddress, getWallet(privateKey, web3ProviderURL)); } + +const getClaimValidator = (chainId: number) => { + switch (chainId) { + case 11155111: + return challengeAndResolveClaimArbToEth; + } +}; + +const getTransactionHandler = (chainId: number) => { + switch (chainId) { + case 11155111: + return ArbToEthTransactionHandler; + } +}; export { - getVeaOutboxArbToEth, getWalletRPC, getWallet, + getVeaInbox, + getVeaOutbox, getVeaOutboxArbToEthDevnet, - getVeaInboxArbToEth, - getVeaOutboxArbToGnosis, - getVeaInboxArbToGnosis, - getVeaRouterArbToGnosis, getWETH, getAMB, + getClaimValidator, + getTransactionHandler, + getVeaRouter, }; diff --git a/validator-cli/src/utils/logger.ts b/validator-cli/src/utils/logger.ts index bb573adf..7b858b2e 100644 --- a/validator-cli/src/utils/logger.ts +++ b/validator-cli/src/utils/logger.ts @@ -19,7 +19,7 @@ export const initialize = (emitter: EventEmitter) => { export const configurableInitialize = (emitter: EventEmitter) => { // Bridger state logs emitter.on(BotEvents.STARTED, () => { - console.log("Bridger started"); + console.log("Validator started"); }); emitter.on(BotEvents.CHECKING, (epoch: number) => { @@ -30,10 +30,6 @@ export const configurableInitialize = (emitter: EventEmitter) => { console.log(`Waiting for next verifiable epoch after ${epoch}`); }); - emitter.on(BotEvents.NO_NEW_MESSAGES, () => { - console.log("No new messages found"); - }); - emitter.on(BotEvents.NO_SNAPSHOT, () => { console.log("No snapshot saved for epoch"); }); @@ -88,4 +84,16 @@ export const configurableInitialize = (emitter: EventEmitter) => { emitter.on(BotEvents.WITHDRAWING, () => { console.log(`Withdrawing challenge deposit for epoch`); }); + + emitter.on(BotEvents.WAITING_ARB_TIMEOUT, (epoch: number) => { + console.log(`Waiting for arbitrum bridge timeout for epoch ${epoch}`); + }); + + // validator + emitter.on(BotEvents.NO_CLAIM, (epoch: number) => { + console.log(`No claim was made for ${epoch}`); + }); + emitter.on(BotEvents.VALID_CLAIM, (epoch: number) => { + console.log(`Valid claim was made for ${epoch}`); + }); }; diff --git a/validator-cli/src/utils/shutdown.ts b/validator-cli/src/utils/shutdown.ts new file mode 100644 index 00000000..74671caf --- /dev/null +++ b/validator-cli/src/utils/shutdown.ts @@ -0,0 +1,18 @@ +/** + * A class to represent a shutdown signal. + */ +export class ShutdownSignal { + private isShutdownSignal: boolean; + + constructor(initialState: boolean = false) { + this.isShutdownSignal = initialState; + } + + public getIsShutdownSignal(): boolean { + return this.isShutdownSignal; + } + + public setShutdownSignal(): void { + this.isShutdownSignal = true; + } +} diff --git a/validator-cli/src/watcher.ts b/validator-cli/src/watcher.ts new file mode 100644 index 00000000..ce562d71 --- /dev/null +++ b/validator-cli/src/watcher.ts @@ -0,0 +1,71 @@ +import { JsonRpcProvider } from "@ethersproject/providers"; +import { getBridgeConfig, Bridge } from "./consts/bridgeRoutes"; +import { getVeaInbox, getVeaOutbox, getTransactionHandler } from "./utils/ethers"; +import { setEpochRange, getLatestChallengeableEpoch } from "./utils/epochHandler"; +import { getClaimValidator } from "./utils/ethers"; +import { defaultEmitter } from "./utils/emitter"; +import { BotEvents } from "./utils/botEvents"; +import { initialize as initializeLogger } from "./utils/logger"; +import { ShutdownSignal } from "./utils/shutDown"; + +export const watch = async ( + shutDownSignal: ShutdownSignal = new ShutdownSignal(), + emitter: typeof defaultEmitter = defaultEmitter +) => { + initializeLogger(emitter); + emitter.emit(BotEvents.STARTED); + const chainId = Number(process.env.VEAOUTBOX_CHAIN_ID); + const veaBridge: Bridge = getBridgeConfig(chainId); + const veaInbox = getVeaInbox(veaBridge.inboxAddress, process.env.PRIVATE_KEY, veaBridge.inboxRPC, chainId); + const veaOutbox = getVeaOutbox(veaBridge.outboxAddress, process.env.PRIVATE_KEY, veaBridge.outboxRPC, chainId); + const veaInboxProvider = new JsonRpcProvider(veaBridge.inboxRPC); + const veaOutboxProvider = new JsonRpcProvider(veaBridge.outboxRPC); + const checkAndChallengeResolve = getClaimValidator(chainId); + const TransactionHandler = getTransactionHandler(chainId); + + let veaOutboxLatestBlock = await veaOutboxProvider.getBlock("latest"); + const transactionHandlers: { [epoch: number]: InstanceType } = {}; + const epochRange = setEpochRange(veaOutboxLatestBlock.timestamp, chainId); + + let latestEpoch = getLatestChallengeableEpoch(chainId); + while (!shutDownSignal.getIsShutdownSignal()) { + let i = 0; + while (i < epochRange.length) { + const epoch = epochRange[i]; + emitter.emit(BotEvents.CHECKING, epoch); + const updatedTransactions = await checkAndChallengeResolve( + epoch, + veaBridge.epochPeriod, + veaInbox as any, + veaInboxProvider, + veaOutboxProvider, + veaOutbox as any, + transactionHandlers[epoch], + emitter + ); + if (updatedTransactions) { + transactionHandlers[epoch] = updatedTransactions; + } else { + delete transactionHandlers[epoch]; + epochRange.splice(i, 1); + i--; + } + i++; + } + const newEpoch = getLatestChallengeableEpoch(chainId); + if (newEpoch > latestEpoch) { + epochRange.push(newEpoch); + latestEpoch = newEpoch; + } else { + emitter.emit(BotEvents.WAITING, latestEpoch); + } + await wait(1000 * 10); + } +}; + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +if (require.main === module) { + const shutDownSignal = new ShutdownSignal(false); + watch(shutDownSignal); +} diff --git a/validator-cli/src/ArbToEth/watcher.ts b/validator-cli/src/watcherDevnet.ts similarity index 100% rename from validator-cli/src/ArbToEth/watcher.ts rename to validator-cli/src/watcherDevnet.ts From c4fa598f7b0cd7c5fad2ac8e7c81a795fad93b01 Mon Sep 17 00:00:00 2001 From: Mani Brar Date: Fri, 3 Jan 2025 12:56:55 +0530 Subject: [PATCH 7/9] fix: sendSnapshot txn update --- validator-cli/src/utils/claim.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/validator-cli/src/utils/claim.ts b/validator-cli/src/utils/claim.ts index d06c794c..1cb8653f 100644 --- a/validator-cli/src/utils/claim.ts +++ b/validator-cli/src/utils/claim.ts @@ -46,7 +46,6 @@ const getClaim = async ( claim.blocknumberVerification = verificationLogs[0].blockNumber; } if (challengeLogs.length > 0) { - console.log(challengeLogs); claim.challenger = "0x" + challengeLogs[0].topics[2].substring(26); } @@ -91,7 +90,10 @@ const getClaimResolveState = async ( }; if (sentSnapshotLogs.length === 0) return claimResolveState; - else claimResolveState.sendSnapshot.status = true; + else { + claimResolveState.sendSnapshot.status = true; + claimResolveState.sendSnapshot.txHash = sentSnapshotLogs[0].transactionHash; + } const status = await getMessageStatus(sentSnapshotLogs[0].transactionHash, veaInboxProvider, veaOutboxProvider); claimResolveState.execution.status = status; From 76dc985fec36824d15d34f72f5cd4d856cb9aec4 Mon Sep 17 00:00:00 2001 From: Mani Brar Date: Mon, 6 Jan 2025 12:24:13 +0530 Subject: [PATCH 8/9] chore: dev comments --- .../src/ArbToEth/transactionHandler.test.ts | 33 +++++----- .../src/ArbToEth/transactionHandler.ts | 19 +++--- validator-cli/src/consts/bridgeRoutes.ts | 4 +- validator-cli/src/utils/arbMsgExecutor.ts | 19 +++++- validator-cli/src/utils/arbToEthState.ts | 37 ++++++++++- validator-cli/src/utils/claim.test.ts | 61 ++++++++++++++++++- validator-cli/src/utils/claim.ts | 5 +- validator-cli/src/utils/epochHandler.ts | 11 ++++ validator-cli/src/utils/errors.ts | 10 ++- validator-cli/src/utils/ethers.ts | 3 + validator-cli/src/watcher.ts | 10 ++- 11 files changed, 177 insertions(+), 35 deletions(-) diff --git a/validator-cli/src/ArbToEth/transactionHandler.test.ts b/validator-cli/src/ArbToEth/transactionHandler.test.ts index 29a81d39..677ed6cf 100644 --- a/validator-cli/src/ArbToEth/transactionHandler.test.ts +++ b/validator-cli/src/ArbToEth/transactionHandler.test.ts @@ -35,6 +35,7 @@ describe("ArbToEthTransactionHandler", () => { challenger: "0x1234", }; }); + describe("constructor", () => { it("should create a new TransactionHandler without claim", () => { const transactionHandler = new ArbToEthTransactionHandler( @@ -84,14 +85,14 @@ describe("ArbToEthTransactionHandler", () => { veaInboxProvider.getBlock.mockResolvedValue({ number: finalityBlock }); }); - it("should return false if transaction is not final", async () => { + it("should return 2 if transaction is not final", async () => { jest.spyOn(mockEmitter, "emit"); veaInboxProvider.getTransactionReceipt.mockResolvedValue({ blockNumber: finalityBlock - (transactionHandler.requiredConfirmations - 1), }); const trnxHash = "0x123456"; const status = await transactionHandler.checkTransactionStatus(trnxHash, ContractType.INBOX); - expect(status).toBeTruthy(); + expect(status).toEqual(2); expect(mockEmitter.emit).toHaveBeenCalledWith( BotEvents.TXN_NOT_FINAL, trnxHash, @@ -99,23 +100,23 @@ describe("ArbToEthTransactionHandler", () => { ); }); - it("should return true if transaction is pending", async () => { + it("should return 1 if transaction is pending", async () => { jest.spyOn(mockEmitter, "emit"); veaInboxProvider.getTransactionReceipt.mockResolvedValue(null); const trnxHash = "0x123456"; const status = await transactionHandler.checkTransactionStatus(trnxHash, ContractType.INBOX); - expect(status).toBeTruthy(); + expect(status).toEqual(1); expect(mockEmitter.emit).toHaveBeenCalledWith(BotEvents.TXN_PENDING, trnxHash); }); - it("should return false if transaction is final", async () => { + it("should return 3 if transaction is final", async () => { jest.spyOn(mockEmitter, "emit"); veaInboxProvider.getTransactionReceipt.mockResolvedValue({ blockNumber: finalityBlock - transactionHandler.requiredConfirmations, }); const trnxHash = "0x123456"; const status = await transactionHandler.checkTransactionStatus(trnxHash, ContractType.INBOX); - expect(status).toBeFalsy(); + expect(status).toEqual(3); expect(mockEmitter.emit).toHaveBeenCalledWith( BotEvents.TXN_FINAL, trnxHash, @@ -123,10 +124,10 @@ describe("ArbToEthTransactionHandler", () => { ); }); - it("should return false if transaction hash is null", async () => { + it("should return 0 if transaction hash is null", async () => { const trnxHash = null; const status = await transactionHandler.checkTransactionStatus(trnxHash, ContractType.INBOX); - expect(status).toBeFalsy(); + expect(status).toEqual(0); }); }); @@ -153,7 +154,7 @@ describe("ArbToEthTransactionHandler", () => { }); it("should not challenge claim if txn is pending", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(true); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); transactionHandler.transactions.challengeTxn = "0x1234"; await transactionHandler.challengeClaim(); expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( @@ -164,7 +165,7 @@ describe("ArbToEthTransactionHandler", () => { }); it("should challenge claim", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(false); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); const mockChallenge = jest.fn().mockResolvedValue({ hash: "0x1234" }) as any; (mockChallenge as any).estimateGas = jest.fn().mockResolvedValue(BigInt(100000)); veaOutbox["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"] = mockChallenge; @@ -193,7 +194,7 @@ describe("ArbToEthTransactionHandler", () => { }); it("should withdraw deposit", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(false); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); veaOutbox.withdrawChallengeDeposit.mockResolvedValue({ hash: "0x1234" }); await transactionHandler.withdrawChallengeDeposit(); expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith(null, ContractType.OUTBOX); @@ -201,7 +202,7 @@ describe("ArbToEthTransactionHandler", () => { }); it("should not withdraw deposit if txn is pending", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(true); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); transactionHandler.transactions.withdrawChallengeDepositTxn = "0x1234"; await transactionHandler.withdrawChallengeDeposit(); expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( @@ -238,7 +239,7 @@ describe("ArbToEthTransactionHandler", () => { }); it("should send snapshot", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(false); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); veaInbox.sendSnapshot.mockResolvedValue({ hash: "0x1234" }); await transactionHandler.sendSnapshot(); expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith(null, ContractType.INBOX); @@ -246,7 +247,7 @@ describe("ArbToEthTransactionHandler", () => { }); it("should not send snapshot if txn is pending", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(true); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); transactionHandler.transactions.sendSnapshotTxn = "0x1234"; await transactionHandler.sendSnapshot(); expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( @@ -280,7 +281,7 @@ describe("ArbToEthTransactionHandler", () => { ); }); it("should resolve challenged claim", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(false); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); transactionHandler.transactions.sendSnapshotTxn = "0x1234"; mockMessageExecutor.mockResolvedValue({ hash: "0x1234" }); await transactionHandler.resolveChallengedClaim( @@ -295,7 +296,7 @@ describe("ArbToEthTransactionHandler", () => { }); it("should not resolve challenged claim if txn is pending", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(true); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); transactionHandler.transactions.sendSnapshotTxn = "0x1234"; await transactionHandler.resolveChallengedClaim(mockMessageExecutor); expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( diff --git a/validator-cli/src/ArbToEth/transactionHandler.ts b/validator-cli/src/ArbToEth/transactionHandler.ts index cb23bf45..f4fb453a 100644 --- a/validator-cli/src/ArbToEth/transactionHandler.ts +++ b/validator-cli/src/ArbToEth/transactionHandler.ts @@ -73,7 +73,7 @@ export class ArbToEthTransactionHandler { * * @returns False if transaction is pending || not final || not made, else True. */ - public async checkTransactionStatus(trnxHash: string | null, contract: ContractType): Promise { + public async checkTransactionStatus(trnxHash: string | null, contract: ContractType): Promise { let provider: JsonRpcProvider; if (contract === ContractType.INBOX) { provider = this.veaInboxProvider; @@ -82,14 +82,15 @@ export class ArbToEthTransactionHandler { } if (trnxHash == null) { - return false; + return 0; } const receipt = await provider.getTransactionReceipt(trnxHash); if (!receipt) { + // TODO: Add transaction pending timeout- redo transaction. this.emitter.emit(BotEvents.TXN_PENDING, trnxHash); - return true; + return 1; } const currentBlock = await provider.getBlock("latest"); @@ -97,10 +98,10 @@ export class ArbToEthTransactionHandler { if (confirmations >= this.requiredConfirmations) { this.emitter.emit(BotEvents.TXN_FINAL, trnxHash, confirmations); - return false; + return 3; } else { this.emitter.emit(BotEvents.TXN_NOT_FINAL, trnxHash, confirmations); - return true; + return 2; } } @@ -113,7 +114,7 @@ export class ArbToEthTransactionHandler { if (!this.claim) { throw new ClaimNotSetError(); } - if (await this.checkTransactionStatus(this.transactions.challengeTxn, ContractType.OUTBOX)) { + if ((await this.checkTransactionStatus(this.transactions.challengeTxn, ContractType.OUTBOX)) > 0) { return; } const { deposit } = getBridgeConfig(this.chainId); @@ -151,7 +152,7 @@ export class ArbToEthTransactionHandler { if (!this.claim) { throw new ClaimNotSetError(); } - if (await this.checkTransactionStatus(this.transactions.withdrawChallengeDepositTxn, ContractType.OUTBOX)) { + if ((await this.checkTransactionStatus(this.transactions.withdrawChallengeDepositTxn, ContractType.OUTBOX)) > 0) { return; } const withdrawDepositTxn = await this.veaOutbox.withdrawChallengeDeposit(this.epoch, this.claim); @@ -167,7 +168,7 @@ export class ArbToEthTransactionHandler { if (!this.claim) { throw new ClaimNotSetError(); } - if (await this.checkTransactionStatus(this.transactions.sendSnapshotTxn, ContractType.INBOX)) { + if ((await this.checkTransactionStatus(this.transactions.sendSnapshotTxn, ContractType.INBOX)) > 0) { return; } const sendSnapshotTxn = await this.veaInbox.sendSnapshot(this.epoch, this.claim); @@ -180,7 +181,7 @@ export class ArbToEthTransactionHandler { */ public async resolveChallengedClaim(sendSnapshotTxn: string, executeMsg: typeof messageExecutor = messageExecutor) { this.emitter.emit(BotEvents.EXECUTING_SNAPSHOT, this.epoch); - if (await this.checkTransactionStatus(this.transactions.sendSnapshotTxn, ContractType.OUTBOX)) { + if ((await this.checkTransactionStatus(this.transactions.sendSnapshotTxn, ContractType.OUTBOX)) > 0) { return; } const msgExecuteTrnx = await executeMsg(sendSnapshotTxn, this.veaInboxProvider, this.veaOutboxProvider); diff --git a/validator-cli/src/consts/bridgeRoutes.ts b/validator-cli/src/consts/bridgeRoutes.ts index b2f6eb00..b61e4696 100644 --- a/validator-cli/src/consts/bridgeRoutes.ts +++ b/validator-cli/src/consts/bridgeRoutes.ts @@ -11,7 +11,7 @@ interface Bridge { inboxAddress: string; outboxAddress: string; routerAddress?: string; - roueterProvider?: string; + routerProvider?: string; } const bridges: { [chainId: number]: Bridge } = { @@ -34,7 +34,7 @@ const bridges: { [chainId: number]: Bridge } = { sequencerDelayLimit: 86400, inboxRPC: process.env.RPC_ARB, outboxRPC: process.env.RPC_GNOSIS, - roueterProvider: process.env.RPC_ETH, + routerProvider: process.env.RPC_ETH, inboxAddress: process.env.VEAINBOX_ARB_TO_GNOSIS_ADDRESS, routerAddress: process.env.VEA_ROUTER_ARB_TO_GNOSIS_ADDRESS, outboxAddress: process.env.VEAOUTBOX_ARB_TO_GNOSIS_ADDRESS, diff --git a/validator-cli/src/utils/arbMsgExecutor.ts b/validator-cli/src/utils/arbMsgExecutor.ts index 0e9a7e47..64e7840b 100644 --- a/validator-cli/src/utils/arbMsgExecutor.ts +++ b/validator-cli/src/utils/arbMsgExecutor.ts @@ -9,7 +9,16 @@ import { JsonRpcProvider, TransactionReceipt } from "@ethersproject/providers"; import { Signer } from "@ethersproject/abstract-signer"; import { ContractTransaction } from "@ethersproject/contracts"; -// Execute the child-to-parent (arbitrum-to-ethereum) message, for reference see: https://docs.arbitrum.io/sdk/reference/message/ChildToParentMessage +/** + * Execute the child-to-parent (arbitrum-to-ethereum) message, + * for reference see: https://docs.arbitrum.io/sdk/reference/message/ChildToParentMessage + * + * @param trnxHash Hash of the transaction + * @param childJsonRpc L2 provider + * @param parentJsonRpc L1 provider + * @returns Execution transaction for the message + * + * */ async function messageExecutor( trnxHash: string, childJsonRpc: JsonRpcProvider, @@ -37,6 +46,14 @@ async function messageExecutor( return res; } +/** + * + * @param trnxHash Hash of the transaction + * @param childJsonRpc L2 provider + * @param parentJsonRpc L1 provider + * @returns status of the message: 0 - not ready, 1 - ready + * + */ async function getMessageStatus( trnxHash: string, childJsonRpc: JsonRpcProvider, diff --git a/validator-cli/src/utils/arbToEthState.ts b/validator-cli/src/utils/arbToEthState.ts index 2e3774d5..ed4cfca5 100644 --- a/validator-cli/src/utils/arbToEthState.ts +++ b/validator-cli/src/utils/arbToEthState.ts @@ -10,12 +10,23 @@ import { SequencerInbox__factory } from "@arbitrum/sdk/dist/lib/abi/factories/Se const slotsPerEpochEth = 32; const secondsPerSlotEth = 12; +/** + * This function checks the finality of the blocks on Arbitrum and Ethereum. + * It returns the latest/finalized block on Arbitrum(found on Ethereum) and Ethereum and a flag indicating if there is a finality issue on Ethereum. + * + * @param EthProvider Ethereum provider + * @param ArbProvider Arbitrum provider + * @param veaEpoch epoch number of the claim to be fetched + * @param veaEpochPeriod epoch period of the claim to be fetched + * + * @returns [Arbitrum block, Ethereum block, finalityIssueFlag] + * */ const getBlocksAndCheckFinality = async ( EthProvider: JsonRpcProvider, ArbProvider: JsonRpcProvider, veaEpoch: number, veaEpochPeriod: number -): Promise<[Block, Block, Boolean] | undefined> => { +): Promise<[Block, Block, boolean] | undefined> => { const currentEpoch = Math.floor(Date.now() / 1000 / veaEpochPeriod); const l2Network = await getArbitrumNetwork(ArbProvider); @@ -134,6 +145,20 @@ const getBlocksAndCheckFinality = async ( return [blockArbitrum, blockFinalizedEth, finalityIssueFlagEth]; }; +/** + * + * This function finds the corresponding L1(Eth) block for a given L2(Arb) block. + * + * @param L2Provider Arbitrum provider + * @param sequencer Arbitrum sequencerInbox + * @param L2Block L2 block + * @param fromBlockEth from block number on Eth + * @param fromArbBlock from block number on Arb + * @param fallbackLatest fallback to latest L2 block if the L2 block is not found on L1 + * + * @returns [L1Block, L2BlockNumberFallback] + */ + const ArbBlockToL1Block = async ( L2Provider: JsonRpcProvider, sequencer: SequencerInbox, @@ -182,6 +207,16 @@ const ArbBlockToL1Block = async ( return [L1Block, L2BlockNumberFallback]; }; +/** + * This function finds the latest L2 batch and block number that has a corresponding batch on L1. + * + * @param nodeInterface Arbitrum NodeInterface + * @param fromArbBlock from block number on Arb + * @param latestBlockNumber latest block number on Arb + * + * @returns [latest L2 batch number, latest L2 block number] + */ + const findLatestL2BatchAndBlock = async ( nodeInterface: NodeInterface, fromArbBlock: number, diff --git a/validator-cli/src/utils/claim.test.ts b/validator-cli/src/utils/claim.test.ts index 4a22ec2e..ed319105 100644 --- a/validator-cli/src/utils/claim.test.ts +++ b/validator-cli/src/utils/claim.test.ts @@ -1,8 +1,7 @@ import { ethers } from "ethers"; import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; -import { getClaim, hashClaim } from "./claim"; +import { getClaim, hashClaim, getClaimResolveState } from "./claim"; import { ClaimNotFoundError } from "./errors"; -import { getBlock } from "web3/lib/commonjs/eth.exports"; let mockClaim: ClaimStruct; // Pre calculated from the deployed contracts @@ -185,4 +184,62 @@ describe("snapshotClaim", () => { expect(hash).not.toEqual(hashedMockClaim); }); }); + + describe("getClaimResolveState", () => { + let veaInbox: any; + let veaInboxProvider: any; + let veaOutboxProvider: any; + const epoch = 1; + const blockNumberOutboxLowerBound = 1234; + const toBlock = "latest"; + beforeEach(() => { + mockClaim = { + stateRoot: "0xeac817ed5c5b3d1c2c548f231b7cf9a0dfd174059f450ec6f0805acf6a16a551", + claimer: "0xFa00D29d378EDC57AA1006946F0fc6230a5E3288", + timestampClaimed: 1730276784, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress, + }; + veaInbox = { + queryFilter: jest.fn(), + filters: { + SnapshotSent: jest.fn(), + }, + }; + }); + + it("should return pending state for both", async () => { + veaInbox.queryFilter.mockResolvedValueOnce([]); + const claimResolveState = await getClaimResolveState( + veaInbox, + veaInboxProvider, + veaOutboxProvider, + epoch, + blockNumberOutboxLowerBound, + toBlock + ); + expect(claimResolveState).toBeDefined(); + expect(claimResolveState.sendSnapshot.status).toBeFalsy(); + expect(claimResolveState.execution.status).toBe(0); + }); + + it("should return pending state for execution", async () => { + veaInbox.queryFilter.mockResolvedValueOnce([{ transactionHash: "0x1234" }]); + const mockGetMessageStatus = jest.fn().mockResolvedValueOnce(0); + const claimResolveState = await getClaimResolveState( + veaInbox, + veaInboxProvider, + veaOutboxProvider, + epoch, + blockNumberOutboxLowerBound, + toBlock, + mockGetMessageStatus + ); + expect(claimResolveState).toBeDefined(); + expect(claimResolveState.sendSnapshot.status).toBeTruthy(); + expect(claimResolveState.execution.status).toBe(0); + }); + }); }); diff --git a/validator-cli/src/utils/claim.ts b/validator-cli/src/utils/claim.ts index 1cb8653f..b24efa04 100644 --- a/validator-cli/src/utils/claim.ts +++ b/validator-cli/src/utils/claim.ts @@ -80,7 +80,8 @@ const getClaimResolveState = async ( veaOutboxProvider: JsonRpcProvider, epoch: number, fromBlock: number, - toBlock: number | string + toBlock: number | string, + fetchMessageStatus: typeof getMessageStatus = getMessageStatus ): Promise => { const sentSnapshotLogs = await veaInbox.queryFilter(veaInbox.filters.SnapshotSent(epoch, null), fromBlock, toBlock); @@ -95,7 +96,7 @@ const getClaimResolveState = async ( claimResolveState.sendSnapshot.txHash = sentSnapshotLogs[0].transactionHash; } - const status = await getMessageStatus(sentSnapshotLogs[0].transactionHash, veaInboxProvider, veaOutboxProvider); + const status = await fetchMessageStatus(sentSnapshotLogs[0].transactionHash, veaInboxProvider, veaOutboxProvider); claimResolveState.execution.status = status; return claimResolveState; diff --git a/validator-cli/src/utils/epochHandler.ts b/validator-cli/src/utils/epochHandler.ts index d6842a40..3cb79b52 100644 --- a/validator-cli/src/utils/epochHandler.ts +++ b/validator-cli/src/utils/epochHandler.ts @@ -1,5 +1,16 @@ import { getBridgeConfig } from "../consts/bridgeRoutes"; +/** + * Sets the epoch range to check for claims. + * + * @param currentTimestamp - The current timestamp + * @param chainId - The chain ID + * @param now - The current time in milliseconds (optional, defaults to Date.now()) + * @param fetchBridgeConfig - The function to fetch the bridge configuration (optional, defaults to getBridgeConfig) + * + * @returns The epoch range to check for claims + */ + const setEpochRange = ( currentTimestamp: number, chainId: number, diff --git a/validator-cli/src/utils/errors.ts b/validator-cli/src/utils/errors.ts index c50753b3..c87a65a4 100644 --- a/validator-cli/src/utils/errors.ts +++ b/validator-cli/src/utils/errors.ts @@ -22,4 +22,12 @@ class ContractNotSupportedError extends Error { } } -export { ClaimNotFoundError, ClaimNotSetError, ContractNotSupportedError }; +class TransactionHandlerNotDefinedError extends Error { + constructor() { + super(); + this.name = "TransactionHandlerNotDefinedError"; + this.message = "TransactionHandler is not defined"; + } +} + +export { ClaimNotFoundError, ClaimNotSetError, ContractNotSupportedError, TransactionHandlerNotDefinedError }; diff --git a/validator-cli/src/utils/ethers.ts b/validator-cli/src/utils/ethers.ts index 45ada19b..29187eb2 100644 --- a/validator-cli/src/utils/ethers.ts +++ b/validator-cli/src/utils/ethers.ts @@ -11,6 +11,7 @@ import { } from "@kleros/vea-contracts/typechain-types"; import { challengeAndResolveClaim as challengeAndResolveClaimArbToEth } from "../ArbToEth/validator"; import { ArbToEthTransactionHandler } from "../ArbToEth/transactionHandler"; +import { TransactionHandlerNotDefinedError } from "./errors"; function getWallet(privateKey: string, web3ProviderURL: string) { return new Wallet(privateKey, new JsonRpcProvider(web3ProviderURL)); @@ -68,6 +69,8 @@ const getTransactionHandler = (chainId: number) => { switch (chainId) { case 11155111: return ArbToEthTransactionHandler; + default: + throw new TransactionHandlerNotDefinedError(); } }; export { diff --git a/validator-cli/src/watcher.ts b/validator-cli/src/watcher.ts index ce562d71..9de4dcb0 100644 --- a/validator-cli/src/watcher.ts +++ b/validator-cli/src/watcher.ts @@ -6,7 +6,15 @@ import { getClaimValidator } from "./utils/ethers"; import { defaultEmitter } from "./utils/emitter"; import { BotEvents } from "./utils/botEvents"; import { initialize as initializeLogger } from "./utils/logger"; -import { ShutdownSignal } from "./utils/shutDown"; +import { ShutdownSignal } from "./utils/shutdown"; + +/** + * @file This file contains the logic for watching a bridge and validating/resolving for claims. + * + * @param shutDownSignal - The signal to shut down the watcher + * @param emitter - The emitter to emit events + * + */ export const watch = async ( shutDownSignal: ShutdownSignal = new ShutdownSignal(), From f9a91e94cee52887ea86401404e8c7842acb755b Mon Sep 17 00:00:00 2001 From: Mani Brar Date: Wed, 8 Jan 2025 14:14:18 +0530 Subject: [PATCH 9/9] chore: added enums --- .../src/ArbToEth/transactionHandler.test.ts | 8 +- .../src/ArbToEth/transactionHandler.ts | 41 +++++-- validator-cli/src/ArbToEth/validator.test.ts | 114 ++++++------------ validator-cli/src/ArbToEth/validator.ts | 40 ++++-- validator-cli/src/utils/claim.ts | 17 ++- validator-cli/src/utils/epochHandler.ts | 1 - validator-cli/src/utils/errors.ts | 10 +- validator-cli/src/watcher.ts | 15 +-- 8 files changed, 111 insertions(+), 135 deletions(-) diff --git a/validator-cli/src/ArbToEth/transactionHandler.test.ts b/validator-cli/src/ArbToEth/transactionHandler.test.ts index 677ed6cf..12b7fb6f 100644 --- a/validator-cli/src/ArbToEth/transactionHandler.test.ts +++ b/validator-cli/src/ArbToEth/transactionHandler.test.ts @@ -288,19 +288,15 @@ describe("ArbToEthTransactionHandler", () => { transactionHandler.transactions.sendSnapshotTxn, mockMessageExecutor ); - expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( - transactionHandler.transactions.sendSnapshotTxn, - ContractType.OUTBOX - ); expect(transactionHandler.transactions.executeSnapshotTxn).toEqual("0x1234"); }); it("should not resolve challenged claim if txn is pending", async () => { jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); - transactionHandler.transactions.sendSnapshotTxn = "0x1234"; + transactionHandler.transactions.executeSnapshotTxn = "0x1234"; await transactionHandler.resolveChallengedClaim(mockMessageExecutor); expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( - transactionHandler.transactions.sendSnapshotTxn, + transactionHandler.transactions.executeSnapshotTxn, ContractType.OUTBOX ); }); diff --git a/validator-cli/src/ArbToEth/transactionHandler.ts b/validator-cli/src/ArbToEth/transactionHandler.ts index f4fb453a..571b3684 100644 --- a/validator-cli/src/ArbToEth/transactionHandler.ts +++ b/validator-cli/src/ArbToEth/transactionHandler.ts @@ -23,6 +23,13 @@ type Transactions = { executeSnapshotTxn: string | null; }; +enum TransactionStatus { + NOT_MADE = 0, + PENDING = 1, + NOT_FINAL = 2, + FINAL = 3, +} + export enum ContractType { INBOX = "inbox", OUTBOX = "outbox", @@ -71,9 +78,9 @@ export class ArbToEthTransactionHandler { * @param trnxHash Transaction hash to check the status of. * @param contract Contract type to check the transaction status in. * - * @returns False if transaction is pending || not final || not made, else True. + * @returns TransactionStatus. */ - public async checkTransactionStatus(trnxHash: string | null, contract: ContractType): Promise { + public async checkTransactionStatus(trnxHash: string | null, contract: ContractType): Promise { let provider: JsonRpcProvider; if (contract === ContractType.INBOX) { provider = this.veaInboxProvider; @@ -82,15 +89,14 @@ export class ArbToEthTransactionHandler { } if (trnxHash == null) { - return 0; + return TransactionStatus.NOT_MADE; } const receipt = await provider.getTransactionReceipt(trnxHash); if (!receipt) { - // TODO: Add transaction pending timeout- redo transaction. this.emitter.emit(BotEvents.TXN_PENDING, trnxHash); - return 1; + return TransactionStatus.PENDING; } const currentBlock = await provider.getBlock("latest"); @@ -98,11 +104,10 @@ export class ArbToEthTransactionHandler { if (confirmations >= this.requiredConfirmations) { this.emitter.emit(BotEvents.TXN_FINAL, trnxHash, confirmations); - return 3; - } else { - this.emitter.emit(BotEvents.TXN_NOT_FINAL, trnxHash, confirmations); - return 2; + return TransactionStatus.FINAL; } + this.emitter.emit(BotEvents.TXN_NOT_FINAL, trnxHash, confirmations); + return TransactionStatus.NOT_FINAL; } /** @@ -114,7 +119,8 @@ export class ArbToEthTransactionHandler { if (!this.claim) { throw new ClaimNotSetError(); } - if ((await this.checkTransactionStatus(this.transactions.challengeTxn, ContractType.OUTBOX)) > 0) { + const transactionStatus = await this.checkTransactionStatus(this.transactions.challengeTxn, ContractType.OUTBOX); + if (transactionStatus > 0) { return; } const { deposit } = getBridgeConfig(this.chainId); @@ -152,7 +158,11 @@ export class ArbToEthTransactionHandler { if (!this.claim) { throw new ClaimNotSetError(); } - if ((await this.checkTransactionStatus(this.transactions.withdrawChallengeDepositTxn, ContractType.OUTBOX)) > 0) { + const transactionStatus = await this.checkTransactionStatus( + this.transactions.withdrawChallengeDepositTxn, + ContractType.OUTBOX + ); + if (transactionStatus > 0) { return; } const withdrawDepositTxn = await this.veaOutbox.withdrawChallengeDeposit(this.epoch, this.claim); @@ -168,7 +178,8 @@ export class ArbToEthTransactionHandler { if (!this.claim) { throw new ClaimNotSetError(); } - if ((await this.checkTransactionStatus(this.transactions.sendSnapshotTxn, ContractType.INBOX)) > 0) { + const transactionStatus = await this.checkTransactionStatus(this.transactions.sendSnapshotTxn, ContractType.INBOX); + if (transactionStatus > 0) { return; } const sendSnapshotTxn = await this.veaInbox.sendSnapshot(this.epoch, this.claim); @@ -181,7 +192,11 @@ export class ArbToEthTransactionHandler { */ public async resolveChallengedClaim(sendSnapshotTxn: string, executeMsg: typeof messageExecutor = messageExecutor) { this.emitter.emit(BotEvents.EXECUTING_SNAPSHOT, this.epoch); - if ((await this.checkTransactionStatus(this.transactions.sendSnapshotTxn, ContractType.OUTBOX)) > 0) { + const transactionStatus = await this.checkTransactionStatus( + this.transactions.executeSnapshotTxn, + ContractType.OUTBOX + ); + if (transactionStatus > 0) { return; } const msgExecuteTrnx = await executeMsg(sendSnapshotTxn, this.veaInboxProvider, this.veaOutboxProvider); diff --git a/validator-cli/src/ArbToEth/validator.test.ts b/validator-cli/src/ArbToEth/validator.test.ts index 7ff2d1d1..21ccb8ca 100644 --- a/validator-cli/src/ArbToEth/validator.test.ts +++ b/validator-cli/src/ArbToEth/validator.test.ts @@ -12,7 +12,7 @@ describe("validator", () => { let mockClaim: any; let mockGetClaimState: any; let mockGetBlockFinality: any; - + let mockDeps: any; beforeEach(() => { veaInbox = { snapshots: jest.fn(), @@ -41,24 +41,25 @@ describe("validator", () => { }; mockGetClaim = jest.fn(); mockGetBlockFinality = jest.fn().mockResolvedValue([{ number: 0 }, { number: 0, timestamp: 100 }, false]); + mockDeps = { + epoch: 0, + epochPeriod: 10, + veaInbox, + veaInboxProvider, + veaOutboxProvider, + veaOutbox, + transactionHandler: null, + emitter, + fetchClaim: mockGetClaim, + fetchClaimResolveState: mockGetClaimState, + fetchBlocksAndCheckFinality: mockGetBlockFinality, + }; }); describe("challengeAndResolveClaim", () => { it("should return null if no claim is made", async () => { - const transactionHandler = null; mockGetClaim = jest.fn().mockReturnValue(null); - const result = await challengeAndResolveClaim( - 0, - 10, - veaInbox, - veaInboxProvider, - veaOutbox, - veaOutboxProvider, - transactionHandler, - emitter, - mockGetClaim, - mockGetClaimState, - mockGetBlockFinality - ); + mockDeps.fetchClaim = mockGetClaim; + const result = await challengeAndResolveClaim(mockDeps); expect(result).toBeNull(); expect(emitter.emit).toHaveBeenCalledWith(BotEvents.NO_CLAIM, 0); @@ -77,19 +78,10 @@ describe("validator", () => { }; veaInbox.snapshots = jest.fn().mockReturnValue("0x321"); mockGetClaim = jest.fn().mockReturnValue(mockClaim); - const updatedTransactionHandler = await challengeAndResolveClaim( - 0, - 10, - veaInbox, - veaInboxProvider, - veaOutbox, - veaOutboxProvider, - mockTransactionHandler as any, - emitter, - mockGetClaim, - mockGetClaimState, - mockGetBlockFinality - ); + + mockDeps.transactionHandler = mockTransactionHandler; + mockDeps.fetchClaim = mockGetClaim; + const updatedTransactionHandler = await challengeAndResolveClaim(mockDeps); expect(updatedTransactionHandler.transactions.challengeTxn).toBe(challengeTxn); expect(mockTransactionHandler.challengeClaim).toHaveBeenCalled(); }); @@ -98,19 +90,8 @@ describe("validator", () => { mockClaim.challenger = mockClaim.claimer; mockGetClaim = jest.fn().mockReturnValue(mockClaim); veaInbox.snapshots = jest.fn().mockReturnValue(mockClaim.stateRoot); - const updatedTransactionHandler = await challengeAndResolveClaim( - 0, - 10, - veaInbox, - veaInboxProvider, - veaOutbox, - veaOutboxProvider, - null, - emitter, - mockGetClaim, - mockGetClaimState, - mockGetBlockFinality - ); + mockDeps.fetchClaim = mockGetClaim; + const updatedTransactionHandler = await challengeAndResolveClaim(mockDeps); expect(updatedTransactionHandler).toBeNull(); }); @@ -130,19 +111,10 @@ describe("validator", () => { sendSnapshotTxn: "0x0", }, }; - const updatedTransactionHandler = await challengeAndResolveClaim( - 0, - 10, - veaInbox, - veaInboxProvider, - veaOutbox, - veaOutboxProvider, - mockTransactionHandler as any, - emitter, - mockGetClaim, - mockGetClaimState, - mockGetBlockFinality - ); + mockDeps.transactionHandler = mockTransactionHandler; + mockDeps.fetchClaimResolveState = mockGetClaimState; + mockDeps.fetchClaim = mockGetClaim; + const updatedTransactionHandler = await challengeAndResolveClaim(mockDeps); expect(updatedTransactionHandler.transactions.sendSnapshotTxn).toEqual("0x123"); expect(mockTransactionHandler.sendSnapshot).toHaveBeenCalled(); expect(updatedTransactionHandler.claim).toEqual(mockClaim); @@ -164,19 +136,10 @@ describe("validator", () => { executeSnapshotTxn: "0x0", }, }; - const updatedTransactionHandler = await challengeAndResolveClaim( - 0, - 10, - veaInbox, - veaInboxProvider, - veaOutbox, - veaOutboxProvider, - mockTransactionHandler as any, - emitter, - mockGetClaim, - mockGetClaimState, - mockGetBlockFinality - ); + mockDeps.transactionHandler = mockTransactionHandler; + mockDeps.fetchClaimResolveState = mockGetClaimState; + mockDeps.fetchClaim = mockGetClaim; + const updatedTransactionHandler = await challengeAndResolveClaim(mockDeps); expect(updatedTransactionHandler.transactions.executeSnapshotTxn).toEqual("0x123"); expect(mockTransactionHandler.resolveChallengedClaim).toHaveBeenCalled(); expect(updatedTransactionHandler.claim).toEqual(mockClaim); @@ -199,19 +162,10 @@ describe("validator", () => { withdrawChallengeDepositTxn: "0x0", }, }; - const updatedTransactionHandler = await challengeAndResolveClaim( - 0, - 10, - veaInbox, - veaInboxProvider, - veaOutbox, - veaOutboxProvider, - mockTransactionHandler as any, - emitter, - mockGetClaim, - mockGetClaimState, - mockGetBlockFinality - ); + mockDeps.transactionHandler = mockTransactionHandler; + mockDeps.fetchClaimResolveState = mockGetClaimState; + mockDeps.fetchClaim = mockGetClaim; + const updatedTransactionHandler = await challengeAndResolveClaim(mockDeps); expect(updatedTransactionHandler.transactions.withdrawChallengeDepositTxn).toEqual("0x1234"); expect(mockTransactionHandler.withdrawChallengeDeposit).toHaveBeenCalled(); expect(updatedTransactionHandler.claim).toEqual(mockClaim); diff --git a/validator-cli/src/ArbToEth/validator.ts b/validator-cli/src/ArbToEth/validator.ts index 5033c45b..f5fef06b 100644 --- a/validator-cli/src/ArbToEth/validator.ts +++ b/validator-cli/src/ArbToEth/validator.ts @@ -10,19 +10,33 @@ import { getBlocksAndCheckFinality } from "../utils/arbToEthState"; // https://github.com/prysmaticlabs/prysm/blob/493905ee9e33a64293b66823e69704f012b39627/config/params/mainnet_config.go#L103 const secondsPerSlotEth = 12; -export async function challengeAndResolveClaim( - epoch: number, - epochPeriod: number, - veaInbox: VeaInboxArbToEth, - veaInboxProvider: JsonRpcProvider, - veaOutboxProvider: JsonRpcProvider, - veaOutbox: VeaOutboxArbToEth, - transactionHandler: ArbToEthTransactionHandler | null, - emitter: typeof defaultEmitter = defaultEmitter, - fetchClaim: typeof getClaim = getClaim, - fetchClaimResolveState: typeof getClaimResolveState = getClaimResolveState, - fetchBlocksAndCheckFinality: typeof getBlocksAndCheckFinality = getBlocksAndCheckFinality -): Promise { +export interface ChallengeAndResolveClaimParams { + epoch: number; + epochPeriod: number; + veaInbox: any; + veaInboxProvider: JsonRpcProvider; + veaOutboxProvider: JsonRpcProvider; + veaOutbox: any; + transactionHandler: ArbToEthTransactionHandler | null; + emitter?: typeof defaultEmitter; + fetchClaim?: typeof getClaim; + fetchClaimResolveState?: typeof getClaimResolveState; + fetchBlocksAndCheckFinality?: typeof getBlocksAndCheckFinality; +} + +export async function challengeAndResolveClaim({ + epoch, + epochPeriod, + veaInbox, + veaInboxProvider, + veaOutboxProvider, + veaOutbox, + transactionHandler, + emitter = defaultEmitter, + fetchClaim = getClaim, + fetchClaimResolveState = getClaimResolveState, + fetchBlocksAndCheckFinality = getBlocksAndCheckFinality, +}: ChallengeAndResolveClaimParams): Promise { const [arbitrumBlock, ethFinalizedBlock, finalityIssueFlagEth] = await fetchBlocksAndCheckFinality( veaOutboxProvider, veaInboxProvider, diff --git a/validator-cli/src/utils/claim.ts b/validator-cli/src/utils/claim.ts index b24efa04..6b7e2262 100644 --- a/validator-cli/src/utils/claim.ts +++ b/validator-cli/src/utils/claim.ts @@ -10,6 +10,12 @@ import { getMessageStatus } from "./arbMsgExecutor"; * @param epoch epoch number of the claim to be fetched * @returns claim type of ClaimStruct */ + +enum ClaimHonestState { + NONE = 0, + CLAIMER = 1, + CHALLENGER = 2, +} const getClaim = async ( veaOutbox: any, veaOutboxProvider: JsonRpcProvider, @@ -52,11 +58,11 @@ const getClaim = async ( if (hashClaim(claim) == claimHash) { return claim; } - claim.honest = 1; // Assuming claimer is honest + claim.honest = ClaimHonestState.CLAIMER; // Assuming claimer is honest if (hashClaim(claim) == claimHash) { return claim; } - claim.honest = 2; // Assuming challenger is honest + claim.honest = ClaimHonestState.CHALLENGER; // Assuming challenger is honest if (hashClaim(claim) == claimHash) { return claim; } @@ -91,10 +97,9 @@ const getClaimResolveState = async ( }; if (sentSnapshotLogs.length === 0) return claimResolveState; - else { - claimResolveState.sendSnapshot.status = true; - claimResolveState.sendSnapshot.txHash = sentSnapshotLogs[0].transactionHash; - } + + claimResolveState.sendSnapshot.status = true; + claimResolveState.sendSnapshot.txHash = sentSnapshotLogs[0].transactionHash; const status = await fetchMessageStatus(sentSnapshotLogs[0].transactionHash, veaInboxProvider, veaOutboxProvider); claimResolveState.execution.status = status; diff --git a/validator-cli/src/utils/epochHandler.ts b/validator-cli/src/utils/epochHandler.ts index 3cb79b52..0d57128a 100644 --- a/validator-cli/src/utils/epochHandler.ts +++ b/validator-cli/src/utils/epochHandler.ts @@ -57,7 +57,6 @@ const getLatestChallengeableEpoch = ( now: number = Date.now(), fetchBridgeConfig: typeof getBridgeConfig = getBridgeConfig ): number => { - // NOTE: Add logic to check if claim was made here or in main function? const { epochPeriod } = fetchBridgeConfig(chainId); return Math.floor(now / 1000 / epochPeriod) - 2; }; diff --git a/validator-cli/src/utils/errors.ts b/validator-cli/src/utils/errors.ts index c87a65a4..7d4256e5 100644 --- a/validator-cli/src/utils/errors.ts +++ b/validator-cli/src/utils/errors.ts @@ -14,14 +14,6 @@ class ClaimNotSetError extends Error { } } -class ContractNotSupportedError extends Error { - constructor(contract: string) { - super(); - this.name = "ContractNotSupportedError"; - this.message = `Unsupported contract type: ${contract}`; - } -} - class TransactionHandlerNotDefinedError extends Error { constructor() { super(); @@ -30,4 +22,4 @@ class TransactionHandlerNotDefinedError extends Error { } } -export { ClaimNotFoundError, ClaimNotSetError, ContractNotSupportedError, TransactionHandlerNotDefinedError }; +export { ClaimNotFoundError, ClaimNotSetError, TransactionHandlerNotDefinedError }; diff --git a/validator-cli/src/watcher.ts b/validator-cli/src/watcher.ts index 9de4dcb0..0d5795ee 100644 --- a/validator-cli/src/watcher.ts +++ b/validator-cli/src/watcher.ts @@ -41,16 +41,17 @@ export const watch = async ( while (i < epochRange.length) { const epoch = epochRange[i]; emitter.emit(BotEvents.CHECKING, epoch); - const updatedTransactions = await checkAndChallengeResolve( + const checkAndChallengeResolveDeps = { epoch, - veaBridge.epochPeriod, - veaInbox as any, + epochPeriod: veaBridge.epochPeriod, + veaInbox, veaInboxProvider, veaOutboxProvider, - veaOutbox as any, - transactionHandlers[epoch], - emitter - ); + veaOutbox, + transactionHandler: transactionHandlers[epoch], + emitter, + }; + const updatedTransactions = await checkAndChallengeResolve(checkAndChallengeResolveDeps); if (updatedTransactions) { transactionHandlers[epoch] = updatedTransactions; } else {