sonic_callreq/
uri.rs

1// SONIC: Standard library for formally-verifiable distributed contracts
2//
3// SPDX-License-Identifier: Apache-2.0
4//
5// Designed in 2019-2025 by Dr Maxim Orlovsky <orlovsky@ubideco.org>
6// Written in 2024-2025 by Dr Maxim Orlovsky <orlovsky@ubideco.org>
7//
8// Copyright (C) 2019-2024 LNP/BP Standards Association, Switzerland.
9// Copyright (C) 2024-2025 Laboratories for Ubiquitous Deterministic Computing (UBIDECO),
10//                         Institute for Distributed and Cognitive Systems (InDCS), Switzerland.
11// Copyright (C) 2019-2025 Dr Maxim Orlovsky.
12// All rights under the above copyrights are reserved.
13//
14// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
15// in compliance with the License. You may obtain a copy of the License at
16//
17//        http://www.apache.org/licenses/LICENSE-2.0
18//
19// Unless required by applicable law or agreed to in writing, software distributed under the License
20// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
21// or implied. See the License for the specific language governing permissions and limitations under
22// the License.
23
24use alloc::collections::VecDeque;
25use core::error::Error;
26use core::fmt::{self, Display, Formatter};
27use core::str::FromStr;
28
29use amplify::confinement::{ConfinedVec, TinyBlob};
30use baid64::base64::alphabet::Alphabet;
31use baid64::base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
32use baid64::base64::{DecodeError, Engine};
33use baid64::BAID64_ALPHABET;
34use chrono::{DateTime, Utc};
35use fluent_uri::Uri;
36use indexmap::map::Entry;
37use indexmap::IndexMap;
38use percent_encoding::{percent_decode, utf8_percent_encode, AsciiSet, CONTROLS};
39use strict_types::{InvalidRString, StrictVal};
40
41use crate::{CallRequest, CallState, Endpoint};
42
43const URI_SCHEME: &str = "contract";
44const LOCK: &str = "lock";
45const EXPIRY: &str = "expiry";
46const ENDPOINTS: &str = "endpoints";
47const ENDPOINT_SEP: char = ',';
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
59impl<T, A> CallRequest<T, A> {
60    pub fn has_query(&self) -> bool {
61        !self.unknown_query.is_empty() || self.expiry.is_some() || self.lock.is_some() || !self.endpoints.is_empty()
62    }
63}
64
65impl<T, A> Display for CallRequest<T, A>
66where
67    T: Display,
68    A: Display,
69{
70    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
71        write!(f, "contract:{}@{:-}/", self.layer1, self.scope)?;
72        if let Some(api) = &self.api {
73            write!(f, "{api}/")?;
74        }
75        if let Some(call) = &self.call {
76            write!(f, "{}/", call.method)?;
77            if let Some(state) = &call.owned {
78                write!(f, "{state}/")?;
79            }
80        }
81
82        if let Some(data) = &self.data {
83            write!(f, "{}@", utf8_percent_encode(&data.to_string(), QUERY_ENCODE))?;
84        }
85        write!(f, "{}/", self.auth)?;
86
87        if self.has_query() {
88            f.write_str("?")?;
89        }
90
91        if let Some(lock) = &self.lock {
92            let alphabet = Alphabet::new(BAID64_ALPHABET).expect("invalid Baid64 alphabet");
93            let engine = GeneralPurpose::new(&alphabet, GeneralPurposeConfig::new().with_encode_padding(false));
94            write!(f, "{LOCK}={}", engine.encode(lock))?;
95        }
96        if let Some(expiry) = &self.expiry {
97            write!(f, "{EXPIRY}={}", expiry.to_rfc3339())?;
98        }
99        if !self.endpoints.is_empty() {
100            write!(f, "{ENDPOINTS}=")?;
101            let mut iter = self.endpoints.iter().peekable();
102            while let Some(endpoint) = iter.next() {
103                write!(f, "{}", utf8_percent_encode(&endpoint.to_string(), QUERY_ENCODE))?;
104                if iter.peek().is_some() {
105                    write!(f, "{ENDPOINT_SEP}")?;
106                }
107            }
108        }
109
110        let mut iter = self.unknown_query.iter().peekable();
111        while let Some((key, value)) = iter.next() {
112            write!(f, "{}={}", utf8_percent_encode(key, QUERY_ENCODE), utf8_percent_encode(value, QUERY_ENCODE))?;
113            if iter.peek().is_some() {
114                f.write_str("&")?;
115            }
116        }
117        Ok(())
118    }
119}
120
121impl<T, A> FromStr for CallRequest<T, A>
122where
123    T: FromStr,
124    A: FromStr,
125    T::Err: Error,
126    A::Err: Error,
127{
128    type Err = ParseError<T::Err, A::Err>;
129
130    /// # Special conditions
131    ///
132    /// If a URI contains more than 10 endpoints, endpoints from number 10 are ignored.
133    fn from_str(s: &str) -> Result<Self, Self::Err> {
134        let uri = Uri::parse(s)?;
135
136        let scheme = uri.scheme();
137        if scheme.as_str() != URI_SCHEME {
138            return Err(ParseError::SchemeInvalid(scheme.to_string()));
139        }
140
141        let path = uri.path();
142        if path.is_absolute() || uri.authority().is_some() {
143            return Err(ParseError::Authority);
144        }
145
146        let mut path = path.split('/').collect::<VecDeque<_>>();
147
148        let scope = path.pop_front().ok_or(ParseError::ScopeMissed)?.as_str();
149        let (layer1, scope) = scope.split_once('@').ok_or(ParseError::NoLayer1)?;
150        let layer1 = layer1.parse().map_err(|_| ParseError::Layer1)?;
151        let scope = scope.parse().map_err(ParseError::Scope)?;
152
153        let empty = path.pop_back().ok_or(ParseError::PathNoAuth)?;
154        if !empty.is_empty() {
155            return Err(ParseError::PathLastNoEmpty);
156        }
157
158        let value_auth = path.pop_back().ok_or(ParseError::PathNoAuth)?.as_str();
159        let (data, auth) =
160            if let Some((data, auth)) = value_auth.split_once('@') { (Some(data), auth) } else { (None, value_auth) };
161        let data = data.map(|data| {
162            u64::from_str(data)
163                .map(StrictVal::num)
164                .unwrap_or_else(|_| StrictVal::str(data))
165        });
166        let auth = auth.parse().map_err(ParseError::AuthInvalid)?;
167
168        let api = path
169            .pop_front()
170            .map(|s| s.as_str().parse())
171            .transpose()
172            .map_err(ParseError::ApiInvalid)?;
173        let method = path.pop_front();
174        let state = path.pop_front();
175        let mut call = None;
176        if let Some(method) = method {
177            let method = method.as_str().parse().map_err(ParseError::MethodInvalid)?;
178            let owned = if let Some(state) = state {
179                Some(state.as_str().parse().map_err(ParseError::StateInvalid)?)
180            } else {
181                None
182            };
183            call = Some(CallState { method, owned });
184        }
185
186        let mut query_params: IndexMap<String, String> = IndexMap::new();
187        if let Some(q) = uri.query() {
188            let params = q.split('&');
189            for p in params {
190                if let Some((k, v)) = p.split_once('=') {
191                    let key = percent_decode(k.as_str().as_bytes())
192                        .decode_utf8_lossy()
193                        .to_string();
194                    let value = percent_decode(v.as_str().as_bytes())
195                        .decode_utf8_lossy()
196                        .to_string();
197                    match query_params.entry(key) {
198                        Entry::Occupied(mut prev) => {
199                            prev.insert(format!("{},{value}", prev.get()));
200                        }
201                        Entry::Vacant(entry) => {
202                            entry.insert(value);
203                        }
204                    }
205                } else {
206                    return Err(ParseError::QueryParamInvalid(p.to_string()));
207                }
208            }
209        }
210
211        let lock = query_params
212            .shift_remove(LOCK)
213            .map(|lock| {
214                let alphabet = Alphabet::new(BAID64_ALPHABET).expect("invalid Baid64 alphabet");
215                let engine = GeneralPurpose::new(
216                    &alphabet,
217                    GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::RequireNone),
218                );
219                let lock = engine
220                    .decode(lock.as_bytes())
221                    .map_err(ParseError::LockInvalidEncoding)?;
222                TinyBlob::try_from(lock).map_err(|_| ParseError::LockTooLong)
223            })
224            .transpose()?;
225
226        let expiry = query_params
227            .shift_remove(EXPIRY)
228            .map(|expiry| DateTime::parse_from_rfc3339(expiry.as_str()).map(|dt| dt.with_timezone(&Utc)))
229            .transpose()?;
230
231        let endpoints = query_params
232            .shift_remove(ENDPOINTS)
233            .unwrap_or_default()
234            .split(ENDPOINT_SEP)
235            .map(Endpoint::from_str)
236            .map(Result::unwrap)
237            .filter(|endpoint| endpoint != &Endpoint::UnspecifiedMeans(s!("")))
238            .take(10)
239            .collect::<Vec<_>>();
240        let endpoints = ConfinedVec::from_checked(endpoints);
241
242        Ok(Self {
243            scope,
244            layer1,
245            api,
246            call,
247            auth,
248            data,
249            lock,
250            expiry,
251            endpoints,
252            unknown_query: query_params,
253        })
254    }
255}
256
257#[derive(Debug, Display, Error, From)]
258#[display(doc_comments)]
259pub enum ParseError<E1: Error, E2: Error> {
260    #[from]
261    #[display(inner)]
262    Uri(fluent_uri::error::ParseError),
263
264    /// invalid contract call request URI scheme '{0}'.
265    SchemeInvalid(String),
266
267    /// contract call request must not contain any URI authority data, including empty one.
268    Authority,
269
270    #[display(inner)]
271    Scope(E1),
272
273    /// absent information about layer 1
274    NoLayer1,
275
276    /// unrecognized layer 1 identifier
277    Layer1,
278
279    /// contract call request scope (first path component) is missed.
280    ScopeMissed,
281
282    /// contract call request path must end with `/`
283    PathLastNoEmpty,
284
285    /// contract call request URI misses the beneficiary authority token.
286    PathNoAuth,
287
288    /// invalid beneficiary authentication token - {0}.
289    AuthInvalid(E2),
290
291    /// invalid API name - {0}.
292    ApiInvalid(InvalidRString),
293
294    /// invalid call method name - {0}.
295    MethodInvalid(InvalidRString),
296
297    /// invalid state method name - {0}.
298    StateInvalid(InvalidRString),
299
300    /// invalid lock data encoding - {0}.
301    LockInvalidEncoding(DecodeError),
302
303    /// Lock data conditions are too long (they must not exceed 256 bytes).
304    LockTooLong,
305
306    #[from]
307    /// invalid expity time - {0}.
308    ExpiryInvalid(chrono::ParseError),
309
310    /// invalid query parameter {0}.
311    QueryParamInvalid(String),
312}
313
314#[cfg(test)]
315mod test {
316    #![cfg_attr(coverage_nightly, coverage(off))]
317
318    use amplify::confinement::Confined;
319    use chrono::TimeZone;
320    use indexmap::indexmap;
321    use ultrasonic::{AuthToken, ContractId};
322
323    use super::*;
324
325    #[test]
326    fn short() {
327        let s = "contract:tb@qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw/10@at:\
328                 5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA/";
329        let req = CallRequest::<ContractId, AuthToken>::from_str(s).unwrap();
330        assert_eq!(s, req.to_string());
331
332        assert_eq!(
333            req.scope,
334            ContractId::from_str("contract:qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw").unwrap()
335        );
336        assert_eq!(req.data, Some(StrictVal::num(10u64)));
337        assert_eq!(req.auth, AuthToken::from_str("at:5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA").unwrap());
338        assert_eq!(req.api, None);
339        assert_eq!(req.call, None);
340        assert_eq!(req.lock, None);
341        assert_eq!(req.expiry, None);
342        assert_eq!(req.endpoints, none!());
343        assert!(req.unknown_query.is_empty());
344    }
345
346    #[test]
347    fn api() {
348        let s = "contract:tb@qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw/RGB20/10@at:\
349                 5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA/";
350        let req = CallRequest::<ContractId, AuthToken>::from_str(s).unwrap();
351        assert_eq!(s, req.to_string());
352
353        assert_eq!(
354            req.scope,
355            ContractId::from_str("contract:qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw").unwrap()
356        );
357        assert_eq!(req.data, Some(StrictVal::num(10u64)));
358        assert_eq!(req.auth, AuthToken::from_str("at:5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA").unwrap());
359        assert_eq!(req.api, Some(tn!("RGB20")));
360        assert_eq!(req.call, None);
361        assert_eq!(req.lock, None);
362        assert_eq!(req.expiry, None);
363        assert_eq!(req.endpoints, none!());
364        assert!(req.unknown_query.is_empty());
365    }
366
367    #[test]
368    fn method() {
369        let s = "contract:tb@qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw/RGB20/transfer/10@at:\
370                 5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA/";
371        let req = CallRequest::<ContractId, AuthToken>::from_str(s).unwrap();
372        assert_eq!(s, req.to_string());
373
374        assert_eq!(
375            req.scope,
376            ContractId::from_str("contract:qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw").unwrap()
377        );
378        assert_eq!(req.data, Some(StrictVal::num(10u64)));
379        assert_eq!(req.auth, AuthToken::from_str("at:5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA").unwrap());
380        assert_eq!(req.api, Some(tn!("RGB20")));
381        assert_eq!(req.call, Some(CallState::new("transfer")));
382        assert_eq!(req.lock, None);
383        assert_eq!(req.expiry, None);
384        assert_eq!(req.endpoints, none!());
385        assert!(req.unknown_query.is_empty());
386    }
387
388    #[test]
389    fn state() {
390        let s = "contract:tb@qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw/RGB20/transfer/amount/10@at:\
391                 5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA/";
392        let req = CallRequest::<ContractId, AuthToken>::from_str(s).unwrap();
393        assert_eq!(s, req.to_string());
394
395        assert_eq!(
396            req.scope,
397            ContractId::from_str("contract:qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw").unwrap()
398        );
399        assert_eq!(req.data, Some(StrictVal::num(10u64)));
400        assert_eq!(req.auth, AuthToken::from_str("at:5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA").unwrap());
401        assert_eq!(req.api, Some(tn!("RGB20")));
402        assert_eq!(req.call, Some(CallState::with("transfer", "amount")));
403        assert_eq!(req.lock, None);
404        assert_eq!(req.expiry, None);
405        assert_eq!(req.endpoints, none!());
406        assert!(req.unknown_query.is_empty());
407    }
408
409    #[test]
410    fn lock() {
411        let s = "contract:tb@qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw/RGB20/transfer/amount/10@at:\
412                 5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA/?lock=A64CDrfmG483";
413        let req = CallRequest::<ContractId, AuthToken>::from_str(s).unwrap();
414        assert_eq!(s, req.to_string());
415
416        assert_eq!(
417            req.scope,
418            ContractId::from_str("contract:qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw").unwrap()
419        );
420        assert_eq!(req.data, Some(StrictVal::num(10u64)));
421        assert_eq!(req.auth, AuthToken::from_str("at:5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA").unwrap());
422        assert_eq!(req.api, Some(tn!("RGB20")));
423        assert_eq!(req.call, Some(CallState::with("transfer", "amount")));
424        assert_eq!(req.lock, Some(TinyBlob::from_checked(vec![3, 174, 2, 14, 183, 230, 27, 143, 55])));
425        assert_eq!(req.expiry, None);
426        assert_eq!(req.endpoints, none!());
427        assert!(req.unknown_query.is_empty());
428    }
429
430    #[test]
431    fn expiry() {
432        let s = "contract:tb@qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw/RGB20/transfer/amount/10@at:\
433                 5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA/?expiry=2021-05-20T08:32:48+00:00";
434        let req = CallRequest::<ContractId, AuthToken>::from_str(s).unwrap();
435        assert_eq!(s, req.to_string());
436
437        assert_eq!(
438            req.scope,
439            ContractId::from_str("contract:qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw").unwrap()
440        );
441        assert_eq!(req.data, Some(StrictVal::num(10u64)));
442        assert_eq!(req.auth, AuthToken::from_str("at:5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA").unwrap());
443        assert_eq!(req.api, Some(tn!("RGB20")));
444        assert_eq!(req.call, Some(CallState::with("transfer", "amount")));
445        assert_eq!(req.lock, None);
446        assert_eq!(req.expiry, Some(Utc.with_ymd_and_hms(2021, 5, 20, 8, 32, 48).unwrap()));
447        assert_eq!(req.endpoints, none!());
448        assert!(req.unknown_query.is_empty());
449    }
450
451    #[test]
452    fn endpoints() {
453        let s = "contract:tb@qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw/RGB20/transfer/amount/10@at:\
454             5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA/?\
455             endpoints=http://127.0.0.1:8080,\
456             https+json-rpc://127.0.0.1:8081,\
457             wss://127.0.0.1:8081,\
458             storm://127.0.0.1:8082,some_bullshit";
459        let req = CallRequest::<ContractId, AuthToken>::from_str(s).unwrap();
460        assert_eq!(s, req.to_string());
461
462        assert_eq!(
463            req.scope,
464            ContractId::from_str("contract:qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw").unwrap()
465        );
466        assert_eq!(req.data, Some(StrictVal::num(10u64)));
467        assert_eq!(req.auth, AuthToken::from_str("at:5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA").unwrap());
468        assert_eq!(req.api, Some(tn!("RGB20")));
469        assert_eq!(req.call, Some(CallState::with("transfer", "amount")));
470        assert_eq!(req.lock, None);
471        assert_eq!(req.expiry, None);
472        assert_eq!(
473            req.endpoints,
474            Confined::from_iter_checked([
475                Endpoint::RestHttp("http://127.0.0.1:8080".to_owned()),
476                Endpoint::JsonRpc("https+json-rpc://127.0.0.1:8081".to_owned()),
477                Endpoint::WebSockets("wss://127.0.0.1:8081".to_owned()),
478                Endpoint::Storm("storm://127.0.0.1:8082".to_owned()),
479                Endpoint::UnspecifiedMeans("some_bullshit".to_owned())
480            ])
481        );
482        assert!(req.unknown_query.is_empty());
483
484        let req = CallRequest::<ContractId, AuthToken>::from_str(
485            "contract:tb@qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw/RGB20/transfer/amount/10@at:\
486             5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA/?\
487             endpoints=http://127.0.0.1:8080,\
488             https+json-rpc://127.0.0.1:8081&\
489             endpoints=wss://127.0.0.1:8081,\
490             storm://127.0.0.1:8082&endpoints=some_bullshit",
491        )
492            .unwrap();
493        assert_eq!(s, req.to_string());
494
495        assert_eq!(
496            req.scope,
497            ContractId::from_str("contract:qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw").unwrap()
498        );
499        assert_eq!(req.data, Some(StrictVal::num(10u64)));
500        assert_eq!(req.auth, AuthToken::from_str("at:5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA").unwrap());
501        assert_eq!(req.api, Some(tn!("RGB20")));
502        assert_eq!(req.call, Some(CallState::with("transfer", "amount")));
503        assert_eq!(req.lock, None);
504        assert_eq!(req.expiry, None);
505        assert_eq!(
506            req.endpoints,
507            Confined::from_iter_checked([
508                Endpoint::RestHttp("http://127.0.0.1:8080".to_owned()),
509                Endpoint::JsonRpc("https+json-rpc://127.0.0.1:8081".to_owned()),
510                Endpoint::WebSockets("wss://127.0.0.1:8081".to_owned()),
511                Endpoint::Storm("storm://127.0.0.1:8082".to_owned()),
512                Endpoint::UnspecifiedMeans("some_bullshit".to_owned())
513            ])
514        );
515        assert!(req.unknown_query.is_empty());
516    }
517
518    #[test]
519    fn unknown_query() {
520        let s = "contract:tb@qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw/RGB20/transfer/amount/10@at:\
521                 5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA/?sats=40&bull=shit&other=x";
522        let req = CallRequest::<ContractId, AuthToken>::from_str(s).unwrap();
523        assert_eq!(s, req.to_string());
524
525        assert_eq!(
526            req.scope,
527            ContractId::from_str("contract:qKpMlzOe-Imn6ysZ-a8JjG2p-WHWvaFm-BWMiPi3-_LvnfRw").unwrap()
528        );
529        assert_eq!(req.data, Some(StrictVal::num(10u64)));
530        assert_eq!(req.auth, AuthToken::from_str("at:5WIb5EMY-RCLbO3Wq-hGdddRP4-IeCQzP1y-S5H_UKzd-ViYmlA").unwrap());
531        assert_eq!(req.api, Some(tn!("RGB20")));
532        assert_eq!(req.call, Some(CallState::with("transfer", "amount")));
533        assert_eq!(req.lock, None);
534        assert_eq!(req.expiry, None);
535        assert_eq!(req.endpoints, none!());
536        assert_eq!(
537            req.unknown_query,
538            indexmap! { s!("sats") => s!("40"), s!("bull") => s!("shit"), s!("other") => s!("x") }
539        );
540    }
541}