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        let http = reqwest::Client::builder()
209            .timeout(DEFAULT_TIMEOUT)
210            .build()
211            .map_err(|e| Error::InvalidRequest(format!("cannot build an HTTP client: {e}")))?;
212        Self::with_http_client(base_url, http)
213    }
214
215    /// A client using an HTTP client the caller has already configured —
216    /// timeouts, proxies, a custom TLS root store.
217    ///
218    /// # Errors
219    /// [`Error::InvalidRequest`] if `base_url` is not a URL.
220    pub fn with_http_client(base_url: &str, http: reqwest::Client) -> Result<Self> {
221        // A trailing slash matters to `Url::join`, and the paths below are
222        // relative, so it is normalised here once.
223        let normalised = format!("{}/", base_url.trim_end_matches('/'));
224        let base = reqwest::Url::parse(&normalised).map_err(|e| {
225            Error::InvalidRequest(format!("`{base_url}` is not a registry URL: {e}"))
226        })?;
227        Ok(Self { base, http })
228    }
229
230    /// Build a URL from path segments.
231    ///
232    /// Every segment is **percent-encoded**. Interpolating them into a string
233    /// let a caller-supplied id change which resource was addressed: an
234    /// instrument id of `a?b` fetched instrument `a` and returned its name,
235    /// symbol and decimals with no error at all, and `../../info` walked out of
236    /// the collection entirely. Instrument ids are admin-assigned free-form
237    /// strings, so nothing upstream rules that out.
238    fn url(&self, segments: &[&str]) -> Result<reqwest::Url> {
239        let mut url = self.base.clone();
240        url.path_segments_mut()
241            .map_err(|()| Error::InvalidRequest("the registry URL cannot have a path".to_string()))?
242            .pop_if_empty()
243            .extend(segments);
244        Ok(url)
245    }
246
247    /// The registry's own description of itself — including `admin_id`, which
248    /// is the `expectedAdmin` a factory choice names.
249    ///
250    /// # Errors
251    /// As any HTTP call, plus [`Error::UnexpectedResponse`] on a body that does
252    /// not match the standard.
253    pub async fn info(&self) -> Result<RegistryInfo> {
254        self.get(self.url(&["registry", "metadata", "v1", "info"])?)
255            .await
256    }
257
258    /// One page of the instruments this registry issues.
259    ///
260    /// Returns the page and the token for the next one, or `None` at the end.
261    ///
262    /// # Errors
263    /// As [`info`](Self::info).
264    pub async fn list_instruments(
265        &self,
266        page_size: Option<u32>,
267        page_token: Option<&str>,
268    ) -> Result<(Vec<Instrument>, Option<String>)> {
269        let mut url = self.url(&["registry", "metadata", "v1", "instruments"])?;
270        {
271            // Encoded, not formatted: a page token is an instrument id by the
272            // standard's own definition, so one containing `&` would otherwise
273            // split into extra query parameters.
274            let mut query = url.query_pairs_mut();
275            if let Some(size) = page_size {
276                query.append_pair("pageSize", &size.to_string());
277            }
278            if let Some(token) = page_token {
279                query.append_pair("pageToken", token);
280            }
281        }
282        let response: ListInstrumentsResponse = self.get(url).await?;
283        Ok((response.instruments, response.next_page_token))
284    }
285
286    /// One instrument by id, or `None` if this registry does not issue it.
287    ///
288    /// # Errors
289    /// As [`info`](Self::info). A `404` is not an error here — not issuing an
290    /// instrument is an answer — but only when the *registry* is the one
291    /// answering it. A `404` whose body is not JSON did not come from a
292    /// registry handler at all: it is a base URL one path component off, or a
293    /// gateway in front of one, and reporting that as "this registry does not
294    /// issue it" is the worst of both worlds. A wallet polling for an
295    /// instrument would see a steady, quiet `None` while every other call on
296    /// the same client failed loudly.
297    pub async fn instrument(&self, instrument_id: &str) -> Result<Option<Instrument>> {
298        let url = self.url(&["registry", "metadata", "v1", "instruments", instrument_id])?;
299        let response = self
300            .http
301            .get(url.clone())
302            .send()
303            .await
304            .map_err(|e| connection(&e))?;
305        if response.status() == reqwest::StatusCode::NOT_FOUND {
306            // A failed body read on a 404 is not "the registry does not issue
307            // it": it is a transport failure, and reporting it as `None`
308            // would tell a wallet the instrument does not exist.
309            let body = response.text().await.map_err(|e| connection(&e))?;
310            let trimmed = body.trim();
311            if trimmed.is_empty() || serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
312                return Ok(None);
313            }
314            // Not the registry's own 404 — a proxy or a wrong base URL. The
315            // body stays the body; the URL goes to the log, where it is
316            // already, rather than into a field the error model reads as JSON.
317            tracing::warn!(%url, "a 404 that is not the registry's own — check the base URL");
318            return Err(Error::http_at(404, truncate(trimmed), url.as_str()));
319        }
320        Ok(Some(Self::decode(response, url.as_str()).await?))
321    }
322
323    /// Resolve the transfer factory for a transfer, with its choice context.
324    ///
325    /// `choice_arguments` is a serialized `TransferFactory_Transfer` with empty
326    /// `extraArgs` — see [`crate::transfer()`], which builds it.
327    ///
328    /// # Errors
329    /// As [`info`](Self::info).
330    pub async fn transfer_factory(
331        &self,
332        choice_arguments: &serde_json::Value,
333    ) -> Result<FactoryWithContext> {
334        let url = self.url(&["registry", "transfer-instruction", "v1", "transfer-factory"])?;
335        let wire: WireTransferFactory = self.post_factory(url, choice_arguments).await?;
336        Ok(FactoryWithContext {
337            factory_id: wire.factory_id,
338            transfer_kind: Some(wire.transfer_kind),
339            context: ChoiceContext::from_wire(wire.choice_context)?,
340        })
341    }
342
343    /// Resolve the allocation factory, with its choice context.
344    ///
345    /// # Errors
346    /// As [`info`](Self::info).
347    pub async fn allocation_factory(
348        &self,
349        choice_arguments: &serde_json::Value,
350    ) -> Result<FactoryWithContext> {
351        let url = self.url(&[
352            "registry",
353            "allocation-instruction",
354            "v1",
355            "allocation-factory",
356        ])?;
357        let wire: WireFactory = self.post_factory(url, choice_arguments).await?;
358        Ok(FactoryWithContext {
359            factory_id: wire.factory_id,
360            transfer_kind: None,
361            context: ChoiceContext::from_wire(wire.choice_context)?,
362        })
363    }
364
365    // ---- CIP-0112 (V2) ------------------------------------------------------
366    //
367    // Separate methods rather than a version flag on the ones above: the V2
368    // paths are not the V1 paths with a digit changed, and the shapes differ
369    // too. Allocation V2 has no `execute-transfer` context at all — settlement
370    // moved to a factory — and allocation-instruction V2 gained `accept` and
371    // `withdraw`, which V1 has not. A flag would have made those differences
372    // silent.
373
374    /// Resolve the **V2** transfer factory, with its choice context.
375    ///
376    /// `POST /registry/transfer-instruction/v2/transfer-factory`
377    ///
378    /// # Errors
379    /// As [`info`](Self::info).
380    pub async fn transfer_factory_v2(
381        &self,
382        choice_arguments: &serde_json::Value,
383    ) -> Result<FactoryWithContext> {
384        let url = self.url(&["registry", "transfer-instruction", "v2", "transfer-factory"])?;
385        let wire: WireTransferFactory = self.post_factory(url, choice_arguments).await?;
386        Ok(FactoryWithContext {
387            factory_id: wire.factory_id,
388            transfer_kind: Some(wire.transfer_kind),
389            context: ChoiceContext::from_wire(wire.choice_context)?,
390        })
391    }
392
393    /// Resolve the **V2** allocation factory.
394    ///
395    /// `POST /registry/allocation-instruction/v2/allocation-factory`
396    ///
397    /// # Errors
398    /// As [`info`](Self::info).
399    pub async fn allocation_factory_v2(
400        &self,
401        choice_arguments: &serde_json::Value,
402    ) -> Result<FactoryWithContext> {
403        let url = self.url(&[
404            "registry",
405            "allocation-instruction",
406            "v2",
407            "allocation-factory",
408        ])?;
409        let wire: WireFactory = self.post_factory(url, choice_arguments).await?;
410        Ok(FactoryWithContext {
411            factory_id: wire.factory_id,
412            transfer_kind: None,
413            context: ChoiceContext::from_wire(wire.choice_context)?,
414        })
415    }
416
417    /// Resolve the **settlement factory** — how a V2 allocation is settled.
418    ///
419    /// `POST /registry/allocation/v2/settlement-factory`
420    ///
421    /// Note the **singular** `allocation` here against the plural
422    /// `allocations` of the choice-context paths below. That is what the
423    /// specification says, inconsistent as it looks, and a client that
424    /// regularises it gets a 404 from a registry that is working correctly.
425    ///
426    /// V1 has no equivalent: there, an allocation is executed through a choice
427    /// context on the allocation itself. V2 settles a *batch* through this
428    /// factory instead, which is what lets both legs of a delivery-versus-
429    /// payment settle together.
430    ///
431    /// # Errors
432    /// As [`info`](Self::info).
433    pub async fn settlement_factory_v2(
434        &self,
435        choice_arguments: &serde_json::Value,
436    ) -> Result<FactoryWithContext> {
437        let url = self.url(&["registry", "allocation", "v2", "settlement-factory"])?;
438        let wire: WireFactory = self.post_factory(url, choice_arguments).await?;
439        Ok(FactoryWithContext {
440            factory_id: wire.factory_id,
441            transfer_kind: None,
442            context: ChoiceContext::from_wire(wire.choice_context)?,
443        })
444    }
445
446    /// The **V2** context for a choice on an existing transfer instruction.
447    ///
448    /// `POST /registry/transfer-instruction/v2/{id}/choice-contexts/{choice}`
449    ///
450    /// # Errors
451    /// As [`info`](Self::info).
452    pub async fn transfer_instruction_context_v2(
453        &self,
454        transfer_instruction_id: &str,
455        choice: TransferInstructionChoice,
456        request: &ChoiceContextRequest,
457    ) -> Result<ChoiceContext> {
458        self.choice_context(
459            self.url(&[
460                "registry",
461                "transfer-instruction",
462                "v2",
463                transfer_instruction_id,
464                "choice-contexts",
465                choice.path(),
466            ])?,
467            request,
468        )
469        .await
470    }
471
472    /// The **V2** context for a choice on an existing allocation.
473    ///
474    /// `POST /registry/allocations/v2/{id}/choice-contexts/{choice}` — plural,
475    /// unlike the settlement factory above.
476    ///
477    /// # Errors
478    /// As [`info`](Self::info), plus [`Error::InvalidRequest`] for
479    /// [`AllocationChoice::ExecuteTransfer`], which V2 does not have: settling
480    /// goes through [`settlement_factory_v2`](Self::settlement_factory_v2).
481    /// Refusing locally names the reason; the registry would answer 404.
482    pub async fn allocation_context_v2(
483        &self,
484        allocation_id: &str,
485        choice: AllocationChoice,
486        request: &ChoiceContextRequest,
487    ) -> Result<ChoiceContext> {
488        if choice == AllocationChoice::ExecuteTransfer {
489            return Err(Error::InvalidRequest(
490                "V2 allocations are settled through the settlement factory, not an \
491                 execute-transfer context — see `settlement_factory_v2`"
492                    .to_string(),
493            ));
494        }
495        self.choice_context(
496            self.url(&[
497                "registry",
498                "allocations",
499                "v2",
500                allocation_id,
501                "choice-contexts",
502                choice.path(),
503            ])?,
504            request,
505        )
506        .await
507    }
508
509    /// The **V2** context for a choice on an allocation *instruction*.
510    ///
511    /// `POST /registry/allocation-instruction/v2/{id}/choice-contexts/{choice}`
512    ///
513    /// V1 has no such endpoint — an allocation instruction there is driven
514    /// entirely through the factory.
515    ///
516    /// # Errors
517    /// As [`info`](Self::info).
518    pub async fn allocation_instruction_context_v2(
519        &self,
520        allocation_instruction_id: &str,
521        choice: AllocationInstructionChoice,
522        request: &ChoiceContextRequest,
523    ) -> Result<ChoiceContext> {
524        self.choice_context(
525            self.url(&[
526                "registry",
527                "allocation-instruction",
528                "v2",
529                allocation_instruction_id,
530                "choice-contexts",
531                choice.path(),
532            ])?,
533            request,
534        )
535        .await
536    }
537
538    /// The context for a choice on an existing transfer instruction.
539    ///
540    /// # Errors
541    /// As [`info`](Self::info).
542    pub async fn transfer_instruction_context(
543        &self,
544        transfer_instruction_id: &str,
545        choice: TransferInstructionChoice,
546        request: &ChoiceContextRequest,
547    ) -> Result<ChoiceContext> {
548        self.choice_context(
549            self.url(&[
550                "registry",
551                "transfer-instruction",
552                "v1",
553                transfer_instruction_id,
554                "choice-contexts",
555                choice.path(),
556            ])?,
557            request,
558        )
559        .await
560    }
561
562    /// The context for a choice on an existing allocation.
563    ///
564    /// # Errors
565    /// As [`info`](Self::info).
566    pub async fn allocation_context(
567        &self,
568        allocation_id: &str,
569        choice: AllocationChoice,
570        request: &ChoiceContextRequest,
571    ) -> Result<ChoiceContext> {
572        self.choice_context(
573            self.url(&[
574                "registry",
575                "allocations",
576                "v1",
577                allocation_id,
578                "choice-contexts",
579                choice.path(),
580            ])?,
581            request,
582        )
583        .await
584    }
585
586    async fn post_factory<T: serde::de::DeserializeOwned>(
587        &self,
588        url: reqwest::Url,
589        choice_arguments: &serde_json::Value,
590    ) -> Result<T> {
591        let response = self
592            .http
593            .post(url.clone())
594            .json(&GetFactoryRequest {
595                choice_arguments,
596                // The debug fields are explicitly untrustworthy unless the
597                // provider is trusted, and nothing here reads them, so they are
598                // bytes on the wire for no purpose.
599                exclude_debug_fields: true,
600            })
601            .send()
602            .await
603            .map_err(|e| connection(&e))?;
604        Self::decode(response, url.as_str()).await
605    }
606
607    async fn choice_context(
608        &self,
609        url: reqwest::Url,
610        request: &ChoiceContextRequest,
611    ) -> Result<ChoiceContext> {
612        let response = self
613            .http
614            .post(url.clone())
615            .json(request)
616            .send()
617            .await
618            .map_err(|e| connection(&e))?;
619        let wire: WireChoiceContext = Self::decode(response, url.as_str()).await?;
620        ChoiceContext::from_wire(wire)
621    }
622
623    async fn get<T: serde::de::DeserializeOwned>(&self, url: reqwest::Url) -> Result<T> {
624        let response = self
625            .http
626            .get(url.clone())
627            .send()
628            .await
629            .map_err(|e| connection(&e))?;
630        Self::decode(response, url.as_str()).await
631    }
632
633    /// One place where a non-success status becomes an error and a body becomes
634    /// a value, so every call reports failure the same way.
635    async fn decode<T: serde::de::DeserializeOwned>(
636        response: reqwest::Response,
637        url: &str,
638    ) -> Result<T> {
639        let status = response.status();
640        let body = response.text().await.map_err(|e| connection(&e))?;
641        if status == reqwest::StatusCode::CONFLICT {
642            // Every token-standard document defines 409 the same way: a
643            // contract the reply would name is in the middle of a
644            // reassignment between synchronizers. It resolves by itself in
645            // seconds, so this is the one non-2xx that is transient, and the
646            // shared error model's HTTP rule (408, 429, 5xx) would call it
647            // permanent. Reported as a connection-class error, which retries.
648            let message = serde_json::from_str::<ErrorResponse>(&body)
649                .map(|e| e.error)
650                .unwrap_or(body);
651            return Err(Error::Connection(format!(
652                "{url}: the registry reports a contract mid-reassignment (409), retry shortly: {}",
653                truncate(&message)
654            )));
655        }
656        if !status.is_success() {
657            // The standard's error body is `{"error": "…"}`; anything else is
658            // passed through, since a registry that is failing is exactly when
659            // a truncated-to-nothing message helps least. The body is the
660            // body — `canton-core` reads `Error::Http::body` as the response
661            // (for a category, a retry delay, a correlation id), and a URL
662            // prefixed onto it made every one of those reads fail. The URL is
663            // in the log line instead.
664            tracing::warn!(%url, status = status.as_u16(), "the registry answered with an error");
665            let message = serde_json::from_str::<ErrorResponse>(&body)
666                .map(|e| e.error)
667                .unwrap_or(body);
668            return Err(Error::http_at(status.as_u16(), truncate(&message), url));
669        }
670        serde_json::from_str(&body).map_err(|e| {
671            Error::UnexpectedResponse(format!(
672                "{url} returned a body this does not understand: {e}"
673            ))
674        })
675    }
676}
677
678#[derive(Deserialize)]
679struct ErrorResponse {
680    error: String,
681}
682
683/// Turn a transport failure into an error a caller can act on.
684///
685/// Two things this must get right, and the first version got neither.
686///
687/// **The message.** A `reqwest::Error` prints only its outer sentence —
688/// `error sending request for url (…)` — and keeps what actually went wrong in
689/// its source chain. Reporting only the outer message hides the one line that
690/// says *certificate*, or *dns*, or *connection refused*.
691///
692/// **The verdict.** [`Error::Connection`] is unconditionally retriable, so
693/// mapping everything to it makes an application that loops on `is_retriable()`
694/// retry forever on a condition no amount of waiting fixes — a certificate it
695/// cannot verify, a URL it cannot build, a redirect loop. Those are reported as
696/// [`Error::InvalidRequest`], which is not retriable.
697fn connection(e: &reqwest::Error) -> Error {
698    let kind = if e.is_timeout() {
699        Transport::Timeout
700    } else if e.is_builder() {
701        Transport::Builder
702    } else if e.is_redirect() {
703        Transport::Redirect
704    } else if e.is_decode() {
705        Transport::Decode
706    } else {
707        Transport::Connect
708    };
709    classify_transport(kind, &canton_core::chain(e))
710}
711
712/// What `reqwest` says about a failure, by type, in the order the verdict
713/// reads them. Separated from the error so the verdict below can be tested on
714/// the strings it reads, which are the only place a TLS failure shows.
715#[derive(Clone, Copy, Debug, PartialEq, Eq)]
716enum Transport {
717    Timeout,
718    Builder,
719    Redirect,
720    Decode,
721    /// Everything else: the connection itself failed, and the chain says how.
722    Connect,
723}
724
725fn classify_transport(kind: Transport, detail: &str) -> Error {
726    match kind {
727        // The 30s default this client sets. Retriable, and `Error::Timeout`
728        // says why without the caller reading the message.
729        Transport::Timeout => return Error::Timeout,
730        Transport::Builder | Transport::Redirect => {
731            return Error::InvalidRequest(format!(
732                "the registry request could not be made: {detail}"
733            ));
734        }
735        Transport::Decode => {
736            return Error::UnexpectedResponse(format!(
737                "the registry's reply could not be read: {detail}"
738            ));
739        }
740        Transport::Connect => {}
741    }
742    // A TLS failure arrives as a connect error, indistinguishable by type from
743    // a refused connection — reqwest exposes no typed TLS error — so the chain
744    // is the only place the difference shows. Worth the string match: a
745    // certificate the client cannot verify is permanent, and retrying it
746    // forever is the failure this whole function exists to avoid.
747    let lower = detail.to_ascii_lowercase();
748    if lower.contains("certificate") || lower.contains("tls handshake") {
749        return Error::InvalidRequest(format!(
750            "the registry's TLS certificate could not be verified: {detail}"
751        ));
752    }
753    Error::Connection(format!("cannot reach the registry: {detail}"))
754}
755
756/// The choices a transfer instruction offers, as the registry paths name them.
757#[derive(Clone, Copy, Debug, Eq, PartialEq)]
758#[non_exhaustive]
759pub enum TransferInstructionChoice {
760    /// The receiver accepts an offered transfer.
761    Accept,
762    /// The receiver rejects it.
763    Reject,
764    /// The sender withdraws it.
765    Withdraw,
766}
767
768impl TransferInstructionChoice {
769    fn path(self) -> &'static str {
770        match self {
771            Self::Accept => "accept",
772            Self::Reject => "reject",
773            Self::Withdraw => "withdraw",
774        }
775    }
776}
777
778/// The choices a V2 allocation *instruction* offers.
779///
780/// V1 has no equivalent: an allocation instruction there is driven through the
781/// factory and has no choice contexts of its own.
782#[derive(Clone, Copy, Debug, Eq, PartialEq)]
783#[non_exhaustive]
784pub enum AllocationInstructionChoice {
785    /// The registry accepts a pending instruction.
786    Accept,
787    /// The sender withdraws it.
788    Withdraw,
789}
790
791impl AllocationInstructionChoice {
792    fn path(self) -> &'static str {
793        match self {
794            Self::Accept => "accept",
795            Self::Withdraw => "withdraw",
796        }
797    }
798}
799
800/// The choices an allocation offers.
801#[derive(Clone, Copy, Debug, Eq, PartialEq)]
802#[non_exhaustive]
803pub enum AllocationChoice {
804    /// The executor settles the allocated transfer.
805    ExecuteTransfer,
806    /// The sender withdraws the allocation.
807    Withdraw,
808    /// The executor cancels it.
809    Cancel,
810}
811
812impl AllocationChoice {
813    fn path(self) -> &'static str {
814        match self {
815            Self::ExecuteTransfer => "execute-transfer",
816            Self::Withdraw => "withdraw",
817            Self::Cancel => "cancel",
818        }
819    }
820}
821
822/// A body excerpt short enough for an error message. An HTML error page is
823/// otherwise long enough to bury the sentence that names the problem.
824fn truncate(body: &str) -> String {
825    const LIMIT: usize = 200;
826    if body.len() <= LIMIT {
827        return body.to_string();
828    }
829    let cut = body
830        .char_indices()
831        .map(|(i, _)| i)
832        .take_while(|i| *i <= LIMIT)
833        .last()
834        .unwrap_or(0);
835    format!("{}…", &body[..cut])
836}
837
838#[cfg(test)]
839#[allow(clippy::unwrap_used, clippy::expect_used)]
840mod tests {
841    use super::*;
842
843    /// The paths are the standard's, spelled as its `operationId`s imply. A
844    /// wrong one is a 404 from a registry that is working perfectly.
845    #[test]
846    fn the_choice_paths_are_the_ones_the_standard_publishes() {
847        assert_eq!(TransferInstructionChoice::Accept.path(), "accept");
848        assert_eq!(TransferInstructionChoice::Reject.path(), "reject");
849        assert_eq!(TransferInstructionChoice::Withdraw.path(), "withdraw");
850        assert_eq!(AllocationChoice::ExecuteTransfer.path(), "execute-transfer");
851        assert_eq!(AllocationChoice::Withdraw.path(), "withdraw");
852        assert_eq!(AllocationChoice::Cancel.path(), "cancel");
853    }
854
855    /// `self` is a Rust keyword and the wire spells it exactly that, so the
856    /// rename is load-bearing: without it the variant would never match.
857    #[test]
858    fn the_transfer_kinds_decode_from_the_words_the_wire_uses() {
859        let kinds: Vec<TransferKind> =
860            serde_json::from_str(r#"["self", "direct", "offer"]"#).expect("decodes");
861        assert_eq!(
862            kinds,
863            [
864                TransferKind::SelfTransfer,
865                TransferKind::Direct,
866                TransferKind::Offer
867            ]
868        );
869    }
870
871    /// A trailing slash on the base URL would otherwise produce `//registry/…`,
872    /// which some servers route and others do not.
873    #[test]
874    fn a_trailing_slash_on_the_base_url_does_not_double_up() {
875        let client = RegistryClient::new("https://scan.example.com/").unwrap();
876        assert_eq!(
877            client
878                .url(&["registry", "metadata", "v1", "info"])
879                .unwrap()
880                .as_str(),
881            "https://scan.example.com/registry/metadata/v1/info"
882        );
883    }
884
885    /// A path segment from outside must not be able to change which resource is
886    /// addressed. Formatted into a string, an instrument id of `a?b` fetched
887    /// instrument `a` and returned its name, symbol and decimals with no error
888    /// — and `../../info` walked out of the collection entirely.
889    #[test]
890    fn a_hostile_path_segment_cannot_redirect_the_request() {
891        let client = RegistryClient::new("https://scan.example.com").unwrap();
892        for hostile in ["a?b", "a#b", "../../info", "a/b"] {
893            let url = client
894                .url(&["registry", "metadata", "v1", "instruments", hostile])
895                .unwrap();
896            assert_eq!(
897                url.query(),
898                None,
899                "`{hostile}` must not become a query: {url}"
900            );
901            assert_eq!(
902                url.path_segments().map(Iterator::count),
903                Some(5),
904                "`{hostile}` must stay one segment: {url}"
905            );
906            assert!(
907                url.as_str()
908                    .starts_with("https://scan.example.com/registry/metadata/v1/instruments/"),
909                "`{hostile}` escaped the collection: {url}"
910            );
911        }
912    }
913
914    /// A base URL that is not a URL is a configuration mistake, and it has to
915    /// arrive as one. Left to surface from the first request it became
916    /// `Error::Connection`, which the SDK reports as retriable — so an
917    /// application would retry a typo forever.
918    #[test]
919    fn a_base_url_that_is_not_a_url_is_refused_immediately() {
920        let err = RegistryClient::new("not a url").expect_err("not a URL");
921        assert!(!err.is_retriable(), "a typo is not worth retrying: {err}");
922        assert!(err.to_string().contains("not a url"), "{err}");
923    }
924
925    #[test]
926    fn the_factory_request_is_the_shape_the_standard_specifies() {
927        let args = serde_json::json!({ "expectedAdmin": "dso::1220", "transfer": {} });
928        let request = GetFactoryRequest {
929            choice_arguments: &args,
930            exclude_debug_fields: true,
931        };
932        assert_eq!(
933            serde_json::to_value(&request).unwrap(),
934            serde_json::json!({
935                "choiceArguments": { "expectedAdmin": "dso::1220", "transfer": {} },
936                "excludeDebugFields": true
937            })
938        );
939    }
940
941    /// The response is read by the field names the standard uses, and the
942    /// factory's context is translated on the way through.
943    #[test]
944    fn a_factory_response_decodes_and_its_context_is_translated() {
945        let json = serde_json::json!({
946            "factoryId": "00factory",
947            "transferKind": "direct",
948            "choiceContext": {
949                "choiceContextData": { "values": {} },
950                "disclosedContracts": [{
951                    "templateId": "pkg:Splice.AmuletRules:AmuletRules",
952                    "contractId": "001",
953                    "createdEventBlob": "AQI=",
954                    "synchronizerId": "sync::1220ab"
955                }]
956            }
957        });
958        let wire: WireTransferFactory = serde_json::from_value(json).expect("decodes");
959        assert_eq!(wire.factory_id, "00factory");
960        assert_eq!(wire.transfer_kind, TransferKind::Direct);
961
962        let context = ChoiceContext::from_wire(wire.choice_context).expect("translates");
963        assert_eq!(context.disclosed_contracts().len(), 1);
964        assert_eq!(context.disclosed_contracts()[0].created_event_blob, [1, 2]);
965    }
966
967    /// The allocation factory's response has no `transferKind` at all, and it
968    /// is a different type for that reason: "this API does not classify the
969    /// workflow" and "a registry omitted a field its spec requires" must not
970    /// arrive as the same value.
971    #[test]
972    fn the_allocation_factory_response_has_no_transfer_kind() {
973        let json = serde_json::json!({
974            "factoryId": "00factory",
975            "choiceContext": { "choiceContextData": {}, "disclosedContracts": [] }
976        });
977        let wire: WireFactory = serde_json::from_value(json).expect("decodes");
978        assert_eq!(wire.factory_id, "00factory");
979
980        // And the transfer factory refuses the same body, because its spec
981        // marks the field required.
982        let json = serde_json::json!({
983            "factoryId": "00factory",
984            "choiceContext": { "choiceContextData": {}, "disclosedContracts": [] }
985        });
986        assert!(serde_json::from_value::<WireTransferFactory>(json).is_err());
987    }
988
989    /// A registry that adds a fourth kind must not break the transfers whose
990    /// kind this build understands perfectly well.
991    #[test]
992    fn an_unknown_transfer_kind_decodes_instead_of_failing_the_response() {
993        let json = serde_json::json!({
994            "factoryId": "00factory",
995            "transferKind": "escrow-something-new",
996            "choiceContext": { "choiceContextData": {}, "disclosedContracts": [] }
997        });
998        let wire: WireTransferFactory =
999            serde_json::from_value(json).expect("an unknown kind is a value, not a decode failure");
1000        assert_eq!(wire.transfer_kind, TransferKind::Unknown);
1001    }
1002
1003    /// The standard marks `decimals` required with a default of ten, so an
1004    /// absent field means ten places — not "unknown". It is what a wallet
1005    /// formats an amount with.
1006    #[test]
1007    fn an_absent_decimals_is_the_standards_default_and_not_unknown() {
1008        let instrument: Instrument = serde_json::from_value(serde_json::json!({
1009            "id": "Amulet", "name": "Canton Coin", "symbol": "CC"
1010        }))
1011        .expect("decodes");
1012        assert_eq!(instrument.decimals, 10);
1013    }
1014
1015    #[test]
1016    fn instruments_decode_with_the_optional_fields_absent() {
1017        let json = serde_json::json!({
1018            "instruments": [{ "id": "Amulet", "name": "Canton Coin", "symbol": "CC" }]
1019        });
1020        let response: ListInstrumentsResponse = serde_json::from_value(json).expect("decodes");
1021        assert_eq!(response.instruments.len(), 1);
1022        assert_eq!(response.instruments[0].id, "Amulet");
1023        assert_eq!(response.instruments[0].total_supply, None);
1024        assert_eq!(response.instruments[0].decimals, 10);
1025        assert_eq!(response.next_page_token, None);
1026    }
1027
1028    /// The transport verdict, on the strings it has to read. A certificate
1029    /// failure is permanent whichever of the two phrasings the chain carries;
1030    /// a refused connection is not.
1031    #[test]
1032    fn a_tls_failure_is_permanent_under_either_phrasing() {
1033        for detail in [
1034            "error sending request: invalid peer certificate: UnknownIssuer",
1035            "error sending request: tls handshake eof",
1036            "CERTIFICATE verify failed",
1037        ] {
1038            let error = classify_transport(Transport::Connect, detail);
1039            assert!(
1040                matches!(error, Error::InvalidRequest(_)),
1041                "{detail}: {error:?}"
1042            );
1043            assert!(!error.is_retriable(), "{detail}");
1044            assert!(error.to_string().contains("TLS certificate"), "{error}");
1045        }
1046        let refused = classify_transport(Transport::Connect, "connection refused (os error 61)");
1047        assert!(matches!(refused, Error::Connection(_)), "{refused:?}");
1048        assert!(refused.is_retriable());
1049        assert!(
1050            refused.to_string().contains("connection refused"),
1051            "{refused}"
1052        );
1053    }
1054
1055    /// Each typed verdict on its own: a timeout is `Timeout`, an unbuildable
1056    /// request or a redirect loop is permanent, a body that does not read is
1057    /// an unexpected response.
1058    #[test]
1059    fn the_typed_verdicts_are_read_before_the_strings() {
1060        let timeout = classify_transport(
1061            Transport::Timeout,
1062            "certificate", // would otherwise be read as permanent
1063        );
1064        assert!(matches!(timeout, Error::Timeout), "{timeout:?}");
1065
1066        for kind in [Transport::Builder, Transport::Redirect] {
1067            let error = classify_transport(kind, "x");
1068            assert!(
1069                matches!(&error, Error::InvalidRequest(m) if m.contains("could not be made")),
1070                "{kind:?}: {error:?}"
1071            );
1072        }
1073
1074        let decode = classify_transport(Transport::Decode, "x");
1075        assert!(matches!(decode, Error::UnexpectedResponse(_)), "{decode:?}");
1076    }
1077
1078    /// The excerpt boundary: exactly the limit is left alone, one byte over is
1079    /// cut and marked, and the cut never splits a character.
1080    #[test]
1081    fn truncate_cuts_past_the_limit_and_on_a_character_boundary() {
1082        let exact = "x".repeat(200);
1083        assert_eq!(truncate(&exact), exact);
1084
1085        let over = "x".repeat(201);
1086        let cut = truncate(&over);
1087        assert!(cut.ends_with('…'), "{cut}");
1088        assert_eq!(cut.trim_end_matches('…').len(), 200);
1089
1090        // 199 ASCII bytes, then a three-byte character straddling the limit.
1091        let straddle = format!("{}€€", "x".repeat(199));
1092        let cut = truncate(&straddle);
1093        assert!(cut.ends_with('…'), "{cut}");
1094        assert!(cut.starts_with(&"x".repeat(199)), "{cut}");
1095    }
1096}