diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 00000000..7155934b --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,35 @@ +# Agent instructions (Mesh Multisig) + +Project-specific context for AI coding agents. See also [.cursor/skills/multisig/SKILL.md](../.cursor/skills/multisig/SKILL.md) for the multisig Cursor skill. + +## Stack and layout + +- **Stack**: Next.js (Pages Router), TypeScript, tRPC, Prisma, PostgreSQL, Cardano (Mesh SDK). Auth: NextAuth (user) + JWT (API: wallet sign-in or bot keys). +- **API**: REST v1 under `/api/v1/*`. OpenAPI: `GET /api/swagger`. Interactive docs: `/api-docs`. +- **Key paths**: Pages in `src/pages/`, UI in `src/components/`, tRPC in `src/server/api/routers/`, REST handlers in `src/pages/api/v1/*.ts`, DB schema in `prisma/schema.prisma`. + +## Build and test + +- **Install**: `npm install` +- **Env**: Copy `.env.example` to `.env`; set `DATABASE_URL`, `JWT_SECRET`, Blockfrost keys, etc. For local DB: `docker compose -f docker-compose.dev.yml up -d postgres` +- **DB**: `npm run db:update` (format + push schema + generate client). Prisma Studio: `npm run db:studio` +- **Dev**: `npm run dev` → http://localhost:3000 +- **Lint**: `npm run lint` +- **Tests**: `npm test` or `npm run test:ci` for CI + +## Conventions + +- **Wallet ID**: UUID from DB. **Address**: Cardano payment (or stake) address. Don’t confuse them. +- **Scripts**: Use `scripts/` (e.g. `scripts/bot-ref/`). Run TS with `npx tsx`. +- **New v1 endpoint**: Add handler in `src/pages/api/v1/.ts`, apply CORS and rate limits, then add path and docs in `src/utils/swagger.ts`. If bots can call it, update the landing “Developers & Bots” section and `scripts/bot-ref/README.md` as needed. +- **Bot auth**: Implemented in `src/pages/api/v1/botAuth.ts` and `src/lib/auth/botKey.ts`, `botAccess.ts`. Bot keys created in-app (User → Create bot). One key → one `paymentAddress`; use `Authorization: Bearer ` for v1 after `POST /api/v1/botAuth`. + +## Bot integration (machine-friendly) + +- **OpenAPI**: `GET /api/swagger` (JSON). +- **Bot auth**: `POST /api/v1/botAuth` with body `{ "botKeyId", "secret", "paymentAddress" }` → `{ "token", "botId" }`. Use token as Bearer for `walletIds`, `pendingTransactions`, `freeUtxos`, `addTransaction`, `signTransaction`, etc. Reference client: `scripts/bot-ref/` (see README there). + +## Docs to keep in sync + +- Landing “Developers & Bots” section: `src/components/pages/homepage/index.tsx` (id `#developers-and-bots`). +- API/bot docs: `src/utils/swagger.ts`, `scripts/bot-ref/README.md`. diff --git a/.cursor/skills/multisig/SKILL.md b/.cursor/skills/multisig/SKILL.md new file mode 100644 index 00000000..0a61a3c1 --- /dev/null +++ b/.cursor/skills/multisig/SKILL.md @@ -0,0 +1,46 @@ +--- +name: multisig +description: Build and integrate with the Mesh Multisig (Cardano multisig wallet) codebase. Use when working on multisig wallets, bot API, v1 REST endpoints, wallet flows, governance, or Cardano treasury tooling. +--- + +# Multisig (Mesh) + +## Project overview + +- **Stack**: Next.js (Pages Router), tRPC, Prisma, Cardano (Mesh SDK). +- **Auth**: NextAuth (user) + JWT for API (wallet sign-in or bot keys). +- **API**: REST v1 under `/api/v1/*` (Swagger at `/api-docs`, spec at `/api/swagger`). + +## Key areas + +| Area | Location | Notes | +|------|----------|--------| +| Landing page | `src/components/pages/homepage/index.tsx` | Hero, features, DApps, Developers & Bots section | +| API docs (Swagger) | `src/pages/api-docs.tsx`, `src/utils/swagger.ts` | OpenAPI 3.0; add new paths in `swagger.ts` | +| Bot API | `src/pages/api/v1/botAuth.ts`, `src/lib/auth/botKey.ts`, `src/lib/auth/botAccess.ts` | Bot auth: POST `/api/v1/botAuth` with `botKeyId`, `secret`, `paymentAddress` | +| Reference bot client | `scripts/bot-ref/` | `bot-client.ts`; auth → walletIds, pendingTransactions, freeUtxos | +| Wallet flows | `src/components/pages/homepage/wallets/new-wallet-flow/`, `useWalletFlowState.tsx` | New wallet creation and invite flow | +| tRPC | `src/server/api/routers/`, `src/server/api/root.ts` | Wallets, bot routers | +| DB | `prisma/schema.prisma` | Wallet, BotKey, BotUser, etc. | + +## Bot integration (machine-friendly) + +- **OpenAPI spec (JSON)**: `GET /api/swagger` — use for codegen or automation. +- **Auth (bots)**: `POST /api/v1/botAuth` + Body: `{ "botKeyId": string, "secret": string, "paymentAddress": string, "stakeAddress"?: string }` + Response: `{ "token": string, "botId": string }`. Use `Authorization: Bearer ` for v1 endpoints. +- **Bot keys**: Created in-app (User → Create bot). One bot key can have one `paymentAddress`; same address cannot be used by another bot. +- **Scopes**: Bot keys have scope (e.g. `multisig:read`); `botAccess.ts` enforces wallet access for bots. +- **V1 endpoints used by bots**: `walletIds` (query `address` = bot’s `paymentAddress`), `pendingTransactions`, `freeUtxos`, `addTransaction`, `signTransaction`, etc. Same as wallet-authenticated calls but identity is the bot’s registered address. + +## Conventions + +- **Wallet ID**: UUID from DB; **address**: Cardano payment (or stake) address. +- **Scripts**: Reference scripts in `scripts/` (e.g. `scripts/bot-ref/`). Use `npx tsx` for TS scripts. +- **Env**: `JWT_SECRET` required for API tokens; bot keys stored hashed in DB. + +## When editing + +- Adding a new v1 endpoint: implement in `src/pages/api/v1/.ts`, add path and CORS/rate limits, then add to `src/utils/swagger.ts` and document bot usage if applicable. +- Changing bot auth or scopes: update `botAuth.ts`, `botAccess.ts`, and landing “Developers & Bots” section plus `scripts/bot-ref/README.md` if needed. +- Landing page: human and bot-friendly docs live in the “Developers & Bots” section; keep OpenAPI URL and bot auth summary accurate. diff --git a/.gitignore b/.gitignore index c24a8359..68834a99 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,11 @@ yarn-error.log* .env .env*.local +# bot ref client config (contains secret) +scripts/bot-ref/bot-config.json +# generated bot wallet for testing (mnemonic + address) +scripts/bot-ref/bot-wallet.json + # vercel .vercel diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9068df60 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# Agent instructions + +Project instructions for coding agents are in **[.agents/README.md](.agents/README.md)**. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7d9862f6..2543595e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -3,7 +3,7 @@ services: image: postgres:14-alpine container_name: multisig-postgres-dev ports: - - "5433:5432" + - "5434:5432" environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres diff --git a/docker/init-db.sh b/docker/init-db.sh index b4d7fa76..659312d0 100755 --- a/docker/init-db.sh +++ b/docker/init-db.sh @@ -3,7 +3,17 @@ set -e echo "Initializing database..." -# This script runs automatically when PostgreSQL container starts for the first time -# Add any custom initialization SQL here if needed +# Roles required by migration 20251215090000_enable_rls_disable_postgrest (Supabase-style RLS) +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN; + END IF; + END \$\$; +EOSQL echo "Database initialization complete." diff --git a/package-lock.json b/package-lock.json index 035e7fb0..3c4af8e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -209,6 +209,16 @@ } } }, + "node_modules/@auth/prisma-adapter/node_modules/@simplewebauthn/browser": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-9.0.1.tgz", + "integrity": "sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==", + "optional": true, + "peer": true, + "dependencies": { + "@simplewebauthn/types": "^9.0.1" + } + }, "node_modules/@auth/prisma-adapter/node_modules/jose": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", @@ -5608,6 +5618,14 @@ "integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==", "license": "MIT" }, + "node_modules/@simplewebauthn/types": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz", + "integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "optional": true, + "peer": true + }, "node_modules/@sinclair/typebox": { "version": "0.34.47", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", diff --git a/package.json b/package.json index dfba8858..1df4831b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:ci": "jest --ci --coverage --watchAll=false", - "analyze": "ANALYZE=true npm run build" + "analyze": "ANALYZE=true npm run build", + "apply-project": "node scripts/apply-project-to-github.mjs" }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/prisma/migrations/20250925091447_add_stake_credential_and_script_type_to_new_wallet/migration.sql b/prisma/migrations/20250925091447_add_stake_credential_and_script_type_to_new_wallet/migration.sql index 0c432d0a..7d78968e 100644 --- a/prisma/migrations/20250925091447_add_stake_credential_and_script_type_to_new_wallet/migration.sql +++ b/prisma/migrations/20250925091447_add_stake_credential_and_script_type_to_new_wallet/migration.sql @@ -1,2 +1,3 @@ -- AlterTable +ALTER TABLE "NewWallet" ADD COLUMN "stakeCredentialHash" TEXT; ALTER TABLE "NewWallet" ADD COLUMN "scriptType" TEXT; diff --git a/prisma/migrations/20260223000000_add_bot_models_and_wallet_owner/migration.sql b/prisma/migrations/20260223000000_add_bot_models_and_wallet_owner/migration.sql new file mode 100644 index 00000000..99edbbc8 --- /dev/null +++ b/prisma/migrations/20260223000000_add_bot_models_and_wallet_owner/migration.sql @@ -0,0 +1,60 @@ +-- AlterTable +ALTER TABLE "Wallet" ADD COLUMN IF NOT EXISTS "ownerAddress" TEXT; + +-- CreateTable +CREATE TABLE "BotKey" ( + "id" TEXT NOT NULL, + "ownerAddress" TEXT NOT NULL, + "name" TEXT NOT NULL, + "keyHash" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BotKey_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BotUser" ( + "id" TEXT NOT NULL, + "botKeyId" TEXT NOT NULL, + "paymentAddress" TEXT NOT NULL, + "stakeAddress" TEXT, + "displayName" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BotUser_pkey" PRIMARY KEY ("id") +); + +-- CreateEnum +CREATE TYPE "BotWalletRole" AS ENUM ('cosigner', 'observer'); + +-- CreateTable +CREATE TABLE "WalletBotAccess" ( + "walletId" TEXT NOT NULL, + "botId" TEXT NOT NULL, + "role" "BotWalletRole" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "WalletBotAccess_pkey" PRIMARY KEY ("walletId","botId") +); + +-- CreateIndex +CREATE INDEX "BotKey_ownerAddress_idx" ON "BotKey"("ownerAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "BotUser_botKeyId_key" ON "BotUser"("botKeyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "BotUser_paymentAddress_key" ON "BotUser"("paymentAddress"); + +-- CreateIndex +CREATE INDEX "BotUser_paymentAddress_idx" ON "BotUser"("paymentAddress"); + +-- CreateIndex +CREATE INDEX "WalletBotAccess_walletId_idx" ON "WalletBotAccess"("walletId"); + +-- CreateIndex +CREATE INDEX "WalletBotAccess_botId_idx" ON "WalletBotAccess"("botId"); + +-- AddForeignKey +ALTER TABLE "BotUser" ADD CONSTRAINT "BotUser_botKeyId_fkey" FOREIGN KEY ("botKeyId") REFERENCES "BotKey"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260226000000_add_ballot_rationale_comments/migration.sql b/prisma/migrations/20260226000000_add_ballot_rationale_comments/migration.sql new file mode 100644 index 00000000..0cf09c13 --- /dev/null +++ b/prisma/migrations/20260226000000_add_ballot_rationale_comments/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "Ballot" +ADD COLUMN "rationaleComments" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3c206a8e..41ce09bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,7 @@ model Wallet { rawImportBodies Json? migrationTargetWalletId String? profileImageIpfsUrl String? + ownerAddress String? } model Transaction { @@ -112,8 +113,10 @@ model Ballot { choices String[] anchorUrls String[] @default([]) anchorHashes String[] @default([]) + rationaleComments String[] @default([]) type Int createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Proxy { @@ -178,3 +181,43 @@ model Contact { @@index([walletId]) @@index([address]) } + +model BotKey { + id String @id @default(cuid()) + ownerAddress String // Human creator + name String + keyHash String + scope String // JSON array e.g. ["multisig:create","multisig:read","multisig:sign"] + createdAt DateTime @default(now()) + botUser BotUser? + + @@index([ownerAddress]) +} + +model BotUser { + id String @id @default(cuid()) + botKeyId String @unique + paymentAddress String @unique // One bot, one address + stakeAddress String? + displayName String? + createdAt DateTime @default(now()) + botKey BotKey @relation(fields: [botKeyId], references: [id], onDelete: Cascade) + + @@index([paymentAddress]) +} + +enum BotWalletRole { + cosigner + observer +} + +model WalletBotAccess { + walletId String + botId String + role BotWalletRole + createdAt DateTime @default(now()) + + @@unique([walletId, botId]) + @@index([walletId]) + @@index([botId]) +} diff --git a/scripts/bot-ref/README.md b/scripts/bot-ref/README.md new file mode 100644 index 00000000..45938cfb --- /dev/null +++ b/scripts/bot-ref/README.md @@ -0,0 +1,150 @@ +# Reference bot client + +Minimal client to test the multisig v1 bot API. Use it from the Cursor agent or locally. + +## Config + +One JSON blob (from the "Create bot" UI or manually): + +```json +{ + "baseUrl": "http://localhost:3000", + "botKeyId": "", + "secret": "", + "paymentAddress": "" +} +``` + +- **baseUrl**: API base (e.g. `http://localhost:3000` for dev). +- **botKeyId** / **secret**: From the Create bot dialog (copy the JSON blob, fill `paymentAddress`). +- **paymentAddress**: The bot’s **own** Cardano payment address (a wallet the bot controls, not the owner’s address). One bot, one address. Required for `auth` and for all authenticated calls. + +Provide config in one of these ways: + +1. **Env** + `BOT_CONFIG='{"baseUrl":"http://localhost:3000","botKeyId":"...","secret":"...","paymentAddress":"addr1_..."}'` + +2. **File** + Save the JSON as `bot-config.json` in the current directory, or set `BOT_CONFIG_PATH` to the file path. + +## Commands + +From repo root (or from `scripts/bot-ref` with config in cwd): + +```bash +cd scripts/bot-ref +npm install +``` + +### 1. Register / get token + +```bash +BOT_CONFIG='{"baseUrl":"http://localhost:3000","botKeyId":"YOUR_KEY","secret":"YOUR_SECRET","paymentAddress":"addr1_xxx"}' npx tsx bot-client.ts auth +``` + +Or with a config file: + +```bash +# bot-config.json has baseUrl, botKeyId, secret, paymentAddress +npx tsx bot-client.ts auth +``` + +Prints `{ "token": "...", "botId": "..." }`. Set `BOT_TOKEN` to the token for the next steps. + +### 2. List wallet IDs + +```bash +export BOT_TOKEN='' +# BOT_CONFIG or bot-config.json must still have baseUrl and paymentAddress +npx tsx bot-client.ts walletIds +``` + +### 3. Pending transactions + +```bash +npx tsx bot-client.ts pendingTransactions +``` + +### 4. Free UTxOs + +```bash +npx tsx bot-client.ts freeUtxos +``` + +### 5. Bot “me” (owner address) + +```bash +npx tsx bot-client.ts botMe +``` + +Returns the bot’s own info: `botId`, `paymentAddress`, `displayName`, `botName`, **`ownerAddress`** (the address of the human who created the bot). No `paymentAddress` in config needed for this command. + +### 6. Owner info + +```bash +npx tsx bot-client.ts ownerInfo +``` + +Returns `ownerAddress`, `type` (`user` | `bot` | `all` | null), and optional `user` or `bot` details. + +### 7. Create wallet (API) + +The bot must have the **multisig:create** scope. Create a JSON payload with at least `name` and `signersAddresses`, then: + +```bash +# From file +npx tsx bot-client.ts createWallet create-wallet-payload.json + +# From stdin +echo '{"name":"Me and Bot","signersAddresses":["addr1_your...","addr1_bot..."],"numRequiredSigners":2}' | npx tsx bot-client.ts createWallet +``` + +Optional fields: `description`, `signersDescriptions`, `signersStakeKeys`, `signersDRepKeys`, `numRequiredSigners`, `scriptType` (`atLeast`|`all`|`any`), `stakeCredentialHash`, `network` (0=testnet, 1=mainnet). + +### 8. Generate a bot wallet (testing) + +From **repo root**: `npx tsx scripts/bot-ref/generate-bot-wallet.ts` — creates gitignored `bot-wallet.json` (mnemonic + address) and updates `bot-config.json`. + +### 9. Create “Me and Bot” 2-of-2 wallet + +```bash +cd scripts/bot-ref && npx tsx create-wallet-us.ts +``` + +Uses the owner’s address from `botMe` and the bot’s address from config. **The bot must have its own wallet and address** (not the same as the owner). Set `paymentAddress` in `bot-config.json` to the bot’s Cardano address, register it with POST /api/v1/botAuth, then run the script. + +## Cursor agent testing + +1. Create a bot in the app (User page → Create bot). Copy the JSON blob and add the bot’s `paymentAddress`. +2. Save as `scripts/bot-ref/bot-config.json` (or pass via `BOT_CONFIG`). +3. Run auth and use the token: + +```bash +cd /path/to/multisig/scripts/bot-ref +BOT_CONFIG_PATH=bot-config.json npx tsx bot-client.ts auth +# Then for walletIds (set BOT_TOKEN from auth output): +BOT_TOKEN='...' BOT_CONFIG_PATH=bot-config.json npx tsx bot-client.ts walletIds +``` + +The reference client only uses **bot-key auth** (POST /api/v1/botAuth). Wallet-based auth (getNonce + sign + authSigner) would require a real Cardano signer; implement that in your bot if needed. + +## Governance bot flow + +For governance automation, grant these bot scopes when creating the bot key: + +- `governance:read` to call `GET /api/v1/governanceActiveProposals` +- `ballot:write` to call `POST /api/v1/botBallotsUpsert` + +Typical sequence: + +1. `POST /api/v1/botAuth` -> get bot JWT +2. `GET /api/v1/governanceActiveProposals?network=0|1&details=false` +3. Bot decides `Yes`/`No`/`Abstain` + optional `rationaleComment` +4. `POST /api/v1/botBallotsUpsert` with `{ walletId, ballotId|ballotName, proposals[] }` +5. Human reviews draft rationale in UI and uploads to IPFS via the existing "Upload to IPFS & Save" action + +Notes: + +- `proposalId` format is `#`. +- Bots cannot set `anchorUrl` or `anchorHash`; only `rationaleComment` draft text is accepted. +- If `ballotName` matches multiple governance ballots, the API returns `409`; use `ballotId` to disambiguate. diff --git a/scripts/bot-ref/bot-client.ts b/scripts/bot-ref/bot-client.ts new file mode 100644 index 00000000..5803b691 --- /dev/null +++ b/scripts/bot-ref/bot-client.ts @@ -0,0 +1,315 @@ +/** + * Reference bot client for the multisig v1 API. + * Load config from BOT_CONFIG (JSON string), BOT_CONFIG_PATH (file path), or bot-config.json in cwd. + * Used by Cursor agent and local scripts to test bot flows. + * + * Usage: + * BOT_CONFIG='{"baseUrl":"http://localhost:3000","botKeyId":"...","secret":"...","paymentAddress":"addr1_..."}' npx tsx bot-client.ts auth + * npx tsx bot-client.ts walletIds + * npx tsx bot-client.ts pendingTransactions + */ + +export type BotConfig = { + baseUrl: string; + botKeyId: string; + secret: string; + paymentAddress: string; +}; + +export async function loadConfig(): Promise { + const fromEnv = process.env.BOT_CONFIG; + if (fromEnv) { + try { + return JSON.parse(fromEnv) as BotConfig; + } catch (e) { + throw new Error("BOT_CONFIG is invalid JSON: " + (e as Error).message); + } + } + const path = process.env.BOT_CONFIG_PATH ?? "bot-config.json"; + const { readFileSync } = await import("fs"); + const { join } = await import("path"); + const fullPath = path.startsWith("/") ? path : join(process.cwd(), path); + try { + const raw = readFileSync(fullPath, "utf8"); + return JSON.parse(raw) as BotConfig; + } catch (e) { + throw new Error(`Failed to load config from ${path}: ${(e as Error).message}`); + } +} + +function ensureSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +/** Authenticate with bot key + payment address; returns JWT. */ +export async function botAuth(config: BotConfig): Promise<{ token: string; botId: string }> { + const base = ensureSlash(config.baseUrl); + const res = await fetch(`${base}/api/v1/botAuth`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + botKeyId: config.botKeyId, + secret: config.secret, + paymentAddress: config.paymentAddress, + }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`botAuth failed ${res.status}: ${text}`); + } + const data = (await res.json()) as { token: string; botId: string }; + return { token: data.token, botId: data.botId }; +} + +/** Get wallet IDs for the bot (requires prior auth; pass JWT). */ +export async function getWalletIds(baseUrl: string, token: string, address: string): Promise<{ walletId: string; walletName: string }[]> { + const base = ensureSlash(baseUrl); + const res = await fetch(`${base}/api/v1/walletIds?address=${encodeURIComponent(address)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`walletIds failed ${res.status}: ${await res.text()}`); + return (await res.json()) as { walletId: string; walletName: string }[]; +} + +/** Get pending transactions for a wallet. */ +export async function getPendingTransactions( + baseUrl: string, + token: string, + walletId: string, + address: string, +): Promise { + const base = ensureSlash(baseUrl); + const res = await fetch( + `${base}/api/v1/pendingTransactions?walletId=${encodeURIComponent(walletId)}&address=${encodeURIComponent(address)}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!res.ok) throw new Error(`pendingTransactions failed ${res.status}: ${await res.text()}`); + return (await res.json()) as unknown[]; +} + +/** Get free UTxOs for a wallet. */ +export async function getFreeUtxos( + baseUrl: string, + token: string, + walletId: string, + address: string, +): Promise { + const base = ensureSlash(baseUrl); + const res = await fetch( + `${base}/api/v1/freeUtxos?walletId=${encodeURIComponent(walletId)}&address=${encodeURIComponent(address)}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!res.ok) throw new Error(`freeUtxos failed ${res.status}: ${await res.text()}`); + return (await res.json()) as unknown[]; +} + +/** Get the bot's own info including owner's address (bot JWT only). */ +export async function getBotMe( + baseUrl: string, + token: string, +): Promise<{ + botId: string; + paymentAddress: string; + displayName: string | null; + botName: string; + ownerAddress: string; +}> { + const base = ensureSlash(baseUrl); + const res = await fetch(`${base}/api/v1/botMe`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`botMe failed ${res.status}: ${await res.text()}`); + return (await res.json()) as { + botId: string; + paymentAddress: string; + displayName: string | null; + botName: string; + ownerAddress: string; + }; +} + +/** Get owner info for a wallet (requires access). */ +export async function getOwnerInfo( + baseUrl: string, + token: string, + walletId: string, +): Promise<{ + ownerAddress: string | null; + type: "user" | "bot" | "all" | null; + user: { address: string; stakeAddress: string } | null; + bot: { botId: string; paymentAddress: string; displayName: string | null; botName: string } | null; +}> { + const base = ensureSlash(baseUrl); + const res = await fetch( + `${base}/api/v1/ownerInfo?walletId=${encodeURIComponent(walletId)}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!res.ok) throw new Error(`ownerInfo failed ${res.status}: ${await res.text()}`); + return (await res.json()) as { + ownerAddress: string | null; + type: "user" | "bot" | "all" | null; + user: { address: string; stakeAddress: string } | null; + bot: { botId: string; paymentAddress: string; displayName: string | null; botName: string } | null; + }; +} + +/** Create a new multisig wallet (bot must have multisig:create scope). */ +export async function createWallet( + baseUrl: string, + token: string, + body: { + name: string; + description?: string; + signersAddresses: string[]; + signersDescriptions?: string[]; + signersStakeKeys?: (string | null)[]; + signersDRepKeys?: (string | null)[]; + numRequiredSigners?: number; + scriptType?: "atLeast" | "all" | "any"; + stakeCredentialHash?: string; + network?: number; + }, +): Promise<{ walletId: string; address: string; name: string }> { + const base = ensureSlash(baseUrl); + const res = await fetch(`${base}/api/v1/createWallet`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`createWallet failed ${res.status}: ${text}`); + } + return (await res.json()) as { walletId: string; address: string; name: string }; +} + +async function main() { + const config = await loadConfig(); + const cmd = process.argv[2]; + if (!cmd) { + console.error("Usage: bot-client.ts [args]"); + console.error(" auth - register/login and print token"); + console.error(" walletIds - list wallet IDs (requires auth first; set BOT_TOKEN)"); + console.error(" pendingTransactions "); + console.error(" freeUtxos "); + console.error(" ownerInfo - get wallet owner info"); + console.error(" botMe - get bot's own info (incl. owner address)"); + console.error(" createWallet [file] - create wallet via API (body from file or stdin); bot needs multisig:create"); + console.error("Env: BOT_CONFIG (JSON), BOT_CONFIG_PATH, BOT_TOKEN (after auth)."); + process.exit(1); + } + + if (cmd === "auth") { + if (!config.paymentAddress) { + console.error("paymentAddress is required in config for auth."); + process.exit(1); + } + const { token, botId } = await botAuth(config); + console.log(JSON.stringify({ token, botId }, null, 2)); + console.error("Set BOT_TOKEN to the token above for subsequent calls."); + return; + } + + const token = process.env.BOT_TOKEN; + if (!token) { + console.error("BOT_TOKEN required. Run 'auth' first and set BOT_TOKEN."); + process.exit(1); + } + + if (cmd === "botMe") { + const info = await getBotMe(config.baseUrl, token); + console.log(JSON.stringify(info, null, 2)); + return; + } + + const address = config.paymentAddress; + if (!address) { + console.error("paymentAddress required in config."); + process.exit(1); + } + + switch (cmd) { + case "walletIds": { + const list = await getWalletIds(config.baseUrl, token, address); + console.log(JSON.stringify(list, null, 2)); + break; + } + case "pendingTransactions": { + const walletId = process.argv[3]; + if (!walletId) { + console.error("Usage: bot-client.ts pendingTransactions "); + process.exit(1); + } + const list = await getPendingTransactions(config.baseUrl, token, walletId, address); + console.log(JSON.stringify(list, null, 2)); + break; + } + case "freeUtxos": { + const walletId = process.argv[3]; + if (!walletId) { + console.error("Usage: bot-client.ts freeUtxos "); + process.exit(1); + } + const list = await getFreeUtxos(config.baseUrl, token, walletId, address); + console.log(JSON.stringify(list, null, 2)); + break; + } + case "ownerInfo": { + const walletId = process.argv[3]; + if (!walletId) { + console.error("Usage: bot-client.ts ownerInfo "); + process.exit(1); + } + const info = await getOwnerInfo(config.baseUrl, token, walletId); + console.log(JSON.stringify(info, null, 2)); + break; + } + case "createWallet": { + const fileArg = process.argv[3]; + let raw: string; + if (fileArg) { + const { readFileSync } = await import("fs"); + const { join } = await import("path"); + raw = readFileSync(fileArg.startsWith("/") ? fileArg : join(process.cwd(), fileArg), "utf8"); + } else { + const { createInterface } = await import("readline"); + const rl = createInterface({ input: process.stdin, terminal: false }); + const lines: string[] = []; + for await (const line of rl) lines.push(line); + raw = lines.join("\n"); + } + const body = JSON.parse(raw) as { name: string; signersAddresses: string[]; [k: string]: unknown }; + if (!body.name || !Array.isArray(body.signersAddresses) || body.signersAddresses.length === 0) { + console.error("Body must have name (string) and signersAddresses (non-empty string array)."); + process.exit(1); + } + const result = await createWallet(config.baseUrl, token, { + name: body.name, + description: body.description as string | undefined, + signersAddresses: body.signersAddresses, + signersDescriptions: body.signersDescriptions as string[] | undefined, + signersStakeKeys: body.signersStakeKeys as (string | null)[] | undefined, + signersDRepKeys: body.signersDRepKeys as (string | null)[] | undefined, + numRequiredSigners: body.numRequiredSigners as number | undefined, + scriptType: body.scriptType as "atLeast" | "all" | "any" | undefined, + stakeCredentialHash: body.stakeCredentialHash as string | undefined, + network: body.network as number | undefined, + }); + console.log(JSON.stringify(result, null, 2)); + break; + } + default: + console.error("Unknown command:", cmd); + process.exit(1); + } +} + +if (process.argv[1]?.includes("bot-client")) { + main().catch((e) => { + console.error(e); + process.exit(1); + }); +} diff --git a/scripts/bot-ref/bot-config.sample.json b/scripts/bot-ref/bot-config.sample.json new file mode 100644 index 00000000..b04538ca --- /dev/null +++ b/scripts/bot-ref/bot-config.sample.json @@ -0,0 +1,6 @@ +{ + "baseUrl": "http://localhost:3000", + "botKeyId": "", + "secret": "", + "paymentAddress": "" +} diff --git a/scripts/bot-ref/create-wallet-us.ts b/scripts/bot-ref/create-wallet-us.ts new file mode 100644 index 00000000..338bc449 --- /dev/null +++ b/scripts/bot-ref/create-wallet-us.ts @@ -0,0 +1,85 @@ +/** + * Create a 2-of-2 multisig "Owner + Bot" via the bot API. + * The bot gets the owner's address from GET /api/v1/botMe (you are the bot's owner). + * Usage: npx tsx create-wallet-us.ts + */ +import { loadConfig, botAuth, createWallet, getOwnerInfo, getBotMe } from "./bot-client"; + +async function main() { + const config = await loadConfig(); + if (!config.paymentAddress) { + console.error("bot-config must have paymentAddress (bot's address)."); + process.exit(1); + } + + console.error("Authenticating bot..."); + const { token } = await botAuth(config); + + console.error("Fetching bot info (owner address)..."); + const botMe = await getBotMe(config.baseUrl, token); + const ownerAddress = botMe.ownerAddress; + if (!ownerAddress || !ownerAddress.startsWith("addr")) { + console.error("Bot has no valid owner address. Ensure the bot was created by a connected wallet."); + process.exit(1); + } + // Use bot address from config (so you can set a real address in bot-config.json); + // call POST /api/v1/botAuth with that address once so the bot can sign. + const botAddress = config.paymentAddress; + const looksLikePlaceholder = /addr_test1qpx+x/.test(botAddress) || (botAddress.includes("xxx") && botAddress.length > 80); + if (looksLikePlaceholder) { + const base = config.baseUrl.replace(/\/$/, ""); + console.error("Bot address in config looks like a placeholder (invalid)."); + console.error("The bot must have its own wallet and address. Set paymentAddress in bot-config.json to the bot's Cardano address (not the owner's), then register it:"); + console.error(` curl -X POST "${base}/api/v1/botAuth" -H "Content-Type: application/json" -d '{"botKeyId":"${config.botKeyId}","secret":"","paymentAddress":""}'`); + console.error("Then run this script again."); + process.exit(1); + } + if (ownerAddress === botAddress) { + console.error("The bot must have its own wallet and address, not the same as the owner."); + console.error("Right now paymentAddress in bot-config.json is the same as the owner's address."); + console.error("Set paymentAddress to a different Cardano address (a wallet the bot controls), then:"); + console.error(" 1. POST /api/v1/botAuth with { botKeyId, secret, paymentAddress: '' }"); + console.error(" 2. Run this script again."); + process.exit(1); + } + console.error("Owner:", ownerAddress, "| Bot:", botAddress); + + console.error("Creating 2-of-2 wallet (owner + bot)..."); + let result: { walletId: string; address: string; name: string }; + try { + result = await createWallet(config.baseUrl, token, { + name: "Me and Bot", + description: "2-of-2 multisig created via bot API (owner + bot)", + signersAddresses: [ownerAddress, botAddress], + signersDescriptions: ["Owner", "Bot"], + numRequiredSigners: 2, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("index 1") || msg.includes("Invalid payment address")) { + console.error(""); + console.error("The bot's payment address in bot-config.json is not a valid Cardano address."); + console.error("Do this:"); + console.error(" 1. Set paymentAddress in bot-config.json to a real address (the bot's wallet)."); + console.error(" 2. Register it once: POST /api/v1/botAuth with { botKeyId, secret, paymentAddress }."); + console.error(" 3. Run this script again."); + } + throw err; + } + + console.log(JSON.stringify(result, null, 2)); + console.error("Done. Wallet ID:", result.walletId, "Address:", result.address); + + console.error("Owner info:"); + try { + const ownerInfo = await getOwnerInfo(config.baseUrl, token, result.walletId); + console.log(JSON.stringify(ownerInfo, null, 2)); + } catch (e) { + console.error("(ownerInfo failed:", (e as Error).message + ")"); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/bot-ref/generate-bot-wallet.ts b/scripts/bot-ref/generate-bot-wallet.ts new file mode 100644 index 00000000..d48124d2 --- /dev/null +++ b/scripts/bot-ref/generate-bot-wallet.ts @@ -0,0 +1,55 @@ +/** + * Generate a fresh Cardano wallet for the bot (testing only). + * Writes scripts/bot-ref/bot-wallet.json (gitignored) and updates bot-config.json paymentAddress. + * Run from repo root: npx tsx scripts/bot-ref/generate-bot-wallet.ts + */ +import { writeFileSync, readFileSync, existsSync } from "fs"; +import { join } from "path"; + +async function main() { + const { MeshWallet } = await import("@meshsdk/core"); + const mnemonic = MeshWallet.brew() as string[]; + const networkId = 1; // mainnet; use 0 for testnet + const wallet = new MeshWallet({ + networkId, + key: { type: "mnemonic", words: mnemonic }, + }); + await wallet.init(); + const paymentAddress = await wallet.getChangeAddress(); + + const repoRoot = process.cwd(); + const botRefDir = join(repoRoot, "scripts", "bot-ref"); + const walletPath = join(botRefDir, "bot-wallet.json"); + const configPath = join(botRefDir, "bot-config.json"); + + writeFileSync( + walletPath, + JSON.stringify( + { + mnemonic, + paymentAddress, + networkId, + _comment: "Generated for testing. Gitignored. Do not commit.", + }, + null, + 2, + ) + "\n", + "utf8", + ); + console.error("Wrote", walletPath); + + if (existsSync(configPath)) { + const config = JSON.parse(readFileSync(configPath, "utf8")) as Record; + config.paymentAddress = paymentAddress; + writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); + console.error("Updated bot-config.json paymentAddress"); + } + + console.error("Bot payment address:", paymentAddress); + console.error("Run: npx tsx create-wallet-us.ts (after POST /api/v1/botAuth with this address)"); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/bot-ref/package.json b/scripts/bot-ref/package.json new file mode 100644 index 00000000..3d266c2f --- /dev/null +++ b/scripts/bot-ref/package.json @@ -0,0 +1,16 @@ +{ + "name": "multisig-bot-ref", + "version": "1.0.0", + "description": "Reference bot client for multisig v1 API (Cursor agent testing)", + "type": "module", + "scripts": { + "auth": "tsx bot-client.ts auth", + "walletIds": "tsx bot-client.ts walletIds", + "pending": "tsx bot-client.ts pendingTransactions", + "freeUtxos": "tsx bot-client.ts freeUtxos" + }, + "devDependencies": { + "tsx": "^4.7.0" + }, + "engines": { "node": ">=18.0.0" } +} diff --git a/src/__tests__/botBallotsUpsert.test.ts b/src/__tests__/botBallotsUpsert.test.ts new file mode 100644 index 00000000..1d487b77 --- /dev/null +++ b/src/__tests__/botBallotsUpsert.test.ts @@ -0,0 +1,201 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; + +const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); +const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>(); +const applyBotRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, botId: string) => boolean>(); +const enforceBodySizeMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean>(); +const verifyJwtMock = jest.fn(); +const isBotJwtMock = jest.fn(); +const assertBotWalletAccessMock = jest.fn(); +const findBotUserMock = jest.fn(); +const transactionMock = jest.fn(); +const parseScopeMock = jest.fn(); +const scopeIncludesMock = jest.fn(); +const isValidChoiceMock = jest.fn(); +const parseProposalIdMock = jest.fn(); + +const txMock = { + ballot: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + updateMany: jest.fn(), + }, +}; + +jest.mock( + "@/lib/cors", + () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, + cors: corsMock, + }), + { virtual: true }, +); + +jest.mock( + "@/lib/security/requestGuards", + () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, + applyBotRateLimit: applyBotRateLimitMock, + enforceBodySize: enforceBodySizeMock, + }), + { virtual: true }, +); + +jest.mock( + "@/lib/verifyJwt", + () => ({ + __esModule: true, + verifyJwt: verifyJwtMock, + isBotJwt: isBotJwtMock, + }), + { virtual: true }, +); + +jest.mock( + "@/lib/governance", + () => ({ + __esModule: true, + isValidChoice: isValidChoiceMock, + parseProposalId: parseProposalIdMock, + }), + { virtual: true }, +); + +jest.mock( + "@/lib/auth/botKey", + () => ({ + __esModule: true, + parseScope: parseScopeMock, + scopeIncludes: scopeIncludesMock, + }), + { virtual: true }, +); + +jest.mock( + "@/lib/auth/botAccess", + () => ({ + __esModule: true, + assertBotWalletAccess: assertBotWalletAccessMock, + }), + { virtual: true }, +); + +jest.mock( + "@/server/db", + () => ({ + __esModule: true, + db: { + botUser: { + findUnique: findBotUserMock, + }, + $transaction: transactionMock, + }, + }), + { virtual: true }, +); + +type ResponseMock = NextApiResponse & { statusCode?: number }; + +function createMockResponse(): ResponseMock { + const res = { + statusCode: undefined as number | undefined, + status: jest.fn<(code: number) => NextApiResponse>(), + json: jest.fn<(payload: unknown) => unknown>(), + end: jest.fn<() => void>(), + setHeader: jest.fn<(name: string, value: string) => void>(), + }; + + res.status.mockImplementation((code: number) => { + res.statusCode = code; + return res as unknown as NextApiResponse; + }); + res.json.mockImplementation((payload: unknown) => payload); + return res as unknown as ResponseMock; +} + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/v1/botBallotsUpsert")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + applyRateLimitMock.mockReturnValue(true); + applyBotRateLimitMock.mockReturnValue(true); + enforceBodySizeMock.mockReturnValue(true); + corsMock.mockResolvedValue(undefined); + verifyJwtMock.mockReturnValue({ address: "addr_test1", botId: "bot-1", type: "bot" }); + isBotJwtMock.mockReturnValue(true); + parseScopeMock.mockImplementation((scope: string) => JSON.parse(scope)); + scopeIncludesMock.mockImplementation((scopes: string[], required: string) => + scopes.includes(required), + ); + isValidChoiceMock.mockReturnValue(true); + parseProposalIdMock.mockImplementation((value: string) => { + const [txHash, certIndex] = value.split("#"); + return { txHash, certIndex: Number(certIndex) }; + }); + findBotUserMock.mockResolvedValue({ + id: "bot-1", + botKey: { scope: JSON.stringify(["multisig:read", "ballot:write"]) }, + }); + assertBotWalletAccessMock.mockResolvedValue({ wallet: { id: "wallet-1" }, role: "cosigner" }); + transactionMock.mockImplementation(async (cb: any) => cb(txMock)); +}); + +describe("botBallotsUpsert API", () => { + it("rejects anchor fields in proposal payload", async () => { + const req = { + method: "POST", + headers: { authorization: "Bearer token" }, + body: { + walletId: "wallet-1", + proposals: [ + { + proposalId: "tx#0", + proposalTitle: "Title", + choice: "Yes", + anchorUrl: "ipfs://should-not-be-allowed", + }, + ], + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(transactionMock).not.toHaveBeenCalled(); + }); + + it("returns 409 when ballotName is ambiguous", async () => { + txMock.ballot.findMany.mockResolvedValue([ + { id: "b1", walletId: "wallet-1", type: 1, description: "Gov", updatedAt: new Date() }, + { id: "b2", walletId: "wallet-1", type: 1, description: "Gov", updatedAt: new Date() }, + ]); + + const req = { + method: "POST", + headers: { authorization: "Bearer token" }, + body: { + walletId: "wallet-1", + ballotName: "Gov", + proposals: [{ proposalId: "tx#0", proposalTitle: "Title", choice: "No" }], + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ + error: "Multiple ballots match ballotName; provide ballotId to disambiguate", + }); + }); +}); diff --git a/src/__tests__/governanceActiveProposals.test.ts b/src/__tests__/governanceActiveProposals.test.ts new file mode 100644 index 00000000..cccafea1 --- /dev/null +++ b/src/__tests__/governanceActiveProposals.test.ts @@ -0,0 +1,203 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; + +const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); +const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>(); +const applyBotRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, botId: string) => boolean>(); +const verifyJwtMock = jest.fn(); +const isBotJwtMock = jest.fn(); +const findBotUserMock = jest.fn(); +const providerGetMock = jest.fn(); +const parseScopeMock = jest.fn(); +const scopeIncludesMock = jest.fn(); +const getProposalStatusMock = jest.fn(); + +jest.mock( + "@/lib/cors", + () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, + cors: corsMock, + }), + { virtual: true }, +); + +jest.mock( + "@/lib/security/requestGuards", + () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, + applyBotRateLimit: applyBotRateLimitMock, + }), + { virtual: true }, +); + +jest.mock( + "@/lib/verifyJwt", + () => ({ + __esModule: true, + verifyJwt: verifyJwtMock, + isBotJwt: isBotJwtMock, + }), + { virtual: true }, +); + +jest.mock( + "@/lib/governance", + () => ({ + __esModule: true, + getProposalStatus: getProposalStatusMock, + }), + { virtual: true }, +); + +jest.mock( + "@/lib/auth/botKey", + () => ({ + __esModule: true, + parseScope: parseScopeMock, + scopeIncludes: scopeIncludesMock, + }), + { virtual: true }, +); + +jest.mock( + "@/server/db", + () => ({ + __esModule: true, + db: { + botUser: { + findUnique: findBotUserMock, + }, + }, + }), + { virtual: true }, +); + +jest.mock( + "@/utils/get-provider", + () => ({ + __esModule: true, + getProvider: () => ({ + get: providerGetMock, + }), + }), + { virtual: true }, +); + +type ResponseMock = NextApiResponse & { statusCode?: number }; + +function createMockResponse(): ResponseMock { + const res = { + statusCode: undefined as number | undefined, + status: jest.fn<(code: number) => NextApiResponse>(), + json: jest.fn<(payload: unknown) => unknown>(), + end: jest.fn<() => void>(), + setHeader: jest.fn<(name: string, value: string) => void>(), + }; + + res.status.mockImplementation((code: number) => { + res.statusCode = code; + return res as unknown as NextApiResponse; + }); + res.json.mockImplementation((payload: unknown) => payload); + return res as unknown as ResponseMock; +} + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/v1/governanceActiveProposals")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + applyRateLimitMock.mockReturnValue(true); + applyBotRateLimitMock.mockReturnValue(true); + corsMock.mockResolvedValue(undefined); + verifyJwtMock.mockReturnValue({ address: "addr_test1", botId: "bot-1", type: "bot" }); + isBotJwtMock.mockReturnValue(true); + parseScopeMock.mockImplementation((scope: string) => JSON.parse(scope)); + scopeIncludesMock.mockImplementation((scopes: string[], required: string) => + scopes.includes(required), + ); + getProposalStatusMock.mockImplementation((details: any) => { + if (details.enacted_epoch || details.dropped_epoch || details.expired_epoch || details.ratified_epoch) { + return "ratified"; + } + return "active"; + }); + findBotUserMock.mockResolvedValue({ + id: "bot-1", + botKey: { scope: JSON.stringify(["multisig:read", "governance:read"]) }, + }); +}); + +describe("governanceActiveProposals API", () => { + it("returns 401 when token is missing", async () => { + const req = { method: "GET", headers: {}, query: {} } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: "Unauthorized - Missing token" }); + }); + + it("returns only active proposals and tolerates metadata 404", async () => { + providerGetMock.mockImplementation(async (path: string) => { + if (path.startsWith("/governance/proposals?")) { + return [ + { + tx_hash: "tx-active", + cert_index: 0, + governance_type: "hard_fork_initiation", + enacted_epoch: null, + dropped_epoch: null, + expired_epoch: null, + ratified_epoch: null, + }, + { + tx_hash: "tx-ratified", + cert_index: 1, + governance_type: "info_action", + enacted_epoch: null, + dropped_epoch: null, + expired_epoch: null, + ratified_epoch: 530, + }, + ]; + } + if (path === "/governance/proposals/tx-active/0/metadata") { + const error = new Error("404") as Error & { status?: number }; + error.status = 404; + throw error; + } + return null; + }); + + const req = { + method: "GET", + headers: { authorization: "Bearer token" }, + query: { network: "1", count: "100", page: "1", order: "desc" }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + const payload = res.json.mock.calls[0]?.[0] as any; + expect(Array.isArray(payload.proposals)).toBe(true); + expect(payload.proposals).toHaveLength(1); + expect(payload.proposals[0]).toMatchObject({ + proposalId: "tx-active#0", + status: "active", + title: null, + abstract: null, + motivation: null, + rationale: null, + authors: [], + }); + }); +}); diff --git a/src/components/multisig/proxy/offchain.ts b/src/components/multisig/proxy/offchain.ts index ca8944f1..a38ce957 100644 --- a/src/components/multisig/proxy/offchain.ts +++ b/src/components/multisig/proxy/offchain.ts @@ -8,6 +8,7 @@ import { import type { UTxO, MeshTxBuilder } from "@meshsdk/core"; // import { parseDatumCbor } from "@meshsdk/core-cst"; import { DREP_DEPOSIT_STRING } from "@/utils/protocol-deposit-constants"; +import { parseProposalId } from "@/lib/governance"; import { MeshTxInitiator } from "./common"; import type { MeshTxInitiatorInput } from "./common"; @@ -912,8 +913,13 @@ export class MeshProxyContract extends MeshTxInitiator { // 5. Add votes for each proposal for (const vote of votes) { - const [txHash, certIndex] = vote.proposalId.split("#"); - if (!txHash || certIndex === undefined) { + let txHash = ""; + let certIndex = 0; + try { + const parsed = parseProposalId(vote.proposalId); + txHash = parsed.txHash; + certIndex = parsed.certIndex; + } catch { throw new Error(`Invalid proposal ID format: ${vote.proposalId}`); } @@ -926,7 +932,7 @@ export class MeshProxyContract extends MeshTxInitiator { }, { txHash: txHash, - txIndex: parseInt(certIndex), + txIndex: certIndex, }, { voteKind: vote.voteKind, diff --git a/src/components/pages/homepage/features/index.tsx b/src/components/pages/homepage/features/index.tsx index cfd3f2f6..7bf670e3 100644 --- a/src/components/pages/homepage/features/index.tsx +++ b/src/components/pages/homepage/features/index.tsx @@ -4,6 +4,13 @@ import Image from "next/image"; export function PageFeature() { const features = [ + { + title: "Multi-signature security", + description: + "M-of-N signing: require multiple signers to approve every transaction. Choose at least, all, or any threshold per wallet.", + skeleton: , + className: "lg:col-span-3", + }, { title: "Manage all your wallets", description: diff --git a/src/components/pages/homepage/index.tsx b/src/components/pages/homepage/index.tsx index 840d1cbb..da2fdd4e 100644 --- a/src/components/pages/homepage/index.tsx +++ b/src/components/pages/homepage/index.tsx @@ -10,7 +10,7 @@ import CardUI from "@/components/ui/card-content"; import RowLabelInfo from "@/components/common/row-label-info"; import Image from "next/image"; import { useEffect, useState } from "react"; -import { Database, Sparkles, Info } from "lucide-react"; +import { Database, Sparkles, Bot, Code, Download } from "lucide-react"; // DApp Card Component function DappCard({ title, description, url }: { title: string; description: string; url: string }) { @@ -228,9 +228,24 @@ export function PageHomepage() {
- {/* Wallet Management */} - + {/* Multisig */} + +
+ Multi-signature +
+
+ {/* Wallet Management */} + + + {/* Developers & Bots – machine- and bot-friendly docs */} +
+
+

+ Developers & Bots +

+

+ OpenAPI spec, REST v1 endpoints, and bot authentication for integrations and automation. +

+ +
+ + + + +
+ + GET /api/swagger + + → OpenAPI JSON +
+

+ Base URL: same origin (e.g. https://your-domain.com or http://localhost:3000). Use for client generation and automated tests. +

+
+ +
+
+ + +
+

POST /api/v1/botAuth

+

+ Body: botKeyId, secret, paymentAddress (Cardano address for this bot). Optional: stakeAddress. +

+

+ Response: {`{ "token", "botId" }`}. Send Authorization: Bearer <token> on subsequent requests. +

+

+ Bot keys are created in the app (User → Create bot). One bot key maps to one paymentAddress; that address is used as the caller for walletIds, pendingTransactions, freeUtxos, and other v1 endpoints. +

+
+ +
+
+ +
+ +
    +
  • GET /api/v1/walletIds?address=<paymentAddress> — list wallets for the bot
  • +
  • GET /api/v1/pendingTransactions?walletId=<id>&address=<paymentAddress> — pending transactions
  • +
  • GET /api/v1/freeUtxos?walletId=<id>&address=<paymentAddress> — free UTxOs
  • +
  • POST /api/v1/addTransaction, POST /api/v1/signTransaction — add/sign transactions (with Bearer token)
  • +
+
+
+
+
@@ -488,7 +601,7 @@ export function PageHomepage() {