diff --git a/utils/README.md b/utils/README.md new file mode 100644 index 0000000..89e03bf --- /dev/null +++ b/utils/README.md @@ -0,0 +1,27 @@ +# Validator Inclusion Proof Generator + +This script generates validator inclusion proofs for the Ethereum beacon chain. + +## Setup Instructions + +1. Install dependencies: + ``` + yarn install + ``` + +2. Set the Beacon Node URL as an environment variable: + + ``` + export BEACON_NODE_URL=https://your-beacon-node-url + ``` + +3. Run the script: + ``` + node validatorProofs.js + ``` + +## Usage + +The script is currently set up to generate a validator inclusion proof for validator 912203 at slot 9000000. You can modify these values in the `validatorProofs.js` file to generate proofs for different validators and slots. + +Example output will be logged to the console, showing the generated inclusion proof. \ No newline at end of file diff --git a/utils/libs/beaconUtils.js b/utils/libs/beaconUtils.js new file mode 100644 index 0000000..f1c840a --- /dev/null +++ b/utils/libs/beaconUtils.js @@ -0,0 +1,244 @@ +import { getClient } from "@lodestar/api"; +import { config } from "@lodestar/config/default"; +import { createHash } from "node:crypto"; +import { ssz } from "@lodestar/types"; +import { + concatGindices, + createProof, + ProofType, +} from "@chainsafe/persistent-merkle-tree"; +import BN from "bn.js"; + +const BeaconState = ssz.deneb.BeaconState; +const BeaconBlock = ssz.deneb.BeaconBlock; + +export async function createClient() { + const beaconNodeUrl = process.env.BEACON_NODE_URL; + const client = getClient( + { baseUrl: beaconNodeUrl, timeoutMs: 60_000 }, + { config } + ); + + { + let r = await client.beacon.getGenesis(); + if (!r.ok) { + throw r.error; + } + + client.beacon.genesisTime = r.response.data.genesisTime; + } + + { + let r = await client.config.getSpec(); + if (!r.ok) { + throw r.error; + } + + client.beacon.secsPerSlot = r.response.data.SECONDS_PER_SLOT; + } + + client.slotToTS = (slot) => { + return client.beacon.genesisTime + slot * client.beacon.secsPerSlot; + }; + + return client; +} + +// modified version of https://github.com/ethereum/go-ethereum/blob/master/beacon/merkle/merkle.go +export function verifyProofAndGetValidatorsRoot(root, index, proof, value) { + let buf = value; + let validatorsRoot; + + proof.forEach((p, i) => { + const hasher = createHash("sha256"); + if (index % 2n == 0n) { + hasher.update(buf); + hasher.update(p); + } else { + hasher.update(p); + hasher.update(buf); + } + if (i == 41) { + validatorsRoot = toHex(buf); + } + buf = hasher.digest(); + index >>= 1n; + if (index == 0n) { + throw new Error("branch has extra item"); + } + }); + + if (index != 1n) { + throw new Error("branch is missing items"); + } + + if (toHex(root) != toHex(buf)) { + throw new Error("proof is not valid"); + } + + return validatorsRoot; +} + +export function toHex(t) { + return "0x" + Buffer.from(t).toString("hex"); +} + +export function log2(n) { + return Math.ceil(Math.log2(Number(n))) || 1; +} + +export function hexToBuffer(hexStr, length) { + let str = hexStr.startsWith("0x") ? hexStr.slice(2) : hexStr; + if (str.length % 2 !== 0) { + str = "0" + str; + } + const buf = Buffer.from(str, "hex"); + if (length && buf.length !== length) { + throw new Error(`Expected buffer length ${length}, got ${buf.length}`); + } + return buf; +} + +export function numberToLEBuffer(value, byteLength) { + const bn = new BN(value.toString(), 10); + const buf = bn.toArrayLike(Buffer, "le", byteLength); + return buf; +} + +export function validatorToChunks(validator) { + const chunks = []; + + const zeros16 = Buffer.alloc(16, 0); + const concatenated = Buffer.concat([ + hexToBuffer(validator.pubkey, 48), + zeros16, + ]); + // chunks[0]: hash of pubkey (Bytes32) + chunks[0] = "0x" + createHash("sha256").update(concatenated).digest("hex"); + + // chunks[1]: withdrawal_credentials (Bytes32) + let withdrawalBuf = hexToBuffer(validator.withdrawal_credentials, 32); + chunks[1] = "0x" + withdrawalBuf.toString("hex"); + + // chunks[2]: effective_balance (uint64), little-endian, padded to 32 bytes + let effectiveBalanceBuf = numberToLEBuffer(validator.effective_balance, 8); + chunks[2] = + "0x" + + Buffer.concat([effectiveBalanceBuf, Buffer.alloc(24)]).toString("hex"); + + // chunks[3]: slashed (boolean), 1 byte, padded to 32 bytes + let slashedByte = validator.slashed + ? Buffer.from([0x01]) + : Buffer.from([0x00]); + chunks[3] = + "0x" + Buffer.concat([slashedByte, Buffer.alloc(31)]).toString("hex"); + + // chunks[4]: activation_eligibility_epoch (uint64) + let activationEligibilityEpochBuf = numberToLEBuffer( + validator.activation_eligibility_epoch, + 8 + ); + chunks[4] = + "0x" + + Buffer.concat([activationEligibilityEpochBuf, Buffer.alloc(24)]).toString( + "hex" + ); + + // chunks[5]: activation_epoch (uint64) + let activationEpochBuf = numberToLEBuffer(validator.activation_epoch, 8); + chunks[5] = + "0x" + + Buffer.concat([activationEpochBuf, Buffer.alloc(24)]).toString("hex"); + + // chunks[6]: exit_epoch (uint64) + let exitEpochBuf = numberToLEBuffer(validator.exit_epoch, 8); + chunks[6] = + "0x" + Buffer.concat([exitEpochBuf, Buffer.alloc(24)]).toString("hex"); + + // chunks[7]: withdrawable_epoch (uint64) + let withdrawableEpochBuf = numberToLEBuffer(validator.withdrawable_epoch, 8); + chunks[7] = + "0x" + + Buffer.concat([withdrawableEpochBuf, Buffer.alloc(24)]).toString("hex"); + + return chunks; +} + + +export async function generateValidatorInclusionProof(slot, validatorIndex) { + const client = await createClient(); + + let r; + + r = await client.debug.getStateV2(slot, "ssz"); + if (!r.ok) { + throw r.error; + } + + const stateView = BeaconState.deserializeToView(r.response); + + r = await client.beacon.getBlockV2(slot); + if (!r.ok) { + throw r.error; + } + + const blockView = BeaconBlock.toView(r.response.data.message); + const blockRoot = blockView.hashTreeRoot(); + + const tree = blockView.tree.clone(); + // Patching the tree by attaching the state in the `stateRoot` field of the block. + tree.setNode(blockView.type.getPropertyGindex("stateRoot"), stateView.node); + // Create a proof for the state of the validator against the block. + const gI = concatGindices([ + blockView.type.getPathInfo(["stateRoot"]).gindex, + stateView.type.getPathInfo(["validators", validatorIndex]).gindex, + ]); + const p = createProof(tree.rootNode, { type: ProofType.single, gindex: gI }); + + // Sanity check: verify gIndex and proof match. + const validatorsRoot = verifyProofAndGetValidatorsRoot( + blockRoot, + gI, + p.witnesses, + stateView.validators.get(validatorIndex).hashTreeRoot() + ); + + // Since EIP-4788 stores parentRoot, we have to find the descendant block of + // the block from the state. + r = await client.beacon.getBlockHeaders({ parentRoot: blockRoot }); + if (!r.ok) { + throw r.error; + } + + const nextBlock = r.response.data[0]?.header; + if (!nextBlock) { + throw new Error("No block to fetch timestamp from"); + } + + const validatorChunks = validatorToChunks( + stateView.validators.type.elementType.toJson( + stateView.validators.get(validatorIndex) + ) + ); + + const proofArray = p.witnesses.map(toHex); + const validatorProof = proofArray.slice(0, 41); // First 41 items + const beaconStateProof = proofArray.slice(41, 46); // Next 5 items + const beaconBlockProofForState = proofArray.slice(46, 49); // Next 3 items + const beaconBlockProofForProposerIndex = []; + + // Construct the InclusionProof object + const inclusionProof = { + validator: validatorChunks, // bytes32[8] + validatorIndex: validatorIndex, // uint256 + validatorProof: validatorProof, // bytes32[] + validatorsRoot: validatorsRoot, // bytes32 + beaconStateProof: beaconStateProof, // bytes32[] + beaconStateRoot: toHex(blockView.stateRoot), // bytes32 + beaconBlockProofForState: beaconBlockProofForState, // bytes32[] + beaconBlockProofForProposerIndex: beaconBlockProofForProposerIndex, // bytes32[] + timestamp: client.slotToTS(slot), // uint256 + }; + + return inclusionProof; +} diff --git a/utils/package.json b/utils/package.json new file mode 100644 index 0000000..7523c27 --- /dev/null +++ b/utils/package.json @@ -0,0 +1,18 @@ +{ + "name": "unifi-utils", + "version": "1.0.0", + "description": "", + "main": "index.js", + "author": "", + "license": "MIT", + "type": "module", + "dependencies": { + "@chainsafe/persistent-merkle-tree": "^0.6.1", + "@chainsafe/ssz": "^0.11.1", + "@lodestar/api": "1.11.0", + "@lodestar/config": "^1.11.0", + "@lodestar/params": "^1.11.0", + "@lodestar/types": "^1.11.0", + "bn.js": "^5.2.1" + } +} diff --git a/utils/scripts/validatorProofs.js b/utils/scripts/validatorProofs.js new file mode 100644 index 0000000..b68cb2c --- /dev/null +++ b/utils/scripts/validatorProofs.js @@ -0,0 +1,5 @@ + +import { generateValidatorInclusionProof} from '../libs/beaconUtils.js'; + +// example of how to generate a validator inclusion proof for validator 912203 at slot 9000000 +generateValidatorInclusionProof(9000000, 912203).then(console.log).catch(console.error);