diff --git a/validator-cli/.env.dist b/validator-cli/.env.dist index ffa74e1c..b2a4929d 100644 --- a/validator-cli/.env.dist +++ b/validator-cli/.env.dist @@ -3,28 +3,30 @@ PRIVATE_KEY= # Devnet RPCs RPC_CHIADO=https://rpc.chiadochain.net RPC_ARB_SEPOLIA=https://sepolia-rollup.arbitrum.io/rpc -RPC_GNOSIS=https://rpc.chiadochain.net -RPC_ARB=https://sepolia-rollup.arbitrum.io/rpc RPC_SEPOLIA= # Testnet or Mainnet RPCs -RPC_ARB= +RPC_ARB=https://sepolia-rollup.arbitrum.io/rpc RPC_ETH= +RPC_GNOSIS=https://rpc.chiadochain.net # Testnet or Mainnet Addresses +# VEA Arbitrum to Ethereum VEAINBOX_ARB_TO_ETH_ADDRESS=0xE12daFE59Bc3A996362d54b37DFd2BA9279cAd06 VEAOUTBOX_ARB_TO_ETH_ADDRESS=0x209BFdC6B7c66b63A8382196Ba3d06619d0F12c9 +# VEA Arbitrum to GNOSIS +VEAINBOX_ARB_TO_GNOSIS_ADDRESS=0x854374483572FFcD4d0225290346279d0718240b +VEAOUTBOX_ARB_TO_GNOSIS_ADDRESS=0x2f1788F7B74e01c4C85578748290467A5f063B0b +VEAROUTER_ARB_TO_GNOSIS_ADDRESS=0x5BE03fDE7794Bc188416ba16932510Ed1277b193 +GNOSIS_AMB_ADDRESS=0x8448E15d0e706C0298dECA99F0b4744030e59d7d +VEAOUTBOX_CHAIN_ID=421611 +VEAOUTBOX_SUBGRAPH=https://api.studio.thegraph.com/query/user/outbox-arb-sep/version/latest # Devnet Addresses VEAINBOX_ARBSEPOLIA_TO_SEPOLIA_ADDRESS=0x906dE43dBef27639b1688Ac46532a16dc07Ce410 VEAOUTBOX_ARBSEPOLIA_TO_SEPOLIA_ADDRESS=0x906dE43dBef27639b1688Ac46532a16dc07Ce410 -#For arbToGnosis bridge -VEAINBOX_ARB_TO_GNOSIS_ADDRESS=0x854374483572FFcD4d0225290346279d0718240b -VEAOUTBOX_ARB_TO_GNOSIS_ADDRESS=0x2f1788F7B74e01c4C85578748290467A5f063B0b -VEAROUTER_ARB_TO_GNOSIS_ADDRESS=0x5BE03fDE7794Bc188416ba16932510Ed1277b193 -GNOSIS_AMB_ADDRESS=0x8448E15d0e706C0298dECA99F0b4744030e59d7d TRANSACTION_BATCHER_CONTRACT_ADDRESS_SEPOLIA=0xe7953da7751063d0a41ba727c32c762d3523ade8 TRANSACTION_BATCHER_CONTRACT_ADDRESS_CHIADO=0xcC0a08D4BCC5f91ee9a1587608f7a2975EA75d73 \ No newline at end of file diff --git a/validator-cli/jest.config.ts b/validator-cli/jest.config.ts new file mode 100644 index 00000000..1927a555 --- /dev/null +++ b/validator-cli/jest.config.ts @@ -0,0 +1,10 @@ +import type { Config } from "jest"; + +const config: Config = { + preset: "ts-jest", + testEnvironment: "node", + collectCoverage: true, + collectCoverageFrom: ["**/*.ts"], +}; + +export default config; diff --git a/validator-cli/package.json b/validator-cli/package.json index 9139a3e0..e9deeb03 100644 --- a/validator-cli/package.json +++ b/validator-cli/package.json @@ -10,11 +10,12 @@ "yarn": "4.2.2" }, "scripts": { - "start": "npx ts-node ./src/ArbToEth/watcher.ts", + "start": "npx ts-node ./src/watcher.ts", "start-chiado-devnet": "npx ts-node ./src/devnet/arbToChiado/happyPath.ts", "start-sepolia-devnet": "npx ts-node ./src/devnet/arbToSepolia/happyPath.ts", "start-sepolia-testnet": "npx ts-node ./src/ArbToEth/watcherArbToEth.ts", - "start-arbitrum-to-gnosis": "npx ts-node ./src/ArbToEth/watcherArbToGnosis.ts" + "start-arbitrum-to-gnosis": "npx ts-node ./src/ArbToEth/watcherArbToGnosis.ts", + "test": "jest --coverage" }, "dependencies": { "@arbitrum/sdk": "4.0.1", @@ -28,6 +29,9 @@ "web3-batched-send": "^1.0.3" }, "devDependencies": { + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", "ts-node": "^10.9.2" } } diff --git a/validator-cli/src/ArbToEth/claimer.test.ts b/validator-cli/src/ArbToEth/claimer.test.ts new file mode 100644 index 00000000..c0aea9a4 --- /dev/null +++ b/validator-cli/src/ArbToEth/claimer.test.ts @@ -0,0 +1,164 @@ +import { ethers } from "ethers"; +import { checkAndClaim } from "./claimer"; +import { ClaimHonestState } from "../utils/claim"; + +describe("claimer", () => { + let veaOutbox: any; + let veaInbox: any; + let veaInboxProvider: any; + let veaOutboxProvider: any; + let emitter: any; + let mockClaim: any; + let mockGetLatestClaimedEpoch: any; + let mockDeps: any; + beforeEach(() => { + mockClaim = { + stateRoot: "0x1234", + claimer: "0xFa00D29d378EDC57AA1006946F0fc6230a5E3288", + timestampClaimed: 1234, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress, + }; + veaInbox = { + snapshots: jest.fn().mockResolvedValue(mockClaim.stateRoot), + }; + + veaOutbox = { + stateRoot: jest.fn().mockResolvedValue(mockClaim.stateRoot), + }; + veaOutboxProvider = { + getBlock: jest.fn().mockResolvedValue({ number: 0, timestamp: 110 }), + }; + emitter = { + emit: jest.fn(), + }; + + mockGetLatestClaimedEpoch = jest.fn(); + mockDeps = { + claim: mockClaim, + epoch: 10, + epochPeriod: 10, + veaInbox, + veaInboxProvider, + veaOutboxProvider, + veaOutbox, + transactionHandler: null, + emitter, + fetchLatestClaimedEpoch: mockGetLatestClaimedEpoch, + }; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe("checkAndClaim", () => { + let mockTransactionHandler: any; + const mockTransactions = { + claimTxn: "0x111", + withdrawClaimDepositTxn: "0x222", + startVerificationTxn: "0x333", + verifySnapshotTxn: "0x444", + }; + beforeEach(() => { + mockTransactionHandler = { + withdrawClaimDeposit: jest.fn().mockImplementation(() => { + mockTransactionHandler.transactions.withdrawClaimDepositTxn = mockTransactions.withdrawClaimDepositTxn; + return Promise.resolve(); + }), + makeClaim: jest.fn().mockImplementation(() => { + mockTransactionHandler.transactions.claimTxn = mockTransactions.claimTxn; + return Promise.resolve(); + }), + startVerification: jest.fn().mockImplementation(() => { + mockTransactionHandler.transactions.startVerificationTxn = mockTransactions.startVerificationTxn; + return Promise.resolve(); + }), + verifySnapshot: jest.fn().mockImplementation(() => { + mockTransactionHandler.transactions.verifySnapshotTxn = mockTransactions.verifySnapshotTxn; + return Promise.resolve(); + }), + transactions: { + claimTxn: "0x0", + withdrawClaimDepositTxn: "0x0", + startVerificationTxn: "0x0", + verifySnapshotTxn: "0x0", + }, + }; + }); + it("should return null if no claim is made for a passed epoch", async () => { + mockDeps.epoch = 7; // claimable epoch - 3 + mockDeps.claim = null; + const result = await checkAndClaim(mockDeps); + expect(result).toBeNull(); + }); + it("should return null if no snapshot is saved on the inbox for a claimable epoch", async () => { + veaInbox.snapshots = jest.fn().mockResolvedValue(ethers.ZeroHash); + mockGetLatestClaimedEpoch = jest.fn().mockResolvedValue({ + challenged: false, + stateroot: "0x1111", + }); + mockDeps.claim = null; + mockDeps.fetchLatestClaimedEpoch = mockGetLatestClaimedEpoch; + const result = await checkAndClaim(mockDeps); + expect(result).toBeNull(); + }); + it("should return null if there are no new messages in the inbox", async () => { + veaInbox.snapshots = jest.fn().mockResolvedValue(mockClaim.stateRoot); + mockGetLatestClaimedEpoch = jest.fn().mockResolvedValue({ + challenged: false, + stateroot: "0x1111", + }); + mockDeps.claim = null; + mockDeps.fetchLatestClaimedEpoch = mockGetLatestClaimedEpoch; + const result = await checkAndClaim(mockDeps); + expect(result).toBeNull(); + }); + it("should make a valid claim if no claim is made", async () => { + veaInbox.snapshots = jest.fn().mockResolvedValue("0x7890"); + mockGetLatestClaimedEpoch = jest.fn().mockResolvedValue({ + challenged: false, + stateroot: mockClaim.stateRoot, + }); + mockDeps.transactionHandler = mockTransactionHandler; + mockDeps.fetchLatestClaimedEpoch = mockGetLatestClaimedEpoch; + mockDeps.claim = null; + mockDeps.veaInbox = veaInbox; + const result = await checkAndClaim(mockDeps); + expect(result.transactions.claimTxn).toBe(mockTransactions.claimTxn); + }); + it("should make a valid claim if last claim was challenged", async () => { + veaInbox.snapshots = jest.fn().mockResolvedValue(mockClaim.stateRoot); + mockGetLatestClaimedEpoch = jest.fn().mockResolvedValue({ + challenged: true, + stateroot: mockClaim.stateRoot, + }); + mockDeps.transactionHandler = mockTransactionHandler; + mockDeps.fetchLatestClaimedEpoch = mockGetLatestClaimedEpoch; + mockDeps.claim = null; + mockDeps.veaInbox = veaInbox; + const result = await checkAndClaim(mockDeps); + expect(result.transactions.claimTxn).toEqual(mockTransactions.claimTxn); + }); + it("should withdraw claim deposit if claimer is honest", async () => { + mockDeps.transactionHandler = mockTransactionHandler; + mockClaim.honest = ClaimHonestState.CLAIMER; + const result = await checkAndClaim(mockDeps); + expect(result.transactions.withdrawClaimDepositTxn).toEqual(mockTransactions.withdrawClaimDepositTxn); + }); + it("should start verification if verification is not started", async () => { + mockDeps.transactionHandler = mockTransactionHandler; + mockClaim.honest = ClaimHonestState.NONE; + const result = await checkAndClaim(mockDeps); + expect(result.transactions.startVerificationTxn).toEqual(mockTransactions.startVerificationTxn); + }); + it("should verify snapshot if verification is started", async () => { + mockDeps.transactionHandler = mockTransactionHandler; + mockClaim.honest = ClaimHonestState.NONE; + mockClaim.timestampVerification = 1234; + mockDeps.claim = mockClaim; + const result = await checkAndClaim(mockDeps); + expect(result.transactions.verifySnapshotTxn).toEqual(mockTransactions.verifySnapshotTxn); + }); + }); +}); diff --git a/validator-cli/src/ArbToEth/claimer.ts b/validator-cli/src/ArbToEth/claimer.ts new file mode 100644 index 00000000..2810a039 --- /dev/null +++ b/validator-cli/src/ArbToEth/claimer.ts @@ -0,0 +1,75 @@ +import { EventEmitter } from "events"; +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"; +interface checkAndClaimParams { + claim: ClaimStruct | null; + epochPeriod: number; + epoch: number; + veaInbox: any; + veaInboxProvider: JsonRpcProvider; + veaOutbox: any; + veaOutboxProvider: JsonRpcProvider; + transactionHandler: ArbToEthTransactionHandler | null; + emitter: EventEmitter; + fetchClaim?: typeof getClaim; + fetchLatestClaimedEpoch?: typeof getLastClaimedEpoch; +} + +export async function checkAndClaim({ + claim, + epoch, + epochPeriod, + veaInbox, + veaInboxProvider, + veaOutbox, + veaOutboxProvider, + transactionHandler, + emitter, + fetchLatestClaimedEpoch = getLastClaimedEpoch, +}: checkAndClaimParams) { + let outboxStateRoot = await veaOutbox.stateRoot(); + const finalizedOutboxBlock = await veaOutboxProvider.getBlock("finalized"); + const claimAbleEpoch = Math.floor(finalizedOutboxBlock.timestamp / epochPeriod) - 1; + if (!transactionHandler) { + transactionHandler = new ArbToEthTransactionHandler( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + emitter, + claim + ); + } else { + transactionHandler.claim = claim; + } + if (claim == null && epoch == claimAbleEpoch) { + const [savedSnapshot, claimData] = await Promise.all([veaInbox.snapshots(epoch), fetchLatestClaimedEpoch()]); + const newMessagesToBridge: boolean = savedSnapshot != outboxStateRoot && savedSnapshot != ethers.ZeroHash; + const lastClaimChallenged: boolean = claimData.challenged && savedSnapshot == outboxStateRoot; + if (newMessagesToBridge || lastClaimChallenged) { + await transactionHandler.makeClaim(savedSnapshot); + return transactionHandler; + } + } else if (claim != null) { + if (claim.honest == ClaimHonestState.CLAIMER) { + await transactionHandler.withdrawClaimDeposit(); + return transactionHandler; + } else if (claim.honest == ClaimHonestState.NONE) { + if (claim.timestampVerification == 0) { + await transactionHandler.startVerification(finalizedOutboxBlock.timestamp); + } else { + await transactionHandler.verifySnapshot(finalizedOutboxBlock.timestamp); + } + return transactionHandler; + } + } else { + emitter.emit(BotEvents.CLAIM_EPOCH_PASSED, epoch); + } + return null; +} diff --git a/validator-cli/src/ArbToEth/transactionHandler.test.ts b/validator-cli/src/ArbToEth/transactionHandler.test.ts new file mode 100644 index 00000000..ffcdf735 --- /dev/null +++ b/validator-cli/src/ArbToEth/transactionHandler.test.ts @@ -0,0 +1,574 @@ +import { + ArbToEthTransactionHandler, + ContractType, + Transaction, + MAX_PENDING_CONFIRMATIONS, + MAX_PENDING_TIME, +} from "./transactionHandler"; +import { MockEmitter, defaultEmitter } from "../utils/emitter"; +import { BotEvents } from "../utils/botEvents"; +import { ClaimNotSetError } from "../utils/errors"; +import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; +import { getBridgeConfig } 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; + + 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(), + }; + claim = { + stateRoot: "0x1234", + claimer: "0x1234", + timestampClaimed: 1234, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: "0x1234", + }; + }); + + describe("constructor", () => { + it("should create a new TransactionHandler without claim", () => { + const transactionHandler = new ArbToEthTransactionHandler( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider + ); + expect(transactionHandler).toBeDefined(); + expect(transactionHandler.epoch).toEqual(epoch); + expect(transactionHandler.veaOutbox).toEqual(veaOutbox); + expect(transactionHandler.emitter).toEqual(defaultEmitter); + }); + + it("should create a new TransactionHandler with claim", () => { + const transactionHandler = new ArbToEthTransactionHandler( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + defaultEmitter, + claim + ); + expect(transactionHandler).toBeDefined(); + expect(transactionHandler.epoch).toEqual(epoch); + expect(transactionHandler.veaOutbox).toEqual(veaOutbox); + expect(transactionHandler.claim).toEqual(claim); + expect(transactionHandler.emitter).toEqual(defaultEmitter); + }); + }); + + describe("checkTransactionStatus", () => { + let transactionHandler: ArbToEthTransactionHandler; + let finalityBlock: number = 100; + const mockEmitter = new MockEmitter(); + let mockBroadcastedTimestamp: number = 1000; + beforeEach(() => { + transactionHandler = new ArbToEthTransactionHandler( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + 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, MAX_PENDING_CONFIRMATIONS - 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); + }); + }); + + // Happy path (claimer) + describe("makeClaim", () => { + let transactionHandler: ArbToEthTransactionHandler; + const mockEmitter = new MockEmitter(); + const { deposit } = getBridgeConfig(chainId); + beforeEach(() => { + const mockClaim = jest.fn().mockResolvedValue({ hash: "0x1234" }) as any; + (mockClaim as any).estimateGas = jest.fn().mockResolvedValue(BigInt(100000)); + veaOutbox["claim(uint256,bytes32)"] = mockClaim; + transactionHandler = new ArbToEthTransactionHandler( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + 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 mockEmitter = new MockEmitter(); + const { epochPeriod, sequencerDelayLimit } = getBridgeConfig(chainId); + let startVerificationFlipTime: number; + const mockStartVerification = jest.fn().mockResolvedValue({ hash: "0x1234" }) as any; + (mockStartVerification as any).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( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + 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; + const mockEmitter = new MockEmitter(); + beforeEach(() => { + const mockVerifySnapshot = jest.fn().mockResolvedValue({ hash: "0x1234" }) as any; + (mockVerifySnapshot as any).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( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + 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; + const mockEmitter = new MockEmitter(); + beforeEach(() => { + const mockWithdrawClaimDeposit = jest.fn().mockResolvedValue({ hash: "0x1234" }) as any; + (mockWithdrawClaimDeposit as any).estimateGas = jest.fn().mockResolvedValue(BigInt(100000)); + veaOutbox["withdrawClaimDeposit(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"] = + mockWithdrawClaimDeposit; + transactionHandler = new ArbToEthTransactionHandler( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + 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; + const mockEmitter = new MockEmitter(); + beforeEach(() => { + transactionHandler = new ArbToEthTransactionHandler( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + transactionHandler.claim = claim; + }); + + it("should emit CHALLENGING event and throw error if claim is not set", async () => { + jest.spyOn(mockEmitter, "emit"); + transactionHandler.claim = null; + await expect(transactionHandler.challengeClaim()).rejects.toThrow(ClaimNotSetError); + expect(mockEmitter.emit).toHaveBeenCalledWith(BotEvents.CHALLENGING); + }); + + it("should not challenge claim if txn is pending", async () => { + jest.spyOn(transactionHandler, "checkTransactionStatus").mockResolvedValue(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; + const mockEmitter = new MockEmitter(); + beforeEach(() => { + transactionHandler = new ArbToEthTransactionHandler( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + 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; + const mockEmitter = new MockEmitter(); + beforeEach(() => { + transactionHandler = new ArbToEthTransactionHandler( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + 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; + const mockEmitter = new MockEmitter(); + beforeEach(() => { + mockMessageExecutor = jest.fn(); + transactionHandler = new ArbToEthTransactionHandler( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + mockEmitter + ); + }); + 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 new file mode 100644 index 00000000..664e197e --- /dev/null +++ b/validator-cli/src/ArbToEth/transactionHandler.ts @@ -0,0 +1,395 @@ +import { VeaInboxArbToEth, VeaOutboxArbToEth } from "@kleros/vea-contracts/typechain-types"; +import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; +import { JsonRpcProvider } from "@ethersproject/providers"; +import { messageExecutor } from "../utils/arbMsgExecutor"; +import { defaultEmitter } from "../utils/emitter"; +import { BotEvents } from "../utils/botEvents"; +import { ClaimNotSetError } from "../utils/errors"; +import { getBridgeConfig } 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 type Transaction = { + hash: string; + broadcastedTimestamp: number; +}; + +type Transactions = { + claimTxn: Transaction | null; + withdrawClaimDepositTxn: Transaction | null; + startVerificationTxn: Transaction | null; + verifySnapshotTxn: Transaction | null; + challengeTxn: Transaction | null; + withdrawChallengeDepositTxn: Transaction | null; + sendSnapshotTxn: Transaction | null; + executeSnapshotTxn: Transaction | null; +}; + +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 veaInbox: VeaInboxArbToEth; + public veaOutbox: VeaOutboxArbToEth; + 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, + sendSnapshotTxn: null, + executeSnapshotTxn: null, + }; + + constructor( + epoch: number, + veaInbox: VeaInboxArbToEth, + veaOutbox: VeaOutboxArbToEth, + veaInboxProvider: JsonRpcProvider, + veaOutboxProvider: JsonRpcProvider, + emitter: typeof defaultEmitter = defaultEmitter, + claim: ClaimStruct | null = null + ) { + 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, 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.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. + * + */ + 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. + * + */ + 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, + }; + } + + /** + * 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/validator.test.ts b/validator-cli/src/ArbToEth/validator.test.ts new file mode 100644 index 00000000..253831c6 --- /dev/null +++ b/validator-cli/src/ArbToEth/validator.test.ts @@ -0,0 +1,162 @@ +import { ethers } from "ethers"; +import { challengeAndResolveClaim } from "./validator"; +import { BotEvents } from "../utils/botEvents"; + +describe("validator", () => { + let veaOutbox: any; + let veaInbox: any; + let veaInboxProvider: any; + let veaOutboxProvider: any; + let emitter: any; + let mockClaim: any; + let mockGetClaimState: any; + let mockGetBlockFinality: any; + let mockDeps: any; + beforeEach(() => { + veaInbox = { + snapshots: jest.fn(), + provider: { + getBlock: jest.fn(), + }, + }; + veaOutbox = { + claimHashes: jest.fn(), + queryFilter: jest.fn(), + provider: { + getBlock: jest.fn(), + }, + }; + emitter = { + emit: jest.fn(), + }; + mockClaim = { + stateRoot: "0x1234", + claimer: "0xFa00D29d378EDC57AA1006946F0fc6230a5E3288", + timestampClaimed: 1234, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress, + }; + mockGetBlockFinality = jest.fn().mockResolvedValue([{ number: 0 }, { number: 0, timestamp: 100 }, false]); + mockDeps = { + claim: mockClaim, + epoch: 0, + epochPeriod: 10, + veaInbox, + veaInboxProvider, + veaOutboxProvider, + veaOutbox, + transactionHandler: null, + emitter, + fetchClaimResolveState: mockGetClaimState, + fetchBlocksAndCheckFinality: mockGetBlockFinality, + }; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe("challengeAndResolveClaim", () => { + it("should return null if no claim is made", async () => { + mockDeps.claim = null; + const result = await challengeAndResolveClaim(mockDeps); + + expect(result).toBeNull(); + expect(emitter.emit).toHaveBeenCalledWith(BotEvents.NO_CLAIM, 0); + }); + + it("should challenge if claim is invalid and not challenged", async () => { + const challengeTxn = "0x123"; + const mockTransactionHandler = { + challengeClaim: jest.fn().mockImplementation(() => { + mockTransactionHandler.transactions.challengeTxn = challengeTxn; + return Promise.resolve(); + }), + transactions: { + challengeTxn: "0x0", + }, + }; + veaInbox.snapshots = jest.fn().mockReturnValue("0x321"); + mockDeps.transactionHandler = mockTransactionHandler; + const updatedTransactionHandler = await challengeAndResolveClaim(mockDeps); + expect(updatedTransactionHandler.transactions.challengeTxn).toBe(challengeTxn); + expect(mockTransactionHandler.challengeClaim).toHaveBeenCalled(); + }); + + it("should not challenge if claim is valid", async () => { + veaInbox.snapshots = jest.fn().mockReturnValue(mockClaim.stateRoot); + const updatedTransactionHandler = await challengeAndResolveClaim(mockDeps); + expect(updatedTransactionHandler).toBeNull(); + }); + + it("send snapshot if snapshot not sent", async () => { + mockClaim.challenger = mockClaim.claimer; + mockGetClaimState = jest + .fn() + .mockReturnValue({ sendSnapshot: { status: false, txnHash: "" }, execution: { status: 0, txnHash: "" } }); + veaInbox.snapshots = jest.fn().mockReturnValue("0x0"); + const mockTransactionHandler = { + sendSnapshot: jest.fn().mockImplementation(() => { + mockTransactionHandler.transactions.sendSnapshotTxn = "0x123"; + return Promise.resolve(); + }), + transactions: { + sendSnapshotTxn: "0x0", + }, + }; + mockDeps.transactionHandler = mockTransactionHandler; + mockDeps.fetchClaimResolveState = mockGetClaimState; + const updatedTransactionHandler = await challengeAndResolveClaim(mockDeps); + expect(updatedTransactionHandler.transactions.sendSnapshotTxn).toEqual("0x123"); + expect(mockTransactionHandler.sendSnapshot).toHaveBeenCalled(); + expect(updatedTransactionHandler.claim).toEqual(mockClaim); + }); + + it("resolve challenged claim if snapshot sent but not executed", async () => { + mockClaim.challenger = mockClaim.claimer; + mockGetClaimState = jest + .fn() + .mockReturnValue({ sendSnapshot: { status: true, txnHash: "0x123" }, execution: { status: 1, txnHash: "" } }); + veaInbox.snapshots = jest.fn().mockReturnValue("0x0"); + const mockTransactionHandler = { + resolveChallengedClaim: jest.fn().mockImplementation(() => { + mockTransactionHandler.transactions.executeSnapshotTxn = "0x123"; + return Promise.resolve(); + }), + transactions: { + executeSnapshotTxn: "0x0", + }, + }; + mockDeps.transactionHandler = mockTransactionHandler; + mockDeps.fetchClaimResolveState = mockGetClaimState; + const updatedTransactionHandler = await challengeAndResolveClaim(mockDeps); + expect(updatedTransactionHandler.transactions.executeSnapshotTxn).toEqual("0x123"); + expect(mockTransactionHandler.resolveChallengedClaim).toHaveBeenCalled(); + expect(updatedTransactionHandler.claim).toEqual(mockClaim); + }); + + it("withdraw challenge deposit if snapshot sent and executed", async () => { + mockClaim.challenger = mockClaim.claimer; + mockGetClaimState = jest.fn().mockReturnValue({ + sendSnapshot: { status: true, txnHash: "0x123" }, + execution: { status: 2, txnHash: "0x321" }, + }); + veaInbox.snapshots = jest.fn().mockReturnValue("0x0"); + const mockTransactionHandler = { + withdrawChallengeDeposit: jest.fn().mockImplementation(() => { + mockTransactionHandler.transactions.withdrawChallengeDepositTxn = "0x1234"; + return Promise.resolve(); + }), + transactions: { + withdrawChallengeDepositTxn: "0x0", + }, + }; + mockDeps.transactionHandler = mockTransactionHandler; + mockDeps.fetchClaimResolveState = mockGetClaimState; + const updatedTransactionHandler = await challengeAndResolveClaim(mockDeps); + expect(updatedTransactionHandler.transactions.withdrawChallengeDepositTxn).toEqual("0x1234"); + expect(mockTransactionHandler.withdrawChallengeDeposit).toHaveBeenCalled(); + expect(updatedTransactionHandler.claim).toEqual(mockClaim); + }); + }); +}); diff --git a/validator-cli/src/ArbToEth/validator.ts b/validator-cli/src/ArbToEth/validator.ts new file mode 100644 index 00000000..c3b76dcb --- /dev/null +++ b/validator-cli/src/ArbToEth/validator.ts @@ -0,0 +1,107 @@ +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 { 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( + epoch, + veaInbox, + veaOutbox, + veaInboxProvider, + veaOutboxProvider, + defaultEmitter, + claim + ); + } else { + transactionHandler.claim = claim; + } + + const claimSnapshot = await veaInbox.snapshots(epoch, { blockTag: arbitrumBlock.number }); + + if (claimSnapshot != claim.stateRoot && claim.challenger == ethers.ZeroAddress) { + await transactionHandler.challengeClaim(); + } else { + if (claimSnapshot == claim.stateRoot) { + emitter.emit(BotEvents.VALID_CLAIM, epoch); + return null; + } else { + const claimResolveState = await fetchClaimResolveState( + veaInbox, + veaInboxProvider, + veaOutboxProvider, + epoch, + blockNumberOutboxLowerBound, + ethBlockTag + ); + + if (!claimResolveState.sendSnapshot.status) { + await transactionHandler.sendSnapshot(); + } else if (claimResolveState.execution.status == 1) { + await transactionHandler.resolveChallengedClaim(claimResolveState.sendSnapshot.txHash); + } else if (claimResolveState.execution.status == 2) { + await transactionHandler.withdrawChallengeDeposit(); + } else { + emitter.emit(BotEvents.WAITING_ARB_TIMEOUT, epoch); + } + } + } + + return transactionHandler; +} diff --git a/validator-cli/src/ArbToEth/watcherArbToEth.ts b/validator-cli/src/ArbToEth/watcherArbToEth.ts deleted file mode 100644 index e68e6562..00000000 --- a/validator-cli/src/ArbToEth/watcherArbToEth.ts +++ /dev/null @@ -1,740 +0,0 @@ -import { getVeaOutboxArbToEth, getVeaInboxArbToEth } from "../utils/ethers"; -import { JsonRpcProvider } from "@ethersproject/providers"; -import { getArbitrumNetwork } from "@arbitrum/sdk"; -import { NODE_INTERFACE_ADDRESS } from "@arbitrum/sdk/dist/lib/dataEntities/constants"; -import { NodeInterface__factory } from "@arbitrum/sdk/dist/lib/abi/factories/NodeInterface__factory"; -import { SequencerInbox__factory } from "@arbitrum/sdk/dist/lib/abi/factories/SequencerInbox__factory"; -import { ContractTransaction, ContractTransactionResponse, ethers } from "ethers"; -import { Block, Log, TransactionReceipt } from "@ethersproject/abstract-provider"; -import { SequencerInbox } from "@arbitrum/sdk/dist/lib/abi/SequencerInbox"; -import { NodeInterface } from "@arbitrum/sdk/dist/lib/abi/NodeInterface"; -import { getMessageStatus, messageExecutor } from "../utils/arbMsgExecutor"; - -require("dotenv").config(); - -// https://github.com/prysmaticlabs/prysm/blob/493905ee9e33a64293b66823e69704f012b39627/config/params/mainnet_config.go#L103 -const slotsPerEpochEth = 32; -const secondsPerSlotEth = 12; - -// This script monitors claims made on VeaOutbox and initiates challenges if required. -// The core flow includes: -// 1. `challenge(veaOutbox)`: Check claims and challenge if necassary. -// 2. `sendSnapshot(veaInbox)`: Send the snapshot from veaInbox for a challenged epoch. -// 3. `resolveDisputeClaim(arbitrumBridge)`: Execute the sent snapshot to resolve the dispute. -// 4. `withdrawChallengeDeposit(veaOutbox)`: Withdraw the deposit if the challenge is successful. - -const watch = async () => { - // connect to RPCs - const providerEth = new JsonRpcProvider(process.env.RPC_ETH); - const providerArb = new JsonRpcProvider(process.env.RPC_ARB); - - // use typechain generated contract factories for vea outbox and inbox - const veaOutbox = getVeaOutboxArbToEth( - process.env.VEAOUTBOX_ARB_TO_ETH_ADDRESS, - process.env.PRIVATE_KEY, - process.env.RPC_ETH - ); - const veaInbox = getVeaInboxArbToEth( - process.env.VEAINBOX_ARB_TO_ETH_ADDRESS, - process.env.PRIVATE_KEY, - process.env.RPC_ARB - ); - - // get Arb sequencer params - const l2Network = await getArbitrumNetwork(providerArb); - const sequencer = SequencerInbox__factory.connect(l2Network.ethBridge.sequencerInbox, providerEth); - const maxDelaySeconds = Number((await retryOperation(() => sequencer.maxTimeVariation(), 1000, 10))[1]); - - // get vea outbox params - const deposit = BigInt((await retryOperation(() => veaOutbox.deposit(), 1000, 10)) as any); - const epochPeriod = Number(await retryOperation(() => veaOutbox.epochPeriod(), 1000, 10)); - const sequencerDelayLimit = Number(await retryOperation(() => veaOutbox.sequencerDelayLimit(), 1000, 10)); - - // * - // calculate epoch range to check claims on Eth - // * - - // Finalized Eth block provides an 'anchor point' for the vea epochs in the outbox that are claimable - const blockFinalizedEth: Block = (await retryOperation(() => providerEth.getBlock("finalized"), 1000, 10)) as Block; - - const coldStartBacklog = 7 * 24 * 60 * 60; // when starting the watcher, specify an extra backlog to check - - // When Sequencer is malicious, even when L1 is finalized, L2 state might be unknown for up to sequencerDelayLimit + epochPeriod. - const L2SyncPeriod = sequencerDelayLimit + epochPeriod; - // When we start the watcher, we need to go back far enough to check for claims which may have been pending L2 state finalization. - const veaEpochOutboxWatchLowerBound = - Math.floor((blockFinalizedEth.timestamp - L2SyncPeriod - coldStartBacklog) / epochPeriod) - 2; - - // ETH / Gnosis POS assumes synchronized clocks - // using local time as a proxy for true "latest" L1 time - const timeLocal = Math.floor(Date.now() / 1000); - - let veaEpochOutboxClaimableNow = Math.floor(timeLocal / epochPeriod) - 1; - - // only past epochs are claimable, hence shift by one here - const veaEpochOutboxRange = veaEpochOutboxClaimableNow - veaEpochOutboxWatchLowerBound + 1; - const veaEpochOutboxCheckClaimsRangeArray: number[] = new Array(veaEpochOutboxRange) - .fill(veaEpochOutboxWatchLowerBound) - .map((el, i) => el + i); - - console.log( - "cold start: checking past claim history from epoch " + - veaEpochOutboxCheckClaimsRangeArray[0] + - " to the current claimable epoch " + - veaEpochOutboxCheckClaimsRangeArray[veaEpochOutboxCheckClaimsRangeArray.length - 1] - ); - - const challengeTxnHashes = new Map(); - - while (true) { - // returns the most recent finalized arbBlock found on Ethereum and info about finality issues on Eth. - // if L1 is experiencing finalization problems, returns the latest arbBlock found in the latest L1 block - const [blockArbFoundOnL1, blockFinalizedEth, finalityIssueFlagEth] = await getBlocksAndCheckFinality( - providerEth, - providerArb, - sequencer, - maxDelaySeconds - ); - - if (!blockArbFoundOnL1) { - console.error("Critical Error: Arbitrum block is not found on L1."); - return; - } - - // claims can be made for the previous epoch, hence - // if an epoch is 2 or more epochs behind the L1 finalized epoch, no further claims can be made, we call this 'veaEpochOutboxFinalized' - const veaEpochOutboxClaimableFinalized = Math.floor(blockFinalizedEth.timestamp / epochPeriod) - 2; - - const timeLocal = Math.floor(Date.now() / 1000); - const timeEth = finalityIssueFlagEth ? timeLocal : blockFinalizedEth.timestamp; - - // if the sequencer is offline for maxDelaySeconds, the l2 timestamp in the next block is clamp to the current L1 timestamp - maxDelaySeconds - const l2Time = Math.max(blockArbFoundOnL1.timestamp, blockFinalizedEth.timestamp - maxDelaySeconds); - - // the latest epoch that is finalized from the L2 POV - // this depends on the L2 clock - const veaEpochInboxFinalized = Math.floor(l2Time / epochPeriod) - 1; - const veaEpochOutboxClaimableNowOld = veaEpochOutboxClaimableNow; - veaEpochOutboxClaimableNow = Math.floor(timeEth / epochPeriod) - 1; - if (veaEpochOutboxClaimableNow > veaEpochOutboxClaimableNowOld) { - const veaEpochsOutboxClaimableNew: number[] = new Array( - veaEpochOutboxClaimableNow - veaEpochOutboxClaimableNowOld - ) - .fill(veaEpochOutboxClaimableNowOld + 1) - .map((el, i) => el + i); - veaEpochOutboxCheckClaimsRangeArray.push(...veaEpochsOutboxClaimableNew); - } - - if (veaEpochOutboxCheckClaimsRangeArray.length == 0) { - console.log("no claims to check"); - const timeToNextEpoch = epochPeriod - (Math.floor(Date.now() / 1000) % epochPeriod); - console.log("waiting till next epoch in " + timeToNextEpoch + " seconds. . ."); - continue; - } - - for (let index = 0; index < veaEpochOutboxCheckClaimsRangeArray.length; index++) { - console.log("Checking claim for epoch " + veaEpochOutboxCheckClaimsRangeArray[index]); - const challenge = challengeTxnHashes.get(index); - const veaEpochOutboxCheck = veaEpochOutboxCheckClaimsRangeArray[index]; - - // if L1 experiences finality failure, we use the latest block - const blockTagEth = finalityIssueFlagEth ? "latest" : "finalized"; - const claimHash = (await retryOperation( - () => veaOutbox.claimHashes(veaEpochOutboxCheck, { blockTag: blockTagEth }), - 1000, - 10 - )) as string; - - // no claim - if (claimHash == "0x0000000000000000000000000000000000000000000000000000000000000000") { - // if epoch is not claimable anymore, remove from array - if (veaEpochOutboxCheck <= veaEpochOutboxClaimableFinalized) { - console.log( - "no claim for epoch " + - veaEpochOutboxCheck + - " and the vea epoch in the outbox is finalized (can no longer be claimed)." - ); - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - index--; - continue; - } else { - console.log( - "no claim for epoch " + - veaEpochOutboxCheck + - " and the vea epoch in the outbox is not finalized (can still be claimed)." - ); - } - } else { - // claim exists - let blockNumberOutboxLowerBound: number; - - // to query event performantly, we limit the block range with the heuristic that. delta blocknumber <= delta timestamp / secondsPerSlot - if (veaEpochOutboxCheck <= veaEpochOutboxClaimableFinalized) { - blockNumberOutboxLowerBound = - blockFinalizedEth.number - - Math.ceil(((veaEpochOutboxClaimableFinalized - veaEpochOutboxCheck + 2) * epochPeriod) / secondsPerSlotEth); - } else { - blockNumberOutboxLowerBound = blockFinalizedEth.number - Math.ceil(epochPeriod / secondsPerSlotEth); - } - - // get claim data - const logClaimed = ( - await retryOperation( - () => - veaOutbox.queryFilter( - veaOutbox.filters.Claimed(null, veaEpochOutboxCheck, null), - blockNumberOutboxLowerBound, - blockTagEth - ), - 1000, - 10 - ) - )[0] as Log; - // check the snapshot on the inbox on Arbitrum - // only check the state from L1 POV, don't trust the sequencer feed. - // arbBlock is a recent (finalized or latest if there are finality problems) block found posted on L1 - const claimSnapshot = (await retryOperation( - () => veaInbox.snapshots(veaEpochOutboxCheck, { blockTag: blockArbFoundOnL1.number }), - 1000, - 10 - )) as string; - - // claim differs from snapshot - if (logClaimed.data != claimSnapshot) { - console.log("!! Claimed merkle root mismatch for epoch " + veaEpochOutboxCheck); - - // if Eth is finalizing but sequencer is malfunctioning, we can wait until the snapshot is considered finalized (L2 time is in the next epoch) - if (!finalityIssueFlagEth && veaEpochInboxFinalized < veaEpochOutboxCheck) { - // note as long as L1 does not have finalization probelms, sequencer could still be malfunctioning - console.log("L2 snapshot is not yet finalized, waiting for finalization to determine challengable status"); - } else { - const timestampClaimed = ( - (await retryOperation(() => providerEth.getBlock(logClaimed.blockNumber), 1000, 10)) as Block - ).timestamp; - - /* - - we want to constrcut the struct below from events, since only the hash is stored onchain - - struct Claim { - bytes32 stateRoot; - address claimer; - uint32 timestampClaimed; - uint32 timestampVerification; - uint32 blocknumberVerification; - Party honest; - address challenger; - } - - */ - var claim = { - stateRoot: logClaimed.data, - claimer: "0x" + logClaimed.topics[1].substring(26), - timestampClaimed: timestampClaimed, - timestampVerification: 0, - blocknumberVerification: 0, - honest: 0, - challenger: "0x0000000000000000000000000000000000000000", - }; - - // check if the claim is in verification or verified - const logVerficiationStarted = (await retryOperation( - () => - veaOutbox.queryFilter( - veaOutbox.filters.VerificationStarted(veaEpochOutboxCheck), - blockNumberOutboxLowerBound, - blockTagEth - ), - 1000, - 10 - )) as Log[]; - - if (logVerficiationStarted.length > 0) { - const timestampVerification = ( - (await retryOperation( - () => providerEth.getBlock(logVerficiationStarted[logVerficiationStarted.length - 1].blockNumber), - 1000, - 10 - )) as Block - ).timestamp; - - // Update the claim struct with verification details - claim.timestampVerification = timestampVerification; - claim.blocknumberVerification = logVerficiationStarted[logVerficiationStarted.length - 1].blockNumber; - - const claimHashCalculated = hashClaim(claim); - - // The hash should match if there is no challenge made and no honest party yet - if (claimHashCalculated != claimHash) { - // Either challenge is made or honest party is set with or without a challenge - claim.honest = 1; - const claimerHonestHash = hashClaim(claim); - if (claimerHonestHash == claimHash) { - console.log("Claim is honest for epoch " + veaEpochOutboxCheck); - // As the claim is honest, remove the epoch from the local array - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - challengeTxnHashes.delete(index); - continue; - } - // The claim is challenged and anyone can be the honest party - } - } - - const logChallenges = (await retryOperation( - () => - veaOutbox.queryFilter( - veaOutbox.filters.Challenged(veaEpochOutboxCheck, null), - blockNumberOutboxLowerBound, - blockTagEth - ), - 1000, - 10 - )) as Log[]; - - // if not challenged, keep checking all claim struct variables - if (logChallenges.length == 0 && challengeTxnHashes[index] == undefined) { - console.log("Claim is challengeable for epoch " + veaEpochOutboxCheck); - } else if (logChallenges.length > 0) { - // Claim is challenged, we check if the snapShot is sent and if the dispute is resolved - console.log("Claim is already challenged for epoch " + veaEpochOutboxCheck); - claim.challenger = "0x" + logChallenges[0].topics[2].substring(26); - - // if claim hash with challenger as winner matches the claimHash, then the challenge is over and challenger won - const challengerWinClaim = { ...claim }; - challengerWinClaim.honest = 2; // challenger wins - - const claimerWinClaim = { ...claim }; - claimerWinClaim.honest = 1; // claimer wins - if (hashClaim(challengerWinClaim) == claimHash) { - // The challenge is over and challenger won - console.log("Challenger won the challenge for epoch " + veaEpochOutboxCheck); - const withdrawChlngDepositTxn = (await retryOperation( - () => veaOutbox.withdrawChallengeDeposit(veaEpochOutboxCheck, challengerWinClaim), - 1000, - 10 - )) as ContractTransactionResponse; - console.log( - "Deposit withdrawn by challenger for " + - veaEpochOutboxCheck + - " with txn hash " + - withdrawChlngDepositTxn.hash - ); - // As the challenge is over, remove the epoch from the local array - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - challengeTxnHashes.delete(index); - continue; - } else if (hashClaim(claimerWinClaim) == claimHash) { - // The challenge is over and claimer won - console.log("Claimer won the challenge for epoch " + veaEpochOutboxCheck); - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - challengeTxnHashes.delete(index); - continue; - } - - // Claim is challenged, no honest party yet - if (logChallenges[0].blockNumber < blockFinalizedEth.number) { - // Send the "stateRoot" snapshot from Arbitrum to the Eth inbox if not sent already - const claimTimestamp = veaEpochOutboxCheckClaimsRangeArray[index] * epochPeriod; - - let blockLatestArb = (await retryOperation(() => providerArb.getBlock("latest"), 1000, 10)) as Block; - let blockoldArb = (await retryOperation( - () => providerArb.getBlock(blockLatestArb.number - 100), - 1000, - 10 - )) as Block; - - const arbAverageBlockTime = (blockLatestArb.timestamp - blockoldArb.timestamp) / 100; - - const fromClaimEpochBlock = Math.ceil( - blockLatestArb.number - (blockLatestArb.timestamp - claimTimestamp) / arbAverageBlockTime - ); - - const sendSnapshotLogs = (await retryOperation( - () => - veaInbox.queryFilter( - veaInbox.filters.SnapshotSent(veaEpochOutboxCheck, null), - fromClaimEpochBlock, - blockTagEth - ), - 1000, - 10 - )) as Log[]; - if (sendSnapshotLogs.length == 0) { - // No snapshot sent so, send snapshot - try { - const gasEstimate = await retryOperation( - () => veaInbox.sendSnapshot.estimateGas(veaEpochOutboxCheck, claim), - 1000, - 10 - ); - - const txnSendSnapshot = (await retryOperation( - () => - veaInbox["sendSnapshot(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"]( - veaEpochOutboxCheck, - claim, // the claim struct has to be updated with the correct challenger - { - gasLimit: gasEstimate, - } - ), - 1000, - 10 - )) as ContractTransactionResponse; - console.log( - "Snapshot message sent for epoch " + - veaEpochOutboxCheck + - " with txn hash " + - txnSendSnapshot.hash - ); - } catch (error) { - console.error("Error sending snapshot for epoch " + veaEpochOutboxCheck + " with error " + error); - } - } else { - // snapshot already sent, check if the snapshot can be relayed to veaOutbox - console.log("Snapshot already sent for epoch " + veaEpochOutboxCheck); - const msgStatus = await getMessageStatus( - sendSnapshotLogs[0].transactionHash, - process.env.RPC_ARB, - process.env.RPC_ETH - ); - if (msgStatus === 1) { - // msg waiting for execution - const msgExecuteTrnx = await messageExecutor( - sendSnapshotLogs[0].transactionHash, - process.env.RPC_ARB, - process.env.RPC_ETH - ); - if (msgExecuteTrnx) { - // msg executed successfully - console.log("Snapshot message relayed to veaOutbox for epoch " + veaEpochOutboxCheck); - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - challengeTxnHashes.delete(index); - } else { - // msg failed to execute - console.error("Error sending snapshot to veaOutbox for epoch " + veaEpochOutboxCheck); - } - } - } - continue; - } - continue; - } - - if (challengeTxnHashes[index] != undefined) { - const txnReceipt = (await retryOperation( - () => providerEth.getTransactionReceipt(challengeTxnHashes[index]), - 10, - 1000 - )) as TransactionReceipt; - if (!txnReceipt) { - console.log("challenge txn " + challenge[index] + " not mined yet"); - continue; - } - const blockNumber = txnReceipt.blockNumber; - const challengeBlock = (await retryOperation(() => providerEth.getBlock(blockNumber), 1000, 10)) as Block; - if (challengeBlock.number < blockFinalizedEth.number) { - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - index--; - challengeTxnHashes.delete(index); - // the challenge is finalized, no further action needed - console.log("challenge is finalized"); - continue; - } - } - const gasEstimate = (await retryOperation( - () => - veaOutbox["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"].estimateGas( - veaEpochOutboxCheck, - claim, - { value: deposit } - ), - 1000, - 10 - )) as bigint; - - // Adjust the calculation to ensure maxFeePerGas is reasonable - const maxFeePerGasProfitable = deposit / (gasEstimate * BigInt(6)); - - // Set a reasonable maxPriorityFeePerGas but ensure it's lower than maxFeePerGas - let maxPriorityFeePerGasMEV = BigInt(6667000000000); // 6667 gwei - console.log("Transaction Challenge Gas Estimate", gasEstimate.toString()); - - // Ensure maxPriorityFeePerGas <= maxFeePerGas - if (maxPriorityFeePerGasMEV > maxFeePerGasProfitable) { - console.warn( - "maxPriorityFeePerGas is higher than maxFeePerGasProfitable, adjusting maxPriorityFeePerGas" - ); - maxPriorityFeePerGasMEV = maxFeePerGasProfitable; // adjust to be equal or less - } - try { - const txnChallenge = (await retryOperation( - () => - veaOutbox["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"]( - veaEpochOutboxCheck, - claim, - { - maxFeePerGas: maxFeePerGasProfitable, - maxPriorityFeePerGas: maxPriorityFeePerGasMEV, - value: deposit, - gasLimit: gasEstimate, - } - ), - 1000, - 10 - )) as ContractTransactionResponse; - // Make wait for receipt and check if the challenge is finalized - console.log("Transaction Challenge Hash", txnChallenge.hash); - // Update local var with the challenge txn hash - challengeTxnHashes.set(index, txnChallenge.hash); - console.log("challenging claim for epoch " + veaEpochOutboxCheck + " with txn hash " + txnChallenge.hash); - } catch (error) { - console.error("Error challenging claim for epoch " + veaEpochOutboxCheck + " with error " + error); - } - } - } else { - console.log("claim hash matches snapshot for epoch " + veaEpochOutboxCheck); - if ( - veaEpochOutboxCheck <= veaEpochOutboxClaimableFinalized && - veaEpochOutboxCheck >= veaEpochInboxFinalized - ) { - veaEpochOutboxCheckClaimsRangeArray.splice(index, 1); - index--; - continue; - } - } - } - } - - // 3 second delay for potential block and attestation propogation - console.log("waiting 3 seconds for potential block and attestation propogation. . ."); - await wait(1000 * 3); - } -}; - -const wait = (ms) => new Promise((r) => setTimeout(r, ms)); - -const retryOperation = (operation, delay, retries) => - new Promise((resolve, reject) => { - return operation() - .then(resolve) - .catch((reason) => { - if (retries > 0) { - // log retry - console.log("retrying", retries); - return wait(delay) - .then(retryOperation.bind(null, operation, delay, retries - 1)) - .then(resolve) - .catch(reject); - } - return reject(reason); - }); - }); - -const getBlocksAndCheckFinality = async ( - EthProvider: JsonRpcProvider, - ArbProvider: JsonRpcProvider, - sequencer: SequencerInbox, - maxDelaySeconds: number -): Promise<[Block, Block, Boolean] | undefined> => { - const blockFinalizedArb = (await retryOperation(() => ArbProvider.getBlock("finalized"), 1000, 10)) as Block; - const blockFinalizedEth = (await retryOperation(() => EthProvider.getBlock("finalized"), 1000, 10)) as Block; - - const finalityBuffer = 300; // 5 minutes, allows for network delays - const maxFinalityTimeSecondsEth = (slotsPerEpochEth * 3 - 1) * secondsPerSlotEth; // finalization after 2 justified epochs - - let finalityIssueFlagArb = false; - let finalityIssueFlagEth = false; - - // check latest arb block to see if there are any sequencer issues - let blockLatestArb = (await retryOperation(() => ArbProvider.getBlock("latest"), 1000, 10)) as Block; - - const maxDelayInSeconds = 7 * 24 * 60 * 60; // 7 days - let blockoldArb = (await retryOperation(() => ArbProvider.getBlock(blockLatestArb.number - 100), 1000, 10)) as Block; - const arbAverageBlockTime = (blockLatestArb.timestamp - blockoldArb.timestamp) / 100; - const fromBlockArbFinalized = blockFinalizedArb.number - Math.ceil(maxDelayInSeconds / arbAverageBlockTime); - // to performantly query the sequencerInbox's SequencerBatchDelivered event on Eth, we limit the block range - // we use the heuristic that. delta blocknumber <= delta timestamp / secondsPerSlot - // Arb: -----------x <-- Finalized - // || - // \/ - // Eth: -------------------------x <-- Finalized - // /\ - // ||<----------------> <-- Math.floor((timeDiffBlockFinalizedArbL1 + maxDelaySeconds) / secondsPerSlotEth) - // fromBlockEth - - const timeDiffBlockFinalizedArbL1 = blockFinalizedEth.timestamp - blockFinalizedArb.timestamp; - const fromBlockEthFinalized = - blockFinalizedEth.number - Math.floor((timeDiffBlockFinalizedArbL1 + maxDelaySeconds) / secondsPerSlotEth); - - let blockFinalizedArbToL1Block = await ArbBlockToL1Block( - ArbProvider, - sequencer, - blockFinalizedArb, - fromBlockEthFinalized, - fromBlockArbFinalized, - false - ); - - if (!blockFinalizedArbToL1Block) { - console.error("Arbitrum finalized block is not found on L1."); - finalityIssueFlagArb = true; - } else if (Math.abs(blockFinalizedArbToL1Block[0].timestamp - blockFinalizedArb.timestamp) > 1800) { - // The L2 timestamp is drifted from the L1 timestamp in which the L2 block is posted. - console.error("Finalized L2 block time is more than 30 min drifted from L1 clock."); - } - - // blockLatestArbToL1Block[0] is the L1 block, blockLatestArbToL1Block[1] is the L2 block (fallsback on latest L2 block if L2 block is not found on L1) - let blockLatestArbToL1Block = await ArbBlockToL1Block( - ArbProvider, - sequencer, - blockLatestArb, - fromBlockEthFinalized, - fromBlockArbFinalized, - true - ); - - if (finalityIssueFlagArb && !blockLatestArbToL1Block) { - console.error("Arbitrum latest block is not found on L1."); - // this means some issue in the arbitrum node implementation (very bad) - return undefined; - } - - // is blockLatestArb is not found on L1, ArbBlockToL1Block fallsback on the latest L2 block found on L1 - if (blockLatestArbToL1Block[1] != blockLatestArb.number) { - blockLatestArb = (await retryOperation(() => ArbProvider.getBlock(blockLatestArbToL1Block[1]), 1000, 10)) as Block; - } - - // ETH POS assumes synchronized clocks - // using local time as a proxy for true "latest" L1 time - const localTimeSeconds = Math.floor(Date.now() / 1000); - - // The sequencer is completely offline - // Not necessarily a problem, but we should know about it - if (localTimeSeconds - blockLatestArbToL1Block[0].timestamp > 1800) { - console.error("Arbitrum sequencer is offline (from L1 'latest' POV) for atleast 30 minutes."); - } - - // The L2 timestamp is drifted from the L1 timestamp in which the L2 block is posted. - // Not necessarily a problem, but we should know about it - if (Math.abs(blockLatestArbToL1Block[0].timestamp - blockLatestArb.timestamp) > 1800) { - console.error("Latest L2 block time is more than 30 min drifted from L1 clock."); - console.error("L2 block time: " + blockLatestArb.timestamp); - console.error("L1 block time: " + blockLatestArbToL1Block[0].timestamp); - console.error("L2 block number: " + blockLatestArb.number); - } - - // Note: Using last finalized block as a proxy for the latest finalized epoch - // Using a BeaconChain RPC would be more accurate - if (localTimeSeconds - blockFinalizedEth.timestamp > maxFinalityTimeSecondsEth + finalityBuffer) { - console.error("Ethereum mainnet is not finalizing"); - finalityIssueFlagEth = true; - } - - if (blockFinalizedEth.number < blockFinalizedArbToL1Block[0].number) { - console.error( - "Arbitrum 'finalized' block is posted in an L1 block which is not finalized. Arbitrum node is out of sync with L1 node. It's recommended to use the same L1 RPC as the L1 node used by the Arbitrum node." - ); - finalityIssueFlagArb = true; - } - - // if L1 is experiencing finalization problems, we use the latest L2 block - // we could - const blockArbitrum = finalityIssueFlagArb || finalityIssueFlagEth ? blockLatestArb : blockFinalizedArb; - - return [blockArbitrum, blockFinalizedEth, finalityIssueFlagEth]; -}; - -const ArbBlockToL1Block = async ( - L2Provider: JsonRpcProvider, - sequencer: SequencerInbox, - L2Block: Block, - fromBlockEth: number, - fromArbBlock: number, - fallbackLatest: boolean -): Promise<[Block, number] | undefined> => { - const nodeInterface = NodeInterface__factory.connect(NODE_INTERFACE_ADDRESS, L2Provider); - - let latestL2batchOnEth: number; - let latestL2BlockNumberOnEth: number; - let result = (await nodeInterface.functions - .findBatchContainingBlock(L2Block.number, { blockTag: "latest" }) - .catch((e) => { - // If the L2Block is the latest ArbBlock this will always throw an error - console.log("Error finding batch containing block, searching heuristically..."); - })) as any; - - if (!result) { - if (!fallbackLatest) { - return undefined; - } else { - [latestL2batchOnEth, latestL2BlockNumberOnEth] = await findLatestL2BatchAndBlock( - nodeInterface, - fromArbBlock, - L2Block.number - ); - } - } - - const batch = result?.batch?.toNumber() ?? latestL2batchOnEth; - const L2BlockNumberFallback = latestL2BlockNumberOnEth ?? L2Block.number; - /** - * We use the batch number to query the L1 sequencerInbox's SequencerBatchDelivered event - * then, we get its emitted transaction hash. - */ - const queryBatch = sequencer.filters.SequencerBatchDelivered(batch); - - const emittedEvent = (await retryOperation( - () => sequencer.queryFilter(queryBatch, fromBlockEth, "latest"), - 1000, - 10 - )) as any; - if (emittedEvent.length == 0) { - return undefined; - } - - const L1Block = (await retryOperation(() => emittedEvent[0].getBlock(), 1000, 10)) as Block; - return [L1Block, L2BlockNumberFallback]; -}; - -const findLatestL2BatchAndBlock = async ( - nodeInterface: NodeInterface, - fromArbBlock: number, - latestBlockNumber: number -): Promise<[number, number]> => { - let low = fromArbBlock; - let high = latestBlockNumber; - - while (low <= high) { - const mid = Math.floor((low + high) / 2); - try { - (await nodeInterface.functions.findBatchContainingBlock(mid, { blockTag: "latest" })) as any; - low = mid + 1; - } catch (e) { - high = mid - 1; - } - } - if (high < low) return [undefined, undefined]; - // high is now the latest L2 block number that has a corresponding batch on L1 - const result = (await nodeInterface.functions.findBatchContainingBlock(high, { blockTag: "latest" })) as any; - return [result.batch.toNumber(), high]; -}; - -const hashClaim = (claim): any => { - return ethers.solidityPackedKeccak256( - ["bytes32", "address", "uint32", "uint32", "uint32", "uint8", "address"], - [ - claim.stateRoot, - claim.claimer, - claim.timestampClaimed, - claim.timestampVerification, - claim.blocknumberVerification, - claim.honest, - claim.challenger, - ] - ); -}; - -(async () => { - await watch(); -})(); -export default watch; diff --git a/validator-cli/src/ArbToEth/watcherArbToGnosis.ts b/validator-cli/src/ArbToGnosis/watcherArbToGnosis.ts similarity index 100% rename from validator-cli/src/ArbToEth/watcherArbToGnosis.ts rename to validator-cli/src/ArbToGnosis/watcherArbToGnosis.ts diff --git a/validator-cli/src/consts/bridgeRoutes.ts b/validator-cli/src/consts/bridgeRoutes.ts new file mode 100644 index 00000000..b61e4696 --- /dev/null +++ b/validator-cli/src/consts/bridgeRoutes.ts @@ -0,0 +1,48 @@ +require("dotenv").config(); + +interface Bridge { + chain: string; + epochPeriod: number; + deposit: bigint; + minChallengePeriod: number; + sequencerDelayLimit: number; + inboxRPC: string; + outboxRPC: string; + inboxAddress: string; + outboxAddress: string; + routerAddress?: string; + routerProvider?: string; +} + +const bridges: { [chainId: number]: Bridge } = { + 11155111: { + chain: "sepolia", + epochPeriod: 7200, + deposit: BigInt("1000000000000000000"), + minChallengePeriod: 10800, + sequencerDelayLimit: 86400, + inboxRPC: process.env.RPC_ARB, + outboxRPC: process.env.RPC_ETH, + inboxAddress: process.env.VEAINBOX_ARB_TO_ETH_ADDRESS, + outboxAddress: process.env.VEAOUTBOX_ARB_TO_ETH_ADDRESS, + }, + 10200: { + chain: "chiado", + epochPeriod: 3600, + deposit: BigInt("1000000000000000000"), + minChallengePeriod: 10800, + sequencerDelayLimit: 86400, + inboxRPC: process.env.RPC_ARB, + outboxRPC: process.env.RPC_GNOSIS, + routerProvider: process.env.RPC_ETH, + inboxAddress: process.env.VEAINBOX_ARB_TO_GNOSIS_ADDRESS, + routerAddress: process.env.VEA_ROUTER_ARB_TO_GNOSIS_ADDRESS, + outboxAddress: process.env.VEAOUTBOX_ARB_TO_GNOSIS_ADDRESS, + }, +}; + +const getBridgeConfig = (chainId: number): Bridge | undefined => { + return bridges[chainId]; +}; + +export { getBridgeConfig, Bridge }; diff --git a/validator-cli/src/utils/arbMsgExecutor.ts b/validator-cli/src/utils/arbMsgExecutor.ts index e2d8c8bf..64e7840b 100644 --- a/validator-cli/src/utils/arbMsgExecutor.ts +++ b/validator-cli/src/utils/arbMsgExecutor.ts @@ -9,12 +9,23 @@ import { JsonRpcProvider, TransactionReceipt } from "@ethersproject/providers"; import { Signer } from "@ethersproject/abstract-signer"; import { ContractTransaction } from "@ethersproject/contracts"; -// Execute the child-to-parent (arbitrum-to-ethereum) message, for reference see: https://docs.arbitrum.io/sdk/reference/message/ChildToParentMessage -async function messageExecutor(trnxHash: string, childRpc: string, parentRpc: string): Promise { +/** + * Execute the child-to-parent (arbitrum-to-ethereum) message, + * for reference see: https://docs.arbitrum.io/sdk/reference/message/ChildToParentMessage + * + * @param trnxHash Hash of the transaction + * @param childJsonRpc L2 provider + * @param parentJsonRpc L1 provider + * @returns Execution transaction for the message + * + * */ +async function messageExecutor( + trnxHash: string, + childJsonRpc: JsonRpcProvider, + parentProvider: JsonRpcProvider +): Promise { const PRIVATE_KEY = process.env.PRIVATE_KEY; - const childJsonRpc = new JsonRpcProvider(childRpc); const childProvider = new ArbitrumProvider(childJsonRpc); - const parentProvider = new JsonRpcProvider(parentRpc); const childReceipt: TransactionReceipt = await childProvider.getTransactionReceipt(trnxHash); if (!childReceipt) { @@ -35,15 +46,21 @@ async function messageExecutor(trnxHash: string, childRpc: string, parentRpc: st return res; } +/** + * + * @param trnxHash Hash of the transaction + * @param childJsonRpc L2 provider + * @param parentJsonRpc L1 provider + * @returns status of the message: 0 - not ready, 1 - ready + * + */ async function getMessageStatus( trnxHash: string, - childRpc: string, - parentRpc: string + childJsonRpc: JsonRpcProvider, + parentJsonRpc: JsonRpcProvider ): Promise { const PRIVATE_KEY = process.env.PRIVATE_KEY; - const childJsonRpc = new JsonRpcProvider(childRpc); const childProvider = new ArbitrumProvider(childJsonRpc); - const parentProvider = new JsonRpcProvider(parentRpc); let childReceipt: TransactionReceipt | null; @@ -52,7 +69,7 @@ async function getMessageStatus( throw new Error(`Transaction receipt not found for hash: ${trnxHash}`); } const messageReceipt = new ChildTransactionReceipt(childReceipt); - const parentSigner: Signer = new Wallet(PRIVATE_KEY, parentProvider); + const parentSigner: Signer = new Wallet(PRIVATE_KEY, parentJsonRpc); const messages = await messageReceipt.getChildToParentMessages(parentSigner); const childToParentMessage = messages[0]; if (!childToParentMessage) { diff --git a/validator-cli/src/utils/arbToEthState.ts b/validator-cli/src/utils/arbToEthState.ts new file mode 100644 index 00000000..ed4cfca5 --- /dev/null +++ b/validator-cli/src/utils/arbToEthState.ts @@ -0,0 +1,243 @@ +import { JsonRpcProvider, Block } from "@ethersproject/providers"; +import { SequencerInbox } from "@arbitrum/sdk/dist/lib/abi/SequencerInbox"; +import { NodeInterface__factory } from "@arbitrum/sdk/dist/lib/abi/factories/NodeInterface__factory"; +import { NodeInterface } from "@arbitrum/sdk/dist/lib/abi/NodeInterface"; +import { NODE_INTERFACE_ADDRESS } from "@arbitrum/sdk/dist/lib/dataEntities/constants"; +import { getArbitrumNetwork } from "@arbitrum/sdk"; +import { SequencerInbox__factory } from "@arbitrum/sdk/dist/lib/abi/factories/SequencerInbox__factory"; + +// https://github.com/prysmaticlabs/prysm/blob/493905ee9e33a64293b66823e69704f012b39627/config/params/mainnet_config.go#L103 +const slotsPerEpochEth = 32; +const secondsPerSlotEth = 12; + +/** + * This function checks the finality of the blocks on Arbitrum and Ethereum. + * It returns the latest/finalized block on Arbitrum(found on Ethereum) and Ethereum and a flag indicating if there is a finality issue on Ethereum. + * + * @param EthProvider Ethereum provider + * @param ArbProvider Arbitrum provider + * @param veaEpoch epoch number of the claim to be fetched + * @param veaEpochPeriod epoch period of the claim to be fetched + * + * @returns [Arbitrum block, Ethereum block, finalityIssueFlag] + * */ +const getBlocksAndCheckFinality = async ( + EthProvider: JsonRpcProvider, + ArbProvider: JsonRpcProvider, + veaEpoch: number, + veaEpochPeriod: number +): Promise<[Block, Block, boolean] | undefined> => { + const currentEpoch = Math.floor(Date.now() / 1000 / veaEpochPeriod); + + const l2Network = await getArbitrumNetwork(ArbProvider); + const sequencer = SequencerInbox__factory.connect(l2Network.ethBridge.sequencerInbox, EthProvider); + const maxDelaySeconds = Number(await sequencer.maxTimeVariation()); + const blockFinalizedArb = (await ArbProvider.getBlock("finalized")) as Block; + const blockFinalizedEth = (await EthProvider.getBlock("finalized")) as Block; + if ( + currentEpoch - veaEpoch > 2 && + blockFinalizedArb.timestamp > veaEpoch * veaEpochPeriod && + blockFinalizedEth.timestamp > veaEpoch * veaEpochPeriod + ) { + return [blockFinalizedArb, blockFinalizedEth, false]; + } + const finalityBuffer = 300; // 5 minutes, allows for network delays + const maxFinalityTimeSecondsEth = slotsPerEpochEth * 2 * secondsPerSlotEth; // finalization after 2 justified epochs + + let finalityIssueFlagArb = false; + let finalityIssueFlagEth = false; + + // check latest arb block to see if there are any sequencer issues + let blockLatestArb = (await ArbProvider.getBlock("latest")) as Block; + + const maxDelayInSeconds = 7 * 24 * 60 * 60; // 7 days + let blockoldArb = (await ArbProvider.getBlock(blockLatestArb.number - 100)) as Block; + const arbAverageBlockTime = (blockLatestArb.timestamp - blockoldArb.timestamp) / 100; + const fromBlockArbFinalized = blockFinalizedArb.number - Math.ceil(maxDelayInSeconds / arbAverageBlockTime); + // to performantly query the sequencerInbox's SequencerBatchDelivered event on Eth, we limit the block range + // we use the heuristic that. delta blocknumber <= delta timestamp / secondsPerSlot + // Arb: -----------x <-- Finalized + // || + // \/ + // Eth: -------------------------x <-- Finalized + // /\ + // ||<----------------> <-- Math.floor((timeDiffBlockFinalizedArbL1 + maxDelaySeconds) / secondsPerSlotEth) + // fromBlockEth + + const timeDiffBlockFinalizedArbL1 = blockFinalizedEth.timestamp - blockFinalizedArb.timestamp; + const fromBlockEthFinalized = + blockFinalizedEth.number - Math.floor((timeDiffBlockFinalizedArbL1 + maxDelaySeconds) / secondsPerSlotEth); + + let blockFinalizedArbToL1Block = await ArbBlockToL1Block( + ArbProvider, + sequencer, + blockFinalizedArb, + fromBlockEthFinalized, + fromBlockArbFinalized, + false + ); + + if (!blockFinalizedArbToL1Block) { + console.error("Arbitrum finalized block is not found on L1."); + finalityIssueFlagArb = true; + } else if (Math.abs(blockFinalizedArbToL1Block[0].timestamp - blockFinalizedArb.timestamp) > 1800) { + // The L2 timestamp is drifted from the L1 timestamp in which the L2 block is posted. + console.error("Finalized L2 block time is more than 30 min drifted from L1 clock."); + } + + // blockLatestArbToL1Block[0] is the L1 block, blockLatestArbToL1Block[1] is the L2 block (fallsback on latest L2 block if L2 block is not found on L1) + let blockLatestArbToL1Block = await ArbBlockToL1Block( + ArbProvider, + sequencer, + blockLatestArb, + fromBlockEthFinalized, + fromBlockArbFinalized, + true + ); + + if (finalityIssueFlagArb && !blockLatestArbToL1Block) { + console.error("Arbitrum latest block is not found on L1."); + // this means some issue in the arbitrum node implementation (very bad) + return undefined; + } + + // is blockLatestArb is not found on L1, ArbBlockToL1Block fallsback on the latest L2 block found on L1 + if (blockLatestArbToL1Block[1] != blockLatestArb.number) { + blockLatestArb = (await ArbProvider.getBlock(blockLatestArbToL1Block[1])) as Block; + } + + // ETH POS assumes synchronized clocks + // using local time as a proxy for true "latest" L1 time + const localTimeSeconds = Math.floor(Date.now() / 1000); + + // The sequencer is completely offline + // Not necessarily a problem, but we should know about it + if (localTimeSeconds - blockLatestArbToL1Block[0].timestamp > 1800) { + console.error("Arbitrum sequencer is offline (from L1 'latest' POV) for atleast 30 minutes."); + } + + // The L2 timestamp is drifted from the L1 timestamp in which the L2 block is posted. + // Not necessarily a problem, but we should know about it + if (Math.abs(blockLatestArbToL1Block[0].timestamp - blockLatestArb.timestamp) > 1800) { + console.error("Latest L2 block time is more than 30 min drifted from L1 clock."); + console.error("L2 block time: " + blockLatestArb.timestamp); + console.error("L1 block time: " + blockLatestArbToL1Block[0].timestamp); + console.error("L2 block number: " + blockLatestArb.number); + } + + // Note: Using last finalized block as a proxy for the latest finalized epoch + // Using a BeaconChain RPC would be more accurate + if (localTimeSeconds - blockFinalizedEth.timestamp > maxFinalityTimeSecondsEth + finalityBuffer) { + console.error("Ethereum mainnet is not finalizing"); + finalityIssueFlagEth = true; + } + + if (blockFinalizedEth.number < blockFinalizedArbToL1Block[0].number) { + console.error( + "Arbitrum 'finalized' block is posted in an L1 block which is not finalized. Arbitrum node is out of sync with L1 node. It's recommended to use the same L1 RPC as the L1 node used by the Arbitrum node." + ); + finalityIssueFlagArb = true; + } + // if L1 is experiencing finalization problems, we use the latest L2 block + // we could + const blockArbitrum = finalityIssueFlagArb || finalityIssueFlagEth ? blockFinalizedArb : blockLatestArb; + + return [blockArbitrum, blockFinalizedEth, finalityIssueFlagEth]; +}; + +/** + * + * This function finds the corresponding L1(Eth) block for a given L2(Arb) block. + * + * @param L2Provider Arbitrum provider + * @param sequencer Arbitrum sequencerInbox + * @param L2Block L2 block + * @param fromBlockEth from block number on Eth + * @param fromArbBlock from block number on Arb + * @param fallbackLatest fallback to latest L2 block if the L2 block is not found on L1 + * + * @returns [L1Block, L2BlockNumberFallback] + */ + +const ArbBlockToL1Block = async ( + L2Provider: JsonRpcProvider, + sequencer: SequencerInbox, + L2Block: Block, + fromBlockEth: number, + fromArbBlock: number, + fallbackLatest: boolean +): Promise<[Block, number] | undefined> => { + const nodeInterface = NodeInterface__factory.connect(NODE_INTERFACE_ADDRESS, L2Provider); + + let latestL2batchOnEth: number; + let latestL2BlockNumberOnEth: number; + let result = (await nodeInterface.functions + .findBatchContainingBlock(L2Block.number, { blockTag: "latest" }) + .catch((e) => { + // If the L2Block is the latest ArbBlock this will always throw an error + console.log("Error finding batch containing block, searching heuristically..."); + })) as any; + + if (!result) { + if (!fallbackLatest) { + return undefined; + } else { + [latestL2batchOnEth, latestL2BlockNumberOnEth] = await findLatestL2BatchAndBlock( + nodeInterface, + fromArbBlock, + L2Block.number + ); + } + } + + const batch = result?.batch?.toNumber() ?? latestL2batchOnEth; + const L2BlockNumberFallback = latestL2BlockNumberOnEth ?? L2Block.number; + /** + * We use the batch number to query the L1 sequencerInbox's SequencerBatchDelivered event + * then, we get its emitted transaction hash. + */ + const queryBatch = sequencer.filters.SequencerBatchDelivered(batch); + + const emittedEvent = await sequencer.queryFilter(queryBatch, fromBlockEth, "latest"); + if (emittedEvent.length == 0) { + return undefined; + } + + const L1Block = (await emittedEvent[0].getBlock()) as Block; + return [L1Block, L2BlockNumberFallback]; +}; + +/** + * This function finds the latest L2 batch and block number that has a corresponding batch on L1. + * + * @param nodeInterface Arbitrum NodeInterface + * @param fromArbBlock from block number on Arb + * @param latestBlockNumber latest block number on Arb + * + * @returns [latest L2 batch number, latest L2 block number] + */ + +const findLatestL2BatchAndBlock = async ( + nodeInterface: NodeInterface, + fromArbBlock: number, + latestBlockNumber: number +): Promise<[number, number]> => { + let low = fromArbBlock; + let high = latestBlockNumber; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + try { + (await nodeInterface.functions.findBatchContainingBlock(mid)) as any; + low = mid + 1; + } catch (e) { + high = mid - 1; + } + } + + // high is now the latest L2 block number that has a corresponding batch on L1 + const result = (await nodeInterface.functions.findBatchContainingBlock(high)) as any; + return [result.batch.toNumber(), high]; +}; + +export { getBlocksAndCheckFinality }; diff --git a/validator-cli/src/utils/botEvents.ts b/validator-cli/src/utils/botEvents.ts new file mode 100644 index 00000000..bd717f1b --- /dev/null +++ b/validator-cli/src/utils/botEvents.ts @@ -0,0 +1,36 @@ +export enum BotEvents { + // Bridger state + STARTED = "started", + CHECKING = "checking", + WAITING = "waiting", + NO_CLAIM = "no_claim", + VALID_CLAIM = "valid_claim", + + // Epoch state + NO_NEW_MESSAGES = "no_new_messages", + NO_SNAPSHOT = "no_snapshot", + CLAIM_EPOCH_PASSED = "claim_epoch_passed", + + // Claim state + CLAIMING = "claiming", + STARTING_VERIFICATION = "starting_verification", + VERIFICATION_CANT_START = "verification_cant_start", + VERIFYING_SNAPSHOT = "verifying_snapshot", + CANT_VERIFY_SNAPSHOT = "cant_verify_snapshot", + CHALLENGING = "challenging", + CHALLENGER_WON_CLAIM = "challenger_won_claim", + SENDING_SNAPSHOT = "sending_snapshot", + EXECUTING_SNAPSHOT = "executing_snapshot", + CANT_EXECUTE_SNAPSHOT = "cant_execute_snapshot", + WITHDRAWING_CHALLENGE_DEPOSIT = "withdrawing_challenge_deposit", + WITHDRAWING_CLAIM_DEPOSIT = "withdrawing_claim_deposit", + WAITING_ARB_TIMEOUT = "waiting_arb_timeout", + + // Transaction state + TXN_MADE = "txn_made", + TXN_PENDING = "txn_pending", + TXN_PENDING_CONFIRMATIONS = "txn_pending_confirmations", + TXN_FINAL = "txn_final", + TXN_NOT_FINAL = "txn_not_final", + TXN_EXPIRED = "txn_expired", +} diff --git a/validator-cli/src/utils/claim.test.ts b/validator-cli/src/utils/claim.test.ts new file mode 100644 index 00000000..ed319105 --- /dev/null +++ b/validator-cli/src/utils/claim.test.ts @@ -0,0 +1,245 @@ +import { ethers } from "ethers"; +import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; +import { getClaim, hashClaim, getClaimResolveState } from "./claim"; +import { ClaimNotFoundError } from "./errors"; + +let mockClaim: ClaimStruct; +// Pre calculated from the deployed contracts +const hashedMockClaim = "0xfee47661ef0432da320c3b4706ff7d412f421b9d1531c33ce8f2e03bfe5dcfa2"; +const mockBlockTag = "latest"; +const mockFromBlock = 0; + +describe("snapshotClaim", () => { + describe("getClaim", () => { + let veaOutbox: any; + let veaOutboxProvider: any; + const epoch = 1; + beforeEach(() => { + mockClaim = { + stateRoot: "0xeac817ed5c5b3d1c2c548f231b7cf9a0dfd174059f450ec6f0805acf6a16a551", + claimer: "0xFa00D29d378EDC57AA1006946F0fc6230a5E3288", + timestampClaimed: 1730276784, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress, + }; + veaOutbox = { + queryFilter: jest.fn(), + filters: { + VerificationStarted: jest.fn(), + Challenged: jest.fn(), + Claimed: jest.fn(), + }, + claimHashes: jest.fn(), + }; + veaOutboxProvider = { + getBlock: jest.fn().mockResolvedValueOnce({ timestamp: mockClaim.timestampClaimed, number: 1234 }), + }; + }); + + it("should return a valid claim", async () => { + veaOutbox.claimHashes.mockResolvedValueOnce(hashedMockClaim); + veaOutbox.queryFilter + .mockImplementationOnce(() => + Promise.resolve([ + { + data: mockClaim.stateRoot, + topics: [null, `0x000000000000000000000000${mockClaim.claimer.toString().slice(2)}`], + blockNumber: 1234, + }, + ]) + ) // For Claimed + .mockImplementationOnce(() => []) // For Challenged + .mockImplementationOnce(() => Promise.resolve([])); // For VerificationStarted + + const claim = await getClaim(veaOutbox, veaOutboxProvider, epoch, mockFromBlock, mockBlockTag); + + expect(claim).toBeDefined(); + expect(claim).toEqual(mockClaim); + }); + + it("should return a valid claim with challenger", async () => { + mockClaim.challenger = "0xFa00D29d378EDC57AA1006946F0fc6230a5E3288"; + veaOutbox.claimHashes.mockResolvedValueOnce(hashClaim(mockClaim)); + veaOutbox.queryFilter + .mockImplementationOnce(() => + Promise.resolve([ + { + data: mockClaim.stateRoot, + topics: [null, `0x000000000000000000000000${mockClaim.claimer.toString().slice(2)}`], + blockNumber: 1234, + }, + ]) + ) // For Claimed + .mockImplementationOnce(() => + Promise.resolve([ + { + blockHash: "0x1234", + topics: [null, null, `0x000000000000000000000000${mockClaim.challenger.toString().slice(2)}`], + }, + ]) + ) // For Challenged + .mockImplementationOnce(() => Promise.resolve([])); // For VerificationStartedß + + const claim = await getClaim(veaOutbox, veaOutboxProvider, epoch, mockFromBlock, mockBlockTag); + expect(claim).toBeDefined(); + expect(claim).toEqual(mockClaim); + }); + + it("should return a valid claim with verification", async () => { + mockClaim.timestampVerification = 1234; + mockClaim.blocknumberVerification = 1234; + veaOutbox.claimHashes.mockResolvedValueOnce(hashClaim(mockClaim)); + veaOutboxProvider.getBlock.mockResolvedValueOnce({ timestamp: mockClaim.timestampVerification }); + veaOutbox.queryFilter + .mockImplementationOnce(() => + Promise.resolve([ + { + data: mockClaim.stateRoot, + topics: [null, `0x000000000000000000000000${mockClaim.claimer.toString().slice(2)}`], + blockNumber: 1234, + }, + ]) + ) // For Claimed + .mockImplementationOnce(() => Promise.resolve([])) // For Challenged + .mockImplementationOnce(() => + Promise.resolve([ + { + blockNumber: 1234, + }, + ]) + ); // For VerificationStarted + + const claim = await getClaim(veaOutbox, veaOutboxProvider, epoch, mockFromBlock, mockBlockTag); + + expect(claim).toBeDefined(); + expect(claim).toEqual(mockClaim); + }); + + it("should return null if no claim is found", async () => { + veaOutbox.claimHashes.mockResolvedValueOnce(ethers.ZeroHash); + + const claim = await getClaim(veaOutbox, veaOutboxProvider, epoch, mockFromBlock, mockBlockTag); + expect(claim).toBeNull(); + expect(veaOutbox.queryFilter).toHaveBeenCalledTimes(0); + }); + + it("should throw an error if no claim is found", async () => { + veaOutbox.claimHashes.mockResolvedValueOnce(hashedMockClaim); + veaOutbox.queryFilter + .mockImplementationOnce(() => Promise.resolve([])) + .mockImplementationOnce(() => Promise.resolve([])) + .mockImplementationOnce(() => Promise.resolve([])); + + await expect(async () => { + await getClaim(veaOutbox, veaOutboxProvider, epoch, mockFromBlock, mockBlockTag); + }).rejects.toThrow(new ClaimNotFoundError(epoch)); + }); + + it("should throw an error if the claim is not valid", async () => { + veaOutbox.claimHashes.mockResolvedValueOnce(hashClaim(mockClaim)); + mockClaim.honest = 1; + veaOutbox.queryFilter + .mockImplementationOnce(() => + Promise.resolve([ + { + data: mockClaim.stateRoot, + topics: [null, `0x000000000000000000000000${ethers.ZeroAddress.toString().slice(2)}`], + blockNumber: 1234, + }, + ]) + ) // For Claimed + .mockImplementationOnce(() => []) // For Challenged + .mockImplementationOnce(() => Promise.resolve([])); // For VerificationStarted + + await expect(async () => { + await getClaim(veaOutbox, veaOutboxProvider, epoch, mockFromBlock, mockBlockTag); + }).rejects.toThrow(new ClaimNotFoundError(epoch)); + }); + }); + + describe("hashClaim", () => { + beforeEach(() => { + mockClaim = { + stateRoot: "0xeac817ed5c5b3d1c2c548f231b7cf9a0dfd174059f450ec6f0805acf6a16a551", + claimer: "0xFa00D29d378EDC57AA1006946F0fc6230a5E3288", + timestampClaimed: 1730276784, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress, + }; + }); + it("should return a valid hash", () => { + const hash = hashClaim(mockClaim); + expect(hash).toBeDefined(); + expect(hash).toEqual(hashedMockClaim); + }); + + it("should not return a valid hash", () => { + mockClaim.honest = 1; + const hash = hashClaim(mockClaim); + expect(hash).toBeDefined(); + expect(hash).not.toEqual(hashedMockClaim); + }); + }); + + describe("getClaimResolveState", () => { + let veaInbox: any; + let veaInboxProvider: any; + let veaOutboxProvider: any; + const epoch = 1; + const blockNumberOutboxLowerBound = 1234; + const toBlock = "latest"; + beforeEach(() => { + mockClaim = { + stateRoot: "0xeac817ed5c5b3d1c2c548f231b7cf9a0dfd174059f450ec6f0805acf6a16a551", + claimer: "0xFa00D29d378EDC57AA1006946F0fc6230a5E3288", + timestampClaimed: 1730276784, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress, + }; + veaInbox = { + queryFilter: jest.fn(), + filters: { + SnapshotSent: jest.fn(), + }, + }; + }); + + it("should return pending state for both", async () => { + veaInbox.queryFilter.mockResolvedValueOnce([]); + const claimResolveState = await getClaimResolveState( + veaInbox, + veaInboxProvider, + veaOutboxProvider, + epoch, + blockNumberOutboxLowerBound, + toBlock + ); + expect(claimResolveState).toBeDefined(); + expect(claimResolveState.sendSnapshot.status).toBeFalsy(); + expect(claimResolveState.execution.status).toBe(0); + }); + + it("should return pending state for execution", async () => { + veaInbox.queryFilter.mockResolvedValueOnce([{ transactionHash: "0x1234" }]); + const mockGetMessageStatus = jest.fn().mockResolvedValueOnce(0); + const claimResolveState = await getClaimResolveState( + veaInbox, + veaInboxProvider, + veaOutboxProvider, + epoch, + blockNumberOutboxLowerBound, + toBlock, + mockGetMessageStatus + ); + expect(claimResolveState).toBeDefined(); + expect(claimResolveState.sendSnapshot.status).toBeTruthy(); + expect(claimResolveState.execution.status).toBe(0); + }); + }); +}); diff --git a/validator-cli/src/utils/claim.ts b/validator-cli/src/utils/claim.ts new file mode 100644 index 00000000..3a76e268 --- /dev/null +++ b/validator-cli/src/utils/claim.ts @@ -0,0 +1,144 @@ +import { ClaimStruct } from "@kleros/vea-contracts/typechain-types/arbitrumToEth/VeaInboxArbToEth"; +import { JsonRpcProvider } from "@ethersproject/providers"; +import { ethers } from "ethers"; +import { ClaimNotFoundError } from "./errors"; +import { getMessageStatus } from "./arbMsgExecutor"; + +/** + * + * @param veaOutbox VeaOutbox contract instance + * @param epoch epoch number of the claim to be fetched + * @returns claim type of ClaimStruct + */ + +enum ClaimHonestState { + NONE = 0, + CLAIMER = 1, + CHALLENGER = 2, +} +const getClaim = async ( + veaOutbox: any, + veaOutboxProvider: JsonRpcProvider, + epoch: number, + fromBlock: number, + toBlock: number | string +): Promise => { + let claim: ClaimStruct = { + stateRoot: ethers.ZeroHash, + claimer: ethers.ZeroAddress, + timestampClaimed: 0, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress, + }; + const claimHash = await veaOutbox.claimHashes(epoch); + if (claimHash === ethers.ZeroHash) return null; + + const [claimLogs, challengeLogs, verificationLogs] = await Promise.all([ + veaOutbox.queryFilter(veaOutbox.filters.Claimed(null, epoch, null), fromBlock, toBlock), + veaOutbox.queryFilter(veaOutbox.filters.Challenged(epoch, null), fromBlock, toBlock), + veaOutbox.queryFilter(veaOutbox.filters.VerificationStarted(epoch), fromBlock, toBlock), + ]); + + if (claimLogs.length === 0) throw new ClaimNotFoundError(epoch); + + claim.stateRoot = claimLogs[0].data; + claim.claimer = `0x${claimLogs[0].topics[1].slice(26)}`; + claim.timestampClaimed = (await veaOutboxProvider.getBlock(claimLogs[0].blockNumber)).timestamp; + + if (verificationLogs.length > 0) { + claim.timestampVerification = (await veaOutboxProvider.getBlock(verificationLogs[0].blockNumber)).timestamp; + claim.blocknumberVerification = verificationLogs[0].blockNumber; + } + if (challengeLogs.length > 0) { + claim.challenger = "0x" + challengeLogs[0].topics[2].substring(26); + } + + if (hashClaim(claim) == claimHash) { + return claim; + } + claim.honest = ClaimHonestState.CLAIMER; // Assuming claimer is honest + if (hashClaim(claim) == claimHash) { + return claim; + } + claim.honest = ClaimHonestState.CHALLENGER; // Assuming challenger is honest + if (hashClaim(claim) == claimHash) { + return claim; + } + throw new ClaimNotFoundError(epoch); +}; + +type ClaimResolveState = { + sendSnapshot: { + status: boolean; + txHash: string; + }; + execution: { + status: number; // 0: not ready, 1: ready, 2: executed + txHash: string; + }; +}; + +/** + * Fetches the claim resolve state. + * @param veaInbox VeaInbox contract instance + * @param veaInboxProvider VeaInbox provider + * @param veaOutboxProvider VeaOutbox provider + * @param epoch epoch number of the claim to be fetched + * @param fromBlock from block number + * @param toBlock to block number + * @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 sentSnapshotLogs = await veaInbox.queryFilter(veaInbox.filters.SnapshotSent(epoch, null), fromBlock, toBlock); + + const claimResolveState: ClaimResolveState = { + sendSnapshot: { status: false, txHash: "" }, + execution: { status: 0, txHash: "" }, + }; + + if (sentSnapshotLogs.length === 0) return claimResolveState; + + claimResolveState.sendSnapshot.status = true; + claimResolveState.sendSnapshot.txHash = sentSnapshotLogs[0].transactionHash; + + const status = await fetchMessageStatus(sentSnapshotLogs[0].transactionHash, veaInboxProvider, veaOutboxProvider); + claimResolveState.execution.status = status; + + return claimResolveState; +}; + +/** + * Hashes the claim data. + * + * @param claim - The claim data to be hashed + * + * @returns The hash of the claim data + * + */ +const hashClaim = (claim: ClaimStruct) => { + return ethers.solidityPackedKeccak256( + ["bytes32", "address", "uint32", "uint32", "uint32", "uint8", "address"], + [ + claim.stateRoot, + claim.claimer, + claim.timestampClaimed, + claim.timestampVerification, + claim.blocknumberVerification, + claim.honest, + claim.challenger, + ] + ); +}; + +export { getClaim, hashClaim, getClaimResolveState, ClaimHonestState }; diff --git a/validator-cli/src/utils/cli.test.ts b/validator-cli/src/utils/cli.test.ts new file mode 100644 index 00000000..3ad8cc81 --- /dev/null +++ b/validator-cli/src/utils/cli.test.ts @@ -0,0 +1,25 @@ +import { getBotPath, BotPaths } from "./cli"; +import { InvalidBotPathError } from "./errors"; +describe("cli", () => { + describe("getBotPath", () => { + const defCommand = ["yarn", "start"]; + it("should return the default path", () => { + const path = getBotPath({ cliCommand: defCommand }); + expect(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); + }); + it("should return the challenger path", () => { + const command = ["yarn", "start", "--path=challenger"]; + const path = getBotPath({ cliCommand: command }); + expect(path).toEqual(BotPaths.CHALLENGER); + }); + it("should throw an error for invalid path", () => { + const command = ["yarn", "start", "--path=invalid"]; + expect(() => getBotPath({ cliCommand: command })).toThrow(new InvalidBotPathError()); + }); + }); +}); diff --git a/validator-cli/src/utils/cli.ts b/validator-cli/src/utils/cli.ts new file mode 100644 index 00000000..e869beed --- /dev/null +++ b/validator-cli/src/utils/cli.ts @@ -0,0 +1,35 @@ +import { InvalidBotPathError } from "./errors"; +export enum BotPaths { + CLAIMER = 0, // happy path + CHALLENGER = 1, // unhappy path + BOTH = 2, // both happy and unhappy path +} + +interface BotPathParams { + cliCommand: string[]; + defaultPath?: BotPaths; +} + +/** + * Get the bot path from the command line arguments + * @param defaultPath - default path to use if not specified in the command line arguments + * @returns BotPaths - the bot path (BotPaths) + */ +export function getBotPath({ cliCommand, defaultPath = BotPaths.BOTH }: BotPathParams): number { + const args = cliCommand.slice(2); + const pathFlag = args.find((arg) => arg.startsWith("--path=")); + + const path = pathFlag ? pathFlag.split("=")[1] : null; + + const pathMapping: Record = { + claimer: BotPaths.CLAIMER, + challenger: BotPaths.CHALLENGER, + both: BotPaths.BOTH, + }; + + if (path && !(path in pathMapping)) { + throw new InvalidBotPathError(); + } + + return path ? pathMapping[path] : defaultPath; +} diff --git a/validator-cli/src/utils/emitter.ts b/validator-cli/src/utils/emitter.ts new file mode 100644 index 00000000..530f8345 --- /dev/null +++ b/validator-cli/src/utils/emitter.ts @@ -0,0 +1,14 @@ +import { EventEmitter } from "node:events"; +import { BotEvents } from "./botEvents"; + +export const defaultEmitter = new EventEmitter(); + +export class MockEmitter extends EventEmitter { + emit(event: string | symbol, ...args: any[]): boolean { + // Prevent console logs for BotEvents during tests + if (Object.values(BotEvents).includes(event as BotEvents)) { + return true; + } + return super.emit(event, ...args); + } +} diff --git a/validator-cli/src/utils/epochHandler.test.ts b/validator-cli/src/utils/epochHandler.test.ts new file mode 100644 index 00000000..23cb1ed9 --- /dev/null +++ b/validator-cli/src/utils/epochHandler.test.ts @@ -0,0 +1,37 @@ +import { setEpochRange, getLatestChallengeableEpoch } from "./epochHandler"; + +describe("epochHandler", () => { + describe("setEpochRange", () => { + const currentEpoch = 1000000; + + const mockedEpochPeriod = 1000; + const mockedSeqDelayLimit = 1000; + const startCoolDown = 7 * 24 * 60 * 60; + const currentTimestamp = currentEpoch * mockedEpochPeriod; + const now = (currentTimestamp + mockedEpochPeriod + 1) * 1000; // In ms + const startEpoch = + Math.floor((currentTimestamp - (mockedSeqDelayLimit + mockedEpochPeriod + startCoolDown)) / mockedEpochPeriod) - + 2; + it("should return the correct epoch range", () => { + const mockedFetchBridgeConfig = jest.fn(() => ({ + epochPeriod: mockedEpochPeriod, + sequencerDelayLimit: mockedSeqDelayLimit, + })); + const result = setEpochRange(currentEpoch * mockedEpochPeriod, 1, now, mockedFetchBridgeConfig as any); + expect(result[result.length - 1]).toEqual(currentEpoch - 1); + expect(result[0]).toEqual(startEpoch); + }); + }); + + describe("getLatestChallengeableEpoch", () => { + it("should return the correct epoch number", () => { + const chainId = 1; + const now = 1626325200000; + const fetchBridgeConfig = jest.fn(() => ({ + epochPeriod: 600, + })); + const result = getLatestChallengeableEpoch(chainId, now, fetchBridgeConfig as any); + expect(result).toEqual(now / (600 * 1000) - 2); + }); + }); +}); diff --git a/validator-cli/src/utils/epochHandler.ts b/validator-cli/src/utils/epochHandler.ts new file mode 100644 index 00000000..3d9ec386 --- /dev/null +++ b/validator-cli/src/utils/epochHandler.ts @@ -0,0 +1,73 @@ +import { JsonRpcProvider } from "@ethersproject/providers"; +import { getBridgeConfig } from "../consts/bridgeRoutes"; + +/** + * Sets the epoch range to check for claims. + * + * @param currentTimestamp - The current timestamp + * @param chainId - The chain ID + * @param now - The current time in milliseconds (optional, defaults to Date.now()) + * @param fetchBridgeConfig - The function to fetch the bridge configuration (optional, defaults to getBridgeConfig) + * + * @returns The epoch range to check for claims + */ + +const setEpochRange = ( + currentTimestamp: number, + chainId: number, + now: number = Date.now(), + fetchBridgeConfig: typeof getBridgeConfig = getBridgeConfig +): Array => { + const { sequencerDelayLimit, epochPeriod } = fetchBridgeConfig(chainId); + const coldStartBacklog = 7 * 24 * 60 * 60; // when starting the watcher, specify an extra backlog to check + + // When Sequencer is malicious, even when L1 is finalized, L2 state might be unknown for up to sequencerDelayLimit + epochPeriod. + const L2SyncPeriod = sequencerDelayLimit + epochPeriod; + // When we start the watcher, we need to go back far enough to check for claims which may have been pending L2 state finalization. + const veaEpochOutboxWatchLowerBound = + Math.floor((currentTimestamp - L2SyncPeriod - coldStartBacklog) / epochPeriod) - 2; + // ETH / Gnosis POS assumes synchronized clocks + // using local time as a proxy for true "latest" L1 time + const timeLocal = Math.floor(now / 1000); + + let veaEpochOutboxClaimableNow = Math.floor(timeLocal / epochPeriod) - 1; + // only past epochs are claimable, hence shift by one here + const veaEpochOutboxRange = veaEpochOutboxClaimableNow - veaEpochOutboxWatchLowerBound; + const veaEpochOutboxCheckClaimsRangeArray: number[] = new Array(veaEpochOutboxRange) + .fill(veaEpochOutboxWatchLowerBound) + .map((el, i) => el + i); + + return veaEpochOutboxCheckClaimsRangeArray; +}; + +/** + * Checks if a new epoch has started. + * + * @param currentVerifiableEpoch - The current verifiable epoch number + * @param epochPeriod - The epoch period in seconds + * @param now - The current time in milliseconds (optional, defaults to Date.now()) + * + * @returns The updated epoch number + * + * @example + * currentEpoch = checkForNewEpoch(currentEpoch, 7200); + */ +const getLatestChallengeableEpoch = ( + chainId: number, + now: number = Date.now(), + fetchBridgeConfig: typeof getBridgeConfig = getBridgeConfig +): number => { + const { epochPeriod } = fetchBridgeConfig(chainId); + return Math.floor(now / 1000 / epochPeriod) - 2; +}; + +const getBlockFromEpoch = async (epoch: number, epochPeriod: number, provider: JsonRpcProvider): Promise => { + const epochTimestamp = epoch * epochPeriod; + const latestBlock = await provider.getBlock("latest"); + const baseBlock = await provider.getBlock(latestBlock.number - 100); + const secPerBlock = (latestBlock.timestamp - baseBlock.timestamp) / (latestBlock.number - baseBlock.number); + const blockFallBack = Math.floor((latestBlock.timestamp - epochTimestamp) / secPerBlock); + return latestBlock.number - blockFallBack; +}; + +export { setEpochRange, getLatestChallengeableEpoch, getBlockFromEpoch }; diff --git a/validator-cli/src/utils/errors.ts b/validator-cli/src/utils/errors.ts new file mode 100644 index 00000000..79566328 --- /dev/null +++ b/validator-cli/src/utils/errors.ts @@ -0,0 +1,34 @@ +import { BotPaths } from "./cli"; +class ClaimNotFoundError extends Error { + constructor(epoch: number) { + super(); + this.name = "ClaimNotFoundError"; + this.message = `No claim was found for ${epoch}`; + } +} + +class ClaimNotSetError extends Error { + constructor() { + super(); + this.name = "NoClaimSetError"; + this.message = "Claim is not set"; + } +} + +class TransactionHandlerNotDefinedError extends Error { + constructor() { + super(); + this.name = "TransactionHandlerNotDefinedError"; + this.message = "TransactionHandler is not defined"; + } +} + +class InvalidBotPathError extends Error { + constructor() { + super(); + this.name = "InvalidBotPath"; + this.message = `Invalid path provided, Use one of: ${Object.keys(BotPaths).join("), ")}`; + } +} + +export { ClaimNotFoundError, ClaimNotSetError, TransactionHandlerNotDefinedError, InvalidBotPathError }; diff --git a/validator-cli/src/utils/ethers.ts b/validator-cli/src/utils/ethers.ts index 48b542e0..5707a2b8 100644 --- a/validator-cli/src/utils/ethers.ts +++ b/validator-cli/src/utils/ethers.ts @@ -9,6 +9,10 @@ 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 { TransactionHandlerNotDefinedError } from "./errors"; function getWallet(privateKey: string, web3ProviderURL: string) { return new Wallet(privateKey, new JsonRpcProvider(web3ProviderURL)); @@ -18,46 +22,73 @@ function getWalletRPC(privateKey: string, rpc: JsonRpcProvider) { return new Wallet(privateKey, rpc); } -function getVeaInboxArbToEth(veaInboxAddress: string, privateKey: string, web3ProviderURL: string) { - return VeaInboxArbToEth__factory.connect(veaInboxAddress, getWallet(privateKey, web3ProviderURL)); +function getVeaInbox(veaInboxAddress: string, privateKey: string, web3ProviderURL: string, chainId: number) { + switch (chainId) { + case 11155111: + return VeaInboxArbToEth__factory.connect(veaInboxAddress, getWallet(privateKey, web3ProviderURL)); + case 10200: + return VeaInboxArbToGnosis__factory.connect(veaInboxAddress, getWallet(privateKey, web3ProviderURL)); + } } -function getWETH(WETH: string, privateKey: string, web3ProviderURL: string) { - return IWETH__factory.connect(WETH, getWallet(privateKey, web3ProviderURL)); -} - -function getVeaOutboxArbToEth(veaOutboxAddress: string, privateKey: string, web3ProviderURL: string) { - return VeaOutboxArbToEth__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); -} - -function getVeaOutboxArbToEthDevnet(veaOutboxAddress: string, privateKey: string, web3ProviderURL: string) { - return VeaOutboxArbToEthDevnet__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); +function getVeaOutbox(veaOutboxAddress: string, privateKey: string, web3ProviderURL: string, chainId: number) { + switch (chainId) { + case 11155111: + return VeaOutboxArbToEth__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); + case 10200: + return VeaOutboxArbToGnosis__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); + } } -function getVeaOutboxArbToGnosis(veaOutboxAddress: string, privateKey: string, web3ProviderURL: string) { - return VeaOutboxArbToGnosis__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); +function getVeaRouter(veaRouterAddress: string, privateKey: string, web3ProviderURL: string, chainId: number) { + switch (chainId) { + case 10200: + return RouterArbToGnosis__factory.connect(veaRouterAddress, getWallet(privateKey, web3ProviderURL)); + } } -function getVeaInboxArbToGnosis(veaInboxAddress: string, privateKey: string, web3ProviderURL: string) { - return VeaInboxArbToGnosis__factory.connect(veaInboxAddress, getWallet(privateKey, web3ProviderURL)); +function getWETH(WETH: string, privateKey: string, web3ProviderURL: string) { + return IWETH__factory.connect(WETH, getWallet(privateKey, web3ProviderURL)); } -function getVeaRouterArbToGnosis(veaRouterAddress: string, privateKey: string, web3ProviderURL: string) { - return RouterArbToGnosis__factory.connect(veaRouterAddress, getWallet(privateKey, web3ProviderURL)); +function getVeaOutboxArbToEthDevnet(veaOutboxAddress: string, privateKey: string, web3ProviderURL: string) { + return VeaOutboxArbToEthDevnet__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); } function getAMB(ambAddress: string, privateKey: string, web3ProviderURL: string) { return IAMB__factory.connect(ambAddress, getWallet(privateKey, web3ProviderURL)); } + +const getClaimValidator = (chainId: number) => { + switch (chainId) { + case 11155111: + return challengeAndResolveClaimArbToEth; + } +}; +const getClaimer = (chainId: number) => { + switch (chainId) { + case 11155111: + return checkAndClaim; + } +}; +const getTransactionHandler = (chainId: number) => { + switch (chainId) { + case 11155111: + return ArbToEthTransactionHandler; + default: + throw new TransactionHandlerNotDefinedError(); + } +}; export { - getVeaOutboxArbToEth, getWalletRPC, getWallet, + getVeaInbox, + getVeaOutbox, getVeaOutboxArbToEthDevnet, - getVeaInboxArbToEth, - getVeaOutboxArbToGnosis, - getVeaInboxArbToGnosis, - getVeaRouterArbToGnosis, getWETH, getAMB, + getClaimValidator, + getClaimer, + getTransactionHandler, + getVeaRouter, }; diff --git a/validator-cli/src/utils/graphQueries.ts b/validator-cli/src/utils/graphQueries.ts new file mode 100644 index 00000000..a7b5ca6d --- /dev/null +++ b/validator-cli/src/utils/graphQueries.ts @@ -0,0 +1,65 @@ +import request from "graphql-request"; + +interface ClaimData { + id: string; + bridger: string; + stateroot: string; + timestamp: number; + challenged: boolean; + txHash: string; +} + +/** + * Fetches the claim data for a given epoch (used for claimer - happy path) + * @param epoch + * @returns ClaimData + * */ +const getClaimForEpoch = async (epoch: number): Promise => { + try { + const subgraph = process.env.VEAOUTBOX_SUBGRAPH; + + const result = await request( + `${subgraph}`, + `{ + claims(where: {epoch: ${epoch}}) { + id + bridger + stateroot + timestamp + txHash + challenged + } + }` + ); + return result[`claims`][0]; + } catch (e) { + console.log(e); + return undefined; + } +}; + +/** + * Fetches the last claimed epoch (used for claimer - happy path) + * @returns ClaimData + */ +const getLastClaimedEpoch = async (): Promise => { + const subgraph = process.env.VEAOUTBOX_SUBGRAPH; + + const result = await request( + `${subgraph}`, + `{ + claims(first:1, orderBy:timestamp, orderDirection:desc){ + id + bridger + stateroot + timestamp + challenged + txHash + } + + }` + ); + return result[`claims`][0]; +}; + +export { getClaimForEpoch, getLastClaimedEpoch, ClaimData }; diff --git a/validator-cli/src/utils/logger.ts b/validator-cli/src/utils/logger.ts new file mode 100644 index 00000000..b2123e3e --- /dev/null +++ b/validator-cli/src/utils/logger.ts @@ -0,0 +1,124 @@ +import { EventEmitter } from "node:events"; +import { BotEvents } from "./botEvents"; +import { BotPaths } from "./cli"; + +/** + * Listens to relevant events of an EventEmitter instance and issues log lines + * + * @param emitter - The event emitter instance that issues the relevant events + * + * @example + * + * const emitter = new EventEmitter(); + * initialize(emitter); + */ + +export const initialize = (emitter: EventEmitter) => { + return configurableInitialize(emitter); +}; + +export const configurableInitialize = (emitter: EventEmitter) => { + // Bridger state logs + emitter.on(BotEvents.STARTED, (chainId: number, path: number) => { + let pathString = "challenger and claimer"; + if (path === BotPaths.CLAIMER) { + pathString = "bridger"; + } else if (path === BotPaths.CHALLENGER) { + pathString = "challenger"; + } + console.log(`Bot started for chainId ${chainId} as ${pathString}`); + }); + + emitter.on(BotEvents.CHECKING, (epoch: number) => { + console.log(`Running checks for epoch ${epoch}`); + }); + + emitter.on(BotEvents.WAITING, (epoch: number) => { + console.log(`Waiting for next verifiable epoch after ${epoch}`); + }); + + // Epoch state logs + emitter.on(BotEvents.NO_SNAPSHOT, () => { + console.log("No snapshot saved for epoch"); + }); + + emitter.on(BotEvents.CLAIM_EPOCH_PASSED, (epoch: number) => { + console.log(`Epoch ${epoch} has passed for claiming`); + }); + + // Transaction state logs + emitter.on(BotEvents.TXN_MADE, (transaction: string, epoch: number, state: string) => { + console.log(`${state} transaction for ${epoch} made with hash: ${transaction}`); + }); + emitter.on(BotEvents.TXN_PENDING, (transaction: string) => { + console.log(`Transaction is still pending with hash: ${transaction}`); + }); + + emitter.on(BotEvents.TXN_FINAL, (transaction: string, confirmations: number) => { + console.log(`Transaction(${transaction}) is final with ${confirmations} confirmations`); + }); + + emitter.on(BotEvents.TXN_NOT_FINAL, (transaction: string, confirmations: number) => { + console.log(`Transaction(${transaction}) is not final yet, ${confirmations} confirmations left.`); + }); + emitter.on(BotEvents.TXN_PENDING_CONFIRMATIONS, (transaction: string, confirmations: number) => { + console.log(`Transaction(${transaction}) is pending with ${confirmations} confirmations`); + }); + emitter.on(BotEvents.TXN_EXPIRED, (transaction: string) => { + console.log(`Transaction(${transaction}) is expired`); + }); + + // Claim state logs + // claim() + emitter.on(BotEvents.CLAIMING, (epoch: number) => { + console.log(`Claiming for epoch ${epoch}`); + }); + // startVerification() + emitter.on(BotEvents.STARTING_VERIFICATION, (epoch: number) => { + console.log(`Starting verification for epoch ${epoch}`); + }); + emitter.on(BotEvents.VERIFICATION_CANT_START, (epoch: number, timeLeft: number) => { + console.log(`Verification cant start for epoch ${epoch}, time left: ${timeLeft}`); + }); + // verifySnapshot() + emitter.on(BotEvents.VERIFYING_SNAPSHOT, (epoch: number) => { + console.log(`Verifying snapshot for epoch ${epoch}`); + }); + emitter.on(BotEvents.CANT_VERIFY_SNAPSHOT, (epoch: number, timeLeft: number) => { + console.log(`Cant verify snapshot for epoch ${epoch}, time left: ${timeLeft}`); + }); + // challenge() + emitter.on(BotEvents.CHALLENGING, (epoch: number) => { + console.log(`Claim can be challenged, challenging for epoch ${epoch}`); + }); + // startVerification() + emitter.on(BotEvents.SENDING_SNAPSHOT, (epoch: number) => { + console.log(`Sending snapshot for ${epoch}`); + }); + // executeSnapshot() + emitter.on(BotEvents.EXECUTING_SNAPSHOT, (epoch) => { + console.log(`Executing snapshot to resolve dispute for epoch ${epoch}`); + }); + // verifySnapshot() + emitter.on(BotEvents.CANT_EXECUTE_SNAPSHOT, () => { + console.log("Cant execute snapshot, waiting l2 challenge period to pass"); + }); + // withdrawClaimDeposit() + emitter.on(BotEvents.WITHDRAWING_CHALLENGE_DEPOSIT, () => { + console.log(`Withdrawing challenge deposit for epoch`); + }); + emitter.on(BotEvents.WAITING_ARB_TIMEOUT, (epoch: number) => { + console.log(`Waiting for arbitrum bridge timeout for epoch ${epoch}`); + }); + + // validator + emitter.on(BotEvents.NO_CLAIM, (epoch: number) => { + console.log(`No claim was made for ${epoch}`); + }); + emitter.on(BotEvents.VALID_CLAIM, (epoch: number) => { + console.log(`Valid claim was made for ${epoch}`); + }); + emitter.on(BotEvents.CHALLENGER_WON_CLAIM, () => { + console.log("Challenger won claim"); + }); +}; diff --git a/validator-cli/src/utils/shutdown.ts b/validator-cli/src/utils/shutdown.ts new file mode 100644 index 00000000..74671caf --- /dev/null +++ b/validator-cli/src/utils/shutdown.ts @@ -0,0 +1,18 @@ +/** + * A class to represent a shutdown signal. + */ +export class ShutdownSignal { + private isShutdownSignal: boolean; + + constructor(initialState: boolean = false) { + this.isShutdownSignal = initialState; + } + + public getIsShutdownSignal(): boolean { + return this.isShutdownSignal; + } + + public setShutdownSignal(): void { + this.isShutdownSignal = true; + } +} diff --git a/validator-cli/src/watcher.ts b/validator-cli/src/watcher.ts new file mode 100644 index 00000000..8165e665 --- /dev/null +++ b/validator-cli/src/watcher.ts @@ -0,0 +1,94 @@ +import { JsonRpcProvider } from "@ethersproject/providers"; +import { getBridgeConfig, Bridge } from "./consts/bridgeRoutes"; +import { getVeaInbox, getVeaOutbox, getTransactionHandler } 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"; +import { ShutdownSignal } from "./utils/shutdown"; +import { getBotPath, BotPaths } from "./utils/cli"; +import { getClaim } from "./utils/claim"; + +/** + * @file This file contains the logic for watching a bridge and validating/resolving for claims. + * + * @param shutDownSignal - The signal to shut down the watcher + * @param emitter - The emitter to emit events + * + */ + +export const watch = async ( + shutDownSignal: ShutdownSignal = new ShutdownSignal(), + emitter: typeof defaultEmitter = defaultEmitter +) => { + initializeLogger(emitter); + const cliCommand = process.argv; + const path = getBotPath({ cliCommand }); + const chainId = Number(process.env.VEAOUTBOX_CHAIN_ID); + emitter.emit(BotEvents.STARTED, chainId, path); + const veaBridge: Bridge = getBridgeConfig(chainId); + const veaInbox = getVeaInbox(veaBridge.inboxAddress, process.env.PRIVATE_KEY, veaBridge.inboxRPC, chainId); + const veaOutbox = getVeaOutbox(veaBridge.outboxAddress, process.env.PRIVATE_KEY, veaBridge.outboxRPC, chainId); + const veaInboxProvider = new JsonRpcProvider(veaBridge.inboxRPC); + const veaOutboxProvider = new JsonRpcProvider(veaBridge.outboxRPC); + const checkAndChallengeResolve = getClaimValidator(chainId); + const checkAndClaim = getClaimer(chainId); + const TransactionHandler = getTransactionHandler(chainId); + + let veaOutboxLatestBlock = await veaOutboxProvider.getBlock("latest"); + const transactionHandlers: { [epoch: number]: InstanceType } = {}; + const epochRange = setEpochRange(veaOutboxLatestBlock.timestamp, chainId); + let latestEpoch = epochRange[epochRange.length - 1]; + while (!shutDownSignal.getIsShutdownSignal()) { + let i = 0; + while (i < epochRange.length) { + const epoch = epochRange[i]; + emitter.emit(BotEvents.CHECKING, epoch); + const epochBlock = await getBlockFromEpoch(epoch, veaBridge.epochPeriod, veaOutboxProvider); + const claim = await getClaim(veaOutbox, veaOutboxProvider, epoch, epochBlock, "latest"); + const checkAndChallengeResolveDeps = { + claim, + epoch, + epochPeriod: veaBridge.epochPeriod, + veaInbox, + veaInboxProvider, + veaOutboxProvider, + veaOutbox, + transactionHandler: transactionHandlers[epoch], + emitter, + }; + let updatedTransactions; + if (path > BotPaths.CLAIMER && claim != null) { + updatedTransactions = await checkAndChallengeResolve(checkAndChallengeResolveDeps); + } + if (path == BotPaths.CLAIMER || path == BotPaths.BOTH) { + updatedTransactions = await checkAndClaim(checkAndChallengeResolveDeps); + } + + if (updatedTransactions) { + transactionHandlers[epoch] = updatedTransactions; + } else if (epoch != latestEpoch) { + delete transactionHandlers[epoch]; + epochRange.splice(i, 1); + continue; + } + i++; + } + const newVerifiableEpoch = Math.floor(Date.now() / (1000 * veaBridge.epochPeriod)) - 1; + if (newVerifiableEpoch > latestEpoch) { + epochRange.push(newVerifiableEpoch); + latestEpoch = newVerifiableEpoch; + } else { + emitter.emit(BotEvents.WAITING, latestEpoch); + } + await wait(1000 * 10); + } +}; + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +if (require.main === module) { + const shutDownSignal = new ShutdownSignal(false); + watch(shutDownSignal); +} diff --git a/validator-cli/src/ArbToEth/watcher.ts b/validator-cli/src/watcherDevnet.ts similarity index 100% rename from validator-cli/src/ArbToEth/watcher.ts rename to validator-cli/src/watcherDevnet.ts