From a12fd44e2b967ee5be784644e39038186b9ea1a0 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Wed, 29 Mar 2023 04:58:17 -0400 Subject: [PATCH] *Add support for running verified calls with EVM *Add caching of consensus blocks *Add caching of RPC calls --- src/client/client.ts | 77 ++-- src/client/rpc/constants.ts | 10 + src/client/rpc/errors.ts | 13 + src/client/rpc/index.ts | 1 + src/client/rpc/provider.ts | 708 +++++++++++++++++++++++++++++++++++ src/client/rpc/rpc.ts | 150 ++++++++ src/client/rpc/types.ts | 63 ++++ src/client/rpc/utils.ts | 68 ++++ src/client/rpc/validation.ts | 139 +++++++ src/index.ts | 74 ++-- 10 files changed, 1249 insertions(+), 54 deletions(-) create mode 100644 src/client/rpc/constants.ts create mode 100644 src/client/rpc/errors.ts create mode 100644 src/client/rpc/index.ts create mode 100644 src/client/rpc/provider.ts create mode 100644 src/client/rpc/rpc.ts create mode 100644 src/client/rpc/types.ts create mode 100644 src/client/rpc/utils.ts create mode 100644 src/client/rpc/validation.ts diff --git a/src/client/client.ts b/src/client/client.ts index 8e50ea3..77fb5e6 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -32,6 +32,7 @@ import * as bellatrix from "@lodestar/types/bellatrix"; import { init } from "@chainsafe/bls/switchable"; import NodeCache from "node-cache"; import { Mutex } from "async-mutex"; +import { VerifyingProvider } from "./rpc/index.js"; export default class Client { latestCommittee?: Uint8Array[]; @@ -46,13 +47,25 @@ export default class Client { private boot = false; private beaconChainAPIURL: string; private store: IStore; - private syncMutex = new Mutex(); + private rpcUrl: string; - constructor(prover: IProver, store: IStore, beaconUrl: string) { + constructor( + prover: IProver, + store: IStore, + beaconUrl: string, + rpcUrl: string + ) { this.prover = prover; this.store = store; this.beaconChainAPIURL = beaconUrl; + this.rpcUrl = rpcUrl; + } + + private _provider?: VerifyingProvider; + + get provider(): VerifyingProvider { + return this._provider; } private _latestPeriod: number = -1; @@ -82,10 +95,21 @@ export default class Client { await this._sync(); - if (!this.boot) { - this.subscribe(); - this.boot = true; + if (!this._provider) { + const { blockhash, blockNumber } = await this.getNextValidExecutionInfo(); + const provider = new VerifyingProvider( + this.rpcUrl, + blockNumber, + blockhash + ); + this._provider = provider; } + + const ei = await this.getLatestExecution(); + if (ei && ei.blockhash !== this.latestBlockHash) { + this.latestBlockHash = ei.blockhash; + } + this._provider.update(ei.blockhash, ei.blockNumber); } async syncProver( @@ -138,20 +162,6 @@ export default class Client { ); } - public async subscribe() { - setInterval(async () => { - try { - await this._sync(); - const ei = await this.getLatestExecution(); - if (ei && ei.blockhash !== this.latestBlockHash) { - this.latestBlockHash = ei.blockhash; - } - } catch (e) { - console.error(e); - } - }, POLLING_DELAY); - } - optimisticUpdateFromJSON(update: any): OptimisticUpdate { return altair.ssz.LightClientOptimisticUpdate.fromJson(update); } @@ -221,6 +231,8 @@ export default class Client { ); } + console.log(`setting block ${slot} to cache`); + this._blockCache.set(slot as any, res); this._blockHashCache.set(slot as any, expectedBlockRoot); @@ -235,7 +247,11 @@ export default class Client { const currentPeriod = this.getCurrentPeriod(); if (currentPeriod > this._latestPeriod) { - this.latestCommittee = await this.syncFromGenesis(); + if (!this.boot) { + this.latestCommittee = await this.syncFromGenesis(); + } else { + this.latestCommittee = await this.syncFromLastUpdate(); + } this._latestPeriod = currentPeriod; } @@ -263,6 +279,24 @@ export default class Client { throw new Error("no honest prover found"); } + private async syncFromLastUpdate() { + // get the tree size by currentPeriod - genesisPeriod + const currentPeriod = this.getCurrentPeriod(); + let startPeriod = this.latestPeriod; + + let startCommittee = this.latestCommittee; + + const { syncCommittee, period } = await this.syncProver( + startPeriod, + currentPeriod, + startCommittee + ); + if (period === currentPeriod) { + return syncCommittee; + } + throw new Error("no honest prover found"); + } + protected async syncUpdateVerifyGetCommittee( prevCommittee: Uint8Array[], period: number, @@ -311,9 +345,6 @@ export default class Client { console.error(`Invalid Optimistic Update: ${verify?.reason}`); return null; } - console.log( - `Optimistic update verified for slot ${updateJSON.data.attested_header.beacon.slot}` - ); return this.getExecutionFromBlockRoot( updateJSON.data.attested_header.beacon.slot, updateJSON.data.attested_header.beacon.body_root diff --git a/src/client/rpc/constants.ts b/src/client/rpc/constants.ts new file mode 100644 index 0000000..ffe954d --- /dev/null +++ b/src/client/rpc/constants.ts @@ -0,0 +1,10 @@ +export const ZERO_ADDR = '0x0000000000000000000000000000000000000000'; +// TODO: set the correct gas limit! +export const GAS_LIMIT = '0x1c9c380'; +export const REQUEST_BATCH_SIZE = 10; +export const MAX_SOCKET = 10; +export const EMPTY_ACCOUNT_EXTCODEHASH = + '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470'; +export const MAX_BLOCK_HISTORY = BigInt(256); +export const MAX_BLOCK_FUTURE = BigInt(3); +export const DEFAULT_BLOCK_PARAMETER = 'latest'; diff --git a/src/client/rpc/errors.ts b/src/client/rpc/errors.ts new file mode 100644 index 0000000..350ad0e --- /dev/null +++ b/src/client/rpc/errors.ts @@ -0,0 +1,13 @@ +import { JSONRPCErrorCode, JSONRPCErrorException } from 'json-rpc-2.0'; + +export class InternalError extends JSONRPCErrorException { + constructor(message: string) { + super(message, JSONRPCErrorCode.InternalError); + } +} + +export class InvalidParamsError extends JSONRPCErrorException { + constructor(message: string) { + super(message, JSONRPCErrorCode.InvalidParams); + } +} diff --git a/src/client/rpc/index.ts b/src/client/rpc/index.ts new file mode 100644 index 0000000..1ccadd5 --- /dev/null +++ b/src/client/rpc/index.ts @@ -0,0 +1 @@ +export { VerifyingProvider } from "./provider.js"; diff --git a/src/client/rpc/provider.ts b/src/client/rpc/provider.ts new file mode 100644 index 0000000..967ea7f --- /dev/null +++ b/src/client/rpc/provider.ts @@ -0,0 +1,708 @@ +import _ from "lodash"; +import { Trie } from "@ethereumjs/trie"; +import rlp from "rlp"; +import { Common, Chain } from "@ethereumjs/common"; +import { + Address, + Account, + toType, + bufferToHex, + toBuffer, + TypeOutput, + setLengthLeft, + KECCAK256_NULL_S, +} from "@ethereumjs/util"; +import { VM } from "@ethereumjs/vm"; +import { BlockHeader, Block } from "@ethereumjs/block"; +import { Blockchain } from "@ethereumjs/blockchain"; +import { TransactionFactory } from "@ethereumjs/tx"; +import { + AddressHex, + Bytes32, + RPCTx, + AccountResponse, + CodeResponse, + Bytes, + BlockNumber as BlockOpt, + HexString, + JSONRPCReceipt, + AccessList, + GetProof, +} from "./types.js"; +import { + ZERO_ADDR, + MAX_BLOCK_HISTORY, + MAX_BLOCK_FUTURE, + DEFAULT_BLOCK_PARAMETER, +} from "./constants.js"; +import { headerDataFromWeb3Response, blockDataFromWeb3Response } from "./utils"; + +import { keccak256 } from "ethers"; +import { InternalError, InvalidParamsError } from "./errors.js"; +import { RPC } from "./rpc.js"; + +const bigIntToHex = (n: string | bigint | number): string => + "0x" + BigInt(n).toString(16); + +const emptyAccountSerialize = new Account().serialize(); +export class VerifyingProvider { + common: Common; + vm: VM | null = null; + private blockHashes: { [blockNumberHex: string]: Bytes32 } = {}; + private blockPromises: { + [blockNumberHex: string]: { promise: Promise; resolve: () => void }; + } = {}; + private blockHeaders: { [blockHash: string]: BlockHeader } = {}; + private latestBlockNumber: bigint; + private _methods: Map = new Map( + Object.entries({ + eth_getBalance: this.getBalance, + eth_blockNumber: this.blockNumber, + eth_chainId: this.chainId, + eth_getCode: this.getCode, + eth_getTransactionCount: this.getTransactionCount, + eth_call: this.call, + eth_estimateGas: this.estimateGas, + eth_sendRawTransaction: this.sendRawTransaction, + eth_getTransactionReceipt: this.getTransactionReceipt, + }) + ); + + constructor( + providerURL: string, + blockNumber: bigint | number, + blockHash: Bytes32, + chain: bigint | Chain = Chain.Mainnet + ) { + this._rpc = new RPC({ URL: providerURL }); + this.common = new Common({ + chain, + }); + const _blockNumber = BigInt(blockNumber); + this.latestBlockNumber = _blockNumber; + this.blockHashes[bigIntToHex(_blockNumber)] = blockHash; + } + + private _rpc: RPC; + + get rpc(): RPC { + return this._rpc; + } + + async update(blockHash: Bytes32, blockNumber: bigint) { + const blockNumberHex = bigIntToHex(blockNumber); + if ( + blockNumberHex in this.blockHashes && + this.blockHashes[blockNumberHex] !== blockHash + ) { + console.log( + "Overriding an existing verified blockhash. Possibly the chain had a reorg" + ); + } + const latestBlockNumber = this.latestBlockNumber; + this.latestBlockNumber = blockNumber; + this.blockHashes[blockNumberHex] = blockHash; + if (blockNumber > latestBlockNumber) { + for (let b = latestBlockNumber + BigInt(1); b <= blockNumber; b++) { + const bHex = bigIntToHex(b); + if (bHex in this.blockPromises) { + this.blockPromises[bHex].resolve(); + } + } + } + + await this.getBlockHeader("latest"); + } + + public async rpcMethod(method: string, params: any) { + if (this._methods.has(method)) { + return this._methods.get(method)?.bind(this)(...params); + } + + throw new Error("method not found"); + } + + public rpcMethodSupported(method: string): boolean { + return this._methods.has(method); + } + + private async getBalance( + addressHex: AddressHex, + blockOpt: BlockOpt = DEFAULT_BLOCK_PARAMETER + ) { + const header = await this.getBlockHeader(blockOpt); + const address = Address.fromString(addressHex); + const { result: proof, success } = await this._rpc.request({ + method: "eth_getProof", + params: [addressHex, [], bigIntToHex(header.number)], + }); + if (!success) { + throw new InternalError(`RPC request failed`); + } + const isAccountCorrect = await this.verifyProof( + address, + [], + header.stateRoot, + proof + ); + if (!isAccountCorrect) { + throw new InternalError("Invalid account proof provided by the RPC"); + } + + return bigIntToHex(proof.balance); + } + + private async blockNumber(): Promise { + return bigIntToHex(this.latestBlockNumber); + } + + private async chainId(): Promise { + return bigIntToHex(this.common.chainId()); + } + + private async getCode( + addressHex: AddressHex, + blockOpt: BlockOpt = DEFAULT_BLOCK_PARAMETER + ): Promise { + const header = await this.getBlockHeader(blockOpt); + const res = await this._rpc.requestBatch([ + { + method: "eth_getProof", + params: [addressHex, [], bigIntToHex(header.number)], + }, + { + method: "eth_getCode", + params: [addressHex, bigIntToHex(header.number)], + }, + ]); + + if (res.some((r) => !r.success)) { + throw new InternalError(`RPC request failed`); + } + const [accountProof, code] = [res[0].result, res[1].result]; + + const address = Address.fromString(addressHex); + const isAccountCorrect = await this.verifyProof( + address, + [], + header.stateRoot, + accountProof + ); + if (!isAccountCorrect) { + throw new InternalError(`invalid account proof provided by the RPC`); + } + + const isCodeCorrect = await this.verifyCodeHash( + code, + accountProof.codeHash + ); + if (!isCodeCorrect) { + throw new InternalError( + `code provided by the RPC doesn't match the account's codeHash` + ); + } + + return code; + } + + private async getTransactionCount( + addressHex: AddressHex, + blockOpt: BlockOpt = DEFAULT_BLOCK_PARAMETER + ): Promise { + const header = await this.getBlockHeader(blockOpt); + const address = Address.fromString(addressHex); + const { result: proof, success } = await this._rpc.request({ + method: "eth_getProof", + params: [addressHex, [], bigIntToHex(header.number)], + }); + if (!success) { + throw new InternalError(`RPC request failed`); + } + + const isAccountCorrect = await this.verifyProof( + address, + [], + header.stateRoot, + proof + ); + if (!isAccountCorrect) { + throw new InternalError(`invalid account proof provided by the RPC`); + } + + return bigIntToHex(proof.nonce.toString()); + } + + private async call( + transaction: RPCTx, + blockOpt: BlockOpt = DEFAULT_BLOCK_PARAMETER + ) { + try { + this.validateTx(transaction); + } catch (e: any) { + throw new InvalidParamsError((e as Error).message); + } + + const header = await this.getBlockHeader(blockOpt); + const vm = await this.getVM(transaction, header); + const { + from, + to, + gas: gasLimit, + gasPrice, + maxPriorityFeePerGas, + value, + data, + } = transaction; + + try { + const runCallOpts = { + caller: from ? Address.fromString(from) : undefined, + to: to ? Address.fromString(to) : undefined, + gasLimit: toType(gasLimit, TypeOutput.BigInt), + gasPrice: toType(gasPrice || maxPriorityFeePerGas, TypeOutput.BigInt), + value: toType(value, TypeOutput.BigInt), + data: data ? toBuffer(data) : undefined, + block: { header }, + }; + const { execResult } = await vm.evm.runCall(runCallOpts); + + return bufferToHex(execResult.returnValue); + } catch (error: any) { + throw new InternalError(error.message.toString()); + } + } + + private async estimateGas( + transaction: RPCTx, + blockOpt: BlockOpt = DEFAULT_BLOCK_PARAMETER + ) { + try { + this.validateTx(transaction); + } catch (e) { + throw new InvalidParamsError((e as Error).message); + } + const header = await this.getBlockHeader(blockOpt); + + if (transaction.gas == undefined) { + // If no gas limit is specified use the last block gas limit as an upper bound. + transaction.gas = bigIntToHex(header.gasLimit); + } + + const txType = BigInt( + transaction.maxFeePerGas || transaction.maxPriorityFeePerGas + ? 2 + : transaction.accessList + ? 1 + : 0 + ); + if (txType == BigInt(2)) { + transaction.maxFeePerGas = + transaction.maxFeePerGas || bigIntToHex(header.baseFeePerGas!); + } else { + if ( + transaction.gasPrice == undefined || + BigInt(transaction.gasPrice) === BigInt(0) + ) { + transaction.gasPrice = bigIntToHex(header.baseFeePerGas!); + } + } + + const txData = { + ...transaction, + type: bigIntToHex(txType), + gasLimit: transaction.gas, + }; + const tx = TransactionFactory.fromTxData(txData, { + common: this.common, + freeze: false, + }); + + const vm = await this.getVM(transaction, header); + + // set from address + const from = transaction.from + ? Address.fromString(transaction.from) + : Address.zero(); + tx.getSenderAddress = () => { + return from; + }; + + try { + const { totalGasSpent } = await vm.runTx({ + tx, + skipNonce: true, + skipBalance: true, + skipBlockGasLimitValidation: true, + block: { header } as any, + }); + return bigIntToHex(totalGasSpent); + } catch (error: any) { + throw new InternalError(error.message.toString()); + } + } + + private async sendRawTransaction(signedTx: string): Promise { + // TODO: brodcast tx directly to the mem pool? + const { success } = await this._rpc.request({ + method: "eth_sendRawTransaction", + params: [signedTx], + }); + + if (!success) { + throw new InternalError(`RPC request failed`); + } + + const tx = TransactionFactory.fromSerializedData(toBuffer(signedTx), { + common: this.common, + }); + return bufferToHex(tx.hash()); + } + + private async getTransactionReceipt( + txHash: Bytes32 + ): Promise { + const { result: receipt, success } = await this._rpc.request({ + method: "eth_getTransactionReceipt", + params: [txHash], + }); + if (!(success && receipt)) { + return null; + } + const header = await this.getBlockHeader(receipt.blockNumber); + const block = await this.getBlock(header); + const index = block.transactions.findIndex( + (tx) => bufferToHex(tx.hash()) === txHash.toLowerCase() + ); + if (index === -1) { + throw new InternalError("the recipt provided by the RPC is invalid"); + } + const tx = block.transactions[index]; + + return { + transactionHash: txHash, + transactionIndex: bigIntToHex(index), + blockHash: bufferToHex(block.hash()), + blockNumber: bigIntToHex(block.header.number), + from: tx.getSenderAddress().toString(), + to: tx.to?.toString() ?? null, + cumulativeGasUsed: "0x0", + effectiveGasPrice: "0x0", + gasUsed: "0x0", + contractAddress: null, + logs: [], + logsBloom: "0x0", + status: BigInt(receipt.status) ? "0x1" : "0x0", // unverified!! + }; + } + + private async getVMCopy(): Promise { + if (this.vm === null) { + const blockchain = await Blockchain.create({ common: this.common }); + // path the blockchain to return the correct blockhash + (blockchain as any).getBlock = async (blockId: number) => { + const _hash = toBuffer(await this.getBlockHash(BigInt(blockId))); + return { + hash: () => _hash, + }; + }; + this.vm = await VM.create({ common: this.common, blockchain }); + } + return await this.vm!.copy(); + } + + private async getVM(tx: RPCTx, header: BlockHeader): Promise { + // forcefully set gasPrice to 0 to avoid not enough balance error + const _tx = { + to: tx.to, + from: tx.from ? tx.from : ZERO_ADDR, + data: tx.data, + value: tx.value, + gasPrice: "0x0", + gas: tx.gas ? tx.gas : bigIntToHex(header.gasLimit!), + }; + const { result, success } = await this._rpc.request({ + method: "eth_createAccessList", + params: [_tx, bigIntToHex(header.number)], + }); + + if (!success) { + throw new InternalError(`RPC request failed`); + } + + const accessList = result.accessList as AccessList; + accessList.push({ address: _tx.from, storageKeys: [] }); + if (_tx.to && !accessList.some((a) => a.address.toLowerCase() === _tx.to)) { + accessList.push({ address: _tx.to, storageKeys: [] }); + } + + const vm = await this.getVMCopy(); + await vm.stateManager.checkpoint(); + + const requests = accessList + .map((access) => { + return [ + { + method: "eth_getProof", + params: [ + access.address, + access.storageKeys, + bigIntToHex(header.number), + ], + }, + { + method: "eth_getCode", + params: [access.address, bigIntToHex(header.number)], + }, + ]; + }) + .flat(); + const rawResponse = await this._rpc.requestBatch(requests); + if (rawResponse.some((r: any) => !r.success)) { + throw new InternalError(`RPC request failed`); + } + const responses = _.chunk( + rawResponse.map((r: any) => r.result), + 2 + ) as [AccountResponse, CodeResponse][]; + + for (let i = 0; i < accessList.length; i++) { + const { address: addressHex, storageKeys } = accessList[i]; + const [accountProof, code] = responses[i]; + const { + nonce, + balance, + codeHash, + storageProof: storageAccesses, + } = accountProof; + const address = Address.fromString(addressHex); + + const isAccountCorrect = await this.verifyProof( + address, + storageKeys, + header.stateRoot, + accountProof + ); + if (!isAccountCorrect) { + throw new InternalError(`invalid account proof provided by the RPC`); + } + + const isCodeCorrect = await this.verifyCodeHash(code, codeHash); + if (!isCodeCorrect) { + throw new InternalError( + `code provided by the RPC doesn't match the account's codeHash` + ); + } + + const account = Account.fromAccountData({ + nonce: BigInt(nonce), + balance: BigInt(balance), + codeHash, + }); + + await vm.stateManager.putAccount(address, account); + + for (let storageAccess of storageAccesses) { + await vm.stateManager.putContractStorage( + address, + setLengthLeft(toBuffer(storageAccess.key), 32), + setLengthLeft(toBuffer(storageAccess.value), 32) + ); + } + + if (code !== "0x") + await vm.stateManager.putContractCode(address, toBuffer(code)); + } + await vm.stateManager.commit(); + return vm; + } + private async getBlockHeader(blockOpt: BlockOpt): Promise { + const blockNumber = this.getBlockNumberByBlockOpt(blockOpt); + await this.waitForBlockNumber(blockNumber); + const blockHash = await this.getBlockHash(blockNumber); + return this.getBlockHeaderByHash(blockHash); + } + private getBlockNumberByBlockOpt(blockOpt: BlockOpt): bigint { + // TODO: add support for blockOpts below + if ( + typeof blockOpt === "string" && + ["pending", "earliest", "finalized", "safe"].includes(blockOpt) + ) { + throw new InvalidParamsError(`"pending" is not yet supported`); + } else if (blockOpt === "latest") { + return this.latestBlockNumber; + } else { + const blockNumber = BigInt(blockOpt as any); + if (blockNumber > this.latestBlockNumber + MAX_BLOCK_FUTURE) { + 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}` + ); + } + return blockNumber; + } + } + private async waitForBlockNumber(blockNumber: bigint) { + if (blockNumber <= this.latestBlockNumber) return; + console.log(`waiting for blockNumber ${blockNumber}`); + const blockNumberHex = bigIntToHex(blockNumber); + if (!(blockNumberHex in this.blockPromises)) { + let r: () => void = () => {}; + const p = new Promise((resolve) => { + r = resolve; + }); + this.blockPromises[blockNumberHex] = { + promise: p, + resolve: r, + }; + } + return this.blockPromises[blockNumberHex].promise; + } + + private async getBlockHeaderByHash(blockHash: Bytes32) { + if (!this.blockHeaders[blockHash]) { + const { result: blockInfo, success } = await this._rpc.request({ + method: "eth_getBlockByHash", + params: [blockHash, true], + }); + + if (!success) { + throw new InternalError(`RPC request failed`); + } + + const headerData = headerDataFromWeb3Response(blockInfo); + const header = BlockHeader.fromHeaderData(headerData); + + if (!header.hash().equals(toBuffer(blockHash))) { + throw new InternalError( + `blockhash doesn't match the blockInfo provided by the RPC` + ); + } + this.blockHeaders[blockHash] = header; + } + return this.blockHeaders[blockHash]; + } + + private async verifyProof( + address: Address, + storageKeys: Bytes32[], + stateRoot: Buffer, + proof: GetProof + ): Promise { + const trie = new Trie(); + const key = keccak256(address.toString()); + const expectedAccountRLP = await trie.verifyProof( + stateRoot, + toBuffer(key), + proof.accountProof.map((a) => toBuffer(a)) + ); + const account = Account.fromAccountData({ + nonce: BigInt(proof.nonce), + balance: BigInt(proof.balance), + storageRoot: proof.storageHash, + codeHash: proof.codeHash, + }); + const isAccountValid = account + .serialize() + .equals(expectedAccountRLP ? expectedAccountRLP : emptyAccountSerialize); + if (!isAccountValid) return false; + + for (let i = 0; i < storageKeys.length; i++) { + const sp = proof.storageProof[i]; + const key = keccak256( + bufferToHex(setLengthLeft(toBuffer(storageKeys[i]), 32)) + ); + const expectedStorageRLP = await trie.verifyProof( + toBuffer(proof.storageHash), + toBuffer(key), + sp.proof.map((a) => toBuffer(a)) + ); + const isStorageValid = + (!expectedStorageRLP && sp.value === "0x0") || + (!!expectedStorageRLP && + expectedStorageRLP.equals(Buffer.from(rlp.encode(sp.value)))); + if (!isStorageValid) return false; + } + + return true; + } + private verifyCodeHash(code: Bytes, codeHash: Bytes32): boolean { + return ( + (code === "0x" && codeHash === "0x" + KECCAK256_NULL_S) || + keccak256(code) === codeHash + ); + } + + private validateTx(tx: RPCTx) { + if (tx.gasPrice !== undefined && tx.maxFeePerGas !== undefined) { + throw new Error("Cannot send both gasPrice and maxFeePerGas params"); + } + + if (tx.gasPrice !== undefined && tx.maxPriorityFeePerGas !== undefined) { + throw new Error("Cannot send both gasPrice and maxPriorityFeePerGas"); + } + + if ( + tx.maxFeePerGas !== undefined && + tx.maxPriorityFeePerGas !== undefined && + BigInt(tx.maxPriorityFeePerGas) > BigInt(tx.maxFeePerGas) + ) { + throw new Error( + `maxPriorityFeePerGas (${tx.maxPriorityFeePerGas.toString()}) is bigger than maxFeePerGas (${tx.maxFeePerGas.toString()})` + ); + } + } + private async getBlock(header: BlockHeader) { + const { result: blockInfo, success } = await this._rpc.request({ + method: "eth_getBlockByNumber", + params: [bigIntToHex(header.number), true], + }); + + if (!success) { + throw new InternalError(`RPC request failed`); + } + // TODO: add support for uncle headers; First fetch all the uncles + // add it to the blockData, verify the uncles and use it + const blockData = blockDataFromWeb3Response(blockInfo); + const block = Block.fromBlockData(blockData, { common: this.common }); + + if (!block.header.hash().equals(header.hash())) { + throw new InternalError( + `BN(${header.number}): blockhash doest match the blockData provided by the RPC` + ); + } + + if (!(await block.validateTransactionsTrie())) { + throw new InternalError( + `transactionTree doesn't match the transactions provided by the RPC` + ); + } + + return block; + } + + private async getBlockHash(blockNumber: bigint) { + if (blockNumber > this.latestBlockNumber) + throw new Error("cannot return blockhash for a blocknumber in future"); + // TODO: fetch the blockHeader is batched request + let lastVerifiedBlockNumber = this.latestBlockNumber; + while (lastVerifiedBlockNumber > blockNumber) { + const hash = this.blockHashes[bigIntToHex(lastVerifiedBlockNumber)]; + const header = await this.getBlockHeaderByHash(hash); + lastVerifiedBlockNumber--; + const parentBlockHash = bufferToHex(header.parentHash); + const parentBlockNumberHex = bigIntToHex(lastVerifiedBlockNumber); + if ( + parentBlockNumberHex in this.blockHashes && + this.blockHashes[parentBlockNumberHex] !== parentBlockHash + ) { + console.log( + "Overriding an existing verified blockhash. Possibly the chain had a reorg" + ); + } + this.blockHashes[parentBlockNumberHex] = parentBlockHash; + } + + return this.blockHashes[bigIntToHex(blockNumber)]; + } +} diff --git a/src/client/rpc/rpc.ts b/src/client/rpc/rpc.ts new file mode 100644 index 0000000..f8e12a0 --- /dev/null +++ b/src/client/rpc/rpc.ts @@ -0,0 +1,150 @@ +import NodeCache from "node-cache"; +import { PluginAPI } from "@lumeweb/interface-relay"; +import stringify from "json-stringify-deterministic"; + +export type RPCRequest = { + method: string; + params: any[]; +}; + +export type RPCRequestRaw = RPCRequest & { + jsonrpc: string; + id: string; +}; + +export type RPCResponse = { + success: boolean; + result: any; +}; + +export type ProviderConfig = { + URL: string; + unsupportedMethods?: string[]; + supportBatchRequests?: boolean; + batchSize?: number; +}; + +export class RPC { + get cache(): NodeCache { + return this._cache; + } + private provider: ProviderConfig; + private _cache = new NodeCache({ stdTTL: 60 * 60 * 12 }); + + constructor(provider: ProviderConfig) { + this.provider = provider; + } + + private _pluginApi?: PluginAPI; + + set pluginApi(value: PluginAPI) { + this._pluginApi = value; + } + + async request(request: RPCRequest) { + if (this.provider.unsupportedMethods?.includes(request.method)) { + throw new Error("method not supported by the provider"); + } + return await this._retryRequest(request); + } + + async requestBatch(requests: RPCRequest[]) { + if ( + this.provider?.unsupportedMethods && + requests.some((r) => this.provider.unsupportedMethods!.includes(r.method)) + ) { + throw new Error("method not supported by the provider"); + } + + const res = []; + for (const request of requests) { + const r = await this._retryRequest(request); + res.push(r); + } + return res; + } + + protected async _request(request: RPCRequestRaw): Promise { + try { + const response = await ( + await fetch(this.provider.URL, { + method: "POST", + body: JSON.stringify(request), + }) + ).json(); + + if (response.result) { + const tempRequest = { + method: request.method, + params: request.params, + }; + const hash = this._pluginApi.util.crypto + .createHash(stringify(tempRequest)) + .toString("hex"); + this._cache.set(hash, response); + } + return { + success: !response.error, + result: response.error || response.result, + }; + } catch (e) { + return { + success: false, + result: { message: `request failed: ${e}` }, + }; + } + } + + private async _retryRequest( + _request: RPCRequest, + retry = 5 + ): Promise { + const request = { + ..._request, + jsonrpc: "2.0", + id: this.generateId(), + }; + + for (let i = retry; i > 0; i--) { + const res = await this._request(request); + if (res.success) { + return res; + } else if (i == 1) { + console.error( + `RPC batch request failed after maximum retries: ${JSON.stringify( + request, + null, + 2 + )} ${JSON.stringify(res, null, 2)}` + ); + } + } + throw new Error("RPC request failed"); + } + + private generateId(): string { + return Math.floor(Math.random() * 2 ** 64).toFixed(); + } + + public getCachedRequest(request: RPCRequest): RPCResponse | null { + const hash = this.hashRequest(request); + + if (this.cache.has(hash)) { + this.cache.ttl(hash); + return this.cache.get(hash); + } + + return null; + } + + private hashRequest(request: RPCRequest): string { + const tempRequest = { + method: request.method, + params: request.params, + }; + + return this._pluginApi.util.crypto + .createHash(stringify(tempRequest)) + .toString("hex"); + } +} diff --git a/src/client/rpc/types.ts b/src/client/rpc/types.ts new file mode 100644 index 0000000..273c728 --- /dev/null +++ b/src/client/rpc/types.ts @@ -0,0 +1,63 @@ +import type { GetProof } from "web3-eth"; +import type { BlockNumber } from "web3-core"; +import type { Method } from "web3-core-method"; +import type { JsonTx } from "@ethereumjs/tx"; +export type { GetProof, BlockNumber, Method }; + +export type Bytes = string; +export type Bytes32 = string; +export type AddressHex = string; +export type ChainId = number; +export type HexString = string; + +// Some of the types below are taken from: +// https://github.com/ethereumjs/ethereumjs-monorepo/blob/master/packages/client/lib/rpc/modules/eth.ts + +export type AccessList = { address: AddressHex; storageKeys: Bytes32[] }[]; + +export interface RPCTx { + from?: string; + to?: string; + gas?: string; + gasPrice?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + accessList?: AccessList; + value?: string; + data?: string; +} + +export type AccountResponse = GetProof; +export type CodeResponse = string; + +export type JSONRPCReceipt = { + transactionHash: string; // DATA, 32 Bytes - hash of the transaction. + transactionIndex: string; // QUANTITY - integer of the transactions index position in the block. + blockHash: string; // DATA, 32 Bytes - hash of the block where this transaction was in. + blockNumber: string; // QUANTITY - block number where this transaction was in. + from: string; // DATA, 20 Bytes - address of the sender. + to: string | null; // DATA, 20 Bytes - address of the receiver. null when it's a contract creation transaction. + cumulativeGasUsed: string; // QUANTITY - The total amount of gas used when this transaction was executed in the block. + effectiveGasPrice: string; // QUANTITY - The final gas price per gas paid by the sender in wei. + gasUsed: string; // QUANTITY - The amount of gas used by this specific transaction alone. + 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. + // It also returns either: + root?: string; // DATA, 32 bytes of post-transaction stateroot (pre Byzantium) + status?: string; // QUANTITY, either 1 (success) or 0 (failure) +}; + +export type JSONRPCLog = { + removed: boolean; // TAG - true when the log was removed, due to a chain reorganization. false if it's a valid log. + logIndex: string | null; // QUANTITY - integer of the log index position in the block. null when it's pending. + transactionIndex: string | null; // QUANTITY - integer of the transactions index position log was created from. null when it's pending. + transactionHash: string | null; // DATA, 32 Bytes - hash of the transactions this log was created from. null when it's pending. + blockHash: string | null; // DATA, 32 Bytes - hash of the block where this log was in. null when it's pending. + blockNumber: string | null; // QUANTITY - the block number where this log was in. null when it's pending. + address: string; // DATA, 20 Bytes - address from which this log originated. + data: string; // DATA - contains one or more 32 Bytes non-indexed arguments of the log. + topics: string[]; // Array of DATA - Array of 0 to 4 32 Bytes DATA of indexed log arguments. + // (In solidity: The first topic is the hash of the signature of the event + // (e.g. Deposit(address,bytes32,uint256)), except you declared the event with the anonymous specifier.) +}; diff --git a/src/client/rpc/utils.ts b/src/client/rpc/utils.ts new file mode 100644 index 0000000..a18f632 --- /dev/null +++ b/src/client/rpc/utils.ts @@ -0,0 +1,68 @@ +import { + setLengthLeft, + toBuffer, + bigIntToHex, + bufferToHex, + intToHex, +} from "@ethereumjs/util"; +import { HeaderData, BlockData, Block } from "@ethereumjs/block"; +import { + TxData, + AccessListEIP2930TxData, + FeeMarketEIP1559TxData, + TypedTransaction, +} from "@ethereumjs/tx"; + +const isTruthy = (val: any) => !!val; + +// TODO: fix blockInfo type +export function headerDataFromWeb3Response(blockInfo: any): HeaderData { + return { + parentHash: blockInfo.parentHash, + uncleHash: blockInfo.sha3Uncles, + coinbase: blockInfo.miner, + stateRoot: blockInfo.stateRoot, + transactionsTrie: blockInfo.transactionsRoot, + receiptTrie: blockInfo.receiptsRoot, + logsBloom: blockInfo.logsBloom, + difficulty: BigInt(blockInfo.difficulty), + number: BigInt(blockInfo.number), + gasLimit: BigInt(blockInfo.gasLimit), + gasUsed: BigInt(blockInfo.gasUsed), + timestamp: BigInt(blockInfo.timestamp), + extraData: blockInfo.extraData, + mixHash: (blockInfo as any).mixHash, // some reason the types are not up to date :( + nonce: blockInfo.nonce, + baseFeePerGas: blockInfo.baseFeePerGas + ? BigInt(blockInfo.baseFeePerGas) + : undefined, + }; +} + +export function txDataFromWeb3Response( + txInfo: any +): TxData | AccessListEIP2930TxData | FeeMarketEIP1559TxData { + return { + ...txInfo, + data: txInfo.input, + gasPrice: BigInt(txInfo.gasPrice), + gasLimit: txInfo.gas, + to: isTruthy(txInfo.to) + ? setLengthLeft(toBuffer(txInfo.to), 20) + : undefined, + value: BigInt(txInfo.value), + maxFeePerGas: isTruthy(txInfo.maxFeePerGas) + ? BigInt(txInfo.maxFeePerGas) + : undefined, + maxPriorityFeePerGas: isTruthy(txInfo.maxPriorityFeePerGas) + ? BigInt(txInfo.maxPriorityFeePerGas) + : undefined, + }; +} + +export function blockDataFromWeb3Response(blockInfo: any): BlockData { + return { + header: headerDataFromWeb3Response(blockInfo), + transactions: blockInfo.transactions.map(txDataFromWeb3Response), + }; +} diff --git a/src/client/rpc/validation.ts b/src/client/rpc/validation.ts new file mode 100644 index 0000000..9b43892 --- /dev/null +++ b/src/client/rpc/validation.ts @@ -0,0 +1,139 @@ +import { InvalidParamsError } from './errors'; + +// Most of the validations are taken from: +// https://github.com/ethereumjs/ethereumjs-monorepo/blob/master/packages/client/lib/rpc/validation.ts + +/** + * @memberof module:rpc + */ +export const validators = { + /** + * address validator to ensure has `0x` prefix and 20 bytes length + * @param params parameters of method + * @param index index of parameter + */ + address(params: any[], index: number) { + this.hex(params, index); + + const address = params[index].substr(2); + + if (!/^[0-9a-fA-F]+$/.test(address) || address.length !== 40) { + throw new InvalidParamsError( + `invalid argument ${index}: invalid address`, + ); + } + }, + + /** + * hex validator to ensure has `0x` prefix + * @param params parameters of method + * @param index index of parameter + */ + hex(params: any[], index: number) { + if (typeof params[index] !== 'string') { + throw new InvalidParamsError( + `invalid argument ${index}: argument must be a hex string`, + ); + } + + if (params[index].substr(0, 2) !== '0x') { + throw new InvalidParamsError( + `invalid argument ${index}: hex string without 0x prefix`, + ); + } + }, + + /** + * hex validator to validate block hash + * @param params parameters of method + * @param index index of parameter + */ + blockHash(params: any[], index: number) { + this.hex(params, index); + + const blockHash = params[index].substring(2); + + if (!/^[0-9a-fA-F]+$/.test(blockHash) || blockHash.length !== 64) { + throw new InvalidParamsError( + `invalid argument ${index}: invalid block hash`, + ); + } + }, + /** + * validator to ensure valid block integer or hash, or string option ["latest", "earliest", "pending"] + * @param params parameters of method + * @param index index of parameter + */ + blockOption(params: any[], index: number) { + const blockOption = params[index]; + + if (typeof blockOption !== 'string') { + throw new InvalidParamsError( + `invalid argument ${index}: argument must be a string`, + ); + } + + try { + if (['latest', 'earliest', 'pending'].includes(blockOption)) { + return; + } + return this.hex([blockOption], 0); + } catch (e) { + throw new InvalidParamsError( + `invalid argument ${index}: block option must be a valid hex block number, or "latest", "earliest" or "pending"`, + ); + } + }, + + /** + * bool validator to check if type is boolean + * @param params parameters of method + * @param index index of parameter + */ + bool(params: any[], index: number) { + if (typeof params[index] !== 'boolean') { + throw new InvalidParamsError( + `invalid argument ${index}: argument is not boolean`, + ); + } + }, + + /** + * params length validator + * @param params parameters of method + * @requiredParamsLength required length of parameters + */ + paramsLength( + params: any[], + requiredParamsCount: number, + maxParamsCount: number = requiredParamsCount, + ) { + if (params.length < requiredParamsCount || params.length > maxParamsCount) { + throw new InvalidParamsError( + `missing value for required argument ${params.length}`, + ); + } + }, + + transaction(params: any[], index: number) { + const tx = params[index]; + + if (typeof tx !== 'object') { + throw new InvalidParamsError( + `invalid argument ${index}: argument must be an object`, + ); + } + + // validate addresses + for (const field of [tx.to, tx.from]) { + // TODO: the below will create an error with incorrect index if the tx is not at index 0 + if (field !== undefined) this.address([field], 0); + } + + // validate hex + for (const field of [tx.gas, tx.gasPrice, tx.value, tx.data]) { + // TODO: the below will create an error with incorrect index if the tx is not at index 0 + if (field !== undefined) this.hex([field], 0); + } + }, +}; diff --git a/src/index.ts b/src/index.ts index 3381c0d..8b11804 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,12 @@ import type { Plugin, PluginAPI } from "@lumeweb/interface-relay"; import fetch, { Request, RequestInit } from "node-fetch"; import NodeCache from "node-cache"; -import stringify from "json-stringify-deterministic"; import { Client, Prover } from "./client/index.js"; import { MemoryStore } from "./client/memory-store.js"; +import { computeSyncPeriodAtSlot } from "@lodestar/light-client/utils"; +import { toHexString } from "@chainsafe/ssz"; +import { DEFAULT_BATCH_SIZE } from "./client/constants.js"; +import { handleGETRequest } from "./client/utils.js"; const EXECUTION_RPC_URL = "https://g.w.lavanet.xyz:443/gateway/eth/rpc-http/f195d68175eb091ec1f71d00f8952b85"; @@ -76,8 +79,10 @@ const plugin: Plugin = { async plugin(api: PluginAPI): Promise { const prover = new Prover(CONSENSUS_RPC_URL); const store = new MemoryStore(); - client = new Client(prover, store, CONSENSUS_RPC_URL); + client = new Client(prover, store, CONSENSUS_RPC_URL, EXECUTION_RPC_URL); await client.sync(); + client.provider.rpc.pluginApi = api; + const provider = client.provider; api.registerMethod("consensus_committee_hashes", { cacheable: false, @@ -160,32 +165,21 @@ const plugin: Plugin = { api.registerMethod("execution_request", { cacheable: false, async handler(request: ExecutionRequest): Promise { - const tempRequest = { - method: request.method, - ...request.params, - }; - const hash = api.util.crypto - .createHash(stringify(tempRequest)) - .toString("hex"); + const cache = provider.rpc.getCachedRequest(request); - if (RPC_CACHE.has(hash)) { - RPC_CACHE.ttl(hash); - return RPC_CACHE.get(hash); + if (cache) { + return cache; } - try { - let resp = await doFetch(EXECUTION_RPC_URL, { - method: "POST", - body: JSON.stringify(request), - }); - if (resp && resp.result) { - RPC_CACHE.set(hash, resp); - } - - return resp; - } catch (e) { - return e; + if (provider.rpcMethodSupported(request.method)) { + await provider.rpcMethod(request.method, request.params); + } else { + await provider.rpc.request(request); } + let ret = provider.rpc.getCachedRequest(request); + + // @ts-ignore + return { ...ret, id: request.id ?? ret.id }; }, }); @@ -211,12 +205,30 @@ const plugin: Plugin = { const block = request?.block; - if (BigInt(block) > BigInt(client.latestPeriod)) { + if ( + BigInt(block) > BigInt(client.latestPeriod) || + !client.blockHashCache.has(request.block) + ) { await client.sync(); } - if (!client.blockHashCache.has(request.block)) { - throw new Error("block not found"); + if ( + !client.blockHashCache.has(request.block) && + !client.blockCache.has(request.block) + ) { + let state; + try { + const period = computeSyncPeriodAtSlot(request.block); + state = await prover.getSyncUpdate( + period, + period, + DEFAULT_BATCH_SIZE + ); + await client.getExecutionFromBlockRoot( + request.block as any, + toHexString(state.attestedHeader.beacon.bodyRoot) + ); + } catch {} } if (client.blockCache.has(request.block)) { @@ -225,12 +237,12 @@ const plugin: Plugin = { return client.blockCache.get(request.block); } - await client.getExecutionFromBlockRoot( - request.block as any, - client.blockHashCache.get(request.block) + const ret = await handleGETRequest( + `${CONSENSUS_RPC_URL}/eth/v2/beacon/blocks/${request.block}` ); + client.blockCache.set(request.block, ret); - return client.blockCache.get(request.block); + return ret; }, }); },