+
+
+
+
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}
@@ -833,6 +842,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 */}
@@ -982,6 +1000,117 @@ export default function BallotCard({
+
+ {/* Confirmation dialog for moving a proposal between ballots */}
+
);
}
@@ -1004,75 +1133,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 (
@@ -1187,7 +1255,7 @@ function BallotOverviewTable({
} = useProposalRemoval(ballotId, refetchBallots, onBallotChanged);
const computeHashFromJson = useCallback((jsonData: unknown) => {
- return hashDrepAnchor(jsonData as Record
);
+ return computeAnchorHash(jsonData);
}, []);
// Initialize rationale states from ballot data and auto-load existing anchors
@@ -1310,38 +1378,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,
});
await refetchBallots();
toast({
@@ -1356,7 +1408,7 @@ function BallotOverviewTable({
variant: "destructive",
});
}
- }, [rationaleStates, computeHashFromJson, ballotId, updateAnchorMutation, refetchBallots, toast]);
+ }, [rationaleStates, ballotId, updateAnchorMutation, refetchBallots, toast]);
const loadRationaleFromUrl = useCallback(async (idx: number, overrideUrl?: string) => {
const state = rationaleStates[idx];
@@ -1371,28 +1423,23 @@ function BallotOverviewTable({
}
setRationaleStates(prev => ({ ...prev, [idx]: { ...prev[idx]!, loading: true } }));
try {
- const res = await fetch(targetUrl);
- if (!res.ok) throw new Error("Failed to fetch rationale");
- const data = await res.json();
- const hash = computeHashFromJson(data);
- // Extract comment from loaded JSON-LD if present
- const comment = data?.body?.comment || "";
- setRationaleStates(prev => ({
- ...prev,
- [idx]: {
- ...prev[idx]!,
- json: JSON.stringify(data, null, 2),
+ const res = await loadRationale(targetUrl);
+ setRationaleStates(prev => ({
+ ...prev,
+ [idx]: {
+ ...prev[idx]!,
+ json: JSON.stringify(res.json, null, 2),
url: targetUrl,
- hash,
- comment,
- loading: false
- }
+ hash: res.hash,
+ comment: res.comment,
+ loading: false,
+ },
}));
await updateAnchorMutation.mutateAsync({
ballotId,
index: idx,
anchorUrl: targetUrl,
- anchorHash: hash,
+ anchorHash: res.hash,
});
await refetchBallots();
toast({
@@ -1407,7 +1454,7 @@ function BallotOverviewTable({
variant: "destructive",
});
}
- }, [rationaleStates, computeHashFromJson, ballotId, updateAnchorMutation, refetchBallots, toast]);
+ }, [rationaleStates, ballotId, updateAnchorMutation, refetchBallots, toast]);
return (
<>
diff --git a/src/components/pages/wallet/governance/index.tsx b/src/components/pages/wallet/governance/index.tsx
index e5a01ed1..6bf5f710 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";
@@ -35,6 +36,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 0124d9df..87aa688c 100644
--- a/src/components/pages/wallet/governance/proposal/index.tsx
+++ b/src/components/pages/wallet/governance/proposal/index.tsx
@@ -549,6 +549,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 && (
+
+ )}
+ {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 && (
+
+ )}
+ {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({
proposalTitle,
proposalDetails,
onOpenBallotSidebar,
+ anchor = null,
}: VoteButtonProps) {
// Use the custom hook for ballots (still used for proxy / context where needed)
const { ballots } = useBallot(appWallet?.id);
@@ -281,6 +287,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(
{
@@ -291,22 +307,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,
});
@@ -416,9 +431,19 @@ export default function VoteButton({
{loading
? "Voting..."
: utxos.length > 0
- ? `Vote${hasValidProxy ? " (Proxy)" : ""}`
+ ? `Vote${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) && (
+
+ )}
+
+
+
+
+
+
+ {!hideLoad && (
+
+
+
+ {
+ setUrl(e.target.value);
+ if (e.target.value !== url) setHash("");
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && url.trim() && !busy) load();
+ }}
+ placeholder="https://ipfs.io/ipfs/..."
+ className="text-xs flex-1"
+ />
+ {url.trim() && (
+
+ )}
+
+
+ )}
+
+
+
+ {(liveJson || json) && (
+
+
+ View JSON-LD
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/pages/wallet/governance/vote-card.tsx b/src/components/pages/wallet/governance/vote-card.tsx
index baa4dfdd..58bf0f07 100644
--- a/src/components/pages/wallet/governance/vote-card.tsx
+++ b/src/components/pages/wallet/governance/vote-card.tsx
@@ -4,9 +4,15 @@ import { useWalletsStore } from "@/lib/zustand/wallets";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
+import { Button } from "@/components/ui/button";
import { useState } from "react";
import VoteButton from "./proposal/voteButtton";
import type { UTxO } from "@meshsdk/core";
+import {
+ RationaleEditor,
+ type RationaleEditorValue,
+} from "./rationale/RationaleEditor";
+import { FileText } from "lucide-react";
interface VoteCardProps {
appWallet: Wallet;
@@ -27,6 +33,11 @@ export default function VoteCard({
const [localProposalId, setLocalProposalId] = useState(proposalId ?? "");
const [description, setDescription] = useState("");
const [metadata, setMetadata] = useState("");
+ const [showRationale, setShowRationale] = useState(false);
+ const [rationale, setRationale] = useState({
+ comment: "",
+ anchor: null,
+ });
return (
@@ -67,6 +78,40 @@ export default function VoteCard({
/>
+
+ {showRationale ? (
+
+ ) : (
+
+ )}
+ {showRationale && (
+
+
+
+ )}
+
+
{!drepInfo?.active && (
* Please register DRep before creating a vote transaction
@@ -82,6 +127,7 @@ export default function VoteCard({
utxos={utxos}
selectedBallotId={selectedBallotId}
proposalTitle={proposalTitle}
+ anchor={rationale.anchor}
/>
diff --git a/src/components/pages/wallet/info/index.tsx b/src/components/pages/wallet/info/index.tsx
index 1ecb1752..1a3d56b7 100644
--- a/src/components/pages/wallet/info/index.tsx
+++ b/src/components/pages/wallet/info/index.tsx
@@ -4,6 +4,7 @@ import CardInfo from "./card-info";
import CardSigners from "./signers/card-signers";
import { ManageContacts } from "./manage-contacts";
import { MigrateWallet } from "./migrate-wallet";
+import { TransferWallet } from "./transfer-wallet";
import { ArchiveWallet } from "./archive-wallet";
import { UpgradeStakingWallet } from "./upgrade-staking-wallet";
import ProxyControlCard from "./proxy-control";
@@ -22,6 +23,7 @@ export default function WalletInfo() {
+
{multisigWallet && }
{multisigWallet && }
diff --git a/src/components/pages/wallet/info/transfer-wallet.tsx b/src/components/pages/wallet/info/transfer-wallet.tsx
new file mode 100644
index 00000000..84056ece
--- /dev/null
+++ b/src/components/pages/wallet/info/transfer-wallet.tsx
@@ -0,0 +1,307 @@
+import { useState } from "react";
+import CardUI from "@/components/ui/card-content";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+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, Download, Send, CheckCircle, ExternalLink } from "lucide-react";
+import type { Wallet } from "@/types/wallet";
+import type { WalletTransferPayloadV1 } from "@/types/walletTransfer";
+import { toast } from "@/hooks/use-toast";
+import { api } from "@/utils/api";
+import { useUserStore } from "@/lib/zustand/user";
+
+type Mode = "download" | "push";
+
+export function TransferWallet({ appWallet }: { appWallet: Wallet }) {
+ const userAddress = useUserStore((s) => s.userAddress);
+ const isOwner = appWallet.ownerAddress === userAddress;
+
+ const [open, setOpen] = useState(false);
+ const [mode, setMode] = useState("download");
+ const [includeContacts, setIncludeContacts] = useState(false);
+ const [includeBallots, setIncludeBallots] = useState(false);
+ const [targetUrl, setTargetUrl] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [resultInviteUrl, setResultInviteUrl] = useState(null);
+
+ const exportMutation = api.wallet.exportTransferPayload.useMutation();
+
+ const reset = () => {
+ setMode("download");
+ setIncludeContacts(false);
+ setIncludeBallots(false);
+ setTargetUrl("");
+ setBusy(false);
+ setResultInviteUrl(null);
+ };
+
+ const downloadJson = (payload: WalletTransferPayloadV1) => {
+ const blob = new Blob([JSON.stringify(payload, null, 2)], {
+ type: "application/json",
+ });
+ const url = URL.createObjectURL(blob);
+ const safeName = (appWallet.name || "wallet").replace(/[^a-z0-9._-]+/gi, "_");
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${safeName}-transfer-${Date.now()}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ const pushToTarget = async (
+ payload: WalletTransferPayloadV1,
+ rawTarget: string,
+ ) => {
+ let normalized = rawTarget.trim();
+ if (!normalized) throw new Error("Target instance URL is required");
+ if (!/^https?:\/\//i.test(normalized)) normalized = `https://${normalized}`;
+ const base = normalized.replace(/\/+$/, "");
+ const endpoint = `${base}/api/v1/wallet/transfer/import`;
+ const enriched: WalletTransferPayloadV1 = {
+ ...payload,
+ exportedFromOrigin: window.location.origin,
+ };
+ const res = await fetch(endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(enriched),
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`Target rejected the transfer (${res.status}): ${text || res.statusText}`);
+ }
+ return (await res.json()) as { newWalletId: string; inviteUrl: string };
+ };
+
+ const submit = async () => {
+ setBusy(true);
+ try {
+ const payload = await exportMutation.mutateAsync({
+ walletId: appWallet.id,
+ includeContacts,
+ includeBallots,
+ });
+ const enriched: WalletTransferPayloadV1 = {
+ ...payload,
+ exportedFromOrigin: window.location.origin,
+ };
+ if (mode === "download") {
+ downloadJson(enriched);
+ toast({
+ title: "Wallet exported",
+ description: "Transfer JSON downloaded.",
+ });
+ setOpen(false);
+ reset();
+ } else {
+ const result = await pushToTarget(payload, targetUrl);
+ setResultInviteUrl(result.inviteUrl);
+ toast({
+ title: "Wallet sent",
+ description: "Recipient instance accepted the transfer.",
+ });
+ }
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : "Transfer failed";
+ toast({
+ title: "Transfer failed",
+ description: msg,
+ variant: "destructive",
+ });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ Sends the wallet definition (signers, threshold, script) to another
+ instance. On-chain history, balances, and pending transactions stay on
+ chain. Optionally include contacts and ballots.
+
+
+
+ {!isOwner && (
+
+ Only the wallet owner can initiate a transfer.
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/data/public-routes.ts b/src/data/public-routes.ts
index 8d21758f..90ab5ab7 100644
--- a/src/data/public-routes.ts
+++ b/src/data/public-routes.ts
@@ -5,5 +5,6 @@ export const publicRoutes = [
"/governance/drep/[id]",
"/features",
"/api-docs",
- "/dapps"
+ "/dapps",
+ "/bot-setup",
];
diff --git a/src/lib/governance/rationale.ts b/src/lib/governance/rationale.ts
new file mode 100644
index 00000000..c158655a
--- /dev/null
+++ b/src/lib/governance/rationale.ts
@@ -0,0 +1,114 @@
+import { hashDrepAnchor } from "@meshsdk/core";
+
+export type RationaleJsonLd = {
+ "@context": Record;
+ authors: Array<{ name?: string }>;
+ body: { comment: string };
+ hashAlgorithm: "blake2b-256";
+};
+
+export type RationaleAnchor = {
+ url: string;
+ hash: string;
+};
+
+const CIP100_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",
+ },
+ },
+ },
+ },
+} as const;
+
+export function buildRationaleJsonLd(comment: string): RationaleJsonLd {
+ return {
+ "@context": CIP100_CONTEXT,
+ authors: [],
+ body: { comment: comment.trim() },
+ hashAlgorithm: "blake2b-256",
+ };
+}
+
+export function computeAnchorHash(jsonData: unknown): string {
+ return hashDrepAnchor(jsonData as Record);
+}
+
+export async function uploadRationaleToPinata(
+ jsonLd: RationaleJsonLd | Record,
+): Promise {
+ const payload = JSON.stringify(jsonLd, null, 2);
+ const response = await fetch("/api/pinata-storage/put", {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ pathname: `rationale/rationale-${Date.now()}.jsonld`,
+ value: payload,
+ }),
+ });
+ if (!response.ok) {
+ const err = (await response.json().catch(() => ({}))) as { error?: string };
+ throw new Error(err?.error ?? `Upload failed (${response.status})`);
+ }
+ const res = (await response.json()) as { url: string };
+ const hash = computeAnchorHash(jsonLd);
+ return { url: res.url, hash };
+}
+
+export async function loadRationaleFromUrl(url: string): Promise<{
+ json: Record;
+ comment: string;
+ hash: string;
+}> {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`Failed to fetch rationale (${res.status})`);
+ const data = (await res.json()) as Record;
+ const hash = computeAnchorHash(data);
+ const body = (data?.body ?? {}) as { comment?: unknown };
+ const comment = typeof body.comment === "string" ? body.comment : "";
+ return { json: data, comment, hash };
+}
diff --git a/src/pages/api/llms-txt.ts b/src/pages/api/llms-txt.ts
new file mode 100644
index 00000000..d67e8b26
--- /dev/null
+++ b/src/pages/api/llms-txt.ts
@@ -0,0 +1,49 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== "GET") {
+ res.setHeader("Allow", "GET");
+ return res.status(405).end();
+ }
+
+ const proto = (req.headers["x-forwarded-proto"] as string | undefined) ?? "https";
+ const host = req.headers.host ?? "multisig.meshjs.dev";
+ const origin = `${proto}://${host}`;
+
+ const body = `# Mesh Multisig
+
+A Cardano multisig wallet platform. Sign transactions collaboratively,
+participate in governance, and integrate bots and AI agents into wallet
+workflows.
+
+## Primary docs
+
+- Bot setup guide (for AI agents): ${origin}/api/v1/botSetupGuide
+- Bot setup page (HTML rendering of same guide): ${origin}/bot-setup
+- API reference (OpenAPI / Swagger): ${origin}/api-docs
+
+## Key bot endpoints
+
+- POST ${origin}/api/v1/botRegister — bot self-registers, returns claim code
+- GET ${origin}/api/v1/botPickupSecret?pendingBotId=... — bot retrieves credentials after human claim
+- POST ${origin}/api/v1/botAuth — exchange secret for short-lived JWT
+- GET ${origin}/api/v1/botMe — bot self-info including owner address
+- GET ${origin}/api/v1/walletIds?address=... — wallets the authenticated bot can access
+
+## Wallet transfer (cross-instance)
+
+- POST ${origin}/api/v1/wallet/transfer/import — receive a wallet definition exported from another instance
+- GET ${origin}/api/v1/wallet/transfer/export?walletId=... — export a wallet (owner JWT required)
+
+## How to onboard an AI agent
+
+1. Fetch ${origin}/api/v1/botSetupGuide and follow the five-phase flow.
+2. The agent reports pendingBotId and claimCode to its human operator.
+3. The human approves and grants scopes in the UI at ${origin}/user.
+4. The agent picks up its secret and authenticates.
+`;
+
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
+ res.setHeader("Cache-Control", "public, max-age=300");
+ return res.status(200).send(body);
+}
diff --git a/src/pages/api/v1/botSetupGuide.ts b/src/pages/api/v1/botSetupGuide.ts
new file mode 100644
index 00000000..e34f51d4
--- /dev/null
+++ b/src/pages/api/v1/botSetupGuide.ts
@@ -0,0 +1,166 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+const BOT_SCOPES = [
+ "multisig:read",
+ "multisig:create",
+ "multisig:sign",
+ "governance:read",
+ "ballot:write",
+] as const;
+
+function originFromRequest(req: NextApiRequest): string {
+ const proto = (req.headers["x-forwarded-proto"] as string | undefined) ?? "https";
+ const host = req.headers.host ?? "multisig.meshjs.dev";
+ return `${proto}://${host}`;
+}
+
+function buildGuide(origin: string): string {
+ return `# Mesh Multisig Bot Setup Guide
+
+This document is written for AI agents and developer scripts. It describes the
+exact HTTP calls needed to provision a bot identity on this instance and start
+operating against multisig wallets.
+
+Instance base URL: \`${origin}\`
+
+## Concepts
+
+- **Bot** — a non-human identity that authenticates with a stored secret and can
+ read or sign for multisig wallets to which it has been granted access.
+- **Owner** — the human user who claims a registered bot. Owners always
+ authorize scopes and grant wallet access.
+- **Scopes** — capabilities the bot may exercise. Available values:
+ ${BOT_SCOPES.map((s) => `\n - \`${s}\``).join("")}
+- **Wallet access roles** — \`observer\` (read-only) or \`cosigner\` (can sign).
+
+## Five-phase setup
+
+### 1. Register (bot-initiated, no auth)
+
+\`POST ${origin}/api/v1/botRegister\`
+
+Body:
+\`\`\`json
+{
+ "name": "My Bot",
+ "paymentAddress": "addr1_your_bot_payment_address",
+ "stakeAddress": "stake1_optional",
+ "requestedScopes": ["multisig:read"]
+}
+\`\`\`
+
+Response:
+\`\`\`json
+{
+ "pendingBotId": "cxyz...",
+ "claimCode": "base64url_code...",
+ "claimExpiresAt": "ISO-8601 timestamp (10 minutes from now)"
+}
+\`\`\`
+
+Persist \`pendingBotId\` and \`claimCode\`. Surface both to the human user so
+they can approve in the UI within 10 minutes.
+
+### 2. Human claim (in the UI)
+
+The human navigates to the **User → Bot accounts** page and enters
+\`pendingBotId\` + \`claimCode\`. They review and approve scopes. On success
+the server provisions a \`BotKey\` + \`BotUser\` and stages a one-time secret
+for pickup. No action from the bot at this stage; poll
+\`GET ${origin}/api/v1/botPickupSecret?pendingBotId=...\` for readiness.
+
+### 3. Pickup credentials (bot-initiated, no auth)
+
+\`GET ${origin}/api/v1/botPickupSecret?pendingBotId=cxyz...\`
+
+Response (one-time only; secret is cleared after pickup):
+\`\`\`json
+{
+ "botKeyId": "key_id...",
+ "secret": "hex_secret...",
+ "paymentAddress": "addr1_your_bot_payment_address"
+}
+\`\`\`
+
+Persist \`botKeyId\` + \`secret\` in your bot config. Never log the secret.
+
+### 4. Authenticate (exchange secret for JWT)
+
+\`POST ${origin}/api/v1/botAuth\`
+
+Body:
+\`\`\`json
+{
+ "botKeyId": "key_id...",
+ "secret": "hex_secret...",
+ "paymentAddress": "addr1_your_bot_payment_address"
+}
+\`\`\`
+
+Response:
+\`\`\`json
+{
+ "token": "JWT...",
+ "botId": "bot_id..."
+}
+\`\`\`
+
+The JWT expires in 1 hour. Re-authenticate with the same secret when it
+expires; the secret itself does not rotate.
+
+### 5. Confirm and operate
+
+Send the bearer token on every subsequent request:
+\`Authorization: Bearer \`
+
+Sanity check:
+\`GET ${origin}/api/v1/botMe\` — returns the bot's own info plus the
+\`ownerAddress\` of the human who claimed it.
+
+Once the human grants wallet access in the UI, the bot can call any
+bot-enabled endpoint within its scopes.
+
+## Bot-enabled endpoints
+
+| Method | Path | Required scope | Notes |
+| --- | --- | --- | --- |
+| GET | \`/api/v1/botMe\` | — | Bot self-info. |
+| GET | \`/api/v1/walletIds?address=\` | \`multisig:read\` | Wallets the bot can access. |
+| GET | \`/api/v1/pendingTransactions\` | \`multisig:read\` | Pending sigs. |
+| GET | \`/api/v1/freeUtxos\` | \`multisig:read\` | Wallet UTxOs. |
+| POST | \`/api/v1/createWallet\` | \`multisig:create\` | Create a wallet. |
+| POST | \`/api/v1/signTransaction\` | \`multisig:sign\` | Cosigner role required. |
+| GET | \`/api/v1/governanceActiveProposals\` | \`governance:read\` | Live proposals. |
+| POST | \`/api/v1/botBallotsUpsert\` | \`ballot:write\` | Draft ballots. |
+
+## Error model
+
+- \`400\` — malformed request body or missing parameter.
+- \`401\` — missing/expired/invalid JWT, or secret mismatch.
+- \`403\` — insufficient scope or wallet access role.
+- \`409\` — disambiguation needed (e.g., ballot name collision).
+- \`429\` — rate limited.
+
+## Reference client
+
+A Node/TypeScript reference client lives at
+\`scripts/bot-ref/bot-client.ts\` in the repo. It exercises the full
+register → claim → pickup → auth → operate flow.
+
+## Audit
+
+All claim, auth, and privilege-changing actions are recorded in the
+\`AuditLog\` table on the server. Treat your bot's secret like a password.
+`;
+}
+
+export default function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== "GET") {
+ res.setHeader("Allow", "GET");
+ return res.status(405).end();
+ }
+ const guide = buildGuide(originFromRequest(req));
+ res.setHeader("Content-Type", "text/markdown; charset=utf-8");
+ res.setHeader("Cache-Control", "public, max-age=300");
+ return res.status(200).send(guide);
+}
diff --git a/src/pages/api/v1/wallet/transfer/export.ts b/src/pages/api/v1/wallet/transfer/export.ts
new file mode 100644
index 00000000..1424b349
--- /dev/null
+++ b/src/pages/api/v1/wallet/transfer/export.ts
@@ -0,0 +1,155 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { db } from "@/server/db";
+import { verifyJwt } from "@/lib/verifyJwt";
+import { cors, addCorsCacheBustingHeaders } from "@/lib/cors";
+import { applyRateLimit } from "@/lib/security/requestGuards";
+import { getClientIP } from "@/lib/security/rateLimit";
+import { audit } from "@/lib/observability/audit";
+import {
+ WALLET_TRANSFER_FORMAT,
+ WALLET_TRANSFER_VERSION,
+ type WalletTransferBallot,
+ type WalletTransferContact,
+ type WalletTransferPayloadV1,
+ type WalletTransferType,
+} from "@/types/walletTransfer";
+
+const ALLOWED_TYPES: WalletTransferType[] = ["atLeast", "all", "any"];
+
+function parseIncludeFlags(raw: unknown): { contacts: boolean; ballots: boolean } {
+ const values = typeof raw === "string" ? raw.split(",") : Array.isArray(raw) ? raw : [];
+ const set = new Set(values.map((v) => (typeof v === "string" ? v.trim() : "")));
+ return { contacts: set.has("contacts"), ballots: set.has("ballots") };
+}
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ addCorsCacheBustingHeaders(res);
+
+ if (!applyRateLimit(req, res, { keySuffix: "v1/wallet/transfer/export" })) {
+ return;
+ }
+
+ await cors(req, res);
+ if (req.method === "OPTIONS") {
+ return res.status(200).end();
+ }
+
+ if (req.method !== "GET") {
+ return res.status(405).json({ error: "Method Not Allowed" });
+ }
+
+ const authHeader = req.headers.authorization;
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
+ if (!token) {
+ return res.status(401).json({ error: "Unauthorized - Missing token" });
+ }
+ const jwt = verifyJwt(token);
+ if (!jwt) {
+ return res.status(401).json({ error: "Invalid or expired token" });
+ }
+
+ const walletId = typeof req.query.walletId === "string" ? req.query.walletId : null;
+ if (!walletId) {
+ return res.status(400).json({ error: "walletId query parameter is required" });
+ }
+
+ const include = parseIncludeFlags(req.query.include);
+
+ const wallet = await db.wallet.findUnique({ where: { id: walletId } });
+ if (!wallet) {
+ return res.status(404).json({ error: "Wallet not found" });
+ }
+
+ const requester = jwt.address;
+ const isOwner = wallet.ownerAddress === requester;
+ if (!isOwner) {
+ void audit(db, {
+ actorAddress: requester,
+ actorType: "user",
+ action: "wallet.transfer.export",
+ resourceType: "wallet",
+ resourceId: walletId,
+ ip: getClientIP(req),
+ outcome: "denied",
+ reason: "not_owner",
+ });
+ return res.status(403).json({ error: "Only the wallet owner can export this wallet" });
+ }
+
+ const type: WalletTransferType = ALLOWED_TYPES.includes(wallet.type as WalletTransferType)
+ ? (wallet.type as WalletTransferType)
+ : "atLeast";
+
+ const payload: WalletTransferPayloadV1 = {
+ format: WALLET_TRANSFER_FORMAT,
+ version: WALLET_TRANSFER_VERSION,
+ exportedAt: new Date().toISOString(),
+ exportedFromOrigin:
+ (req.headers["x-forwarded-proto"] && req.headers.host
+ ? `${req.headers["x-forwarded-proto"]}://${req.headers.host}`
+ : `https://${req.headers.host ?? "multisig.meshjs.dev"}`),
+ exporterAddress: requester,
+ wallet: {
+ name: wallet.name,
+ description: wallet.description ?? "",
+ type,
+ signersAddresses: wallet.signersAddresses ?? [],
+ signersStakeKeys: wallet.signersStakeKeys ?? [],
+ signersDRepKeys: wallet.signersDRepKeys ?? [],
+ signersDescriptions: wallet.signersDescriptions ?? [],
+ numRequiredSigners: wallet.numRequiredSigners ?? null,
+ scriptCbor: wallet.scriptCbor,
+ stakeCredentialHash: wallet.stakeCredentialHash ?? null,
+ profileImageIpfsUrl: wallet.profileImageIpfsUrl ?? null,
+ },
+ };
+
+ if (include.contacts) {
+ const contacts = await db.contact.findMany({
+ where: { walletId },
+ orderBy: { createdAt: "asc" },
+ take: 500,
+ });
+ payload.contacts = contacts.map((c) => ({
+ name: c.name,
+ address: c.address,
+ description: c.description ?? null,
+ }));
+ }
+
+ if (include.ballots) {
+ const ballots = await db.ballot.findMany({
+ where: { walletId },
+ orderBy: { createdAt: "asc" },
+ take: 200,
+ });
+ payload.ballots = ballots.map((b) => ({
+ description: b.description ?? null,
+ items: b.items ?? [],
+ itemDescriptions: b.itemDescriptions ?? [],
+ choices: b.choices ?? [],
+ anchorUrls: b.anchorUrls ?? [],
+ anchorHashes: b.anchorHashes ?? [],
+ rationaleComments: b.rationaleComments ?? [],
+ type: b.type,
+ }));
+ }
+
+ void audit(db, {
+ actorAddress: requester,
+ actorType: "user",
+ action: "wallet.transfer.export",
+ resourceType: "wallet",
+ resourceId: walletId,
+ ip: getClientIP(req),
+ outcome: "success",
+ metadata: {
+ includeContacts: include.contacts,
+ includeBallots: include.ballots,
+ signerCount: payload.wallet.signersAddresses.length,
+ },
+ });
+
+ res.setHeader("Cache-Control", "no-store");
+ return res.status(200).json(payload);
+}
diff --git a/src/pages/api/v1/wallet/transfer/import.ts b/src/pages/api/v1/wallet/transfer/import.ts
new file mode 100644
index 00000000..73fa66f2
--- /dev/null
+++ b/src/pages/api/v1/wallet/transfer/import.ts
@@ -0,0 +1,286 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { db } from "@/server/db";
+import { cors, addCorsCacheBustingHeaders } from "@/lib/cors";
+import { applyRateLimit, enforceBodySize } from "@/lib/security/requestGuards";
+import { getClientIP } from "@/lib/security/rateLimit";
+import { audit } from "@/lib/observability/audit";
+import {
+ WALLET_TRANSFER_FORMAT,
+ WALLET_TRANSFER_VERSION,
+ type WalletTransferBallot,
+ type WalletTransferContact,
+ type WalletTransferDefinition,
+ type WalletTransferPayloadV1,
+ type WalletTransferType,
+} from "@/types/walletTransfer";
+
+const ALLOWED_TYPES: WalletTransferType[] = ["atLeast", "all", "any"];
+const MAX_SIGNERS = 200;
+const MAX_CONTACTS = 500;
+const MAX_BALLOTS = 200;
+
+function stripHtml(input: string): string {
+ let out = "";
+ let inTag = false;
+ for (let i = 0; i < input.length; i++) {
+ const ch = input[i]!;
+ if (ch === "<") {
+ inTag = true;
+ continue;
+ }
+ if (ch === ">") {
+ inTag = false;
+ continue;
+ }
+ if (!inTag) out += ch;
+ }
+ return out;
+}
+
+function sanitizeText(value: unknown, maxLen: number): string {
+ if (typeof value !== "string") return "";
+ return stripHtml(value).slice(0, maxLen).trim();
+}
+
+function asStringArray(value: unknown, maxItems: number, maxLen: number): string[] {
+ if (!Array.isArray(value)) return [];
+ return value
+ .slice(0, maxItems)
+ .map((v) => (typeof v === "string" ? sanitizeText(v, maxLen) : ""));
+}
+
+function validateDefinition(input: unknown): { ok: true; value: WalletTransferDefinition } | { ok: false; error: string } {
+ if (typeof input !== "object" || input === null) {
+ return { ok: false, error: "wallet field must be an object" };
+ }
+ const w = input as Record;
+ const name = sanitizeText(w.name, 256);
+ if (!name) return { ok: false, error: "wallet.name is required" };
+ const type = w.type as WalletTransferType;
+ if (!ALLOWED_TYPES.includes(type)) {
+ return { ok: false, error: "wallet.type must be 'atLeast', 'all', or 'any'" };
+ }
+ const scriptCbor = typeof w.scriptCbor === "string" ? w.scriptCbor.trim() : "";
+ if (!scriptCbor) {
+ return { ok: false, error: "wallet.scriptCbor is required" };
+ }
+ const signersAddresses = asStringArray(w.signersAddresses, MAX_SIGNERS, 512);
+ if (signersAddresses.length === 0) {
+ return { ok: false, error: "wallet.signersAddresses must be a non-empty array" };
+ }
+ const signersStakeKeys = asStringArray(w.signersStakeKeys, MAX_SIGNERS, 512);
+ const signersDRepKeys = asStringArray(w.signersDRepKeys, MAX_SIGNERS, 512);
+ const signersDescriptions = asStringArray(w.signersDescriptions, MAX_SIGNERS, 256);
+ const description = sanitizeText(w.description, 2000);
+
+ let numRequiredSigners: number | null = null;
+ if (type === "atLeast") {
+ const n = w.numRequiredSigners;
+ if (typeof n !== "number" || !Number.isFinite(n) || n < 1) {
+ return { ok: false, error: "wallet.numRequiredSigners must be a positive number when type is 'atLeast'" };
+ }
+ numRequiredSigners = Math.min(Math.floor(n), signersAddresses.length);
+ }
+
+ const stakeCredentialHash =
+ typeof w.stakeCredentialHash === "string" && w.stakeCredentialHash.length > 0
+ ? w.stakeCredentialHash
+ : null;
+ const profileImageIpfsUrl =
+ typeof w.profileImageIpfsUrl === "string" && w.profileImageIpfsUrl.length > 0
+ ? w.profileImageIpfsUrl.slice(0, 1024)
+ : null;
+
+ return {
+ ok: true,
+ value: {
+ name,
+ description,
+ type,
+ signersAddresses,
+ signersStakeKeys: signersStakeKeys.length ? signersStakeKeys : signersAddresses.map(() => ""),
+ signersDRepKeys: signersDRepKeys.length ? signersDRepKeys : signersAddresses.map(() => ""),
+ signersDescriptions: signersDescriptions.length ? signersDescriptions : signersAddresses.map(() => ""),
+ numRequiredSigners,
+ scriptCbor,
+ stakeCredentialHash,
+ profileImageIpfsUrl,
+ },
+ };
+}
+
+function validateContacts(input: unknown): WalletTransferContact[] {
+ if (!Array.isArray(input)) return [];
+ const result: WalletTransferContact[] = [];
+ for (const c of input.slice(0, MAX_CONTACTS)) {
+ if (typeof c !== "object" || c === null) continue;
+ const obj = c as Record;
+ const name = sanitizeText(obj.name, 128);
+ const address = sanitizeText(obj.address, 512);
+ if (!name || !address) continue;
+ result.push({
+ name,
+ address,
+ description: sanitizeText(obj.description, 1000) || null,
+ });
+ }
+ return result;
+}
+
+function validateBallots(input: unknown): WalletTransferBallot[] {
+ if (!Array.isArray(input)) return [];
+ const result: WalletTransferBallot[] = [];
+ for (const b of input.slice(0, MAX_BALLOTS)) {
+ if (typeof b !== "object" || b === null) continue;
+ const obj = b as Record;
+ const items = asStringArray(obj.items, 256, 256);
+ if (items.length === 0) continue;
+ result.push({
+ description: sanitizeText(obj.description, 1000) || null,
+ items,
+ itemDescriptions: asStringArray(obj.itemDescriptions, 256, 1000),
+ choices: asStringArray(obj.choices, 256, 32),
+ anchorUrls: asStringArray(obj.anchorUrls, 256, 1024),
+ anchorHashes: asStringArray(obj.anchorHashes, 256, 256),
+ rationaleComments: asStringArray(obj.rationaleComments, 256, 4000),
+ type:
+ typeof obj.type === "number" && Number.isFinite(obj.type)
+ ? Math.floor(obj.type)
+ : 0,
+ });
+ }
+ return result;
+}
+
+function buildInviteUrl(req: NextApiRequest, newWalletId: string): string {
+ const proto = (req.headers["x-forwarded-proto"] as string | undefined) ?? "https";
+ const host = req.headers.host ?? "multisig.meshjs.dev";
+ return `${proto}://${host}/wallets/invite/${newWalletId}`;
+}
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ addCorsCacheBustingHeaders(res);
+
+ if (!applyRateLimit(req, res, { keySuffix: "v1/wallet/transfer/import", maxRequests: 5 })) {
+ return;
+ }
+
+ await cors(req, res);
+ if (req.method === "OPTIONS") {
+ return res.status(200).end();
+ }
+
+ if (req.method !== "POST") {
+ return res.status(405).json({ error: "Method Not Allowed" });
+ }
+
+ if (!enforceBodySize(req, res, 200 * 1024)) {
+ return;
+ }
+
+ if (typeof req.body !== "object" || req.body === null) {
+ return res.status(400).json({ error: "Invalid request body" });
+ }
+
+ const body = req.body as Partial;
+
+ if (body.format !== WALLET_TRANSFER_FORMAT) {
+ return res.status(400).json({ error: "Invalid payload format" });
+ }
+ if (body.version !== WALLET_TRANSFER_VERSION) {
+ return res.status(400).json({ error: `Unsupported payload version (expected ${WALLET_TRANSFER_VERSION})` });
+ }
+
+ const validation = validateDefinition(body.wallet);
+ if (!validation.ok) {
+ return res.status(400).json({ error: validation.error });
+ }
+ const def = validation.value;
+
+ const contacts = validateContacts(body.contacts);
+ const ballots = validateBallots(body.ballots);
+
+ try {
+ const exporterAddress = sanitizeText(body.exporterAddress, 512) || null;
+ const exportedFromOrigin = sanitizeText(body.exportedFromOrigin, 512) || null;
+
+ const newWallet = await db.newWallet.create({
+ data: {
+ name: def.name,
+ description: def.description,
+ signersAddresses: def.signersAddresses,
+ signersStakeKeys: def.signersStakeKeys,
+ signersDRepKeys: def.signersDRepKeys,
+ signersDescriptions: def.signersDescriptions,
+ numRequiredSigners: def.numRequiredSigners ?? null,
+ ownerAddress: "all",
+ stakeCredentialHash: def.stakeCredentialHash,
+ scriptType: def.type,
+ paymentCbor: def.scriptCbor,
+ stakeCbor: "",
+ usesStored: false,
+ rawImportBodies: {
+ source: "wallet-transfer",
+ exporterAddress,
+ exportedFromOrigin,
+ exportedAt: typeof body.exportedAt === "string" ? body.exportedAt : null,
+ profileImageIpfsUrl: def.profileImageIpfsUrl ?? null,
+ },
+ },
+ });
+
+ if (contacts.length > 0) {
+ await db.contact.createMany({
+ data: contacts.map((c) => ({
+ walletId: newWallet.id,
+ name: c.name,
+ address: c.address,
+ description: c.description ?? null,
+ })),
+ skipDuplicates: true,
+ });
+ }
+
+ if (ballots.length > 0) {
+ await db.ballot.createMany({
+ data: ballots.map((b) => ({
+ walletId: newWallet.id,
+ description: b.description ?? null,
+ items: b.items,
+ itemDescriptions: b.itemDescriptions,
+ choices: b.choices,
+ anchorUrls: b.anchorUrls,
+ anchorHashes: b.anchorHashes,
+ rationaleComments: b.rationaleComments,
+ type: b.type,
+ })),
+ });
+ }
+
+ const inviteUrl = buildInviteUrl(req, newWallet.id);
+
+ void audit(db, {
+ actorAddress: exporterAddress,
+ actorType: "user",
+ action: "wallet.transfer.import",
+ resourceType: "wallet",
+ resourceId: newWallet.id,
+ ip: getClientIP(req),
+ outcome: "success",
+ metadata: {
+ exportedFromOrigin,
+ signerCount: def.signersAddresses.length,
+ contactCount: contacts.length,
+ ballotCount: ballots.length,
+ },
+ });
+
+ return res.status(200).json({
+ newWalletId: newWallet.id,
+ inviteUrl,
+ });
+ } catch (err) {
+ console.error("[api/v1/wallet/transfer/import] failed:", err);
+ return res.status(500).json({ error: "Failed to import wallet" });
+ }
+}
diff --git a/src/pages/bot-setup.tsx b/src/pages/bot-setup.tsx
new file mode 100644
index 00000000..c0e2ce9a
--- /dev/null
+++ b/src/pages/bot-setup.tsx
@@ -0,0 +1,45 @@
+import type { GetServerSideProps } from "next";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+
+type Props = {
+ origin: string;
+ markdown: string;
+};
+
+export const getServerSideProps: GetServerSideProps = async (ctx) => {
+ const proto =
+ (ctx.req.headers["x-forwarded-proto"] as string | undefined) ?? "https";
+ const host = ctx.req.headers.host ?? "multisig.meshjs.dev";
+ const origin = `${proto}://${host}`;
+ let markdown = "";
+ try {
+ const res = await fetch(`${origin}/api/v1/botSetupGuide`);
+ markdown = await res.text();
+ } catch (e) {
+ markdown = `# Bot setup\n\nFailed to load guide: ${
+ e instanceof Error ? e.message : "unknown error"
+ }`;
+ }
+ return { props: { origin, markdown } };
+};
+
+export default function BotSetupPage({ origin, markdown }: Props) {
+ const rawUrl = `${origin}/api/v1/botSetupGuide`;
+ return (
+
+
+
+ {markdown}
+
+
+ );
+}
diff --git a/src/server/api/routers/wallets.ts b/src/server/api/routers/wallets.ts
index 340ebf91..b48301a0 100644
--- a/src/server/api/routers/wallets.ts
+++ b/src/server/api/routers/wallets.ts
@@ -6,6 +6,16 @@ import type { AuthCtx } from "@/server/api/trpc";
import type { RawImportBodies } from "@/types/wallet";
import { Prisma } from "@prisma/client";
import { audit } from "@/lib/observability/audit";
+import {
+ WALLET_TRANSFER_FORMAT,
+ WALLET_TRANSFER_VERSION,
+ type WalletTransferBallot,
+ type WalletTransferContact,
+ type WalletTransferPayloadV1,
+ type WalletTransferType,
+} from "@/types/walletTransfer";
+
+const TRANSFER_ALLOWED_TYPES: WalletTransferType[] = ["atLeast", "all", "any"];
const requireSessionAddress = (ctx: AuthCtx) => {
const address = ctx.session?.user?.id ?? ctx.sessionAddress;
@@ -755,4 +765,115 @@ export const walletRouter = createTRPCRouter({
});
return updated;
}),
+
+ exportTransferPayload: protectedProcedure
+ .input(
+ z.object({
+ walletId: z.string(),
+ includeContacts: z.boolean().default(false),
+ includeBallots: z.boolean().default(false),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const sessionAddress = requireSessionAddress(ctx);
+ const sessionWallets: string[] = ctx.sessionWallets ?? [];
+ const requesters = sessionWallets.length > 0 ? sessionWallets : [sessionAddress];
+
+ const wallet = await ctx.db.wallet.findUnique({ where: { id: input.walletId } });
+ if (!wallet) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" });
+ }
+ const isOwner = requesters.some((a) => wallet.ownerAddress === a);
+ if (!isOwner) {
+ void audit(ctx.db, {
+ actorAddress: sessionAddress,
+ actorType: "user",
+ action: "wallet.transfer.export",
+ resourceType: "wallet",
+ resourceId: input.walletId,
+ ip: ctx.ip ?? null,
+ outcome: "denied",
+ reason: "not_owner",
+ });
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Only the wallet owner can export this wallet",
+ });
+ }
+
+ const type: WalletTransferType = TRANSFER_ALLOWED_TYPES.includes(
+ wallet.type as WalletTransferType,
+ )
+ ? (wallet.type as WalletTransferType)
+ : "atLeast";
+
+ const payload: WalletTransferPayloadV1 = {
+ format: WALLET_TRANSFER_FORMAT,
+ version: WALLET_TRANSFER_VERSION,
+ exportedAt: new Date().toISOString(),
+ exportedFromOrigin: "",
+ exporterAddress: sessionAddress,
+ wallet: {
+ name: wallet.name,
+ description: wallet.description ?? "",
+ type,
+ signersAddresses: wallet.signersAddresses ?? [],
+ signersStakeKeys: wallet.signersStakeKeys ?? [],
+ signersDRepKeys: wallet.signersDRepKeys ?? [],
+ signersDescriptions: wallet.signersDescriptions ?? [],
+ numRequiredSigners: wallet.numRequiredSigners ?? null,
+ scriptCbor: wallet.scriptCbor,
+ stakeCredentialHash: wallet.stakeCredentialHash ?? null,
+ profileImageIpfsUrl: wallet.profileImageIpfsUrl ?? null,
+ },
+ };
+
+ if (input.includeContacts) {
+ const contacts = await ctx.db.contact.findMany({
+ where: { walletId: input.walletId },
+ orderBy: { createdAt: "asc" },
+ take: 500,
+ });
+ payload.contacts = contacts.map((c) => ({
+ name: c.name,
+ address: c.address,
+ description: c.description ?? null,
+ }));
+ }
+
+ if (input.includeBallots) {
+ const ballots = await ctx.db.ballot.findMany({
+ where: { walletId: input.walletId },
+ orderBy: { createdAt: "asc" },
+ take: 200,
+ });
+ payload.ballots = ballots.map((b) => ({
+ description: b.description ?? null,
+ items: b.items ?? [],
+ itemDescriptions: b.itemDescriptions ?? [],
+ choices: b.choices ?? [],
+ anchorUrls: b.anchorUrls ?? [],
+ anchorHashes: b.anchorHashes ?? [],
+ rationaleComments: b.rationaleComments ?? [],
+ type: b.type,
+ }));
+ }
+
+ void audit(ctx.db, {
+ actorAddress: sessionAddress,
+ actorType: "user",
+ action: "wallet.transfer.export",
+ resourceType: "wallet",
+ resourceId: input.walletId,
+ ip: ctx.ip ?? null,
+ outcome: "success",
+ metadata: {
+ includeContacts: input.includeContacts,
+ includeBallots: input.includeBallots,
+ signerCount: payload.wallet.signersAddresses.length,
+ },
+ });
+
+ return payload;
+ }),
});
diff --git a/src/types/walletTransfer.ts b/src/types/walletTransfer.ts
new file mode 100644
index 00000000..f51341b0
--- /dev/null
+++ b/src/types/walletTransfer.ts
@@ -0,0 +1,46 @@
+export const WALLET_TRANSFER_FORMAT = "multisig-wallet-transfer" as const;
+export const WALLET_TRANSFER_VERSION = 1 as const;
+
+export type WalletTransferType = "atLeast" | "all" | "any";
+
+export type WalletTransferDefinition = {
+ name: string;
+ description: string;
+ type: WalletTransferType;
+ signersAddresses: string[];
+ signersStakeKeys: string[];
+ signersDRepKeys: string[];
+ signersDescriptions: string[];
+ numRequiredSigners: number | null;
+ scriptCbor: string;
+ stakeCredentialHash: string | null;
+ profileImageIpfsUrl?: string | null;
+};
+
+export type WalletTransferContact = {
+ name: string;
+ address: string;
+ description?: string | null;
+};
+
+export type WalletTransferBallot = {
+ description?: string | null;
+ items: string[];
+ itemDescriptions: string[];
+ choices: string[];
+ anchorUrls: string[];
+ anchorHashes: string[];
+ rationaleComments: string[];
+ type: number;
+};
+
+export type WalletTransferPayloadV1 = {
+ format: typeof WALLET_TRANSFER_FORMAT;
+ version: typeof WALLET_TRANSFER_VERSION;
+ exportedAt: string;
+ exportedFromOrigin: string;
+ exporterAddress: string;
+ wallet: WalletTransferDefinition;
+ contacts?: WalletTransferContact[];
+ ballots?: WalletTransferBallot[];
+};