Created
May 22, 2026 11:36
-
-
Save uF4No/92284a48fde0f1fae88bb465dc409f9b to your computer and use it in GitHub Desktop.
L1 hashes for L2 tx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Run: | |
| * node standalone/tx-batch-info-unstable/index.mjs <txHash> | |
| * or set TX_HASH in the env file and run without arguments. | |
| * | |
| * Required env variables: | |
| * ADMIN_PRIVATE_KEY | |
| * PRIVIDIUM_RPC_URL | |
| * PRIVIDIUM_CHAIN_ID | |
| * PRIVIDIUM_SIWE_DOMAIN | |
| * | |
| * Optional env variables: | |
| * TX_HASH | |
| * PRIVIDIUM_API_BASE_URL | |
| * ETH_SEPOLIA_RPC_URL | |
| */ | |
| import { existsSync } from 'node:fs' | |
| import { dirname, resolve } from 'node:path' | |
| import { fileURLToPath } from 'node:url' | |
| import { config as loadEnv } from 'dotenv' | |
| import { | |
| decodeFunctionResult, | |
| encodeFunctionData, | |
| keccak256, | |
| parseAbi, | |
| toBytes, | |
| } from 'viem' | |
| import { privateKeyToAccount } from 'viem/accounts' | |
| const __dirname = dirname(fileURLToPath(import.meta.url)) | |
| const envPath = resolve(__dirname, '.env') | |
| if (existsSync(envPath)) { | |
| loadEnv({ path: envPath }) | |
| } else { | |
| loadEnv() | |
| } | |
| const txHash = process.argv[2] ?? requiredEnv('TX_HASH') | |
| const rpcUrl = requiredEnv('PRIVIDIUM_RPC_URL') | |
| const apiBaseUrl = trimTrailingSlash(process.env.PRIVIDIUM_API_BASE_URL || deriveApiBaseUrl(rpcUrl)) | |
| const expectedChainId = Number(requiredEnv('PRIVIDIUM_CHAIN_ID')) | |
| const siweDomain = requiredEnv('PRIVIDIUM_SIWE_DOMAIN') | |
| const l1RpcUrl = process.env.ETH_SEPOLIA_RPC_URL || 'https://ethereum-sepolia-rpc.publicnode.com' | |
| const privateKey = process.env.ADMIN_PRIVATE_KEY || process.env.PRIVATE_KEY | |
| if (!privateKey) { | |
| throw new Error('Missing ADMIN_PRIVATE_KEY') | |
| } | |
| const account = privateKeyToAccount(normalizePrivateKey(privateKey)) | |
| const auth = await authenticate({ | |
| apiBaseUrl, | |
| domain: siweDomain, | |
| address: account.address, | |
| signMessage: async (message) => account.signMessage({ message }), | |
| }) | |
| const chainIdHex = await rpcCall({ | |
| rpcUrl, | |
| token: auth.token, | |
| method: 'eth_chainId', | |
| params: [], | |
| }) | |
| const chainId = hexToNumber(chainIdHex) | |
| if (chainId !== expectedChainId) { | |
| throw new Error(`Chain ID mismatch: expected ${expectedChainId}, got ${chainId} (${chainIdHex})`) | |
| } | |
| const transaction = await rpcCall({ | |
| rpcUrl, | |
| token: auth.token, | |
| method: 'eth_getTransactionByHash', | |
| params: [txHash], | |
| }) | |
| if (!transaction) { | |
| throw new Error(`Transaction not found: ${txHash}`) | |
| } | |
| const blockNumber = toNullableNumber(transaction.blockNumber) | |
| if (blockNumber === null) { | |
| throw new Error(`Transaction ${txHash} is not included in a block yet`) | |
| } | |
| const batchByBlock = await rpcCall({ | |
| rpcUrl, | |
| token: auth.token, | |
| method: 'unstable_getBatchByBlockNumber', | |
| params: [blockNumber], | |
| }) | |
| const batchNumber = toNullableNumber(batchByBlock?.batch_info?.batch_number) | |
| const executeSettlementBlock = toNullableNumber(batchByBlock?.execute_sl_block_number) | |
| if (batchNumber === null) { | |
| throw new Error(`unstable_getBatchByBlockNumber did not return a batch number for block ${blockNumber}`) | |
| } | |
| const bridgehubAddress = await rpcCall({ | |
| rpcUrl, | |
| token: auth.token, | |
| method: 'zks_getBridgehubContract', | |
| params: [], | |
| }) | |
| const l1LogResolution = await resolveL1TransactionsFromLogs({ | |
| l1RpcUrl, | |
| bridgehubAddress, | |
| l2ChainId: BigInt(chainId), | |
| l1BatchNumber: BigInt(batchNumber), | |
| executeSettlementBlock: executeSettlementBlock === null ? null : BigInt(executeSettlementBlock), | |
| }) | |
| const summary = { | |
| network: { | |
| rpcUrl, | |
| chainId, | |
| l1RpcUrl, | |
| }, | |
| txHash, | |
| l2BlockNumber: blockNumber, | |
| l1BatchNumber: batchNumber, | |
| executeSettlementBlock, | |
| bridgehubAddress, | |
| l1ChainContractAddress: l1LogResolution.l1ChainContractAddress, | |
| batchL1Transactions: { | |
| commitTxHash: l1LogResolution.commit?.transactionHash ?? null, | |
| proveTxHash: l1LogResolution.prove?.transactionHash ?? null, | |
| executeTxHash: l1LogResolution.execute?.transactionHash ?? null, | |
| }, | |
| } | |
| console.log(JSON.stringify(summary, null, 2)) | |
| async function authenticate({ apiBaseUrl, domain, address, signMessage }) { | |
| const siwe = await requestSiweMessage({ apiBaseUrl, address, domain }) | |
| const signature = await signMessage(siwe.msg) | |
| const session = await apiJson(`${apiBaseUrl}/api/auth/login/crypto-native`, { | |
| method: 'POST', | |
| headers: jsonHeaders(), | |
| body: JSON.stringify({ | |
| message: siwe.msg, | |
| signature, | |
| nonceToken: siwe.nonceToken, | |
| }), | |
| }) | |
| return { | |
| ...session, | |
| domain: siwe.domain, | |
| } | |
| } | |
| async function requestSiweMessage({ apiBaseUrl, address, domain }) { | |
| try { | |
| const result = await apiJson(`${apiBaseUrl}/api/siwe-messages`, { | |
| method: 'POST', | |
| headers: jsonHeaders(), | |
| body: JSON.stringify({ address, domain }), | |
| }) | |
| return { ...result, domain } | |
| } catch (error) { | |
| const validDomains = parseValidDomains(error) | |
| if (validDomains.length === 0 || validDomains.includes(domain)) { | |
| throw error | |
| } | |
| const fallbackDomain = validDomains[0] | |
| const result = await apiJson(`${apiBaseUrl}/api/siwe-messages`, { | |
| method: 'POST', | |
| headers: jsonHeaders(), | |
| body: JSON.stringify({ address, domain: fallbackDomain }), | |
| }) | |
| return { ...result, domain: fallbackDomain } | |
| } | |
| } | |
| async function rpcCall({ rpcUrl, token, method, params }) { | |
| const response = await fetch(rpcUrl, { | |
| method: 'POST', | |
| headers: { | |
| ...jsonHeaders(), | |
| ...(token ? { Authorization: `Bearer ${token}` } : {}), | |
| }, | |
| body: JSON.stringify({ | |
| jsonrpc: '2.0', | |
| id: method, | |
| method, | |
| params, | |
| }), | |
| }) | |
| const payload = await response.json() | |
| if (!response.ok) { | |
| throw new Error(`RPC ${method} failed with HTTP ${response.status}: ${JSON.stringify(payload)}`) | |
| } | |
| if (payload.error) { | |
| throw new Error(`RPC ${method} failed: ${JSON.stringify(payload.error)}`) | |
| } | |
| return payload.result | |
| } | |
| async function resolveL1TransactionsFromLogs({ | |
| l1RpcUrl, | |
| bridgehubAddress, | |
| l2ChainId, | |
| l1BatchNumber, | |
| executeSettlementBlock, | |
| }) { | |
| const l1ChainContractAddress = await resolveL1ChainContractAddress({ | |
| l1RpcUrl, | |
| bridgehubAddress, | |
| l2ChainId, | |
| }) | |
| const latestBlockHex = await rpcCall({ | |
| rpcUrl: l1RpcUrl, | |
| method: 'eth_blockNumber', | |
| params: [], | |
| }) | |
| const latestBlock = BigInt(latestBlockHex) | |
| const anchorBlock = executeSettlementBlock === null || executeSettlementBlock > latestBlock | |
| ? latestBlock | |
| : executeSettlementBlock | |
| const batchTopic = numberToTopic(l1BatchNumber) | |
| const eventTopics = { | |
| blockCommit: topicHash('BlockCommit(uint256,bytes32,bytes32)'), | |
| blocksVerification: topicHash('BlocksVerification(uint256,uint256)'), | |
| blockExecution: topicHash('BlockExecution(uint256,bytes32,bytes32)'), | |
| } | |
| const execute = await findNearestLog({ | |
| l1RpcUrl, | |
| address: l1ChainContractAddress, | |
| anchorBlock, | |
| direction: 'both', | |
| topics: [eventTopics.blockExecution, batchTopic], | |
| }) | |
| const prove = await findNearestLog({ | |
| l1RpcUrl, | |
| address: l1ChainContractAddress, | |
| anchorBlock, | |
| direction: 'backward', | |
| topics: [eventTopics.blocksVerification, null, batchTopic], | |
| }) | |
| const commit = await findNearestLog({ | |
| l1RpcUrl, | |
| address: l1ChainContractAddress, | |
| anchorBlock, | |
| direction: 'backward', | |
| topics: [eventTopics.blockCommit, batchTopic], | |
| }) | |
| return { | |
| l1ChainContractAddress, | |
| commit, | |
| prove, | |
| execute, | |
| } | |
| } | |
| async function resolveL1ChainContractAddress({ l1RpcUrl, bridgehubAddress, l2ChainId }) { | |
| const candidates = [ | |
| 'function getZKChain(uint256) view returns (address)', | |
| 'function getHyperchain(uint256) view returns (address)', | |
| ] | |
| for (const signature of candidates) { | |
| const abi = parseAbi([signature]) | |
| const functionName = abi[0].name | |
| const data = encodeFunctionData({ | |
| abi, | |
| functionName, | |
| args: [l2ChainId], | |
| }) | |
| try { | |
| const raw = await rpcCall({ | |
| rpcUrl: l1RpcUrl, | |
| method: 'eth_call', | |
| params: [{ to: bridgehubAddress, data }, 'latest'], | |
| }) | |
| const result = decodeFunctionResult({ | |
| abi, | |
| functionName, | |
| data: raw, | |
| }) | |
| if (result && result !== zeroAddress()) { | |
| return result | |
| } | |
| } catch { | |
| continue | |
| } | |
| } | |
| throw new Error(`Unable to resolve L1 chain contract from Bridgehub ${bridgehubAddress}`) | |
| } | |
| async function findNearestLog({ l1RpcUrl, address, anchorBlock, direction, topics }) { | |
| const maxRange = 50000n | |
| const latestBlockHex = await rpcCall({ | |
| rpcUrl: l1RpcUrl, | |
| method: 'eth_blockNumber', | |
| params: [], | |
| }) | |
| const latestBlock = BigInt(latestBlockHex) | |
| if (direction === 'both') { | |
| const exact = await getLogs({ | |
| l1RpcUrl, | |
| address, | |
| fromBlock: anchorBlock, | |
| toBlock: anchorBlock, | |
| topics, | |
| }) | |
| if (exact.length > 0) return exact[exact.length - 1] | |
| for (let offset = 1n; offset <= latestBlock; offset += maxRange) { | |
| const candidateLogs = [] | |
| const backwardTo = anchorBlock >= offset ? anchorBlock - offset : -1n | |
| if (backwardTo >= 0n) { | |
| const backwardFrom = backwardTo >= maxRange - 1n ? backwardTo - (maxRange - 1n) : 0n | |
| const backwardLogs = await getLogs({ | |
| l1RpcUrl, | |
| address, | |
| fromBlock: backwardFrom, | |
| toBlock: backwardTo, | |
| topics, | |
| }) | |
| candidateLogs.push(...backwardLogs) | |
| } | |
| const forwardFrom = anchorBlock + offset | |
| if (forwardFrom <= latestBlock) { | |
| const forwardTo = forwardFrom + maxRange - 1n <= latestBlock | |
| ? forwardFrom + maxRange - 1n | |
| : latestBlock | |
| const forwardLogs = await getLogs({ | |
| l1RpcUrl, | |
| address, | |
| fromBlock: forwardFrom, | |
| toBlock: forwardTo, | |
| topics, | |
| }) | |
| candidateLogs.push(...forwardLogs) | |
| } | |
| if (candidateLogs.length > 0) { | |
| return pickClosestLog(candidateLogs, anchorBlock) | |
| } | |
| if (backwardTo < 0n && forwardFrom > latestBlock) break | |
| if (backwardTo >= 0n && backwardTo < maxRange && forwardFrom > latestBlock) break | |
| } | |
| return null | |
| } | |
| for (let end = anchorBlock; end >= 0n; end = end >= maxRange ? end - maxRange : -1n) { | |
| const start = end >= maxRange - 1n ? end - (maxRange - 1n) : 0n | |
| const logs = await getLogs({ | |
| l1RpcUrl, | |
| address, | |
| fromBlock: start, | |
| toBlock: end, | |
| topics, | |
| }) | |
| if (logs.length > 0) { | |
| return logs.reduce((latest, current) => { | |
| if (!latest) return current | |
| return BigInt(current.blockNumber) > BigInt(latest.blockNumber) ? current : latest | |
| }, null) | |
| } | |
| if (start === 0n) break | |
| } | |
| return null | |
| } | |
| async function getLogs({ l1RpcUrl, address, fromBlock, toBlock, topics }) { | |
| return rpcCall({ | |
| rpcUrl: l1RpcUrl, | |
| method: 'eth_getLogs', | |
| params: [{ | |
| address, | |
| fromBlock: bigintToHex(fromBlock), | |
| toBlock: bigintToHex(toBlock), | |
| topics, | |
| }], | |
| }) | |
| } | |
| function pickClosestLog(logs, anchorBlock) { | |
| return logs.reduce((closest, current) => { | |
| if (!closest) return current | |
| const currentDistance = absBigInt(BigInt(current.blockNumber) - anchorBlock) | |
| const closestDistance = absBigInt(BigInt(closest.blockNumber) - anchorBlock) | |
| if (currentDistance < closestDistance) return current | |
| if (currentDistance > closestDistance) return closest | |
| return BigInt(current.logIndex) > BigInt(closest.logIndex) ? current : closest | |
| }, null) | |
| } | |
| async function apiJson(url, init) { | |
| const response = await fetch(url, init) | |
| const text = await response.text() | |
| if (!response.ok) { | |
| throw new Error(`Request failed for ${url}: ${response.status} ${text}`) | |
| } | |
| return JSON.parse(text) | |
| } | |
| function parseValidDomains(error) { | |
| if (!(error instanceof Error)) return [] | |
| const match = error.message.match(/Valid values are:\s*(.+)$/) | |
| if (!match) return [] | |
| return match[1] | |
| .split(',') | |
| .map((item) => item.trim()) | |
| .filter(Boolean) | |
| } | |
| function deriveApiBaseUrl(rpcUrl) { | |
| const url = new URL(rpcUrl) | |
| url.pathname = url.pathname.replace(/\/rpc\/?$/, '') | |
| return url.toString().replace(/\/+$/, '') | |
| } | |
| function topicHash(signature) { | |
| return keccak256(toBytes(signature)) | |
| } | |
| function numberToTopic(value) { | |
| return `0x${value.toString(16).padStart(64, '0')}` | |
| } | |
| function bigintToHex(value) { | |
| return `0x${value.toString(16)}` | |
| } | |
| function absBigInt(value) { | |
| return value < 0n ? -value : value | |
| } | |
| function zeroAddress() { | |
| return '0x0000000000000000000000000000000000000000' | |
| } | |
| function toNullableNumber(value) { | |
| if (value == null) return null | |
| if (typeof value === 'number') return value | |
| if (typeof value === 'string' && value.startsWith('0x')) return hexToNumber(value) | |
| return Number(value) | |
| } | |
| function hexToNumber(value) { | |
| return Number(BigInt(value)) | |
| } | |
| function normalizePrivateKey(value) { | |
| return value.startsWith('0x') ? value : `0x${value}` | |
| } | |
| function trimTrailingSlash(value) { | |
| return value.replace(/\/+$/, '') | |
| } | |
| function jsonHeaders() { | |
| return { | |
| 'Content-Type': 'application/json', | |
| } | |
| } | |
| function requiredEnv(name) { | |
| const value = process.env[name] | |
| if (!value) { | |
| throw new Error(`Missing ${name}`) | |
| } | |
| return value | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment