From 52a8c1fb1aed275469cc7bb97970dbe25af57615 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 23 Aug 2023 11:16:11 +0200 Subject: [PATCH] nd: add lnd lightning report sent to UI every min similarly to 2642a554, this adds an lnd HTTP client able to make some queries like getinfo. the daemon then uses the client to compose a lightning status report and sends it over to ngui through comms, periodically. there's also a client playground built on demand with "zig build lndhc". --- build.zig | 15 +++ src/comm.zig | 38 +++++- src/lndhttp.zig | 327 +++++++++++++++++++++++++++++++++++++++++++++ src/nd/Daemon.zig | 160 ++++++++++++++++++++++ src/test/lndhc.zig | 43 ++++++ src/types.zig | 24 ++++ 6 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 src/lndhttp.zig create mode 100644 src/test/lndhc.zig 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); + } + }; +}