Skip to main content

r402_protocol/error/
reason.rs

1//! Machine-readable wire codes (x402 v2 spec §9).
2
3use std::fmt::{self, Display, Formatter};
4
5use compact_str::CompactString;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8/// Canonical error code on the wire when a payment fails.
9///
10/// Unknown codes round-trip through [`Self::Custom`].
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12#[non_exhaustive]
13pub enum ErrorReason {
14    /// Wire code: `insufficient_funds`.
15    InsufficientFunds,
16    /// Wire code: `invalid_exact_evm_payload_authorization_valid_after`.
17    InvalidExactEvmPayloadAuthorizationValidAfter,
18    /// Wire code: `invalid_exact_evm_payload_authorization_valid_before`.
19    InvalidExactEvmPayloadAuthorizationValidBefore,
20    /// Wire code: `invalid_exact_evm_payload_authorization_value_mismatch`.
21    InvalidExactEvmPayloadAuthorizationValueMismatch,
22    /// Wire code: `invalid_exact_evm_payload_signature`.
23    InvalidExactEvmPayloadSignature,
24    /// Wire code: `invalid_exact_evm_payload_recipient_mismatch`.
25    InvalidExactEvmPayloadRecipientMismatch,
26    /// Wire code: `invalid_network`.
27    InvalidNetwork,
28    /// Wire code: `invalid_payload`.
29    InvalidPayload,
30    /// Wire code: `invalid_payment_requirements`.
31    InvalidPaymentRequirements,
32    /// Wire code: `invalid_scheme`.
33    InvalidScheme,
34    /// Wire code: `unsupported_scheme`.
35    UnsupportedScheme,
36    /// Wire code: `invalid_x402_version`.
37    InvalidX402Version,
38    /// Wire code: `invalid_transaction_state`.
39    InvalidTransactionState,
40    /// Wire code: `unexpected_verify_error`.
41    UnexpectedVerifyError,
42    /// Wire code: `unexpected_settle_error`.
43    UnexpectedSettleError,
44    /// Wire code: `settlement_pending`.
45    ///
46    /// Non-terminal. A [`crate::payment::SettleResponse::Failure`] with this
47    /// reason MUST carry a non-empty `transaction`.
48    SettlementPending,
49    /// Wire code: `duplicate_settlement`.
50    DuplicateSettlement,
51    /// Wire code: `nonce_already_used`.
52    NonceAlreadyUsed,
53    /// Wire code: `permit2_allowance_required`. HTTP mapping: 412.
54    Permit2AllowanceRequired,
55    /// Wire code: `invalid_upto_evm_payload_settlement_exceeds_amount`.
56    InvalidUptoEvmPayloadSettlementExceedsAmount,
57    /// Wire code: `upto_facilitator_mismatch`.
58    UptoFacilitatorMismatch,
59    /// Wire code: `upto_unauthorized_facilitator`.
60    UptoUnauthorizedFacilitator,
61    /// Wire code: `upto_amount_exceeds_permitted`.
62    UptoAmountExceedsPermitted,
63    /// Wire code: `invalid_exact_solana_payload_memo_mismatch`.
64    InvalidExactSolanaPayloadMemoMismatch,
65    /// Wire code: `invalid_exact_solana_payload_memo_count`.
66    InvalidExactSolanaPayloadMemoCount,
67    /// Wire code: `incompatible_settlement_mode`. HTTP mapping: 500.
68    IncompatibleSettlementMode,
69    /// Wire code: `settlement_aborted`. HTTP mapping: 402.
70    SettlementAborted,
71    /// Wire code: `extension_echo_mismatch`.
72    ExtensionEchoMismatch,
73    /// Unknown wire code, preserved verbatim.
74    Custom(CompactString),
75}
76
77impl ErrorReason {
78    /// Returns the wire-format code (`snake_case` per spec §9).
79    #[must_use]
80    pub fn as_str(&self) -> &str {
81        match self {
82            Self::InsufficientFunds => "insufficient_funds",
83            Self::InvalidExactEvmPayloadAuthorizationValidAfter => {
84                "invalid_exact_evm_payload_authorization_valid_after"
85            }
86            Self::InvalidExactEvmPayloadAuthorizationValidBefore => {
87                "invalid_exact_evm_payload_authorization_valid_before"
88            }
89            Self::InvalidExactEvmPayloadAuthorizationValueMismatch => {
90                "invalid_exact_evm_payload_authorization_value_mismatch"
91            }
92            Self::InvalidExactEvmPayloadSignature => "invalid_exact_evm_payload_signature",
93            Self::InvalidExactEvmPayloadRecipientMismatch => {
94                "invalid_exact_evm_payload_recipient_mismatch"
95            }
96            Self::InvalidNetwork => "invalid_network",
97            Self::InvalidPayload => "invalid_payload",
98            Self::InvalidPaymentRequirements => "invalid_payment_requirements",
99            Self::InvalidScheme => "invalid_scheme",
100            Self::UnsupportedScheme => "unsupported_scheme",
101            Self::InvalidX402Version => "invalid_x402_version",
102            Self::InvalidTransactionState => "invalid_transaction_state",
103            Self::UnexpectedVerifyError => "unexpected_verify_error",
104            Self::UnexpectedSettleError => "unexpected_settle_error",
105            Self::SettlementPending => "settlement_pending",
106            Self::DuplicateSettlement => "duplicate_settlement",
107            Self::NonceAlreadyUsed => "nonce_already_used",
108            Self::Permit2AllowanceRequired => "permit2_allowance_required",
109            Self::InvalidUptoEvmPayloadSettlementExceedsAmount => {
110                "invalid_upto_evm_payload_settlement_exceeds_amount"
111            }
112            Self::UptoFacilitatorMismatch => "upto_facilitator_mismatch",
113            Self::UptoUnauthorizedFacilitator => "upto_unauthorized_facilitator",
114            Self::UptoAmountExceedsPermitted => "upto_amount_exceeds_permitted",
115            Self::InvalidExactSolanaPayloadMemoMismatch => {
116                "invalid_exact_solana_payload_memo_mismatch"
117            }
118            Self::InvalidExactSolanaPayloadMemoCount => "invalid_exact_solana_payload_memo_count",
119            Self::IncompatibleSettlementMode => "incompatible_settlement_mode",
120            Self::SettlementAborted => "settlement_aborted",
121            Self::ExtensionEchoMismatch => "extension_echo_mismatch",
122            Self::Custom(s) => s.as_str(),
123        }
124    }
125
126    /// Maps a wire code to a canonical variant, or [`Self::Custom`].
127    #[must_use]
128    pub fn from_wire(code: &str) -> Self {
129        match code {
130            "insufficient_funds" => Self::InsufficientFunds,
131            "invalid_exact_evm_payload_authorization_valid_after" => {
132                Self::InvalidExactEvmPayloadAuthorizationValidAfter
133            }
134            "invalid_exact_evm_payload_authorization_valid_before" => {
135                Self::InvalidExactEvmPayloadAuthorizationValidBefore
136            }
137            "invalid_exact_evm_payload_authorization_value_mismatch" => {
138                Self::InvalidExactEvmPayloadAuthorizationValueMismatch
139            }
140            "invalid_exact_evm_payload_signature" => Self::InvalidExactEvmPayloadSignature,
141            "invalid_exact_evm_payload_recipient_mismatch" => {
142                Self::InvalidExactEvmPayloadRecipientMismatch
143            }
144            "invalid_network" => Self::InvalidNetwork,
145            "invalid_payload" => Self::InvalidPayload,
146            "invalid_payment_requirements" => Self::InvalidPaymentRequirements,
147            "invalid_scheme" => Self::InvalidScheme,
148            "unsupported_scheme" => Self::UnsupportedScheme,
149            "invalid_x402_version" => Self::InvalidX402Version,
150            "invalid_transaction_state" => Self::InvalidTransactionState,
151            "unexpected_verify_error" => Self::UnexpectedVerifyError,
152            "unexpected_settle_error" => Self::UnexpectedSettleError,
153            "settlement_pending" => Self::SettlementPending,
154            "duplicate_settlement" => Self::DuplicateSettlement,
155            "nonce_already_used" => Self::NonceAlreadyUsed,
156            "permit2_allowance_required" => Self::Permit2AllowanceRequired,
157            "invalid_upto_evm_payload_settlement_exceeds_amount" => {
158                Self::InvalidUptoEvmPayloadSettlementExceedsAmount
159            }
160            "upto_facilitator_mismatch" => Self::UptoFacilitatorMismatch,
161            "upto_unauthorized_facilitator" => Self::UptoUnauthorizedFacilitator,
162            "upto_amount_exceeds_permitted" => Self::UptoAmountExceedsPermitted,
163            "invalid_exact_solana_payload_memo_mismatch" => {
164                Self::InvalidExactSolanaPayloadMemoMismatch
165            }
166            "invalid_exact_solana_payload_memo_count" => Self::InvalidExactSolanaPayloadMemoCount,
167            "incompatible_settlement_mode" => Self::IncompatibleSettlementMode,
168            "settlement_aborted" => Self::SettlementAborted,
169            "extension_echo_mismatch" => Self::ExtensionEchoMismatch,
170            other => Self::Custom(CompactString::from(other)),
171        }
172    }
173}
174
175impl Display for ErrorReason {
176    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
177        f.write_str(self.as_str())
178    }
179}
180
181impl Serialize for ErrorReason {
182    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
183        serializer.serialize_str(self.as_str())
184    }
185}
186
187impl<'de> Deserialize<'de> for ErrorReason {
188    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
189        let s = CompactString::deserialize(deserializer)?;
190        Ok(Self::from_wire(&s))
191    }
192}
193
194impl From<&str> for ErrorReason {
195    fn from(value: &str) -> Self {
196        Self::from_wire(value)
197    }
198}
199
200impl From<CompactString> for ErrorReason {
201    fn from(value: CompactString) -> Self {
202        Self::from_wire(&value)
203    }
204}
205
206impl From<String> for ErrorReason {
207    fn from(value: String) -> Self {
208        Self::from_wire(&value)
209    }
210}
211
212#[cfg(test)]
213#[allow(clippy::unwrap_used, reason = "unit tests panic on assertion failure")]
214mod tests {
215    use super::ErrorReason;
216
217    #[test]
218    fn settlement_pending_wire_code() {
219        assert_eq!(
220            ErrorReason::SettlementPending.as_str(),
221            "settlement_pending"
222        );
223        assert_eq!(
224            ErrorReason::from_wire("settlement_pending"),
225            ErrorReason::SettlementPending
226        );
227        let json = serde_json::to_value(ErrorReason::SettlementPending).unwrap();
228        assert_eq!(json, serde_json::json!("settlement_pending"));
229        let back: ErrorReason = serde_json::from_value(json).unwrap();
230        assert_eq!(back, ErrorReason::SettlementPending);
231    }
232
233    #[test]
234    fn incompatible_mode_and_aborted_roundtrip() {
235        assert_eq!(
236            ErrorReason::from_wire("incompatible_settlement_mode"),
237            ErrorReason::IncompatibleSettlementMode
238        );
239        assert_eq!(
240            ErrorReason::from_wire("settlement_aborted"),
241            ErrorReason::SettlementAborted
242        );
243        assert_eq!(
244            ErrorReason::IncompatibleSettlementMode.as_str(),
245            "incompatible_settlement_mode"
246        );
247        assert_eq!(
248            ErrorReason::SettlementAborted.as_str(),
249            "settlement_aborted"
250        );
251        assert_eq!(
252            ErrorReason::from_wire("extension_echo_mismatch"),
253            ErrorReason::ExtensionEchoMismatch
254        );
255        assert_eq!(
256            ErrorReason::ExtensionEchoMismatch.as_str(),
257            "extension_echo_mismatch"
258        );
259    }
260
261    #[test]
262    fn custom_preserves_unknown_code() {
263        let reason = ErrorReason::from_wire("vendor_specific_diagnostic_42");
264        assert_eq!(reason.as_str(), "vendor_specific_diagnostic_42");
265        assert_eq!(
266            reason,
267            ErrorReason::Custom(compact_str::CompactString::from(
268                "vendor_specific_diagnostic_42"
269            ))
270        );
271    }
272}