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}