Skip to content

Commit 7c95e2c

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 553c4fa commit 7c95e2c

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
@@ -86,3 +86,149 @@ struct Recurrence {
8686
}
8787

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

0 commit comments

Comments
 (0)