diff --git a/contracts/invoice_liquidity/src/tests_distribution.rs b/contracts/invoice_liquidity/src/tests_distribution.rs new file mode 100644 index 00000000..44cc727d --- /dev/null +++ b/contracts/invoice_liquidity/src/tests_distribution.rs @@ -0,0 +1,169 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + contract, contractimpl, contracttype, + testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, IntoVal, +}; + +const INVOICE_AMOUNT: i128 = 1_000_000_000; +const DISCOUNT_RATE: u32 = 300; +const DUE_DATE_OFFSET: u64 = 60 * 60 * 24 * 30; + +#[contracttype] +enum DistDataKey { + Lp(Address), +} + +#[contract] +struct MockDistribution; + +#[contractimpl] +impl MockDistribution { + pub fn accrue_lp(env: Env, lp: Address, amount_usdc_equivalent: i128) { + let key = DistDataKey::Lp(lp); + let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage() + .persistent() + .set(&key, &(current + amount_usdc_equivalent)); + } + + pub fn lp_volume(env: Env, lp: Address) -> i128 { + env.storage() + .persistent() + .get(&DistDataKey::Lp(lp)) + .unwrap_or(0) + } +} + +struct DistTestEnv { + env: Env, + contract: InvoiceLiquidityContractClient<'static>, + token: TokenClient<'static>, + freelancer: Address, + payer: Address, + funder: Address, +} + +fn setup() -> DistTestEnv { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + + let usdc_admin = Address::generate(&env); + let usdc_contract_id = env.register_stellar_asset_contract_v2(usdc_admin.clone()); + let usdc_address = usdc_contract_id.address(); + let token = TokenClient::new(&env, &usdc_address); + let token_admin = StellarAssetClient::new(&env, &usdc_address); + + let eurc_admin = Address::generate(&env); + let eurc_contract_id = env.register_stellar_asset_contract_v2(eurc_admin); + let eurc_address = eurc_contract_id.address(); + + 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 freelancer = Address::generate(&env); + let payer = Address::generate(&env); + let funder = Address::generate(&env); + + token_admin.mint(&funder, &(INVOICE_AMOUNT * 10)); + token_admin.mint(&payer, &(INVOICE_AMOUNT * 10)); + + let contract_id = env.register_contract(None, InvoiceLiquidityContract); + let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); + token_admin.mint(&contract.address, &(INVOICE_AMOUNT * 100)); + + 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); + + DistTestEnv { + env, + contract, + token, + freelancer, + payer, + funder, + } +} + +#[test] +fn test_set_distribution_contract_succeeds_as_admin() { + let t = setup(); + let dist_id = t.env.register_contract(None, MockDistribution); + + let result = t.contract.try_set_distribution_contract(&dist_id); + assert!(result.is_ok()); +} + +#[test] +fn test_set_distribution_contract_rejects_non_admin() { + let t = setup(); + let dist_id = t.env.register_contract(None, MockDistribution); + let imposter = Address::generate(&t.env); + + t.env.mock_auths(&[MockAuth { + address: &imposter, + invoke: &MockAuthInvoke { + contract: &t.contract.address, + fn_name: "set_distribution_contract", + args: (dist_id.clone(),).into_val(&t.env), + sub_invokes: &[], + }, + }]); + + let result = t.contract.try_set_distribution_contract(&dist_id); + assert!(result.is_err()); +} + +#[test] +fn test_notify_distribution_funding_called_during_funding() { + let t = setup(); + let dist_id = t.env.register_contract(None, MockDistribution); + let dist = MockDistributionClient::new(&t.env, &dist_id); + + t.contract.set_distribution_contract(&dist_id); + + 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, + &ReferralCode::None, + ); + + t.contract + .fund_invoice(&t.funder, &invoice_id, &INVOICE_AMOUNT, &false); + + assert_eq!(dist.lp_volume(&t.funder), INVOICE_AMOUNT); +} + +#[test] +fn test_funding_succeeds_without_distribution_contract_set() { + 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, + &ReferralCode::None, + ); + + let result = t + .contract + .try_fund_invoice(&t.funder, &invoice_id, &INVOICE_AMOUNT, &false); + assert!(result.is_ok()); +} diff --git a/contracts/invoice_liquidity/src/tests_new_features.rs b/contracts/invoice_liquidity/src/tests_new_features.rs index fed22b45..213b3233 100644 --- a/contracts/invoice_liquidity/src/tests_new_features.rs +++ b/contracts/invoice_liquidity/src/tests_new_features.rs @@ -1057,3 +1057,39 @@ fn test_convert_invoice_token_emits_token_changed_event() { assert_eq!(decoded.old_token, t.token.address); assert_eq!(decoded.new_token, t.eurc_address); } + +#[test] +fn test_get_price_oracle_defaults_to_none() { + let t = setup(); + assert_eq!(t.contract.get_price_oracle(), None); +} + +#[test] +fn test_set_price_oracle_succeeds_as_admin() { + let t = setup(); + let oracle_id = t.env.register_contract(None, MockPriceOracle); + + let result = t.contract.try_set_price_oracle(&oracle_id); + assert!(result.is_ok()); + assert_eq!(t.contract.get_price_oracle(), Some(oracle_id)); +} + +#[test] +fn test_set_price_oracle_rejects_non_admin() { + let t = setup(); + let oracle_id = t.env.register_contract(None, MockPriceOracle); + let imposter = Address::generate(&t.env); + + t.env.mock_auths(&[MockAuth { + address: &imposter, + invoke: &MockAuthInvoke { + contract: &t.contract.address, + fn_name: "set_price_oracle", + args: (oracle_id.clone(),).into_val(&t.env), + sub_invokes: &[], + }, + }]); + + let result = t.contract.try_set_price_oracle(&oracle_id); + assert!(result.is_err()); +} diff --git a/contracts/invoice_liquidity/src/tests_referral.rs b/contracts/invoice_liquidity/src/tests_referral.rs new file mode 100644 index 00000000..4828c8d0 --- /dev/null +++ b/contracts/invoice_liquidity/src/tests_referral.rs @@ -0,0 +1,114 @@ +#![cfg(test)] + +use super::*; +use crate::test::setup; +use soroban_sdk::BytesN; + +const INVOICE_AMOUNT: i128 = 1_000_000_000; +const DISCOUNT_RATE: u32 = 300; +const DUE_DATE_OFFSET: u64 = 60 * 60 * 24 * 30; + +#[test] +fn test_referral_count_increments_on_submission() { + let t = setup(); + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + let code = BytesN::from_array(&t.env, &[2u8; 32]); + + t.contract.submit_invoice( + &t.freelancer, + &t.payer, + &INVOICE_AMOUNT, + &due_date, + &DISCOUNT_RATE, + &t.token.address, + &ReferralCode::Present(code.clone()), + ); + + let stats = t.contract.get_referral_stats(&code); + assert_eq!(stats, 1); +} + +#[test] +fn test_referral_count_returns_zero_for_unknown_code() { + let t = setup(); + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + let known_code = BytesN::from_array(&t.env, &[1u8; 32]); + let unknown_code = BytesN::from_array(&t.env, &[9u8; 32]); + + t.contract.submit_invoice( + &t.freelancer, + &t.payer, + &INVOICE_AMOUNT, + &due_date, + &DISCOUNT_RATE, + &t.token.address, + &ReferralCode::Present(known_code), + ); + + let stats = t.contract.get_referral_stats(&unknown_code); + assert_eq!(stats, 0); +} + +#[test] +fn test_referral_count_zero_when_no_submissions() { + let t = setup(); + let code = BytesN::from_array(&t.env, &[5u8; 32]); + + let stats = t.contract.get_referral_stats(&code); + assert_eq!(stats, 0); +} + +#[test] +fn test_submit_invoice_without_referral_does_not_increment_stats() { + let t = setup(); + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + let code = BytesN::from_array(&t.env, &[1u8; 32]); + + t.contract.submit_invoice( + &t.freelancer, + &t.payer, + &INVOICE_AMOUNT, + &due_date, + &DISCOUNT_RATE, + &t.token.address, + &ReferralCode::None, + ); + + let stats = t.contract.get_referral_stats(&code); + assert_eq!(stats, 0); +} + +#[test] +fn test_batch_submission_counts_referrals_correctly() { + let t = setup(); + let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; + let code = BytesN::from_array(&t.env, &[7u8; 32]); + + let mut batch = soroban_sdk::Vec::new(&t.env); + for _ in 0..3 { + batch.push_back(InvoiceParams { + freelancer: t.freelancer.clone(), + payer: t.payer.clone(), + amount: INVOICE_AMOUNT, + due_date, + discount_rate: DISCOUNT_RATE, + token: t.token.address.clone(), + referral_code: ReferralCode::Present(code.clone()), + }); + } + batch.push_back(InvoiceParams { + freelancer: t.freelancer.clone(), + payer: t.payer.clone(), + amount: INVOICE_AMOUNT, + due_date, + discount_rate: DISCOUNT_RATE, + token: t.token.address.clone(), + referral_code: ReferralCode::None, + }); + + let result = t.contract.try_submit_invoices_batch(&batch); + assert!(result.is_ok()); + + let stats = t.contract.get_referral_stats(&code); + assert_eq!(stats, 3); +} diff --git a/sdk/src/types.ts b/sdk/src/types.ts index c53f7d0e..554cc1c9 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -89,3 +89,104 @@ export interface AllowanceResult { /** Ledger sequence at which the allowance expires (0 = no expiry stored). */ expirationLedger: number; } + + +/** + * On-chain metadata for a minted invoice NFT. + */ +export interface InvoiceNftMetadata { + /** The invoice ID this NFT represents. */ + invoiceId: bigint; + /** Full invoice amount in stroops. */ + amount: bigint; + /** Unix timestamp of when the invoice is due. */ + dueDate: number; + /** Discount rate in basis points (e.g. 300 = 3.00%). */ + discountRate: number; + /** Token contract address used for the invoice. */ + token: string; + /** Current owner of the NFT. */ + owner: string; + /** Timestamp when the NFT was minted. */ + mintedAt: number; +} + +/** + * A single entry in the LP priority queue for invoice funding requests. + */ +export interface LpFundRequest { + /** Address of the liquidity provider. */ + lp: string; + /** LP reputation score snapshotted at request time (used for ordering). */ + score: number; +} + +/** + * Appeal record stored per invoice when a default is contested. + */ +export interface AppealRecord { + /** SHA-256 hash of off-chain evidence submitted by the payer. */ + evidenceHash: string; + /** Ledger timestamp when the appeal was filed. */ + appealedAt: number; + /** Payer reputation score just before the default was applied. */ + preDefaultScore: number; +} + +/** + * Dispute record stored per invoice. + */ +export interface DisputeRecord { + /** SHA-256 hash of off-chain dispute evidence. */ + reasonHash: string; + /** Ledger sequence when the dispute was filed. */ + disputedAt: number; +} + +/** + * A single entry in the top-payers leaderboard. + */ +export interface TopPayerEntry { + /** Address of the payer. */ + address: string; + /** Reputation score. */ + score: number; +} + +/** + * Reputation profile tracked per address. + */ +export interface ReputationProfile { + /** The address this profile belongs to. */ + address: string; + /** Number of invoices submitted. */ + invoicesSubmitted: number; + /** Number of invoices paid. */ + invoicesPaid: number; + /** Number of invoices defaulted. */ + invoicesDefaulted: number; + /** Reputation score. */ + score: number; +} + +/** + * Aggregate contract-wide statistics. + */ +export interface ContractStats { + /** Total number of invoices created. */ + totalInvoices: bigint; + /** Total number of invoices funded. */ + totalFunded: bigint; + /** Total number of invoices paid. */ + totalPaid: bigint; + /** Total volume in USDC (token base units). */ + totalVolumeUsdc: bigint; + /** Total volume in EURC (token base units). */ + totalVolumeEurc: bigint; + /** Total volume in XLM (stroops). */ + totalVolumeXlm: bigint; + /** Per-token volume breakdown: [tokenAddress, volume][]. */ + tokenVolumes: [string, bigint][]; + /** Total volume normalized to USD (token base units). */ + totalVolumeUsdNormalized: bigint; +}