lightning tab #26

Manually merged
x1ddos merged 2 commits from lnd into master 1 year ago

@ -156,6 +156,21 @@ pub fn build(b: *std.Build) void {
btcrpc_build_step.dependOn(&b.addInstallArtifact(btcrpc, .{}).step); 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 // default build step
const build_all_step = b.step("all", "build nd and ngui (default step)"); const build_all_step = b.step("all", "build nd and ngui (default step)");
build_all_step.dependOn(ngui_build_step); build_all_step.dependOn(ngui_build_step);

@ -28,6 +28,7 @@ pub const Message = union(MessageTag) {
get_network_report: GetNetworkReport, get_network_report: GetNetworkReport,
poweroff_progress: PoweroffProgress, poweroff_progress: PoweroffProgress,
bitcoind_report: BitcoindReport, bitcoind_report: BitcoindReport,
lightning_report: LightningReport,
pub const WifiConnect = struct { pub const WifiConnect = struct {
ssid: []const u8, ssid: []const u8,
@ -81,6 +82,38 @@ pub const Message = union(MessageTag) {
fullrbf: bool, 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, /// it is important to preserve ordinal values for future compatiblity,
@ -100,7 +133,9 @@ pub const MessageTag = enum(u16) {
poweroff_progress = 0x09, poweroff_progress = 0x09,
// nd -> ngui: bitcoin core daemon status report // nd -> ngui: bitcoin core daemon status report
bitcoind_report = 0x0a, bitcoind_report = 0x0a,
// next: 0x0b // nd -> ngui: lnd status and stats report
lightning_report = 0x0b,
// next: 0x0c
}; };
/// the return value type from `read` fn. /// 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()), .get_network_report => try json.stringify(msg.get_network_report, .{}, data.writer()),
.poweroff_progress => try json.stringify(msg.poweroff_progress, .{}, data.writer()), .poweroff_progress => try json.stringify(msg.poweroff_progress, .{}, data.writer()),
.bitcoind_report => try json.stringify(msg.bitcoind_report, .{}, 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)) { if (data.items.len > std.math.maxInt(u64)) {
return Error.CommWriteTooLarge; return Error.CommWriteTooLarge;

@ -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,
},
};

@ -21,6 +21,7 @@ const screen = @import("../ui/screen.zig");
const types = @import("../types.zig"); const types = @import("../types.zig");
const SysService = @import("SysService.zig"); const SysService = @import("SysService.zig");
const bitcoindrpc = @import("bitcoindrpc.zig"); const bitcoindrpc = @import("bitcoindrpc.zig");
const lndhttp = @import("../lndhttp.zig");
const logger = std.log.scoped(.daemon); const logger = std.log.scoped(.daemon);
@ -55,6 +56,10 @@ wpa_save_config_on_connected: bool = false,
want_bitcoind_report: bool, want_bitcoind_report: bool,
bitcoin_timer: time.Timer, bitcoin_timer: time.Timer,
bitcoin_report_interval: u64 = 1 * time.ns_per_min, 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. /// system services actively managed by the daemon.
/// these are stop'ed during poweroff and their shutdown progress sent to ngui. /// 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 // report bitcoind status immediately on start
.want_bitcoind_report = true, .want_bitcoind_report = true,
.bitcoin_timer = try time.Timer.start(), .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}); 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. /// 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 }); 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" { test "start-stop" {
const t = std.testing; const t = std.testing;
@ -550,6 +708,7 @@ test "start-stop" {
var daemon = try Daemon.init(t.allocator, pipe.reader(), pipe.writer(), "/dev/null"); var daemon = try Daemon.init(t.allocator, pipe.reader(), pipe.writer(), "/dev/null");
daemon.want_network_report = false; daemon.want_network_report = false;
daemon.want_bitcoind_report = false; daemon.want_bitcoind_report = false;
daemon.want_lnd_report = false;
try t.expect(daemon.state == .stopped); try t.expect(daemon.state == .stopped);
try daemon.start(); try daemon.start();
@ -594,6 +753,7 @@ test "start-poweroff" {
var daemon = try Daemon.init(arena, gui_stdout.reader(), gui_stdin.writer(), "/dev/null"); var daemon = try Daemon.init(arena, gui_stdout.reader(), gui_stdin.writer(), "/dev/null");
daemon.want_network_report = false; daemon.want_network_report = false;
daemon.want_bitcoind_report = false; daemon.want_bitcoind_report = false;
daemon.want_lnd_report = false;
defer { defer {
daemon.deinit(); daemon.deinit();
gui_stdin.close(); gui_stdin.close();

@ -42,6 +42,7 @@ var last_report: struct {
mu: std.Thread.Mutex = .{}, mu: std.Thread.Mutex = .{},
network: ?comm.ParsedMessage = null, // NetworkReport network: ?comm.ParsedMessage = null, // NetworkReport
bitcoind: ?comm.ParsedMessage = null, // BitcoinReport bitcoind: ?comm.ParsedMessage = null, // BitcoinReport
lightning: ?comm.ParsedMessage = null, // LightningReport
fn deinit(self: *@This()) void { fn deinit(self: *@This()) void {
self.mu.lock(); self.mu.lock();
@ -54,6 +55,10 @@ var last_report: struct {
v.deinit(); v.deinit();
self.bitcoind = null; self.bitcoind = null;
} }
if (self.lightning) |v| {
v.deinit();
self.lightning = null;
}
} }
fn replace(self: *@This(), new: comm.ParsedMessage) void { fn replace(self: *@This(), new: comm.ParsedMessage) void {
@ -73,6 +78,12 @@ var last_report: struct {
} }
self.bitcoind = new; self.bitcoind = new;
}, },
.lightning_report => {
if (self.lightning) |old| {
old.deinit();
}
self.lightning = new;
},
else => |t| logger.err("last_report: replace: unhandled tag {}", .{t}), else => |t| logger.err("last_report: replace: unhandled tag {}", .{t}),
} }
} }
@ -220,8 +231,10 @@ fn commThreadLoopCycle() !void {
switch (state) { switch (state) {
.standby => switch (msg.value) { .standby => switch (msg.value) {
.ping => try comm.write(gpa, stdout, comm.Message.pong), .ping => try comm.write(gpa, stdout, comm.Message.pong),
.network_report => last_report.replace(msg), .network_report,
.bitcoind_report => last_report.replace(msg), .bitcoind_report,
.lightning_report,
=> last_report.replace(msg),
else => logger.debug("ignoring {s}: in standby", .{@tagName(msg.value)}), else => logger.debug("ignoring {s}: in standby", .{@tagName(msg.value)}),
}, },
.active, .alert => switch (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}); ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err});
last_report.replace(msg); 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)}), else => logger.warn("unhandled msg tag {s}", .{@tagName(msg.value)}),
}, },
} }

@ -128,7 +128,7 @@ fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void {
fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void {
var sectimer = try time.Timer.start(); var sectimer = try time.Timer.start();
var block_count: u64 = 801365; var block_count: u32 = 801365;
while (true) { while (true) {
time.sleep(time.ns_per_s); 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}); 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});
}
} }
} }

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

@ -79,3 +79,27 @@ pub const StringList = struct {
return self.l.items; 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);
}
};
}

@ -25,6 +25,11 @@ int nm_create_info_panel(lv_obj_t *parent);
*/ */
int nm_create_bitcoin_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. * 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 { static struct {
lv_obj_t *wifi_spinner_obj; /* lv_spinner_create */ lv_obj_t *wifi_spinner_obj; /* lv_spinner_create */
lv_obj_t *wifi_status_obj; /* lv_label_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) { if (tab_lnd == NULL) {
return -1; 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"); lv_obj_t *tab_settings = lv_tabview_add_tab(tabview, LV_SYMBOL_SETTINGS " SETTINGS");
if (tab_settings == NULL) { if (tab_settings == NULL) {

@ -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 });
}
}
}

@ -8,6 +8,7 @@ const symbol = @import("symbol.zig");
const widget = @import("widget.zig"); const widget = @import("widget.zig");
pub const poweroff = @import("poweroff.zig"); pub const poweroff = @import("poweroff.zig");
pub const bitcoin = @import("bitcoin.zig"); pub const bitcoin = @import("bitcoin.zig");
pub const lightning = @import("lightning.zig");
const logger = std.log.scoped(.ui); const logger = std.log.scoped(.ui);
@ -43,6 +44,14 @@ export fn nm_create_bitcoin_panel(parent: *lvgl.LvObj) c_int {
return 0; 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 { fn createInfoPanel(cont: lvgl.Container) !void {
const flex = cont.flex(.column, .{}); const flex = cont.flex(.column, .{});
var buf: [100]u8 = undefined; var buf: [100]u8 = undefined;

@ -8,6 +8,16 @@ pub fn unix(sec: u64) std.fmt.Formatter(formatUnix) {
return .{ .data = sec }; 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 { fn formatUnix(sec: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void {
_ = fmt; // unused _ = fmt; // unused
_ = opts; _ = opts;
@ -29,3 +39,33 @@ fn formatUnix(sec: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w
daysec.getSecondsIntoMinute(), 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);
}