rgbinvoice/
parse.rs

1// RGB wallet library for smart contracts on Bitcoin & Lightning network
2//
3// SPDX-License-Identifier: Apache-2.0
4//
5// Written in 2019-2024 by
6//     Dr Maxim Orlovsky <orlovsky@lnp-bp.org>
7//
8// Copyright (C) 2019-2024 LNP/BP Standards Association. All rights reserved.
9//
10// Licensed under the Apache License, Version 2.0 (the "License");
11// you may not use this file except in compliance with the License.
12// You may obtain a copy of the License at
13//
14//     http://www.apache.org/licenses/LICENSE-2.0
15//
16// Unless required by applicable law or agreed to in writing, software
17// distributed under the License is distributed on an "AS IS" BASIS,
18// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
19// See the License for the specific language governing permissions and
20// limitations under the License.
21
22use std::fmt::{self, Debug, Display, Formatter};
23use std::io::{Cursor, Write};
24use std::num::ParseIntError;
25use std::str::FromStr;
26
27use baid64::{Baid64ParseError, DisplayBaid64, FromBaid64Str};
28use fluent_uri::encoding::encoder::Query;
29use fluent_uri::encoding::EStr;
30use fluent_uri::Uri;
31use indexmap::IndexMap;
32use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
33use rgb::bitcoin::hashes::{hash160, sha256};
34use rgb::bitcoin::key::{TweakedPublicKey, UntweakedPublicKey};
35use rgb::bitcoin::params::Params;
36use rgb::bitcoin::{Address, Network, PubkeyHash, ScriptBuf, ScriptHash, WPubkeyHash, WScriptHash};
37use rgb::{ChainNet, ContractId, SchemaId, SecretSeal};
38use strict_types::FieldName;
39
40use crate::invoice::{Beneficiary, InvoiceState, Pay2Vout, RgbInvoice, RgbTransport, XChainNet};
41
42const OMITTED: &str = "~";
43const ASSIGNMENT: &str = "assignment_name";
44const EXPIRY: &str = "expiry";
45const ENDPOINTS: &str = "endpoints";
46const TRANSPORT_SEP: char = ',';
47const TRANSPORT_HOST_SEP: &str = "://";
48const QUERY_ENCODE: &AsciiSet = &CONTROLS
49    .add(b' ')
50    .add(b'"')
51    .add(b'#')
52    .add(b'<')
53    .add(b'>')
54    .add(b'[')
55    .add(b']')
56    .add(b'&')
57    .add(b'=');
58
59#[derive(Clone, PartialEq, Eq, Debug, Display, Error, From)]
60#[display(inner)]
61pub enum TransportParseError {
62    #[display(doc_comments)]
63    /// invalid transport {0}.
64    InvalidTransport(String),
65
66    #[display(doc_comments)]
67    /// invalid transport host {0}.
68    InvalidTransportHost(String),
69}
70
71#[derive(Debug, Display, Error, From)]
72#[display(doc_comments)]
73pub enum InvoiceParseError {
74    /// invalid invoice.
75    Invalid,
76
77    /// RGB invoice must not contain any URI authority data, including empty
78    /// one.
79    Authority,
80
81    /// contract id is missing from the invoice.
82    ContractMissing,
83
84    /// schema information is missing from the invoice.
85    SchemaMissing,
86
87    /// assignment state is missing from the invoice.
88    AssignmentStateMissing,
89
90    /// beneficiary is missing from the invoice.
91    BeneficiaryMissing,
92
93    /// invalid invoice scheme {0}.
94    InvalidScheme(String),
95
96    /// no invoice transport has been provided.
97    NoTransport,
98
99    /// invalid contract ID.
100    InvalidContractId(String),
101
102    /// invalid schema {0}.
103    InvalidSchemaId(String),
104
105    /// invalid assignment state {0}.
106    InvalidAssignmentState(String),
107
108    /// invalid assignment name {0}.
109    InvalidAssignmentName(String),
110
111    /// invalid expiration timestamp {0}.
112    InvalidExpiration(String),
113
114    /// invalid network {0}
115    InvalidNetwork(Network),
116
117    /// invalid query parameter {0}.
118    InvalidQueryParam(String),
119
120    /// can't recognize beneficiary "{0}": it should be either a bitcoin address
121    /// or a blinded UTXO seal.
122    Beneficiary(String),
123
124    #[from]
125    #[display(inner)]
126    Num(ParseIntError),
127
128    /// can't recognize amount "{0}": it should be valid allocation data.
129    Data(String),
130}
131
132impl RgbInvoice {
133    fn has_params(&self) -> bool {
134        self.expiry.is_some()
135            || self.assignment_name.is_some()
136            || self.transports != vec![RgbTransport::UnspecifiedMeans]
137            || !self.unknown_query.is_empty()
138    }
139
140    fn query_params(&self) -> IndexMap<String, String> {
141        let mut query_params: IndexMap<String, String> = IndexMap::new();
142        if let Some(ref assignment) = self.assignment_name {
143            query_params.insert(ASSIGNMENT.to_string(), assignment.to_string());
144        }
145        if let Some(expiry) = self.expiry {
146            query_params.insert(EXPIRY.to_string(), expiry.to_string());
147        }
148        if self.transports != vec![RgbTransport::UnspecifiedMeans] {
149            let mut transports: Vec<String> = vec![];
150            for transport in self.transports.clone() {
151                transports.push(transport.to_string());
152            }
153            query_params.insert(ENDPOINTS.to_string(), transports.join(&TRANSPORT_SEP.to_string()));
154        }
155        query_params.extend(self.unknown_query.clone());
156        query_params
157    }
158}
159
160impl Display for RgbTransport {
161    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
162        match self {
163            RgbTransport::JsonRpc { tls, host } => {
164                let s = if *tls { "s" } else { "" };
165                write!(f, "rpc{s}{TRANSPORT_HOST_SEP}{host}")?;
166            }
167            RgbTransport::RestHttp { tls, host } => {
168                let s = if *tls { "s" } else { "" };
169                write!(f, "http{s}{TRANSPORT_HOST_SEP}{host}")?;
170            }
171            RgbTransport::WebSockets { tls, host } => {
172                let s = if *tls { "s" } else { "" };
173                write!(f, "ws{s}{TRANSPORT_HOST_SEP}{host}")?;
174            }
175            RgbTransport::Storm {} => {
176                write!(f, "storm{TRANSPORT_HOST_SEP}_/")?;
177            }
178            RgbTransport::UnspecifiedMeans => {}
179        };
180        Ok(())
181    }
182}
183
184impl FromStr for RgbTransport {
185    type Err = TransportParseError;
186
187    fn from_str(s: &str) -> Result<Self, Self::Err> {
188        let tokens = s.split_once(TRANSPORT_HOST_SEP);
189        if tokens.is_none() {
190            return Err(TransportParseError::InvalidTransport(s.to_string()));
191        }
192        let (trans_type, host) = tokens.unwrap();
193        if host.is_empty() {
194            return Err(TransportParseError::InvalidTransportHost(host.to_string()));
195        }
196        let host = host.to_string();
197        let transport = match trans_type {
198            "rpc" => RgbTransport::JsonRpc { tls: false, host },
199            "rpcs" => RgbTransport::JsonRpc { tls: true, host },
200            "http" => RgbTransport::RestHttp { tls: false, host },
201            "https" => RgbTransport::RestHttp { tls: true, host },
202            "ws" => RgbTransport::WebSockets { tls: false, host },
203            "wss" => RgbTransport::WebSockets { tls: true, host },
204            "storm" => RgbTransport::Storm {},
205            _ => return Err(TransportParseError::InvalidTransport(s.to_string())),
206        };
207        Ok(transport)
208    }
209}
210
211impl Display for XChainNet<Beneficiary> {
212    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
213        write!(f, "{}:", self.chain_network().prefix())?;
214        match self.into_inner() {
215            Beneficiary::BlindedSeal(seal) => Display::fmt(&seal, f),
216            Beneficiary::WitnessVout(pay2vout, internal_pk) => {
217                write!(
218                    f,
219                    "{}{}",
220                    pay2vout.to_baid64_string(),
221                    if let Some(ipk) = internal_pk { format!("+{ipk}") } else { s!("") }
222                )
223            }
224        }
225    }
226}
227
228/// Internal address content. Consists of serialized hashes or x-only key value.
229#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug, From)]
230pub enum AddressPayload {
231    /// P2PKH payload.
232    #[from]
233    Pkh(PubkeyHash),
234
235    /// P2SH and SegWit nested (proprietary) P2WPKH/WSH-in-P2SH payloads.
236    #[from]
237    Sh(ScriptHash),
238
239    /// P2WPKH payload.
240    #[from]
241    Wpkh(WPubkeyHash),
242
243    /// P2WSH payload.
244    #[from]
245    Wsh(WScriptHash),
246
247    /// P2TR payload.
248    #[from]
249    Tr(TweakedPublicKey),
250}
251
252/// Errors creating address from scriptPubkey.
253#[derive(Clone, Eq, PartialEq, Debug, Display, Error)]
254#[display(doc_comments)]
255pub enum AddressError {
256    /// scriptPubkey contains invalid BIP340 output pubkey.
257    InvalidTaprootKey,
258    /// scriptPubkey can't be represented with any known address standard.
259    UnsupportedScriptPubkey,
260}
261
262impl AddressPayload {
263    pub fn into_address(self, network: Network) -> Address {
264        // since we don't allow to construct AddressPayload without checking its script, the unwrap
265        // is safe here
266        Address::from_script(&self.to_script(), Params::new(network)).unwrap()
267    }
268
269    /// Constructs payload from a given `ScriptBuf`. Fails on future
270    /// (post-taproot) witness types with `None`.
271    pub fn from_script(script: &ScriptBuf) -> Result<Self, AddressError> {
272        Ok(if script.is_p2pkh() {
273            let mut bytes = [0u8; 20];
274            bytes.copy_from_slice(&script.as_bytes()[3..23]);
275            AddressPayload::Pkh(PubkeyHash::from_raw_hash(*hash160::Hash::from_bytes_ref(&bytes)))
276        } else if script.is_p2sh() {
277            let mut bytes = [0u8; 20];
278            bytes.copy_from_slice(&script.as_bytes()[2..22]);
279            AddressPayload::Sh(ScriptHash::from_raw_hash(*hash160::Hash::from_bytes_ref(&bytes)))
280        } else if script.is_p2wpkh() {
281            let mut bytes = [0u8; 20];
282            bytes.copy_from_slice(&script.as_bytes()[2..]);
283            AddressPayload::Wpkh(WPubkeyHash::from_raw_hash(*hash160::Hash::from_bytes_ref(&bytes)))
284        } else if script.is_p2wsh() {
285            let mut bytes = [0u8; 32];
286            bytes.copy_from_slice(&script.as_bytes()[2..]);
287            AddressPayload::Wsh(WScriptHash::from_raw_hash(*sha256::Hash::from_bytes_ref(&bytes)))
288        } else if script.is_p2tr() {
289            let mut bytes = [0u8; 32];
290            bytes.copy_from_slice(&script.as_bytes()[2..]);
291            AddressPayload::Tr(TweakedPublicKey::dangerous_assume_tweaked(
292                UntweakedPublicKey::from_slice(&bytes)
293                    .map_err(|_| AddressError::InvalidTaprootKey)?,
294            ))
295        } else {
296            return Err(AddressError::UnsupportedScriptPubkey);
297        })
298    }
299
300    /// Returns script corresponding to the given address.
301    pub fn to_script(self) -> ScriptBuf {
302        match self {
303            AddressPayload::Pkh(hash) => ScriptBuf::new_p2pkh(&hash),
304            AddressPayload::Sh(hash) => ScriptBuf::new_p2sh(&hash),
305            AddressPayload::Wpkh(hash) => ScriptBuf::new_p2wpkh(&hash),
306            AddressPayload::Wsh(hash) => ScriptBuf::new_p2wsh(&hash),
307            AddressPayload::Tr(output_key) => ScriptBuf::new_p2tr_tweaked(output_key),
308        }
309    }
310}
311
312impl DisplayBaid64<33> for Pay2Vout {
313    const HRI: &'static str = "wvout";
314    const CHUNKING: bool = true;
315    const PREFIX: bool = true;
316    const EMBED_CHECKSUM: bool = true;
317    const MNEMONIC: bool = false;
318
319    fn to_baid64_payload(&self) -> [u8; 33] {
320        let mut payload = [0u8; 33];
321        // tmp stack array to store the tr payload to resolve lifetime issue
322        let schnorr_pk: [u8; 32];
323        let (addr_type, spk) = match &**self {
324            AddressPayload::Pkh(pkh) => (Self::P2PKH, pkh.as_ref()),
325            AddressPayload::Sh(sh) => (Self::P2SH, sh.as_ref()),
326            AddressPayload::Wpkh(wpkh) => (Self::P2WPKH, wpkh.as_ref()),
327            AddressPayload::Wsh(wsh) => (Self::P2WSH, wsh.as_ref()),
328            AddressPayload::Tr(tr) => {
329                schnorr_pk = tr.serialize();
330                (Self::P2TR, &schnorr_pk[..])
331            }
332        };
333        payload[0] = addr_type;
334        Cursor::new(&mut payload[1..])
335            .write_all(spk)
336            .expect("address payload always less than 32 bytes");
337        payload
338    }
339}
340
341impl Display for Pay2Vout {
342    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { self.fmt_baid64(f) }
343}
344impl FromBaid64Str<33> for Pay2Vout {}
345impl FromStr for Pay2Vout {
346    type Err = Baid64ParseError;
347    fn from_str(s: &str) -> Result<Self, Self::Err> { Self::from_baid64_str(s) }
348}
349
350impl FromStr for XChainNet<Beneficiary> {
351    type Err = InvoiceParseError;
352
353    fn from_str(s: &str) -> Result<Self, Self::Err> {
354        let Some((cn, beneficiary)) = s.split_once(':') else {
355            return Err(InvoiceParseError::Beneficiary(s!("missing beneficiary HRI")));
356        };
357        let cn =
358            ChainNet::from_str(cn).map_err(|e| InvoiceParseError::Beneficiary(e.to_string()))?;
359        if let Ok(seal) = SecretSeal::from_str(beneficiary) {
360            return Ok(XChainNet::with(cn, Beneficiary::BlindedSeal(seal)));
361        }
362
363        let (pay2vout, internal_pk) = beneficiary
364            .split_once("+")
365            .map(|(p, i)| (p, Some(i)))
366            .unwrap_or((beneficiary, None));
367
368        let pay2vout = Pay2Vout::from_str(pay2vout)
369            .map_err(|e| InvoiceParseError::Beneficiary(e.to_string()))?;
370
371        let internal_pk = match internal_pk {
372            None => None,
373            Some(i) => {
374                if i.is_empty() {
375                    return Err(InvoiceParseError::Beneficiary(s!("missing internal pk")));
376                }
377                Some(
378                    UntweakedPublicKey::from_str(i)
379                        .map_err(|_| InvoiceParseError::Beneficiary(s!("invalid internal pk")))?,
380                )
381            }
382        };
383
384        Ok(XChainNet::with(cn, Beneficiary::WitnessVout(pay2vout, internal_pk)))
385    }
386}
387
388impl Display for RgbInvoice {
389    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
390        if let Some(contract) = self.contract {
391            let id = if f.alternate() {
392                contract.to_string().replace('-', "")
393            } else {
394                contract.to_string()
395            };
396            write!(f, "{id}/")?;
397        } else {
398            write!(f, "rgb:{OMITTED}/")?;
399        }
400        if let Some(schema) = self.schema {
401            let schema_str = format!("{schema:-#}");
402            let id = if f.alternate() { schema_str.replace('-', "") } else { schema_str };
403            write!(f, "{id}/")?;
404        } else {
405            write!(f, "{OMITTED}/")?;
406        }
407        if let Some(ref assignment_state) = self.assignment_state {
408            write!(f, "{assignment_state}/")?;
409        } else {
410            write!(f, "{OMITTED}/")?;
411        }
412        let beneficiary = if f.alternate() {
413            self.beneficiary.to_string().replace('-', "")
414        } else {
415            self.beneficiary.to_string()
416        };
417        f.write_str(&beneficiary)?;
418        if self.has_params() {
419            f.write_str("?")?;
420        }
421        let query_params = self.query_params();
422        for (key, val) in query_params.iter().take(1) {
423            write!(
424                f,
425                "{}={}",
426                utf8_percent_encode(key, QUERY_ENCODE),
427                utf8_percent_encode(val, QUERY_ENCODE)
428            )?;
429        }
430        for (key, val) in query_params.iter().skip(1) {
431            write!(
432                f,
433                "&{}={}",
434                utf8_percent_encode(key, QUERY_ENCODE),
435                utf8_percent_encode(val, QUERY_ENCODE)
436            )?;
437        }
438        Ok(())
439    }
440}
441
442impl FromStr for RgbInvoice {
443    type Err = InvoiceParseError;
444
445    fn from_str(s: &str) -> Result<Self, Self::Err> {
446        let uri = Uri::parse(s).map_err(|_| InvoiceParseError::Invalid)?;
447
448        let scheme = uri.scheme();
449        if scheme.as_str() != "rgb" {
450            return Err(InvoiceParseError::InvalidScheme(scheme.to_string()));
451        }
452
453        let path = uri.path();
454        if path.is_absolute() || uri.authority().is_some() {
455            return Err(InvoiceParseError::Authority);
456        }
457
458        let mut path = path.split('/');
459
460        let Some(contract_id_str) = path.next() else {
461            return Err(InvoiceParseError::ContractMissing);
462        };
463        let contract = match ContractId::from_str(contract_id_str.as_str()) {
464            Ok(cid) => Some(cid),
465            Err(_) if contract_id_str.as_str() == OMITTED => None,
466            Err(_) => {
467                return Err(InvoiceParseError::InvalidContractId(contract_id_str.to_string()));
468            }
469        };
470
471        let Some(schema_str) = path.next() else {
472            return Err(InvoiceParseError::SchemaMissing);
473        };
474        let schema = match SchemaId::from_str(schema_str.as_ref()) {
475            Ok(i) => Some(i),
476            Err(_) if schema_str.as_str() == OMITTED => None,
477            Err(_) => return Err(InvoiceParseError::InvalidSchemaId(schema_str.to_string())),
478        };
479
480        let Some(assignment_str) = path.next() else {
481            return Err(InvoiceParseError::AssignmentStateMissing);
482        };
483        let assignment_state = match InvoiceState::from_str(assignment_str.as_ref()) {
484            Ok(i) => Some(i),
485            Err(_) if assignment_str.as_str() == OMITTED => None,
486            Err(_) => {
487                return Err(InvoiceParseError::InvalidAssignmentState(assignment_str.to_string()))
488            }
489        };
490
491        let Some(beneficiary_str) = path.next() else {
492            return Err(InvoiceParseError::BeneficiaryMissing);
493        };
494        let beneficiary = XChainNet::<Beneficiary>::from_str(beneficiary_str.as_ref())?;
495        let mut query_params = map_query_params(&uri)?;
496
497        let transports = if let Some(endpoints) = query_params.shift_remove(ENDPOINTS) {
498            let tokens = endpoints.split(TRANSPORT_SEP);
499            let mut transport_vec: Vec<RgbTransport> = vec![];
500            for token in tokens {
501                transport_vec.push(
502                    RgbTransport::from_str(token)
503                        .map_err(|e| InvoiceParseError::InvalidQueryParam(e.to_string()))?,
504                );
505            }
506            transport_vec
507        } else {
508            vec![RgbTransport::UnspecifiedMeans]
509        };
510
511        let mut assignment_name = None;
512        if let Some(assignment) = query_params.shift_remove(ASSIGNMENT) {
513            let name = FieldName::try_from(assignment.clone())
514                .map_err(|_| InvoiceParseError::InvalidAssignmentName(assignment))?;
515            assignment_name = Some(name);
516        }
517
518        let mut expiry = None;
519        if let Some(exp) = query_params.shift_remove(EXPIRY) {
520            let timestamp = exp
521                .parse::<i64>()
522                .map_err(|e| InvoiceParseError::InvalidExpiration(e.to_string()))?;
523            expiry = Some(timestamp);
524        }
525
526        Ok(RgbInvoice {
527            transports,
528            contract,
529            schema,
530            assignment_name,
531            beneficiary,
532            assignment_state,
533            expiry,
534            unknown_query: query_params,
535        })
536    }
537}
538
539fn percent_decode(estr: &EStr<Query>) -> Result<String, InvoiceParseError> {
540    Ok(estr
541        .decode()
542        .into_string()
543        .map_err(|e| InvoiceParseError::InvalidQueryParam(e.to_string()))?
544        .to_string())
545}
546
547fn map_query_params(uri: &Uri<&str>) -> Result<IndexMap<String, String>, InvoiceParseError> {
548    let mut map: IndexMap<String, String> = IndexMap::new();
549    if let Some(q) = uri.query() {
550        let params = q.split('&');
551        for p in params {
552            if let Some((k, v)) = p.split_once('=') {
553                map.insert(percent_decode(k)?, percent_decode(v)?);
554            } else {
555                return Err(InvoiceParseError::InvalidQueryParam(p.to_string()));
556            }
557        }
558    }
559    Ok(map)
560}
561
562#[cfg(test)]
563mod test {
564    use rgb::bitcoin::hashes::{hash160, sha256};
565
566    use super::*;
567    use crate::{Allocation, Amount, NonFungible};
568
569    #[test]
570    fn parse() {
571        // nia parameters
572        let invoice_str = "rgb:eIbQx5Am-XRDjj01-RM~5eo7-rv2nluD-OnBJRAy-S9~Yfts/\
573                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
574                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa";
575        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
576        assert_eq!(invoice.assignment_state, Some(InvoiceState::Amount(Amount::from(100u64))));
577        assert_eq!(invoice.to_string(), invoice_str);
578        assert_eq!(format!("{invoice:#}"), invoice_str.replace('-', ""));
579
580        // uda parameters
581        let invoice_str = "rgb:tx8NOyGe-NkPZex~-U0J_1om-CfrOeoO-7di9xZb-vT3nxyo/\
582                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/1@0/bc:utxob:\
583                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa";
584        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
585        assert_eq!(
586            invoice.assignment_state,
587            Some(InvoiceState::Data(NonFungible::FractionedToken(Allocation::with(0, 1))))
588        );
589        assert_eq!(invoice.to_string(), invoice_str);
590        assert_eq!(format!("{invoice:#}"), invoice_str.replace('-', ""));
591
592        // witness vout without internal pk
593        let invoice_str = "rgb:eIbQx5Am-XRDjj01-RM~5eo7-rv2nluD-OnBJRAy-S9~Yfts/\
594                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/Sa/bc:wvout:\
595                           A8cJ7Ww3-NIzADo3-Tzp_5aD-7CTBWmA-AAAAAAA-AAAAAAA-ALSQkcw";
596        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
597        assert_eq!(invoice.to_string(), invoice_str);
598
599        // witness vout with internal pk
600        let invoice_str = "rgb:eIbQx5Am-XRDjj01-RM~5eo7-rv2nluD-OnBJRAy-S9~Yfts/\
601                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/Sa/bc:wvout:\
602                           A8cJ7Ww3-NIzADo3-Tzp_5aD-7CTBWmA-AAAAAAA-AAAAAAA-ALSQkcw\
603                           +750f58bcca0fdb11891e7979d829b8c56e0963dba08c44f54a256cf7dbc09caf";
604        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
605        assert_eq!(invoice.to_string(), invoice_str);
606
607        // no amount
608        let invoice_str = "rgb:eIbQx5Am-XRDjj01-RM~5eo7-rv2nluD-OnBJRAy-S9~Yfts/\
609                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/~/bc:utxob:\
610                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa";
611        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
612        assert_eq!(invoice.assignment_state, None);
613        assert_eq!(invoice.to_string(), invoice_str);
614
615        // no allocation
616        let invoice_str = "rgb:eIbQx5Am-XRDjj01-RM~5eo7-rv2nluD-OnBJRAy-S9~Yfts/\
617                           V8ujLLtH2k2QSmaDpZI3o06ACIm2UNT0TZl11FiqRuY/~/bc:utxob:\
618                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa";
619        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
620        assert_eq!(invoice.assignment_state, None);
621        assert_eq!(invoice.to_string(), invoice_str);
622
623        // no contract ID
624        let invoice_str = "rgb:~/XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/~/bc:utxob:\
625                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa";
626        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
627        assert_eq!(invoice.to_string(), invoice_str);
628
629        // no contract ID nor schema
630        let invoice_str =
631            "rgb:~/~/~/bc:utxob:4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa";
632        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
633        assert_eq!(invoice.to_string(), invoice_str);
634
635        // contract ID provided but no schema
636        let invoice_str = "rgb:eIbQx5Am-XRDjj01-RM~5eo7-rv2nluD-OnBJRAy-S9~Yfts/~/~/bc:utxob:\
637                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa";
638        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
639        assert_eq!(invoice.to_string(), invoice_str);
640
641        // invalid contract ID
642        let invalid_contract_id = "invalid";
643        let invoice_str = format!(
644            "rgb:{invalid_contract_id}/XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/bc:utxob:\
645             4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa"
646        );
647        let result = RgbInvoice::from_str(&invoice_str);
648        assert!(matches!(result,
649                Err(InvoiceParseError::InvalidContractId(c)) if c == invalid_contract_id));
650
651        // with assignment name
652        let assignment_name = "assetOwner";
653        let invoice_str = format!(
654            "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
655             XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
656             4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?{ASSIGNMENT}={assignment_name}"
657        );
658        let invoice = RgbInvoice::from_str(&invoice_str).unwrap();
659        assert_eq!(invoice.assignment_name, Some(FieldName::from(assignment_name)));
660        assert_eq!(invoice.to_string(), invoice_str);
661
662        // bad assignment_name
663        let assignment_name = "";
664        let invoice_str = format!(
665            "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
666             XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
667             4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?{ASSIGNMENT}={assignment_name}"
668        );
669        let result = RgbInvoice::from_str(&invoice_str);
670        assert!(matches!(result, Err(InvoiceParseError::InvalidAssignmentName(_))));
671
672        // with expiration
673        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
674                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
675                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?\
676                           expiry=1682086371";
677        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
678        assert_eq!(invoice.to_string(), invoice_str);
679
680        // bad expiration
681        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
682                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
683                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?expiry=six";
684        let result = RgbInvoice::from_str(invoice_str);
685        assert!(matches!(result, Err(InvoiceParseError::InvalidExpiration(_))));
686
687        // with bad query parameter
688        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
689                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
690                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?expiry";
691        let result = RgbInvoice::from_str(invoice_str);
692        assert!(matches!(result, Err(InvoiceParseError::InvalidQueryParam(_))));
693
694        // with an unknown query parameter
695        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
696                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
697                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?unknown=new";
698        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
699        assert_eq!(invoice.to_string(), invoice_str);
700
701        // with two unknown query parameters
702        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
703                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
704                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?unknown=new&\
705                           another=new";
706        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
707        assert_eq!(invoice.to_string(), invoice_str);
708
709        // with expiration and an unknown query parameter
710        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
711                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
712                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?\
713                           expiry=1682086371&unknown=new";
714        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
715        assert_eq!(invoice.to_string(), invoice_str);
716
717        // with an unknown query parameter containing percent-encoded text
718        let invoice_base = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
719                            XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
720                            4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?";
721        let query_key_encoded = ":@-%20%23";
722        let query_key_decoded = ":@- #";
723        let query_val_encoded = "?/.%26%3D";
724        let query_val_decoded = "?/.&=";
725        let invoice =
726            RgbInvoice::from_str(&format!("{invoice_base}{query_key_encoded}={query_val_encoded}"))
727                .unwrap();
728        let query_params = invoice.query_params();
729        assert_eq!(query_params[query_key_decoded], query_val_decoded);
730        assert_eq!(
731            invoice.to_string(),
732            format!("{invoice_base}{query_key_encoded}={query_val_encoded}")
733        );
734
735        // no scheme
736        let invoice_str = "eIbQx5Am-XRDjj01-RM~5eo7-rv2nluD-OnBJRAy-S9~Yfts/~/bc:utxob:\
737                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa";
738        let result = RgbInvoice::from_str(invoice_str);
739        assert!(matches!(result, Err(InvoiceParseError::Invalid)));
740
741        // invalid scheme
742        let invoice_str = "bad:2WBcas9-yjzEvGufY-9GEgnyMj7-beMNMWA8r-sPHtV1nPU-TMsGMQX/~/bc:utxob:\
743                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa";
744        let result = RgbInvoice::from_str(invoice_str);
745        assert!(matches!(result, Err(InvoiceParseError::InvalidScheme(_))));
746
747        // empty transport endpoint specification
748        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
749                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
750                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=";
751        let result = RgbInvoice::from_str(invoice_str);
752        assert!(matches!(result, Err(InvoiceParseError::InvalidQueryParam(_))));
753
754        // invalid transport endpoint specification
755        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
756                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
757                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=bad";
758        let result = RgbInvoice::from_str(invoice_str);
759        assert!(matches!(result, Err(InvoiceParseError::InvalidQueryParam(_))));
760
761        // invalid transport variant
762        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
763                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
764                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=rpca:/\
765                           /host.example.com";
766        let result = RgbInvoice::from_str(invoice_str);
767        assert!(matches!(result, Err(InvoiceParseError::InvalidQueryParam(_))));
768
769        // rgb-rpc variant
770        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
771                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
772                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=rpc://\
773                           host.example.com";
774        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
775        assert_eq!(invoice.transports, vec![RgbTransport::JsonRpc {
776            tls: false,
777            host: "host.example.com".to_string()
778        }]);
779        assert_eq!(invoice.to_string(), invoice_str);
780
781        // rgb-rpc variant, host containing authentication, "-" characters and port
782        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
783                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
784                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=rpcs:/\
785                           /user:pass@host-1.ex-ample.com:1234";
786        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
787        assert_eq!(invoice.transports, vec![RgbTransport::JsonRpc {
788            tls: true,
789            host: "user:pass@host-1.ex-ample.com:1234".to_string()
790        }]);
791        assert_eq!(invoice.to_string(), invoice_str);
792
793        // rgb-rpc variant, IPv6 host
794        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
795                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
796                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=rpcs:/\
797                           /%5B2001:db8::1%5D:1234";
798        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
799        assert_eq!(invoice.transports, vec![RgbTransport::JsonRpc {
800            tls: true,
801            host: "[2001:db8::1]:1234".to_string()
802        }]);
803        assert_eq!(invoice.to_string(), invoice_str);
804
805        // rgb-rpc variant with missing host
806        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
807                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
808                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=rpc://";
809        let result = RgbInvoice::from_str(invoice_str);
810        assert!(matches!(result, Err(InvoiceParseError::InvalidQueryParam(_))));
811
812        // rgb-rpc variant with invalid separator
813        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
814                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
815                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=rpc/\
816                           host.example.com";
817        let result = RgbInvoice::from_str(invoice_str);
818        assert!(matches!(result, Err(InvoiceParseError::InvalidQueryParam(_))));
819
820        // rgb-rpc variant with invalid transport host specification
821        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
822                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
823                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=rpc://\
824                           ho]t";
825        let result = RgbInvoice::from_str(invoice_str);
826        assert!(matches!(result, Err(InvoiceParseError::Invalid)));
827
828        // rgb+http variant
829        let invoice_str = "rgb:\
830                           3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/\
831                           BF/bc:utxob:4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=https://\
832                           host.example.com";
833        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
834        let transports = vec![RgbTransport::RestHttp {
835            tls: true,
836            host: "host.example.com".to_string(),
837        }];
838        assert_eq!(invoice.transports, transports);
839        assert_eq!(invoice.to_string(), invoice_str);
840
841        // rgb+ws variant
842        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
843                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
844                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=wss://\
845                           host.example.com";
846        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
847        let transports = vec![RgbTransport::WebSockets {
848            tls: true,
849            host: "host.example.com".to_string(),
850        }];
851        assert_eq!(invoice.transports, transports);
852        assert_eq!(invoice.to_string(), invoice_str);
853
854        // rgb+storm variant
855        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
856                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:utxob:\
857                           4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=storm:\
858                           //_/";
859        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
860        let transports = vec![RgbTransport::Storm {}];
861        assert_eq!(invoice.transports, transports);
862        assert_eq!(invoice.to_string(), invoice_str);
863
864        // multiple transports
865        let invoice_str = "rgb:\
866                           3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/\
867                           BF/bc:utxob:4vm1CX2Z-K8hMo59-e7dgGBS-Jka7mYn-Xe~yP85-yUiHHxr-aVlYa?endpoints=rpcs://\
868                           host1.example.com,http://host2.example.com,ws://host3.example.com";
869        let invoice = RgbInvoice::from_str(invoice_str).unwrap();
870        let transports = vec![
871            RgbTransport::JsonRpc {
872                tls: true,
873                host: "host1.example.com".to_string(),
874            },
875            RgbTransport::RestHttp {
876                tls: false,
877                host: "host2.example.com".to_string(),
878            },
879            RgbTransport::WebSockets {
880                tls: false,
881                host: "host3.example.com".to_string(),
882            },
883        ];
884        assert_eq!(invoice.transports, transports);
885        assert_eq!(invoice.to_string(), invoice_str);
886
887        // empty transport parse error
888        let result = RgbTransport::from_str("");
889        assert!(matches!(result, Err(TransportParseError::InvalidTransport(_))));
890
891        // invalid transport parse error
892        let result = RgbTransport::from_str("bad");
893        assert!(matches!(result, Err(TransportParseError::InvalidTransport(_))));
894
895        // invalid transport variant parse error
896        let result = RgbTransport::from_str("rpca://host.example.com");
897        assert!(matches!(result, Err(TransportParseError::InvalidTransport(_))));
898
899        // rgb-rpc variant with missing host parse error
900        let result = RgbTransport::from_str("rpc://");
901        assert!(matches!(result, Err(TransportParseError::InvalidTransportHost(_))));
902
903        // rgb-rpc variant with invalid separator parse error
904        let result = RgbTransport::from_str("rpc/host.example.com");
905        assert!(matches!(result, Err(TransportParseError::InvalidTransport(_))));
906
907        // invalid witness vout: invalid length of identifier wvout
908        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
909                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:wvout:\
910                           +750f58bcca0fdb11891e7979d829b8c56e0963dba08c44f54a256cf7dbc09caf";
911        let result = RgbInvoice::from_str(invoice_str);
912        assert!(matches!(result, Err(InvoiceParseError::Beneficiary(_))));
913
914        // invalid witness vout: missing beneficiary HRI
915        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
916                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/\
917                           750f58bcca0fdb11891e7979d829b8c56e0963dba08c44f54a256cf7dbc09caf";
918        let result = RgbInvoice::from_str(invoice_str);
919        assert!(matches!(result, Err(InvoiceParseError::Beneficiary(_))));
920
921        // invalid witness vout: invalid chain-network pair
922        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
923                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/:\
924                           +750f58bcca0fdb11891e7979d829b8c56e0963dba08c44f54a256cf7dbc09caf";
925        let result = RgbInvoice::from_str(invoice_str);
926        assert!(matches!(result, Err(InvoiceParseError::Beneficiary(_))));
927
928        // invalid witness vout: invalid internal pk
929        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
930                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:wvout:\
931                           BYWmQlmL-$i5Co3j-LtTvxSr-53!\
932                           Brv7-fc7ZntC-ha988ci-jqKOj4Q+750f58bcca0fdb11891e7979";
933        let result = RgbInvoice::from_str(invoice_str);
934        assert!(matches!(result, Err(InvoiceParseError::Beneficiary(_))));
935
936        // invalid witness vout: missing internal pk
937        let invoice_str = "rgb:3NoxsLum-cRPebTV-gTZY8qY-KS20lx7-OqgtBls-t7muan4/\
938                           XvmU3d4_nQQ8S7oagbXi07x5vjMm7P~ERukQNX6SC4M/BF/bc:wvout:\
939                           BYWmQlmL-$i5Co3j-LtTvxSr-53!Brv7-fc7ZntC-ha988ci-jqKOj4Q+";
940        let result = RgbInvoice::from_str(invoice_str);
941        assert!(matches!(result, Err(InvoiceParseError::Beneficiary(_))));
942    }
943
944    #[test]
945    fn pay2vout_parse() {
946        let p = Pay2Vout::new(AddressPayload::Pkh(PubkeyHash::from_raw_hash(
947            *hash160::Hash::from_bytes_ref(&[0xff; 20]),
948        )));
949        assert_eq!(Pay2Vout::from_str(&p.to_string()).unwrap(), p);
950
951        let p = Pay2Vout::new(AddressPayload::Sh(ScriptHash::from_raw_hash(
952            *hash160::Hash::from_bytes_ref(&[0xff; 20]),
953        )));
954        assert_eq!(Pay2Vout::from_str(&p.to_string()).unwrap(), p);
955
956        let p = Pay2Vout::new(AddressPayload::Wpkh(WPubkeyHash::from_raw_hash(
957            *hash160::Hash::from_bytes_ref(&[0xff; 20]),
958        )));
959        assert_eq!(Pay2Vout::from_str(&p.to_string()).unwrap(), p);
960
961        let p = Pay2Vout::new(AddressPayload::Wsh(WScriptHash::from_raw_hash(
962            *sha256::Hash::from_bytes_ref(&[0xff; 32]),
963        )));
964        assert_eq!(Pay2Vout::from_str(&p.to_string()).unwrap(), p);
965
966        let p = Pay2Vout::new(AddressPayload::Tr(TweakedPublicKey::dangerous_assume_tweaked(
967            UntweakedPublicKey::from_slice(&[
968                0x85, 0xa6, 0x42, 0x59, 0x8b, 0xfe, 0x2e, 0x42, 0xa3, 0x78, 0xcb, 0xb5, 0x3b, 0xf1,
969                0x4a, 0xbe, 0x77, 0xf8, 0x1a, 0xef, 0xed, 0xf7, 0x3b, 0x66, 0x7b, 0x42, 0x85, 0xaf,
970                0x7c, 0xf1, 0xc8, 0xa3,
971            ])
972            .unwrap(),
973        )));
974        assert_eq!(Pay2Vout::from_str(&p.to_string()).unwrap(), p);
975    }
976}