Skip to content

Commit 583201f

Browse files
committed
Consume /lean/v0/checkpoints/justified endpoint for latest justified slot
- Fetch justified slot from upstream /lean/v0/checkpoints/justified (JSON) - Fall back to finalized slot when justified endpoint unavailable - Add parseJustifiedSlotFromJson helper with unit test
1 parent 1683fee commit 583201f

3 files changed

Lines changed: 88 additions & 6 deletions

File tree

src/lean_api.zig

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ pub const Slots = struct {
77
};
88

99
/// Fetch finalized and justified slots from lean node endpoints
10-
/// The finalized endpoint returns SSZ-encoded LeanState data
10+
/// - Finalized: /lean/v0/states/finalized (SSZ-encoded LeanState)
11+
/// - Justified: /lean/v0/checkpoints/justified (JSON checkpoint with slot)
12+
/// Falls back to finalized slot for justified when the justified endpoint is unavailable
1113
pub fn fetchSlots(
1214
allocator: std.mem.Allocator,
1315
client: *std.http.Client,
@@ -24,16 +26,88 @@ pub fn fetchSlots(
2426
out_state_ssz,
2527
);
2628

27-
// For now, use finalized slot as justified slot since /lean/v0/states/justified returns 404
28-
// TODO: Find the correct endpoint for justified slot
29-
const justified_slot = finalized_slot;
29+
// Fetch justified slot from JSON checkpoint endpoint (zeam serves this)
30+
const justified_slot = fetchJustifiedSlotFromJsonEndpoint(
31+
allocator,
32+
client,
33+
base_url,
34+
) catch |err| {
35+
log.debug("Justified checkpoint unavailable ({s}), using finalized slot", .{@errorName(err)});
36+
return Slots{
37+
.justified_slot = finalized_slot,
38+
.finalized_slot = finalized_slot,
39+
};
40+
};
3041

3142
return Slots{
3243
.justified_slot = justified_slot,
3344
.finalized_slot = finalized_slot,
3445
};
3546
}
3647

48+
/// Fetch justified slot from /lean/v0/checkpoints/justified
49+
/// Returns JSON: {"root": "0x...", "slot": 123}
50+
fn fetchJustifiedSlotFromJsonEndpoint(
51+
allocator: std.mem.Allocator,
52+
client: *std.http.Client,
53+
base_url: []const u8,
54+
) !u64 {
55+
var url_buf: [512]u8 = undefined;
56+
const url = try std.fmt.bufPrint(&url_buf, "{s}/lean/v0/checkpoints/justified", .{base_url});
57+
const uri = try std.Uri.parse(url);
58+
59+
var header_buf: [4096]u8 = undefined;
60+
var req = try client.open(.GET, uri, .{
61+
.server_header_buffer = &header_buf,
62+
.extra_headers = &.{
63+
.{ .name = "accept", .value = "application/json" },
64+
.{ .name = "connection", .value = "close" },
65+
},
66+
});
67+
defer req.deinit();
68+
69+
try req.send();
70+
try req.finish();
71+
try req.wait();
72+
73+
if (req.response.status != .ok) {
74+
log.warn("Bad status from {s}: {any}", .{ url, req.response.status });
75+
return error.BadStatus;
76+
}
77+
78+
var body_buf = std.ArrayList(u8).init(allocator);
79+
defer body_buf.deinit();
80+
try req.reader().readAllArrayList(&body_buf, 64 * 1024);
81+
82+
const slot = parseJustifiedSlotFromJson(allocator, body_buf.items) catch |err| {
83+
log.warn("Failed to parse justified checkpoint JSON from {s}: {}", .{ url, err });
84+
return err;
85+
};
86+
87+
log.debug("Successfully fetched justified slot {d} from {s}", .{ slot, url });
88+
return slot;
89+
}
90+
91+
/// Parse slot from justified checkpoint JSON: {"root": "0x...", "slot": N}
92+
fn parseJustifiedSlotFromJson(allocator: std.mem.Allocator, body: []const u8) !u64 {
93+
var parser = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return error.InvalidJson;
94+
defer parser.deinit();
95+
96+
const root = parser.value;
97+
if (root != .object) return error.InvalidJson;
98+
const obj = root.object;
99+
const slot_val = obj.get("slot") orelse return error.MissingSlot;
100+
const slot: u64 = switch (slot_val) {
101+
.integer => |i| if (i >= 0) @intCast(i) else return error.InvalidSlot,
102+
.float => |f| if (f >= 0 and f < 1e18) @intFromFloat(f) else return error.InvalidSlot,
103+
else => return error.InvalidSlot,
104+
};
105+
106+
const max_reasonable_slot: u64 = 1_000_000_000;
107+
if (slot > max_reasonable_slot) return error.InvalidSlot;
108+
return slot;
109+
}
110+
37111
/// Fetch slot from SSZ-encoded endpoint
38112
/// The lean nodes return SSZ-encoded LeanState data in this structure:
39113
/// - config.genesis_time: u64 (8 bytes, offset 0-7)
@@ -164,6 +238,12 @@ fn fetchSlotFromSSZEndpoint(
164238
return slot;
165239
}
166240

241+
test "parse justified checkpoint JSON" {
242+
const json = "{\"root\":\"0x0000000000000000000000000000000000000000000000000000000000000000\",\"slot\":42}";
243+
const slot = try parseJustifiedSlotFromJson(std.testing.allocator, json);
244+
try std.testing.expectEqual(@as(u64, 42), slot);
245+
}
246+
167247
test "extract slot from ssz bytes" {
168248
// Simulate SSZ LeanState data
169249
var data: [300]u8 = undefined;

src/state.zig

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ pub const AppState = struct {
9393
self.last_updated_ms = now_ms;
9494
self.last_success_ms = now_ms;
9595
self.last_latency_ms = latency_ms;
96+
self.error_count = 0; // reset on success so UI reflects current health
9697
if (self.last_error) |msg| allocator.free(msg);
9798
self.last_error = null;
9899

@@ -272,5 +273,5 @@ test "AppState updateSuccess clears error" {
272273

273274
state.updateSuccess(std.testing.allocator, 100, 99, 50, 2000, null);
274275
try std.testing.expect(state.last_error == null);
275-
try std.testing.expectEqual(@as(u64, 1), state.error_count); // error_count persists
276+
try std.testing.expectEqual(@as(u64, 0), state.error_count); // reset on success
276277
}

src/upstreams.zig

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,13 @@ pub const UpstreamManager = struct {
198198
if (result.index >= self.upstreams.items.len) continue;
199199
var upstream = &self.upstreams.items[result.index];
200200

201-
if (result.slots) |slots| {
201+
if (result.slots) |slots| {
202202
// Success: clear error and update state
203203
if (upstream.last_error) |old_err| {
204204
self.allocator.free(old_err);
205205
upstream.last_error = null;
206206
}
207+
upstream.error_count = 0; // reset on success so UI reflects current health
207208
upstream.last_slots = slots;
208209
upstream.last_success_ms = now_ms;
209210

0 commit comments

Comments
 (0)