diff --git a/contracts/invoice_liquidity/src/config.rs b/contracts/invoice_liquidity/src/config.rs index 01e25633..09a32456 100644 --- a/contracts/invoice_liquidity/src/config.rs +++ b/contracts/invoice_liquidity/src/config.rs @@ -11,6 +11,8 @@ pub struct Config { pub decay_period_ledgers: u64, // Ledger count between decay applications pub dispute_timeout_ledgers: u64, // Ledger count after which a dispute can be auto-resolved pub xlm_sac_address: Address, // Stellar Asset Contract address for native XLM wrapper + pub usdc_sac_address: Address, // USDC contract address + pub eurc_sac_address: Address, // EURC contract address pub price_oracle: Option
, // Optional price oracle for USD normalisation } @@ -34,6 +36,8 @@ pub fn update_config( decay_period_ledgers: u64, dispute_timeout_ledgers: u64, xlm_sac_address: Address, + usdc_sac_address: Address, + eurc_sac_address: Address, ) -> Result<(), ConfigError> { let admin = crate::storage::get_admin(env).ok_or(ConfigError::Unauthorized)?; let old_config = crate::storage::get_config(env).ok_or(ConfigError::Unauthorized)?; @@ -57,6 +61,8 @@ pub fn update_config( decay_period_ledgers, dispute_timeout_ledgers, xlm_sac_address, + usdc_sac_address, + eurc_sac_address, price_oracle: old_config.price_oracle, }; diff --git a/contracts/invoice_liquidity/src/events.rs b/contracts/invoice_liquidity/src/events.rs index 096745c3..7d9102bc 100644 --- a/contracts/invoice_liquidity/src/events.rs +++ b/contracts/invoice_liquidity/src/events.rs @@ -312,3 +312,12 @@ pub struct ReputationUpdated { pub invoices_paid: u32, pub invoices_defaulted: u32, } + +#[contractevent(topics = ["token_changed"])] +#[derive(Clone, Debug, PartialEq)] +pub struct InvoiceTokenChanged { + #[topic] + pub invoice_id: u64, + pub old_token: Address, + pub new_token: Address, +} diff --git a/contracts/invoice_liquidity/src/lib.rs b/contracts/invoice_liquidity/src/lib.rs index f7e895f5..4487c4cc 100644 --- a/contracts/invoice_liquidity/src/lib.rs +++ b/contracts/invoice_liquidity/src/lib.rs @@ -47,7 +47,7 @@ use events::{ AdminChanged, AppealResolved, ContractPaused, ContractUnpaused, ContractUpgraded, DefaultAppealed, DisputeResolved, FundQueueResolved, FundRequested, InvoiceCancelled, InvoiceDefaulted, InvoiceDisputed, InvoiceExpired, InvoiceFunded, InvoicePaid, InvoicePartiallyPaid, - InvoiceSubmitted, InvoiceTransferred, InvoiceUpdated, ParameterUpdated, TokenAdded, + InvoiceSubmitted, InvoiceTokenChanged, InvoiceTransferred, InvoiceUpdated, ParameterUpdated, TokenAdded, TokenRemoved, }; use invoice::{ @@ -92,7 +92,8 @@ impl InvoiceLiquidityContract { pub fn initialize( env: Env, admin: Address, - token: Address, + usdc_token: Address, + eurc_token: Address, xlm_token: Address, ) -> Result<(), ContractError> { if env @@ -119,7 +120,7 @@ impl InvoiceLiquidityContract { .set(&StorageKey::NextInvoiceId, &1_u64); } - // Initialize config with XLM SAC address + // Initialize config with token addresses let initial_config = crate::config::Config { high_rep_threshold: 70, bonus_bps: 100, @@ -128,13 +129,20 @@ impl InvoiceLiquidityContract { decay_period_ledgers: 10000, dispute_timeout_ledgers: 10000, xlm_sac_address: xlm_token.clone(), + usdc_sac_address: usdc_token.clone(), + eurc_sac_address: eurc_token.clone(), price_oracle: None, }; crate::storage::set_config(&env, &initial_config); - // approve first token (USDC or default) + // approve initial tokens env.storage().persistent().set( - &crate::storage::DataKey::ApprovedToken(token.clone()), + &crate::storage::DataKey::ApprovedToken(usdc_token.clone()), + &true, + ); + + env.storage().persistent().set( + &crate::storage::DataKey::ApprovedToken(eurc_token.clone()), &true, ); @@ -145,8 +153,9 @@ impl InvoiceLiquidityContract { ); let mut list: Vec = Vec::new(&env); - list.push_back(token.clone()); - list.push_back(xlm_token.clone()); + list.push_back(usdc_token); + list.push_back(xlm_token); + list.push_back(eurc_token); env.storage() .persistent() @@ -567,6 +576,64 @@ impl InvoiceLiquidityContract { Ok(()) } + // ------------------------------------------------------------ + // convert_invoice_token + // ------------------------------------------------------------ + /// Access: Submitter only + pub fn convert_invoice_token( + env: Env, + freelancer: Address, + invoice_id: u64, + new_token: Address, + ) -> Result<(), ContractError> { + if is_paused(&env) { + return Err(ContractError::ContractPaused); + } + + if !invoice_exists(&env, invoice_id) { + return Err(ContractError::InvoiceNotFound); + } + + let mut invoice = load_invoice(&env, invoice_id); + require_submitter_by_id(&env, &freelancer, invoice_id)?; + + // Only allowed in Pending state + if invoice.status != InvoiceStatus::Pending { + match invoice.status { + InvoiceStatus::PartiallyFunded | InvoiceStatus::Funded => { + return Err(ContractError::AlreadyFunded) + } + InvoiceStatus::Paid => return Err(ContractError::AlreadyPaid), + _ => return Err(ContractError::Unauthorized), // Generic unauthorized for other states + } + } + + // Check if invoice is expired (mirroring update_invoice logic) + if env.ledger().timestamp() >= u64::from(invoice.due_date) { + invoice.status = InvoiceStatus::Expired; + save_invoice(&env, &invoice); + return Err(ContractError::InvoiceExpired); + } + + // New token must be in the allowlist + if !is_approved_token(&env, &new_token) { + return Err(ContractError::Unauthorized); + } + + let old_token = invoice.token.clone(); + invoice.token = new_token.clone(); + + save_invoice(&env, &invoice); + + env.events().publish_event(&InvoiceTokenChanged { + invoice_id, + old_token, + new_token, + }); + + Ok(()) + } + // ------------------------------------------------------------ // submit_invoices_batch // ------------------------------------------------------------ @@ -826,9 +893,11 @@ impl InvoiceLiquidityContract { let token = token_client(&env, &invoice.token); let contract_address = env.current_contract_address(); - // Handle XLM precision if needed (SAC wrapper handles conversion internally) + // Handle token precision if needed let normalized_fund_amount = if is_xlm_token(&env, &invoice.token) { normalize_xlm_amount(fund_amount) + } else if is_eurc_token(&env, &invoice.token) { + normalize_eurc_amount(fund_amount) } else { normalize_usdc_amount(fund_amount) }; @@ -1122,9 +1191,11 @@ impl InvoiceLiquidityContract { let token = token_client(&env, &invoice.token); let contract_address = env.current_contract_address(); - // Handle XLM precision if needed (SAC wrapper handles conversion internally) + // Handle token precision if needed let normalized_amount = if is_xlm_token(&env, &invoice.token) { normalize_xlm_amount(amount) + } else if is_eurc_token(&env, &invoice.token) { + normalize_eurc_amount(amount) } else { normalize_usdc_amount(amount) }; @@ -1690,6 +1761,8 @@ impl InvoiceLiquidityContract { decay_period_ledgers: u64, dispute_timeout_ledgers: u64, xlm_sac_address: Address, + usdc_sac_address: Address, + eurc_sac_address: Address, ) -> Result<(), ContractError> { crate::config::update_config( &env, @@ -1701,6 +1774,8 @@ impl InvoiceLiquidityContract { decay_period_ledgers, dispute_timeout_ledgers, xlm_sac_address, + usdc_sac_address, + eurc_sac_address, ) .map_err(|_| ContractError::Unauthorized) } @@ -1830,19 +1905,38 @@ fn is_xlm_token(env: &Env, token: &Address) -> bool { } /// Convert amount from XLM precision (7 decimals) to contract precision -/// This is a no-op for now since we store amounts in their native token precision, -/// but provides a hook for future precision normalization if needed fn normalize_xlm_amount(amount: i128) -> i128 { amount } +/// Check if a token address is the USDC address +fn is_usdc_token(env: &Env, token: &Address) -> bool { + if let Some(config) = crate::storage::get_config(env) { + token == &config.usdc_sac_address + } else { + false + } +} + /// Convert amount from USDC precision (6 decimals) to contract precision -/// This is a no-op for now since we store amounts in their native token precision, -/// but provides a hook for future precision normalization if needed fn normalize_usdc_amount(amount: i128) -> i128 { amount } +/// Check if a token address is the EURC address +fn is_eurc_token(env: &Env, token: &Address) -> bool { + if let Some(config) = crate::storage::get_config(env) { + token == &config.eurc_sac_address + } else { + false + } +} + +/// Convert amount from EURC precision (6 decimals) to contract precision +fn normalize_eurc_amount(amount: i128) -> i128 { + amount +} + fn validate_invoice_terms( env: &Env, amount: i128, @@ -1886,10 +1980,22 @@ fn validate_invoice_terms( } fn is_approved_token(env: &Env, token: &Address) -> bool { - env.storage() + // First check the explicit allowlist in storage + if env.storage() .persistent() .get(&crate::storage::DataKey::ApprovedToken(token.clone())) - .unwrap_or(false) + .unwrap_or(false) { + return true; + } + + // Then check the wired tokens in Config + if let Some(config) = crate::storage::get_config(env) { + if token == &config.usdc_sac_address || token == &config.eurc_sac_address || token == &config.xlm_sac_address { + return true; + } + } + + false } fn notify_distribution_funding(env: &Env, lp: &Address, amount_usdc_equivalent: i128) { @@ -1970,3 +2076,5 @@ mod tests_lazy_storage; mod tests_reputation_events; #[cfg(test)] mod tests_discount_invariants; +#[cfg(test)] +mod tests_token_switch; diff --git a/contracts/invoice_liquidity/src/test.rs b/contracts/invoice_liquidity/src/test.rs index 644a3506..5ff7ba69 100644 --- a/contracts/invoice_liquidity/src/test.rs +++ b/contracts/invoice_liquidity/src/test.rs @@ -37,6 +37,10 @@ pub fn setup() -> TestEnv { let usdc_contract_id = env.register_stellar_asset_contract_v2(usdc_admin.clone()); let usdc_address = usdc_contract_id.address(); + let eurc_admin = Address::generate(&env); + let eurc_contract_id = env.register_stellar_asset_contract_v2(eurc_admin.clone()); + let eurc_address = eurc_contract_id.address(); + let token = TokenClient::new(&env, &usdc_address); let token_admin = StellarAssetClient::new(&env, &usdc_address); @@ -61,8 +65,8 @@ pub fn setup() -> TestEnv { let xlm_contract_id = env.register_stellar_asset_contract_v2(xlm_admin); let xlm_address = xlm_contract_id.address(); - // Initialize with mock USDC and mock XLM SAC addresses - contract.initialize(&usdc_admin, &usdc_address, &xlm_address); + // Initialize with mock USDC, EURC and mock XLM SAC addresses + contract.initialize(&usdc_admin, &usdc_address, &eurc_address, &xlm_address); // ---- Set ledger timestamp to a known baseline ---- let mut ledger_info = env.ledger().get(); diff --git a/contracts/invoice_liquidity/src/tests_appeal.rs b/contracts/invoice_liquidity/src/tests_appeal.rs index 0a5d3303..a0513995 100644 --- a/contracts/invoice_liquidity/src/tests_appeal.rs +++ b/contracts/invoice_liquidity/src/tests_appeal.rs @@ -65,7 +65,8 @@ fn setup_appeal() -> AppealTestEnv { let xlm_addr = xlm_id.address(); // usdc_admin acts as the contract admin. - contract.initialize(&usdc_admin, &usdc_addr, &xlm_addr); + let eurc_addr = Address::generate(&env); + contract.initialize(&usdc_admin, &usdc_addr, &eurc_addr, &xlm_addr); let mut ledger = env.ledger().get(); ledger.timestamp = 1_700_000_000; diff --git a/contracts/invoice_liquidity/src/tests_benchmarks.rs b/contracts/invoice_liquidity/src/tests_benchmarks.rs index a58033b6..08357278 100644 --- a/contracts/invoice_liquidity/src/tests_benchmarks.rs +++ b/contracts/invoice_liquidity/src/tests_benchmarks.rs @@ -38,7 +38,8 @@ fn setup_benchmark_env() -> BaseBenchEnv { let contract_id = env.register(InvoiceLiquidityContract, ()); let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); - contract.initialize(&usdc_admin, &usdc.address(), &xlm.address()); + let eurc_address = Address::generate(&env); + contract.initialize(&usdc_admin, &usdc.address(), &eurc_address, &xlm.address()); let freelancer = Address::generate(&env); let payer = Address::generate(&env); diff --git a/contracts/invoice_liquidity/src/tests_concurrency.rs b/contracts/invoice_liquidity/src/tests_concurrency.rs index a9bfc33a..1df882f6 100644 --- a/contracts/invoice_liquidity/src/tests_concurrency.rs +++ b/contracts/invoice_liquidity/src/tests_concurrency.rs @@ -42,7 +42,8 @@ fn setup_env() -> ConcurrencyEnv { let contract_id = env.register(InvoiceLiquidityContract, ()); let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); - contract.initialize(&usdc_admin, &usdc.address(), &xlm.address()); + let eurc_addr = Address::generate(&env); + contract.initialize(&usdc_admin, &usdc.address(), &eurc_addr, &xlm.address()); let freelancer = Address::generate(&env); let payer = Address::generate(&env); diff --git a/contracts/invoice_liquidity/src/tests_discount_invariants.rs b/contracts/invoice_liquidity/src/tests_discount_invariants.rs index a08f32a0..90d5aee3 100644 --- a/contracts/invoice_liquidity/src/tests_discount_invariants.rs +++ b/contracts/invoice_liquidity/src/tests_discount_invariants.rs @@ -88,7 +88,8 @@ fn setup_invariant(invoice_amount: i128) -> InvariantEnv { let contract_id = env.register(InvoiceLiquidityContract, ()); let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); - contract.initialize(&usdc_admin, &usdc_address, &xlm_address); + let eurc_address = Address::generate(&env); + contract.initialize(&usdc_admin, &usdc_address, &eurc_address, &xlm_address); let mut ledger = env.ledger().get(); ledger.timestamp = LEDGER_TIMESTAMP; diff --git a/contracts/invoice_liquidity/src/tests_dispute.rs b/contracts/invoice_liquidity/src/tests_dispute.rs index 62399a87..a9f49961 100644 --- a/contracts/invoice_liquidity/src/tests_dispute.rs +++ b/contracts/invoice_liquidity/src/tests_dispute.rs @@ -65,7 +65,8 @@ fn setup_dispute() -> DisputeTestEnv { let xlm_addr = xlm_id.address(); // usdc_admin acts as the contract admin. - contract.initialize(&usdc_admin, &usdc_addr, &xlm_addr); + let eurc_addr = Address::generate(&env); + contract.initialize(&usdc_admin, &usdc_addr, &eurc_addr, &xlm_addr); let mut ledger = env.ledger().get(); ledger.timestamp = 1_700_000_000; diff --git a/contracts/invoice_liquidity/src/tests_fuzz.rs b/contracts/invoice_liquidity/src/tests_fuzz.rs index 457393dd..9785f55f 100644 --- a/contracts/invoice_liquidity/src/tests_fuzz.rs +++ b/contracts/invoice_liquidity/src/tests_fuzz.rs @@ -54,7 +54,8 @@ fn setup_fuzz() -> FuzzEnv { let xlm_contract_id = env.register_stellar_asset_contract_v2(xlm_admin); let xlm_address = xlm_contract_id.address(); - contract.initialize(&usdc_admin, &usdc_address, &xlm_address); + let eurc_address = Address::generate(&env); + contract.initialize(&usdc_admin, &usdc_address, &eurc_address, &xlm_address); // Fix ledger timestamp to a known baseline let mut ledger_info = env.ledger().get(); diff --git a/contracts/invoice_liquidity/src/tests_governance_features.rs b/contracts/invoice_liquidity/src/tests_governance_features.rs index 4b94a943..2a3aad7a 100644 --- a/contracts/invoice_liquidity/src/tests_governance_features.rs +++ b/contracts/invoice_liquidity/src/tests_governance_features.rs @@ -66,7 +66,8 @@ fn setup() -> TestEnv { let contract_id = env.register(InvoiceLiquidityContract, ()); let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); - contract.initialize(&admin, &usdc.address, &xlm.address); + let eurc_address = Address::generate(&env); + contract.initialize(&admin, &usdc.address, &eurc_address, &xlm.address); let mut ledger_info = env.ledger().get(); ledger_info.timestamp = 1_700_000_000; diff --git a/contracts/invoice_liquidity/src/tests_invariants.rs b/contracts/invoice_liquidity/src/tests_invariants.rs index d2ea2945..4cdec566 100644 --- a/contracts/invoice_liquidity/src/tests_invariants.rs +++ b/contracts/invoice_liquidity/src/tests_invariants.rs @@ -57,7 +57,8 @@ fn setup() -> TestEnv { let xlm_id = env.register_stellar_asset_contract_v2(xlm_admin); let xlm_address = xlm_id.address(); - contract.initialize(&admin, &usdc_address, &xlm_address); + let eurc_address = Address::generate(&env); + contract.initialize(&admin, &usdc_address, &eurc_address, &xlm_address); let mut info = env.ledger().get(); info.timestamp = 1_700_000_000; diff --git a/contracts/invoice_liquidity/src/tests_lifecycle_integration.rs b/contracts/invoice_liquidity/src/tests_lifecycle_integration.rs index 0dc6ced6..9636d7bb 100644 --- a/contracts/invoice_liquidity/src/tests_lifecycle_integration.rs +++ b/contracts/invoice_liquidity/src/tests_lifecycle_integration.rs @@ -64,7 +64,8 @@ fn setup() -> LifecycleTestEnv { let contract_id = env.register(InvoiceLiquidityContract, ()); let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); - contract.initialize(&admin, &token.address, &xlm.address); + let eurc_address = Address::generate(&env); + contract.initialize(&admin, &token.address, &eurc_address, &xlm.address); let mut ledger_info = env.ledger().get(); ledger_info.timestamp = 1_700_000_000; diff --git a/contracts/invoice_liquidity/src/tests_lp_priority_queue.rs b/contracts/invoice_liquidity/src/tests_lp_priority_queue.rs index c48c8440..c08b6f16 100644 --- a/contracts/invoice_liquidity/src/tests_lp_priority_queue.rs +++ b/contracts/invoice_liquidity/src/tests_lp_priority_queue.rs @@ -65,7 +65,8 @@ fn setup_queue() -> QueueTestEnv { let xlm_id = env.register_stellar_asset_contract_v2(xlm_admin); let xlm_addr = xlm_id.address(); - contract.initialize(&usdc_admin, &usdc_addr, &xlm_addr); + let eurc_addr = Address::generate(&env); + contract.initialize(&usdc_admin, &usdc_addr, &eurc_addr, &xlm_addr); let mut ledger = env.ledger().get(); ledger.timestamp = 1_700_000_000; diff --git a/contracts/invoice_liquidity/src/tests_multi_token.rs b/contracts/invoice_liquidity/src/tests_multi_token.rs index 7b85ec52..b50d6fe7 100644 --- a/contracts/invoice_liquidity/src/tests_multi_token.rs +++ b/contracts/invoice_liquidity/src/tests_multi_token.rs @@ -61,8 +61,7 @@ fn setup() -> MultiTokenTestEnv { let contract_id = env.register(InvoiceLiquidityContract, ()); let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); - contract.initialize(&admin, &usdc.address, &xlm.address); - contract.add_token(&eurc.address); + contract.initialize(&admin, &usdc.address, &eurc.address, &xlm.address); let mut ledger_info = env.ledger().get(); ledger_info.timestamp = 1_700_000_000; @@ -271,8 +270,8 @@ fn test_amounts_preserve_precision_for_6_and_7_decimal_token_paths() { xlm_amount - expected_discount(xlm_amount), ); - env.contract.mark_paid(&eurc_invoice, &INVOICE_AMOUNT); - env.contract.mark_paid(&xlm_invoice, &INVOICE_AMOUNT); + env.contract.mark_paid(&eurc_invoice, &eurc_amount); + env.contract.mark_paid(&xlm_invoice, &xlm_amount); assert_eq!( env.eurc.client.balance(&env.lp) - eurc_lp_before, @@ -283,3 +282,66 @@ fn test_amounts_preserve_precision_for_6_and_7_decimal_token_paths() { expected_discount(xlm_amount), ); } + +#[test] +fn test_cross_token_mismatch_is_physically_impossible_as_token_is_locked() { + let env = setup(); + let eurc_amount = 50_000_000; + let invoice_id = submit_invoice(&env, &env.eurc, eurc_amount); + + // LP has 10,000,000,000 USDC and 10,000,000,000 EURC from setup() + // If LP tries to fund EURC invoice, they MUST have EURC. + // The contract uses invoice.token (EURC) regardless of what the LP "thinks" they are sending. + + // We can't really "mis-fund" because the contract logic is: + // token = token_client(env, &invoice.token) + // token.transfer(...) + + // So the test is more about verifying that the contract correctly uses the invoice's locked token. + env.contract.fund_invoice(&env.lp, &invoice_id, &eurc_amount); + let invoice = env.contract.get_invoice(&invoice_id); + assert_eq!(invoice.token, env.eurc.address); + assert_eq!(invoice.status, InvoiceStatus::Funded); +} + +#[test] +fn test_eurc_token_support_is_wired_in_config() { + let env = setup(); + let config = env.contract.get_config(); + assert_eq!(config.usdc_sac_address, env.usdc.address); + assert_eq!(config.eurc_sac_address, env.eurc.address); + assert_eq!(config.xlm_sac_address, env.xlm.address); +} + +#[test] +fn test_eurc_lifecycle() { + let env = setup(); + let amount = 50_000_000; // 50 EURC + let id = submit_invoice(&env, &env.eurc, amount); + + let freelancer_before = env.eurc.client.balance(&env.freelancer); + let lp_before = env.eurc.client.balance(&env.lp); + let payer_before = env.eurc.client.balance(&env.payer); + + // Fund + env.contract.fund_invoice(&env.lp, &id, &amount); + + let discount = expected_discount(amount); + assert_eq!( + env.eurc.client.balance(&env.freelancer) - freelancer_before, + amount - discount + ); + + // Pay + env.contract.mark_paid(&id, &amount); + + assert_eq!( + env.eurc.client.balance(&env.lp) - lp_before, + discount + ); + assert_eq!( + payer_before - env.eurc.client.balance(&env.payer), + amount + ); + assert_eq!(env.contract.get_invoice(&id).status, InvoiceStatus::Paid); +} diff --git a/contracts/invoice_liquidity/src/tests_mutation.rs b/contracts/invoice_liquidity/src/tests_mutation.rs index 67c416c6..9017f74c 100644 --- a/contracts/invoice_liquidity/src/tests_mutation.rs +++ b/contracts/invoice_liquidity/src/tests_mutation.rs @@ -61,7 +61,8 @@ fn setup() -> TestEnv { let xlm_contract_id = env.register_stellar_asset_contract_v2(xlm_admin); let xlm_address = xlm_contract_id.address(); - contract.initialize(&usdc_admin, &usdc_address, &xlm_address); + let eurc_address = Address::generate(&env); + contract.initialize(&usdc_admin, &usdc_address, &eurc_address, &xlm_address); let mut ledger_info = env.ledger().get(); ledger_info.timestamp = 1_700_000_000; diff --git a/contracts/invoice_liquidity/src/tests_new_features.rs b/contracts/invoice_liquidity/src/tests_new_features.rs index e5c76870..1c907fd4 100644 --- a/contracts/invoice_liquidity/src/tests_new_features.rs +++ b/contracts/invoice_liquidity/src/tests_new_features.rs @@ -55,7 +55,8 @@ fn setup() -> TestEnv { let xlm_contract_id = env.register_stellar_asset_contract_v2(xlm_admin); let xlm_address = xlm_contract_id.address(); - contract.initialize(&admin, &usdc_address, &xlm_address); + let eurc_address = Address::generate(&env); + contract.initialize(&admin, &usdc_address, &eurc_address, &xlm_address); let mut ledger_info = env.ledger().get(); ledger_info.timestamp = 1_700_000_000; diff --git a/contracts/invoice_liquidity/src/tests_partial_payment.rs b/contracts/invoice_liquidity/src/tests_partial_payment.rs index 2742b185..9f715b82 100644 --- a/contracts/invoice_liquidity/src/tests_partial_payment.rs +++ b/contracts/invoice_liquidity/src/tests_partial_payment.rs @@ -46,7 +46,8 @@ fn setup() -> PartialTestEnv { let xlm_id = env.register_stellar_asset_contract_v2(xlm_admin); let xlm_addr = xlm_id.address(); - contract.initialize(&usdc_admin, &usdc_addr, &xlm_addr); + let eurc_addr = Address::generate(&env); + contract.initialize(&usdc_admin, &usdc_addr, &eurc_addr, &xlm_addr); let mut ledger = env.ledger().get(); ledger.timestamp = 1_700_000_000; diff --git a/contracts/invoice_liquidity/src/tests_regression.rs b/contracts/invoice_liquidity/src/tests_regression.rs index b41bde61..fc34239f 100644 --- a/contracts/invoice_liquidity/src/tests_regression.rs +++ b/contracts/invoice_liquidity/src/tests_regression.rs @@ -51,8 +51,9 @@ fn setup_regression() -> RegressionTestEnv { let xlm_admin = Address::generate(&env); let xlm_contract_id = env.register_stellar_asset_contract_v2(xlm_admin); let xlm_address = xlm_contract_id.address(); + let eurc_address = Address::generate(&env); - contract.initialize(&usdc_admin, &usdc_address, &xlm_address); + contract.initialize(&usdc_admin, &usdc_address, &eurc_address, &xlm_address); let mut ledger_info = env.ledger().get(); ledger_info.timestamp = 1_700_000_000; diff --git a/contracts/invoice_liquidity/src/tests_security.rs b/contracts/invoice_liquidity/src/tests_security.rs index 9a491107..e2caa61b 100644 --- a/contracts/invoice_liquidity/src/tests_security.rs +++ b/contracts/invoice_liquidity/src/tests_security.rs @@ -62,7 +62,9 @@ fn setup_security() -> TestEnv { let xlm_admin = Address::generate(&env); let xlm_contract_id = env.register_stellar_asset_contract_v2(xlm_admin); let xlm_address = xlm_contract_id.address(); - contract.initialize(&usdc_admin, &usdc_address, &xlm_address); + let eurc_address = Address::generate(&env); + + contract.initialize(&usdc_admin, &usdc_address, &eurc_address, &xlm_address); // Fix ledger timestamp let mut ledger_info = env.ledger().get(); diff --git a/contracts/invoice_liquidity/src/tests_state_machine.rs b/contracts/invoice_liquidity/src/tests_state_machine.rs index 54b176f5..8e716844 100644 --- a/contracts/invoice_liquidity/src/tests_state_machine.rs +++ b/contracts/invoice_liquidity/src/tests_state_machine.rs @@ -72,7 +72,8 @@ fn setup() -> TestEnv { token_admin.mint(&contract.address, &(1000000000 * 100)); let xlm_address = Address::generate(&env); - contract.initialize(&usdc_admin, &usdc_address, &xlm_address); + let eurc_address = Address::generate(&env); + contract.initialize(&usdc_admin, &usdc_address, &eurc_address, &xlm_address); // Set baseline timestamp let mut ledger_info = env.ledger().get(); diff --git a/contracts/invoice_liquidity/src/tests_storage.rs b/contracts/invoice_liquidity/src/tests_storage.rs index a3d49f05..cca68b5b 100644 --- a/contracts/invoice_liquidity/src/tests_storage.rs +++ b/contracts/invoice_liquidity/src/tests_storage.rs @@ -40,7 +40,9 @@ fn setup() -> TestEnv { let xlm_admin = Address::generate(&env); let xlm_address = env.register_stellar_asset_contract_v2(xlm_admin).address(); - contract.initialize(&usdc_admin, &usdc_address, &xlm_address); + let eurc_address = Address::generate(&env); + + contract.initialize(&usdc_admin, &usdc_address, &eurc_address, &xlm_address); TestEnv { env, diff --git a/contracts/invoice_liquidity/src/tests_stress.rs b/contracts/invoice_liquidity/src/tests_stress.rs index f9b508c6..b9dfbdd9 100644 --- a/contracts/invoice_liquidity/src/tests_stress.rs +++ b/contracts/invoice_liquidity/src/tests_stress.rs @@ -65,9 +65,10 @@ fn setup() -> StressTestEnv { let contract_id = env.register(InvoiceLiquidityContract, ()); let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); - // Need XLM token for initialization + // Need EURC and XLM token for initialization + let eurc = register_mock_token(&env); let xlm = register_mock_token(&env); - contract.initialize(&admin, &token.address, &xlm.address); + contract.initialize(&admin, &token.address, &eurc.address, &xlm.address); let mut ledger_info = env.ledger().get(); ledger_info.timestamp = 1_700_000_000; diff --git a/contracts/invoice_liquidity/src/tests_token_switch.rs b/contracts/invoice_liquidity/src/tests_token_switch.rs new file mode 100644 index 00000000..ea762cbd --- /dev/null +++ b/contracts/invoice_liquidity/src/tests_token_switch.rs @@ -0,0 +1,182 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, +}; + +const INVOICE_AMOUNT: i128 = 1_000_000_000; +const DISCOUNT_RATE: u32 = 300; +const DUE_DATE_OFFSET: u64 = 60 * 60 * 24 * 30; // 30 days + +struct TestEnv { + env: Env, + contract: InvoiceLiquidityContractClient<'static>, + token: TokenClient<'static>, + eurc_token: TokenClient<'static>, + non_allowlisted_token: TokenClient<'static>, + admin: Address, + freelancer: Address, + payer: Address, +} + +fn setup() -> TestEnv { + let env = Env::default(); + env.mock_all_auths(); + + let usdc_admin = Address::generate(&env); + let usdc_address = env.register_stellar_asset_contract_v2(usdc_admin).address(); + let token = TokenClient::new(&env, &usdc_address); + + let eurc_admin = Address::generate(&env); + let eurc_address = env.register_stellar_asset_contract_v2(eurc_admin).address(); + let eurc_token = TokenClient::new(&env, &eurc_address); + + let junk_admin = Address::generate(&env); + let junk_address = env.register_stellar_asset_contract_v2(junk_admin).address(); + let non_allowlisted_token = TokenClient::new(&env, &junk_address); + + let admin = Address::generate(&env); + let freelancer = Address::generate(&env); + let payer = Address::generate(&env); + + let contract_id = env.register(InvoiceLiquidityContract, ()); + let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); + + let xlm_admin = Address::generate(&env); + let xlm_address = env.register_stellar_asset_contract_v2(xlm_admin).address(); + + contract.initialize(&admin, &usdc_address, &eurc_address, &xlm_address); + + let mut ledger_info = env.ledger().get(); + ledger_info.timestamp = 1_700_000_000; + env.ledger().set(ledger_info); + + TestEnv { + env, + contract, + token, + eurc_token, + non_allowlisted_token, + admin, + freelancer, + payer, + } +} + +#[test] +fn test_convert_invoice_token_success() { + let t = setup(); + + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + let invoice_id = t.contract.submit_invoice( + &t.freelancer, + &t.payer, + &INVOICE_AMOUNT, + &due_date, + &DISCOUNT_RATE, + &t.token.address, + ); + + // Switch from USDC to EURC + t.contract.convert_invoice_token(&t.freelancer, &invoice_id, &t.eurc_token.address); + + let invoice = t.contract.get_invoice(&invoice_id).unwrap(); + assert_eq!(invoice.token, t.eurc_token.address); +} + +#[test] +fn test_convert_invoice_token_non_submitter_fails() { + let t = setup(); + + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + let invoice_id = t.contract.submit_invoice( + &t.freelancer, + &t.payer, + &INVOICE_AMOUNT, + &due_date, + &DISCOUNT_RATE, + &t.token.address, + ); + + let someone_else = Address::generate(&t.env); + let result = t.contract.try_convert_invoice_token(&someone_else, &invoice_id, &t.eurc_token.address); + + assert!(result.is_err()); + // Authorized error or Unauthorized depending on how require_submitter works +} + +#[test] +fn test_convert_invoice_token_non_allowlisted_fails() { + let t = setup(); + + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + let invoice_id = t.contract.submit_invoice( + &t.freelancer, + &t.payer, + &INVOICE_AMOUNT, + &due_date, + &DISCOUNT_RATE, + &t.token.address, + ); + + let result = t.contract.try_convert_invoice_token(&t.freelancer, &invoice_id, &t.non_allowlisted_token.address); + + assert!(result.is_err()); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); +} + +#[test] +fn test_convert_invoice_token_after_funding_fails() { + let t = setup(); + + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + let invoice_id = t.contract.submit_invoice( + &t.freelancer, + &t.payer, + &INVOICE_AMOUNT, + &due_date, + &DISCOUNT_RATE, + &t.token.address, + ); + + // Fund it + let funder = Address::generate(&t.env); + let stellar_asset = StellarAssetClient::new(&t.env, &t.token.address); + stellar_asset.mint(&funder, &INVOICE_AMOUNT); + + t.contract.fund_invoice(&funder, &invoice_id, &INVOICE_AMOUNT); + + // Try to switch token after funding + let result = t.contract.try_convert_invoice_token(&t.freelancer, &invoice_id, &t.eurc_token.address); + + assert!(result.is_err()); + assert_eq!(result, Err(Ok(ContractError::AlreadyFunded))); +} + +#[test] +fn test_convert_invoice_token_after_expiry_fails() { + let t = setup(); + + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + let invoice_id = t.contract.submit_invoice( + &t.freelancer, + &t.payer, + &INVOICE_AMOUNT, + &due_date, + &DISCOUNT_RATE, + &t.token.address, + ); + + // Advance time past due date + let mut ledger = t.env.ledger().get(); + ledger.timestamp = due_date + 1; + t.env.ledger().set(ledger); + + let result = t.contract.try_convert_invoice_token(&t.freelancer, &invoice_id, &t.eurc_token.address); + + assert!(result.is_err()); + assert_eq!(result, Err(Ok(ContractError::InvoiceExpired))); +}