1mod problem;
17mod reason;
18
19pub use problem::{AsPaymentProblem, PaymentProblem};
20pub use reason::ErrorReason;
21
22#[derive(Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum VerificationError {
26 #[error("invalid format: {0}")]
28 InvalidFormat(String),
29 #[error("payment amount is below requirements")]
31 InvalidPaymentAmount,
32 #[error("payment authorization is not yet valid")]
34 Early,
35 #[error("payment authorization has expired")]
37 Expired,
38 #[error("chain id mismatch")]
40 ChainIdMismatch,
41 #[error("payment recipient mismatch")]
43 RecipientMismatch,
44 #[error("payment asset mismatch")]
46 AssetMismatch,
47 #[error("insufficient on-chain balance")]
49 InsufficientFunds,
50 #[error("permit2 allowance required")]
52 Permit2AllowanceRequired,
53 #[error("invalid signature: {0}")]
55 InvalidSignature(String),
56 #[error("simulation failed: {0}")]
58 SimulationFailed(String),
59 #[error("unsupported chain")]
61 UnsupportedChain,
62 #[error("unsupported scheme")]
64 UnsupportedScheme,
65 #[error("accepted details do not match requirements")]
67 AcceptedRequirementsMismatch,
68 #[error("authorization nonce already used")]
70 NonceAlreadyUsed,
71 #[error("duplicate settlement attempt")]
73 DuplicateSettlement,
74 #[error("memo data mismatch")]
76 MemoMismatch,
77 #[error("memo instruction count invalid (expected 1, got {count})")]
79 MemoInstructionCountInvalid {
80 count: usize,
82 },
83 #[error("settlement amount {requested} exceeds authorised maximum {authorised}")]
85 SettlementAmountExceedsPermitted {
86 requested: String,
88 authorised: String,
90 },
91 #[error("witness.facilitator {witness} is not authorised on this facilitator")]
93 UptoFacilitatorMismatch {
94 witness: String,
96 },
97 #[error("on-chain proxy rejected settle: msg.sender does not match witness.facilitator")]
99 UptoUnauthorizedFacilitator,
100 #[error("on-chain proxy rejected settle: amount exceeds permitted maximum")]
102 UptoAmountExceedsPermitted,
103 #[error("{0}")]
105 Wire(ErrorReason),
106 #[error("extension_echo_mismatch")]
108 ExtensionEchoMismatch {
109 extension_key: String,
111 },
112}
113
114impl VerificationError {
115 #[must_use]
117 pub fn from_wire(code: &str) -> Self {
118 Self::Wire(ErrorReason::from_wire(code))
119 }
120}
121
122impl From<serde_json::Error> for VerificationError {
123 fn from(err: serde_json::Error) -> Self {
124 Self::InvalidFormat(err.to_string())
125 }
126}
127
128impl AsPaymentProblem for VerificationError {
129 fn as_payment_problem(&self) -> PaymentProblem {
130 let reason = match self {
131 Self::InvalidFormat(_) | Self::InvalidSignature(_) | Self::Early | Self::Expired => {
132 ErrorReason::InvalidPayload
133 }
134 Self::InvalidPaymentAmount
135 | Self::RecipientMismatch
136 | Self::AssetMismatch
137 | Self::AcceptedRequirementsMismatch => ErrorReason::InvalidPaymentRequirements,
138 Self::ChainIdMismatch | Self::UnsupportedChain => ErrorReason::InvalidNetwork,
139 Self::InsufficientFunds => ErrorReason::InsufficientFunds,
140 Self::Permit2AllowanceRequired => ErrorReason::Permit2AllowanceRequired,
141 Self::SimulationFailed(_) => ErrorReason::InvalidTransactionState,
142 Self::UnsupportedScheme => ErrorReason::UnsupportedScheme,
143 Self::NonceAlreadyUsed => ErrorReason::NonceAlreadyUsed,
144 Self::DuplicateSettlement => ErrorReason::DuplicateSettlement,
145 Self::MemoMismatch => ErrorReason::InvalidExactSolanaPayloadMemoMismatch,
146 Self::MemoInstructionCountInvalid { .. } => {
147 ErrorReason::InvalidExactSolanaPayloadMemoCount
148 }
149 Self::SettlementAmountExceedsPermitted { .. } => {
150 ErrorReason::InvalidUptoEvmPayloadSettlementExceedsAmount
151 }
152 Self::UptoFacilitatorMismatch { .. } => ErrorReason::UptoFacilitatorMismatch,
153 Self::UptoUnauthorizedFacilitator => ErrorReason::UptoUnauthorizedFacilitator,
154 Self::UptoAmountExceedsPermitted => ErrorReason::UptoAmountExceedsPermitted,
155 Self::Wire(reason) => reason.clone(),
156 Self::ExtensionEchoMismatch { .. } => ErrorReason::ExtensionEchoMismatch,
157 };
158 PaymentProblem::new(reason, self.to_string())
159 }
160}
161
162#[derive(Debug, thiserror::Error)]
164#[non_exhaustive]
165pub enum SettlementError {
166 #[error("on-chain settlement failed: {0}")]
168 Onchain(String),
169 #[error("settlement timed out")]
171 Timeout,
172 #[error("duplicate settlement attempt")]
174 Duplicate,
175}
176
177impl AsPaymentProblem for SettlementError {
178 fn as_payment_problem(&self) -> PaymentProblem {
179 let reason = match self {
180 Self::Onchain(_) => ErrorReason::InvalidTransactionState,
181 Self::Timeout => ErrorReason::UnexpectedSettleError,
182 Self::Duplicate => ErrorReason::DuplicateSettlement,
183 };
184 PaymentProblem::new(reason, self.to_string())
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
196#[non_exhaustive]
197pub enum FacilitatorTransportKind {
198 #[error("facilitator request timed out")]
200 Timeout,
201 #[error("facilitator HTTP status {status}")]
203 HttpStatus {
204 status: u16,
206 },
207 #[error("facilitator returned a malformed success body")]
209 MalformedSuccessBody,
210 #[error("facilitator I/O failure")]
212 Io,
213}
214
215#[derive(Debug, thiserror::Error)]
217#[non_exhaustive]
218pub enum FacilitatorError {
219 #[error(transparent)]
221 Verification(#[from] VerificationError),
222 #[error(transparent)]
224 Settlement(#[from] SettlementError),
225 #[error("{reason}: {message}")]
227 Aborted {
228 reason: String,
230 message: String,
232 },
233 #[error("on-chain error: {0}")]
235 Onchain(String),
236 #[error("{kind}")]
238 Transport {
239 kind: FacilitatorTransportKind,
241 },
242 #[error(transparent)]
244 Internal(Box<dyn std::error::Error + Send + Sync>),
245}
246
247impl FacilitatorError {
248 #[must_use]
250 pub fn aborted(reason: impl Into<String>, message: impl Into<String>) -> Self {
251 Self::Aborted {
252 reason: reason.into(),
253 message: message.into(),
254 }
255 }
256
257 #[must_use]
259 pub const fn transport(kind: FacilitatorTransportKind) -> Self {
260 Self::Transport { kind }
261 }
262
263 #[must_use]
265 pub const fn is_transport(&self) -> bool {
266 matches!(self, Self::Transport { .. })
267 }
268
269 #[must_use]
271 pub fn internal<E>(err: E) -> Self
272 where
273 E: Into<Box<dyn std::error::Error + Send + Sync>>,
274 {
275 Self::Internal(err.into())
276 }
277
278 #[must_use]
280 pub fn as_payment_problem(&self) -> Option<PaymentProblem> {
281 match self {
282 Self::Verification(e) => Some(e.as_payment_problem()),
283 Self::Settlement(e) => Some(e.as_payment_problem()),
284 Self::Aborted { reason, message } => Some(PaymentProblem::new(
285 ErrorReason::from_wire(reason),
286 format!("{reason}: {message}"),
287 )),
288 Self::Onchain(message) => Some(PaymentProblem::new(
289 ErrorReason::InvalidTransactionState,
290 message.clone(),
291 )),
292 Self::Transport { .. } => None,
293 Self::Internal(e) => Some(PaymentProblem::new(
294 ErrorReason::UnexpectedVerifyError,
295 e.to_string(),
296 )),
297 }
298 }
299}
300
301impl From<FacilitatorTransportKind> for FacilitatorError {
302 fn from(kind: FacilitatorTransportKind) -> Self {
303 Self::Transport { kind }
304 }
305}
306
307#[derive(Debug, thiserror::Error)]
309#[non_exhaustive]
310pub enum ClientError {
311 #[error("no matching payment option")]
313 NoMatchingPaymentOption,
314 #[error("request is not cloneable (streaming body?)")]
316 RequestNotCloneable,
317 #[error("failed to parse 402 response: {0}")]
319 Parse(String),
320 #[error("failed to sign payment: {0}")]
322 Signing(String),
323 #[error("payment pre-condition not met: {0}")]
325 PreConditionFailed(String),
326 #[error(transparent)]
328 Json(#[from] serde_json::Error),
329 #[error("{0}")]
331 SpendControls(String),
332 #[error("no payment requirements with a recognized paymentFlow")]
334 UnrecognizedPaymentFlow,
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn transport_is_distinct_from_verification() {
343 let err = FacilitatorError::transport(FacilitatorTransportKind::Timeout);
344 assert!(err.is_transport());
345 assert!(!matches!(err, FacilitatorError::Verification(_)));
346 assert!(
347 err.as_payment_problem().is_none(),
348 "transport is HTTP 502, not a 402 payment problem"
349 );
350 }
351
352 #[test]
353 fn http_status_kind_preserves_code() {
354 let kind = FacilitatorTransportKind::HttpStatus { status: 503 };
355 assert_eq!(kind.to_string(), "facilitator HTTP status 503");
356 let err = FacilitatorError::from(kind);
357 match err {
358 FacilitatorError::Transport {
359 kind: FacilitatorTransportKind::HttpStatus { status },
360 } => assert_eq!(status, 503, "status must round-trip"),
361 other => panic!("expected transport, got {other:?}"),
362 }
363 }
364
365 #[test]
366 fn io_kind_is_transport_not_payment_problem() {
367 let err = FacilitatorError::transport(FacilitatorTransportKind::Io);
368 assert!(err.is_transport());
369 assert!(
370 err.as_payment_problem().is_none(),
371 "I/O transport is HTTP 502, not a 402 payment problem"
372 );
373 }
374
375 #[test]
376 fn from_wire_preserves_official_exact_codes() {
377 let err = VerificationError::from_wire("eip6492_factory_not_allowed");
378 assert_eq!(
379 err.as_payment_problem().reason().as_str(),
380 "eip6492_factory_not_allowed"
381 );
382 let deployed = VerificationError::from_wire("asset_not_deployed_contract");
383 assert_eq!(
384 deployed.as_payment_problem().reason().as_str(),
385 "asset_not_deployed_contract"
386 );
387 let mismatch = VerificationError::from_wire("invalid_exact_evm_transfer_event_mismatch");
388 assert_eq!(
389 mismatch.as_payment_problem().reason().as_str(),
390 "invalid_exact_evm_transfer_event_mismatch"
391 );
392 }
393
394 #[test]
395 fn extension_echo_mismatch_maps_to_wire_reason() {
396 let err = VerificationError::ExtensionEchoMismatch {
397 extension_key: "builder-code".into(),
398 };
399 let problem = err.as_payment_problem();
400 assert_eq!(problem.reason(), ErrorReason::ExtensionEchoMismatch);
401 let wrapped = FacilitatorError::from(err);
402 let Some(wrapped_problem) = wrapped.as_payment_problem() else {
403 panic!("verification errors must map to a 402 payment problem");
404 };
405 assert_eq!(wrapped_problem.reason(), ErrorReason::ExtensionEchoMismatch);
406 }
407}