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/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/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); + } + }; +} 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); +}