diff --git a/src/components/pages/homepage/governance/drep/index.tsx b/src/components/pages/homepage/governance/drep/index.tsx index fae93900..def276e2 100644 --- a/src/components/pages/homepage/governance/drep/index.tsx +++ b/src/components/pages/homepage/governance/drep/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import SectionTitle from "@/components/ui/section-title"; import Pagination from "@/components/common/overall-layout/pagination"; import { getProvider } from "@/utils/get-provider"; @@ -10,6 +10,7 @@ import RowLabelInfo from "@/components/common/row-label-info"; import { TooltipProvider } from "@/components/ui/tooltip"; import ActiveIndicator from "./activeIndicator"; import ScriptIndicator from "./scriptIndicator"; +import { Button } from "@/components/ui/button"; export default function DrepOverviewPage() { const [drepList, setDrepList] = useState< @@ -22,6 +23,7 @@ export default function DrepOverviewPage() { const [isLastPage, setIsLastPage] = useState(false); // Mainnet for anonymous visitors, the wallet's network once connected. const network = usePublicNetwork(); + const [filter, setFilter] = useState<"all" | "active" | "inactive">("all"); useEffect(() => { async function loadDrepList() { @@ -92,11 +94,79 @@ export default function DrepOverviewPage() { } }; + const aggregate = useMemo(() => { + let active = 0; + let totalLovelace = 0; + for (const { details } of drepList) { + if (details?.active) active += 1; + const amt = details?.amount ? parseInt(details.amount, 10) : 0; + if (Number.isFinite(amt)) totalLovelace += amt; + } + return { + total: drepList.length, + active, + inactive: drepList.length - active, + totalAda: totalLovelace / 1_000_000, + }; + }, [drepList]); + + const visibleDreps = useMemo(() => { + if (filter === "all") return drepList; + if (filter === "active") return drepList.filter((d) => d.details?.active); + return drepList.filter((d) => !d.details?.active); + }, [drepList, filter]); + return (
DREP Overview + {/* Aggregate stats for current page */} +
+
+
On this page
+
+ {aggregate.total} +
+
+
+
Active
+
+ {aggregate.active} +
+
+
+
Inactive
+
+ {aggregate.inactive} +
+
+
+
ADA delegated
+
+ {aggregate.totalAda >= 1_000_000 + ? `${(aggregate.totalAda / 1_000_000).toFixed(2)}M ₳` + : aggregate.totalAda >= 1_000 + ? `${(aggregate.totalAda / 1_000).toFixed(1)}k ₳` + : `${aggregate.totalAda.toFixed(0)} ₳`} +
+
+
+ + {/* Filter controls */} +
+ {(["all", "active", "inactive"] as const).map((f) => ( + + ))} +
+ {/* Pagination Component */} Loading DREP information...

) : ( - drepList.map(({ details, metadata }) => { + visibleDreps.map(({ details, metadata }) => { const drepId = details.drep_id; const givenName = typeof metadata?.json_metadata?.body?.givenName === "object" @@ -172,7 +242,20 @@ export default function DrepOverviewPage() { {/* DRep ID directly under name */} - + +
+ {details?.active_epoch != null && ( + Active since epoch {details.active_epoch} + )} + {details?.hex && ( + hex: {details.hex.slice(0, 16)}… + )} +
{/* ADA Amount (Larger, Aligned Right) */} @@ -190,6 +273,11 @@ export default function DrepOverviewPage() { {!loading && drepList.length === 0 && (

No DREP information available.

)} + {!loading && drepList.length > 0 && visibleDreps.length === 0 && ( +

+ No DReps match the {filter} filter on this page. +

+ )}
diff --git a/src/components/pages/homepage/governance/index.tsx b/src/components/pages/homepage/governance/index.tsx index ea91afd0..43e63c9c 100644 --- a/src/components/pages/homepage/governance/index.tsx +++ b/src/components/pages/homepage/governance/index.tsx @@ -3,6 +3,7 @@ import SectionTitle from "@/components/ui/section-title"; import CardUI from "@/components/ui/card-content"; import Button from "@/components/common/button"; import Link from "next/link"; +import GovernanceNetworkStats from "./network-stats"; export default function PageGovernance() { const governanceFeatures = [ @@ -81,6 +82,8 @@ export default function PageGovernance() { wallet experience.

+ + {governanceFeatures.map((feature, index) => ( diff --git a/src/components/pages/homepage/governance/network-stats.tsx b/src/components/pages/homepage/governance/network-stats.tsx new file mode 100644 index 00000000..f6abee25 --- /dev/null +++ b/src/components/pages/homepage/governance/network-stats.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; +import CardUI from "@/components/ui/card-content"; +import { Users, FileText, Coins } from "lucide-react"; +import { getProvider } from "@/utils/get-provider"; +import { useWallet } from "@meshsdk/react"; +import type { BlockfrostDrepInfo } from "@/types/governance"; + +type Stats = { + drepCount: number | null; + activeDrepCount: number | null; + totalDelegatedAda: number | null; + activeProposals: number | null; +}; + +const INITIAL: Stats = { + drepCount: null, + activeDrepCount: null, + totalDelegatedAda: null, + activeProposals: null, +}; + +function formatNumber(n: number | null): string { + if (n == null) return "…"; + return n.toLocaleString(); +} + +function formatAda(n: number | null): string { + if (n == null) return "…"; + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B ₳`; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M ₳`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k ₳`; + return `${n.toFixed(0)} ₳`; +} + +export default function GovernanceNetworkStats() { + const { wallet, connected } = useWallet(); + const [network, setNetwork] = useState(1); + const [stats, setStats] = useState(INITIAL); + + useEffect(() => { + let cancelled = false; + const fetchNet = async () => { + if (connected && wallet) { + try { + const n = await wallet.getNetworkId(); + if (!cancelled) setNetwork(n); + } catch { + /* default to mainnet */ + } + } + }; + void fetchNet(); + return () => { + cancelled = true; + }; + }, [connected, wallet]); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const provider = getProvider(network); + const [drepsPage, proposalsPage] = await Promise.all([ + provider + .get(`/governance/dreps/?count=100&page=1&order=desc`) + .catch(() => [] as BlockfrostDrepInfo[]), + provider + .get(`/governance/proposals?count=100&page=1&order=desc`) + .catch(() => [] as Array<{ tx_hash: string; cert_index: number }>), + ]); + const dreps = Array.isArray(drepsPage) ? (drepsPage as BlockfrostDrepInfo[]) : []; + const totalLovelace = dreps.reduce((acc, d) => { + const amt = d?.amount ? parseInt(String(d.amount), 10) : 0; + return acc + (Number.isFinite(amt) ? amt : 0); + }, 0); + const activeCount = dreps.filter((d) => Boolean(d?.active)).length; + const proposals = Array.isArray(proposalsPage) ? proposalsPage : []; + + if (!cancelled) { + setStats({ + drepCount: dreps.length, + activeDrepCount: activeCount, + totalDelegatedAda: totalLovelace / 1_000_000, + activeProposals: proposals.length, + }); + } + } catch { + if (!cancelled) setStats(INITIAL); + } + }; + void load(); + return () => { + cancelled = true; + }; + }, [network]); + + return ( + +
+ } + label="DReps tracked" + value={formatNumber(stats.drepCount)} + hint={ + stats.activeDrepCount != null + ? `${stats.activeDrepCount} active` + : "…" + } + /> + } + label="ADA delegated" + value={formatAda(stats.totalDelegatedAda)} + hint="To these DReps" + /> + } + label="Recent proposals" + value={formatNumber(stats.activeProposals)} + hint="Latest 100" + /> + } + label="Network" + value={network === 0 ? "Preprod" : "Mainnet"} + hint="From your wallet, if connected" + /> +
+
+ ); +} + +function Tile({ + icon, + label, + value, + hint, +}: { + icon: React.ReactNode; + label: string; + value: string; + hint?: string; +}) { + return ( +
+
+ {icon} + {label} +
+
{value}
+ {hint &&
{hint}
} +
+ ); +} diff --git a/src/components/pages/homepage/wallets/import-transfer-dialog.tsx b/src/components/pages/homepage/wallets/import-transfer-dialog.tsx new file mode 100644 index 00000000..41c620f9 --- /dev/null +++ b/src/components/pages/homepage/wallets/import-transfer-dialog.tsx @@ -0,0 +1,195 @@ +import { useRef, useState } from "react"; +import { useRouter } from "next/router"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Loader, Upload, CheckCircle } from "lucide-react"; +import { toast } from "@/hooks/use-toast"; +import { + WALLET_TRANSFER_FORMAT, + WALLET_TRANSFER_VERSION, + type WalletTransferPayloadV1, +} from "@/types/walletTransfer"; + +export function ImportTransferDialog() { + const router = useRouter(); + const fileRef = useRef(null); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [fileName, setFileName] = useState(null); + const [payload, setPayload] = useState(null); + const [error, setError] = useState(null); + + const reset = () => { + setBusy(false); + setFileName(null); + setPayload(null); + setError(null); + if (fileRef.current) fileRef.current.value = ""; + }; + + const onFile = async (file: File | undefined) => { + if (!file) return; + setError(null); + setFileName(file.name); + try { + const text = await file.text(); + const parsed = JSON.parse(text) as WalletTransferPayloadV1; + if (parsed.format !== WALLET_TRANSFER_FORMAT) { + throw new Error(`Unexpected format: ${String(parsed.format)}`); + } + if (parsed.version !== WALLET_TRANSFER_VERSION) { + throw new Error(`Unsupported payload version: ${String(parsed.version)}`); + } + if (!parsed.wallet?.scriptCbor || !Array.isArray(parsed.wallet.signersAddresses)) { + throw new Error("Payload is missing wallet definition fields"); + } + setPayload(parsed); + } catch (e) { + setPayload(null); + setError(e instanceof Error ? e.message : "Invalid JSON file"); + } + }; + + const submit = async () => { + if (!payload) return; + setBusy(true); + try { + const res = await fetch("/api/v1/wallet/transfer/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error((body as { error?: string })?.error ?? `Import failed (${res.status})`); + } + const result = body as { newWalletId: string; inviteUrl: string }; + toast({ + title: "Wallet imported", + description: "Redirecting to the invite page so signers can claim.", + }); + setOpen(false); + reset(); + await router.push(`/wallets/invite/${result.newWalletId}`); + } catch (e) { + const msg = e instanceof Error ? e.message : "Import failed"; + setError(msg); + toast({ title: "Import failed", description: msg, variant: "destructive" }); + } finally { + setBusy(false); + } + }; + + return ( + <> + + { + if (!o) reset(); + setOpen(o); + }} + > + + + Import wallet transfer + + Upload a wallet transfer JSON exported from another Multisig + instance. + + + +
+
+ + onFile(e.target.files?.[0])} + /> + {fileName && ( +

{fileName}

+ )} +
+ + {error && ( + + {error} + + )} + + {payload && !error && ( + + + +
+
+ {payload.wallet.name} +
+
+ {payload.wallet.signersAddresses.length} signer + {payload.wallet.signersAddresses.length === 1 ? "" : "s"} + {" · "} + type: {payload.wallet.type} + {payload.contacts ? ` · ${payload.contacts.length} contacts` : ""} + {payload.ballots ? ` · ${payload.ballots.length} ballots` : ""} +
+ {payload.exportedFromOrigin && ( +
+ from {payload.exportedFromOrigin} +
+ )} +
+
+
+ )} +
+ + + + + +
+
+ + ); +} diff --git a/src/components/pages/homepage/wallets/index.tsx b/src/components/pages/homepage/wallets/index.tsx index 7a8a0a0f..f3e801ae 100644 --- a/src/components/pages/homepage/wallets/index.tsx +++ b/src/components/pages/homepage/wallets/index.tsx @@ -25,6 +25,7 @@ import SectionExplanation from "./SectionExplanation"; import WalletCardSkeleton from "./WalletCardSkeleton"; import WalletInviteCardSkeleton from "./WalletInviteCardSkeleton"; import IPFSImage from "@/components/common/ipfs-image"; +import { ImportTransferDialog } from "./import-transfer-dialog"; import BotManagementCard from "@/components/pages/user/BotManagementCard"; @@ -124,6 +125,7 @@ export default function PageWallets() { + diff --git a/src/components/pages/user/BotManagementCard.tsx b/src/components/pages/user/BotManagementCard.tsx index 8e313a92..c8aa3949 100644 --- a/src/components/pages/user/BotManagementCard.tsx +++ b/src/components/pages/user/BotManagementCard.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Bot, Trash2, Loader2, Pencil, Link } from "lucide-react"; +import { Bot, Trash2, Loader2, Pencil, Link, Copy, Check } from "lucide-react"; import CardUI from "@/components/ui/card-content"; import RowLabelInfo from "@/components/ui/row-label-info"; import { Button } from "@/components/ui/button"; @@ -213,6 +213,39 @@ export default function BotManagementCard() { const missingReadScopeInEdit = editScopes.length > 0 && !editScopes.includes(READ_SCOPE); + const [agentPromptCopied, setAgentPromptCopied] = useState(false); + const copyAgentPrompt = async () => { + const origin = + typeof window !== "undefined" ? window.location.origin : "https://multisig.meshjs.dev"; + const prompt = `You are being onboarded as a bot on this Mesh Multisig instance: ${origin} + +Follow these steps and report back to me at each pause: + +1. Generate or load a Cardano payment address you control. Tell me which address you will use. +2. Fetch ${origin}/api/v1/botSetupGuide for the full protocol — that is the source of truth. +3. Call POST ${origin}/api/v1/botRegister with body { name, paymentAddress, requestedScopes }. Request the minimum scopes you need (start with ["multisig:read"]; add "multisig:sign", "multisig:create", "governance:read", or "ballot:write" only if you need them). +4. Report the returned pendingBotId and claimCode to me. I will approve in the UI within 10 minutes. +5. Once I confirm, call GET ${origin}/api/v1/botPickupSecret?pendingBotId= to retrieve your botKeyId and one-time secret. Store them securely; never log the secret. +6. Call POST ${origin}/api/v1/botAuth with { botKeyId, secret, paymentAddress } to obtain a 1-hour JWT. +7. Confirm by calling GET ${origin}/api/v1/botMe with Authorization: Bearer . Print the response. +8. Wait for me to grant your bot access to specific wallets in the UI before attempting any wallet operations. + +Do not skip steps. Do not ask for the secret over an insecure channel. Re-authenticate when the JWT expires.`; + + try { + await navigator.clipboard.writeText(prompt); + setAgentPromptCopied(true); + setTimeout(() => setAgentPromptCopied(false), 2000); + toast({ title: "Prompt copied", description: "Paste it into your AI agent." }); + } catch { + toast({ + title: "Copy failed", + description: "Your browser blocked clipboard access.", + variant: "destructive", + }); + } + }; + return (
+
+
+
+
Connect an AI agent
+

+ Copy a self-contained prompt that walks any AI agent (Claude, + Cursor, etc.) through registering itself as a bot on this + instance. +

+
+ +
+
Bots
-
+
Yes: {voteSummary.yes} @@ -836,6 +844,15 @@ export default function BallotCard({
+
+ + + Rationale uploaded: {voteSummary.withRationale}/{voteSummary.total} + + {voteSummary.drafts > 0 && ( + · {voteSummary.drafts} draft{voteSummary.drafts === 1 ? "" : "s"} pending upload + )} +
{/* Proxy Warning */} @@ -999,6 +1016,117 @@ export default function BallotCard({ + + {/* Confirmation dialog for moving a proposal between ballots */} + { + if (!open) setMoveConfirm(null); + }} + > + + + Move proposal to this ballot? + + {moveConfirm && ( + + “{moveConfirm.proposalTitle}” is already on{" "} + {moveConfirm.sourceBallots.length === 1 + ? `the ballot "${moveConfirm.sourceBallots[0]?.description}"` + : `${moveConfirm.sourceBallots.length} other ballots`} + . Moving it to “{moveConfirm.targetBallotName}” will + remove it from the source ballot(s) — including any choice and + rationale you set there. + + )} + + +
+ + + +
+
+
); } @@ -1023,75 +1151,14 @@ function ProposalRationaleEditor({ }) { const state = rationaleState || { json: "", url: "", hash: "", loading: false, comment: "" }; - // Construct JSON-LD from comment following CIP-100 structure - const constructJsonLdFromComment = useCallback((comment: string) => { - const jsonLd = { - "@context": { - "CIP100": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0100/README.md#", - "hashAlgorithm": "CIP100:hashAlgorithm", - "body": { - "@id": "CIP100:body", - "@context": { - "references": { - "@id": "CIP100:references", - "@container": "@set", - "@context": { - "GovernanceMetadata": "CIP100:GovernanceMetadataReference", - "Other": "CIP100:OtherReference", - "label": "CIP100:reference-label", - "uri": "CIP100:reference-uri", - "referenceHash": { - "@id": "CIP100:referenceHash", - "@context": { - "hashDigest": "CIP100:hashDigest", - "hashAlgorithm": "CIP100:hashAlgorithm" - } - } - } - }, - "comment": "CIP100:comment", - "externalUpdates": { - "@id": "CIP100:externalUpdates", - "@context": { - "title": "CIP100:update-title", - "uri": "CIP100:uri" - } - } - } - }, - "authors": { - "@id": "CIP100:authors", - "@container": "@set", - "@context": { - "name": "http://xmlns.com/foaf/0.1/name", - "witness": { - "@id": "CIP100:witness", - "@context": { - "witnessAlgorithm": "CIP100:witnessAlgorithm", - "publicKey": "CIP100:publicKey", - "signature": "CIP100:signature" - } - } - } - } - }, - "authors": [], - "body": { - "comment": comment.trim() - }, - "hashAlgorithm": "blake2b-256" - }; - return JSON.stringify(jsonLd, null, 2); - }, []); - const handleCommentChange = useCallback((comment: string) => { if (comment.trim()) { - const jsonLd = constructJsonLdFromComment(comment); + const jsonLd = JSON.stringify(buildRationaleJsonLd(comment), null, 2); onStateChange({ comment, json: jsonLd }); } else { onStateChange({ comment, json: "" }); } - }, [constructJsonLdFromComment, onStateChange]); + }, [onStateChange]); return (
@@ -1219,7 +1286,7 @@ function BallotOverviewTable({ } = useProposalRemoval(ballotId, refetchBallots, onBallotChanged); const computeHashFromJson = useCallback((jsonData: unknown) => { - return hashDrepAnchor(jsonData as Record); + return computeAnchorHash(jsonData); }, []); // Seed rationale states from ballot data and auto-load existing anchors. This @@ -1377,38 +1444,22 @@ function BallotOverviewTable({ } setRationaleStates(prev => ({ ...prev, [idx]: { ...prev[idx]!, loading: true } })); try { - const parsed = JSON.parse(state.json); - const response = await fetch("/api/pinata-storage/put", { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", + const parsed = JSON.parse(state.json) as Record; + const anchor = await uploadRationaleToPinata(parsed); + setRationaleStates(prev => ({ + ...prev, + [idx]: { + ...prev[idx]!, + url: anchor.url, + hash: anchor.hash, + loading: false, }, - body: JSON.stringify({ - pathname: `rationale/rationale-${Date.now()}.jsonld`, - value: JSON.stringify(parsed, null, 2), - }), - }); - if (!response.ok) { - const err = await response.json(); - throw new Error(err?.error || "Upload failed"); - } - const res = await response.json(); - const hash = computeHashFromJson(parsed); - setRationaleStates(prev => ({ - ...prev, - [idx]: { - ...prev[idx]!, - url: res.url, - hash, - loading: false - } })); await updateAnchorMutation.mutateAsync({ ballotId, index: idx, - anchorUrl: res.url, - anchorHash: hash, + anchorUrl: anchor.url, + anchorHash: anchor.hash, }); // Cache the rationale comment in the DB alongside the anchor so the // pending-transaction review can render it without an IPFS round-trip. diff --git a/src/components/pages/wallet/governance/index.tsx b/src/components/pages/wallet/governance/index.tsx index 4d32323f..3906ae9c 100644 --- a/src/components/pages/wallet/governance/index.tsx +++ b/src/components/pages/wallet/governance/index.tsx @@ -1,4 +1,5 @@ import CardInfo from "./card-info"; +import GovernanceOverviewSummary from "./overview-summary"; import { useSiteStore } from "@/lib/zustand/site"; import AllProposals from "./proposals"; import useAppWallet from "@/hooks/useAppWallet"; @@ -40,6 +41,9 @@ function PageGovernanceContent() { return ( <>
+ {/* Dashboard summary at the top */} + + {/* Info section */} diff --git a/src/components/pages/wallet/governance/overview-summary.tsx b/src/components/pages/wallet/governance/overview-summary.tsx new file mode 100644 index 00000000..5d37c938 --- /dev/null +++ b/src/components/pages/wallet/governance/overview-summary.tsx @@ -0,0 +1,215 @@ +import { useEffect, useMemo, useState } from "react"; +import CardUI from "@/components/ui/card-content"; +import { Badge } from "@/components/ui/badge"; +import { + CheckCircle2, + Clock, + Vote as VoteIcon, + Trophy, + XCircle, +} from "lucide-react"; +import type { Wallet } from "@/types/wallet"; +import { useBallot } from "@/hooks/useBallot"; +import { useWalletsStore } from "@/lib/zustand/wallets"; +import { useSiteStore } from "@/lib/zustand/site"; +import { getProvider } from "@/utils/get-provider"; +import { + getProposalStatus, + type ProposalStatus, +} from "@/lib/governance"; +import type { ProposalDetails } from "@/types/governance"; + +type StatusCounts = Record; + +const EMPTY_COUNTS: StatusCounts = { + active: 0, + enacted: 0, + ratified: 0, + dropped: 0, + expired: 0, +}; + +function lovelaceToAda(value: string | number | null | undefined): number | null { + if (value == null) return null; + const n = typeof value === "string" ? Number(value) : value; + if (!Number.isFinite(n)) return null; + return n / 1_000_000; +} + +function formatAda(value: number | null): string { + if (value == null) return "—"; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M ADA`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k ADA`; + return `${value.toFixed(2)} ADA`; +} + +export default function GovernanceOverviewSummary({ appWallet }: { appWallet: Wallet }) { + const network = useSiteStore((s) => s.network); + const drepInfo = useWalletsStore((s) => s.drepInfo); + const { ballots } = useBallot(appWallet?.id); + + const [statusCounts, setStatusCounts] = useState(EMPTY_COUNTS); + const [statusLoading, setStatusLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setStatusLoading(true); + const fetchProposals = async () => { + try { + const provider = getProvider(network); + const proposals = (await provider.get( + `/governance/proposals?count=100&page=1&order=desc`, + )) as Array<{ tx_hash: string; cert_index: number | string }>; + if (!Array.isArray(proposals)) { + if (!cancelled) setStatusCounts(EMPTY_COUNTS); + return; + } + const counts: StatusCounts = { ...EMPTY_COUNTS }; + const details = await Promise.all( + proposals.slice(0, 60).map(async (p) => { + try { + return (await provider.get( + `/governance/proposals/${p.tx_hash}/${p.cert_index}`, + )) as ProposalDetails; + } catch { + return null; + } + }), + ); + for (const d of details) { + const status = getProposalStatus(d); + if (status) counts[status] += 1; + } + if (!cancelled) setStatusCounts(counts); + } catch (err) { + console.warn("[overview-summary] failed to fetch proposal statuses", err); + if (!cancelled) setStatusCounts(EMPTY_COUNTS); + } finally { + if (!cancelled) setStatusLoading(false); + } + }; + void fetchProposals(); + return () => { + cancelled = true; + }; + }, [network]); + + const ballotStats = useMemo(() => { + const total = ballots?.length ?? 0; + let totalProposals = 0; + let voted = 0; + let lastUpdated: Date | null = null; + for (const b of ballots ?? []) { + const items = Array.isArray(b.items) ? b.items : []; + const choices = Array.isArray(b.choices) ? b.choices : []; + totalProposals += items.length; + voted += choices.filter((c) => c && c.trim().length > 0).length; + const u = b.updatedAt ? new Date(b.updatedAt) : null; + if (u && (!lastUpdated || u > lastUpdated)) lastUpdated = u; + } + return { total, totalProposals, voted, lastUpdated }; + }, [ballots]); + + const drepStatus = drepInfo?.active ? "Active" : drepInfo ? "Inactive" : "—"; + const votingPowerAda = lovelaceToAda(drepInfo?.amount ?? null); + + const activeProposals = statusCounts.active; + const completedProposals = statusCounts.enacted + statusCounts.ratified; + const closedProposals = statusCounts.dropped + statusCounts.expired; + + return ( + +
+ } + label="Active proposals" + value={statusLoading ? "…" : String(activeProposals)} + hint={`${statusLoading ? "…" : completedProposals} ratified · ${ + statusLoading ? "…" : closedProposals + } closed`} + /> + } + label="Ballot progress" + value={`${ballotStats.voted}/${ballotStats.totalProposals}`} + hint={`${ballotStats.total} ballot${ballotStats.total === 1 ? "" : "s"}`} + /> + } + label="Voting power" + value={formatAda(votingPowerAda)} + hint={`DRep ${drepStatus}`} + /> + + ) : ( + + ) + } + label="Last ballot activity" + value={ + ballotStats.lastUpdated + ? ballotStats.lastUpdated.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }) + : "—" + } + hint={ + ballotStats.lastUpdated + ? ballotStats.lastUpdated.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }) + : "No ballots yet" + } + /> +
+ +
+ Proposal mix: + + {statusCounts.active} active + + + {statusCounts.enacted} enacted + + + {statusCounts.ratified} ratified + + + {statusCounts.dropped} dropped + + + {statusCounts.expired} expired + +
+
+ ); +} + +function Tile({ + icon, + label, + value, + hint, +}: { + icon: React.ReactNode; + label: string; + value: string; + hint?: string; +}) { + return ( +
+
+ {icon} + {label} +
+
{value}
+ {hint &&
{hint}
} +
+ ); +} diff --git a/src/components/pages/wallet/governance/proposal/index.tsx b/src/components/pages/wallet/governance/proposal/index.tsx index b32f3b6b..ec01a0c3 100644 --- a/src/components/pages/wallet/governance/proposal/index.tsx +++ b/src/components/pages/wallet/governance/proposal/index.tsx @@ -407,6 +407,122 @@ function WalletGovernanceProposalContent({ id }: { id: string }) { )} + {/* Your ballot entry - shows the user's rationale + anchor for this proposal */} + {(() => { + if (!ballots || !proposalMetadata) return null; + const proposalId = `${proposalMetadata.tx_hash}#${proposalMetadata.cert_index}`; + for (const b of ballots) { + const idx = Array.isArray(b.items) ? b.items.indexOf(proposalId) : -1; + if (idx === -1) continue; + const choice = b.choices?.[idx] ?? ""; + const rationale = b.rationaleComments?.[idx] ?? ""; + const anchorUrl = b.anchorUrls?.[idx] ?? ""; + const anchorHash = b.anchorHashes?.[idx] ?? ""; + if (!choice && !rationale && !anchorUrl && !anchorHash) continue; + return ( + +
+
+ Ballot: + {b.description || "Untitled ballot"} +
+ {choice && ( +
+ Choice: + {choice} +
+ )} + {rationale && ( +
+
Rationale:
+
+ {rationale} +
+
+ )} + {(anchorUrl || anchorHash) && ( +
+ {anchorUrl && ( +
+ Anchor URL: + + {anchorUrl} + +
+ )} + {anchorHash && ( +
+ Anchor hash: + {anchorHash} +
+ )} +
+ )} +
+
+ ); + } + return null; + })()} + + {/* Technical details - fetched fields not surfaced elsewhere */} + {proposalDetails && ( + +
+ {proposalDetails.governance_description?.tag && ( +
+
Action tag
+
+ {proposalDetails.governance_description.tag} +
+
+ )} + {proposalDetails.return_address && ( +
+
Return address (deposit refund)
+
{proposalDetails.return_address}
+
+ )} + {proposalMetadata?.url && ( +
+
Metadata anchor URL
+ + {proposalMetadata.url} + +
+ )} + {proposalMetadata?.hash && ( +
+
Metadata anchor hash
+
{proposalMetadata.hash}
+
+ )} +
+
Proposal ID
+
+ {proposalDetails.tx_hash}#{proposalDetails.cert_index} +
+
+ {proposalDetails.id && ( +
+
Governance action ID
+
{proposalDetails.id}
+
+ )} +
+
+ )} + {/* Withdrawals Card - Show for treasury withdrawal proposals */} {proposalWithdrawals && proposalWithdrawals.length > 0 && ( void; + /** + * Optional anchor (CIP-100 rationale URL + Blake2b-256 hash) attached + * to the on-chain vote. When provided, the vote tx carries this anchor. + */ + anchor?: { url: string; hash: string } | null; } export default function VoteButton({ @@ -64,6 +69,7 @@ export default function VoteButton({ proposalDetails, currentVote, onOpenBallotSidebar, + anchor = null, }: VoteButtonProps) { // Use the custom hook for ballots (still used for proxy / context where needed) const { ballots } = useBallot(appWallet?.id); @@ -301,6 +307,16 @@ export default function VoteButton({ ) .txInScript(scriptCbor); } + const voteOptions: { + voteKind: "Yes" | "No" | "Abstain"; + anchor?: { anchorUrl: string; anchorDataHash: string }; + } = { voteKind }; + if (anchor?.url && anchor?.hash) { + voteOptions.anchor = { + anchorUrl: anchor.url, + anchorDataHash: anchor.hash, + }; + } txBuilder .vote( { @@ -311,22 +327,21 @@ export default function VoteButton({ txHash: txHash, txIndex: certIndex, }, - { - voteKind: voteKind, - }, + voteOptions, ) .voteScript(drepCbor) .changeAddress(changeAddress); + const withRationale = voteOptions.anchor ? " with rationale" : ""; await newTransaction({ txBuilder, - description: `Vote: ${voteKind} - ${description}`, + description: `Vote: ${voteKind}${withRationale} - ${description}`, metadataValue: metadata ? { label: "674", value: metadata } : undefined, }); toast({ title: "Transaction Successful", - description: `Your vote (${voteKind}) has been recorded.`, + description: `Your vote (${voteKind}${withRationale}) has been recorded.`, duration: 5000, }); @@ -451,9 +466,19 @@ export default function VoteButton({ {loading ? "Voting..." : utxos.length > 0 - ? `Vote ${voteKind}${hasValidProxy ? " (Proxy)" : ""}` + ? `Vote ${voteKind}${hasValidProxy ? " (Proxy)" : ""}${anchor?.hash ? " + rationale" : ""}` : "No UTxOs Available"} + {anchor?.hash && !hasValidProxy && ( +

+ Rationale will be attached on-chain. +

+ )} + {anchor?.hash && hasValidProxy && ( +

+ Note: proxy voting does not yet carry rationale on-chain. +

+ )} )} diff --git a/src/components/pages/wallet/governance/rationale/RationaleEditor.tsx b/src/components/pages/wallet/governance/rationale/RationaleEditor.tsx new file mode 100644 index 00000000..8aca921e --- /dev/null +++ b/src/components/pages/wallet/governance/rationale/RationaleEditor.tsx @@ -0,0 +1,274 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, Trash2 } from "lucide-react"; +import { + buildRationaleJsonLd, + computeAnchorHash, + loadRationaleFromUrl, + uploadRationaleToPinata, + type RationaleAnchor, +} from "@/lib/governance/rationale"; +import { useToast } from "@/hooks/use-toast"; + +export type RationaleEditorValue = { + comment: string; + anchor: RationaleAnchor | null; +}; + +type Props = { + /** Initial state when the editor mounts. */ + initial?: Partial & { url?: string }; + /** Called whenever upload, load, or clear changes the persisted anchor. */ + onChange?: (value: RationaleEditorValue) => void; + /** Compact layout for use inside tables/cards. */ + compact?: boolean; + /** Hide the "Load from URL" affordance — useful when the URL is managed externally. */ + hideLoad?: boolean; + /** Show a "Clear" button that wipes the anchor. */ + allowClear?: boolean; +}; + +export function RationaleEditor({ + initial, + onChange, + compact = false, + hideLoad = false, + allowClear = false, +}: Props) { + const { toast } = useToast(); + const [comment, setComment] = useState(initial?.comment ?? ""); + const [url, setUrl] = useState(initial?.anchor?.url ?? initial?.url ?? ""); + const [hash, setHash] = useState(initial?.anchor?.hash ?? ""); + const [json, setJson] = useState(() => + initial?.comment + ? JSON.stringify(buildRationaleJsonLd(initial.comment), null, 2) + : "", + ); + const [busy, setBusy] = useState(false); + + // If the initial URL is provided and there's no hash yet, auto-load. + useEffect(() => { + if (!url || hash) return; + let cancelled = false; + setBusy(true); + loadRationaleFromUrl(url) + .then((res) => { + if (cancelled) return; + setHash(res.hash); + setJson(JSON.stringify(res.json, null, 2)); + if (res.comment && !comment) setComment(res.comment); + onChange?.({ comment: res.comment, anchor: { url, hash: res.hash } }); + }) + .catch(() => { + // Silent — the user can retry from the UI. + }) + .finally(() => { + if (!cancelled) setBusy(false); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const liveJson = useMemo(() => { + if (!comment.trim()) return ""; + return JSON.stringify(buildRationaleJsonLd(comment), null, 2); + }, [comment]); + + const dirty = useMemo(() => { + if (!hash) return Boolean(comment.trim()); + if (!comment.trim()) return false; + try { + const parsed = JSON.parse(json || "{}") as { body?: { comment?: string } }; + return parsed.body?.comment !== comment; + } catch { + return true; + } + }, [comment, json, hash]); + + const handleCommentChange = (next: string) => { + setComment(next); + setJson(next.trim() ? JSON.stringify(buildRationaleJsonLd(next), null, 2) : ""); + }; + + const upload = useCallback(async () => { + if (!comment.trim()) { + toast({ + title: "Add a comment", + description: "Enter a rationale before uploading.", + variant: "destructive", + }); + return; + } + setBusy(true); + try { + const jsonLd = buildRationaleJsonLd(comment); + const anchor = await uploadRationaleToPinata(jsonLd); + setUrl(anchor.url); + setHash(anchor.hash); + setJson(JSON.stringify(jsonLd, null, 2)); + onChange?.({ comment, anchor }); + toast({ + title: "Rationale uploaded", + description: "Anchor URL and hash are ready to attach to your vote.", + }); + } catch (e) { + toast({ + title: "Upload failed", + description: e instanceof Error ? e.message : "Could not upload rationale.", + variant: "destructive", + }); + } finally { + setBusy(false); + } + }, [comment, onChange, toast]); + + const load = useCallback(async () => { + const target = url.trim(); + if (!target) { + toast({ + title: "Missing URL", + description: "Enter a rationale URL to load.", + variant: "destructive", + }); + return; + } + setBusy(true); + try { + const res = await loadRationaleFromUrl(target); + setHash(res.hash); + setJson(JSON.stringify(res.json, null, 2)); + if (res.comment) setComment(res.comment); + onChange?.({ + comment: res.comment || comment, + anchor: { url: target, hash: res.hash }, + }); + toast({ + title: "Rationale loaded", + description: "Anchor hash computed from the linked document.", + }); + } catch (e) { + toast({ + title: "Load failed", + description: e instanceof Error ? e.message : "Could not load rationale.", + variant: "destructive", + }); + } finally { + setBusy(false); + } + }, [url, comment, onChange, toast]); + + const clear = () => { + setComment(""); + setUrl(""); + setHash(""); + setJson(""); + onChange?.({ comment: "", anchor: null }); + }; + + const padding = compact ? "p-3" : "p-4"; + + return ( +
+
+
+ Voting rationale + {hash ? ( + + Anchor ready · {hash.slice(0, 10)}… + + ) : comment.trim() ? ( + + Draft (not uploaded) + + ) : null} + {hash && dirty && ( + + Edited — re-upload to refresh + + )} +
+ {allowClear && (hash || comment) && ( + + )} +
+ +
+ +