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
9 changes: 7 additions & 2 deletions app/governance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import Footer from "@/components/Footer";
import Navbar from "@/components/Navbar";
import VoteDelegationPanel from "@/components/VoteDelegationPanel";
import VoteProgressBar from "@/components/VoteProgressBar";
import TokenAllowlistPanel from "@/components/governance/TokenAllowlistPanel";
import VotingPowerDisplay from "@/components/VotingPowerDisplay";
Expand All @@ -29,6 +30,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) {
Active: { color: "bg-emerald-500/15 text-emerald-500 border-emerald-500/30", icon: "fiber_manual_record" },
Passed: { color: "bg-primary/15 text-primary border-primary/30", icon: "check_circle" },
Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" },
Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "block" },
Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" },
Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" },
};
Expand Down Expand Up @@ -202,10 +204,13 @@ export default function GovernancePage() {
}, []);

useEffect(() => {
load();
const timeout = window.setTimeout(load, 0);
// Refresh every 30 s for real-time vote counts
const interval = setInterval(load, 30_000);
return () => clearInterval(interval);
return () => {
window.clearTimeout(timeout);
clearInterval(interval);
};
}, [load]);

useEffect(() => {
Expand Down
7 changes: 4 additions & 3 deletions src/components/TokenSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { formatTokenAmount } from "@/utils/format";
import FieldTooltip from "./FieldTooltip";

type TokenLike = ApprovedToken | (Partial<ApprovedToken> & Pick<ApprovedToken, "contractId" | "symbol" | "decimals">);
type TokenIconLike = Pick<TokenLike, "iconLabel" | "logo" | "symbol">;

interface TokenSelectorProps {
label: string;
Expand Down Expand Up @@ -43,11 +44,11 @@ function getTokenName(token: TokenLike): string {
return token.name ?? token.symbol;
}

function getTokenLogo(token: TokenLike): string {
function getTokenLogo(token: TokenIconLike): string {
return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`;
}

function getTokenIconLabel(token: TokenLike): string {
function getTokenIconLabel(token: TokenIconLike): string {
return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK");
}

Expand All @@ -73,7 +74,7 @@ export function TokenIcon({
token,
className = "",
}: {
token: Pick<TokenLike, "iconLabel" | "logo" | "symbol">;
token: TokenIconLike;
className?: string;
}) {
return (
Expand Down
257 changes: 257 additions & 0 deletions src/components/VoteDelegationPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
"use client";

import { FormEvent, useCallback, useEffect, useState } from "react";
import { useWallet } from "@/context/WalletContext";
import { useTransaction } from "@/hooks/useTransaction";
import {
delegateVotes,
DelegationStatus,
formatVotingPower,
getDelegationStatus,
isValidStellarAddress,
resolveDelegateAddress,
ResolvedDelegateAddress,
undelegateVotes,
wouldCreateDelegationCycle,
} from "@/utils/governance";

function shortenAddress(address: string): string {
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}

export default function VoteDelegationPanel() {
const { address, isConnected, connect, signTx } = useWallet();
const [status, setStatus] = useState<DelegationStatus | null>(null);
const [delegateInput, setDelegateInput] = useState("");
const [resolvedDelegate, setResolvedDelegate] = useState<ResolvedDelegateAddress | null>(null);
const [error, setError] = useState<string | null>(null);
const [cycleWarning, setCycleWarning] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isResolving, setIsResolving] = useState(false);

const delegateTransaction = useTransaction({
pendingTitle: "Delegating votes...",
successTitle: "Votes delegated",
errorTitle: "Delegation failed",
});
const undelegateTransaction = useTransaction({
pendingTitle: "Removing delegation...",
successTitle: "Delegation removed",
errorTitle: "Undelegation failed",
});

const loadStatus = useCallback(async () => {
if (!address) {
setStatus(null);
return;
}

setIsLoading(true);
try {
setStatus(await getDelegationStatus(address));
} finally {
setIsLoading(false);
}
}, [address]);

useEffect(() => {
const timeout = window.setTimeout(loadStatus, 0);
return () => window.clearTimeout(timeout);
}, [loadStatus]);

const resolveInput = useCallback(async () => {
const value = delegateInput.trim();
setResolvedDelegate(null);
setCycleWarning(false);

if (!value) {
setError(null);
return null;
}

setIsResolving(true);
try {
const resolved = await resolveDelegateAddress(value);
setResolvedDelegate(resolved);
setError(null);
if (address) {
setCycleWarning(await wouldCreateDelegationCycle(address, resolved.address));
}
return resolved;
} catch (resolveError) {
const message = resolveError instanceof Error ? resolveError.message : "Could not resolve delegate address.";
setError(message);
return null;
} finally {
setIsResolving(false);
}
}, [address, delegateInput]);

const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!address) return;

const resolved = resolvedDelegate ?? (await resolveInput());
if (!resolved) return;
if (resolved.address === address) {
setError("You cannot delegate to yourself.");
return;
}
if (cycleWarning) return;

try {
await delegateTransaction.runTransaction(() => delegateVotes(resolved.address, address, signTx));
setDelegateInput("");
setResolvedDelegate(null);
await loadStatus();
} catch (transactionError) {
setError(transactionError instanceof Error ? transactionError.message : "Delegation failed.");
}
};

const handleUndelegate = async () => {
if (!address) return;

try {
await undelegateTransaction.runTransaction(() => undelegateVotes(address, signTx));
await loadStatus();
} catch (transactionError) {
setError(transactionError instanceof Error ? transactionError.message : "Undelegation failed.");
}
};

const delegateAddress = resolvedDelegate?.address ?? "";
const delegateInvalid =
!delegateAddress ||
!isValidStellarAddress(delegateAddress) ||
delegateAddress === address ||
cycleWarning ||
isResolving;

return (
<section className="mb-8 rounded-2xl border border-outline-variant/20 bg-surface-container-lowest p-6 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-primary mb-2">
Vote delegation
</p>
<h2 className="text-2xl font-headline mb-2">Delegate governance power</h2>
<p className="max-w-2xl text-sm text-on-surface-variant leading-relaxed">
Let a trusted representative vote with your ILN power, or remove delegation before voting directly.
</p>
</div>

<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 lg:min-w-[520px]">
<div className="rounded-xl bg-surface-container-low p-4">
<p className="text-xs text-on-surface-variant mb-1">Own balance</p>
<p className="text-lg font-bold">{status ? formatVotingPower(status.ownVotingPower) : "-"}</p>
</div>
<div className="rounded-xl bg-surface-container-low p-4">
<p className="text-xs text-on-surface-variant mb-1">Incoming delegations</p>
<p className="text-lg font-bold">
{status ? formatVotingPower(status.incomingDelegationPower) : "-"}
</p>
</div>
<div className="rounded-xl bg-primary/10 p-4">
<p className="text-xs text-primary mb-1">Controlled voting weight</p>
<p className="text-lg font-bold text-primary">
{status ? formatVotingPower(status.controlledVotingPower) : "-"}
</p>
</div>
</div>
</div>

{!isConnected ? (
<div className="mt-6 flex flex-col gap-3 rounded-xl border border-outline-variant/20 bg-surface-container-low p-4 sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm text-on-surface-variant">Connect your wallet to manage vote delegation.</span>
<button
onClick={connect}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-primary px-4 py-2 text-sm font-bold text-white transition-colors hover:bg-primary/90"
>
<span className="material-symbols-outlined text-[18px]" aria-hidden="true">account_balance_wallet</span>
Connect wallet
</button>
</div>
) : (
<div className="mt-6 grid gap-6 lg:grid-cols-[1fr_1.2fr]">
<div className="rounded-xl border border-outline-variant/20 bg-surface-container-low p-4">
<p className="text-sm font-semibold mb-2">Current delegation status</p>
{isLoading ? (
<div className="h-6 w-48 animate-pulse rounded bg-surface-container-high" />
) : status?.delegatee ? (
<p className="text-sm text-on-surface-variant">
You are delegating to{" "}
<span className="font-bold text-on-surface">{shortenAddress(status.delegatee)}</span>
</p>
) : (
<p className="text-sm text-on-surface-variant">Not delegating</p>
)}
{status ? (
<p className="mt-3 text-xs text-on-surface-variant">
{status.incomingDelegatorCount} wallet{status.incomingDelegatorCount === 1 ? "" : "s"}{" "}
{status.incomingDelegatorCount === 1 ? "delegates" : "delegate"} to you.
</p>
) : null}
<button
onClick={handleUndelegate}
disabled={!status?.delegatee || undelegateTransaction.isPending}
className="mt-4 inline-flex items-center justify-center gap-2 rounded-xl border border-outline-variant px-4 py-2 text-sm font-bold transition-colors hover:bg-surface-container-high disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined text-[18px]" aria-hidden="true">person_remove</span>
Undelegate
</button>
</div>

<form onSubmit={handleSubmit} className="rounded-xl border border-outline-variant/20 bg-surface-container-low p-4">
<label htmlFor="delegate-address" className="text-sm font-semibold">
Delegate address
</label>
<div className="mt-2 flex flex-col gap-3 sm:flex-row">
<input
id="delegate-address"
value={delegateInput}
onChange={(event) => {
setDelegateInput(event.target.value);
setResolvedDelegate(null);
setCycleWarning(false);
setError(null);
}}
onBlur={() => void resolveInput()}
placeholder="G... or alice*example.com"
className="min-w-0 flex-1 rounded-xl border border-outline-variant/40 bg-surface-container-lowest px-4 py-3 text-sm outline-none transition-colors focus:border-primary"
/>
<button
type="submit"
disabled={delegateInvalid || delegateTransaction.isPending}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-primary px-5 py-3 text-sm font-bold text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined text-[18px]" aria-hidden="true">person_add</span>
Delegate
</button>
</div>

{isResolving ? (
<p className="mt-3 text-xs text-on-surface-variant">Resolving delegate address...</p>
) : null}
{resolvedDelegate ? (
<p className="mt-3 text-xs text-primary">
Resolved to {shortenAddress(resolvedDelegate.address)}
{resolvedDelegate.federationName ? ` from ${resolvedDelegate.federationName}` : ""}
</p>
) : null}
{cycleWarning ? (
<p className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-600">
You cannot delegate to an address that delegates back to you
</p>
) : null}
{error ? (
<p className="mt-3 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-500">
{error}
</p>
) : null}
</form>
</div>
)}
</section>
);
}
Loading