diff --git a/package.json b/package.json index c3b8309..fb25084 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@ethereumjs/util": "^9.0.3", "@ethereumjs/vm": "^8.0.0", "axios": "0.27.2", + "ethereum-bloom-filters": "^1.2.0", "json-rpc-2.0": "1.4.1", "kzg-wasm": "^0.4.0", "lodash": "4.17.21", diff --git a/src/json-rpc-server.ts b/src/json-rpc-server.ts index 9a933e0..880296e 100644 --- a/src/json-rpc-server.ts +++ b/src/json-rpc-server.ts @@ -3,6 +3,7 @@ import { JSONRPCLogFilter, RPCTx } from './types'; import log from './logger'; import { VerifyingProvider } from './provider'; import { validators } from './validation'; +import { InvalidParamsError } from './errors'; export function getJSONRPCServer(provider: VerifyingProvider) { const server = new JSONRPCServer(); @@ -84,6 +85,12 @@ export function getJSONRPCServer(provider: VerifyingProvider) { // Validate block options if they are present and blockHash is not if (filter.fromBlock) validators.blockOption([filter.fromBlock], 0); if (filter.toBlock) validators.blockOption([filter.toBlock], 0); + // Pending blocks cannot be verified in eth_getLogs + if ([filter.fromBlock, filter.toBlock].includes('pending')) { + throw new InvalidParamsError( + 'eth_getLogs does not support pending blocks.', + ); + } } // Validate address if it is present diff --git a/src/provider.ts b/src/provider.ts index 2eacfa8..a97439b 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -15,11 +15,13 @@ import { KECCAK256_RLP_S, KECCAK256_NULL_S, equalsBytes, + KECCAK256_RLP, } from '@ethereumjs/util'; -import { VM } from '@ethereumjs/vm'; +import { VM, encodeReceipt } from '@ethereumjs/vm'; import { BlockHeader, Block } from '@ethereumjs/block'; import { Blockchain } from '@ethereumjs/blockchain'; -import { TransactionFactory } from '@ethereumjs/tx'; +import { TransactionFactory, TransactionType } from '@ethereumjs/tx'; +import { isInBloom, isTopicInBloom } from 'ethereum-bloom-filters'; import { AddressHex, Bytes32, @@ -48,6 +50,7 @@ import { headerDataFromWeb3Response, blockDataFromWeb3Response, toJSONRPCBlock, + txReceiptFromJSONRPCReceipt, } from './utils'; import { RPC } from './rpc'; @@ -164,7 +167,6 @@ export class VerifyingProvider { } async getLogs(filter: JSONRPCLogFilter): Promise { - // naive, forward the request to the RPC const res = await this.rpc.request({ method: 'eth_getLogs', params: [filter], @@ -173,45 +175,175 @@ export class VerifyingProvider { throw new InternalError(`RPC request failed`); } - // throw new InvalidParamsError(`"pending" is not yet supported`); - const logs = res.result as JSONRPCLog[]; - - // check logs against the state + const blockNumbers = new Set( + logs + .map(l => l.blockNumber) + .filter(bn => bn && Number.isInteger(parseInt(bn, 16))), + ) as Set; + + // caches + // blockNumber -> blockHeader + const blockHeaders = new Map(); + // blockNumber -> block + const blocks = new Map(); + // blockHash -> receipts + const blockReceipts = new Map(); + + // fetch blocks + await Promise.all( + Array.from(blockNumbers).map(async blockNumber => { + if (!blockHeaders.has(blockNumber)) { + const header = await this.getBlockHeader(blockNumber); + blockHeaders.set(blockNumber, header); + } + if (!blocks.has(blockNumber)) { + const header = blockHeaders.get(blockNumber)!; + const block = await this.getBlock(header); + blocks.set(blockNumber, block); + } + }), + ); // TODO parallelize - for (const log of logs) { - // TODO handle pending logs - const header = await this.getBlockHeader(log.blockNumber); + for (const l of logs) { + if ( + typeof l.logIndex !== 'string' || + typeof l.blockNumber !== 'string' || + typeof l.blockHash !== 'string' || + typeof l.transactionHash !== 'string' || + typeof l.transactionIndex !== 'string' + ) { + throw new InternalError(`"pending" logs are not supported`); + } - // receiptTrie - // logsBloom - const block = await this.getBlock(header); + const block = blocks.get(l.blockNumber); + if (!block) { + throw new InternalError(`Block ${l.blockNumber} not found`); + } + const blockHash = bytesToHex(block.hash()); + + // verify block hash matches + if (blockHash !== l.blockHash.toLowerCase()) { + throw new InternalError( + 'the log provided by the RPC is invalid: blockHash not matching', + ); + } - // verify transaction - // log.transactionHash, log.transactionIndex + // verify transaction index and hash matches const txIndex = block.transactions.findIndex( - tx => bytesToHex(tx.hash()) === log.transactionHash.toLowerCase(), + tx => bytesToHex(tx.hash()) === l.transactionHash!.toLowerCase(), ); - if (txIndex === -1 || txIndex !== log.transactionIndex) { - throw new InternalError('the recipt provided by the RPC is invalid'); + if (txIndex === -1 || txIndex !== parseInt(l.transactionIndex, 16)) { + throw new InternalError( + 'the log provided by the RPC is invalid: transactionHash not matching', + ); + } + + // check if the log is not in the logsBloom + const logsBloom = bytesToHex(block.header.logsBloom); + if ( + !isInBloom(logsBloom, l.address) || + l.topics.some(topic => !isTopicInBloom(logsBloom, topic)) + ) { + throw new InternalError('the log is not in the logsBloom'); + } + + // fetch all receipts in the block + let receipts: JSONRPCReceipt[]; + if (blockReceipts.has(blockHash)) { + receipts = blockReceipts.get(blockHash)!; + } else { + receipts = await this.getBlockReceipts(blockHash); + blockReceipts.set(blockHash, receipts); } - const tx = block.transactions[txIndex]; - // log.logIndex - // eth_blockReceipts - // eth_getTransactionReceipt + // reconstruct receipt trie + const reconstructedReceiptTrie = new Trie(); + for (let i = 0; i < receipts.length; i++) { + const receiptJson = receipts[i] as JSONRPCReceipt; + const receipt = txReceiptFromJSONRPCReceipt(receiptJson); + const type: TransactionType = parseInt(receiptJson.type, 16); + const encoded = encodeReceipt(receipt, type); + await reconstructedReceiptTrie.put(rlp.encode(i), encoded); + } - // TODO: to verify the params below download all the tx recipts - // of the block, compute the recipt root and verify the recipt - // root matches that in the blockHeader + // check if it matches + const computedReceiptRoot = + reconstructedReceiptTrie !== undefined + ? bytesToHex(reconstructedReceiptTrie.root()) + : KECCAK256_RLP; - // log.data, log.topics? + if (computedReceiptRoot !== bytesToHex(block.header.receiptTrie)) { + throw new InternalError( + 'Receipt trie root does not match the block header receiptTrie', + ); + } + + // check log is included in the receipt + const receipt = receipts.find( + r => + r.transactionHash.toLowerCase() === l.transactionHash!.toLowerCase(), + ); + if (!receipt) { + throw new InternalError('Receipt not found for the log'); + } + const logFound = receipt.logs.some( + log => + log.address.toLowerCase() === l.address.toLowerCase() && + log.data.toLowerCase() === l.data.toLowerCase() && + log.topics.length === l.topics.length && + log.topics.every( + (topic, index) => + topic.toLowerCase() === l.topics[index].toLowerCase(), + ), + ); + if (!logFound) { + throw new InternalError('Log not found in the receipt'); + } } return res.result; } + // TODO expose as an RPC request (and verify) + async getBlockReceipts(blockHash: Bytes32): Promise { + try { + const { result: receipts, success } = await this.rpc.request({ + method: 'eth_getBlockReceipts', + params: [blockHash], + }); + + if (success) { + return receipts; + } else { + throw new Error('eth_getBlockReceipts RPC request failed'); + } + } catch (error) { + // only fallback to eth_getTransactionReceipt if the method is not supported + // otherwise fail + if (!error?.message.includes('method not supported')) { + throw error; + } + + const header = await this.getBlockHeaderByHash(blockHash); + const block = await this.getBlock(header); + + const receipts = await this.rpc.requestBatch( + block.transactions.map(tx => ({ + method: 'eth_getTransactionReceipt', + params: [bytesToHex(tx.hash())], + })), + ); + + if (receipts.some(r => !r.success)) { + throw new InternalError(`eth_getTransactionReceipt RPC request failed`); + } + + return receipts.map(r => r.result); + } + } + async getCode( addressHex: AddressHex, blockOpt: BlockOpt = DEFAULT_BLOCK_PARAMETER, @@ -433,7 +565,7 @@ export class VerifyingProvider { tx => bytesToHex(tx.hash()) === txHash.toLowerCase(), ); if (index === -1) { - throw new InternalError('the recipt provided by the RPC is invalid'); + throw new InternalError('the receipt provided by the RPC is invalid'); } const tx = block.transactions[index]; @@ -444,8 +576,9 @@ export class VerifyingProvider { blockNumber: bigIntToHex(block.header.number), from: tx.getSenderAddress().toString(), to: tx.to?.toString() ?? null, - // TODO: to verify the params below download all the tx recipts - // of the block, compute the recipt root and verify the recipt + type: bigIntToHex(tx.type), + // TODO: to verify the params below download all the tx receipts + // of the block, compute the receipt root and verify the receipt // root matches that in the blockHeader cumulativeGasUsed: '0x0', effectiveGasPrice: '0x0', @@ -546,7 +679,7 @@ export class VerifyingProvider { throw new InvalidParamsError('specified block is too far in future'); } else if (blockNumber + MAX_BLOCK_HISTORY < this.latestBlockNumber) { throw new InvalidParamsError( - `specified block cannot older that ${MAX_BLOCK_HISTORY}`, + `specified block (${blockNumber}) cannot be older that ${MAX_BLOCK_HISTORY}`, ); } return blockNumber; diff --git a/src/rpc.ts b/src/rpc.ts index aa7eca6..4ac5852 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -35,6 +35,9 @@ export class RPC { ) { throw new Error('method not supported by the provider'); } + log.debug( + `RPC Request: ${request.method}: ${JSON.stringify(request.params)}`, + ); return await this._retryRequest(request); } diff --git a/src/types.ts b/src/types.ts index 5b08bc7..68f8660 100644 --- a/src/types.ts +++ b/src/types.ts @@ -112,6 +112,7 @@ export type JSONRPCReceipt = { contractAddress: string | null; // DATA, 20 Bytes - The contract address created, if the transaction was a contract creation, otherwise null. logs: JSONRPCLog[]; // Array - Array of log objects, which this transaction generated. logsBloom: string; // DATA, 256 Bytes - Bloom filter for light clients to quickly retrieve related logs. + type: string; // QUANTITY - EIP-2718 Typed Transaction type // It also returns either: root?: string; // DATA, 32 bytes of post-transaction stateroot (pre Byzantium) status?: string; // QUANTITY, either 1 (success) or 0 (failure) diff --git a/src/utils.ts b/src/utils.ts index 4ad61dd..4b02c1d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,7 +12,13 @@ import { FeeMarketEIP1559TxData, TypedTransaction, } from '@ethereumjs/tx'; -import { JSONRPCTx, JSONRPCBlock } from './types'; +import { + TxReceipt, + PreByzantiumTxReceipt, + PostByzantiumTxReceipt, +} from '@ethereumjs/vm'; +import { Log } from '@ethereumjs/evm'; +import { JSONRPCTx, JSONRPCBlock, JSONRPCReceipt } from './types'; const isTruthy = (val: any) => !!val; @@ -138,3 +144,40 @@ export function toJSONRPCBlock( baseFeePerGas: header.baseFeePerGas, }; } + +export function txReceiptFromJSONRPCReceipt( + receipt: JSONRPCReceipt, +): TxReceipt { + // Transform logs + const logs: Log[] = receipt.logs.map(log => [ + hexToBytes(log.address), + log.topics.map(topic => hexToBytes(topic)), + hexToBytes(log.data), + ]); + + // Base receipt fields + const baseReceipt = { + cumulativeBlockGasUsed: BigInt(receipt.cumulativeGasUsed), + bitvector: hexToBytes(receipt.logsBloom), + logs, + }; + + // Determine the type of receipt + if (receipt.root) { + // Pre-Byzantium receipt + const preByzantiumReceipt: PreByzantiumTxReceipt = { + ...baseReceipt, + stateRoot: hexToBytes(receipt.root), + }; + return preByzantiumReceipt; + } else if (receipt.status !== undefined) { + // Post-Byzantium receipt + const postByzantiumReceipt: PostByzantiumTxReceipt = { + ...baseReceipt, + status: parseInt(receipt.status, 16) as 0 | 1, + }; + return postByzantiumReceipt; + } else { + throw new Error('Unsupported receipt type'); + } +} diff --git a/src/validation.ts b/src/validation.ts index 2dbb970..56d2b14 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -60,7 +60,7 @@ export const validators = { } }, /** - * validator to ensure valid block integer or hash, or string option ["latest", "earliest", "pending"] + * validator to ensure valid block integer or hash, or string option ["latest", "earliest", "pending", "finalized", "safe"] * @param params parameters of method * @param index index of parameter */ @@ -74,7 +74,11 @@ export const validators = { } try { - if (['latest', 'earliest', 'pending'].includes(blockOption)) { + if ( + ['latest', 'earliest', 'pending', 'finalized', 'safe'].includes( + blockOption, + ) + ) { return; } return this.hex([blockOption], 0); diff --git a/yarn.lock b/yarn.lock index ce9419c..9e8467f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -902,6 +902,11 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.3.tgz#39908da56a4adc270147bb07968bf3b16cfe1699" integrity sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA== +"@noble/hashes@^1.4.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.5.0.tgz#abadc5ca20332db2b1b2aa3e496e9af1213570b0" + integrity sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA== + "@scure/base@^1.1.5", "@scure/base@~1.1.4": version "1.1.6" resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.6.tgz#8ce5d304b436e4c84f896e0550c83e4d88cb917d" @@ -2427,6 +2432,13 @@ ethereum-bloom-filters@^1.0.6: dependencies: js-sha3 "^0.8.0" +ethereum-bloom-filters@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/ethereum-bloom-filters/-/ethereum-bloom-filters-1.2.0.tgz#8294f074c1a6cbd32c39d2cc77ce86ff14797dab" + integrity sha512-28hyiE7HVsWubqhpVLVmZXFd4ITeHi+BUu05o9isf0GUpMtzBUi+8/gFrGaGYzvGAJQmJ3JKj77Mk9G98T84rA== + dependencies: + "@noble/hashes" "^1.4.0" + ethereum-cryptography@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/ethereum-cryptography/-/ethereum-cryptography-0.1.3.tgz#8d6143cfc3d74bf79bbd8edecdf29e4ae20dd191"