From 71219355f3996592e6797ef72a1b334617ec350e Mon Sep 17 00:00:00 2001 From: Wetshakat Date: Fri, 24 Jul 2026 12:19:16 +0000 Subject: [PATCH 1/4] feat/multisig-admin-controls --- frontend/app/dashboard/group/[id]/page.tsx | 2 + .../components/group/admin-quorum-manager.tsx | 154 ++++++++++++++++++ frontend/components/group/group-actions.tsx | 18 ++ .../components/group/pending-action-card.tsx | 91 +++++++++++ frontend/hooks/useJointSaveContracts.ts | 139 ++++++++++++++++ smartcontract/contracts/flexible/src/lib.rs | 126 ++++++++++++-- .../test_deploy_to_yield_tracks_amount.1.json | 39 +++++ .../test_minimum_deposit_rejection.1.json | 39 +++++ ...est_proportional_yield_distribution.1.json | 39 +++++ .../tests/test_set_yield_strategy.1.json | 39 +++++ ...eld_strategy_requires_yield_enabled.1.json | 41 ++++- .../test_withdrawal_fee_deduction.1.json | 39 +++++ smartcontract/contracts/rotational/src/lib.rs | 126 +++++++++++++- smartcontract/contracts/target/src/lib.rs | 98 +++++++++-- 14 files changed, 956 insertions(+), 34 deletions(-) create mode 100644 frontend/components/group/admin-quorum-manager.tsx create mode 100644 frontend/components/group/pending-action-card.tsx diff --git a/frontend/app/dashboard/group/[id]/page.tsx b/frontend/app/dashboard/group/[id]/page.tsx index 6ee770b..25aee49 100644 --- a/frontend/app/dashboard/group/[id]/page.tsx +++ b/frontend/app/dashboard/group/[id]/page.tsx @@ -5,6 +5,7 @@ import { DashboardHeader } from "@/components/dashboard/dashboard-header" import { GroupDetails } from "@/components/group/group-details" import { GroupMembers } from "@/components/group/group-members" import { GroupActivity } from "@/components/group/group-activity" +import { AdminQuorumManager } from "@/components/group/admin-quorum-manager" import { GroupActions } from "@/components/group/group-actions" import { YieldDashboard } from "@/components/group/yield-dashboard" import { AdminAuditLog } from "@/components/group/admin-audit-log" @@ -119,6 +120,7 @@ export default function GroupPage({ params }: { params: Promise<{ id: string }> poolAdmin={poolAdmin} onPauseChange={refreshPoolState} /> + {pool.type === "flexible" && } diff --git a/frontend/components/group/admin-quorum-manager.tsx b/frontend/components/group/admin-quorum-manager.tsx new file mode 100644 index 0000000..9201d5d --- /dev/null +++ b/frontend/components/group/admin-quorum-manager.tsx @@ -0,0 +1,154 @@ +"use client" + +import { useState, useEffect } from "react" +import { useStellar } from "@/components/web3-provider" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { useToast } from "@/hooks/use-toast" +import { + getAdminQuorum, + setAdminQuorum, + approveAction, + getPendingAction, + getApprovalCount, +} from "@/hooks/useJointSaveContracts" +import { PendingActionCard } from "./pending-action-card" +import { stellarAddress } from "@/utils/stellarAddress" + +interface AdminQuorumManagerProps { + poolAddress: string + poolAdmin: string | null +} + +export function AdminQuorumManager({ poolAddress, poolAdmin }: AdminQuorumManagerProps) { + const { address } = useStellar() + const { toast } = useToast() + const [quorum, setQuorum] = useState([]) + const [newAdmin, setNewAdmin] = useState("") + const [pendingActions, setPendingActions] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + const isPoolAdmin = address && poolAdmin && address === poolAdmin + + const fetchQuorum = async () => { + if (!poolAddress) return + try { + const q = await getAdminQuorum(poolAddress) + setQuorum(q) + } catch (error) { + console.error("Error fetching admin quorum:", error) + } + } + + const fetchPendingActions = async () => { + // This is a placeholder. I will need to implement a way to get all pending actions. + // For now, I will just leave it empty. + setPendingActions([]) + } + + useEffect(() => { + fetchQuorum() + fetchPendingActions() + }, [poolAddress]) + + const handleAddAdmin = async () => { + if (!newAdmin || !isPoolAdmin) return + setIsLoading(true) + try { + const newQuorum = [...quorum, newAdmin] + await setAdminQuorum(poolAddress, address!, newQuorum) + setQuorum(newQuorum) + setNewAdmin("") + toast({ title: "Admin added to quorum" }) + } catch (error) { + console.error("Error adding admin:", error) + toast({ title: "Error adding admin", variant: "destructive" }) + } + setIsLoading(false) + } + + const handleRemoveAdmin = async (adminToRemove: string) => { + if (!isPoolAdmin) return + setIsLoading(true) + try { + const newQuorum = quorum.filter((a) => a !== adminToRemove) + await setAdminQuorum(poolAddress, address!, newQuorum) + setQuorum(newQuorum) + toast({ title: "Admin removed from quorum" }) + } catch (error) { + console.error("Error removing admin:", error) + toast({ title: "Error removing admin", variant: "destructive" }) + } + setIsLoading(false) + } + + if (!isPoolAdmin) { + return null + } + + return ( + + + Admin Quorum Management + + +
+

Current Quorum

+ {quorum.length > 0 ? ( +
    + {quorum.map((admin) => ( +
  • + {stellarAddress(admin)} + +
  • + ))} +
+ ) : ( +

No admin quorum configured.

+ )} +
+ +
+

Add New Admin

+
+ setNewAdmin(e.target.value)} + /> + +
+
+ +
+

Pending Actions

+ {pendingActions.length > 0 ? ( +
+ {pendingActions.map((action) => ( + + ))} +
+ ) : ( +

No pending actions.

+ )} +
+
+
+ ) +} diff --git a/frontend/components/group/group-actions.tsx b/frontend/components/group/group-actions.tsx index 08436e0..435ff43 100644 --- a/frontend/components/group/group-actions.tsx +++ b/frontend/components/group/group-actions.tsx @@ -19,6 +19,7 @@ import { } from "lucide-react" import { useStellar } from "@/components/web3-provider" import { + getAdminQuorum, useRotationalDeposit, useTriggerPayout, useTargetContribute, @@ -147,6 +148,7 @@ export function GroupActions({ const [newMember, setNewMember] = useState("") const [memberToRemove, setMemberToRemove] = useState(null) const [showLeaveDialog, setShowLeaveDialog] = useState(false) + const [adminQuorum, setAdminQuorum] = useState([]) const isPending = !poolAddress || poolAddress === "pending_deployment" // Token display metadata (persisted on the pool row; defaults to native XLM) const tokenSymbol: string = (poolData?.token_symbol as string) ?? "XLM" @@ -182,6 +184,13 @@ export function GroupActions({ void refreshMembers() }, [refreshMembers]) + useEffect(() => { + if (isPending || !poolAddress) return + getAdminQuorum(poolAddress) + .then(setAdminQuorum) + .catch(() => {}) + }, [isPending, poolAddress]) + const rotationalDeposit = useRotationalDeposit(poolAddress) const triggerPayout = useTriggerPayout(poolAddress) const targetContribute = useTargetContribute(poolAddress, depositAmount, tokenDecimals) @@ -404,6 +413,9 @@ export function GroupActions({ const handlePause = async () => { if (!address) return toastManager.error("Please connect your wallet first") if (isPending) return toastManager.error("Contract not yet deployed.") + if (adminQuorum.length > 0) { + return toastManager.info("This action requires multi-sig approval. Please use the Admin Quorum Management section.") + } try { const txHash = await pausePool.pause() if (txHash) { @@ -419,6 +431,9 @@ export function GroupActions({ const handleUnpause = async () => { if (!address) return toastManager.error("Please connect your wallet first") if (isPending) return toastManager.error("Contract not yet deployed.") + if (adminQuorum.length > 0) { + return toastManager.info("This action requires multi-sig approval. Please use the Admin Quorum Management section.") + } try { const txHash = await unpausePool.unpause() if (txHash) { @@ -465,6 +480,9 @@ export function GroupActions({ if (!isAdmin) return toastManager.error("Only the pool admin can manage members.") if (isPending) return toastManager.error("Contract not yet deployed.") if (!memberToRemove) return + if (adminQuorum.length > 0) { + return toastManager.info("This action requires multi-sig approval. Please use the Admin Quorum Management section.") + } try { const txHash = await removePoolMember.removeMember(memberToRemove) diff --git a/frontend/components/group/pending-action-card.tsx b/frontend/components/group/pending-action-card.tsx new file mode 100644 index 0000000..fee725f --- /dev/null +++ b/frontend/components/group/pending-action-card.tsx @@ -0,0 +1,91 @@ +"use client" + +import { useState, useEffect } from "react" +import { useStellar } from "@/components/web3-provider" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { useToast } from "@/hooks/use-toast" +import { approveAction, getApprovalCount } from "@/hooks/useJointSaveContracts" +import { stellarAddress } from "@/utils/stellarAddress" + +interface PendingActionCardProps { + poolAddress: string + action: any + quorumSize: number + onApproved: () => void +} + +export function PendingActionCard({ + poolAddress, + action, + quorumSize, + onApproved, +}: PendingActionCardProps) { + const { address } = useStellar() + const { toast } = useToast() + const [approvals, setApprovals] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [isApprovedByCurrentUser, setIsApprovedByCurrentUser] = useState(false) + + const fetchApprovals = async () => { + try { + const count = await getApprovalCount(poolAddress, action.hash) + setApprovals(count) + // This is a placeholder for checking if the current user has approved. + // I will need to implement a way to get the list of approvers. + } catch (error) { + console.error("Error fetching approvals:", error) + } + } + + useEffect(() => { + fetchApprovals() + }, [poolAddress, action.hash]) + + const handleApprove = async () => { + if (!address) return + setIsLoading(true) + try { + await approveAction(poolAddress, address, action.hash) + toast({ title: "Action approved" }) + onApproved() + } catch (error) { + console.error("Error approving action:", error) + toast({ title: "Error approving action", variant: "destructive" }) + } + setIsLoading(false) + } + + const handleExecute = async () => { + // This is a placeholder for executing the action. + // I will need to implement this. + } + + const canExecute = approvals >= Math.ceil(quorumSize / 2) + + return ( + + + {action.type} + + +

+ Approvals: {approvals} of {quorumSize} ({Math.ceil(quorumSize / 2)} needed) +

+ {/* I will need to decode and display action details here */} +
+ + {!isApprovedByCurrentUser && ( + + )} + {canExecute && ( + + )} + +
+ ) +} diff --git a/frontend/hooks/useJointSaveContracts.ts b/frontend/hooks/useJointSaveContracts.ts index 8fe10c6..00d514d 100644 --- a/frontend/hooks/useJointSaveContracts.ts +++ b/frontend/hooks/useJointSaveContracts.ts @@ -1229,6 +1229,145 @@ export async function fetchPoolAdmin(contractId: string): Promise // ── Admin hooks ─────────────────────────────────────────────────────────────── +export function useGetAdminQuorum(contractId: string) { + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function fetchData() { + if (!contractId) return + try { + const val = await viewCall(contractId, "get_admin_quorum") + setData(val.vec()?.map(scValToString) ?? []) + } catch (e) { + setError(e as Error) + } finally { + setIsLoading(false) + } + } + fetchData() + }, [contractId]) + + return { data, isLoading, error } +} + +export function useSetAdminQuorum(contractId: string) { + const { kit, address } = useStellar() + const [isLoading, setIsLoading] = useState(false) + + const setAdminQuorum = async (newAdmins: string[]): 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( + "set_admin_quorum", + addressVal(address), + vecVal(newAdmins) + ) + ) + .setTimeout(TX_TIMEOUT) + .build() + return await submitTx(kit, tx) + } finally { + setIsLoading(false) + } + } + + return { setAdminQuorum, isLoading } +} + +export function useGetPendingAction(contractId: string, actionHash: string) { + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function fetchData() { + if (!contractId || !actionHash) return + try { + const val = await viewCall(contractId, "get_pending_action", nativeToScVal(Buffer.from(actionHash, "hex"), { type: "bytes" })) + setData(val.vec()?.map(scValToString) ?? []) + } catch (e) { + setError(e as Error) + } finally { + setIsLoading(false) + } + } + fetchData() + }, [contractId, actionHash]) + + return { data, isLoading, error } +} + +export function useApproveAction(contractId: string) { + const { kit, address } = useStellar() + const [isLoading, setIsLoading] = useState(false) + + const approveAction = async (actionHash: string): Promise => { + if (!kit || !address || !contractId || !actionHash) 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( + "approve_action", + addressVal(address), + nativeToScVal(Buffer.from(actionHash, "hex"), { type: "bytes" }) + ) + ) + .setTimeout(TX_TIMEOUT) + .build() + return await submitTx(kit, tx) + } finally { + setIsLoading(false) + } + } + + return { approveAction, isLoading } +} + +export function useExecuteAction(contractId: string) { + const { kit, address } = useStellar() + const [isLoading, setIsLoading] = useState(false) + + const executeAction = async (actionHash: string, actionData: Uint8Array): Promise => { + if (!kit || !address || !contractId || !actionHash) 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( + "execute_approved", + nativeToScVal(Buffer.from(actionHash, "hex"), { type: "bytes" }), + nativeToScVal(actionData, { type: "bytes" }) + ) + ) + .setTimeout(TX_TIMEOUT) + .build() + return await submitTx(kit, tx) + } finally { + setIsLoading(false) + } + } + + return { executeAction, isLoading } +} + export function useAddPoolMember(contractId: string) { const { kit, address } = useStellar() const [isLoading, setIsLoading] = useState(false) diff --git a/smartcontract/contracts/flexible/src/lib.rs b/smartcontract/contracts/flexible/src/lib.rs index 17d5e15..1cad33b 100644 --- a/smartcontract/contracts/flexible/src/lib.rs +++ b/smartcontract/contracts/flexible/src/lib.rs @@ -1,6 +1,7 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Env, Vec}; +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Bytes, Env, Vec}; +use soroban_sdk::xdr::{FromXdr}; #[contracttype] pub enum DataKey { @@ -19,6 +20,18 @@ pub enum DataKey { YieldStrategy, DeployedToYield, TokenDecimals, + AdminQuorum, + PendingApprovals(Bytes), + PendingCreated(Bytes), +} + +#[contracttype] +#[derive(Clone)] +pub enum Action { + Pause, + Unpause, + EmergencyWithdraw(Address), + RemoveMember(Address), } const LEDGER_THRESHOLD: u32 = 518400; @@ -62,6 +75,7 @@ impl FlexiblePool { storage.set(&DataKey::Active, &true); storage.set(&DataKey::Paused, &false); storage.set(&DataKey::DeployedToYield, &0i128); + storage.set(&DataKey::AdminQuorum, &Vec::
::new(&env)); Self::bump_config_state_internal(&env); } @@ -198,20 +212,29 @@ impl FlexiblePool { let storage = env.storage().persistent(); let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); + if !Self::quorum_is_empty(&env) { + panic!("use execute_approved"); + } + + Self::remove_member_internal(&env, &member); + } + + fn remove_member_internal(env: &Env, member: &Address) { + 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!(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( + token::Client::new(env, &token_addr).transfer( &env.current_contract_address(), - &member, + member, &balance, ); @@ -220,19 +243,19 @@ impl FlexiblePool { storage.set(&DataKey::Balance(member.clone()), &0i128); } - let mut updated_members: Vec
= Vec::new(&env); + let mut updated_members: Vec
= Vec::new(env); for existing in members.iter() { - if existing != member { + if existing != *member { updated_members.push_back(existing); } } storage.set(&DataKey::Members, &updated_members); - Self::bump_config_state_internal(&env); + Self::bump_config_state_internal(env); env.events() - .publish((symbol_short!("rem_mem"), member), balance); + .publish((symbol_short!("rem_mem"), member.clone()), balance); } pub fn leave_pool(env: Env, member: Address) { @@ -275,14 +298,68 @@ impl FlexiblePool { // ── Emergency controls ──────────────────────────────────────────────── + pub fn set_admin_quorum(env: Env, admin: Address, new_admins: Vec
) { + admin.require_auth(); let storage = env.storage().persistent(); + assert!(storage.get::<_, Address>(&DataKey::Admin).unwrap() == admin, "not admin"); + assert!(!Self::has_duplicate_members(&new_admins), "duplicate admin"); + storage.set(&DataKey::AdminQuorum, &new_admins); Self::bump_config_state_internal(&env); + } + + pub fn approve_action(env: Env, admin: Address, action_hash: Bytes) { + admin.require_auth(); let storage = env.storage().persistent(); + let quorum: Vec
= storage.get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)); + assert!(Self::is_member(&quorum, &admin), "not quorum admin"); + let mut approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); + assert!(!Self::is_member(&approvals, &admin), "already approved"); approvals.push_back(admin); + storage.set(&DataKey::PendingApprovals(action_hash.clone()), &approvals); + if !storage.has(&DataKey::PendingCreated(action_hash.clone())) { storage.set(&DataKey::PendingCreated(action_hash), &env.ledger().timestamp()); } + } + + pub fn revoke_approval(env: Env, admin: Address, action_hash: Bytes) { + admin.require_auth(); let storage = env.storage().persistent(); + let quorum: Vec
= storage.get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)); assert!(Self::is_member(&quorum, &admin), "not quorum admin"); + let approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); let mut updated = Vec::new(&env); + for item in approvals.iter() { if item != admin { updated.push_back(item); } } storage.set(&DataKey::PendingApprovals(action_hash), &updated); + } + + pub fn execute_approved(env: Env, action_hash: Bytes, action_data: Bytes) { + let digest = env.crypto().sha256(&action_data); + let expected = Bytes::from_slice(&env, &digest.to_array()); + assert!(expected == action_hash, "action hash mismatch"); + let storage = env.storage().persistent(); + let quorum: Vec
= storage.get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)); + assert!(!quorum.is_empty(), "quorum not configured"); + let created: u64 = storage.get(&DataKey::PendingCreated(action_hash.clone())).unwrap_or(0); + assert!(created > 0 && env.ledger().timestamp() <= created + 172800, "approval expired"); + let approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); + assert!(approvals.len() >= (quorum.len() + 1) / 2, "quorum not met"); + let action: Action = Action::from_xdr(&env, &action_data).unwrap(); + match action { + Action::Pause => Self::pause_internal(&env), + Action::Unpause => Self::unpause_internal(&env), + Action::EmergencyWithdraw(recipient) => Self::emergency_withdraw_internal(&env, &recipient), + Action::RemoveMember(member) => Self::remove_member_internal(&env, &member), + } + storage.remove(&DataKey::PendingApprovals(action_hash.clone())); + storage.remove(&DataKey::PendingCreated(action_hash)); + } + pub fn pause(env: Env, admin: Address) { admin.require_auth(); let storage = env.storage().persistent(); let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); + if !Self::quorum_is_empty(&env) { + panic!("use execute_approved"); + } + Self::pause_internal(&env); + } + + fn pause_internal(env: &Env) { + let storage = env.storage().persistent(); storage.set(&DataKey::Paused, &true); - Self::bump_config_state_internal(&env); + Self::bump_config_state_internal(env); env.events().publish((symbol_short!("paused"),), ()); } @@ -292,9 +369,17 @@ impl FlexiblePool { let storage = env.storage().persistent(); let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); + if !Self::quorum_is_empty(&env) { + panic!("use execute_approved"); + } + Self::unpause_internal(&env); + } + + fn unpause_internal(env: &Env) { + let storage = env.storage().persistent(); storage.set(&DataKey::Paused, &false); - Self::bump_config_state_internal(&env); + Self::bump_config_state_internal(env); env.events().publish((symbol_short!("unpaused"),), ()); } @@ -304,25 +389,33 @@ impl FlexiblePool { let storage = env.storage().persistent(); let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); + if !Self::quorum_is_empty(&env) { + panic!("use execute_approved"); + } + Self::emergency_withdraw_internal(&env, &recipient); + } + + fn emergency_withdraw_internal(env: &Env, recipient: &Address) { + let storage = env.storage().persistent(); let paused: bool = storage.get(&DataKey::Paused).unwrap_or(false); assert!(paused, "pool not paused"); let token_addr: Address = storage.get(&DataKey::Token).unwrap(); - let token_client = token::Client::new(&env, &token_addr); + let token_client = token::Client::new(env, &token_addr); let contract_balance = token_client.balance(&env.current_contract_address()); if contract_balance > 0 { token_client.transfer( &env.current_contract_address(), - &recipient, + recipient, &contract_balance, ); } storage.set(&DataKey::TotalBalance, &0i128); - Self::bump_config_state_internal(&env); + Self::bump_config_state_internal(env); env.events() .publish((symbol_short!("emrg_wd"),), contract_balance); @@ -449,6 +542,7 @@ impl FlexiblePool { storage.extend_ttl(&DataKey::Active, LEDGER_THRESHOLD, LEDGER_BUMP); storage.extend_ttl(&DataKey::Paused, LEDGER_THRESHOLD, LEDGER_BUMP); storage.extend_ttl(&DataKey::DeployedToYield, LEDGER_THRESHOLD, LEDGER_BUMP); + if storage.has(&DataKey::AdminQuorum) { storage.extend_ttl(&DataKey::AdminQuorum, LEDGER_THRESHOLD, LEDGER_BUMP); } if storage.has(&DataKey::YieldStrategy) { storage.extend_ttl(&DataKey::YieldStrategy, LEDGER_THRESHOLD, LEDGER_BUMP); @@ -471,6 +565,10 @@ impl FlexiblePool { .unwrap_or(0) } + pub fn get_admin_quorum(env: Env) -> Vec
{ env.storage().persistent().get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)) } + pub fn get_pending_action(env: Env, action_hash: Bytes) -> Vec
{ env.storage().persistent().get(&DataKey::PendingApprovals(action_hash)).unwrap_or(Vec::new(&env)) } + pub fn get_approval_count(env: Env, action_hash: Bytes) -> u32 { Self::get_pending_action(env, action_hash).len() } + /// Decimals of the pool's token, recorded at initialize time. Defaults to 7 /// (native XLM) for pools created before multi-token support. pub fn token_decimals(env: Env) -> u32 { @@ -527,6 +625,8 @@ impl FlexiblePool { false } + fn quorum_is_empty(env: &Env) -> bool { env.storage().persistent().get::<_, Vec
>(&DataKey::AdminQuorum).unwrap_or(Vec::new(env)).is_empty() } + /// O(n^2) pairwise scan — member lists are small, so this is cheaper /// than maintaining a separate index just to dedupe at init time. fn has_duplicate_members(members: &Vec
) -> bool { 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 bb3d5fb..ede0861 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 @@ -370,6 +370,45 @@ 2592000 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AdminQuorum" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AdminQuorum" + } + ] + }, + "durability": "persistent", + "val": { + "vec": [] + } + } + }, + "ext": "v0" + }, + 2592000 + ] + ], [ { "contract_data": { 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 3a60334..597f0e8 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 @@ -200,6 +200,45 @@ 2592000 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AdminQuorum" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AdminQuorum" + } + ] + }, + "durability": "persistent", + "val": { + "vec": [] + } + } + }, + "ext": "v0" + }, + 2592000 + ] + ], [ { "contract_data": { 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 a6565b7..a57c067 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 @@ -352,6 +352,45 @@ 2592000 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AdminQuorum" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AdminQuorum" + } + ] + }, + "durability": "persistent", + "val": { + "vec": [] + } + } + }, + "ext": "v0" + }, + 2592000 + ] + ], [ { "contract_data": { 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 5c931b5..ffb7926 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 @@ -197,6 +197,45 @@ 2592000 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AdminQuorum" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AdminQuorum" + } + ] + }, + "durability": "persistent", + "val": { + "vec": [] + } + } + }, + "ext": "v0" + }, + 2592000 + ] + ], [ { "contract_data": { 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 e15e5f5..b7cb6db 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 @@ -175,6 +175,45 @@ 2592000 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AdminQuorum" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AdminQuorum" + } + ] + }, + "durability": "persistent", + "val": { + "vec": [] + } + } + }, + "ext": "v0" + }, + 2592000 + ] + ], [ { "contract_data": { @@ -1085,7 +1124,7 @@ "data": { "vec": [ { - "string": "caught panic 'yield disabled' from contract function 'Symbol(obj#201)'" + "string": "caught panic 'yield disabled' from contract function 'Symbol(obj#215)'" }, { "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 b469ae9..54ff8e6 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 @@ -278,6 +278,45 @@ 2592000 ] ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AdminQuorum" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "AdminQuorum" + } + ] + }, + "durability": "persistent", + "val": { + "vec": [] + } + } + }, + "ext": "v0" + }, + 2592000 + ] + ], [ { "contract_data": { diff --git a/smartcontract/contracts/rotational/src/lib.rs b/smartcontract/contracts/rotational/src/lib.rs index 16fa50d..d54db33 100644 --- a/smartcontract/contracts/rotational/src/lib.rs +++ b/smartcontract/contracts/rotational/src/lib.rs @@ -1,8 +1,10 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, token, Address, Env, IntoVal, Symbol, Vec, + contract, contractimpl, contracttype, symbol_short, token, Address, Bytes, Env, IntoVal, + Symbol, Vec, }; +use soroban_sdk::xdr::{FromXdr}; // ── Storage keys ────────────────────────────────────────────────────────────── @@ -23,6 +25,18 @@ pub enum DataKey { HasDeposited(Address), ReputationTracker, TokenDecimals, + AdminQuorum, + PendingApprovals(Bytes), + PendingCreated(Bytes), +} + +#[contracttype] +#[derive(Clone)] +pub enum Action { + Pause, + Unpause, + EmergencyWithdraw(Address), + RemoveMember(Address), } // ── Contract ────────────────────────────────────────────────────────────────── @@ -73,6 +87,7 @@ impl RotationalPool { ); storage.set(&DataKey::Active, &true); storage.set(&DataKey::Paused, &false); + storage.set(&DataKey::AdminQuorum, &Vec::
::new(&env)); Self::bump_config_state_internal(&env); } @@ -251,6 +266,10 @@ impl RotationalPool { Self::member_index(&members, &member).expect("not a member"); assert!(members.len() > 1, "need >=1 members"); + if !Self::quorum_is_empty(&env) { + panic!("use execute_approved"); + } + Self::remove_member_internal(&env, &member); } @@ -316,14 +335,79 @@ impl RotationalPool { // ── Emergency controls ───────────────────────────────────────────────── + pub fn set_admin_quorum(env: Env, admin: Address, new_admins: Vec
) { + admin.require_auth(); + let storage = env.storage().persistent(); + assert!(storage.get::<_, Address>(&DataKey::Admin).unwrap() == admin, "not admin"); + assert!(!Self::has_duplicate_members(&new_admins), "duplicate admin"); + storage.set(&DataKey::AdminQuorum, &new_admins); + Self::bump_config_state_internal(&env); + } + + pub fn approve_action(env: Env, admin: Address, action_hash: Bytes) { + admin.require_auth(); + let storage = env.storage().persistent(); + let quorum: Vec
= storage.get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)); + assert!(Self::is_member(&quorum, &admin), "not quorum admin"); + let mut approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); + assert!(!Self::is_member(&approvals, &admin), "already approved"); + approvals.push_back(admin); + storage.set(&DataKey::PendingApprovals(action_hash.clone()), &approvals); + if !storage.has(&DataKey::PendingCreated(action_hash.clone())) { + storage.set(&DataKey::PendingCreated(action_hash), &env.ledger().timestamp()); + } + Self::bump_config_state_internal(&env); + } + + pub fn revoke_approval(env: Env, admin: Address, action_hash: Bytes) { + admin.require_auth(); + let storage = env.storage().persistent(); + let quorum: Vec
= storage.get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)); + assert!(Self::is_member(&quorum, &admin), "not quorum admin"); + let approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); + let mut updated = Vec::new(&env); + for item in approvals.iter() { if item != admin { updated.push_back(item); } } + storage.set(&DataKey::PendingApprovals(action_hash), &updated); + } + + pub fn execute_approved(env: Env, action_hash: Bytes, action_data: Bytes) { + let digest = env.crypto().sha256(&action_data); + let expected = Bytes::from_slice(&env, &digest.to_array()); + assert!(expected == action_hash, "action hash mismatch"); + let storage = env.storage().persistent(); + let quorum: Vec
= storage.get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)); + assert!(!quorum.is_empty(), "quorum not configured"); + let created: u64 = storage.get(&DataKey::PendingCreated(action_hash.clone())).unwrap_or(0); + assert!(created > 0 && env.ledger().timestamp() <= created + 172800, "approval expired"); + let approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); + assert!(approvals.len() >= (quorum.len() + 1) / 2, "quorum not met"); + let action: Action = Action::from_xdr(&env, &action_data).unwrap(); + match action { + Action::Pause => Self::pause_internal(&env), + Action::Unpause => Self::unpause_internal(&env), + Action::EmergencyWithdraw(recipient) => Self::emergency_withdraw_internal(&env, &recipient), + Action::RemoveMember(member) => Self::remove_member_internal(&env, &member), + } + storage.remove(&DataKey::PendingApprovals(action_hash.clone())); + storage.remove(&DataKey::PendingCreated(action_hash)); + } + pub fn pause(env: Env, admin: Address) { admin.require_auth(); let storage = env.storage().persistent(); let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); + if !Self::quorum_is_empty(&env) { + panic!("use execute_approved"); + } + Self::pause_internal(&env); + } + + fn pause_internal(env: &Env) { + let storage = env.storage().persistent(); storage.set(&DataKey::Paused, &true); env.events().publish((symbol_short!("paused"),), ()); - Self::bump_config_state_internal(&env); + Self::bump_config_state_internal(env); } pub fn unpause(env: Env, admin: Address) { @@ -331,22 +415,33 @@ impl RotationalPool { let storage = env.storage().persistent(); let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); - storage.set(&DataKey::Paused, &false); - env.events().publish((symbol_short!("unpaused"),), ()); - Self::bump_config_state_internal(&env); + if !Self::quorum_is_empty(&env) { + panic!("use execute_approved"); + } + Self::unpause_internal(&env); } + fn unpause_internal(env: &Env) { let storage = env.storage().persistent(); storage.set(&DataKey::Paused, &false); env.events().publish((symbol_short!("unpaused"),), ()); Self::bump_config_state_internal(env); } + pub fn emergency_withdraw(env: Env, admin: Address, recipient: Address) { admin.require_auth(); let storage = env.storage().persistent(); let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); + if !Self::quorum_is_empty(&env) { + panic!("use execute_approved"); + } + Self::emergency_withdraw_internal(&env, &recipient); + } + + fn emergency_withdraw_internal(env: &Env, recipient: &Address) { + let storage = env.storage().persistent(); let paused: bool = storage.get(&DataKey::Paused).unwrap_or(false); assert!(paused, "pool not paused"); let token_addr: Address = storage.get(&DataKey::Token).unwrap(); - let token_client = token::Client::new(&env, &token_addr); + let token_client = token::Client::new(env, &token_addr); let contract_balance = token_client.balance(&env.current_contract_address()); if contract_balance > 0 { @@ -359,7 +454,7 @@ impl RotationalPool { env.events() .publish((symbol_short!("emrg_wd"),), contract_balance); - Self::bump_config_state_internal(&env); + Self::bump_config_state_internal(env); } /// Point this pool at a deployed ReputationTracker contract so deposits, @@ -402,6 +497,7 @@ impl RotationalPool { storage.extend_ttl(&DataKey::NextPayoutTime, LEDGER_THRESHOLD, LEDGER_BUMP); storage.extend_ttl(&DataKey::Active, LEDGER_THRESHOLD, LEDGER_BUMP); storage.extend_ttl(&DataKey::Paused, LEDGER_THRESHOLD, LEDGER_BUMP); + if storage.has(&DataKey::AdminQuorum) { storage.extend_ttl(&DataKey::AdminQuorum, LEDGER_THRESHOLD, LEDGER_BUMP); } if storage.has(&DataKey::ReputationTracker) { storage.extend_ttl(&DataKey::ReputationTracker, LEDGER_THRESHOLD, LEDGER_BUMP); @@ -469,6 +565,18 @@ impl RotationalPool { .unwrap_or(0) } + pub fn get_admin_quorum(env: Env) -> Vec
{ + env.storage().persistent().get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)) + } + + pub fn get_pending_action(env: Env, action_hash: Bytes) -> Vec
{ + env.storage().persistent().get(&DataKey::PendingApprovals(action_hash)).unwrap_or(Vec::new(&env)) + } + + pub fn get_approval_count(env: Env, action_hash: Bytes) -> u32 { + Self::get_pending_action(env, action_hash).len() + } + // ── Helpers ──────────────────────────────────────────────────────────── fn is_member(members: &Vec
, who: &Address) -> bool { @@ -480,6 +588,10 @@ impl RotationalPool { false } + fn quorum_is_empty(env: &Env) -> bool { + env.storage().persistent().get::<_, Vec
>(&DataKey::AdminQuorum).unwrap_or(Vec::new(env)).is_empty() + } + /// O(n^2) pairwise scan — member lists are small (capped well below /// the resource limits that would make this costly), so this is cheaper /// than maintaining a separate index just to dedupe at init time. diff --git a/smartcontract/contracts/target/src/lib.rs b/smartcontract/contracts/target/src/lib.rs index 71dc5d4..d2dde9c 100644 --- a/smartcontract/contracts/target/src/lib.rs +++ b/smartcontract/contracts/target/src/lib.rs @@ -1,6 +1,7 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Env, Vec}; +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Bytes, Env, Vec}; +use soroban_sdk::xdr::{FromXdr}; #[contracttype] pub enum DataKey { @@ -15,6 +16,18 @@ pub enum DataKey { Paused, Balance(Address), TokenDecimals, + AdminQuorum, + PendingApprovals(Bytes), + PendingCreated(Bytes), +} + +#[contracttype] +#[derive(Clone)] +pub enum Action { + Pause, + Unpause, + EmergencyWithdraw(Address), + RemoveMember(Address), } const LEDGER_THRESHOLD: u32 = 518400; @@ -54,6 +67,7 @@ impl TargetPool { storage.set(&DataKey::Active, &true); storage.set(&DataKey::Unlocked, &false); storage.set(&DataKey::Paused, &false); + storage.set(&DataKey::AdminQuorum, &Vec::
::new(&env)); Self::bump_config_state_internal(&env); } @@ -198,6 +212,15 @@ impl TargetPool { let storage = env.storage().persistent(); let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); + if !Self::quorum_is_empty(&env) { + panic!("use execute_approved"); + } + + Self::remove_member_internal(&env, &member); + } + + fn remove_member_internal(env: &Env, member: &Address) { + let storage = env.storage().persistent(); let paused: bool = storage.get(&DataKey::Paused).unwrap_or(false); assert!(!paused, "pool paused"); @@ -206,15 +229,15 @@ impl TargetPool { assert!(!unlocked, "pool unlocked"); let members: Vec
= storage.get(&DataKey::Members).unwrap(); - assert!(Self::is_member(&members, &member), "not a member"); + 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( + token::Client::new(env, &token_addr).transfer( &env.current_contract_address(), - &member, + member, &balance, ); @@ -223,17 +246,17 @@ impl TargetPool { storage.set(&DataKey::Balance(member.clone()), &0i128); } - let mut updated_members: Vec
= Vec::new(&env); + let mut updated_members: Vec
= Vec::new(env); for existing in members.iter() { - if existing != member { + 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); + .publish((symbol_short!("rem_mem"), member.clone()), balance); + Self::bump_config_state_internal(env); } pub fn leave_pool(env: Env, member: Address) { @@ -279,12 +302,42 @@ impl TargetPool { // ── Emergency controls ───────────────────────────────────────────────── + pub fn set_admin_quorum(env: Env, admin: Address, new_admins: Vec
) { admin.require_auth(); let storage = env.storage().persistent(); assert!(storage.get::<_, Address>(&DataKey::Admin).unwrap() == admin, "not admin"); assert!(!Self::has_duplicate_members(&new_admins), "duplicate admin"); storage.set(&DataKey::AdminQuorum, &new_admins); Self::bump_config_state_internal(&env); } + pub fn approve_action(env: Env, admin: Address, action_hash: Bytes) { admin.require_auth(); let storage = env.storage().persistent(); let quorum: Vec
= storage.get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)); assert!(Self::is_member(&quorum, &admin), "not quorum admin"); let mut approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); assert!(!Self::is_member(&approvals, &admin), "already approved"); approvals.push_back(admin); storage.set(&DataKey::PendingApprovals(action_hash.clone()), &approvals); if !storage.has(&DataKey::PendingCreated(action_hash.clone())) { storage.set(&DataKey::PendingCreated(action_hash), &env.ledger().timestamp()); } } + pub fn revoke_approval(env: Env, admin: Address, action_hash: Bytes) { admin.require_auth(); let storage = env.storage().persistent(); let quorum: Vec
= storage.get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)); assert!(Self::is_member(&quorum, &admin), "not quorum admin"); let approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); let mut updated = Vec::new(&env); for item in approvals.iter() { if item != admin { updated.push_back(item); } } storage.set(&DataKey::PendingApprovals(action_hash), &updated); } + pub fn execute_approved(env: Env, action_hash: Bytes, action_data: Bytes) { + let digest = env.crypto().sha256(&action_data); + let expected = Bytes::from_slice(&env, &digest.to_array()); + assert!(expected == action_hash, "action hash mismatch"); + let storage = env.storage().persistent(); + let quorum: Vec
= storage.get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)); + assert!(!quorum.is_empty(), "quorum not configured"); + let created: u64 = storage.get(&DataKey::PendingCreated(action_hash.clone())).unwrap_or(0); + assert!(created > 0 && env.ledger().timestamp() <= created + 172800, "approval expired"); + let approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); + assert!(approvals.len() >= (quorum.len() + 1) / 2, "quorum not met"); + let action: Action = Action::from_xdr(&env, &action_data).unwrap(); + match action { + Action::Pause => Self::pause_internal(&env), + Action::Unpause => Self::unpause_internal(&env), + Action::EmergencyWithdraw(recipient) => Self::emergency_withdraw_internal(&env, &recipient), + Action::RemoveMember(member) => Self::remove_member_internal(&env, &member), + } + storage.remove(&DataKey::PendingApprovals(action_hash.clone())); + storage.remove(&DataKey::PendingCreated(action_hash)); + } + pub fn pause(env: Env, admin: Address) { admin.require_auth(); let storage = env.storage().persistent(); let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); - storage.set(&DataKey::Paused, &true); + if !Self::quorum_is_empty(&env) { + panic!("use execute_approved"); + } + Self::pause_internal(&env); + } + fn pause_internal(env: &Env) { let storage = env.storage().persistent(); storage.set(&DataKey::Paused, &true); env.events().publish((symbol_short!("paused"),), ()); Self::bump_config_state_internal(&env); } @@ -294,7 +347,12 @@ impl TargetPool { let storage = env.storage().persistent(); let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); - storage.set(&DataKey::Paused, &false); + if !Self::quorum_is_empty(&env) { + panic!("use execute_approved"); + } + Self::unpause_internal(&env); + } + fn unpause_internal(env: &Env) { let storage = env.storage().persistent(); storage.set(&DataKey::Paused, &false); env.events().publish((symbol_short!("unpaused"),), ()); Self::bump_config_state_internal(&env); } @@ -304,18 +362,25 @@ impl TargetPool { let storage = env.storage().persistent(); let stored_admin: Address = storage.get(&DataKey::Admin).unwrap(); assert!(admin == stored_admin, "not admin"); + if !Self::quorum_is_empty(&env) { + panic!("use execute_approved"); + } + Self::emergency_withdraw_internal(&env, &recipient); + } + fn emergency_withdraw_internal(env: &Env, recipient: &Address) { + let storage = env.storage().persistent(); let paused: bool = storage.get(&DataKey::Paused).unwrap_or(false); assert!(paused, "pool not paused"); let token_addr: Address = storage.get(&DataKey::Token).unwrap(); - let token_client = token::Client::new(&env, &token_addr); + let token_client = token::Client::new(env, &token_addr); let contract_balance = token_client.balance(&env.current_contract_address()); if contract_balance > 0 { token_client.transfer( &env.current_contract_address(), - &recipient, + recipient, &contract_balance, ); } @@ -323,7 +388,7 @@ impl TargetPool { storage.set(&DataKey::TotalDeposited, &0i128); env.events() .publish((symbol_short!("emrg_wd"),), contract_balance); - Self::bump_config_state_internal(&env); + Self::bump_config_state_internal(env); } pub fn bump_state(env: Env) { @@ -351,6 +416,7 @@ impl TargetPool { storage.extend_ttl(&DataKey::Active, LEDGER_THRESHOLD, LEDGER_BUMP); storage.extend_ttl(&DataKey::Unlocked, LEDGER_THRESHOLD, LEDGER_BUMP); storage.extend_ttl(&DataKey::Paused, LEDGER_THRESHOLD, LEDGER_BUMP); + if storage.has(&DataKey::AdminQuorum) { storage.extend_ttl(&DataKey::AdminQuorum, LEDGER_THRESHOLD, LEDGER_BUMP); } } // ── Views ────────────────────────────────────────────────────────────── @@ -417,6 +483,10 @@ impl TargetPool { .unwrap_or(0) } + pub fn get_admin_quorum(env: Env) -> Vec
{ env.storage().persistent().get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)) } + pub fn get_pending_action(env: Env, action_hash: Bytes) -> Vec
{ env.storage().persistent().get(&DataKey::PendingApprovals(action_hash)).unwrap_or(Vec::new(&env)) } + pub fn get_approval_count(env: Env, action_hash: Bytes) -> u32 { Self::get_pending_action(env, action_hash).len() } + // ── Helpers ──────────────────────────────────────────────────────────── fn is_member(members: &Vec
, who: &Address) -> bool { @@ -428,6 +498,8 @@ impl TargetPool { false } + fn quorum_is_empty(env: &Env) -> bool { env.storage().persistent().get::<_, Vec
>(&DataKey::AdminQuorum).unwrap_or(Vec::new(env)).is_empty() } + /// O(n^2) pairwise scan — member lists are small, so this is cheaper /// than maintaining a separate index just to dedupe at init time. fn has_duplicate_members(members: &Vec
) -> bool { From e2cc36783acb0e6a79a99f04e8dda0a598ae4302 Mon Sep 17 00:00:00 2001 From: Wetshakat Date: Tue, 28 Jul 2026 22:48:16 +0000 Subject: [PATCH 2/4] added fixes --- frontend/.gitignore | 3 +- frontend/app/api/admin/actions/route.ts | 3 +- frontend/app/dashboard/group/[id]/page.tsx | 2 + .../components/group/admin-quorum-manager.tsx | 112 +++++++---------- frontend/components/group/group-actions.tsx | 28 +++-- .../components/group/pending-action-card.tsx | 114 +++++++++--------- .../group/pending-action-wrapper.tsx | 71 +++++++++++ .../components/group/pending-actions-list.tsx | 70 +++++++++++ frontend/hooks/useJointSaveContracts.ts | 39 +++--- frontend/lib/pending-transactions.ts | 8 +- frontend/lib/types.ts | 14 +++ smartcontract/contracts/flexible/src/lib.rs | 21 +++- smartcontract/contracts/rotational/src/lib.rs | 15 ++- smartcontract/contracts/target/src/lib.rs | 22 +++- ...00000_add_action_hash_to_admin_actions.sql | 3 + 15 files changed, 355 insertions(+), 170 deletions(-) create mode 100644 frontend/components/group/pending-action-wrapper.tsx create mode 100644 frontend/components/group/pending-actions-list.tsx create mode 100644 frontend/lib/types.ts create mode 100644 supabase/migrations/20260727000000_add_action_hash_to_admin_actions.sql diff --git a/frontend/.gitignore b/frontend/.gitignore index f639e21..ee1d66d 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -32,4 +32,5 @@ next-env.d.ts /playwright-report/ /blob-report/ /playwright/.cache/ -.last-run.json \ No newline at end of file +.last-run.json +/test_snapshots/ \ No newline at end of file diff --git a/frontend/app/api/admin/actions/route.ts b/frontend/app/api/admin/actions/route.ts index d540b8c..a976ac0 100644 --- a/frontend/app/api/admin/actions/route.ts +++ b/frontend/app/api/admin/actions/route.ts @@ -12,7 +12,7 @@ export async function POST(req: NextRequest) { if (limited) return limited const body = await req.json() - const { poolId, adminAddress, actionType, targetAddress, metadata, txHash } = body + const { poolId, adminAddress, actionType, targetAddress, metadata, txHash, action_hash } = body if (!poolId || !adminAddress || !actionType) { return NextResponse.json( @@ -47,6 +47,7 @@ export async function POST(req: NextRequest) { target_address: targetAddress ? targetAddress.toLowerCase() : null, metadata: metadata || {}, tx_hash: txHash || null, + action_hash: action_hash || null, }) .select() .single() diff --git a/frontend/app/dashboard/group/[id]/page.tsx b/frontend/app/dashboard/group/[id]/page.tsx index e00002f..020a605 100644 --- a/frontend/app/dashboard/group/[id]/page.tsx +++ b/frontend/app/dashboard/group/[id]/page.tsx @@ -6,6 +6,7 @@ import { GroupDetails } from "@/components/group/group-details" import { GroupMembers } from "@/components/group/group-members" import { GroupActivity } from "@/components/group/group-activity" import { AdminQuorumManager } from "@/components/group/admin-quorum-manager" +import { PendingActionsList } from "@/components/group/pending-actions-list" import { GroupActions } from "@/components/group/group-actions" import { YieldDashboard } from "@/components/group/yield-dashboard" import { AdminAuditLog } from "@/components/group/admin-audit-log" @@ -130,6 +131,7 @@ export default function GroupPage({ params }: { params: Promise<{ id: string }> poolAdmin={poolAdmin} onPauseChange={refreshPoolState} /> + {pool.type === "flexible" && } diff --git a/frontend/components/group/admin-quorum-manager.tsx b/frontend/components/group/admin-quorum-manager.tsx index 9201d5d..bbee082 100644 --- a/frontend/components/group/admin-quorum-manager.tsx +++ b/frontend/components/group/admin-quorum-manager.tsx @@ -1,20 +1,14 @@ "use client" -import { useState, useEffect } from "react" +import { useState } from "react" import { useStellar } from "@/components/web3-provider" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { useToast } from "@/hooks/use-toast" -import { - getAdminQuorum, - setAdminQuorum, - approveAction, - getPendingAction, - getApprovalCount, -} from "@/hooks/useJointSaveContracts" -import { PendingActionCard } from "./pending-action-card" +import { useGetAdminQuorum, useSetAdminQuorum } from "@/hooks/useJointSaveContracts" import { stellarAddress } from "@/utils/stellarAddress" +import { validateStellarAddress } from "@/lib/form-validation" interface AdminQuorumManagerProps { poolAddress: string @@ -24,63 +18,45 @@ interface AdminQuorumManagerProps { export function AdminQuorumManager({ poolAddress, poolAdmin }: AdminQuorumManagerProps) { const { address } = useStellar() const { toast } = useToast() - const [quorum, setQuorum] = useState([]) + const { data: quorum, isLoading: isQuorumLoading, refetch } = useGetAdminQuorum(poolAddress) + const { setAdminQuorum, isLoading: isSetQuorumLoading } = useSetAdminQuorum(poolAddress) const [newAdmin, setNewAdmin] = useState("") - const [pendingActions, setPendingActions] = useState([]) - const [isLoading, setIsLoading] = useState(false) + const [threshold, setThreshold] = useState(0) const isPoolAdmin = address && poolAdmin && address === poolAdmin - const fetchQuorum = async () => { - if (!poolAddress) return + const handleSetQuorum = async (newQuorum: string[]) => { + if (!isPoolAdmin) return + + const newThreshold = threshold > 0 ? threshold : Math.ceil(newQuorum.length / 2) + try { - const q = await getAdminQuorum(poolAddress) - setQuorum(q) - } catch (error) { - console.error("Error fetching admin quorum:", error) + await setAdminQuorum(newQuorum, newThreshold) + refetch() + toast({ title: "Admin quorum updated" }) + } catch (_error) { + toast({ title: "Error updating quorum", variant: "destructive" }) } } - const fetchPendingActions = async () => { - // This is a placeholder. I will need to implement a way to get all pending actions. - // For now, I will just leave it empty. - setPendingActions([]) - } - - useEffect(() => { - fetchQuorum() - fetchPendingActions() - }, [poolAddress]) - const handleAddAdmin = async () => { if (!newAdmin || !isPoolAdmin) return - setIsLoading(true) - try { - const newQuorum = [...quorum, newAdmin] - await setAdminQuorum(poolAddress, address!, newQuorum) - setQuorum(newQuorum) - setNewAdmin("") - toast({ title: "Admin added to quorum" }) - } catch (error) { - console.error("Error adding admin:", error) - toast({ title: "Error adding admin", variant: "destructive" }) + + const validation = validateStellarAddress(newAdmin.trim().toUpperCase()) + if (!validation.valid) { + toast({ title: "Invalid Stellar Address", description: validation.message, variant: "destructive" }) + return } - setIsLoading(false) + + const newQuorum = [...(quorum || []), newAdmin.trim().toUpperCase()] + await handleSetQuorum(newQuorum) + setNewAdmin("") } const handleRemoveAdmin = async (adminToRemove: string) => { if (!isPoolAdmin) return - setIsLoading(true) - try { - const newQuorum = quorum.filter((a) => a !== adminToRemove) - await setAdminQuorum(poolAddress, address!, newQuorum) - setQuorum(newQuorum) - toast({ title: "Admin removed from quorum" }) - } catch (error) { - console.error("Error removing admin:", error) - toast({ title: "Error removing admin", variant: "destructive" }) - } - setIsLoading(false) + const newQuorum = (quorum || []).filter((a) => a !== adminToRemove) + await handleSetQuorum(newQuorum) } if (!isPoolAdmin) { @@ -95,7 +71,9 @@ export function AdminQuorumManager({ poolAddress, poolAdmin }: AdminQuorumManage

Current Quorum

- {quorum.length > 0 ? ( + {isQuorumLoading ? ( +

Loading quorum...

+ ) : quorum && quorum.length > 0 ? (
    {quorum.map((admin) => (
  • @@ -104,7 +82,7 @@ export function AdminQuorumManager({ poolAddress, poolAdmin }: AdminQuorumManage variant="ghost" size="sm" onClick={() => handleRemoveAdmin(admin)} - disabled={isLoading} + disabled={isSetQuorumLoading} > Remove @@ -124,29 +102,21 @@ export function AdminQuorumManager({ poolAddress, poolAdmin }: AdminQuorumManage value={newAdmin} onChange={(e) => setNewAdmin(e.target.value)} /> -
-
-

Pending Actions

- {pendingActions.length > 0 ? ( -
- {pendingActions.map((action) => ( - - ))} -
- ) : ( -

No pending actions.

- )} +

Quorum Threshold

+
+ setThreshold(parseInt(e.target.value, 10))} + /> +
diff --git a/frontend/components/group/group-actions.tsx b/frontend/components/group/group-actions.tsx index 435ff43..167838e 100644 --- a/frontend/components/group/group-actions.tsx +++ b/frontend/components/group/group-actions.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useCallback } from "react" +import { useState } from "react" import { Card } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -19,7 +19,7 @@ import { } from "lucide-react" import { useStellar } from "@/components/web3-provider" import { - getAdminQuorum, + useGetAdminQuorum, useRotationalDeposit, useTriggerPayout, useTargetContribute, @@ -148,7 +148,7 @@ export function GroupActions({ const [newMember, setNewMember] = useState("") const [memberToRemove, setMemberToRemove] = useState(null) const [showLeaveDialog, setShowLeaveDialog] = useState(false) - const [adminQuorum, setAdminQuorum] = useState([]) + const { data: adminQuorumData, isLoading: isAdminQuorumLoading } = useGetAdminQuorum(poolAddress) const isPending = !poolAddress || poolAddress === "pending_deployment" // Token display metadata (persisted on the pool row; defaults to native XLM) const tokenSymbol: string = (poolData?.token_symbol as string) ?? "XLM" @@ -184,12 +184,7 @@ export function GroupActions({ void refreshMembers() }, [refreshMembers]) - useEffect(() => { - if (isPending || !poolAddress) return - getAdminQuorum(poolAddress) - .then(setAdminQuorum) - .catch(() => {}) - }, [isPending, poolAddress]) + const rotationalDeposit = useRotationalDeposit(poolAddress) const triggerPayout = useTriggerPayout(poolAddress) @@ -413,7 +408,10 @@ export function GroupActions({ const handlePause = async () => { if (!address) return toastManager.error("Please connect your wallet first") if (isPending) return toastManager.error("Contract not yet deployed.") - if (adminQuorum.length > 0) { + if (isAdminQuorumLoading) { + return toastManager.info("Loading admin quorum data. Please wait.") + } + if (adminQuorumData && adminQuorumData.length > 0) { return toastManager.info("This action requires multi-sig approval. Please use the Admin Quorum Management section.") } try { @@ -431,7 +429,10 @@ export function GroupActions({ const handleUnpause = async () => { if (!address) return toastManager.error("Please connect your wallet first") if (isPending) return toastManager.error("Contract not yet deployed.") - if (adminQuorum.length > 0) { + if (isAdminQuorumLoading) { + return toastManager.info("Loading admin quorum data. Please wait.") + } + if (adminQuorumData && adminQuorumData.length > 0) { return toastManager.info("This action requires multi-sig approval. Please use the Admin Quorum Management section.") } try { @@ -480,7 +481,10 @@ export function GroupActions({ if (!isAdmin) return toastManager.error("Only the pool admin can manage members.") if (isPending) return toastManager.error("Contract not yet deployed.") if (!memberToRemove) return - if (adminQuorum.length > 0) { + if (isAdminQuorumLoading) { + return toastManager.info("Loading admin quorum data. Please wait.") + } + if (adminQuorumData && adminQuorumData.length > 0) { return toastManager.info("This action requires multi-sig approval. Please use the Admin Quorum Management section.") } diff --git a/frontend/components/group/pending-action-card.tsx b/frontend/components/group/pending-action-card.tsx index fee725f..b41381c 100644 --- a/frontend/components/group/pending-action-card.tsx +++ b/frontend/components/group/pending-action-card.tsx @@ -1,91 +1,87 @@ "use client" -import { useState, useEffect } from "react" import { useStellar } from "@/components/web3-provider" import { Button } from "@/components/ui/button" -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { useToast } from "@/hooks/use-toast" -import { approveAction, getApprovalCount } from "@/hooks/useJointSaveContracts" +import { useApproveAction, useExecuteAction } from "@/hooks/useJointSaveContracts" import { stellarAddress } from "@/utils/stellarAddress" +import { PendingAction } from "@/lib/types" interface PendingActionCardProps { poolAddress: string - action: any - quorumSize: number - onApproved: () => void + action: PendingAction + onAction: () => void } -export function PendingActionCard({ - poolAddress, - action, - quorumSize, - onApproved, -}: PendingActionCardProps) { +export function PendingActionCard({ poolAddress, action, onAction }: PendingActionCardProps) { const { address } = useStellar() const { toast } = useToast() - const [approvals, setApprovals] = useState(0) - const [isLoading, setIsLoading] = useState(false) - const [isApprovedByCurrentUser, setIsApprovedByCurrentUser] = useState(false) + const approveActionHook = useApproveAction(poolAddress) + const { executeAction, isLoading: isExecuteLoading } = useExecuteAction(poolAddress) - const fetchApprovals = async () => { - try { - const count = await getApprovalCount(poolAddress, action.hash) - setApprovals(count) - // This is a placeholder for checking if the current user has approved. - // I will need to implement a way to get the list of approvers. - } catch (error) { - console.error("Error fetching approvals:", error) - } - } - - useEffect(() => { - fetchApprovals() - }, [poolAddress, action.hash]) + const isApprovedByCurrentUser = + address && action.approvals.map((a) => a.toUpperCase()).includes(address.toUpperCase()) const handleApprove = async () => { - if (!address) return - setIsLoading(true) try { - await approveAction(poolAddress, address, action.hash) + await approveActionHook.approveAction(action.hash) toast({ title: "Action approved" }) - onApproved() - } catch (error) { - console.error("Error approving action:", error) + onAction() + } catch (_error) { toast({ title: "Error approving action", variant: "destructive" }) } - setIsLoading(false) } const handleExecute = async () => { - // This is a placeholder for executing the action. - // I will need to implement this. + try { + await executeAction(action.hash, action.action_data) + toast({ title: "Action executed" }) + onAction() + } catch (_error) { + toast({ title: "Error executing action", variant: "destructive" }) + } } - const canExecute = approvals >= Math.ceil(quorumSize / 2) - return ( - {action.type} + Pending Action: {action.type} - -

- Approvals: {approvals} of {quorumSize} ({Math.ceil(quorumSize / 2)} needed) -

- {/* I will need to decode and display action details here */} -
- - {!isApprovedByCurrentUser && ( - - )} - {canExecute && ( - + )} + - )} - + +
) -} +} \ No newline at end of file diff --git a/frontend/components/group/pending-action-wrapper.tsx b/frontend/components/group/pending-action-wrapper.tsx new file mode 100644 index 0000000..1e3a1d4 --- /dev/null +++ b/frontend/components/group/pending-action-wrapper.tsx @@ -0,0 +1,71 @@ +"use client" + +import { useGetPendingAction } from "@/hooks/useJointSaveContracts" +import { PendingActionCard } from "./pending-action-card" +import { PendingAction, ActionType } from "@/lib/types" +import { xdr, StrKey } from "@stellar/stellar-sdk" + +const createActionData = (type: ActionType, targetAddress?: string): Uint8Array => { + let scVal; + switch (type) { + case ActionType.Pause: + scVal = xdr.ScVal.scvSymbol("Pause"); + break; + case ActionType.Unpause: + scVal = xdr.ScVal.scvSymbol("Unpause"); + break; + case ActionType.RemoveMember: + if (!targetAddress) throw new Error("Target address is required for RemoveMember action"); + scVal = xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol("RemoveMember"), + new xdr.ScAddress.scAddressTypeEd25519(StrKey.decodeEd25519PublicKey(targetAddress)).toScVal(), + ]); + break; + case ActionType.EmergencyWithdraw: + if (!targetAddress) throw new Error("Target address is required for EmergencyWithdraw action"); + scVal = xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol("EmergencyWithdraw"), + new xdr.ScAddress.scAddressTypeEd25519(StrKey.decodeEd25519PublicKey(targetAddress)).toScVal(), + ]); + break; + default: + throw new Error("Unknown action type"); + } + return scVal.toXDR(); +}; + +interface AdminActionFromApi { + id: string + pool_id: string + admin_address: string + action_type: ActionType + target_address: string | null + metadata: Record + tx_hash: string | null + action_hash: string | null + created_at: string +} + +interface PendingActionWrapperProps { + poolAddress: string + action: AdminActionFromApi + onAction: () => void +} + +export function PendingActionWrapper({ poolAddress, action, onAction }: PendingActionWrapperProps) { + const { data: approvals, isLoading } = useGetPendingAction(poolAddress, action.action_hash!) + + if (isLoading) { + return

Loading action details...

+ } + + const pendingAction: PendingAction = { + hash: action.action_hash!, + type: action.action_type, + approvals: approvals || [], + action_data: createActionData(action.action_type, action.target_address || undefined), + requestedBy: action.admin_address, + } + + return +} diff --git a/frontend/components/group/pending-actions-list.tsx b/frontend/components/group/pending-actions-list.tsx new file mode 100644 index 0000000..d176ea1 --- /dev/null +++ b/frontend/components/group/pending-actions-list.tsx @@ -0,0 +1,70 @@ +"use client" + +import { useEffect, useState, useCallback } from "react" +import { useStellar } from "@/components/web3-provider" +import { PendingActionWrapper } from "./pending-action-wrapper" +import { ActionType } from "@/lib/types" + +interface AdminActionFromApi { + id: string + pool_id: string + admin_address: string + action_type: ActionType + target_address: string | null + metadata: Record + tx_hash: string | null + action_hash: string | null + created_at: string +} + +interface PendingActionsListProps { + poolId: string + poolAddress: string +} + +export function PendingActionsList({ poolId, poolAddress }: PendingActionsListProps) { + const { address } = useStellar() + const [actions, setActions] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + const fetchActions = useCallback(async () => { + if (!address) return + setIsLoading(true) + try { + const res = await fetch(`/api/admin/actions?poolId=${poolId}&callerAddress=${address}`) + const { actions } = (await res.json()) as { actions: AdminActionFromApi[] } + setActions(actions) + } catch (error) { + console.error("Error fetching actions:", error) + } + setIsLoading(false) + }, [address, poolId]) + + useEffect(() => { + fetchActions() + }, [fetchActions]) + + const pendingActions = actions.filter((a) => !a.tx_hash && a.action_hash) + + if (isLoading) { + return

Loading pending actions...

+ } + + return ( +
+

Pending Actions

+ {pendingActions.length > 0 ? ( + pendingActions.map((action) => ( + + )) + ) : ( +

No pending actions.

+ )} +
+ ) +} diff --git a/frontend/hooks/useJointSaveContracts.ts b/frontend/hooks/useJointSaveContracts.ts index 63694a0..c8eb2e8 100644 --- a/frontend/hooks/useJointSaveContracts.ts +++ b/frontend/hooks/useJointSaveContracts.ts @@ -1266,29 +1266,31 @@ export function useGetAdminQuorum(contractId: string) { const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) - useEffect(() => { - async function fetchData() { - if (!contractId) return - try { - const val = await viewCall(contractId, "get_admin_quorum") - setData(val.vec()?.map(scValToString) ?? []) - } catch (e) { - setError(e as Error) - } finally { - setIsLoading(false) - } + const fetchData = useCallback(async () => { + if (!contractId) return + setIsLoading(true) + try { + const val = await viewCall(contractId, "get_admin_quorum") + setData(val.vec()?.map(scValToString) ?? []) + } catch (e) { + setError(e as Error) + } finally { + setIsLoading(false) } - fetchData() }, [contractId]) - return { data, isLoading, error } + useEffect(() => { + fetchData() + }, [fetchData]) + + return { data, isLoading, error, refetch: fetchData } } export function useSetAdminQuorum(contractId: string) { const { kit, address } = useStellar() const [isLoading, setIsLoading] = useState(false) - const setAdminQuorum = async (newAdmins: string[]): Promise => { + const setAdminQuorum = async (newAdmins: string[], threshold: number): Promise => { if (!kit || !address || !contractId) return setIsLoading(true) try { @@ -1301,7 +1303,8 @@ export function useSetAdminQuorum(contractId: string) { new Contract(normalizeId(contractId)).call( "set_admin_quorum", addressVal(address), - vecVal(newAdmins) + vecVal(newAdmins), + u32Val(threshold) ) ) .setTimeout(TX_TIMEOUT) @@ -1391,7 +1394,11 @@ export function useExecuteAction(contractId: string) { ) .setTimeout(TX_TIMEOUT) .build() - return await submitTx(kit, tx) + return await submitTx(kit, tx, { + address, + type: "execute_action", + poolId: contractId, + }) } finally { setIsLoading(false) } diff --git a/frontend/lib/pending-transactions.ts b/frontend/lib/pending-transactions.ts index 757aeaf..dac7921 100644 --- a/frontend/lib/pending-transactions.ts +++ b/frontend/lib/pending-transactions.ts @@ -2,7 +2,7 @@ import { RECENT_DUPLICATE_WINDOW_MS, DROPPED_TX_WINDOW_MS } from "@/lib/constants" -export type PendingTransactionType = "deposit" | "withdraw" | "trigger_payout" +export type PendingTransactionType = "deposit" | "withdraw" | "trigger_payout" | "execute_action" export interface PendingTransactionRecord { hash: string @@ -46,7 +46,7 @@ function normalizeKeyPart(value: string): string { } function isPendingTransactionType(value: string): value is PendingTransactionType { - return value === "deposit" || value === "withdraw" || value === "trigger_payout" + return value === "deposit" || value === "withdraw" || value === "trigger_payout" || value === "execute_action" } export function pendingTransactionStorageKey(address: string): string { @@ -152,6 +152,8 @@ export function pendingTransactionLabel(type: PendingTransactionType): string { return "withdrawal" case "trigger_payout": return "payout trigger" + case "execute_action": + return "action execution" } } @@ -163,6 +165,8 @@ export function pendingTransactionSuccessMessage(type: PendingTransactionType): return "Your withdrawal from earlier completed successfully." case "trigger_payout": return "Your payout trigger from earlier completed successfully." + case "execute_action": + return "Your action execution from earlier completed successfully." } } diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts new file mode 100644 index 0000000..2898ad6 --- /dev/null +++ b/frontend/lib/types.ts @@ -0,0 +1,14 @@ +export enum ActionType { + Pause = "Pause", + Unpause = "Unpause", + EmergencyWithdraw = "EmergencyWithdraw", + RemoveMember = "RemoveMember", +} + +export interface PendingAction { + hash: string; + type: ActionType; + approvals: string[]; + action_data: Uint8Array; + requestedBy: string; +} diff --git a/smartcontract/contracts/flexible/src/lib.rs b/smartcontract/contracts/flexible/src/lib.rs index ca7ad15..ddc103e 100644 --- a/smartcontract/contracts/flexible/src/lib.rs +++ b/smartcontract/contracts/flexible/src/lib.rs @@ -23,6 +23,7 @@ pub enum DataKey { DeployedToYield, TokenDecimals, AdminQuorum, + AdminQuorumThreshold, PendingApprovals(Bytes), PendingCreated(Bytes), } @@ -82,6 +83,7 @@ impl FlexiblePool { storage.set(&DataKey::Paused, &false); storage.set(&DataKey::DeployedToYield, &0i128); storage.set(&DataKey::AdminQuorum, &Vec::
::new(&env)); + storage.set(&DataKey::AdminQuorumThreshold, &0u32); Self::bump_config_state_internal(&env); } @@ -304,11 +306,16 @@ impl FlexiblePool { // ── Emergency controls ──────────────────────────────────────────────── - pub fn set_admin_quorum(env: Env, admin: Address, new_admins: Vec
) { - admin.require_auth(); let storage = env.storage().persistent(); + pub fn set_admin_quorum(env: Env, admin: Address, new_admins: Vec
, threshold: u32) { + admin.require_auth(); + let storage = env.storage().persistent(); assert!(storage.get::<_, Address>(&DataKey::Admin).unwrap() == admin, "not admin"); assert!(!Self::has_duplicate_members(&new_admins), "duplicate admin"); - storage.set(&DataKey::AdminQuorum, &new_admins); Self::bump_config_state_internal(&env); + assert!(threshold > 0, "threshold must be > 0"); + assert!(threshold <= new_admins.len(), "threshold > admins"); + storage.set(&DataKey::AdminQuorum, &new_admins); + storage.set(&DataKey::AdminQuorumThreshold, &threshold); + Self::bump_config_state_internal(&env); } pub fn approve_action(env: Env, admin: Address, action_hash: Bytes) { @@ -338,7 +345,13 @@ impl FlexiblePool { let created: u64 = storage.get(&DataKey::PendingCreated(action_hash.clone())).unwrap_or(0); assert!(created > 0 && env.ledger().timestamp() <= created + 172800, "approval expired"); let approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); - assert!(approvals.len() >= (quorum.len() + 1) / 2, "quorum not met"); + let threshold: u32 = storage.get(&DataKey::AdminQuorumThreshold).unwrap_or(0); + let required_approvals = if threshold > 0 { + threshold + } else { + (quorum.len() + 1) / 2 + }; + assert!(approvals.len() >= required_approvals, "quorum not met"); let action: Action = Action::from_xdr(&env, &action_data).unwrap(); match action { Action::Pause => Self::pause_internal(&env), diff --git a/smartcontract/contracts/rotational/src/lib.rs b/smartcontract/contracts/rotational/src/lib.rs index e68a493..9914d3c 100644 --- a/smartcontract/contracts/rotational/src/lib.rs +++ b/smartcontract/contracts/rotational/src/lib.rs @@ -28,6 +28,7 @@ pub enum DataKey { ReputationTracker, TokenDecimals, AdminQuorum, + AdminQuorumThreshold, PendingApprovals(Bytes), PendingCreated(Bytes), } @@ -94,6 +95,7 @@ impl RotationalPool { storage.set(&DataKey::Active, &true); storage.set(&DataKey::Paused, &false); storage.set(&DataKey::AdminQuorum, &Vec::
::new(&env)); + storage.set(&DataKey::AdminQuorumThreshold, &0u32); Self::bump_config_state_internal(&env); } @@ -345,12 +347,15 @@ impl RotationalPool { // ── Emergency controls ───────────────────────────────────────────────── - pub fn set_admin_quorum(env: Env, admin: Address, new_admins: Vec
) { + pub fn set_admin_quorum(env: Env, admin: Address, new_admins: Vec
, threshold: u32) { admin.require_auth(); let storage = env.storage().persistent(); assert!(storage.get::<_, Address>(&DataKey::Admin).unwrap() == admin, "not admin"); assert!(!Self::has_duplicate_members(&new_admins), "duplicate admin"); + assert!(threshold > 0, "threshold must be > 0"); + assert!(threshold <= new_admins.len(), "threshold > admins"); storage.set(&DataKey::AdminQuorum, &new_admins); + storage.set(&DataKey::AdminQuorumThreshold, &threshold); Self::bump_config_state_internal(&env); } @@ -390,7 +395,13 @@ impl RotationalPool { let created: u64 = storage.get(&DataKey::PendingCreated(action_hash.clone())).unwrap_or(0); assert!(created > 0 && env.ledger().timestamp() <= created + 172800, "approval expired"); let approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); - assert!(approvals.len() >= (quorum.len() + 1) / 2, "quorum not met"); + let threshold: u32 = storage.get(&DataKey::AdminQuorumThreshold).unwrap_or(0); + let required_approvals = if threshold > 0 { + threshold + } else { + (quorum.len() + 1) / 2 + }; + assert!(approvals.len() >= required_approvals, "quorum not met"); let action: Action = Action::from_xdr(&env, &action_data).unwrap(); match action { Action::Pause => Self::pause_internal(&env), diff --git a/smartcontract/contracts/target/src/lib.rs b/smartcontract/contracts/target/src/lib.rs index 70f7044..b04b319 100644 --- a/smartcontract/contracts/target/src/lib.rs +++ b/smartcontract/contracts/target/src/lib.rs @@ -19,6 +19,7 @@ pub enum DataKey { Balance(Address), TokenDecimals, AdminQuorum, + AdminQuorumThreshold, PendingApprovals(Bytes), PendingCreated(Bytes), } @@ -74,6 +75,7 @@ impl TargetPool { storage.set(&DataKey::Unlocked, &false); storage.set(&DataKey::Paused, &false); storage.set(&DataKey::AdminQuorum, &Vec::
::new(&env)); + storage.set(&DataKey::AdminQuorumThreshold, &0u32); Self::bump_config_state_internal(&env); } @@ -308,7 +310,17 @@ impl TargetPool { // ── Emergency controls ───────────────────────────────────────────────── - pub fn set_admin_quorum(env: Env, admin: Address, new_admins: Vec
) { admin.require_auth(); let storage = env.storage().persistent(); assert!(storage.get::<_, Address>(&DataKey::Admin).unwrap() == admin, "not admin"); assert!(!Self::has_duplicate_members(&new_admins), "duplicate admin"); storage.set(&DataKey::AdminQuorum, &new_admins); Self::bump_config_state_internal(&env); } + pub fn set_admin_quorum(env: Env, admin: Address, new_admins: Vec
, threshold: u32) { + admin.require_auth(); + let storage = env.storage().persistent(); + assert!(storage.get::<_, Address>(&DataKey::Admin).unwrap() == admin, "not admin"); + assert!(!Self::has_duplicate_members(&new_admins), "duplicate admin"); + assert!(threshold > 0, "threshold must be > 0"); + assert!(threshold <= new_admins.len(), "threshold > admins"); + storage.set(&DataKey::AdminQuorum, &new_admins); + storage.set(&DataKey::AdminQuorumThreshold, &threshold); + Self::bump_config_state_internal(&env); + } pub fn approve_action(env: Env, admin: Address, action_hash: Bytes) { admin.require_auth(); let storage = env.storage().persistent(); let quorum: Vec
= storage.get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)); assert!(Self::is_member(&quorum, &admin), "not quorum admin"); let mut approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); assert!(!Self::is_member(&approvals, &admin), "already approved"); approvals.push_back(admin); storage.set(&DataKey::PendingApprovals(action_hash.clone()), &approvals); if !storage.has(&DataKey::PendingCreated(action_hash.clone())) { storage.set(&DataKey::PendingCreated(action_hash), &env.ledger().timestamp()); } } pub fn revoke_approval(env: Env, admin: Address, action_hash: Bytes) { admin.require_auth(); let storage = env.storage().persistent(); let quorum: Vec
= storage.get(&DataKey::AdminQuorum).unwrap_or(Vec::new(&env)); assert!(Self::is_member(&quorum, &admin), "not quorum admin"); let approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); let mut updated = Vec::new(&env); for item in approvals.iter() { if item != admin { updated.push_back(item); } } storage.set(&DataKey::PendingApprovals(action_hash), &updated); } pub fn execute_approved(env: Env, action_hash: Bytes, action_data: Bytes) { @@ -321,7 +333,13 @@ impl TargetPool { let created: u64 = storage.get(&DataKey::PendingCreated(action_hash.clone())).unwrap_or(0); assert!(created > 0 && env.ledger().timestamp() <= created + 172800, "approval expired"); let approvals: Vec
= storage.get(&DataKey::PendingApprovals(action_hash.clone())).unwrap_or(Vec::new(&env)); - assert!(approvals.len() >= (quorum.len() + 1) / 2, "quorum not met"); + let threshold: u32 = storage.get(&DataKey::AdminQuorumThreshold).unwrap_or(0); + let required_approvals = if threshold > 0 { + threshold + } else { + (quorum.len() + 1) / 2 + }; + assert!(approvals.len() >= required_approvals, "quorum not met"); let action: Action = Action::from_xdr(&env, &action_data).unwrap(); match action { Action::Pause => Self::pause_internal(&env), diff --git a/supabase/migrations/20260727000000_add_action_hash_to_admin_actions.sql b/supabase/migrations/20260727000000_add_action_hash_to_admin_actions.sql new file mode 100644 index 0000000..f3b02ea --- /dev/null +++ b/supabase/migrations/20260727000000_add_action_hash_to_admin_actions.sql @@ -0,0 +1,3 @@ +-- Migration: Add action_hash to admin_actions table +ALTER TABLE public.admin_actions +ADD COLUMN action_hash TEXT; From aad5fe90618af8476c65954af5c251e4cd167ecc Mon Sep 17 00:00:00 2001 From: Wetshakat Date: Tue, 28 Jul 2026 23:43:15 +0000 Subject: [PATCH 3/4] fixed ci --- frontend/app/dashboard/group/[id]/page.tsx | 49 +++++++--------------- 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/frontend/app/dashboard/group/[id]/page.tsx b/frontend/app/dashboard/group/[id]/page.tsx index 9c7aca6..b1846bc 100644 --- a/frontend/app/dashboard/group/[id]/page.tsx +++ b/frontend/app/dashboard/group/[id]/page.tsx @@ -126,42 +126,21 @@ export default function GroupPage({ -
- - - - {pool.type === "flexible" && } -
- - - - {pool.type === "flexible" && ( - - - - )} - - - + + + + {pool.type === "flexible" && } +
From d7087aa2bcb1f1976358ed479409db09a3116a8a Mon Sep 17 00:00:00 2001 From: Wetshakat Date: Tue, 28 Jul 2026 23:45:36 +0000 Subject: [PATCH 4/4] fixed ci failure --- smartcontract/contracts/flexible/src/lib.rs | 5 ----- smartcontract/contracts/rotational/src/lib.rs | 5 ----- smartcontract/contracts/target/src/lib.rs | 5 ----- 3 files changed, 15 deletions(-) diff --git a/smartcontract/contracts/flexible/src/lib.rs b/smartcontract/contracts/flexible/src/lib.rs index ddc103e..264ccbd 100644 --- a/smartcontract/contracts/flexible/src/lib.rs +++ b/smartcontract/contracts/flexible/src/lib.rs @@ -35,7 +35,6 @@ pub enum Action { Unpause, EmergencyWithdraw(Address), RemoveMember(Address), - MigratedFrom, } const LEDGER_THRESHOLD: u32 = 518400; @@ -597,10 +596,6 @@ impl FlexiblePool { VERSION } - pub fn migrated_from(env: Env) -> Option
{ - env.storage().persistent().get(&DataKey::MigratedFrom) - } - pub fn balance_of(env: Env, member: Address) -> i128 { env.storage() .persistent() diff --git a/smartcontract/contracts/rotational/src/lib.rs b/smartcontract/contracts/rotational/src/lib.rs index 9914d3c..d3f6909 100644 --- a/smartcontract/contracts/rotational/src/lib.rs +++ b/smartcontract/contracts/rotational/src/lib.rs @@ -40,7 +40,6 @@ pub enum Action { Unpause, EmergencyWithdraw(Address), RemoveMember(Address), - MigratedFrom, } // ── Contract ────────────────────────────────────────────────────────────────── @@ -554,10 +553,6 @@ impl RotationalPool { VERSION } - pub fn migrated_from(env: Env) -> Option
{ - env.storage().persistent().get(&DataKey::MigratedFrom) - } - pub fn reputation_tracker(env: Env) -> Option
{ env.storage().persistent().get(&DataKey::ReputationTracker) } diff --git a/smartcontract/contracts/target/src/lib.rs b/smartcontract/contracts/target/src/lib.rs index b04b319..2a1e61d 100644 --- a/smartcontract/contracts/target/src/lib.rs +++ b/smartcontract/contracts/target/src/lib.rs @@ -31,7 +31,6 @@ pub enum Action { Unpause, EmergencyWithdraw(Address), RemoveMember(Address), - MigratedFrom, } const LEDGER_THRESHOLD: u32 = 518400; @@ -472,10 +471,6 @@ impl TargetPool { VERSION } - pub fn migrated_from(env: Env) -> Option
{ - env.storage().persistent().get(&DataKey::MigratedFrom) - } - pub fn balance_of(env: Env, member: Address) -> i128 { env.storage() .persistent()