From 7ae18c17e453f3ef100ce9812d06dd5064de7ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Tue, 6 Oct 2026 10:51:26 -0400 Subject: [PATCH 1/3] Report ambiguous payments as unknown, add LNbits deadlines and wipe secrets --- README.md | 8 +- src/bolt11.zig | 130 ++++++++++++++ src/config.zig | 12 +- src/lnbits.zig | 476 +++++++++++++++++++++++++++++++++++++++---------- src/main.zig | 216 +++++++++++++++------- src/relay.zig | 26 +++ 6 files changed, 713 insertions(+), 155 deletions(-) create mode 100644 src/bolt11.zig diff --git a/README.md b/README.md index cee49da..0ffa9d5 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,13 @@ nostr+walletconnect://?relay=&secret= Add the public key of `CLIENT_SECRET` (hex or npub, for example from `nak key public `) to `client_pubkeys`, one entry per connection, and restart Puck. Requests from any other key are ignored. Puck does not store the secret. -Requests must use NIP-44 (`["encryption", "nip44_v2"]`); NIP-04 requests get an `UNSUPPORTED_ENCRYPTION` error. Requests older than 10 minutes, past their `expiration` tag, or already seen are ignored. +Requests must use NIP-44 (`["encryption", "nip44_v2"]`); NIP-04 requests get an `UNSUPPORTED_ENCRYPTION` error, and requests with any other `encryption` tag get an `UNSUPPORTED_ENCRYPTION` error encrypted with NIP-44 and an empty `result_type`. Requests older than 10 minutes, past their `expiration` tag, or already seen are ignored. The record of seen requests is kept in memory only, so requests created more than 60 seconds before Puck started are ignored as well and get no reply; the client must send a new request. + +## Payment Outcomes + +`pay_invoice` reports `PAYMENT_FAILED` only when LNbits says the payment failed or was never attempted (for example insufficient balance, an invalid invoice, or LNbits unreachable). When the outcome is unclear (a timeout, a dropped connection, a 5xx or unreadable response, or a payment LNbits still lists as pending), Puck looks the payment up by the invoice's payment hash for about 15 seconds. It then replies with the preimage if the payment went through, `PAYMENT_FAILED` if LNbits marked it failed, and otherwise an `INTERNAL` error whose message says the status is unknown. Treat that error as "may still complete": check `lookup_invoice` before paying again. + +Every LNbits call has a deadline: 30 seconds for paying, 10 seconds for everything else. Puck handles one request at a time, so a payment whose outcome stays unclear holds up other requests for up to about 95 seconds in the worst case. The relay connection uses TCP keepalive; on Linux a relay that disappears without closing the connection is detected within about 90 seconds and Puck reconnects. Use with Alby, Amethyst, Damus, or any NWC-compatible app. diff --git a/src/bolt11.zig b/src/bolt11.zig new file mode 100644 index 0000000..8bec41f --- /dev/null +++ b/src/bolt11.zig @@ -0,0 +1,130 @@ +const std = @import("std"); + +pub const Error = error{InvalidInvoice}; + +const charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const timestamp_len = 7; +const signature_len = 104; +const checksum_len = 6; +const hash_field_len = 52; +const tag_payment_hash = 1; + +fn charValue(c: u8) ?u5 { + const lower = std.ascii.toLower(c); + return for (charset, 0..) |ch, i| { + if (ch == lower) break @intCast(i); + } else null; +} + +fn polymodStep(chk: u32, value: u5) u32 { + const gen = [_]u32{ 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3 }; + const top = chk >> 25; + var next = ((chk & 0x1ffffff) << 5) ^ value; + inline for (0..5) |i| { + if ((top >> i) & 1 == 1) next ^= gen[i]; + } + return next; +} + +/// Payment hash of a BOLT 11 invoice: the first `p` field with the required +/// length of 52 groups. Verifies the bech32 checksum (without the 90 character +/// limit, which BOLT 11 lifts) but not the signature. +pub fn paymentHash(invoice: []const u8) Error![32]u8 { + var has_lower = false; + var has_upper = false; + for (invoice) |c| { + if (c < 33 or c > 126) return error.InvalidInvoice; + if (std.ascii.isLower(c)) has_lower = true; + if (std.ascii.isUpper(c)) has_upper = true; + } + if (has_lower and has_upper) return error.InvalidInvoice; + + const sep = std.mem.lastIndexOfScalar(u8, invoice, '1') orelse return error.InvalidInvoice; + const hrp = invoice[0..sep]; + const data = invoice[sep + 1 ..]; + if (hrp.len < 3 or !std.ascii.eqlIgnoreCase(hrp[0..2], "ln")) return error.InvalidInvoice; + if (data.len < timestamp_len + signature_len + checksum_len) return error.InvalidInvoice; + + var chk: u32 = 1; + for (hrp) |c| chk = polymodStep(chk, @truncate(std.ascii.toLower(c) >> 5)); + chk = polymodStep(chk, 0); + for (hrp) |c| chk = polymodStep(chk, @truncate(std.ascii.toLower(c) & 31)); + for (data) |c| chk = polymodStep(chk, charValue(c) orelse return error.InvalidInvoice); + if (chk != 1) return error.InvalidInvoice; + + const fields = data[timestamp_len .. data.len - checksum_len - signature_len]; + var pos: usize = 0; + while (pos < fields.len) { + if (fields.len - pos < 3) return error.InvalidInvoice; + const tag = charValue(fields[pos]).?; + const len = @as(usize, charValue(fields[pos + 1]).?) * 32 + charValue(fields[pos + 2]).?; + pos += 3; + if (fields.len - pos < len) return error.InvalidInvoice; + if (tag == tag_payment_hash and len == hash_field_len) { + return decodeHash(fields[pos..][0..hash_field_len]); + } + pos += len; + } + return error.InvalidInvoice; +} + +fn decodeHash(groups: *const [hash_field_len]u8) [32]u8 { + var out: [32]u8 = undefined; + var acc: u32 = 0; + var bits: u5 = 0; + var i: usize = 0; + for (groups) |c| { + acc = (acc << 5) | charValue(c).?; + bits += 5; + if (bits >= 8) { + bits -= 8; + if (i < out.len) out[i] = @truncate(acc >> bits); + i += 1; + } + acc &= (@as(u32, 1) << bits) - 1; + } + return out; +} + +const testing = std.testing; + +fn expectHash(want_hex: []const u8, invoice: []const u8) !void { + var want: [32]u8 = undefined; + _ = try std.fmt.hexToBytes(&want, want_hex); + try testing.expectEqualSlices(u8, &want, &try paymentHash(invoice)); +} + +const sequential_hash = "0001020304050607080900010203040506070809000102030405060708090102"; + +test "paymentHash reads BOLT 11 test vectors" { + try expectHash(sequential_hash, "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql"); + try expectHash(sequential_hash, "lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8"); + try expectHash("462264ede7e14047e9b249da94fefc47f41f7d02ee9b091815a5506bc8abf75f", "lnbc9678785340p1pwmna7lpp5gc3xfm08u9qy06djf8dfflhugl6p7lgza6dsjxq454gxhj9t7a0sd8dgfkx7cmtwd68yetpd5s9xar0wfjn5gpc8qhrsdfq24f5ggrxdaezqsnvda3kkum5wfjkzmfqf3jkgem9wgsyuctwdus9xgrcyqcjcgpzgfskx6eqf9hzqnteypzxz7fzypfhg6trddjhygrcyqezcgpzfysywmm5ypxxjemgw3hxjmn8yptk7untd9hxwg3q2d6xjcmtv4ezq7pqxgsxzmnyyqcjqmt0wfjjq6t5v4khxsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsxqyjw5qcqp2rzjq0gxwkzc8w6323m55m4jyxcjwmy7stt9hwkwe2qxmy8zpsgg7jcuwz87fcqqeuqqqyqqqqlgqqqqn3qq9q9qrsgqrvgkpnmps664wgkp43l22qsgdw4ve24aca4nymnxddlnp8vh9v2sdxlu5ywdxefsfvm0fq3sesf08uf6q9a2ke0hc9j6z6wlxg5z5kqpu2v9wz"); + try expectHash(sequential_hash, "LNBC25M1PVJLUEZPP5QQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQYPQDQ5VDHKVEN9V5SXYETPDEESSP5ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYGS9Q5SQQQQQQQQQQQQQQQQSGQ2A25DXL5HRNTDTN6ZVYDT7D66HYZSYHQS4WDYNAVYS42XGL6SGX9C4G7ME86A27T07MDTFRY458RTJR0V92CNMSWPSJSCGT2VCSE3SGPZ3UAPA"); +} + +test "paymentHash reads an invoice with an unsigned payload" { + try expectHash("72cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793", "lnbc10n1pj48ugqpp5wtxkappzcsrlkmgfs6g0zyct0hkhashh7hsaxz7e65slq9fkx7fsdqdwp6kx6eqv5ex2sp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg3hk8m"); +} + +test "paymentHash skips p fields with the wrong length" { + try expectHash(sequential_hash, "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2qrqqqfppnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqppnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhpnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqspnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnp5qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnpkqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqz599y53s3ujmcfjp5xrdap68qxymkqphwsexhmhr8wdz5usdzkzrse33chw6dlp3jhuhge9ley7j2ayx36kawe7kmgg8sv5ugdyusdcqzn8z9x"); +} + +test "paymentHash rejects malformed invoices" { + const bad = [_][]const u8{ + // BOLT 11: Bech32 checksum is invalid. + "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrnt", + // BOLT 11: Malformed bech32 string (no 1). + "pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrny", + // BOLT 11: Malformed bech32 string (mixed case). + "LNBC2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrny", + // BOLT 11: String is too short. + "lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6na6hlh", + // Checksum-valid invoice without a `p` field. + "lnbc10n1pj48ugqdqdwp6kx6eqv5ex2sp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqlf93rv", + "lnbc50n1pdummy", + "", + }; + for (bad) |invoice| try testing.expectError(error.InvalidInvoice, paymentHash(invoice)); +} diff --git a/src/config.zig b/src/config.zig index d286c82..3d0b97e 100644 --- a/src/config.zig +++ b/src/config.zig @@ -9,12 +9,15 @@ pub const Config = struct { lnbits_host: []const u8, lnbits_admin_key: []const u8, - _allocated: std.ArrayListUnmanaged([]const u8), + _allocated: std.ArrayListUnmanaged([]u8), _allocator: std.mem.Allocator, pub fn load(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !Config { const content = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)); - defer allocator.free(content); + defer { + std.crypto.secureZero(u8, content); + allocator.free(content); + } return parse(allocator, content); } @@ -116,6 +119,7 @@ pub const Config = struct { } } else if (value.len == 64) { var key: [32]u8 = undefined; + defer std.crypto.secureZero(u8, &key); _ = std.fmt.hexToBytes(&key, value) catch return error.InvalidPrivkey; return key; } @@ -149,6 +153,7 @@ pub const Config = struct { pub fn deinit(self: *Config) void { for (self._allocated.items) |s| { + std.crypto.secureZero(u8, s); self._allocator.free(s); } self._allocated.deinit(self._allocator); @@ -182,6 +187,9 @@ test "parse full config with client pubkey list" { defer config.deinit(); try std.testing.expectEqualStrings("ws://127.0.0.1:7777", config.relay); + var want_privkey: [32]u8 = undefined; + _ = try std.fmt.hexToBytes(&want_privkey, test_privkey); + try std.testing.expectEqualSlices(u8, &want_privkey, &config.privkey); try std.testing.expectEqual(@as(usize, 2), config.client_pubkeys.items.len); var client: [32]u8 = undefined; diff --git a/src/lnbits.zig b/src/lnbits.zig index aa221e6..e91d9e1 100644 --- a/src/lnbits.zig +++ b/src/lnbits.zig @@ -1,14 +1,24 @@ const std = @import("std"); +const log = std.log.scoped(.lnbits); + pub const LnbitsError = error{ RequestFailed, InvalidResponse, - PaymentFailed, InvoiceNotFound, - InsufficientBalance, InvalidAmount, }; +/// Deadline for every LNbits call other than paying. +pub const request_timeout_s = 10; +/// LNbits itself answers a payment that is still in flight as pending after +/// `LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS` (5 s by default). +pub const pay_timeout_s = 30; +/// Delays before each lookup of a payment whose outcome the pay call left open. +pub const lookup_backoff_s = [_]i64{ 0, 1, 2, 4, 8 }; +/// Larger responses are treated as unreadable; memos and route hints fit easily. +pub const max_response_len = 64 * 1024; + pub const WalletInfo = struct { name: []const u8, balance: u64, @@ -17,26 +27,57 @@ pub const WalletInfo = struct { pub const Invoice = struct { payment_hash: []const u8, payment_request: []const u8, - checking_id: []const u8, }; -pub const PaymentResult = struct { - payment_hash: []const u8, - checking_id: []const u8, -}; +pub const PaymentState = enum { paid, failed, pending }; pub const PaymentDetails = struct { - pending: bool, - amount: i64, - fee: i64, - memo: []const u8, - preimage: []const u8, - payment_hash: []const u8, - bolt11: []const u8, + state: PaymentState, + /// Only set when it hashes to the payment hash. + preimage: ?[32]u8 = null, + /// Negative for outgoing payments. + amount_msat: ?i64 = null, + fee_msat: ?u64 = null, + memo: ?[]const u8 = null, + bolt11: ?[]const u8 = null, +}; + +pub const Paid = struct { + preimage: ?[32]u8, + fee_msat: ?u64, +}; + +pub const PayOutcome = union(enum) { + paid: Paid, + /// LNbits definitively reports the payment as not made; carries its reason. + failed: []const u8, + /// Still in flight, or LNbits could not be asked: it may yet complete. + unknown, +}; + +/// What the pay call alone says, before any lookup. +pub const PostOutcome = union(enum) { + paid: Paid, + failed: []const u8, + ambiguous, +}; + +pub const HttpResponse = struct { + status: u16, + body: []const u8, +}; + +pub const SendError = error{ + /// No connection was made, so LNbits never saw the request. + NotSent, + /// The request may have reached LNbits but no complete response came back. + NoResponse, + Timeout, }; pub const Client = struct { allocator: std.mem.Allocator, + io: std.Io, host: []const u8, admin_key: []const u8, http_client: std.http.Client, @@ -44,9 +85,10 @@ pub const Client = struct { pub fn init(allocator: std.mem.Allocator, io: std.Io, host: []const u8, admin_key: []const u8) Client { return .{ .allocator = allocator, + .io = io, .host = host, .admin_key = admin_key, - .http_client = .{ .allocator = allocator, .io = io }, + .http_client = .{ .allocator = allocator, .io = io, .read_buffer_size = 16 * 1024 }, }; } @@ -54,86 +96,145 @@ pub const Client = struct { self.http_client.deinit(); } - pub fn getWallet(self: *Client) !WalletInfo { - var response_buf: [4096]u8 = undefined; - const response = try self.request(.GET, "/api/v1/wallet", null, &response_buf); - + pub fn getWallet(self: *Client, arena: std.mem.Allocator) !WalletInfo { + const response = try self.expectOk(arena, .GET, "/api/v1/wallet", null); const name = extractJsonString(response, "name") orelse "LNbits Wallet"; const balance_msat = extractJsonInt(response, "balance") orelse 0; - return .{ .name = name, .balance = std.math.cast(u64, balance_msat) orelse return LnbitsError.InvalidResponse, }; } - pub fn createInvoice(self: *Client, amount_msat: u64, memo: ?[]const u8) !Invoice { + pub fn createInvoice(self: *Client, arena: std.mem.Allocator, amount_msat: u64, memo: ?[]const u8) !Invoice { if (amount_msat == 0 or amount_msat % 1000 != 0) return LnbitsError.InvalidAmount; - var body_buf: [2048]u8 = undefined; const amount_sats = amount_msat / 1000; const body = if (memo) |m| - std.fmt.bufPrint(&body_buf, "{{\"out\":false,\"amount\":{d},\"memo\":\"{s}\"}}", .{ amount_sats, m }) catch return LnbitsError.RequestFailed + try std.fmt.allocPrint(arena, "{{\"out\":false,\"amount\":{d},\"memo\":\"{s}\"}}", .{ amount_sats, m }) else - std.fmt.bufPrint(&body_buf, "{{\"out\":false,\"amount\":{d}}}", .{amount_sats}) catch return LnbitsError.RequestFailed; - - var response_buf: [8192]u8 = undefined; - const response = try self.request(.POST, "/api/v1/payments", body, &response_buf); + try std.fmt.allocPrint(arena, "{{\"out\":false,\"amount\":{d}}}", .{amount_sats}); + const response = try self.expectOk(arena, .POST, "/api/v1/payments", body); return .{ .payment_hash = extractJsonString(response, "payment_hash") orelse return LnbitsError.InvalidResponse, .payment_request = extractJsonString(response, "payment_request") orelse return LnbitsError.InvalidResponse, - .checking_id = extractJsonString(response, "checking_id") orelse "", }; } - pub fn payInvoice(self: *Client, bolt11: []const u8) !PaymentResult { - var body_buf: [2048]u8 = undefined; - const body = std.fmt.bufPrint(&body_buf, "{{\"out\":true,\"bolt11\":\"{s}\"}}", .{bolt11}) catch return LnbitsError.RequestFailed; - - var response_buf: [4096]u8 = undefined; - const response = try self.request(.POST, "/api/v1/payments", body, &response_buf); + /// Pays `bolt11`, whose payment hash the caller decoded, and never reports + /// failure unless LNbits says the payment did not happen: every other + /// outcome is resolved by looking the payment up by hash. + pub fn payInvoice(self: *Client, arena: std.mem.Allocator, bolt11: []const u8, payment_hash: *const [32]u8) !PayOutcome { + const hash_hex = std.fmt.bytesToHex(payment_hash, .lower); + const body = try std.fmt.allocPrint(arena, "{{\"out\":true,\"bolt11\":\"{s}\"}}", .{bolt11}); + + const post: PostOutcome = if (self.send(arena, .POST, "/api/v1/payments", body, pay_timeout_s)) |response| + classifyPayResponse(arena, response, payment_hash) + else |err| switch (err) { + error.NotSent => { + log.warn("payment {s}: LNbits unreachable, not attempted", .{&hash_hex}); + return .{ .failed = "LNbits unreachable, payment not attempted" }; + }, + error.NoResponse, error.Timeout => blk: { + log.warn("payment {s}: no response from LNbits ({t})", .{ &hash_hex, err }); + break :blk .ambiguous; + }, + }; - if (std.mem.indexOf(u8, response, "\"error\"") != null) { - return LnbitsError.PaymentFailed; + switch (post) { + .paid => |paid| return .{ .paid = paid }, + .failed => |reason| { + // A rejection can follow a completed payment (LNbits bookkeeping + // after the payment), or race an earlier attempt still in flight. + const details = self.lookupOutgoing(arena, &hash_hex) orelse return .{ .failed = reason }; + return switch (details.state) { + .paid => .{ .paid = .{ .preimage = details.preimage, .fee_msat = details.fee_msat } }, + .failed => .{ .failed = reason }, + .pending => .unknown, + }; + }, + .ambiguous => { + for (lookup_backoff_s) |delay| { + if (delay > 0) std.Io.sleep(self.io, .fromSeconds(delay), .awake) catch {}; + const details = self.lookupOutgoing(arena, &hash_hex) orelse continue; + switch (details.state) { + .paid => return .{ .paid = .{ .preimage = details.preimage, .fee_msat = details.fee_msat } }, + .failed => return .{ .failed = "LNbits reported the payment as failed" }, + .pending => {}, + } + } + log.warn("payment {s}: outcome still unknown after lookups", .{&hash_hex}); + return .unknown; + }, } + } - return .{ - .payment_hash = extractJsonString(response, "payment_hash") orelse return LnbitsError.InvalidResponse, - .checking_id = extractJsonString(response, "checking_id") orelse "", + /// The wallet's outgoing payment for `hash_hex`, or null if LNbits has none + /// or could not be asked. + fn lookupOutgoing(self: *Client, arena: std.mem.Allocator, hash_hex: *const [64]u8) ?PaymentDetails { + const details = self.lookupPayment(arena, hash_hex) catch |err| { + log.warn("payment {s}: lookup failed: {t}", .{ hash_hex, err }); + return null; }; + if (details.amount_msat) |amount| if (amount > 0) return null; + log.info("payment {s}: lookup state {t}", .{ hash_hex, details.state }); + return details; } - pub fn lookupPayment(self: *Client, payment_hash: []const u8) !PaymentDetails { + pub fn lookupPayment(self: *Client, arena: std.mem.Allocator, payment_hash: []const u8) !PaymentDetails { if (!isPaymentHash(payment_hash)) return LnbitsError.InvoiceNotFound; - var path_buf: [256]u8 = undefined; - const path = std.fmt.bufPrint(&path_buf, "/api/v1/payments/{s}", .{payment_hash}) catch return LnbitsError.RequestFailed; - - var response_buf: [8192]u8 = undefined; - const response = try self.request(.GET, path, null, &response_buf); + var hash: [32]u8 = undefined; + _ = std.fmt.hexToBytes(&hash, payment_hash) catch return LnbitsError.InvoiceNotFound; + const path = try std.fmt.allocPrint(arena, "/api/v1/payments/{s}", .{payment_hash}); + + const response = self.send(arena, .GET, path, null, request_timeout_s) catch return LnbitsError.RequestFailed; + if (response.status == 404) return LnbitsError.InvoiceNotFound; + if (response.status != 200) return LnbitsError.RequestFailed; + return parsePaymentDetails(arena, response.body, &hash) orelse LnbitsError.InvalidResponse; + } - if (std.mem.indexOf(u8, response, "\"detail\"") != null) { - return LnbitsError.InvoiceNotFound; - } + fn expectOk(self: *Client, arena: std.mem.Allocator, method: std.http.Method, path: []const u8, body: ?[]const u8) ![]const u8 { + const response = self.send(arena, method, path, body, request_timeout_s) catch return LnbitsError.RequestFailed; + if (response.status != 200 and response.status != 201) return LnbitsError.RequestFailed; + return response.body; + } - const pending_str = extractJsonString(response, "pending"); - const pending = if (pending_str) |p| std.mem.eql(u8, p, "true") else extractJsonBool(response, "pending") orelse true; + const Race = union(enum) { + response: SendError!HttpResponse, + deadline: std.Io.Cancelable!void, + }; + + /// Runs one request against a deadline. std.http.Client has no timeout, so + /// the request runs as a concurrent task that is canceled, interrupting any + /// blocked connect, write or read, when the deadline task finishes first. + fn send(self: *Client, arena: std.mem.Allocator, method: std.http.Method, path: []const u8, body: ?[]const u8, timeout_s: i64) SendError!HttpResponse { + var buf: [2]Race = undefined; + var race = std.Io.Select(Race).init(self.io, &buf); + race.concurrent(.deadline, std.Io.sleep, .{ self.io, .fromSeconds(timeout_s), .awake }) catch return error.NotSent; + race.concurrent(.response, sendNow, .{ self, arena, method, path, body }) catch { + race.cancelDiscard(); + return error.NotSent; + }; - return .{ - .pending = pending, - .amount = extractJsonInt(response, "amount") orelse 0, - .fee = extractJsonInt(response, "fee") orelse 0, - .memo = extractJsonString(response, "memo") orelse "", - .preimage = extractJsonString(response, "preimage") orelse "", - .payment_hash = extractJsonString(response, "payment_hash") orelse payment_hash, - .bolt11 = extractJsonString(response, "bolt11") orelse "", + var result: ?(SendError!HttpResponse) = null; + var timed_out = false; + var next: ?Race = race.await() catch race.cancel(); + while (next) |done| : (next = race.cancel()) switch (done) { + .response => |r| result = r, + .deadline => |d| if (d) |_| { + if (result == null) timed_out = true; + } else |_| {}, }; - } - fn request(self: *Client, method: std.http.Method, path: []const u8, body: ?[]const u8, response_buf: []u8) ![]const u8 { - var uri_buf: [512]u8 = undefined; - const uri_str = std.fmt.bufPrint(&uri_buf, "{s}{s}", .{ self.host, path }) catch return LnbitsError.RequestFailed; + const r = result orelse return error.Timeout; + if (r) |response| return response else |err| { + return if (timed_out and err == error.NoResponse) error.Timeout else err; + } + } - const uri = std.Uri.parse(uri_str) catch return LnbitsError.RequestFailed; + fn sendNow(self: *Client, arena: std.mem.Allocator, method: std.http.Method, path: []const u8, body: ?[]const u8) SendError!HttpResponse { + const uri_str = std.fmt.allocPrint(arena, "{s}{s}", .{ self.host, path }) catch return error.NotSent; + const uri = std.Uri.parse(uri_str) catch return error.NotSent; var req = self.http_client.request(method, uri, .{ .headers = .{ .accept_encoding = .omit, .content_type = .{ .override = "application/json" } }, @@ -141,31 +242,135 @@ pub const Client = struct { .{ .name = "X-Api-Key", .value = self.admin_key }, }, .redirect_behavior = .unhandled, - }) catch return LnbitsError.RequestFailed; + .keep_alive = false, + }) catch return error.NotSent; defer req.deinit(); + // Never return a connection to the pool, even after a failed send. + if (req.connection) |connection| connection.closing = true; if (body) |b| { req.transfer_encoding = .{ .content_length = b.len }; - req.sendBodyComplete(@constCast(b)) catch return LnbitsError.RequestFailed; + req.sendBodyComplete(@constCast(b)) catch return error.NoResponse; } else { - req.sendBodiless() catch return LnbitsError.RequestFailed; + req.sendBodiless() catch return error.NoResponse; } - var redirect_buf: [1024]u8 = undefined; - var response = req.receiveHead(&redirect_buf) catch return LnbitsError.RequestFailed; - - if (response.head.status != .ok and response.head.status != .created) { - return LnbitsError.RequestFailed; - } + var response = req.receiveHead(&.{}) catch return error.NoResponse; + const status: u16 = @intFromEnum(response.head.status); var transfer_buf: [4096]u8 = undefined; - const body_reader = response.reader(&transfer_buf); - const len = body_reader.readSliceShort(response_buf) catch return LnbitsError.RequestFailed; - if (len == response_buf.len) return LnbitsError.InvalidResponse; - return response_buf[0..len]; + const reader = response.reader(&transfer_buf); + const data = reader.allocRemaining(arena, .limited(max_response_len)) catch |err| { + log.warn("{s}: unreadable response body (HTTP {d}): {t}", .{ path, status, err }); + return error.NoResponse; + }; + return .{ .status = status, .body = data }; } }; +/// Classifies LNbits' answer to `POST /api/v1/payments` with `out: true`. +/// Only a 201 with status "success", a `PaymentError` with status "failed" +/// (HTTP 520), or a 4xx with a JSON `detail` (request rejected before any +/// payment) are definitive; anything else needs a lookup. +pub fn classifyPayResponse(arena: std.mem.Allocator, response: HttpResponse, payment_hash: *const [32]u8) PostOutcome { + const root = std.json.parseFromSliceLeaky(std.json.Value, arena, response.body, .{}) catch return .ambiguous; + const obj = switch (root) { + .object => |o| o, + else => return .ambiguous, + }; + const status = stringField(obj, "status"); + const detail = stringField(obj, "detail"); + + if (response.status == 200 or response.status == 201) { + if (stringField(obj, "payment_hash")) |h| { + const want = std.fmt.bytesToHex(payment_hash, .lower); + if (!std.ascii.eqlIgnoreCase(h, &want)) return .ambiguous; + } + const s = status orelse return .ambiguous; + if (std.mem.eql(u8, s, "success")) return .{ .paid = .{ + .preimage = verifiedPreimage(stringField(obj, "preimage"), payment_hash), + .fee_msat = feeMsat(obj.get("fee")), + } }; + if (std.mem.eql(u8, s, "failed")) return .{ .failed = "LNbits reported the payment as failed" }; + return .ambiguous; + } + + if (status) |s| { + if (std.mem.eql(u8, s, "failed")) return .{ .failed = detail orelse "LNbits reported the payment as failed" }; + return .ambiguous; + } + + if (response.status >= 400 and response.status < 500 and obj.get("detail") != null) { + return .{ .failed = detail orelse "LNbits rejected the payment request" }; + } + return .ambiguous; +} + +/// Parses `GET /api/v1/payments/{hash}`: `paid` is authoritative, otherwise a +/// status of "failed" (top level or in `details`) is a failure and anything +/// else is still pending. +pub fn parsePaymentDetails(arena: std.mem.Allocator, body: []const u8, payment_hash: *const [32]u8) ?PaymentDetails { + const root = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{}) catch return null; + const obj = switch (root) { + .object => |o| o, + else => return null, + }; + const details: ?std.json.ObjectMap = if (obj.get("details")) |d| switch (d) { + .object => |o| o, + else => null, + } else null; + + const paid = if (obj.get("paid")) |p| switch (p) { + .bool => |b| b, + else => return null, + } else return null; + + const failed = blk: { + if (stringField(obj, "status")) |s| break :blk std.mem.eql(u8, s, "failed"); + if (details) |d| if (stringField(d, "status")) |s| break :blk std.mem.eql(u8, s, "failed"); + break :blk false; + }; + + var result: PaymentDetails = .{ .state = if (paid) .paid else if (failed) .failed else .pending }; + if (paid) result.preimage = verifiedPreimage(stringField(obj, "preimage"), payment_hash); + if (details) |d| { + if (d.get("amount")) |a| if (a == .integer) { + result.amount_msat = a.integer; + }; + if (paid) result.fee_msat = feeMsat(d.get("fee")); + result.memo = stringField(d, "memo"); + result.bolt11 = stringField(d, "bolt11"); + } + return result; +} + +fn stringField(obj: std.json.ObjectMap, key: []const u8) ?[]const u8 { + const v = obj.get(key) orelse return null; + return switch (v) { + .string => |s| s, + else => null, + }; +} + +fn feeMsat(value: ?std.json.Value) ?u64 { + const v = value orelse return null; + return switch (v) { + .integer => |i| if (i == 0) null else @abs(i), + else => null, + }; +} + +fn verifiedPreimage(hex: ?[]const u8, payment_hash: *const [32]u8) ?[32]u8 { + const h = hex orelse return null; + if (h.len != 64) return null; + var preimage: [32]u8 = undefined; + _ = std.fmt.hexToBytes(&preimage, h) catch return null; + var digest: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(&preimage, &digest, .{}); + if (!std.mem.eql(u8, &digest, payment_hash)) return null; + return preimage; +} + pub fn isPaymentHash(value: []const u8) bool { if (value.len != 64) return false; for (value) |c| if (!std.ascii.isHex(c)) return false; @@ -212,19 +417,6 @@ fn extractJsonInt(json: []const u8, key: []const u8) ?i64 { return std.fmt.parseInt(i64, json[pos..end], 10) catch null; } -fn extractJsonBool(json: []const u8, key: []const u8) ?bool { - var search_buf: [68]u8 = undefined; - const search = std.fmt.bufPrint(&search_buf, "\"{s}\":", .{key}) catch return null; - const key_pos = std.mem.indexOf(u8, json, search) orelse return null; - - var pos = key_pos + search.len; - while (pos < json.len and (json[pos] == ' ' or json[pos] == '\t')) : (pos += 1) {} - - if (pos + 4 <= json.len and std.mem.eql(u8, json[pos..][0..4], "true")) return true; - if (pos + 5 <= json.len and std.mem.eql(u8, json[pos..][0..5], "false")) return false; - return null; -} - test "payment hash validation" { var hash: [64]u8 = @splat('a'); try std.testing.expect(isPaymentHash(&hash)); @@ -237,6 +429,108 @@ test "payment hash validation" { test "createInvoice rejects amounts that are not whole sats" { var client = Client.init(std.testing.allocator, std.testing.io, "http://127.0.0.1:1", "key"); defer client.deinit(); - try std.testing.expectError(LnbitsError.InvalidAmount, client.createInvoice(0, null)); - try std.testing.expectError(LnbitsError.InvalidAmount, client.createInvoice(1500, null)); + try std.testing.expectError(LnbitsError.InvalidAmount, client.createInvoice(std.testing.allocator, 0, null)); + try std.testing.expectError(LnbitsError.InvalidAmount, client.createInvoice(std.testing.allocator, 1500, null)); +} + +const TestPayment = struct { + preimage: [32]u8 = @splat(0x42), + hash: [32]u8 = undefined, + preimage_hex: [64]u8 = undefined, + hash_hex: [64]u8 = undefined, + + fn init() TestPayment { + var p: TestPayment = .{}; + std.crypto.hash.sha2.Sha256.hash(&p.preimage, &p.hash, .{}); + p.preimage_hex = std.fmt.bytesToHex(&p.preimage, .lower); + p.hash_hex = std.fmt.bytesToHex(&p.hash, .lower); + return p; + } +}; + +fn classifyBody(arena: std.mem.Allocator, status: u16, comptime fmt: []const u8, p: *const TestPayment) !PostOutcome { + const body = try std.fmt.allocPrint(arena, fmt, .{ .hash = &p.hash_hex, .preimage = &p.preimage_hex }); + return classifyPayResponse(arena, .{ .status = status, .body = body }, &p.hash); +} + +test "classifyPayResponse separates definitive outcomes from ambiguous ones" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const p = TestPayment.init(); + + const paid = try classifyBody(arena, 201, + \\{{"payment_hash":"{[hash]s}","status":"success","preimage":"{[preimage]s}","fee":-2000,"memo":"x"}} + , &p); + try std.testing.expectEqualSlices(u8, &p.preimage, &paid.paid.preimage.?); + try std.testing.expectEqual(@as(?u64, 2000), paid.paid.fee_msat); + + const wrong_preimage = try classifyBody(arena, 201, + \\{{"payment_hash":"{[hash]s}","status":"success","preimage":"{[hash]s}","memo":"{[preimage]s}"}} + , &p); + try std.testing.expectEqual(@as(?[32]u8, null), wrong_preimage.paid.preimage); + + const insufficient = classifyPayResponse(arena, .{ .status = 520, .body = "{\"detail\":\"Insufficient balance.\",\"status\":\"failed\"}" }, &p.hash); + try std.testing.expectEqualStrings("Insufficient balance.", insufficient.failed); + + const bad_request = classifyPayResponse(arena, .{ .status = 400, .body = "{\"detail\":\"Missing BOLT11 invoice\"}" }, &p.hash); + try std.testing.expectEqualStrings("Missing BOLT11 invoice", bad_request.failed); + + const validation = classifyPayResponse(arena, .{ .status = 400, .body = "{\"detail\":[{\"loc\":[\"body\",\"bolt11\"]}]}" }, &p.hash); + try std.testing.expect(validation == .failed); + + const failed_201 = try classifyBody(arena, 201, + \\{{"payment_hash":"{[hash]s}","status":"failed","preimage":"{[preimage]s}"}} + , &p); + try std.testing.expect(failed_201 == .failed); + + const ambiguous = [_]struct { status: u16, body: []const u8 }{ + .{ .status = 201, .body = "{\"status\":\"pending\"}" }, + .{ .status = 201, .body = "{\"payment_hash\":\"00\",\"status\":\"success\"}" }, + .{ .status = 201, .body = "{\"payment_hash\":\"x\",\"checking_id\":\"y\"}" }, + .{ .status = 520, .body = "{\"detail\":\"Payment is still pending.\",\"status\":\"pending\"}" }, + .{ .status = 520, .body = "{\"detail\":\"Payment already paid.\",\"status\":\"success\"}" }, + .{ .status = 500, .body = "{\"detail\":\"Unexpected error! ID: 1\"}" }, + .{ .status = 502, .body = "Bad Gateway" }, + .{ .status = 404, .body = "Not Found" }, + .{ .status = 201, .body = "{\"status\":\"succ" }, + .{ .status = 201, .body = "" }, + }; + for (ambiguous) |case| { + const outcome = classifyPayResponse(arena, .{ .status = case.status, .body = case.body }, &p.hash); + std.testing.expect(outcome == .ambiguous) catch |err| { + std.debug.print("expected ambiguous for {d} {s}\n", .{ case.status, case.body }); + return err; + }; + } +} + +test "parsePaymentDetails reads LNbits payment lookups" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const p = TestPayment.init(); + + const paid_body = try std.fmt.allocPrint(arena, + \\{{"paid":true,"preimage":"{s}","details":{{"amount":-1000,"fee":-3000,"status":"success","memo":"m","bolt11":"lnbc"}}}} + , .{&p.preimage_hex}); + const paid = parsePaymentDetails(arena, paid_body, &p.hash).?; + try std.testing.expectEqual(PaymentState.paid, paid.state); + try std.testing.expectEqualSlices(u8, &p.preimage, &paid.preimage.?); + try std.testing.expectEqual(@as(?i64, -1000), paid.amount_msat); + try std.testing.expectEqual(@as(?u64, 3000), paid.fee_msat); + try std.testing.expectEqualStrings("m", paid.memo.?); + + const failed = parsePaymentDetails(arena, "{\"paid\":false,\"status\":\"failed\",\"details\":{\"amount\":-1000,\"fee\":-10}}", &p.hash).?; + try std.testing.expectEqual(PaymentState.failed, failed.state); + try std.testing.expectEqual(@as(?u64, null), failed.fee_msat); + + const pending = parsePaymentDetails(arena, "{\"paid\":false,\"status\":\"pending\",\"preimage\":null}", &p.hash).?; + try std.testing.expectEqual(PaymentState.pending, pending.state); + + const no_status = parsePaymentDetails(arena, "{\"paid\":false}", &p.hash).?; + try std.testing.expectEqual(PaymentState.pending, no_status.state); + + try std.testing.expect(parsePaymentDetails(arena, "{\"detail\":\"Payment does not exist.\"}", &p.hash) == null); + try std.testing.expect(parsePaymentDetails(arena, "[]", &p.hash) == null); } diff --git a/src/main.zig b/src/main.zig index de81eea..6fcab28 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,6 +2,7 @@ const std = @import("std"); const nostr = @import("nostr"); const Config = @import("config.zig").Config; const LnbitsClient = @import("lnbits.zig").Client; +const bolt11 = @import("bolt11.zig"); const Relay = @import("relay.zig").Relay; const nwc = nostr.nwc; @@ -19,6 +20,10 @@ const supported_methods = [_]nwc.Method{ /// least this long, so a captured request cannot be replayed to run twice. const max_request_age_s: i64 = 600; +/// The replay cache does not survive a restart, so requests created more than +/// this long before the process started are ignored. +const start_skew_s: i64 = 60; + var g_shutdown: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); var g_relay_fd: std.atomic.Value(std.posix.fd_t) = std.atomic.Value(std.posix.fd_t).init(-1); @@ -48,8 +53,9 @@ pub fn main(init: std.process.Init) !void { var pubkey_hex: [64]u8 = undefined; nostr.hex.encode(&config.pubkey, &pubkey_hex); std.log.info("Pubkey: {s}", .{&pubkey_hex}); - std.log.info("Relay: {s}", .{config.relay}); - std.log.info("LNbits: {s}", .{config.lnbits_host}); + var url_buf: [512]u8 = undefined; + std.log.info("Relay: {s}", .{redactUserinfo(config.relay, &url_buf)}); + std.log.info("LNbits: {s}", .{redactUserinfo(config.lnbits_host, &url_buf)}); std.log.info("Authorized client pubkeys: {d}", .{config.client_pubkeys.items.len}); const sa = std.posix.Sigaction{ @@ -70,7 +76,7 @@ pub fn main(init: std.process.Init) !void { var lnbits = LnbitsClient.init(allocator, io, config.lnbits_host, config.lnbits_admin_key); defer lnbits.deinit(); - var guard: ReplayGuard = .{}; + var guard: ReplayGuard = .{ .not_before = nostr.io.timestamp() - start_skew_s }; defer guard.deinit(allocator); while (!g_shutdown.load(.acquire)) { @@ -87,6 +93,14 @@ pub fn main(init: std.process.Init) !void { std.log.info("Shutdown complete", .{}); } +/// `url` with any userinfo (`user:password@`) replaced, for logging. +fn redactUserinfo(url: []const u8, buf: []u8) []const u8 { + const authority_start = (std.mem.indexOf(u8, url, "://") orelse return url) + 3; + const authority_end = std.mem.indexOfAnyPos(u8, url, authority_start, "/?#") orelse url.len; + const at = std.mem.lastIndexOfScalar(u8, url[authority_start..authority_end], '@') orelse return url; + return std.fmt.bufPrint(buf, "{s}***@{s}", .{ url[0..authority_start], url[authority_start + at + 1 ..] }) catch ""; +} + fn runEventLoop(allocator: std.mem.Allocator, config: *Config, lnbits: *LnbitsClient, guard: *ReplayGuard) !void { std.log.info("Connecting to relay...", .{}); var relay = try Relay.connect(allocator, config.relay); @@ -100,7 +114,7 @@ fn runEventLoop(allocator: std.mem.Allocator, config: *Config, lnbits: *LnbitsCl try publishInfoEvent(config, &relay); var filter_buf: [4096]u8 = undefined; - const filter = try buildRequestFilter(config, nostr.io.timestamp(), &filter_buf); + const filter = try buildRequestFilter(config, @max(nostr.io.timestamp() - max_request_age_s, guard.not_before), &filter_buf); try relay.subscribe("nwc", filter); std.log.info("Subscribed to NWC requests", .{}); @@ -130,7 +144,7 @@ fn runEventLoop(allocator: std.mem.Allocator, config: *Config, lnbits: *LnbitsCl } } -fn buildRequestFilter(config: *const Config, now: i64, buf: []u8) ![]const u8 { +fn buildRequestFilter(config: *const Config, since: i64, buf: []u8) ![]const u8 { var w = std.Io.Writer.fixed(buf); var hex_buf: [64]u8 = undefined; nostr.hex.encode(&config.pubkey, &hex_buf); @@ -140,7 +154,7 @@ fn buildRequestFilter(config: *const Config, now: i64, buf: []u8) ![]const u8 { nostr.hex.encode(pk, &hex_buf); try w.print("\"{s}\"", .{&hex_buf}); } - try w.print("],\"since\":{d}}}", .{now - max_request_age_s}); + try w.print("],\"since\":{d}}}", .{since}); return w.buffered(); } @@ -158,10 +172,11 @@ fn publishInfoEvent(config: *Config, relay: *Relay) !void { } const content = content_buf[0..content_pos]; - const keypair = nostr.Keypair{ + var keypair = nostr.Keypair{ .secret_key = config.privkey, .public_key = config.pubkey, }; + defer std.crypto.secureZero(u8, &keypair.secret_key); const tags = [_][]const []const u8{ &[_][]const u8{ "encryption", nwc.Encryption.nip44_v2.toString() }, @@ -185,12 +200,14 @@ const RequestError = error{ InvalidSignature, Expired, Stale, + BeforeStart, }; /// Checks run before a request is decrypted: it must be a kind 23194 event /// addressed to this wallet service, authored by a configured client key, -/// correctly signed, unexpired (NIP-47 `expiration` tag) and recent. -fn checkRequest(config: *const Config, event: *const nostr.Event, now: i64) RequestError!void { +/// correctly signed, unexpired (NIP-47 `expiration` tag), recent, and not +/// created before `not_before` (shortly before this process started). +fn checkRequest(config: *const Config, event: *const nostr.Event, now: i64, not_before: i64) RequestError!void { if (event.kind() != nwc.Kind.request) return error.WrongKind; if (!config.isAuthorized(event.pubkey())) return error.Unauthorized; @@ -219,6 +236,7 @@ fn checkRequest(config: *const Config, event: *const nostr.Event, now: i64) Requ if (expired) return error.Expired; if (event.createdAt() < now - max_request_age_s) return error.Stale; + if (event.createdAt() < not_before) return error.BeforeStart; } /// NIP-47: a request without an `encryption` tag uses NIP-04. @@ -238,6 +256,8 @@ fn requestEncryption(event: *const nostr.Event) error{UnsupportedEncryption}!nwc // Linear scan; only authorized, signed requests are recorded, so it stays small. const ReplayGuard = struct { entries: std.ArrayListUnmanaged(Entry) = .empty, + /// Process start minus `start_skew_s`; earlier requests may predate the cache. + not_before: i64, const Entry = struct { id: [32]u8, created_at: i64 }; @@ -270,7 +290,7 @@ fn handleEvent(allocator: std.mem.Allocator, config: *Config, lnbits: *LnbitsCli nostr.hex.encode(&event.id_bytes, &id_hex); const now = nostr.io.timestamp(); - checkRequest(config, &event, now) catch |err| { + checkRequest(config, &event, now, guard.not_before) catch |err| { std.log.warn("Ignoring request {s}: {}", .{ &id_hex, err }); return; }; @@ -279,19 +299,32 @@ fn handleEvent(allocator: std.mem.Allocator, config: *Config, lnbits: *LnbitsCli return; } + const sender_pubkey = event.pubkey(); + var event_buf: [32768]u8 = undefined; + const encryption = requestEncryption(&event) catch { - std.log.warn("Ignoring request {s}: unsupported encryption", .{&id_hex}); + std.log.warn("Request {s}: unsupported encryption", .{&id_hex}); + // The method is unreadable, so result_type stays empty; the reply uses + // the only scheme the info event advertises. + const response_event = try buildResponseEvent(allocator, config, sender_pubkey, &event.id_bytes, unsupported_encryption_response, .nip44_v2, &event_buf); + try relay.publish(response_event); return; }; - const sender_pubkey = event.pubkey(); const decrypted = decrypt(allocator, config, sender_pubkey, event.content(), encryption) catch |err| { std.log.err("Decryption failed: {}", .{err}); return; }; - defer allocator.free(decrypted); + defer { + std.crypto.secureZero(u8, decrypted); + allocator.free(decrypted); + } + + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); - var response_buf: [4096]u8 = undefined; + var response_buf: [16384]u8 = undefined; + defer std.crypto.secureZero(u8, &response_buf); const response_json = switch (encryption) { .nip04 => blk: { const method = nwc.Method.fromString(utils.extractJsonString(decrypted, "method") orelse return) orelse return; @@ -303,19 +336,20 @@ fn handleEvent(allocator: std.mem.Allocator, config: *Config, lnbits: *LnbitsCli }, .nip44_v2 => if (nwc.Request.parseJson(decrypted)) |request| blk: { std.log.info("Received {s} request", .{request.method.toString()}); - break :blk try handleRequest(request, utils.findJsonValue(decrypted, "params") orelse "{}", lnbits, &response_buf); + break :blk try handleRequest(arena.allocator(), request, utils.findJsonValue(decrypted, "params") orelse "{}", lnbits, &response_buf); } else serializeInvalidRequest(decrypted, &response_buf) orelse { std.log.err("Failed to parse NWC request", .{}); return; }, }; - var event_buf: [16384]u8 = undefined; const response_event = try buildResponseEvent(allocator, config, sender_pubkey, &event.id_bytes, response_json, encryption, &event_buf); try relay.publish(response_event); std.log.debug("Published response", .{}); } +const unsupported_encryption_response = "{\"result_type\":\"\",\"error\":{\"code\":\"UNSUPPORTED_ENCRYPTION\",\"message\":\"Use nip44_v2 encryption\"},\"result\":null}"; + fn decrypt(allocator: std.mem.Allocator, config: *const Config, sender: *const [32]u8, content: []const u8, encryption: nwc.Encryption) ![]u8 { return switch (encryption) { .nip44_v2 => nostr.crypto.nip44Decrypt(&config.privkey, sender, content, allocator), @@ -371,13 +405,16 @@ fn bolt11AmountMsat(invoice: []const u8) error{InvalidInvoice}!?u64 { return std.math.mul(u64, n, msat_per_unit) catch error.InvalidInvoice; } +fn isAlphanumeric(s: []const u8) bool { + for (s) |c| if (!std.ascii.isAlphanumeric(c)) return false; + return true; +} + /// Rejects a pay_invoice request whose amount could be read differently by the /// client and by the wallet: a malformed `amount`, an amount that differs from /// the invoice's, or an amountless invoice (LNbits is never sent `amount`). fn checkPayInvoice(params_json: []const u8, params: nwc.Request.PayInvoice) ?nwc.Response.Error { - for (params.invoice) |c| { - if (!std.ascii.isAlphanumeric(c)) return .{ .code = .other, .message = "Invalid invoice" }; - } + if (!isAlphanumeric(params.invoice)) return .{ .code = .other, .message = "Invalid invoice" }; if (utils.findJsonFieldStart(params_json, "amount")) |start| { if (params.amount == null and !std.mem.startsWith(u8, params_json[start..], "null")) { @@ -394,19 +431,24 @@ fn checkPayInvoice(params_json: []const u8, params: nwc.Request.PayInvoice) ?nwc return null; } -fn handleRequest(request: nwc.Request, params_json: []const u8, lnbits: *LnbitsClient, buf: []u8) ![]u8 { +/// Response to a pay_invoice whose outcome LNbits could not confirm. Not +/// PAYMENT_FAILED: the payment may still complete, so a blind retry could pay twice. +const payment_unknown_message = "Payment status unknown, it may still complete; check lookup_invoice before retrying"; + +fn handleRequest(arena: std.mem.Allocator, request: nwc.Request, params_json: []const u8, lnbits: *LnbitsClient, buf: []u8) ![]u8 { var response: nwc.Response = .{ .result_type = request.method }; + var preimage_hex: [64]u8 = undefined; switch (request.params) { .get_balance => { - const wallet = lnbits.getWallet() catch { + const wallet = lnbits.getWallet(arena) catch { response.err = .{ .code = .internal, .message = "Failed to get balance" }; return response.serialize(buf); }; response.result = .{ .get_balance = .{ .balance = wallet.balance } }; }, .get_info => { - const wallet = lnbits.getWallet() catch { + const wallet = lnbits.getWallet(arena) catch { response.err = .{ .code = .internal, .message = "Failed to get wallet info" }; return response.serialize(buf); }; @@ -421,7 +463,7 @@ fn handleRequest(request: nwc.Request, params_json: []const u8, lnbits: *LnbitsC response.err = .{ .code = .other, .message = "Amount must be a positive whole number of sats" }; return response.serialize(buf); } - const invoice = lnbits.createInvoice(params.amount, params.description) catch { + const invoice = lnbits.createInvoice(arena, params.amount, params.description) catch { response.err = .{ .code = .internal, .message = "Failed to create invoice" }; return response.serialize(buf); }; @@ -441,23 +483,31 @@ fn handleRequest(request: nwc.Request, params_json: []const u8, lnbits: *LnbitsC return response.serialize(buf); } - const result = lnbits.payInvoice(params.invoice) catch { - response.err = .{ .code = .payment_failed, .message = "Payment failed" }; - return response.serialize(buf); - }; - - const details = lnbits.lookupPayment(result.payment_hash) catch { - response.result = .{ .pay_invoice = .{ - .preimage = "", - .fees_paid = null, - } }; + const payment_hash = bolt11.paymentHash(params.invoice) catch { + response.err = .{ .code = .other, .message = "Invalid invoice" }; return response.serialize(buf); }; - response.result = .{ .pay_invoice = .{ - .preimage = details.preimage, - .fees_paid = if (details.fee != 0) @abs(details.fee) else null, - } }; + switch (try lnbits.payInvoice(arena, params.invoice, &payment_hash)) { + .paid => |paid| { + const preimage: []const u8 = if (paid.preimage) |p| blk: { + preimage_hex = std.fmt.bytesToHex(&p, .lower); + break :blk &preimage_hex; + } else blk: { + std.log.warn("Payment succeeded but LNbits returned no matching preimage", .{}); + break :blk ""; + }; + response.result = .{ .pay_invoice = .{ + .preimage = preimage, + .fees_paid = paid.fee_msat, + } }; + }, + .failed => |reason| response.err = .{ + .code = .payment_failed, + .message = try std.fmt.allocPrint(arena, "Payment failed: {s}", .{reason}), + }, + .unknown => response.err = .{ .code = .internal, .message = payment_unknown_message }, + } }, .lookup_invoice => |params| { const hash = params.payment_hash orelse { @@ -465,22 +515,29 @@ fn handleRequest(request: nwc.Request, params_json: []const u8, lnbits: *LnbitsC return response.serialize(buf); }; - const details = lnbits.lookupPayment(hash) catch { + const details = lnbits.lookupPayment(arena, hash) catch { response.err = .{ .code = .not_found, .message = "Invoice not found" }; return response.serialize(buf); }; - const state: nwc.TransactionState = if (details.pending) .pending else .settled; - response.result = .{ .lookup_invoice = .{ - .tx_type = if (details.amount > 0) .incoming else .outgoing, - .state = state, - .invoice = if (details.bolt11.len > 0) details.bolt11 else null, - .payment_hash = details.payment_hash, - .preimage = if (details.preimage.len > 0) details.preimage else null, - .amount = if (details.amount != 0) @abs(details.amount) else null, - .fees_paid = if (details.fee != 0) @abs(details.fee) else null, - .description = if (details.memo.len > 0) details.memo else null, - } }; + const incoming = if (details.amount_msat) |a| a > 0 else false; + response.result = .{ + .lookup_invoice = .{ + .tx_type = if (incoming) .incoming else .outgoing, + .state = switch (details.state) { + .paid => .settled, + .failed => if (incoming) .expired else .failed, + .pending => .pending, + }, + // libnostr-z writes the invoice unescaped. + .invoice = if (details.bolt11) |b| if (isAlphanumeric(b)) b else null else null, + .payment_hash = hash, + .preimage = if (details.preimage) |p| try arena.dupe(u8, &std.fmt.bytesToHex(&p, .lower)) else null, + .amount = if (details.amount_msat) |a| if (a != 0) @abs(a) else null else null, + .fees_paid = details.fee_msat, + .description = if (details.memo) |m| if (m.len > 0) m else null else null, + }, + }; }, else => { response.err = .{ .code = .not_implemented, .message = "Method not supported" }; @@ -507,10 +564,11 @@ fn buildResponseEvent( }; defer allocator.free(encrypted); - const keypair = nostr.Keypair{ + var keypair = nostr.Keypair{ .secret_key = config.privkey, .public_key = config.pubkey, }; + defer std.crypto.secureZero(u8, &keypair.secret_key); var p_tag_hex: [64]u8 = undefined; nostr.hex.encode(recipient_pubkey, &p_tag_hex); @@ -531,6 +589,7 @@ fn buildResponseEvent( } test { + _ = @import("bolt11.zig"); _ = @import("config.zig"); _ = @import("lnbits.zig"); _ = @import("relay.zig"); @@ -602,7 +661,7 @@ test "checkRequest accepts a signed request from an authorized client" { var event = try nostr.Event.parseWithAllocator(signed.json, testing.allocator); defer event.deinit(); - try checkRequest(&keys.config, &event, now); + try checkRequest(&keys.config, &event, now, 0); } test "checkRequest rejects an unauthorized author" { @@ -617,7 +676,7 @@ test "checkRequest rejects an unauthorized author" { var event = try nostr.Event.parseWithAllocator(signed.json, testing.allocator); defer event.deinit(); - try testing.expectError(error.Unauthorized, checkRequest(&keys.config, &event, now)); + try testing.expectError(error.Unauthorized, checkRequest(&keys.config, &event, now, 0)); } test "checkRequest rejects a forged signature" { @@ -642,7 +701,7 @@ test "checkRequest rejects a forged signature" { var event = try nostr.Event.parseWithAllocator(tampered, testing.allocator); defer event.deinit(); - try testing.expectError(error.InvalidSignature, checkRequest(&keys.config, &event, now)); + try testing.expectError(error.InvalidSignature, checkRequest(&keys.config, &event, now, 0)); } test "checkRequest rejects requests not addressed to this wallet" { @@ -657,7 +716,7 @@ test "checkRequest rejects requests not addressed to this wallet" { var event = try nostr.Event.parseWithAllocator(signed.json, testing.allocator); defer event.deinit(); - try testing.expectError(error.NotAddressedToUs, checkRequest(&keys.config, &event, now)); + try testing.expectError(error.NotAddressedToUs, checkRequest(&keys.config, &event, now, 0)); } test "checkRequest rejects expired, malformed-expiration and stale requests" { @@ -674,14 +733,14 @@ test "checkRequest rejects expired, malformed-expiration and stale requests" { try signRequest(&expired, &keys.client, &expired_tags, now, "x"); var expired_event = try nostr.Event.parseWithAllocator(expired.json, testing.allocator); defer expired_event.deinit(); - try testing.expectError(error.Expired, checkRequest(&keys.config, &expired_event, now)); + try testing.expectError(error.Expired, checkRequest(&keys.config, &expired_event, now, 0)); const bad_tags = [_][]const []const u8{ &.{ "p", &wallet_hex }, &.{ "expiration", "soon" } }; var bad: SignedEvent = .{}; try signRequest(&bad, &keys.client, &bad_tags, now, "x"); var bad_event = try nostr.Event.parseWithAllocator(bad.json, testing.allocator); defer bad_event.deinit(); - try testing.expectError(error.Expired, checkRequest(&keys.config, &bad_event, now)); + try testing.expectError(error.Expired, checkRequest(&keys.config, &bad_event, now, 0)); var future_buf: [20]u8 = undefined; const future = try std.fmt.bufPrint(&future_buf, "{d}", .{now + 60}); @@ -690,13 +749,44 @@ test "checkRequest rejects expired, malformed-expiration and stale requests" { try signRequest(&stale, &keys.client, &live_tags, now - max_request_age_s - 1, "x"); var stale_event = try nostr.Event.parseWithAllocator(stale.json, testing.allocator); defer stale_event.deinit(); - try testing.expectError(error.Stale, checkRequest(&keys.config, &stale_event, now)); + try testing.expectError(error.Stale, checkRequest(&keys.config, &stale_event, now, 0)); var live: SignedEvent = .{}; try signRequest(&live, &keys.client, &live_tags, now, "x"); var live_event = try nostr.Event.parseWithAllocator(live.json, testing.allocator); defer live_event.deinit(); - try checkRequest(&keys.config, &live_event, now); + try checkRequest(&keys.config, &live_event, now, 0); +} + +test "checkRequest rejects requests created before the process started" { + var keys = try TestKeys.init(); + defer keys.deinit(); + + const now = nostr.io.timestamp(); + const not_before = now - start_skew_s; + const wallet_hex = hexOf(&keys.wallet.public_key); + const tags = [_][]const []const u8{&.{ "p", &wallet_hex }}; + + var old: SignedEvent = .{}; + try signRequest(&old, &keys.client, &tags, not_before - 1, "x"); + var old_event = try nostr.Event.parseWithAllocator(old.json, testing.allocator); + defer old_event.deinit(); + try testing.expectError(error.BeforeStart, checkRequest(&keys.config, &old_event, now, not_before)); + try checkRequest(&keys.config, &old_event, now, not_before - 1); + + var fresh: SignedEvent = .{}; + try signRequest(&fresh, &keys.client, &tags, not_before, "x"); + var fresh_event = try nostr.Event.parseWithAllocator(fresh.json, testing.allocator); + defer fresh_event.deinit(); + try checkRequest(&keys.config, &fresh_event, now, not_before); +} + +test "redactUserinfo hides credentials in URLs" { + var buf: [128]u8 = undefined; + try testing.expectEqualStrings("https://***@lnbits.example/api", redactUserinfo("https://user:pass@lnbits.example/api", &buf)); + try testing.expectEqualStrings("http://***@127.0.0.1:5000", redactUserinfo("http://a@b@127.0.0.1:5000", &buf)); + try testing.expectEqualStrings("http://127.0.0.1:5000/x?y=a@b", redactUserinfo("http://127.0.0.1:5000/x?y=a@b", &buf)); + try testing.expectEqualStrings("lnbits.local", redactUserinfo("lnbits.local", &buf)); } test "requestEncryption follows the NIP-47 encryption tag" { @@ -725,7 +815,7 @@ test "requestEncryption follows the NIP-47 encryption tag" { } test "ReplayGuard rejects a repeated id and forgets expired ones" { - var guard: ReplayGuard = .{}; + var guard: ReplayGuard = .{ .not_before = 0 }; defer guard.deinit(testing.allocator); const id: [32]u8 = @splat(7); @@ -825,12 +915,16 @@ test "handleRequest rejects invalid make_invoice amounts without calling LNbits" var buf: [1024]u8 = undefined; for ([_]u64{ 0, 1500, 999 }) |amount| { const request = nwc.Request{ .method = .make_invoice, .params = .{ .make_invoice = .{ .amount = amount } } }; - const json = try handleRequest(request, "{}", &lnbits, &buf); + const json = try handleRequest(testing.allocator, request, "{}", &lnbits, &buf); const parsed = nwc.Response.parseJson(json).?; try testing.expectEqual(nwc.ErrorCode.other, parsed.err.?.code); } const pay = nwc.Request{ .method = .pay_invoice, .params = .{ .pay_invoice = .{ .invoice = "lnbc50n1pdummy", .amount = 1 } } }; - const pay_json = try handleRequest(pay, "{\"invoice\":\"lnbc50n1pdummy\",\"amount\":1}", &lnbits, &buf); + const pay_json = try handleRequest(testing.allocator, pay, "{\"invoice\":\"lnbc50n1pdummy\",\"amount\":1}", &lnbits, &buf); try testing.expectEqualStrings("Amount does not match invoice", nwc.Response.parseJson(pay_json).?.err.?.message); + + const bad_checksum = nwc.Request{ .method = .pay_invoice, .params = .{ .pay_invoice = .{ .invoice = "lnbc50n1pdummy" } } }; + const bad_json = try handleRequest(testing.allocator, bad_checksum, "{\"invoice\":\"lnbc50n1pdummy\"}", &lnbits, &buf); + try testing.expectEqualStrings("Invalid invoice", nwc.Response.parseJson(bad_json).?.err.?.message); } diff --git a/src/relay.zig b/src/relay.zig index dce1342..87f0aa9 100644 --- a/src/relay.zig +++ b/src/relay.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const nostr = @import("nostr"); const ws = nostr.ws; const utils = nostr.utils; @@ -41,6 +42,7 @@ pub const Relay = struct { log.err("WebSocket connection failed: {}", .{err}); return RelayError.ConnectionFailed; }; + enableKeepalive(client.tcp_stream.socket.handle); return .{ .allocator = allocator, @@ -93,6 +95,30 @@ pub const Relay = struct { } }; +/// A relay that vanishes without closing the connection (host down, NAT +/// timeout) would otherwise leave the blocking read waiting forever. Kernel +/// keepalive probes, plus a cap on unacknowledged writes, turn that into a read +/// error after about 90 seconds, which drops into the reconnect loop. +// Detects a dead peer, not a live relay that stops sending; an application +// level ping (REQ answered by EOSE) would cover that too. +fn enableKeepalive(fd: std.posix.fd_t) void { + const set = struct { + fn opt(sock: std.posix.fd_t, level: i32, name: u32, value: c_int) void { + std.posix.setsockopt(sock, level, name, std.mem.asBytes(&value)) catch |err| { + log.warn("setsockopt {d}/{d} failed: {}", .{ level, name, err }); + }; + } + }.opt; + set(fd, std.posix.SOL.SOCKET, std.posix.SO.KEEPALIVE, 1); + if (builtin.os.tag == .linux) { + const tcp = std.posix.IPPROTO.TCP; + set(fd, tcp, std.posix.TCP.KEEPIDLE, 60); + set(fd, tcp, std.posix.TCP.KEEPINTVL, 10); + set(fd, tcp, std.posix.TCP.KEEPCNT, 3); + set(fd, tcp, std.posix.TCP.USER_TIMEOUT, 90_000); + } +} + fn freeMessageWith(allocator: std.mem.Allocator, message: *Message) void { switch (message.*) { .event => |e| { From cd74462520cafd574860c13695b5fce2c6f027be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Tue, 6 Oct 2026 11:15:16 -0400 Subject: [PATCH 2/3] Require a verified preimage and ignore stale failed rows --- README.md | 6 +- src/lnbits.zig | 168 +++++++++++++++++++++++++++++++++++++++---------- src/main.zig | 104 +++++++++++++++++++++++++----- 3 files changed, 228 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 0ffa9d5..290f6c9 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,13 @@ nostr+walletconnect://?relay=&secret= Add the public key of `CLIENT_SECRET` (hex or npub, for example from `nak key public `) to `client_pubkeys`, one entry per connection, and restart Puck. Requests from any other key are ignored. Puck does not store the secret. -Requests must use NIP-44 (`["encryption", "nip44_v2"]`); NIP-04 requests get an `UNSUPPORTED_ENCRYPTION` error, and requests with any other `encryption` tag get an `UNSUPPORTED_ENCRYPTION` error encrypted with NIP-44 and an empty `result_type`. Requests older than 10 minutes, past their `expiration` tag, or already seen are ignored. The record of seen requests is kept in memory only, so requests created more than 60 seconds before Puck started are ignored as well and get no reply; the client must send a new request. +Requests must use NIP-44 (`["encryption", "nip44_v2"]`); NIP-04 requests get an `UNSUPPORTED_ENCRYPTION` error, and requests with any other `encryption` tag get an `UNSUPPORTED_ENCRYPTION` error encrypted with NIP-44 and an empty `result_type`. Requests older than 10 minutes, past their `expiration` tag, or already seen are ignored. The record of seen requests is kept in memory only, so requests created more than 60 seconds before Puck started are ignored as well and get no reply; the client must send a new request. A request created in the last 60 seconds before a restart can still be replayed once after it; a replayed `pay_invoice` is then caught by LNbits, which refuses a second payment for a payment hash it has already paid or attempted. ## Payment Outcomes -`pay_invoice` reports `PAYMENT_FAILED` only when LNbits says the payment failed or was never attempted (for example insufficient balance, an invalid invoice, or LNbits unreachable). When the outcome is unclear (a timeout, a dropped connection, a 5xx or unreadable response, or a payment LNbits still lists as pending), Puck looks the payment up by the invoice's payment hash for about 15 seconds. It then replies with the preimage if the payment went through, `PAYMENT_FAILED` if LNbits marked it failed, and otherwise an `INTERNAL` error whose message says the status is unknown. Treat that error as "may still complete": check `lookup_invoice` before paying again. +`pay_invoice` reports `PAYMENT_FAILED` only when LNbits says the payment failed or was never attempted (for example insufficient balance, an invalid invoice, or LNbits unreachable). When the outcome is unclear (a timeout, a dropped connection, a 5xx or unreadable response, or a payment LNbits still lists as pending), Puck looks the payment up by the invoice's payment hash for about 15 seconds. It then replies with the preimage if the payment went through, `PAYMENT_FAILED` if LNbits marked it failed during this attempt (a failure left over from an earlier attempt on the same invoice does not count), and otherwise an `INTERNAL` error whose message says the status is unknown. Treat that error as "may still complete": check `lookup_invoice` before paying again. Success is only reported with a preimage that matches the invoice's payment hash; if LNbits reports success without one, the reply is the same `INTERNAL` error. -Every LNbits call has a deadline: 30 seconds for paying, 10 seconds for everything else. Puck handles one request at a time, so a payment whose outcome stays unclear holds up other requests for up to about 95 seconds in the worst case. The relay connection uses TCP keepalive; on Linux a relay that disappears without closing the connection is detected within about 90 seconds and Puck reconnects. +Every LNbits call has a deadline: 30 seconds for paying, 10 seconds for everything else. Puck handles one request at a time, so a payment whose outcome stays unclear holds up other requests for up to about 105 seconds in the worst case. The relay connection uses TCP keepalive; on Linux a relay that disappears without closing the connection is detected within about 90 seconds and Puck reconnects. Use with Alby, Amethyst, Damus, or any NWC-compatible app. diff --git a/src/lnbits.zig b/src/lnbits.zig index e91d9e1..b04416f 100644 --- a/src/lnbits.zig +++ b/src/lnbits.zig @@ -40,13 +40,33 @@ pub const PaymentDetails = struct { fee_msat: ?u64 = null, memo: ?[]const u8 = null, bolt11: ?[]const u8 = null, + /// LNbits' `details.updated_at`, compared verbatim to detect a changed row. + updated_at: ?[]const u8 = null, }; pub const Paid = struct { - preimage: ?[32]u8, + /// Always verified against the payment hash. + preimage: [32]u8, fee_msat: ?u64, }; +/// Result of asking LNbits for the wallet's outgoing payment with a hash. +pub const Lookup = union(enum) { + found: PaymentDetails, + not_found, + /// LNbits could not be asked or gave no usable answer. + unavailable, +}; + +/// The wallet's row for the payment hash just before the pay call, so a +/// failure recorded by an earlier attempt is not mistaken for this one's. +pub const Baseline = union(enum) { + none, + /// Existing row; holds its `updated_at` if LNbits returned one. + row: ?[]const u8, + unknown, +}; + pub const PayOutcome = union(enum) { paid: Paid, /// LNbits definitively reports the payment as not made; carries its reason. @@ -128,6 +148,14 @@ pub const Client = struct { const hash_hex = std.fmt.bytesToHex(payment_hash, .lower); const body = try std.fmt.allocPrint(arena, "{{\"out\":true,\"bolt11\":\"{s}\"}}", .{bolt11}); + // Compared against the row's `updated_at` instead of a clock, so + // clock skew between Puck and LNbits cannot make a stale row look new. + const baseline: Baseline = switch (self.lookupOutgoing(arena, &hash_hex)) { + .found => |d| .{ .row = d.updated_at }, + .not_found => .none, + .unavailable => .unknown, + }; + const post: PostOutcome = if (self.send(arena, .POST, "/api/v1/payments", body, pay_timeout_s)) |response| classifyPayResponse(arena, response, payment_hash) else |err| switch (err) { @@ -143,25 +171,13 @@ pub const Client = struct { switch (post) { .paid => |paid| return .{ .paid = paid }, - .failed => |reason| { - // A rejection can follow a completed payment (LNbits bookkeeping - // after the payment), or race an earlier attempt still in flight. - const details = self.lookupOutgoing(arena, &hash_hex) orelse return .{ .failed = reason }; - return switch (details.state) { - .paid => .{ .paid = .{ .preimage = details.preimage, .fee_msat = details.fee_msat } }, - .failed => .{ .failed = reason }, - .pending => .unknown, - }; - }, + // A rejection can follow a completed payment (LNbits bookkeeping + // after the payment), or race an earlier attempt still in flight. + .failed => |reason| return resolveRejected(reason, self.lookupOutgoing(arena, &hash_hex)), .ambiguous => { for (lookup_backoff_s) |delay| { if (delay > 0) std.Io.sleep(self.io, .fromSeconds(delay), .awake) catch {}; - const details = self.lookupOutgoing(arena, &hash_hex) orelse continue; - switch (details.state) { - .paid => return .{ .paid = .{ .preimage = details.preimage, .fee_msat = details.fee_msat } }, - .failed => return .{ .failed = "LNbits reported the payment as failed" }, - .pending => {}, - } + if (resolveUnclear(self.lookupOutgoing(arena, &hash_hex), baseline)) |outcome| return outcome; } log.warn("payment {s}: outcome still unknown after lookups", .{&hash_hex}); return .unknown; @@ -169,16 +185,22 @@ pub const Client = struct { } } - /// The wallet's outgoing payment for `hash_hex`, or null if LNbits has none - /// or could not be asked. - fn lookupOutgoing(self: *Client, arena: std.mem.Allocator, hash_hex: *const [64]u8) ?PaymentDetails { - const details = self.lookupPayment(arena, hash_hex) catch |err| { - log.warn("payment {s}: lookup failed: {t}", .{ hash_hex, err }); - return null; + /// The wallet's outgoing payment for `hash_hex`. An incoming row with the + /// same hash (paying the wallet's own invoice) counts as not found. + fn lookupOutgoing(self: *Client, arena: std.mem.Allocator, hash_hex: *const [64]u8) Lookup { + const details = self.lookupPayment(arena, hash_hex) catch |err| switch (err) { + LnbitsError.InvoiceNotFound => { + log.info("payment {s}: lookup found no payment", .{hash_hex}); + return .not_found; + }, + else => { + log.warn("payment {s}: lookup failed: {t}", .{ hash_hex, err }); + return .unavailable; + }, }; - if (details.amount_msat) |amount| if (amount > 0) return null; + if (details.amount_msat) |amount| if (amount > 0) return .not_found; log.info("payment {s}: lookup state {t}", .{ hash_hex, details.state }); - return details; + return .{ .found = details }; } pub fn lookupPayment(self: *Client, arena: std.mem.Allocator, payment_hash: []const u8) !PaymentDetails { @@ -287,10 +309,11 @@ pub fn classifyPayResponse(arena: std.mem.Allocator, response: HttpResponse, pay if (!std.ascii.eqlIgnoreCase(h, &want)) return .ambiguous; } const s = status orelse return .ambiguous; - if (std.mem.eql(u8, s, "success")) return .{ .paid = .{ - .preimage = verifiedPreimage(stringField(obj, "preimage"), payment_hash), - .fee_msat = feeMsat(obj.get("fee")), - } }; + if (std.mem.eql(u8, s, "success")) { + // Success without a verifiable preimage is settled by a lookup. + const preimage = verifiedPreimage(stringField(obj, "preimage"), payment_hash) orelse return .ambiguous; + return .{ .paid = .{ .preimage = preimage, .fee_msat = feeMsat(obj.get("fee")) } }; + } if (std.mem.eql(u8, s, "failed")) return .{ .failed = "LNbits reported the payment as failed" }; return .ambiguous; } @@ -306,6 +329,52 @@ pub fn classifyPayResponse(arena: std.mem.Allocator, response: HttpResponse, pay return .ambiguous; } +fn paidOutcome(details: PaymentDetails) ?PayOutcome { + const preimage = details.preimage orelse return null; + return .{ .paid = .{ .preimage = preimage, .fee_msat = details.fee_msat } }; +} + +/// Outcome after LNbits definitively rejected the pay call, given a lookup +/// made afterwards. Only a lookup that could not be made, a pending row, or a +/// paid row without a verifiable preimage leave the outcome unknown. +pub fn resolveRejected(reason: []const u8, lookup: Lookup) PayOutcome { + return switch (lookup) { + .not_found => .{ .failed = reason }, + .unavailable => .unknown, + .found => |d| switch (d.state) { + .paid => paidOutcome(d) orelse .unknown, + .failed => .{ .failed = reason }, + .pending => .unknown, + }, + }; +} + +/// Final outcome from one lookup after an unclear pay call, or null to keep +/// polling. A failed row only counts if it is new since `baseline`. +pub fn resolveUnclear(lookup: Lookup, baseline: Baseline) ?PayOutcome { + const d = switch (lookup) { + .found => |d| d, + .not_found, .unavailable => return null, + }; + return switch (d.state) { + .paid => paidOutcome(d), + .pending => null, + .failed => if (failedSince(baseline, d.updated_at)) .{ .failed = "LNbits reported the payment as failed" } else null, + }; +} + +fn failedSince(baseline: Baseline, updated_at: ?[]const u8) bool { + return switch (baseline) { + .none => true, + .unknown => false, + .row => |before| blk: { + const b = before orelse break :blk false; + const now = updated_at orelse break :blk false; + break :blk !std.mem.eql(u8, b, now); + }, + }; +} + /// Parses `GET /api/v1/payments/{hash}`: `paid` is authoritative, otherwise a /// status of "failed" (top level or in `details`) is a failure and anything /// else is still pending. @@ -340,6 +409,7 @@ pub fn parsePaymentDetails(arena: std.mem.Allocator, body: []const u8, payment_h if (paid) result.fee_msat = feeMsat(d.get("fee")); result.memo = stringField(d, "memo"); result.bolt11 = stringField(d, "bolt11"); + result.updated_at = stringField(d, "updated_at"); } return result; } @@ -462,13 +532,16 @@ test "classifyPayResponse separates definitive outcomes from ambiguous ones" { const paid = try classifyBody(arena, 201, \\{{"payment_hash":"{[hash]s}","status":"success","preimage":"{[preimage]s}","fee":-2000,"memo":"x"}} , &p); - try std.testing.expectEqualSlices(u8, &p.preimage, &paid.paid.preimage.?); + try std.testing.expectEqualSlices(u8, &p.preimage, &paid.paid.preimage); try std.testing.expectEqual(@as(?u64, 2000), paid.paid.fee_msat); const wrong_preimage = try classifyBody(arena, 201, \\{{"payment_hash":"{[hash]s}","status":"success","preimage":"{[hash]s}","memo":"{[preimage]s}"}} , &p); - try std.testing.expectEqual(@as(?[32]u8, null), wrong_preimage.paid.preimage); + try std.testing.expect(wrong_preimage == .ambiguous); + + const no_preimage = classifyPayResponse(arena, .{ .status = 201, .body = "{\"status\":\"success\",\"preimage\":null}" }, &p.hash); + try std.testing.expect(no_preimage == .ambiguous); const insufficient = classifyPayResponse(arena, .{ .status = 520, .body = "{\"detail\":\"Insufficient balance.\",\"status\":\"failed\"}" }, &p.hash); try std.testing.expectEqualStrings("Insufficient balance.", insufficient.failed); @@ -512,7 +585,7 @@ test "parsePaymentDetails reads LNbits payment lookups" { const p = TestPayment.init(); const paid_body = try std.fmt.allocPrint(arena, - \\{{"paid":true,"preimage":"{s}","details":{{"amount":-1000,"fee":-3000,"status":"success","memo":"m","bolt11":"lnbc"}}}} + \\{{"paid":true,"preimage":"{s}","details":{{"amount":-1000,"fee":-3000,"status":"success","memo":"m","bolt11":"lnbc","updated_at":"2026-10-06T10:00:00.123456+00:00"}}}} , .{&p.preimage_hex}); const paid = parsePaymentDetails(arena, paid_body, &p.hash).?; try std.testing.expectEqual(PaymentState.paid, paid.state); @@ -520,6 +593,7 @@ test "parsePaymentDetails reads LNbits payment lookups" { try std.testing.expectEqual(@as(?i64, -1000), paid.amount_msat); try std.testing.expectEqual(@as(?u64, 3000), paid.fee_msat); try std.testing.expectEqualStrings("m", paid.memo.?); + try std.testing.expectEqualStrings("2026-10-06T10:00:00.123456+00:00", paid.updated_at.?); const failed = parsePaymentDetails(arena, "{\"paid\":false,\"status\":\"failed\",\"details\":{\"amount\":-1000,\"fee\":-10}}", &p.hash).?; try std.testing.expectEqual(PaymentState.failed, failed.state); @@ -534,3 +608,33 @@ test "parsePaymentDetails reads LNbits payment lookups" { try std.testing.expect(parsePaymentDetails(arena, "{\"detail\":\"Payment does not exist.\"}", &p.hash) == null); try std.testing.expect(parsePaymentDetails(arena, "[]", &p.hash) == null); } + +test "resolveRejected and resolveUnclear never report an unverified success or a stale failure" { + const p = TestPayment.init(); + const paid: PaymentDetails = .{ .state = .paid, .preimage = p.preimage, .fee_msat = 5 }; + const paid_no_preimage: PaymentDetails = .{ .state = .paid }; + const pending: PaymentDetails = .{ .state = .pending }; + const old_failed: PaymentDetails = .{ .state = .failed, .updated_at = "2026-01-01T00:00:00+00:00" }; + const new_failed: PaymentDetails = .{ .state = .failed, .updated_at = "2026-10-06T10:00:01+00:00" }; + const undated_failed: PaymentDetails = .{ .state = .failed }; + + try std.testing.expectEqualStrings("no funds", resolveRejected("no funds", .not_found).failed); + try std.testing.expectEqualStrings("no funds", resolveRejected("no funds", .{ .found = old_failed }).failed); + try std.testing.expect(resolveRejected("no funds", .unavailable) == .unknown); + try std.testing.expect(resolveRejected("no funds", .{ .found = pending }) == .unknown); + try std.testing.expect(resolveRejected("no funds", .{ .found = paid_no_preimage }) == .unknown); + try std.testing.expectEqualSlices(u8, &p.preimage, &resolveRejected("no funds", .{ .found = paid }).paid.preimage); + + const old_row: Baseline = .{ .row = "2026-01-01T00:00:00+00:00" }; + try std.testing.expect(resolveUnclear(.{ .found = old_failed }, old_row) == null); + try std.testing.expect(resolveUnclear(.{ .found = new_failed }, old_row).? == .failed); + try std.testing.expect(resolveUnclear(.{ .found = undated_failed }, old_row) == null); + try std.testing.expect(resolveUnclear(.{ .found = new_failed }, .{ .row = null }) == null); + try std.testing.expect(resolveUnclear(.{ .found = new_failed }, .unknown) == null); + try std.testing.expect(resolveUnclear(.{ .found = undated_failed }, .none).? == .failed); + try std.testing.expect(resolveUnclear(.{ .found = paid_no_preimage }, .none) == null); + try std.testing.expect(resolveUnclear(.{ .found = pending }, .none) == null); + try std.testing.expect(resolveUnclear(.not_found, .none) == null); + try std.testing.expect(resolveUnclear(.unavailable, .none) == null); + try std.testing.expectEqual(@as(?u64, 5), resolveUnclear(.{ .found = paid }, old_row).?.paid.fee_msat); +} diff --git a/src/main.zig b/src/main.zig index 6fcab28..85fbf20 100644 --- a/src/main.zig +++ b/src/main.zig @@ -435,9 +435,40 @@ fn checkPayInvoice(params_json: []const u8, params: nwc.Request.PayInvoice) ?nwc /// PAYMENT_FAILED: the payment may still complete, so a blind retry could pay twice. const payment_unknown_message = "Payment status unknown, it may still complete; check lookup_invoice before retrying"; +/// Cap on free text copied into a response (LNbits error details, memos, +/// wallet names). JSON escaping can grow text sixfold, and the response must +/// fit the 16 KiB buffer together with a bolt11 of up to `max_invoice_len`. +const max_text_len = 1024; +const max_invoice_len = 4096; + +/// `s` cut to at most `max_text_len` bytes without splitting a UTF-8 sequence. +fn clip(s: []const u8) []const u8 { + if (s.len <= max_text_len) return s; + var end: usize = max_text_len; + while (end > 0 and s[end] & 0xC0 == 0x80) end -= 1; + return s[0..end]; +} + +/// Serializes `response`, or, if it does not fit, an INTERNAL error that +/// still answers the request (for pay_invoice without claiming failure). +fn serializeOrFallback(response: nwc.Response, buf: []u8) ![]u8 { + return response.serialize(buf) catch { + std.log.warn("{s} response does not fit, sending an error instead", .{response.result_type.toString()}); + const fallback: nwc.Response = .{ + .result_type = response.result_type, + .err = .{ + .code = .internal, + .message = if (response.result_type == .pay_invoice) payment_unknown_message else "Response too large", + }, + }; + return fallback.serialize(buf); + }; +} + fn handleRequest(arena: std.mem.Allocator, request: nwc.Request, params_json: []const u8, lnbits: *LnbitsClient, buf: []u8) ![]u8 { var response: nwc.Response = .{ .result_type = request.method }; var preimage_hex: [64]u8 = undefined; + var message_buf: [max_text_len + 64]u8 = undefined; switch (request.params) { .get_balance => { @@ -453,7 +484,7 @@ fn handleRequest(arena: std.mem.Allocator, request: nwc.Request, params_json: [] return response.serialize(buf); }; response.result = .{ .get_info = .{ - .alias = wallet.name, + .alias = clip(wallet.name), .network = "mainnet", .methods = &supported_methods, } }; @@ -473,7 +504,7 @@ fn handleRequest(arena: std.mem.Allocator, request: nwc.Request, params_json: [] .invoice = invoice.payment_request, .payment_hash = invoice.payment_hash, .amount = params.amount, - .description = params.description, + .description = if (params.description) |d| clip(d) else null, .created_at = nostr.io.timestamp(), } }; }, @@ -490,21 +521,15 @@ fn handleRequest(arena: std.mem.Allocator, request: nwc.Request, params_json: [] switch (try lnbits.payInvoice(arena, params.invoice, &payment_hash)) { .paid => |paid| { - const preimage: []const u8 = if (paid.preimage) |p| blk: { - preimage_hex = std.fmt.bytesToHex(&p, .lower); - break :blk &preimage_hex; - } else blk: { - std.log.warn("Payment succeeded but LNbits returned no matching preimage", .{}); - break :blk ""; - }; + preimage_hex = std.fmt.bytesToHex(&paid.preimage, .lower); response.result = .{ .pay_invoice = .{ - .preimage = preimage, + .preimage = &preimage_hex, .fees_paid = paid.fee_msat, } }; }, .failed => |reason| response.err = .{ .code = .payment_failed, - .message = try std.fmt.allocPrint(arena, "Payment failed: {s}", .{reason}), + .message = std.fmt.bufPrint(&message_buf, "Payment failed: {s}", .{clip(reason)}) catch "Payment failed", }, .unknown => response.err = .{ .code = .internal, .message = payment_unknown_message }, } @@ -530,12 +555,15 @@ fn handleRequest(arena: std.mem.Allocator, request: nwc.Request, params_json: [] .pending => .pending, }, // libnostr-z writes the invoice unescaped. - .invoice = if (details.bolt11) |b| if (isAlphanumeric(b)) b else null else null, + .invoice = if (details.bolt11) |b| if (b.len <= max_invoice_len and isAlphanumeric(b)) b else null else null, .payment_hash = hash, - .preimage = if (details.preimage) |p| try arena.dupe(u8, &std.fmt.bytesToHex(&p, .lower)) else null, + .preimage = if (details.preimage) |p| blk: { + preimage_hex = std.fmt.bytesToHex(&p, .lower); + break :blk &preimage_hex; + } else null, .amount = if (details.amount_msat) |a| if (a != 0) @abs(a) else null else null, .fees_paid = details.fee_msat, - .description = if (details.memo) |m| if (m.len > 0) m else null else null, + .description = if (details.memo) |m| if (m.len > 0) clip(m) else null else null, }, }; }, @@ -544,7 +572,7 @@ fn handleRequest(arena: std.mem.Allocator, request: nwc.Request, params_json: [] }, } - return response.serialize(buf); + return serializeOrFallback(response, buf); } /// Signs a kind 23195 response encrypted to the requester with the scheme the @@ -928,3 +956,49 @@ test "handleRequest rejects invalid make_invoice amounts without calling LNbits" const bad_json = try handleRequest(testing.allocator, bad_checksum, "{\"invoice\":\"lnbc50n1pdummy\"}", &lnbits, &buf); try testing.expectEqualStrings("Invalid invoice", nwc.Response.parseJson(bad_json).?.err.?.message); } + +test "clip keeps responses with huge LNbits text inside the response buffer" { + const huge = try testing.allocator.alloc(u8, 64 * 1024); + defer testing.allocator.free(huge); + @memset(huge, '"'); + const memo = try testing.allocator.alloc(u8, 64 * 1024); + defer testing.allocator.free(memo); + @memset(memo, 0x01); + + var buf: [16384]u8 = undefined; + var message_buf: [max_text_len + 64]u8 = undefined; + const failed: nwc.Response = .{ .result_type = .pay_invoice, .err = .{ + .code = .payment_failed, + .message = try std.fmt.bufPrint(&message_buf, "Payment failed: {s}", .{clip(huge)}), + } }; + const failed_json = try failed.serialize(&buf); + const parsed = nwc.Response.parseJson(failed_json).?; + try testing.expectEqual(nwc.ErrorCode.payment_failed, parsed.err.?.code); + + const invoice: [max_invoice_len]u8 = @splat('q'); + const preimage: [64]u8 = @splat('a'); + const lookup: nwc.Response = .{ .result_type = .lookup_invoice, .result = .{ .lookup_invoice = .{ + .tx_type = .outgoing, + .state = .settled, + .invoice = &invoice, + .payment_hash = &preimage, + .preimage = &preimage, + .amount = 1000, + .fees_paid = 1000, + .description = clip(memo), + } } }; + _ = try lookup.serialize(&buf); + + const unclipped: nwc.Response = .{ .result_type = .pay_invoice, .err = .{ .code = .payment_failed, .message = huge } }; + const fallback = nwc.Response.parseJson(try serializeOrFallback(unclipped, &buf)).?; + try testing.expectEqual(nwc.ErrorCode.internal, fallback.err.?.code); + try testing.expectEqualStrings(payment_unknown_message, fallback.err.?.message); +} + +test "clip does not split UTF-8 sequences" { + var text: [max_text_len + 2]u8 = @splat('a'); + text[max_text_len - 1] = 0xC3; + text[max_text_len] = 0xA9; + try testing.expectEqual(@as(usize, max_text_len - 1), clip(&text).len); + try testing.expectEqualStrings("short", clip("short")); +} From 5fe65c485847c3b5cecb6b95feceb7f171ea6e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Tue, 6 Oct 2026 11:22:26 -0400 Subject: [PATCH 3/3] Never treat a payment row without a negative amount as this payment --- src/lnbits.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lnbits.zig b/src/lnbits.zig index b04416f..318b0ca 100644 --- a/src/lnbits.zig +++ b/src/lnbits.zig @@ -186,7 +186,9 @@ pub const Client = struct { } /// The wallet's outgoing payment for `hash_hex`. An incoming row with the - /// same hash (paying the wallet's own invoice) counts as not found. + /// same hash (paying the wallet's own invoice) counts as not found, and so + /// does a row with no amount: the wallet's own paid invoice carries a valid + /// preimage, so it must never be mistaken for this payment. fn lookupOutgoing(self: *Client, arena: std.mem.Allocator, hash_hex: *const [64]u8) Lookup { const details = self.lookupPayment(arena, hash_hex) catch |err| switch (err) { LnbitsError.InvoiceNotFound => { @@ -198,7 +200,8 @@ pub const Client = struct { return .unavailable; }, }; - if (details.amount_msat) |amount| if (amount > 0) return .not_found; + const amount = details.amount_msat orelse return .not_found; + if (amount >= 0) return .not_found; log.info("payment {s}: lookup state {t}", .{ hash_hex, details.state }); return .{ .found = details }; }