diff --git a/validator-cli/.env.dist b/validator-cli/.env.dist index 0fa990da..d890dc25 100644 --- a/validator-cli/.env.dist +++ b/validator-cli/.env.dist @@ -15,6 +15,9 @@ GNOSIS_AMB_ADDRESS=0x8448E15d0e706C0298dECA99F0b4744030e59d7d VEAOUTBOX_CHAINS=11155111,421611 # vescan subgraph endpoints -VEAINBOX_SUBGRAPH=https://api.studio.thegraph.com/query/user/inbox-arb-sep/version/latest -VEAOUTBOX_SUBGRAPH=https://api.studio.thegraph.com/query/user/outbox-arb-sep/version/latest +VEAINBOX_SUBGRAPH_ARBSEPOLIA=https://api.studio.thegraph.com/query/user/inbox-arb-sep/version/latest +VEAOUTBOX_SUBGRAPH_SEPOLIA=https://api.studio.thegraph.com/query/user/outbox-arb-sep/version/latest +VEAOUTBOX_SUBGRAPH_CHIADO=https://api.studio.thegraph.com/query/user/outbox-arb-sep/version/latest + +GNOSIS_WETH=0x8d74e5e4DA11629537C4575cB0f33b4F0Dfa42EB diff --git a/validator-cli/src/ArbToEth/transactionHandler.test.ts b/validator-cli/src/ArbToEth/transactionHandler.test.ts deleted file mode 100644 index a094baff..00000000 --- a/validator-cli/src/ArbToEth/transactionHandler.test.ts +++ /dev/null @@ -1,533 +0,0 @@ -import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; -import { - ArbToEthTransactionHandler, - ContractType, - Transaction, - MAX_PENDING_CONFIRMATIONS, - TransactionHandlerConstructor, -} from "./transactionHandler"; -import { MockEmitter, defaultEmitter } from "../utils/emitter"; -import { BotEvents } from "../utils/botEvents"; -import { ClaimNotSetError } from "../utils/errors"; -import { getBridgeConfig, Network } from "../consts/bridgeRoutes"; -import { saveSnapshot } from "src/utils/snapshot"; - -describe("ArbToEthTransactionHandler", () => { - const chainId = 11155111; - let epoch: number = 100; - let veaInbox: any; - let veaOutbox: any; - let veaInboxProvider: any; - let veaOutboxProvider: any; - let claim: ClaimStruct = null; - let transactionHandlerParams: TransactionHandlerConstructor; - const mockEmitter = new MockEmitter(); - beforeEach(() => { - veaInboxProvider = { - getTransactionReceipt: jest.fn(), - getBlock: jest.fn(), - }; - veaOutbox = { - estimateGas: { - claim: jest.fn(), - }, - withdrawChallengeDeposit: jest.fn(), - ["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"]: jest.fn(), - claim: jest.fn(), - startVerification: jest.fn(), - verifySnapshot: jest.fn(), - withdrawClaimDeposit: jest.fn(), - }; - veaInbox = { - sendSnapshot: jest.fn(), - saveSnapshot: jest.fn(), - }; - claim = { - stateRoot: "0x1234", - claimer: "0x1234", - timestampClaimed: 1234, - timestampVerification: 0, - blocknumberVerification: 0, - honest: 0, - challenger: "0x1234", - }; - transactionHandlerParams = { - network: Network.TESTNET, - epoch, - veaInbox, - veaOutbox, - veaInboxProvider, - veaOutboxProvider, - emitter: mockEmitter, - claim: null, - }; - }); - - describe("constructor", () => { - it("should create a new TransactionHandler without claim", () => { - const transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); - 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", () => { - transactionHandlerParams.claim = claim; - const transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); - 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; - let mockBroadcastedTimestamp: number = 1000; - beforeEach(() => { - transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); - veaInboxProvider.getBlock.mockResolvedValue({ number: finalityBlock }); - }); - - it("should return 2 if transaction is not final", async () => { - jest.spyOn(mockEmitter, "emit"); - veaInboxProvider.getTransactionReceipt.mockResolvedValue({ - blockNumber: finalityBlock - (MAX_PENDING_CONFIRMATIONS - 1), - }); - const trnx: Transaction = { hash: "0x123456", broadcastedTimestamp: mockBroadcastedTimestamp }; - const status = await transactionHandler.checkTransactionStatus( - trnx, - ContractType.INBOX, - mockBroadcastedTimestamp + 1 - ); - expect(status).toEqual(2); - expect(mockEmitter.emit).toHaveBeenCalledWith(BotEvents.TXN_NOT_FINAL, trnx.hash, 1); - }); - - it("should return 1 if transaction is pending", async () => { - jest.spyOn(mockEmitter, "emit"); - veaInboxProvider.getTransactionReceipt.mockResolvedValue(null); - const trnx: Transaction = { hash: "0x123456", broadcastedTimestamp: mockBroadcastedTimestamp }; - const status = await transactionHandler.checkTransactionStatus( - trnx, - ContractType.INBOX, - mockBroadcastedTimestamp + 1 - ); - expect(status).toEqual(1); - expect(mockEmitter.emit).toHaveBeenCalledWith(BotEvents.TXN_PENDING, trnx.hash); - }); - - it("should return 3 if transaction is final", async () => { - jest.spyOn(mockEmitter, "emit"); - veaInboxProvider.getTransactionReceipt.mockResolvedValue({ - blockNumber: finalityBlock - MAX_PENDING_CONFIRMATIONS, - }); - const trnx: Transaction = { hash: "0x123456", broadcastedTimestamp: mockBroadcastedTimestamp }; - - const status = await transactionHandler.checkTransactionStatus( - trnx, - ContractType.INBOX, - mockBroadcastedTimestamp + 1 - ); - expect(status).toEqual(3); - expect(mockEmitter.emit).toHaveBeenCalledWith(BotEvents.TXN_FINAL, trnx.hash, MAX_PENDING_CONFIRMATIONS); - }); - - it("should return 0 if transaction hash is null", async () => { - const trnx = null; - const status = await transactionHandler.checkTransactionStatus( - trnx, - ContractType.INBOX, - mockBroadcastedTimestamp - ); - expect(status).toEqual(0); - }); - }); - - describe("saveSnapshot", () => { - let transactionHandler: ArbToEthTransactionHandler; - beforeEach(() => { - transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); - }); - - it("should save snapshot and set pending saveSnapshotTxn", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); - veaInbox.saveSnapshot.mockResolvedValue({ hash: "0x1234" }); - await transactionHandler.saveSnapshot(); - expect(veaInbox.saveSnapshot).toHaveBeenCalled(); - expect(transactionHandler.transactions.saveSnapshotTxn).toEqual({ - hash: "0x1234", - broadcastedTimestamp: expect.any(Number), - }); - }); - - it("should not save snapshot if a saveSnapshot transaction is pending", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); - transactionHandler.transactions.saveSnapshotTxn = { hash: "0x1234", broadcastedTimestamp: 1000 }; - await transactionHandler.saveSnapshot(); - expect(veaInbox.saveSnapshot).not.toHaveBeenCalled(); - expect(transactionHandler.transactions.saveSnapshotTxn).toEqual({ - hash: "0x1234", - broadcastedTimestamp: 1000, - }); - }); - }); - - // Happy path (claimer) - describe("makeClaim", () => { - let transactionHandler: ArbToEthTransactionHandler; - const { deposit } = getBridgeConfig(chainId); - beforeEach(() => { - const mockClaim = jest.fn().mockResolvedValue({ hash: "0x1234" }) as any; - mockClaim.estimateGas = jest.fn().mockResolvedValue(BigInt(100000)); - veaOutbox["claim(uint256,bytes32)"] = mockClaim; - - transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); - veaOutbox.claim.mockResolvedValue({ hash: "0x1234" }); - }); - - it("should make a claim and set pending claim trnx", async () => { - // Mock checkTransactionPendingStatus to always return false - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); - - await transactionHandler.makeClaim(claim.stateRoot as string); - - expect(veaOutbox.claim).toHaveBeenCalledWith(epoch, claim.stateRoot, { - gasLimit: BigInt(100000), - value: deposit, - }); - expect(transactionHandler.transactions.claimTxn).toEqual({ - hash: "0x1234", - broadcastedTimestamp: expect.any(Number), - }); - }); - - it("should not make a claim if a claim transaction is pending", async () => { - // Mock checkTransactionPendingStatus to always return true - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); - await transactionHandler.makeClaim(claim.stateRoot as string); - expect(veaOutbox.claim).not.toHaveBeenCalled(); - expect(transactionHandler.transactions.claimTxn).toBeNull(); - }); - }); - - describe("startVerification", () => { - let transactionHandler: ArbToEthTransactionHandler; - const { routeConfig, sequencerDelayLimit } = getBridgeConfig(chainId); - const epochPeriod = routeConfig[Network.TESTNET].epochPeriod; - let startVerificationFlipTime: number; - const mockStartVerification = jest.fn().mockResolvedValue({ hash: "0x1234" }) as any; - mockStartVerification.estimateGas = jest.fn().mockResolvedValue(BigInt(100000)); - beforeEach(() => { - veaOutbox["startVerification(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"] = - mockStartVerification; - veaOutbox.startVerification.mockResolvedValue({ hash: "0x1234" }); - startVerificationFlipTime = Number(claim.timestampClaimed) + epochPeriod + sequencerDelayLimit; - transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); - transactionHandler.claim = claim; - }); - - it("should start verification and set pending startVerificationTxm", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); - - await transactionHandler.startVerification(startVerificationFlipTime); - - expect( - veaOutbox["startVerification(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"].estimateGas - ).toHaveBeenCalledWith(epoch, claim); - expect(veaOutbox.startVerification).toHaveBeenCalledWith(epoch, claim, { gasLimit: BigInt(100000) }); - expect(transactionHandler.transactions.startVerificationTxn).toEqual({ - hash: "0x1234", - broadcastedTimestamp: expect.any(Number), - }); - }); - - it("should not start verification if a startVerification transaction is pending", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); - - await transactionHandler.startVerification(startVerificationFlipTime); - - expect(veaOutbox.startVerification).not.toHaveBeenCalled(); - expect(transactionHandler.transactions.startVerificationTxn).toBeNull(); - }); - - it("should throw an error if claim is not set", async () => { - transactionHandler.claim = null; - await expect(transactionHandler.startVerification(startVerificationFlipTime)).rejects.toThrow(ClaimNotSetError); - }); - - it("should not start verification if timeout has not passed", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); - await transactionHandler.startVerification(startVerificationFlipTime - 1); - expect(veaOutbox.startVerification).not.toHaveBeenCalled(); - expect(transactionHandler.transactions.startVerificationTxn).toBeNull(); - }); - }); - - describe("verifySnapshot", () => { - let verificationFlipTime: number; - let transactionHandler: ArbToEthTransactionHandler; - beforeEach(() => { - const mockVerifySnapshot = jest.fn().mockResolvedValue({ hash: "0x1234" }) as any; - mockVerifySnapshot.estimateGas = jest.fn().mockResolvedValue(BigInt(100000)); - veaOutbox["verifySnapshot(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"] = mockVerifySnapshot; - veaOutbox.verifySnapshot.mockResolvedValue({ hash: "0x1234" }); - transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); - verificationFlipTime = Number(claim.timestampVerification) + getBridgeConfig(chainId).minChallengePeriod; - transactionHandler.claim = claim; - }); - - it("should verify snapshot and set pending verifySnapshotTxn", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); - - await transactionHandler.verifySnapshot(verificationFlipTime); - - expect( - veaOutbox["verifySnapshot(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"].estimateGas - ).toHaveBeenCalledWith(epoch, claim); - expect(veaOutbox.verifySnapshot).toHaveBeenCalledWith(epoch, claim, { gasLimit: BigInt(100000) }); - expect(transactionHandler.transactions.verifySnapshotTxn).toEqual({ - hash: "0x1234", - broadcastedTimestamp: expect.any(Number), - }); - }); - - it("should not verify snapshot if a verifySnapshot transaction is pending", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); - - await transactionHandler.verifySnapshot(verificationFlipTime); - - expect(veaOutbox.verifySnapshot).not.toHaveBeenCalled(); - expect(transactionHandler.transactions.verifySnapshotTxn).toBeNull(); - }); - - it("should throw an error if claim is not set", async () => { - transactionHandler.claim = null; - await expect(transactionHandler.verifySnapshot(verificationFlipTime)).rejects.toThrow(ClaimNotSetError); - }); - - it("should not verify snapshot if timeout has not passed", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); - await transactionHandler.verifySnapshot(verificationFlipTime - 1); - expect(veaOutbox.verifySnapshot).not.toHaveBeenCalled(); - expect(transactionHandler.transactions.verifySnapshotTxn).toBeNull(); - }); - }); - - describe("withdrawClaimDeposit", () => { - let transactionHandler: ArbToEthTransactionHandler; - beforeEach(() => { - const mockWithdrawClaimDeposit = jest.fn().mockResolvedValue({ hash: "0x1234" }) as any; - mockWithdrawClaimDeposit.estimateGas = jest.fn().mockResolvedValue(BigInt(100000)); - veaOutbox["withdrawClaimDeposit(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"] = - mockWithdrawClaimDeposit; - transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); - veaOutbox.withdrawClaimDeposit.mockResolvedValue("0x1234"); - transactionHandler.claim = claim; - }); - - it("should withdraw deposit", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); - veaOutbox.withdrawClaimDeposit.mockResolvedValue({ hash: "0x1234" }); - await transactionHandler.withdrawClaimDeposit(); - expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( - null, - ContractType.OUTBOX, - expect.any(Number) - ); - expect(transactionHandler.transactions.withdrawClaimDepositTxn).toEqual({ - hash: "0x1234", - broadcastedTimestamp: expect.any(Number), - }); - }); - - it("should not withdraw deposit if txn is pending", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); - transactionHandler.transactions.withdrawClaimDepositTxn = { hash: "0x1234", broadcastedTimestamp: 1000 }; - await transactionHandler.withdrawClaimDeposit(); - expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( - transactionHandler.transactions.withdrawClaimDepositTxn, - ContractType.OUTBOX, - expect.any(Number) - ); - }); - - it("should throw an error if claim is not set", async () => { - transactionHandler.claim = null; - await expect(transactionHandler.withdrawClaimDeposit()).rejects.toThrow(ClaimNotSetError); - }); - }); - - // Unhappy path (challenger) - describe("challengeClaim", () => { - let transactionHandler: ArbToEthTransactionHandler; - beforeEach(() => { - transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); - 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(1); - transactionHandler.transactions.challengeTxn = { hash: "0x1234", broadcastedTimestamp: 1000 }; - await transactionHandler.challengeClaim(); - expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( - transactionHandler.transactions.challengeTxn, - ContractType.OUTBOX, - expect.any(Number) - ); - expect( - veaOutbox["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"] - ).not.toHaveBeenCalled(); - }); - - it("should challenge claim", async () => { - 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; - await transactionHandler.challengeClaim(); - expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( - null, - ContractType.OUTBOX, - expect.any(Number) - ); - expect(transactionHandler.transactions.challengeTxn).toEqual({ - hash: "0x1234", - broadcastedTimestamp: expect.any(Number), - }); - }); - - it.todo("should set challengeTxn as completed when txn is final"); - }); - - describe("withdrawChallengeDeposit", () => { - let transactionHandler: ArbToEthTransactionHandler; - beforeEach(() => { - transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); - veaOutbox.withdrawChallengeDeposit.mockResolvedValue("0x1234"); - transactionHandler.claim = claim; - }); - - it("should withdraw deposit", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); - veaOutbox.withdrawChallengeDeposit.mockResolvedValue({ hash: "0x1234" }); - await transactionHandler.withdrawChallengeDeposit(); - expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( - null, - ContractType.OUTBOX, - expect.any(Number) - ); - expect(transactionHandler.transactions.withdrawChallengeDepositTxn).toEqual({ - hash: "0x1234", - broadcastedTimestamp: expect.any(Number), - }); - }); - - it("should not withdraw deposit if txn is pending", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); - transactionHandler.transactions.withdrawChallengeDepositTxn = { hash: "0x1234", broadcastedTimestamp: 1000 }; - await transactionHandler.withdrawChallengeDeposit(); - expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( - transactionHandler.transactions.withdrawChallengeDepositTxn, - ContractType.OUTBOX, - expect.any(Number) - ); - }); - - 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_CHALLENGE_DEPOSIT); - }); - }); - - describe("sendSnapshot", () => { - let transactionHandler: ArbToEthTransactionHandler; - beforeEach(() => { - transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); - transactionHandler.claim = claim; - }); - - it("should send snapshot", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); - veaInbox.sendSnapshot.mockResolvedValue({ hash: "0x1234" }); - await transactionHandler.sendSnapshot(); - expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( - null, - ContractType.INBOX, - expect.any(Number) - ); - expect(transactionHandler.transactions.sendSnapshotTxn).toEqual({ - hash: "0x1234", - broadcastedTimestamp: expect.any(Number), - }); - }); - - it("should not send snapshot if txn is pending", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); - transactionHandler.transactions.sendSnapshotTxn = { hash: "0x1234", broadcastedTimestamp: 1000 }; - await transactionHandler.sendSnapshot(); - expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( - transactionHandler.transactions.sendSnapshotTxn, - ContractType.INBOX, - expect.any(Number) - ); - 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 mockMessageExecutor: any; - let transactionHandler: ArbToEthTransactionHandler; - beforeEach(() => { - mockMessageExecutor = jest.fn(); - transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); - }); - it("should resolve challenged claim", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); - transactionHandler.transactions.sendSnapshotTxn = { hash: "0x1234", broadcastedTimestamp: 1000 }; - mockMessageExecutor.mockResolvedValue({ hash: "0x1234" }); - await transactionHandler.resolveChallengedClaim( - transactionHandler.transactions.sendSnapshotTxn.hash, - mockMessageExecutor - ); - expect(transactionHandler.transactions.executeSnapshotTxn).toEqual({ - hash: "0x1234", - broadcastedTimestamp: expect.any(Number), - }); - }); - - it("should not resolve challenged claim if txn is pending", async () => { - jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); - transactionHandler.transactions.executeSnapshotTxn = { hash: "0x1234", broadcastedTimestamp: 1000 }; - await transactionHandler.resolveChallengedClaim(mockMessageExecutor); - expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( - transactionHandler.transactions.executeSnapshotTxn, - ContractType.OUTBOX, - expect.any(Number) - ); - }); - }); -}); diff --git a/validator-cli/src/ArbToEth/transactionHandler.ts b/validator-cli/src/ArbToEth/transactionHandler.ts deleted file mode 100644 index c4e2e667..00000000 --- a/validator-cli/src/ArbToEth/transactionHandler.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { VeaInboxArbToEth, VeaOutboxArbToEth, VeaOutboxArbToEthDevnet } from "@kleros/vea-contracts/typechain-types"; -import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; -import { JsonRpcProvider } from "@ethersproject/providers"; -import { messageExecutor } from "../utils/arbMsgExecutor"; -import { defaultEmitter } from "../utils/emitter"; -import { BotEvents } from "../utils/botEvents"; -import { ClaimNotSetError } from "../utils/errors"; -import { getBridgeConfig, Network } from "../consts/bridgeRoutes"; - -/** - * @file This file contains the logic for handling transactions from Arbitrum to Ethereum. - * It is responsible for: - * makeClaim() - Make a claim on the VeaOutbox(ETH). - * startVerification() - Start verification for this.epoch in VeaOutbox(ETH). - * verifySnapshot() - Verify snapshot for this.epoch in VeaOutbox(ETH). - * withdrawClaimDeposit() - Withdraw the claim deposit. - * 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). - */ - -export interface TransactionHandlerConstructor { - network: Network; - epoch: number; - veaInbox: VeaInboxArbToEth; - veaOutbox: VeaOutboxArbToEth; - veaInboxProvider: JsonRpcProvider; - veaOutboxProvider: JsonRpcProvider; - emitter: typeof defaultEmitter; - claim: ClaimStruct | null; -} - -export type Transaction = { - hash: string; - broadcastedTimestamp: number; -}; - -export type Transactions = { - claimTxn: Transaction | null; - withdrawClaimDepositTxn: Transaction | null; - startVerificationTxn: Transaction | null; - verifySnapshotTxn: Transaction | null; - challengeTxn: Transaction | null; - withdrawChallengeDepositTxn: Transaction | null; - saveSnapshotTxn: Transaction | null; - sendSnapshotTxn: Transaction | null; - executeSnapshotTxn: Transaction | null; - devnetAdvanceStateTxn?: Transaction | null; -}; - -export enum TransactionStatus { - NOT_MADE = 0, - PENDING = 1, - NOT_FINAL = 2, - FINAL = 3, - EXPIRED = 4, -} - -export enum ContractType { - INBOX = "inbox", - OUTBOX = "outbox", -} - -export const MAX_PENDING_TIME = 5 * 60 * 1000; // 3 minutes -export const MAX_PENDING_CONFIRMATIONS = 10; -const CHAIN_ID = 11155111; - -export class ArbToEthTransactionHandler { - public claim: ClaimStruct | null = null; - public network: Network; - public veaInbox: VeaInboxArbToEth; - public veaOutbox: VeaOutboxArbToEth | VeaOutboxArbToEthDevnet; - public veaInboxProvider: JsonRpcProvider; - public veaOutboxProvider: JsonRpcProvider; - public epoch: number; - public emitter: typeof defaultEmitter; - - public transactions: Transactions = { - claimTxn: null, - withdrawClaimDepositTxn: null, - startVerificationTxn: null, - verifySnapshotTxn: null, - challengeTxn: null, - withdrawChallengeDepositTxn: null, - saveSnapshotTxn: null, - sendSnapshotTxn: null, - executeSnapshotTxn: null, - }; - - constructor({ - network, - epoch, - veaInbox, - veaOutbox, - veaInboxProvider, - veaOutboxProvider, - emitter, - claim, - }: TransactionHandlerConstructor) { - this.network = network; - this.epoch = epoch; - 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 TransactionStatus. - */ - public async checkTransactionStatus( - trnx: Transaction | null, - contract: ContractType, - currentTime: number - ): Promise { - const provider = contract === ContractType.INBOX ? this.veaInboxProvider : this.veaOutboxProvider; - if (trnx == null) { - return TransactionStatus.NOT_MADE; - } - - const receipt = await provider.getTransactionReceipt(trnx.hash); - - if (!receipt) { - this.emitter.emit(BotEvents.TXN_PENDING, trnx.hash); - if (currentTime - trnx.broadcastedTimestamp > MAX_PENDING_TIME) { - this.emitter.emit(BotEvents.TXN_EXPIRED, trnx.hash); - return TransactionStatus.EXPIRED; - } - return TransactionStatus.PENDING; - } - - const currentBlock = await provider.getBlock("latest"); - const confirmations = currentBlock.number - receipt.blockNumber; - - if (confirmations >= MAX_PENDING_CONFIRMATIONS) { - this.emitter.emit(BotEvents.TXN_FINAL, trnx.hash, confirmations); - return TransactionStatus.FINAL; - } - this.emitter.emit(BotEvents.TXN_NOT_FINAL, trnx.hash, MAX_PENDING_CONFIRMATIONS - confirmations); - return TransactionStatus.NOT_FINAL; - } - - /** - * Make a claim on the VeaOutbox(ETH). - * - * @param snapshot - The snapshot to be claimed. - */ - public async makeClaim(stateRoot: string) { - this.emitter.emit(BotEvents.CLAIMING, this.epoch); - const currentTime = Date.now(); - const transactionStatus = await this.checkTransactionStatus( - this.transactions.claimTxn, - ContractType.OUTBOX, - currentTime - ); - if (transactionStatus != TransactionStatus.NOT_MADE && transactionStatus != TransactionStatus.EXPIRED) { - return; - } - const { deposit } = getBridgeConfig(CHAIN_ID); - - const estimateGas = await this.veaOutbox["claim(uint256,bytes32)"].estimateGas(this.epoch, stateRoot, { - value: deposit, - }); - const claimTransaction = await this.veaOutbox.claim(this.epoch, stateRoot, { - value: deposit, - gasLimit: estimateGas, - }); - this.emitter.emit(BotEvents.TXN_MADE, claimTransaction.hash, this.epoch, "Claim"); - this.transactions.claimTxn = { - hash: claimTransaction.hash, - broadcastedTimestamp: currentTime, - }; - } - - /** - * Start verification for this.epoch in VeaOutbox(ETH). - */ - public async startVerification(currentTimestamp: number) { - this.emitter.emit(BotEvents.STARTING_VERIFICATION, this.epoch); - if (this.claim == null) { - throw new ClaimNotSetError(); - } - const currentTime = Date.now(); - const transactionStatus = await this.checkTransactionStatus( - this.transactions.startVerificationTxn, - ContractType.OUTBOX, - currentTime - ); - if (transactionStatus != TransactionStatus.NOT_MADE && transactionStatus != TransactionStatus.EXPIRED) { - return; - } - - const bridgeConfig = getBridgeConfig(CHAIN_ID); - const timeOver = - currentTimestamp - - Number(this.claim.timestampClaimed) - - bridgeConfig.sequencerDelayLimit - - bridgeConfig.routeConfig[this.network].epochPeriod; - - if (timeOver < 0) { - this.emitter.emit(BotEvents.VERIFICATION_CANT_START, this.epoch, -1 * timeOver); - return; - } - const estimateGas = await this.veaOutbox[ - "startVerification(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))" - ].estimateGas(this.epoch, this.claim); - const startVerifTrx = await this.veaOutbox.startVerification(this.epoch, this.claim, { gasLimit: estimateGas }); - this.emitter.emit(BotEvents.TXN_MADE, startVerifTrx.hash, this.epoch, "Start Verification"); - this.transactions.startVerificationTxn = { - hash: startVerifTrx.hash, - broadcastedTimestamp: currentTime, - }; - } - - /** - * Verify snapshot for this.epoch in VeaOutbox(ETH). - */ - public async verifySnapshot(currentTimestamp: number) { - this.emitter.emit(BotEvents.VERIFYING_SNAPSHOT, this.epoch); - if (this.claim == null) { - throw new ClaimNotSetError(); - } - const currentTime = Date.now(); - const transactionStatus = await this.checkTransactionStatus( - this.transactions.verifySnapshotTxn, - ContractType.OUTBOX, - currentTime - ); - if (transactionStatus != TransactionStatus.NOT_MADE && transactionStatus != TransactionStatus.EXPIRED) { - return; - } - const bridgeConfig = getBridgeConfig(CHAIN_ID); - const timeLeft = currentTimestamp - Number(this.claim.timestampVerification) - bridgeConfig.minChallengePeriod; - // Claim not resolved yet, check if we can verifySnapshot - if (timeLeft < 0) { - this.emitter.emit(BotEvents.CANT_VERIFY_SNAPSHOT, this.epoch, -1 * timeLeft); - return; - } - // Estimate gas for verifySnapshot - const estimateGas = await this.veaOutbox[ - "verifySnapshot(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))" - ].estimateGas(this.epoch, this.claim); - const claimTransaction = await this.veaOutbox.verifySnapshot(this.epoch, this.claim, { - gasLimit: estimateGas, - }); - this.emitter.emit(BotEvents.TXN_MADE, claimTransaction.hash, this.epoch, "Verify Snapshot"); - this.transactions.verifySnapshotTxn = { - hash: claimTransaction.hash, - broadcastedTimestamp: currentTime, - }; - } - - /** - * Withdraw the claim deposit from VeaOutbox(ETH). - * - */ - public async withdrawClaimDeposit() { - this.emitter.emit(BotEvents.WITHDRAWING_CLAIM_DEPOSIT, this.epoch); - if (this.claim == null) { - throw new ClaimNotSetError(); - } - const currentTime = Date.now(); - const transactionStatus = await this.checkTransactionStatus( - this.transactions.withdrawClaimDepositTxn, - ContractType.OUTBOX, - currentTime - ); - if (transactionStatus != TransactionStatus.NOT_MADE && transactionStatus != TransactionStatus.EXPIRED) { - return; - } - const estimateGas = await this.veaOutbox[ - "withdrawClaimDeposit(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))" - ].estimateGas(this.epoch, this.claim); - const withdrawTxn = await this.veaOutbox.withdrawClaimDeposit(this.epoch, this.claim, { - gasLimit: estimateGas, - }); - this.emitter.emit(BotEvents.TXN_MADE, withdrawTxn.hash, this.epoch, "Withdraw Deposit"); - this.transactions.withdrawClaimDepositTxn = { - hash: withdrawTxn.hash, - broadcastedTimestamp: currentTime, - }; - } - - /** - * Challenge claim for this.epoch in VeaOutbox(ETH). - * - */ - public async challengeClaim() { - this.emitter.emit(BotEvents.CHALLENGING); - if (!this.claim) { - throw new ClaimNotSetError(); - } - const currentTime = Date.now(); - const transactionStatus = await this.checkTransactionStatus( - this.transactions.challengeTxn, - ContractType.OUTBOX, - currentTime - ); - if (transactionStatus != TransactionStatus.NOT_MADE && transactionStatus != TransactionStatus.EXPIRED) { - return; - } - const { deposit } = getBridgeConfig(CHAIN_ID); - const gasEstimate: bigint = await this.veaOutbox[ - "challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))" - ].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 - - // 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: deposit, - gasLimit: gasEstimate, - }); - this.emitter.emit(BotEvents.TXN_MADE, challengeTxn.hash, this.epoch, "Challenge"); - this.transactions.challengeTxn = { - hash: challengeTxn.hash, - broadcastedTimestamp: currentTime, - }; - } - - /** - * Withdraw the challenge deposit from VeaOutbox(ETH). - * - */ - public async withdrawChallengeDeposit() { - this.emitter.emit(BotEvents.WITHDRAWING_CHALLENGE_DEPOSIT); - if (!this.claim) { - throw new ClaimNotSetError(); - } - const currentTime = Date.now(); - const transactionStatus = await this.checkTransactionStatus( - this.transactions.withdrawChallengeDepositTxn, - ContractType.OUTBOX, - currentTime - ); - if (transactionStatus != TransactionStatus.NOT_MADE && transactionStatus != TransactionStatus.EXPIRED) { - 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 = { - hash: withdrawDepositTxn.hash, - broadcastedTimestamp: currentTime, - }; - } - - /** - * Save a snapshot on VeaInbox(Arb). - */ - public async saveSnapshot() { - this.emitter.emit(BotEvents.SAVING_SNAPSHOT, this.epoch); - const currentTime = Date.now(); - const transactionStatus = await this.checkTransactionStatus( - this.transactions.saveSnapshotTxn, - ContractType.INBOX, - currentTime - ); - if (transactionStatus != TransactionStatus.NOT_MADE && transactionStatus != TransactionStatus.EXPIRED) { - return; - } - const saveSnapshotTxn = await this.veaInbox.saveSnapshot(); - this.emitter.emit(BotEvents.TXN_MADE, saveSnapshotTxn.hash, this.epoch, "Save Snapshot"); - this.transactions.saveSnapshotTxn = { - hash: saveSnapshotTxn.hash, - broadcastedTimestamp: currentTime, - }; - } - - /** - * 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(); - } - const currentTime = Date.now(); - const transactionStatus = await this.checkTransactionStatus( - this.transactions.sendSnapshotTxn, - ContractType.INBOX, - currentTime - ); - if (transactionStatus != TransactionStatus.NOT_MADE && transactionStatus != TransactionStatus.EXPIRED) { - 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 = { - hash: sendSnapshotTxn.hash, - broadcastedTimestamp: currentTime, - }; - } - - /** - * Execute a sent snapshot to resolve dispute in VeaOutbox (ETH). - */ - public async resolveChallengedClaim(sendSnapshotTxn: string, executeMsg: typeof messageExecutor = messageExecutor) { - this.emitter.emit(BotEvents.EXECUTING_SNAPSHOT, this.epoch); - const currentTime = Date.now(); - const transactionStatus = await this.checkTransactionStatus( - this.transactions.executeSnapshotTxn, - ContractType.OUTBOX, - currentTime - ); - if (transactionStatus != TransactionStatus.NOT_MADE && transactionStatus != TransactionStatus.EXPIRED) { - return; - } - const msgExecuteTrnx = await executeMsg(sendSnapshotTxn, this.veaInboxProvider, this.veaOutboxProvider); - this.emitter.emit(BotEvents.TXN_MADE, msgExecuteTrnx.hash, this.epoch, "Execute Snapshot"); - this.transactions.executeSnapshotTxn = { - hash: msgExecuteTrnx.hash, - broadcastedTimestamp: currentTime, - }; - } -} diff --git a/validator-cli/src/ArbToEth/transactionHandlerDevnet.ts b/validator-cli/src/ArbToEth/transactionHandlerDevnet.ts deleted file mode 100644 index 0c1c7781..00000000 --- a/validator-cli/src/ArbToEth/transactionHandlerDevnet.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { VeaOutboxArbToEthDevnet } from "@kleros/vea-contracts/typechain-types"; -import { - ArbToEthTransactionHandler, - ContractType, - TransactionStatus, - Transactions, - Transaction, - TransactionHandlerConstructor, -} from "./transactionHandler"; -import { BotEvents } from "../utils/botEvents"; -import { getBridgeConfig } from "../consts/bridgeRoutes"; - -type DevnetTransactions = Transactions & { - devnetAdvanceStateTxn: Transaction | null; -}; - -const CHAIN_ID = 11155111; - -export class ArbToEthDevnetTransactionHandler extends ArbToEthTransactionHandler { - public veaOutboxDevnet: VeaOutboxArbToEthDevnet; - public transactions: DevnetTransactions = { - claimTxn: null, - withdrawClaimDepositTxn: null, - startVerificationTxn: null, - verifySnapshotTxn: null, - challengeTxn: null, - withdrawChallengeDepositTxn: null, - saveSnapshotTxn: null, - sendSnapshotTxn: null, - executeSnapshotTxn: null, - devnetAdvanceStateTxn: null, - }; - constructor({ - veaInbox, - veaOutbox, - veaInboxProvider, - veaOutboxProvider, - epoch, - emitter, - }: TransactionHandlerConstructor) { - super({ - epoch, - veaInbox, - veaOutbox, - veaInboxProvider, - veaOutboxProvider, - emitter, - } as TransactionHandlerConstructor); - this.veaOutboxDevnet = veaOutbox as VeaOutboxArbToEthDevnet; - } - public async devnetAdvanceState(stateRoot: string): Promise { - this.emitter.emit(BotEvents.ADV_DEVNET, this.epoch); - const currentTime = Date.now(); - const transactionStatus = await this.checkTransactionStatus( - this.transactions.devnetAdvanceStateTxn, - ContractType.OUTBOX, - currentTime - ); - if (transactionStatus != TransactionStatus.NOT_MADE && transactionStatus != TransactionStatus.EXPIRED) { - return; - } - const deposit = getBridgeConfig(CHAIN_ID).deposit; - const startVerifTrx = await this.veaOutboxDevnet.devnetAdvanceState(this.epoch, stateRoot, { - value: deposit, - }); - this.emitter.emit(BotEvents.TXN_MADE, startVerifTrx.hash, this.epoch, "Advance Devnet State"); - this.transactions.devnetAdvanceStateTxn = { - hash: startVerifTrx.hash, - broadcastedTimestamp: currentTime, - }; - } -} diff --git a/validator-cli/src/ArbToEth/validator.ts b/validator-cli/src/ArbToEth/validator.ts deleted file mode 100644 index 881ed6e0..00000000 --- a/validator-cli/src/ArbToEth/validator.ts +++ /dev/null @@ -1,109 +0,0 @@ -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"; -import { Network } from "../consts/bridgeRoutes"; -import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; - -// https://github.com/prysmaticlabs/prysm/blob/493905ee9e33a64293b66823e69704f012b39627/config/params/mainnet_config.go#L103 -const secondsPerSlotEth = 12; - -export interface ChallengeAndResolveClaimParams { - claim: ClaimStruct; - 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({ - claim, - epoch, - epochPeriod, - veaInbox, - veaInboxProvider, - veaOutboxProvider, - veaOutbox, - transactionHandler, - emitter = defaultEmitter, - fetchClaimResolveState = getClaimResolveState, - fetchBlocksAndCheckFinality = getBlocksAndCheckFinality, -}: ChallengeAndResolveClaimParams): Promise { - if (!claim) { - emitter.emit(BotEvents.NO_CLAIM, epoch); - return null; - } - 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"; - if (!transactionHandler) { - transactionHandler = new ArbToEthTransactionHandler({ - network: Network.TESTNET, // Hardcoded as TESTNET & MAINNET have same contracts - epoch, - veaInbox, - veaOutbox, - veaInboxProvider, - veaOutboxProvider, - emitter: 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/ArbToGnosis/watcherArbToGnosis.ts b/validator-cli/src/ArbToGnosis/watcherArbToGnosis.ts deleted file mode 100644 index 890382e6..00000000 --- a/validator-cli/src/ArbToGnosis/watcherArbToGnosis.ts +++ /dev/null @@ -1,1069 +0,0 @@ -import { - getVeaOutboxArbToGnosis, - getVeaInboxArbToGnosis, - getWETH, - getWallet, - getVeaRouterArbToGnosis, - getAMB, -} from "../utils/ethers"; -import { JsonRpcProvider } from "@ethersproject/providers"; -import { Wallet } from "@ethersproject/wallet"; -import { ChildToParentMessageStatus, ChildTransactionReceipt, 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 { ethers, ContractTransactionResponse } from "ethers"; -import { Block, Log } from "@ethersproject/abstract-provider"; -import { SequencerInbox } from "@arbitrum/sdk/dist/lib/abi/SequencerInbox"; -import { NodeInterface } from "@arbitrum/sdk/dist/lib/abi/NodeInterface"; -import { - IAMB, - RouterArbToGnosis, - VeaInboxArbToGnosis, - VeaOutboxArbToGnosis, -} from "@kleros/vea-contracts/typechain-types"; -import { messageExecutor } from "../utils/arbMsgExecutor"; - -require("dotenv").config(); - -interface ChallengeProgress { - challenge: { - txHash: string; - timestamp: number; - finalized: boolean; - status: "mined" | "pending" | "none"; - }; - snapshot: { - txHash: string; - timestamp: number; - finalized: boolean; - status: "mined" | "pending" | "none"; - }; - - L2toL1Message: { - status: ChildToParentMessageStatus; - }; - route: { - txHash: string; - timestamp: number; - finalized: boolean; - status: "mined" | "pending" | "none"; - }; - AMB: { - ambMessageId: string; - txHash: string; - timestamp: number; - finalized: boolean; - status: "mined" | "pending" | "none"; - }; - withdrawal: { - txHash: string; - timestamp: number; - finalized: boolean; - status: "mined" | "pending" | "none"; - }; - status: - | "Unclaimed" - | "Claimed" - | "Challenged" - | "ChallengePending" - | "SnapshotSent" - | "SnapshotPending" - | "Routed" - | "RoutePending" - | "AMBMessageSent" - | "AMBMessagePending" - | "WithdrawalPending" - | "Completed"; -} -// https://github.com/prysmaticlabs/prysm/blob/493905ee9e33a64293b66823e69704f012b39627/config/params/mainnet_config.go#L103 -const slotsPerEpochEth = 32; -const secondsPerSlotEth = 12; -// https://github.com/gnosischain/prysm-launch/blob/4163b9fddd57bcc07293d9a6d0723baec1fb0675/config/config.yml#L72 -const slotsPerEpochGnosis = 16; -const secondsPerSlotGnosis = 5; - -const veaOutboxAddress = process.env.VEAOUTBOX_ARB_TO_GNOSIS_ADDRESS; -const veaInboxAddress = process.env.VEAINBOX_ARB_TO_GNOSIS_ADDRESS; -const veaRouterAddress = process.env.VEAROUTER_ARB_TO_GNOSIS_ADDRESS; -const gnosisAMBAddress = process.env.GNOSIS_AMB_ADDRESS; - -const challenges = new Map(); - -const watch = async () => { - // connect to RPCs - const providerEth = new JsonRpcProvider(process.env.RPC_ETH); - const providerGnosis = new JsonRpcProvider(process.env.RPC_GNOSIS); - const providerArb = new JsonRpcProvider(process.env.RPC_ARB); - - const watcherAddress = getWallet(process.env.PRIVATE_KEY, process.env.RPC_GNOSIS).address; - - // use typechain generated contract factories for vea outbox and inbox - const veaOutbox = getVeaOutboxArbToGnosis(veaOutboxAddress, process.env.PRIVATE_KEY, process.env.RPC_GNOSIS); - const veaInbox = getVeaInboxArbToGnosis(veaInboxAddress, process.env.PRIVATE_KEY, process.env.RPC_ARB); - const veaRouter = getVeaRouterArbToGnosis(veaRouterAddress, process.env.PRIVATE_KEY, process.env.RPC_ETH); - const amb = getAMB(gnosisAMBAddress, process.env.PRIVATE_KEY, process.env.RPC_GNOSIS); - - const wethAddress = (await retryOperation(() => veaOutbox.weth(), 1000, 10)) as string; - const weth = getWETH(wethAddress, process.env.PRIVATE_KEY, process.env.RPC_GNOSIS); - const balance = (await retryOperation(() => weth.balanceOf(watcherAddress), 1000, 10)) as bigint; - const allowance = (await retryOperation(() => weth.allowance(watcherAddress, veaOutboxAddress), 1000, 10)) as bigint; - - // 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] as bigint); - - // get vea outbox params - const deposit = (await retryOperation(() => veaOutbox.deposit(), 1000, 10)) as bigint; - const epochPeriod = Number(await retryOperation(() => veaOutbox.epochPeriod(), 1000, 10)); - const sequencerDelayLimit = Number(await retryOperation(() => veaOutbox.sequencerDelayLimit(), 1000, 10)); - - const inactive = balance < deposit; - if (inactive) { - console.error( - "insufficient weth balance to run an active watcher. Try bridging eth to gnosis with https://omni.gnosischain.com/bridge" - ); - console.log("running watcher in passive mode (no challenges)"); - } - - if (BigInt(allowance.toString()) < ethers.MaxUint256 / BigInt(2)) { - console.log("setting infinite weth approval to vea outbox to prepare to challenge. . ."); - const approvalTxn = (await retryOperation( - () => weth.approve(veaOutboxAddress, ethers.MaxUint256), - 1000, - 10 - )) as ContractTransactionResponse; - await approvalTxn.wait(); - console.log("weth approval txn hash: " + approvalTxn.hash); - } - - // * - // calculate epoch range to check claims on Gnosis - // * - - // Finalized Gnosis block provides an 'anchor point' for the vea epochs in the outbox that are claimable - const blockFinalizedGnosis: Block = (await retryOperation( - () => providerGnosis.getBlock("finalized"), - 1000, - 10 - )) as Block; - - const coldStartBacklog = 7 * 24 * 60 * 60; // when starting the watcher, specify an extra backlog to check - const sevenDaysInSeconds = 7 * 24 * 60 * 60; - // 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 veaEpochOutboxWacthLowerBound = - Math.floor((blockFinalizedGnosis.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 - veaEpochOutboxWacthLowerBound + 1; - const veaEpochOutboxCheckClaimsRangeArray: number[] = new Array(veaEpochOutboxRange) - .fill(veaEpochOutboxWacthLowerBound) - .map((el, i) => el + i); - // epoch => (minChallengePeriodDeadline, maxPriorityFeePerGas, maxFeePerGas) - - console.log( - "cold start: checking past claim history from epoch " + - veaEpochOutboxCheckClaimsRangeArray[0] + - " to the current claimable epoch " + - veaEpochOutboxCheckClaimsRangeArray[veaEpochOutboxCheckClaimsRangeArray.length - 1] - ); - - while (true) { - // returns the most recent finalized arbBlock found on Ethereum and info about finality issues on Eth and Gnosis - // if L1 is experiencing finalization problems, returns the latest arbBlock found in the latest L1 block - const [blockArbFoundOnL1, blockFinalizedEth, finalityIssueFlagEth, blockFinalizedGnosis, finalityIssueFlagGnosis] = - await getBlocksAndCheckFinality(providerEth, providerGnosis, 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(blockFinalizedGnosis.timestamp / epochPeriod) - 2; - - const timeLocal = Math.floor(Date.now() / 1000); - const timeGnosis = finalityIssueFlagGnosis ? timeLocal : blockFinalizedGnosis.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(timeGnosis / 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. . ."); - await wait(timeToNextEpoch); - } - - for (let index = 0; index < veaEpochOutboxCheckClaimsRangeArray.length; index++) { - const veaEpochOutboxCheck = veaEpochOutboxCheckClaimsRangeArray[index]; - console.log("checking claim for epoch " + veaEpochOutboxCheck); - // if L1 experiences finality failure, we use the latest block - const blockTagGnosis = finalityIssueFlagGnosis ? "latest" : "finalized"; - const claimHash = (await retryOperation( - () => veaOutbox.claimHashes(veaEpochOutboxCheck, { blockTag: blockTagGnosis }), - 1000, - 10 - )) as string; - - // no claim - if (claimHash == ethers.ZeroHash) { - // 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--; - if (challenges.has(veaEpochOutboxCheck)) challenges.delete(veaEpochOutboxCheck); - 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 - - console.log("claim exists for epoch " + veaEpochOutboxCheck); - - let blockNumberOutboxLowerBound: number; - // to query event perpformantly, we limit the block range with the heuristic that. delta blocknumber <= delta timestamp / secondsPerSlot - if (veaEpochOutboxCheck <= veaEpochOutboxClaimableFinalized) { - blockNumberOutboxLowerBound = - blockFinalizedGnosis.number - - Math.ceil( - ((veaEpochOutboxClaimableFinalized - veaEpochOutboxCheck + 2) * epochPeriod) / secondsPerSlotGnosis - ); - } else { - blockNumberOutboxLowerBound = blockFinalizedGnosis.number - Math.ceil(epochPeriod / secondsPerSlotGnosis); - } - - // get claim data - const logClaimed: Log = ( - await retryOperation( - () => - veaOutbox.queryFilter( - veaOutbox.filters.Claimed(null, veaEpochOutboxCheck, null), - blockNumberOutboxLowerBound, - blockTagGnosis - ), - 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"); - continue; - } - console.log("claim " + veaEpochOutboxCheck + " is challengable"); - let claim = await getClaimForEpoch( - veaEpochOutboxCheck, - veaOutbox, - providerGnosis, - blockNumberOutboxLowerBound - ); - if (claim === null) { - console.error("Error finding claim for epoch " + veaEpochOutboxCheck); - continue; - } - console.log(veaEpochOutboxCheck, "claim found ", { claim }); - const previousProgress = challenges.get(veaEpochOutboxCheck) || ({} as any); - let challengeProgress = await reconstructChallengeProgress( - veaEpochOutboxCheck, - veaOutbox, - veaInbox, - veaRouter, - providerGnosis, - providerArb, - providerEth, - blockNumberOutboxLowerBound, - amb, - previousProgress - ); - challenges.set(veaEpochOutboxCheck, challengeProgress); - console.log( - "challenge progess for epoch " + veaEpochOutboxCheck + " is " + JSON.stringify(challengeProgress) - ); - //TODO : check profitablity of the whole dispute resolution - if (claim.challenger == ethers.ZeroAddress) { - if (challengeProgress?.challenge.status == "pending") continue; - const txnChallenge = (await retryOperation( - () => veaOutbox.challenge(veaEpochOutboxCheck, claim), - 1000, - 10 - )) as ContractTransactionResponse; - console.log("Epoch " + veaEpochOutboxCheck + " challenged with txn " + txnChallenge.hash); - challengeProgress.challenge = { - status: "pending", - txHash: txnChallenge.hash, - timestamp: 0, - finalized: false, - }; - challengeProgress.status = "ChallengePending"; - challenges.set(veaEpochOutboxCheck, challengeProgress); - continue; - } - if (claim?.challenger === watcherAddress) { - if (challengeProgress.challenge.finalized) { - console.log(veaEpochInboxFinalized, "A finalized challenge made by bot detected"); - if (!challengeProgress?.snapshot.txHash) { - const txnSendSnapshot = (await retryOperation( - () => veaInbox.sendSnapshot(veaEpochOutboxCheck, 200000, claim), // execute transaction required around 142000 gas so we set gas limit to 200000 - 1000, - 10 - )) as ContractTransactionResponse; - console.log("Epoch " + veaEpochOutboxCheck + " sendSnapshot called with txn " + txnSendSnapshot.hash); - challengeProgress.snapshot = { - status: "pending", - txHash: txnSendSnapshot.hash, - timestamp: 0, - finalized: false, - }; - challengeProgress.status = "SnapshotPending"; - challenges.set(veaEpochOutboxCheck, challengeProgress); - } - } - if ( - challengeProgress.snapshot.finalized && - challengeProgress.snapshot.timestamp <= Math.floor(Date.now() / 1000) - sevenDaysInSeconds - ) { - if (challengeProgress.L2toL1Message.status === ChildToParentMessageStatus.CONFIRMED) { - console.log("epoch " + veaEpochOutboxCheck + " L2 to L1 transaction ready to be executed"); - await messageExecutor(challengeProgress.snapshot.txHash, process.env.RPC_ARB, process.env.RPC_ETH); - } else if (challengeProgress.L2toL1Message.status === ChildToParentMessageStatus.UNCONFIRMED) - console.log("epoch " + veaEpochOutboxCheck + " L2 to L1 transaction waiting for confirmation"); - } - if (challengeProgress.route.finalized && challengeProgress.AMB.finalized) { - const txnWithdrawalDeposit = (await retryOperation( - () => veaOutbox.withdrawChallengeDeposit(veaEpochOutboxCheck, claim), - 1000, - 10 - )) as ContractTransactionResponse; - - if (txnWithdrawalDeposit.hash) { - console.log( - "Epoch " + veaEpochOutboxCheck + " Withdrawal called with txn " + txnWithdrawalDeposit.hash - ); - challengeProgress.withdrawal = { - status: "pending", - txHash: txnWithdrawalDeposit.hash, - timestamp: 0, - finalized: false, - }; - challengeProgress.status = "WithdrawalPending"; - challenges.set(veaEpochOutboxCheck, challengeProgress); - } - } - } - } 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); - console.log(reason); - return wait(delay) - .then(retryOperation.bind(null, operation, delay, retries - 1)) - .then(resolve) - .catch(reject); - } - return reject(reason); - }); - }); - -const getBlocksAndCheckFinality = async ( - EthProvider: JsonRpcProvider, - GnosisProvider: JsonRpcProvider, - ArbProvider: JsonRpcProvider, - sequencer: SequencerInbox, - maxDelaySeconds: number -): Promise<[Block, Block, Boolean, 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 blockFinalizedGnosis = (await retryOperation(() => GnosisProvider.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 - const maxFinalityTimeSecondsGnosis = (slotsPerEpochGnosis * 3 - 1) * secondsPerSlotGnosis; // finalization after 2 justified epochs - - let finalityIssueFlagArb = false; - let finalityIssueFlagEth = false; - let finalityIssueFlagGnosis = false; - // check latest arb block to see if there are any sequencer issues - let blockLatestArb = (await retryOperation(() => ArbProvider.getBlock("latest"), 1000, 10)) as Block; - let blockoldArb = (await retryOperation(() => ArbProvider.getBlock(blockLatestArb.number - 100), 1000, 10)) as Block; - const arbAverageBlockTime = (blockLatestArb.timestamp - blockoldArb.timestamp) / 100; - const maxDelayInSeconds = 7 * 24 * 60 * 60; // 7 days in seconds - 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; - } - - // Note: Using last finalized block as a proxy for the latest finalized epoch - // Using a BeaconChain RPC would be more accurate - if (localTimeSeconds - blockFinalizedGnosis.timestamp > maxFinalityTimeSecondsGnosis + finalityBuffer) { - console.error("Gnosis is not finalizing"); - finalityIssueFlagGnosis = 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 - const blockArbitrum = finalityIssueFlagArb || finalityIssueFlagEth ? blockLatestArb : blockFinalizedArb; - - return [blockArbitrum, blockFinalizedEth, finalityIssueFlagEth, blockFinalizedGnosis, finalityIssueFlagGnosis]; -}; - -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) => { - console.error("Error finding batch containing block:", JSON.parse(JSON.stringify(e)).error.body); - })) 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 | undefined, number | undefined]> => { - 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" }) - .catch(console.error)) as any; - return [result.batch.toNumber(), high]; -}; - -async function getClaimForEpoch( - epoch: number, - veaOutbox: VeaOutboxArbToGnosis, - providerGnosis: JsonRpcProvider, - blockNumberOutboxLowerBound: number -) { - // Get the claim hash from the contract - const claimHash = (await retryOperation(() => veaOutbox.claimHashes(epoch), 1000, 10)) as any; - - // If there's no claim, return null - if (claimHash === ethers.ZeroHash) { - return null; - } - - // Query for the Claimed event - const claimedFilter = veaOutbox.filters.Claimed(null, epoch, null); - const claimedEvents = (await retryOperation( - () => - providerGnosis.getLogs({ - ...claimedFilter, - fromBlock: blockNumberOutboxLowerBound, - toBlock: "latest", - }), - 1000, - 10 - )) as any; - - // If we can't find the event, something is wrong - if (claimedEvents.length === 0) { - console.error(`No Claimed event found for epoch ${epoch}`); - return null; - } - - // Parse the event data - const event = veaOutbox.interface.parseLog(claimedEvents[0]); - - const timestampClaimed = ( - (await retryOperation(() => providerGnosis.getBlock(claimedEvents[0].blockNumber), 1000, 10)) as any - ).timestamp; - // Reconstruct the basic claim struct - let claim = { - stateRoot: event.args._stateRoot, - claimer: event.args._claimer, - timestampClaimed: timestampClaimed, - timestampVerification: 0, - blocknumberVerification: 0, - honest: 0, // 0 for None, 1 for Claimer, 2 for Challenger - challenger: ethers.ZeroAddress, - }; - let other = {} as any; - let calculatedHash = hashClaim(claim); - if (calculatedHash == claimHash) return claim; - - // Check for Challenged event - const challengedFilter = veaOutbox.filters.Challenged(epoch, null); - const challengedEvents = (await retryOperation( - () => - providerGnosis.getLogs({ - ...challengedFilter, - fromBlock: claimedEvents[0].blockNumber, - toBlock: "latest", - }), - 1000, - 10 - )) as any; - - if (challengedEvents.length > 0) { - const challengeEvent = veaOutbox.interface.parseLog(challengedEvents[challengedEvents.length - 1]); - claim.challenger = challengeEvent.args._challenger; - other.challengeBlock = challengedEvents[0].blockNumber; - } - - calculatedHash = hashClaim(claim); - if (calculatedHash == claimHash) return claim; - - // Check for VerificationStarted event - const verificationStartedFilter = veaOutbox.filters.VerificationStarted(epoch); - - const verificationStartedEvents = (await retryOperation( - () => - providerGnosis.getLogs({ - ...verificationStartedFilter, - fromBlock: blockNumberOutboxLowerBound, - toBlock: "latest", - }), - 1000, - 10 - )) as any; - - if (verificationStartedEvents.length > 0) { - const verificationBlock = await providerGnosis.getBlock( - verificationStartedEvents[verificationStartedEvents.length - 1].blockNumber - ); - claim.timestampVerification = verificationBlock.timestamp; - claim.blocknumberVerification = verificationBlock.number; - } - - calculatedHash = hashClaim(claim); - if (calculatedHash == claimHash) return claim; - - const claimBridgerHonest = hashClaim({ ...claim, honest: 1 }); - const claimChallengerHonest = hashClaim({ ...claim, honest: 2 }); - - if (claimBridgerHonest === claimHash) return { ...claim, honest: 1 }; - if (claimChallengerHonest === claimHash) return { ...claim, honest: 2 }; - return null; -} - -function needsRetry(current: ChallengeProgress, previous: ChallengeProgress | undefined): boolean { - if (!previous) return false; - - // Check if any pending transaction has been pending too long - const MAX_PENDING_TIME = 3600; // 1 hour - const now = Math.floor(Date.now() / 1000); - - // Helper to check if a state needs retry - const stateNeedsRetry = (state) => state.status === "pending" && now - state.timestamp > MAX_PENDING_TIME; - - return ( - stateNeedsRetry(current.challenge) || - stateNeedsRetry(current.snapshot) || - stateNeedsRetry(current.route) || - stateNeedsRetry(current.AMB) - ); -} - -async function reconstructChallengeProgress( - epoch: number, - veaOutbox: VeaOutboxArbToGnosis, - veaInbox: VeaInboxArbToGnosis, - router: RouterArbToGnosis, - providerGnosis: JsonRpcProvider, - providerArb: JsonRpcProvider, - providerEth: JsonRpcProvider, - blockNumberOutboxLowerBound: number, - amb: IAMB, - previousProgress?: ChallengeProgress -): Promise { - const emptyState = { - txHash: "", - timestamp: 0, - blockNumber: 0, - finalized: false, - status: "none" as const, - }; - - const challengeProgress: ChallengeProgress = { - challenge: { ...emptyState }, - snapshot: { ...emptyState }, - route: { ...emptyState }, - AMB: { - ...emptyState, - ambMessageId: "", - }, - withdrawal: { ...emptyState }, - L2toL1Message: { - status: ChildToParentMessageStatus.UNCONFIRMED, - }, - status: "Unclaimed", - }; - - // Get current and finalized blocks for all chains with retry - const [gnosisFinalized, gnosisLatest] = await Promise.all([ - retryOperation(() => providerGnosis.getBlock("finalized"), 1000, 10) as any, - retryOperation(() => providerGnosis.getBlock("latest"), 1000, 10) as any, - ]); - - // Check for claim with retry - const claimedFilter = veaOutbox.filters.Claimed(null, epoch, null); - const claimedLogs = (await retryOperation( - () => - providerGnosis.getLogs({ - ...claimedFilter, - fromBlock: blockNumberOutboxLowerBound, - toBlock: gnosisFinalized.number, - }), - 1000, - 10 - )) as any; - - if (claimedLogs.length === 0) { - return challengeProgress; - } - - challengeProgress.status = "Claimed"; - - // Check challenge status with retry - if (previousProgress?.challenge?.status === "pending") { - const tx = (await retryOperation( - () => providerGnosis.getTransaction(previousProgress.challenge.txHash), - 1000, - 10 - )) as any; - if (tx) { - if (!tx.blockNumber) { - return previousProgress; - } - } - } - - const challengedFilter = veaOutbox.filters.Challenged(epoch, null); - const challengeLogs = (await retryOperation( - () => - providerGnosis.getLogs({ - ...challengedFilter, - fromBlock: claimedLogs[0].blockNumber, - toBlock: "latest", - }), - 1000, - 10 - )) as any; - - if (challengeLogs.length === 0) { - return challengeProgress; - } - - const challengeBlock = (await retryOperation( - () => providerGnosis.getBlock(challengeLogs[0].blockNumber), - 1000, - 10 - )) as any; - - challengeProgress.challenge = { - txHash: challengeLogs[0].transactionHash, - timestamp: challengeBlock.timestamp, - finalized: challengeLogs[0].blockNumber <= gnosisFinalized.number, - status: "mined", - }; - challengeProgress.status = "Challenged"; - - // Check snapshot status on Arbitrum with retry - if (previousProgress?.snapshot?.status === "pending") { - const tx = (await retryOperation( - () => providerArb.getTransaction(previousProgress.snapshot.txHash), - 1000, - 10 - )) as any; - if (tx && !tx.blockNumber) { - return { - ...challengeProgress, - status: "SnapshotPending", - }; - } - } - - // Get Arbitrum blocks with retry - const [arbFinalized, arbLatest] = await Promise.all([ - retryOperation(() => providerArb.getBlock("finalized"), 1000, 10) as any, - retryOperation(() => providerArb.getBlock("latest"), 1000, 10) as any, - ]); - - const blockTimeWindow = 100; // Calculate average over last 100 blocks - const oldBlock = await providerArb.getBlock(arbLatest.number - blockTimeWindow); - const averageArbitrumBlocktime = (arbLatest.timestamp - oldBlock.timestamp) / blockTimeWindow; - const estimatedArbBlocks = Math.ceil((arbLatest.timestamp - challengeBlock.timestamp) / averageArbitrumBlocktime); - - const snapshotSentFilter = veaInbox.filters.SnapshotSent(epoch, null); - const snapshotLogs = (await retryOperation( - () => - providerArb.getLogs({ - ...snapshotSentFilter, - fromBlock: arbLatest.number - estimatedArbBlocks, - toBlock: "latest", - }), - 1000, - 10 - )) as any; - - if (snapshotLogs.length === 0) { - return challengeProgress; - } - - const snapshotBlock = (await retryOperation( - () => providerArb.getBlock(snapshotLogs[0].blockNumber), - 1000, - 10 - )) as any; - - challengeProgress.snapshot = { - txHash: snapshotLogs[0].transactionHash, - timestamp: snapshotBlock.timestamp, - finalized: snapshotLogs[0].blockNumber <= arbFinalized.number, - status: "mined", - }; - challengeProgress.status = "SnapshotSent"; - - const snapshotTxnReceipt = (await retryOperation( - () => providerArb.getTransactionReceipt(challengeProgress?.snapshot.txHash), - 1000, - 10 - )) as any; - - const messageReceipt = new ChildTransactionReceipt(snapshotTxnReceipt); - const parentSigner = new Wallet(process.env.PRIVATE_KEY, providerEth); - const messages = await messageReceipt.getChildToParentMessages(parentSigner); - const childToParentMessage = messages[0]; - if (!childToParentMessage) { - throw new Error("No child-to-parent messages found"); - } - const status = await childToParentMessage.status(providerArb); - - challengeProgress.L2toL1Message.status = status; - - // Check route status on Ethereum with retry - if (previousProgress?.route?.status === "pending") { - const tx = (await retryOperation(() => providerEth.getTransaction(previousProgress.route.txHash), 1000, 10)) as any; - if (tx && !tx.blockNumber) { - return { - ...challengeProgress, - status: "RoutePending", - }; - } - } - - // Get Ethereum blocks with retry - const [ethFinalized, ethLatest] = (await Promise.all([ - retryOperation(() => providerEth.getBlock("finalized"), 1000, 10), - retryOperation(() => providerEth.getBlock("latest"), 1000, 10), - ])) as any; - - const estimatedEthBlocks = Math.ceil((ethLatest.timestamp - snapshotBlock.timestamp) / secondsPerSlotEth); - - const routedFilter = router.filters.Routed(epoch, null); - const routedLogs = (await retryOperation( - () => - providerEth.getLogs({ - ...routedFilter, - fromBlock: ethLatest.number - estimatedEthBlocks, - toBlock: "latest", - }), - 1000, - 10 - )) as any; - - if (routedLogs.length === 0) { - return challengeProgress; - } - - const routeBlock = (await retryOperation(() => providerEth.getBlock(routedLogs[0].blockNumber), 1000, 10)) as any; - - challengeProgress.route = { - txHash: routedLogs[0].transactionHash, - timestamp: routeBlock.timestamp, - finalized: routedLogs[0].blockNumber <= ethFinalized.number, - status: "mined", - }; - challengeProgress.status = "Routed"; - - // Check AMB message status on Gnosis with retry - if (previousProgress?.AMB?.status === "pending") { - const tx = (await retryOperation( - () => providerGnosis.getTransaction(previousProgress.AMB.txHash), - 1000, - 10 - )) as any; - if (tx && !tx.blockNumber) { - return { - ...challengeProgress, - status: "AMBMessagePending", - }; - } - } - - const estimatedGnosisBlocks = Math.ceil((gnosisLatest.timestamp - routeBlock.timestamp) / secondsPerSlotGnosis); - - const messageId = routedLogs[0].data; - - const ambFilter = amb.filters.AffirmationCompleted(null, null, messageId, null); - const ambLogs = (await retryOperation( - () => - providerGnosis.getLogs({ - ...ambFilter, - fromBlock: gnosisLatest.number - estimatedGnosisBlocks, - toBlock: "latest", - }), - 1000, - 10 - )) as any; - - if (ambLogs.length > 0) { - const ambBlock = (await retryOperation(() => providerGnosis.getBlock(ambLogs[0].blockNumber), 1000, 10)) as any; - - challengeProgress.AMB = { - ambMessageId: messageId, - txHash: ambLogs[0].transactionHash, - timestamp: ambBlock.timestamp, - finalized: ambLogs[0].blockNumber <= gnosisFinalized.number, - status: "mined", - }; - challengeProgress.status = "AMBMessageSent"; - } - - if (previousProgress?.withdrawal?.status === "pending") { - const tx = (await retryOperation( - () => providerGnosis.getTransaction(previousProgress.withdrawal.txHash), - 1000, - 10 - )) as any; - if (tx && !tx.blockNumber) { - return { - ...challengeProgress, - status: "WithdrawalPending", - }; - } - } - - // there is no event in case of withdrawal hence no way to track it , - // but if a withdrawal is processed ,claimHash for the epoch will be deleted ,challenged progess will not be recontructed in the first place. - return challengeProgress; -} - -const hashClaim = (claim) => { - 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 () => { - retryOperation(() => watch(), 1000, 10); -})(); -export default watch; diff --git a/validator-cli/src/consts/bridgeRoutes.ts b/validator-cli/src/consts/bridgeRoutes.ts index a8b4153e..e9464471 100644 --- a/validator-cli/src/consts/bridgeRoutes.ts +++ b/validator-cli/src/consts/bridgeRoutes.ts @@ -13,13 +13,13 @@ import veaOutboxArbToGnosisTestnet from "@kleros/vea-contracts/deployments/chiad import veaRouterArbToGnosisTestnet from "@kleros/vea-contracts/deployments/sepolia/RouterArbToGnosisTestnet.json"; interface Bridge { chain: string; - deposit: bigint; minChallengePeriod: number; sequencerDelayLimit: number; inboxRPC: string; outboxRPC: string; routerRPC?: string; routeConfig: { [key in Network]: RouteConfigs }; + depositToken?: string; } type RouteConfigs = { @@ -27,6 +27,7 @@ type RouteConfigs = { veaOutbox: any; veaRouter?: any; epochPeriod: number; + deposit: bigint; }; export enum Network { @@ -39,11 +40,13 @@ const arbToEthConfigs: { [key in Network]: RouteConfigs } = { veaInbox: veaInboxArbToEthDevnet, veaOutbox: veaOutboxArbToEthDevnet, epochPeriod: 1800, + deposit: BigInt("1000000000000000000"), }, [Network.TESTNET]: { veaInbox: veaInboxArbToEthTestnet, veaOutbox: veaOutboxArbToEthTestnet, epochPeriod: 7200, + deposit: BigInt("1000000000000000000"), }, }; @@ -51,20 +54,21 @@ const arbToGnosisConfigs: { [key in Network]: RouteConfigs } = { [Network.DEVNET]: { veaInbox: veaInboxArbToGnosisDevnet, veaOutbox: veaOutboxArbToGnosisDevnet, - epochPeriod: 3600, + epochPeriod: 1800, + deposit: BigInt("100000000000000000"), }, [Network.TESTNET]: { veaInbox: veaInboxArbToGnosisTestnet, veaOutbox: veaOutboxArbToGnosisTestnet, veaRouter: veaRouterArbToGnosisTestnet, - epochPeriod: 7200, + epochPeriod: 3600, + deposit: BigInt("200000000000000000"), }, }; const bridges: { [chainId: number]: Bridge } = { 11155111: { chain: "sepolia", - deposit: BigInt("1000000000000000000"), minChallengePeriod: 10800, sequencerDelayLimit: 86400, inboxRPC: process.env.RPC_ARB, @@ -73,13 +77,13 @@ const bridges: { [chainId: number]: Bridge } = { }, 10200: { chain: "chiado", - deposit: BigInt("1000000000000000000"), minChallengePeriod: 10800, sequencerDelayLimit: 86400, inboxRPC: process.env.RPC_ARB, outboxRPC: process.env.RPC_GNOSIS, routerRPC: process.env.RPC_ETH, routeConfig: arbToGnosisConfigs, + depositToken: process.env.GNOSIS_WETH, }, }; diff --git a/validator-cli/src/ArbToEth/claimer.test.ts b/validator-cli/src/helpers/claimer.test.ts similarity index 100% rename from validator-cli/src/ArbToEth/claimer.test.ts rename to validator-cli/src/helpers/claimer.test.ts diff --git a/validator-cli/src/ArbToEth/claimer.ts b/validator-cli/src/helpers/claimer.ts similarity index 84% rename from validator-cli/src/ArbToEth/claimer.ts rename to validator-cli/src/helpers/claimer.ts index 37f9f884..b754ab7c 100644 --- a/validator-cli/src/ArbToEth/claimer.ts +++ b/validator-cli/src/helpers/claimer.ts @@ -3,11 +3,9 @@ import { ethers } from "ethers"; import { JsonRpcProvider } from "@ethersproject/providers"; import { getClaim, ClaimHonestState } from "../utils/claim"; import { getLastClaimedEpoch } from "../utils/graphQueries"; -import { ArbToEthTransactionHandler } from "./transactionHandler"; import { BotEvents } from "../utils/botEvents"; import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; -import { ArbToEthDevnetTransactionHandler } from "./transactionHandlerDevnet"; -import { getTransactionHandler } from "../utils/ethers"; +import { ITransactionHandler, IDevnetTransactionHandler, getTransactionHandler } from "../utils/transactionHandlers"; import { Network } from "../consts/bridgeRoutes"; interface CheckAndClaimParams { chainId: number; @@ -19,7 +17,7 @@ interface CheckAndClaimParams { veaInboxProvider: JsonRpcProvider; veaOutbox: any; veaOutboxProvider: JsonRpcProvider; - transactionHandler: ArbToEthTransactionHandler | null; + transactionHandler: ITransactionHandler | null; emitter: EventEmitter; fetchClaim?: typeof getClaim; fetchLatestClaimedEpoch?: typeof getLastClaimedEpoch; @@ -48,6 +46,7 @@ async function checkAndClaim({ if (!transactionHandler) { const TransactionHandler = fetchTransactionHandler(chainId, network); transactionHandler = new TransactionHandler({ + chainId, network, epoch, veaInbox, @@ -60,18 +59,17 @@ async function checkAndClaim({ } else { transactionHandler.claim = claim; } - if (network == Network.DEVNET) { return makeClaimDevnet( epoch, claim, outboxStateRoot, - transactionHandler as ArbToEthDevnetTransactionHandler, + transactionHandler as IDevnetTransactionHandler, veaInbox, emitter ); } else if (claim == null && epoch == claimAbleEpoch) { - return makeClaim(epoch, transactionHandler, outboxStateRoot, veaInbox, veaOutbox, fetchLatestClaimedEpoch); + return makeClaim(chainId, epoch, transactionHandler, outboxStateRoot, veaInbox, veaOutbox, fetchLatestClaimedEpoch); } else if (claim != null) { return verifyClaim(transactionHandler, claim, veaOutboxProvider); } else { @@ -84,10 +82,10 @@ async function makeClaimDevnet( epoch: number, claim: ClaimStruct | null, outboxStateRoot: string, - transactionHandler: ArbToEthDevnetTransactionHandler, + transactionHandler: IDevnetTransactionHandler, veaInbox: any, emitter: EventEmitter -): Promise { +): Promise { if (claim == null) { const [savedSnapshot] = await Promise.all([veaInbox.snapshots(epoch)]); @@ -102,16 +100,17 @@ async function makeClaimDevnet( } async function makeClaim( + chainId: number, epoch: number, - transactionHandler: ArbToEthTransactionHandler, + transactionHandler: ITransactionHandler, outboxStateRoot: string, veaInbox: any, veaOutbox: any, fetchLatestClaimedEpoch: typeof getLastClaimedEpoch = getLastClaimedEpoch -): Promise { +): Promise { const [savedSnapshot, claimData] = await Promise.all([ veaInbox.snapshots(epoch), - fetchLatestClaimedEpoch(veaOutbox.target), + fetchLatestClaimedEpoch(veaOutbox.target, chainId), ]); const newMessagesToBridge = savedSnapshot != outboxStateRoot && savedSnapshot != ethers.ZeroHash; const lastClaimChallenged = claimData?.challenged && savedSnapshot == outboxStateRoot; @@ -122,7 +121,7 @@ async function makeClaim( } async function verifyClaim( - transactionHandler: ArbToEthTransactionHandler, + transactionHandler: ITransactionHandler, claim: ClaimStruct, veaOutboxProvider: JsonRpcProvider ) { diff --git a/validator-cli/src/utils/snapshot.test.ts b/validator-cli/src/helpers/snapshot.test.ts similarity index 96% rename from validator-cli/src/utils/snapshot.test.ts rename to validator-cli/src/helpers/snapshot.test.ts index 066ae809..a78f52a8 100644 --- a/validator-cli/src/utils/snapshot.test.ts +++ b/validator-cli/src/helpers/snapshot.test.ts @@ -1,10 +1,11 @@ import { Network } from "../consts/bridgeRoutes"; import { isSnapshotNeeded, saveSnapshot } from "./snapshot"; -import { MockEmitter } from "./emitter"; +import { MockEmitter } from "../utils/emitter"; describe("snapshot", () => { let veaInbox: any; let count: number = 1; + const chainId = 11155111; let fetchLastSavedMessage: jest.Mock; beforeEach(() => { veaInbox = { @@ -24,6 +25,7 @@ describe("snapshot", () => { fetchLastSavedMessage = jest.fn(); veaInbox.queryFilter.mockResolvedValue([{ args: ["0x1", "0x2", currentCount] }]); const params = { + chainId, veaInbox, count, fetchLastSavedMessage, @@ -41,6 +43,7 @@ describe("snapshot", () => { fetchLastSavedMessage = jest.fn(); veaInbox.queryFilter.mockResolvedValue([{ args: ["0x1", "0x2", currentCount] }]); const params = { + chainId, veaInbox, count, fetchLastSavedMessage, @@ -57,6 +60,7 @@ describe("snapshot", () => { fetchLastSavedMessage = jest.fn(); veaInbox.queryFilter.mockResolvedValue([{ args: ["0x1", "0x2", currentCount] }]); const params = { + chainId, veaInbox, count, fetchLastSavedMessage, @@ -73,6 +77,7 @@ describe("snapshot", () => { fetchLastSavedMessage = jest.fn(); veaInbox.queryFilter.mockResolvedValue([{ args: ["0x1", "0x2", 1] }]); const params = { + chainId, veaInbox, count, fetchLastSavedMessage, @@ -89,6 +94,7 @@ describe("snapshot", () => { fetchLastSavedMessage = jest.fn().mockResolvedValue("message-0"); veaInbox.queryFilter.mockRejectedValue(new Error("queryFilter failed")); const params = { + chainId, veaInbox, count, fetchLastSavedMessage, @@ -111,6 +117,7 @@ describe("snapshot", () => { saveSnapshot: jest.fn(), }; const res = await saveSnapshot({ + chainId, veaInbox, network, epochPeriod, @@ -137,6 +144,7 @@ describe("snapshot", () => { saveSnapshot: jest.fn(), }; const res = await saveSnapshot({ + chainId, veaInbox, network, epochPeriod, @@ -162,6 +170,7 @@ describe("snapshot", () => { saveSnapshot: jest.fn(), }; const res = await saveSnapshot({ + chainId, veaInbox, network, epochPeriod, @@ -188,6 +197,7 @@ describe("snapshot", () => { saveSnapshot: jest.fn(), }; const res = await saveSnapshot({ + chainId, veaInbox, network: Network.DEVNET, epochPeriod, diff --git a/validator-cli/src/utils/snapshot.ts b/validator-cli/src/helpers/snapshot.ts similarity index 89% rename from validator-cli/src/utils/snapshot.ts rename to validator-cli/src/helpers/snapshot.ts index bda415da..a37172b9 100644 --- a/validator-cli/src/utils/snapshot.ts +++ b/validator-cli/src/helpers/snapshot.ts @@ -1,15 +1,17 @@ import { Network } from "../consts/bridgeRoutes"; -import { BotEvents } from "./botEvents"; -import { getLastMessageSaved } from "./graphQueries"; -import { defaultEmitter } from "./emitter"; +import { getLastMessageSaved } from "../utils/graphQueries"; +import { BotEvents } from "../utils/botEvents"; +import { defaultEmitter } from "../utils/emitter"; interface SnapshotCheckParams { + chainId: number; veaInbox: any; count: number; fetchLastSavedMessage?: typeof getLastMessageSaved; } export interface SaveSnapshotParams { + chainId: number; veaInbox: any; network: Network; epochPeriod: number; @@ -21,6 +23,7 @@ export interface SaveSnapshotParams { } export const saveSnapshot = async ({ + chainId, veaInbox, network, epochPeriod, @@ -28,7 +31,7 @@ export const saveSnapshot = async ({ transactionHandler, emitter = defaultEmitter, toSaveSnapshot = isSnapshotNeeded, - now = Date.now(), + now = Math.floor(Date.now() / 1000), }: SaveSnapshotParams): Promise => { if (network != Network.DEVNET) { const timeElapsed = now % epochPeriod; @@ -40,6 +43,7 @@ export const saveSnapshot = async ({ } } const { snapshotNeeded, latestCount } = await toSaveSnapshot({ + chainId, veaInbox, count, }); @@ -49,6 +53,7 @@ export const saveSnapshot = async ({ }; export const isSnapshotNeeded = async ({ + chainId, veaInbox, count, fetchLastSavedMessage = getLastMessageSaved, @@ -63,7 +68,7 @@ export const isSnapshotNeeded = async ({ lastSavedCount = Number(saveSnapshotLogs[saveSnapshotLogs.length - 1].args[2]); } catch { const veaInboxAddress = await veaInbox.getAddress(); - const lastSavedMessageId = await fetchLastSavedMessage(veaInboxAddress); + const lastSavedMessageId = await fetchLastSavedMessage(veaInboxAddress, chainId); const messageIndex = extractMessageIndex(lastSavedMessageId); // adding 1 to the message index to get the last saved count lastSavedCount = messageIndex + 1; @@ -75,6 +80,7 @@ export const isSnapshotNeeded = async ({ }; function extractMessageIndex(id: string): number { + if (id === undefined) return 0; const parts = id.split("-"); if (parts.length < 2) { throw new Error(`Invalid message-id format: ${id}`); diff --git a/validator-cli/src/ArbToEth/validator.test.ts b/validator-cli/src/helpers/validator.test.ts similarity index 94% rename from validator-cli/src/ArbToEth/validator.test.ts rename to validator-cli/src/helpers/validator.test.ts index 253831c6..ad10cd28 100644 --- a/validator-cli/src/ArbToEth/validator.test.ts +++ b/validator-cli/src/helpers/validator.test.ts @@ -11,6 +11,7 @@ describe("validator", () => { let mockClaim: any; let mockGetClaimState: any; let mockGetBlockFinality: any; + let mockGetBlockFromEpoch: any; let mockDeps: any; beforeEach(() => { veaInbox = { @@ -38,8 +39,10 @@ describe("validator", () => { honest: 0, challenger: ethers.ZeroAddress, }; + mockGetBlockFromEpoch = jest.fn().mockResolvedValue({ number: 0, timestamp: 100 }); mockGetBlockFinality = jest.fn().mockResolvedValue([{ number: 0 }, { number: 0, timestamp: 100 }, false]); mockDeps = { + chainId: 11155111, claim: mockClaim, epoch: 0, epochPeriod: 10, @@ -51,6 +54,7 @@ describe("validator", () => { emitter, fetchClaimResolveState: mockGetClaimState, fetchBlocksAndCheckFinality: mockGetBlockFinality, + fetchBlockFromEpoch: mockGetBlockFromEpoch, }; }); afterEach(() => { @@ -135,7 +139,7 @@ describe("validator", () => { expect(updatedTransactionHandler.claim).toEqual(mockClaim); }); - it("withdraw challenge deposit if snapshot sent and executed", async () => { + it("withdraw challenge deposit if snapshot sent and challenger won", async () => { mockClaim.challenger = mockClaim.claimer; mockGetClaimState = jest.fn().mockReturnValue({ sendSnapshot: { status: true, txnHash: "0x123" }, @@ -153,6 +157,7 @@ describe("validator", () => { }; mockDeps.transactionHandler = mockTransactionHandler; mockDeps.fetchClaimResolveState = mockGetClaimState; + mockDeps.claim.honest = 2; // Set honest to 1 to indicate the challenger is honest const updatedTransactionHandler = await challengeAndResolveClaim(mockDeps); expect(updatedTransactionHandler.transactions.withdrawChallengeDepositTxn).toEqual("0x1234"); expect(mockTransactionHandler.withdrawChallengeDeposit).toHaveBeenCalled(); diff --git a/validator-cli/src/helpers/validator.ts b/validator-cli/src/helpers/validator.ts new file mode 100644 index 00000000..1c96b3da --- /dev/null +++ b/validator-cli/src/helpers/validator.ts @@ -0,0 +1,185 @@ +import { JsonRpcProvider } from "@ethersproject/providers"; +import { ethers } from "ethers"; +import { ITransactionHandler, getTransactionHandler } from "../utils/transactionHandlers"; +import { getClaim, getClaimResolveState } from "../utils/claim"; +import { defaultEmitter } from "../utils/emitter"; +import { BotEvents } from "../utils/botEvents"; +import { getBlocksAndCheckFinality } from "../utils/arbToEthState"; +import { Network } from "../consts/bridgeRoutes"; +import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; +import { getBlockFromEpoch } from "../utils/epochHandler"; + +export interface ChallengeAndResolveClaimParams { + chainId: number; + claim: ClaimStruct; + epoch: number; + epochPeriod: number; + veaInbox: any; + veaInboxProvider: JsonRpcProvider; + veaOutboxProvider: JsonRpcProvider; + veaRouterProvider?: JsonRpcProvider; + veaOutbox: any; + transactionHandler: ITransactionHandler | null; + emitter?: typeof defaultEmitter; + fetchClaim?: typeof getClaim; + fetchClaimResolveState?: typeof getClaimResolveState; + fetchBlocksAndCheckFinality?: typeof getBlocksAndCheckFinality; + fetchTransactionHandler?: typeof getTransactionHandler; + fetchBlockFromEpoch?: typeof getBlockFromEpoch; +} + +export async function challengeAndResolveClaim({ + chainId, + claim, + epoch, + epochPeriod, + veaInbox, + veaInboxProvider, + veaOutboxProvider, + veaOutbox, + transactionHandler, + emitter = defaultEmitter, + veaRouterProvider, + fetchClaimResolveState = getClaimResolveState, + fetchBlocksAndCheckFinality = getBlocksAndCheckFinality, + fetchTransactionHandler = getTransactionHandler, + fetchBlockFromEpoch = getBlockFromEpoch, +}: ChallengeAndResolveClaimParams): Promise { + if (!claim) { + emitter.emit(BotEvents.NO_CLAIM, epoch); + return null; + } + const queryRpc = veaRouterProvider ?? veaOutboxProvider; + const [arbitrumBlock, , finalityIssueFlagEth] = await fetchBlocksAndCheckFinality( + queryRpc, + veaInboxProvider, + epoch, + epochPeriod + ); + const ethBlockTag = finalityIssueFlagEth ? "finalized" : "latest"; + if (!transactionHandler) { + const TransactionHandler = fetchTransactionHandler(chainId, Network.TESTNET); + transactionHandler = new TransactionHandler({ + chainId, + network: Network.TESTNET, // Hardcoded as TESTNET & MAINNET have same contracts + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + veaRouterProvider, + emitter, + claim, + }); + } else { + transactionHandler.claim = claim; + } + + const { challenged, toRelay } = await challengeAndCheckRelay({ + veaInbox, + epoch, + claim, + transactionHandler, + arbitrumBlockNumber: arbitrumBlock.number, + }); + if (!toRelay && !challenged) { + return null; + } else if (challenged && !toRelay) { + return transactionHandler; + } + await handleResolveFlow({ + chainId, + epoch, + epochPeriod, + claim, + veaInbox, + veaInboxProvider, + queryRpc, + ethBlockTag, + transactionHandler, + fetchClaimResolveState, + fetchBlockFromEpoch, + }); + + return transactionHandler; +} + +interface ChallengeAndCheckRelayParams { + veaInbox: any; + epoch: number; + claim: ClaimStruct; + transactionHandler: ITransactionHandler; + arbitrumBlockNumber: number; +} +async function challengeAndCheckRelay({ + veaInbox, + epoch, + claim, + transactionHandler, + arbitrumBlockNumber, +}: ChallengeAndCheckRelayParams): Promise<{ challenged: boolean; toRelay: boolean }> { + const onChainSnapshot = await veaInbox.snapshots(epoch, { blockTag: arbitrumBlockNumber }); + const isNotChallenged = claim.challenger === ethers.ZeroAddress; + const challengeAndRelayState = { + challenged: false, + toRelay: false, + }; + if (onChainSnapshot !== claim.stateRoot && isNotChallenged) { + await transactionHandler.challengeClaim(); + challengeAndRelayState.challenged = true; + } + if (!isNotChallenged) { + return { challenged: true, toRelay: true }; + } + return challengeAndRelayState; +} + +interface ResolveFlowParams { + chainId: number; + epoch: number; + epochPeriod: number; + claim: ClaimStruct; + veaInbox: any; + veaInboxProvider: JsonRpcProvider; + queryRpc: JsonRpcProvider; + ethBlockTag: "latest" | "finalized"; + transactionHandler: ITransactionHandler; + fetchClaimResolveState: typeof getClaimResolveState; + fetchBlockFromEpoch: typeof getBlockFromEpoch; +} +async function handleResolveFlow({ + chainId, + epoch, + epochPeriod, + claim, + veaInbox, + veaInboxProvider, + queryRpc, + ethBlockTag, + transactionHandler, + fetchClaimResolveState, + fetchBlockFromEpoch, +}: ResolveFlowParams): Promise { + const blockNumberOutboxLowerBound = await fetchBlockFromEpoch(epoch, epochPeriod, queryRpc); + const claimResolveState = await fetchClaimResolveState({ + chainId, + veaInbox, + veaInboxProvider, + veaOutboxProvider: queryRpc, + epoch, + fromBlock: blockNumberOutboxLowerBound, + toBlock: ethBlockTag, + }); + + if (!claimResolveState.sendSnapshot.status) { + await transactionHandler.sendSnapshot(); + return; + } + + const execStatus = claimResolveState.execution.status; + if (execStatus === 1) { + await transactionHandler.resolveChallengedClaim(claimResolveState.sendSnapshot.txHash); + } else if (execStatus === 2 && claim.honest === 2) { + await transactionHandler.withdrawChallengeDeposit(); + } +} diff --git a/validator-cli/src/utils/botConfig.test.ts b/validator-cli/src/utils/botConfig.test.ts index 175b357d..c0d95cdf 100644 --- a/validator-cli/src/utils/botConfig.test.ts +++ b/validator-cli/src/utils/botConfig.test.ts @@ -4,18 +4,18 @@ describe("cli", () => { describe("getBotPath", () => { const defCommand = ["yarn", "start"]; it("should return the default path", () => { - const path = getBotPath({ cliCommand: defCommand }); - expect(path).toEqual(BotPaths.BOTH); + const config = getBotPath({ cliCommand: defCommand }); + expect(config.path).toEqual(BotPaths.BOTH); }); it("should return the claimer path", () => { const command = ["yarn", "start", "--path=claimer"]; - const path = getBotPath({ cliCommand: command }); - expect(path).toEqual(BotPaths.CLAIMER); + const config = getBotPath({ cliCommand: command }); + expect(config.path).toEqual(BotPaths.CLAIMER); }); it("should return the challenger path", () => { const command = ["yarn", "start", "--path=challenger"]; - const path = getBotPath({ cliCommand: command }); - expect(path).toEqual(BotPaths.CHALLENGER); + const config = getBotPath({ cliCommand: command }); + expect(config.path).toEqual(BotPaths.CHALLENGER); }); it("should throw an error for invalid path", () => { const command = ["yarn", "start", "--path=invalid"]; diff --git a/validator-cli/src/utils/claim.test.ts b/validator-cli/src/utils/claim.test.ts index 660e5553..0f490590 100644 --- a/validator-cli/src/utils/claim.test.ts +++ b/validator-cli/src/utils/claim.test.ts @@ -1,6 +1,6 @@ import { ethers } from "ethers"; import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; -import { getClaim, hashClaim, getClaimResolveState } from "./claim"; +import { getClaim, hashClaim, getClaimResolveState, ClaimResolveStateParams } from "./claim"; import { ClaimNotFoundError } from "./errors"; let mockClaim: ClaimStruct; @@ -237,6 +237,7 @@ describe("snapshotClaim", () => { const epoch = 1; const blockNumberOutboxLowerBound = 1234; const toBlock = "latest"; + let mockClaimResolveStateParams: any; beforeEach(() => { mockClaim = { stateRoot: "0xeac817ed5c5b3d1c2c548f231b7cf9a0dfd174059f450ec6f0805acf6a16a551", @@ -253,18 +254,25 @@ describe("snapshotClaim", () => { SnapshotSent: jest.fn(), }, }; + mockClaimResolveStateParams = { + chainId: 11155111, + veaInbox, + veaInboxProvider: { + getBlock: jest.fn().mockResolvedValueOnce({ timestamp: mockClaim.timestampClaimed, number: 1234 }), + } as any, + veaOutboxProvider: { + getBlock: jest.fn().mockResolvedValueOnce({ timestamp: mockClaim.timestampClaimed, number: 1234 }), + } as any, + epoch, + fromBlock: blockNumberOutboxLowerBound, + toBlock, + fetchMessageStatus: jest.fn(), + }; }); it("should return pending state for both", async () => { veaInbox.queryFilter.mockResolvedValueOnce([]); - const claimResolveState = await getClaimResolveState( - veaInbox, - veaInboxProvider, - veaOutboxProvider, - epoch, - blockNumberOutboxLowerBound, - toBlock - ); + const claimResolveState = await getClaimResolveState(mockClaimResolveStateParams); expect(claimResolveState).toBeDefined(); expect(claimResolveState.sendSnapshot.status).toBeFalsy(); expect(claimResolveState.execution.status).toBe(0); @@ -273,15 +281,8 @@ describe("snapshotClaim", () => { 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 - ); + mockClaimResolveStateParams.fetchMessageStatus = mockGetMessageStatus; + const claimResolveState = await getClaimResolveState(mockClaimResolveStateParams); 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 a13cbe96..9263e121 100644 --- a/validator-cli/src/utils/claim.ts +++ b/validator-cli/src/utils/claim.ts @@ -17,6 +17,7 @@ enum ClaimHonestState { } interface ClaimParams { + chainId: number; veaOutbox: any; veaOutboxProvider: JsonRpcProvider; epoch: number; @@ -34,6 +35,7 @@ interface ClaimParams { * @returns claim type of ClaimStruct */ const getClaim = async ({ + chainId, veaOutbox, veaOutboxProvider, epoch, @@ -70,11 +72,11 @@ const getClaim = async ({ } if (challengeLogs.length > 0) claim.challenger = "0x" + challengeLogs[0].topics[2].substring(26); } catch { - const claimFromGraph = await fetchClaimForEpoch(epoch, await veaOutbox.getAddress()); + const claimFromGraph = await fetchClaimForEpoch(epoch, await veaOutbox.getAddress(), chainId); if (!claimFromGraph) throw new ClaimNotFoundError(epoch); const [verificationFromGraph, challengeFromGraph] = await Promise.all([ - fetchVerificationForClaim(claimFromGraph.id), - fetchChallengerForClaim(claimFromGraph.id), + fetchVerificationForClaim(claimFromGraph.id, chainId), + fetchChallengerForClaim(claimFromGraph.id, chainId), ]); claim.stateRoot = claimFromGraph.stateroot; claim.claimer = claimFromGraph.bridger; @@ -112,6 +114,17 @@ type ClaimResolveState = { }; }; +export interface ClaimResolveStateParams { + chainId: number; + veaInbox: any; + veaInboxProvider: JsonRpcProvider; + veaOutboxProvider: JsonRpcProvider; + epoch: number; + fromBlock: number; + toBlock: number | string; + fetchMessageStatus?: typeof getMessageStatus; +} + /** * Fetches the claim resolve state. * @param veaInbox VeaInbox contract instance @@ -123,15 +136,16 @@ type ClaimResolveState = { * @param fetchMessageStatus function to fetch message status * @returns ClaimResolveState **/ -const getClaimResolveState = async ( - veaInbox: any, - veaInboxProvider: JsonRpcProvider, - veaOutboxProvider: JsonRpcProvider, - epoch: number, - fromBlock: number, - toBlock: number | string, - fetchMessageStatus: typeof getMessageStatus = getMessageStatus -): Promise => { +const getClaimResolveState = async ({ + chainId, + veaInbox, + veaInboxProvider, + veaOutboxProvider, + epoch, + fromBlock, + toBlock, + fetchMessageStatus, +}: ClaimResolveStateParams): Promise => { let claimResolveState: ClaimResolveState = { sendSnapshot: { status: false, @@ -152,7 +166,7 @@ const getClaimResolveState = async ( return claimResolveState; } } catch { - const sentSnapshotFromGraph = await getSnapshotSentForEpoch(epoch, await veaInbox.getAddress()); + const sentSnapshotFromGraph = await getSnapshotSentForEpoch(epoch, await veaInbox.getAddress(), chainId); if (sentSnapshotFromGraph) { claimResolveState.sendSnapshot.status = true; claimResolveState.sendSnapshot.txHash = sentSnapshotFromGraph.txHash; diff --git a/validator-cli/src/utils/epochHandler.ts b/validator-cli/src/utils/epochHandler.ts index b7b7f9a5..4a348ee6 100644 --- a/validator-cli/src/utils/epochHandler.ts +++ b/validator-cli/src/utils/epochHandler.ts @@ -64,7 +64,7 @@ const getLatestChallengeableEpoch = (epochPeriod: number, now: number = Date.now */ const getBlockFromEpoch = async (epoch: number, epochPeriod: number, provider: JsonRpcProvider): Promise => { const epochTimestamp = epoch * epochPeriod; - const latestBlock = await provider.getBlock("latest"); + const latestBlock = await provider.getBlock("final"); const baseBlock = await provider.getBlock(latestBlock.number - 500); const secPerBlock = (latestBlock.timestamp - baseBlock.timestamp) / (latestBlock.number - baseBlock.number); const blockFallBack = Math.floor((latestBlock.timestamp - epochTimestamp) / secPerBlock); diff --git a/validator-cli/src/utils/ethers.ts b/validator-cli/src/utils/ethers.ts index 6420055a..64bd91ec 100644 --- a/validator-cli/src/utils/ethers.ts +++ b/validator-cli/src/utils/ethers.ts @@ -10,10 +10,6 @@ import { RouterArbToGnosis__factory, IAMB__factory, } from "@kleros/vea-contracts/typechain-types"; -import { challengeAndResolveClaim as challengeAndResolveClaimArbToEth } from "../ArbToEth/validator"; -import { checkAndClaim } from "../ArbToEth/claimer"; -import { ArbToEthTransactionHandler } from "../ArbToEth/transactionHandler"; -import { ArbToEthDevnetTransactionHandler } from "../ArbToEth/transactionHandlerDevnet"; import { NotDefinedError, InvalidNetworkError } from "./errors"; import { Network } from "../consts/bridgeRoutes"; @@ -81,43 +77,6 @@ function getAMB(ambAddress: string, privateKey: string, rpcUrl: string) { return IAMB__factory.connect(ambAddress, getWallet(privateKey, rpcUrl)); } -const getClaimValidator = (chainId: number, network: Network) => { - switch (chainId) { - case 11155111: - return challengeAndResolveClaimArbToEth; - default: - throw new NotDefinedError("Claim Validator"); - } -}; -const getClaimer = (chainId: number, network: Network): typeof checkAndClaim => { - switch (chainId) { - case 11155111: - switch (network) { - case Network.DEVNET: - - case Network.TESTNET: - return checkAndClaim; - - default: - throw new InvalidNetworkError(`${network}(claimer)`); - } - default: - throw new NotDefinedError("Claimer"); - } -}; -const getTransactionHandler = (chainId: number, network: Network) => { - if (chainId === 11155111) { - if (network === Network.DEVNET) { - return ArbToEthDevnetTransactionHandler; - } else if (network === Network.TESTNET) { - return ArbToEthTransactionHandler; - } else { - throw new InvalidNetworkError(`${network}(transactionHandler)`); - } - } else { - throw new NotDefinedError("Transaction Handler"); - } -}; export { getWalletRPC, getWallet, @@ -126,8 +85,5 @@ export { getVeaOutboxArbToEthDevnet, getWETH, getAMB, - getClaimValidator, - getClaimer, - getTransactionHandler, getVeaRouter, }; diff --git a/validator-cli/src/utils/graphQueries.ts b/validator-cli/src/utils/graphQueries.ts index 45364dd5..99b7548a 100644 --- a/validator-cli/src/utils/graphQueries.ts +++ b/validator-cli/src/utils/graphQueries.ts @@ -16,14 +16,28 @@ interface ClaimData { }; } +const getOutboxSubgraphUrl = (chainId: number): string => { + if (chainId === 11155111) { + return process.env.VEAOUTBOX_SUBGRAPH_SEPOLIA || ""; + } else if (chainId === 10200) { + return process.env.VEAOUTBOX_SUBGRAPH_CHIADO || ""; + } +}; +const getInboxSubgraphUrl = (chainId: number): string => { + // using destination chainId's for inbox + if (chainId === 11155111 || chainId === 10200) { + return process.env.VEAINBOX_SUBGRAPH_ARBSEPOLIA || ""; + } +}; + /** * Fetches the claim data for a given epoch (used for claimer - happy path) * @param epoch * @returns ClaimData * */ -const getClaimForEpoch = async (epoch: number, outbox: string): Promise => { +const getClaimForEpoch = async (epoch: number, outbox: string, chainId: number): Promise => { try { - const subgraph = process.env.VEAOUTBOX_SUBGRAPH; + const subgraph = getOutboxSubgraphUrl(chainId); const result = await request( `${subgraph}`, @@ -49,8 +63,8 @@ const getClaimForEpoch = async (epoch: number, outbox: string): Promise => { - const subgraph = process.env.VEAOUTBOX_SUBGRAPH; +const getLastClaimedEpoch = async (outbox: string, chainId: number): Promise => { + const subgraph = getOutboxSubgraphUrl(chainId); try { const result = await request( `${subgraph}`, @@ -83,9 +97,9 @@ type VerificationData = { * @param claimId * @returns VerificationData */ -const getVerificationForClaim = async (claimId: string): Promise => { +const getVerificationForClaim = async (claimId: string, chainId: number): Promise => { try { - const subgraph = process.env.VEAOUTBOX_SUBGRAPH; + const subgraph = getOutboxSubgraphUrl(chainId); const result = await request( `${subgraph}`, `{ @@ -107,9 +121,9 @@ const getVerificationForClaim = async (claimId: string): Promise => { +const getChallengerForClaim = async (claimId: string, chainId: number): Promise<{ challenger: string } | undefined> => { try { - const subgraph = process.env.VEAOUTBOX_SUBGRAPH; + const subgraph = getOutboxSubgraphUrl(chainId); const result = await request( `${subgraph}`, `{ @@ -136,15 +150,18 @@ type SenSnapshotResponse = { * @param epoch * @returns snapshot data */ -const getSnapshotSentForEpoch = async (epoch: number, veaInbox: any): Promise<{ txHash: string }> => { +const getSnapshotSentForEpoch = async ( + epoch: number, + veaInbox: string, + chainId: number +): Promise<{ txHash: string }> => { try { - const subgraph = process.env.VEAINBOX_SUBGRAPH; - const veaInboxAddress = veaInbox.toLowerCase(); + const subgraph = getInboxSubgraphUrl(chainId); const result: SenSnapshotResponse = await request( `${subgraph}`, `{ - snapshots(where: {epoch: ${epoch}, inbox_: { id: "${veaInboxAddress}" }}) { + snapshots(where: {epoch: ${epoch}, inbox_: { id: "${veaInbox}" }}) { fallback{ txHash } @@ -171,8 +188,8 @@ type SnapshotSavedResponse = { * @param veaInbox * @returns message id */ -const getLastMessageSaved = async (veaInbox: string): Promise => { - const subgraph = process.env.VEAINBOX_SUBGRAPH; +const getLastMessageSaved = async (veaInbox: string, chainId: number): Promise => { + const subgraph = getInboxSubgraphUrl(chainId); const result: SnapshotSavedResponse = await request( `${subgraph}`, `{ @@ -183,6 +200,7 @@ const getLastMessageSaved = async (veaInbox: string): Promise => { } }` ); + if (result.snapshots.length < 2 || result.snapshots[1].messages.length === 0) return; return result.snapshots[1].messages[0].id; }; diff --git a/validator-cli/src/utils/transactionHandlers/arbToEthHandler.test.ts b/validator-cli/src/utils/transactionHandlers/arbToEthHandler.test.ts new file mode 100644 index 00000000..40251c03 --- /dev/null +++ b/validator-cli/src/utils/transactionHandlers/arbToEthHandler.test.ts @@ -0,0 +1,218 @@ +import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; +import { ArbToEthTransactionHandler } from "./arbToEthHandler"; +import { ContractType, BaseTransactionHandlerConstructor } from "./baseTransactionHandler"; +import { MockEmitter } from "../../utils/emitter"; +import { BotEvents } from "../../utils/botEvents"; +import { ClaimNotSetError } from "../../utils/errors"; +import { getBridgeConfig, Network } from "../../consts/bridgeRoutes"; + +describe("ArbToEthTransactionHandler", () => { + const chainId = 11155111; + let epoch: number = 100; + let veaInbox: any; + let veaOutbox: any; + let veaInboxProvider: any; + let veaOutboxProvider: any; + let claim: ClaimStruct = null; + let transactionHandlerParams: BaseTransactionHandlerConstructor; + const mockEmitter = new MockEmitter(); + beforeEach(() => { + veaInboxProvider = { + getTransactionReceipt: jest.fn(), + getBlock: jest.fn(), + }; + veaOutbox = { + estimateGas: { + claim: jest.fn(), + }, + withdrawChallengeDeposit: jest.fn(), + ["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"]: jest.fn(), + claim: jest.fn(), + startVerification: jest.fn(), + verifySnapshot: jest.fn(), + withdrawClaimDeposit: jest.fn(), + }; + veaInbox = { + sendSnapshot: jest.fn(), + saveSnapshot: jest.fn(), + }; + claim = { + stateRoot: "0x1234", + claimer: "0x1234", + timestampClaimed: 1234, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: "0x1234", + }; + transactionHandlerParams = { + chainId, + network: Network.TESTNET, + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + emitter: mockEmitter, + claim: null, + }; + }); + describe("makeClaim", () => { + let transactionHandler: ArbToEthTransactionHandler; + const { routeConfig } = getBridgeConfig(chainId); + const deposit = routeConfig[Network.TESTNET].deposit; + beforeEach(() => { + const mockClaim = jest.fn().mockResolvedValue({ hash: "0x1234" }) as any; + mockClaim.estimateGas = jest.fn().mockResolvedValue(BigInt(100000)); + veaOutbox["claim(uint256,bytes32)"] = mockClaim; + + transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); + veaOutbox.claim.mockResolvedValue({ hash: "0x1234" }); + }); + + it("should make a claim and set pending claim trnx", async () => { + // Mock checkTransactionPendingStatus to always return false + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); + + await transactionHandler.makeClaim(claim.stateRoot as string); + + expect(veaOutbox.claim).toHaveBeenCalledWith(epoch, claim.stateRoot, { + gasLimit: BigInt(100000), + value: deposit, + }); + expect(transactionHandler.transactions.claimTxn).toEqual({ + hash: "0x1234", + broadcastedTimestamp: expect.any(Number), + }); + }); + + it("should not make a claim if a claim transaction is pending", async () => { + // Mock checkTransactionPendingStatus to always return true + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); + await transactionHandler.makeClaim(claim.stateRoot as string); + expect(veaOutbox.claim).not.toHaveBeenCalled(); + expect(transactionHandler.transactions.claimTxn).toBeNull(); + }); + }); + + describe("challengeClaim", () => { + let transactionHandler: ArbToEthTransactionHandler; + beforeEach(() => { + transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); + transactionHandler.claim = claim; + }); + + it("should throw error if claim is not set", async () => { + transactionHandler.claim = null; + await expect(transactionHandler.challengeClaim()).rejects.toThrow(ClaimNotSetError); + }); + + it("should not challenge claim if txn is pending", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); + transactionHandler.transactions.challengeTxn = { hash: "0x1234", broadcastedTimestamp: 1000 }; + await transactionHandler.challengeClaim(); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( + transactionHandler.transactions.challengeTxn, + ContractType.OUTBOX, + expect.any(Number) + ); + expect( + veaOutbox["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"] + ).not.toHaveBeenCalled(); + }); + + it("should challenge claim", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); + const mockChallenge = jest.fn().mockResolvedValue({ hash: "0x1234" }) as any; + mockChallenge.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.any(Number) + ); + expect(transactionHandler.transactions.challengeTxn).toEqual({ + hash: "0x1234", + broadcastedTimestamp: expect.any(Number), + }); + }); + + it.todo("should set challengeTxn as completed when txn is final"); + }); + + describe("sendSnapshot", () => { + let transactionHandler: ArbToEthTransactionHandler; + beforeEach(() => { + transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); + transactionHandler.claim = claim; + }); + + it("should send snapshot", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); + veaInbox.sendSnapshot.mockResolvedValue({ hash: "0x1234" }); + await transactionHandler.sendSnapshot(); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( + null, + ContractType.INBOX, + expect.any(Number) + ); + expect(transactionHandler.transactions.sendSnapshotTxn).toEqual({ + hash: "0x1234", + broadcastedTimestamp: expect.any(Number), + }); + }); + + it("should not send snapshot if txn is pending", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); + transactionHandler.transactions.sendSnapshotTxn = { hash: "0x1234", broadcastedTimestamp: 1000 }; + await transactionHandler.sendSnapshot(); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( + transactionHandler.transactions.sendSnapshotTxn, + ContractType.INBOX, + expect.any(Number) + ); + 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 mockMessageExecutor: any; + let transactionHandler: ArbToEthTransactionHandler; + beforeEach(() => { + mockMessageExecutor = jest.fn(); + transactionHandler = new ArbToEthTransactionHandler(transactionHandlerParams); + }); + it("should resolve challenged claim", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(0); + transactionHandler.transactions.sendSnapshotTxn = { hash: "0x1234", broadcastedTimestamp: 1000 }; + mockMessageExecutor.mockResolvedValue({ hash: "0x1234" }); + await transactionHandler.resolveChallengedClaim( + transactionHandler.transactions.sendSnapshotTxn.hash, + mockMessageExecutor + ); + expect(transactionHandler.transactions.executeSnapshotTxn).toEqual({ + hash: "0x1234", + broadcastedTimestamp: expect.any(Number), + }); + }); + + it("should not resolve challenged claim if txn is pending", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); + transactionHandler.transactions.executeSnapshotTxn = { hash: "0x1234", broadcastedTimestamp: 1000 }; + await transactionHandler.resolveChallengedClaim("0x1234", mockMessageExecutor); + expect(transactionHandler.checkTransactionStatus).toHaveBeenCalledWith( + transactionHandler.transactions.executeSnapshotTxn, + ContractType.OUTBOX, + expect.any(Number) + ); + }); + }); +}); diff --git a/validator-cli/src/utils/transactionHandlers/arbToEthHandler.ts b/validator-cli/src/utils/transactionHandlers/arbToEthHandler.ts new file mode 100644 index 00000000..7a74bdcd --- /dev/null +++ b/validator-cli/src/utils/transactionHandlers/arbToEthHandler.ts @@ -0,0 +1,157 @@ +import { VeaInboxArbToEth, VeaOutboxArbToEth, VeaOutboxArbToEthDevnet } from "@kleros/vea-contracts/typechain-types"; +import { toBigInt } from "ethers"; +import { + BaseTransactionHandler, + BaseTransactionHandlerConstructor, + ContractType, + TransactionStatus, + Transaction, + Transactions, +} from "./baseTransactionHandler"; +import { BotEvents } from "../../utils/botEvents"; +import { ClaimNotSetError } from "../../utils/errors"; +import { getBridgeConfig, Network } from "../../consts/bridgeRoutes"; +import { messageExecutor } from "../../utils/arbMsgExecutor"; + +// Handler for Arbitrum → Ethereum claims and snapshots, now leveraging BaseTransactionHandler +export class ArbToEthTransactionHandler extends BaseTransactionHandler { + constructor(opts: BaseTransactionHandlerConstructor) { + super(opts); + } + + /** + * Make a claim on the Ethereum outbox. + */ + public async makeClaim(stateRoot: string): Promise { + this.emitter.emit(BotEvents.CLAIMING, this.epoch); + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.claimTxn, ContractType.OUTBOX, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) { + return; + } + + const { routeConfig } = getBridgeConfig(this.chainId); + const { deposit } = routeConfig[this.network]; + // Estimate gas and send claim with deposit + const gasLimit = await this.veaOutbox["claim(uint256,bytes32)"].estimateGas(this.epoch, stateRoot, { + value: deposit, + }); + const tx = await this.veaOutbox.claim(this.epoch, stateRoot, { + value: deposit, + gasLimit, + }); + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Claim"); + this.transactions.claimTxn = { hash: tx.hash, broadcastedTimestamp: now }; + } + + /** + * Challenge an existing claim on the Ethereum outbox. + */ + public async challengeClaim(): Promise { + this.emitter.emit(BotEvents.CHALLENGING, this.epoch); + if (!this.claim) throw new ClaimNotSetError(); + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.challengeTxn, ContractType.OUTBOX, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) { + return; + } + + const { routeConfig } = getBridgeConfig(this.chainId); + const { deposit } = routeConfig[this.network]; + const gasEstimate = await this.veaOutbox[ + "challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))" + ].estimateGas(this.epoch, this.claim, { value: deposit }); + + // Profit-driven fee calculation + const maxFeePerGas = deposit / (toBigInt(gasEstimate) * BigInt(6)); + let maxPriorityFeePerGas = BigInt(6_667_000_000_000); + if (maxPriorityFeePerGas > maxFeePerGas) { + maxPriorityFeePerGas = maxFeePerGas; + } + + const tx = await this.veaOutbox["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"]( + this.epoch, + this.claim, + { + maxFeePerGas, + maxPriorityFeePerGas, + value: deposit, + gasLimit: gasEstimate, + } + ); + + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Challenge"); + this.transactions.challengeTxn = { hash: tx.hash, broadcastedTimestamp: now }; + } + + /** + * Send a snapshot from Arbitrum inbox to Ethereum outbox. + */ + public async sendSnapshot(): Promise { + this.emitter.emit(BotEvents.SENDING_SNAPSHOT, this.epoch); + if (!this.claim) throw new ClaimNotSetError(); + + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.sendSnapshotTxn, ContractType.INBOX, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) { + return; + } + + const tx = await this.veaInbox.sendSnapshot(this.epoch, this.claim); + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Send Snapshot"); + this.transactions.sendSnapshotTxn = { hash: tx.hash, broadcastedTimestamp: now }; + } + + /** + * Execute a challenged snapshot via the messageExecutor utility. + */ + public async resolveChallengedClaim(sendSnapshotHash: string, execFn = messageExecutor): Promise { + this.emitter.emit(BotEvents.EXECUTING_SNAPSHOT, this.epoch); + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.executeSnapshotTxn, ContractType.OUTBOX, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) { + return; + } + + const result = await execFn(sendSnapshotHash, this.veaInboxProvider, this.veaOutboxProvider); + this.emitter.emit(BotEvents.TXN_MADE, result.hash, this.epoch, "Execute Snapshot"); + this.transactions.executeSnapshotTxn = { hash: result.hash, broadcastedTimestamp: now }; + } +} + +// Devnet-only extension +export interface DevnetTransactions extends Transactions { + devnetAdvanceStateTxn: Transaction | null; +} + +export class ArbToEthDevnetTransactionHandler extends ArbToEthTransactionHandler { + public veaOutboxDevnet: VeaOutboxArbToEthDevnet; + public transactions: DevnetTransactions = { + ...this.transactions, + devnetAdvanceStateTxn: null, + }; + + constructor(opts: BaseTransactionHandlerConstructor) { + super(opts); + this.veaOutboxDevnet = opts.veaOutbox as VeaOutboxArbToEthDevnet; + } + + /** + * Advance the devnet state via a special call on the Devnet outbox. + */ + public async devnetAdvanceState(stateRoot: string): Promise { + this.emitter.emit(BotEvents.ADV_DEVNET, this.epoch); + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.devnetAdvanceStateTxn, ContractType.OUTBOX, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) { + return; + } + const { routeConfig } = getBridgeConfig(this.chainId); + const { deposit } = routeConfig[Network.DEVNET]; + const tx = await this.veaOutboxDevnet.devnetAdvanceState(this.epoch, stateRoot, { + value: deposit, + }); + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Advance Devnet State"); + this.transactions.devnetAdvanceStateTxn = { hash: tx.hash, broadcastedTimestamp: now }; + } +} diff --git a/validator-cli/src/utils/transactionHandlers/arbToGnosisHandler.test.ts b/validator-cli/src/utils/transactionHandlers/arbToGnosisHandler.test.ts new file mode 100644 index 00000000..57db7761 --- /dev/null +++ b/validator-cli/src/utils/transactionHandlers/arbToGnosisHandler.test.ts @@ -0,0 +1,216 @@ +import { ArbToGnosisTransactionHandler } from "./arbToGnosisHandler"; +import { getBridgeConfig, Network } from "../../consts/bridgeRoutes"; +import { getWallet, getWETH } from "../ethers"; +import { messageExecutor } from "../arbMsgExecutor"; +import { ClaimNotSetError } from "../errors"; +import { TransactionStatus, BaseTransactionHandlerConstructor } from "./baseTransactionHandler"; +import { MockEmitter } from "../emitter"; + +jest.mock("../../consts/bridgeRoutes", () => ({ + getBridgeConfig: jest.fn(), + Network: { TESTNET: "testnet" }, +})); +jest.mock("../ethers", () => ({ + getWallet: jest.fn(), + getWETH: jest.fn(), +})); +jest.mock("../arbMsgExecutor", () => ({ messageExecutor: jest.fn() })); + +describe("ArbToGnosisTransactionHandler", () => { + const mockEmitter = new MockEmitter(); + const chainId = 11155111; + const epoch = 42; + const network = Network.TESTNET as any; + const deposit = BigInt(1000); + const depositToken = "0xToken"; + const outboxRPC = "https://rpc"; + const sequencerDelayLimit = 5; + const minChallengePeriod = 3; + const routeConfig = { + [network]: { veaOutbox: { address: "0xOutbox" }, epochPeriod: 10, deposit }, + }; + + let inboxProvider: any; + let outboxProvider: any; + let routerProvider: any; + let veaInbox: any; + let veaOutbox: any; + let transactionHandler: ArbToGnosisTransactionHandler; + let transactionHandlerParams: BaseTransactionHandlerConstructor; + let claim: any; + let weth: any; + + beforeEach(() => { + // Mock bridge config + (getBridgeConfig as jest.Mock).mockReturnValue({ + depositToken, + outboxRPC, + sequencerDelayLimit, + minChallengePeriod, + routeConfig, + }); + + // Providers + inboxProvider = { getTransactionReceipt: jest.fn(), getBlock: jest.fn() }; + outboxProvider = { getTransactionReceipt: jest.fn(), getBlock: jest.fn() }; + routerProvider = { getTransactionReceipt: jest.fn(), getBlock: jest.fn() }; + + // Stub veaInbox/veaOutbox contract methods + veaInbox = { + sendSnapshot: jest.fn().mockResolvedValue({ hash: "0xsnap" }), + }; + veaOutbox = { + ["claim(uint256,bytes32)"]: { + estimateGas: jest.fn().mockResolvedValue(BigInt(12345)), + }, + claim: jest.fn().mockResolvedValue({ hash: "0xclaim" }), + ["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"]: { + estimateGas: jest.fn().mockResolvedValue(BigInt(54321)), + }, + challenge: jest.fn().mockResolvedValue({ hash: "0xchallenge" }), + }; + + // Default claim object + claim = { + stateRoot: "0xdead", + claimer: "0xabc", + timestampClaimed: 1000, + timestampVerification: 2000, + blocknumberVerification: 0, + honest: 1, + challenger: "0xdef", + }; + + transactionHandlerParams = { + chainId, + network, + epoch, + veaInbox: veaInbox, + veaOutbox: veaOutbox, + veaInboxProvider: inboxProvider, + veaOutboxProvider: outboxProvider, + veaRouterProvider: routerProvider, + emitter: mockEmitter, + claim: null, + }; + + // Instantiate with no claim by default + transactionHandler = new ArbToGnosisTransactionHandler(transactionHandlerParams); + weth = { + allowance: jest.fn().mockResolvedValue(BigInt(0)), + approve: jest.fn().mockResolvedValue({ wait: jest.fn().mockResolvedValue({}) }), + }; + (getWETH as jest.Mock).mockReturnValue(weth); + }); + + describe("approveWeth", () => { + beforeEach(() => { + (getWallet as jest.Mock).mockReturnValue({ address: "0xSigner" }); + }); + + it("should approve WETH when allowance < deposit and then claim", async () => { + await transactionHandler.approveWeth(); + expect(weth.allowance).toHaveBeenCalledWith("0xSigner", routeConfig[network].veaOutbox.address); + expect(weth.approve).toHaveBeenCalled(); + }); + + it("should not approve WETH if allowance >= deposit", async () => { + weth.allowance.mockResolvedValue(deposit); + await transactionHandler.makeClaim("0xroot"); + expect(weth.approve).not.toHaveBeenCalled(); + }); + }); + + describe("makeClaim()", () => { + it("should not claim if status is PENDING", async () => { + transactionHandler.claim = claim; + transactionHandler.transactions.claimTxn = { hash: "0x", broadcastedTimestamp: 0 }; + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.PENDING); + await transactionHandler.makeClaim("0xroot"); + expect(veaOutbox.claim).not.toHaveBeenCalled(); + }); + it("should make claim", async () => { + await transactionHandler.makeClaim("0xroot"); + expect(veaOutbox.claim).toHaveBeenCalledWith(epoch, "0xroot", { gasLimit: BigInt(12345) }); + expect(transactionHandler.transactions.claimTxn).toHaveProperty("hash", "0xclaim"); + }); + }); + + describe("challengeClaim()", () => { + beforeEach(() => { + transactionHandler = new ArbToGnosisTransactionHandler({ + chainId, + network, + epoch, + veaInbox: veaInbox, + veaOutbox: veaOutbox, + veaInboxProvider: inboxProvider, + veaOutboxProvider: outboxProvider, + emitter: mockEmitter, + claim, + }); + }); + + it("throws if claim not set", async () => { + const h = new ArbToGnosisTransactionHandler({ ...transactionHandlerParams, claim: null }); + await expect(h.challengeClaim()).rejects.toThrow(ClaimNotSetError); + }); + + it("challenges when status NOT_MADE", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.NOT_MADE); + await transactionHandler.challengeClaim(); + // Gas estimation + expect( + veaOutbox["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"].estimateGas + ).toHaveBeenCalledWith(epoch, claim); + // Challenge call with computed fees + expect(veaOutbox.challenge).toHaveBeenCalled(); + expect(transactionHandler.transactions.challengeTxn).toHaveProperty("hash", "0xchallenge"); + }); + + it("does not challenge if status PENDING", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.PENDING); + await transactionHandler.challengeClaim(); + expect(veaOutbox.challenge).not.toHaveBeenCalled(); + }); + }); + + describe("sendSnapshot()", () => { + it("throws if claim not set", async () => { + await expect(transactionHandler.sendSnapshot()).rejects.toThrow(ClaimNotSetError); + }); + + it("sends snapshot when none pending", async () => { + transactionHandler.claim = claim; + await transactionHandler.sendSnapshot(); + expect(veaInbox.sendSnapshot).toHaveBeenCalledWith(epoch, BigInt(3000000), claim); + expect(transactionHandler.transactions.sendSnapshotTxn).toHaveProperty("hash", "0xsnap"); + }); + + it("does nothing if pending", async () => { + transactionHandler.claim = claim; + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.PENDING); + transactionHandler.transactions.sendSnapshotTxn = { hash: "0x", broadcastedTimestamp: 0 }; + await transactionHandler.sendSnapshot(); + expect(veaInbox.sendSnapshot).not.toHaveBeenCalled(); + }); + }); + + describe("resolveChallengedClaim()", () => { + beforeEach(() => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.NOT_MADE); + (messageExecutor as jest.Mock).mockResolvedValue({ hash: "0xexec" }); + }); + + it("throws if claim not set", async () => { + await expect(transactionHandler.resolveChallengedClaim("0xtx")).rejects.toThrow(ClaimNotSetError); + }); + + it("executes and records transaction", async () => { + transactionHandler = new ArbToGnosisTransactionHandler({ ...transactionHandlerParams, claim }); + await transactionHandler.resolveChallengedClaim("0xtx"); + expect(messageExecutor).toHaveBeenCalledWith("0xtx", inboxProvider, routerProvider); + expect(transactionHandler.transactions.executeSnapshotTxn).toHaveProperty("hash", "0xexec"); + }); + }); +}); diff --git a/validator-cli/src/utils/transactionHandlers/arbToGnosisHandler.ts b/validator-cli/src/utils/transactionHandlers/arbToGnosisHandler.ts new file mode 100644 index 00000000..3192fa05 --- /dev/null +++ b/validator-cli/src/utils/transactionHandlers/arbToGnosisHandler.ts @@ -0,0 +1,148 @@ +import { + VeaInboxArbToGnosis, + VeaOutboxArbToGnosis, + VeaOutboxArbToGnosisDevnet, +} from "@kleros/vea-contracts/typechain-types"; +import { toBigInt } from "ethers"; +import { + BaseTransactionHandler, + BaseTransactionHandlerConstructor, + ContractType, + TransactionStatus, + Transaction, + Transactions, +} from "./baseTransactionHandler"; +import { BotEvents } from "../botEvents"; +import { ClaimNotSetError } from "../errors"; +import { getBridgeConfig, Network } from "../../consts/bridgeRoutes"; +import { getWETH, getWallet } from "../ethers"; +import { messageExecutor } from "../arbMsgExecutor"; + +export class ArbToGnosisTransactionHandler extends BaseTransactionHandler { + constructor(opts: BaseTransactionHandlerConstructor) { + super(opts); + } + + public async approveWeth(): Promise { + const { depositToken, outboxRPC, routeConfig } = getBridgeConfig(this.chainId); + const { veaOutbox, deposit } = routeConfig[this.network]; + const privateKey = process.env.PRIVATE_KEY!; + const signer = getWallet(privateKey, outboxRPC); + + const weth = getWETH(depositToken, privateKey, outboxRPC); + const currentAllowance: bigint = await weth.allowance(signer.address, veaOutbox.address); + if (currentAllowance < deposit) { + const approvalAmount = deposit * BigInt(10); // Approving for 10 claims + const approveTx = await weth.approve(routeConfig[Network.TESTNET].veaOutbox.address, deposit * approvalAmount); + await approveTx.wait(); + } + } + + public async makeClaim(stateRoot: string): Promise { + this.emitter.emit(BotEvents.CLAIMING, this.epoch); + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.claimTxn, ContractType.OUTBOX, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) return; + + // Approves WETH for the claim if not already approved + await this.approveWeth(); + + const gasEstimate = await this.veaOutbox["claim(uint256,bytes32)"].estimateGas(this.epoch, stateRoot); + const tx = await this.veaOutbox.claim(this.epoch, stateRoot, { gasLimit: gasEstimate }); + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Claim"); + this.transactions.claimTxn = { hash: tx.hash, broadcastedTimestamp: now }; + } + + public async challengeClaim(): Promise { + this.emitter.emit(BotEvents.CHALLENGING, this.epoch); + if (!this.claim) throw new ClaimNotSetError(); + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.challengeTxn, ContractType.OUTBOX, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) return; + + const gasEstimate = await this.veaOutbox[ + "challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))" + ].estimateGas(this.epoch, this.claim); + const { routeConfig } = getBridgeConfig(this.chainId); + const { deposit } = routeConfig[this.network]; + const maxFeePerGasProfitable = deposit / (toBigInt(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 tx = await this.veaOutbox.challenge(this.epoch, this.claim, { + maxFeePerGas: maxFeePerGasProfitable, + maxPriorityFeePerGas: maxPriorityFeePerGasMEV, + gasLimit: gasEstimate, + }); + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Challenge"); + this.transactions.challengeTxn = { hash: tx.hash, broadcastedTimestamp: now }; + } + + public async sendSnapshot(): Promise { + this.emitter.emit(BotEvents.SENDING_SNAPSHOT, this.epoch); + if (!this.claim) throw new ClaimNotSetError(); + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.sendSnapshotTxn, ContractType.INBOX, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) return; + + const ambGasLimit = BigInt(3000000); + const tx = await this.veaInbox.sendSnapshot(this.epoch, ambGasLimit, this.claim); + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Send Snapshot"); + this.transactions.sendSnapshotTxn = { hash: tx.hash, broadcastedTimestamp: now }; + } + + public async resolveChallengedClaim(sendSnapshotTxn: string): Promise { + this.emitter.emit(BotEvents.EXECUTING_SNAPSHOT, this.epoch); + if (!this.claim) throw new ClaimNotSetError(); + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.executeSnapshotTxn, ContractType.ROUTER, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) return; + const msgExecuteTrnx = await messageExecutor(sendSnapshotTxn, this.veaInboxProvider, this.veaRouterProvider); + this.emitter.emit(BotEvents.TXN_MADE, msgExecuteTrnx.hash, this.epoch, "Execute Snapshot"); + this.transactions.executeSnapshotTxn = { + hash: msgExecuteTrnx.hash, + broadcastedTimestamp: now, + }; + } +} + +/** + * Devnet-only extension for Arb→Gnosis handler + */ +export interface GnosisDevnetTransactions extends Transactions { + devnetAdvanceStateTxn: Transaction | null; +} + +export class ArbToGnosisDevnetTransactionHandler extends ArbToGnosisTransactionHandler { + public veaOutboxDevnet: VeaOutboxArbToGnosisDevnet; + public transactions: GnosisDevnetTransactions = { + ...(this.transactions as Transactions), + devnetAdvanceStateTxn: null, + }; + + constructor(opts: BaseTransactionHandlerConstructor) { + super(opts); + this.veaOutboxDevnet = opts.veaOutbox as VeaOutboxArbToGnosisDevnet; + } + + /** + * Advance the devnet state via a special call on the Devnet outbox. + */ + public async devnetAdvanceState(stateRoot: string): Promise { + this.emitter.emit(BotEvents.ADV_DEVNET, this.epoch); + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.devnetAdvanceStateTxn, ContractType.OUTBOX, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) return; + await this.approveWeth(); + const { routeConfig } = getBridgeConfig(this.chainId); + const { deposit } = routeConfig[Network.DEVNET]; + const tx = await this.veaOutboxDevnet.devnetAdvanceState(this.epoch, stateRoot, { + value: deposit, + }); + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Advance Devnet State"); + this.transactions.devnetAdvanceStateTxn = { hash: tx.hash, broadcastedTimestamp: now }; + } +} diff --git a/validator-cli/src/utils/transactionHandlers/baseTransactionHandler.test.ts b/validator-cli/src/utils/transactionHandlers/baseTransactionHandler.test.ts new file mode 100644 index 00000000..14e85a1a --- /dev/null +++ b/validator-cli/src/utils/transactionHandlers/baseTransactionHandler.test.ts @@ -0,0 +1,267 @@ +import { + BaseTransactionHandler, + BaseTransactionHandlerConstructor, + ContractType, + TransactionStatus, + MAX_PENDING_CONFIRMATIONS, + Transaction, +} from "./baseTransactionHandler"; +import { BotEvents } from "../botEvents"; +import { ClaimNotSetError } from "../errors"; +import { getBridgeConfig, Network } from "../../consts/bridgeRoutes"; +import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; +import { MockEmitter } from "../emitter"; + +// Concrete subclass to enable testing of BaseTransactionHandler +class DummyHandler extends BaseTransactionHandler { + public async makeClaim(_stateRoot: string): Promise { + return; + } + public async challengeClaim(): Promise { + return; + } + public async sendSnapshot(): Promise { + return; + } + public async resolveChallengedClaim(_sendSnapshotTxnHash: string): Promise { + return; + } +} + +describe("BaseTransactionHandler", () => { + let veaInboxProvider: any; + let veaOutboxProvider: any; + let veaInbox: any; + let veaOutbox: any; + let transactionHandler: BaseTransactionHandler; + let transactionHandlerParams: BaseTransactionHandlerConstructor; + const chainId = 11155111; // Using Sepolia for bridge config + const epoch = 42; + const network = Network.TESTNET; + const claim: ClaimStruct = { + stateRoot: "0xdead", + claimer: "0xabc", + timestampClaimed: 1000, + timestampVerification: 2000, + blocknumberVerification: 0, + honest: 1, + challenger: "0xdef", + }; + const mockEmitter = new MockEmitter(); + + beforeEach(() => { + veaOutbox = { + withdrawChallengeDeposit: jest.fn(), + ["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"]: jest.fn(), + claim: jest.fn(), + startVerification: jest.fn(), + verifySnapshot: jest.fn(), + withdrawClaimDeposit: jest.fn(), + }; + veaInbox = { + sendSnapshot: jest.fn(), + saveSnapshot: jest.fn(), + }; + + // Providers + veaInboxProvider = { getTransactionReceipt: jest.fn(), getBlock: jest.fn() }; + veaOutboxProvider = { getTransactionReceipt: jest.fn(), getBlock: jest.fn() }; + + // veaInbox & veaOutbox mocks + veaInbox = { + saveSnapshot: jest.fn().mockResolvedValue({ hash: "0x1" }), + }; + veaOutbox = { + startVerification: jest.fn().mockResolvedValue({ hash: "0xa" }), + verifySnapshot: jest.fn().mockResolvedValue({ hash: "0xb" }), + withdrawClaimDeposit: jest.fn().mockResolvedValue({ hash: "0xc" }), + withdrawChallengeDeposit: jest.fn().mockResolvedValue({ hash: "0xd" }), + }; + + transactionHandlerParams = { + chainId, + network: Network.TESTNET, + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + emitter: mockEmitter, + claim: null, + }; + transactionHandler = new DummyHandler({ ...transactionHandlerParams }); + }); + + describe("checkTransactionStatus", () => { + let finalityBlock: number = 100; + let mockBroadcastedTimestamp: number = 1000; + beforeEach(() => { + veaInboxProvider.getBlock.mockResolvedValue({ number: finalityBlock }); + }); + + it("should return 2 if transaction is not final", async () => { + jest.spyOn(mockEmitter, "emit"); + veaInboxProvider.getTransactionReceipt.mockResolvedValue({ + blockNumber: finalityBlock - (MAX_PENDING_CONFIRMATIONS - 1), + }); + const trnx: Transaction = { hash: "0x123456", broadcastedTimestamp: mockBroadcastedTimestamp }; + const status = await transactionHandler.checkTransactionStatus( + trnx, + ContractType.INBOX, + mockBroadcastedTimestamp + 1 + ); + expect(status).toEqual(2); + expect(mockEmitter.emit).toHaveBeenCalledWith(BotEvents.TXN_NOT_FINAL, trnx.hash, 1); + }); + + it("should return 1 if transaction is pending", async () => { + jest.spyOn(mockEmitter, "emit"); + veaInboxProvider.getTransactionReceipt.mockResolvedValue(null); + const trnx: Transaction = { hash: "0x123456", broadcastedTimestamp: mockBroadcastedTimestamp }; + const status = await transactionHandler.checkTransactionStatus( + trnx, + ContractType.INBOX, + mockBroadcastedTimestamp + 1 + ); + expect(status).toEqual(1); + expect(mockEmitter.emit).toHaveBeenCalledWith(BotEvents.TXN_PENDING, trnx.hash); + }); + + it("should return 3 if transaction is final", async () => { + jest.spyOn(mockEmitter, "emit"); + veaInboxProvider.getTransactionReceipt.mockResolvedValue({ + blockNumber: finalityBlock - MAX_PENDING_CONFIRMATIONS, + }); + const trnx: Transaction = { hash: "0x123456", broadcastedTimestamp: mockBroadcastedTimestamp }; + + const status = await transactionHandler.checkTransactionStatus( + trnx, + ContractType.INBOX, + mockBroadcastedTimestamp + 1 + ); + expect(status).toEqual(3); + expect(mockEmitter.emit).toHaveBeenCalledWith(BotEvents.TXN_FINAL, trnx.hash, MAX_PENDING_CONFIRMATIONS); + }); + + it("should return 0 if transaction hash is null", async () => { + const trnx = null; + const status = await transactionHandler.checkTransactionStatus( + trnx, + ContractType.INBOX, + mockBroadcastedTimestamp + ); + expect(status).toEqual(0); + }); + }); + + describe("saveSnapshot()", () => { + it("saves snapshot when none pending", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.NOT_MADE); + await transactionHandler.saveSnapshot(); + expect(veaInbox.saveSnapshot).toHaveBeenCalled(); + expect(transactionHandler.transactions.saveSnapshotTxn).toEqual(expect.objectContaining({ hash: "0x1" })); + }); + + it("does nothing when snapshot pending", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.PENDING); + transactionHandler.transactions.saveSnapshotTxn = { hash: "0x1", broadcastedTimestamp: Date.now() }; + await transactionHandler.saveSnapshot(); + expect(veaInbox.saveSnapshot).not.toHaveBeenCalled(); + }); + }); + + describe("startVerification()", () => { + const cfg = getBridgeConfig(chainId); + const flipTime = Number(claim.timestampClaimed) + cfg.sequencerDelayLimit + cfg.routeConfig[network].epochPeriod; + it("throws if claim not set", async () => { + transactionHandler = new DummyHandler({ ...transactionHandlerParams }); + await expect(transactionHandler.startVerification(flipTime)).rejects.toThrow(ClaimNotSetError); + }); + + it("should not start verification if timeout has not passed", async () => { + transactionHandler = new DummyHandler({ ...transactionHandlerParams, claim: claim }); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.NOT_MADE); + await transactionHandler.startVerification(flipTime - 1); + expect(veaOutbox.startVerification).not.toHaveBeenCalled(); + }); + + it("starts verification when ready", async () => { + transactionHandler = new DummyHandler({ ...transactionHandlerParams, claim: claim }); + + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.NOT_MADE); + await transactionHandler.startVerification(flipTime); + expect(veaOutbox.startVerification).toHaveBeenCalledWith(epoch, claim); + expect(transactionHandler.transactions.startVerificationTxn).toHaveProperty("hash", "0xa"); + }); + it("should not start verification if a startVerification transaction is pending", async () => { + transactionHandler = new DummyHandler({ ...transactionHandlerParams, claim: claim }); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(1); + + await transactionHandler.startVerification(flipTime); + + expect(veaOutbox.startVerification).not.toHaveBeenCalled(); + expect(transactionHandler.transactions.startVerificationTxn).toBeNull(); + }); + }); + + describe("verifySnapshot()", () => { + const cfg = getBridgeConfig(chainId); + const flipTime = Number(claim.timestampVerification) + cfg.minChallengePeriod; + + it("throws if claim not set", async () => { + const h = new DummyHandler({ ...transactionHandlerParams, claim: null }); + await expect(h.verifySnapshot(flipTime)).rejects.toThrow(ClaimNotSetError); + }); + + it("does nothing when status is pending", async () => { + transactionHandler = new DummyHandler({ ...transactionHandlerParams, claim: claim }); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.PENDING); + await transactionHandler.verifySnapshot(flipTime); + expect(veaOutbox.verifySnapshot).not.toHaveBeenCalled(); + }); + + it("should not verify snapshot", async () => { + transactionHandler = new DummyHandler({ ...transactionHandlerParams, claim: claim }); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.NOT_MADE); + await transactionHandler.verifySnapshot(flipTime - 1); + expect(veaOutbox.verifySnapshot).not.toHaveBeenCalled(); + }); + + it("verifies snapshot when ready", async () => { + transactionHandler = new DummyHandler({ ...transactionHandlerParams, claim: claim }); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.NOT_MADE); + await transactionHandler.verifySnapshot(flipTime); + expect(veaOutbox.verifySnapshot).toHaveBeenCalledWith(epoch, claim); + expect(transactionHandler.transactions.verifySnapshotTxn).toHaveProperty("hash", "0xb"); + }); + }); + + describe("withdrawClaimDeposit()", () => { + it("throws if claim not set", async () => { + await expect(transactionHandler.withdrawClaimDeposit()).rejects.toThrow(ClaimNotSetError); + }); + + it("withdraws when none pending", async () => { + transactionHandler = new DummyHandler({ ...transactionHandlerParams, claim: claim }); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.NOT_MADE); + await transactionHandler.withdrawClaimDeposit(); + expect(veaOutbox.withdrawClaimDeposit).toHaveBeenCalledWith(epoch, claim); + expect(transactionHandler.transactions.withdrawClaimDepositTxn).toHaveProperty("hash", "0xc"); + }); + }); + + describe("withdrawChallengeDeposit()", () => { + it("throws if claim not set", async () => { + const h = new DummyHandler({ ...transactionHandlerParams, claim: null }); + await expect(h.withdrawChallengeDeposit()).rejects.toThrow(ClaimNotSetError); + }); + + it("withdraws when none pending", async () => { + transactionHandler = new DummyHandler({ ...transactionHandlerParams, claim: claim }); + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(TransactionStatus.NOT_MADE); + await transactionHandler.withdrawChallengeDeposit(); + expect(veaOutbox.withdrawChallengeDeposit).toHaveBeenCalledWith(epoch, claim); + expect(transactionHandler.transactions.withdrawChallengeDepositTxn).toHaveProperty("hash", "0xd"); + }); + }); +}); diff --git a/validator-cli/src/utils/transactionHandlers/baseTransactionHandler.ts b/validator-cli/src/utils/transactionHandlers/baseTransactionHandler.ts new file mode 100644 index 00000000..a2c5af69 --- /dev/null +++ b/validator-cli/src/utils/transactionHandlers/baseTransactionHandler.ts @@ -0,0 +1,303 @@ +import { JsonRpcProvider } from "@ethersproject/providers"; +import { BotEvents } from "../botEvents"; +import { ClaimNotSetError } from "../errors"; +import { getBridgeConfig, Network } from "../../consts/bridgeRoutes"; +import { defaultEmitter } from "../emitter"; +import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; + +export interface ITransactionHandler { + /* Public properties */ + chainId: number; + network: Network; + epoch: number; + veaInbox: any; + veaOutbox: any; + veaInboxProvider: JsonRpcProvider; + veaOutboxProvider: JsonRpcProvider; + veaRouterProvider?: JsonRpcProvider; + emitter: typeof defaultEmitter; + claim: ClaimStruct | null; + transactions: Transactions; + + /* Public methods */ + checkTransactionStatus( + trnx: Transaction | null, + contract: ContractType, + currentTime: number + ): Promise; + makeClaim(stateRoot: string): Promise; + startVerification(currentTimestamp: number): Promise; + verifySnapshot(currentTimestamp: number): Promise; + withdrawClaimDeposit(): Promise; + challengeClaim(): Promise; + withdrawChallengeDeposit(): Promise; + saveSnapshot(): Promise; + sendSnapshot(): Promise; + resolveChallengedClaim(sendSnapshotTxn: string): Promise; + routeSnapshot?(): Promise; +} + +export enum ContractType { + INBOX = "inbox", + OUTBOX = "outbox", + ROUTER = "router", +} + +export enum TransactionStatus { + NOT_MADE = 0, + PENDING = 1, + NOT_FINAL = 2, + FINAL = 3, + EXPIRED = 4, +} + +export type Transaction = { + hash: string; + broadcastedTimestamp: number; +}; + +export type Transactions = { + claimTxn: Transaction | null; + withdrawClaimDepositTxn: Transaction | null; + startVerificationTxn: Transaction | null; + verifySnapshotTxn: Transaction | null; + challengeTxn: Transaction | null; + withdrawChallengeDepositTxn: Transaction | null; + saveSnapshotTxn: Transaction | null; + sendSnapshotTxn: Transaction | null; + executeSnapshotTxn: Transaction | null; + devnetAdvanceStateTxn?: Transaction | null; +}; + +export const MAX_PENDING_TIME = 5 * 60 * 1000; // 5 minutes +export const MAX_PENDING_CONFIRMATIONS = 10; + +export interface BaseTransactionHandlerConstructor { + chainId: number; + network: Network; + epoch: number; + veaInbox: any; + veaOutbox: any; + veaInboxProvider: JsonRpcProvider; + veaOutboxProvider: JsonRpcProvider; + veaRouterProvider?: JsonRpcProvider; + emitter: typeof defaultEmitter; + claim: ClaimStruct | null; +} + +/** + * Abstract base that implements all of the “status check” logic + * and the shared handlers: + * - startVerification + * - verifySnapshot + * - withdrawClaimDeposit + * - withdrawChallengeDeposit + * - saveSnapshot + * + * Subclasses must implement: + * - makeClaim + * - challengeClaim + * - sendSnapshot + * - resolveChallengedClaim + * - (optionally) routeAssets + */ +export abstract class BaseTransactionHandler implements ITransactionHandler { + public chainId: number; + public network: Network; + public epoch: number; + public veaInbox: Inbox; + public veaOutbox: Outbox; + public veaInboxProvider: JsonRpcProvider; + public veaOutboxProvider: JsonRpcProvider; + public veaRouterProvider?: JsonRpcProvider; + public emitter: typeof defaultEmitter; + public claim: ClaimStruct | null; + public transactions: Transactions = { + claimTxn: null, + withdrawClaimDepositTxn: null, + startVerificationTxn: null, + verifySnapshotTxn: null, + challengeTxn: null, + withdrawChallengeDepositTxn: null, + saveSnapshotTxn: null, + sendSnapshotTxn: null, + executeSnapshotTxn: null, + }; + + constructor({ + chainId, + network, + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + emitter, + claim, + veaRouterProvider, + }: BaseTransactionHandlerConstructor) { + this.chainId = chainId; + this.network = network; + this.epoch = epoch; + this.veaInbox = veaInbox; + this.veaOutbox = veaOutbox; + this.veaInboxProvider = veaInboxProvider; + this.veaOutboxProvider = veaOutboxProvider; + this.veaRouterProvider = veaRouterProvider; + this.emitter = emitter; + this.claim = claim; + } + + public async checkTransactionStatus( + trnx: Transaction | null, + contract: ContractType, + currentTime: number + ): Promise { + let provider: JsonRpcProvider; + switch (contract) { + case ContractType.INBOX: + provider = this.veaInboxProvider; + break; + case ContractType.OUTBOX: + provider = this.veaOutboxProvider; + break; + case ContractType.ROUTER: + provider = this.veaRouterProvider; + break; + } + + if (!trnx) return TransactionStatus.NOT_MADE; + + const receipt = await provider.getTransactionReceipt(trnx.hash); + if (!receipt) { + this.emitter.emit(BotEvents.TXN_PENDING, trnx.hash); + if (currentTime - trnx.broadcastedTimestamp > MAX_PENDING_TIME) { + this.emitter.emit(BotEvents.TXN_EXPIRED, trnx.hash); + return TransactionStatus.EXPIRED; + } + return TransactionStatus.PENDING; + } + + const block = await provider.getBlock("latest"); + const confirmations = block.number - receipt.blockNumber; + if (confirmations >= MAX_PENDING_CONFIRMATIONS) { + this.emitter.emit(BotEvents.TXN_FINAL, trnx.hash, confirmations); + return TransactionStatus.FINAL; + } + + this.emitter.emit(BotEvents.TXN_NOT_FINAL, trnx.hash, MAX_PENDING_CONFIRMATIONS - confirmations); + return TransactionStatus.NOT_FINAL; + } + + public async startVerification(currentTimestamp: number) { + this.emitter.emit(BotEvents.STARTING_VERIFICATION, this.epoch); + if (!this.claim) throw new ClaimNotSetError(); + + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.startVerificationTxn, ContractType.OUTBOX, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) return; + + const cfg = getBridgeConfig(this.chainId); + const timeOver = + currentTimestamp - + Number(this.claim.timestampClaimed) - + cfg.sequencerDelayLimit - + cfg.routeConfig[this.network].epochPeriod; + + if (timeOver < 0) { + this.emitter.emit(BotEvents.VERIFICATION_CANT_START, this.epoch, -timeOver); + return; + } + + const tx = await (this.veaOutbox as any).startVerification(this.epoch, this.claim); + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Start Verification"); + this.transactions.startVerificationTxn = { + hash: tx.hash, + broadcastedTimestamp: now, + }; + } + + public async verifySnapshot(currentTimestamp: number) { + this.emitter.emit(BotEvents.VERIFYING_SNAPSHOT, this.epoch); + if (!this.claim) throw new ClaimNotSetError(); + + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.verifySnapshotTxn, ContractType.OUTBOX, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) return; + + const cfg = getBridgeConfig(this.chainId); + const timeLeft = currentTimestamp - Number(this.claim.timestampVerification) - cfg.minChallengePeriod; + + if (timeLeft < 0) { + this.emitter.emit(BotEvents.CANT_VERIFY_SNAPSHOT, this.epoch, -timeLeft); + return; + } + + const tx = await (this.veaOutbox as any).verifySnapshot(this.epoch, this.claim); + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Verify Snapshot"); + this.transactions.verifySnapshotTxn = { + hash: tx.hash, + broadcastedTimestamp: now, + }; + } + + public async withdrawClaimDeposit() { + this.emitter.emit(BotEvents.WITHDRAWING_CLAIM_DEPOSIT, this.epoch); + if (!this.claim) throw new ClaimNotSetError(); + + const now = Date.now(); + const status = await this.checkTransactionStatus( + this.transactions.withdrawClaimDepositTxn, + ContractType.OUTBOX, + now + ); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) return; + + const tx = await (this.veaOutbox as any).withdrawClaimDeposit(this.epoch, this.claim); + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Withdraw Claim Deposit"); + this.transactions.withdrawClaimDepositTxn = { + hash: tx.hash, + broadcastedTimestamp: now, + }; + } + + public async withdrawChallengeDeposit() { + this.emitter.emit(BotEvents.WITHDRAWING_CHALLENGE_DEPOSIT, this.epoch); + if (!this.claim) throw new ClaimNotSetError(); + + const now = Date.now(); + const status = await this.checkTransactionStatus( + this.transactions.withdrawChallengeDepositTxn, + ContractType.OUTBOX, + now + ); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) return; + + const tx = await (this.veaOutbox as any).withdrawChallengeDeposit(this.epoch, this.claim); + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Withdraw Challenge Deposit"); + this.transactions.withdrawChallengeDepositTxn = { + hash: tx.hash, + broadcastedTimestamp: now, + }; + } + + public async saveSnapshot() { + this.emitter.emit(BotEvents.SAVING_SNAPSHOT, this.epoch); + + const now = Date.now(); + const status = await this.checkTransactionStatus(this.transactions.saveSnapshotTxn, ContractType.INBOX, now); + if (status !== TransactionStatus.NOT_MADE && status !== TransactionStatus.EXPIRED) return; + + const tx = await (this.veaInbox as any).saveSnapshot(); + this.emitter.emit(BotEvents.TXN_MADE, tx.hash, this.epoch, "Save Snapshot"); + this.transactions.saveSnapshotTxn = { + hash: tx.hash, + broadcastedTimestamp: now, + }; + } + + public abstract makeClaim(stateRoot: string): Promise; + public abstract challengeClaim(): Promise; + public abstract sendSnapshot(): Promise; + public abstract resolveChallengedClaim(sendSnapshotTxn: string): Promise; +} diff --git a/validator-cli/src/utils/transactionHandlers/index.ts b/validator-cli/src/utils/transactionHandlers/index.ts new file mode 100644 index 00000000..6a840ee5 --- /dev/null +++ b/validator-cli/src/utils/transactionHandlers/index.ts @@ -0,0 +1,39 @@ +import { ITransactionHandler, BaseTransactionHandler } from "./baseTransactionHandler"; +import { ArbToEthTransactionHandler, ArbToEthDevnetTransactionHandler } from "./arbToEthHandler"; +import { ArbToGnosisTransactionHandler, ArbToGnosisDevnetTransactionHandler } from "./arbToGnosisHandler"; +import { InvalidNetworkError, NotDefinedError } from "../../utils/errors"; +import { Network } from "../../consts/bridgeRoutes"; +export { + ArbToEthTransactionHandler, + ArbToEthDevnetTransactionHandler, + ArbToGnosisTransactionHandler, + ArbToGnosisDevnetTransactionHandler, + BaseTransactionHandler, + ITransactionHandler, +}; + +export interface IDevnetTransactionHandler extends ITransactionHandler { + devnetAdvanceState(stateRoot: string): Promise; +} + +export const getTransactionHandler = (chainId: number, network: Network) => { + if (chainId === 11155111) { + if (network === Network.DEVNET) { + return ArbToEthDevnetTransactionHandler; + } else if (network === Network.TESTNET) { + return ArbToEthTransactionHandler; + } else { + throw new InvalidNetworkError(`${network}(transactionHandler)`); + } + } else if (chainId === 10200) { + if (network === Network.DEVNET) { + return ArbToGnosisDevnetTransactionHandler; + } else if (network === Network.TESTNET) { + return ArbToGnosisTransactionHandler; + } else { + throw new InvalidNetworkError(`${network}(transactionHandler)`); + } + } else { + throw new NotDefinedError("Transaction Handler"); + } +}; diff --git a/validator-cli/src/watcher.ts b/validator-cli/src/watcher.ts index 6409be6c..43fcd092 100644 --- a/validator-cli/src/watcher.ts +++ b/validator-cli/src/watcher.ts @@ -1,8 +1,7 @@ import { JsonRpcProvider } from "@ethersproject/providers"; import { getBridgeConfig, Network } from "./consts/bridgeRoutes"; -import { getTransactionHandler, getVeaInbox, getVeaOutbox } from "./utils/ethers"; +import { getVeaInbox, getVeaOutbox } from "./utils/ethers"; import { getBlockFromEpoch, setEpochRange } from "./utils/epochHandler"; -import { getClaimValidator, getClaimer } from "./utils/ethers"; import { defaultEmitter } from "./utils/emitter"; import { BotEvents } from "./utils/botEvents"; import { initialize as initializeLogger } from "./utils/logger"; @@ -10,9 +9,10 @@ import { ShutdownSignal } from "./utils/shutdown"; import { getBotPath, BotPaths, getNetworkConfig, NetworkConfig } from "./utils/botConfig"; import { getClaim } from "./utils/claim"; import { MissingEnvError } from "./utils/errors"; -import { CheckAndClaimParams } from "./ArbToEth/claimer"; -import { ChallengeAndResolveClaimParams } from "./ArbToEth/validator"; -import { saveSnapshot, SaveSnapshotParams } from "./utils/snapshot"; +import { CheckAndClaimParams, checkAndClaim } from "./helpers/claimer"; +import { ChallengeAndResolveClaimParams, challengeAndResolveClaim } from "./helpers/validator"; +import { saveSnapshot, SaveSnapshotParams } from "./helpers/snapshot"; +import { getTransactionHandler } from "./utils/transactionHandlers"; const RPC_BLOCK_LIMIT = 500; // RPC_BLOCK_LIMIT is the limit of blocks that can be queried at once @@ -54,7 +54,7 @@ async function processNetwork( emitter: typeof defaultEmitter ): Promise { const { chainId, networks } = networkConfig; - const { routeConfig, inboxRPC, outboxRPC } = getBridgeConfig(chainId); + const { routeConfig, inboxRPC, outboxRPC, routerRPC } = getBridgeConfig(chainId); for (const network of networks) { emitter.emit(BotEvents.WATCHING, chainId, network); const networkKey = `${chainId}_${network}`; @@ -85,6 +85,7 @@ async function processNetwork( routeConfig, inboxRPC, outboxRPC, + routerRPC, toWatch, transactionHandlers, emitter, @@ -109,6 +110,7 @@ interface ProcessEpochParams { routeConfig: any; inboxRPC: string; outboxRPC: string; + routerRPC: string | undefined; toWatch: { [key: string]: { count: number; epochs: number[] } }; transactionHandlers: { [epoch: number]: any }; emitter: typeof defaultEmitter; @@ -122,6 +124,7 @@ async function processEpochsForNetwork({ routeConfig, inboxRPC, outboxRPC, + routerRPC, toWatch, transactionHandlers, emitter, @@ -131,6 +134,7 @@ async function processEpochsForNetwork({ const veaOutbox = getVeaOutbox(routeConfig[network].veaOutbox.address, privKey, outboxRPC, chainId, network); const veaInboxProvider = new JsonRpcProvider(inboxRPC); const veaOutboxProvider = new JsonRpcProvider(outboxRPC); + const veaRouterProvider = routerRPC ? new JsonRpcProvider(routerRPC) : undefined; let i = toWatch[networkKey].epochs.length - 1; const latestEpoch = toWatch[networkKey].epochs[i]; const currentEpoch = Math.floor(Date.now() / (1000 * routeConfig[network].epochPeriod)); @@ -146,9 +150,11 @@ async function processEpochsForNetwork({ veaOutbox, veaInboxProvider, veaOutboxProvider, + veaRouterProvider, emitter, }); const { updatedTransactionHandler, latestCount } = await saveSnapshot({ + chainId, veaInbox, network, epochPeriod: routeConfig[network].epochPeriod, @@ -171,25 +177,24 @@ async function processEpochsForNetwork({ toBlock = epochBlock + RPC_BLOCK_LIMIT; } - const claim = await getClaim({ veaOutbox, veaOutboxProvider, epoch, fromBlock: epochBlock, toBlock }); + const claim = await getClaim({ chainId, veaOutbox, veaOutboxProvider, epoch, fromBlock: epochBlock, toBlock }); - const checkAndChallengeResolve = getClaimValidator(chainId, network); - const checkAndClaim = getClaimer(chainId, network); let updatedTransactions; - if (path > BotPaths.CLAIMER && claim != null) { const checkAndChallengeResolveDeps: ChallengeAndResolveClaimParams = { + chainId, claim, epoch, epochPeriod: routeConfig[network].epochPeriod, veaInbox, veaInboxProvider, veaOutboxProvider, + veaRouterProvider, veaOutbox, transactionHandler: transactionHandlers[epoch], emitter, }; - updatedTransactions = await checkAndChallengeResolve(checkAndChallengeResolveDeps); + updatedTransactions = await challengeAndResolveClaim(checkAndChallengeResolveDeps); } if (path == BotPaths.CLAIMER || path == BotPaths.BOTH) { const checkAndClaimParams: CheckAndClaimParams = {