diff --git a/frontend/.gitignore b/frontend/.gitignore index 7f0682f..817414d 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -33,4 +33,5 @@ next-env.d.ts /blob-report/ /playwright/.cache/ .last-run.json -/coverage \ No newline at end of file +/test_snapshots/ +/coverage 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 f55a3b1..b1846bc 100644 --- a/frontend/app/dashboard/group/[id]/page.tsx +++ b/frontend/app/dashboard/group/[id]/page.tsx @@ -5,6 +5,8 @@ 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 { 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" @@ -125,26 +127,20 @@ export default function GroupPage({
- - - - {pool.type === "flexible" && ( - - - - )} - - - + + + + {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..bbee082 --- /dev/null +++ b/frontend/components/group/admin-quorum-manager.tsx @@ -0,0 +1,124 @@ +"use client" + +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 { useGetAdminQuorum, useSetAdminQuorum } from "@/hooks/useJointSaveContracts" +import { stellarAddress } from "@/utils/stellarAddress" +import { validateStellarAddress } from "@/lib/form-validation" + +interface AdminQuorumManagerProps { + poolAddress: string + poolAdmin: string | null +} + +export function AdminQuorumManager({ poolAddress, poolAdmin }: AdminQuorumManagerProps) { + const { address } = useStellar() + const { toast } = useToast() + const { data: quorum, isLoading: isQuorumLoading, refetch } = useGetAdminQuorum(poolAddress) + const { setAdminQuorum, isLoading: isSetQuorumLoading } = useSetAdminQuorum(poolAddress) + const [newAdmin, setNewAdmin] = useState("") + const [threshold, setThreshold] = useState(0) + + const isPoolAdmin = address && poolAdmin && address === poolAdmin + + const handleSetQuorum = async (newQuorum: string[]) => { + if (!isPoolAdmin) return + + const newThreshold = threshold > 0 ? threshold : Math.ceil(newQuorum.length / 2) + + try { + await setAdminQuorum(newQuorum, newThreshold) + refetch() + toast({ title: "Admin quorum updated" }) + } catch (_error) { + toast({ title: "Error updating quorum", variant: "destructive" }) + } + } + + const handleAddAdmin = async () => { + if (!newAdmin || !isPoolAdmin) return + + const validation = validateStellarAddress(newAdmin.trim().toUpperCase()) + if (!validation.valid) { + toast({ title: "Invalid Stellar Address", description: validation.message, variant: "destructive" }) + return + } + + const newQuorum = [...(quorum || []), newAdmin.trim().toUpperCase()] + await handleSetQuorum(newQuorum) + setNewAdmin("") + } + + const handleRemoveAdmin = async (adminToRemove: string) => { + if (!isPoolAdmin) return + const newQuorum = (quorum || []).filter((a) => a !== adminToRemove) + await handleSetQuorum(newQuorum) + } + + if (!isPoolAdmin) { + return null + } + + return ( + + + Admin Quorum Management + + +
+

Current Quorum

+ {isQuorumLoading ? ( +

Loading quorum...

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

No admin quorum configured.

+ )} +
+ +
+

Add New Admin

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

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 08436e0..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,6 +19,7 @@ import { } from "lucide-react" import { useStellar } from "@/components/web3-provider" import { + useGetAdminQuorum, 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 { 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" @@ -182,6 +184,8 @@ export function GroupActions({ void refreshMembers() }, [refreshMembers]) + + const rotationalDeposit = useRotationalDeposit(poolAddress) const triggerPayout = useTriggerPayout(poolAddress) const targetContribute = useTargetContribute(poolAddress, depositAmount, tokenDecimals) @@ -404,6 +408,12 @@ 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 (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 { const txHash = await pausePool.pause() if (txHash) { @@ -419,6 +429,12 @@ 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 (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 { const txHash = await unpausePool.unpause() if (txHash) { @@ -465,6 +481,12 @@ 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 (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 { 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..b41381c --- /dev/null +++ b/frontend/components/group/pending-action-card.tsx @@ -0,0 +1,87 @@ +"use client" + +import { useStellar } from "@/components/web3-provider" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { useToast } from "@/hooks/use-toast" +import { useApproveAction, useExecuteAction } from "@/hooks/useJointSaveContracts" +import { stellarAddress } from "@/utils/stellarAddress" +import { PendingAction } from "@/lib/types" + +interface PendingActionCardProps { + poolAddress: string + action: PendingAction + onAction: () => void +} + +export function PendingActionCard({ poolAddress, action, onAction }: PendingActionCardProps) { + const { address } = useStellar() + const { toast } = useToast() + const approveActionHook = useApproveAction(poolAddress) + const { executeAction, isLoading: isExecuteLoading } = useExecuteAction(poolAddress) + + const isApprovedByCurrentUser = + address && action.approvals.map((a) => a.toUpperCase()).includes(address.toUpperCase()) + + const handleApprove = async () => { + try { + await approveActionHook.approveAction(action.hash) + toast({ title: "Action approved" }) + onAction() + } catch (_error) { + toast({ title: "Error approving action", variant: "destructive" }) + } + } + + const handleExecute = async () => { + try { + await executeAction(action.hash, action.action_data) + toast({ title: "Action executed" }) + onAction() + } catch (_error) { + toast({ title: "Error executing action", variant: "destructive" }) + } + } + + return ( + + + Pending Action: {action.type} + + +
+

Action Hash

+

{action.hash}

+
+
+

Requested by

+

{stellarAddress(action.requestedBy)}

+
+
+

Approvals

+ {action.approvals.length > 0 ? ( +
    + {action.approvals.map((approver) => ( +
  • + {stellarAddress(approver)} +
  • + ))} +
+ ) : ( +

No approvals yet.

+ )} +
+
+ {!isApprovedByCurrentUser && ( + + )} + +
+
+
+ ) +} \ 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 26b0168..c8eb2e8 100644 --- a/frontend/hooks/useJointSaveContracts.ts +++ b/frontend/hooks/useJointSaveContracts.ts @@ -1261,6 +1261,152 @@ export async function fetchContractVersion(contractId: string): Promise(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + 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) + } + }, [contractId]) + + 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[], threshold: number): 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), + u32Val(threshold) + ) + ) + .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, { + address, + type: "execute_action", + poolId: contractId, + }) + } finally { + setIsLoading(false) + } + } + + return { executeAction, isLoading } +} + export function useAddPoolMember(contractId: string) { const { kit, address } = useStellar() const [isLoading, setIsLoading] = useState(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 d28ae86..264ccbd 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}; const VERSION: u32 = 1; @@ -21,7 +22,19 @@ pub enum DataKey { YieldStrategy, DeployedToYield, TokenDecimals, - MigratedFrom, + AdminQuorum, + AdminQuorumThreshold, + PendingApprovals(Bytes), + PendingCreated(Bytes), +} + +#[contracttype] +#[derive(Clone)] +pub enum Action { + Pause, + Unpause, + EmergencyWithdraw(Address), + RemoveMember(Address), } const LEDGER_THRESHOLD: u32 = 518400; @@ -68,6 +81,8 @@ impl FlexiblePool { storage.set(&DataKey::Active, &true); 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); } @@ -204,20 +219,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, ); @@ -226,19 +250,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) { @@ -281,14 +305,79 @@ impl FlexiblePool { // ── Emergency controls ──────────────────────────────────────────────── + 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) { + 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)); + 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), + 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"),), ()); } @@ -298,9 +387,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"),), ()); } @@ -310,25 +407,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); @@ -478,6 +583,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); @@ -490,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() @@ -508,6 +610,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 { @@ -564,6 +670,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 3b2b540..d3f6909 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 ────────────────────────────────────────────────────────────── @@ -25,7 +27,19 @@ pub enum DataKey { HasDeposited(Address), ReputationTracker, TokenDecimals, - MigratedFrom, + AdminQuorum, + AdminQuorumThreshold, + PendingApprovals(Bytes), + PendingCreated(Bytes), +} + +#[contracttype] +#[derive(Clone)] +pub enum Action { + Pause, + Unpause, + EmergencyWithdraw(Address), + RemoveMember(Address), } // ── Contract ────────────────────────────────────────────────────────────────── @@ -79,6 +93,8 @@ 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); } @@ -257,6 +273,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); } @@ -326,14 +346,88 @@ impl RotationalPool { // ── Emergency controls ───────────────────────────────────────────────── + 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()); + } + 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)); + 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), + 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) { @@ -341,22 +435,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 { @@ -369,7 +474,7 @@ impl RotationalPool { env.events() .publish((symbol_short!("emrg_wd"),), contract_balance); - Self::bump_config_state_internal(&env); + Self::bump_config_state_internal(env); } /// Migrate this contract to a new version. Admin-only. @@ -435,6 +540,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); @@ -447,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) } @@ -510,6 +612,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 { @@ -521,6 +635,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 326c44c..2a1e61d 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}; const VERSION: u32 = 1; @@ -17,7 +18,19 @@ pub enum DataKey { Paused, Balance(Address), TokenDecimals, - MigratedFrom, + AdminQuorum, + AdminQuorumThreshold, + PendingApprovals(Bytes), + PendingCreated(Bytes), +} + +#[contracttype] +#[derive(Clone)] +pub enum Action { + Pause, + Unpause, + EmergencyWithdraw(Address), + RemoveMember(Address), } const LEDGER_THRESHOLD: u32 = 518400; @@ -60,6 +73,8 @@ impl TargetPool { storage.set(&DataKey::Active, &true); 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); } @@ -204,6 +219,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"); @@ -212,15 +236,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, ); @@ -229,17 +253,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) { @@ -285,12 +309,58 @@ impl TargetPool { // ── Emergency controls ───────────────────────────────────────────────── + 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) { + 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)); + 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), + 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); } @@ -300,7 +370,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); } @@ -310,18 +385,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, ); } @@ -329,7 +411,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); } /// Migrate this contract to a new version. Admin-only. @@ -380,6 +462,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 ────────────────────────────────────────────────────────────── @@ -388,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() @@ -454,6 +533,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 { @@ -465,6 +548,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 { 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;