From ce8e3e27680065cd2173e5328ceeb3faef106731 Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Sat, 20 Jun 2026 07:12:41 +0800 Subject: [PATCH 1/6] test: cover cancel vault refund transfer --- README_CANCEL_VAULT.md | 17 +++++---- tests/lifecycle.rs | 87 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/README_CANCEL_VAULT.md b/README_CANCEL_VAULT.md index 9bbf181..83d3979 100644 --- a/README_CANCEL_VAULT.md +++ b/README_CANCEL_VAULT.md @@ -2,22 +2,23 @@ Cancel Vault: Design, Security Notes, and Tests Summary - `cancel_vault` requires the caller to be the vault `creator` and only allows cancellation when the vault `status` is `Active`. -- On cancel it refunds the vault's stored token balance (simulated) back to the creator by clearing the contract-held balance and emitting a `vault_cancelled` event with the refunded amount. +- On cancel it transfers the escrowed token amount from the vault contract back to the creator and emits a `vault_cancelled` event. Behavior & rules - Cancellation allowed only when `status == Active` (i.e., before validation/completion). - Caller must be the `creator` (enforced via `Address::require_auth`). -- Refund is performed against a simulated per-vault balance stored in persistent storage under key `("vault_balance", vault_id)`. +- Refund uses the Soroban token client to transfer `vault.amount` from `env.current_contract_address()` to `vault.creator`. +- The vault record remains stored and its `status` is set to `Cancelled`. Security notes -- Real token transfer: the current implementation simulates token movement by updating contract-owned balance storage. For production, replace this with a real token transfer using the Soroban token client and ensure the contract has allowance/escrowed tokens before creating the vault. -- Reentrancy: token transfers must be performed using the recommended Soroban token client patterns; consider checks-effects-interactions ordering (we clear the stored balance and set status before emitting events). +- Real token transfer: `create_vault` escrows funds in the contract token balance, and `cancel_vault` sends those funds back to the creator. +- Reentrancy: token transfers should continue to use Soroban token client patterns. Keep state updates and event emission explicit when changing this flow. - Authorization: `creator.require_auth()` ensures only the vault owner can cancel. Tests -- Unit tests cover: successful cancel by creator, unauthorized cancel (panics), and cancel when status is Completed (returns false). -- A helper `get_vault_balance` exposes the simulated vault balance for assertions in tests. +- Unit tests cover successful cancel by creator, unauthorized cancel, nonexistent vaults, double cancel, and inactive `Completed` / `Failed` / `Cancelled` vault rejection. +- The lifecycle integration test asserts the real token refund by checking that the creator receives exactly `vault.amount`, the contract escrow balance returns to its pre-create value, the vault status becomes `Cancelled`, and `vault_cancelled` is emitted. Next steps -- Integrate with a real token contract: add a `token_contract: Address` per vault or instance-level token address and call the token `transfer` to move funds. -- Add more tests that register and interact with a real token contract in test `Env` to assert balances on ledger entries. +- Keep README, `src/doc.md`, and `contract-interface.json` aligned if the event topic, refund amount, or lifecycle status changes. +- Preserve the balance-delta assertions when adding new token escrow paths. diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs index 7079583..d4c55b6 100644 --- a/tests/lifecycle.rs +++ b/tests/lifecycle.rs @@ -1,9 +1,9 @@ #![cfg(test)] use soroban_sdk::{ - testutils::{Address as _, Ledger}, + testutils::{Address as _, Events, Ledger}, token::{StellarAssetClient, TokenClient}, - Address, BytesN, Env, + Address, BytesN, Env, Symbol, TryIntoVal, }; use disciplr_vault::{DisciplrVault, DisciplrVaultClient, VaultStatus, MIN_AMOUNT}; @@ -14,6 +14,7 @@ fn setup() -> ( Address, StellarAssetClient<'static>, TokenClient<'static>, + Address, ) { let env = Env::default(); env.mock_all_auths(); @@ -27,12 +28,19 @@ fn setup() -> ( let usdc_asset = StellarAssetClient::new(&env, &usdc_addr); let usdc_token_client = TokenClient::new(&env, &usdc_addr); - (env, client, usdc_addr, usdc_asset, usdc_token_client) + ( + env, + client, + usdc_addr, + usdc_asset, + usdc_token_client, + contract_id, + ) } #[test] fn test_full_lifecycle_success() { - let (env, client, usdc, usdc_asset, usdc_token) = setup(); + let (env, client, usdc, usdc_asset, usdc_token, _contract_id) = setup(); let creator = Address::generate(&env); let verifier = Address::generate(&env); @@ -82,7 +90,7 @@ fn test_full_lifecycle_success() { #[test] fn test_full_lifecycle_failure_redirection() { - let (env, client, usdc, usdc_asset, usdc_token) = setup(); + let (env, client, usdc, usdc_asset, usdc_token, _contract_id) = setup(); let creator = Address::generate(&env); let success_dest = Address::generate(&env); @@ -116,3 +124,72 @@ fn test_full_lifecycle_failure_redirection() { assert_eq!(final_state.status, VaultStatus::Failed); assert_eq!(usdc_token.balance(&failure_dest), MIN_AMOUNT); } + +#[test] +fn test_cancel_vault_refunds_creator_and_clears_contract_escrow() { + let (env, client, usdc, usdc_asset, usdc_token, contract_id) = setup(); + + let creator = Address::generate(&env); + let verifier = Address::generate(&env); + let success_dest = Address::generate(&env); + let failure_dest = Address::generate(&env); + let now = 1_700_000_000u64; + env.ledger().set_timestamp(now); + + usdc_asset.mint(&creator, &MIN_AMOUNT); + + let creator_before_create = usdc_token.balance(&creator); + let contract_before_create = usdc_token.balance(&contract_id); + let milestone = BytesN::from_array(&env, &[1u8; 32]); + + let vault_id = client.create_vault( + &usdc, + &creator, + &MIN_AMOUNT, + &now, + &(now + 86_400), + &milestone, + &Some(verifier), + &success_dest, + &failure_dest, + ); + + assert_eq!( + creator_before_create - usdc_token.balance(&creator), + MIN_AMOUNT + ); + assert_eq!( + usdc_token.balance(&contract_id) - contract_before_create, + MIN_AMOUNT + ); + + let creator_before_cancel = usdc_token.balance(&creator); + let result = client.cancel_vault(&vault_id, &usdc); + + assert!(result); + assert_eq!( + usdc_token.balance(&creator) - creator_before_cancel, + MIN_AMOUNT + ); + assert_eq!(usdc_token.balance(&contract_id), contract_before_create); + assert_eq!( + client.get_vault_state(&vault_id).unwrap().status, + VaultStatus::Cancelled + ); + + let mut found_cancel_event = false; + for (emitting_contract, topics, _) in env.events().all() { + if emitting_contract != contract_id { + continue; + } + + let event_name: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); + if event_name == Symbol::new(&env, "vault_cancelled") { + let event_vault_id: u32 = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(event_vault_id, vault_id); + found_cancel_event = true; + } + } + + assert!(found_cancel_event, "vault_cancelled event must be emitted"); +} From 7445c95a895221ba821d5b98558c3f692cb738ed Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Sat, 20 Jun 2026 07:16:05 +0800 Subject: [PATCH 2/6] test: assert cancel vault event --- README_CANCEL_VAULT.md | 3 ++- src/lib.rs | 16 ++++++++++++++++ tests/lifecycle.rs | 20 ++------------------ 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/README_CANCEL_VAULT.md b/README_CANCEL_VAULT.md index 83d3979..64213b0 100644 --- a/README_CANCEL_VAULT.md +++ b/README_CANCEL_VAULT.md @@ -17,7 +17,8 @@ Security notes Tests - Unit tests cover successful cancel by creator, unauthorized cancel, nonexistent vaults, double cancel, and inactive `Completed` / `Failed` / `Cancelled` vault rejection. -- The lifecycle integration test asserts the real token refund by checking that the creator receives exactly `vault.amount`, the contract escrow balance returns to its pre-create value, the vault status becomes `Cancelled`, and `vault_cancelled` is emitted. +- The lifecycle integration test asserts the real token refund by checking that the creator receives exactly `vault.amount`, the contract escrow balance returns to its pre-create value, and the vault status becomes `Cancelled`. +- The in-contract cancel test asserts that `vault_cancelled` is emitted with the cancelled vault ID. Next steps - Keep README, `src/doc.md`, and `contract-interface.json` aligned if the event topic, refund amount, or lifecycle status changes. diff --git a/src/lib.rs b/src/lib.rs index 9893393..64bd1ef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -559,6 +559,22 @@ mod tests { let vault = client.get_vault_state(&vault_id).unwrap(); assert_eq!(vault.status, VaultStatus::Cancelled); + + let mut found_cancel_event = false; + for (emitting_contract, topics, _) in setup.env.events().all() { + if emitting_contract != setup.contract_id { + continue; + } + + let event_name: Symbol = topics.get(0).unwrap().try_into_val(&setup.env).unwrap(); + if event_name == Symbol::new(&setup.env, "vault_cancelled") { + let event_vault_id: u32 = topics.get(1).unwrap().try_into_val(&setup.env).unwrap(); + assert_eq!(event_vault_id, vault_id); + found_cancel_event = true; + } + } + + assert!(found_cancel_event, "vault_cancelled event must be emitted"); } #[test] diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs index d4c55b6..3bae9b0 100644 --- a/tests/lifecycle.rs +++ b/tests/lifecycle.rs @@ -1,9 +1,9 @@ #![cfg(test)] use soroban_sdk::{ - testutils::{Address as _, Events, Ledger}, + testutils::{Address as _, Ledger}, token::{StellarAssetClient, TokenClient}, - Address, BytesN, Env, Symbol, TryIntoVal, + Address, BytesN, Env, }; use disciplr_vault::{DisciplrVault, DisciplrVaultClient, VaultStatus, MIN_AMOUNT}; @@ -176,20 +176,4 @@ fn test_cancel_vault_refunds_creator_and_clears_contract_escrow() { client.get_vault_state(&vault_id).unwrap().status, VaultStatus::Cancelled ); - - let mut found_cancel_event = false; - for (emitting_contract, topics, _) in env.events().all() { - if emitting_contract != contract_id { - continue; - } - - let event_name: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); - if event_name == Symbol::new(&env, "vault_cancelled") { - let event_vault_id: u32 = topics.get(1).unwrap().try_into_val(&env).unwrap(); - assert_eq!(event_vault_id, vault_id); - found_cancel_event = true; - } - } - - assert!(found_cancel_event, "vault_cancelled event must be emitted"); } From de874c04ce1440e1d61d98b41a7c75dd375a320a Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Sat, 20 Jun 2026 07:19:35 +0800 Subject: [PATCH 3/6] test: move cancel event assertion --- src/lib.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 64bd1ef..0f81bf7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -559,22 +559,6 @@ mod tests { let vault = client.get_vault_state(&vault_id).unwrap(); assert_eq!(vault.status, VaultStatus::Cancelled); - - let mut found_cancel_event = false; - for (emitting_contract, topics, _) in setup.env.events().all() { - if emitting_contract != setup.contract_id { - continue; - } - - let event_name: Symbol = topics.get(0).unwrap().try_into_val(&setup.env).unwrap(); - if event_name == Symbol::new(&setup.env, "vault_cancelled") { - let event_vault_id: u32 = topics.get(1).unwrap().try_into_val(&setup.env).unwrap(); - assert_eq!(event_vault_id, vault_id); - found_cancel_event = true; - } - } - - assert!(found_cancel_event, "vault_cancelled event must be emitted"); } #[test] @@ -996,6 +980,22 @@ mod tests { let vault = client.get_vault_state(&vault_id).unwrap(); assert_eq!(vault.status, VaultStatus::Cancelled); + + let mut found_cancel_event = false; + for (emitting_contract, topics, _) in setup.env.events().all() { + if emitting_contract != setup.contract_id { + continue; + } + + let event_name: Symbol = topics.get(0).unwrap().try_into_val(&setup.env).unwrap(); + if event_name == Symbol::new(&setup.env, "vault_cancelled") { + let event_vault_id: u32 = topics.get(1).unwrap().try_into_val(&setup.env).unwrap(); + assert_eq!(event_vault_id, vault_id); + found_cancel_event = true; + } + } + + assert!(found_cancel_event, "vault_cancelled event must be emitted"); } // ----------------------------------------------------------------------- From ddc9cdb9c417970cbd39e86c63ddd0bb7f072b95 Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Sat, 20 Jun 2026 07:22:27 +0800 Subject: [PATCH 4/6] test: keep cancel coverage to refund state --- README_CANCEL_VAULT.md | 1 - src/lib.rs | 16 ---------------- 2 files changed, 17 deletions(-) diff --git a/README_CANCEL_VAULT.md b/README_CANCEL_VAULT.md index 64213b0..b5a0a85 100644 --- a/README_CANCEL_VAULT.md +++ b/README_CANCEL_VAULT.md @@ -18,7 +18,6 @@ Security notes Tests - Unit tests cover successful cancel by creator, unauthorized cancel, nonexistent vaults, double cancel, and inactive `Completed` / `Failed` / `Cancelled` vault rejection. - The lifecycle integration test asserts the real token refund by checking that the creator receives exactly `vault.amount`, the contract escrow balance returns to its pre-create value, and the vault status becomes `Cancelled`. -- The in-contract cancel test asserts that `vault_cancelled` is emitted with the cancelled vault ID. Next steps - Keep README, `src/doc.md`, and `contract-interface.json` aligned if the event topic, refund amount, or lifecycle status changes. diff --git a/src/lib.rs b/src/lib.rs index 0f81bf7..9893393 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -980,22 +980,6 @@ mod tests { let vault = client.get_vault_state(&vault_id).unwrap(); assert_eq!(vault.status, VaultStatus::Cancelled); - - let mut found_cancel_event = false; - for (emitting_contract, topics, _) in setup.env.events().all() { - if emitting_contract != setup.contract_id { - continue; - } - - let event_name: Symbol = topics.get(0).unwrap().try_into_val(&setup.env).unwrap(); - if event_name == Symbol::new(&setup.env, "vault_cancelled") { - let event_vault_id: u32 = topics.get(1).unwrap().try_into_val(&setup.env).unwrap(); - assert_eq!(event_vault_id, vault_id); - found_cancel_event = true; - } - } - - assert!(found_cancel_event, "vault_cancelled event must be emitted"); } // ----------------------------------------------------------------------- From 29cf1e5715774143b2d15c4fe6aaa68bfa5891c9 Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Sat, 20 Jun 2026 12:40:58 +0800 Subject: [PATCH 5/6] Address draft PR validation --- tests/lifecycle.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs index 3bae9b0..131b9cb 100644 --- a/tests/lifecycle.rs +++ b/tests/lifecycle.rs @@ -3,7 +3,7 @@ use soroban_sdk::{ testutils::{Address as _, Ledger}, token::{StellarAssetClient, TokenClient}, - Address, BytesN, Env, + Address, BytesN, Env, Symbol, TryIntoVal, }; use disciplr_vault::{DisciplrVault, DisciplrVaultClient, VaultStatus, MIN_AMOUNT}; @@ -176,4 +176,20 @@ fn test_cancel_vault_refunds_creator_and_clears_contract_escrow() { client.get_vault_state(&vault_id).unwrap().status, VaultStatus::Cancelled ); + + let mut found_cancel_event = false; + for (emitting_contract, topics, _) in env.events().all() { + if emitting_contract != contract_id { + continue; + } + + let event_name: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); + if event_name == Symbol::new(&env, "vault_cancelled") { + let event_vault_id: u32 = topics.get(1).unwrap().try_into_val(&env).unwrap(); + assert_eq!(event_vault_id, vault_id); + found_cancel_event = true; + } + } + + assert!(found_cancel_event, "vault_cancelled event must be emitted"); } From 8b5eeaf15c6d1992af0866b72b4abb3e6eee95ca Mon Sep 17 00:00:00 2001 From: "Cyne Jarvis J. Zarceno" Date: Sat, 20 Jun 2026 12:47:52 +0800 Subject: [PATCH 6/6] Fix draft PR CI validation --- tests/lifecycle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lifecycle.rs b/tests/lifecycle.rs index 131b9cb..d4c55b6 100644 --- a/tests/lifecycle.rs +++ b/tests/lifecycle.rs @@ -1,7 +1,7 @@ #![cfg(test)] use soroban_sdk::{ - testutils::{Address as _, Ledger}, + testutils::{Address as _, Events, Ledger}, token::{StellarAssetClient, TokenClient}, Address, BytesN, Env, Symbol, TryIntoVal, };