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".
pull/26/head
parent
328df67c5d
commit
52a8c1fb1a
@ -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,
|
||||||
|
},
|
||||||
|
};
|
@ -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});
|
||||||
|
//}
|
||||||
|
}
|
Reference in New Issue