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 3ab3938..71aae83 100644 --- a/README.md +++ b/README.md @@ -10,7 +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) -- **Request ID propagation** (UUID per flow with tracing) +- **Fallback anchor selection** (automatic rerouting on failure) - Event emission for all state changes - Comprehensive error handling with stable error codes @@ -102,7 +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 -- **[REQUEST_ID_PROPAGATION.md](./REQUEST_ID_PROPAGATION.md)** - Request ID tracking and tracing +- **[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 b5e6de3..8b9f9c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,7 +5,7 @@ mod credentials; mod error_mapping; mod errors; mod events; -mod request_id; +mod fallback; mod retry; mod serialization; mod storage; @@ -54,7 +54,7 @@ mod cross_platform_tests; mod zerocopy_tests; #[cfg(test)] -mod request_id_tests; +mod fallback_tests; use soroban_sdk::{contract, contractimpl, Address, Bytes, BytesN, Env, String, Vec}; @@ -67,7 +67,7 @@ pub use events::{ OperationLogged, QuoteReceived, QuoteSubmitted, ServicesConfigured, SessionCreated, SettlementConfirmed, TransferInitiated, }; -pub use request_id::{RequestId, RequestTracker, TracingSpan}; +pub use fallback::{AnchorFailureState, FallbackConfig, FallbackSelector}; pub use storage::Storage; pub use types::{ AnchorMetadata, AnchorOption, AnchorServices, Attestation, AuditLog, Endpoint, HealthStatus, @@ -1200,120 +1200,113 @@ impl AnchorKitContract { Ok(()) } - // ============ Request ID Propagation ============ + // ============ Fallback Anchor Selection ============ - /// Generate a unique request ID for flow tracking. - pub fn generate_request_id(env: Env) -> RequestId { - RequestId::generate(&env) - } - - /// Submit attestation with request ID tracking. - pub fn submit_with_request_id( + /// Configure fallback anchor order. Only callable by admin. + pub fn configure_fallback( env: Env, - request_id: RequestId, - issuer: Address, - subject: Address, - timestamp: u64, - payload_hash: BytesN<32>, - signature: Bytes, - ) -> Result { - issuer.require_auth(); + 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 start_time = env.ledger().timestamp(); + let config = FallbackConfig { + anchor_order, + max_retries, + failure_threshold, + }; - // Perform attestation - let result = if timestamp == 0 { - Err(Error::InvalidTimestamp) - } else if !Storage::is_attestor(&env, &issuer) { - Err(Error::UnauthorizedAttestor) - } else if Storage::is_hash_used(&env, &payload_hash) { - Err(Error::ReplayAttack) - } else { - Self::verify_signature(&env, &issuer, &subject, timestamp, &payload_hash, &signature)?; - - let id = Storage::get_and_increment_counter(&env); - let attestation = Attestation { - id, - issuer: issuer.clone(), - subject: subject.clone(), - timestamp, - payload_hash: payload_hash.clone(), - signature, - }; + FallbackSelector::set_config(&env, &config); + Ok(()) + } - Storage::set_attestation(&env, id, &attestation); - Storage::mark_hash_used(&env, &payload_hash); - AttestationRecorded::publish(&env, id, &subject, timestamp, payload_hash); + /// Get fallback configuration. + pub fn get_fallback_config(env: Env) -> Option { + FallbackSelector::get_config(&env) + } - Ok(id) - }; + /// 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(()) + } - // Store tracing span - let span = TracingSpan { - request_id: request_id.clone(), - operation: soroban_sdk::String::from_str(&env, "submit_attestation"), - actor: issuer, - started_at: start_time, - completed_at: env.ledger().timestamp(), - status: if result.is_ok() { - soroban_sdk::String::from_str(&env, "success") - } else { - soroban_sdk::String::from_str(&env, "failed") - }, - }; - RequestTracker::store_span(&env, &span); + /// Record anchor success (clears failure state). + pub fn record_anchor_success(env: Env, anchor: Address) -> Result<(), Error> { + FallbackSelector::record_success(&env, &anchor); + Ok(()) + } - result + /// Get anchor failure state. + pub fn get_anchor_failure_state(env: Env, anchor: Address) -> Option { + FallbackSelector::get_failure_state(&env, &anchor) } - /// Get tracing span by request ID. - pub fn get_tracing_span(env: Env, request_id: BytesN<16>) -> Option { - RequestTracker::get_span(&env, &request_id) + /// 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 request ID tracking. - pub fn quote_with_request_id( + /// Submit quote with automatic fallback on failure. + pub fn submit_quote_with_fallback( env: Env, - request_id: RequestId, - anchor: Address, - base_asset: soroban_sdk::String, - quote_asset: soroban_sdk::String, + base_asset: String, + quote_asset: String, rate: u64, fee_percentage: u32, minimum_amount: u64, maximum_amount: u64, valid_until: u64, ) -> Result { - anchor.require_auth(); - - let start_time = env.ledger().timestamp(); - - let result = Self::submit_quote( - env.clone(), - anchor.clone(), - base_asset, - quote_asset, - rate, - fee_percentage, - minimum_amount, - maximum_amount, - valid_until, - ); + let config = FallbackSelector::get_config(&env).ok_or(Error::InvalidConfig)?; - let span = TracingSpan { - request_id: request_id.clone(), - operation: soroban_sdk::String::from_str(&env, "submit_quote"), - actor: anchor, - started_at: start_time, - completed_at: env.ledger().timestamp(), - status: if result.is_ok() { - soroban_sdk::String::from_str(&env, "success") + for retry in 0..config.max_retries { + let anchor = if retry == 0 { + config.anchor_order.get(0).ok_or(Error::NoAnchorsAvailable)? } else { - soroban_sdk::String::from_str(&env, "failed") - }, - }; - RequestTracker::store_span(&env, &span); + FallbackSelector::select_next_anchor(&env, &config, None)? + }; - result + 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) } } +