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}