Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 91 additions & 29 deletions bindings/napi/BeaconStateView.zig
Original file line number Diff line number Diff line change
Expand Up @@ -608,40 +608,43 @@ pub fn isExecutionStateType(self: *const BeaconStateView) !js.Boolean {
return js.Boolean.from(fork_seq.gte(.bellatrix));
}

/// Check if the merge transition is complete.
pub fn isExecutionEnabled(self: *const BeaconStateView, fork_name_value: js.String, signed_block_bytes: js.Uint8Array) !js.Boolean {
/// Check whether execution is enabled for the given Lodestar-shaped block object.
///
/// For normal post-merge operation this short-circuits from state alone and does
/// not inspect `block`. The block object is only read for the historical pre-merge
/// Bellatrix case, where execution is enabled iff the block carries the first
/// non-default execution payload.
pub fn isExecutionEnabled(self: *const BeaconStateView, block: js.Value) !js.Boolean {
const cached_state = try self.requireState();
const fork_seq = cached_state.state.forkSeq();
if (fork_seq.lt(.bellatrix)) return js.Boolean.from(false);

var fork_name_buf: [16]u8 = undefined;
const fork_name = try fork_name_value.toSlice(&fork_name_buf);
const fork_seq = c.ForkSeq.fromName(fork_name);
const merge_complete: bool = switch (fork_seq) {
inline .bellatrix, .capella, .deneb, .electra, .fulu => |f| st.isMergeTransitionComplete(f, cached_state.state.castToFork(f)),
else => unreachable,
};
Comment on lines +622 to +625

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This line exceeds the 100-column limit specified in the Repository Style Guide (Rule 400). Please wrap the switch arm to keep it under 100 columns.

    const merge_complete: bool = switch (fork_seq) {
        inline .bellatrix, .capella, .deneb, .electra, .fulu => |f| st.isMergeTransitionComplete(
            f,
            cached_state.state.castToFork(f),
        ),
        else => unreachable,
    };
References
  1. Rule 400: Hard limit all line lengths, without exception, to at most 100 columns. (link)

if (merge_complete) return js.Boolean.from(true);

const bytes = try signed_block_bytes.toSlice();
const signed_block = try AnySignedBeaconBlock.deserialize(
allocator,
.full,
fork_seq,
bytes,
);
defer signed_block.deinit(allocator);
if (fork_seq != .bellatrix) return js.Boolean.from(false);

if (signed_block.forkSeq() != cached_state.state.forkSeq()) {
return throwNullAs(js.Boolean, "FORK_MISMATCH", "Fork of signed block does not match state fork");
}
// After the above check, we reach the slow path: pre-merge Bellatrix.
// Walk the JS block into a native ExecutionPayload to compare against `default_value`.
const block_raw = block.toValue();
if (try block_raw.typeof() != .object) return error.InvalidBlockObject;

const result = switch (cached_state.state.forkSeq()) {
inline else => |f| switch (signed_block.blockType()) {
inline else => |bt| if (comptime (bt == .blinded and f.lt(.bellatrix)) or (bt == .blinded and f.gte(.gloas))) {
return error.InvalidBlockTypeForFork;
} else st.isExecutionEnabled(
f,
cached_state.state.castToFork(f),
bt,
signed_block.beaconBlock().castToFork(bt, f),
),
},
};
return js.Boolean.from(result);
const body = try (try block_raw.getNamedProperty("body")).coerceToObject();

// Lodestar treats blinded pre-merge Bellatrix blocks as not-yet-merged: the state's
// execution payload header is still default, so the block doesn't kick off the transition.
if (try body.hasNamedProperty("executionPayloadHeader")) return js.Boolean.from(false);

const payload_js = try (try body.getNamedProperty("executionPayload")).coerceToObject();
var payload: ct.bellatrix.ExecutionPayload.Type = ct.bellatrix.ExecutionPayload.default_value;
defer ct.bellatrix.ExecutionPayload.deinit(allocator, &payload);
try executionPayloadFromJs(payload_js, &payload);

const is_default = ct.bellatrix.ExecutionPayload.equals(&payload, &ct.bellatrix.ExecutionPayload.default_value);
return js.Boolean.from(!is_default);
}

/// Check if the merge transition is complete.
Expand Down Expand Up @@ -1059,3 +1062,62 @@ fn optionalBool(options: ?js.Value, name: [:0]const u8, default_value: bool) !bo
}
return default_value;
}

/// Populate a native Bellatrix `ExecutionPayload.Type` from a JS object with the Lodestar
/// shape. Caller must `ct.bellatrix.ExecutionPayload.deinit(allocator, out)` to free
/// `extra_data` and `transactions`.
fn executionPayloadFromJs(payload: napi.Value, out: *ct.bellatrix.ExecutionPayload.Type) !void {
try readByteArrayInto(payload, "parentHash", &out.parent_hash);
try readByteArrayInto(payload, "feeRecipient", &out.fee_recipient);
try readByteArrayInto(payload, "stateRoot", &out.state_root);
try readByteArrayInto(payload, "receiptsRoot", &out.receipts_root);
try readByteArrayInto(payload, "logsBloom", &out.logs_bloom);
try readByteArrayInto(payload, "prevRandao", &out.prev_randao);
try readByteArrayInto(payload, "blockHash", &out.block_hash);

out.block_number = @intCast(try (try payload.getNamedProperty("blockNumber")).getValueInt64());
out.gas_limit = @intCast(try (try payload.getNamedProperty("gasLimit")).getValueInt64());
out.gas_used = @intCast(try (try payload.getNamedProperty("gasUsed")).getValueInt64());
out.timestamp = @intCast(try (try payload.getNamedProperty("timestamp")).getValueInt64());

out.base_fee_per_gas = try readBigintU256(try payload.getNamedProperty("baseFeePerGas"));

const extra_data = try payload.getNamedProperty("extraData");
const extra_data_info = try extra_data.getTypedarrayInfo();
if (extra_data_info.array_type != .uint8) return error.InvalidExtraData;
try out.extra_data.appendSlice(allocator, extra_data_info.data);

const transactions = try payload.getNamedProperty("transactions");
const tx_count = try transactions.getArrayLength();
try out.transactions.ensureTotalCapacity(allocator, tx_count);
var i: u32 = 0;
while (i < tx_count) : (i += 1) {
const tx_value = try transactions.getElement(i);
const tx_info = try tx_value.getTypedarrayInfo();
if (tx_info.array_type != .uint8) return error.InvalidTransaction;
var tx: std.ArrayListUnmanaged(u8) = .empty;
errdefer tx.deinit(allocator);
try tx.appendSlice(allocator, tx_info.data);
out.transactions.appendAssumeCapacity(tx);
}
}

fn readByteArrayInto(parent: napi.Value, comptime field: [:0]const u8, out: []u8) !void {
const value = try parent.getNamedProperty(field);
const info = try value.getTypedarrayInfo();
if (info.array_type != .uint8) return error.InvalidByteArrayField;
if (info.data.len != out.len) return error.InvalidByteArrayLength;
@memcpy(out, info.data);
}

/// Read a JS bigint into u256. Consensus-types bigints are spec-defined unsigned, so we pass
/// `null` for `sign_bit` and skip the sign check entirely.
fn readBigintU256(value: napi.Value) !u256 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this has to be fixed on the zapi level first since casting a u1 to *c_int is UB due to alignment

var words: [4]u64 = .{ 0, 0, 0, 0 };
const got = try value.getValueBigintWords(null, &words);
var result: u256 = 0;
for (got, 0..) |word, i| {
result |= @as(u256, word) << @intCast(i * 64);
}
return result;
}
Comment on lines +1115 to +1123

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In readBigintU256, if the JS bigint is larger than 256 bits, napi_get_value_bigint_words will write the first 4 words and update word_count to the total number of words required (which will be greater than 4).

Because the loop condition uses @min(word_count, 4), any words beyond the 4th will be silently ignored, resulting in silent truncation of the bigint. To prevent silent truncation and potential consensus/correctness issues, we should explicitly check if word_count > 4 and return an error (e.g., error.BigintOverflow).

fn readBigintU256(value: napi.Value) !u256 {
    var sign_bit: c_int = 0;
    var word_count: usize = 4;
    var words: [4]u64 = .{ 0, 0, 0, 0 };
    try napi.status.check(napi.c.napi_get_value_bigint_words(
        value.env,
        value.value,
        &sign_bit,
        &word_count,
        &words,
    ));
    if (sign_bit != 0) return error.NegativeBigint;
    if (word_count > 4) return error.BigintOverflow;
    var result: u256 = 0;
    var i: usize = 0;
    while (i < word_count) : (i += 1) {
        result |= @as(u256, words[i]) << @intCast(i * 64);
    }
    return result;
}

42 changes: 38 additions & 4 deletions bindings/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,33 @@ interface ExecutionPayloadHeader {
excessBlobGas?: number; // deneb+
}

/*
* We don't need *all* the fields to check if a block
* is a pre-merge or a merge transition block, so we just
* have a minimum interface that is like a `BeaconBlock`.
*/
interface BeaconBlockLike {
body: {
executionPayload?: {
parentHash: Uint8Array;
feeRecipient: Uint8Array;
stateRoot: Uint8Array;
receiptsRoot: Uint8Array;
logsBloom: Uint8Array;
prevRandao: Uint8Array;
blockNumber: number;
gasLimit: number;
gasUsed: number;
timestamp: number;
extraData: Uint8Array;
baseFeePerGas: bigint;
blockHash: Uint8Array;
transactions: Uint8Array[];
};
executionPayloadHeader?: ExecutionPayloadHeader;
};
}

interface Fork {
previousVersion: Uint8Array;
currentVersion: Uint8Array;
Expand Down Expand Up @@ -184,10 +211,17 @@ declare class BeaconStateView {

isExecutionStateType: boolean;
isMergeTransitionComplete: boolean;
// TODO remove
isExecutionEnabled(fork: string, signedBlockBytes: Uint8Array): boolean;

// getExpectedWithdrawals(): ExpectedWithdrawals;
/** True iff state is pre-merge AND the given block carries a non-default execution payload. Bellatrix-only. */
isMergeTransitionBlock(signedBlockBytes: Uint8Array): boolean;
Comment on lines +214 to +215

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The method isMergeTransitionBlock is declared in the BeaconStateView class interface, but it is not implemented or exported in BeaconStateView.zig. Calling this method at runtime will result in a TypeError. Please implement this method in BeaconStateView.zig or remove it from the type definitions if it is not intended to be exposed.

/**
* Check whether execution is enabled for the given block at this state.
*
* For normal post-merge operation this short-circuits from state alone and does
* not inspect `block`. The block object is only read for the historical pre-merge
* Bellatrix case, where execution is enabled iff the block carries the first
* non-default execution payload.
*/
isExecutionEnabled(block: BeaconBlockLike): boolean;

proposerRewards: ProposerRewards;
// computeBlockRewards(block: BeaconBlock, proposerRewards: RewardsCache): BlockRewards;
Expand Down
89 changes: 89 additions & 0 deletions bindings/test/beaconStateView.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {SecretKey} from "@chainsafe/blst";
import {config} from "@lodestar/config/default";
import * as era from "@lodestar/era";
import {computeEpochAtSlot} from "@lodestar/state-transition";
Expand All @@ -6,6 +7,45 @@ import {beforeAll, describe, expect, it} from "vitest";
import bindings from "../src/index.js";
import {getFirstEraFilePath} from "./eraFiles.ts";

// TODO(bing): it's kinda annoying to have to do this, i guess we
// expose the config somehow maybe?
/* Mainnet preset constants the binding is compiled against. */
const SLOTS_PER_EPOCH = 32;
const SYNC_COMMITTEE_SIZE = 512;
const BELLATRIX_FORK_EPOCH = 144896;
const FAR_FUTURE_EPOCH = Number.MAX_SAFE_INTEGER;
const MAX_EFFECTIVE_BALANCE = 32_000_000_000;

const VALIDATOR_COUNT = 16;

interface Validator {
pubkey: Uint8Array;
withdrawalCredentials: Uint8Array;
effectiveBalance: number;
slashed: boolean;
activationEligibilityEpoch: number;
activationEpoch: number;
exitEpoch: number;
withdrawableEpoch: number;
}

function makeValidators(count: number): Validator[] {
return Array.from({length: count}, (_, i) => {
const seed = new Uint8Array(32);
new DataView(seed.buffer).setUint32(0, i + 1);
return {
activationEligibilityEpoch: 0,
activationEpoch: 0,
effectiveBalance: MAX_EFFECTIVE_BALANCE,
exitEpoch: FAR_FUTURE_EPOCH,
pubkey: SecretKey.fromKeygen(seed).toPublicKey().toBytes(),
slashed: false,
withdrawableEpoch: FAR_FUTURE_EPOCH,
withdrawalCredentials: new Uint8Array(32),
};
});
}

describe("BeaconStateView", () => {
let state: InstanceType<typeof bindings.BeaconStateView>;
let stateBytes: Uint8Array;
Expand Down Expand Up @@ -280,6 +320,55 @@ describe("BeaconStateView", () => {
});
});

describe("isExecutionEnabled", () => {
const validators = makeValidators(VALIDATOR_COUNT);

// Each sync-committee pubkey must be in the global pubkey_to_index map or
// EpochCache.createFromState throws PubkeyNotFound. Round-robin our 16 validators
// across the 512 slots — repeated pubkeys are fine for the lookup.
const syncCommitteePubkeys = Array.from(
{length: SYNC_COMMITTEE_SIZE},
(_, i) => validators[i % VALIDATOR_COUNT].pubkey
);
const syncCommittee = {
aggregatePubkey: validators[0].pubkey,
pubkeys: syncCommitteePubkeys,
};

const phase0State = ssz.phase0.BeaconState.defaultValue();
const bellatrixState = ssz.bellatrix.BeaconState.defaultValue();
bellatrixState.slot = BELLATRIX_FORK_EPOCH * SLOTS_PER_EPOCH;
bellatrixState.validators = validators;
bellatrixState.currentSyncCommittee = syncCommittee;
bellatrixState.nextSyncCommittee = syncCommittee;

const phase0View = bindings.BeaconStateView.createFromBytes(ssz.phase0.BeaconState.serialize(phase0State));
const bellatrixView = bindings.BeaconStateView.createFromBytes(ssz.bellatrix.BeaconState.serialize(bellatrixState));

it("should true on post-merge state without reading the block", () => {
// body is empty — binding short-circuits before touching it.
expect(state.isExecutionEnabled({body: {}})).toBe(true);
});

it("returns false even when block carries a non-default executionPayload", () => {
const payload = ssz.bellatrix.ExecutionPayload.defaultValue();
payload.blockNumber = 1;
expect(phase0View.isExecutionEnabled({body: {executionPayload: payload}})).toBe(false);
});

it("returns true after walking block for non-default payload", () => {
const payload = ssz.bellatrix.ExecutionPayload.defaultValue();
payload.blockNumber = 1;
expect(bellatrixView.isExecutionEnabled({body: {executionPayload: payload}})).toBe(true);
});

it("returns false when block is blinded (body has executionPayloadHeader)", () => {
// Lodestar treats blinded pre-merge Bellatrix blocks as not-yet-merged because the
// state header is still default. The Zig short-circuits on the presence of the field.
expect(bellatrixView.isExecutionEnabled({body: {executionPayloadHeader: {}}})).toBe(false);
});
});

describe("validators and balances", () => {
it("getBalance(0) should return first validator balance", () => {
expect(state.getBalance(0)).toBe(BigInt(expected.balance0));
Expand Down
4 changes: 2 additions & 2 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
.hash = "zig_yaml-0.1.0-C1161kFWAwDxjKAFmklKwWVDvz2mmq0Q__bDhGGjeyd3",
},
.zapi = .{
.url = "https://github.com/ChainSafe/zapi/archive/refs/tags/zapi-v2.2.0.tar.gz",
.hash = "zapi-2.2.0-rIqzUXBaBADUsvvvB2N5kOBdLEO0rGPhowTqmIXe3qVh",
.url = "git+https://github.com/chainsafe/zapi.git#35016d1c94e13a0384f4cca542f1944352f3359a",
.hash = "zapi-2.2.0-rIqzURRgBACUCBgzRw3K7uRSjAWJN78aexv4bkctuaBV",
},
.zbench = .{
.url = "git+https://github.com/hendriknielaender/zBench#b2b89c475e3ef1bb2bd71255c80478a82d3e0ca8",
Expand Down
Loading