*Add support for running verified calls with EVM

*Add caching of consensus blocks
*Add caching of RPC calls
This commit is contained in:
Derrick Hammer 2023-03-29 04:58:17 -04:00
parent 236338485b
commit a12fd44e2b
Signed by: pcfreak30
GPG Key ID: C997C339BE476FF2
10 changed files with 1249 additions and 54 deletions

View File

@ -32,6 +32,7 @@ import * as bellatrix from "@lodestar/types/bellatrix";
import { init } from "@chainsafe/bls/switchable"; import { init } from "@chainsafe/bls/switchable";
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import { Mutex } from "async-mutex"; import { Mutex } from "async-mutex";
import { VerifyingProvider } from "./rpc/index.js";
export default class Client { export default class Client {
latestCommittee?: Uint8Array[]; latestCommittee?: Uint8Array[];
@ -46,13 +47,25 @@ export default class Client {
private boot = false; private boot = false;
private beaconChainAPIURL: string; private beaconChainAPIURL: string;
private store: IStore; private store: IStore;
private syncMutex = new Mutex(); 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.prover = prover;
this.store = store; this.store = store;
this.beaconChainAPIURL = beaconUrl; this.beaconChainAPIURL = beaconUrl;
this.rpcUrl = rpcUrl;
}
private _provider?: VerifyingProvider;
get provider(): VerifyingProvider {
return this._provider;
} }
private _latestPeriod: number = -1; private _latestPeriod: number = -1;
@ -82,10 +95,21 @@ export default class Client {
await this._sync(); await this._sync();
if (!this.boot) { if (!this._provider) {
this.subscribe(); const { blockhash, blockNumber } = await this.getNextValidExecutionInfo();
this.boot = true; 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( 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 { optimisticUpdateFromJSON(update: any): OptimisticUpdate {
return altair.ssz.LightClientOptimisticUpdate.fromJson(update); 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._blockCache.set(slot as any, res);
this._blockHashCache.set(slot as any, expectedBlockRoot); this._blockHashCache.set(slot as any, expectedBlockRoot);
@ -235,7 +247,11 @@ export default class Client {
const currentPeriod = this.getCurrentPeriod(); const currentPeriod = this.getCurrentPeriod();
if (currentPeriod > this._latestPeriod) { if (currentPeriod > this._latestPeriod) {
if (!this.boot) {
this.latestCommittee = await this.syncFromGenesis(); this.latestCommittee = await this.syncFromGenesis();
} else {
this.latestCommittee = await this.syncFromLastUpdate();
}
this._latestPeriod = currentPeriod; this._latestPeriod = currentPeriod;
} }
@ -263,6 +279,24 @@ export default class Client {
throw new Error("no honest prover found"); 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( protected async syncUpdateVerifyGetCommittee(
prevCommittee: Uint8Array[], prevCommittee: Uint8Array[],
period: number, period: number,
@ -311,9 +345,6 @@ export default class Client {
console.error(`Invalid Optimistic Update: ${verify?.reason}`); console.error(`Invalid Optimistic Update: ${verify?.reason}`);
return null; return null;
} }
console.log(
`Optimistic update verified for slot ${updateJSON.data.attested_header.beacon.slot}`
);
return this.getExecutionFromBlockRoot( return this.getExecutionFromBlockRoot(
updateJSON.data.attested_header.beacon.slot, updateJSON.data.attested_header.beacon.slot,
updateJSON.data.attested_header.beacon.body_root updateJSON.data.attested_header.beacon.body_root

View File

@ -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';

13
src/client/rpc/errors.ts Normal file
View File

@ -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);
}
}

1
src/client/rpc/index.ts Normal file
View File

@ -0,0 +1 @@
export { VerifyingProvider } from "./provider.js";

708
src/client/rpc/provider.ts Normal file
View File

@ -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<void>; resolve: () => void };
} = {};
private blockHeaders: { [blockHash: string]: BlockHeader } = {};
private latestBlockNumber: bigint;
private _methods: Map<string, Function> = new Map<string, Function>(
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<HexString> {
return bigIntToHex(this.latestBlockNumber);
}
private async chainId(): Promise<HexString> {
return bigIntToHex(this.common.chainId());
}
private async getCode(
addressHex: AddressHex,
blockOpt: BlockOpt = DEFAULT_BLOCK_PARAMETER
): Promise<HexString> {
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<HexString> {
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<string> {
// 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<JSONRPCReceipt | null> {
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<VM> {
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<VM> {
// 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<BlockHeader> {
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<void>((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<boolean> {
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)];
}
}

150
src/client/rpc/rpc.ts Normal file
View File

@ -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<RPCResponse> {
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<RPCResponse> {
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");
}
}

63
src/client/rpc/types.ts Normal file
View File

@ -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.)
};

68
src/client/rpc/utils.ts Normal file
View File

@ -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),
};
}

View File

@ -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);
}
},
};

View File

@ -1,9 +1,12 @@
import type { Plugin, PluginAPI } from "@lumeweb/interface-relay"; import type { Plugin, PluginAPI } from "@lumeweb/interface-relay";
import fetch, { Request, RequestInit } from "node-fetch"; import fetch, { Request, RequestInit } from "node-fetch";
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import stringify from "json-stringify-deterministic";
import { Client, Prover } from "./client/index.js"; import { Client, Prover } from "./client/index.js";
import { MemoryStore } from "./client/memory-store.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 = const EXECUTION_RPC_URL =
"https://g.w.lavanet.xyz:443/gateway/eth/rpc-http/f195d68175eb091ec1f71d00f8952b85"; "https://g.w.lavanet.xyz:443/gateway/eth/rpc-http/f195d68175eb091ec1f71d00f8952b85";
@ -76,8 +79,10 @@ const plugin: Plugin = {
async plugin(api: PluginAPI): Promise<void> { async plugin(api: PluginAPI): Promise<void> {
const prover = new Prover(CONSENSUS_RPC_URL); const prover = new Prover(CONSENSUS_RPC_URL);
const store = new MemoryStore(); 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(); await client.sync();
client.provider.rpc.pluginApi = api;
const provider = client.provider;
api.registerMethod("consensus_committee_hashes", { api.registerMethod("consensus_committee_hashes", {
cacheable: false, cacheable: false,
@ -160,32 +165,21 @@ const plugin: Plugin = {
api.registerMethod("execution_request", { api.registerMethod("execution_request", {
cacheable: false, cacheable: false,
async handler(request: ExecutionRequest): Promise<object> { async handler(request: ExecutionRequest): Promise<object> {
const tempRequest = { const cache = provider.rpc.getCachedRequest(request);
method: request.method,
...request.params,
};
const hash = api.util.crypto
.createHash(stringify(tempRequest))
.toString("hex");
if (RPC_CACHE.has(hash)) { if (cache) {
RPC_CACHE.ttl(hash); return cache;
return RPC_CACHE.get(hash);
} }
try { if (provider.rpcMethodSupported(request.method)) {
let resp = await doFetch(EXECUTION_RPC_URL, { await provider.rpcMethod(request.method, request.params);
method: "POST", } else {
body: JSON.stringify(request), await provider.rpc.request(request);
});
if (resp && resp.result) {
RPC_CACHE.set(hash, resp);
} }
let ret = provider.rpc.getCachedRequest(request);
return resp; // @ts-ignore
} catch (e) { return { ...ret, id: request.id ?? ret.id };
return e;
}
}, },
}); });
@ -211,12 +205,30 @@ const plugin: Plugin = {
const block = request?.block; const block = request?.block;
if (BigInt(block) > BigInt(client.latestPeriod)) { if (
BigInt(block) > BigInt(client.latestPeriod) ||
!client.blockHashCache.has(request.block)
) {
await client.sync(); await client.sync();
} }
if (!client.blockHashCache.has(request.block)) { if (
throw new Error("block not found"); !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)) { if (client.blockCache.has(request.block)) {
@ -225,12 +237,12 @@ const plugin: Plugin = {
return client.blockCache.get(request.block); return client.blockCache.get(request.block);
} }
await client.getExecutionFromBlockRoot( const ret = await handleGETRequest(
request.block as any, `${CONSENSUS_RPC_URL}/eth/v2/beacon/blocks/${request.block}`
client.blockHashCache.get(request.block)
); );
client.blockCache.set(request.block, ret);
return client.blockCache.get(request.block); return ret;
}, },
}); });
}, },