From 6a67f9a72b6bb123803322351a133fe050369465 Mon Sep 17 00:00:00 2001 From: arisu6804 Date: Thu, 27 Aug 2026 21:44:10 +0530 Subject: [PATCH] feat: add exhaustive pool error taxonomy --- docs/errors.md | 111 ++++++++++ src/error_taxonomy.rs | 478 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 10 + 3 files changed, 599 insertions(+) create mode 100644 docs/errors.md create mode 100644 src/error_taxonomy.rs diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..710475a --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,111 @@ +# StableRoute pool error taxonomy + +`RouterError` is the contract's typed negative-path ABI. Every pool failure +uses one of its append-only numeric codes; callers should never need to parse a +panic string or infer an error from an empty return value. The read-only +`get_error_catalog` entrypoint exposes the same code/name/class metadata used +by the SDK and the test matrix. + +## Codes + +| Code | Error | Class | Retryable | Configuration fix | +|---:|---|---|---|---| +| 1 | `AlreadyInitialized` | Governance | no | no | +| 2 | `NotInitialized` | Governance | no | yes | +| 3 | `SourceEqualsDestination` | Input | no | no | +| 4 | `FeeBpsTooHigh` | Limit | no | yes | +| 5 | `PairNotRegistered` | State | no | yes | +| 6 | `AmountMustBePositive` | Input | no | no | +| 7 | `NoPendingAdminTransfer` | Governance | no | yes | +| 8 | `NotPendingAdmin` | Authorization | no | no | +| 9 | `ContractPaused` | Safety | yes | yes | +| 10 | `AmountBelowMin` | Limit | no | yes | +| 11 | `AmountAboveMax` | Limit | no | yes | +| 12 | `InsufficientLiquidity` | Safety | yes | yes | +| 13 | `MigrationVersionMismatch` | Governance | no | yes | +| 14 | `TimelockNotElapsed` | Governance | yes | no | +| 15 | `ReentrantCall` | Safety | yes | no | +| 16 | `NotAuthorized` | Authorization | no | yes | +| 17 | `RouteCooldownActive` | Safety | yes | yes | +| 18 | `BatchTooLarge` | Limit | no | yes | +| 19 | `EmptyBatch` | Input | no | no | +| 20 | `CooldownTooLarge` | Limit | no | yes | +| 21 | `ZeroFeeCap` | Input | no | yes | + +Codes are append-only. Never renumber, reuse, or change the meaning of an +existing code. A future failure must append a new variant and add its catalog +metadata, exact trigger, and negative-path tests in the same change. + +## Handling rules + +Input and authorization errors are deterministic for the same call and should +be corrected before retrying. Retryable safety errors can succeed after state +changes: an administrator may unpause the router, liquidity may be refreshed, +a cooldown may elapse, or a reentrant invocation may finish. The SDK should +use the catalog's `retryable` bit as guidance, not as permission to retry in a +tight loop. + +Configuration-fix metadata means that an administrator can normally resolve +the condition, not that the caller is authorized to change configuration. +`NotAuthorized` remains a caller/role failure even when an admin could rotate +the oracle or update a setting. `PairNotRegistered` requires registration; +setting a fee or liquidity value must never create an implicit pair. + +## Pool negative-path matrix + +The taxonomy is exhaustive across the pool boundary: + +| Operation | Preconditions | Expected error | +|---|---|---| +| constructor/init | duplicate initialization | `AlreadyInitialized` | +| admin read/write | absent admin | `NotInitialized` | +| register | identical source/destination | `SourceEqualsDestination` | +| register/configure | unknown pair | `PairNotRegistered` | +| fee configuration | fee above max | `FeeBpsTooHigh` | +| route/quote | non-positive amount | `AmountMustBePositive` | +| route | amount below floor | `AmountBelowMin` | +| route | amount above ceiling | `AmountAboveMax` | +| route/quote | finite liquidity exhausted | `InsufficientLiquidity` | +| route | cooldown not elapsed | `RouteCooldownActive` | +| pair config | unauthorized oracle/caller | `NotAuthorized` | +| batch | zero entries | `EmptyBatch` | +| batch | more than max entries | `BatchTooLarge` | +| cooldown config | cooldown above max | `CooldownTooLarge` | +| fee cap config | zero cap | `ZeroFeeCap` | +| admin handover | no pending admin | `NoPendingAdminTransfer` | +| admin handover | wrong pending caller | `NotPendingAdmin` | +| admin handover | timelock active | `TimelockNotElapsed` | +| migration | schema is not v1 | `MigrationVersionMismatch` | +| any gated write | router paused | `ContractPaused` | +| guarded route | lock already held | `ReentrantCall` | + +The same typed error is used by `quote_route` and `compute_route_fee` for the +same precondition where both operations expose it. Quote is read-only and does +not acquire the route reentrancy lock, but it still follows the registration, +amount, bounds, and liquidity taxonomy. + +## Client integration + +Clients should decode the contract error code, look it up with `from_code` or +the catalog, and retain the raw code in logs. Unknown future codes must remain +unknown; do not coerce them to a generic error or assume that a missing code +means success. The symbolic name is intended for display and metrics, while +the numeric code is the stable switch value. + +Events and off-chain logs should include the operation, pair direction, and +error code without embedding arbitrary user input in metric labels. A failed +transaction is atomic, so an error means the guarded state mutation did not +commit. A caller may still see a prior event from an earlier successful +transaction for the same pair; error handling must not treat that as a rollback +signal. + +## Review checklist + +- new failures use `RouterError`, never an untyped `panic!`; +- error code is appended and documented; +- `get_error_catalog` metadata is updated; +- exact `#[should_panic(expected = "Error(Contract, #N)")]` coverage exists; +- success paths and failure paths are checked for storage mutations; +- paused, unauthorized, malformed, boundary, and reentrant cases are tested; +- `cargo fmt`, `cargo clippy --all-targets --all-features`, and `cargo test` + are run before release. diff --git a/src/error_taxonomy.rs b/src/error_taxonomy.rs new file mode 100644 index 0000000..a1d78ee --- /dev/null +++ b/src/error_taxonomy.rs @@ -0,0 +1,478 @@ +//! Exhaustive, stable error metadata for pool operations. +//! +//! `RouterError` is the ABI enum. This module adds the metadata needed by +//! clients and reviewers: a stable code, operation-independent category, +//! retry guidance, and whether the error can be resolved by configuration. +//! Adding metadata here does not change the serialized `RouterError` values. + +use crate::RouterError; +use soroban_sdk::{contracttype, Env, Vec}; + +/// Broad class used by SDKs for consistent negative-path handling. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ErrorClass { + /// Caller input cannot satisfy the contract's preconditions. + Input, + /// Caller lacks the required role or signature. + Authorization, + /// Pair or contract state does not permit this operation. + State, + /// Amount, rate, or batch limit was exceeded. + Limit, + /// Governance or deployment lifecycle failure. + Governance, + /// Safety guard such as pause, reentrancy, or liquidity protection. + Safety, +} + +/// Stable symbolic name for a catalog entry. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ErrorName { + AlreadyInitialized, + NotInitialized, + SourceEqualsDestination, + FeeBpsTooHigh, + PairNotRegistered, + AmountMustBePositive, + NoPendingAdminTransfer, + NotPendingAdmin, + ContractPaused, + AmountBelowMin, + AmountAboveMax, + InsufficientLiquidity, + MigrationVersionMismatch, + TimelockNotElapsed, + ReentrantCall, + NotAuthorized, + RouteCooldownActive, + BatchTooLarge, + EmptyBatch, + CooldownTooLarge, + ZeroFeeCap, +} + +/// Metadata exposed by the read-only error catalog. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ErrorDescriptor { + /// Append-only numeric code from `RouterError`. + pub code: u32, + /// Stable machine-readable symbolic name. + pub name: ErrorName, + /// Handling category. + pub class: ErrorClass, + /// Whether retrying the same request can succeed without changing input. + pub retryable: bool, + /// Whether an administrator can normally resolve it by changing config. + pub configuration_fix: bool, +} + +/// Operations that have a typed failure contract at the pool boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PoolOperation { + Register, + ConfigureFee, + ConfigureBounds, + ConfigureLiquidity, + ConfigureCooldown, + Quote, + Compute, + Governance, + Migration, + Batch, +} + +/// Map a stable error to the operation family it protects. +pub fn operation_for(error: RouterError) -> PoolOperation { + match error { + RouterError::AlreadyInitialized + | RouterError::NotInitialized + | RouterError::NoPendingAdminTransfer + | RouterError::NotPendingAdmin + | RouterError::TimelockNotElapsed => PoolOperation::Governance, + RouterError::SourceEqualsDestination | RouterError::PairNotRegistered => { + PoolOperation::Register + } + RouterError::FeeBpsTooHigh | RouterError::ZeroFeeCap => PoolOperation::ConfigureFee, + RouterError::AmountMustBePositive + | RouterError::AmountBelowMin + | RouterError::AmountAboveMax => PoolOperation::ConfigureBounds, + RouterError::InsufficientLiquidity => PoolOperation::Compute, + RouterError::MigrationVersionMismatch => PoolOperation::Migration, + RouterError::ContractPaused | RouterError::ReentrantCall => PoolOperation::Compute, + RouterError::NotAuthorized => PoolOperation::ConfigureLiquidity, + RouterError::RouteCooldownActive => PoolOperation::Compute, + RouterError::BatchTooLarge | RouterError::EmptyBatch => PoolOperation::Batch, + RouterError::CooldownTooLarge => PoolOperation::ConfigureCooldown, + } +} + +/// Return the complete append-only error list in numeric order. +pub const fn all_errors() -> [RouterError; 21] { + [ + RouterError::AlreadyInitialized, + RouterError::NotInitialized, + RouterError::SourceEqualsDestination, + RouterError::FeeBpsTooHigh, + RouterError::PairNotRegistered, + RouterError::AmountMustBePositive, + RouterError::NoPendingAdminTransfer, + RouterError::NotPendingAdmin, + RouterError::ContractPaused, + RouterError::AmountBelowMin, + RouterError::AmountAboveMax, + RouterError::InsufficientLiquidity, + RouterError::MigrationVersionMismatch, + RouterError::TimelockNotElapsed, + RouterError::ReentrantCall, + RouterError::NotAuthorized, + RouterError::RouteCooldownActive, + RouterError::BatchTooLarge, + RouterError::EmptyBatch, + RouterError::CooldownTooLarge, + RouterError::ZeroFeeCap, + ] +} + +/// Return the exact stable descriptor for a router error. +pub const fn descriptor(error: RouterError) -> ErrorDescriptor { + let (name, class, retryable, configuration_fix) = match error { + RouterError::AlreadyInitialized => ( + ErrorName::AlreadyInitialized, + ErrorClass::Governance, + false, + false, + ), + RouterError::NotInitialized => ( + ErrorName::NotInitialized, + ErrorClass::Governance, + false, + true, + ), + RouterError::SourceEqualsDestination => ( + ErrorName::SourceEqualsDestination, + ErrorClass::Input, + false, + false, + ), + RouterError::FeeBpsTooHigh => (ErrorName::FeeBpsTooHigh, ErrorClass::Limit, false, true), + RouterError::PairNotRegistered => { + (ErrorName::PairNotRegistered, ErrorClass::State, false, true) + } + RouterError::AmountMustBePositive => ( + ErrorName::AmountMustBePositive, + ErrorClass::Input, + false, + false, + ), + RouterError::NoPendingAdminTransfer => ( + ErrorName::NoPendingAdminTransfer, + ErrorClass::Governance, + false, + true, + ), + RouterError::NotPendingAdmin => ( + ErrorName::NotPendingAdmin, + ErrorClass::Authorization, + false, + false, + ), + RouterError::ContractPaused => (ErrorName::ContractPaused, ErrorClass::Safety, true, true), + RouterError::AmountBelowMin => (ErrorName::AmountBelowMin, ErrorClass::Limit, false, true), + RouterError::AmountAboveMax => (ErrorName::AmountAboveMax, ErrorClass::Limit, false, true), + RouterError::InsufficientLiquidity => ( + ErrorName::InsufficientLiquidity, + ErrorClass::Safety, + true, + true, + ), + RouterError::MigrationVersionMismatch => ( + ErrorName::MigrationVersionMismatch, + ErrorClass::Governance, + false, + true, + ), + RouterError::TimelockNotElapsed => ( + ErrorName::TimelockNotElapsed, + ErrorClass::Governance, + true, + false, + ), + RouterError::ReentrantCall => (ErrorName::ReentrantCall, ErrorClass::Safety, true, false), + RouterError::NotAuthorized => ( + ErrorName::NotAuthorized, + ErrorClass::Authorization, + false, + true, + ), + RouterError::RouteCooldownActive => ( + ErrorName::RouteCooldownActive, + ErrorClass::Safety, + true, + true, + ), + RouterError::BatchTooLarge => (ErrorName::BatchTooLarge, ErrorClass::Limit, false, true), + RouterError::EmptyBatch => (ErrorName::EmptyBatch, ErrorClass::Input, false, false), + RouterError::CooldownTooLarge => { + (ErrorName::CooldownTooLarge, ErrorClass::Limit, false, true) + } + RouterError::ZeroFeeCap => (ErrorName::ZeroFeeCap, ErrorClass::Input, false, true), + }; + ErrorDescriptor { + code: error as u32, + name, + class, + retryable, + configuration_fix, + } +} + +/// Look up an error by its wire code without exposing a generic failure. +pub const fn from_code(code: u32) -> Option { + match code { + 1 => Some(RouterError::AlreadyInitialized), + 2 => Some(RouterError::NotInitialized), + 3 => Some(RouterError::SourceEqualsDestination), + 4 => Some(RouterError::FeeBpsTooHigh), + 5 => Some(RouterError::PairNotRegistered), + 6 => Some(RouterError::AmountMustBePositive), + 7 => Some(RouterError::NoPendingAdminTransfer), + 8 => Some(RouterError::NotPendingAdmin), + 9 => Some(RouterError::ContractPaused), + 10 => Some(RouterError::AmountBelowMin), + 11 => Some(RouterError::AmountAboveMax), + 12 => Some(RouterError::InsufficientLiquidity), + 13 => Some(RouterError::MigrationVersionMismatch), + 14 => Some(RouterError::TimelockNotElapsed), + 15 => Some(RouterError::ReentrantCall), + 16 => Some(RouterError::NotAuthorized), + 17 => Some(RouterError::RouteCooldownActive), + 18 => Some(RouterError::BatchTooLarge), + 19 => Some(RouterError::EmptyBatch), + 20 => Some(RouterError::CooldownTooLarge), + 21 => Some(RouterError::ZeroFeeCap), + _ => None, + } +} + +/// Return the catalog as a read-only Soroban value for client discovery. +pub fn catalog(env: &Env) -> Vec { + let mut result = Vec::new(env); + for error in all_errors() { + result.push_back(descriptor(error)); + } + result +} + +/// Return the retry recommendation without requiring clients to unpack metadata. +pub const fn should_retry(error: RouterError) -> bool { + descriptor(error).retryable +} + +/// Return whether an administrator action is a plausible remediation. +pub const fn has_configuration_fix(error: RouterError) -> bool { + descriptor(error).configuration_fix +} + +/// Return only errors associated with one operation family. +pub fn catalog_for_operation(env: &Env, operation: PoolOperation) -> Vec { + let mut result = Vec::new(env); + for error in all_errors() { + if operation_for(error) == operation { + result.push_back(descriptor(error)); + } + } + result +} + +/// Return the number of errors in one class. Useful for catalog sanity checks. +pub fn class_count(class: ErrorClass) -> u32 { + let errors = all_errors(); + let mut count = 0; + let mut index = 0; + while index < errors.len() { + if descriptor(errors[index]).class == class { + count += 1; + } + index += 1; + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_are_contiguous_and_append_only() { + let errors = all_errors(); + for (index, error) in errors.iter().enumerate() { + assert_eq!(descriptor(*error).code, index as u32 + 1); + } + } + + #[test] + fn every_code_round_trips() { + for error in all_errors() { + let code = descriptor(error).code; + assert_eq!(from_code(code), Some(error)); + } + assert_eq!(from_code(0), None); + assert_eq!(from_code(22), None); + assert_eq!(from_code(u32::MAX), None); + } + + #[test] + fn every_descriptor_has_metadata() { + for error in all_errors() { + let details = descriptor(error); + assert!(details.code > 0); + assert!(matches!(details.name, _)); + assert!(matches!(details.class, _)); + } + } + + #[test] + fn safety_errors_are_retryable_when_state_can_change() { + assert!(descriptor(RouterError::ContractPaused).retryable); + assert!(descriptor(RouterError::InsufficientLiquidity).retryable); + assert!(descriptor(RouterError::RouteCooldownActive).retryable); + assert!(!descriptor(RouterError::AmountMustBePositive).retryable); + } + + #[test] + fn deterministic_input_and_authorization_errors_are_not_retryable() { + for error in [ + RouterError::SourceEqualsDestination, + RouterError::AmountMustBePositive, + RouterError::NotPendingAdmin, + RouterError::NotAuthorized, + RouterError::EmptyBatch, + ] { + assert!(!descriptor(error).retryable); + } + } + + #[test] + fn configuration_fix_metadata_is_explicit() { + assert!(descriptor(RouterError::FeeBpsTooHigh).configuration_fix); + assert!(descriptor(RouterError::PairNotRegistered).configuration_fix); + assert!(descriptor(RouterError::CooldownTooLarge).configuration_fix); + assert!(!descriptor(RouterError::SourceEqualsDestination).configuration_fix); + } + + #[test] + fn operation_mapping_covers_every_error() { + for error in all_errors() { + assert!(matches!( + operation_for(error), + PoolOperation::Register + | PoolOperation::ConfigureFee + | PoolOperation::ConfigureBounds + | PoolOperation::ConfigureLiquidity + | PoolOperation::ConfigureCooldown + | PoolOperation::Quote + | PoolOperation::Compute + | PoolOperation::Governance + | PoolOperation::Migration + | PoolOperation::Batch + )); + } + } + + #[test] + fn catalog_order_matches_wire_order() { + let env = Env::default(); + let entries = catalog(&env); + assert_eq!(entries.len(), 21); + for (index, entry) in entries.iter().enumerate() { + assert_eq!(entry.code, index as u32 + 1); + } + } + + #[test] + fn unknown_codes_are_not_coerced_into_known_errors() { + for code in [22, 42, 100, u32::MAX] { + assert!(from_code(code).is_none()); + } + } + + #[test] + fn retry_and_configuration_helpers_match_descriptors() { + for error in all_errors() { + assert_eq!(should_retry(error), descriptor(error).retryable); + assert_eq!( + has_configuration_fix(error), + descriptor(error).configuration_fix + ); + } + } + + #[test] + fn operation_catalogs_are_disjoint_subsets() { + let env = Env::default(); + let operations = [ + PoolOperation::Register, + PoolOperation::ConfigureFee, + PoolOperation::ConfigureBounds, + PoolOperation::ConfigureLiquidity, + PoolOperation::ConfigureCooldown, + PoolOperation::Quote, + PoolOperation::Compute, + PoolOperation::Governance, + PoolOperation::Migration, + PoolOperation::Batch, + ]; + let mut total = 0; + for operation in operations { + let entries = catalog_for_operation(&env, operation); + total += entries.len(); + for entry in entries.iter() { + assert_eq!(operation_for(from_code(entry.code).unwrap()), operation); + } + } + assert_eq!(total, all_errors().len() as u32); + } + + #[test] + fn class_counts_cover_all_codes() { + let total = class_count(ErrorClass::Input) + + class_count(ErrorClass::Authorization) + + class_count(ErrorClass::State) + + class_count(ErrorClass::Limit) + + class_count(ErrorClass::Governance) + + class_count(ErrorClass::Safety); + assert_eq!(total, all_errors().len() as u32); + } + + #[test] + fn exact_boundary_errors_have_exact_metadata() { + assert_eq!(descriptor(RouterError::BatchTooLarge).code, 18); + assert_eq!(descriptor(RouterError::EmptyBatch).code, 19); + assert_eq!(descriptor(RouterError::CooldownTooLarge).code, 20); + assert_eq!(descriptor(RouterError::ZeroFeeCap).code, 21); + assert_eq!( + descriptor(RouterError::BatchTooLarge).class, + ErrorClass::Limit + ); + assert_eq!(descriptor(RouterError::EmptyBatch).class, ErrorClass::Input); + } + + #[test] + fn state_and_safety_paths_remain_distinguishable() { + assert_eq!( + descriptor(RouterError::PairNotRegistered).class, + ErrorClass::State + ); + assert_eq!( + descriptor(RouterError::InsufficientLiquidity).class, + ErrorClass::Safety + ); + assert_eq!( + descriptor(RouterError::ContractPaused).class, + ErrorClass::Safety + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 971cbd3..24c0ecb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,8 @@ use soroban_sdk::{ Bytes, BytesN, Env, Symbol, Vec, }; +mod error_taxonomy; + /// Aggregated read of every pair-scoped storage slot (base fields). #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -548,6 +550,14 @@ impl StableRouteRouter { } } + /// Return the append-only typed error catalog for SDK discovery. + /// + /// This read-only surface lets clients display exact negative-path codes + /// without copying the enum into a separate, drift-prone configuration. + pub fn get_error_catalog(env: Env) -> Vec { + error_taxonomy::catalog(&env) + } + /// Migrate the schema from v1 to v2. Admin-gated; panics with /// MigrationVersionMismatch on a non-v1 starting state. v2 readers /// default sensibly when their new slots are absent, so the body