From fa24e8135b992a7110f988bfee58b9a5fd11d233 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 25 Jul 2026 18:47:08 +0100 Subject: [PATCH] Add tests for referral stats, distribution contract, and price oracle - tests_referral.rs: referral count increments on submission, returns 0 for unknown codes, and batch submissions count referrals correctly (also fixes an existing type error: ReferralCode::Present(..) instead of Some(..)) - tests_distribution.rs: set_distribution_contract as admin, rejects non-admin, notify_distribution_funding invoked during fund_invoice (also fixes broken initialize/submit_invoice call arity) - tests_new_features.rs: get_price_oracle defaults to None, set_price_oracle succeeds as admin and is reflected in get_price_oracle, rejects non-admin - sdk/src/types.ts: add TypeScript types for InvoiceNftMetadata, LpFundRequest, AppealRecord, DisputeRecord, TopPayerEntry, ReputationProfile, ContractStats Resolves #492, #493, #494, #564 --- .../src/tests_distribution.rs | 174 ++++++++++++------ .../src/tests_new_features.rs | 36 ++++ .../invoice_liquidity/src/tests_referral.rs | 85 +++++++-- sdk/src/types.ts | 101 ++++++++++ 4 files changed, 326 insertions(+), 70 deletions(-) diff --git a/contracts/invoice_liquidity/src/tests_distribution.rs b/contracts/invoice_liquidity/src/tests_distribution.rs index 05021c0d..44cc727d 100644 --- a/contracts/invoice_liquidity/src/tests_distribution.rs +++ b/contracts/invoice_liquidity/src/tests_distribution.rs @@ -3,16 +3,18 @@ use super::*; use soroban_sdk::{ contract, contractimpl, contracttype, - testutils::{Address as _, Ledger}, + testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}, token::{Client as TokenClient, StellarAssetClient}, - Address, Env, + 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), - Freelancer(Address), - PayerOnTime(Address), } #[contract] @@ -28,86 +30,140 @@ impl MockDistribution { .set(&key, &(current + amount_usdc_equivalent)); } - pub fn accrue_settlement(env: Env, freelancer: Address, payer: Address, on_time: bool) { - let freelancer_key = DistDataKey::Freelancer(freelancer); - let freelancer_count: u64 = env.storage().persistent().get(&freelancer_key).unwrap_or(0); - env.storage() - .persistent() - .set(&freelancer_key, &(freelancer_count + 1)); - - if on_time { - let payer_key = DistDataKey::PayerOnTime(payer); - let payer_count: u64 = env.storage().persistent().get(&payer_key).unwrap_or(0); - env.storage() - .persistent() - .set(&payer_key, &(payer_count + 1)); - } - } - pub fn lp_volume(env: Env, lp: Address) -> i128 { env.storage() .persistent() .get(&DistDataKey::Lp(lp)) .unwrap_or(0) } +} - pub fn freelancer_settled(env: Env, freelancer: Address) -> u64 { - env.storage() - .persistent() - .get(&DistDataKey::Freelancer(freelancer)) - .unwrap_or(0) - } - - pub fn payer_on_time(env: Env, payer: Address) -> u64 { - env.storage() - .persistent() - .get(&DistDataKey::PayerOnTime(payer)) - .unwrap_or(0) - } +struct DistTestEnv { + env: Env, + contract: InvoiceLiquidityContractClient<'static>, + token: TokenClient<'static>, + freelancer: Address, + payer: Address, + funder: Address, } -#[test] -fn distribution_hooks_track_lp_freelancer_and_payer() { +fn setup() -> DistTestEnv { let env = Env::default(); env.mock_all_auths(); + let admin = Address::generate(&env); + let usdc_admin = Address::generate(&env); - let usdc_id = env.register_stellar_asset_contract_v2(usdc_admin.clone()); - let usdc = TokenClient::new(&env, &usdc_id.address()); - let usdc_admin_client = StellarAssetClient::new(&env, &usdc_id.address()); + 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); - let invoice_amount: i128 = 1_000_000_000; - usdc_admin_client.mint(&funder, &(invoice_amount * 10)); - usdc_admin_client.mint(&payer, &(invoice_amount * 10)); + token_admin.mint(&funder, &(INVOICE_AMOUNT * 10)); + token_admin.mint(&payer, &(INVOICE_AMOUNT * 10)); - let invoice_id = env.register_contract(None, InvoiceLiquidityContract); - let invoice = InvoiceLiquidityContractClient::new(&env, &invoice_id); + let contract_id = env.register_contract(None, InvoiceLiquidityContract); + let contract = InvoiceLiquidityContractClient::new(&env, &contract_id); + token_admin.mint(&contract.address, &(INVOICE_AMOUNT * 100)); - let xlm_admin = Address::generate(&env); - let xlm_id = env.register_stellar_asset_contract_v2(xlm_admin); + contract.initialize(&admin, &usdc_address, &eurc_address, &xlm_address); - invoice.initialize(&usdc_admin, &usdc_id.address(), &xlm_id.address()); + let mut ledger_info = env.ledger().get(); + ledger_info.timestamp = 1_700_000_000; + env.ledger().set(ledger_info); - let dist_id = env.register_contract(None, MockDistribution); - let dist = MockDistributionClient::new(&env, &dist_id); - invoice.set_distribution_contract(&dist_id); + DistTestEnv { + env, + contract, + token, + freelancer, + payer, + funder, + } +} - let mut ledger = env.ledger().get(); - ledger.timestamp = 1_700_000_000; - env.ledger().set(ledger.clone()); +#[test] +fn test_set_distribution_contract_succeeds_as_admin() { + let t = setup(); + let dist_id = t.env.register_contract(None, MockDistribution); - let due_date = ledger.timestamp + (24 * 60 * 60) + 3600; // 25 hours in the future - let submitted = invoice.submit_invoice(&ReferralCode::None); + let result = t.contract.try_set_distribution_contract(&dist_id); + assert!(result.is_ok()); +} - invoice.fund_invoice(&funder, &submitted, &invoice_amount, &false); - assert_eq!(dist.lp_volume(&funder), invoice_amount); +#[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()); +} - invoice.mark_paid(&submitted, &invoice_amount); +#[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); +} - assert_eq!(dist.freelancer_settled(&freelancer), 1); - assert_eq!(dist.payer_on_time(&payer), 1); +#[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 3d9de46a..0c9366b7 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 index faf469e1..4828c8d0 100644 --- a/contracts/invoice_liquidity/src/tests_referral.rs +++ b/contracts/invoice_liquidity/src/tests_referral.rs @@ -9,43 +9,106 @@ const DISCOUNT_RATE: u32 = 300; const DUE_DATE_OFFSET: u64 = 60 * 60 * 24 * 30; #[test] -fn test_submit_invoice_without_referral_does_not_increment_stats() { +fn test_referral_count_increments_on_submission() { let t = setup(); let due_date = t.env.ledger().timestamp() + DUE_DATE_OFFSET; - // create a code to query - let code = BytesN::from_array(&t.env, &[1u8; 32]); + let code = BytesN::from_array(&t.env, &[2u8; 32]); - let id = t.contract.submit_invoice( + t.contract.submit_invoice( &t.freelancer, &t.payer, &INVOICE_AMOUNT, &due_date, &DISCOUNT_RATE, &t.token.address, - &ReferralCode::None, + &ReferralCode::Present(code.clone()), ); - // stats for arbitrary code should be zero 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_submit_invoice_with_referral_increments_stats() { +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, &[2u8; 32]); + let code = BytesN::from_array(&t.env, &[1u8; 32]); - let id = t.contract.submit_invoice( + t.contract.submit_invoice( &t.freelancer, &t.payer, &INVOICE_AMOUNT, &due_date, &DISCOUNT_RATE, &t.token.address, - &Some(code.clone()), + &ReferralCode::None, ); let stats = t.contract.get_referral_stats(&code); - assert_eq!(stats, 1); + 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; +}