diff --git a/.gitignore b/.gitignore index 9a19c58..c523263 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,21 @@ +# ============================================================ # Build output +# ============================================================ target/ *.wasm wasm32-unknown-unknown/ **/wasm32-unknown-unknown/ -# Environment files +# ============================================================ +# Environment / secrets +# ============================================================ .env .env.local .env.*.local -# OS / Editor files +# ============================================================ +# OS / editor artefacts +# ============================================================ .DS_Store Thumbs.db desktop.ini @@ -21,7 +27,9 @@ desktop.ini *.sublime-project *.sublime-workspace -# Test snapshots and generated artifacts +# ============================================================ +# Test snapshots and generated test artefacts +# ============================================================ **/__snapshots__/ *.snap *.snap.orig @@ -30,35 +38,44 @@ desktop.ini tests/snapshots/ tests/fixtures/ +# ============================================================ # Stellar CLI +# ============================================================ .stellar/ identity/ .stellar-cli/ +# ============================================================ # Logs +# ============================================================ *.log *.log.* npm-debug.log* yarn-debug.log* yarn-error.log* +# ============================================================ # Coverage reports +# ============================================================ lcov.info tarpaulin-report.html coverage/ .nyc_output/ profdata/ +# ============================================================ # Temporary files +# ============================================================ *.tmp *.bak *.orig *.rej -*~ .*.swp .*.swo -# Node / JS tooling +# ============================================================ +# Node / JS tooling (not used in this project, kept for safety) +# ============================================================ node_modules/ dist/ .pnp @@ -69,21 +86,49 @@ dist/ .parcel-cache/ .cache/ +# ============================================================ # Rust / Cargo artefacts not caught by target/ +# ============================================================ **/*.rs.bk Cargo.lock.bak **/*.profraw **/*.profdata -# Fuzz corpus (generated during fuzzing runs) +# ============================================================ +# Fuzz corpus (generated during fuzzing runs — seed files are +# committed via explicit !fuzz/corpus//seed_* rules) +# ============================================================ fuzz/corpus/* -!fuzz/corpus/.gitkeep +!fuzz/corpus/fuzz_sequence/seed_zeros +!fuzz/corpus/fuzz_sequence/seed_empty +!fuzz/corpus/fuzz_sequence/seed_mixed +!fuzz/corpus/fuzz_sequence/seed_ones +!fuzz/corpus/fuzz_create_invoice/seed_zeros +!fuzz/corpus/fuzz_create_invoice/seed_empty +!fuzz/corpus/fuzz_create_invoice/seed_mixed +!fuzz/corpus/fuzz_create_invoice/seed_ones +!fuzz/corpus/fuzz_pay/seed_zeros +!fuzz/corpus/fuzz_pay/seed_empty +!fuzz/corpus/fuzz_pay/seed_mixed +!fuzz/corpus/fuzz_pay/seed_ones +!fuzz/corpus/fuzz_refund/seed_zeros +!fuzz/corpus/fuzz_refund/seed_empty +!fuzz/corpus/fuzz_refund/seed_mixed +!fuzz/corpus/fuzz_refund/seed_ones +!fuzz/corpus/fuzz_release/seed_zeros +!fuzz/corpus/fuzz_release/seed_empty +!fuzz/corpus/fuzz_release/seed_mixed +!fuzz/corpus/fuzz_release/seed_ones -# Cargo audit / deny / outdated cache +# ============================================================ +# Cargo audit / deny cache +# ============================================================ .cargo-ok cargo-audit.toml +# ============================================================ # Python / misc tooling +# ============================================================ __pycache__/ *.pyc .pytest_cache/ diff --git a/contracts/factory/src/lib.rs b/contracts/factory/src/lib.rs index 70054ff..9086ec7 100644 --- a/contracts/factory/src/lib.rs +++ b/contracts/factory/src/lib.rs @@ -35,8 +35,13 @@ const MAX_DEPLOYMENTS_PER_CREATOR: u32 = 10_000; /// Emitted when a new split contract is deployed by the factory. /// -/// Topics: (factory, deployed, creator) -/// Data: (contract_address, salt) +/// Topics: `(factory, deployed, creator)` +/// Data: `contract_address` +/// +/// # Parameters +/// * `env` — The current Soroban execution environment. +/// * `creator` — The address that triggered the deployment. +/// * `contract_address` — The address of the newly deployed contract. pub fn contract_deployed(env: &Env, creator: &Address, contract_address: &Address) { env.events().publish( ( @@ -58,7 +63,17 @@ pub struct SplitFactory; #[contractimpl] impl SplitFactory { /// Initialise the factory by recording its admin. - /// Can only be called once. + /// + /// Must be called exactly once after the contract is deployed. Subsequent + /// calls panic with `"already initialized"`. + /// + /// # Parameters + /// * `env` — The current Soroban execution environment. + /// * `admin` — The address that will become the factory administrator. + /// + /// # Panics + /// Panics with `"already initialized"` if the factory has already been + /// initialised. pub fn initialize(env: Env, admin: Address) { assert!( !env.storage().instance().has(&factory_admin_key()), @@ -150,6 +165,18 @@ impl SplitFactory { } /// Return all deployed contract addresses for a given creator. + /// + /// Looks up the persistent list of contracts that `creator` has deployed + /// through this factory. Returns an empty `Vec` when the creator has not + /// yet deployed any contracts. + /// + /// # Parameters + /// * `env` — The current Soroban execution environment. + /// * `creator` — The creator address to query. + /// + /// # Returns + /// A `Vec
` of contract addresses in deployment order. Empty if + /// no contracts have been deployed for this creator. pub fn get_deployments(env: Env, creator: Address) -> Vec
{ env.storage() .persistent() @@ -158,6 +185,20 @@ impl SplitFactory { } /// Check whether a specific (creator, salt) pair has already been used. + /// + /// Because the deployed address is deterministic per `(creator, salt)`, + /// the factory rejects duplicate salts for the same creator. Use this + /// view function before calling [`Self::deploy_invoice_contract`] to + /// pre-check whether a salt is available. + /// + /// # Parameters + /// * `env` — The current Soroban execution environment. + /// * `creator` — The creator address to check. + /// * `salt` — The 32-byte salt to check. + /// + /// # Returns + /// `true` if the `(creator, salt)` pair was already used in a previous + /// deployment; `false` otherwise. pub fn is_salt_used(env: Env, creator: Address, salt: BytesN<32>) -> bool { env.storage().persistent().has(&salt_key(&creator, &salt)) } diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index 18b8df0..4b212f7 100644 --- a/contracts/invoice-escrow/src/lib.rs +++ b/contracts/invoice-escrow/src/lib.rs @@ -27,7 +27,7 @@ use errors::Error; use soroban_sdk::{ contract, contractimpl, symbol_short, token, Address, BytesN, Env, Symbol, Vec, }; -use types::{BlacklistEntry, EscrowInvoice, EscrowStatus}; +use types::{BlacklistEntry, EscrowInvoice, EscrowReleased, EscrowStatus}; // --------------------------------------------------------------------------- // Storage key helpers @@ -476,6 +476,17 @@ impl InvoiceEscrowContract { let token_client = token::Client::new(&env, &invoice.token); token_client.transfer(&env.current_contract_address(), &creator, &total); emit_released(&env, invoice_id, total); + + // Emit structured EscrowReleased event for off-chain indexers. + env.events().publish( + (symbol_short!("escrow"), symbol_short!("released")), + EscrowReleased { + invoice_id, + recipient: creator, + amount: total, + }, + ); + Ok(()) } @@ -774,6 +785,26 @@ impl InvoiceEscrowContract { get_invoice(&env, invoice_id) } + /// Return the current locked balance held in escrow for a given invoice. + /// + /// This is a read-only view function — it performs no state mutations. + /// Returns `0` when no escrow entry exists for `invoice_id` (i.e. the + /// invoice has never been created or has already been released/refunded). + /// + /// # Arguments + /// * `invoice_id` — The ID of the invoice to query. + /// + /// # Returns + /// The `funded_amount` currently locked in this escrow invoice, or `0` + /// if the invoice does not exist. + pub fn get_escrow_balance(env: Env, invoice_id: u64) -> i128 { + env.storage() + .persistent() + .get::<_, EscrowInvoice>(&invoice_key(invoice_id)) + .map(|inv| inv.funded_amount) + .unwrap_or(0) + } + /// Return the amount a specific payer has deposited toward an invoice. pub fn get_deposit(env: Env, invoice_id: u64, payer: Address) -> i128 { env.storage() diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index 2e51e41..6431a27 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -716,3 +716,106 @@ fn test_cannot_finalise_twice() { let result = client.try_finalise_blacklist(&admin, &payer, &true); assert_eq!(result, Err(Ok(Error::AlreadyFinalised))); } + +// --------------------------------------------------------------------------- +// #735: get_escrow_balance +// --------------------------------------------------------------------------- + +#[test] +fn test_get_escrow_balance_existing_invoice_returns_funded_amount() { + let (env, contract_id) = setup(); + let client = InvoiceEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let payer = Address::generate(&env); + + client.initialize(&admin); + + let token = create_token(&env, &token_admin); + let total: i128 = 1_000; + let deposit_amount: i128 = 400; + let deadline: u64 = env.ledger().timestamp() + 10_000; + + // Mint enough tokens to the payer. + mint(&env, &token, &token_admin, &payer, total); + + let invoice_id = client.create_invoice( + &Address::generate(&env), + &token, + &total, + &deadline, + ); + + // Before any deposit, balance should equal 0. + assert_eq!(client.get_escrow_balance(&invoice_id), 0); + + // Deposit a partial amount. + client.deposit(&payer, &invoice_id, &deposit_amount); + + // Balance must reflect the deposited amount. + assert_eq!(client.get_escrow_balance(&invoice_id), deposit_amount); +} + +#[test] +fn test_get_escrow_balance_unknown_invoice_returns_zero() { + let (env, contract_id) = setup(); + let client = InvoiceEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + + client.initialize(&admin); + + // Invoice ID 9999 was never created — must return 0, not panic. + assert_eq!(client.get_escrow_balance(&9999), 0); +} + +// --------------------------------------------------------------------------- +// #736: EscrowReleased event +// --------------------------------------------------------------------------- + +#[test] +fn test_release_emits_escrow_released_event() { + let (env, contract_id) = setup(); + let client = InvoiceEscrowContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let token_admin = Address::generate(&env); + let creator = Address::generate(&env); + let payer = Address::generate(&env); + + client.initialize(&admin); + + let token = create_token(&env, &token_admin); + let total: i128 = 500; + let deadline: u64 = env.ledger().timestamp() + 10_000; + + mint(&env, &token, &token_admin, &payer, total); + + let invoice_id = client.create_invoice(&creator, &token, &total, &deadline); + + // Deposit the full amount so the invoice becomes fully funded. + client.deposit(&payer, &invoice_id, &total); + + // Verify EscrowReleased event was emitted. + let all_events = env.events().all(); + let mut found = false; + for event in all_events.iter() { + // Topics for the structured release event are (escrow, released). + let topics = event.1; + if topics.len() >= 2 { + if let Ok(t0) = >::try_from_val( + &env, + &topics.get_unchecked(0), + ) { + if let Ok(t1) = >::try_from_val( + &env, + &topics.get_unchecked(1), + ) { + if t0 == symbol_short!("escrow") && t1 == symbol_short!("released") { + found = true; + break; + } + } + } + } + } + assert!(found, "EscrowReleased event was not emitted during release"); +} diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs index b5c6b1a..0a498e0 100644 --- a/contracts/invoice-escrow/src/types.rs +++ b/contracts/invoice-escrow/src/types.rs @@ -79,6 +79,26 @@ pub struct AdminTransferCancelledEvent { pub ledger: u32, } +// ────────────────────────────────────────────────────────────────────── +// Escrow release event +// ────────────────────────────────────────────────────────────────────── + +/// Emitted after funds are successfully transferred to a recipient during +/// a release call. +/// +/// Event topics: `(escrow, released)` +/// Event data: `EscrowReleased { invoice_id, recipient, amount }` +#[contracttype] +#[derive(Clone, Debug)] +pub struct EscrowReleased { + /// The ID of the invoice that was released. + pub invoice_id: u64, + /// The address that received the released funds. + pub recipient: Address, + /// The amount of tokens transferred to the recipient. + pub amount: i128, +} + // ────────────────────────────────────────────────────────────────────── // Payer Blacklist types // ────────────────────────────────────────────────────────────────────── diff --git a/contracts/split/src/storage_snapshot.rs b/contracts/split/src/storage_snapshot.rs index b45b99e..2616943 100644 --- a/contracts/split/src/storage_snapshot.rs +++ b/contracts/split/src/storage_snapshot.rs @@ -349,3 +349,100 @@ fn storage_key_snapshot() { ); } } + +// --------------------------------------------------------------------------- +// #738: Invoice state persistence — storage round-trip tests +// --------------------------------------------------------------------------- + +/// Test 1: snapshot round-trip — write then read equals original. +/// +/// Writes a value under a persistent storage key and reads it back. Verifies +/// that what goes in comes back out byte-for-byte identical (the XDR +/// serialisation round-trips correctly). +#[test] +fn storage_roundtrip_write_then_read_equals_original() { + let env = Env::default(); + + // Use a simple (Symbol, u64) key identical to the pattern used throughout + // the split contract (e.g. invoice_key). + let key = (symbol_short!("inv"), 1u64); + let original: i128 = 123_456_789; + + env.storage().persistent().set(&key, &original); + + let retrieved: i128 = env + .storage() + .persistent() + .get(&key) + .expect("value should be present after write"); + + assert_eq!( + retrieved, original, + "round-trip mismatch: stored {original} but read {retrieved}" + ); +} + +/// Test 2: partial field update does not overwrite unrelated keys. +/// +/// Writes values under two distinct storage keys, updates one of them, and +/// asserts that the other key's value is unchanged. This guards against +/// accidental key aliasing or mis-keyed writes. +#[test] +fn storage_partial_update_does_not_overwrite_unrelated_keys() { + let env = Env::default(); + + let key_a = (symbol_short!("inv"), 1u64); + let key_b = (symbol_short!("inv"), 2u64); + + let value_a: i128 = 1_000; + let value_b: i128 = 2_000; + + // Write both keys. + env.storage().persistent().set(&key_a, &value_a); + env.storage().persistent().set(&key_b, &value_b); + + // Update only key_a. + let updated_a: i128 = 9_999; + env.storage().persistent().set(&key_a, &updated_a); + + // key_b must be unchanged. + let still_b: i128 = env + .storage() + .persistent() + .get(&key_b) + .expect("key_b should still be present"); + + assert_eq!( + still_b, value_b, + "key_b was incorrectly modified by update to key_a" + ); + + // Sanity check: key_a reflects the update. + let new_a: i128 = env + .storage() + .persistent() + .get(&key_a) + .expect("key_a should be present after update"); + assert_eq!(new_a, updated_a); +} + +/// Test 3: missing key returns None without panic. +/// +/// Reads from a key that was never written. Asserts that `.get()` returns +/// `None` (no panic, no spurious default) — matching the contract's expected +/// `unwrap_or` / `unwrap_or_default` behaviour for absent entries. +#[test] +fn storage_missing_key_returns_none_without_panic() { + let env = Env::default(); + + // A key that has never been written. + let key = (symbol_short!("inv"), 99_999u64); + + let result: Option = env.storage().persistent().get(&key); + + assert!( + result.is_none(), + "expected None for missing key, got Some({:?})", + result + ); +}