Skip to content

Commit 7a0569d

Browse files
benthecarmanclaude
andcommitted
Add DecodeOffer RPC
Adds a new DecodeOffer endpoint that parses a BOLT12 offer string and returns its fields: offer_id, description, issuer, amount (bitcoin or currency), issuer_signing_pubkey, absolute_expiry, supported_quantity, blinded paths, features, chains, metadata, and expiry status. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cdaeb98 commit 7a0569d

11 files changed

Lines changed: 474 additions & 33 deletions

File tree

e2e-tests/tests/e2e.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,52 @@ async fn test_cli_bolt12_receive() {
229229
assert_eq!(offer.id().0, offer_id);
230230
}
231231

232+
#[tokio::test]
233+
async fn test_cli_decode_offer() {
234+
let bitcoind = TestBitcoind::new();
235+
let server_a = LdkServerHandle::start(&bitcoind).await;
236+
let server_b = LdkServerHandle::start(&bitcoind).await;
237+
// BOLT12 offers need announced channels for blinded reply paths
238+
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;
239+
240+
// Create a BOLT12 offer with known parameters
241+
let output = run_cli(&server_a, &["bolt12-receive", "decode offer test"]);
242+
let offer_str = output["offer"].as_str().unwrap();
243+
244+
// Decode it
245+
let decoded = run_cli(&server_a, &["decode-offer", offer_str]);
246+
247+
// Verify fields match
248+
assert_eq!(decoded["offer_id"], output["offer_id"]);
249+
assert_eq!(decoded["description"], "decode offer test");
250+
assert_eq!(decoded["is_expired"], false);
251+
252+
// Chains should include regtest
253+
let chains = decoded["chains"].as_array().unwrap();
254+
assert!(chains.iter().any(|c| c == "regtest"), "Expected regtest in chains: {:?}", chains);
255+
256+
// Paths should be present (BOLT12 offers with blinded paths)
257+
let paths = decoded["paths"].as_array().unwrap();
258+
assert!(!paths.is_empty(), "Expected at least one blinded path");
259+
for path in paths {
260+
assert!(path["num_hops"].as_u64().unwrap() > 0);
261+
assert!(!path["blinding_point"].as_str().unwrap().is_empty());
262+
}
263+
264+
// Features — OfferContext has no known features in LDK, so this should be empty
265+
let features = decoded["features"].as_object().unwrap();
266+
assert!(features.is_empty(), "Expected empty offer features, got: {:?}", features);
267+
268+
// Variable-amount offer should have no amount
269+
assert!(decoded.get("amount").is_none() || decoded["amount"].is_null());
270+
271+
// Test a fixed-amount offer
272+
let output_fixed = run_cli(&server_a, &["bolt12-receive", "fixed amount", "50000sat"]);
273+
let decoded_fixed =
274+
run_cli(&server_a, &["decode-offer", output_fixed["offer"].as_str().unwrap()]);
275+
assert_eq!(decoded_fixed["amount"]["amount"]["bitcoin_amount_msats"], 50_000_000);
276+
}
277+
232278
#[tokio::test]
233279
async fn test_cli_onchain_send() {
234280
let bitcoind = TestBitcoind::new();

ldk-server-cli/src/main.rs

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,19 @@ use ldk_server_client::ldk_server_protos::api::{
2929
Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse,
3030
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse,
3131
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
32-
DecodeInvoiceRequest, DecodeInvoiceResponse, DisconnectPeerRequest, DisconnectPeerResponse,
33-
ExportPathfindingScoresRequest, ForceCloseChannelRequest, ForceCloseChannelResponse,
34-
GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse,
35-
GetPaymentDetailsRequest, GetPaymentDetailsResponse, GraphGetChannelRequest,
36-
GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest,
37-
GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest,
38-
ListChannelsResponse, ListForwardedPaymentsRequest, ListPaymentsRequest, ListPeersRequest,
39-
ListPeersResponse, OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest,
40-
OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, SignMessageRequest,
41-
SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse,
42-
SpontaneousSendRequest, SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse,
43-
UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest,
44-
VerifySignatureResponse,
32+
DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse,
33+
DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest,
34+
ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse,
35+
GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse,
36+
GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse,
37+
GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest,
38+
GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse,
39+
ListForwardedPaymentsRequest, ListPaymentsRequest, ListPeersRequest, ListPeersResponse,
40+
OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse,
41+
OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse,
42+
SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest,
43+
SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest,
44+
UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse,
4545
};
4646
use ldk_server_client::ldk_server_protos::types::{
4747
bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, PageToken,
@@ -344,6 +344,11 @@ enum Commands {
344344
#[arg(help = "The BOLT11 invoice string to decode")]
345345
invoice: String,
346346
},
347+
#[command(about = "Decode a BOLT12 offer and display its fields")]
348+
DecodeOffer {
349+
#[arg(help = "The BOLT12 offer string to decode")]
350+
offer: String,
351+
},
347352
#[command(about = "Cooperatively close the channel specified by the given channel ID")]
348353
CloseChannel {
349354
#[arg(help = "The local user_channel_id of this channel")]
@@ -873,6 +878,11 @@ async fn main() {
873878
client.decode_invoice(DecodeInvoiceRequest { invoice }).await,
874879
);
875880
},
881+
Commands::DecodeOffer { offer } => {
882+
handle_response_result::<_, DecodeOfferResponse>(
883+
client.decode_offer(DecodeOfferRequest { offer }).await,
884+
);
885+
},
876886
Commands::CloseChannel { user_channel_id, counterparty_node_id } => {
877887
handle_response_result::<_, CloseChannelResponse>(
878888
client

ldk-server-client/src/client.rs

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,29 @@ use ldk_server_protos::api::{
1919
Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse,
2020
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse,
2121
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
22-
DecodeInvoiceRequest, DecodeInvoiceResponse, DisconnectPeerRequest, DisconnectPeerResponse,
23-
ExportPathfindingScoresRequest, ExportPathfindingScoresResponse, ForceCloseChannelRequest,
24-
ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest,
25-
GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse,
26-
GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse,
27-
GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest,
28-
GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse,
29-
ListForwardedPaymentsRequest, ListForwardedPaymentsResponse, ListPaymentsRequest,
30-
ListPaymentsResponse, ListPeersRequest, ListPeersResponse, OnchainReceiveRequest,
31-
OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, OpenChannelRequest,
32-
OpenChannelResponse, SignMessageRequest, SignMessageResponse, SpliceInRequest,
33-
SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest,
22+
DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse,
23+
DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest,
24+
ExportPathfindingScoresResponse, ForceCloseChannelRequest, ForceCloseChannelResponse,
25+
GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse,
26+
GetPaymentDetailsRequest, GetPaymentDetailsResponse, GraphGetChannelRequest,
27+
GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest,
28+
GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest,
29+
ListChannelsResponse, ListForwardedPaymentsRequest, ListForwardedPaymentsResponse,
30+
ListPaymentsRequest, ListPaymentsResponse, ListPeersRequest, ListPeersResponse,
31+
OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse,
32+
OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse,
33+
SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest,
3434
SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest,
3535
UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse,
3636
};
3737
use ldk_server_protos::endpoints::{
3838
BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH,
3939
BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH,
4040
BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH,
41-
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DECODE_INVOICE_PATH, DISCONNECT_PEER_PATH,
42-
EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH,
43-
GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH,
44-
GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH,
41+
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH,
42+
DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH,
43+
GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GRAPH_GET_CHANNEL_PATH,
44+
GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH,
4545
LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH,
4646
ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH,
4747
SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH,
@@ -374,6 +374,15 @@ impl LdkServerClient {
374374
self.post_request(&request, &url).await
375375
}
376376

377+
/// Decode a BOLT12 offer and return its parsed fields.
378+
/// For API contract/usage, refer to docs for [`DecodeOfferRequest`] and [`DecodeOfferResponse`].
379+
pub async fn decode_offer(
380+
&self, request: DecodeOfferRequest,
381+
) -> Result<DecodeOfferResponse, LdkServerError> {
382+
let url = format!("https://{}/{DECODE_OFFER_PATH}", self.base_url);
383+
self.post_request(&request, &url).await
384+
}
385+
377386
/// Sign a message with the node's secret key.
378387
/// For API contract/usage, refer to docs for [`SignMessageRequest`] and [`SignMessageResponse`].
379388
pub async fn sign_message(

ldk-server-protos/src/api.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,3 +1131,58 @@ pub struct DecodeInvoiceResponse {
11311131
#[prost(bool, tag = "15")]
11321132
pub is_expired: bool,
11331133
}
1134+
/// Decode a BOLT12 offer and return its parsed fields.
1135+
/// This does not require a running node — it only parses the offer string.
1136+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1137+
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1138+
#[allow(clippy::derive_partial_eq_without_eq)]
1139+
#[derive(Clone, PartialEq, ::prost::Message)]
1140+
pub struct DecodeOfferRequest {
1141+
/// The BOLT12 offer string to decode.
1142+
#[prost(string, tag = "1")]
1143+
pub offer: ::prost::alloc::string::String,
1144+
}
1145+
/// The response `content` for the `DecodeOffer` API, when HttpStatusCode is OK (200).
1146+
/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`.
1147+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1148+
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1149+
#[allow(clippy::derive_partial_eq_without_eq)]
1150+
#[derive(Clone, PartialEq, ::prost::Message)]
1151+
pub struct DecodeOfferResponse {
1152+
/// The hex-encoded offer ID.
1153+
#[prost(string, tag = "1")]
1154+
pub offer_id: ::prost::alloc::string::String,
1155+
/// The description of the offer, if any.
1156+
#[prost(string, optional, tag = "2")]
1157+
pub description: ::core::option::Option<::prost::alloc::string::String>,
1158+
/// The issuer of the offer, if any.
1159+
#[prost(string, optional, tag = "3")]
1160+
pub issuer: ::core::option::Option<::prost::alloc::string::String>,
1161+
/// The amount, if specified.
1162+
#[prost(message, optional, tag = "4")]
1163+
pub amount: ::core::option::Option<super::types::OfferAmount>,
1164+
/// The hex-encoded public key used by the issuer to sign invoices, if any.
1165+
#[prost(string, optional, tag = "5")]
1166+
pub issuer_signing_pubkey: ::core::option::Option<::prost::alloc::string::String>,
1167+
/// The absolute expiry time in seconds since the UNIX epoch, if any.
1168+
#[prost(uint64, optional, tag = "6")]
1169+
pub absolute_expiry: ::core::option::Option<u64>,
1170+
/// The supported quantity of items.
1171+
#[prost(message, optional, tag = "7")]
1172+
pub quantity: ::core::option::Option<super::types::OfferQuantity>,
1173+
/// Blinded paths to the offer recipient.
1174+
#[prost(message, repeated, tag = "8")]
1175+
pub paths: ::prost::alloc::vec::Vec<super::types::BlindedPath>,
1176+
/// Feature bits advertised in the offer, keyed by bit number.
1177+
#[prost(map = "uint32, message", tag = "9")]
1178+
pub features: ::std::collections::HashMap<u32, super::types::Bolt11Feature>,
1179+
/// Supported blockchain networks (e.g., "bitcoin", "testnet", "signet", "regtest").
1180+
#[prost(string, repeated, tag = "10")]
1181+
pub chains: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
1182+
/// The metadata, hex-encoded, if any.
1183+
#[prost(string, optional, tag = "11")]
1184+
pub metadata: ::core::option::Option<::prost::alloc::string::String>,
1185+
/// Whether the offer has expired.
1186+
#[prost(bool, tag = "12")]
1187+
pub is_expired: bool,
1188+
}

ldk-server-protos/src/endpoints.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,4 @@ pub const GRAPH_GET_CHANNEL_PATH: &str = "GraphGetChannel";
4444
pub const GRAPH_LIST_NODES_PATH: &str = "GraphListNodes";
4545
pub const GRAPH_GET_NODE_PATH: &str = "GraphGetNode";
4646
pub const DECODE_INVOICE_PATH: &str = "DecodeInvoice";
47+
pub const DECODE_OFFER_PATH: &str = "DecodeOffer";

ldk-server-protos/src/proto/api.proto

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,3 +878,50 @@ message DecodeInvoiceResponse {
878878
// Whether the invoice has expired.
879879
bool is_expired = 15;
880880
}
881+
882+
// Decode a BOLT12 offer and return its parsed fields.
883+
// This does not require a running node — it only parses the offer string.
884+
message DecodeOfferRequest {
885+
// The BOLT12 offer string to decode.
886+
string offer = 1;
887+
}
888+
889+
// The response `content` for the `DecodeOffer` API, when HttpStatusCode is OK (200).
890+
// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`.
891+
message DecodeOfferResponse {
892+
// The hex-encoded offer ID.
893+
string offer_id = 1;
894+
895+
// The description of the offer, if any.
896+
optional string description = 2;
897+
898+
// The issuer of the offer, if any.
899+
optional string issuer = 3;
900+
901+
// The amount, if specified.
902+
types.OfferAmount amount = 4;
903+
904+
// The hex-encoded public key used by the issuer to sign invoices, if any.
905+
optional string issuer_signing_pubkey = 5;
906+
907+
// The absolute expiry time in seconds since the UNIX epoch, if any.
908+
optional uint64 absolute_expiry = 6;
909+
910+
// The supported quantity of items.
911+
types.OfferQuantity quantity = 7;
912+
913+
// Blinded paths to the offer recipient.
914+
repeated types.BlindedPath paths = 8;
915+
916+
// Feature bits advertised in the offer, keyed by bit number.
917+
map<uint32, types.Bolt11Feature> features = 9;
918+
919+
// Supported blockchain networks (e.g., "bitcoin", "testnet", "signet", "regtest").
920+
repeated string chains = 10;
921+
922+
// The metadata, hex-encoded, if any.
923+
optional string metadata = 11;
924+
925+
// Whether the offer has expired.
926+
bool is_expired = 12;
927+
}

ldk-server-protos/src/proto/types.proto

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,57 @@ message Bolt11HopHint {
843843
uint32 cltv_expiry_delta = 5;
844844
}
845845

846+
// The amount specified in a BOLT12 offer.
847+
message OfferAmount {
848+
oneof amount {
849+
// Amount in millisatoshis for Bitcoin payments.
850+
uint64 bitcoin_amount_msats = 1;
851+
852+
// Amount in a non-Bitcoin currency.
853+
CurrencyAmount currency_amount = 2;
854+
}
855+
}
856+
857+
// A non-Bitcoin currency amount.
858+
message CurrencyAmount {
859+
// ISO 4217 currency code (e.g., "USD", "EUR").
860+
string iso4217_code = 1;
861+
862+
// The amount in the specified currency's minor unit.
863+
uint64 amount = 2;
864+
}
865+
866+
// The quantity of items supported by a BOLT12 offer.
867+
message OfferQuantity {
868+
oneof quantity {
869+
// Only one item may be requested.
870+
bool one = 1;
871+
872+
// Up to this many items may be requested.
873+
uint64 bounded = 2;
874+
875+
// Any number of items may be requested.
876+
bool unbounded = 3;
877+
}
878+
}
879+
880+
// A blinded path to the offer recipient.
881+
message BlindedPath {
882+
// The hex-encoded public key of the introduction node, if available.
883+
// If the introduction node is a directed short channel ID, this will be empty
884+
// and `introduction_scid` will be set instead.
885+
optional string introduction_node_id = 1;
886+
887+
// The hex-encoded blinding point.
888+
string blinding_point = 2;
889+
890+
// The number of blinded hops in the path.
891+
uint32 num_hops = 3;
892+
893+
// If the introduction node is a directed short channel ID rather than a node ID.
894+
optional uint64 introduction_scid = 4;
895+
}
896+
846897
// A feature bit advertised in a BOLT11 invoice.
847898
message Bolt11Feature {
848899
// Human-readable feature name.

0 commit comments

Comments
 (0)