Skip to main content

vta_sdk/
error.rs

1//! Structured error type for VTA SDK operations.
2
3/// Errors returned by VTA SDK client operations.
4#[derive(Debug, thiserror::Error)]
5pub enum VtaError {
6    /// Network-level error (connection refused, timeout, DNS failure).
7    #[cfg(feature = "client")]
8    #[error("network error: {0}")]
9    Network(#[from] reqwest::Error),
10
11    /// Authentication failed (401) or token expired.
12    #[error("authentication failed: {0}")]
13    Auth(String),
14
15    /// Resource not found (404).
16    #[error("not found: {0}")]
17    NotFound(String),
18
19    /// Request validation error (400).
20    #[error("validation error: {0}")]
21    Validation(String),
22
23    /// Permission denied (403).
24    #[error("forbidden: {0}")]
25    Forbidden(String),
26
27    /// Conflict (409) — e.g. duplicate key ID.
28    #[error("conflict: {0}")]
29    Conflict(String),
30
31    /// Gone (410) — the resource existed but is now permanently unavailable.
32    /// Most often emitted by the bootstrap carve-out endpoint after it has
33    /// been consumed; the CLI surfaces this with a "did you mean to run
34    /// `… provision-request`" hint instead of a flat string.
35    #[error("gone: {0}")]
36    Gone(String),
37
38    /// Server error (5xx).
39    #[error("server error ({status}): {body}")]
40    Server { status: u16, body: String },
41
42    /// The operation does not support the transport the client is
43    /// configured for (e.g. calling a REST-only helper on a client built
44    /// with DIDComm-only transport, or vice versa).
45    #[error("unsupported transport: {0}")]
46    UnsupportedTransport(String),
47
48    /// DIDComm transport failure (pack/send/pickup). Network-ish —
49    /// caller may want to retry. Distinct from [`Self::Network`] which
50    /// is REST-specific and carries a `reqwest::Error`.
51    #[error("didcomm transport error: {0}")]
52    DidcommTransport(String),
53
54    /// Remote endpoint returned a DIDComm problem-report whose `code`
55    /// did not match any of the standard `e.p.msg.*` taxonomy variants
56    /// (which map to the typed REST-aligned variants above). Inspect
57    /// `code` to handle it; a typed [`Self::Conflict`] / [`Self::NotFound`]
58    /// / [`Self::Auth`] / [`Self::Validation`] / [`Self::Server`] will
59    /// already have been emitted for the standard codes.
60    #[error("didcomm remote error ({code}): {comment}")]
61    DidcommRemote { code: String, comment: String },
62
63    /// Programmer-level protocol error (response shape did not match
64    /// what the SDK expected — version mismatch or bug). Distinct from
65    /// remote-error: a peer that returned a problem-report becomes a
66    /// typed variant via [`Self::from_problem_report`], not this one.
67    #[error("protocol error: {0}")]
68    Protocol(String),
69
70    /// Serialization/deserialization error.
71    #[error("serialization error: {0}")]
72    Serialization(#[from] serde_json::Error),
73
74    // ── Runtime service-management variants (spec §4) ──────────────
75    //
76    // These are emitted by the post-setup service-management surface
77    // (`services {rest,didcomm} {enable,update,disable,rollback}`).
78    // Structured data for the variants that carry numeric fields
79    // round-trips lossless via [`TypedErrorPayload`] across both
80    // REST response bodies and DIDComm problem-report args.
81    /// The operation would leave the VTA's DID document with no
82    /// advertised transport services. Per spec §3.2, this is rejected
83    /// without a `--force` escape hatch — enable the other transport
84    /// first if a swap is intended.
85    #[error("refusing operation: would leave the VTA with no advertised services")]
86    LastServiceRefused,
87
88    /// `update`, `disable`, or a kind-specific drain action was
89    /// invoked for a service kind that isn't currently enabled.
90    #[error("service is not present (not currently enabled)")]
91    ServiceNotPresent,
92
93    /// `enable` was invoked for a service kind that's already
94    /// enabled. Use `update` to change its configuration.
95    #[error("service is already enabled")]
96    ServiceAlreadyEnabled,
97
98    /// DIDComm handshake against the candidate mediator failed
99    /// (trust-ping refused, timed out, or peer was unreachable).
100    #[error("mediator handshake failed: {reason}")]
101    MediatorHandshakeFailed { reason: String },
102
103    /// Drain TTL is outside the valid range. Bounds are
104    /// `MIN_DRAIN_TTL_OVER_DIDCOMM` (3600s, when the disable command
105    /// is itself delivered over DIDComm) and `MAX_DRAIN_TTL`
106    /// (30 days). All three fields are in seconds.
107    #[error("drain ttl {requested}s outside allowed range [{min}s, {max}s]")]
108    DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
109
110    /// `rollback` was invoked for a service kind that has no prior
111    /// mutation in its snapshot store to fail-forward from.
112    #[error("no prior mutation to roll back from")]
113    NoPriorMutation,
114
115    /// Catch-all for other errors.
116    /// No transport protocol is advertised by **both** this party and the
117    /// counterparty, so there is no way to communicate. Carries each side's
118    /// advertised set (in preference order) so the CLI can show the operator
119    /// what each offers and which transport to enable. Determined locally by
120    /// [`crate::protocol::matching::select_protocol`] after resolving the
121    /// peer's DID document — never a server-returned wire error.
122    #[error(
123        "no transport protocol in common with {counterparty_did}: \
124         we advertise {ours:?}, they advertise {theirs:?}"
125    )]
126    NoMatchingProtocol {
127        counterparty_did: String,
128        ours: Vec<crate::protocol::matching::Protocol>,
129        theirs: Vec<crate::protocol::matching::Protocol>,
130    },
131
132    #[error("{0}")]
133    Other(String),
134}
135
136/// Wire-format companion to the typed [`VtaError`] variants emitted
137/// by the runtime service-management surface.
138///
139/// The free-form `comment` string carried by DIDComm problem-reports
140/// (and the `body` string of REST error responses) is fine for the
141/// variants whose only data is a human-readable message
142/// ([`VtaError::Conflict`], [`VtaError::NotFound`], …) but lossy for
143/// variants like [`VtaError::DrainTtlOutOfBounds`] that carry three
144/// numeric fields the CLI needs to switch on.
145///
146/// Servers serialize a `TypedErrorPayload` into the response body
147/// (REST) or problem-report `args` (DIDComm); clients deserialize
148/// it back via [`VtaError::from_typed_payload`]. The discriminator
149/// is the kebab-cased variant name in the `code` field.
150///
151/// Variants line up 1:1 with the §4 spec list — the existing
152/// [`VtaError::UnsupportedTransport`] is included so the same
153/// channel carries every typed-error wire form.
154#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
155#[serde(tag = "code", rename_all = "kebab-case")]
156pub enum TypedErrorPayload {
157    LastServiceRefused,
158    ServiceNotPresent,
159    ServiceAlreadyEnabled,
160    MediatorHandshakeFailed { reason: String },
161    DrainTtlOutOfBounds { min: u64, max: u64, requested: u64 },
162    NoPriorMutation,
163    UnsupportedTransport { detail: String },
164}
165
166impl VtaError {
167    /// Create from an HTTP response status and error body.
168    ///
169    /// Public so a downstream SDK consumer wiring its own HTTP transport
170    /// (e.g. a wasm `gloo-net` client) can produce typed `VtaError`s
171    /// from status codes without re-implementing the mapping.
172    #[cfg(feature = "client")]
173    pub fn from_http(status: reqwest::StatusCode, body: String) -> Self {
174        match status.as_u16() {
175            401 => Self::Auth(body),
176            403 => Self::Forbidden(body),
177            404 => Self::NotFound(body),
178            400 | 422 => Self::Validation(body),
179            409 => Self::Conflict(body),
180            410 => Self::Gone(body),
181            s if s >= 500 => Self::Server { status: s, body },
182            s => Self::Other(format!("{s}: {body}")),
183        }
184    }
185
186    /// Create from a DIDComm problem-report `code` + `comment`. Mirrors
187    /// the REST [`Self::from_http`] mapping so callers can `match` on the
188    /// same variants regardless of transport.
189    ///
190    /// Standard codes (`e.p.msg.unauthorized` / `bad-request` / `not-found`
191    /// / `conflict` / `internal-error`) become typed variants. Anything
192    /// else lands in [`Self::DidcommRemote`] preserving the original code.
193    pub fn from_problem_report(code: &str, comment: impl Into<String>) -> Self {
194        use crate::protocols::problem_report_codes as c;
195        let comment = comment.into();
196        match code {
197            c::CONFLICT => Self::Conflict(comment),
198            c::NOT_FOUND => Self::NotFound(comment),
199            c::UNAUTHORIZED => Self::Auth(comment),
200            c::FORBIDDEN => Self::Forbidden(comment),
201            c::BAD_REQUEST => Self::Validation(comment),
202            c::INTERNAL => Self::Server {
203                status: 500,
204                body: comment,
205            },
206            other => Self::DidcommRemote {
207                code: other.to_string(),
208                comment,
209            },
210        }
211    }
212
213    /// Reconstruct the typed [`VtaError`] variant from a wire-format
214    /// [`TypedErrorPayload`]. Used by the client when decoding REST
215    /// response bodies / DIDComm problem-report args for the runtime
216    /// service-management surface (spec §4).
217    pub fn from_typed_payload(payload: TypedErrorPayload) -> Self {
218        match payload {
219            TypedErrorPayload::LastServiceRefused => Self::LastServiceRefused,
220            TypedErrorPayload::ServiceNotPresent => Self::ServiceNotPresent,
221            TypedErrorPayload::ServiceAlreadyEnabled => Self::ServiceAlreadyEnabled,
222            TypedErrorPayload::MediatorHandshakeFailed { reason } => {
223                Self::MediatorHandshakeFailed { reason }
224            }
225            TypedErrorPayload::DrainTtlOutOfBounds {
226                min,
227                max,
228                requested,
229            } => Self::DrainTtlOutOfBounds {
230                min,
231                max,
232                requested,
233            },
234            TypedErrorPayload::NoPriorMutation => Self::NoPriorMutation,
235            TypedErrorPayload::UnsupportedTransport { detail } => {
236                Self::UnsupportedTransport(detail)
237            }
238        }
239    }
240
241    /// Project this error onto the wire-format [`TypedErrorPayload`]
242    /// when the variant is one of the runtime service-management
243    /// errors. Returns `None` for variants that don't have a
244    /// structured wire form (network errors, generic conflicts,
245    /// programmer-level protocol errors, …).
246    #[must_use]
247    pub fn to_typed_payload(&self) -> Option<TypedErrorPayload> {
248        match self {
249            Self::LastServiceRefused => Some(TypedErrorPayload::LastServiceRefused),
250            Self::ServiceNotPresent => Some(TypedErrorPayload::ServiceNotPresent),
251            Self::ServiceAlreadyEnabled => Some(TypedErrorPayload::ServiceAlreadyEnabled),
252            Self::MediatorHandshakeFailed { reason } => {
253                Some(TypedErrorPayload::MediatorHandshakeFailed {
254                    reason: reason.clone(),
255                })
256            }
257            Self::DrainTtlOutOfBounds {
258                min,
259                max,
260                requested,
261            } => Some(TypedErrorPayload::DrainTtlOutOfBounds {
262                min: *min,
263                max: *max,
264                requested: *requested,
265            }),
266            Self::NoPriorMutation => Some(TypedErrorPayload::NoPriorMutation),
267            Self::UnsupportedTransport(detail) => Some(TypedErrorPayload::UnsupportedTransport {
268                detail: detail.clone(),
269            }),
270            _ => None,
271        }
272    }
273
274    /// Returns true if the resource was permanently consumed/gone (410).
275    pub fn is_gone(&self) -> bool {
276        matches!(self, Self::Gone(_))
277    }
278
279    /// Returns true if a create/insert collided with an existing entry (409).
280    pub fn is_conflict(&self) -> bool {
281        matches!(self, Self::Conflict(_))
282    }
283
284    /// Returns true if this is an authentication/authorization error.
285    pub fn is_auth(&self) -> bool {
286        matches!(self, Self::Auth(_) | Self::Forbidden(_))
287    }
288
289    /// Returns true if this is a network-level error (retryable).
290    pub fn is_network(&self) -> bool {
291        #[cfg(feature = "client")]
292        if matches!(self, Self::Network(_)) {
293            return true;
294        }
295        false
296    }
297
298    /// Returns true if the resource was not found.
299    pub fn is_not_found(&self) -> bool {
300        matches!(self, Self::NotFound(_))
301    }
302
303    /// Operator-actionable hint matching this error variant.
304    ///
305    /// `None` for variants where no generic guidance applies (the message
306    /// itself is the hint, or the failure is a programmer error). The
307    /// CLI layer (`vta-cli-common::render::print_cli_error`) already
308    /// implements bin-aware suggestions ("`pnm acl create …`"); this
309    /// method gives **non-CLI consumers** — web UIs, GUIs, custom
310    /// dashboards — the same hint surface without needing to fork the
311    /// dispatch logic.
312    ///
313    /// Returns a `&'static str` so callers can compose it into their
314    /// own UI without lifetime juggling. The bin-specific substitution
315    /// (`pnm` vs `cnm`) is left to the CLI layer because only it
316    /// knows which binary the operator is running.
317    #[must_use]
318    pub fn suggested_fix(&self) -> Option<&'static str> {
319        match self {
320            Self::Auth(_) => Some(
321                "Token may be expired. Re-authenticate against the VTA, or check that \
322                 the `/auth` endpoint is reachable.",
323            ),
324            Self::Forbidden(_) => Some(
325                "Your role or context access doesn't permit this operation. Inspect \
326                 the ACL entry for your DID against the target context.",
327            ),
328            Self::Gone(_) => Some(
329                "The resource has been permanently consumed. The single-use bootstrap \
330                 carve-out has likely already been used; ask an existing admin to \
331                 provision-integration a new operator instead.",
332            ),
333            Self::Conflict(_) => Some(
334                "The resource already exists. Use the corresponding `update` or \
335                 `delete-then-create` flow rather than `create`.",
336            ),
337            Self::Validation(_) => Some(
338                "The request body or parameters were rejected by the VTA's schema. \
339                 Inspect the response body for the specific field that failed.",
340            ),
341            Self::Server { .. } => {
342                Some("VTA-side failure. Check the VTA's server logs or contact the operator.")
343            }
344            Self::UnsupportedTransport(_) => Some(
345                "The operation requires a specific transport (REST or DIDComm). \
346                 Check which mode the client is in and whether the endpoint supports it.",
347            ),
348            Self::DidcommTransport(_) => {
349                Some("Mediator or peer unreachable. Retry after checking mediator connectivity.")
350            }
351            #[cfg(feature = "client")]
352            Self::Network(_) => Some(
353                "Network error reaching the VTA. Confirm the URL is correct and the \
354                 host is reachable.",
355            ),
356            // Runtime service-management variants (spec §4). The CLI
357            // layer enriches these with the specific kind/command
358            // it just ran; this is the generic fallback hint for
359            // non-CLI consumers.
360            Self::LastServiceRefused => Some(
361                "This operation would leave the VTA with no advertised transport \
362                 services. Enable the other transport first (REST or DIDComm) \
363                 before disabling this one.",
364            ),
365            Self::ServiceNotPresent => Some(
366                "The service kind isn't currently enabled. Use \
367                 `services <kind> enable …` to bring it online before \
368                 updating, disabling, or rolling it back.",
369            ),
370            Self::ServiceAlreadyEnabled => Some(
371                "The service kind is already enabled. Use \
372                 `services <kind> update …` to change its configuration, \
373                 or `disable` to remove it.",
374            ),
375            Self::MediatorHandshakeFailed { .. } => Some(
376                "DIDComm handshake against the candidate mediator failed. \
377                 Confirm the mediator DID is correct and the mediator is \
378                 reachable; check the inner reason for the specific cause.",
379            ),
380            Self::DrainTtlOutOfBounds { .. } => Some(
381                "The supplied drain TTL is outside the allowed range. Pick a \
382                 value within the [min, max] interval shown in the error message.",
383            ),
384            Self::NoPriorMutation => Some(
385                "No prior mutation for this service kind to roll back from. Use \
386                 the direct `enable`/`update`/`disable` command instead.",
387            ),
388            Self::NoMatchingProtocol { .. } => Some(
389                "The two parties share no transport protocol. Enable a common \
390                 transport (TSP, DIDComm, or REST) on both sides — compare each \
391                 DID document's advertised `service` entries and add the missing one.",
392            ),
393            // No generic hint for these — the message itself is the
394            // hint, or the failure is a protocol/programmer error
395            // surface that an automated suggestion would only confuse.
396            Self::NotFound(_)
397            | Self::DidcommRemote { .. }
398            | Self::Protocol(_)
399            | Self::Serialization(_)
400            | Self::Other(_) => None,
401        }
402    }
403}
404
405impl From<crate::did_key::DidKeyError> for VtaError {
406    fn from(e: crate::did_key::DidKeyError) -> Self {
407        Self::Validation(e.to_string())
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[cfg(feature = "client")]
416    #[test]
417    fn from_http_410_maps_to_gone() {
418        let err = VtaError::from_http(reqwest::StatusCode::GONE, "carve-out closed".into());
419        assert!(err.is_gone(), "410 must map to VtaError::Gone, got {err:?}");
420    }
421
422    #[test]
423    fn problem_report_conflict_maps_to_typed_conflict() {
424        let err = VtaError::from_problem_report(
425            crate::protocols::problem_report_codes::CONFLICT,
426            "key id already exists",
427        );
428        assert!(matches!(err, VtaError::Conflict(_)), "got {err:?}");
429        assert!(err.is_conflict());
430    }
431
432    #[test]
433    fn problem_report_unknown_code_lands_in_didcomm_remote() {
434        let err = VtaError::from_problem_report("e.custom.xyz", "weird thing");
435        match err {
436            VtaError::DidcommRemote { code, comment } => {
437                assert_eq!(code, "e.custom.xyz");
438                assert_eq!(comment, "weird thing");
439            }
440            other => panic!("expected DidcommRemote, got {other:?}"),
441        }
442    }
443
444    #[test]
445    fn suggested_fix_present_for_actionable_variants() {
446        // Each "operator can do something about this" variant must have
447        // a hint string; the message-is-the-hint / programmer-error
448        // variants return None.
449        assert!(VtaError::Auth("expired".into()).suggested_fix().is_some());
450        assert!(VtaError::Forbidden("nope".into()).suggested_fix().is_some());
451        assert!(VtaError::Gone("used".into()).suggested_fix().is_some());
452        assert!(VtaError::Conflict("dup".into()).suggested_fix().is_some());
453        assert!(VtaError::Validation("bad".into()).suggested_fix().is_some());
454        assert!(
455            VtaError::Server {
456                status: 500,
457                body: "boom".into(),
458            }
459            .suggested_fix()
460            .is_some()
461        );
462        assert!(
463            VtaError::UnsupportedTransport("rest only".into())
464                .suggested_fix()
465                .is_some()
466        );
467        assert!(
468            VtaError::DidcommTransport("offline".into())
469                .suggested_fix()
470                .is_some()
471        );
472
473        // Runtime service-management variants (spec §4) all have hints.
474        assert!(VtaError::LastServiceRefused.suggested_fix().is_some());
475        assert!(VtaError::ServiceNotPresent.suggested_fix().is_some());
476        assert!(VtaError::ServiceAlreadyEnabled.suggested_fix().is_some());
477        assert!(
478            VtaError::MediatorHandshakeFailed {
479                reason: "trust-ping timeout".into()
480            }
481            .suggested_fix()
482            .is_some()
483        );
484        assert!(
485            VtaError::DrainTtlOutOfBounds {
486                min: 3600,
487                max: 2_592_000,
488                requested: 30,
489            }
490            .suggested_fix()
491            .is_some()
492        );
493        assert!(VtaError::NoPriorMutation.suggested_fix().is_some());
494
495        // Self-explanatory / programmer-error: no canned hint.
496        assert!(VtaError::NotFound("x".into()).suggested_fix().is_none());
497        assert!(VtaError::Protocol("shape".into()).suggested_fix().is_none());
498        assert!(
499            VtaError::DidcommRemote {
500                code: "e.unknown".into(),
501                comment: "x".into()
502            }
503            .suggested_fix()
504            .is_none()
505        );
506    }
507
508    /// Every typed runtime service-management variant must round-trip
509    /// through [`TypedErrorPayload`] without losing structured data.
510    /// The test cases line up 1:1 with the spec §4 list.
511    #[test]
512    fn typed_payload_round_trips_every_runtime_service_variant() {
513        let cases: Vec<VtaError> = vec![
514            VtaError::LastServiceRefused,
515            VtaError::ServiceNotPresent,
516            VtaError::ServiceAlreadyEnabled,
517            VtaError::MediatorHandshakeFailed {
518                reason: "trust-ping timeout after 10s".into(),
519            },
520            VtaError::DrainTtlOutOfBounds {
521                min: 3600,
522                max: 2_592_000,
523                requested: 30,
524            },
525            VtaError::NoPriorMutation,
526            VtaError::UnsupportedTransport("services didcomm enable is REST-only".into()),
527        ];
528
529        for original in cases {
530            let payload = original.to_typed_payload().unwrap_or_else(|| {
531                panic!("variant must project to TypedErrorPayload: {original:?}")
532            });
533
534            // Round-trip through JSON to mirror what REST and DIDComm
535            // transports actually do on the wire.
536            let json = serde_json::to_string(&payload)
537                .unwrap_or_else(|e| panic!("payload must serialize: {e}"));
538            let restored: TypedErrorPayload = serde_json::from_str(&json)
539                .unwrap_or_else(|e| panic!("payload must deserialize: {e}; raw={json}"));
540
541            assert_eq!(
542                payload, restored,
543                "TypedErrorPayload must round-trip through JSON",
544            );
545
546            // Reconstructing back to VtaError preserves the variant
547            // discriminant and any structured data.
548            let reconstructed = VtaError::from_typed_payload(restored);
549            match (&original, &reconstructed) {
550                (VtaError::LastServiceRefused, VtaError::LastServiceRefused)
551                | (VtaError::ServiceNotPresent, VtaError::ServiceNotPresent)
552                | (VtaError::ServiceAlreadyEnabled, VtaError::ServiceAlreadyEnabled)
553                | (VtaError::NoPriorMutation, VtaError::NoPriorMutation) => {}
554                (
555                    VtaError::MediatorHandshakeFailed { reason: a },
556                    VtaError::MediatorHandshakeFailed { reason: b },
557                ) => assert_eq!(a, b),
558                (
559                    VtaError::DrainTtlOutOfBounds {
560                        min: m1,
561                        max: x1,
562                        requested: r1,
563                    },
564                    VtaError::DrainTtlOutOfBounds {
565                        min: m2,
566                        max: x2,
567                        requested: r2,
568                    },
569                ) => {
570                    assert_eq!(m1, m2);
571                    assert_eq!(x1, x2);
572                    assert_eq!(r1, r2);
573                }
574                (VtaError::UnsupportedTransport(a), VtaError::UnsupportedTransport(b)) => {
575                    assert_eq!(a, b)
576                }
577                (a, b) => panic!("variant changed across round-trip: {a:?} → {b:?}"),
578            }
579        }
580    }
581
582    /// The kebab-case `code` discriminator on the wire JSON is part of
583    /// the contract for both REST and DIDComm transports — pin it
584    /// explicitly so a `serde(rename)` change doesn't silently break
585    /// existing peers.
586    #[test]
587    fn typed_payload_wire_discriminator_is_kebab_case() {
588        let payload = TypedErrorPayload::DrainTtlOutOfBounds {
589            min: 3600,
590            max: 2_592_000,
591            requested: 30,
592        };
593        let json = serde_json::to_value(&payload).unwrap();
594        assert_eq!(json["code"], "drain-ttl-out-of-bounds");
595        assert_eq!(json["min"], 3600);
596        assert_eq!(json["max"], 2_592_000);
597        assert_eq!(json["requested"], 30);
598    }
599
600    /// `to_typed_payload` returns `None` for variants outside the
601    /// runtime service-management surface — the wire-format channel
602    /// is reserved for those typed variants and shouldn't blanket
603    /// every error.
604    #[test]
605    fn typed_payload_is_none_for_non_service_management_variants() {
606        assert!(VtaError::Auth("x".into()).to_typed_payload().is_none());
607        assert!(VtaError::NotFound("x".into()).to_typed_payload().is_none());
608        assert!(VtaError::Conflict("x".into()).to_typed_payload().is_none());
609        assert!(
610            VtaError::Server {
611                status: 500,
612                body: "x".into(),
613            }
614            .to_typed_payload()
615            .is_none()
616        );
617        assert!(VtaError::Protocol("x".into()).to_typed_payload().is_none());
618        assert!(
619            VtaError::DidcommRemote {
620                code: "e.x".into(),
621                comment: "x".into()
622            }
623            .to_typed_payload()
624            .is_none()
625        );
626        assert!(VtaError::Other("x".into()).to_typed_payload().is_none());
627    }
628}