From 98f141b6656e8694175d59c00043558cde3dbbb6 Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Wed, 11 Mar 2026 18:03:29 +0100 Subject: [PATCH] plugin: Add opening_fee_msat to LspInvoiceResponse Include the LSP's JIT channel opening fee in the lsp_invoice response so clients can display the negotiated fees when presenting an invoice. The fee is computed from the selected LSP's LSPS2 fee parameters using max(min_fee_msat, ceil(amount * proportional / 1_000_000)). When the node has sufficient incoming capacity and no JIT channel is needed, the fee is reported as 0. Also surfaces the fee through gl-sdk's ReceiveResponse for UniFFI bindings consumers. Co-Authored-By: Claude Opus 4.6 --- libs/gl-client-py/glclient/greenlight.proto | 10 ++++--- libs/gl-client-py/glclient/greenlight_pb2.py | 26 +++++++++---------- libs/gl-client-py/glclient/greenlight_pb2.pyi | 13 +++++++--- libs/gl-client-py/tests/test_plugin.py | 5 ++++ .../proto/glclient/greenlight.proto | 10 ++++--- .../proto/glclient/greenlight.proto | 10 ++++--- libs/gl-plugin/src/node/mod.rs | 14 +++++++++- libs/gl-plugin/src/responses.rs | 6 +++++ libs/gl-sdk/src/node.rs | 8 +++++- libs/proto/glclient/greenlight.proto | 4 +++ 10 files changed, 79 insertions(+), 27 deletions(-) diff --git a/libs/gl-client-py/glclient/greenlight.proto b/libs/gl-client-py/glclient/greenlight.proto index 94d2cfacd..67c3fafe7 100644 --- a/libs/gl-client-py/glclient/greenlight.proto +++ b/libs/gl-client-py/glclient/greenlight.proto @@ -187,8 +187,8 @@ message NodeConfig { // The `GlConfig` is used to pass greenlight-specific startup parameters -// to the node. The `gl-plugin` will look for a serialized config object in -// the node's datastore to load these values from. Please refer to the +// to the node. The `gl-plugin` will look for a serialized config object in +// the node's datastore to load these values from. Please refer to the // individual fields to learn what they do. message GlConfig { string close_to_addr = 1; @@ -240,7 +240,7 @@ message LspInvoiceRequest { // Optional: for discounts/API keys string token = 2; // len=0 => None // Pass-through of cln invoice rpc params - uint64 amount_msat = 3; // 0 => Any + uint64 amount_msat = 3; // 0 => Any string description = 4; string label = 5; } @@ -250,6 +250,10 @@ message LspInvoiceResponse { uint32 expires_at = 3; bytes payment_hash = 4; bytes payment_secret = 5; + // The fee charged by the LSP for opening a JIT channel, in + // millisatoshi. This is 0 if the node already had sufficient + // incoming capacity and no JIT channel was needed. + uint64 opening_fee_msat = 6; } // Request for streaming node events. Currently empty but defined as diff --git a/libs/gl-client-py/glclient/greenlight_pb2.py b/libs/gl-client-py/glclient/greenlight_pb2.py index 1c0619a63..62bae322a 100644 --- a/libs/gl-client-py/glclient/greenlight_pb2.py +++ b/libs/gl-client-py/glclient/greenlight_pb2.py @@ -24,7 +24,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19glclient/greenlight.proto\x12\ngreenlight\"H\n\x11HsmRequestContext\x12\x0f\n\x07node_id\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64\x62id\x18\x02 \x01(\x04\x12\x14\n\x0c\x63\x61pabilities\x18\x03 \x01(\x04\"q\n\x0bHsmResponse\x12\x12\n\nrequest_id\x18\x01 \x01(\r\x12\x0b\n\x03raw\x18\x02 \x01(\x0c\x12\x32\n\x0csigner_state\x18\x05 \x03(\x0b\x32\x1c.greenlight.SignerStateEntry\x12\r\n\x05\x65rror\x18\x06 \x01(\t\"\xbf\x01\n\nHsmRequest\x12\x12\n\nrequest_id\x18\x01 \x01(\r\x12.\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\x1d.greenlight.HsmRequestContext\x12\x0b\n\x03raw\x18\x03 \x01(\x0c\x12\x32\n\x0csigner_state\x18\x04 \x03(\x0b\x32\x1c.greenlight.SignerStateEntry\x12,\n\x08requests\x18\x05 \x03(\x0b\x32\x1a.greenlight.PendingRequest\"\x07\n\x05\x45mpty\"l\n\x06\x41mount\x12\x16\n\x0cmillisatoshi\x18\x01 \x01(\x04H\x00\x12\x11\n\x07satoshi\x18\x02 \x01(\x04H\x00\x12\x11\n\x07\x62itcoin\x18\x03 \x01(\x04H\x00\x12\r\n\x03\x61ll\x18\x04 \x01(\x08H\x00\x12\r\n\x03\x61ny\x18\x05 \x01(\x08H\x00\x42\x06\n\x04unit\"\x16\n\x14StreamIncomingFilter\"\'\n\x08TlvField\x12\x0c\n\x04type\x18\x01 \x01(\x04\x12\r\n\x05value\x18\x02 \x01(\x0c\"\xa5\x01\n\x0fOffChainPayment\x12\r\n\x05label\x18\x01 \x01(\t\x12\x10\n\x08preimage\x18\x02 \x01(\x0c\x12\"\n\x06\x61mount\x18\x03 \x01(\x0b\x32\x12.greenlight.Amount\x12\'\n\textratlvs\x18\x04 \x03(\x0b\x32\x14.greenlight.TlvField\x12\x14\n\x0cpayment_hash\x18\x05 \x01(\x0c\x12\x0e\n\x06\x62olt11\x18\x06 \x01(\t\"M\n\x0fIncomingPayment\x12/\n\x08offchain\x18\x01 \x01(\x0b\x32\x1b.greenlight.OffChainPaymentH\x00\x42\t\n\x07\x64\x65tails\"\x12\n\x10StreamLogRequest\"\x18\n\x08LogEntry\x12\x0c\n\x04line\x18\x01 \x01(\t\"?\n\x10SignerStateEntry\x12\x0f\n\x07version\x18\x01 \x01(\x04\x12\x0b\n\x03key\x18\x02 \x01(\t\x12\r\n\x05value\x18\x03 \x01(\x0c\"r\n\x0ePendingRequest\x12\x0f\n\x07request\x18\x01 \x01(\x0c\x12\x0b\n\x03uri\x18\x02 \x01(\t\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x12\x0e\n\x06pubkey\x18\x04 \x01(\x0c\x12\x11\n\ttimestamp\x18\x05 \x01(\x04\x12\x0c\n\x04rune\x18\x06 \x01(\x0c\"=\n\nNodeConfig\x12/\n\x0bstartupmsgs\x18\x01 \x03(\x0b\x32\x1a.greenlight.StartupMessage\"!\n\x08GlConfig\x12\x15\n\rclose_to_addr\x18\x01 \x01(\t\"3\n\x0eStartupMessage\x12\x0f\n\x07request\x18\x01 \x01(\x0c\x12\x10\n\x08response\x18\x02 \x01(\x0c\"\x18\n\x16StreamCustommsgRequest\"-\n\tCustommsg\x12\x0f\n\x07peer_id\x18\x01 \x01(\x0c\x12\x0f\n\x07payload\x18\x02 \x01(\x0c\"\xa4\x01\n\x14TrampolinePayRequest\x12\x0e\n\x06\x62olt11\x18\x01 \x01(\t\x12\x1a\n\x12trampoline_node_id\x18\x02 \x01(\x0c\x12\x13\n\x0b\x61mount_msat\x18\x03 \x01(\x04\x12\r\n\x05label\x18\x04 \x01(\t\x12\x15\n\rmaxfeepercent\x18\x05 \x01(\x02\x12\x10\n\x08maxdelay\x18\x06 \x01(\r\x12\x13\n\x0b\x64\x65scription\x18\x07 \x01(\t\"\xd5\x01\n\x15TrampolinePayResponse\x12\x18\n\x10payment_preimage\x18\x01 \x01(\x0c\x12\x14\n\x0cpayment_hash\x18\x02 \x01(\x0c\x12\x12\n\ncreated_at\x18\x03 \x01(\x01\x12\r\n\x05parts\x18\x04 \x01(\r\x12\x13\n\x0b\x61mount_msat\x18\x05 \x01(\x04\x12\x18\n\x10\x61mount_sent_msat\x18\x06 \x01(\x04\x12\x13\n\x0b\x64\x65stination\x18\x07 \x01(\x0c\"%\n\tPayStatus\x12\x0c\n\x08\x43OMPLETE\x10\x00\x12\n\n\x06\x46\x41ILED\x10\x02\"k\n\x11LspInvoiceRequest\x12\x0e\n\x06lsp_id\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\t\x12\x13\n\x0b\x61mount_msat\x18\x03 \x01(\x04\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\r\n\x05label\x18\x05 \x01(\t\"}\n\x12LspInvoiceResponse\x12\x0e\n\x06\x62olt11\x18\x01 \x01(\t\x12\x15\n\rcreated_index\x18\x02 \x01(\r\x12\x12\n\nexpires_at\x18\x03 \x01(\r\x12\x14\n\x0cpayment_hash\x18\x04 \x01(\x0c\x12\x16\n\x0epayment_secret\x18\x05 \x01(\x0c\"\x13\n\x11NodeEventsRequest\"E\n\tNodeEvent\x12/\n\x0cinvoice_paid\x18\x01 \x01(\x0b\x32\x17.greenlight.InvoicePaidH\x00\x42\x07\n\x05\x65vent\"\x92\x01\n\x0bInvoicePaid\x12\x14\n\x0cpayment_hash\x18\x01 \x01(\x0c\x12\x0e\n\x06\x62olt11\x18\x02 \x01(\t\x12\x10\n\x08preimage\x18\x03 \x01(\x0c\x12\r\n\x05label\x18\x04 \x01(\t\x12\x13\n\x0b\x61mount_msat\x18\x05 \x01(\x04\x12\'\n\textratlvs\x18\x06 \x03(\x0b\x32\x14.greenlight.TlvField2\xa6\x05\n\x04Node\x12M\n\nLspInvoice\x12\x1d.greenlight.LspInvoiceRequest\x1a\x1e.greenlight.LspInvoiceResponse\"\x00\x12S\n\x0eStreamIncoming\x12 .greenlight.StreamIncomingFilter\x1a\x1b.greenlight.IncomingPayment\"\x00\x30\x01\x12\x43\n\tStreamLog\x12\x1c.greenlight.StreamLogRequest\x1a\x14.greenlight.LogEntry\"\x00\x30\x01\x12P\n\x0fStreamCustommsg\x12\".greenlight.StreamCustommsgRequest\x1a\x15.greenlight.Custommsg\"\x00\x30\x01\x12L\n\x10StreamNodeEvents\x12\x1d.greenlight.NodeEventsRequest\x1a\x15.greenlight.NodeEvent\"\x00\x30\x01\x12\x42\n\x11StreamHsmRequests\x12\x11.greenlight.Empty\x1a\x16.greenlight.HsmRequest\"\x00\x30\x01\x12\x41\n\x11RespondHsmRequest\x12\x17.greenlight.HsmResponse\x1a\x11.greenlight.Empty\"\x00\x12\x36\n\tConfigure\x12\x14.greenlight.GlConfig\x1a\x11.greenlight.Empty\"\x00\x12V\n\rTrampolinePay\x12 .greenlight.TrampolinePayRequest\x1a!.greenlight.TrampolinePayResponse\"\x00\x32s\n\x03Hsm\x12<\n\x07Request\x12\x16.greenlight.HsmRequest\x1a\x17.greenlight.HsmResponse\"\x00\x12.\n\x04Ping\x12\x11.greenlight.Empty\x1a\x11.greenlight.Empty\"\x00\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19glclient/greenlight.proto\x12\ngreenlight\"H\n\x11HsmRequestContext\x12\x0f\n\x07node_id\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64\x62id\x18\x02 \x01(\x04\x12\x14\n\x0c\x63\x61pabilities\x18\x03 \x01(\x04\"q\n\x0bHsmResponse\x12\x12\n\nrequest_id\x18\x01 \x01(\r\x12\x0b\n\x03raw\x18\x02 \x01(\x0c\x12\x32\n\x0csigner_state\x18\x05 \x03(\x0b\x32\x1c.greenlight.SignerStateEntry\x12\r\n\x05\x65rror\x18\x06 \x01(\t\"\xbf\x01\n\nHsmRequest\x12\x12\n\nrequest_id\x18\x01 \x01(\r\x12.\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\x1d.greenlight.HsmRequestContext\x12\x0b\n\x03raw\x18\x03 \x01(\x0c\x12\x32\n\x0csigner_state\x18\x04 \x03(\x0b\x32\x1c.greenlight.SignerStateEntry\x12,\n\x08requests\x18\x05 \x03(\x0b\x32\x1a.greenlight.PendingRequest\"\x07\n\x05\x45mpty\"l\n\x06\x41mount\x12\x16\n\x0cmillisatoshi\x18\x01 \x01(\x04H\x00\x12\x11\n\x07satoshi\x18\x02 \x01(\x04H\x00\x12\x11\n\x07\x62itcoin\x18\x03 \x01(\x04H\x00\x12\r\n\x03\x61ll\x18\x04 \x01(\x08H\x00\x12\r\n\x03\x61ny\x18\x05 \x01(\x08H\x00\x42\x06\n\x04unit\"\x16\n\x14StreamIncomingFilter\"\'\n\x08TlvField\x12\x0c\n\x04type\x18\x01 \x01(\x04\x12\r\n\x05value\x18\x02 \x01(\x0c\"\xa5\x01\n\x0fOffChainPayment\x12\r\n\x05label\x18\x01 \x01(\t\x12\x10\n\x08preimage\x18\x02 \x01(\x0c\x12\"\n\x06\x61mount\x18\x03 \x01(\x0b\x32\x12.greenlight.Amount\x12\'\n\textratlvs\x18\x04 \x03(\x0b\x32\x14.greenlight.TlvField\x12\x14\n\x0cpayment_hash\x18\x05 \x01(\x0c\x12\x0e\n\x06\x62olt11\x18\x06 \x01(\t\"M\n\x0fIncomingPayment\x12/\n\x08offchain\x18\x01 \x01(\x0b\x32\x1b.greenlight.OffChainPaymentH\x00\x42\t\n\x07\x64\x65tails\"\x12\n\x10StreamLogRequest\"\x18\n\x08LogEntry\x12\x0c\n\x04line\x18\x01 \x01(\t\"?\n\x10SignerStateEntry\x12\x0f\n\x07version\x18\x01 \x01(\x04\x12\x0b\n\x03key\x18\x02 \x01(\t\x12\r\n\x05value\x18\x03 \x01(\x0c\"r\n\x0ePendingRequest\x12\x0f\n\x07request\x18\x01 \x01(\x0c\x12\x0b\n\x03uri\x18\x02 \x01(\t\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x12\x0e\n\x06pubkey\x18\x04 \x01(\x0c\x12\x11\n\ttimestamp\x18\x05 \x01(\x04\x12\x0c\n\x04rune\x18\x06 \x01(\x0c\"=\n\nNodeConfig\x12/\n\x0bstartupmsgs\x18\x01 \x03(\x0b\x32\x1a.greenlight.StartupMessage\"!\n\x08GlConfig\x12\x15\n\rclose_to_addr\x18\x01 \x01(\t\"3\n\x0eStartupMessage\x12\x0f\n\x07request\x18\x01 \x01(\x0c\x12\x10\n\x08response\x18\x02 \x01(\x0c\"\x18\n\x16StreamCustommsgRequest\"-\n\tCustommsg\x12\x0f\n\x07peer_id\x18\x01 \x01(\x0c\x12\x0f\n\x07payload\x18\x02 \x01(\x0c\"\xa4\x01\n\x14TrampolinePayRequest\x12\x0e\n\x06\x62olt11\x18\x01 \x01(\t\x12\x1a\n\x12trampoline_node_id\x18\x02 \x01(\x0c\x12\x13\n\x0b\x61mount_msat\x18\x03 \x01(\x04\x12\r\n\x05label\x18\x04 \x01(\t\x12\x15\n\rmaxfeepercent\x18\x05 \x01(\x02\x12\x10\n\x08maxdelay\x18\x06 \x01(\r\x12\x13\n\x0b\x64\x65scription\x18\x07 \x01(\t\"\xd5\x01\n\x15TrampolinePayResponse\x12\x18\n\x10payment_preimage\x18\x01 \x01(\x0c\x12\x14\n\x0cpayment_hash\x18\x02 \x01(\x0c\x12\x12\n\ncreated_at\x18\x03 \x01(\x01\x12\r\n\x05parts\x18\x04 \x01(\r\x12\x13\n\x0b\x61mount_msat\x18\x05 \x01(\x04\x12\x18\n\x10\x61mount_sent_msat\x18\x06 \x01(\x04\x12\x13\n\x0b\x64\x65stination\x18\x07 \x01(\x0c\"%\n\tPayStatus\x12\x0c\n\x08\x43OMPLETE\x10\x00\x12\n\n\x06\x46\x41ILED\x10\x02\"k\n\x11LspInvoiceRequest\x12\x0e\n\x06lsp_id\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\t\x12\x13\n\x0b\x61mount_msat\x18\x03 \x01(\x04\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\x12\r\n\x05label\x18\x05 \x01(\t\"\x97\x01\n\x12LspInvoiceResponse\x12\x0e\n\x06\x62olt11\x18\x01 \x01(\t\x12\x15\n\rcreated_index\x18\x02 \x01(\r\x12\x12\n\nexpires_at\x18\x03 \x01(\r\x12\x14\n\x0cpayment_hash\x18\x04 \x01(\x0c\x12\x16\n\x0epayment_secret\x18\x05 \x01(\x0c\x12\x18\n\x10opening_fee_msat\x18\x06 \x01(\x04\"\x13\n\x11NodeEventsRequest\"E\n\tNodeEvent\x12/\n\x0cinvoice_paid\x18\x01 \x01(\x0b\x32\x17.greenlight.InvoicePaidH\x00\x42\x07\n\x05\x65vent\"\x92\x01\n\x0bInvoicePaid\x12\x14\n\x0cpayment_hash\x18\x01 \x01(\x0c\x12\x0e\n\x06\x62olt11\x18\x02 \x01(\t\x12\x10\n\x08preimage\x18\x03 \x01(\x0c\x12\r\n\x05label\x18\x04 \x01(\t\x12\x13\n\x0b\x61mount_msat\x18\x05 \x01(\x04\x12\'\n\textratlvs\x18\x06 \x03(\x0b\x32\x14.greenlight.TlvField2\xa6\x05\n\x04Node\x12M\n\nLspInvoice\x12\x1d.greenlight.LspInvoiceRequest\x1a\x1e.greenlight.LspInvoiceResponse\"\x00\x12S\n\x0eStreamIncoming\x12 .greenlight.StreamIncomingFilter\x1a\x1b.greenlight.IncomingPayment\"\x00\x30\x01\x12\x43\n\tStreamLog\x12\x1c.greenlight.StreamLogRequest\x1a\x14.greenlight.LogEntry\"\x00\x30\x01\x12P\n\x0fStreamCustommsg\x12\".greenlight.StreamCustommsgRequest\x1a\x15.greenlight.Custommsg\"\x00\x30\x01\x12L\n\x10StreamNodeEvents\x12\x1d.greenlight.NodeEventsRequest\x1a\x15.greenlight.NodeEvent\"\x00\x30\x01\x12\x42\n\x11StreamHsmRequests\x12\x11.greenlight.Empty\x1a\x16.greenlight.HsmRequest\"\x00\x30\x01\x12\x41\n\x11RespondHsmRequest\x12\x17.greenlight.HsmResponse\x1a\x11.greenlight.Empty\"\x00\x12\x36\n\tConfigure\x12\x14.greenlight.GlConfig\x1a\x11.greenlight.Empty\"\x00\x12V\n\rTrampolinePay\x12 .greenlight.TrampolinePayRequest\x1a!.greenlight.TrampolinePayResponse\"\x00\x32s\n\x03Hsm\x12<\n\x07Request\x12\x16.greenlight.HsmRequest\x1a\x17.greenlight.HsmResponse\"\x00\x12.\n\x04Ping\x12\x11.greenlight.Empty\x1a\x11.greenlight.Empty\"\x00\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -75,16 +75,16 @@ _globals['_TRAMPOLINEPAYRESPONSE_PAYSTATUS']._serialized_end=1687 _globals['_LSPINVOICEREQUEST']._serialized_start=1689 _globals['_LSPINVOICEREQUEST']._serialized_end=1796 - _globals['_LSPINVOICERESPONSE']._serialized_start=1798 - _globals['_LSPINVOICERESPONSE']._serialized_end=1923 - _globals['_NODEEVENTSREQUEST']._serialized_start=1925 - _globals['_NODEEVENTSREQUEST']._serialized_end=1944 - _globals['_NODEEVENT']._serialized_start=1946 - _globals['_NODEEVENT']._serialized_end=2015 - _globals['_INVOICEPAID']._serialized_start=2018 - _globals['_INVOICEPAID']._serialized_end=2164 - _globals['_NODE']._serialized_start=2167 - _globals['_NODE']._serialized_end=2845 - _globals['_HSM']._serialized_start=2847 - _globals['_HSM']._serialized_end=2962 + _globals['_LSPINVOICERESPONSE']._serialized_start=1799 + _globals['_LSPINVOICERESPONSE']._serialized_end=1950 + _globals['_NODEEVENTSREQUEST']._serialized_start=1952 + _globals['_NODEEVENTSREQUEST']._serialized_end=1971 + _globals['_NODEEVENT']._serialized_start=1973 + _globals['_NODEEVENT']._serialized_end=2042 + _globals['_INVOICEPAID']._serialized_start=2045 + _globals['_INVOICEPAID']._serialized_end=2191 + _globals['_NODE']._serialized_start=2194 + _globals['_NODE']._serialized_end=2872 + _globals['_HSM']._serialized_start=2874 + _globals['_HSM']._serialized_end=2989 # @@protoc_insertion_point(module_scope) diff --git a/libs/gl-client-py/glclient/greenlight_pb2.pyi b/libs/gl-client-py/glclient/greenlight_pb2.pyi index a09488cbf..65e182744 100644 --- a/libs/gl-client-py/glclient/greenlight_pb2.pyi +++ b/libs/gl-client-py/glclient/greenlight_pb2.pyi @@ -369,8 +369,8 @@ Global___NodeConfig: _TypeAlias = NodeConfig # noqa: Y015 @_typing.final class GlConfig(_message.Message): """The `GlConfig` is used to pass greenlight-specific startup parameters - to the node. The `gl-plugin` will look for a serialized config object in - the node's datastore to load these values from. Please refer to the + to the node. The `gl-plugin` will look for a serialized config object in + the node's datastore to load these values from. Please refer to the individual fields to learn what they do. """ @@ -568,11 +568,17 @@ class LspInvoiceResponse(_message.Message): EXPIRES_AT_FIELD_NUMBER: _builtins.int PAYMENT_HASH_FIELD_NUMBER: _builtins.int PAYMENT_SECRET_FIELD_NUMBER: _builtins.int + OPENING_FEE_MSAT_FIELD_NUMBER: _builtins.int bolt11: _builtins.str created_index: _builtins.int expires_at: _builtins.int payment_hash: _builtins.bytes payment_secret: _builtins.bytes + opening_fee_msat: _builtins.int + """The fee charged by the LSP for opening a JIT channel, in + millisatoshi. This is 0 if the node already had sufficient + incoming capacity and no JIT channel was needed. + """ def __init__( self, *, @@ -581,8 +587,9 @@ class LspInvoiceResponse(_message.Message): expires_at: _builtins.int = ..., payment_hash: _builtins.bytes = ..., payment_secret: _builtins.bytes = ..., + opening_fee_msat: _builtins.int = ..., ) -> None: ... - _ClearFieldArgType: _TypeAlias = _typing.Literal["bolt11", b"bolt11", "created_index", b"created_index", "expires_at", b"expires_at", "payment_hash", b"payment_hash", "payment_secret", b"payment_secret"] # noqa: Y015 + _ClearFieldArgType: _TypeAlias = _typing.Literal["bolt11", b"bolt11", "created_index", b"created_index", "expires_at", b"expires_at", "opening_fee_msat", b"opening_fee_msat", "payment_hash", b"payment_hash", "payment_secret", b"payment_secret"] # noqa: Y015 def ClearField(self, field_name: _ClearFieldArgType) -> None: ... Global___LspInvoiceResponse: _TypeAlias = LspInvoiceResponse # noqa: Y015 diff --git a/libs/gl-client-py/tests/test_plugin.py b/libs/gl-client-py/tests/test_plugin.py index e1fe5d3ac..296ac2a65 100644 --- a/libs/gl-client-py/tests/test_plugin.py +++ b/libs/gl-client-py/tests/test_plugin.py @@ -236,3 +236,8 @@ def test_lsps_plugin_calls(clients, bitcoind, node_factory, lsps_server): assert len(inv.route_hints.route_hints) == 1 rh = inv.route_hints.route_hints[0] assert rh.pubkey == bytes.fromhex(lsp_id) + + # The response should include the LSP opening fee computed from + # the LSPS2 fee parameters (min_fee_msat=1000, proportional=1000). + # For 31337 msat: max(1000, ceil(31337 * 1000 / 1_000_000)) = 1000 + assert res.opening_fee_msat == 1000 diff --git a/libs/gl-client/.resources/proto/glclient/greenlight.proto b/libs/gl-client/.resources/proto/glclient/greenlight.proto index 94d2cfacd..67c3fafe7 100644 --- a/libs/gl-client/.resources/proto/glclient/greenlight.proto +++ b/libs/gl-client/.resources/proto/glclient/greenlight.proto @@ -187,8 +187,8 @@ message NodeConfig { // The `GlConfig` is used to pass greenlight-specific startup parameters -// to the node. The `gl-plugin` will look for a serialized config object in -// the node's datastore to load these values from. Please refer to the +// to the node. The `gl-plugin` will look for a serialized config object in +// the node's datastore to load these values from. Please refer to the // individual fields to learn what they do. message GlConfig { string close_to_addr = 1; @@ -240,7 +240,7 @@ message LspInvoiceRequest { // Optional: for discounts/API keys string token = 2; // len=0 => None // Pass-through of cln invoice rpc params - uint64 amount_msat = 3; // 0 => Any + uint64 amount_msat = 3; // 0 => Any string description = 4; string label = 5; } @@ -250,6 +250,10 @@ message LspInvoiceResponse { uint32 expires_at = 3; bytes payment_hash = 4; bytes payment_secret = 5; + // The fee charged by the LSP for opening a JIT channel, in + // millisatoshi. This is 0 if the node already had sufficient + // incoming capacity and no JIT channel was needed. + uint64 opening_fee_msat = 6; } // Request for streaming node events. Currently empty but defined as diff --git a/libs/gl-plugin/.resources/proto/glclient/greenlight.proto b/libs/gl-plugin/.resources/proto/glclient/greenlight.proto index 94d2cfacd..67c3fafe7 100644 --- a/libs/gl-plugin/.resources/proto/glclient/greenlight.proto +++ b/libs/gl-plugin/.resources/proto/glclient/greenlight.proto @@ -187,8 +187,8 @@ message NodeConfig { // The `GlConfig` is used to pass greenlight-specific startup parameters -// to the node. The `gl-plugin` will look for a serialized config object in -// the node's datastore to load these values from. Please refer to the +// to the node. The `gl-plugin` will look for a serialized config object in +// the node's datastore to load these values from. Please refer to the // individual fields to learn what they do. message GlConfig { string close_to_addr = 1; @@ -240,7 +240,7 @@ message LspInvoiceRequest { // Optional: for discounts/API keys string token = 2; // len=0 => None // Pass-through of cln invoice rpc params - uint64 amount_msat = 3; // 0 => Any + uint64 amount_msat = 3; // 0 => Any string description = 4; string label = 5; } @@ -250,6 +250,10 @@ message LspInvoiceResponse { uint32 expires_at = 3; bytes payment_hash = 4; bytes payment_secret = 5; + // The fee charged by the LSP for opening a JIT channel, in + // millisatoshi. This is 0 if the node already had sufficient + // incoming capacity and no JIT channel was needed. + uint64 opening_fee_msat = 6; } // Request for streaming node events. Currently empty but defined as diff --git a/libs/gl-plugin/src/node/mod.rs b/libs/gl-plugin/src/node/mod.rs index 1c559a066..f31d62e72 100644 --- a/libs/gl-plugin/src/node/mod.rs +++ b/libs/gl-plugin/src/node/mod.rs @@ -247,6 +247,7 @@ impl Node for PluginNodeServer { expires_at: res.expires_at as u32, payment_hash: >::borrow(&res.payment_hash).to_vec(), payment_secret: res.payment_secret.to_vec(), + opening_fee_msat: 0, })); } @@ -279,8 +280,18 @@ impl Node for PluginNodeServer { let lsp = &lsps[0]; log::info!("Selecting {:?} for invoice negotiation", lsp); + // Compute the expected opening fee from the LSP's fee parameters. + let opening_fee_msat = lsp.params.first().map_or(0, |p| { + let min_fee: u64 = p.min_fee_msat.parse().unwrap_or(0); + let proportional_fee = req + .amount_msat + .saturating_mul(p.proportional) + .div_ceil(1_000_000); + std::cmp::max(min_fee, proportional_fee) + }); + // Use the new RPC method name for versions > v25.05gl1 - let res = if *version > *"v25.05gl1" { + let mut res = if *version > *"v25.05gl1" { let mut invreq: crate::requests::LspInvoiceRequestV2 = req.into(); invreq.lsp_id = lsp.node_id.to_owned(); rpc.call_typed(&invreq) @@ -294,6 +305,7 @@ impl Node for PluginNodeServer { .map_err(|e| Status::new(Code::Internal, e.to_string()))? }; + res.opening_fee_msat = opening_fee_msat; Ok(Response::new(res.into())) } diff --git a/libs/gl-plugin/src/responses.rs b/libs/gl-plugin/src/responses.rs index 65cc63f5f..556f89e6f 100644 --- a/libs/gl-plugin/src/responses.rs +++ b/libs/gl-plugin/src/responses.rs @@ -341,6 +341,10 @@ pub struct InvoiceResponse { pub expires_at: u32, pub payment_hash: String, pub payment_secret: String, + /// LSP opening fee in millisatoshi. Not returned by CLN, but set + /// by the caller based on LSPS2 fee parameters. + #[serde(default)] + pub opening_fee_msat: u64, } #[derive(Debug, Clone, Deserialize)] @@ -369,6 +373,7 @@ impl From for crate::pb::LspInvoiceResponse { expires_at: o.expires_at, payment_hash: hex::decode(o.payment_hash).unwrap(), payment_secret: hex::decode(o.payment_secret).unwrap(), + opening_fee_msat: o.opening_fee_msat, } } } @@ -414,6 +419,7 @@ mod test { expires_at: 123, payment_hash: "AABBCCDDEEFF".to_owned(), payment_secret: "1122334455".to_owned(), + opening_fee_msat: 0, }]; for t in tests { diff --git a/libs/gl-sdk/src/node.rs b/libs/gl-sdk/src/node.rs index 0fb679c48..a289edd00 100644 --- a/libs/gl-sdk/src/node.rs +++ b/libs/gl-sdk/src/node.rs @@ -76,7 +76,10 @@ impl Node { let res = exec(gl_client.lsp_invoice(req)) .map_err(|s| Error::Rpc(s.to_string()))? .into_inner(); - Ok(ReceiveResponse { bolt11: res.bolt11 }) + Ok(ReceiveResponse { + bolt11: res.bolt11, + opening_fee_msat: res.opening_fee_msat, + }) } pub fn send(&self, invoice: String, amount_msat: Option) -> Result { @@ -330,6 +333,9 @@ impl From for SendResponse { #[derive(uniffi::Record)] pub struct ReceiveResponse { pub bolt11: String, + /// The fee charged by the LSP for opening a JIT channel, in + /// millisatoshi. This is 0 if no JIT channel was needed. + pub opening_fee_msat: u64, } #[derive(uniffi::Enum, Clone)] diff --git a/libs/proto/glclient/greenlight.proto b/libs/proto/glclient/greenlight.proto index 9454ec4c3..67c3fafe7 100644 --- a/libs/proto/glclient/greenlight.proto +++ b/libs/proto/glclient/greenlight.proto @@ -250,6 +250,10 @@ message LspInvoiceResponse { uint32 expires_at = 3; bytes payment_hash = 4; bytes payment_secret = 5; + // The fee charged by the LSP for opening a JIT channel, in + // millisatoshi. This is 0 if the node already had sufficient + // incoming capacity and no JIT channel was needed. + uint64 opening_fee_msat = 6; } // Request for streaming node events. Currently empty but defined as