diff --git a/ASSET_VALIDATOR.md b/ASSET_VALIDATOR.md new file mode 100644 index 0000000..7723fbf --- /dev/null +++ b/ASSET_VALIDATOR.md @@ -0,0 +1,164 @@ +# Asset Compatibility Validator + +## Overview + +Validate asset compatibility before initiating flows to reject unsupported assets early with clear errors. + +## Features + +- ✅ Early rejection of unsupported assets +- ✅ Clear error output +- ✅ Per-anchor asset configuration +- ✅ Asset pair validation + +## Usage + +### Configure Supported Assets + +```rust +let assets = vec![ + &env, + String::from_str(&env, "USDC"), + String::from_str(&env, "BTC"), + String::from_str(&env, "ETH"), +]; + +client.set_supported_assets(&anchor, &assets); +``` + +### Check Asset Support + +```rust +if client.is_asset_supported(&anchor, &String::from_str(&env, "USDC")) { + // Asset is supported +} +``` + +### Validate Asset Pair + +```rust +match client.try_validate_asset_pair(&anchor, &base_asset, "e_asset) { + Ok(()) => { + // Both assets supported + } + Err(Ok(Error::UnsupportedAsset)) => { + // One or both assets not supported + } + Err(Ok(Error::AssetNotConfigured)) => { + // No assets configured for anchor + } +} +``` + +### Submit Quote with Validation + +```rust +// Automatically validates assets before submission +let quote_id = client.submit_quote_validated( + &anchor, + &base_asset, + "e_asset, + &rate, + &fee_percentage, + &minimum_amount, + &maximum_amount, + &valid_until, +); +``` + +## API Methods + +```rust +// Configure assets +pub fn set_supported_assets( + anchor: Address, + assets: Vec, +) -> Result<(), Error> + +// Query support +pub fn get_supported_assets(anchor: Address) -> Option> +pub fn is_asset_supported(anchor: Address, asset: String) -> bool + +// Validate +pub fn validate_asset_pair( + anchor: Address, + base_asset: String, + quote_asset: String, +) -> Result<(), Error> + +// Submit with validation +pub fn submit_quote_validated(...) -> Result +``` + +## Error Codes + +- `Error::UnsupportedAsset` (48) - Asset not in supported list +- `Error::AssetNotConfigured` (49) - No assets configured for anchor + +## Configuration Structure + +```rust +pub struct AssetConfig { + pub anchor: Address, + pub supported_assets: Vec, +} +``` + +## Example Flow + +```rust +// 1. Configure supported assets +let assets = vec![ + &env, + String::from_str(&env, "USD"), + String::from_str(&env, "USDC"), +]; +client.set_supported_assets(&anchor, &assets); + +// 2. Validate before operation +client.validate_asset_pair( + &anchor, + &String::from_str(&env, "USD"), + &String::from_str(&env, "USDC"), +)?; + +// 3. Or use automatic validation +let quote_id = client.submit_quote_validated( + &anchor, + &String::from_str(&env, "USD"), + &String::from_str(&env, "USDC"), + &10000, + &100, + &100, + &10000, + &valid_until, +); +``` + +## Benefits + +### Early Rejection +Fail fast before expensive operations. + +### Clear Errors +Specific error codes for debugging: +- `UnsupportedAsset` - Know exactly what's wrong +- `AssetNotConfigured` - Know configuration is missing + +### Prevent Invalid Flows +Stop invalid transactions before they start. + +### Better UX +Users get immediate feedback on unsupported assets. + +## Storage + +Asset configurations stored in persistent storage with 90-day TTL. + +## Best Practices + +1. **Configure on registration** - Set assets when registering anchor +2. **Validate early** - Check before expensive operations +3. **Use validated methods** - Prefer `submit_quote_validated` over manual checks +4. **Update as needed** - Reconfigure when asset support changes +5. **Handle errors clearly** - Provide user-friendly messages for unsupported assets diff --git a/README.md b/README.md index 71aae83..f7e05be 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) -- **Fallback anchor selection** (automatic rerouting on failure) +- **Asset compatibility validation** (early rejection of unsupported assets) - 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 -- **[FALLBACK_SELECTION.md](./FALLBACK_SELECTION.md)** - Automatic fallback anchor selection +- **[ASSET_VALIDATOR.md](./ASSET_VALIDATOR.md)** - Asset compatibility validation - **[API_SPEC.md](./API_SPEC.md)** - API specification and error codes ### Technical Documentation diff --git a/src/asset_validator.rs b/src/asset_validator.rs new file mode 100644 index 0000000..3e3b8c9 --- /dev/null +++ b/src/asset_validator.rs @@ -0,0 +1,58 @@ +use soroban_sdk::{contracttype, Address, Env, String, Vec}; + +use crate::Error; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AssetConfig { + pub anchor: Address, + pub supported_assets: Vec, // Asset codes (e.g., "USDC", "BTC") +} + +pub struct AssetValidator; + +impl AssetValidator { + pub fn set_supported_assets(env: &Env, anchor: &Address, assets: Vec) { + let config = AssetConfig { + anchor: anchor.clone(), + supported_assets: assets, + }; + let key = (soroban_sdk::symbol_short!("ASSETS"), anchor); + env.storage().persistent().set(&key, &config); + env.storage().persistent().extend_ttl(&key, 7776000, 7776000); // 90 days + } + + pub fn get_supported_assets(env: &Env, anchor: &Address) -> Option> { + let key = (soroban_sdk::symbol_short!("ASSETS"), anchor); + let config: Option = env.storage().persistent().get(&key); + config.map(|c| c.supported_assets) + } + + pub fn is_asset_supported(env: &Env, anchor: &Address, asset: &String) -> bool { + if let Some(assets) = Self::get_supported_assets(env, anchor) { + assets.contains(asset) + } else { + false + } + } + + pub fn validate_asset_pair( + env: &Env, + anchor: &Address, + base_asset: &String, + quote_asset: &String, + ) -> Result<(), Error> { + let assets = Self::get_supported_assets(env, anchor) + .ok_or(Error::AssetNotConfigured)?; + + if !assets.contains(base_asset) { + return Err(Error::UnsupportedAsset); + } + + if !assets.contains(quote_asset) { + return Err(Error::UnsupportedAsset); + } + + Ok(()) + } +} diff --git a/src/asset_validator_tests.rs b/src/asset_validator_tests.rs new file mode 100644 index 0000000..bb1722b --- /dev/null +++ b/src/asset_validator_tests.rs @@ -0,0 +1,234 @@ +#[cfg(test)] +mod asset_validator_tests { + use crate::{AnchorKitContract, AnchorKitContractClient, Error, ServiceType}; + use soroban_sdk::{testutils::Address as _, vec, Address, Env, String}; + + #[test] + fn test_set_supported_assets() { + 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); + client.register_attestor(&anchor); + + let assets = vec![ + &env, + String::from_str(&env, "USDC"), + String::from_str(&env, "BTC"), + String::from_str(&env, "ETH"), + ]; + + client.set_supported_assets(&anchor, &assets); + + let supported = client.get_supported_assets(&anchor); + assert!(supported.is_some()); + assert_eq!(supported.unwrap().len(), 3); + } + + #[test] + fn test_is_asset_supported() { + 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); + client.register_attestor(&anchor); + + let assets = vec![ + &env, + String::from_str(&env, "USDC"), + String::from_str(&env, "BTC"), + ]; + + client.set_supported_assets(&anchor, &assets); + + assert!(client.is_asset_supported(&anchor, &String::from_str(&env, "USDC"))); + assert!(client.is_asset_supported(&anchor, &String::from_str(&env, "BTC"))); + assert!(!client.is_asset_supported(&anchor, &String::from_str(&env, "ETH"))); + } + + #[test] + fn test_validate_asset_pair_success() { + 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); + client.register_attestor(&anchor); + + let assets = vec![ + &env, + String::from_str(&env, "USD"), + String::from_str(&env, "USDC"), + ]; + + client.set_supported_assets(&anchor, &assets); + + let result = client.validate_asset_pair( + &anchor, + &String::from_str(&env, "USD"), + &String::from_str(&env, "USDC"), + ); + + assert!(result.is_ok()); + } + + #[test] + fn test_validate_asset_pair_unsupported_base() { + 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); + client.register_attestor(&anchor); + + let assets = vec![&env, String::from_str(&env, "USDC")]; + + client.set_supported_assets(&anchor, &assets); + + let result = client.try_validate_asset_pair( + &anchor, + &String::from_str(&env, "BTC"), + &String::from_str(&env, "USDC"), + ); + + assert_eq!(result, Err(Ok(Error::UnsupportedAsset))); + } + + #[test] + fn test_validate_asset_pair_unsupported_quote() { + 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); + client.register_attestor(&anchor); + + let assets = vec![&env, String::from_str(&env, "USD")]; + + client.set_supported_assets(&anchor, &assets); + + let result = client.try_validate_asset_pair( + &anchor, + &String::from_str(&env, "USD"), + &String::from_str(&env, "USDC"), + ); + + assert_eq!(result, Err(Ok(Error::UnsupportedAsset))); + } + + #[test] + fn test_validate_asset_pair_not_configured() { + 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); + client.register_attestor(&anchor); + + // Don't configure assets + + let result = client.try_validate_asset_pair( + &anchor, + &String::from_str(&env, "USD"), + &String::from_str(&env, "USDC"), + ); + + assert_eq!(result, Err(Ok(Error::AssetNotConfigured))); + } + + #[test] + fn test_submit_quote_validated_success() { + 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); + client.register_attestor(&anchor); + + let services = vec![&env, ServiceType::Quotes]; + client.configure_services(&anchor, &services); + + let assets = vec![ + &env, + String::from_str(&env, "USD"), + String::from_str(&env, "USDC"), + ]; + client.set_supported_assets(&anchor, &assets); + + let quote_id = client.submit_quote_validated( + &anchor, + &String::from_str(&env, "USD"), + &String::from_str(&env, "USDC"), + &10000, + &100, + &100, + &10000, + &(env.ledger().timestamp() + 3600), + ); + + assert!(quote_id > 0); + } + + #[test] + fn test_submit_quote_validated_rejects_unsupported() { + 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); + client.register_attestor(&anchor); + + let services = vec![&env, ServiceType::Quotes]; + client.configure_services(&anchor, &services); + + let assets = vec![&env, String::from_str(&env, "USDC")]; + client.set_supported_assets(&anchor, &assets); + + let result = client.try_submit_quote_validated( + &anchor, + &String::from_str(&env, "BTC"), + &String::from_str(&env, "USDC"), + &10000, + &100, + &100, + &10000, + &(env.ledger().timestamp() + 3600), + ); + + assert_eq!(result, Err(Ok(Error::UnsupportedAsset))); + } +} diff --git a/src/errors.rs b/src/errors.rs index 56c82bb..47a02fc 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -71,6 +71,7 @@ pub enum Error { ProtocolRateLimitExceeded = 46, // Rate limiting (retryable) ProtocolComplianceViolation = 47, // Compliance/KYC errors - /// Rate limiter errors - RateLimitExceeded = 48, + /// Asset validation errors + UnsupportedAsset = 48, + AssetNotConfigured = 49, } diff --git a/src/lib.rs b/src/lib.rs index 8b9f9c1..a03d3ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ #![no_std] +mod asset_validator; mod config; mod credentials; mod error_mapping; @@ -54,11 +55,12 @@ mod cross_platform_tests; mod zerocopy_tests; #[cfg(test)] -mod fallback_tests; +mod asset_validator_tests; use soroban_sdk::{contract, contractimpl, Address, Bytes, BytesN, Env, String, Vec}; +pub use asset_validator::{AssetConfig, AssetValidator}; pub use config::{AttestorConfig, ContractConfig, SessionConfig}; pub use credentials::{CredentialManager, CredentialPolicy, CredentialType, SecureCredential}; pub use errors::Error; @@ -1200,67 +1202,49 @@ impl AnchorKitContract { Ok(()) } - // ============ Fallback Anchor Selection ============ + // ============ Asset Compatibility Validation ============ - /// Configure fallback anchor order. Only callable by admin. - pub fn configure_fallback( + /// Configure supported assets for an anchor. Only callable by admin or anchor. + pub fn set_supported_assets( env: Env, - anchor_order: Vec
, - max_retries: u32, - failure_threshold: u32, + anchor: Address, + assets: Vec, ) -> Result<(), Error> { let admin = Storage::get_admin(&env)?; admin.require_auth(); - if anchor_order.is_empty() { - return Err(Error::InvalidConfig); + if !Storage::is_attestor(&env, &anchor) { + return Err(Error::AttestorNotRegistered); } - let config = FallbackConfig { - anchor_order, - max_retries, - failure_threshold, - }; - - FallbackSelector::set_config(&env, &config); + AssetValidator::set_supported_assets(&env, &anchor, assets); Ok(()) } - /// Get fallback configuration. - pub fn get_fallback_config(env: Env) -> Option { - FallbackSelector::get_config(&env) + /// Get supported assets for an anchor. + pub fn get_supported_assets(env: Env, anchor: Address) -> Option> { + AssetValidator::get_supported_assets(&env, &anchor) } - /// 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(()) + /// Check if asset is supported by anchor. + pub fn is_asset_supported(env: Env, anchor: Address, asset: String) -> bool { + AssetValidator::is_asset_supported(&env, &anchor, &asset) } - /// 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( + /// Validate asset pair before operation. + pub fn validate_asset_pair( 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()) + anchor: Address, + base_asset: String, + quote_asset: String, + ) -> Result<(), Error> { + AssetValidator::validate_asset_pair(&env, &anchor, &base_asset, "e_asset) } - /// Submit quote with automatic fallback on failure. - pub fn submit_quote_with_fallback( + /// Submit quote with asset validation. + pub fn submit_quote_validated( env: Env, + anchor: Address, base_asset: String, quote_asset: String, rate: u64, @@ -1269,44 +1253,21 @@ impl AnchorKitContract { 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); - } - } - } - } + // Validate assets first + AssetValidator::validate_asset_pair(&env, &anchor, &base_asset, "e_asset)?; - Err(Error::NoAnchorsAvailable) + // Proceed with quote submission + Self::submit_quote( + env, + anchor, + base_asset, + quote_asset, + rate, + fee_percentage, + minimum_amount, + maximum_amount, + valid_until, + ) } }