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..a38c70ad 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::{ @@ -567,6 +567,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 // ------------------------------------------------------------ @@ -1970,3 +2028,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/tests_token_switch.rs b/contracts/invoice_liquidity/src/tests_token_switch.rs new file mode 100644 index 00000000..59aa0a7a --- /dev/null +++ b/contracts/invoice_liquidity/src/tests_token_switch.rs @@ -0,0 +1,185 @@ +#![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, &xlm_address); + + // Add EURC to allowlist + contract.add_token(&eurc_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))); +}