Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions contracts/invoice_liquidity/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
62 changes: 61 additions & 1 deletion contracts/invoice_liquidity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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
// ------------------------------------------------------------
Expand Down Expand Up @@ -1970,3 +2028,5 @@ mod tests_lazy_storage;
mod tests_reputation_events;
#[cfg(test)]
mod tests_discount_invariants;
#[cfg(test)]
mod tests_token_switch;
185 changes: 185 additions & 0 deletions contracts/invoice_liquidity/src/tests_token_switch.rs
Original file line number Diff line number Diff line change
@@ -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)));
}