Skip to main content

datumesh_core/
envelope.rs

1//! Response envelopes (5.5): the mesh's unsigned statement around a receipt.
2
3use crate::error::{err, ErrorCode, Result};
4use crate::json::{self, Value};
5use crate::keys::Keys;
6use crate::lexical::{self, limits};
7use crate::receipt::Receipt;
8use crate::schema;
9
10/// The eight outcomes of 5.5.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub enum Outcome {
13    /// 200: the receipt answers the cell as of the requested `asof`: its `asof` is at or
14    /// before the requested one, and the mesh asserts it is the cell's latest version known
15    /// as of then.
16    Ok,
17    /// 200: as `Ok`, except that the mesh cannot assert the receipt is current; it serves the
18    /// last receipt it knows.
19    Stale,
20    /// 404: no receipt for this cell. With a `fault` (`spec-0.4.0`, see
21    /// [`check_envelope_extras`]), the cell is a computed cell whose evaluation faulted.
22    NotCovered,
23    /// 403: the requester may not receive this cell.
24    Unlicensed,
25    /// 402: available for the quoted price.
26    PaymentRequired,
27    /// 429: too many requests.
28    RateLimited,
29    /// 400: the cell URI does not parse.
30    BadRequest,
31    /// 5xx: the mesh failed.
32    Error,
33}
34
35impl Outcome {
36    /// The outcome as written in JSON.
37    pub const fn as_str(self) -> &'static str {
38        match self {
39            Outcome::Ok => "ok",
40            Outcome::Stale => "stale",
41            Outcome::NotCovered => "not_covered",
42            Outcome::Unlicensed => "unlicensed",
43            Outcome::PaymentRequired => "payment_required",
44            Outcome::RateLimited => "rate_limited",
45            Outcome::BadRequest => "bad_request",
46            Outcome::Error => "error",
47        }
48    }
49}
50
51/// The mesh's trust label (5.5).
52#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
53pub enum Trust {
54    /// Signed by the namespace's host, with no inputs.
55    Primary,
56    /// The mesh says it recomputed the receipt.
57    Verified,
58    /// Neither.
59    Unverified,
60}
61
62impl Trust {
63    /// The label as written in JSON.
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Trust::Primary => "primary",
67            Trust::Verified => "verified",
68            Trust::Unverified => "unverified",
69        }
70    }
71}
72
73/// A formula evaluation fault (SPEC 5.5, 15.11; `spec-0.4.0`): the optional `fault` member
74/// of a `not_covered` envelope. The cell has no value, deterministically; this is not
75/// missing data. These are formula codes, not verification codes, so they are not
76/// [`ErrorCode`]s.
77#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
78#[non_exhaustive]
79pub enum Fault {
80    /// `E_FORMULA_DIV_ZERO`
81    DivZero,
82    /// `E_FORMULA_DOMAIN`
83    Domain,
84    /// `E_FORMULA_RANGE`
85    Range,
86    /// `E_FORMULA_EMPTY`
87    Empty,
88    /// `E_FORMULA_ABSENT`
89    Absent,
90    /// `E_FORMULA_FUTURE`
91    Future,
92}
93
94impl Fault {
95    /// Every fault an envelope may carry, in the order of SPEC 5.5.
96    pub const ALL: &'static [Fault] =
97        &[Fault::DivZero, Fault::Domain, Fault::Range, Fault::Empty, Fault::Absent, Fault::Future];
98
99    /// The code as written, e.g. `"E_FORMULA_DIV_ZERO"`.
100    pub const fn as_str(self) -> &'static str {
101        match self {
102            Fault::DivZero => "E_FORMULA_DIV_ZERO",
103            Fault::Domain => "E_FORMULA_DOMAIN",
104            Fault::Range => "E_FORMULA_RANGE",
105            Fault::Empty => "E_FORMULA_EMPTY",
106            Fault::Absent => "E_FORMULA_ABSENT",
107            Fault::Future => "E_FORMULA_FUTURE",
108        }
109    }
110
111    /// Looks a fault up by its code; `None` for any other text (a budget, a cycle or too deep
112    /// an evaluation is not a fault but the mesh's failure, SPEC 5.5).
113    pub fn from_code(text: &str) -> Option<Fault> {
114        Fault::ALL.iter().copied().find(|f| f.as_str() == text)
115    }
116}
117
118impl core::fmt::Display for Fault {
119    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
120        f.write_str(self.as_str())
121    }
122}
123
124/// What failed in reaching a foreign mesh (SPEC 5.5, `spec-0.4.3`): the `detail` member, required
125/// with `E_FOREIGN_UNAVAILABLE` ([`ErrorCode::ForeignUnavailable`]) and forbidden in every other
126/// envelope. The list is closed; it is non-exhaustive only so that later revisions can add to
127/// it.
128#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
129#[non_exhaustive]
130pub enum ForeignDetail {
131    /// `offline`: the asked party has no network, or is configured not to use it.
132    Offline,
133    /// `network`: any other network failure.
134    Network,
135    /// `dns`: the foreign host's name did not resolve.
136    Dns,
137    /// `tls`: the TLS handshake or the certificate failed.
138    Tls,
139    /// `timeout`.
140    Timeout,
141    /// `refused`: the connection was refused.
142    Refused,
143}
144
145impl ForeignDetail {
146    /// Every detail, in the order of SPEC 5.5.
147    pub const ALL: &'static [ForeignDetail] = &[
148        ForeignDetail::Offline,
149        ForeignDetail::Network,
150        ForeignDetail::Dns,
151        ForeignDetail::Tls,
152        ForeignDetail::Timeout,
153        ForeignDetail::Refused,
154    ];
155
156    /// The detail as written in JSON, e.g. `"offline"`.
157    pub const fn as_str(self) -> &'static str {
158        match self {
159            ForeignDetail::Offline => "offline",
160            ForeignDetail::Network => "network",
161            ForeignDetail::Dns => "dns",
162            ForeignDetail::Tls => "tls",
163            ForeignDetail::Timeout => "timeout",
164            ForeignDetail::Refused => "refused",
165        }
166    }
167
168    /// Looks a detail up by its JSON name; `None` for anything else (case included).
169    pub fn from_name(text: &str) -> Option<ForeignDetail> {
170        ForeignDetail::ALL.iter().copied().find(|d| d.as_str() == text)
171    }
172}
173
174impl core::fmt::Display for ForeignDetail {
175    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
176        f.write_str(self.as_str())
177    }
178}
179
180/// The members of a checked envelope that [`Envelope`] has no field for, because they were
181/// added to SPEC 5.5 after it was frozen (0.3.0): see [`check_envelope_extras`].
182#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
183#[non_exhaustive]
184pub struct EnvelopeExtras {
185    /// The formula evaluation fault of a `not_covered` envelope (`spec-0.4.0`).
186    pub fault: Option<Fault>,
187    /// The function the answer is of: the echo of a pin, or the hash the name resolved to
188    /// (`spec-0.4.2`).
189    pub fn_hash: Option<String>,
190    /// What failed in reaching a foreign mesh, with `E_FOREIGN_UNAVAILABLE` (`spec-0.4.3`).
191    pub detail: Option<ForeignDetail>,
192}
193
194/// A price quote (`payment_required`).
195#[derive(Clone, Debug, PartialEq, Eq, Hash)]
196pub struct Quote {
197    /// A canonical decimal greater than zero.
198    pub amount: String,
199    /// A currency code from `units.md`.
200    pub currency: String,
201    /// Where to pay: an https URL.
202    pub authorise: String,
203}
204
205/// What the client asked for, for the binding of 5.5 step 5.
206#[derive(Clone, Debug, PartialEq, Eq, Hash)]
207pub struct Request {
208    pub(crate) cell_id: String,
209    pub(crate) asof: Option<String>,
210    pub(crate) job: Option<String>,
211    pub(crate) pin: Option<String>,
212    pub(crate) mesh: Option<String>,
213}
214
215/// A malformed request description (5.5 step 5): the caller's mistake, not an `E_*` code.
216#[derive(Clone, Debug, PartialEq, Eq, Hash)]
217pub struct InvalidRequest(String);
218
219impl core::fmt::Display for InvalidRequest {
220    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
221        write!(f, "invalid request: {}", self.0)
222    }
223}
224
225impl std::error::Error for InvalidRequest {}
226
227impl Request {
228    /// A request for the cell with id `cell_id` (64 lowercase hex digits) as of `asof` (an
229    /// instant) or now. Anything else is [`InvalidRequest`]: SPEC 5.5 makes a malformed
230    /// request the caller's error, reported outside the `E_*` codes.
231    pub fn new(cell_id: &str, asof: Option<&str>) -> core::result::Result<Request, InvalidRequest> {
232        if lexical::check_hex(cell_id, 64, "").is_err() {
233            return Err(InvalidRequest(format!("cell_id must be 64 lowercase hex digits: {cell_id:?}")));
234        }
235        if let Some(a) = asof {
236            if !lexical::is_instant(a) {
237                return Err(InvalidRequest(format!("asof must be an instant: {a:?}")));
238            }
239        }
240        Ok(Request { cell_id: cell_id.into(), asof: asof.map(String::from), job: None, pin: None, mesh: None })
241    }
242
243    /// The request description for `GET` of a job's `poll` URL (5.5, `spec-0.4.1`): the
244    /// original request (`cell_id`, `asof`) and the job polled, 32 to 64 lowercase hex digits
245    /// (otherwise [`InvalidRequest`]). With it, an envelope may be `bad_request` with
246    /// `E_JOB_UNKNOWN`, and a `pending` one must carry this `job`; any other answer is bound
247    /// to the original request, as a `/cell` answer is.
248    pub fn with_job(cell_id: &str, asof: Option<&str>, job: &str) -> core::result::Result<Request, InvalidRequest> {
249        if !is_job(job) {
250            return Err(InvalidRequest(format!("job must be 32 to 64 lowercase hex digits: {job:?}")));
251        }
252        let mut r = Request::new(cell_id, asof)?;
253        r.job = Some(job.into());
254        Ok(r)
255    }
256
257    /// The same request, pinned to the function `fn_hash` (64 lowercase hex digits; otherwise
258    /// [`InvalidRequest`]): the answer must echo it (5.5, `spec-0.4.2`). `cell_id` is then the
259    /// id of the cell of that function.
260    pub fn with_pin(self, fn_hash: &str) -> core::result::Result<Request, InvalidRequest> {
261        if lexical::check_hex(fn_hash, 64, "").is_err() {
262            return Err(InvalidRequest(format!("fn_hash must be 64 lowercase hex digits: {fn_hash:?}")));
263        }
264        Ok(Request { pin: Some(fn_hash.into()), ..self })
265    }
266
267    /// The same request, naming the host it was sent to (a DNS name; otherwise
268    /// [`InvalidRequest`]): `computed` is then checked against the receipt's signer, and
269    /// `poll` against the host (5.5, `spec-0.4.2`). Without it those checks are the caller's.
270    pub fn with_mesh(self, host: &str) -> core::result::Result<Request, InvalidRequest> {
271        if lexical::check_dns_name(host, "").is_err() {
272            return Err(InvalidRequest(format!("mesh must be a DNS name: {host:?}")));
273        }
274        Ok(Request { mesh: Some(host.into()), ..self })
275    }
276
277    /// The pin, for a pinned request ([`Request::with_pin`]).
278    pub fn fn_hash(&self) -> Option<&str> {
279        self.pin.as_deref()
280    }
281
282    /// The host the request was sent to, when given ([`Request::with_mesh`]).
283    pub fn mesh(&self) -> Option<&str> {
284        self.mesh.as_deref()
285    }
286
287    /// The job polled, for a request made at a job URL ([`Request::with_job`]).
288    pub fn job(&self) -> Option<&str> {
289        self.job.as_deref()
290    }
291
292    /// The requested cell id.
293    pub fn cell_id(&self) -> &str {
294        &self.cell_id
295    }
296
297    /// The requested `asof`, if any.
298    pub fn asof(&self) -> Option<&str> {
299        self.asof.as_deref()
300    }
301}
302
303/// A checked envelope.
304#[derive(Clone, Debug, PartialEq, Eq)]
305pub struct Envelope {
306    /// The outcome.
307    pub outcome: Outcome,
308    /// The receipt (`ok`, `stale`), signature-valid and bound to the request.
309    pub receipt: Option<Receipt>,
310    /// The trust label (`ok`, `stale`).
311    pub trust: Option<Trust>,
312    /// Seconds since the mesh last confirmed the receipt (`stale`).
313    pub age_seconds: Option<u64>,
314    /// Hash of a newer receipt for the same cell.
315    pub superseded_by: Option<String>,
316    /// Why the receipt was superseded.
317    pub reason: Option<String>,
318    /// The quote (`payment_required`).
319    pub quote: Option<Quote>,
320    /// Seconds to wait (`rate_limited`).
321    pub retry_after_seconds: Option<u64>,
322    /// The failure code (`bad_request`, `error`).
323    pub error: Option<ErrorCode>,
324}
325
326pub(crate) const BAD_REQUEST_CODES: [ErrorCode; 15] = [
327    ErrorCode::ParseUri,
328    ErrorCode::CanonName,
329    ErrorCode::CanonArgName,
330    ErrorCode::CanonArgDuplicate,
331    ErrorCode::CanonArgOrder,
332    ErrorCode::CanonPercent,
333    ErrorCode::CanonType,
334    ErrorCode::CanonNumber,
335    ErrorCode::CanonDate,
336    ErrorCode::CanonInstant,
337    ErrorCode::CanonString,
338    ErrorCode::CanonBlob,
339    ErrorCode::CanonLimit,
340    ErrorCode::ArgUnknown,
341    ErrorCode::ArgMissing,
342];
343
344pub(crate) fn env_err<T>(path: &str, detail: impl Into<String>) -> Result<T> {
345    err(ErrorCode::Envelope, path, detail)
346}
347
348/// `0` or `[1-9][0-9]*`, at most 2^53 - 1, judged on the number's text.
349pub(crate) fn integer_member(v: &Value, path: &str) -> Result<u64> {
350    let Some(t) = v.as_number_text() else {
351        return env_err(path, "must be a non-negative integer");
352    };
353    let ok =
354        !t.is_empty() && t.bytes().all(|c| c.is_ascii_digit()) && (t == "0" || !t.starts_with('0')) && t.len() <= 16;
355    match (ok, t.parse::<u64>()) {
356        (true, Ok(x)) if x <= limits::BLOB_LENGTH => Ok(x),
357        _ => env_err(path, format!("must be written 0|[1-9][0-9]* and at most 2^53 - 1, not {t}")),
358    }
359}
360
361/// Checks a response envelope (5.5) in the order of its procedure: strict JSON, members,
362/// HTTP status, the receipt through step 6 of 10.1, the binding to `request`, then `trust`
363/// and `superseded_by`. Never touches the network. A `not_covered` envelope's `fault`
364/// (`spec-0.4.0`) is checked and accepted; [`check_envelope_extras`] also returns it.
365pub fn check_envelope(status: u16, body: &[u8], keys: &Keys, request: &Request) -> Result<Envelope> {
366    check_envelope_extras(status, body, keys, request).map(|(env, _)| env)
367}
368
369/// [`check_envelope`], also returning the members [`Envelope`] has no field for
370/// ([`EnvelopeExtras`]): the `fault` of a `not_covered` envelope, one of the six formula
371/// evaluation faults of SPEC 5.5 (`E_ENVELOPE` for any other value, or on another outcome).
372///
373/// Its [`Envelope`] has no `pending` outcome, so a `pending` envelope is `E_ENVELOPE` here, as
374/// in 0.3.4; [`check_envelope_any`] accepts it. Nor has its [`Trust`] `computed` (`spec-0.4.2`):
375/// an envelope with it is `E_ENVELOPE` here and in [`check_envelope_any`], as in 0.3.5;
376/// [`crate::check_answer`] accepts it. Everything else of `spec-0.4.2` (the `fn_hash` echo, in
377/// [`EnvelopeExtras`]; `not_covered`'s `error`; the new codes) is checked as there, and so is
378/// `spec-0.4.3`'s `E_FOREIGN_UNAVAILABLE` (502, with its `detail` in [`EnvelopeExtras`]).
379pub fn check_envelope_extras(
380    status: u16,
381    body: &[u8],
382    keys: &Keys,
383    request: &Request,
384) -> Result<(Envelope, EnvelopeExtras)> {
385    crate::answer::check_answer(status, body, keys, request)?.into_envelope()
386}
387
388/// Checks a response envelope (5.5) of any of the nine outcomes, `pending` included
389/// (`spec-0.4.1`), in the order of its procedure, and binds it to `request`: a `pending`
390/// envelope's echoed request to the request made (and its `job` to the job polled, when
391/// `request` has one), any other outcome to the original request's cell id and `asof`,
392/// whether it answers the `/cell` request or the job URL. `E_JOB_UNKNOWN` is accepted only in a
393/// `bad_request` answering a job URL (`request` with a job). Never touches the network.
394pub fn check_envelope_any(status: u16, body: &[u8], keys: &Keys, request: &Request) -> Result<AnyEnvelope> {
395    let a = crate::answer::check_answer(status, body, keys, request)?;
396    if let Some(p) = a.pending {
397        return Ok(AnyEnvelope::Pending(p));
398    }
399    a.into_envelope().map(|(e, x)| AnyEnvelope::Envelope(e, x))
400}
401
402/// `[0-9a-f]{32,64}`.
403pub(crate) fn is_job(s: &str) -> bool {
404    (32..=64).contains(&s.len()) && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
405}
406
407/// The answer to a request: a [`Pending`] envelope, or one of the eight outcomes an
408/// [`Envelope`] holds, with its [`EnvelopeExtras`] (see [`check_envelope_any`]).
409// Not boxed: one value per checked response, and matching on it stays plain.
410#[allow(clippy::large_enum_variant)]
411#[derive(Clone, Debug, PartialEq, Eq)]
412#[non_exhaustive]
413pub enum AnyEnvelope {
414    /// `ok`, `stale`, `not_covered`, `unlicensed`, `payment_required`, `rate_limited`,
415    /// `bad_request` or `error`.
416    Envelope(Envelope, EnvelopeExtras),
417    /// `pending` (202): the mesh is still computing; the answer will be at `poll`.
418    Pending(Pending),
419}
420
421impl AnyEnvelope {
422    /// The outcome as written in JSON, `"pending"` included.
423    pub fn outcome_str(&self) -> &'static str {
424        match self {
425            AnyEnvelope::Envelope(e, _) => e.outcome.as_str(),
426            AnyEnvelope::Pending(_) => "pending",
427        }
428    }
429}
430
431/// A `pending` envelope (5.5, `spec-0.4.1`): the job, an estimate, the URL to poll and the
432/// original request echoed. Build one with [`Pending::new`] (a mesh), or get one from
433/// [`check_envelope_any`] (a client).
434#[derive(Clone, Debug, PartialEq, Eq, Hash)]
435#[non_exhaustive]
436pub struct Pending {
437    /// The job id: 32 to 64 lowercase hex digits, a capability.
438    pub job: String,
439    /// The mesh's estimate of the seconds until the result is ready; a hint.
440    pub estimate_seconds: u64,
441    /// The job's URL: `https://` and printable ASCII, at most 512 characters, ending
442    /// `/jobs/<job>`.
443    pub poll: String,
444    /// The original request, echoed: its `cell_id`, `asof` and pin (never a job or a mesh).
445    pub request: Request,
446}
447
448impl Pending {
449    /// The HTTP status of a `pending` envelope.
450    pub const STATUS: u16 = 202;
451
452    /// A `pending` envelope answering `request` (its `cell_id`, `asof` and pin are echoed; a
453    /// job or mesh in it is not), checked by the rules of 5.5: `job` 32 to 64 lowercase hex digits,
454    /// `estimate_seconds` at most 2^53 - 1, `poll` an `https://` URL of printable ASCII of at
455    /// most 512 characters whose host (up to the first `/`) is a DNS name (2.1), ending
456    /// `/jobs/<job>`. That `poll` names the mesh's own host is the caller's to ensure. A failure
457    /// is `E_ENVELOPE`, at the member's path.
458    pub fn new(job: &str, estimate_seconds: u64, poll: &str, request: Request) -> Result<Pending> {
459        check_job_and_poll(job, poll)?;
460        if estimate_seconds > limits::BLOB_LENGTH {
461            return env_err("$.estimate_seconds", "estimate_seconds is at most 2^53 - 1");
462        }
463        let request = Request { job: None, mesh: None, ..request };
464        Ok(Pending { job: job.into(), estimate_seconds, poll: poll.into(), request })
465    }
466
467    /// The JSON form of the envelope.
468    pub fn to_value(&self) -> Value {
469        // Value has no public number constructor: the canonical text, parsed.
470        json::parse(&self.to_json()).unwrap_or(Value::Null)
471    }
472
473    /// The envelope's body: its canonical JSON text (JCS), served with [`Pending::STATUS`].
474    pub fn to_json(&self) -> Vec<u8> {
475        let mut out = String::new();
476        out.push_str("{\"estimate_seconds\":");
477        out.push_str(&self.estimate_seconds.to_string());
478        out.push_str(",\"job\":");
479        json::write_string(&self.job, &mut out);
480        out.push_str(",\"outcome\":\"pending\",\"poll\":");
481        json::write_string(&self.poll, &mut out);
482        out.push_str(",\"request\":{");
483        if let Some(a) = &self.request.asof {
484            out.push_str("\"asof\":");
485            json::write_string(a, &mut out);
486            out.push(',');
487        }
488        out.push_str("\"cell_id\":");
489        json::write_string(&self.request.cell_id, &mut out);
490        if let Some(p) = &self.request.pin {
491            out.push_str(",\"fn_hash\":");
492            json::write_string(p, &mut out);
493        }
494        out.push_str("}}");
495        out.into_bytes()
496    }
497}
498
499pub(crate) fn check_job_and_poll(job: &str, poll: &str) -> Result<()> {
500    if !is_job(job) {
501        return env_err("$.job", "job is 32 to 64 lowercase hex digits");
502    }
503    let url_ok =
504        poll.strip_prefix("https://").is_some_and(|r| !r.is_empty() && r.bytes().all(|c| (0x21..=0x7E).contains(&c)))
505            && poll.len() <= limits::LICENCE_URL;
506    if !url_ok {
507        return env_err("$.poll", "poll is an https URL of printable ASCII, at most 512 characters");
508    }
509    // The text between https:// and the next / is a DNS name (2.1; 3f79313).
510    let host = poll["https://".len()..].split('/').next().unwrap_or("");
511    if !poll["https://".len()..].contains('/') || lexical::check_dns_name(host, "").is_err() {
512        return env_err("$.poll", "poll's host is a DNS name");
513    }
514    if !poll.ends_with(&format!("/jobs/{job}")) {
515        return env_err("$.poll", "poll ends /jobs/<job>");
516    }
517    Ok(())
518}
519
520pub(crate) fn quote(q: &Value) -> Result<Quote> {
521    let m = schema::object_or(q, "$.quote", ErrorCode::Envelope, "quote")?;
522    schema::exact(m, &["amount", "authorise", "currency"], &[], "$.quote", ErrorCode::Envelope)?;
523    let amount = schema::string_or(m, "amount", "$.quote", ErrorCode::Envelope)?;
524    let authorise = schema::string_or(m, "authorise", "$.quote", ErrorCode::Envelope)?;
525    let currency = schema::string_or(m, "currency", "$.quote", ErrorCode::Envelope)?;
526    if lexical::check_number(amount, "").is_err() || amount.starts_with('-') || amount == "0" {
527        return env_err("$.quote.amount", "amount is a canonical decimal greater than zero");
528    }
529    let url_ok = authorise
530        .strip_prefix("https://")
531        .is_some_and(|r| !r.is_empty() && r.bytes().all(|c| (0x21..=0x7E).contains(&c)))
532        && authorise.len() <= limits::LICENCE_URL;
533    if !url_ok {
534        return env_err("$.quote.authorise", "authorise is an https URL");
535    }
536    if !lexical::is_currency(currency) {
537        return env_err("$.quote.currency", "currency is a currency code from units.md");
538    }
539    Ok(Quote { amount: amount.into(), currency: currency.into(), authorise: authorise.into() })
540}