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)
}
}
+