Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ next-env.d.ts
/blob-report/
/playwright/.cache/
.last-run.json
/coverage
/test_snapshots/
/coverage
3 changes: 2 additions & 1 deletion frontend/app/api/admin/actions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
36 changes: 16 additions & 20 deletions frontend/app/dashboard/group/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -125,26 +127,20 @@ export default function GroupPage({
</div>

<div className="space-y-6">
<SectionErrorBoundary sectionName="Group Actions" walletAddress={address}>
<GroupActions
groupId={id}
poolAddress={pool.contract_address}
poolType={pool.type}
tokenAddress={pool.token_address}
creatorAddress={pool.creator_address}
isPaused={isPaused}
poolAdmin={poolAdmin}
onPauseChange={refreshPoolState}
/>
</SectionErrorBoundary>
{pool.type === "flexible" && (
<SectionErrorBoundary sectionName="Yield Dashboard" walletAddress={address}>
<YieldDashboard poolAddress={pool.contract_address} />
</SectionErrorBoundary>
)}
<SectionErrorBoundary sectionName="Members List" walletAddress={address}>
<GroupMembers groupId={id} contractAddress={cacheKey} poolType={pool.type} />
</SectionErrorBoundary>
<GroupActions
groupId={id}
poolAddress={pool.contract_address}
poolType={pool.type}
tokenAddress={pool.token_address}
creatorAddress={pool.creator_address}
isPaused={isPaused}
poolAdmin={poolAdmin}
onPauseChange={refreshPoolState}
/>
<PendingActionsList poolId={id} poolAddress={pool.contract_address} />
<AdminQuorumManager poolAddress={pool.contract_address} poolAdmin={poolAdmin} />
{pool.type === "flexible" && <YieldDashboard poolAddress={pool.contract_address} />}
<GroupMembers groupId={id} contractAddress={cacheKey} poolType={pool.type} />
</div>
</div>
</main>
Expand Down
124 changes: 124 additions & 0 deletions frontend/components/group/admin-quorum-manager.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card>
<CardHeader>
<CardTitle>Admin Quorum Management</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="font-semibold">Current Quorum</h3>
{isQuorumLoading ? (
<p className="text-muted-foreground">Loading quorum...</p>
) : quorum && quorum.length > 0 ? (
<ul className="list-disc list-inside">
{quorum.map((admin) => (
<li key={admin} className="flex items-center justify-between">
<span>{stellarAddress(admin)}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAdmin(admin)}
disabled={isSetQuorumLoading}
>
Remove
</Button>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground">No admin quorum configured.</p>
)}
</div>

<div>
<h3 className="font-semibold">Add New Admin</h3>
<div className="flex items-center space-x-2">
<Input
placeholder="Stellar Address"
value={newAdmin}
onChange={(e) => setNewAdmin(e.target.value)}
/>
<Button onClick={handleAddAdmin} disabled={isSetQuorumLoading}>
Add
</Button>
</div>
</div>
<div>
<h3 className="font-semibold">Quorum Threshold</h3>
<div className="flex items-center space-x-2">
<Input
type="number"
placeholder="e.g. 2"
value={threshold}
onChange={(e) => setThreshold(parseInt(e.target.value, 10))}
/>
</div>
</div>
</CardContent>
</Card>
)
}
24 changes: 23 additions & 1 deletion frontend/components/group/group-actions.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -19,6 +19,7 @@
} from "lucide-react"
import { useStellar } from "@/components/web3-provider"
import {
useGetAdminQuorum,
useRotationalDeposit,
useTriggerPayout,
useTargetContribute,
Expand Down Expand Up @@ -147,6 +148,7 @@
const [newMember, setNewMember] = useState("")
const [memberToRemove, setMemberToRemove] = useState<string | null>(null)
const [showLeaveDialog, setShowLeaveDialog] = useState(false)
const { data: adminQuorumData, isLoading: isAdminQuorumLoading } = useGetAdminQuorum(poolAddress)

Check failure on line 151 in frontend/components/group/group-actions.tsx

View workflow job for this annotation

GitHub Actions / Run React Component Tests

__tests__/group-actions.test.tsx > GroupActions Component > renders contribute and withdraw buttons for target pools

TypeError: (0 , useGetAdminQuorum) is not a function ❯ GroupActions components/group/group-actions.tsx:151:70 ❯ Object.react_stack_bottom_frame node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17469:41 ❯ renderRootSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17450:11 ❯ performWorkOnRoot node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:16583:35

Check failure on line 151 in frontend/components/group/group-actions.tsx

View workflow job for this annotation

GitHub Actions / Run React Component Tests

__tests__/group-actions.test.tsx > GroupActions Component > renders rotational pool trigger payout button for rotational pools

TypeError: (0 , useGetAdminQuorum) is not a function ❯ GroupActions components/group/group-actions.tsx:151:70 ❯ Object.react_stack_bottom_frame node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17469:41 ❯ renderRootSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17450:11 ❯ performWorkOnRoot node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:16583:35

Check failure on line 151 in frontend/components/group/group-actions.tsx

View workflow job for this annotation

GitHub Actions / Run React Component Tests

__tests__/group-actions.test.tsx > GroupActions Component > disables deposit button when contract is pending

TypeError: (0 , useGetAdminQuorum) is not a function ❯ GroupActions components/group/group-actions.tsx:151:70 ❯ Object.react_stack_bottom_frame node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17469:41 ❯ renderRootSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17450:11 ❯ performWorkOnRoot node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:16583:35

Check failure on line 151 in frontend/components/group/group-actions.tsx

View workflow job for this annotation

GitHub Actions / Run React Component Tests

__tests__/group-actions.test.tsx > GroupActions Component > shows contract pending notification when contract address is pending_deployment

TypeError: (0 , useGetAdminQuorum) is not a function ❯ GroupActions components/group/group-actions.tsx:151:70 ❯ Object.react_stack_bottom_frame node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17469:41 ❯ renderRootSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17450:11 ❯ performWorkOnRoot node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:16583:35

Check failure on line 151 in frontend/components/group/group-actions.tsx

View workflow job for this annotation

GitHub Actions / Run React Component Tests

__tests__/group-actions.test.tsx > GroupActions Component > renders quick actions title and stellar address info

TypeError: (0 , useGetAdminQuorum) is not a function ❯ GroupActions components/group/group-actions.tsx:151:70 ❯ Object.react_stack_bottom_frame node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:25904:20 ❯ renderWithHooks node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:7662:22 ❯ updateFunctionComponent node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:10166:19 ❯ beginWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:11778:18 ❯ runWithFiberInDEV node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:874:13 ❯ performUnitOfWork node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17641:22 ❯ workLoopSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17469:41 ❯ renderRootSync node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:17450:11 ❯ performWorkOnRoot node_modules/.pnpm/react-dom@19.2.5_react@19.2.5/node_modules/react-dom/cjs/react-dom-client.development.js:16583:35
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"
Expand Down Expand Up @@ -182,6 +184,8 @@
void refreshMembers()
}, [refreshMembers])



const rotationalDeposit = useRotationalDeposit(poolAddress)
const triggerPayout = useTriggerPayout(poolAddress)
const targetContribute = useTargetContribute(poolAddress, depositAmount, tokenDecimals)
Expand Down Expand Up @@ -404,6 +408,12 @@
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) {
Expand All @@ -419,6 +429,12 @@
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) {
Expand Down Expand Up @@ -465,6 +481,12 @@
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)
Expand Down
87 changes: 87 additions & 0 deletions frontend/components/group/pending-action-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card>
<CardHeader>
<CardTitle className="text-lg">Pending Action: {action.type}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="font-semibold">Action Hash</h3>
<p className="text-sm text-muted-foreground break-all">{action.hash}</p>
</div>
<div>
<h3 className="font-semibold">Requested by</h3>
<p className="text-sm text-muted-foreground break-all">{stellarAddress(action.requestedBy)}</p>
</div>
<div>
<h3 className="font-semibold">Approvals</h3>
{action.approvals.length > 0 ? (
<ul className="list-disc list-inside">
{action.approvals.map((approver) => (
<li key={approver} className="text-sm">
{stellarAddress(approver)}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">No approvals yet.</p>
)}
</div>
<div className="flex space-x-2">
{!isApprovedByCurrentUser && (
<Button onClick={handleApprove} disabled={approveActionHook.isLoading || !address}>
{approveActionHook.isLoading ? "Approving..." : "Approve"}
</Button>
)}
<Button onClick={handleExecute} disabled={isExecuteLoading || !address}>
{isExecuteLoading ? "Executing..." : "Execute"}
</Button>
</div>
</CardContent>
</Card>
)
}
Loading
Loading