diff --git a/bridger-cli/.env.dist b/bridger-cli/.env.dist new file mode 100644 index 00000000..0d08f874 --- /dev/null +++ b/bridger-cli/.env.dist @@ -0,0 +1,15 @@ +PRIVATE_KEY= + + +VEAOUTBOX_CHAIN_ID=11155111 + +# VeaInbox Address and Rpc Provider +VEAINBOX_ADDRESS=0xE12daFE59Bc3A996362d54b37DFd2BA9279cAd06 +VEAINBOX_PROVIDER=http://localhost:8545 + +# VeaOutbox Address and Rpc Provider +VEAOUTBOX_ADDRESS=0x209BFdC6B7c66b63A8382196Ba3d06619d0F12c9 +VEAOUTBOX_PROVIDER=http://localhost:8546 + +# Ex: https://api.studio.thegraph.com/query/85918/outbox-arb-sep-sep-testnet-vs/version/latest +VEAOUTBOX_SUBGRAPH=http://localhost:8000/subgraphs/name/kleros/veascan-outbox/graphql diff --git a/bridger-cli/jest.config.ts b/bridger-cli/jest.config.ts new file mode 100644 index 00000000..1927a555 --- /dev/null +++ b/bridger-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/bridger-cli/package.json b/bridger-cli/package.json new file mode 100644 index 00000000..26dc9586 --- /dev/null +++ b/bridger-cli/package.json @@ -0,0 +1,28 @@ +{ + "name": "@kleros/bridger-cli", + "license": "MIT", + "packageManager": "yarn@4.2.2", + "engines": { + "node": ">=18.0.0" + }, + "volta": { + "node": "18.20.3", + "yarn": "4.2.2" + }, + "scripts": { + "start-bridger": "npx ts-node ./src/bridger.ts", + "test": "jest --coverage" + }, + "dependencies": { + "@kleros/vea-contracts": "workspace:^", + "@typechain/ethers-v5": "^10.2.0", + "dotenv": "^16.4.5", + "typescript": "^4.9.5", + "web3": "^1.10.4" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "ts-jest": "^29.2.5" + } +} diff --git a/bridger-cli/src/bridger.test.ts b/bridger-cli/src/bridger.test.ts new file mode 100644 index 00000000..9c5a4e3e --- /dev/null +++ b/bridger-cli/src/bridger.test.ts @@ -0,0 +1,318 @@ +require("dotenv").config(); +import { assert } from "chai"; +import { JsonRpcProvider } from "@ethersproject/providers"; +import { MockEmitter } from "./utils/emitter"; +import { ClaimStruct, hashClaim } from "./utils/claim"; +import { getVeaOutbox, getVeaInbox } from "./utils/ethers"; +import { watch } from "./bridger"; +import { ShutdownSignal } from "./utils/shutdown"; + +jest.setTimeout(15000); +describe("bridger", function () { + const ZERO_HASH = "0x0000000000000000000000000000000000000000000000000000000000000000"; + const FAKE_HASH = "0x0000000000000000000000000000000000000000000000000000000000000001"; + const mockMessage = { + data: "0x000000000000000000000000abcdefabcdefabcdefabcdefabcdefabcd00000000000000000000000000000000000000000000000000000000000003e8", + to: "0x1234567890abcdef1234567890abcdef12345678", + fnSelector: "0x12345678", + }; + const inboxProvider = new JsonRpcProvider(process.env.VEAINBOX_PROVIDER); + const outboxProvider = new JsonRpcProvider(process.env.VEAOUTBOX_PROVIDER); + + let claimEpoch: number; + let epochPeriod: number; + let deposit: bigint; + let sequencerDelay: number; + + const veaInbox = getVeaInbox( + process.env.VEAINBOX_ADDRESS, + process.env.PRIVATE_KEY, + process.env.VEAINBOX_PROVIDER, + Number(process.env.VEAOUTBOX_CHAIN_ID) + ); + const veaOutbox = getVeaOutbox( + process.env.VEAOUTBOX_ADDRESS, + process.env.PRIVATE_KEY, + process.env.VEAOUTBOX_PROVIDER, + Number(process.env.VEAOUTBOX_CHAIN_ID) + ); + + // Increase epoch on both evn chains to maintain consistency + async function increaseEpoch() { + await inboxProvider.send("evm_increaseTime", [epochPeriod]); + await outboxProvider.send("evm_increaseTime", [epochPeriod]); + await inboxProvider.send("evm_mine", []); + await outboxProvider.send("evm_mine", []); + } + + // Start bridger with a timeout + async function startBridgerWithTimeout(timeout: number, startEpoch: number = 0) { + const shutDownSignal = new ShutdownSignal(); + const mockEmitter = new MockEmitter(); + const bridgerPromise = watch(shutDownSignal, startEpoch, mockEmitter); + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + shutDownSignal.setShutdownSignal(); + resolve("Timeout reached"); + }, timeout); + }); + await Promise.race([bridgerPromise, timeoutPromise]); + } + + // Sync epochs on both chains + async function syncEpochs() { + const inboxEpoch = Number(await veaInbox.epochNow()); + const outboxEpoch = Number(await veaOutbox.epochNow()); + if (inboxEpoch !== outboxEpoch) { + if (inboxEpoch > outboxEpoch) { + await outboxProvider.send("evm_increaseTime", [epochPeriod * (inboxEpoch - outboxEpoch)]); + await outboxProvider.send("evm_mine", []); + } else { + await inboxProvider.send("evm_increaseTime", [epochPeriod * (outboxEpoch - inboxEpoch)]); + await inboxProvider.send("evm_mine", []); + } + } + } + + beforeEach(async () => { + epochPeriod = Number(await veaOutbox.epochPeriod()); + deposit = await veaOutbox.deposit(); + await increaseEpoch(); + await syncEpochs(); + claimEpoch = Number(await veaInbox.epochNow()); + sequencerDelay = Number(await veaOutbox.sequencerDelayLimit()); + }); + + describe("Integration tests: Claiming", function () { + it("should claim for new saved snapshot", async function () { + // Send a message and save snapshot + await veaInbox.sendMessage(mockMessage.to, mockMessage.fnSelector, mockMessage.data); + await veaInbox.saveSnapshot(); + + // Increase epoch so that claim can be made + await increaseEpoch(); + + // Start bridger + await startBridgerWithTimeout(5000, claimEpoch); + + const toBeClaimedStateRoot = await veaInbox.snapshots(claimEpoch); + const claimData = await veaOutbox.queryFilter(veaOutbox.filters.Claimed(null, claimEpoch, null)); + + const bridger = `0x${claimData[0].topics[1].slice(26)}`; + const claimBlock = await outboxProvider.getBlock(claimData[0].blockNumber); + const claim: ClaimStruct = { + stateRoot: toBeClaimedStateRoot, + claimer: bridger as `0x${string}`, + timestampClaimed: claimBlock.timestamp, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: "0x0000000000000000000000000000000000000000", + }; + // Check if claim was made + const claimHash = await veaOutbox.claimHashes(claimEpoch); + assert.notEqual(claimHash, ZERO_HASH, "Claim was not made"); + assert.equal(claimHash, hashClaim(claim), "Wrong claim was made"); + }); + + it("should not claim for old snapshot", async function () { + await veaInbox.sendMessage(mockMessage.to, mockMessage.fnSelector, mockMessage.data); + + // Increase epoch on both evn chains to maintain consistency + await increaseEpoch(); + + // Start bridger + await startBridgerWithTimeout(5000, claimEpoch); + + // Assert no new claims were made since last claim + const currentEpochClaim = await veaOutbox.claimHashes(claimEpoch); + assert.equal(currentEpochClaim, ZERO_HASH, "Claim was made"); + }); + + it("should make new claim if last claim was challenged", async function () { + await veaInbox.sendMessage(mockMessage.to, mockMessage.fnSelector, mockMessage.data); + await veaInbox.saveSnapshot(); + + // Increase epoch on both evn chains to maintain consistency + await increaseEpoch(); + await veaInbox.saveSnapshot(); + + // Make claim and challenge it + const claimTxn = await veaOutbox.claim(claimEpoch, FAKE_HASH, { value: deposit }); + const claimBlock = await outboxProvider.getBlock(claimTxn.blockNumber); + + const claim = { + stateRoot: FAKE_HASH, + claimer: claimTxn.from, + timestampClaimed: claimBlock.timestamp, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: "0x0000000000000000000000000000000000000000", + }; + await veaOutbox["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"](claimEpoch, claim, { + value: deposit, + }); + // Increase epoch + await increaseEpoch(); + + await startBridgerWithTimeout(5000, claimEpoch); + + // Check if claim was made + const claimLogs = await veaOutbox.queryFilter(veaOutbox.filters.Claimed(null, claimEpoch + 1, null)); + + const snapshotOnInbox = await veaInbox.snapshots(claimEpoch + 1); + assert.equal(claimLogs.length, 1, "Claim was not made"); + assert.equal(Number(claimLogs[0].topics[2]), claimEpoch + 1, "Claim was made for wrong epoch"); + assert.equal(snapshotOnInbox, claimLogs[0].data, "Snapshot was not saved"); + }); + }); + + describe("Integration tests: Verification", function () { + it("should start verification when claim is verifiable", async function () { + await veaInbox.sendMessage(mockMessage.to, mockMessage.fnSelector, mockMessage.data); + await veaInbox.saveSnapshot(); + await increaseEpoch(); + + const savedSnapshot = await veaInbox.snapshots(claimEpoch); + await veaOutbox.claim(claimEpoch, savedSnapshot, { value: deposit }); + await outboxProvider.send("evm_mine", []); + + await inboxProvider.send("evm_increaseTime", [epochPeriod + sequencerDelay]); + await inboxProvider.send("evm_mine", []); + await outboxProvider.send("evm_increaseTime", [epochPeriod + sequencerDelay]); + await outboxProvider.send("evm_mine", []); + + await startBridgerWithTimeout(5000, claimEpoch); + + // Check if verification was started + const verificationLogs = await veaOutbox.queryFilter(veaOutbox.filters.VerificationStarted(claimEpoch)); + assert.equal(verificationLogs.length, 1, "Verification was not started"); + }); + + it("should not verify claim when claim is challenged", async function () { + await veaInbox.sendMessage(mockMessage.to, mockMessage.fnSelector, mockMessage.data); + await veaInbox.saveSnapshot(); + + await increaseEpoch(); + + // Make claim and challenge it + const claimTx = await veaOutbox.claim(claimEpoch, FAKE_HASH, { value: deposit }); + + const oldClaimLogs = await veaOutbox.queryFilter(veaOutbox.filters.Claimed(null, claimEpoch, null)); + const claimBlock = await outboxProvider.getBlock(oldClaimLogs[0].blockNumber); + + let claim = { + stateRoot: FAKE_HASH, + claimer: claimTx.from, + timestampClaimed: claimBlock.timestamp, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: "0x0000000000000000000000000000000000000000", + }; + await veaOutbox["challenge(uint256,(bytes32,address,uint32,uint32,uint32,uint8,address))"](claimEpoch, claim, { + value: deposit, + }); + await outboxProvider.send("evm_mine", []); + + await inboxProvider.send("evm_increaseTime", [epochPeriod + sequencerDelay]); + await inboxProvider.send("evm_mine", []); + await outboxProvider.send("evm_increaseTime", [epochPeriod + sequencerDelay]); + await outboxProvider.send("evm_mine", []); + + await startBridgerWithTimeout(5000, claimEpoch); + + const verificationLogs = await veaOutbox.queryFilter(veaOutbox.filters.VerificationStarted(claimEpoch)); + assert.equal(verificationLogs.length, 0, "Verification was started"); + }); + + it("should verify snapshot when claim is verified", async function () { + // Also add a test for the case when the claim is not verifiable + + await veaInbox.sendMessage(mockMessage.to, mockMessage.fnSelector, mockMessage.data); + await veaInbox.saveSnapshot(); + await increaseEpoch(); + + const claimTxn = await veaOutbox.claim(claimEpoch, FAKE_HASH, { value: deposit }); + const claimBlock = await outboxProvider.getBlock(claimTxn.blockNumber); + await inboxProvider.send("evm_increaseTime", [epochPeriod + sequencerDelay]); + await inboxProvider.send("evm_mine", []); + await outboxProvider.send("evm_increaseTime", [epochPeriod + sequencerDelay]); + await outboxProvider.send("evm_mine", []); + + var claim = { + stateRoot: FAKE_HASH, + claimer: claimTxn.from, + timestampClaimed: claimBlock.timestamp, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: "0x0000000000000000000000000000000000000000", + }; + const verifyTxn = await veaOutbox.startVerification(claimEpoch, claim); + const verifyBlock = await outboxProvider.getBlock(verifyTxn.blockNumber); + claim.timestampVerification = verifyBlock.timestamp; + claim.blocknumberVerification = verifyBlock.number; + + const minChallengePeriod = Number(await veaOutbox.minChallengePeriod()); + + await outboxProvider.send("evm_increaseTime", [minChallengePeriod]); + await outboxProvider.send("evm_mine", []); + + await startBridgerWithTimeout(5000, claimEpoch); + + const postVerifyStateRoot = await veaOutbox.stateRoot(); + const latestVerifiedEpoch = await veaOutbox.latestVerifiedEpoch(); + + assert.equal(postVerifyStateRoot, FAKE_HASH, "Snapshot was not verified"); + assert.equal(Number(latestVerifiedEpoch), claimEpoch, "Snapshot was veified for wrong epoch"); + }); + + it("should withdraw deposit when claimer is honest", async function () { + await veaInbox.sendMessage(mockMessage.to, mockMessage.fnSelector, mockMessage.data); + await veaInbox.saveSnapshot(); + await increaseEpoch(); + + const claimTxn = await veaOutbox.claim(claimEpoch, FAKE_HASH, { value: deposit }); + const claimBlock = await outboxProvider.getBlock(claimTxn.blockNumber); + await inboxProvider.send("evm_increaseTime", [epochPeriod + sequencerDelay]); + await inboxProvider.send("evm_mine", []); + await outboxProvider.send("evm_increaseTime", [epochPeriod + sequencerDelay]); + await outboxProvider.send("evm_mine", []); + + var claim = { + stateRoot: FAKE_HASH, + claimer: claimTxn.from, + timestampClaimed: claimBlock.timestamp, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: "0x0000000000000000000000000000000000000000", + }; + const verifyTxn = await veaOutbox.startVerification(claimEpoch, claim); + const verifyBlock = await outboxProvider.getBlock(verifyTxn.blockNumber); + claim.timestampVerification = verifyBlock.timestamp; + claim.blocknumberVerification = verifyBlock.number; + + const minChallengePeriod = Number(await veaOutbox.minChallengePeriod()); + + await outboxProvider.send("evm_increaseTime", [minChallengePeriod]); + await outboxProvider.send("evm_mine", []); + + await veaOutbox.verifySnapshot(claimEpoch, claim); + + const balancePreWithdraw = await outboxProvider.getBalance(claimTxn.from); + const contractBalancePreWithdraw = await outboxProvider.getBalance(process.env.VEAOUTBOX_ADDRESS); + + await startBridgerWithTimeout(5000, claimEpoch); + const balancePostWithdraw = await outboxProvider.getBalance(claimTxn.from); + const contractBalancePostWithdraw = await outboxProvider.getBalance(process.env.VEAOUTBOX_ADDRESS); + + assert(balancePostWithdraw.gt(balancePreWithdraw), "Deposit was not withdrawn"); + assert(contractBalancePostWithdraw.eq(contractBalancePreWithdraw.sub(deposit)), "Deposit was not withdrawn"); + }); + + it.todo("should not withdraw deposit when claimer is dishonest"); + }); +}); diff --git a/bridger-cli/src/bridger.ts b/bridger-cli/src/bridger.ts new file mode 100644 index 00000000..4dd43529 --- /dev/null +++ b/bridger-cli/src/bridger.ts @@ -0,0 +1,129 @@ +require("dotenv").config(); +import { JsonRpcProvider } from "@ethersproject/providers"; +import { ethers } from "ethers"; +import { EventEmitter } from "events"; +import { getLastClaimedEpoch } from "./utils/graphQueries"; +import { getVeaInbox, getVeaOutbox } from "./utils/ethers"; +import { fetchClaim, hashClaim } from "./utils/claim"; +import { TransactionHandler } from "./utils/transactionHandler"; +import { setEpochRange, getLatestVerifiableEpoch } from "./utils/epochHandler"; +import { ShutdownSignal } from "./utils/shutdown"; +import { initialize as initializeLogger } from "./utils/logger"; +import { defaultEmitter } from "./utils/emitter"; +import { BotEvents } from "./utils/botEvents"; + +export const watch = async ( + shutDownSignal: ShutdownSignal = new ShutdownSignal(), + startEpoch: number = 0, + emitter: EventEmitter = defaultEmitter +) => { + initializeLogger(emitter); + emitter.emit(BotEvents.STARTED); + const chainId = Number(process.env.VEAOUTBOX_CHAIN_ID); + const veaInboxAddress = process.env.VEAINBOX_ADDRESS; + const veaOutboxAddress = process.env.VEAOUTBOX_ADDRESS; + const PRIVATE_KEY = process.env.PRIVATE_KEY; + const veaInboxRPC = process.env.VEAINBOX_PROVIDER; + const veaOutboxRPC = process.env.VEAOUTBOX_PROVIDER; + const veaInbox = getVeaInbox(veaInboxAddress, PRIVATE_KEY, veaInboxRPC, chainId); + const veaOutbox = getVeaOutbox(veaOutboxAddress, PRIVATE_KEY, veaOutboxRPC, chainId); + const veaOutboxProvider = new JsonRpcProvider(veaOutboxRPC); + const epochs = await setEpochRange(veaOutbox, startEpoch); + let verifiableEpoch = epochs[epochs.length - 1] - 1; + + const transactionHandlers: { [epoch: number]: TransactionHandler } = {}; + + while (!shutDownSignal.getIsShutdownSignal()) { + let i = 0; + while (i < epochs.length) { + const activeEpoch = epochs[i]; + emitter.emit(BotEvents.CHECKING, activeEpoch); + let claimableEpochHash = await veaOutbox.claimHashes(activeEpoch); + let outboxStateRoot = await veaOutbox.stateRoot(); + const finalizedOutboxBlock = await veaOutboxProvider.getBlock("finalized"); + + if (claimableEpochHash == ethers.ZeroAddress && activeEpoch == verifiableEpoch) { + // Claim can be made + const savedSnapshot = await veaInbox.snapshots(activeEpoch); + if (savedSnapshot != outboxStateRoot && savedSnapshot != ethers.ZeroHash) { + // Its possible that a claim was made for previous epoch but its not verified yet + // Making claim if there are new messages or last claim was challenged. + const claimData = await getLastClaimedEpoch(); + + if (claimData.challenged || claimData.stateroot != savedSnapshot) { + // Making claim as either last claim was challenged or there are new messages + if (!transactionHandlers[activeEpoch]) { + transactionHandlers[activeEpoch] = new TransactionHandler(chainId, activeEpoch, veaOutbox, null, emitter); + } + await transactionHandlers[activeEpoch].makeClaim(savedSnapshot); + } else { + emitter.emit(BotEvents.NO_NEW_MESSAGES); + epochs.splice(i, 1); + i--; + continue; + } + } else { + if (savedSnapshot == ethers.ZeroHash) { + emitter.emit(BotEvents.NO_SNAPSHOT); + } else { + emitter.emit(BotEvents.NO_NEW_MESSAGES); + } + epochs.splice(i, 1); + i--; + } + } else if (claimableEpochHash != ethers.ZeroHash) { + const claim = await fetchClaim(veaOutbox, activeEpoch); + if (!transactionHandlers[activeEpoch]) { + transactionHandlers[activeEpoch] = new TransactionHandler(chainId, activeEpoch, veaOutbox, claim, emitter); + } else { + transactionHandlers[activeEpoch].claim = claim; + } + const transactionHandler = transactionHandlers[activeEpoch]; + if (claim.timestampVerification != 0) { + // Check if the verification is already resolved + if (hashClaim(claim) == claimableEpochHash) { + // Claim not resolved yet, try to verify snapshot + await transactionHandler.verifySnapshot(finalizedOutboxBlock.timestamp); + } else { + // Claim is already verified, withdraw deposit + claim.honest = 1; // Assume the claimer is honest + if (hashClaim(claim) == claimableEpochHash) { + await transactionHandler.withdrawClaimDeposit(); + } else { + emitter.emit(BotEvents.CHALLENGER_WON_CLAIM); + } + epochs.splice(i, 1); + i--; + } + } else if (claim.challenger == ethers.ZeroAddress) { + // No verification started yet, check if we can start it + await transactionHandler.startVerification(finalizedOutboxBlock.timestamp); + } else { + epochs.splice(i, 1); + i--; + emitter.emit(BotEvents.CLAIM_CHALLENGED); + } + } else { + epochs.splice(i, 1); + i--; + emitter.emit(BotEvents.EPOCH_PASSED, activeEpoch); + } + i++; + } + const newEpoch = getLatestVerifiableEpoch(chainId); + if (newEpoch > verifiableEpoch) { + epochs.push(newEpoch); + verifiableEpoch = newEpoch; + } + emitter.emit(BotEvents.WAITING, verifiableEpoch); + await wait(1000 * 10); + } + return epochs; +}; + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +if (require.main === module) { + const shutDownSignal = new ShutdownSignal(false); + watch(shutDownSignal); +} diff --git a/bridger-cli/src/consts/bridgeRoutes.ts b/bridger-cli/src/consts/bridgeRoutes.ts new file mode 100644 index 00000000..4726f29b --- /dev/null +++ b/bridger-cli/src/consts/bridgeRoutes.ts @@ -0,0 +1,30 @@ +interface IBridge { + chain: string; + epochPeriod: number; + deposit: bigint; + minChallengePeriod: number; + sequencerDelayLimit: number; +} + +const bridges: { [chainId: number]: IBridge } = { + 11155111: { + chain: "sepolia", + epochPeriod: 7200, + deposit: BigInt("1000000000000000000"), + minChallengePeriod: 10800, + sequencerDelayLimit: 86400, + }, + 10200: { + chain: "chiado", + epochPeriod: 3600, + deposit: BigInt("1000000000000000000"), + minChallengePeriod: 10800, + sequencerDelayLimit: 86400, + }, +}; + +const getBridgeConfig = (chainId: number): IBridge | undefined => { + return bridges[chainId]; +}; + +export { getBridgeConfig }; diff --git a/bridger-cli/src/utils/botEvents.ts b/bridger-cli/src/utils/botEvents.ts new file mode 100644 index 00000000..e4109190 --- /dev/null +++ b/bridger-cli/src/utils/botEvents.ts @@ -0,0 +1,28 @@ +export enum BotEvents { + // Bridger state + STARTED = "started", + CHECKING = "checking", + WAITING = "waiting", + + // Epoch state + NO_NEW_MESSAGES = "no_new_messages", + NO_SNAPSHOT = "no_snapshot", + EPOCH_PASSED = "epoch_passed", + + // Claim state + CLAIMING = "claiming", + CHALLENGER_WON_CLAIM = "challenger_won_claim", + VERIFICATION_CANT_START = "verification_cant_started", + CANT_VERIFY_SNAPSHOT = "cant_verify_snapshot", + CLAIM_CHALLENGED = "claim_challenged", + STARTING_VERIFICATION = "starting_verification", + VERIFYING = "verifying", + WITHDRAWING = "withdrawing", + + // Transaction state + TXN_MADE = "txn_made", + TXN_PENDING = "txn_pending", + TXN_PENDING_CONFIRMATIONS = "txn_pending_confirmations", + TXN_FINAL = "txn_final", + TXN_NOT_FINAL = "txn_not_final", +} diff --git a/bridger-cli/src/utils/claim.test.ts b/bridger-cli/src/utils/claim.test.ts new file mode 100644 index 00000000..81b45582 --- /dev/null +++ b/bridger-cli/src/utils/claim.test.ts @@ -0,0 +1,165 @@ +import { fetchClaim, ClaimStruct, hashClaim } from "./claim"; +import { ClaimNotFoundError } from "./errors"; +import { ethers } from "ethers"; + +describe("snapshotClaim", () => { + describe("fetchClaim", () => { + let mockClaim: ClaimStruct; + let getClaimForEpoch: jest.Mock; + let veaOutbox: any; + const epoch = 1; + + beforeEach(() => { + mockClaim = { + stateRoot: "0x1234", + claimer: "0x1234", + timestampClaimed: 1234, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress as `0x${string}`, + }; + getClaimForEpoch = jest.fn().mockResolvedValue({ + stateroot: mockClaim.stateRoot, + bridger: mockClaim.claimer, + timestamp: mockClaim.timestampClaimed, + txHash: "0x1234", + challenged: false, + }); + + veaOutbox = { + queryFilter: jest.fn(), + provider: { + getBlock: jest.fn().mockResolvedValue({ timestamp: 1234, number: 1234 }), + }, + filters: { + VerificationStarted: jest.fn(), + Challenged: jest.fn(), + Claimed: jest.fn(), + }, + }; + }); + + it("should return a valid claim", async () => { + veaOutbox.queryFilter.mockImplementationOnce(() => Promise.resolve([])); + veaOutbox.queryFilter.mockImplementationOnce(() => + Promise.resolve([{ blockHash: "0x1234", args: { challenger: ethers.ZeroAddress } }]) + ); + + const claim = await fetchClaim(veaOutbox, epoch, getClaimForEpoch); + + expect(claim).toBeDefined(); + expect(claim).toEqual(mockClaim); + expect(getClaimForEpoch).toHaveBeenCalledWith(epoch); + }); + + it("should return a valid claim with challenger", async () => { + // we want fetchClaimForEpoch to return a claim + mockClaim.challenger = "0x1234"; + veaOutbox.queryFilter.mockImplementationOnce(() => Promise.resolve([])); + veaOutbox.queryFilter.mockImplementationOnce(() => + Promise.resolve([{ blockHash: "0x1234", args: { challenger: mockClaim.challenger } }]) + ); + // Update getClaimForEpoch to return a challenged claim + getClaimForEpoch.mockResolvedValueOnce({ + stateroot: mockClaim.stateRoot, + bridger: mockClaim.claimer, + timestamp: mockClaim.timestampClaimed, + txHash: "0x1234", + challenged: true, + }); + + const claim = await fetchClaim(veaOutbox, epoch, getClaimForEpoch); + + expect(claim).toBeDefined(); + expect(claim).toEqual(mockClaim); + expect(getClaimForEpoch).toHaveBeenCalledWith(epoch); + }); + + it("should return a valid claim with verification", async () => { + mockClaim.timestampVerification = 1234; + mockClaim.blocknumberVerification = 1234; + veaOutbox.queryFilter.mockImplementationOnce(() => + Promise.resolve([{ blockHash: "0x1234", args: { challenger: ethers.ZeroAddress } }]) + ); + veaOutbox.queryFilter.mockImplementationOnce(() => Promise.resolve([])); + getClaimForEpoch.mockResolvedValueOnce({ + stateroot: mockClaim.stateRoot, + bridger: mockClaim.claimer, + timestamp: mockClaim.timestampClaimed, + txHash: "0x1234", + challenged: false, + }); + + veaOutbox.provider.getBlock.mockResolvedValueOnce({ + timestamp: mockClaim.timestampVerification, + number: mockClaim.blocknumberVerification, + }); + + const claim = await fetchClaim(veaOutbox, epoch, getClaimForEpoch); + + expect(claim).toBeDefined(); + expect(claim).toEqual(mockClaim); + expect(getClaimForEpoch).toHaveBeenCalledWith(epoch); + }); + + it("should fallback on logs if claimData is undefined", async () => { + getClaimForEpoch.mockResolvedValueOnce(undefined); + veaOutbox.queryFilter.mockImplementationOnce(() => + Promise.resolve([ + { + blockNumber: 1234, + data: mockClaim.stateRoot, + topics: [ethers.ZeroAddress, `0x${"0".repeat(24)}${mockClaim.claimer.slice(2)}`], + }, + ]) + ); + veaOutbox.queryFilter.mockImplementationOnce(() => Promise.resolve([])); + veaOutbox.queryFilter.mockImplementationOnce(() => Promise.resolve([])); + + const claim = await fetchClaim(veaOutbox, epoch, getClaimForEpoch); + + expect(claim).toBeDefined(); + expect(claim).toEqual(mockClaim); + expect(getClaimForEpoch).toHaveBeenCalledWith(epoch); + }); + + it("should throw an error if no claim is found", async () => { + getClaimForEpoch.mockResolvedValueOnce(undefined); + veaOutbox.queryFilter.mockImplementationOnce(() => Promise.resolve([])); + veaOutbox.queryFilter.mockImplementationOnce(() => Promise.resolve([])); + veaOutbox.queryFilter.mockImplementationOnce(() => Promise.resolve([])); + + await expect(async () => { + await fetchClaim(veaOutbox, epoch, getClaimForEpoch); + }).rejects.toThrow(new ClaimNotFoundError(epoch)); + }); + }); + + describe("hashClaim", () => { + let mockClaim: ClaimStruct = { + stateRoot: "0xeac817ed5c5b3d1c2c548f231b7cf9a0dfd174059f450ec6f0805acf6a16a551", + claimer: "0xFa00D29d378EDC57AA1006946F0fc6230a5E3288", + timestampClaimed: 1730276784, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress as `0x${string}`, + }; + // Pre calculated from the deployed contracts + const hashOfMockClaim = "0xfee47661ef0432da320c3b4706ff7d412f421b9d1531c33ce8f2e03bfe5dcfa2"; + + it("should return a valid hash", () => { + const hash = hashClaim(mockClaim); + expect(hash).toBeDefined(); + expect(hash).toEqual(hashOfMockClaim); + }); + + it("should not return a valid hash", () => { + mockClaim.honest = 1; + const hash = hashClaim(mockClaim); + expect(hash).toBeDefined(); + expect(hash).not.toEqual(hashOfMockClaim); + }); + }); +}); diff --git a/bridger-cli/src/utils/claim.ts b/bridger-cli/src/utils/claim.ts new file mode 100644 index 00000000..71e9d739 --- /dev/null +++ b/bridger-cli/src/utils/claim.ts @@ -0,0 +1,97 @@ +import { ClaimData, getClaimForEpoch } from "./graphQueries"; +import { ethers } from "ethers"; +import { ClaimNotFoundError } from "./errors"; + +type ClaimStruct = { + stateRoot: string; + claimer: `0x${string}`; + timestampClaimed: number; + timestampVerification: number; + blocknumberVerification: number; + honest: number; + challenger: `0x${string}`; +}; + +/** + * Fetches the claim data for a given epoch. + * + * @param veaOutbox - The VeaOutbox contract instance + * @param epoch - The epoch number for which the claim is needed + * + * @returns The claim data for the given epoch + * + * @example + * const claim = await fetchClaim(veaOutbox, 240752); + */ + +const fetchClaim = async ( + veaOutbox: any, + epoch: number, + fetchClaimForEpoch: typeof getClaimForEpoch = getClaimForEpoch +): Promise => { + let claimData: ClaimData | undefined = await fetchClaimForEpoch(epoch); + // TODO: Check for logs block range Rpc dependency, if needed used claimEpochBlock + if (claimData === undefined) { + // Initialize claimData as an empty object + claimData = {} as ClaimData; + const claimLogs = await veaOutbox.queryFilter(veaOutbox.filters.Claimed(null, epoch, null)); + if (claimLogs.length === 0) { + throw new ClaimNotFoundError(epoch); + } + claimData.bridger = `0x${claimLogs[0].topics[1].slice(26)}`; + claimData.stateroot = claimLogs[0].data; + claimData.timestamp = (await veaOutbox.provider.getBlock(claimLogs[0].blockNumber)).timestamp; + } + let claim: ClaimStruct = { + stateRoot: claimData.stateroot, + claimer: claimData.bridger as `0x${string}`, + timestampClaimed: claimData.timestamp, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: ethers.ZeroAddress as `0x${string}`, + }; + const [verifyLogs, challengeLogs] = await Promise.all([ + veaOutbox.queryFilter(veaOutbox.filters.VerificationStarted(epoch)), + veaOutbox.queryFilter(veaOutbox.filters.Challenged(epoch)), + ]); + + if (verifyLogs.length > 0) { + const verificationStartBlock = await veaOutbox.provider.getBlock(verifyLogs[0].blockHash); + claim.timestampVerification = verificationStartBlock.timestamp; + claim.blocknumberVerification = verificationStartBlock.number; + } + + if (challengeLogs.length > 0) { + claim.challenger = challengeLogs[0].args.challenger; + } + + return claim; +}; + +/** + * Hashes the claim data. + * + * @param claim - The claim data to be hashed + * + * @returns The hash of the claim data + * + * @example + * const claimHash = hashClaim(claim); + */ +const hashClaim = (claim: ClaimStruct) => { + return ethers.solidityPackedKeccak256( + ["bytes32", "address", "uint32", "uint32", "uint32", "uint8", "address"], + [ + claim.stateRoot, + claim.claimer, + claim.timestampClaimed, + claim.timestampVerification, + claim.blocknumberVerification, + claim.honest, + claim.challenger, + ] + ); +}; + +export { ClaimStruct, fetchClaim, hashClaim }; diff --git a/bridger-cli/src/utils/emitter.ts b/bridger-cli/src/utils/emitter.ts new file mode 100644 index 00000000..530f8345 --- /dev/null +++ b/bridger-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/bridger-cli/src/utils/epochHandler.test.ts b/bridger-cli/src/utils/epochHandler.test.ts new file mode 100644 index 00000000..7a4287e0 --- /dev/null +++ b/bridger-cli/src/utils/epochHandler.test.ts @@ -0,0 +1,90 @@ +import { setEpochRange, getBlockNumberFromEpoch, getLatestVerifiableEpoch } from "./epochHandler"; +import { InvalidStartEpochError } from "./errors"; + +describe("epochHandler", () => { + describe("setEpochRange", () => { + let veaOutbox: any; + let startEpoch: number; + beforeEach(() => { + veaOutbox = { + epochNow: jest.fn().mockResolvedValue(10), + }; + startEpoch = 10; + }); + it("should return an array of epoch", async () => { + const result = await setEpochRange(veaOutbox, startEpoch); + expect(result).toBeDefined(); + expect(result).toEqual([10]); + }); + + it("should throw an error if start { + startEpoch = 12; + await expect(setEpochRange(veaOutbox, startEpoch)).rejects.toThrow(InvalidStartEpochError); + }); + + it("should return an array rolled back to default when no startEpoch provided", async () => { + const currEpoch = await veaOutbox.epochNow(); + // 10 is the default epoch rollback + const defaultEpochRollback = 10; + startEpoch = currEpoch - defaultEpochRollback; + const epochs: number[] = new Array(currEpoch - startEpoch + 1) + .fill(currEpoch - defaultEpochRollback) + .map((el, i) => el + i); + const result = await setEpochRange(veaOutbox, startEpoch); + expect(result).toBeDefined(); + expect(result).toEqual(epochs); + }); + }); + describe("getBlockNumberFromEpoch", () => { + let veaOutboxRpc: any; + let epoch: number; + const epochPeriod = 10; + beforeEach(() => { + veaOutboxRpc = { + getBlock: jest.fn().mockImplementation((blockNumber) => { + if (blockNumber === "latest") { + return { + number: 100000, + timestamp: 1000000, + }; + } else { + return { + number: 99000, + timestamp: 990000, + }; + } + }), + }; + epoch = 1000; + }); + it("should return epoch block number", async () => { + const result = await getBlockNumberFromEpoch(veaOutboxRpc, epoch, epochPeriod); + expect(result).toBeDefined(); + expect(result).toBe(900); + }); + }); + + describe("getLatestVerifiableEpoch", () => { + const epochPeriod = 10; + const chainId = 1; + let currentEpoch: number; + let mockGetBridgeConfig: jest.Mock; + + beforeEach(() => { + currentEpoch = 99; + mockGetBridgeConfig = jest.fn().mockReturnValue({ + epochPeriod, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it.only("should return a new epoch", () => { + const mockNow = 1625097600000; + const currentEpoch = getLatestVerifiableEpoch(chainId, mockNow, mockGetBridgeConfig); + expect(currentEpoch).toBe(Math.floor(mockNow / 1000 / epochPeriod) - 1); + }); + }); +}); diff --git a/bridger-cli/src/utils/epochHandler.ts b/bridger-cli/src/utils/epochHandler.ts new file mode 100644 index 00000000..f5d2dd9f --- /dev/null +++ b/bridger-cli/src/utils/epochHandler.ts @@ -0,0 +1,75 @@ +import { JsonRpcProvider } from "@ethersproject/providers"; +import { InvalidStartEpochError } from "./errors"; +import { getBridgeConfig } from "../consts/bridgeRoutes"; + +/** + * Sets the range of epochs from the start epoch to the current epoch. + * + * @param veaOutbox - The VeaOutbox instance to get the current epoch + * @param startEpoch - The starting epoch number + * @returns An array of epoch numbers from startEpoch to currentEpoch + * + * @example + * const epochs = await setEpochRange(veaOutbox, 0); + */ +const setEpochRange = async (veaOutbox: any, startEpoch: number): Promise> => { + const defaultEpochRollback = 10; // When no start epoch is provided, we will start from current epoch - defaultEpochRollback + const currentEpoch = Number(await veaOutbox.epochNow()); + if (currentEpoch < startEpoch) { + throw new InvalidStartEpochError(startEpoch); + } + if (startEpoch == 0) { + startEpoch = currentEpoch - defaultEpochRollback; + } + const epochs: number[] = new Array(currentEpoch - startEpoch + 1).fill(startEpoch).map((el, i) => el + i); + return epochs; +}; + +/** + * Gets the block number for a given epoch. + * + * @param veaOutboxRpc - The VeaOutbox RPC instance to get the block number + * @param epoch - The epoch number for which the block number is needed + * @param epochPeriod - The epoch period in seconds + * + * @returns The block number for the given epoch + * + * @example + * const blockNumber = await getBlockNumberFromEpoch(veaOutboxRpc, 240752, 7200); + */ +const getBlockNumberFromEpoch = async ( + veaOutboxRpc: JsonRpcProvider, + epoch: number, + epochPeriod: number +): Promise => { + const latestBlock = await veaOutboxRpc.getBlock("latest"); + const preBlock = await veaOutboxRpc.getBlock(latestBlock.number - 1000); + const avgBlockTime = (latestBlock.timestamp - preBlock.timestamp) / 1000; + + const epochInSeconds = epoch * epochPeriod; + const epochBlock = Math.floor(latestBlock.number - (latestBlock.timestamp - epochInSeconds) / avgBlockTime); + return epochBlock - 100; +}; + +/** + * Checks if a new epoch has started. + * + * @param currentVerifiableEpoch - The current verifiable epoch number + * @param epochPeriod - The epoch period in seconds + * @param now - The current time in milliseconds (optional, defaults to Date.now()) + * + * @returns The updated epoch number + * + * @example + * currentEpoch = checkForNewEpoch(currentEpoch, 7200); + */ +const getLatestVerifiableEpoch = ( + chainId: number, + now: number = Date.now(), + fetchBridgeConfig: typeof getBridgeConfig = getBridgeConfig +): number => { + const { epochPeriod } = fetchBridgeConfig(chainId); + return Math.floor(now / 1000 / epochPeriod) - 1; +}; + +export { setEpochRange, getBlockNumberFromEpoch, getLatestVerifiableEpoch }; diff --git a/bridger-cli/src/utils/errors.ts b/bridger-cli/src/utils/errors.ts new file mode 100644 index 00000000..8172595e --- /dev/null +++ b/bridger-cli/src/utils/errors.ts @@ -0,0 +1,27 @@ +/** + * Custom errors for the CLI + */ +class ClaimNotFoundError extends Error { + constructor(epoch: number) { + super(); + this.name = "ClaimNotFoundError"; + this.message = `No claim was found for ${epoch}`; + } +} + +class InvalidStartEpochError extends Error { + constructor(epoch: number) { + super(); + this.name = "InvalidStartEpochError"; + this.message = `Current epoch is smaller than start epoch ${epoch}`; + } +} + +class ClaimNotSetError extends Error { + constructor() { + super(); + this.name = "NoClaimSetError"; + this.message = "Claim is not set"; + } +} +export { ClaimNotFoundError, InvalidStartEpochError, ClaimNotSetError }; diff --git a/bridger-cli/src/utils/ethers.ts b/bridger-cli/src/utils/ethers.ts new file mode 100644 index 00000000..aad87bf7 --- /dev/null +++ b/bridger-cli/src/utils/ethers.ts @@ -0,0 +1,54 @@ +import { Wallet, JsonRpcProvider } from "ethers"; +import { + VeaOutboxArbToEth__factory, + VeaOutboxArbToEthDevnet__factory, + VeaOutboxArbToGnosisDevnet__factory, + VeaInboxArbToEth__factory, + VeaInboxArbToGnosis__factory, + VeaOutboxArbToGnosis__factory, +} from "@kleros/vea-contracts/typechain-types"; +import { getBridgeConfig } from "../consts/bridgeRoutes"; + +function getWallet(privateKey: string, web3ProviderURL: string) { + return new Wallet(privateKey, new JsonRpcProvider(web3ProviderURL)); +} + +function getWalletRPC(privateKey: string, rpc: JsonRpcProvider) { + return new Wallet(privateKey, rpc); +} + +// Using destination chainId as identifier, Ex: Arbitrum One (42161) -> Ethereum Mainnet (1): Use "1" as chainId +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 getVeaOutbox(veaOutboxAddress: string, privateKey: string, web3ProviderURL: string, chainId: number) { + const bridge = getBridgeConfig(chainId); + switch (bridge.chain) { + case "sepolia": + case "mainnet": + return VeaOutboxArbToEth__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); + case "chiado": + case "gnosis": + return VeaOutboxArbToGnosis__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); + default: + throw new Error(`Unsupported chainId: ${chainId}`); + } +} + +function getVeaOutboxDevnet(veaOutboxAddress: string, privateKey: string, web3ProviderURL: string, chainId: number) { + if (chainId == 11155111) { + return VeaOutboxArbToEthDevnet__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); + } else if (chainId == 10200) { + return VeaOutboxArbToGnosisDevnet__factory.connect(veaOutboxAddress, getWallet(privateKey, web3ProviderURL)); + } else { + throw new Error(`Unsupported chainId: ${chainId}`); + } +} + +export { getWalletRPC, getVeaOutbox, getVeaInbox, getVeaOutboxDevnet }; diff --git a/bridger-cli/src/utils/graphQueries.ts b/bridger-cli/src/utils/graphQueries.ts new file mode 100644 index 00000000..2fb1751a --- /dev/null +++ b/bridger-cli/src/utils/graphQueries.ts @@ -0,0 +1,56 @@ +import request from "graphql-request"; + +interface ClaimData { + id: string; + bridger: string; + stateroot: string; + timestamp: number; + challenged: boolean; + txHash: string; +} + +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; + } +}; + +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/bridger-cli/src/utils/logger.ts b/bridger-cli/src/utils/logger.ts new file mode 100644 index 00000000..7aa60cd6 --- /dev/null +++ b/bridger-cli/src/utils/logger.ts @@ -0,0 +1,98 @@ +import { EventEmitter } from "node:events"; +import { BotEvents } from "./botEvents"; + +/** + * Listens to relevant events of an EventEmitter instance and issues log lines + * + * @param emitter - The event emitter instance that issues the relevant events + * + * @example + * + * const emitter = new EventEmitter(); + * initialize(emitter); + */ + +export const initialize = (emitter: EventEmitter) => { + return configurableInitialize(emitter); +}; + +export const configurableInitialize = (emitter: EventEmitter) => { + // Bridger state logs + emitter.on(BotEvents.STARTED, () => { + console.log("Bridger started"); + }); + + emitter.on(BotEvents.CHECKING, (epoch: number) => { + console.log(`Running checks for epoch ${epoch}`); + }); + + emitter.on(BotEvents.WAITING, (epoch: number) => { + console.log(`Waiting for next verifiable epoch after ${epoch}`); + }); + + emitter.on(BotEvents.NO_NEW_MESSAGES, () => { + console.log("No new messages found"); + }); + + emitter.on(BotEvents.NO_SNAPSHOT, () => { + console.log("No snapshot saved for epoch"); + }); + + emitter.on(BotEvents.EPOCH_PASSED, (epoch: number) => { + console.log(`Epoch ${epoch} has passed`); + }); + + emitter.on(BotEvents.CHALLENGER_WON_CLAIM, () => { + console.log("Challenger won claim"); + }); + + emitter.on(BotEvents.CLAIM_CHALLENGED, () => { + console.log("Claim was challenged, skipping"); + }); + + // Transaction state logs + emitter.on(BotEvents.TXN_MADE, (transaction: string, epoch: number, state: string) => { + console.log(`${state} transaction for ${epoch} made with hash: ${transaction}`); + }); + emitter.on(BotEvents.TXN_PENDING, (transaction: string) => { + console.log(`Transaction is still pending with hash: ${transaction}`); + }); + + emitter.on(BotEvents.TXN_FINAL, (transaction: string, confirmations: number) => { + console.log(`Transaction(${transaction}) is final with ${confirmations} confirmations`); + }); + + emitter.on(BotEvents.TXN_NOT_FINAL, (transaction: string, confirmations: number) => { + console.log(`Transaction(${transaction}) is not final yet, ${confirmations} confirmations left.`); + }); + emitter.on(BotEvents.TXN_PENDING_CONFIRMATIONS, (transaction: string, confirmations: number) => { + console.log(`Transaction(${transaction}) is pending with ${confirmations} confirmations`); + }); + + // Claim state logs + // makeClaim() + emitter.on(BotEvents.CLAIMING, (epoch: number) => { + console.log(`Making claim for epoch ${epoch}`); + }); + + // startVerification() + emitter.on(BotEvents.STARTING_VERIFICATION, (epoch: number) => { + console.log(`Starting verification for epoch ${epoch}`); + }); + emitter.on(BotEvents.VERIFICATION_CANT_START, (time) => { + console.log(`Waiting for sequencer delay to pass to start verification, seconds left: ${time}`); + }); + + // verifySnapshot() + emitter.on(BotEvents.VERIFYING, (epoch: number) => { + console.log(`Verifying snapshot for epoch ${epoch}`); + }); + emitter.on(BotEvents.CANT_VERIFY_SNAPSHOT, (time) => { + console.log(`Waiting for min challenge period to pass to verify snapshot, seconds left: ${time}`); + }); + + // withdrawClaimDeposit() + emitter.on(BotEvents.WITHDRAWING, (epoch: number) => { + console.log(`Withdrawing deposit for epoch ${epoch}`); + }); +}; diff --git a/bridger-cli/src/utils/shutdown.ts b/bridger-cli/src/utils/shutdown.ts new file mode 100644 index 00000000..74671caf --- /dev/null +++ b/bridger-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/bridger-cli/src/utils/transactionHandler.test.ts b/bridger-cli/src/utils/transactionHandler.test.ts new file mode 100644 index 00000000..3cf34219 --- /dev/null +++ b/bridger-cli/src/utils/transactionHandler.test.ts @@ -0,0 +1,273 @@ +import { TransactionHandler } from "./transactionHandler"; +import { ClaimNotSetError } from "./errors"; +import { BotEvents } from "./botEvents"; +import { getBridgeConfig } from "../consts/bridgeRoutes"; + +describe("TransactionHandler Tests", () => { + const chainId = 11155111; + let epoch: number; + let claim: any; + let veaOutbox: any; + beforeEach(() => { + claim = { + stateRoot: "0x1234", + claimer: "0x1234", + timestampClaimed: 1234, + timestampVerification: 0, + blocknumberVerification: 0, + honest: 0, + challenger: "0x1234", + }; + veaOutbox = { + estimateGas: { + claim: jest.fn(), + verifySnapshot: jest.fn(), + startVerification: jest.fn(), + withdrawClaimDeposit: jest.fn(), + }, + claim: jest.fn(), + verifySnapshot: jest.fn(), + startVerification: jest.fn(), + withdrawClaimDeposit: jest.fn(), + provider: { + getTransactionReceipt: jest.fn(), + getBlock: jest.fn(), + }, + }; + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("constructor", () => { + it("should create a new TransactionHandler without claim", () => { + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox); + expect(transactionHandler).toBeDefined(); + expect(transactionHandler.epoch).toEqual(epoch); + expect(transactionHandler.veaOutbox).toEqual(veaOutbox); + expect(transactionHandler.chainId).toEqual(chainId); + }); + + it("should create a new TransactionHandler with claim", () => { + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox, claim); + expect(transactionHandler).toBeDefined(); + expect(transactionHandler.epoch).toEqual(epoch); + expect(transactionHandler.veaOutbox).toEqual(veaOutbox); + expect(transactionHandler.chainId).toEqual(chainId); + expect(transactionHandler.claim).toEqual(claim); + }); + }); + + describe("checkTransactionPendingStatus", () => { + const blockNumber = 10; + const trnxHash = "0x1234"; + let transactionHandler: TransactionHandler; + beforeEach(() => { + transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox); + veaOutbox.provider = { + getTransactionReceipt: jest.fn().mockResolvedValue({ blockNumber }), + getBlock: jest.fn(), + }; + }); + it("should return true if transaction is not final", async () => { + veaOutbox.provider.getBlock.mockReturnValue({ + number: blockNumber + transactionHandler.requiredConfirmations - 1, + }); + const emitSpy = jest.spyOn(transactionHandler.emitter, "emit"); + const result = await transactionHandler.checkTransactionPendingStatus(trnxHash); + expect(result).toBeTruthy(); + expect(emitSpy).toHaveBeenCalledWith( + BotEvents.TXN_NOT_FINAL, + trnxHash, + transactionHandler.requiredConfirmations - 1 + ); + }); + + it("should return false if transaction is confirmed", async () => { + veaOutbox.provider.getBlock.mockReturnValue({ + number: blockNumber + transactionHandler.requiredConfirmations, + }); + const emitSpy = jest.spyOn(transactionHandler.emitter, "emit"); + const result = await transactionHandler.checkTransactionPendingStatus(trnxHash); + expect(result).toBeFalsy(); + expect(emitSpy).toHaveBeenCalledWith(BotEvents.TXN_FINAL, trnxHash, transactionHandler.requiredConfirmations); + }); + + it("should return true if transaction receipt is not found", async () => { + veaOutbox.provider.getTransactionReceipt.mockResolvedValue(null); + const emitSpy = jest.spyOn(transactionHandler.emitter, "emit"); + const result = await transactionHandler.checkTransactionPendingStatus(trnxHash); + expect(result).toBeTruthy(); + expect(emitSpy).toHaveBeenCalledWith(BotEvents.TXN_PENDING, trnxHash); + }); + + it("should return false if transaction is null", async () => { + const emitSpy = jest.spyOn(transactionHandler.emitter, "emit"); + const result = await transactionHandler.checkTransactionPendingStatus(null); + expect(result).toBeFalsy(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + }); + + describe("makeClaim", () => { + beforeEach(() => { + veaOutbox.estimateGas.claim.mockResolvedValue(1000); + 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.prototype, "checkTransactionPendingStatus").mockResolvedValue(false); + + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox, null); + await transactionHandler.makeClaim(claim.stateRoot); + + expect(veaOutbox.estimateGas.claim).toHaveBeenCalled(); + expect(veaOutbox.claim).toHaveBeenCalled(); + expect(transactionHandler.pendingTransactions.claim).toEqual("0x1234"); + }); + + it("should not make a claim if a claim transaction is pending", async () => { + // Mock checkTransactionPendingStatus to always return true + jest.spyOn(TransactionHandler.prototype, "checkTransactionPendingStatus").mockResolvedValue(true); + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox); + await transactionHandler.makeClaim(claim.stateRoot); + + expect(veaOutbox.estimateGas.claim).not.toHaveBeenCalled(); + expect(veaOutbox.claim).not.toHaveBeenCalled(); + expect(transactionHandler.pendingTransactions.claim).toBeNull(); + }); + }); + + describe("startVerification", () => { + let startVerifyTimeFlip: number; + beforeEach(() => { + veaOutbox.estimateGas.startVerification.mockResolvedValue(1000); + veaOutbox.startVerification.mockResolvedValue({ hash: "0x1234" }); + startVerifyTimeFlip = + claim.timestampClaimed + getBridgeConfig(chainId).epochPeriod + getBridgeConfig(chainId).sequencerDelayLimit; + }); + it("should start verification and set pending startVerification trnx", async () => { + // Mock checkTransactionPendingStatus to always return false + jest.spyOn(TransactionHandler.prototype, "checkTransactionPendingStatus").mockResolvedValue(false); + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox, claim); + + await transactionHandler.startVerification(startVerifyTimeFlip); + + expect(veaOutbox.estimateGas.startVerification).toHaveBeenCalledWith(epoch, claim); + expect(veaOutbox.startVerification).toHaveBeenCalledWith(epoch, claim, { gasLimit: 1000 }); + expect(transactionHandler.pendingTransactions.startVerification).toEqual("0x1234"); + }); + + it("should not start verification if timeout has not passed", async () => { + // Mock checkTransactionPendingStatus to always return false + jest.spyOn(TransactionHandler.prototype, "checkTransactionPendingStatus").mockResolvedValue(false); + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox, claim); + + await transactionHandler.startVerification(startVerifyTimeFlip - 1); + + expect(veaOutbox.estimateGas.startVerification).not.toHaveBeenCalled(); + expect(veaOutbox.startVerification).not.toHaveBeenCalled(); + expect(transactionHandler.pendingTransactions.startVerification).toBeNull(); + }); + + it("should not start verification if claim is not set", async () => { + // Mock checkTransactionPendingStatus to always return false + jest.spyOn(TransactionHandler.prototype, "checkTransactionPendingStatus").mockResolvedValue(false); + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox, null); + + await expect(transactionHandler.startVerification(startVerifyTimeFlip)).rejects.toThrow(ClaimNotSetError); + }); + + it("should not start verification if a startVerification transaction is pending", async () => { + // Mock checkTransactionPendingStatus to always return true + jest.spyOn(TransactionHandler.prototype, "checkTransactionPendingStatus").mockResolvedValue(true); + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox, claim); + await transactionHandler.startVerification(startVerifyTimeFlip); + expect(veaOutbox.estimateGas.startVerification).not.toHaveBeenCalled(); + expect(veaOutbox.startVerification).not.toHaveBeenCalled(); + expect(transactionHandler.pendingTransactions.startVerification).toBeNull(); + }); + }); + + describe("verifySnapshot", () => { + let verificationFlipTime: number; + beforeEach(() => { + veaOutbox.estimateGas.verifySnapshot.mockResolvedValue(1000); + veaOutbox.verifySnapshot.mockResolvedValue({ hash: "0x1234" }); + verificationFlipTime = claim.timestampClaimed + getBridgeConfig(chainId).minChallengePeriod; + }); + + it("should verify snapshot and set pending verifySnapshot trnx", async () => { + // Mock checkTransactionPendingStatus to always return false + jest.spyOn(TransactionHandler.prototype, "checkTransactionPendingStatus").mockResolvedValue(false); + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox, claim); + + await transactionHandler.verifySnapshot(verificationFlipTime); + + expect(veaOutbox.estimateGas.verifySnapshot).toHaveBeenCalledWith(epoch, claim); + expect(veaOutbox.verifySnapshot).toHaveBeenCalledWith(epoch, claim, { gasLimit: 1000 }); + expect(transactionHandler.pendingTransactions.verifySnapshot).toEqual("0x1234"); + }); + + it("should not verify snapshot if timeout has not passed", async () => { + // Mock checkTransactionPendingStatus to always return false + jest.spyOn(TransactionHandler.prototype, "checkTransactionPendingStatus").mockResolvedValue(false); + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox, claim); + + await transactionHandler.verifySnapshot(verificationFlipTime - 1); + + expect(veaOutbox.estimateGas.verifySnapshot).not.toHaveBeenCalled(); + expect(veaOutbox.verifySnapshot).not.toHaveBeenCalled(); + expect(transactionHandler.pendingTransactions.verifySnapshot).toBeNull(); + }); + + it("should not verify snapshot if claim is not set", async () => { + // Mock checkTransactionPendingStatus to always return false + jest.spyOn(TransactionHandler.prototype, "checkTransactionPendingStatus").mockResolvedValue(false); + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox, null); + + await expect(transactionHandler.verifySnapshot(verificationFlipTime)).rejects.toThrow(ClaimNotSetError); + }); + + it("should not verify snapshot if a verifySnapshot transaction is pending", async () => { + // Mock checkTransactionPendingStatus to always return true + jest.spyOn(TransactionHandler.prototype, "checkTransactionPendingStatus").mockResolvedValue(true); + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox, claim); + + await transactionHandler.verifySnapshot(verificationFlipTime); + + expect(veaOutbox.estimateGas.verifySnapshot).not.toHaveBeenCalled(); + expect(veaOutbox.verifySnapshot).not.toHaveBeenCalled(); + expect(transactionHandler.pendingTransactions.verifySnapshot).toBeNull(); + }); + }); + + describe("withdrawClaimDeposit", () => { + beforeEach(() => { + veaOutbox.estimateGas.withdrawClaimDeposit.mockResolvedValue(1000); + veaOutbox.withdrawClaimDeposit.mockResolvedValue({ hash: "0x1234" }); + }); + it("should withdraw deposit and set pending withdrawClaimDeposit trnx", async () => { + // Mock checkTransactionPendingStatus to always return false + jest.spyOn(TransactionHandler.prototype, "checkTransactionPendingStatus").mockResolvedValue(false); + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox, claim); + + await transactionHandler.withdrawClaimDeposit(); + expect(veaOutbox.estimateGas.withdrawClaimDeposit).toHaveBeenCalledWith(epoch, claim); + expect(veaOutbox.withdrawClaimDeposit).toHaveBeenCalledWith(epoch, claim, { gasLimit: 1000 }); + expect(transactionHandler.pendingTransactions.withdrawClaimDeposit).toEqual("0x1234"); + }); + + it("should not withdraw deposit if a withdrawClaimDeposit transaction is pending", async () => { + // Mock checkTransactionPendingStatus to always return true + jest.spyOn(TransactionHandler.prototype, "checkTransactionPendingStatus").mockResolvedValue(true); + const transactionHandler = new TransactionHandler(chainId, epoch, veaOutbox, claim); + + await transactionHandler.withdrawClaimDeposit(); + expect(veaOutbox.estimateGas.withdrawClaimDeposit).not.toHaveBeenCalled(); + expect(veaOutbox.withdrawClaimDeposit).not.toHaveBeenCalled(); + expect(transactionHandler.pendingTransactions.withdrawClaimDeposit).toBeNull(); + }); + }); +}); diff --git a/bridger-cli/src/utils/transactionHandler.ts b/bridger-cli/src/utils/transactionHandler.ts new file mode 100644 index 00000000..519aa25c --- /dev/null +++ b/bridger-cli/src/utils/transactionHandler.ts @@ -0,0 +1,155 @@ +import { EventEmitter } from "node:events"; +import { getBridgeConfig } from "../consts/bridgeRoutes"; +import { ClaimStruct } from "./claim"; +import { ClaimNotSetError } from "./errors"; +import { defaultEmitter } from "./emitter"; +import { BotEvents } from "./botEvents"; + +interface PendingTransactions { + claim: string | null; + verifySnapshot: string | null; + withdrawClaimDeposit: string | null; + startVerification: string | null; +} + +/** + * Handles transactions for a given veaOutbox and epoch. + * + * @param chainId - The chainId of veaOutbox chain + * @param epoch - The epoch number for which the transactions are being handled + * @param veaOutbox - The veaOutbox instance to use for sending transactions + * @param claim - The claim object for the epoch + * @param fetchBridgeConfig - The function to fetch the bridge config + * @param emiitter - The event emitter instance to use for emitting events + * @returns An instance of the TransactionHandler class + * + * @example + * const txHandler = new TransactionHandler(11155111, 240752, veaOutbox, claim); + * txHandler.sendTransaction(txData); + */ + +export class TransactionHandler { + public epoch: number; + public veaOutbox: any; + public chainId: number; + public claim: ClaimStruct | null; + public requiredConfirmations: number = 12; + public emitter: EventEmitter; + + public pendingTransactions: PendingTransactions = { + claim: null, + verifySnapshot: null, + withdrawClaimDeposit: null, + startVerification: null, + }; + + constructor(chainId: number, epoch: number, veaOutbox: any, claim?: ClaimStruct, emiitter?: EventEmitter) { + this.epoch = epoch; + this.veaOutbox = veaOutbox; + this.chainId = chainId; + this.claim = claim; + this.emitter = emiitter || defaultEmitter; + } + + public async checkTransactionPendingStatus(trnxHash: string | null): Promise { + if (trnxHash == null) { + return false; + } + + const receipt = await this.veaOutbox.provider.getTransactionReceipt(trnxHash); + + if (!receipt) { + this.emitter.emit(BotEvents.TXN_PENDING, trnxHash); + return true; + } + + const currentBlock = await this.veaOutbox.provider.getBlock("latest"); + const confirmations = currentBlock.number - receipt.blockNumber; + + if (confirmations >= this.requiredConfirmations) { + this.emitter.emit(BotEvents.TXN_FINAL, trnxHash, confirmations); + return false; + } else { + this.emitter.emit(BotEvents.TXN_NOT_FINAL, trnxHash, confirmations); + return true; + } + } + + public async makeClaim(stateRoot: string) { + this.emitter.emit(BotEvents.CLAIMING, this.epoch); + if (await this.checkTransactionPendingStatus(this.pendingTransactions.claim)) { + return; + } + const { deposit } = getBridgeConfig(this.chainId); + + const estimateGas = await this.veaOutbox.estimateGas.claim(this.epoch, stateRoot, { value: deposit }); + const claimTransaction = await this.veaOutbox.claim(this.epoch, stateRoot, { + value: deposit, + gasLimit: estimateGas, + }); + this.emitter.emit(BotEvents.TXN_MADE, this.epoch, claimTransaction.hash, "Claim"); + this.pendingTransactions.claim = claimTransaction.hash; + } + + public async startVerification(latestBlockTimestamp: number) { + this.emitter.emit(BotEvents.STARTING_VERIFICATION, this.epoch); + if (this.claim == null) { + throw new ClaimNotSetError(); + } + if (await this.checkTransactionPendingStatus(this.pendingTransactions.startVerification)) { + return; + } + + const bridgeConfig = getBridgeConfig(this.chainId); + const timeOver = + latestBlockTimestamp - this.claim.timestampClaimed - bridgeConfig.sequencerDelayLimit - bridgeConfig.epochPeriod; + + if (timeOver < 0) { + this.emitter.emit(BotEvents.VERIFICATION_CANT_START, -1 * timeOver); + return; + } + const estimateGas = await this.veaOutbox.estimateGas.startVerification(this.epoch, this.claim); + const startVerifTrx = await this.veaOutbox.startVerification(this.epoch, this.claim, { gasLimit: estimateGas }); + this.emitter.emit(BotEvents.TXN_MADE, this.epoch, startVerifTrx.hash, "Start Verification"); + this.pendingTransactions.startVerification = startVerifTrx.hash; + } + + public async verifySnapshot(latestBlockTimestamp: number) { + this.emitter.emit(BotEvents.VERIFYING, this.epoch); + if (this.claim == null) { + throw new ClaimNotSetError(); + } + if (await this.checkTransactionPendingStatus(this.pendingTransactions.verifySnapshot)) { + return; + } + const bridgeConfig = getBridgeConfig(this.chainId); + + const timeLeft = latestBlockTimestamp - this.claim.timestampClaimed - bridgeConfig.minChallengePeriod; + + // Claim not resolved yet, check if we can verifySnapshot + if (timeLeft < 0) { + this.emitter.emit(BotEvents.CANT_VERIFY_SNAPSHOT, -1 * timeLeft); + return; + } + // Estimate gas for verifySnapshot + const estimateGas = await this.veaOutbox.estimateGas.verifySnapshot(this.epoch, this.claim); + const claimTransaction = await this.veaOutbox.verifySnapshot(this.epoch, this.claim, { + gasLimit: estimateGas, + }); + this.emitter.emit(BotEvents.TXN_MADE, this.epoch, claimTransaction.hash, "Verify Snapshot"); + this.pendingTransactions.verifySnapshot = claimTransaction.hash; + } + + public async withdrawClaimDeposit() { + this.emitter.emit(BotEvents.WITHDRAWING, this.epoch); + if (await this.checkTransactionPendingStatus(this.pendingTransactions.withdrawClaimDeposit)) { + return; + } + const estimateGas = await this.veaOutbox.estimateGas.withdrawClaimDeposit(this.epoch, this.claim); + const withdrawTxn = await this.veaOutbox.withdrawClaimDeposit(this.epoch, this.claim, { + gasLimit: estimateGas, + }); + this.emitter.emit(BotEvents.TXN_MADE, this.epoch, withdrawTxn.hash, "Withdraw Deposit"); + this.pendingTransactions.withdrawClaimDeposit = withdrawTxn.hash; + } +} diff --git a/bridger-cli/tsconfig.json b/bridger-cli/tsconfig.json new file mode 100644 index 00000000..e727fea7 --- /dev/null +++ b/bridger-cli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": "src", + "esModuleInterop": true, + "resolveJsonModule": true + }, + "ts-node": { + "require": [ + "tsconfig-paths/register" + ] + } +} diff --git a/contracts/.env.example b/contracts/.env.example index 2fdfa0eb..81b1a8fa 100644 --- a/contracts/.env.example +++ b/contracts/.env.example @@ -8,6 +8,18 @@ ETHERSCAN_API_KEY_FIX=ABC123ABC123ABC123ABC123ABC123ABC1 ARBISCAN_API_KEY=ABC123ABC123ABC123ABC123ABC123ABC1 GNOSISSCAN_API_KEY=ABC123ABC123ABC123ABC123ABC123ABC1 + +## For testing +# Names should correspond to the infura network names +INBOX_NETWORK=arbitrum-sepolia +OUTBOX_NETWORK=sepolia +# Empty for direct routes +ROUTER_NETWORK= +# RPC port of local fork for the Graph node: +# 8545 for inboxFork, 8546 for outboxFork, 8547 for routerFork +RPC_GRAPH=8456 + + # Optionally for debugging # TENDERLY_USERNAME=your_username # TENDERLY_PROJECT=your_project diff --git a/contracts/Dockerfile b/contracts/Dockerfile new file mode 100644 index 00000000..2864d670 --- /dev/null +++ b/contracts/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20.12.1-alpine + +WORKDIR /home/node + +COPY package.json ./ + +RUN yarn install --ignore-scripts + +COPY . . + +ENV INFURA_API_KEY="" + +RUN chmod +x /home/node/start-forks.sh + +USER node + +EXPOSE 8545 8546 8547 + +CMD ["/bin/sh", "/home/node/start-forks.sh"] diff --git a/contracts/docker-compose.yml b/contracts/docker-compose.yml new file mode 100644 index 00000000..81725239 --- /dev/null +++ b/contracts/docker-compose.yml @@ -0,0 +1,127 @@ +version: "3.8" + +services: + postgres: + image: postgres:latest + container_name: some-postgres + environment: + POSTGRES_PASSWORD: mysecretpassword + POSTGRES_USER: postgres + POSTGRES_DB: postgres + LC_COLLATE: C + LC_CTYPE: C + ports: + - "5432:5432" + networks: + - graph-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + retries: 5 + command: + - sh + - -c + - | + docker-entrypoint.sh postgres & + sleep 5; + psql -U postgres -c "CREATE DATABASE graphnode_db ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"; + wait + + ipfs: + image: ipfs/kubo:latest + container_name: ipfs_host + volumes: + - ./ipfs/staging:/export + - ./ipfs/data:/data/ipfs + ports: + - "4001:4001" + - "4001:4001/udp" + - "8080:8080" + - "5001:5001" + networks: + - graph-network + + hardhat-inbox-fork: + build: + context: ./ + dockerfile: Dockerfile + container_name: inbox_fork + environment: + - NETWORK=${INBOX_NETWORK} + - PORT=8545 + - INFURA_API_KEY=${INFURA_API_KEY} + command: ["/bin/sh", "/home/node/start-forks.sh"] + ports: + - "8545:8545" + depends_on: + - postgres + - ipfs + networks: + - graph-network + + hardhat-outbox-fork: + build: + context: ./ + dockerfile: Dockerfile + container_name: outbox_fork + environment: + - NETWORK=${OUTBOX_NETWORK} + - PORT=8546 + - INFURA_API_KEY=${INFURA_API_KEY} + command: ["/bin/sh", "/home/node/start-forks.sh"] + ports: + - "8546:8546" + depends_on: + - postgres + - ipfs + networks: + - graph-network + + hardhat-router-fork: + build: + context: ./ + dockerfile: Dockerfile + container_name: router_fork + environment: + - NETWORK=${ROUTER_NETWORK} + - PORT=8547 + - INFURA_API_KEY=${INFURA_API_KEY} + command: ["/bin/sh", "/home/node/start-forks.sh"] + ports: + - "8547:8547" + depends_on: + - postgres + - ipfs + networks: + - graph-network + + + graph-node: + image: graphprotocol/graph-node:latest + container_name: graph_node + environment: + postgres_host: some-postgres + postgres_port: 5432 + postgres_user: postgres + postgres_pass: mysecretpassword + postgres_db: graphnode_db + ipfs: ipfs_host:5001 + ethereum: mainnet:http://host.docker.internal:${RPC_GRAPH}/ + ETHEREUM_REORG_THRESHOLD: 1 + ETHEREUM_ANCESTOR_COUNT: 10 + GRAPH_LOG: debug + ports: + - "8020:8020" + - "8000:8000" + depends_on: + - postgres + - ipfs + - hardhat-inbox-fork + - hardhat-outbox-fork + - hardhat-router-fork + networks: + - graph-network + +networks: + graph-network: + driver: bridge diff --git a/contracts/initGraphDB.sql b/contracts/initGraphDB.sql new file mode 100644 index 00000000..59cbbe0f --- /dev/null +++ b/contracts/initGraphDB.sql @@ -0,0 +1 @@ +CREATE DATABASE graphnode_db ENCODING='UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE=template0; \ No newline at end of file diff --git a/contracts/start-forks.sh b/contracts/start-forks.sh new file mode 100644 index 00000000..fcab359b --- /dev/null +++ b/contracts/start-forks.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +if [ -z "$INFURA_API_KEY" ]; then + echo "Error: INFURA_API_KEY is not set." + exit 1 +fi + +if [ -z "$NETWORK" ]; then + echo "Error: NETWORK is not set for $PORT." + exit 1 +fi + +# Set for 8545: Arbitrum Sepolia fork +echo "Starting Hardhat fork for $NETWORK on port $PORT..." +npx hardhat node --fork https://$NETWORK.infura.io/v3/$INFURA_API_KEY --no-deploy --hostname 0.0.0.0 --port $PORT & + +wait diff --git a/package.json b/package.json index 0d3611b5..28b54177 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "license": "MIT", "private": true, "workspaces": [ + "bridger-cli", "contracts", "relayer-subgraph-inbox", "validator-cli", diff --git a/veascan-subgraph-inbox/src/VeaInbox.ts b/veascan-subgraph-inbox/src/VeaInbox.ts index bf2c62d5..620c4124 100644 --- a/veascan-subgraph-inbox/src/VeaInbox.ts +++ b/veascan-subgraph-inbox/src/VeaInbox.ts @@ -115,12 +115,14 @@ export function handleSnapshotSent(event: SnapshotSent): void { const snapshotId = BigInt.fromI32(i).toString(); snapshot = Snapshot.load(snapshotId); - if (snapshot && snapshot.epoch === epochSent) { - // Snapshot found, update resolving field and save - snapshot.resolving = true; - snapshot.save(); - fallback.snapshot = snapshotId; - break; + if (snapshot && snapshot.epoch) { + if (BigInt.compare(snapshot.epoch as BigInt, epochSent) == 0) { + // Snapshot found, update resolving field and save + snapshot.resolving = true; + snapshot.save(); + fallback.snapshot = snapshotId; + break; + } } } fallback.save(); diff --git a/veascan-subgraph-outbox/src/VeaOutbox.ts b/veascan-subgraph-outbox/src/VeaOutbox.ts index 6e7e41f6..6a7e40fe 100644 --- a/veascan-subgraph-outbox/src/VeaOutbox.ts +++ b/veascan-subgraph-outbox/src/VeaOutbox.ts @@ -64,12 +64,12 @@ export function handleVerified(event: Verified): void { for ( let i = ref.totalClaims.minus(BigInt.fromI32(1)); i.ge(BigInt.fromI32(0)); - i.minus(BigInt.fromI32(1)) + i = i.minus(BigInt.fromI32(1)) ) { const claim = Claim.load(i.toString()); - if (claim!.epoch.equals(event.params._epoch)) { - const verification = new Verification(claim!.id); - verification.claim = claim!.id; + if (claim && claim.epoch.equals(event.params._epoch)) { + const verification = new Verification(claim.id); + verification.claim = claim.id; verification.timestamp = event.block.timestamp; verification.caller = event.transaction.from; verification.txHash = event.transaction.hash; diff --git a/yarn.lock b/yarn.lock index 2f660a06..1de7052c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3339,6 +3339,18 @@ __metadata: languageName: node linkType: hard +"@kleros/bridger-cli@workspace:bridger-cli": + version: 0.0.0-use.local + resolution: "@kleros/bridger-cli@workspace:bridger-cli" + dependencies: + "@kleros/vea-contracts": "workspace:^" + "@typechain/ethers-v5": "npm:^10.2.0" + dotenv: "npm:^16.4.5" + typescript: "npm:^4.9.5" + web3: "npm:^1.10.4" + languageName: unknown + linkType: soft + "@kleros/ui-components-library@npm:^2.14.0": version: 2.16.0 resolution: "@kleros/ui-components-library@npm:2.16.0"