diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs index 38e977d..76b49c2 100644 --- a/contracts/invoice-escrow/src/errors.rs +++ b/contracts/invoice-escrow/src/errors.rs @@ -37,4 +37,6 @@ pub enum Error { EscrowCancelled = 14, /// Payer is not the authorized debtor for this invoice. InvalidPayer = 15, + /// Due date is invalid (e.g., in the past or zero). + InvalidDueDate = 16, } diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index 0004b0a..c3553c0 100644 --- a/contracts/invoice-escrow/src/lib.rs +++ b/contracts/invoice-escrow/src/lib.rs @@ -60,6 +60,13 @@ impl InvoiceEscrow { if face_value <= 0 || purchase_price <= 0 { return Err(Error::InvalidAmount); } + if due_date == 0 { + return Err(Error::InvalidDueDate); + } + let current_timestamp = env.ledger().timestamp(); + if due_date <= current_timestamp { + return Err(Error::InvalidDueDate); + } storage::get_config(&env).ok_or(Error::NotInit)?; if storage::has_escrow(&env, invoice_id.clone()) { return Err(Error::EscrowExists); diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index 51901d2..51073af 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -1976,3 +1976,144 @@ fn test_commitment_persists_through_lifecycle() { assert_eq!(escrow_data.commitment, commitment); assert_eq!(escrow_data.status, EscrowStatus::Settled); } + +// ========== Due Date Validation Tests ========== + +#[test] +fn test_create_escrow_due_date_in_past_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow_client = InvoiceEscrowClient::new(&env, &escrow_id); + + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let payment_token = Address::generate(&env); + let inv_token = Address::generate(&env); + + escrow_client.initialize(&admin, &300); + + // Set ledger timestamp to a known time + env.ledger().with_mut(|li| li.timestamp = 1000000); + let current_timestamp = env.ledger().timestamp(); + + // Try to create escrow with due_date in the past + let past_due_date = current_timestamp - 1000; + let result = escrow_client.try_create_escrow( + &Symbol::new(&env, "INV_PAST"), + &seller, + &seller, + &1000, + &950, + &past_due_date, + &payment_token, + &inv_token, + &test_commitment(&env, "past_due_test"), + ); + assert_eq!(result, Err(Ok(Error::InvalidDueDate))); +} + +#[test] +fn test_create_escrow_due_date_equal_to_current_timestamp_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow_client = InvoiceEscrowClient::new(&env, &escrow_id); + + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let payment_token = Address::generate(&env); + let inv_token = Address::generate(&env); + + escrow_client.initialize(&admin, &300); + + // Set ledger timestamp to a known time + env.ledger().with_mut(|li| li.timestamp = 1000000); + let current_timestamp = env.ledger().timestamp(); + + // Try to create escrow with due_date equal to current timestamp + let result = escrow_client.try_create_escrow( + &Symbol::new(&env, "INV_EQUAL"), + &seller, + &seller, + &1000, + &950, + ¤t_timestamp, + &payment_token, + &inv_token, + &test_commitment(&env, "equal_timestamp_test"), + ); + assert_eq!(result, Err(Ok(Error::InvalidDueDate))); +} + +#[test] +fn test_create_escrow_due_date_zero_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow_client = InvoiceEscrowClient::new(&env, &escrow_id); + + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let payment_token = Address::generate(&env); + let inv_token = Address::generate(&env); + + escrow_client.initialize(&admin, &300); + + // Try to create escrow with due_date = 0 + let result = escrow_client.try_create_escrow( + &Symbol::new(&env, "INV_ZERO"), + &seller, + &seller, + &1000, + &950, + &0, + &payment_token, + &inv_token, + &test_commitment(&env, "zero_due_date_test"), + ); + assert_eq!(result, Err(Ok(Error::InvalidDueDate))); +} + +#[test] +fn test_create_escrow_due_date_in_future_accepted() { + let env = Env::default(); + env.mock_all_auths(); + + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow_client = InvoiceEscrowClient::new(&env, &escrow_id); + + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let payment_token = Address::generate(&env); + let inv_token = Address::generate(&env); + + escrow_client.initialize(&admin, &300); + + // Set ledger timestamp to a known time + env.ledger().with_mut(|li| li.timestamp = 1000000); + let current_timestamp = env.ledger().timestamp(); + + // Create escrow with due_date in the future - should succeed + let future_due_date = current_timestamp + 1000000; + let invoice_id = Symbol::new(&env, "INV_FUTURE"); + escrow_client.create_escrow( + &invoice_id, + &seller, + &seller, + &1000, + &950, + &future_due_date, + &payment_token, + &inv_token, + &test_commitment(&env, "future_due_test"), + ); + + // Verify escrow was created successfully + let escrow_data = escrow_client.get_escrow(&invoice_id); + assert_eq!(escrow_data.due_dt, future_due_date); + assert_eq!(escrow_data.status, EscrowStatus::Created); +} diff --git a/contracts/invoice-token/src/lib.rs b/contracts/invoice-token/src/lib.rs index 2f136bf..ab77e99 100644 --- a/contracts/invoice-token/src/lib.rs +++ b/contracts/invoice-token/src/lib.rs @@ -114,6 +114,9 @@ impl InvoiceToken { expiration_ledger: u32, ) -> Result<(), Error> { from.require_auth(); + if amount < 0 { + return Err(Error::InvalidAmount); + } let ledger = env.ledger().sequence(); if amount != 0 && expiration_ledger < ledger { return Err(Error::InvalidExpiration); diff --git a/contracts/invoice-token/src/test.rs b/contracts/invoice-token/src/test.rs index 2782aec..ca441bc 100644 --- a/contracts/invoice-token/src/test.rs +++ b/contracts/invoice-token/src/test.rs @@ -1037,3 +1037,60 @@ fn test_approve_update_expiration_shortens() { .with_mut(|li| li.sequence_number = new_expiration + 1); assert_eq!(client.allowance(&admin, &spender), 0); } + +// ========== Negative Amount Tests ========== + +#[test] +fn test_approve_negative_amount_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _minter) = setup_token(&env); + + let spender = Address::generate(&env); + let expiration = env.ledger().sequence() + 100; + + // Attempt to approve with negative amount should fail + let result = client.try_approve(&admin, &spender, &(-100i128), &expiration); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidAmount))); + + // Verify no allowance was set + assert_eq!(client.allowance(&admin, &spender), 0); +} + +#[test] +fn test_approve_zero_amount_accepted() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _minter) = setup_token(&env); + + let spender = Address::generate(&env); + let expiration = env.ledger().sequence() + 100; + + // First set a non-zero allowance + client.approve(&admin, &spender, &500, &expiration); + assert_eq!(client.allowance(&admin, &spender), 500); + + // Approve with zero amount should remove allowance (current behavior) + client.approve(&admin, &spender, &0, &expiration); + assert_eq!(client.allowance(&admin, &spender), 0); +} + +#[test] +fn test_approve_positive_amount_invalid_expiration_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _minter) = setup_token(&env); + + let spender = Address::generate(&env); + + // Advance ledger to ensure we can test past expiration + env.ledger().with_mut(|li| li.sequence_number = 10); + let current_ledger = env.ledger().sequence(); + + // Attempt to approve with positive amount and invalid expiration should fail + let result = client.try_approve(&admin, &spender, &500, &(current_ledger - 1)); + assert_eq!(result, Err(Ok(crate::errors::Error::InvalidExpiration))); + + // Verify no allowance was set + assert_eq!(client.allowance(&admin, &spender), 0); +} diff --git a/docs/API.md b/docs/API.md index 33f39c0..ec8769c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -30,7 +30,22 @@ Returns the current administrative address of the contract. (Legacy documentation for reference) ### `initialize(admin: Address, platform_fee_bps: u32)` -### `create_escrow(invoice_id: Symbol, seller: Address, amount: i128, due_date: u64, payment_token: Address, invoice_token: Address)` +### `create_escrow(invoice_id: Symbol, seller: Address, debtor: Address, face_value: i128, purchase_price: i128, due_date: u64, payment_token: Address, invoice_token: Address, commitment: BytesN<32>)` +Creates an escrow for an invoice with the specified parameters. +- **invoice_id**: Unique identifier for the invoice. +- **seller**: Address of the invoice seller (creator of the escrow). +- **debtor**: Address of the party responsible for paying the invoice. +- **face_value**: Total amount owed by the debtor (must be > 0). +- **purchase_price**: Amount the investor will pay to fund the escrow (must be > 0). +- **due_date**: Unix timestamp when the invoice is due (must be > 0 and > current ledger timestamp). +- **payment_token**: Address of the token used for payments. +- **invoice_token**: Address of the invoice token contract. +- **commitment**: SHA-256 hash of off-chain invoice data (immutable anchor). + +**Constraints:** +- face_value and purchase_price must be positive (> 0) +- due_date must be non-zero and strictly greater than the current ledger timestamp +- Each invoice_id can only be used once ### `fund_escrow(invoice_id: Symbol, buyer: Address)` ### `record_payment(invoice_id: Symbol, payer: Address, amount: i128)` Records a full or partial payment for a funded invoice.