diff --git a/build.zig b/build.zig index 7cc13ca..4339a32 100644 --- a/build.zig +++ b/build.zig @@ -156,6 +156,21 @@ pub fn build(b: *std.Build) void { btcrpc_build_step.dependOn(&b.addInstallArtifact(btcrpc, .{}).step); } + // lnd HTTP API client playground + { + const lndhc = b.addExecutable(.{ + .name = "lndhc", + .root_source_file = .{ .path = "src/test/lndhc.zig" }, + .target = target, + .optimize = optimize, + }); + lndhc.strip = strip; + lndhc.addModule("lndhttp", b.createModule(.{ .source_file = .{ .path = "src/lndhttp.zig" } })); + + const lndhc_build_step = b.step("lndhc", "lnd HTTP API client playground"); + lndhc_build_step.dependOn(&b.addInstallArtifact(lndhc, .{}).step); + } + // default build step const build_all_step = b.step("all", "build nd and ngui (default step)"); build_all_step.dependOn(ngui_build_step); diff --git a/src/comm.zig b/src/comm.zig index 2b5c811..2ecf273 100644 --- a/src/comm.zig +++ b/src/comm.zig @@ -28,6 +28,7 @@ pub const Message = union(MessageTag) { get_network_report: GetNetworkReport, poweroff_progress: PoweroffProgress, bitcoind_report: BitcoindReport, + lightning_report: LightningReport, pub const WifiConnect = struct { ssid: []const u8, @@ -81,6 +82,38 @@ pub const Message = union(MessageTag) { fullrbf: bool, }, }; + + pub const LightningReport = struct { + version: []const u8, + pubkey: []const u8, + alias: []const u8, + npeers: u32, + height: u32, + hash: []const u8, + sync: struct { chain: bool, graph: bool }, + uris: []const []const u8, + /// only lightning channels balance is reported here + totalbalance: struct { local: i64, remote: i64, unsettled: i64, pending: i64 }, + totalfees: struct { day: u64, week: u64, month: u64 }, // sats + channels: []const struct { + id: ?[]const u8 = null, // null for pending_xxx state + state: enum { active, inactive, pending_open, pending_close }, + private: bool, + point: []const u8, // funding txid:index + closetxid: ?[]const u8 = null, // non-null for pending_close + peer_pubkey: []const u8, + peer_alias: []const u8, + capacity: i64, + balance: struct { local: i64, remote: i64, unsettled: i64, limbo: i64 }, + totalsats: struct { sent: i64, received: i64 }, + fees: struct { + base: i64, // msat + ppm: i64, // per milli-satoshis, in millionths of satoshi + // TODO: remote base and ppm from getchaninfo + // https://docs.lightning.engineering/lightning-network-tools/lnd/channel-fees + }, + }, + }; }; /// it is important to preserve ordinal values for future compatiblity, @@ -100,7 +133,9 @@ pub const MessageTag = enum(u16) { poweroff_progress = 0x09, // nd -> ngui: bitcoin core daemon status report bitcoind_report = 0x0a, - // next: 0x0b + // nd -> ngui: lnd status and stats report + lightning_report = 0x0b, + // next: 0x0c }; /// the return value type from `read` fn. @@ -173,6 +208,7 @@ pub fn write(allocator: mem.Allocator, writer: anytype, msg: Message) !void { .get_network_report => try json.stringify(msg.get_network_report, .{}, data.writer()), .poweroff_progress => try json.stringify(msg.poweroff_progress, .{}, data.writer()), .bitcoind_report => try json.stringify(msg.bitcoind_report, .{}, data.writer()), + .lightning_report => try json.stringify(msg.lightning_report, .{}, data.writer()), } if (data.items.len > std.math.maxInt(u64)) { return Error.CommWriteTooLarge; diff --git a/src/lndhttp.zig b/src/lndhttp.zig new file mode 100644 index 0000000..ccac0ab --- /dev/null +++ b/src/lndhttp.zig @@ -0,0 +1,327 @@ +//! lnd lightning HTTP client and utility functions. + +const std = @import("std"); +const types = @import("types.zig"); + +pub const Client = struct { + allocator: std.mem.Allocator, + hostname: []const u8 = "localhost", + port: u16 = 10010, + apibase: []const u8, // https://localhost:10010 + macaroon: struct { + readonly: []const u8, + admin: ?[]const u8, + }, + httpClient: std.http.Client, + + pub const ApiMethod = enum { + feereport, // fees of all active channels + getinfo, // general host node info + getnetworkinfo, // visible graph info + listchannels, // active channels + pendingchannels, // pending open/close channels + walletbalance, // onchain balance + walletstatus, // server/wallet status + // fwdinghistory, getchaninfo, getnodeinfo + // genseed, initwallet, unlockwallet + // watchtower: getinfo, stats, list, add, remove + + fn apipath(self: @This()) []const u8 { + return switch (self) { + .feereport => "v1/fees", + .getinfo => "v1/getinfo", + .getnetworkinfo => "v1/graph/info", + .listchannels => "v1/channels", + .pendingchannels => "v1/channels/pending", + .walletbalance => "v1/balance/blockchain", + .walletstatus => "v1/state", + }; + } + }; + + pub fn MethodArgs(comptime m: ApiMethod) type { + return switch (m) { + .listchannels => struct { + status: ?enum { active, inactive } = null, + advert: ?enum { public, private } = null, + peer: ?[]const u8 = null, // hex pubkey; filter out non-matching peers + peer_alias_lookup: bool, // performance penalty if set to true + }, + else => void, + }; + } + + pub fn ResultValue(comptime m: ApiMethod) type { + return switch (m) { + .feereport => FeeReport, + .getinfo => LndInfo, + .getnetworkinfo => NetworkInfo, + .listchannels => ChannelsList, + .pendingchannels => PendingList, + .walletbalance => WalletBalance, + .walletstatus => WalletStatus, + }; + } + + pub const InitOpt = struct { + allocator: std.mem.Allocator, + hostname: []const u8 = "localhost", // must be present in tlscert_path SANs + port: u16 = 10010, // HTTP API port + tlscert_path: []const u8, // must contain the hostname in SANs + macaroon_ro_path: []const u8, // readonly macaroon path + macaroon_admin_path: ?[]const u8 = null, // required only for requests mutating lnd state + }; + + /// opt slices are dup'ed and need not be kept alive. + /// must deinit when done. + pub fn init(opt: InitOpt) !Client { + var ca = std.crypto.Certificate.Bundle{}; // deinit'ed by http.Client.deinit + errdefer ca.deinit(opt.allocator); + try ca.addCertsFromFilePathAbsolute(opt.allocator, opt.tlscert_path); + const apibase = try std.fmt.allocPrint(opt.allocator, "https://{s}:{d}", .{ opt.hostname, opt.port }); + errdefer opt.allocator.free(apibase); + const mac_ro = try readMacaroon(opt.allocator, opt.macaroon_ro_path); + errdefer opt.allocator.free(mac_ro); + const mac_admin = if (opt.macaroon_admin_path) |p| try readMacaroon(opt.allocator, p) else null; + return .{ + .allocator = opt.allocator, + .apibase = apibase, + .macaroon = .{ .readonly = mac_ro, .admin = mac_admin }, + .httpClient = std.http.Client{ + .allocator = opt.allocator, + .ca_bundle = ca, + .next_https_rescan_certs = false, // use only the provided CA bundle above + }, + }; + } + + pub fn deinit(self: *Client) void { + self.httpClient.deinit(); + self.allocator.free(self.apibase); + self.allocator.free(self.macaroon.readonly); + if (self.macaroon.admin) |a| { + self.allocator.free(a); + } + } + + pub fn Result(comptime m: ApiMethod) type { + return if (@TypeOf(ResultValue(m)) == void) void else types.Deinitable(ResultValue(m)); + } + + pub fn call(self: *Client, comptime apimethod: ApiMethod, args: MethodArgs(apimethod)) !Result(apimethod) { + const formatted = try self.formatreq(apimethod, args); + defer formatted.deinit(); + const reqinfo = formatted.value; + const opt = std.http.Client.Options{ .handle_redirects = false }; // no redirects in REST API + var req = try self.httpClient.request(reqinfo.httpmethod, reqinfo.url, reqinfo.headers, opt); + defer req.deinit(); + try req.start(); + if (reqinfo.payload) |p| { + try req.writer().writeAll(p); + try req.finish(); + } + try req.wait(); + if (req.response.status.class() != .success) { + return error.LndHttpBadStatusCode; + } + if (@TypeOf(Result(apimethod)) == void) { + return; // void response; need no json parsing + } + + const body = try req.reader().readAllAlloc(self.allocator, 1 << 20); // 1Mb should be enough for all response types + defer self.allocator.free(body); + var res = try Result(apimethod).init(self.allocator); + errdefer res.deinit(); + res.value = try std.json.parseFromSliceLeaky(ResultValue(apimethod), res.arena.allocator(), body, .{ + .ignore_unknown_fields = true, + .allocate = .alloc_always, + }); + return res; + } + + const HttpReqInfo = struct { + httpmethod: std.http.Method, + url: std.Uri, + headers: std.http.Headers, + payload: ?[]const u8, + }; + + fn formatreq(self: Client, comptime apimethod: ApiMethod, args: MethodArgs(apimethod)) !types.Deinitable(HttpReqInfo) { + const authHeaderName = "grpc-metadata-macaroon"; + var reqinfo = try types.Deinitable(HttpReqInfo).init(self.allocator); + errdefer reqinfo.deinit(); + const arena = reqinfo.arena.allocator(); + reqinfo.value = switch (apimethod) { + .feereport, .getinfo, .getnetworkinfo, .pendingchannels, .walletbalance, .walletstatus => |m| .{ + .httpmethod = .GET, + .url = try std.Uri.parse(try std.fmt.allocPrint(arena, "{s}/{s}", .{ self.apibase, m.apipath() })), + .headers = blk: { + var h = std.http.Headers{ .allocator = arena }; + try h.append(authHeaderName, self.macaroon.readonly); + break :blk h; + }, + .payload = null, + }, + .listchannels => .{ + .httpmethod = .GET, + .url = blk: { + var buf = std.ArrayList(u8).init(arena); + const w = buf.writer(); + try std.fmt.format(w, "{s}/v1/channels?peer_alias_lookup={}", .{ self.apibase, args.peer_alias_lookup }); + if (args.status) |v| switch (v) { + .active => try w.writeAll("&active_only=true"), + .inactive => try w.writeAll("&inactive_only=true"), + }; + if (args.advert) |v| switch (v) { + .public => try w.writeAll("&public_only=true"), + .private => try w.writeAll("&private_only=true"), + }; + if (args.peer) |v| { + // TODO: sanitize; Uri.writeEscapedQuery(w, q); + try std.fmt.format(w, "&peer={s}", .{v}); + } + break :blk try std.Uri.parse(buf.items); + }, + .headers = blk: { + var h = std.http.Headers{ .allocator = arena }; + try h.append(authHeaderName, self.macaroon.readonly); + break :blk h; + }, + .payload = null, + }, + }; + return reqinfo; + } + + /// callers own returned value. + fn readMacaroon(gpa: std.mem.Allocator, path: []const u8) ![]const u8 { + const file = try std.fs.openFileAbsolute(path, .{ .mode = .read_only }); + defer file.close(); + const cont = try file.readToEndAlloc(gpa, 1024); + defer gpa.free(cont); + return std.fmt.allocPrint(gpa, "{}", .{std.fmt.fmtSliceHexLower(cont)}); + } +}; + +/// general info and stats around the host lnd. +pub const LndInfo = struct { + version: []const u8, + identity_pubkey: []const u8, + alias: []const u8, + color: []const u8, + num_pending_channels: u32, + num_active_channels: u32, + num_inactive_channels: u32, + num_peers: u32, + block_height: u32, + block_hash: []const u8, + synced_to_chain: bool, + synced_to_graph: bool, + chains: []const struct { + chain: []const u8, + network: []const u8, + }, + uris: []const []const u8, + // best_header_timestamp and features? +}; + +pub const NetworkInfo = struct { + graph_diameter: u32, + avg_out_degree: f32, + max_out_degree: u32, + num_nodes: u32, + num_channels: u32, + total_network_capacity: i64, + avg_channel_size: f64, + min_channel_size: i64, + max_channel_size: i64, + median_channel_size_sat: i64, + num_zombie_chans: u64, +}; + +pub const FeeReport = struct { + day_fee_sum: u64, + week_fee_sum: u64, + month_fee_sum: u64, + channel_fees: []struct { + chan_id: []const u8, + channel_point: []const u8, + base_fee_msat: i64, + fee_per_mil: i64, // per milli-satoshis, in millionths of satoshi + fee_rate: f64, // fee_per_mil/10^6, in milli-satoshis + }, +}; + +pub const ChannelsList = struct { + channels: []struct { + chan_id: []const u8, // [0..3]: height, [3..6]: index within block, [6..8]: chan out idx + remote_pubkey: []const u8, + channel_point: []const u8, // txid:index of the funding tx + capacity: i64, + local_balance: i64, + remote_balance: i64, + unsettled_balance: i64, + total_satoshis_sent: i64, + total_satoshis_received: i64, + active: bool, + private: bool, + initiator: bool, + peer_alias: []const u8, + // https://github.com/lightningnetwork/lnd/blob/d930dcec/channeldb/channel.go#L616-L644 + //chan_status_flag: ChannelStatus + //local_constraints, remote_constraints, pending_htlcs + }, +}; + +pub const PendingList = struct { + total_limbo_balance: i64, // balance in satoshis encumbered in pending channels + pending_open_channels: []struct { + channel: PendingChannel, + commit_fee: i64, + funding_expiry_blocks: i32, + }, + pending_force_closing_channels: []struct { + channel: PendingChannel, + closing_txid: []const u8, + limbo_balance: i64, + maturity_height: u32, + blocks_til_maturity: i32, // negative indicates n blocks since maturity + recovered_balance: i64, // total funds successfully recovered from this channel + // pending_htlcs, anchor + }, + waiting_close_channels: []struct { // waiting for closing tx confirmation + channel: PendingChannel, + limbo_balance: i64, + closing_txid: []const u8, + // commitments? + }, +}; + +pub const PendingChannel = struct { + remote_node_pub: []const u8, + channel_point: []const u8, + capacity: i64, + local_balance: i64, + remote_balance: i64, + private: bool, + // local_chan_reserve_sat, remote_chan_reserve_sat, initiator, chan_status_flags, memo +}; + +pub const WalletBalance = struct { + total_balance: i64, + confirmed_balance: i64, + unconfirmed_balance: i64, + locked_balance: i64, // output leases + reserved_balance_anchor_chan: i64, // for fee bumps +}; + +pub const WalletStatus = struct { + state: enum(u8) { + NON_EXISTING = 0, // uninitialized + LOCKED = 1, // requires password to unlocked + UNLOCKED = 2, // RPC isn't ready + RPC_ACTIVE = 3, // lnd server active but not ready for calls yet + SERVER_ACTIVE = 4, // ready to accept calls + WAITING_TO_START = 255, + }, +}; diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index fc31daf..9a01662 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -21,6 +21,7 @@ const screen = @import("../ui/screen.zig"); const types = @import("../types.zig"); const SysService = @import("SysService.zig"); const bitcoindrpc = @import("bitcoindrpc.zig"); +const lndhttp = @import("../lndhttp.zig"); const logger = std.log.scoped(.daemon); @@ -55,6 +56,10 @@ wpa_save_config_on_connected: bool = false, want_bitcoind_report: bool, bitcoin_timer: time.Timer, bitcoin_report_interval: u64 = 1 * time.ns_per_min, +// lightning flags +want_lnd_report: bool, +lnd_timer: time.Timer, +lnd_report_interval: u64 = 1 * time.ns_per_min, /// system services actively managed by the daemon. /// these are stop'ed during poweroff and their shutdown progress sent to ngui. @@ -90,6 +95,9 @@ pub fn init(a: std.mem.Allocator, r: std.fs.File.Reader, w: std.fs.File.Writer, // report bitcoind status immediately on start .want_bitcoind_report = true, .bitcoin_timer = try time.Timer.start(), + // report lightning status immediately on start + .want_lnd_report = true, + .lnd_timer = try time.Timer.start(), }; } @@ -285,6 +293,14 @@ fn mainThreadLoopCycle(self: *Daemon) !void { logger.err("sendBitcoinReport: {any}", .{err}); } } + if (self.want_lnd_report or self.lnd_timer.read() > self.lnd_report_interval) { + if (self.sendLightningReport()) { + self.lnd_timer.reset(); + self.want_lnd_report = false; + } else |err| { + logger.err("sendLightningReport: {any}", .{err}); + } + } } /// comm thread entry point: reads messages sent from ngui and acts accordinly. @@ -543,6 +559,148 @@ fn sendBitcoindReport(self: *Daemon) !void { try comm.write(self.allocator, self.uiwriter, .{ .bitcoind_report = btcrep }); } +fn sendLightningReport(self: *Daemon) !void { + var client = try lndhttp.Client.init(.{ + .allocator = self.allocator, + .tlscert_path = "/home/lnd/.lnd/tls.cert", + .macaroon_ro_path = "/ssd/lnd/data/chain/bitcoin/mainnet/readonly.macaroon", + }); + defer client.deinit(); + + const info = try client.call(.getinfo, {}); + defer info.deinit(); + const feerep = try client.call(.feereport, {}); + defer feerep.deinit(); + const chanlist = try client.call(.listchannels, .{ .peer_alias_lookup = true }); + defer chanlist.deinit(); + const pending = try client.call(.pendingchannels, {}); + defer pending.deinit(); + + var lndrep = comm.Message.LightningReport{ + .version = info.value.version, + .pubkey = info.value.identity_pubkey, + .alias = info.value.alias, + .npeers = info.value.num_peers, + .height = info.value.block_height, + .hash = info.value.block_hash, + .sync = .{ + .chain = info.value.synced_to_chain, + .graph = info.value.synced_to_graph, + }, + .uris = &.{}, // TODO: dedup info.uris + .totalbalance = .{ + .local = 0, // available; computed below + .remote = 0, // available; computed below + .unsettled = 0, // computed below + .pending = pending.value.total_limbo_balance, + }, + .totalfees = .{ + .day = feerep.value.day_fee_sum, + .week = feerep.value.week_fee_sum, + .month = feerep.value.month_fee_sum, + }, + .channels = undefined, // populated below + }; + + var feemap = std.StringHashMap(struct { base: i64, ppm: i64 }).init(self.allocator); + defer feemap.deinit(); + for (feerep.value.channel_fees) |item| { + try feemap.put(item.chan_id, .{ .base = item.base_fee_msat, .ppm = item.fee_per_mil }); + } + + var channels = std.ArrayList(@typeInfo(@TypeOf(lndrep.channels)).Pointer.child).init(self.allocator); + defer channels.deinit(); + for (pending.value.pending_open_channels) |item| { + try channels.append(.{ + .id = null, + .state = .pending_open, + .private = item.channel.private, + .point = item.channel.channel_point, + .closetxid = null, + .peer_pubkey = item.channel.remote_node_pub, + .peer_alias = "", // TODO: a cached getnodeinfo? + .capacity = item.channel.capacity, + .balance = .{ + .local = item.channel.local_balance, + .remote = item.channel.remote_balance, + .unsettled = 0, + .limbo = 0, + }, + .totalsats = .{ .sent = 0, .received = 0 }, + .fees = .{ .base = 0, .ppm = 0 }, + }); + } + for (pending.value.waiting_close_channels) |item| { + try channels.append(.{ + .id = null, + .state = .pending_close, + .private = item.channel.private, + .point = item.channel.channel_point, + .closetxid = item.closing_txid, + .peer_pubkey = item.channel.remote_node_pub, + .peer_alias = "", // TODO: a cached getnodeinfo? + .capacity = item.channel.capacity, + .balance = .{ + .local = item.channel.local_balance, + .remote = item.channel.remote_balance, + .unsettled = 0, + .limbo = item.limbo_balance, + }, + .totalsats = .{ .sent = 0, .received = 0 }, + .fees = .{ .base = 0, .ppm = 0 }, + }); + } + for (pending.value.pending_force_closing_channels) |item| { + try channels.append(.{ + .id = null, + .state = .pending_close, + .private = item.channel.private, + .point = item.channel.channel_point, + .closetxid = item.closing_txid, + .peer_pubkey = item.channel.remote_node_pub, + .peer_alias = "", // TODO: a cached getnodeinfo? + .capacity = item.channel.capacity, + .balance = .{ + .local = item.channel.local_balance, + .remote = item.channel.remote_balance, + .unsettled = 0, + .limbo = item.limbo_balance, + }, + .totalsats = .{ .sent = 0, .received = 0 }, + .fees = .{ .base = 0, .ppm = 0 }, + }); + } + for (chanlist.value.channels) |ch| { + lndrep.totalbalance.local += ch.local_balance; + lndrep.totalbalance.remote += ch.remote_balance; + lndrep.totalbalance.unsettled += ch.unsettled_balance; + try channels.append(.{ + .id = ch.chan_id, + .state = if (ch.active) .active else .inactive, + .private = ch.private, + .point = ch.channel_point, + .closetxid = null, + .peer_pubkey = ch.remote_pubkey, + .peer_alias = ch.peer_alias, + .capacity = ch.capacity, + .balance = .{ + .local = ch.local_balance, + .remote = ch.remote_balance, + .unsettled = ch.unsettled_balance, + .limbo = 0, + }, + .totalsats = .{ + .sent = ch.total_satoshis_sent, + .received = ch.total_satoshis_received, + }, + .fees = if (feemap.get(ch.chan_id)) |v| .{ .base = v.base, .ppm = v.ppm } else .{ .base = 0, .ppm = 0 }, + }); + } + + lndrep.channels = channels.items; + try comm.write(self.allocator, self.uiwriter, .{ .lightning_report = lndrep }); +} + test "start-stop" { const t = std.testing; @@ -550,6 +708,7 @@ test "start-stop" { var daemon = try Daemon.init(t.allocator, pipe.reader(), pipe.writer(), "/dev/null"); daemon.want_network_report = false; daemon.want_bitcoind_report = false; + daemon.want_lnd_report = false; try t.expect(daemon.state == .stopped); try daemon.start(); @@ -594,6 +753,7 @@ test "start-poweroff" { var daemon = try Daemon.init(arena, gui_stdout.reader(), gui_stdin.writer(), "/dev/null"); daemon.want_network_report = false; daemon.want_bitcoind_report = false; + daemon.want_lnd_report = false; defer { daemon.deinit(); gui_stdin.close(); diff --git a/src/test/lndhc.zig b/src/test/lndhc.zig new file mode 100644 index 0000000..a9bcfbc --- /dev/null +++ b/src/test/lndhc.zig @@ -0,0 +1,43 @@ +const std = @import("std"); +const lndhttp = @import("lndhttp"); + +pub fn main() !void { + var gpa_state = std.heap.GeneralPurposeAllocator(.{}){}; + defer if (gpa_state.deinit() == .leak) { + std.debug.print("memory leaks detected!", .{}); + }; + const gpa = gpa_state.allocator(); + + var client = try lndhttp.Client.init(.{ + .allocator = gpa, + .tlscert_path = "/home/lnd/.lnd/tls.cert", + .macaroon_ro_path = "/ssd/lnd/data/chain/bitcoin/mainnet/readonly.macaroon", + }); + defer client.deinit(); + + { + const res = try client.call(.getinfo, {}); + defer res.deinit(); + std.debug.print("{any}\n", .{res.value}); + } + //{ + // const res = try client.call(.getnetworkinfo, {}); + // defer res.deinit(); + // std.debug.print("{any}\n", .{res.value}); + //} + //{ + // const res = try client.call(.listchannels, .{ .peer_alias_lookup = false }); + // defer res.deinit(); + // std.debug.print("{any}\n", .{res.value.channels}); + //} + //{ + // const res = try client.call(.walletstatus, {}); + // defer res.deinit(); + // std.debug.print("{s}\n", .{@tagName(res.value.state)}); + //} + //{ + // const res = try client.call(.feereport, {}); + // defer res.deinit(); + // std.debug.print("{any}\n", .{res.value}); + //} +} diff --git a/src/types.zig b/src/types.zig index c1d0265..ee4f392 100644 --- a/src/types.zig +++ b/src/types.zig @@ -79,3 +79,27 @@ pub const StringList = struct { return self.l.items; } }; + +pub fn Deinitable(comptime T: type) type { + return struct { + value: T, + arena: *std.heap.ArenaAllocator, + + const Self = @This(); + + pub fn init(allocator: std.mem.Allocator) !Self { + var res = Self{ + .arena = try allocator.create(std.heap.ArenaAllocator), + .value = undefined, + }; + res.arena.* = std.heap.ArenaAllocator.init(allocator); + return res; + } + + pub fn deinit(self: Self) void { + const allocator = self.arena.child_allocator; + self.arena.deinit(); + allocator.destroy(self.arena); + } + }; +}