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 #[error("chain RPC missing a required method")]
216 RpcMethodMissing,
217}
218
219#[derive(Debug, thiserror::Error)]
221#[non_exhaustive]
222pub enum FacilitatorError {
223 #[error(transparent)]
225 Verification(#[from] VerificationError),
226 #[error(transparent)]
228 Settlement(#[from] SettlementError),
229 #[error("{reason}: {message}")]
231 Aborted {
232 reason: String,
234 message: String,
236 },
237 #[error("on-chain error: {0}")]
239 Onchain(String),
240 #[error("{kind}")]
242 Transport {
243 kind: FacilitatorTransportKind,
245 },
246 #[error(transparent)]
248 Internal(Box<dyn std::error::Error + Send + Sync>),
249}
250
251impl FacilitatorError {
252 #[must_use]
254 pub fn aborted(reason: impl Into<String>, message: impl Into<String>) -> Self {
255 Self::Aborted {
256 reason: reason.into(),
257 message: message.into(),
258 }
259 }
260
261 #[must_use]
263 pub const fn transport(kind: FacilitatorTransportKind) -> Self {
264 Self::Transport { kind }
265 }
266
267 #[must_use]
269 pub const fn is_transport(&self) -> bool {
270 matches!(self, Self::Transport { .. })
271 }
272
273 #[must_use]
275 pub fn internal<E>(err: E) -> Self
276 where
277 E: Into<Box<dyn std::error::Error + Send + Sync>>,
278 {
279 Self::Internal(err.into())
280 }
281
282 #[must_use]
284 pub fn as_payment_problem(&self) -> Option<PaymentProblem> {
285 match self {
286 Self::Verification(e) => Some(e.as_payment_problem()),
287 Self::Settlement(e) => Some(e.as_payment_problem()),
288 Self::Aborted { reason, message } => Some(PaymentProblem::new(
289 ErrorReason::from_wire(reason),
290 format!("{reason}: {message}"),
291 )),
292 Self::Onchain(message) => Some(PaymentProblem::new(
293 ErrorReason::InvalidTransactionState,
294 message.clone(),
295 )),
296 Self::Transport { .. } => None,
297 Self::Internal(e) => Some(PaymentProblem::new(
298 ErrorReason::UnexpectedVerifyError,
299 e.to_string(),
300 )),
301 }
302 }
303}
304
305impl From<FacilitatorTransportKind> for FacilitatorError {
306 fn from(kind: FacilitatorTransportKind) -> Self {
307 Self::Transport { kind }
308 }
309}
310
311#[derive(Debug, thiserror::Error)]
313#[non_exhaustive]
314pub enum ClientError {
315 #[error("no matching payment option")]
317 NoMatchingPaymentOption,
318 #[error("request is not cloneable (streaming body?)")]
320 RequestNotCloneable,
321 #[error("failed to parse 402 response: {0}")]
323 Parse(String),
324 #[error("failed to sign payment: {0}")]
326 Signing(String),
327 #[error("payment pre-condition not met: {0}")]
329 PreConditionFailed(String),
330 #[error(transparent)]
332 Json(#[from] serde_json::Error),
333 #[error("{0}")]
335 SpendControls(String),
336 #[error("no payment requirements with a recognized paymentFlow")]
338 UnrecognizedPaymentFlow,
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn transport_is_distinct_from_verification() {
347 let err = FacilitatorError::transport(FacilitatorTransportKind::Timeout);
348 assert!(err.is_transport());
349 assert!(!matches!(err, FacilitatorError::Verification(_)));
350 assert!(
351 err.as_payment_problem().is_none(),
352 "transport is HTTP 502, not a 402 payment problem"
353 );
354 }
355
356 #[test]
357 fn http_status_kind_preserves_code() {
358 let kind = FacilitatorTransportKind::HttpStatus { status: 503 };
359 assert_eq!(kind.to_string(), "facilitator HTTP status 503");
360 let err = FacilitatorError::from(kind);
361 match err {
362 FacilitatorError::Transport {
363 kind: FacilitatorTransportKind::HttpStatus { status },
364 } => assert_eq!(status, 503, "status must round-trip"),
365 other => panic!("expected transport, got {other:?}"),
366 }
367 }
368
369 #[test]
370 fn io_kind_is_transport_not_payment_problem() {
371 let err = FacilitatorError::transport(FacilitatorTransportKind::Io);
372 assert!(err.is_transport());
373 assert!(
374 err.as_payment_problem().is_none(),
375 "I/O transport is HTTP 502, not a 402 payment problem"
376 );
377 }
378
379 #[test]
380 fn rpc_method_missing_is_transport_not_payment_problem() {
381 let kind = FacilitatorTransportKind::RpcMethodMissing;
382 assert_eq!(kind.to_string(), "chain RPC missing a required method");
383 let err = FacilitatorError::transport(kind);
384 assert!(err.is_transport());
385 assert!(
386 err.as_payment_problem().is_none(),
387 "missing RPC method is HTTP 502, not a 402 payment problem"
388 );
389 }
390
391 #[test]
392 fn from_wire_preserves_official_exact_codes() {
393 let err = VerificationError::from_wire("eip6492_factory_not_allowed");
394 assert_eq!(
395 err.as_payment_problem().reason().as_str(),
396 "eip6492_factory_not_allowed"
397 );
398 let deployed = VerificationError::from_wire("asset_not_deployed_contract");
399 assert_eq!(
400 deployed.as_payment_problem().reason().as_str(),
401 "asset_not_deployed_contract"
402 );
403 let mismatch = VerificationError::from_wire("invalid_exact_evm_transfer_event_mismatch");
404 assert_eq!(
405 mismatch.as_payment_problem().reason().as_str(),
406 "invalid_exact_evm_transfer_event_mismatch"
407 );
408 }
409
410 #[test]
411 fn extension_echo_mismatch_maps_to_wire_reason() {
412 let err = VerificationError::ExtensionEchoMismatch {
413 extension_key: "builder-code".into(),
414 };
415 let problem = err.as_payment_problem();
416 assert_eq!(problem.reason(), ErrorReason::ExtensionEchoMismatch);
417 let wrapped = FacilitatorError::from(err);
418 let Some(wrapped_problem) = wrapped.as_payment_problem() else {
419 panic!("verification errors must map to a 402 payment problem");
420 };
421 assert_eq!(wrapped_problem.reason(), ErrorReason::ExtensionEchoMismatch);
422 }
423}