Skip to main content

canton_token/
registry.rs

1//! The registry's off-ledger HTTP API.
2//!
3//! Every path and payload here comes from the token standard's OpenAPI
4//! documents — `token-metadata-v1`, `transfer-instruction-v1`,
5//! `allocation-v1`, `allocation-instruction-v1`, `transfer-instruction-v2`,
6//! `allocation-v2`, `allocation-instruction-v2` (vendored under
7//! `testdata/openapi/`) — and the method names follow their `operationId`s,
8//! so a reader can match one to the other.
9//!
10//! # What the registry is for
11//!
12//! Two things. It *describes* the instruments it issues (the metadata API),
13//! and it supplies the **choice context** for exercising a token-standard
14//! choice: the reference data and the contracts a participant must be shown.
15//! A client cannot assemble that itself — the contracts are the registry's own
16//! and usually invisible to the submitting party.
17
18use canton_core::{Error, Result};
19use serde::{Deserialize, Serialize};
20
21use crate::context::{ChoiceContext, ChoiceContextRequest, WireChoiceContext};
22
23/// A client for one registry's off-ledger API.
24/// How long to wait on a registry that accepts the connection and then says
25/// nothing. `reqwest`'s own default is unbounded, which turns an unresponsive
26/// registry into a transfer that never returns.
27const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
28
29/// A client for one registry's off-ledger API.
30#[derive(Clone, Debug)]
31pub struct RegistryClient {
32    base: reqwest::Url,
33    http: reqwest::Client,
34}
35
36/// What a registry says about itself.
37#[derive(Clone, Debug, Deserialize)]
38#[serde(rename_all = "camelCase")]
39#[non_exhaustive]
40pub struct RegistryInfo {
41    /// The party that administers the instruments — the `expectedAdmin` a
42    /// factory choice is exercised against.
43    pub admin_id: String,
44    /// Which token-standard APIs this registry implements.
45    #[serde(default)]
46    pub supported_apis: std::collections::BTreeMap<String, i32>,
47}
48
49/// An instrument the registry issues.
50#[derive(Clone, Debug, Deserialize)]
51#[serde(rename_all = "camelCase")]
52#[non_exhaustive]
53pub struct Instrument {
54    /// The instrument id, as it appears in a `Holding`.
55    pub id: String,
56    /// Human-readable name.
57    pub name: String,
58    /// Ticker-style symbol.
59    pub symbol: String,
60    /// Total supply, when the registry publishes one.
61    #[serde(default)]
62    pub total_supply: Option<String>,
63    /// When `total_supply` was measured.
64    #[serde(default)]
65    pub total_supply_as_of: Option<String>,
66    /// How many decimal places amounts of this instrument carry.
67    ///
68    /// The standard marks this required with a default of 10, so an absent
69    /// field means ten places — not "unknown". Reporting it as unknown pushes a
70    /// display-precision decision about money onto a caller who has no better
71    /// information than this default.
72    #[serde(default = "default_decimals")]
73    pub decimals: i32,
74    /// Which token-standard APIs apply to this instrument.
75    #[serde(default)]
76    pub supported_apis: std::collections::BTreeMap<String, i32>,
77    /// A paused instrument cannot be transferred or allocated (metadata-v1
78    /// 1.2.0). Absent on older registries, which is the same as `false`.
79    #[serde(default)]
80    pub paused: bool,
81    /// Why, and until when, if the registry says.
82    #[serde(default)]
83    pub pause_info: Option<PauseInfo>,
84    /// For V2 wallets: which `Account` input fields this instrument's users
85    /// must fill in — `provider`, `accountId`. Absent means the registry did
86    /// not say, not that there are none.
87    #[serde(default)]
88    pub account_input_fields_to_show: Option<Vec<String>>,
89}
90
91/// Why an instrument is paused, and until when.
92#[derive(Clone, Debug, Deserialize)]
93#[serde(rename_all = "camelCase")]
94#[non_exhaustive]
95pub struct PauseInfo {
96    /// Free text from the registry.
97    #[serde(default)]
98    pub reason: Option<String>,
99    /// RFC 3339, when the pause is scheduled to end.
100    #[serde(default)]
101    pub until: Option<String>,
102}
103
104/// The standard's default for `decimals`.
105fn default_decimals() -> i32 {
106    10
107}
108
109#[derive(Debug, Deserialize)]
110#[serde(rename_all = "camelCase")]
111struct ListInstrumentsResponse {
112    #[serde(default)]
113    instruments: Vec<Instrument>,
114    #[serde(default)]
115    next_page_token: Option<String>,
116}
117
118/// How a transfer will be carried out, as the registry decides.
119///
120/// Not a caller's choice: it follows from whether the receiver has pre-approved
121/// direct transfers, and whether sender and receiver are the same party. A
122/// client that assumes `Direct` and gets `Offer` has a transfer that is
123/// pending someone else's acceptance, which is a different workflow.
124#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize)]
125#[serde(rename_all = "lowercase")]
126#[non_exhaustive]
127pub enum TransferKind {
128    /// Sender and receiver are the same party; no approval, usually immediate.
129    #[serde(rename = "self")]
130    SelfTransfer,
131    /// The receiver has pre-approved direct transfers, so it completes at once.
132    Direct,
133    /// The receiver is offered the transfer and it completes only if accepted.
134    Offer,
135    /// A kind this build does not know.
136    ///
137    /// Without this, a registry that adds a fourth kind makes the whole factory
138    /// response fail to decode and *every* transfer stop — including the ones
139    /// whose kind is perfectly understood. `#[non_exhaustive]` promises a
140    /// caller their match will keep compiling; this is what makes the promise
141    /// reach runtime.
142    #[serde(other)]
143    Unknown,
144}
145
146/// A factory contract together with the context for exercising its choice.
147#[derive(Clone, Debug)]
148#[non_exhaustive]
149pub struct FactoryWithContext {
150    /// The contract id of the contract implementing the factory interface.
151    pub factory_id: String,
152    /// How the transfer will be carried out. `None` for factories whose API
153    /// does not classify the workflow — the allocation factory, for one.
154    pub transfer_kind: Option<TransferKind>,
155    /// The context for the factory's choice.
156    pub context: ChoiceContext,
157}
158
159/// `TransferFactoryWithChoiceContext` — `transferKind` is `required` here.
160#[derive(Debug, Deserialize)]
161#[serde(rename_all = "camelCase")]
162struct WireTransferFactory {
163    factory_id: String,
164    transfer_kind: TransferKind,
165    choice_context: WireChoiceContext,
166}
167
168/// `FactoryWithChoiceContext` — the allocation API has no `transferKind` at
169/// all. Kept separate from the transfer factory so that "this API does not
170/// classify the workflow" and "a registry omitted a required field" cannot
171/// arrive as the same value.
172#[derive(Debug, Deserialize)]
173#[serde(rename_all = "camelCase")]
174struct WireFactory {
175    factory_id: String,
176    choice_context: WireChoiceContext,
177}
178
179/// A request for a factory.
180///
181/// `choice_arguments` is the choice as the Daml JSON API encodes it, **with
182/// `extraArgs.context` and `extraArgs.meta` empty** — the standard says so
183/// explicitly, and it is what lets a registry tailor the context to the actual
184/// arguments (the configuration for one instrument id, say).
185///
186/// So the argument is a *generated* choice type, serialized. Nothing here
187/// hand-writes the JSON.
188#[derive(Debug, Serialize)]
189#[serde(rename_all = "camelCase")]
190struct GetFactoryRequest<'a> {
191    choice_arguments: &'a serde_json::Value,
192    #[serde(skip_serializing_if = "std::ops::Not::not")]
193    exclude_debug_fields: bool,
194}
195
196impl RegistryClient {
197    /// A client for the registry served at `base_url`.
198    ///
199    /// The URL is parsed here rather than at the first request, so a typo is a
200    /// configuration error the caller sees immediately — and not, as it was, a
201    /// `Connection` failure that the SDK reports as retriable and an
202    /// application retries forever.
203    ///
204    /// # Errors
205    /// [`Error::InvalidRequest`] if `base_url` is not a URL, or an HTTP client
206    /// cannot be built.
207    pub fn new(base_url: &str) -> Result<Self> {
208        // No redirects: what the registry answers is put into commands the
209        // caller signs, so it must come from the registry the caller named,
210        // not from wherever a 3xx points.
211        let http = reqwest::Client::builder()
212            .timeout(DEFAULT_TIMEOUT)
213            .redirect(reqwest::redirect::Policy::none())
214            .build()
215            .map_err(|e| Error::InvalidRequest(format!("cannot build an HTTP client: {e}")))?;
216        Self::with_http_client(base_url, http)
217    }
218
219    /// A client using an HTTP client the caller has already configured —
220    /// timeouts, proxies, a custom TLS root store. Configure it with
221    /// `reqwest::redirect::Policy::none()`, as [`Self::new`] does: a registry
222    /// answer reached through a redirect is not the registry's.
223    ///
224    /// # Errors
225    /// [`Error::InvalidRequest`] if `base_url` is not a URL.
226    pub fn with_http_client(base_url: &str, http: reqwest::Client) -> Result<Self> {
227        // A trailing slash matters to `Url::join`, and the paths below are
228        // relative, so it is normalised here once.
229        let normalised = format!("{}/", base_url.trim_end_matches('/'));
230        let base = reqwest::Url::parse(&normalised).map_err(|e| {
231            Error::InvalidRequest(format!("`{base_url}` is not a registry URL: {e}"))
232        })?;
233        Ok(Self { base, http })
234    }
235
236    /// Build a URL from path segments.
237    ///
238    /// Every segment is **percent-encoded**. Interpolating them into a string
239    /// let a caller-supplied id change which resource was addressed: an
240    /// instrument id of `a?b` fetched instrument `a` and returned its name,
241    /// symbol and decimals with no error at all, and `../../info` walked out of
242    /// the collection entirely. Instrument ids are admin-assigned free-form
243    /// strings, so nothing upstream rules that out.
244    fn url(&self, segments: &[&str]) -> Result<reqwest::Url> {
245        let mut url = self.base.clone();
246        url.path_segments_mut()
247            .map_err(|()| Error::InvalidRequest("the registry URL cannot have a path".to_string()))?
248            .pop_if_empty()
249            .extend(segments);
250        Ok(url)
251    }
252
253    /// The registry's own description of itself — including `admin_id`, which
254    /// is the `expectedAdmin` a factory choice names.
255    ///
256    /// # Errors
257    /// As any HTTP call, plus [`Error::UnexpectedResponse`] on a body that does
258    /// not match the standard.
259    pub async fn info(&self) -> Result<RegistryInfo> {
260        self.get(self.url(&["registry", "metadata", "v1", "info"])?)
261            .await
262    }
263
264    /// One page of the instruments this registry issues.
265    ///
266    /// Returns the page and the token for the next one, or `None` at the end.
267    ///
268    /// # Errors
269    /// As [`info`](Self::info).
270    pub async fn list_instruments(
271        &self,
272        page_size: Option<u32>,
273        page_token: Option<&str>,
274    ) -> Result<(Vec<Instrument>, Option<String>)> {
275        let mut url = self.url(&["registry", "metadata", "v1", "instruments"])?;
276        {
277            // Encoded, not formatted: a page token is an instrument id by the
278            // standard's own definition, so one containing `&` would otherwise
279            // split into extra query parameters.
280            let mut query = url.query_pairs_mut();
281            if let Some(size) = page_size {
282                query.append_pair("pageSize", &size.to_string());
283            }
284            if let Some(token) = page_token {
285                query.append_pair("pageToken", token);
286            }
287        }
288        let response: ListInstrumentsResponse = self.get(url).await?;
289        Ok((response.instruments, response.next_page_token))
290    }
291
292    /// One instrument by id, or `None` if this registry does not issue it.
293    ///
294    /// # Errors
295    /// As [`info`](Self::info). A `404` is not an error here — not issuing an
296    /// instrument is an answer — but only when the *registry* is the one
297    /// answering it. A `404` whose body is not JSON did not come from a
298    /// registry handler at all: it is a base URL one path component off, or a
299    /// gateway in front of one, and reporting that as "this registry does not
300    /// issue it" is the worst of both worlds. A wallet polling for an
301    /// instrument would see a steady, quiet `None` while every other call on
302    /// the same client failed loudly.
303    pub async fn instrument(&self, instrument_id: &str) -> Result<Option<Instrument>> {
304        let url = self.url(&["registry", "metadata", "v1", "instruments", instrument_id])?;
305        let response = self
306            .http
307            .get(url.clone())
308            .send()
309            .await
310            .map_err(|e| connection(&e))?;
311        if response.status() == reqwest::StatusCode::NOT_FOUND {
312            // A failed body read on a 404 is not "the registry does not issue
313            // it": it is a transport failure, and reporting it as `None`
314            // would tell a wallet the instrument does not exist.
315            let body = read_body(response, ERROR_BODY_CAP, url.as_str()).await?;
316            let trimmed = body.trim();
317            if trimmed.is_empty() || serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
318                return Ok(None);
319            }
320            // Not the registry's own 404 — a proxy or a wrong base URL. The
321            // body stays the body; the URL goes to the log, where it is
322            // already, rather than into a field the error model reads as JSON.
323            tracing::warn!(%url, "a 404 that is not the registry's own — check the base URL");
324            return Err(Error::http_at(404, truncate(trimmed), url.as_str()));
325        }
326        Ok(Some(Self::decode(response, url.as_str()).await?))
327    }
328
329    /// Resolve the transfer factory for a transfer, with its choice context.
330    ///
331    /// `choice_arguments` is a serialized `TransferFactory_Transfer` with empty
332    /// `extraArgs` — see [`crate::transfer()`], which builds it.
333    ///
334    /// # Errors
335    /// As [`info`](Self::info).
336    pub async fn transfer_factory(
337        &self,
338        choice_arguments: &serde_json::Value,
339    ) -> Result<FactoryWithContext> {
340        let url = self.url(&["registry", "transfer-instruction", "v1", "transfer-factory"])?;
341        let wire: WireTransferFactory = self.post_factory(url, choice_arguments).await?;
342        Ok(FactoryWithContext {
343            factory_id: wire.factory_id,
344            transfer_kind: Some(wire.transfer_kind),
345            context: ChoiceContext::from_wire(wire.choice_context)?,
346        })
347    }
348
349    /// Resolve the allocation factory, with its choice context.
350    ///
351    /// # Errors
352    /// As [`info`](Self::info).
353    pub async fn allocation_factory(
354        &self,
355        choice_arguments: &serde_json::Value,
356    ) -> Result<FactoryWithContext> {
357        let url = self.url(&[
358            "registry",
359            "allocation-instruction",
360            "v1",
361            "allocation-factory",
362        ])?;
363        let wire: WireFactory = self.post_factory(url, choice_arguments).await?;
364        Ok(FactoryWithContext {
365            factory_id: wire.factory_id,
366            transfer_kind: None,
367            context: ChoiceContext::from_wire(wire.choice_context)?,
368        })
369    }
370
371    // ---- CIP-0112 (V2) ------------------------------------------------------
372    //
373    // Separate methods rather than a version flag on the ones above: the V2
374    // paths are not the V1 paths with a digit changed, and the shapes differ
375    // too. Allocation V2 has no `execute-transfer` context at all — settlement
376    // moved to a factory — and allocation-instruction V2 gained `accept` and
377    // `withdraw`, which V1 has not. A flag would have made those differences
378    // silent.
379
380    /// Resolve the **V2** transfer factory, with its choice context.
381    ///
382    /// `POST /registry/transfer-instruction/v2/transfer-factory`
383    ///
384    /// # Errors
385    /// As [`info`](Self::info).
386    pub async fn transfer_factory_v2(
387        &self,
388        choice_arguments: &serde_json::Value,
389    ) -> Result<FactoryWithContext> {
390        let url = self.url(&["registry", "transfer-instruction", "v2", "transfer-factory"])?;
391        let wire: WireTransferFactory = self.post_factory(url, choice_arguments).await?;
392        Ok(FactoryWithContext {
393            factory_id: wire.factory_id,
394            transfer_kind: Some(wire.transfer_kind),
395            context: ChoiceContext::from_wire(wire.choice_context)?,
396        })
397    }
398
399    /// Resolve the **V2** allocation factory.
400    ///
401    /// `POST /registry/allocation-instruction/v2/allocation-factory`
402    ///
403    /// # Errors
404    /// As [`info`](Self::info).
405    pub async fn allocation_factory_v2(
406        &self,
407        choice_arguments: &serde_json::Value,
408    ) -> Result<FactoryWithContext> {
409        let url = self.url(&[
410            "registry",
411            "allocation-instruction",
412            "v2",
413            "allocation-factory",
414        ])?;
415        let wire: WireFactory = self.post_factory(url, choice_arguments).await?;
416        Ok(FactoryWithContext {
417            factory_id: wire.factory_id,
418            transfer_kind: None,
419            context: ChoiceContext::from_wire(wire.choice_context)?,
420        })
421    }
422
423    /// Resolve the **settlement factory** — how a V2 allocation is settled.
424    ///
425    /// `POST /registry/allocation/v2/settlement-factory`
426    ///
427    /// Note the **singular** `allocation` here against the plural
428    /// `allocations` of the choice-context paths below. That is what the
429    /// specification says, inconsistent as it looks, and a client that
430    /// regularises it gets a 404 from a registry that is working correctly.
431    ///
432    /// V1 has no equivalent: there, an allocation is executed through a choice
433    /// context on the allocation itself. V2 settles a *batch* through this
434    /// factory instead, which is what lets both legs of a delivery-versus-
435    /// payment settle together.
436    ///
437    /// # Errors
438    /// As [`info`](Self::info).
439    pub async fn settlement_factory_v2(
440        &self,
441        choice_arguments: &serde_json::Value,
442    ) -> Result<FactoryWithContext> {
443        let url = self.url(&["registry", "allocation", "v2", "settlement-factory"])?;
444        let wire: WireFactory = self.post_factory(url, choice_arguments).await?;
445        Ok(FactoryWithContext {
446            factory_id: wire.factory_id,
447            transfer_kind: None,
448            context: ChoiceContext::from_wire(wire.choice_context)?,
449        })
450    }
451
452    /// The **V2** context for a choice on an existing transfer instruction.
453    ///
454    /// `POST /registry/transfer-instruction/v2/{id}/choice-contexts/{choice}`
455    ///
456    /// # Errors
457    /// As [`info`](Self::info).
458    pub async fn transfer_instruction_context_v2(
459        &self,
460        transfer_instruction_id: &str,
461        choice: TransferInstructionChoice,
462        request: &ChoiceContextRequest,
463    ) -> Result<ChoiceContext> {
464        self.choice_context(
465            self.url(&[
466                "registry",
467                "transfer-instruction",
468                "v2",
469                transfer_instruction_id,
470                "choice-contexts",
471                choice.path(),
472            ])?,
473            request,
474        )
475        .await
476    }
477
478    /// The **V2** context for a choice on an existing allocation.
479    ///
480    /// `POST /registry/allocations/v2/{id}/choice-contexts/{choice}` — plural,
481    /// unlike the settlement factory above.
482    ///
483    /// # Errors
484    /// As [`info`](Self::info), plus [`Error::InvalidRequest`] for
485    /// [`AllocationChoice::ExecuteTransfer`], which V2 does not have: settling
486    /// goes through [`settlement_factory_v2`](Self::settlement_factory_v2).
487    /// Refusing locally names the reason; the registry would answer 404.
488    pub async fn allocation_context_v2(
489        &self,
490        allocation_id: &str,
491        choice: AllocationChoice,
492        request: &ChoiceContextRequest,
493    ) -> Result<ChoiceContext> {
494        if choice == AllocationChoice::ExecuteTransfer {
495            return Err(Error::InvalidRequest(
496                "V2 allocations are settled through the settlement factory, not an \
497                 execute-transfer context — see `settlement_factory_v2`"
498                    .to_string(),
499            ));
500        }
501        self.choice_context(
502            self.url(&[
503                "registry",
504                "allocations",
505                "v2",
506                allocation_id,
507                "choice-contexts",
508                choice.path(),
509            ])?,
510            request,
511        )
512        .await
513    }
514
515    /// The **V2** context for a choice on an allocation *instruction*.
516    ///
517    /// `POST /registry/allocation-instruction/v2/{id}/choice-contexts/{choice}`
518    ///
519    /// V1 has no such endpoint — an allocation instruction there is driven
520    /// entirely through the factory.
521    ///
522    /// # Errors
523    /// As [`info`](Self::info).
524    pub async fn allocation_instruction_context_v2(
525        &self,
526        allocation_instruction_id: &str,
527        choice: AllocationInstructionChoice,
528        request: &ChoiceContextRequest,
529    ) -> Result<ChoiceContext> {
530        self.choice_context(
531            self.url(&[
532                "registry",
533                "allocation-instruction",
534                "v2",
535                allocation_instruction_id,
536                "choice-contexts",
537                choice.path(),
538            ])?,
539            request,
540        )
541        .await
542    }
543
544    /// The context for a choice on an existing transfer instruction.
545    ///
546    /// # Errors
547    /// As [`info`](Self::info).
548    pub async fn transfer_instruction_context(
549        &self,
550        transfer_instruction_id: &str,
551        choice: TransferInstructionChoice,
552        request: &ChoiceContextRequest,
553    ) -> Result<ChoiceContext> {
554        self.choice_context(
555            self.url(&[
556                "registry",
557                "transfer-instruction",
558                "v1",
559                transfer_instruction_id,
560                "choice-contexts",
561                choice.path(),
562            ])?,
563            request,
564        )
565        .await
566    }
567
568    /// The context for a choice on an existing allocation.
569    ///
570    /// # Errors
571    /// As [`info`](Self::info).
572    pub async fn allocation_context(
573        &self,
574        allocation_id: &str,
575        choice: AllocationChoice,
576        request: &ChoiceContextRequest,
577    ) -> Result<ChoiceContext> {
578        self.choice_context(
579            self.url(&[
580                "registry",
581                "allocations",
582                "v1",
583                allocation_id,
584                "choice-contexts",
585                choice.path(),
586            ])?,
587            request,
588        )
589        .await
590    }
591
592    async fn post_factory<T: serde::de::DeserializeOwned>(
593        &self,
594        url: reqwest::Url,
595        choice_arguments: &serde_json::Value,
596    ) -> Result<T> {
597        let response = self
598            .http
599            .post(url.clone())
600            .json(&GetFactoryRequest {
601                choice_arguments,
602                // The debug fields are explicitly untrustworthy unless the
603                // provider is trusted, and nothing here reads them, so they are
604                // bytes on the wire for no purpose.
605                exclude_debug_fields: true,
606            })
607            .send()
608            .await
609            .map_err(|e| connection(&e))?;
610        Self::decode(response, url.as_str()).await
611    }
612
613    async fn choice_context(
614        &self,
615        url: reqwest::Url,
616        request: &ChoiceContextRequest,
617    ) -> Result<ChoiceContext> {
618        let response = self
619            .http
620            .post(url.clone())
621            .json(request)
622            .send()
623            .await
624            .map_err(|e| connection(&e))?;
625        let wire: WireChoiceContext = Self::decode(response, url.as_str()).await?;
626        ChoiceContext::from_wire(wire)
627    }
628
629    async fn get<T: serde::de::DeserializeOwned>(&self, url: reqwest::Url) -> Result<T> {
630        let response = self
631            .http
632            .get(url.clone())
633            .send()
634            .await
635            .map_err(|e| connection(&e))?;
636        Self::decode(response, url.as_str()).await
637    }
638
639    /// One place where a non-success status becomes an error and a body becomes
640    /// a value, so every call reports failure the same way.
641    async fn decode<T: serde::de::DeserializeOwned>(
642        response: reqwest::Response,
643        url: &str,
644    ) -> Result<T> {
645        let status = response.status();
646        let cap = if status.is_success() {
647            MAX_RESPONSE_BYTES
648        } else {
649            ERROR_BODY_CAP
650        };
651        let body = read_body(response, cap, url).await?;
652        if status == reqwest::StatusCode::CONFLICT {
653            // Every token-standard document defines 409 the same way: a
654            // contract the reply would name is in the middle of a
655            // reassignment between synchronizers. It resolves by itself in
656            // seconds, so this is the one non-2xx that is transient, and the
657            // shared error model's HTTP rule (408, 429, 5xx) would call it
658            // permanent. Reported as a connection-class error, which retries.
659            let message = serde_json::from_str::<ErrorResponse>(&body)
660                .map(|e| e.error)
661                .unwrap_or(body);
662            return Err(Error::Connection(format!(
663                "{url}: the registry reports a contract mid-reassignment (409), retry shortly: {}",
664                truncate(&message)
665            )));
666        }
667        if !status.is_success() {
668            // The standard's error body is `{"error": "…"}`; anything else is
669            // passed through, since a registry that is failing is exactly when
670            // a truncated-to-nothing message helps least. The body is the
671            // body — `canton-core` reads `Error::Http::body` as the response
672            // (for a category, a retry delay, a correlation id), and a URL
673            // prefixed onto it made every one of those reads fail. The URL is
674            // in the log line instead.
675            tracing::warn!(%url, status = status.as_u16(), "the registry answered with an error");
676            let message = serde_json::from_str::<ErrorResponse>(&body)
677                .map(|e| e.error)
678                .unwrap_or(body);
679            return Err(Error::http_at(status.as_u16(), truncate(&message), url));
680        }
681        serde_json::from_str(&body).map_err(|e| {
682            Error::UnexpectedResponse(format!(
683                "{url} returned a body this does not understand: {e}"
684            ))
685        })
686    }
687}
688
689#[derive(Deserialize)]
690struct ErrorResponse {
691    error: String,
692}
693
694/// Turn a transport failure into an error a caller can act on.
695///
696/// Two things this must get right, and the first version got neither.
697///
698/// **The message.** A `reqwest::Error` prints only its outer sentence —
699/// `error sending request for url (…)` — and keeps what actually went wrong in
700/// its source chain. Reporting only the outer message hides the one line that
701/// says *certificate*, or *dns*, or *connection refused*.
702///
703/// **The verdict.** [`Error::Connection`] is unconditionally retriable, so
704/// mapping everything to it makes an application that loops on `is_retriable()`
705/// retry forever on a condition no amount of waiting fixes — a certificate it
706/// The most a registry answer may be. A choice context carries the disclosed
707/// contracts' `createdEventBlob`s, so it is kilobytes to a few megabytes;
708/// nothing the standard defines is larger, and a registry (or whatever answers
709/// in its place) does not get to size the client's memory.
710const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
711/// How much of an error body is kept for the message.
712const ERROR_BODY_CAP: usize = 64 * 1024;
713
714/// Read a response body, refusing one that declares or grows past `cap`.
715/// `reqwest`'s `text()` reads to the end whatever the size; this reads chunk
716/// by chunk, so the bound holds for a chunked stream without `Content-Length`.
717async fn read_body(mut response: reqwest::Response, cap: usize, url: &str) -> Result<String> {
718    if let Some(declared) = response
719        .content_length()
720        .filter(|declared| usize::try_from(*declared).map_or(true, |n| n > cap))
721    {
722        {
723            return Err(Error::UnexpectedResponse(format!(
724                "{url}: the registry answered with a {declared}-byte body, above the {cap}-byte limit"
725            )));
726        }
727    }
728    let mut buffer: Vec<u8> = Vec::new();
729    while let Some(chunk) = response.chunk().await.map_err(|e| connection(&e))? {
730        if buffer.len() + chunk.len() > cap {
731            return Err(Error::UnexpectedResponse(format!(
732                "{url}: the registry answered with a body above the {cap}-byte limit"
733            )));
734        }
735        buffer.extend_from_slice(&chunk);
736    }
737    String::from_utf8(buffer).map_err(|e| {
738        Error::UnexpectedResponse(format!(
739            "{url}: the registry answered with a body that is not UTF-8: {e}"
740        ))
741    })
742}
743
744/// cannot verify, a URL it cannot build, a redirect loop. Those are reported as
745/// [`Error::InvalidRequest`], which is not retriable.
746fn connection(e: &reqwest::Error) -> Error {
747    let kind = if e.is_timeout() {
748        Transport::Timeout
749    } else if e.is_builder() {
750        Transport::Builder
751    } else if e.is_redirect() {
752        Transport::Redirect
753    } else if e.is_decode() {
754        Transport::Decode
755    } else {
756        Transport::Connect
757    };
758    classify_transport(kind, &canton_core::chain(e))
759}
760
761/// What `reqwest` says about a failure, by type, in the order the verdict
762/// reads them. Separated from the error so the verdict below can be tested on
763/// the strings it reads, which are the only place a TLS failure shows.
764#[derive(Clone, Copy, Debug, PartialEq, Eq)]
765enum Transport {
766    Timeout,
767    Builder,
768    Redirect,
769    Decode,
770    /// Everything else: the connection itself failed, and the chain says how.
771    Connect,
772}
773
774fn classify_transport(kind: Transport, detail: &str) -> Error {
775    match kind {
776        // The 30s default this client sets. Retriable, and `Error::Timeout`
777        // says why without the caller reading the message.
778        Transport::Timeout => return Error::Timeout,
779        Transport::Builder | Transport::Redirect => {
780            return Error::InvalidRequest(format!(
781                "the registry request could not be made: {detail}"
782            ));
783        }
784        Transport::Decode => {
785            return Error::UnexpectedResponse(format!(
786                "the registry's reply could not be read: {detail}"
787            ));
788        }
789        Transport::Connect => {}
790    }
791    // A TLS failure arrives as a connect error, indistinguishable by type from
792    // a refused connection — reqwest exposes no typed TLS error — so the chain
793    // is the only place the difference shows. Worth the string match: a
794    // certificate the client cannot verify is permanent, and retrying it
795    // forever is the failure this whole function exists to avoid.
796    let lower = detail.to_ascii_lowercase();
797    if lower.contains("certificate") || lower.contains("tls handshake") {
798        return Error::InvalidRequest(format!(
799            "the registry's TLS certificate could not be verified: {detail}"
800        ));
801    }
802    Error::Connection(format!("cannot reach the registry: {detail}"))
803}
804
805/// The choices a transfer instruction offers, as the registry paths name them.
806#[derive(Clone, Copy, Debug, Eq, PartialEq)]
807#[non_exhaustive]
808pub enum TransferInstructionChoice {
809    /// The receiver accepts an offered transfer.
810    Accept,
811    /// The receiver rejects it.
812    Reject,
813    /// The sender withdraws it.
814    Withdraw,
815}
816
817impl TransferInstructionChoice {
818    fn path(self) -> &'static str {
819        match self {
820            Self::Accept => "accept",
821            Self::Reject => "reject",
822            Self::Withdraw => "withdraw",
823        }
824    }
825}
826
827/// The choices a V2 allocation *instruction* offers.
828///
829/// V1 has no equivalent: an allocation instruction there is driven through the
830/// factory and has no choice contexts of its own.
831#[derive(Clone, Copy, Debug, Eq, PartialEq)]
832#[non_exhaustive]
833pub enum AllocationInstructionChoice {
834    /// The registry accepts a pending instruction.
835    Accept,
836    /// The sender withdraws it.
837    Withdraw,
838}
839
840impl AllocationInstructionChoice {
841    fn path(self) -> &'static str {
842        match self {
843            Self::Accept => "accept",
844            Self::Withdraw => "withdraw",
845        }
846    }
847}
848
849/// The choices an allocation offers.
850#[derive(Clone, Copy, Debug, Eq, PartialEq)]
851#[non_exhaustive]
852pub enum AllocationChoice {
853    /// The executor settles the allocated transfer.
854    ExecuteTransfer,
855    /// The sender withdraws the allocation.
856    Withdraw,
857    /// The executor cancels it.
858    Cancel,
859}
860
861impl AllocationChoice {
862    fn path(self) -> &'static str {
863        match self {
864            Self::ExecuteTransfer => "execute-transfer",
865            Self::Withdraw => "withdraw",
866            Self::Cancel => "cancel",
867        }
868    }
869}
870
871/// A body excerpt short enough for an error message. An HTML error page is
872/// otherwise long enough to bury the sentence that names the problem.
873fn truncate(body: &str) -> String {
874    const LIMIT: usize = 200;
875    if body.len() <= LIMIT {
876        return body.to_string();
877    }
878    let cut = body
879        .char_indices()
880        .map(|(i, _)| i)
881        .take_while(|i| *i <= LIMIT)
882        .last()
883        .unwrap_or(0);
884    format!("{}…", &body[..cut])
885}
886
887#[cfg(test)]
888#[allow(clippy::unwrap_used, clippy::expect_used)]
889mod tests {
890    use super::*;
891
892    /// The paths are the standard's, spelled as its `operationId`s imply. A
893    /// wrong one is a 404 from a registry that is working perfectly.
894    #[test]
895    fn the_choice_paths_are_the_ones_the_standard_publishes() {
896        assert_eq!(TransferInstructionChoice::Accept.path(), "accept");
897        assert_eq!(TransferInstructionChoice::Reject.path(), "reject");
898        assert_eq!(TransferInstructionChoice::Withdraw.path(), "withdraw");
899        assert_eq!(AllocationChoice::ExecuteTransfer.path(), "execute-transfer");
900        assert_eq!(AllocationChoice::Withdraw.path(), "withdraw");
901        assert_eq!(AllocationChoice::Cancel.path(), "cancel");
902    }
903
904    /// `self` is a Rust keyword and the wire spells it exactly that, so the
905    /// rename is load-bearing: without it the variant would never match.
906    #[test]
907    fn the_transfer_kinds_decode_from_the_words_the_wire_uses() {
908        let kinds: Vec<TransferKind> =
909            serde_json::from_str(r#"["self", "direct", "offer"]"#).expect("decodes");
910        assert_eq!(
911            kinds,
912            [
913                TransferKind::SelfTransfer,
914                TransferKind::Direct,
915                TransferKind::Offer
916            ]
917        );
918    }
919
920    /// A trailing slash on the base URL would otherwise produce `//registry/…`,
921    /// which some servers route and others do not.
922    #[test]
923    fn a_trailing_slash_on_the_base_url_does_not_double_up() {
924        let client = RegistryClient::new("https://scan.example.com/").unwrap();
925        assert_eq!(
926            client
927                .url(&["registry", "metadata", "v1", "info"])
928                .unwrap()
929                .as_str(),
930            "https://scan.example.com/registry/metadata/v1/info"
931        );
932    }
933
934    /// A path segment from outside must not be able to change which resource is
935    /// addressed. Formatted into a string, an instrument id of `a?b` fetched
936    /// instrument `a` and returned its name, symbol and decimals with no error
937    /// — and `../../info` walked out of the collection entirely.
938    #[test]
939    fn a_hostile_path_segment_cannot_redirect_the_request() {
940        let client = RegistryClient::new("https://scan.example.com").unwrap();
941        for hostile in ["a?b", "a#b", "../../info", "a/b"] {
942            let url = client
943                .url(&["registry", "metadata", "v1", "instruments", hostile])
944                .unwrap();
945            assert_eq!(
946                url.query(),
947                None,
948                "`{hostile}` must not become a query: {url}"
949            );
950            assert_eq!(
951                url.path_segments().map(Iterator::count),
952                Some(5),
953                "`{hostile}` must stay one segment: {url}"
954            );
955            assert!(
956                url.as_str()
957                    .starts_with("https://scan.example.com/registry/metadata/v1/instruments/"),
958                "`{hostile}` escaped the collection: {url}"
959            );
960        }
961    }
962
963    /// A base URL that is not a URL is a configuration mistake, and it has to
964    /// arrive as one. Left to surface from the first request it became
965    /// `Error::Connection`, which the SDK reports as retriable — so an
966    /// application would retry a typo forever.
967    #[test]
968    fn a_base_url_that_is_not_a_url_is_refused_immediately() {
969        let err = RegistryClient::new("not a url").expect_err("not a URL");
970        assert!(!err.is_retriable(), "a typo is not worth retrying: {err}");
971        assert!(err.to_string().contains("not a url"), "{err}");
972    }
973
974    #[test]
975    fn the_factory_request_is_the_shape_the_standard_specifies() {
976        let args = serde_json::json!({ "expectedAdmin": "dso::1220", "transfer": {} });
977        let request = GetFactoryRequest {
978            choice_arguments: &args,
979            exclude_debug_fields: true,
980        };
981        assert_eq!(
982            serde_json::to_value(&request).unwrap(),
983            serde_json::json!({
984                "choiceArguments": { "expectedAdmin": "dso::1220", "transfer": {} },
985                "excludeDebugFields": true
986            })
987        );
988    }
989
990    /// The response is read by the field names the standard uses, and the
991    /// factory's context is translated on the way through.
992    #[test]
993    fn a_factory_response_decodes_and_its_context_is_translated() {
994        let json = serde_json::json!({
995            "factoryId": "00factory",
996            "transferKind": "direct",
997            "choiceContext": {
998                "choiceContextData": { "values": {} },
999                "disclosedContracts": [{
1000                    "templateId": "pkg:Splice.AmuletRules:AmuletRules",
1001                    "contractId": "001",
1002                    "createdEventBlob": "AQI=",
1003                    "synchronizerId": "sync::1220ab"
1004                }]
1005            }
1006        });
1007        let wire: WireTransferFactory = serde_json::from_value(json).expect("decodes");
1008        assert_eq!(wire.factory_id, "00factory");
1009        assert_eq!(wire.transfer_kind, TransferKind::Direct);
1010
1011        let context = ChoiceContext::from_wire(wire.choice_context).expect("translates");
1012        assert_eq!(context.disclosed_contracts().len(), 1);
1013        assert_eq!(context.disclosed_contracts()[0].created_event_blob, [1, 2]);
1014    }
1015
1016    /// The allocation factory's response has no `transferKind` at all, and it
1017    /// is a different type for that reason: "this API does not classify the
1018    /// workflow" and "a registry omitted a field its spec requires" must not
1019    /// arrive as the same value.
1020    #[test]
1021    fn the_allocation_factory_response_has_no_transfer_kind() {
1022        let json = serde_json::json!({
1023            "factoryId": "00factory",
1024            "choiceContext": { "choiceContextData": {}, "disclosedContracts": [] }
1025        });
1026        let wire: WireFactory = serde_json::from_value(json).expect("decodes");
1027        assert_eq!(wire.factory_id, "00factory");
1028
1029        // And the transfer factory refuses the same body, because its spec
1030        // marks the field required.
1031        let json = serde_json::json!({
1032            "factoryId": "00factory",
1033            "choiceContext": { "choiceContextData": {}, "disclosedContracts": [] }
1034        });
1035        assert!(serde_json::from_value::<WireTransferFactory>(json).is_err());
1036    }
1037
1038    /// A registry that adds a fourth kind must not break the transfers whose
1039    /// kind this build understands perfectly well.
1040    #[test]
1041    fn an_unknown_transfer_kind_decodes_instead_of_failing_the_response() {
1042        let json = serde_json::json!({
1043            "factoryId": "00factory",
1044            "transferKind": "escrow-something-new",
1045            "choiceContext": { "choiceContextData": {}, "disclosedContracts": [] }
1046        });
1047        let wire: WireTransferFactory =
1048            serde_json::from_value(json).expect("an unknown kind is a value, not a decode failure");
1049        assert_eq!(wire.transfer_kind, TransferKind::Unknown);
1050    }
1051
1052    /// The standard marks `decimals` required with a default of ten, so an
1053    /// absent field means ten places — not "unknown". It is what a wallet
1054    /// formats an amount with.
1055    #[test]
1056    fn an_absent_decimals_is_the_standards_default_and_not_unknown() {
1057        let instrument: Instrument = serde_json::from_value(serde_json::json!({
1058            "id": "Amulet", "name": "Canton Coin", "symbol": "CC"
1059        }))
1060        .expect("decodes");
1061        assert_eq!(instrument.decimals, 10);
1062    }
1063
1064    #[test]
1065    fn instruments_decode_with_the_optional_fields_absent() {
1066        let json = serde_json::json!({
1067            "instruments": [{ "id": "Amulet", "name": "Canton Coin", "symbol": "CC" }]
1068        });
1069        let response: ListInstrumentsResponse = serde_json::from_value(json).expect("decodes");
1070        assert_eq!(response.instruments.len(), 1);
1071        assert_eq!(response.instruments[0].id, "Amulet");
1072        assert_eq!(response.instruments[0].total_supply, None);
1073        assert_eq!(response.instruments[0].decimals, 10);
1074        assert_eq!(response.next_page_token, None);
1075    }
1076
1077    /// The transport verdict, on the strings it has to read. A certificate
1078    /// failure is permanent whichever of the two phrasings the chain carries;
1079    /// a refused connection is not.
1080    #[test]
1081    fn a_tls_failure_is_permanent_under_either_phrasing() {
1082        for detail in [
1083            "error sending request: invalid peer certificate: UnknownIssuer",
1084            "error sending request: tls handshake eof",
1085            "CERTIFICATE verify failed",
1086        ] {
1087            let error = classify_transport(Transport::Connect, detail);
1088            assert!(
1089                matches!(error, Error::InvalidRequest(_)),
1090                "{detail}: {error:?}"
1091            );
1092            assert!(!error.is_retriable(), "{detail}");
1093            assert!(error.to_string().contains("TLS certificate"), "{error}");
1094        }
1095        let refused = classify_transport(Transport::Connect, "connection refused (os error 61)");
1096        assert!(matches!(refused, Error::Connection(_)), "{refused:?}");
1097        assert!(refused.is_retriable());
1098        assert!(
1099            refused.to_string().contains("connection refused"),
1100            "{refused}"
1101        );
1102    }
1103
1104    /// Each typed verdict on its own: a timeout is `Timeout`, an unbuildable
1105    /// request or a redirect loop is permanent, a body that does not read is
1106    /// an unexpected response.
1107    #[test]
1108    fn the_typed_verdicts_are_read_before_the_strings() {
1109        let timeout = classify_transport(
1110            Transport::Timeout,
1111            "certificate", // would otherwise be read as permanent
1112        );
1113        assert!(matches!(timeout, Error::Timeout), "{timeout:?}");
1114
1115        for kind in [Transport::Builder, Transport::Redirect] {
1116            let error = classify_transport(kind, "x");
1117            assert!(
1118                matches!(&error, Error::InvalidRequest(m) if m.contains("could not be made")),
1119                "{kind:?}: {error:?}"
1120            );
1121        }
1122
1123        let decode = classify_transport(Transport::Decode, "x");
1124        assert!(matches!(decode, Error::UnexpectedResponse(_)), "{decode:?}");
1125    }
1126
1127    /// The excerpt boundary: exactly the limit is left alone, one byte over is
1128    /// cut and marked, and the cut never splits a character.
1129    #[test]
1130    fn truncate_cuts_past_the_limit_and_on_a_character_boundary() {
1131        let exact = "x".repeat(200);
1132        assert_eq!(truncate(&exact), exact);
1133
1134        let over = "x".repeat(201);
1135        let cut = truncate(&over);
1136        assert!(cut.ends_with('…'), "{cut}");
1137        assert_eq!(cut.trim_end_matches('…').len(), 200);
1138
1139        // 199 ASCII bytes, then a three-byte character straddling the limit.
1140        let straddle = format!("{}€€", "x".repeat(199));
1141        let cut = truncate(&straddle);
1142        assert!(cut.ends_with('…'), "{cut}");
1143        assert!(cut.starts_with(&"x".repeat(199)), "{cut}");
1144    }
1145}