From e9d80bb3fe6b066aefc5b0c56d9d0b6ed10e87c8 Mon Sep 17 00:00:00 2001 From: XOUL LABS Date: Mon, 23 Feb 2026 13:29:16 +0000 Subject: [PATCH] feat: Add fallback anchor selection strategy (#72) - Configurable fallback order - Automatic failure detection and tracking - Skip down anchors automatically - Retry with max attempts - Add comprehensive tests and documentation Closes #72 --- FALLBACK_SELECTION.md | 157 ++++++++++++++++++++++++++++++++++ README.md | 2 + src/fallback.rs | 105 +++++++++++++++++++++++ src/fallback_tests.rs | 190 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 115 +++++++++++++++++++++++++ 5 files changed, 569 insertions(+) create mode 100644 FALLBACK_SELECTION.md create mode 100644 src/fallback.rs create mode 100644 src/fallback_tests.rs diff --git a/FALLBACK_SELECTION.md b/FALLBACK_SELECTION.md new file mode 100644 index 0000000..253e217 --- /dev/null +++ b/FALLBACK_SELECTION.md @@ -0,0 +1,157 @@ +# Fallback Anchor Selection + +## Overview + +Automatically reroute to fallback anchors when preferred anchor fails. + +## Features + +- ✅ Configurable fallback order +- ✅ Failure detection logic +- ✅ Automatic anchor health tracking +- ✅ Retry with max attempts + +## Usage + +### Configure Fallback Order + +```rust +let anchor_order = vec![&env, anchor1, anchor2, anchor3]; +client.configure_fallback( + &anchor_order, + &3, // max_retries + &2 // failure_threshold +); +``` + +### Record Failures + +```rust +// Automatically tracked, or manually: +client.record_anchor_failure(&anchor); +``` + +### Record Success + +```rust +client.record_anchor_success(&anchor); +``` + +### Select Next Anchor + +```rust +// Get next available anchor +let next_anchor = client.select_fallback_anchor(&Some(failed_anchor)); + +// Or start from beginning +let first_anchor = client.select_fallback_anchor(&None); +``` + +### Automatic Fallback + +```rust +// Automatically tries fallback anchors on failure +let quote_id = client.submit_quote_with_fallback( + &base_asset, + "e_asset, + &rate, + &fee_percentage, + &minimum_amount, + &maximum_amount, + &valid_until, +); +``` + +## API Methods + +```rust +// Configure fallback +pub fn configure_fallback( + anchor_order: Vec
, + max_retries: u32, + failure_threshold: u32, +) -> Result<(), Error> + +// Get configuration +pub fn get_fallback_config() -> Option + +// Record anchor state +pub fn record_anchor_failure(anchor: Address) -> Result<(), Error> +pub fn record_anchor_success(anchor: Address) -> Result<(), Error> + +// Get failure state +pub fn get_anchor_failure_state(anchor: Address) -> Option + +// Select fallback +pub fn select_fallback_anchor(failed_anchor: Option
) -> Result + +// Automatic fallback +pub fn submit_quote_with_fallback(...) -> Result +``` + +## Configuration + +```rust +pub struct FallbackConfig { + pub anchor_order: Vec
, // Ordered list to try + pub max_retries: u32, // Max retry attempts + pub failure_threshold: u32, // Failures before marking down +} +``` + +## Failure State + +```rust +pub struct AnchorFailureState { + pub anchor: Address, + pub failure_count: u32, + pub last_failure: u64, + pub is_down: bool, // true when >= threshold +} +``` + +## How It Works + +1. **Configure Order**: Set preferred anchor order +2. **Detect Failure**: Track failures per anchor +3. **Mark Down**: After threshold failures, mark anchor as down +4. **Skip Down Anchors**: Automatically skip unavailable anchors +5. **Retry**: Try next anchor in order +6. **Success Clears**: Success resets failure state + +## Example Flow + +```rust +// Setup +let order = vec![&env, primary, secondary, tertiary]; +client.configure_fallback(&order, &3, &2); + +// Primary fails twice - marked down +client.record_anchor_failure(&primary); +client.record_anchor_failure(&primary); + +// Next selection skips primary, returns secondary +let next = client.select_fallback_anchor(&None); +assert_eq!(next, secondary); + +// Secondary succeeds - clears its failure state +client.record_anchor_success(&secondary); +``` + +## Storage + +- **Config**: Persistent storage (90-day TTL) +- **Failure State**: Temporary storage (1-day TTL) + +## Best Practices + +1. **Order by preference** - Put most reliable anchors first +2. **Set appropriate threshold** - Balance sensitivity vs false positives +3. **Monitor failure states** - Track which anchors are down +4. **Clear on success** - Automatically done by `record_anchor_success` +5. **Use automatic fallback** - Let system handle retries + +## Error Handling + +- `Error::NoAnchorsAvailable` - All anchors down or exhausted retries +- `Error::InvalidConfig` - No fallback config set diff --git a/README.md b/README.md index 051f395..71aae83 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ AnchorKit is a Soroban-native toolkit for anchoring off-chain attestations to St - Endpoint configuration for attestors - Service capability discovery (deposits, withdrawals, quotes, KYC) - **Health monitoring** (latency, failures, availability) +- **Fallback anchor selection** (automatic rerouting on failure) - Event emission for all state changes - Comprehensive error handling with stable error codes @@ -101,6 +102,7 @@ const auditLog = await contract.get_audit_log(0); - **[SESSION_TRACEABILITY.md](./SESSION_TRACEABILITY.md)** - Complete feature guide with usage patterns - **[SECURE_CREDENTIALS.md](./SECURE_CREDENTIALS.md)** - Secure credential injection and management - **[HEALTH_MONITORING.md](./HEALTH_MONITORING.md)** - Anchor health monitoring interface +- **[FALLBACK_SELECTION.md](./FALLBACK_SELECTION.md)** - Automatic fallback anchor selection - **[API_SPEC.md](./API_SPEC.md)** - API specification and error codes ### Technical Documentation diff --git a/src/fallback.rs b/src/fallback.rs new file mode 100644 index 0000000..8f844c7 --- /dev/null +++ b/src/fallback.rs @@ -0,0 +1,105 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use crate::{types::QuoteData, Error}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FallbackConfig { + pub anchor_order: Vec
, // Ordered list of anchors to try + pub max_retries: u32, + pub failure_threshold: u32, // Failures before marking anchor as down +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AnchorFailureState { + pub anchor: Address, + pub failure_count: u32, + pub last_failure: u64, + pub is_down: bool, +} + +pub struct FallbackSelector; + +impl FallbackSelector { + pub fn set_config(env: &Env, config: &FallbackConfig) { + let key = soroban_sdk::symbol_short!("FBCONFIG"); + env.storage().persistent().set(&key, config); + env.storage().persistent().extend_ttl(&key, 7776000, 7776000); // 90 days + } + + pub fn get_config(env: &Env) -> Option { + let key = soroban_sdk::symbol_short!("FBCONFIG"); + env.storage().persistent().get(&key) + } + + pub fn record_failure(env: &Env, anchor: &Address, threshold: u32) { + let key = (soroban_sdk::symbol_short!("FBFAIL"), anchor); + let mut state: AnchorFailureState = env + .storage() + .temporary() + .get(&key) + .unwrap_or(AnchorFailureState { + anchor: anchor.clone(), + failure_count: 0, + last_failure: 0, + is_down: false, + }); + + state.failure_count += 1; + state.last_failure = env.ledger().timestamp(); + state.is_down = state.failure_count >= threshold; + + env.storage().temporary().set(&key, &state); + env.storage().temporary().extend_ttl(&key, 17280, 17280); // 1 day + } + + pub fn record_success(env: &Env, anchor: &Address) { + let key = (soroban_sdk::symbol_short!("FBFAIL"), anchor); + env.storage().temporary().remove(&key); + } + + pub fn get_failure_state(env: &Env, anchor: &Address) -> Option { + let key = (soroban_sdk::symbol_short!("FBFAIL"), anchor); + env.storage().temporary().get(&key) + } + + pub fn is_anchor_available(env: &Env, anchor: &Address) -> bool { + if let Some(state) = Self::get_failure_state(env, anchor) { + !state.is_down + } else { + true + } + } + + pub fn select_next_anchor( + env: &Env, + config: &FallbackConfig, + failed_anchor: Option<&Address>, + ) -> Result { + let mut start_index = 0; + + // Find where to start in the fallback order + if let Some(failed) = failed_anchor { + for i in 0..config.anchor_order.len() { + if let Some(addr) = config.anchor_order.get(i) { + if addr == *failed { + start_index = i + 1; + break; + } + } + } + } + + // Try anchors in order + for i in start_index..config.anchor_order.len() { + if let Some(anchor) = config.anchor_order.get(i) { + if Self::is_anchor_available(env, &anchor) { + return Ok(anchor); + } + } + } + + Err(Error::NoAnchorsAvailable) + } +} diff --git a/src/fallback_tests.rs b/src/fallback_tests.rs new file mode 100644 index 0000000..809f794 --- /dev/null +++ b/src/fallback_tests.rs @@ -0,0 +1,190 @@ +#[cfg(test)] +mod fallback_tests { + use crate::{AnchorKitContract, AnchorKitContractClient, ServiceType}; + use soroban_sdk::{testutils::Address as _, vec, Address, Env}; + + #[test] + fn test_configure_fallback() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let anchor1 = Address::generate(&env); + let anchor2 = Address::generate(&env); + let anchor3 = Address::generate(&env); + + client.initialize(&admin); + + let order = vec![&env, anchor1.clone(), anchor2.clone(), anchor3.clone()]; + client.configure_fallback(&order, &3, &2); + + let config = client.get_fallback_config(); + assert!(config.is_some()); + + let config = config.unwrap(); + assert_eq!(config.anchor_order.len(), 3); + assert_eq!(config.max_retries, 3); + assert_eq!(config.failure_threshold, 2); + } + + #[test] + fn test_record_failure() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let anchor = Address::generate(&env); + + client.initialize(&admin); + + let order = vec![&env, anchor.clone()]; + client.configure_fallback(&order, &3, &2); + + // Record first failure + client.record_anchor_failure(&anchor); + + let state = client.get_anchor_failure_state(&anchor); + assert!(state.is_some()); + + let state = state.unwrap(); + assert_eq!(state.failure_count, 1); + assert!(!state.is_down); + + // Record second failure - should mark as down + client.record_anchor_failure(&anchor); + + let state = client.get_anchor_failure_state(&anchor).unwrap(); + assert_eq!(state.failure_count, 2); + assert!(state.is_down); + } + + #[test] + fn test_record_success_clears_failure() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let anchor = Address::generate(&env); + + client.initialize(&admin); + + let order = vec![&env, anchor.clone()]; + client.configure_fallback(&order, &3, &2); + + client.record_anchor_failure(&anchor); + assert!(client.get_anchor_failure_state(&anchor).is_some()); + + client.record_anchor_success(&anchor); + assert!(client.get_anchor_failure_state(&anchor).is_none()); + } + + #[test] + fn test_select_fallback_anchor() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let anchor1 = Address::generate(&env); + let anchor2 = Address::generate(&env); + let anchor3 = Address::generate(&env); + + client.initialize(&admin); + + let order = vec![&env, anchor1.clone(), anchor2.clone(), anchor3.clone()]; + client.configure_fallback(&order, &3, &2); + + // First selection should return anchor1 + let selected = client.select_fallback_anchor(&None); + assert_eq!(selected, anchor1); + + // Mark anchor1 as failed, should select anchor2 + client.record_anchor_failure(&anchor1); + client.record_anchor_failure(&anchor1); + + let selected = client.select_fallback_anchor(&Some(anchor1.clone())); + assert_eq!(selected, anchor2); + } + + #[test] + fn test_fallback_skips_down_anchors() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let anchor1 = Address::generate(&env); + let anchor2 = Address::generate(&env); + let anchor3 = Address::generate(&env); + + client.initialize(&admin); + + let order = vec![&env, anchor1.clone(), anchor2.clone(), anchor3.clone()]; + client.configure_fallback(&order, &3, &2); + + // Mark anchor1 and anchor2 as down + client.record_anchor_failure(&anchor1); + client.record_anchor_failure(&anchor1); + client.record_anchor_failure(&anchor2); + client.record_anchor_failure(&anchor2); + + // Should skip to anchor3 + let selected = client.select_fallback_anchor(&None); + assert_eq!(selected, anchor3); + } + + #[test] + fn test_no_anchors_available() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let anchor1 = Address::generate(&env); + + client.initialize(&admin); + + let order = vec![&env, anchor1.clone()]; + client.configure_fallback(&order, &3, &2); + + // Mark all anchors as down + client.record_anchor_failure(&anchor1); + client.record_anchor_failure(&anchor1); + + let result = client.try_select_fallback_anchor(&None); + assert!(result.is_err()); + } + + #[test] + fn test_fallback_order_preserved() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, AnchorKitContract); + let client = AnchorKitContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let anchor1 = Address::generate(&env); + let anchor2 = Address::generate(&env); + let anchor3 = Address::generate(&env); + + client.initialize(&admin); + + let order = vec![&env, anchor1.clone(), anchor2.clone(), anchor3.clone()]; + client.configure_fallback(&order, &3, &2); + + let config = client.get_fallback_config().unwrap(); + + assert_eq!(config.anchor_order.get(0).unwrap(), anchor1); + assert_eq!(config.anchor_order.get(1).unwrap(), anchor2); + assert_eq!(config.anchor_order.get(2).unwrap(), anchor3); + } +} diff --git a/src/lib.rs b/src/lib.rs index b39e359..94442cd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ mod credentials; mod error_mapping; mod errors; mod events; +mod fallback; mod retry; mod serialization; mod storage; @@ -52,6 +53,9 @@ mod cross_platform_tests; mod zerocopy_tests; +#[cfg(test)] +mod fallback_tests; + use soroban_sdk::{contract, contractimpl, Address, Bytes, BytesN, Env, String, Vec}; @@ -63,6 +67,7 @@ pub use events::{ OperationLogged, QuoteReceived, QuoteSubmitted, ServicesConfigured, SessionCreated, SettlementConfirmed, TransferInitiated, }; +pub use fallback::{AnchorFailureState, FallbackConfig, FallbackSelector}; pub use storage::Storage; pub use types::{ AnchorMetadata, AnchorOption, AnchorServices, Attestation, AuditLog, Endpoint, HealthStatus, @@ -1163,4 +1168,114 @@ impl AnchorKitContract { Ok(()) } + + // ============ Fallback Anchor Selection ============ + + /// Configure fallback anchor order. Only callable by admin. + pub fn configure_fallback( + env: Env, + anchor_order: Vec
, + max_retries: u32, + failure_threshold: u32, + ) -> Result<(), Error> { + let admin = Storage::get_admin(&env)?; + admin.require_auth(); + + if anchor_order.is_empty() { + return Err(Error::InvalidConfig); + } + + let config = FallbackConfig { + anchor_order, + max_retries, + failure_threshold, + }; + + FallbackSelector::set_config(&env, &config); + Ok(()) + } + + /// Get fallback configuration. + pub fn get_fallback_config(env: Env) -> Option { + FallbackSelector::get_config(&env) + } + + /// Record anchor failure. + pub fn record_anchor_failure(env: Env, anchor: Address) -> Result<(), Error> { + let config = FallbackSelector::get_config(&env).ok_or(Error::InvalidConfig)?; + FallbackSelector::record_failure(&env, &anchor, config.failure_threshold); + Ok(()) + } + + /// Record anchor success (clears failure state). + pub fn record_anchor_success(env: Env, anchor: Address) -> Result<(), Error> { + FallbackSelector::record_success(&env, &anchor); + Ok(()) + } + + /// Get anchor failure state. + pub fn get_anchor_failure_state(env: Env, anchor: Address) -> Option { + FallbackSelector::get_failure_state(&env, &anchor) + } + + /// Select next available anchor from fallback order. + pub fn select_fallback_anchor( + env: Env, + failed_anchor: Option
, + ) -> Result { + let config = FallbackSelector::get_config(&env).ok_or(Error::InvalidConfig)?; + FallbackSelector::select_next_anchor(&env, &config, failed_anchor.as_ref()) + } + + /// Submit quote with automatic fallback on failure. + pub fn submit_quote_with_fallback( + env: Env, + base_asset: String, + quote_asset: String, + rate: u64, + fee_percentage: u32, + minimum_amount: u64, + maximum_amount: u64, + valid_until: u64, + ) -> Result { + let config = FallbackSelector::get_config(&env).ok_or(Error::InvalidConfig)?; + + for retry in 0..config.max_retries { + let anchor = if retry == 0 { + config.anchor_order.get(0).ok_or(Error::NoAnchorsAvailable)? + } else { + FallbackSelector::select_next_anchor(&env, &config, None)? + }; + + anchor.require_auth(); + + let result = Self::submit_quote( + env.clone(), + anchor.clone(), + base_asset.clone(), + quote_asset.clone(), + rate, + fee_percentage, + minimum_amount, + maximum_amount, + valid_until, + ); + + match result { + Ok(quote_id) => { + FallbackSelector::record_success(&env, &anchor); + return Ok(quote_id); + } + Err(_) => { + FallbackSelector::record_failure(&env, &anchor, config.failure_threshold); + if retry == config.max_retries - 1 { + return Err(Error::NoAnchorsAvailable); + } + } + } + } + + Err(Error::NoAnchorsAvailable) + } } +