1use crate::error::LightningError;
32use secp256k1::{PublicKey, Secp256k1, SecretKey};
33use sha2::{Sha256, Digest};
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum OfferAmount {
38 Fixed(u64),
40 Variable,
42 Currency {
44 currency: String,
46 amount: u64,
48 },
49}
50
51impl OfferAmount {
52 pub fn msats(amount: u64) -> Self {
54 OfferAmount::Fixed(amount)
55 }
56
57 pub fn variable() -> Self {
59 OfferAmount::Variable
60 }
61
62 pub fn currency(currency: impl Into<String>, amount: u64) -> Self {
64 OfferAmount::Currency {
65 currency: currency.into(),
66 amount,
67 }
68 }
69
70 pub fn is_fixed(&self) -> bool {
72 matches!(self, OfferAmount::Fixed(_))
73 }
74
75 pub fn as_msats(&self) -> Option<u64> {
77 match self {
78 OfferAmount::Fixed(amount) => Some(*amount),
79 _ => None,
80 }
81 }
82}
83
84#[derive(Debug, Clone)]
86pub struct BlindedPath {
87 pub introduction_node: PublicKey,
89 pub blinding_point: PublicKey,
91 pub encrypted_data: Vec<u8>,
93}
94
95impl BlindedPath {
96 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#[derive(Debug, Clone)]
115pub struct Bolt12Offer {
116 offer_id: [u8; 32],
118 amount: Option<OfferAmount>,
120 description: String,
122 expiry: Option<u64>,
124 issuer: Option<String>,
126 node_id: Option<PublicKey>,
128 paths: Vec<BlindedPath>,
130 chains: Vec<[u8; 32]>,
132 min_amount: Option<u64>,
134 max_amount: Option<u64>,
136 quantity_max: Option<u64>,
138 signature: Option<[u8; 64]>,
140 raw_tlv: Vec<u8>,
142}
143
144impl Bolt12Offer {
145 pub fn parse(s: &str) -> Result<Self, LightningError> {
149 let s = s.trim().to_lowercase();
150
151 if !s.starts_with("lno1") {
153 return Err(LightningError::InvalidFormat(
154 "BOLT12 offer must start with 'lno1'".into()
155 ));
156 }
157
158 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 Self::parse_tlv(&data)
171 }
172
173 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 let (tlv_type, bytes_read) = read_bigsize(&data[pos..])?;
195 pos += bytes_read;
196
197 if pos >= data.len() {
198 break;
199 }
200
201 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 match tlv_type {
214 2 => {
215 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 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 let amount = read_tu64(value)?;
237 offer.amount = Some(OfferAmount::Fixed(amount));
238 }
239 10 => {
240 offer.description = String::from_utf8_lossy(value).to_string();
242 }
243 12 => {
244 }
246 14 => {
247 offer.expiry = Some(read_tu64(value)?);
249 }
250 16 => {
251 }
254 18 => {
255 offer.issuer = Some(String::from_utf8_lossy(value).to_string());
257 }
258 20 => {
259 offer.quantity_max = Some(read_tu64(value)?);
261 }
262 22 => {
263 if value.len() == 33 {
265 offer.node_id = PublicKey::from_slice(value).ok();
266 }
267 }
268 240 => {
269 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 }
279 }
280 }
281
282 offer.offer_id = compute_offer_id(&offer.raw_tlv);
284
285 Ok(offer)
286 }
287
288 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 pub fn offer_id(&self) -> &[u8; 32] {
297 &self.offer_id
298 }
299
300 pub fn offer_id_hex(&self) -> String {
302 hex::encode(self.offer_id)
303 }
304
305 pub fn amount(&self) -> Option<&OfferAmount> {
307 self.amount.as_ref()
308 }
309
310 pub fn description(&self) -> &str {
312 &self.description
313 }
314
315 pub fn expiry(&self) -> Option<u64> {
317 self.expiry
318 }
319
320 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 pub fn issuer(&self) -> Option<&str> {
335 self.issuer.as_deref()
336 }
337
338 pub fn node_id(&self) -> Option<&PublicKey> {
340 self.node_id.as_ref()
341 }
342
343 pub fn paths(&self) -> &[BlindedPath] {
345 &self.paths
346 }
347
348 pub fn chains(&self) -> &[[u8; 32]] {
350 &self.chains
351 }
352
353 pub fn supports_bitcoin_mainnet(&self) -> bool {
355 if self.chains.is_empty() {
356 return true; }
358 let bitcoin_mainnet = hex::decode(
360 "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
361 ).unwrap();
362 self.chains.iter().any(|c| c[..] == bitcoin_mainnet[..])
363 }
364
365 pub fn min_amount(&self) -> Option<u64> {
367 self.min_amount
368 }
369
370 pub fn max_amount(&self) -> Option<u64> {
372 self.max_amount
373 }
374
375 pub fn quantity_max(&self) -> Option<u64> {
377 self.quantity_max
378 }
379
380 pub fn signature(&self) -> Option<&[u8; 64]> {
382 self.signature.as_ref()
383 }
384
385 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 let msg = compute_offer_id(&self.raw_tlv);
396
397 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#[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 pub fn new() -> Self {
441 Self::default()
442 }
443
444 pub fn description(mut self, description: impl Into<String>) -> Self {
446 self.description = description.into();
447 self
448 }
449
450 pub fn amount_msats(mut self, amount: u64) -> Self {
452 self.amount = Some(OfferAmount::Fixed(amount));
453 self
454 }
455
456 pub fn amount_variable(mut self) -> Self {
458 self.amount = Some(OfferAmount::Variable);
459 self
460 }
461
462 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 pub fn expiry(mut self, timestamp: u64) -> Self {
473 self.expiry = Some(timestamp);
474 self
475 }
476
477 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 pub fn issuer(mut self, issuer: impl Into<String>) -> Self {
489 self.issuer = Some(issuer.into());
490 self
491 }
492
493 pub fn node_id(mut self, node_id: PublicKey) -> Self {
495 self.node_id = Some(node_id);
496 self
497 }
498
499 pub fn add_path(mut self, path: BlindedPath) -> Self {
501 self.paths.push(path);
502 self
503 }
504
505 pub fn add_chain(mut self, chain_hash: [u8; 32]) -> Self {
507 self.chains.push(chain_hash);
508 self
509 }
510
511 pub fn min_amount(mut self, amount: u64) -> Self {
513 self.min_amount = Some(amount);
514 self
515 }
516
517 pub fn max_amount(mut self, amount: u64) -> Self {
519 self.max_amount = Some(amount);
520 self
521 }
522
523 pub fn quantity_max(mut self, quantity: u64) -> Self {
525 self.quantity_max = Some(quantity);
526 self
527 }
528
529 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 let mut tlv = Vec::new();
539
540 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 if let Some(OfferAmount::Fixed(amount)) = &self.amount {
551 write_tlv(&mut tlv, 8, &encode_tu64(*amount));
552 }
553
554 write_tlv(&mut tlv, 10, self.description.as_bytes());
556
557 if let Some(expiry) = self.expiry {
559 write_tlv(&mut tlv, 14, &encode_tu64(expiry));
560 }
561
562 if let Some(issuer) = &self.issuer {
564 write_tlv(&mut tlv, 18, issuer.as_bytes());
565 }
566
567 if let Some(qty) = self.quantity_max {
569 write_tlv(&mut tlv, 20, &encode_tu64(qty));
570 }
571
572 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 pub fn build_signed(self, secret_key: &SecretKey) -> Result<Bolt12Offer, LightningError> {
598 let mut offer = self.build()?;
599
600 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 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
616fn 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
653fn 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
667fn 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
674fn 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
690fn 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
697fn 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 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 assert!(offer.supports_bitcoin_mainnet());
821 }
822}