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;