1mod problem;
18mod reason;
19
20pub use problem::{AsPaymentProblem, PaymentProblem};
21pub use reason::ErrorReason;
22
23#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum VerificationError {
27 #[error("invalid format: {0}")]
29 InvalidFormat(String),
30 #[error("payment amount is below requirements")]
32 InvalidPaymentAmount,
33 #[error("payment authorization is not yet valid")]
35 Early,
36 #[error("payment authorization has expired")]
38 Expired,
39 #[error("chain id mismatch")]
41 ChainIdMismatch,
42 #[error("payment recipient mismatch")]
44 RecipientMismatch,
45 #[error("payment asset mismatch")]
47 AssetMismatch,
48 #[error("insufficient on-chain balance")]
50 InsufficientFunds,
51 #[error("permit2 allowance required")]
53 Permit2AllowanceRequired,
54 #[error("invalid signature: {0}")]
56 InvalidSignature(String),
57 #[error("simulation failed: {0}")]
59 SimulationFailed(String),
60 #[error("unsupported chain")]
62 UnsupportedChain,
63 #[error("unsupported scheme")]
65 UnsupportedScheme,
66 #[error("accepted details do not match requirements")]
68 AcceptedRequirementsMismatch,
69 #[error("authorization nonce already used")]
71 NonceAlreadyUsed,
72 #[error("duplicate settlement attempt")]
74 DuplicateSettlement,
75 #[error("memo data mismatch")]
77 MemoMismatch,
78 #[error("memo instruction count invalid (expected 1, got {count})")]
80 MemoInstructionCountInvalid {
81 count: usize,
83 },
84 #[error("settlement amount {requested} exceeds authorised maximum {authorised}")]
86 SettlementAmountExceedsPermitted {
87 requested: String,
89 authorised: String,
91 },
92 #[error("witness.facilitator {witness} is not authorised on this facilitator")]
94 UptoFacilitatorMismatch {
95 witness: String,
97 },
98 #[error("on-chain proxy rejected settle: msg.sender does not match witness.facilitator")]
100 UptoUnauthorizedFacilitator,
101 #[error("on-chain proxy rejected settle: amount exceeds permitted maximum")]
103 UptoAmountExceedsPermitted,
104 #[error("{0}")]
106 Wire(ErrorReason),
107 #[error("extension_echo_mismatch")]
109 ExtensionEchoMismatch {
110 extension_key: String,
112 },
113}
114
115impl VerificationError {
116 #[must_use]
118 pub fn from_wire(code: &str) -> Self {
119 Self::Wire(ErrorReason::from_wire(code))
120 }
121}
122
123impl From<serde_json::Error> for VerificationError {
124 fn from(err: serde_json::Error) -> Self {
125 Self::InvalidFormat(err.to_string())
126 }
127}
128
129impl AsPaymentProblem for VerificationError {
130 fn as_payment_problem(&self) -> PaymentProblem {
131 let reason = match self {
132 Self::InvalidFormat(_) | Self::InvalidSignature(_) | Self::Early | Self::Expired => {
133 ErrorReason::InvalidPayload
134 }
135 Self::InvalidPaymentAmount
136 | Self::RecipientMismatch
137 | Self::AssetMismatch
138 | Self::AcceptedRequirementsMismatch => ErrorReason::InvalidPaymentRequirements,
139 Self::ChainIdMismatch | Self::UnsupportedChain => ErrorReason::InvalidNetwork,
140 Self::InsufficientFunds => ErrorReason::InsufficientFunds,
141 Self::Permit2AllowanceRequired => ErrorReason::Permit2AllowanceRequired,
142 Self::SimulationFailed(_) => ErrorReason::InvalidTransactionState,
143 Self::UnsupportedScheme => ErrorReason::UnsupportedScheme,
144 Self::NonceAlreadyUsed => ErrorReason::NonceAlreadyUsed,
145 Self::DuplicateSettlement => ErrorReason::DuplicateSettlement,
146 Self::MemoMismatch => ErrorReason::InvalidExactSolanaPayloadMemoMismatch,
147 Self::MemoInstructionCountInvalid { .. } => {
148 ErrorReason::InvalidExactSolanaPayloadMemoCount
149 }
150 Self::SettlementAmountExceedsPermitted { .. } => {
151 ErrorReason::InvalidUptoEvmPayloadSettlementExceedsAmount
152 }
153 Self::UptoFacilitatorMismatch { .. } => ErrorReason::UptoFacilitatorMismatch,
154 Self::UptoUnauthorizedFacilitator => ErrorReason::UptoUnauthorizedFacilitator,
155 Self::UptoAmountExceedsPermitted => ErrorReason::UptoAmountExceedsPermitted,
156 Self::Wire(reason) => reason.clone(),
157 Self::ExtensionEchoMismatch { .. } => ErrorReason::ExtensionEchoMismatch,
158 };
159 PaymentProblem::new(reason, self.to_string())
160 }
161}
162
163#[derive(Debug, thiserror::Error)]
165#[non_exhaustive]
166pub enum SettlementError {
167 #[error("on-chain settlement failed: {0}")]
169 Onchain(String),
170 #[error("settlement timed out")]
172 Timeout,
173 #[error("duplicate settlement attempt")]
175 Duplicate,
176}
177
178impl AsPaymentProblem for SettlementError {
179 fn as_payment_problem(&self) -> PaymentProblem {
180 let reason = match self {
181 Self::Onchain(_) => ErrorReason::InvalidTransactionState,
182 Self::Timeout => ErrorReason::UnexpectedSettleError,
183 Self::Duplicate => ErrorReason::DuplicateSettlement,
184 };
185 PaymentProblem::new(reason, self.to_string())
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
197#[non_exhaustive]
198pub enum FacilitatorTransportKind {
199 #[error("facilitator request timed out")]
201 Timeout,
202 #[error("facilitator HTTP status {status}")]
204 HttpStatus {
205 status: u16,
207 },
208 #[error("facilitator returned a malformed success body")]
210 MalformedSuccessBody,
211 #[error("facilitator I/O failure")]
213 Io,
214}
215
216#[derive(Debug, thiserror::Error)]
218#[non_exhaustive]
219pub enum FacilitatorError {
220 #[error(transparent)]
222 Verification(#[from] VerificationError),
223 #[error(transparent)]
225 Settlement(#[from] SettlementError),
226 #[error("{reason}: {message}")]
228 Aborted {
229 reason: String,
231 message: String,
233 },
234 #[error("on-chain error: {0}")]
236 Onchain(String),
237 #[error("{kind}")]
239 Transport {
240 kind: FacilitatorTransportKind,
242 },
243 #[error(transparent)]
245 Internal(Box<dyn std::error::Error + Send + Sync>),
246}
247
248impl FacilitatorError {
249 #[must_use]
251 pub fn aborted(reason: impl Into<String>, message: impl Into<String>) -> Self {
252 Self::Aborted {
253 reason: reason.into(),
254 message: message.into(),
255 }
256 }
257
258 #[must_use]
260 pub const fn transport(kind: FacilitatorTransportKind) -> Self {
261 Self::Transport { kind }
262 }
263
264 #[must_use]
266 pub const fn is_transport(&self) -> bool {
267 matches!(self, Self::Transport { .. })
268 }
269
270 #[must_use]
272 pub fn internal<E>(err: E) -> Self
273 where
274 E: Into<Box<dyn std::error::Error + Send + Sync>>,
275 {
276 Self::Internal(err.into())
277 }
278
279 #[must_use]
281 pub fn as_payment_problem(&self) -> Option<PaymentProblem> {
282 match self {
283 Self::Verification(e) => Some(e.as_payment_problem()),
284 Self::Settlement(e) => Some(e.as_payment_problem()),
285 Self::Aborted { reason, message } => Some(PaymentProblem::new(
286 ErrorReason::from_wire(reason),
287 format!("{reason}: {message}"),
288 )),
289 Self::Onchain(message) => Some(PaymentProblem::new(
290 ErrorReason::InvalidTransactionState,
291 message.clone(),
292 )),
293 Self::Transport { .. } => None,
294 Self::Internal(e) => Some(PaymentProblem::new(
295 ErrorReason::UnexpectedVerifyError,
296 e.to_string(),
297 )),
298 }
299 }
300}
301
302impl From<FacilitatorTransportKind> for FacilitatorError {
303 fn from(kind: FacilitatorTransportKind) -> Self {
304 Self::Transport { kind }
305 }
306}
307
308#[derive(Debug, thiserror::Error)]
310#[non_exhaustive]
311pub enum ClientError {
312 #[error("no matching payment option")]
314 NoMatchingPaymentOption,
315 #[error("request is not cloneable (streaming body?)")]
317 RequestNotCloneable,
318 #[error("failed to parse 402 response: {0}")]
320 Parse(String),
321 #[error("failed to sign payment: {0}")]
323 Signing(String),
324 #[error("payment pre-condition not met: {0}")]
326 PreConditionFailed(String),
327 #[error(transparent)]
329 Json(#[from] serde_json::Error),
330 #[error("{0}")]
332 SpendControls(String),
333 #[error("no payment requirements with a recognized paymentFlow")]
335 UnrecognizedPaymentFlow,
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn transport_is_distinct_from_verification() {
344 let err = FacilitatorError::transport(FacilitatorTransportKind::Timeout);
345 assert!(err.is_transport());
346 assert!(!matches!(err, FacilitatorError::Verification(_)));
347 assert!(
348 err.as_payment_problem().is_none(),
349 "transport is HTTP 502, not a 402 payment problem"
350 );
351 }
352
353 #[test]
354 fn http_status_kind_preserves_code() {
355 let kind = FacilitatorTransportKind::HttpStatus { status: 503 };
356 assert_eq!(kind.to_string(), "facilitator HTTP status 503");
357 let err = FacilitatorError::from(kind);
358 match err {
359 FacilitatorError::Transport {
360 kind: FacilitatorTransportKind::HttpStatus { status },
361 } => assert_eq!(status, 503, "status must round-trip"),
362 other => panic!("expected transport, got {other:?}"),
363 }
364 }
365
366 #[test]
367 fn io_kind_is_transport_not_payment_problem() {
368 let err = FacilitatorError::transport(FacilitatorTransportKind::Io);
369 assert!(err.is_transport());
370 assert!(
371 err.as_payment_problem().is_none(),
372 "I/O transport is HTTP 502, not a 402 payment problem"
373 );
374 }
375
376 #[test]
377 fn from_wire_preserves_official_exact_codes() {
378 let err = VerificationError::from_wire("eip6492_factory_not_allowed");
379 assert_eq!(
380 err.as_payment_problem().reason().as_str(),
381 "eip6492_factory_not_allowed"
382 );
383 let deployed = VerificationError::from_wire("asset_not_deployed_contract");
384 assert_eq!(
385 deployed.as_payment_problem().reason().as_str(),
386 "asset_not_deployed_contract"
387 );
388 let mismatch = VerificationError::from_wire("invalid_exact_evm_transfer_event_mismatch");
389 assert_eq!(
390 mismatch.as_payment_problem().reason().as_str(),
391 "invalid_exact_evm_transfer_event_mismatch"
392 );
393 }
394
395 #[test]
396 fn extension_echo_mismatch_maps_to_wire_reason() {
397 let err = VerificationError::ExtensionEchoMismatch {
398 extension_key: "builder-code".into(),
399 };
400 let problem = err.as_payment_problem();
401 assert_eq!(problem.reason(), ErrorReason::ExtensionEchoMismatch);
402 let wrapped = FacilitatorError::from(err);
403 let Some(wrapped_problem) = wrapped.as_payment_problem() else {
404 panic!("verification errors must map to a 402 payment problem");
405 };
406 assert_eq!(wrapped_problem.reason(), ErrorReason::ExtensionEchoMismatch);
407 }
408}