Skip to content
Merged
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
21 changes: 21 additions & 0 deletions frontend/app/api/pools/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,27 @@ export async function PATCH(req: NextRequest) {
description: `${activity_type} transaction`,
}])
if (actErr) console.error('Activity log error:', actErr)

// Synchronize database pool_members table with on-chain membership changes
if (activity_type === 'member_added' && body.memberAddress) {
const { error: addErr } = await supabase
.from('pool_members')
.insert([{
pool_id: poolId,
member_address: body.memberAddress.toLowerCase(),
contribution_amount: 0,
status: 'pending',
}])
if (addErr) console.error('Failed to add member in DB:', addErr)
} else if (activity_type === 'member_removed' && body.memberAddress) {
const { error: remErr } = await supabase
.from('pool_members')
.delete()
.eq('pool_id', poolId)
.eq('member_address', body.memberAddress.toLowerCase())
if (remErr) console.error('Failed to remove member in DB:', remErr)
}

return NextResponse.json({ success: true })
}

Expand Down
1 change: 1 addition & 0 deletions frontend/components/create-group/flexible-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export function FlexibleForm() {
const withdrawalFeeBps = Math.round(parseFloat(formData.withdrawalFee) * 100)
await initFlexible(contractId, {
token: TOKEN === "native" ? "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" : TOKEN,
admin: address,
members: validMembers,
minimumDeposit: formData.minimumDeposit,
withdrawalFeeBps,
Expand Down
1 change: 1 addition & 0 deletions frontend/components/create-group/rotational-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export function RotationalForm() {
setStep("initializing")
await initRotational(contractId, {
token: TOKEN === "native" ? "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" : TOKEN,
admin: address,
members: validMembers,
depositAmount: formData.contributionAmount,
roundDuration: FREQUENCY_SECONDS[formData.frequency],
Expand Down
172 changes: 170 additions & 2 deletions frontend/components/group/group-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
ShieldOff,
ShieldCheck,
Clock,
UserPlus,
Trash2,
} from "lucide-react";
import { useStellar } from "@/components/web3-provider";
import {
Expand All @@ -27,9 +29,13 @@ import {
useFlexibleWithdraw,
usePausePool,
useUnpausePool,
useAddPoolMember,
useRemovePoolMember,
stroopsToXlm,
fetchRotationalState,
fetchPoolMembers,
} from "@/hooks/useJointSaveContracts";
import { validateStellarAddress } from "@/lib/form-validation";
import {
Tooltip,
TooltipContent,
Expand Down Expand Up @@ -62,6 +68,7 @@ async function logActivity(
userAddress: string,
amount: string | null,
txHash: string,
memberAddress?: string,
) {
try {
await fetch("/api/pools", {
Expand All @@ -75,6 +82,7 @@ async function logActivity(
amount: amount ? parseFloat(amount) : null,
tx_hash: txHash,
},
memberAddress: memberAddress || null,
}),
});
} catch {}
Expand All @@ -100,6 +108,10 @@ export function GroupActions({

// Pool metadata from Supabase
const [poolData, setPoolData] = useState<any>(null);
const [members, setMembers] = useState<string[]>([]);
const [newMember, setNewMember] = useState("");
const [memberToRemove, setMemberToRemove] = useState<string | null>(null);
const isPending = !poolAddress || poolAddress === "pending_deployment";

// Modal Preview states
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
Expand All @@ -120,6 +132,16 @@ export function GroupActions({
.catch((err) => console.error("Failed to load pool details:", err));
}, [groupId]);

const refreshMembers = async () => {
if (isPending || !isAdmin || !poolAddress) return;
const onchainMembers = await fetchPoolMembers(poolAddress);
setMembers(onchainMembers);
};

useEffect(() => {
refreshMembers();
}, [isAdmin, isPending, poolAddress]);

const rotationalDeposit = useRotationalDeposit(poolAddress);
const triggerPayout = useTriggerPayout(poolAddress);
const targetContribute = useTargetContribute(poolAddress, depositAmount);
Expand All @@ -129,12 +151,12 @@ export function GroupActions({
const flexibleWithdraw = useFlexibleWithdraw(poolAddress, withdrawAmount);
const pausePool = usePausePool(poolAddress);
const unpausePool = useUnpausePool(poolAddress);
const addPoolMember = useAddPoolMember(poolAddress);
const removePoolMember = useRemovePoolMember(poolAddress);

const { optimisticState, registerOptimistic, updateTxHash, markFailed } =
useOptimisticTransactions(poolAddress);

const isPending = !poolAddress || poolAddress === "pending_deployment";

// Watch for confirmation/failure from optimistic state
useEffect(() => {
const { pendingTx } = optimisticState;
Expand Down Expand Up @@ -380,6 +402,50 @@ export function GroupActions({
}
};

const handleAddMember = async () => {
setError("");
setSuccessMsg("");
if (!address) return setError("Please connect your wallet first");
if (!isAdmin) return setError("Only the pool admin can manage members.");
if (isPending) return setError("Contract not yet deployed.");

const validation = validateStellarAddress(newMember.trim().toUpperCase());
if (!validation.valid) return setError(validation.message);

try {
const txHash = await addPoolMember.addMember(newMember.trim().toUpperCase());
if (txHash) {
await logActivity(groupId, "member_added", address, null, txHash, newMember.trim().toUpperCase());
setSuccessMsg("Member added successfully.");
setNewMember("");
await refreshMembers();
}
} catch (e: any) {
setError(e.message || "Transaction failed");
}
};

const handleRemoveMember = async () => {
setError("");
setSuccessMsg("");
if (!address) return setError("Please connect your wallet first");
if (!isAdmin) return setError("Only the pool admin can manage members.");
if (isPending) return setError("Contract not yet deployed.");
if (!memberToRemove) return;

try {
const txHash = await removePoolMember.removeMember(memberToRemove);
if (txHash) {
await logActivity(groupId, "member_removed", address, null, txHash, memberToRemove);
setSuccessMsg("Member removed successfully.");
setMemberToRemove(null);
await refreshMembers();
}
} catch (e: any) {
setError(e.message || "Transaction failed");
}
};

const isDepositLoading =
optimisticState.pendingTx?.type === "deposit" &&
optimisticState.pendingTx.status === "pending"
Expand All @@ -402,6 +468,8 @@ export function GroupActions({
const isTarget = poolType === "target";
const isFlexible = poolType === "flexible";
const actionsDisabled = isPaused || isPending || !address;
const formatAddress = (addr: string) =>
addr.length > 18 ? `${addr.slice(0, 8)}...${addr.slice(-8)}` : addr;

// Helper to render pending badge
const renderPendingBadge = () => {
Expand Down Expand Up @@ -712,6 +780,67 @@ export function GroupActions({
</div>
)}

{isAdmin && !isPending && (
<div className="border-t border-border pt-6 space-y-4">
<p className="text-xs text-muted-foreground font-medium">
Manage Members
</p>
<div className="space-y-2">
<Label htmlFor="new-member">Add Stellar Address</Label>
<div className="flex gap-2">
<Input
id="new-member"
value={newMember}
onChange={(event) => setNewMember(event.target.value)}
placeholder="G..."
disabled={addPoolMember.isLoading || removePoolMember.isLoading}
/>
<Button
type="button"
variant="outline"
onClick={handleAddMember}
disabled={
addPoolMember.isLoading ||
removePoolMember.isLoading ||
!newMember.trim()
}
aria-label="Add member"
>
{addPoolMember.isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<UserPlus className="h-4 w-4" />
)}
</Button>
</div>
</div>

<div className="space-y-2">
{members.map((member) => (
<div
key={member}
className="flex items-center justify-between gap-2 rounded-md border border-border p-2"
>
<span className="text-xs font-mono break-all">
{formatAddress(member)}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => setMemberToRemove(member)}
disabled={removePoolMember.isLoading || addPoolMember.isLoading}
aria-label={`Remove ${member}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}

<div className="border-t border-border pt-6">
<p className="text-xs text-muted-foreground mb-2">
Your Stellar address
Expand Down Expand Up @@ -810,6 +939,45 @@ export function GroupActions({
</DialogFooter>
</DialogContent>
</Dialog>

<Dialog
open={!!memberToRemove}
onOpenChange={(open) => {
if (!open) setMemberToRemove(null);
}}
>
<DialogContent className="sm:max-w-[425px] bg-background border border-border">
<DialogHeader>
<DialogTitle>Remove Member</DialogTitle>
<DialogDescription>
This will remove {memberToRemove || "this address"} from the pool. Their balance will be refunded.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setMemberToRemove(null)}
disabled={removePoolMember.isLoading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleRemoveMember}
disabled={removePoolMember.isLoading}
>
{removePoolMember.isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Removing...
</>
) : (
"Remove"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
10 changes: 4 additions & 6 deletions frontend/components/group/yield-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Loader2, TrendingUp, Zap, AlertTriangle, RefreshCw } from "lucide-react"
import { useStellar } from "@/components/web3-provider"
import { useStellar, STELLAR_RPC_URL, STELLAR_NETWORK_PASSPHRASE } from "@/components/web3-provider"
import {
Contract, TransactionBuilder, BASE_FEE, nativeToScVal, xdr,
Address,
rpc,
Address, rpc,
} from "@stellar/stellar-sdk"
import { STELLAR_RPC_URL } from "@/components/web3-provider"
import { STELLAR_NETWORK_PASSPHRASE } from "@/components/web3-provider"
import { getRpc } from "@/hooks/useJointSaveContracts"

const TX_TIMEOUT = 300

Expand Down Expand Up @@ -89,7 +87,7 @@ export function YieldDashboard({ poolAddress }: YieldDashboardProps) {
// scvOption wrapping an address
const inner = stratVal.switch().name === "scvAddress"
? stratVal
: stratVal.value() as xdr.ScVal
: stratVal.value() as unknown as xdr.ScVal
strategyAddress = Address.fromScVal(inner).toString()
}
} catch {}
Expand Down
Loading