From 495ce643c556c86c8977a27feea0fba1ff14624d Mon Sep 17 00:00:00 2001 From: davieslennox0 <102302431+davieslennox0@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:31:04 +0000 Subject: [PATCH] feat: add self-service leave_pool for non-admin members - Rotational: extract shared remove logic into helper, add leave_pool with beneficiary guard - Flexible: add leave_pool with balance refund - Target: add leave_pool blocked after unlock - Frontend: useLeavePool hook + Leave Pool button with confirmation dialog (non-admin only) - Unit tests for all three pool types Closes #140 Co-Authored-By: Claude Sonnet 4.6 --- frontend/components/group/group-actions.tsx | 79 ++++++ frontend/hooks/useJointSaveContracts.ts | 30 +++ smartcontract/Cargo.lock | 253 +++++++++++++++++- smartcontract/contracts/flexible/src/lib.rs | 38 +++ smartcontract/contracts/flexible/src/tests.rs | 95 +++++++ .../test_deploy_to_yield_tracks_amount.1.json | 86 ++++++ .../test_minimum_deposit_rejection.1.json | 86 ++++++ ...est_proportional_yield_distribution.1.json | 86 ++++++ .../tests/test_set_yield_strategy.1.json | 86 ++++++ ...eld_strategy_requires_yield_enabled.1.json | 88 +++++- .../test_withdrawal_fee_deduction.1.json | 86 ++++++ smartcontract/contracts/rotational/src/lib.rs | 45 +++- .../contracts/rotational/src/tests.rs | 156 +++++++++++ smartcontract/contracts/target/src/lib.rs | 41 +++ smartcontract/contracts/target/src/tests.rs | 190 +++++++++++++ 15 files changed, 1424 insertions(+), 21 deletions(-) diff --git a/frontend/components/group/group-actions.tsx b/frontend/components/group/group-actions.tsx index 6631a15..3361ea3 100644 --- a/frontend/components/group/group-actions.tsx +++ b/frontend/components/group/group-actions.tsx @@ -17,6 +17,7 @@ import { Clock, UserPlus, Trash2, + LogOut, } from "lucide-react"; import { useStellar } from "@/components/web3-provider"; import { @@ -31,6 +32,7 @@ import { useUnpausePool, useAddPoolMember, useRemovePoolMember, + useLeavePool, fetchRotationalState, fetchPoolMembers, } from "@/hooks/useJointSaveContracts"; @@ -113,6 +115,7 @@ export function GroupActions({ const [members, setMembers] = useState([]); const [newMember, setNewMember] = useState(""); const [memberToRemove, setMemberToRemove] = useState(null); + const [showLeaveDialog, setShowLeaveDialog] = useState(false); const isPending = !poolAddress || poolAddress === "pending_deployment"; // Token display metadata (persisted on the pool row; defaults to native XLM) const tokenSymbol: string = poolData?.token_symbol ?? "XLM"; @@ -160,6 +163,7 @@ export function GroupActions({ const unpausePool = useUnpausePool(poolAddress); const addPoolMember = useAddPoolMember(poolAddress); const removePoolMember = useRemovePoolMember(poolAddress); + const leavePool = useLeavePool(poolAddress); const { optimisticState, registerOptimistic, updateTxHash, markFailed } = useOptimisticTransactions(poolAddress); @@ -848,6 +852,23 @@ export function GroupActions({ )} + {!isAdmin && address && !isPending && ( +
+

+ Leave Pool +

+ +
+ )} +

Your Stellar address @@ -947,6 +968,64 @@ export function GroupActions({ +

{ + if (!open) setShowLeaveDialog(false); + }} + > + + + Leave this pool? + + {poolType === "rotational" && + "You will lose your position in the rotation. This action cannot be undone."} + {poolType === "target" && + "Your deposited balance will be refunded before the target is reached."} + {poolType === "flexible" && + "Your current balance will be refunded to your wallet."} + + + + + + + + + { diff --git a/frontend/hooks/useJointSaveContracts.ts b/frontend/hooks/useJointSaveContracts.ts index 96b5982..cb40304 100644 --- a/frontend/hooks/useJointSaveContracts.ts +++ b/frontend/hooks/useJointSaveContracts.ts @@ -1205,6 +1205,36 @@ export function useRemovePoolMember(contractId: string) { return { removeMember, isLoading } } +export function useLeavePool(contractId: string) { + const { kit, address } = useStellar() + const [isLoading, setIsLoading] = useState(false) + + const leavePool = async (): Promise => { + if (!kit || !address || !contractId) return + setIsLoading(true) + try { + const account = await getRpc().getAccount(address) + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation( + new Contract(normalizeId(contractId)).call( + "leave_pool", + addressVal(address) + ) + ) + .setTimeout(TX_TIMEOUT) + .build() + return await submitTx(kit, tx) + } finally { + setIsLoading(false) + } + } + + return { leavePool, isLoading } +} + export function usePausePool(contractId: string) { const { kit, address } = useStellar() const [isLoading, setIsLoading] = useState(false) diff --git a/smartcontract/Cargo.lock b/smartcontract/Cargo.lock index 3311eab..8d496a0 100644 --- a/smartcontract/Cargo.lock +++ b/smartcontract/Cargo.lock @@ -86,6 +86,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "block-buffer" version = "0.10.4" @@ -180,7 +201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -388,7 +409,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -413,7 +434,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -425,6 +446,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -437,13 +468,19 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -489,6 +526,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "gimli" version = "0.32.3" @@ -502,7 +562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -641,6 +701,7 @@ name = "jointsave-rotational" version = "0.1.0" dependencies = [ "jointsave-reputation", + "proptest", "soroban-sdk", ] @@ -701,6 +762,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -853,6 +920,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -862,6 +954,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -869,8 +973,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -880,7 +994,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -889,7 +1013,25 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", ] [[package]] @@ -912,6 +1054,12 @@ dependencies = [ "syn", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rfc6979" version = "0.4.0" @@ -937,12 +1085,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "schemars" version = "0.9.0" @@ -1094,7 +1267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1156,7 +1329,7 @@ dependencies = [ "ed25519-dalek", "elliptic-curve", "generic-array", - "getrandom", + "getrandom 0.2.17", "hex-literal", "hmac", "k256", @@ -1164,8 +1337,8 @@ dependencies = [ "num-integer", "num-traits", "p256", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "sec1", "sha2", "sha3", @@ -1217,7 +1390,7 @@ dependencies = [ "ctor", "derive_arbitrary", "ed25519-dalek", - "rand", + "rand 0.8.5", "rustc_version", "serde", "serde_json", @@ -1361,6 +1534,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1418,6 +1604,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1430,12 +1622,30 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.118" @@ -1577,6 +1787,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.8.48" diff --git a/smartcontract/contracts/flexible/src/lib.rs b/smartcontract/contracts/flexible/src/lib.rs index 595cd31..17d5e15 100644 --- a/smartcontract/contracts/flexible/src/lib.rs +++ b/smartcontract/contracts/flexible/src/lib.rs @@ -235,6 +235,44 @@ impl FlexiblePool { .publish((symbol_short!("rem_mem"), member), balance); } + pub fn leave_pool(env: Env, member: Address) { + member.require_auth(); + + let storage = env.storage().persistent(); + let paused: bool = storage.get(&DataKey::Paused).unwrap_or(false); + assert!(!paused, "pool paused"); + + let members: Vec
= storage.get(&DataKey::Members).unwrap(); + assert!(Self::is_member(&members, &member), "not a member"); + assert!(members.len() > 1, "need >=1 members"); + + let balance: i128 = storage.get(&DataKey::Balance(member.clone())).unwrap_or(0); + if balance > 0 { + let token_addr: Address = storage.get(&DataKey::Token).unwrap(); + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &member, + &balance, + ); + + let total: i128 = storage.get(&DataKey::TotalBalance).unwrap(); + storage.set(&DataKey::TotalBalance, &(total - balance)); + storage.set(&DataKey::Balance(member.clone()), &0i128); + } + + let mut updated_members: Vec
= Vec::new(&env); + for existing in members.iter() { + if existing != member { + updated_members.push_back(existing); + } + } + + storage.set(&DataKey::Members, &updated_members); + Self::bump_config_state_internal(&env); + env.events() + .publish((symbol_short!("rem_mem"), member), balance); + } + // ── Emergency controls ──────────────────────────────────────────────── pub fn pause(env: Env, admin: Address) { diff --git a/smartcontract/contracts/flexible/src/tests.rs b/smartcontract/contracts/flexible/src/tests.rs index 9cc0708..8314bda 100644 --- a/smartcontract/contracts/flexible/src/tests.rs +++ b/smartcontract/contracts/flexible/src/tests.rs @@ -415,6 +415,101 @@ fn test_remove_member_fails_when_paused() { client.remove_member(&admin, &member_a); } +#[test] +fn test_leave_pool_non_admin_member_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _token, admin, _treasury, member_a, member_b) = setup_pool(&env, false); + let member_c = Address::generate(&env); + + client.add_member(&admin, &member_c); + + // member_b leaves (not admin, pool has 3 members) + client.leave_pool(&member_b); + + assert_eq!(client.members().len(), 2); + let remaining = client.members(); + assert_eq!(remaining.get(0).unwrap(), member_a); + assert_eq!(remaining.get(1).unwrap(), member_c); +} + +#[test] +fn test_leave_pool_refunds_balance() { + let env = Env::default(); + env.mock_all_auths(); + let (client, token_address, admin, _treasury, _member_a, member_b) = setup_pool(&env, false); + let token_client = token::StellarAssetClient::new(&env, &token_address); + let token_iface = token::Client::new(&env, &token_address); + let member_c = Address::generate(&env); + + client.add_member(&admin, &member_c); + + token_client.mint(&member_b, &150i128); + client.deposit(&member_b, &150i128); + + client.leave_pool(&member_b); + + assert_eq!(token_iface.balance(&member_b), 150); + assert_eq!(client.balance_of(&member_b), 0); + assert_eq!(client.total_balance(), 0); + assert_eq!(client.members().len(), 2); +} + +#[test] +fn test_leave_pool_admin_can_leave_as_regular_member() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, FlexiblePool); + let client = FlexiblePoolClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let member_a = Address::generate(&env); + let member_b = Address::generate(&env); + + // admin is also a member + let mut members = Vec::new(&env); + members.push_back(admin.clone()); + members.push_back(member_a.clone()); + members.push_back(member_b.clone()); + + client.initialize(&token_address, &admin, &members, &10i128, &0u32, &false, &treasury, &0u32); + + // Admin leaves — leave_pool has no admin restriction + client.leave_pool(&admin); + + assert_eq!(client.members().len(), 2); +} + +#[test] +#[should_panic(expected = "pool paused")] +fn test_leave_pool_panics_when_paused() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _token, admin, _treasury, _member_a, member_b) = setup_pool(&env, false); + client.pause(&admin); + client.leave_pool(&member_b); +} + +#[test] +#[should_panic(expected = "need >=1 members")] +fn test_leave_pool_panics_when_only_one_member() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _token, admin, _treasury, member_a, member_b) = setup_pool(&env, false); + + // admin removes member_b, leaving only member_a + client.remove_member(&admin, &member_b); + + // member_a is the last member — leave would drop to 0 + client.leave_pool(&member_a); +} + #[test] fn test_bump_state() { let env = Env::default(); diff --git a/smartcontract/contracts/flexible/test_snapshots/tests/test_deploy_to_yield_tracks_amount.1.json b/smartcontract/contracts/flexible/test_snapshots/tests/test_deploy_to_yield_tracks_amount.1.json index 993b237..bb3d5fb 100644 --- a/smartcontract/contracts/flexible/test_snapshots/tests/test_deploy_to_yield_tracks_amount.1.json +++ b/smartcontract/contracts/flexible/test_snapshots/tests/test_deploy_to_yield_tracks_amount.1.json @@ -674,6 +674,45 @@ 2592000 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TokenDecimals" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TokenDecimals" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 7 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], [ { "contract_data": { @@ -1782,6 +1821,53 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4" + }, + { + "symbol": "decimals" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "decimals" + } + ], + "data": { + "u32": 7 + } + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", diff --git a/smartcontract/contracts/flexible/test_snapshots/tests/test_minimum_deposit_rejection.1.json b/smartcontract/contracts/flexible/test_snapshots/tests/test_minimum_deposit_rejection.1.json index a3c9e3b..3a60334 100644 --- a/smartcontract/contracts/flexible/test_snapshots/tests/test_minimum_deposit_rejection.1.json +++ b/smartcontract/contracts/flexible/test_snapshots/tests/test_minimum_deposit_rejection.1.json @@ -408,6 +408,45 @@ 2592000 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TokenDecimals" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TokenDecimals" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 7 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], [ { "contract_data": { @@ -1061,6 +1100,53 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4" + }, + { + "symbol": "decimals" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "decimals" + } + ], + "data": { + "u32": 7 + } + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", diff --git a/smartcontract/contracts/flexible/test_snapshots/tests/test_proportional_yield_distribution.1.json b/smartcontract/contracts/flexible/test_snapshots/tests/test_proportional_yield_distribution.1.json index 85f3155..a6565b7 100644 --- a/smartcontract/contracts/flexible/test_snapshots/tests/test_proportional_yield_distribution.1.json +++ b/smartcontract/contracts/flexible/test_snapshots/tests/test_proportional_yield_distribution.1.json @@ -659,6 +659,45 @@ 2592000 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TokenDecimals" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TokenDecimals" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 7 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], [ { "contract_data": { @@ -1593,6 +1632,53 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4" + }, + { + "symbol": "decimals" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "decimals" + } + ], + "data": { + "u32": 7 + } + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", diff --git a/smartcontract/contracts/flexible/test_snapshots/tests/test_set_yield_strategy.1.json b/smartcontract/contracts/flexible/test_snapshots/tests/test_set_yield_strategy.1.json index eee3d2e..5c931b5 100644 --- a/smartcontract/contracts/flexible/test_snapshots/tests/test_set_yield_strategy.1.json +++ b/smartcontract/contracts/flexible/test_snapshots/tests/test_set_yield_strategy.1.json @@ -405,6 +405,45 @@ 2592000 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TokenDecimals" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TokenDecimals" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 7 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], [ { "contract_data": { @@ -1024,6 +1063,53 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4" + }, + { + "symbol": "decimals" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "decimals" + } + ], + "data": { + "u32": 7 + } + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", diff --git a/smartcontract/contracts/flexible/test_snapshots/tests/test_set_yield_strategy_requires_yield_enabled.1.json b/smartcontract/contracts/flexible/test_snapshots/tests/test_set_yield_strategy_requires_yield_enabled.1.json index f4d7ffb..e15e5f5 100644 --- a/smartcontract/contracts/flexible/test_snapshots/tests/test_set_yield_strategy_requires_yield_enabled.1.json +++ b/smartcontract/contracts/flexible/test_snapshots/tests/test_set_yield_strategy_requires_yield_enabled.1.json @@ -383,6 +383,45 @@ 2592000 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TokenDecimals" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TokenDecimals" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 7 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], [ { "contract_data": { @@ -930,6 +969,53 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4" + }, + { + "symbol": "decimals" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "decimals" + } + ], + "data": { + "u32": 7 + } + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", @@ -999,7 +1085,7 @@ "data": { "vec": [ { - "string": "caught panic 'yield disabled' from contract function 'Symbol(obj#193)'" + "string": "caught panic 'yield disabled' from contract function 'Symbol(obj#201)'" }, { "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" diff --git a/smartcontract/contracts/flexible/test_snapshots/tests/test_withdrawal_fee_deduction.1.json b/smartcontract/contracts/flexible/test_snapshots/tests/test_withdrawal_fee_deduction.1.json index 2688374..b469ae9 100644 --- a/smartcontract/contracts/flexible/test_snapshots/tests/test_withdrawal_fee_deduction.1.json +++ b/smartcontract/contracts/flexible/test_snapshots/tests/test_withdrawal_fee_deduction.1.json @@ -534,6 +534,45 @@ 2592000 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TokenDecimals" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "TokenDecimals" + } + ] + }, + "durability": "persistent", + "val": { + "u32": 7 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], [ { "contract_data": { @@ -1399,6 +1438,53 @@ }, "failed_call": false }, + { + "event": { + "ext": "v0", + "contract_id": "0000000000000000000000000000000000000000000000000000000000000001", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_call" + }, + { + "bytes": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4" + }, + { + "symbol": "decimals" + } + ], + "data": "void" + } + } + }, + "failed_call": false + }, + { + "event": { + "ext": "v0", + "contract_id": "692c360a04a982db02db346a106cbf008ad9e058c384bdaaf77bc0c48799b3a4", + "type_": "diagnostic", + "body": { + "v0": { + "topics": [ + { + "symbol": "fn_return" + }, + { + "symbol": "decimals" + } + ], + "data": { + "u32": 7 + } + } + } + }, + "failed_call": false + }, { "event": { "ext": "v0", diff --git a/smartcontract/contracts/rotational/src/lib.rs b/smartcontract/contracts/rotational/src/lib.rs index 3c71175..16fa50d 100644 --- a/smartcontract/contracts/rotational/src/lib.rs +++ b/smartcontract/contracts/rotational/src/lib.rs @@ -248,12 +248,45 @@ impl RotationalPool { assert!(!has_deposited, "member deposited this round"); let members: Vec
= storage.get(&DataKey::Members).unwrap(); - let removed_index = Self::member_index(&members, &member).expect("not a member"); + Self::member_index(&members, &member).expect("not a member"); assert!(members.len() > 1, "need >=1 members"); - let mut updated_members: Vec
= Vec::new(&env); + Self::remove_member_internal(&env, &member); + } + + pub fn leave_pool(env: Env, member: Address) { + member.require_auth(); + + let storage = env.storage().persistent(); + let paused: bool = storage.get(&DataKey::Paused).unwrap_or(false); + assert!(!paused, "pool paused"); + + let members: Vec
= storage.get(&DataKey::Members).unwrap(); + assert!(Self::is_member(&members, &member), "not a member"); + + let has_deposited: bool = storage + .get(&DataKey::HasDeposited(member.clone())) + .unwrap_or(false); + assert!(!has_deposited, "member deposited this round"); + + assert!(members.len() > 1, "need >=1 members"); + + let current_round: u32 = storage.get(&DataKey::CurrentRound).unwrap_or(0); + let beneficiary = members.get(current_round).unwrap(); + assert!(member != beneficiary, "current beneficiary cannot leave mid-round"); + + Self::remove_member_internal(&env, &member); + } + + fn remove_member_internal(env: &Env, member: &Address) { + let storage = env.storage().persistent(); + + let members: Vec
= storage.get(&DataKey::Members).unwrap(); + let removed_index = Self::member_index(&members, member).expect("not a member"); + + let mut updated_members: Vec
= Vec::new(env); for existing in members.iter() { - if existing != member { + if existing != *member { updated_members.push_back(existing); } } @@ -274,11 +307,11 @@ impl RotationalPool { if pool_completed { storage.set(&DataKey::Active, &false); env.events() - .publish((symbol_short!("complete"),), Symbol::new(&env, "pool_done")); + .publish((symbol_short!("complete"),), Symbol::new(env, "pool_done")); } storage.remove(&DataKey::HasDeposited(member.clone())); - env.events().publish((symbol_short!("rem_mem"), member), ()); - Self::bump_config_state_internal(&env); + env.events().publish((symbol_short!("rem_mem"), member.clone()), ()); + Self::bump_config_state_internal(env); } // ── Emergency controls ───────────────────────────────────────────────── diff --git a/smartcontract/contracts/rotational/src/tests.rs b/smartcontract/contracts/rotational/src/tests.rs index c31c6d9..934f7a9 100644 --- a/smartcontract/contracts/rotational/src/tests.rs +++ b/smartcontract/contracts/rotational/src/tests.rs @@ -925,6 +925,162 @@ fn test_remove_member_fails_when_paused() { client.remove_member(&admin, &member_b); } +#[test] +fn test_leave_pool_non_admin_member_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, RotationalPool); + let client = RotationalPoolClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + + let treasury = Address::generate(&env); + let admin = Address::generate(&env); + let member_a = Address::generate(&env); + let member_b = Address::generate(&env); + let member_c = Address::generate(&env); + + let mut members = Vec::new(&env); + members.push_back(member_a.clone()); + members.push_back(member_b.clone()); + members.push_back(member_c.clone()); + + client.initialize(&token_address, &admin, &members, &100i128, &100u64, &0u32, &0u32, &treasury); + + // member_b is not admin and not the current beneficiary (index 0 = member_a) + client.leave_pool(&member_b); + + assert_eq!(client.members().len(), 2); + let remaining = client.members(); + assert_eq!(remaining.get(0).unwrap(), member_a); + assert_eq!(remaining.get(1).unwrap(), member_c); +} + +#[test] +#[should_panic(expected = "current beneficiary cannot leave mid-round")] +fn test_leave_pool_admin_panics_as_current_beneficiary() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, RotationalPool); + let client = RotationalPoolClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + + let treasury = Address::generate(&env); + let admin = Address::generate(&env); + let member_b = Address::generate(&env); + let member_c = Address::generate(&env); + + // admin is member[0] — the current beneficiary in round 0 + let mut members = Vec::new(&env); + members.push_back(admin.clone()); + members.push_back(member_b.clone()); + members.push_back(member_c.clone()); + + client.initialize(&token_address, &admin, &members, &100i128, &100u64, &0u32, &0u32, &treasury); + + // admin is index 0 = current beneficiary at round 0, so leave_pool must block them + client.leave_pool(&admin); +} + +#[test] +#[should_panic(expected = "pool paused")] +fn test_leave_pool_panics_when_paused() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, RotationalPool); + let client = RotationalPoolClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + + let treasury = Address::generate(&env); + let admin = Address::generate(&env); + let member_a = Address::generate(&env); + let member_b = Address::generate(&env); + + let mut members = Vec::new(&env); + members.push_back(member_a.clone()); + members.push_back(member_b.clone()); + + client.initialize(&token_address, &admin, &members, &100i128, &100u64, &0u32, &0u32, &treasury); + + client.pause(&admin); + client.leave_pool(&member_b); +} + +#[test] +#[should_panic(expected = "need >=1 members")] +fn test_leave_pool_panics_when_only_one_member() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, RotationalPool); + let client = RotationalPoolClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + + let treasury = Address::generate(&env); + let admin = Address::generate(&env); + let member_a = Address::generate(&env); + let member_b = Address::generate(&env); + let member_c = Address::generate(&env); + + let mut members = Vec::new(&env); + members.push_back(member_a.clone()); + members.push_back(member_b.clone()); + members.push_back(member_c.clone()); + + client.initialize(&token_address, &admin, &members, &100i128, &100u64, &0u32, &0u32, &treasury); + + // admin removes two members so only member_a remains + client.remove_member(&admin, &member_c); + client.remove_member(&admin, &member_b); + + // len guard (1 > 1 = false) fires before beneficiary guard + client.leave_pool(&member_a); +} + +#[test] +#[should_panic(expected = "current beneficiary cannot leave mid-round")] +fn test_leave_pool_panics_when_current_beneficiary_non_admin() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, RotationalPool); + let client = RotationalPoolClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + + let treasury = Address::generate(&env); + let admin = Address::generate(&env); + let member_a = Address::generate(&env); + let member_b = Address::generate(&env); + let member_c = Address::generate(&env); + + let mut members = Vec::new(&env); + members.push_back(member_a.clone()); + members.push_back(member_b.clone()); + members.push_back(member_c.clone()); + + client.initialize(&token_address, &admin, &members, &100i128, &100u64, &0u32, &0u32, &treasury); + + // current_round = 0, beneficiary is member_a (index 0) + client.leave_pool(&member_a); +} + #[test] fn test_bump_state() { use soroban_sdk::testutils::storage::Persistent; diff --git a/smartcontract/contracts/target/src/lib.rs b/smartcontract/contracts/target/src/lib.rs index 4931c97..6acf539 100644 --- a/smartcontract/contracts/target/src/lib.rs +++ b/smartcontract/contracts/target/src/lib.rs @@ -236,6 +236,47 @@ impl TargetPool { Self::bump_config_state_internal(&env); } + pub fn leave_pool(env: Env, member: Address) { + member.require_auth(); + + let storage = env.storage().persistent(); + let paused: bool = storage.get(&DataKey::Paused).unwrap_or(false); + assert!(!paused, "pool paused"); + + let unlocked: bool = storage.get(&DataKey::Unlocked).unwrap_or(false); + assert!(!unlocked, "pool already unlocked, use withdraw"); + + let members: Vec
= storage.get(&DataKey::Members).unwrap(); + assert!(Self::is_member(&members, &member), "not a member"); + assert!(members.len() > 1, "need >=1 members"); + + let balance: i128 = storage.get(&DataKey::Balance(member.clone())).unwrap_or(0); + if balance > 0 { + let token_addr: Address = storage.get(&DataKey::Token).unwrap(); + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &member, + &balance, + ); + + let total: i128 = storage.get(&DataKey::TotalDeposited).unwrap(); + storage.set(&DataKey::TotalDeposited, &(total - balance)); + storage.set(&DataKey::Balance(member.clone()), &0i128); + } + + let mut updated_members: Vec
= Vec::new(&env); + for existing in members.iter() { + if existing != member { + updated_members.push_back(existing); + } + } + + storage.set(&DataKey::Members, &updated_members); + env.events() + .publish((symbol_short!("rem_mem"), member), balance); + Self::bump_config_state_internal(&env); + } + // ── Emergency controls ───────────────────────────────────────────────── pub fn pause(env: Env, admin: Address) { diff --git a/smartcontract/contracts/target/src/tests.rs b/smartcontract/contracts/target/src/tests.rs index 5ff94b7..8fc0797 100644 --- a/smartcontract/contracts/target/src/tests.rs +++ b/smartcontract/contracts/target/src/tests.rs @@ -508,6 +508,196 @@ fn test_remove_member_fails_when_paused() { client.remove_member(&admin, &member_b); } +#[test] +fn test_leave_pool_non_admin_member_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, TargetPool); + let client = TargetPoolClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + + let admin = Address::generate(&env); + let member_a = Address::generate(&env); + let member_b = Address::generate(&env); + let member_c = Address::generate(&env); + + let mut members = Vec::new(&env); + members.push_back(member_a.clone()); + members.push_back(member_b.clone()); + members.push_back(member_c.clone()); + + client.initialize(&token_address, &admin, &members, &500i128, &1000u32); + + // member_b leaves (not admin) + client.leave_pool(&member_b); + + assert_eq!(client.members().len(), 2); + let remaining = client.members(); + assert_eq!(remaining.get(0).unwrap(), member_a); + assert_eq!(remaining.get(1).unwrap(), member_c); +} + +#[test] +fn test_leave_pool_refunds_balance() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, TargetPool); + let client = TargetPoolClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + let token_client = token::StellarAssetClient::new(&env, &token_address); + let token_iface = token::Client::new(&env, &token_address); + + let admin = Address::generate(&env); + let member_a = Address::generate(&env); + let member_b = Address::generate(&env); + let member_c = Address::generate(&env); + + let mut members = Vec::new(&env); + members.push_back(member_a.clone()); + members.push_back(member_b.clone()); + members.push_back(member_c.clone()); + + client.initialize(&token_address, &admin, &members, &500i128, &1000u32); + + token_client.mint(&member_b, &80i128); + client.deposit(&member_b, &80i128); + + client.leave_pool(&member_b); + + assert_eq!(token_iface.balance(&member_b), 80); + assert_eq!(client.balance_of(&member_b), 0); + assert_eq!(client.total_deposited(), 0); + assert_eq!(client.members().len(), 2); +} + +#[test] +fn test_leave_pool_admin_can_leave_as_regular_member() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, TargetPool); + let client = TargetPoolClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + + let admin = Address::generate(&env); + let member_a = Address::generate(&env); + let member_b = Address::generate(&env); + + // admin is also a member + let mut members = Vec::new(&env); + members.push_back(admin.clone()); + members.push_back(member_a.clone()); + members.push_back(member_b.clone()); + + client.initialize(&token_address, &admin, &members, &500i128, &1000u32); + + // Admin leaves — leave_pool has no admin restriction + client.leave_pool(&admin); + + assert_eq!(client.members().len(), 2); +} + +#[test] +#[should_panic(expected = "pool paused")] +fn test_leave_pool_panics_when_paused() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, TargetPool); + let client = TargetPoolClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + + let admin = Address::generate(&env); + let member_a = Address::generate(&env); + let member_b = Address::generate(&env); + + let mut members = Vec::new(&env); + members.push_back(member_a.clone()); + members.push_back(member_b.clone()); + + client.initialize(&token_address, &admin, &members, &500i128, &1000u32); + + client.pause(&admin); + client.leave_pool(&member_b); +} + +#[test] +#[should_panic(expected = "need >=1 members")] +fn test_leave_pool_panics_when_only_one_member() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, TargetPool); + let client = TargetPoolClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + + let admin = Address::generate(&env); + let member_a = Address::generate(&env); + let member_b = Address::generate(&env); + + let mut members = Vec::new(&env); + members.push_back(member_a.clone()); + members.push_back(member_b.clone()); + + client.initialize(&token_address, &admin, &members, &500i128, &1000u32); + + // admin removes member_b, leaving only member_a + client.remove_member(&admin, &member_b); + + // member_a is the last member — leave would drop to 0 + client.leave_pool(&member_a); +} + +#[test] +#[should_panic(expected = "pool already unlocked, use withdraw")] +fn test_leave_pool_panics_when_pool_unlocked() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, TargetPool); + let client = TargetPoolClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + let token_client = token::StellarAssetClient::new(&env, &token_address); + + let admin = Address::generate(&env); + let member_a = Address::generate(&env); + let member_b = Address::generate(&env); + + let mut members = Vec::new(&env); + members.push_back(member_a.clone()); + members.push_back(member_b.clone()); + + client.initialize(&token_address, &admin, &members, &100i128, &1000u32); + + // Deposit enough to unlock the pool + token_client.mint(&member_a, &100i128); + client.deposit(&member_a, &100i128); + assert!(client.is_unlocked()); + + // leave_pool should be blocked after unlock + client.leave_pool(&member_b); +} + #[test] fn test_bump_state() { use soroban_sdk::testutils::storage::Persistent;