diff --git a/apps/backend/drizzle/0008_treasury_multisig_voting.sql b/apps/backend/drizzle/0008_treasury_multisig_voting.sql new file mode 100644 index 0000000..27869a1 --- /dev/null +++ b/apps/backend/drizzle/0008_treasury_multisig_voting.sql @@ -0,0 +1,32 @@ +CREATE TYPE "public"."treasury_proposal_status" AS ENUM('active', 'approved', 'rejected', 'executed', 'expired');--> statement-breakpoint +CREATE TYPE "public"."proposal_vote_type" AS ENUM('approve', 'reject');--> statement-breakpoint +CREATE TABLE "treasury_proposals" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "contract_id" text NOT NULL, + "proposal_id" text NOT NULL, + "conversation_id" uuid, + "status" "treasury_proposal_status" DEFAULT 'active' NOT NULL, + "approvals_count" integer DEFAULT 0 NOT NULL, + "rejections_count" integer DEFAULT 0 NOT NULL, + "recipient" text, + "amount" text, + "token" text, + "threshold" integer DEFAULT 3 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "proposal_votes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "treasury_proposal_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "vote" "proposal_vote_type" NOT NULL, + "signature" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "treasury_proposals" ADD CONSTRAINT "treasury_proposals_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "proposal_votes" ADD CONSTRAINT "proposal_votes_treasury_proposal_id_treasury_proposals_id_fk" FOREIGN KEY ("treasury_proposal_id") REFERENCES "public"."treasury_proposals"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "proposal_votes" ADD CONSTRAINT "proposal_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "treasury_proposals_contract_proposal_idx" ON "treasury_proposals" USING btree ("contract_id","proposal_id");--> statement-breakpoint +CREATE UNIQUE INDEX "proposal_votes_proposal_user_unique" ON "proposal_votes" USING btree ("treasury_proposal_id","user_id"); diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index a58ae36..80ffc6a 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1782345600000, "tag": "0007_user_devices", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1751001600000, + "tag": "0008_treasury_multisig_voting", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 9e09e99..4e51ee7 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -207,6 +207,8 @@ export const treasuryProposalStatusEnum = pgEnum('treasury_proposal_status', [ 'expired', ]); +export const proposalVoteTypeEnum = pgEnum('proposal_vote_type', ['approve', 'reject']); + export const treasuryProposals = pgTable( 'treasury_proposals', { @@ -219,6 +221,10 @@ export const treasuryProposals = pgTable( status: treasuryProposalStatusEnum('status').notNull().default('active'), approvalsCount: integer('approvals_count').notNull().default(0), rejectionsCount: integer('rejections_count').notNull().default(0), + recipient: text('recipient'), + amount: text('amount'), + token: text('token'), + threshold: integer('threshold').notNull().default(3), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, @@ -230,6 +236,28 @@ export const treasuryProposals = pgTable( export type TreasuryProposal = typeof treasuryProposals.$inferSelect; export type NewTreasuryProposal = typeof treasuryProposals.$inferInsert; +export const proposalVotes = pgTable( + 'proposal_votes', + { + id: uuid('id').primaryKey().defaultRandom(), + treasuryProposalId: uuid('treasury_proposal_id') + .notNull() + .references(() => treasuryProposals.id, { onDelete: 'cascade' }), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + vote: proposalVoteTypeEnum('vote').notNull(), + signature: text('signature'), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('proposal_votes_proposal_user_unique').on(table.treasuryProposalId, table.userId), + ], +); + +export type ProposalVote = typeof proposalVotes.$inferSelect; +export type NewProposalVote = typeof proposalVotes.$inferInsert; + // ─── Relations ──────────────────────────────────────────────────────────────── export const usersRelations = relations(users, ({ many }) => ({ @@ -238,6 +266,7 @@ export const usersRelations = relations(users, ({ many }) => ({ messages: many(messages), transfers: many(tokenTransfers), devices: many(devices), + proposalVotes: many(proposalVotes), })); export const walletsRelations = relations(wallets, ({ one }) => ({ @@ -292,6 +321,22 @@ export const oneTimePreKeysRelations = relations(oneTimePreKeys, ({ one }) => ({ device: one(devices, { fields: [oneTimePreKeys.deviceId], references: [devices.id] }), })); +export const treasuryProposalsRelations = relations(treasuryProposals, ({ one, many }) => ({ + conversation: one(conversations, { + fields: [treasuryProposals.conversationId], + references: [conversations.id], + }), + votes: many(proposalVotes), +})); + +export const proposalVotesRelations = relations(proposalVotes, ({ one }) => ({ + proposal: one(treasuryProposals, { + fields: [proposalVotes.treasuryProposalId], + references: [treasuryProposals.id], + }), + user: one(users, { fields: [proposalVotes.userId], references: [users.id] }), +})); + // ─── Types ──────────────────────────────────────────────────────────────────── export type User = typeof users.$inferSelect; diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts index 660f768..06200d0 100644 --- a/apps/backend/src/routes/treasury.ts +++ b/apps/backend/src/routes/treasury.ts @@ -1,5 +1,8 @@ -import { Router } from 'express'; +import { Router, type Response } from 'express'; import { z } from 'zod'; +import { and, desc, eq, inArray } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { treasuryProposals, proposalVotes } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; @@ -18,24 +21,124 @@ const proposeSchema = z.object({ token: z.string().min(1), recipient: z.string().regex(/^G[A-Z2-7]{55}$/, 'Invalid Stellar public key'), ttl: z.enum(['24h', '72h', '7d']), + conversationId: z.string().uuid().optional(), + threshold: z.number().int().min(1).optional(), +}); + +const voteSchema = z.object({ + signature: z.string().optional(), }); /** * POST /treasury/propose - * Body: { amount, token, recipient, ttl } - * Stub: records intent and returns the ledger count for TTL. + * Body: { amount, token, recipient, ttl, conversationId?, threshold? } */ treasuryRouter.post('/propose', validate(proposeSchema), async (req, res) => { - const { amount, token, recipient, ttl } = req.body as z.infer; + const { amount, token, recipient, ttl, conversationId, threshold } = + req.body as z.infer; + + const [proposal] = await db + .insert(treasuryProposals) + .values({ + contractId: process.env.GROUP_TREASURY_CONTRACT_ID ?? 'stub', + proposalId: `prop-${Date.now()}`, + conversationId: conversationId ?? null, + status: 'active', + recipient, + amount: String(amount), + token, + threshold: threshold ?? 3, + }) + .returning(); + + res.status(201).json({ ...proposal, ttlLedgers: TTL_LEDGERS[ttl] }); +}); + +/** + * GET /treasury/proposals?conversationId= + * Returns proposals (optionally filtered by conversationId) with the + * authenticated user's vote status included in each row. + */ +treasuryRouter.get('/proposals', async (req, res) => { const auth = (req as AuthRequest).auth!; + const cid = typeof req.query.conversationId === 'string' ? req.query.conversationId : null; + + const rows = await db + .select() + .from(treasuryProposals) + .where(cid ? eq(treasuryProposals.conversationId, cid) : undefined) + .orderBy(desc(treasuryProposals.createdAt)); + + if (rows.length === 0) { + res.json([]); + return; + } + + const ids = rows.map((r) => r.id); + const votes = await db + .select({ treasuryProposalId: proposalVotes.treasuryProposalId, vote: proposalVotes.vote }) + .from(proposalVotes) + .where(and(eq(proposalVotes.userId, auth.userId), inArray(proposalVotes.treasuryProposalId, ids))); + + const votedMap = new Map(votes.map((v) => [v.treasuryProposalId, v.vote])); + + res.json( + rows.map((r) => ({ + ...r, + hasVoted: votedMap.has(r.id), + myVote: votedMap.get(r.id) ?? null, + })), + ); +}); + +async function handleVote(req: AuthRequest, res: Response, vote: 'approve' | 'reject'): Promise { + const auth = req.auth!; + const { id } = req.params as { id: string }; + const { signature } = req.body as z.infer; + + const [proposal] = await db + .select() + .from(treasuryProposals) + .where(eq(treasuryProposals.id, id)) + .limit(1); + + if (!proposal) { + res.status(404).json({ error: 'Proposal not found' }); + return; + } + + if (proposal.status !== 'active') { + res.status(409).json({ error: 'Proposal is no longer active' }); + return; + } + + try { + await db.insert(proposalVotes).values({ + treasuryProposalId: proposal.id, + userId: auth.userId, + vote, + signature: signature ?? null, + }); + } catch (err: unknown) { + if ((err as { code?: string })?.code === '23505') { + res.status(409).json({ error: 'Already voted on this proposal' }); + return; + } + throw err; + } + + res.json({ success: true }); +} + +/** + * POST /treasury/proposals/:id/approve + * POST /treasury/proposals/:id/reject + * Body: { signature?: string } + */ +treasuryRouter.post('/proposals/:id/approve', validate(voteSchema), async (req, res) => { + await handleVote(req as AuthRequest, res, 'approve'); +}); - // In production this would submit a multisig proposal transaction via Soroban SDK. - // For now, return the resolved ledger TTL so the frontend can display it. - res.status(201).json({ - proposer: auth.userId, - amount, - token, - recipient, - ttlLedgers: TTL_LEDGERS[ttl], - }); +treasuryRouter.post('/proposals/:id/reject', validate(voteSchema), async (req, res) => { + await handleVote(req as AuthRequest, res, 'reject'); }); diff --git a/apps/web/src/app/app/treasury/page.tsx b/apps/web/src/app/app/treasury/page.tsx index cd173ea..49d135b 100644 --- a/apps/web/src/app/app/treasury/page.tsx +++ b/apps/web/src/app/app/treasury/page.tsx @@ -1,10 +1,18 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { ProposeWithdrawalModal } from "@/components/treasury/ProposeWithdrawalModal"; +import { ProposalCard, type Proposal } from "@/components/treasury/ProposalCard"; +import { apiFetch } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; +import { useSocket } from "@/hooks/useSocket"; export default function TreasuryPage() { const [isModalOpen, setIsModalOpen] = useState(false); + const { token } = useAuth(); + const socket = useSocket(token); + const [proposals, setProposals] = useState([]); + const [loadingProposals, setLoadingProposals] = useState(true); const assets = [ { name: "Stellar Lumens", symbol: "XLM", balance: "420,500 XLM", value: "$42,050.00", percentage: "65%", color: "bg-accent" }, @@ -18,6 +26,57 @@ export default function TreasuryPage() { { id: "3", type: "Disbursement", desc: "Developer Grant - Phase 1", amount: "-10,000 USDC", date: "May 28, 2026", status: "Completed", statusColor: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20" }, ]; + const fetchProposals = useCallback(async () => { + if (!token) { + setLoadingProposals(false); + return; + } + try { + const res = await apiFetch("/treasury/proposals", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = (await res.json()) as Proposal[]; + setProposals(data); + } + } catch { + // network error — leave existing list in place + } finally { + setLoadingProposals(false); + } + }, [token]); + + useEffect(() => { + fetchProposals(); + }, [fetchProposals]); + + // Real-time proposal updates via Socket.IO + useEffect(() => { + if (!socket) return; + + function onProposalUpdated(data: { + proposalId: string; + status: Proposal["status"]; + approvalsCount: number; + rejectionsCount: number; + }) { + setProposals((prev) => + prev.map((p) => + p.proposalId === data.proposalId + ? { ...p, status: data.status, approvalsCount: data.approvalsCount, rejectionsCount: data.rejectionsCount } + : p, + ), + ); + } + + socket.on("treasury_proposal_updated", onProposalUpdated); + return () => { + socket.off("treasury_proposal_updated", onProposalUpdated); + }; + }, [socket]); + + const activeProposals = proposals.filter((p) => p.status === "active"); + return (
{/* Page Header */} @@ -40,7 +99,7 @@ export default function TreasuryPage() { setIsModalOpen(false)} - onSuccess={() => { /* list refresh hook can be added here */ }} + onSuccess={fetchProposals} /> {/* Summary Cards */} @@ -64,11 +123,39 @@ export default function TreasuryPage() {

Pending Transactions

-

0

-

All sign-offs completed

+

{loadingProposals ? "—" : activeProposals.length}

+

+ {activeProposals.length === 0 ? "All sign-offs completed" : `${activeProposals.length} awaiting signatures`} +

+ {/* Active Proposals */} +
+

Active Proposals

+ {loadingProposals ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : proposals.length === 0 ? ( +
+

No proposals yet. Use "Propose Withdrawal" to create one.

+
+ ) : ( +
+ {proposals.map((p) => ( + fetchProposals()} + /> + ))} +
+ )} +
+
{/* Assets List */}
diff --git a/apps/web/src/components/treasury/ProposalCard.tsx b/apps/web/src/components/treasury/ProposalCard.tsx new file mode 100644 index 0000000..bfb7214 --- /dev/null +++ b/apps/web/src/components/treasury/ProposalCard.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState } from "react"; +import { signWalletMessage } from "@/lib/freighter"; +import { apiFetch } from "@/lib/api"; +import { useToast } from "@/lib/useToast"; +import { useAuth } from "@/contexts/AuthContext"; + +type ProposalStatus = "active" | "approved" | "rejected" | "executed" | "expired"; + +export interface Proposal { + id: string; + proposalId: string; + status: ProposalStatus; + approvalsCount: number; + rejectionsCount: number; + recipient: string | null; + amount: string | null; + token: string | null; + threshold: number; + hasVoted: boolean; + myVote: "approve" | "reject" | null; +} + +interface Props { + proposal: Proposal; + onVoted?: (id: string, vote: "approve" | "reject") => void; +} + +const STATUS_STYLES: Record = { + active: "text-blue-400 bg-blue-500/10 border-blue-500/20", + approved: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20", + rejected: "text-rose-400 bg-rose-500/10 border-rose-500/20", + executed: "text-purple-400 bg-purple-500/10 border-purple-500/20", + expired: "text-slate-400 bg-slate-500/10 border-slate-500/20", +}; + +const STATUS_LABEL: Record = { + active: "Pending", + approved: "Approved", + rejected: "Rejected", + executed: "Executed", + expired: "Expired", +}; + +function truncateAddress(address: string | null): string { + if (!address) return "—"; + if (address.length <= 12) return address; + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +export function ProposalCard({ proposal, onVoted }: Props) { + const { token } = useAuth(); + const { success, error: toastError } = useToast(); + const [voting, setVoting] = useState<"approve" | "reject" | null>(null); + const [localVote, setLocalVote] = useState<"approve" | "reject" | null>(proposal.myVote); + + const isDisabled = proposal.hasVoted || localVote !== null || proposal.status !== "active"; + const progressPct = Math.min(100, (proposal.approvalsCount / proposal.threshold) * 100); + + async function castVote(type: "approve" | "reject") { + if (isDisabled || voting) return; + setVoting(type); + try { + let signature: string | undefined; + try { + signature = await signWalletMessage(`${type}:${proposal.proposalId}`); + } catch { + toastError("Freighter signing was cancelled or failed"); + return; + } + + const res = await apiFetch(`/treasury/proposals/${proposal.id}/${type}`, { + method: "POST", + body: JSON.stringify({ signature }), + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + toastError(body.error ?? `Failed to ${type} proposal`); + return; + } + + setLocalVote(type); + success(type === "approve" ? "Vote cast — approved" : "Vote cast — rejected"); + onVoted?.(proposal.id, type); + } finally { + setVoting(null); + } + } + + const hasVotedNow = isDisabled; + + return ( +
+ {/* Header row */} +
+
+

+ Proposal #{proposal.proposalId} +

+

+ {proposal.amount ?? "—"} {proposal.token ?? ""} +

+

+ → {truncateAddress(proposal.recipient)} +

+
+ + {STATUS_LABEL[proposal.status]} + +
+ + {/* Approval progress */} +
+
+ Approvals + + {proposal.approvalsCount} / {proposal.threshold} + +
+
+
+
+
+ + {/* Vote buttons */} +
+ + +
+
+ ); +}