Skip to content

Commit 7c91eeb

Browse files
committed
Offer parsing for BOLT 12
Offer TLV streams are bech32 encoded without a checksum and optionally broken into parts separated by '+' and whitespace. Implement FromStr for parsing this encoding and Display for generating it.
1 parent 95c1bd8 commit 7c91eeb

File tree

1 file changed

+146
-0
lines changed

1 file changed

+146
-0
lines changed

lightning/src/offers/mod.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,149 @@ struct Recurrence {
8383
}
8484

8585
impl_writeable!(Recurrence, { time_unit, period });
86+
87+
/// Error when parsing a bech32 encoded message using [`str::parse`].
88+
#[derive(Debug, PartialEq)]
89+
pub enum ParseError {
90+
/// The bech32 encoding does not conform to the BOLT 12 requirements for continuing messages
91+
/// across multiple parts (i.e., '+' followed by whitespace).
92+
InvalidContinuation,
93+
/// The bech32 encoding's human-readable part does not match what was expected for the message
94+
/// being parsed.
95+
InvalidBech32Hrp,
96+
/// The string could not be bech32 decoded.
97+
Bech32(bech32::Error),
98+
/// The bech32 decoded string could not be decoded as the expected message type.
99+
Decode(DecodeError),
100+
}
101+
102+
impl From<bech32::Error> for ParseError {
103+
fn from(error: bech32::Error) -> Self {
104+
Self::Bech32(error)
105+
}
106+
}
107+
108+
impl From<DecodeError> for ParseError {
109+
fn from(error: DecodeError) -> Self {
110+
Self::Decode(error)
111+
}
112+
}
113+
114+
const OFFER_BECH32_HRP: &str = "lno";
115+
116+
impl FromStr for OfferTlvStream {
117+
type Err = ParseError;
118+
119+
fn from_str(s: &str) -> Result<Self, <Self as FromStr>::Err> {
120+
// Offer encoding may be split by '+' followed by optional whitespace.
121+
for chunk in s.split('+') {
122+
let chunk = chunk.trim_start();
123+
if chunk.is_empty() || chunk.contains(char::is_whitespace) {
124+
return Err(ParseError::InvalidContinuation);
125+
}
126+
}
127+
128+
let s = s.chars().filter(|c| *c != '+' && !c.is_whitespace()).collect::<String>();
129+
let (hrp, data) = bech32::decode_without_checksum(&s)?;
130+
131+
if hrp != OFFER_BECH32_HRP {
132+
return Err(ParseError::InvalidBech32Hrp);
133+
}
134+
135+
let data = Vec::<u8>::from_base32(&data)?;
136+
Ok(Readable::read(&mut &data[..])?)
137+
}
138+
}
139+
140+
impl core::fmt::Display for OfferTlvStream {
141+
fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> {
142+
use util::ser::Writeable;
143+
let mut buffer = Vec::new();
144+
self.write(&mut buffer).unwrap();
145+
146+
use bitcoin::bech32::ToBase32;
147+
let data = buffer.to_base32();
148+
bech32::encode_without_checksum_to_fmt(f, OFFER_BECH32_HRP, data).expect("HRP is valid").unwrap();
149+
150+
Ok(())
151+
}
152+
}
153+
154+
#[cfg(test)]
155+
mod tests {
156+
use super::{OfferTlvStream, ParseError};
157+
use bitcoin::bech32;
158+
use ln::msgs::DecodeError;
159+
160+
#[test]
161+
fn encodes_offer_as_bech32_without_checksum() {
162+
let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy";
163+
let offer = encoded_offer.parse::<OfferTlvStream>().unwrap();
164+
assert_eq!(offer.to_string(), encoded_offer);
165+
}
166+
167+
#[test]
168+
fn parses_bech32_encoded_offers() {
169+
let offers = [
170+
// BOLT 12 test vectors
171+
"lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
172+
"l+no1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
173+
"l+no1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
174+
"lno1qcp4256ypqpq+86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn0+0fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0+sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qs+y",
175+
"lno1qcp4256ypqpq+ 86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn0+ 0fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0+\nsqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43l+\r\nastpwuh73k29qs+\r y",
176+
// Two blinded paths
177+
"lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0yg06qg2qdd7t628sgykwj5kuc837qmlv9m9gr7sq8ap6erfgacv26nhp8zzcqgzhdvttlk22pw8fmwqqrvzst792mj35ypylj886ljkcmug03wg6heqqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6muh550qsfva9fdes0ruph7ctk2s8aqq06r4jxj3msc448wzwy9sqs9w6ckhlv55zuwnkuqqxc9qhu24h9rggzflyw04l9d3hcslzu340jqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
178+
];
179+
for encoded_offer in &offers {
180+
if let Err(e) = encoded_offer.parse::<OfferTlvStream>() {
181+
panic!("Invalid offer ({:?}): {}", e, encoded_offer);
182+
}
183+
}
184+
}
185+
186+
#[test]
187+
fn fails_parsing_bech32_encoded_offers_with_invalid_continuations() {
188+
let offers = [
189+
// BOLT 12 test vectors
190+
"lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy+",
191+
"lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy+ ",
192+
"+lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
193+
"+ lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
194+
"ln++o1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
195+
];
196+
for encoded_offer in &offers {
197+
match encoded_offer.parse::<OfferTlvStream>() {
198+
Ok(_) => panic!("Valid offer: {}", encoded_offer),
199+
Err(e) => assert_eq!(e, ParseError::InvalidContinuation),
200+
}
201+
}
202+
203+
}
204+
205+
#[test]
206+
fn fails_parsing_bech32_encoded_offer_with_invalid_hrp() {
207+
let encoded_offer = "lni1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy";
208+
match encoded_offer.parse::<OfferTlvStream>() {
209+
Ok(_) => panic!("Valid offer: {}", encoded_offer),
210+
Err(e) => assert_eq!(e, ParseError::InvalidBech32Hrp),
211+
}
212+
}
213+
214+
#[test]
215+
fn fails_parsing_bech32_encoded_offer_with_invalid_bech32_data() {
216+
let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qso";
217+
match encoded_offer.parse::<OfferTlvStream>() {
218+
Ok(_) => panic!("Valid offer: {}", encoded_offer),
219+
Err(e) => assert_eq!(e, ParseError::Bech32(bech32::Error::InvalidChar('o'))),
220+
}
221+
}
222+
223+
#[test]
224+
fn fails_parsing_bech32_encoded_offer_with_invalid_tlv_data() {
225+
let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsyqqqqq";
226+
match encoded_offer.parse::<OfferTlvStream>() {
227+
Ok(_) => panic!("Valid offer: {}", encoded_offer),
228+
Err(e) => assert_eq!(e, ParseError::Decode(DecodeError::InvalidValue)),
229+
}
230+
}
231+
}

0 commit comments

Comments
 (0)