Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9c879da
docs: add Zig peer manager implementation design spec
nazarhussain Apr 2, 2026
c79f2cc
docs: fix spec review issues (4 BLOCKs, 7 FLAGs)
nazarhussain Apr 2, 2026
fc55331
docs: add Zig peer manager implementation plan
nazarhussain Apr 2, 2026
f70bee5
feat(peer_manager): add constants.zig with score thresholds and inter…
nazarhussain Apr 2, 2026
e9bc9a1
feat(peer_manager): add types.zig with all shared types and helpers
nazarhussain Apr 2, 2026
2d7fe8a
feat(peer_manager): add root.zig and register module in build.zig
nazarhussain Apr 2, 2026
e923c07
feat(peer_manager): add PeerStore with HashMap-backed peer data storage
nazarhussain Apr 2, 2026
357aca5
feat(peer_manager): add PeerScorer with decay, gossipsub blending, an…
nazarhussain Apr 2, 2026
30837d6
feat(peer_manager): add assertPeerRelevance with 4-check relevance va…
nazarhussain Apr 2, 2026
e1fdea0
feat(peer_manager): add prioritizePeers with subnet-aware connect/dis…
nazarhussain Apr 2, 2026
d814397
feat(peer_manager): add PeerManager orchestrating store, scorer, and …
nazarhussain Apr 2, 2026
c1b990d
feat(peer_manager): add NAPI bindings for peer manager
nazarhussain Apr 2, 2026
318db58
test(peer_manager): add TypeScript integration tests for NAPI bindings
nazarhussain Apr 2, 2026
3c5b01c
chore: remove the unncessary docs
nazarhussain Apr 2, 2026
ecce100
fix(peer_manager): register module in zbuild.zon and regenerate build…
nazarhussain Apr 2, 2026
0065ed6
fix(peer_manager): fix scorer decay test hitting ban threshold
nazarhussain Apr 2, 2026
feb1b88
fix(peer_manager): fix NAPI bindings for zapi Value API
nazarhussain Apr 2, 2026
b534d04
fix(peer-manager): accept Lodestar peer action names
nazarhussain Apr 7, 2026
85f656e
fix(peer-manager): retain discovery query payloads
nazarhussain Apr 7, 2026
f1e8047
fix(bindings): harden peerManager NAPI surface
nazarhussain Apr 7, 2026
bc4be0c
chore: update peer-manage build
nazarhussain Apr 14, 2026
69dd58a
chore: fix the zapi import path
nazarhussain Apr 14, 2026
10ccab3
fix(peer_manager): simplify onConnectionOpen error handling
nazarhussain Apr 14, 2026
98542d3
refactor(peer_manager): use getOrPut in addPeer for efficiency
nazarhussain Apr 14, 2026
f4ed937
fix(peer_manager): correct detectStarvation logic
nazarhussain Apr 14, 2026
0aa82d6
fix(peer_manager): apply reconnection cooldown onGoodbye
nazarhussain Apr 14, 2026
f01ed53
chore: add missing interfaces for the PeerStore
nazarhussain Apr 14, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ node_modules/
# Claude Code local config
.claude/settings.local.json
.claude/plans/
docs/superpowers
587 changes: 587 additions & 0 deletions bindings/napi/peer_manager.zig

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions bindings/napi/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const metrics = @import("./metrics.zig");
const BeaconStateView = @import("./BeaconStateView.zig");
const blst = @import("./blst.zig");
const state_transition = @import("./state_transition.zig");
const peer_manager_bindings = @import("./peer_manager.zig");

comptime {
napi.module.register(register);
Expand All @@ -22,6 +23,7 @@ const EnvCleanup = struct {
fn hook(_: *EnvCleanup) void {
if (env_refcount.fetchSub(1, .acq_rel) == 1) {
// Last environment — tear down shared state.
peer_manager_bindings.state.deinit();
config.state.deinit();
pubkeys.state.deinit();
pool.state.deinit();
Expand Down Expand Up @@ -50,4 +52,5 @@ fn register(env: napi.Env, exports: napi.Value) !void {
try blst.register(env, exports);
try state_transition.register(env, exports);
try metrics.register(env, exports);
try peer_manager_bindings.register(env, exports);
}
145 changes: 145 additions & 0 deletions bindings/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,150 @@ interface CompactMultiProof {
descriptor: Uint8Array;
}

type PeerManagerDirection = "inbound" | "outbound";

type PeerManagerForkName =
| "phase0"
| "altair"
| "bellatrix"
| "capella"
| "deneb"
| "electra"
| "fulu"
| "gloas"
| "heze";

type PeerManagerReportPeerAction =
| "Fatal"
| "LowToleranceError"
| "MidToleranceError"
| "HighToleranceError"
| "fatal"
| "low_tolerance"
| "mid_tolerance"
| "high_tolerance";

interface PeerManagerConfig {
targetPeers: number;
maxPeers: number;
targetGroupPeers: number;
pingIntervalInboundMs: number;
pingIntervalOutboundMs: number;
statusIntervalMs: number;
statusInboundGracePeriodMs: number;
gossipsubNegativeScoreWeight: number;
gossipsubPositiveScoreWeight: number;
negativeGossipScoreIgnoreThreshold: number;
disablePeerScoring: boolean;
initialForkName: PeerManagerForkName;
numberOfCustodyGroups: number;
custodyRequirement: number;
samplesPerSlot: number;
slotsPerEpoch: number;
}

interface PeerManagerStatus {
forkDigest: Uint8Array;
finalizedRoot: Uint8Array;
finalizedEpoch: number;
headRoot: Uint8Array;
headSlot: number;
earliestAvailableSlot?: number | null;
}

interface PeerManagerMetadata {
seqNumber: number;
attnets: Uint8Array;
syncnets: Uint8Array;
custodyGroupCount: number;
custodyGroups?: number[] | null;
samplingGroups?: number[] | null;
}

interface PeerManagerRequestedSubnet {
subnet: number;
toSlot: number;
}

interface PeerManagerGossipScoreUpdate {
peerId: string;
score: number;
}

interface PeerManagerDiscoveryQuery {
subnet: number;
toSlot: number;
maxPeersToDiscover: number;
}

interface PeerManagerCustodyGroupQuery {
group: number;
maxPeersToDiscover: number;
}

type PeerManagerAction =
| {type: "send_ping"; peerId: string}
| {type: "send_status"; peerId: string}
| {type: "send_goodbye"; peerId: string; reason: number}
| {type: "request_metadata"; peerId: string}
| {type: "disconnect_peer"; peerId: string}
| {
type: "request_discovery";
peersToConnect: number;
attnetQueries: PeerManagerDiscoveryQuery[];
syncnetQueries: PeerManagerDiscoveryQuery[];
custodyGroupQueries: PeerManagerCustodyGroupQuery[];
}
| {type: "tag_peer_relevant"; peerId: string}
| {type: "emit_peer_connected"; peerId: string; direction: PeerManagerDirection}
| {type: "emit_peer_disconnected"; peerId: string};

interface PeerManagerPeerData {
peerId: string;
direction: PeerManagerDirection;
relevantStatus: "unknown" | "relevant" | "irrelevant";
connectedUnixTsMs: number;
lastReceivedMsgUnixTsMs: number;
lastStatusUnixTsMs: number;
agentVersion: string | null;
agentClient: string | null;
encodingPreference: string | null;
}

interface PeerManagerApi {
init: (config: PeerManagerConfig) => void;
close: () => void;
heartbeat: (currentSlot: number, localStatus: PeerManagerStatus) => PeerManagerAction[];
checkPingAndStatus: () => PeerManagerAction[];
onConnectionOpen: (peerId: string, direction: PeerManagerDirection) => PeerManagerAction[];
onConnectionClose: (peerId: string) => PeerManagerAction[];
onStatusReceived: (
peerId: string,
remoteStatus: PeerManagerStatus,
localStatus: PeerManagerStatus,
currentSlot: number
) => PeerManagerAction[];
onMetadataReceived: (peerId: string, metadata: PeerManagerMetadata) => void;
onMessageReceived: (peerId: string) => void;
onGoodbye: (peerId: string, reason: number) => PeerManagerAction[];
onPing: (peerId: string, seqNumber: number) => PeerManagerAction[];
reportPeer: (peerId: string, action: PeerManagerReportPeerAction) => void;
updateGossipScores: (scores: PeerManagerGossipScoreUpdate[]) => void;
setSubnetRequirements: (
attnets: PeerManagerRequestedSubnet[],
syncnets: PeerManagerRequestedSubnet[]
) => void;
setForkName: (forkName: PeerManagerForkName) => void;
setSamplingGroups: (groups: number[]) => void;
getConnectedPeerCount: () => number;
getConnectedPeers: () => string[];
getPeerData: (peerId: string) => PeerManagerPeerData | null;
getEncodingPreference: (peerId: string) => string | null;
getPeerKind: (peerId: string) => string | null;
getAgentVersion: (peerId: string) => string | null;
getPeerScore: (peerId: string) => number;
}

/** Options to control how state transition is run */
interface TransitionOpts {
/** Verify the post-state root matches the block's state root. Default: true. */
Expand Down Expand Up @@ -240,6 +384,7 @@ declare const bindings: {
init: () => void;
scrapeMetrics: () => string;
};
peerManager: PeerManagerApi;
BeaconStateView: typeof BeaconStateView;
};

Expand Down
120 changes: 120 additions & 0 deletions bindings/test/peerManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {describe, expect, it, beforeEach, afterEach} from "vitest";

// The peerManager binding is registered on the native addon's exports object.
// Import the raw addon to access it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let bindings: any;

const defaultConfig = {
targetPeers: 10,
maxPeers: 15,
targetGroupPeers: 6,
pingIntervalInboundMs: 15000,
pingIntervalOutboundMs: 20000,
statusIntervalMs: 300000,
statusInboundGracePeriodMs: 15000,
gossipsubNegativeScoreWeight: -0.5,
gossipsubPositiveScoreWeight: 0.5,
negativeGossipScoreIgnoreThreshold: -100,
disablePeerScoring: false,
initialForkName: "deneb",
numberOfCustodyGroups: 128,
custodyRequirement: 4,
samplesPerSlot: 8,
slotsPerEpoch: 32,
};

const localStatus = {
forkDigest: new Uint8Array([1, 2, 3, 4]),
finalizedRoot: new Uint8Array(32).fill(0xaa),
finalizedEpoch: 100,
headRoot: new Uint8Array(32).fill(0xbb),
headSlot: 3200,
};

describe("peerManager", () => {
beforeEach(async () => {
bindings = (await import("../src/index.js")).default;
bindings.peerManager.init(defaultConfig);
});

afterEach(() => {
try {
bindings.peerManager.close();
} catch {
// Already closed
}
});

it("init and close without error", () => {
// init called in beforeEach, close called in afterEach
expect(bindings.peerManager).toBeDefined();
});

it("onConnectionOpen increases peer count", () => {
const actions = bindings.peerManager.onConnectionOpen("peer1", "outbound");
expect(Array.isArray(actions)).toBe(true);
expect(bindings.peerManager.getConnectedPeerCount()).toBe(1);
});

it("onConnectionOpen outbound emits ping and status", () => {
const actions = bindings.peerManager.onConnectionOpen("peer1", "outbound");
const types = actions.map((a: {type: string}) => a.type);
expect(types).toContain("send_ping");
expect(types).toContain("send_status");
});

it("onConnectionOpen duplicate is no-op", () => {
bindings.peerManager.onConnectionOpen("peer1", "outbound");
const actions = bindings.peerManager.onConnectionOpen("peer1", "outbound");
expect(actions).toHaveLength(0);
expect(bindings.peerManager.getConnectedPeerCount()).toBe(1);
});

it("onConnectionClose emits disconnect event", () => {
bindings.peerManager.onConnectionOpen("peer1", "outbound");
const actions = bindings.peerManager.onConnectionClose("peer1");
const types = actions.map((a: {type: string}) => a.type);
expect(types).toContain("emit_peer_disconnected");
expect(bindings.peerManager.getConnectedPeerCount()).toBe(0);
});

it("heartbeat returns action array", () => {
bindings.peerManager.onConnectionOpen("peer1", "outbound");
const actions = bindings.peerManager.heartbeat(100, localStatus);
expect(Array.isArray(actions)).toBe(true);
});

it("heartbeat discovery includes custody group queries when sampling groups are set", () => {
bindings.peerManager.setSamplingGroups([0, 1, 2]);
const actions = bindings.peerManager.heartbeat(100, localStatus);
const discovery = actions.find((a: {type: string}) => a.type === "request_discovery");
expect(discovery).toBeDefined();
expect(Array.isArray(discovery.custodyGroupQueries)).toBe(true);
expect(discovery.custodyGroupQueries.length).toBeGreaterThan(0);
});

it("getPeerScore returns number", () => {
bindings.peerManager.onConnectionOpen("peer1", "outbound");
const score = bindings.peerManager.getPeerScore("peer1");
expect(typeof score).toBe("number");
});

it("reportPeer reflects in getPeerScore", () => {
bindings.peerManager.onConnectionOpen("peer1", "outbound");
const scoreBefore = bindings.peerManager.getPeerScore("peer1");
bindings.peerManager.reportPeer("peer1", "MidToleranceError");
const scoreAfter = bindings.peerManager.getPeerScore("peer1");
expect(scoreAfter).toBeLessThan(scoreBefore);
});

it("getConnectedPeers returns string array", () => {
bindings.peerManager.onConnectionOpen("peer1", "outbound");
bindings.peerManager.onConnectionOpen("peer2", "inbound");
const peers = bindings.peerManager.getConnectedPeers();
expect(Array.isArray(peers)).toBe(true);
expect(peers).toHaveLength(2);
expect(peers).toContain("peer1");
expect(peers).toContain("peer2");
});
});
22 changes: 22 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ pub fn build(b: *std.Build) void {
});
b.modules.put(b.dupe("state_transition"), module_state_transition) catch @panic("OOM");

const module_peer_manager = b.createModule(.{
.root_source_file = b.path("src/peer_manager/root.zig"),
.target = target,
.optimize = optimize,
});
b.modules.put(b.dupe("peer_manager"), module_peer_manager) catch @panic("OOM");

const module_download_era_files = b.createModule(.{
.root_source_file = b.path("scripts/download_era_files.zig"),
.target = target,
Expand Down Expand Up @@ -695,6 +702,20 @@ pub fn build(b: *std.Build) void {
tls_run_test_state_transition.dependOn(&run_test_state_transition.step);
tls_run_test.dependOn(&run_test_state_transition.step);

const test_peer_manager = b.addTest(.{
.name = "peer_manager",
.root_module = module_peer_manager,
.filters = b.option([][]const u8, "peer_manager.filters", "peer_manager test filters") orelse &[_][]const u8{},
});
const install_test_peer_manager = b.addInstallArtifact(test_peer_manager, .{});
const tls_install_test_peer_manager = b.step("build-test:peer_manager", "Install the peer_manager test");
tls_install_test_peer_manager.dependOn(&install_test_peer_manager.step);

const run_test_peer_manager = b.addRunArtifact(test_peer_manager);
const tls_run_test_peer_manager = b.step("test:peer_manager", "Run the peer_manager test");
tls_run_test_peer_manager.dependOn(&run_test_peer_manager.step);
tls_run_test.dependOn(&run_test_peer_manager.step);

const test_download_era_files = b.addTest(.{
.name = "download_era_files",
.root_module = module_download_era_files,
Expand Down Expand Up @@ -1162,6 +1183,7 @@ pub fn build(b: *std.Build) void {
module_bindings.addImport("fork_types", module_fork_types);
module_bindings.addImport("state_transition", module_state_transition);
module_bindings.addImport("zapi:zapi", dep_zapi.module("zapi"));
module_bindings.addImport("peer_manager", module_peer_manager);

module_int.addImport("config", module_config);
module_int.addImport("download_era_options", options_module_download_era_options);
Expand Down
Loading
Loading