From 52a8c1fb1aed275469cc7bb97970dbe25af57615 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 23 Aug 2023 11:16:11 +0200 Subject: [PATCH 1/2] 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); + } + }; +} -- 2.46.2 From 05c89bbd1c1538aa6094702e6aad224c1d36d662 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 23 Aug 2023 11:30:09 +0200 Subject: [PATCH 2/2] ui: visualize lnd lightning report on the tab panel similarly to 0260d477, the lightning tab has now some basic info including channels list. the gui playground is updated to send some stub data via comms periodically. --- src/ngui.zig | 21 ++++- src/test/guiplay.zig | 72 +++++++++++++++- src/ui/c/ui.c | 18 ++-- src/ui/lightning.zig | 198 +++++++++++++++++++++++++++++++++++++++++++ src/ui/ui.zig | 9 ++ src/xfmt.zig | 40 +++++++++ 6 files changed, 345 insertions(+), 13 deletions(-) create mode 100644 src/ui/lightning.zig diff --git a/src/ngui.zig b/src/ngui.zig index 0055b14..01682e2 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -42,6 +42,7 @@ var last_report: struct { mu: std.Thread.Mutex = .{}, network: ?comm.ParsedMessage = null, // NetworkReport bitcoind: ?comm.ParsedMessage = null, // BitcoinReport + lightning: ?comm.ParsedMessage = null, // LightningReport fn deinit(self: *@This()) void { self.mu.lock(); @@ -54,6 +55,10 @@ var last_report: struct { v.deinit(); self.bitcoind = null; } + if (self.lightning) |v| { + v.deinit(); + self.lightning = null; + } } fn replace(self: *@This(), new: comm.ParsedMessage) void { @@ -73,6 +78,12 @@ var last_report: struct { } self.bitcoind = new; }, + .lightning_report => { + if (self.lightning) |old| { + old.deinit(); + } + self.lightning = new; + }, else => |t| logger.err("last_report: replace: unhandled tag {}", .{t}), } } @@ -220,8 +231,10 @@ fn commThreadLoopCycle() !void { switch (state) { .standby => switch (msg.value) { .ping => try comm.write(gpa, stdout, comm.Message.pong), - .network_report => last_report.replace(msg), - .bitcoind_report => last_report.replace(msg), + .network_report, + .bitcoind_report, + .lightning_report, + => last_report.replace(msg), else => logger.debug("ignoring {s}: in standby", .{@tagName(msg.value)}), }, .active, .alert => switch (msg.value) { @@ -238,6 +251,10 @@ fn commThreadLoopCycle() !void { ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err}); last_report.replace(msg); }, + .lightning_report => |rep| { + ui.lightning.updateTabPanel(rep) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); + last_report.replace(msg); + }, else => logger.warn("unhandled msg tag {s}", .{@tagName(msg.value)}), }, } diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig index 29e3999..d8802e5 100644 --- a/src/test/guiplay.zig +++ b/src/test/guiplay.zig @@ -128,7 +128,7 @@ fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { var sectimer = try time.Timer.start(); - var block_count: u64 = 801365; + var block_count: u32 = 801365; while (true) { time.sleep(time.ns_per_s); @@ -164,6 +164,76 @@ fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { }, }; comm.write(gpa, w, .{ .bitcoind_report = btcrep }) catch |err| logger.err("comm.write: {any}", .{err}); + + if (block_count % 2 == 0) { + const lndrep: comm.Message.LightningReport = .{ + .version = "0.16.4-beta commit=v0.16.4-beta", + .pubkey = "142874abcdeadbeef8839bdfaf8439fac9b0327bf78acdee8928efbac982de822a", + .alias = "testnode", + .npeers = 15, + .height = block_count, + .hash = "00000000000000000002bf8029f6be4e40b4a3e0e161b6a1044ddaf9eb126504", + .sync = .{ .chain = true, .graph = true }, + .uris = &.{}, // TODO + .totalbalance = .{ .local = 10123567, .remote = 4239870, .unsettled = 0, .pending = 430221 }, + .totalfees = .{ .day = 13, .week = 132, .month = 1321 }, + .channels = &.{ + .{ + .id = null, + .state = .pending_open, + .private = false, + .point = "1b332afe982befbdcbadff33099743099eef00bcdbaef788320db328efeaa91b:0", + .closetxid = null, + .peer_pubkey = "def3829fbdeadbeef8839bdfaf8439fac9b0327bf78acdee8928efbac229aaabc2", + .peer_alias = "chan-peer-alias1", + .capacity = 900000, + .balance = .{ .local = 1123456, .remote = 0, .unsettled = 0, .limbo = 0 }, + .totalsats = .{ .sent = 0, .received = 0 }, + .fees = .{ .base = 0, .ppm = 0 }, + }, + .{ + .id = null, + .state = .pending_close, + .private = false, + .point = "932baef3982befbdcbadff33099743099eef00bcdbaef788320db328e82afdd7:0", + .closetxid = "fe829832982befbdcbadff33099743099eef00bcdbaef788320db328eaffeb2b", + .peer_pubkey = "01feba38fe8adbeef8839bdfaf8439fac9b0327bf78acdee8928efbac2abfec831", + .peer_alias = "chan-peer-alias2", + .capacity = 800000, + .balance = .{ .local = 10000, .remote = 788000, .unsettled = 0, .limbo = 10000 }, + .totalsats = .{ .sent = 0, .received = 0 }, + .fees = .{ .base = 0, .ppm = 0 }, + }, + .{ + .id = "848352385882718209", + .state = .active, + .private = false, + .point = "36277666abcbefbdcbadff33099743099eef00bcdbaef788320db328e828e00d:1", + .closetxid = null, + .peer_pubkey = "e7287abcfdeadbeef8839bdfaf8439fac9b0327bf78acdee8928efbac229acddbe", + .peer_alias = "chan-peer-alias3", + .capacity = 1000000, + .balance = .{ .local = 1000000 / 2, .remote = 1000000 / 2, .unsettled = 0, .limbo = 0 }, + .totalsats = .{ .sent = 3287320, .received = 2187482 }, + .fees = .{ .base = 1000, .ppm = 400 }, + }, + .{ + .id = "134439885882718428", + .state = .inactive, + .private = false, + .point = "abafe483982befbdcbadff33099743099eef00bcdbaef788320db328e828339c:0", + .closetxid = null, + .peer_pubkey = "20398287fdeadbeef8839bdfaf8439fac9b0327bf78acdee8928efbac229a03928", + .peer_alias = "chan-peer-alias4", + .capacity = 900000, + .balance = .{ .local = 900000, .remote = 0, .unsettled = 0, .limbo = 0 }, + .totalsats = .{ .sent = 328732, .received = 2187482 }, + .fees = .{ .base = 1000, .ppm = 500 }, + }, + }, + }; + comm.write(gpa, w, .{ .lightning_report = lndrep }) catch |err| logger.err("comm.write: {any}", .{err}); + } } } diff --git a/src/ui/c/ui.c b/src/ui/c/ui.c index bb12fb9..eb28f4c 100644 --- a/src/ui/c/ui.c +++ b/src/ui/c/ui.c @@ -25,6 +25,11 @@ int nm_create_info_panel(lv_obj_t *parent); */ int nm_create_bitcoin_panel(lv_obj_t *parent); +/** + * creates the lightning tab panel. + */ +int nm_create_lightning_panel(lv_obj_t *parent); + /** * invoken when the UI is switched to the network settings tab. */ @@ -108,15 +113,6 @@ static void textarea_event_cb(lv_event_t *e) } } -static void create_lnd_panel(lv_obj_t *parent) -{ - lv_obj_t *label = lv_label_create(parent); - lv_label_set_text_static(label, - "lightning tab isn't designed yet\n" - "follow https://nakamochi.io"); - lv_obj_center(label); -} - static struct { lv_obj_t *wifi_spinner_obj; /* lv_spinner_create */ lv_obj_t *wifi_status_obj; /* lv_label_create */ @@ -342,7 +338,9 @@ extern int nm_ui_init(lv_disp_t *disp) if (tab_lnd == NULL) { return -1; } - create_lnd_panel(tab_lnd); + if (nm_create_lightning_panel(tab_lnd) != 0) { + return -1; + } lv_obj_t *tab_settings = lv_tabview_add_tab(tabview, LV_SYMBOL_SETTINGS " SETTINGS"); if (tab_settings == NULL) { diff --git a/src/ui/lightning.zig b/src/ui/lightning.zig new file mode 100644 index 0000000..3b7100a --- /dev/null +++ b/src/ui/lightning.zig @@ -0,0 +1,198 @@ +//! lightning main tab panel and other functionality. +//! all functions assume LVGL is init'ed and ui mutex is locked on entry. + +const std = @import("std"); + +const comm = @import("../comm.zig"); +const lvgl = @import("lvgl.zig"); +const xfmt = @import("../xfmt.zig"); + +const logger = std.log.scoped(.ui_lnd); +/// label color mark start to make "label:" part of a "label: value" +/// in a different color. +const cmark = "#bbbbbb "; + +var tab: struct { + info: struct { + alias: lvgl.Label, + blockhash: lvgl.Label, + currblock: lvgl.Label, + npeers: lvgl.Label, + pubkey: lvgl.Label, + version: lvgl.Label, + }, + balance: struct { + avail: lvgl.Bar, // local vs remote + local: lvgl.Label, + remote: lvgl.Label, + unsettled: lvgl.Label, + pending: lvgl.Label, + fees: lvgl.Label, // day, week, month + }, + channels_cont: lvgl.FlexLayout, +} = undefined; + +/// creates the tab content with all elements. +/// must be called only once at UI init. +pub fn initTabPanel(cont: lvgl.Container) !void { + const parent = cont.flex(.column, .{}); + + // info section + { + const card = try lvgl.Card.new(parent, "INFO"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setHeightToContent(); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + // left column + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setHeightToContent(); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(10, .row, .{}); + tab.info.alias = try lvgl.Label.new(left, "ALIAS\n", .{ .recolor = true }); + tab.info.pubkey = try lvgl.Label.new(left, "PUBKEY\n", .{ .recolor = true }); + tab.info.version = try lvgl.Label.new(left, "VERSION\n", .{ .recolor = true }); + // right column + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setHeightToContent(); + right.setWidth(lvgl.sizePercent(50)); + right.setPad(10, .row, .{}); + tab.info.currblock = try lvgl.Label.new(right, "HEIGHT\n", .{ .recolor = true }); + tab.info.blockhash = try lvgl.Label.new(right, "BLOCK HASH\n", .{ .recolor = true }); + tab.info.npeers = try lvgl.Label.new(right, "CONNECTED PEERS\n", .{ .recolor = true }); + } + // balance section + { + const card = try lvgl.Card.new(parent, "BALANCE"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + // left column + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(10, .row, .{}); + tab.balance.avail = try lvgl.Bar.new(left); + tab.balance.avail.setWidth(lvgl.sizePercent(90)); + const subrow = try lvgl.FlexLayout.new(left, .row, .{ .main = .space_between }); + subrow.setWidth(lvgl.sizePercent(90)); + subrow.setHeightToContent(); + tab.balance.local = try lvgl.Label.new(subrow, "LOCAL\n", .{ .recolor = true }); + tab.balance.remote = try lvgl.Label.new(subrow, "REMOTE\n", .{ .recolor = true }); + // right column + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setWidth(lvgl.sizePercent(50)); + right.setPad(10, .row, .{}); + tab.balance.pending = try lvgl.Label.new(right, "PENDING\n", .{ .recolor = true }); + tab.balance.unsettled = try lvgl.Label.new(right, "UNSETTLED\n", .{ .recolor = true }); + // bottom + tab.balance.fees = try lvgl.Label.new(card, "ACCUMULATED FORWARDING FEES\n", .{ .recolor = true }); + } + // channels section + { + const card = try lvgl.Card.new(parent, "CHANNELS"); + tab.channels_cont = try lvgl.FlexLayout.new(card, .column, .{}); + tab.channels_cont.setHeightToContent(); + tab.channels_cont.setWidth(lvgl.sizePercent(100)); + tab.channels_cont.clearFlag(.scrollable); + tab.channels_cont.setPad(10, .row, .{}); + } +} + +/// updates the tab with new data from the report. +/// the tab must be inited first with initTabPanel. +pub fn updateTabPanel(rep: comm.Message.LightningReport) !void { + var buf: [512]u8 = undefined; + + // info section + try tab.info.alias.setTextFmt(&buf, cmark ++ "ALIAS#\n{s}", .{rep.alias}); + try tab.info.pubkey.setTextFmt(&buf, cmark ++ "PUBKEY#\n{s}\n{s}", .{ rep.pubkey[0..33], rep.pubkey[33..] }); + try tab.info.version.setTextFmt(&buf, cmark ++ "VERSION#\n{s}", .{rep.version}); + try tab.info.currblock.setTextFmt(&buf, cmark ++ "HEIGHT#\n{d}", .{rep.height}); + try tab.info.blockhash.setTextFmt(&buf, cmark ++ "BLOCK HASH#\n{s}\n{s}", .{ rep.hash[0..32], rep.hash[32..] }); + try tab.info.npeers.setTextFmt(&buf, cmark ++ "CONNECTED PEERS#\n{d}", .{rep.npeers}); + + // balance section + const local_pct: i32 = pct: { + const total = rep.totalbalance.local + rep.totalbalance.remote; + if (total == 0) { + break :pct 0; + } + const v = @as(f64, @floatFromInt(rep.totalbalance.local)) / @as(f64, @floatFromInt(total)); + break :pct @intFromFloat(v * 100); + }; + tab.balance.avail.setValue(local_pct); + try tab.balance.local.setTextFmt(&buf, cmark ++ "LOCAL#\n{} sat", .{xfmt.imetric(rep.totalbalance.local)}); + try tab.balance.remote.setTextFmt(&buf, cmark ++ "REMOTE#\n{} sat", .{xfmt.imetric(rep.totalbalance.remote)}); + try tab.balance.pending.setTextFmt(&buf, cmark ++ "PENDING#\n{} sat", .{xfmt.imetric(rep.totalbalance.pending)}); + try tab.balance.unsettled.setTextFmt(&buf, cmark ++ "UNSETTLED#\n{}", .{xfmt.imetric(rep.totalbalance.unsettled)}); + try tab.balance.fees.setTextFmt(&buf, cmark ++ "ACCUMULATED FORWARDING FEES#\nDAY: {} sat WEEK: {} sat MONTH: {} sat", .{ + xfmt.umetric(rep.totalfees.day), + xfmt.umetric(rep.totalfees.week), + xfmt.umetric(rep.totalfees.month), + }); + + // channels section + tab.channels_cont.deleteChildren(); + for (rep.channels) |ch| { + const chbox = (try lvgl.Container.new(tab.channels_cont)).flex(.column, .{}); + chbox.setWidth(lvgl.sizePercent(100)); + chbox.setHeightToContent(); + _ = try switch (ch.state) { + // TODO: sanitize peer_alias? + .active => lvgl.Label.newFmt(chbox, &buf, "{s}", .{ch.peer_alias}, .{}), + .inactive => lvgl.Label.newFmt(chbox, &buf, "#ff0000 [INACTIVE]# {s}", .{ch.peer_alias}, .{ .recolor = true }), + .pending_open => lvgl.Label.new(chbox, "#00ff00 [PENDING OPEN]#", .{ .recolor = true }), + .pending_close => lvgl.Label.new(chbox, "#ffff00 [PENDING CLOSE]#", .{ .recolor = true }), + }; + const row = try lvgl.FlexLayout.new(chbox, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + row.setHeightToContent(); + + // left column + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(46)); + left.setHeightToContent(); + left.setPad(10, .row, .{}); + const bbar = try lvgl.Bar.new(left); + bbar.setWidth(lvgl.sizePercent(100)); + const chan_local_pct: i32 = pct: { + const total = ch.balance.local + ch.balance.remote; + if (total == 0) { + break :pct 0; + } + const v = @as(f64, @floatFromInt(ch.balance.local)) / @as(f64, @floatFromInt(total)); + break :pct @intFromFloat(v * 100); + }; + bbar.setValue(chan_local_pct); + const subrow = try lvgl.FlexLayout.new(left, .row, .{ .main = .space_between }); + subrow.setWidth(lvgl.sizePercent(100)); + subrow.setHeightToContent(); + const subcol1 = try lvgl.FlexLayout.new(subrow, .column, .{}); + subcol1.setPad(10, .row, .{}); + subcol1.setHeightToContent(); + const subcol2 = try lvgl.FlexLayout.new(subrow, .column, .{}); + subcol2.setPad(10, .row, .{}); + _ = try lvgl.Label.newFmt(subcol1, &buf, cmark ++ "LOCAL#\n{} sat", .{xfmt.imetric(ch.balance.local)}, .{ .recolor = true }); + _ = try lvgl.Label.newFmt(subcol1, &buf, cmark ++ "RECEIVED#\n{} sat", .{xfmt.imetric(ch.totalsats.received)}, .{ .recolor = true }); + if (ch.state == .active or ch.state == .inactive) { + _ = try lvgl.Label.newFmt(subcol1, &buf, cmark ++ "BASE FEE#\n{} msat", .{xfmt.imetric(ch.fees.base)}, .{ .recolor = true }); + _ = try lvgl.Label.newFmt(subcol1, &buf, cmark ++ "FEE PPM#\n{d}", .{ch.fees.ppm}, .{ .recolor = true }); + } + _ = try lvgl.Label.newFmt(subcol2, &buf, cmark ++ "REMOTE#\n{} sat", .{xfmt.imetric(ch.balance.remote)}, .{ .recolor = true }); + _ = try lvgl.Label.newFmt(subcol2, &buf, cmark ++ "SENT#\n{} sat", .{xfmt.imetric(ch.totalsats.sent)}, .{ .recolor = true }); + + // right column + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setWidth(lvgl.sizePercent(54)); + right.setHeightToContent(); + right.setPad(10, .row, .{}); + if (ch.id) |id| { + _ = try lvgl.Label.newFmt(right, &buf, cmark ++ "ID#\n{s}", .{id}, .{ .recolor = true }); + } + _ = try lvgl.Label.newFmt(right, &buf, cmark ++ "FUNDING TX#\n{s}\n{s}", .{ ch.point[0..32], ch.point[32..] }, .{ .recolor = true }); + if (ch.closetxid) |tx| { + _ = try lvgl.Label.newFmt(right, &buf, cmark ++ "CLOSING TX#\n{s}\n{s}", .{ tx[0..32], tx[32..] }, .{ .recolor = true }); + } + } +} diff --git a/src/ui/ui.zig b/src/ui/ui.zig index e23f37b..5940bb9 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -8,6 +8,7 @@ const symbol = @import("symbol.zig"); const widget = @import("widget.zig"); pub const poweroff = @import("poweroff.zig"); pub const bitcoin = @import("bitcoin.zig"); +pub const lightning = @import("lightning.zig"); const logger = std.log.scoped(.ui); @@ -43,6 +44,14 @@ export fn nm_create_bitcoin_panel(parent: *lvgl.LvObj) c_int { return 0; } +export fn nm_create_lightning_panel(parent: *lvgl.LvObj) c_int { + lightning.initTabPanel(lvgl.Container{ .lvobj = parent }) catch |err| { + logger.err("createLightningPanel: {any}", .{err}); + return -1; + }; + return 0; +} + fn createInfoPanel(cont: lvgl.Container) !void { const flex = cont.flex(.column, .{}); var buf: [100]u8 = undefined; diff --git a/src/xfmt.zig b/src/xfmt.zig index db89cf4..0460f86 100644 --- a/src/xfmt.zig +++ b/src/xfmt.zig @@ -8,6 +8,16 @@ pub fn unix(sec: u64) std.fmt.Formatter(formatUnix) { return .{ .data = sec }; } +/// returns a metric formatter, outputting the value with SI unit suffix. +pub fn imetric(val: i64) std.fmt.Formatter(formatMetricI) { + return .{ .data = val }; +} + +/// returns a metric formatter, outputting the value with SI unit suffix. +pub fn umetric(val: u64) std.fmt.Formatter(formatMetricU) { + return .{ .data = val }; +} + fn formatUnix(sec: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void { _ = fmt; // unused _ = opts; @@ -29,3 +39,33 @@ fn formatUnix(sec: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w daysec.getSecondsIntoMinute(), }); } + +fn formatMetricI(value: i64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void { + const uval: u64 = std.math.absCast(value); + const base: u64 = 1000; + if (uval < base) { + return std.fmt.formatIntValue(value, fmt, opts, w); + } + + if (value < 0) { + try w.writeByte('-'); + } + return formatMetricU(uval, fmt, opts, w); +} + +/// based on `std.fmt.fmtIntSizeDec`. +fn formatMetricU(value: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void { + const lossyCast = std.math.lossyCast; + const base: u64 = 1000; + if (value < base) { + return std.fmt.formatIntValue(value, fmt, opts, w); + } + + const mags_si = " kMGTPEZY"; + const log2 = std.math.log2(value); + const m = @min(log2 / comptime std.math.log2(base), mags_si.len - 1); + const newval = lossyCast(f64, value) / std.math.pow(f64, lossyCast(f64, base), lossyCast(f64, m)); + const suffix = mags_si[m]; + try std.fmt.formatFloatDecimal(newval, opts, w); + try w.writeByte(suffix); +} -- 2.46.2