diff --git a/frontend/app/api/pools/route.ts b/frontend/app/api/pools/route.ts index e5d567d..bb7af6d 100644 --- a/frontend/app/api/pools/route.ts +++ b/frontend/app/api/pools/route.ts @@ -215,6 +215,27 @@ export async function PATCH(req: NextRequest) { description: `${activity_type} transaction`, }]) if (actErr) console.error('Activity log error:', actErr) + + // Synchronize database pool_members table with on-chain membership changes + if (activity_type === 'member_added' && body.memberAddress) { + const { error: addErr } = await supabase + .from('pool_members') + .insert([{ + pool_id: poolId, + member_address: body.memberAddress.toLowerCase(), + contribution_amount: 0, + status: 'pending', + }]) + if (addErr) console.error('Failed to add member in DB:', addErr) + } else if (activity_type === 'member_removed' && body.memberAddress) { + const { error: remErr } = await supabase + .from('pool_members') + .delete() + .eq('pool_id', poolId) + .eq('member_address', body.memberAddress.toLowerCase()) + if (remErr) console.error('Failed to remove member in DB:', remErr) + } + return NextResponse.json({ success: true }) } diff --git a/frontend/components/create-group/flexible-form.tsx b/frontend/components/create-group/flexible-form.tsx index d66f400..879450f 100644 --- a/frontend/components/create-group/flexible-form.tsx +++ b/frontend/components/create-group/flexible-form.tsx @@ -108,6 +108,7 @@ export function FlexibleForm() { const withdrawalFeeBps = Math.round(parseFloat(formData.withdrawalFee) * 100) await initFlexible(contractId, { token: TOKEN === "native" ? "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" : TOKEN, + admin: address, members: validMembers, minimumDeposit: formData.minimumDeposit, withdrawalFeeBps, diff --git a/frontend/components/create-group/rotational-form.tsx b/frontend/components/create-group/rotational-form.tsx index 7922b6f..1410652 100644 --- a/frontend/components/create-group/rotational-form.tsx +++ b/frontend/components/create-group/rotational-form.tsx @@ -130,6 +130,7 @@ export function RotationalForm() { setStep("initializing") await initRotational(contractId, { token: TOKEN === "native" ? "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" : TOKEN, + admin: address, members: validMembers, depositAmount: formData.contributionAmount, roundDuration: FREQUENCY_SECONDS[formData.frequency], diff --git a/frontend/components/group/group-actions.tsx b/frontend/components/group/group-actions.tsx index 85e0a99..df68911 100644 --- a/frontend/components/group/group-actions.tsx +++ b/frontend/components/group/group-actions.tsx @@ -15,6 +15,8 @@ import { ShieldOff, ShieldCheck, Clock, + UserPlus, + Trash2, } from "lucide-react"; import { useStellar } from "@/components/web3-provider"; import { @@ -27,9 +29,13 @@ import { useFlexibleWithdraw, usePausePool, useUnpausePool, + useAddPoolMember, + useRemovePoolMember, stroopsToXlm, fetchRotationalState, + fetchPoolMembers, } from "@/hooks/useJointSaveContracts"; +import { validateStellarAddress } from "@/lib/form-validation"; import { Tooltip, TooltipContent, @@ -62,6 +68,7 @@ async function logActivity( userAddress: string, amount: string | null, txHash: string, + memberAddress?: string, ) { try { await fetch("/api/pools", { @@ -75,6 +82,7 @@ async function logActivity( amount: amount ? parseFloat(amount) : null, tx_hash: txHash, }, + memberAddress: memberAddress || null, }), }); } catch {} @@ -100,6 +108,10 @@ export function GroupActions({ // Pool metadata from Supabase const [poolData, setPoolData] = useState(null); + const [members, setMembers] = useState([]); + const [newMember, setNewMember] = useState(""); + const [memberToRemove, setMemberToRemove] = useState(null); + const isPending = !poolAddress || poolAddress === "pending_deployment"; // Modal Preview states const [isPreviewOpen, setIsPreviewOpen] = useState(false); @@ -120,6 +132,16 @@ export function GroupActions({ .catch((err) => console.error("Failed to load pool details:", err)); }, [groupId]); + const refreshMembers = async () => { + if (isPending || !isAdmin || !poolAddress) return; + const onchainMembers = await fetchPoolMembers(poolAddress); + setMembers(onchainMembers); + }; + + useEffect(() => { + refreshMembers(); + }, [isAdmin, isPending, poolAddress]); + const rotationalDeposit = useRotationalDeposit(poolAddress); const triggerPayout = useTriggerPayout(poolAddress); const targetContribute = useTargetContribute(poolAddress, depositAmount); @@ -129,12 +151,12 @@ export function GroupActions({ const flexibleWithdraw = useFlexibleWithdraw(poolAddress, withdrawAmount); const pausePool = usePausePool(poolAddress); const unpausePool = useUnpausePool(poolAddress); + const addPoolMember = useAddPoolMember(poolAddress); + const removePoolMember = useRemovePoolMember(poolAddress); const { optimisticState, registerOptimistic, updateTxHash, markFailed } = useOptimisticTransactions(poolAddress); - const isPending = !poolAddress || poolAddress === "pending_deployment"; - // Watch for confirmation/failure from optimistic state useEffect(() => { const { pendingTx } = optimisticState; @@ -380,6 +402,50 @@ export function GroupActions({ } }; + const handleAddMember = async () => { + setError(""); + setSuccessMsg(""); + if (!address) return setError("Please connect your wallet first"); + if (!isAdmin) return setError("Only the pool admin can manage members."); + if (isPending) return setError("Contract not yet deployed."); + + const validation = validateStellarAddress(newMember.trim().toUpperCase()); + if (!validation.valid) return setError(validation.message); + + try { + const txHash = await addPoolMember.addMember(newMember.trim().toUpperCase()); + if (txHash) { + await logActivity(groupId, "member_added", address, null, txHash, newMember.trim().toUpperCase()); + setSuccessMsg("Member added successfully."); + setNewMember(""); + await refreshMembers(); + } + } catch (e: any) { + setError(e.message || "Transaction failed"); + } + }; + + const handleRemoveMember = async () => { + setError(""); + setSuccessMsg(""); + if (!address) return setError("Please connect your wallet first"); + if (!isAdmin) return setError("Only the pool admin can manage members."); + if (isPending) return setError("Contract not yet deployed."); + if (!memberToRemove) return; + + try { + const txHash = await removePoolMember.removeMember(memberToRemove); + if (txHash) { + await logActivity(groupId, "member_removed", address, null, txHash, memberToRemove); + setSuccessMsg("Member removed successfully."); + setMemberToRemove(null); + await refreshMembers(); + } + } catch (e: any) { + setError(e.message || "Transaction failed"); + } + }; + const isDepositLoading = optimisticState.pendingTx?.type === "deposit" && optimisticState.pendingTx.status === "pending" @@ -402,6 +468,8 @@ export function GroupActions({ const isTarget = poolType === "target"; const isFlexible = poolType === "flexible"; const actionsDisabled = isPaused || isPending || !address; + const formatAddress = (addr: string) => + addr.length > 18 ? `${addr.slice(0, 8)}...${addr.slice(-8)}` : addr; // Helper to render pending badge const renderPendingBadge = () => { @@ -712,6 +780,67 @@ export function GroupActions({ )} + {isAdmin && !isPending && ( +
+

+ Manage Members +

+
+ +
+ setNewMember(event.target.value)} + placeholder="G..." + disabled={addPoolMember.isLoading || removePoolMember.isLoading} + /> + +
+
+ +
+ {members.map((member) => ( +
+ + {formatAddress(member)} + + +
+ ))} +
+
+ )} +

Your Stellar address @@ -810,6 +939,45 @@ export function GroupActions({ + +

{ + if (!open) setMemberToRemove(null); + }} + > + + + Remove Member + + This will remove {memberToRemove || "this address"} from the pool. Their balance will be refunded. + + + + + + + + ); } diff --git a/frontend/components/group/yield-dashboard.tsx b/frontend/components/group/yield-dashboard.tsx index 4a4165f..71fb0f9 100644 --- a/frontend/components/group/yield-dashboard.tsx +++ b/frontend/components/group/yield-dashboard.tsx @@ -7,14 +7,12 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Badge } from "@/components/ui/badge" import { Loader2, TrendingUp, Zap, AlertTriangle, RefreshCw } from "lucide-react" -import { useStellar } from "@/components/web3-provider" +import { useStellar, STELLAR_RPC_URL, STELLAR_NETWORK_PASSPHRASE } from "@/components/web3-provider" import { Contract, TransactionBuilder, BASE_FEE, nativeToScVal, xdr, - Address, - rpc, + Address, rpc, } from "@stellar/stellar-sdk" -import { STELLAR_RPC_URL } from "@/components/web3-provider" -import { STELLAR_NETWORK_PASSPHRASE } from "@/components/web3-provider" +import { getRpc } from "@/hooks/useJointSaveContracts" const TX_TIMEOUT = 300 @@ -89,7 +87,7 @@ export function YieldDashboard({ poolAddress }: YieldDashboardProps) { // scvOption wrapping an address const inner = stratVal.switch().name === "scvAddress" ? stratVal - : stratVal.value() as xdr.ScVal + : stratVal.value() as unknown as xdr.ScVal strategyAddress = Address.fromScVal(inner).toString() } } catch {} diff --git a/frontend/hooks/useJointSaveContracts.ts b/frontend/hooks/useJointSaveContracts.ts index 690da0a..b40c5a3 100644 --- a/frontend/hooks/useJointSaveContracts.ts +++ b/frontend/hooks/useJointSaveContracts.ts @@ -199,6 +199,7 @@ export function useInitializePool() { contractId: string, params: { token: string + admin: string members: string[] depositAmount: string roundDuration: number @@ -219,6 +220,7 @@ export function useInitializePool() { new Contract(normalizeId(contractId)).call( "initialize", addressVal(params.token), + addressVal(params.admin), vecVal(params.members), i128Val(toStroops(params.depositAmount)), u64Val(BigInt(params.roundDuration)), @@ -275,6 +277,7 @@ export function useInitializePool() { contractId: string, params: { token: string + admin: string members: string[] minimumDeposit: string withdrawalFeeBps: number @@ -295,6 +298,7 @@ export function useInitializePool() { new Contract(normalizeId(contractId)).call( "initialize", addressVal(params.token), + addressVal(params.admin), vecVal(params.members), i128Val(toStroops(params.minimumDeposit)), u32Val(params.withdrawalFeeBps), @@ -665,12 +669,11 @@ async function fetchContractStorage(contractId: string, keySymbol: string): Prom if (entry && typeof entry === "object") { if ("xdr" in entry) { - rawXdr = entry.xdr + rawXdr = entry.xdr as string } else if (entry.val && typeof (entry.val as any).toXDR === "function") { rawXdr = (entry.val as any).toXDR("base64") } } - if (!rawXdr) return null const ledgerData = xdr.LedgerEntryData.fromXDR(rawXdr, "base64") return ledgerData.contractData().val() @@ -928,6 +931,15 @@ export async function fetchFlexibleState( } } +export async function fetchPoolMembers(contractId: string): Promise { + try { + const val = await viewCall(contractId, "members") + return val.vec()?.map(scValToString) ?? [] + } catch { + return [] + } +} + export async function fetchIsPaused(contractId: string): Promise { try { const val = await viewCall(contractId, "is_paused") @@ -946,7 +958,69 @@ export async function fetchPoolAdmin(contractId: string): Promise } } -// ── Pause / Unpause hooks ───────────────────────────────────────────────────── +// ── Admin hooks ─────────────────────────────────────────────────────────────── + +export function useAddPoolMember(contractId: string) { + const { kit, address } = useStellar() + const [isLoading, setIsLoading] = useState(false) + + const addMember = async (newMember: string): Promise => { + if (!kit || !address || !contractId || !newMember) 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( + "add_member", + addressVal(address), + addressVal(newMember) + ) + ) + .setTimeout(TX_TIMEOUT) + .build() + return await submitTx(kit, tx) + } finally { + setIsLoading(false) + } + } + + return { addMember, isLoading } +} + +export function useRemovePoolMember(contractId: string) { + const { kit, address } = useStellar() + const [isLoading, setIsLoading] = useState(false) + + const removeMember = async (member: string): Promise => { + if (!kit || !address || !contractId || !member) 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( + "remove_member", + addressVal(address), + addressVal(member) + ) + ) + .setTimeout(TX_TIMEOUT) + .build() + return await submitTx(kit, tx) + } finally { + setIsLoading(false) + } + } + + return { removeMember, isLoading } +} export function usePausePool(contractId: string) { const { kit, address } = useStellar() diff --git a/smartcontract/contracts/flexible/src/lib.rs b/smartcontract/contracts/flexible/src/lib.rs index 78c5996..f675d62 100644 --- a/smartcontract/contracts/flexible/src/lib.rs +++ b/smartcontract/contracts/flexible/src/lib.rs @@ -1,8 +1,6 @@ #![no_std] -use soroban_sdk::{ - contract, contractimpl, contracttype, token, Address, Env, Vec, symbol_short, -}; +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Env, Vec}; #[contracttype] pub enum DataKey { @@ -82,7 +80,8 @@ impl FlexiblePool { let total: i128 = storage.get(&DataKey::TotalBalance).unwrap(); storage.set(&DataKey::TotalBalance, &(total + amount)); - env.events().publish((symbol_short!("deposit"), member), amount); + env.events() + .publish((symbol_short!("deposit"), member), amount); } pub fn withdraw(env: Env, member: Address, amount: i128) { @@ -114,7 +113,8 @@ impl FlexiblePool { } token_client.transfer(&env.current_contract_address(), &member, &net); - env.events().publish((symbol_short!("withdraw"), member), net); + env.events() + .publish((symbol_short!("withdraw"), member), net); } /// Distribute yield proportionally to all members with a balance. @@ -142,7 +142,67 @@ impl FlexiblePool { } storage.set(&DataKey::TotalBalance, &(total + yield_amount)); - env.events().publish((symbol_short!("yield"),), yield_amount); + env.events() + .publish((symbol_short!("yield"),), yield_amount); + } + + pub fn add_member(env: Env, admin: Address, new_member: Address) { + admin.require_auth(); + + let storage = env.storage().persistent(); + let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); + assert!(admin == stored_admin, "not admin"); + + let paused: bool = storage.get(&DataKey::Paused).unwrap_or(false); + assert!(!paused, "pool paused"); + + let mut members: Vec
= storage.get(&DataKey::Members).unwrap(); + assert!(!Self::is_member(&members, &new_member), "already a member"); + + members.push_back(new_member.clone()); + storage.set(&DataKey::Members, &members); + env.events() + .publish((symbol_short!("add_mem"), new_member), ()); + } + + pub fn remove_member(env: Env, admin: Address, member: Address) { + admin.require_auth(); + + let storage = env.storage().persistent(); + let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); + assert!(admin == stored_admin, "not admin"); + + 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); + env.events() + .publish((symbol_short!("rem_mem"), member), balance); } // ── Emergency controls ──────────────────────────────────────────────── @@ -179,11 +239,16 @@ impl FlexiblePool { let contract_balance = token_client.balance(&env.current_contract_address()); if contract_balance > 0 { - token_client.transfer(&env.current_contract_address(), &recipient, &contract_balance); + token_client.transfer( + &env.current_contract_address(), + &recipient, + &contract_balance, + ); } storage.set(&DataKey::TotalBalance, &0i128); - env.events().publish((symbol_short!("emrg_wd"),), contract_balance); + env.events() + .publish((symbol_short!("emrg_wd"),), contract_balance); } // ── Yield strategy ──────────────────────────────────────────────────── @@ -197,7 +262,8 @@ impl FlexiblePool { let yield_enabled: bool = storage.get(&DataKey::YieldEnabled).unwrap_or(false); assert!(yield_enabled, "yield disabled"); storage.set(&DataKey::YieldStrategy, &strategy); - env.events().publish((symbol_short!("set_strat"),), strategy); + env.events() + .publish((symbol_short!("set_strat"),), strategy); } /// Deploy `amount` of pool funds to the yield strategy contract. @@ -209,7 +275,9 @@ impl FlexiblePool { let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); - let strategy: Address = storage.get(&DataKey::YieldStrategy).expect("no strategy set"); + let strategy: Address = storage + .get(&DataKey::YieldStrategy) + .expect("no strategy set"); let total: i128 = storage.get(&DataKey::TotalBalance).unwrap(); assert!(total >= amount, "insufficient pool balance"); @@ -241,7 +309,9 @@ impl FlexiblePool { let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); - let strategy: Address = storage.get(&DataKey::YieldStrategy).expect("no strategy set"); + let strategy: Address = storage + .get(&DataKey::YieldStrategy) + .expect("no strategy set"); let yield_amount: i128 = env.invoke_contract( &strategy, @@ -260,30 +330,46 @@ impl FlexiblePool { } } storage.set(&DataKey::TotalBalance, &(total + yield_amount)); - env.events().publish((symbol_short!("harvested"),), yield_amount); + env.events() + .publish((symbol_short!("harvested"),), yield_amount); } } // ── Views ───────────────────────────────────────────────────────────── pub fn balance_of(env: Env, member: Address) -> i128 { - env.storage().persistent().get(&DataKey::Balance(member)).unwrap_or(0) + env.storage() + .persistent() + .get(&DataKey::Balance(member)) + .unwrap_or(0) } pub fn total_balance(env: Env) -> i128 { - env.storage().persistent().get(&DataKey::TotalBalance).unwrap_or(0) + env.storage() + .persistent() + .get(&DataKey::TotalBalance) + .unwrap_or(0) } pub fn members(env: Env) -> Vec
{ - env.storage().persistent().get(&DataKey::Members).unwrap_or(Vec::new(&env)) + env.storage() + .persistent() + .get(&DataKey::Members) + .unwrap_or(Vec::new(&env)) } pub fn is_active(env: Env) -> bool { - env.storage().persistent().get(&DataKey::Active).unwrap_or(false) + env.storage() + .persistent() + .get(&DataKey::Active) + .unwrap_or(false) } pub fn is_paused(env: Env) -> bool { - env.storage().persistent().get(&DataKey::Paused).unwrap_or(false) + env.storage() + .persistent() + .get(&DataKey::Paused) + .unwrap_or(false) } pub fn admin(env: Env) -> Address { @@ -295,14 +381,19 @@ impl FlexiblePool { } pub fn deployed_to_yield(env: Env) -> i128 { - env.storage().persistent().get(&DataKey::DeployedToYield).unwrap_or(0) + env.storage() + .persistent() + .get(&DataKey::DeployedToYield) + .unwrap_or(0) } // ── Helpers ─────────────────────────────────────────────────────────── fn is_member(members: &Vec
, who: &Address) -> bool { for m in members.iter() { - if m == *who { return true; } + if m == *who { + return true; + } } false } diff --git a/smartcontract/contracts/flexible/src/tests.rs b/smartcontract/contracts/flexible/src/tests.rs index d5aa2d9..dfd80d5 100644 --- a/smartcontract/contracts/flexible/src/tests.rs +++ b/smartcontract/contracts/flexible/src/tests.rs @@ -6,7 +6,14 @@ use soroban_sdk::{testutils::Address as _, token, Address, Env, Vec}; fn setup_pool( env: &Env, yield_enabled: bool, -) -> (FlexiblePoolClient<'static>, Address, Address, Address, Address, Address) { +) -> ( + FlexiblePoolClient<'static>, + Address, + Address, + Address, + Address, + Address, +) { let contract_id = env.register_contract(None, FlexiblePool); let client = FlexiblePoolClient::new(env, &contract_id); @@ -23,7 +30,16 @@ fn setup_pool( members.push_back(member_a.clone()); members.push_back(member_b.clone()); - client.initialize(&token_address, &admin, &members, &10i128, &0u32, &yield_enabled, &treasury, &0u32); + client.initialize( + &token_address, + &admin, + &members, + &10i128, + &0u32, + &yield_enabled, + &treasury, + &0u32, + ); (client, token_address, admin, treasury, member_a, member_b) } @@ -64,7 +80,16 @@ fn test_withdrawal_fee_deduction() { members.push_back(member_a.clone()); members.push_back(member_b.clone()); - client.initialize(&token_address, &admin, &members, &10i128, &200u32, &false, &treasury, &0u32); + client.initialize( + &token_address, + &admin, + &members, + &10i128, + &200u32, + &false, + &treasury, + &0u32, + ); token_client.mint(&member_a, &1000i128); client.deposit(&member_a, &1000i128); @@ -102,7 +127,16 @@ fn test_proportional_yield_distribution() { members.push_back(member_b.clone()); members.push_back(member_c.clone()); - client.initialize(&token_address, &admin, &members, &10i128, &0u32, &true, &treasury, &0u32); + client.initialize( + &token_address, + &admin, + &members, + &10i128, + &0u32, + &true, + &treasury, + &0u32, + ); token_client.mint(&member_a, &100i128); token_client.mint(&member_b, &200i128); @@ -119,6 +153,64 @@ fn test_proportional_yield_distribution() { assert_eq!(client.total_balance(), 360); } +#[test] +fn test_add_member_can_deposit() { + 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 member_c = Address::generate(&env); + + client.add_member(&admin, &member_c); + token_client.mint(&member_c, &100i128); + client.deposit(&member_c, &100i128); + + assert_eq!(client.members().len(), 3); + assert_eq!(client.balance_of(&member_c), 100); +} + +#[test] +fn test_remove_member_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, &100i128); + client.deposit(&member_b, &100i128); + + client.remove_member(&admin, &member_b); + + assert_eq!(token_iface.balance(&member_b), 100); + assert_eq!(client.balance_of(&member_b), 0); + assert_eq!(client.total_balance(), 0); + assert_eq!(client.members().len(), 2); + + token_client.mint(&member_a, &100i128); + client.deposit(&member_a, &100i128); +} + +#[test] +#[should_panic(expected = "not a member")] +fn test_removed_member_cannot_deposit() { + 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 member_c = Address::generate(&env); + + client.add_member(&admin, &member_c); + client.remove_member(&admin, &member_b); + token_client.mint(&member_b, &100i128); + client.deposit(&member_b, &100i128); +} + // ── Upstream pause/emergency tests ──────────────────────────────────────────── #[test] @@ -256,6 +348,27 @@ fn test_deploy_to_yield_tracks_amount() { assert_eq!(client.deployed_to_yield(), 200); } +#[test] +#[should_panic(expected = "pool paused")] +fn test_add_member_fails_when_paused() { + 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.pause(&admin); + client.add_member(&admin, &member_c); +} + +#[test] +#[should_panic(expected = "pool paused")] +fn test_remove_member_fails_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.remove_member(&admin, &member_a); +} + // ── Mock strategy ───────────────────────────────────────────────────────────── mod mock_strategy { @@ -267,7 +380,9 @@ mod mock_strategy { #[contractimpl] impl MockStrategy { pub fn deploy(_env: Env, _amount: i128) {} - pub fn harvest(_env: Env) -> i128 { 50 } + pub fn harvest(_env: Env) -> i128 { + 50 + } } } diff --git a/smartcontract/contracts/rotational/src/lib.rs b/smartcontract/contracts/rotational/src/lib.rs index f642b18..f0d7db9 100644 --- a/smartcontract/contracts/rotational/src/lib.rs +++ b/smartcontract/contracts/rotational/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, token, Address, Env, IntoVal, Symbol, Vec, symbol_short, + contract, contractimpl, contracttype, symbol_short, token, Address, Env, IntoVal, Symbol, Vec, }; // ── Storage keys ────────────────────────────────────────────────────────────── @@ -90,10 +90,8 @@ impl RotationalPool { token_client.transfer(&member, &env.current_contract_address(), &deposit_amount); storage.set(&DataKey::HasDeposited(member.clone()), &true); - env.events().publish( - (symbol_short!("deposit"), member.clone()), - deposit_amount, - ); + env.events() + .publish((symbol_short!("deposit"), member.clone()), deposit_amount); Self::report_deposit(&env, &member, deposit_amount); } @@ -110,10 +108,7 @@ impl RotationalPool { assert!(!paused, "pool paused"); let next_payout_time: u64 = storage.get(&DataKey::NextPayoutTime).unwrap(); - assert!( - env.ledger().timestamp() >= next_payout_time, - "too early" - ); + assert!(env.ledger().timestamp() >= next_payout_time, "too early"); let members: Vec
= storage.get(&DataKey::Members).unwrap(); let deposit_amount: i128 = storage.get(&DataKey::DepositAmount).unwrap(); @@ -154,7 +149,11 @@ impl RotationalPool { if relayer_cut > 0 { token_client.transfer(&env.current_contract_address(), &relayer, &relayer_cut); } - token_client.transfer(&env.current_contract_address(), &beneficiary, &payout_amount); + token_client.transfer( + &env.current_contract_address(), + &beneficiary, + &payout_amount, + ); env.events().publish( (symbol_short!("payout"), beneficiary.clone()), @@ -185,6 +184,83 @@ impl RotationalPool { } } + pub fn add_member(env: Env, admin: Address, new_member: Address) { + admin.require_auth(); + + let storage = env.storage().persistent(); + let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); + assert!(admin == stored_admin, "not admin"); + + let paused: bool = storage.get(&DataKey::Paused).unwrap_or(false); + assert!(!paused, "pool paused"); + + let current_round: u32 = storage.get(&DataKey::CurrentRound).unwrap_or(0); + assert!(current_round == 0, "round already started"); + + let mut members: Vec
= storage.get(&DataKey::Members).unwrap(); + assert!(!Self::is_member(&members, &new_member), "already a member"); + + for member in members.iter() { + let has_deposited: bool = storage + .get(&DataKey::HasDeposited(member.clone())) + .unwrap_or(false); + assert!(!has_deposited, "round already started"); + } + + members.push_back(new_member.clone()); + storage.set(&DataKey::Members, &members); + env.events() + .publish((symbol_short!("add_mem"), new_member), ()); + } + + pub fn remove_member(env: Env, admin: Address, member: Address) { + admin.require_auth(); + + let storage = env.storage().persistent(); + let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); + assert!(admin == stored_admin, "not admin"); + + let paused: bool = storage.get(&DataKey::Paused).unwrap_or(false); + assert!(!paused, "pool paused"); + + let has_deposited: bool = storage + .get(&DataKey::HasDeposited(member.clone())) + .unwrap_or(false); + 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"); + assert!(members.len() > 1, "need >=1 members"); + + let mut updated_members: Vec
= Vec::new(&env); + for existing in members.iter() { + if existing != member { + updated_members.push_back(existing); + } + } + + let current_round: u32 = storage.get(&DataKey::CurrentRound).unwrap_or(0); + let mut pool_completed = false; + let updated_round = if removed_index < current_round { + current_round - 1 + } else if removed_index == current_round && current_round >= updated_members.len() { + pool_completed = true; + 0 + } else { + current_round + }; + + storage.set(&DataKey::Members, &updated_members); + storage.set(&DataKey::CurrentRound, &updated_round); + if pool_completed { + storage.set(&DataKey::Active, &false); + env.events() + .publish((symbol_short!("complete"),), Symbol::new(&env, "pool_done")); + } + storage.remove(&DataKey::HasDeposited(member.clone())); + env.events().publish((symbol_short!("rem_mem"), member), ()); + } + // ── Emergency controls ───────────────────────────────────────────────── pub fn pause(env: Env, admin: Address) { @@ -219,7 +295,11 @@ impl RotationalPool { let contract_balance = token_client.balance(&env.current_contract_address()); if contract_balance > 0 { - token_client.transfer(&env.current_contract_address(), &recipient, &contract_balance); + token_client.transfer( + &env.current_contract_address(), + &recipient, + &contract_balance, + ); } env.events() @@ -300,6 +380,17 @@ impl RotationalPool { false } + fn member_index(members: &Vec
, who: &Address) -> Option { + let mut index = 0u32; + for m in members.iter() { + if m == *who { + return Some(index); + } + index += 1; + } + None + } + /// Best-effort report to the configured ReputationTracker. Reputation /// tracking is supplementary, so a missing/misconfigured tracker must /// never block the pool's core deposit/payout flow. diff --git a/smartcontract/contracts/rotational/src/tests.rs b/smartcontract/contracts/rotational/src/tests.rs index e76bee5..44b3d1c 100644 --- a/smartcontract/contracts/rotational/src/tests.rs +++ b/smartcontract/contracts/rotational/src/tests.rs @@ -38,7 +38,7 @@ fn test_happy_path() { let deposit_amount = 100i128; let round_duration = 100u64; let treasury_fee_bps = 500u32; // 5% - let relayer_fee_bps = 200u32; // 2% + let relayer_fee_bps = 200u32; // 2% // Initialize pool client.initialize( @@ -57,7 +57,10 @@ fn test_happy_path() { assert!(!client.is_paused()); assert_eq!(client.current_round(), 0); assert_eq!(client.members().len(), 3); - assert_eq!(client.next_payout_time(), env.ledger().timestamp() + round_duration); + assert_eq!( + client.next_payout_time(), + env.ledger().timestamp() + round_duration + ); // Mint tokens to members token_client.mint(&member_a, &deposit_amount); @@ -182,6 +185,89 @@ fn test_duplicate_deposit_rejection() { client.deposit(&member_a); } +#[test] +fn test_add_member_can_deposit() { + 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 token_client = token::StellarAssetClient::new(&env, &token_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()); + + client.initialize( + &token_address, + &admin, + &members, + &100i128, + &100u64, + &0u32, + &0u32, + &treasury, + ); + + client.add_member(&admin, &member_c); + token_client.mint(&member_c, &100i128); + client.deposit(&member_c); + + assert_eq!(client.members().len(), 3); + assert!(client.has_deposited(&member_c)); +} + +#[test] +#[should_panic(expected = "not a member")] +fn test_removed_member_cannot_deposit() { + 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 token_client = token::StellarAssetClient::new(&env, &token_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, + ); + + client.remove_member(&admin, &member_b); + token_client.mint(&member_b, &100i128); + client.deposit(&member_b); +} + #[test] #[should_panic(expected = "too early")] fn test_premature_payout_rejection() { @@ -621,3 +707,180 @@ fn test_emergency_withdraw_drains_contract() { assert_eq!(token_iface.balance(&recipient), 200); } + +#[test] +fn test_remove_last_beneficiary_completes_pool() { + 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 token_client = token::StellarAssetClient::new(&env, &token_address); + + let treasury = Address::generate(&env); + let admin = Address::generate(&env); + let relayer = 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, + ); + + // Mints + token_client.mint(&member_a, &200i128); + token_client.mint(&member_b, &200i128); + + // Round 0 + client.deposit(&member_a); + client.deposit(&member_b); + env.ledger().set_timestamp(100); + client.trigger_payout(&relayer); + + assert!(client.is_active()); + assert_eq!(client.current_round(), 1); + + // In Round 1, member_b is the beneficiary. + // If we remove member_b before they deposit, the pool should complete immediately because no beneficiary remains for the final round. + client.remove_member(&admin, &member_b); + + assert!(!client.is_active()); +} + +#[test] +fn test_remove_member_general() { + 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 token_client = token::StellarAssetClient::new(&env, &token_address); + + let treasury = Address::generate(&env); + let admin = Address::generate(&env); + let relayer = Address::generate(&env); + let member_a = Address::generate(&env); + let member_b = Address::generate(&env); + let member_c = Address::generate(&env); + let member_d = 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()); + members.push_back(member_d.clone()); + + client.initialize( + &token_address, + &admin, + &members, + &100i128, + &100u64, + &0u32, + &0u32, + &treasury, + ); + + // Mints + token_client.mint(&member_a, &200i128); + token_client.mint(&member_b, &200i128); + token_client.mint(&member_c, &200i128); + token_client.mint(&member_d, &200i128); + + // Initial state: current_round = 0 (beneficiary is member_a) + assert_eq!(client.current_round(), 0); + + // Scenario A: Remove a member after current_round index. + // current_round = 0, we remove member_c (index 2). + // removed_index (2) > current_round (0), so current_round should remain 0. + client.remove_member(&admin, &member_c); + assert_eq!(client.current_round(), 0); + assert_eq!(client.members().len(), 3); + // Members list should now be [member_a, member_b, member_d] + assert_eq!(client.members().get(0).unwrap(), member_a); + assert_eq!(client.members().get(1).unwrap(), member_b); + assert_eq!(client.members().get(2).unwrap(), member_d); + + // Deposit and trigger payout for Round 0 (member_a is beneficiary) + client.deposit(&member_a); + client.deposit(&member_b); + client.deposit(&member_d); + env.ledger().set_timestamp(100); + client.trigger_payout(&relayer); + + // Now in Round 1 (beneficiary is member_b) + assert_eq!(client.current_round(), 1); + + // Scenario B: Remove a member before current_round index. + // current_round = 1, we remove member_a (index 0). + // removed_index (0) < current_round (1), so current_round should shift down to 0. + client.remove_member(&admin, &member_a); + assert_eq!(client.current_round(), 0); + assert_eq!(client.members().len(), 2); + assert_eq!(client.members().get(0).unwrap(), member_b); + assert_eq!(client.members().get(1).unwrap(), member_d); +} + +#[test] +#[should_panic(expected = "pool paused")] +fn test_add_member_fails_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 member_c = 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.add_member(&admin, &member_c); +} + +#[test] +#[should_panic(expected = "pool paused")] +fn test_remove_member_fails_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.remove_member(&admin, &member_b); +} diff --git a/smartcontract/contracts/target/src/lib.rs b/smartcontract/contracts/target/src/lib.rs index 5956f28..01dc945 100644 --- a/smartcontract/contracts/target/src/lib.rs +++ b/smartcontract/contracts/target/src/lib.rs @@ -1,8 +1,6 @@ #![no_std] -use soroban_sdk::{ - contract, contractimpl, contracttype, token, Address, Env, Vec, symbol_short, -}; +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Env, Vec}; #[contracttype] pub enum DataKey { @@ -52,7 +50,10 @@ impl TargetPool { member.require_auth(); let storage = env.storage().persistent(); - assert!(storage.get::<_, bool>(&DataKey::Active).unwrap(), "pool inactive"); + assert!( + storage.get::<_, bool>(&DataKey::Active).unwrap(), + "pool inactive" + ); let paused: bool = storage.get(&DataKey::Paused).unwrap_or(false); assert!(!paused, "pool paused"); @@ -62,14 +63,14 @@ impl TargetPool { assert!(amount > 0, "amount must be > 0"); let deadline: u32 = storage.get(&DataKey::Deadline).unwrap(); - assert!( - env.ledger().sequence() <= deadline, - "deadline passed" - ); + assert!(env.ledger().sequence() <= deadline, "deadline passed"); let token_addr: Address = storage.get(&DataKey::Token).unwrap(); - token::Client::new(&env, &token_addr) - .transfer(&member, &env.current_contract_address(), &amount); + token::Client::new(&env, &token_addr).transfer( + &member, + &env.current_contract_address(), + &amount, + ); let prev: i128 = storage.get(&DataKey::Balance(member.clone())).unwrap_or(0); storage.set(&DataKey::Balance(member.clone()), &(prev + amount)); @@ -82,10 +83,12 @@ impl TargetPool { let target: i128 = storage.get(&DataKey::TargetAmount).unwrap(); if new_total >= target { storage.set(&DataKey::Unlocked, &true); - env.events().publish((symbol_short!("unlocked"),), new_total); + env.events() + .publish((symbol_short!("unlocked"),), new_total); } - env.events().publish((symbol_short!("deposit"), member), amount); + env.events() + .publish((symbol_short!("deposit"), member), amount); } /// Withdraw proportional share. Only allowed once target is reached. @@ -107,10 +110,14 @@ impl TargetPool { storage.set(&DataKey::TotalDeposited, &(total - balance)); let token_addr: Address = storage.get(&DataKey::Token).unwrap(); - token::Client::new(&env, &token_addr) - .transfer(&env.current_contract_address(), &member, &balance); + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &member, + &balance, + ); - env.events().publish((symbol_short!("withdraw"), member), balance); + env.events() + .publish((symbol_short!("withdraw"), member), balance); } /// Admin can close the pool and refund all members if deadline passed without reaching target. @@ -147,6 +154,71 @@ impl TargetPool { env.events().publish((symbol_short!("refunded"),), ()); } + pub fn add_member(env: Env, admin: Address, new_member: Address) { + admin.require_auth(); + + let storage = env.storage().persistent(); + let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); + assert!(admin == stored_admin, "not admin"); + + 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 unlocked"); + + let mut members: Vec
= storage.get(&DataKey::Members).unwrap(); + assert!(!Self::is_member(&members, &new_member), "already a member"); + + members.push_back(new_member.clone()); + storage.set(&DataKey::Members, &members); + env.events() + .publish((symbol_short!("add_mem"), new_member), ()); + } + + pub fn remove_member(env: Env, admin: Address, member: Address) { + admin.require_auth(); + + let storage = env.storage().persistent(); + let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); + assert!(admin == stored_admin, "not admin"); + + 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 unlocked"); + + 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); + } + // ── Emergency controls ───────────────────────────────────────────────── pub fn pause(env: Env, admin: Address) { @@ -181,7 +253,11 @@ impl TargetPool { let contract_balance = token_client.balance(&env.current_contract_address()); if contract_balance > 0 { - token_client.transfer(&env.current_contract_address(), &recipient, &contract_balance); + token_client.transfer( + &env.current_contract_address(), + &recipient, + &contract_balance, + ); } storage.set(&DataKey::TotalDeposited, &0i128); @@ -192,19 +268,31 @@ impl TargetPool { // ── Views ────────────────────────────────────────────────────────────── pub fn balance_of(env: Env, member: Address) -> i128 { - env.storage().persistent().get(&DataKey::Balance(member)).unwrap_or(0) + env.storage() + .persistent() + .get(&DataKey::Balance(member)) + .unwrap_or(0) } pub fn total_deposited(env: Env) -> i128 { - env.storage().persistent().get(&DataKey::TotalDeposited).unwrap_or(0) + env.storage() + .persistent() + .get(&DataKey::TotalDeposited) + .unwrap_or(0) } pub fn is_unlocked(env: Env) -> bool { - env.storage().persistent().get(&DataKey::Unlocked).unwrap_or(false) + env.storage() + .persistent() + .get(&DataKey::Unlocked) + .unwrap_or(false) } pub fn target_amount(env: Env) -> i128 { - env.storage().persistent().get(&DataKey::TargetAmount).unwrap_or(0) + env.storage() + .persistent() + .get(&DataKey::TargetAmount) + .unwrap_or(0) } pub fn is_paused(env: Env) -> bool { @@ -218,11 +306,20 @@ impl TargetPool { env.storage().persistent().get(&DataKey::Admin).unwrap() } + pub fn members(env: Env) -> Vec
{ + env.storage() + .persistent() + .get(&DataKey::Members) + .unwrap_or(Vec::new(&env)) + } + // ── Helpers ──────────────────────────────────────────────────────────── fn is_member(members: &Vec
, who: &Address) -> bool { for m in members.iter() { - if m == *who { return true; } + if m == *who { + return true; + } } false } diff --git a/smartcontract/contracts/target/src/tests.rs b/smartcontract/contracts/target/src/tests.rs index e4ab40b..582c311 100644 --- a/smartcontract/contracts/target/src/tests.rs +++ b/smartcontract/contracts/target/src/tests.rs @@ -22,21 +22,17 @@ fn test_unlock_on_target() { 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()); let target_amount = 100i128; let deadline = 1000u32; - client.initialize( - &token_address, - &admin, - &members, - &target_amount, - &deadline, - ); + client.initialize(&token_address, &admin, &members, &target_amount, &deadline); assert!(!client.is_unlocked()); assert_eq!(client.total_deposited(), 0); @@ -72,18 +68,14 @@ fn test_proportional_withdraw() { 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, - &1000u32, - ); + client.initialize(&token_address, &admin, &members, &100i128, &1000u32); token_client.mint(&member_a, &100i128); token_client.mint(&member_b, &100i128); @@ -123,19 +115,15 @@ fn test_refund_and_deadline_rejection() { 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()); // Deadline sequence is 100 - client.initialize( - &token_address, - &admin, - &members, - &100i128, - &100u32, - ); + client.initialize(&token_address, &admin, &members, &100i128, &100u32); token_client.mint(&member_a, &100i128); token_client.mint(&member_b, &100i128); @@ -156,6 +144,104 @@ fn test_refund_and_deadline_rejection() { assert_eq!(client.total_deposited(), 0); } +#[test] +fn test_add_member_can_deposit() { + 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 member_c = 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); + + client.add_member(&admin, &member_c); + token_client.mint(&member_c, &50i128); + client.deposit(&member_c, &50i128); + + assert_eq!(client.members().len(), 3); + assert_eq!(client.balance_of(&member_c), 50); +} + +#[test] +#[should_panic(expected = "not a member")] +fn test_removed_member_cannot_deposit() { + 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 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, &1000u32); + + client.remove_member(&admin, &member_b); + token_client.mint(&member_b, &50i128); + client.deposit(&member_b, &50i128); +} + +#[test] +fn test_remove_member_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_interface_client = token::Client::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, &200i128, &1000u32); + + token_client.mint(&member_b, &75i128); + client.deposit(&member_b, &75i128); + + client.remove_member(&admin, &member_b); + + assert_eq!(token_interface_client.balance(&member_b), 75); + assert_eq!(client.balance_of(&member_b), 0); + assert_eq!(client.total_deposited(), 0); + assert_eq!(client.members().len(), 1); +} + #[test] #[should_panic(expected = "pool paused")] fn test_deposit_fails_when_paused() { @@ -340,13 +426,7 @@ fn test_deposit_after_deadline_rejection() { members.push_back(member_a.clone()); members.push_back(member_b.clone()); - client.initialize( - &token_address, - &admin, - &members, - &100i128, - &100u32, - ); + client.initialize(&token_address, &admin, &members, &100i128, &100u32); token_client.mint(&member_a, &100i128); @@ -357,4 +437,45 @@ fn test_deposit_after_deadline_rejection() { client.deposit(&member_a, &40i128); } +#[test] +#[should_panic(expected = "pool paused")] +fn test_add_member_fails_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 member_c = 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); + client.pause(&admin); + client.add_member(&admin, &member_c); +} +#[test] +#[should_panic(expected = "pool paused")] +fn test_remove_member_fails_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, &100i128, &1000u32); + client.pause(&admin); + client.remove_member(&admin, &member_b); +}