1use 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 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 SchemeInvalid(String),
266
267 Authority,
269
270 #[display(inner)]
271 Scope(E1),
272
273 NoLayer1,
275
276 Layer1,
278
279 ScopeMissed,
281
282 PathLastNoEmpty,
284
285 PathNoAuth,
287
288 AuthInvalid(E2),
290
291 ApiInvalid(InvalidRString),
293
294 MethodInvalid(InvalidRString),
296
297 StateInvalid(InvalidRString),
299
300 LockInvalidEncoding(DecodeError),
302
303 LockTooLong,
305
306 #[from]
307 ExpiryInvalid(chrono::ParseError),
309
310 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}