+ 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 c3d9b3f0..f4058b7b 100644
--- a/src/data/public-routes.ts
+++ b/src/data/public-routes.ts
@@ -8,6 +8,7 @@ export const publicRoutes = [
"/roadmap/graph",
"/api-docs",
"/dapps",
+ "/bot-setup",
"/blog",
"/blog/[slug]",
// The import wizard renders before a wallet is connected so the user
diff --git a/src/lib/governance/rationale.ts b/src/lib/governance/rationale.ts
index b2fd056b..e4b074f3 100644
--- a/src/lib/governance/rationale.ts
+++ b/src/lib/governance/rationale.ts
@@ -1,8 +1,10 @@
import { hashDrepAnchor } from "@meshsdk/core";
+import { fetchIpfsJson } from "@/lib/ipfs";
+
/**
- * CIP-100 vote-rationale document tooling for the transaction builder's
- * edit-vote flow.
+ * CIP-100 vote-rationale document tooling, shared by the ballot editor, the
+ * standalone rationale editor and the transaction builder's edit-vote flow.
*
* INVARIANT: the bytes pinned to IPFS are `JSON.stringify(doc, null, 2)` and
* `hashDrepAnchor(doc)` hashes exactly that same 2-space serialization —
@@ -10,82 +12,100 @@ import { hashDrepAnchor } from "@meshsdk/core";
* verify against the fetched document.
*/
+export type RationaleJsonLd = {
+ "@context": Record;
+ authors: Array<{ name?: string }>;
+ body: { comment: string };
+ hashAlgorithm: "blake2b-256";
+};
+
+export type RationaleAnchor = {
+ url: string;
+ hash: string;
+};
+
/**
- * Builds the CIP-100 JSON-LD rationale document from a free-text comment.
- * The shape (context key order included — key order changes the serialized
- * bytes and therefore the hash) must stay identical to the ballot editor's
- * `constructJsonLdFromComment` in
+ * The CIP-100 context block. Key order is part of the hashed bytes, so it must
+ * stay identical to the ballot editor's `constructJsonLdFromComment` in
* `src/components/pages/wallet/governance/ballot/ballot.tsx`, where it is
* duplicated inside a non-exported component; converge them later.
*/
-export function buildRationaleJsonLd(comment: string): object {
- return {
+const CIP100_CONTEXT = {
+ CIP100:
+ "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0100/README.md#",
+ hashAlgorithm: "CIP100:hashAlgorithm",
+ body: {
+ "@id": "CIP100:body",
"@context": {
- CIP100:
- "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0100/README.md#",
- hashAlgorithm: "CIP100:hashAlgorithm",
- body: {
- "@id": "CIP100:body",
+ references: {
+ "@id": "CIP100:references",
+ "@container": "@set",
"@context": {
- references: {
- "@id": "CIP100:references",
- "@container": "@set",
+ GovernanceMetadata: "CIP100:GovernanceMetadataReference",
+ Other: "CIP100:OtherReference",
+ label: "CIP100:reference-label",
+ uri: "CIP100:reference-uri",
+ referenceHash: {
+ "@id": "CIP100:referenceHash",
"@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",
+ hashDigest: "CIP100:hashDigest",
+ hashAlgorithm: "CIP100:hashAlgorithm",
},
},
},
},
- authors: {
- "@id": "CIP100:authors",
- "@container": "@set",
+ comment: "CIP100:comment",
+ externalUpdates: {
+ "@id": "CIP100:externalUpdates",
"@context": {
- name: "http://xmlns.com/foaf/0.1/name",
- witness: {
- "@id": "CIP100:witness",
- "@context": {
- witnessAlgorithm: "CIP100:witnessAlgorithm",
- publicKey: "CIP100:publicKey",
- signature: "CIP100:signature",
- },
- },
+ title: "CIP100:update-title",
+ uri: "CIP100:uri",
},
},
},
- authors: [],
- body: {
- comment: comment.trim(),
+ },
+ 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;
+
+/**
+ * Builds the CIP-100 JSON-LD rationale document from a free-text comment.
+ */
+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);
+}
+
/**
- * Uploads a rationale document to IPFS (pinata proxy route) and returns the
- * anchor for the rebuilt vote.
+ * Pins an already-built rationale document to IPFS (pinata proxy route) and
+ * returns the anchor. The hash is taken over the same document object that was
+ * serialized into the request body — see the INVARIANT above.
*/
-export async function uploadRationale(
- comment: string,
-): Promise<{ anchorUrl: string; anchorDataHash: string }> {
- const doc = buildRationaleJsonLd(comment);
+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: {
@@ -94,7 +114,7 @@ export async function uploadRationale(
},
body: JSON.stringify({
pathname: `rationale/rationale-${Date.now()}.jsonld`,
- value: JSON.stringify(doc, null, 2),
+ value: payload,
}),
});
if (!response.ok) {
@@ -111,7 +131,40 @@ export async function uploadRationale(
if (!res.url) {
throw new Error("Rationale upload failed: no URL returned");
}
- return { anchorUrl: res.url, anchorDataHash: hashDrepAnchor(doc) };
+ return { url: res.url, hash: computeAnchorHash(jsonLd) };
+}
+
+/**
+ * Builds a rationale document from a comment and uploads it, returning the
+ * anchor in the shape the transaction builder threads into `txBuilder.vote()`.
+ */
+export async function uploadRationale(
+ comment: string,
+): Promise<{ anchorUrl: string; anchorDataHash: string }> {
+ const { url, hash } = await uploadRationaleToPinata(
+ buildRationaleJsonLd(comment),
+ );
+ return { anchorUrl: url, anchorDataHash: hash };
+}
+
+/**
+ * Fetches a previously pinned rationale so the editor can reload it from an
+ * anchor URL, returning the document, its comment and its recomputed hash.
+ *
+ * Goes through `fetchIpfsJson` rather than a bare `fetch`: the anchor URL is
+ * attacker-controlled input (any co-signer can store one), so IPFS references
+ * must take the server-side resolver proxy and plain URLs must be https.
+ */
+export async function loadRationaleFromUrl(url: string): Promise<{
+ json: Record;
+ comment: string;
+ hash: string;
+}> {
+ const data = await fetchIpfsJson>(url);
+ 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/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 (
+
+