rustywallet_lightning/
bolt12.rs

1//! BOLT12 Offers support.
2//!
3//! This module implements BOLT12 offers, which provide a more flexible
4//! and privacy-preserving way to request payments compared to BOLT11 invoices.
5//!
6//! ## Features
7//!
8//! - Parse and encode BOLT12 offer strings
9//! - Support for amount, description, expiry, and other fields
10//! - Signature validation
11//! - Blinded paths for receiver privacy
12//!
13//! ## Example
14//!
15//! ```rust,ignore
16//! use rustywallet_lightning::bolt12::{Bolt12Offer, OfferBuilder};
17//!
18//! // Parse an offer
19//! let offer = Bolt12Offer::parse("lno1...")?;
20//! println!("Amount: {:?}", offer.amount());
21//! println!("Description: {}", offer.description());
22//!
23//! // Create an offer
24//! let offer = OfferBuilder::new()
25//!     .description("Coffee")
26//!     .amount_msats(10_000)
27//!     .build()?;
28//! println!("Offer: {}", offer.encode());
29//! ```
30
31use crate::error::LightningError;
32use secp256k1::{PublicKey, Secp256k1, SecretKey};
33use sha2::{Sha256, Digest};
34
35/// Amount in an offer (can be fixed or variable).
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum OfferAmount {
38    /// Fixed amount in millisatoshis
39    Fixed(u64),
40    /// Variable amount (payer chooses)
41    Variable,
42    /// Currency amount (e.g., USD)
43    Currency {
44        /// ISO 4217 currency code
45        currency: String,
46        /// Amount in smallest currency unit
47        amount: u64,
48    },
49}
50
51impl OfferAmount {
52    /// Create a fixed amount in millisatoshis.
53    pub fn msats(amount: u64) -> Self {
54        OfferAmount::Fixed(amount)
55    }
56
57    /// Create a variable amount.
58    pub fn variable() -> Self {
59        OfferAmount::Variable
60    }
61
62    /// Create a currency amount.
63    pub fn currency(currency: impl Into<String>, amount: u64) -> Self {
64        OfferAmount::Currency {
65            currency: currency.into(),
66            amount,
67        }
68    }
69
70    /// Check if this is a fixed amount.
71    pub fn is_fixed(&self) -> bool {
72        matches!(self, OfferAmount::Fixed(_))
73    }
74
75    /// Get the fixed amount in millisatoshis, if any.
76    pub fn as_msats(&self) -> Option<u64> {
77        match self {
78            OfferAmount::Fixed(amount) => Some(*amount),
79            _ => None,
80        }
81    }
82}
83
84/// A blinded path for receiver privacy.
85#[derive(Debug, Clone)]
86pub struct BlindedPath {
87    /// Introduction node public key
88    pub introduction_node: PublicKey,
89    /// Blinding point
90    pub blinding_point: PublicKey,
91    /// Encrypted path data
92    pub encrypted_data: Vec<u8>,
93}
94
95impl BlindedPath {
96    /// Create a new blinded path.
97    pub fn new(
98        introduction_node: PublicKey,
99        blinding_point: PublicKey,
100        encrypted_data: Vec<u8>,
101    ) -> Self {
102        Self {
103            introduction_node,
104            blinding_point,
105            encrypted_data,
106        }
107    }
108}
109
110/// BOLT12 Offer.
111///
112/// An offer is a static payment request that can be used multiple times.
113/// Unlike BOLT11 invoices, offers don't expire and can be reused.
114#[derive(Debug, Clone)]
115pub struct Bolt12Offer {
116    /// Offer ID (hash of the offer)
117    offer_id: [u8; 32],
118    /// Amount (optional for variable amount offers)
119    amount: Option<OfferAmount>,
120    /// Human-readable description
121    description: String,
122    /// Absolute expiry time (Unix timestamp)
123    expiry: Option<u64>,
124    /// Issuer name/identifier
125    issuer: Option<String>,
126    /// Node ID of the recipient
127    node_id: Option<PublicKey>,
128    /// Blinded paths for privacy
129    paths: Vec<BlindedPath>,
130    /// Supported chains (empty = Bitcoin mainnet only)
131    chains: Vec<[u8; 32]>,
132    /// Minimum amount in millisatoshis
133    min_amount: Option<u64>,
134    /// Maximum amount in millisatoshis
135    max_amount: Option<u64>,
136    /// Quantity supported
137    quantity_max: Option<u64>,
138    /// Signature over the offer
139    signature: Option<[u8; 64]>,
140    /// Raw TLV data for encoding
141    raw_tlv: Vec<u8>,
142}
143
144impl Bolt12Offer {
145    /// Parse a BOLT12 offer from a string.
146    ///
147    /// The string should start with "lno1" (Lightning Network Offer).
148    pub fn parse(s: &str) -> Result<Self, LightningError> {
149        let s = s.trim().to_lowercase();
150        
151        // Check prefix
152        if !s.starts_with("lno1") {
153            return Err(LightningError::InvalidFormat(
154                "BOLT12 offer must start with 'lno1'".into()
155            ));
156        }
157
158        // Decode bech32
159        let (hrp, data) = bech32::decode(&s)
160            .map_err(|e| LightningError::InvalidFormat(format!("Bech32 decode error: {}", e)))?;
161
162        let hrp_str = hrp.to_string();
163        if hrp_str != "lno" {
164            return Err(LightningError::InvalidFormat(
165                format!("Invalid HRP: expected 'lno', got '{}'", hrp_str)
166            ));
167        }
168
169        // Parse TLV stream
170        Self::parse_tlv(&data)
171    }
172
173    /// Parse TLV data into an offer.
174    fn parse_tlv(data: &[u8]) -> Result<Self, LightningError> {
175        let mut offer = Bolt12Offer {
176            offer_id: [0u8; 32],
177            amount: None,
178            description: String::new(),
179            expiry: None,
180            issuer: None,
181            node_id: None,
182            paths: Vec::new(),
183            chains: Vec::new(),
184            min_amount: None,
185            max_amount: None,
186            quantity_max: None,
187            signature: None,
188            raw_tlv: data.to_vec(),
189        };
190
191        let mut pos = 0;
192        while pos < data.len() {
193            // Read type (BigSize)
194            let (tlv_type, bytes_read) = read_bigsize(&data[pos..])?;
195            pos += bytes_read;
196
197            if pos >= data.len() {
198                break;
199            }
200
201            // Read length (BigSize)
202            let (tlv_len, bytes_read) = read_bigsize(&data[pos..])?;
203            pos += bytes_read;
204
205            if pos + tlv_len as usize > data.len() {
206                return Err(LightningError::InvalidFormat("TLV length exceeds data".into()));
207            }
208
209            let value = &data[pos..pos + tlv_len as usize];
210            pos += tlv_len as usize;
211
212            // Parse known TLV types
213            match tlv_type {
214                2 => {
215                    // chains
216                    if value.len().is_multiple_of(32) {
217                        for chunk in value.chunks(32) {
218                            let mut chain = [0u8; 32];
219                            chain.copy_from_slice(chunk);
220                            offer.chains.push(chain);
221                        }
222                    }
223                }
224                6 => {
225                    // amount (currency)
226                    if value.len() >= 3 {
227                        let currency = String::from_utf8_lossy(&value[..3]).to_string();
228                        if value.len() > 3 {
229                            let amount = read_tu64(&value[3..])?;
230                            offer.amount = Some(OfferAmount::Currency { currency, amount });
231                        }
232                    }
233                }
234                8 => {
235                    // amount (msats)
236                    let amount = read_tu64(value)?;
237                    offer.amount = Some(OfferAmount::Fixed(amount));
238                }
239                10 => {
240                    // description
241                    offer.description = String::from_utf8_lossy(value).to_string();
242                }
243                12 => {
244                    // features (ignored for now)
245                }
246                14 => {
247                    // absolute_expiry
248                    offer.expiry = Some(read_tu64(value)?);
249                }
250                16 => {
251                    // paths (blinded paths)
252                    // Simplified parsing - just store raw data
253                }
254                18 => {
255                    // issuer
256                    offer.issuer = Some(String::from_utf8_lossy(value).to_string());
257                }
258                20 => {
259                    // quantity_max
260                    offer.quantity_max = Some(read_tu64(value)?);
261                }
262                22 => {
263                    // node_id
264                    if value.len() == 33 {
265                        offer.node_id = PublicKey::from_slice(value).ok();
266                    }
267                }
268                240 => {
269                    // signature
270                    if value.len() == 64 {
271                        let mut sig = [0u8; 64];
272                        sig.copy_from_slice(value);
273                        offer.signature = Some(sig);
274                    }
275                }
276                _ => {
277                    // Unknown TLV - skip
278                }
279            }
280        }
281
282        // Compute offer ID
283        offer.offer_id = compute_offer_id(&offer.raw_tlv);
284
285        Ok(offer)
286    }
287
288    /// Encode the offer to a string.
289    pub fn encode(&self) -> String {
290        let hrp = bech32::Hrp::parse("lno").unwrap();
291        bech32::encode::<bech32::Bech32m>(hrp, &self.raw_tlv)
292            .unwrap_or_else(|_| String::from("lno1invalid"))
293    }
294
295    /// Get the offer ID.
296    pub fn offer_id(&self) -> &[u8; 32] {
297        &self.offer_id
298    }
299
300    /// Get the offer ID as hex string.
301    pub fn offer_id_hex(&self) -> String {
302        hex::encode(self.offer_id)
303    }
304
305    /// Get the amount.
306    pub fn amount(&self) -> Option<&OfferAmount> {
307        self.amount.as_ref()
308    }
309
310    /// Get the description.
311    pub fn description(&self) -> &str {
312        &self.description
313    }
314
315    /// Get the expiry timestamp.
316    pub fn expiry(&self) -> Option<u64> {
317        self.expiry
318    }
319
320    /// Check if the offer has expired.
321    pub fn is_expired(&self) -> bool {
322        if let Some(expiry) = self.expiry {
323            let now = std::time::SystemTime::now()
324                .duration_since(std::time::UNIX_EPOCH)
325                .map(|d| d.as_secs())
326                .unwrap_or(0);
327            now > expiry
328        } else {
329            false
330        }
331    }
332
333    /// Get the issuer.
334    pub fn issuer(&self) -> Option<&str> {
335        self.issuer.as_deref()
336    }
337
338    /// Get the node ID.
339    pub fn node_id(&self) -> Option<&PublicKey> {
340        self.node_id.as_ref()
341    }
342
343    /// Get the blinded paths.
344    pub fn paths(&self) -> &[BlindedPath] {
345        &self.paths
346    }
347
348    /// Get the supported chains.
349    pub fn chains(&self) -> &[[u8; 32]] {
350        &self.chains
351    }
352
353    /// Check if this offer supports Bitcoin mainnet.
354    pub fn supports_bitcoin_mainnet(&self) -> bool {
355        if self.chains.is_empty() {
356            return true; // Empty means Bitcoin mainnet only
357        }
358        // Bitcoin mainnet genesis block hash
359        let bitcoin_mainnet = hex::decode(
360            "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
361        ).unwrap();
362        self.chains.iter().any(|c| c[..] == bitcoin_mainnet[..])
363    }
364
365    /// Get the minimum amount.
366    pub fn min_amount(&self) -> Option<u64> {
367        self.min_amount
368    }
369
370    /// Get the maximum amount.
371    pub fn max_amount(&self) -> Option<u64> {
372        self.max_amount
373    }
374
375    /// Get the maximum quantity.
376    pub fn quantity_max(&self) -> Option<u64> {
377        self.quantity_max
378    }
379
380    /// Get the signature.
381    pub fn signature(&self) -> Option<&[u8; 64]> {
382        self.signature.as_ref()
383    }
384
385    /// Validate the signature.
386    pub fn validate_signature(&self) -> bool {
387        let Some(sig_bytes) = &self.signature else {
388            return false;
389        };
390        let Some(node_id) = &self.node_id else {
391            return false;
392        };
393
394        // Create message to sign (offer without signature)
395        let msg = compute_offer_id(&self.raw_tlv);
396        
397        // Verify signature
398        let secp = Secp256k1::verification_only();
399        let msg = secp256k1::Message::from_digest(msg);
400        
401        if let Ok(sig) = secp256k1::ecdsa::Signature::from_compact(sig_bytes) {
402            secp.verify_ecdsa(&msg, &sig, node_id).is_ok()
403        } else {
404            false
405        }
406    }
407}
408
409impl std::fmt::Display for Bolt12Offer {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        write!(f, "{}", self.encode())
412    }
413}
414
415impl std::str::FromStr for Bolt12Offer {
416    type Err = LightningError;
417
418    fn from_str(s: &str) -> Result<Self, Self::Err> {
419        Self::parse(s)
420    }
421}
422
423/// Builder for creating BOLT12 offers.
424#[derive(Debug, Default)]
425pub struct OfferBuilder {
426    amount: Option<OfferAmount>,
427    description: String,
428    expiry: Option<u64>,
429    issuer: Option<String>,
430    node_id: Option<PublicKey>,
431    paths: Vec<BlindedPath>,
432    chains: Vec<[u8; 32]>,
433    min_amount: Option<u64>,
434    max_amount: Option<u64>,
435    quantity_max: Option<u64>,
436}
437
438impl OfferBuilder {
439    /// Create a new offer builder.
440    pub fn new() -> Self {
441        Self::default()
442    }
443
444    /// Set the description.
445    pub fn description(mut self, description: impl Into<String>) -> Self {
446        self.description = description.into();
447        self
448    }
449
450    /// Set a fixed amount in millisatoshis.
451    pub fn amount_msats(mut self, amount: u64) -> Self {
452        self.amount = Some(OfferAmount::Fixed(amount));
453        self
454    }
455
456    /// Set a variable amount (payer chooses).
457    pub fn amount_variable(mut self) -> Self {
458        self.amount = Some(OfferAmount::Variable);
459        self
460    }
461
462    /// Set a currency amount.
463    pub fn amount_currency(mut self, currency: impl Into<String>, amount: u64) -> Self {
464        self.amount = Some(OfferAmount::Currency {
465            currency: currency.into(),
466            amount,
467        });
468        self
469    }
470
471    /// Set the expiry timestamp.
472    pub fn expiry(mut self, timestamp: u64) -> Self {
473        self.expiry = Some(timestamp);
474        self
475    }
476
477    /// Set the expiry relative to now.
478    pub fn expires_in(mut self, seconds: u64) -> Self {
479        let now = std::time::SystemTime::now()
480            .duration_since(std::time::UNIX_EPOCH)
481            .map(|d| d.as_secs())
482            .unwrap_or(0);
483        self.expiry = Some(now + seconds);
484        self
485    }
486
487    /// Set the issuer.
488    pub fn issuer(mut self, issuer: impl Into<String>) -> Self {
489        self.issuer = Some(issuer.into());
490        self
491    }
492
493    /// Set the node ID.
494    pub fn node_id(mut self, node_id: PublicKey) -> Self {
495        self.node_id = Some(node_id);
496        self
497    }
498
499    /// Add a blinded path.
500    pub fn add_path(mut self, path: BlindedPath) -> Self {
501        self.paths.push(path);
502        self
503    }
504
505    /// Add a supported chain.
506    pub fn add_chain(mut self, chain_hash: [u8; 32]) -> Self {
507        self.chains.push(chain_hash);
508        self
509    }
510
511    /// Set the minimum amount.
512    pub fn min_amount(mut self, amount: u64) -> Self {
513        self.min_amount = Some(amount);
514        self
515    }
516
517    /// Set the maximum amount.
518    pub fn max_amount(mut self, amount: u64) -> Self {
519        self.max_amount = Some(amount);
520        self
521    }
522
523    /// Set the maximum quantity.
524    pub fn quantity_max(mut self, quantity: u64) -> Self {
525        self.quantity_max = Some(quantity);
526        self
527    }
528
529    /// Build the offer.
530    pub fn build(self) -> Result<Bolt12Offer, LightningError> {
531        if self.description.is_empty() {
532            return Err(LightningError::InvalidFormat(
533                "Offer must have a description".into()
534            ));
535        }
536
537        // Build TLV stream
538        let mut tlv = Vec::new();
539
540        // chains (type 2)
541        if !self.chains.is_empty() {
542            let mut chain_data = Vec::new();
543            for chain in &self.chains {
544                chain_data.extend_from_slice(chain);
545            }
546            write_tlv(&mut tlv, 2, &chain_data);
547        }
548
549        // amount (type 8)
550        if let Some(OfferAmount::Fixed(amount)) = &self.amount {
551            write_tlv(&mut tlv, 8, &encode_tu64(*amount));
552        }
553
554        // description (type 10)
555        write_tlv(&mut tlv, 10, self.description.as_bytes());
556
557        // absolute_expiry (type 14)
558        if let Some(expiry) = self.expiry {
559            write_tlv(&mut tlv, 14, &encode_tu64(expiry));
560        }
561
562        // issuer (type 18)
563        if let Some(issuer) = &self.issuer {
564            write_tlv(&mut tlv, 18, issuer.as_bytes());
565        }
566
567        // quantity_max (type 20)
568        if let Some(qty) = self.quantity_max {
569            write_tlv(&mut tlv, 20, &encode_tu64(qty));
570        }
571
572        // node_id (type 22)
573        if let Some(node_id) = &self.node_id {
574            write_tlv(&mut tlv, 22, &node_id.serialize());
575        }
576
577        let offer_id = compute_offer_id(&tlv);
578
579        Ok(Bolt12Offer {
580            offer_id,
581            amount: self.amount,
582            description: self.description,
583            expiry: self.expiry,
584            issuer: self.issuer,
585            node_id: self.node_id,
586            paths: self.paths,
587            chains: self.chains,
588            min_amount: self.min_amount,
589            max_amount: self.max_amount,
590            quantity_max: self.quantity_max,
591            signature: None,
592            raw_tlv: tlv,
593        })
594    }
595
596    /// Build and sign the offer.
597    pub fn build_signed(self, secret_key: &SecretKey) -> Result<Bolt12Offer, LightningError> {
598        let mut offer = self.build()?;
599        
600        // Sign the offer
601        let secp = Secp256k1::signing_only();
602        let msg = secp256k1::Message::from_digest(offer.offer_id);
603        let sig = secp.sign_ecdsa(&msg, secret_key);
604        
605        // Add signature to TLV
606        let sig_bytes = sig.serialize_compact();
607        write_tlv(&mut offer.raw_tlv, 240, &sig_bytes);
608        
609        offer.signature = Some(sig_bytes);
610        offer.node_id = Some(secret_key.public_key(&secp));
611        
612        Ok(offer)
613    }
614}
615
616// Helper functions
617
618/// Read a BigSize value from bytes.
619fn read_bigsize(data: &[u8]) -> Result<(u64, usize), LightningError> {
620    if data.is_empty() {
621        return Err(LightningError::InvalidFormat("Empty BigSize".into()));
622    }
623
624    match data[0] {
625        0..=0xfc => Ok((data[0] as u64, 1)),
626        0xfd => {
627            if data.len() < 3 {
628                return Err(LightningError::InvalidFormat("Truncated BigSize".into()));
629            }
630            let val = u16::from_be_bytes([data[1], data[2]]) as u64;
631            Ok((val, 3))
632        }
633        0xfe => {
634            if data.len() < 5 {
635                return Err(LightningError::InvalidFormat("Truncated BigSize".into()));
636            }
637            let val = u32::from_be_bytes([data[1], data[2], data[3], data[4]]) as u64;
638            Ok((val, 5))
639        }
640        0xff => {
641            if data.len() < 9 {
642                return Err(LightningError::InvalidFormat("Truncated BigSize".into()));
643            }
644            let val = u64::from_be_bytes([
645                data[1], data[2], data[3], data[4],
646                data[5], data[6], data[7], data[8],
647            ]);
648            Ok((val, 9))
649        }
650    }
651}
652
653/// Read a truncated u64 (tu64) from bytes.
654fn read_tu64(data: &[u8]) -> Result<u64, LightningError> {
655    if data.is_empty() {
656        return Ok(0);
657    }
658    if data.len() > 8 {
659        return Err(LightningError::InvalidFormat("tu64 too long".into()));
660    }
661    
662    let mut bytes = [0u8; 8];
663    bytes[8 - data.len()..].copy_from_slice(data);
664    Ok(u64::from_be_bytes(bytes))
665}
666
667/// Encode a u64 as truncated bytes.
668fn encode_tu64(val: u64) -> Vec<u8> {
669    let bytes = val.to_be_bytes();
670    let start = bytes.iter().position(|&b| b != 0).unwrap_or(7);
671    bytes[start..].to_vec()
672}
673
674/// Write a BigSize value.
675fn write_bigsize(out: &mut Vec<u8>, val: u64) {
676    if val <= 0xfc {
677        out.push(val as u8);
678    } else if val <= 0xffff {
679        out.push(0xfd);
680        out.extend_from_slice(&(val as u16).to_be_bytes());
681    } else if val <= 0xffffffff {
682        out.push(0xfe);
683        out.extend_from_slice(&(val as u32).to_be_bytes());
684    } else {
685        out.push(0xff);
686        out.extend_from_slice(&val.to_be_bytes());
687    }
688}
689
690/// Write a TLV record.
691fn write_tlv(out: &mut Vec<u8>, tlv_type: u64, value: &[u8]) {
692    write_bigsize(out, tlv_type);
693    write_bigsize(out, value.len() as u64);
694    out.extend_from_slice(value);
695}
696
697/// Compute the offer ID (SHA256 hash of TLV data).
698fn compute_offer_id(tlv: &[u8]) -> [u8; 32] {
699    let mut hasher = Sha256::new();
700    hasher.update(b"lightning");
701    hasher.update(b"offer");
702    hasher.update(b"offer_id");
703    hasher.update(tlv);
704    let result = hasher.finalize();
705    let mut id = [0u8; 32];
706    id.copy_from_slice(&result);
707    id
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    #[test]
715    fn test_offer_builder() {
716        let offer = OfferBuilder::new()
717            .description("Test offer")
718            .amount_msats(10_000)
719            .build()
720            .unwrap();
721
722        assert_eq!(offer.description(), "Test offer");
723        assert_eq!(offer.amount().unwrap().as_msats(), Some(10_000));
724    }
725
726    #[test]
727    fn test_offer_builder_with_expiry() {
728        let offer = OfferBuilder::new()
729            .description("Expiring offer")
730            .expires_in(3600)
731            .build()
732            .unwrap();
733
734        assert!(offer.expiry().is_some());
735        assert!(!offer.is_expired());
736    }
737
738    #[test]
739    fn test_offer_builder_with_issuer() {
740        let offer = OfferBuilder::new()
741            .description("Coffee")
742            .issuer("Bob's Coffee Shop")
743            .build()
744            .unwrap();
745
746        assert_eq!(offer.issuer(), Some("Bob's Coffee Shop"));
747    }
748
749    #[test]
750    fn test_offer_encode_decode() {
751        let offer = OfferBuilder::new()
752            .description("Test roundtrip")
753            .amount_msats(50_000)
754            .build()
755            .unwrap();
756
757        let encoded = offer.encode();
758        assert!(encoded.starts_with("lno1"));
759
760        let decoded = Bolt12Offer::parse(&encoded).unwrap();
761        assert_eq!(decoded.description(), "Test roundtrip");
762        assert_eq!(decoded.amount().unwrap().as_msats(), Some(50_000));
763    }
764
765    #[test]
766    fn test_offer_variable_amount() {
767        let offer = OfferBuilder::new()
768            .description("Donation")
769            .amount_variable()
770            .build()
771            .unwrap();
772
773        assert!(matches!(offer.amount(), Some(OfferAmount::Variable)));
774    }
775
776    #[test]
777    fn test_offer_id_computation() {
778        let offer1 = OfferBuilder::new()
779            .description("Offer 1")
780            .build()
781            .unwrap();
782
783        let offer2 = OfferBuilder::new()
784            .description("Offer 2")
785            .build()
786            .unwrap();
787
788        // Different offers should have different IDs
789        assert_ne!(offer1.offer_id(), offer2.offer_id());
790    }
791
792    #[test]
793    fn test_offer_amount_types() {
794        let fixed = OfferAmount::msats(1000);
795        assert!(fixed.is_fixed());
796        assert_eq!(fixed.as_msats(), Some(1000));
797
798        let variable = OfferAmount::variable();
799        assert!(!variable.is_fixed());
800        assert_eq!(variable.as_msats(), None);
801
802        let currency = OfferAmount::currency("USD", 100);
803        assert!(!currency.is_fixed());
804    }
805
806    #[test]
807    fn test_empty_description_fails() {
808        let result = OfferBuilder::new().build();
809        assert!(result.is_err());
810    }
811
812    #[test]
813    fn test_bitcoin_mainnet_support() {
814        let offer = OfferBuilder::new()
815            .description("Bitcoin offer")
816            .build()
817            .unwrap();
818
819        // Empty chains means Bitcoin mainnet only
820        assert!(offer.supports_bitcoin_mainnet());
821    }
822}