From 1facdc38159955b90df011940de965f80f72e8a7 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Mon, 23 Feb 2026 15:25:52 +0100 Subject: [PATCH 1/3] chore: update configuration and enhance bot functionality - Added bot configuration and wallet files to .gitignore for security. - Updated docker-compose to change PostgreSQL port for development. - Enhanced package-lock.json with new dependencies for bot authentication. - Introduced new bot-related models in Prisma schema for better bot management. - Improved database initialization script to create necessary roles for bot functionality. - Updated API endpoints to support bot authentication and rate limiting. - Enhanced homepage with new sections for bot features and developer documentation. - Implemented pagination for transaction display and improved wallet transaction handling. - Added utility functions for bot access control and JWT verification. --- .agents/README.md | 35 ++ .cursor/skills/multisig/SKILL.md | 46 +++ .gitignore | 5 + AGENTS.md | 3 + docker-compose.dev.yml | 2 +- docker/init-db.sh | 14 +- package-lock.json | 18 + package.json | 3 +- .../migration.sql | 1 + .../migration.sql | 60 ++++ prisma/schema.prisma | 41 +++ scripts/bot-ref/README.md | 129 +++++++ scripts/bot-ref/bot-client.ts | 315 ++++++++++++++++++ scripts/bot-ref/bot-config.sample.json | 6 + scripts/bot-ref/create-wallet-us.ts | 85 +++++ scripts/bot-ref/generate-bot-wallet.ts | 55 +++ scripts/bot-ref/package.json | 16 + .../pages/homepage/features/index.tsx | 7 + src/components/pages/homepage/index.tsx | 127 ++++++- .../shared/useWalletFlowState.tsx | 1 + .../homepage/wallets/new-wallet/index.tsx | 21 ++ .../wallets/new-wallet/nWSignersCard.tsx | 50 ++- .../pages/user/BotManagementCard.tsx | 273 +++++++++++++++ .../migration/useMigrationWalletFlowState.tsx | 1 + .../wallet/transactions/all-transactions.tsx | 73 +++- src/env.js | 6 + src/lib/auth/botAccess.ts | 77 +++++ src/lib/auth/botKey.ts | 50 +++ src/lib/security/rateLimit.ts | 23 +- src/lib/security/requestGuards.ts | 28 +- src/lib/verifyJwt.ts | 20 +- src/pages/_app.tsx | 14 + src/pages/api/skill.ts | 28 ++ src/pages/api/v1/README.md | 31 ++ src/pages/api/v1/addTransaction.ts | 40 ++- src/pages/api/v1/authSigner.ts | 14 +- src/pages/api/v1/botAuth.ts | 88 +++++ src/pages/api/v1/botMe.ts | 66 ++++ src/pages/api/v1/createWallet.ts | 250 ++++++++++++++ src/pages/api/v1/freeUtxos.ts | 47 ++- src/pages/api/v1/pendingTransactions.ts | 20 +- src/pages/api/v1/signTransaction.ts | 41 ++- src/pages/api/v1/submitDatum.ts | 32 +- src/pages/api/v1/walletIds.ts | 36 +- src/pages/user/index.tsx | 3 + src/server/api/root.ts | 3 +- src/server/api/routers/bot.ts | 140 ++++++++ src/server/api/routers/wallets.ts | 17 +- src/utils/swagger.ts | 47 +++ 49 files changed, 2390 insertions(+), 118 deletions(-) create mode 100644 .agents/README.md create mode 100644 .cursor/skills/multisig/SKILL.md create mode 100644 AGENTS.md create mode 100644 prisma/migrations/20260223000000_add_bot_models_and_wallet_owner/migration.sql create mode 100644 scripts/bot-ref/README.md create mode 100644 scripts/bot-ref/bot-client.ts create mode 100644 scripts/bot-ref/bot-config.sample.json create mode 100644 scripts/bot-ref/create-wallet-us.ts create mode 100644 scripts/bot-ref/generate-bot-wallet.ts create mode 100644 scripts/bot-ref/package.json create mode 100644 src/components/pages/user/BotManagementCard.tsx create mode 100644 src/lib/auth/botAccess.ts create mode 100644 src/lib/auth/botKey.ts create mode 100644 src/pages/api/skill.ts create mode 100644 src/pages/api/v1/botAuth.ts create mode 100644 src/pages/api/v1/botMe.ts create mode 100644 src/pages/api/v1/createWallet.ts create mode 100644 src/server/api/routers/bot.ts 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/schema.prisma b/prisma/schema.prisma index 3c206a8e..4a60805d 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 { @@ -178,3 +179,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..13f9c8fd --- /dev/null +++ b/scripts/bot-ref/README.md @@ -0,0 +1,129 @@ +# 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. 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/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() {