diff --git a/.agents/README.md b/.agents/README.md index 3d3e4c3f..9da4aee7 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -37,7 +37,22 @@ Specs live in the vault, not this repo. The maintainer keeps a document-driven d - **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). +## MCP (AI agents) + +- **Endpoint**: `POST /api/mcp` — a stateless Model Context Protocol server built on + `@modelcontextprotocol/server` v2. Docs: `src/pages/api/mcp/README.md`. +- **Surface**: read-only plus governance ballot drafts. It cannot sign, spend or + broadcast, and that boundary is enforced by a test (`src/__tests__/mcpTools.test.ts`) — + adding a write tool must be a deliberate decision, not a registry addition. +- **Tools wrap the existing v1 handlers in-process** via `src/lib/mcp/invokeV1.ts`, so + authorization and validation stay defined once. Handler imports in + `src/lib/mcp/tools.ts` must stay **lazy** or the Mesh/whisky WASM lands in the route's + cold path. +- **Auth**: an OAuth 2.1 access token, or an existing v1 bearer token. The authorization + server lives under `src/pages/api/oauth/` — see `src/pages/api/oauth/README.md`. + ## 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`. +- MCP/OAuth: `src/pages/api/mcp/README.md`, `src/pages/api/oauth/README.md`. diff --git a/.claude/worktrees/peaceful-northcutt b/.claude/worktrees/peaceful-northcutt deleted file mode 160000 index 0d5ee84c..00000000 --- a/.claude/worktrees/peaceful-northcutt +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0d5ee84c2d2509c9e1543f417eec317992a5d00e diff --git a/.cursor/skills/multisig/SKILL.md b/.cursor/skills/multisig/SKILL.md index 0a61a3c1..2c00e8c4 100644 --- a/.cursor/skills/multisig/SKILL.md +++ b/.cursor/skills/multisig/SKILL.md @@ -26,9 +26,15 @@ description: Build and integrate with the Mesh Multisig (Cardano multisig wallet ## Bot integration (machine-friendly) - **OpenAPI spec (JSON)**: `GET /api/swagger` — use for codegen or automation. +- **Registration (new bots)**: `POST /api/v1/botRegister` + Body: `{ "name": string, "requestedScopes": string[], "paymentAddress"?: string }` + New bots should initially register **without** `paymentAddress` — a fresh bot has no wallet yet. Register with just name + scopes, have the owner claim you, pick up your credentials, generate a wallet, then bind the address at first `botAuth`. Only pass `paymentAddress` at registration if the bot already controls a wallet. - **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. + Body: `{ "botKeyId": string, "secret": string, "paymentAddress"?: string, "stakeAddress"?: string }` + Response: `{ "token": string, "botId": string }`. Use `Authorization: Bearer ` for v1 endpoints. The first successful `botAuth` binds `paymentAddress` to the bot (required then; creates its `BotUser` if registration was address-less). Afterwards `paymentAddress` is optional — the JWT always carries the server-side bound address, and a mismatching supplied address is rejected (409). The token lives ~1 hour: cache it and re-auth on 401; the `secret` is picked up once via `botPickupSecret` but stays valid for repeated auths — store it safely. +- **Ballot drafting lifecycle (bots)**: `POST /api/v1/botBallotsUpsert` (drafts; proposalIds validated against the chain; response has `created` + `ballot.id`), `GET /api/v1/botBallots?walletId=` (reconcile your drafts), `DELETE /api/v1/botBallots` with `{walletId, ballotId}` (clean up stale drafts). All need `ballot:write` scope + any wallet grant (observer is enough). `GET /api/v1/botMe` returns `botWallets` (grants + roles) for self-discovery; `POST /api/v1/botRotateSecret` with the current secret mints a replacement if it leaks. +- **Rate-limit etiquette (bots)**: requests are limited per IP and per bot (default 40/min). Don't fan out in parallel — space calls ~200–500 ms apart. Responses carry `X-RateLimit-Remaining`/`X-RateLimit-Reset`; a 429 carries `Retry-After` (seconds) — wait that long (the reference client's `fetchWithBackoff` in `scripts/bot-ref/bot-client.ts` does this). Rejected requests never extend the window. +- **Governance reads (bots)**: `GET /api/v1/governanceActiveProposals?details=true` — use `details=true` to get `expiration` (voting-deadline epoch) and `deposit`; the response's `currentEpoch` gives time-to-deadline. "Active" = no terminal epoch on-chain; explorers may show a higher "active" count because they still display ratified-but-not-enacted actions — pass `includeRatified=true` to include those (status `ratified`, outcome already decided). - **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. diff --git a/.dockerignore b/.dockerignore index 32f19463..3c505b35 100644 --- a/.dockerignore +++ b/.dockerignore @@ -42,6 +42,12 @@ Thumbs.db # Documentation *.md !README.md +# The feature vault is data, not documentation: /roadmap/graph reads these notes +# at runtime, so they have to survive the *.md filter above. Keep this as the +# bare directory — Railpack turns each negation into a literal copy instruction, +# and a `!vault/**` glob becomes `copy /vault/** /app/**`, which buildkit rejects +# with "cannot copy to non-directory". `!vault` alone copies the tree recursively. +!vault docs # Docker diff --git a/.env.example b/.env.example index 6369a1d8..182d3c80 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,21 @@ NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD="your-blockfrost-preprod-api-key" # DISCORD_CLIENT_SECRET="your-discord-client-secret" # DISCORD_BOT_TOKEN="your-discord-bot-token" # DISCORD_GUILD_ID="your-discord-guild-id" + +# Optional: Email notifications via Resend +# RESEND_API_KEY="re_..." +# EMAIL_FROM="Mesh Multisig " +# EMAIL_REPLY_TO="support@your-domain.example" +# Optional: override notification email links. Required for localhost links when +# using `next start` locally (NODE_ENV is production). Defaults to +# http://localhost:3000 in `next dev` and the production site URL otherwise. +# NOTIFICATION_LINK_BASE_URL="http://localhost:3000" +# NOTIFICATION_DRAIN_SECRET="your-notification-drain-secret" +# NOTIFICATIONS_EMAIL_ENABLED="false" +# Optional: OAuth 2.1 issuer for the MCP endpoint (/api/mcp) +# Canonical origin of the authorization server. Access tokens carry it as `iss`, +# and the /.well-known discovery documents are built from it. Falls back to +# NEXT_PUBLIC_SITE_URL; set this only if the issuer must differ from the site +# origin. In non-production it falls back to the request host, so a local +# `next start` on any port works without setting anything. +# OAUTH_ISSUER_URL="https://multisig.meshjs.dev" diff --git a/.github/workflows/ci-smoke-preprod.yml b/.github/workflows/ci-smoke-preprod.yml index 1857bfd7..3a983052 100644 --- a/.github/workflows/ci-smoke-preprod.yml +++ b/.github/workflows/ci-smoke-preprod.yml @@ -35,6 +35,9 @@ jobs: with: node-version: 22 + - name: Use repo npm version + run: npm install -g "$(node -p 'require("./package.json").packageManager')" && npm --version + - name: Check secrets configured id: check-secrets run: | diff --git a/.github/workflows/deploy-migrations.yml b/.github/workflows/deploy-migrations.yml index 1da1822b..a9ebbbe0 100644 --- a/.github/workflows/deploy-migrations.yml +++ b/.github/workflows/deploy-migrations.yml @@ -20,7 +20,10 @@ jobs: with: node-version: '22' cache: 'npm' - + + - name: Use repo npm version + run: npm install -g "$(node -p 'require("./package.json").packageManager')" && npm --version + - name: Install dependencies run: npm ci diff --git a/.github/workflows/notification-outbox-drain.yml b/.github/workflows/notification-outbox-drain.yml new file mode 100644 index 00000000..c8a4bdf9 --- /dev/null +++ b/.github/workflows/notification-outbox-drain.yml @@ -0,0 +1,49 @@ +name: Notification Outbox Drain + +# Drains the notification outbox (pending + due-for-retry deliveries) by calling +# the authenticated drain endpoint. Requires NOTIFICATION_DRAIN_SECRET to be set +# both as a GitHub Actions secret and as an env var on the deployment; until +# both exist, runs are graceful no-ops. + +on: + schedule: + # Every 15 minutes; the shortest retry delay in the worker is 5 minutes. + - cron: '*/15 * * * *' + # Allow manual triggering for testing + workflow_dispatch: + +jobs: + drain: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Drain notification outbox + env: + API_BASE_URL: 'https://multisig.meshjs.dev' + DRAIN_SECRET: ${{ secrets.NOTIFICATION_DRAIN_SECRET }} + run: | + if [ -z "$DRAIN_SECRET" ]; then + echo "NOTIFICATION_DRAIN_SECRET repo secret is not set; skipping." + exit 0 + fi + + status=$(curl -s -o response.json -w "%{http_code}" -X POST \ + "$API_BASE_URL/api/notifications/drain?limit=100" \ + -H "Authorization: Bearer $DRAIN_SECRET") + + echo "HTTP $status" + cat response.json || true + echo + + case "$status" in + 200) + ;; + 503) + echo "Drain endpoint reports NOTIFICATION_DRAIN_SECRET is not configured on the deployment; skipping." + ;; + *) + echo "Drain request failed." + exit 1 + ;; + esac diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index c597de7a..77d6a30d 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -23,6 +23,9 @@ jobs: node-version: 22 cache: 'npm' + - name: Use repo npm version + run: npm install -g "$(node -p 'require("./package.json").packageManager')" && npm --version + - name: Install dependencies run: npm ci diff --git a/.github/workflows/pr-multisig-v1-smoke.yml b/.github/workflows/pr-multisig-v1-smoke.yml index cb34aea2..9dff6cbf 100644 --- a/.github/workflows/pr-multisig-v1-smoke.yml +++ b/.github/workflows/pr-multisig-v1-smoke.yml @@ -27,6 +27,14 @@ on: default: "" type: string +# Serialize against the Playwright e2e workflow: both broadcast from the same +# preprod CI wallets, so they must never run at the same time. A constant group +# (not per-PR) ensures mutual exclusion across PRs too. cancel-in-progress:false +# means runs queue rather than cancel each other. +concurrency: + group: ci-preprod-wallets + cancel-in-progress: false + jobs: multisig-v1-smoke: if: github.repository == 'MeshJS/multisig' @@ -53,7 +61,23 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + # Dependabot-triggered runs never receive repo Actions secrets. When no + # core secret is present at all, skip the smoke instead of failing so + # dependabot PRs are not systematically red. A partial secret set still + # fails loudly in the validate step below. + - name: Check CI secrets configured + id: check-secrets + shell: bash + run: | + if [[ -z "$CI_JWT_SECRET" && -z "$CI_MNEMONIC_1" && -z "$CI_MNEMONIC_2" && -z "$CI_MNEMONIC_3" && -z "$CI_BLOCKFROST_PREPROD_API_KEY" ]]; then + echo "configured=false" >> "$GITHUB_OUTPUT" + echo "Multisig v1 smoke skipped: CI_* secrets not available to this run (e.g. dependabot-triggered)." + else + echo "configured=true" >> "$GITHUB_OUTPUT" + fi + - name: Validate required CI secrets + if: steps.check-secrets.outputs.configured == 'true' shell: bash run: | missing=() @@ -91,23 +115,27 @@ jobs: fi - name: Pull base image (with retry) + if: steps.check-secrets.outputs.configured == 'true' shell: bash run: | for i in 1 2 3; do - docker pull node:20-alpine && break + docker pull node:22-slim && break echo "Pull attempt $i failed, retrying in 30s..." sleep 30 done - name: Build CI containers + if: steps.check-secrets.outputs.configured == 'true' shell: bash run: docker compose -f docker-compose.ci.yml build - name: Start Postgres + App containers + if: steps.check-secrets.outputs.configured == 'true' shell: bash run: docker compose -f docker-compose.ci.yml up -d postgres app - name: Wait for app healthcheck + if: steps.check-secrets.outputs.configured == 'true' shell: bash run: | for i in {1..60}; do @@ -124,11 +152,12 @@ jobs: exit 1 - name: Run CI wallet bootstrap + v1 route-chain smoke + if: steps.check-secrets.outputs.configured == 'true' shell: bash run: docker compose -f docker-compose.ci.yml --profile ci-test run --rm ci-runner - name: Dump container logs on failure - if: failure() + if: failure() && steps.check-secrets.outputs.configured == 'true' shell: bash run: | docker compose -f docker-compose.ci.yml logs --no-color \ @@ -145,14 +174,14 @@ jobs: > docker-compose-ci.log - name: Upload logs on failure - if: failure() + if: failure() && steps.check-secrets.outputs.configured == 'true' uses: actions/upload-artifact@v4 with: name: docker-compose-ci-logs path: docker-compose-ci.log - name: Upload route-chain report - if: always() + if: always() && steps.check-secrets.outputs.configured == 'true' uses: actions/upload-artifact@v4 with: name: ci-route-chain-report @@ -160,7 +189,7 @@ jobs: if-no-files-found: warn - name: Tear down CI containers - if: always() + if: always() && steps.check-secrets.outputs.configured == 'true' shell: bash run: docker compose -f docker-compose.ci.yml down -v --remove-orphans diff --git a/.github/workflows/pr-playwright-browser.yml b/.github/workflows/pr-playwright-browser.yml new file mode 100644 index 00000000..95510888 --- /dev/null +++ b/.github/workflows/pr-playwright-browser.yml @@ -0,0 +1,142 @@ +name: PR Playwright Browser Tests + +on: + pull_request: + branches: + - main + - preprod + workflow_dispatch: + inputs: + transfer_lovelace: + description: "Lovelace amount for ring transfer legs" + required: false + default: "2000000" + type: string + +# Serialize against the Multisig v1 smoke workflow: both broadcast from the same +# preprod CI wallets, so they must never run at the same time. A constant group +# (not per-PR) ensures mutual exclusion across PRs too. cancel-in-progress:false +# means runs queue rather than cancel each other. +concurrency: + group: ci-preprod-wallets + cancel-in-progress: false + +jobs: + playwright-browser: + if: github.repository == 'MeshJS/multisig' + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + CI_JWT_SECRET: ${{ secrets.CI_JWT_SECRET }} + CI_MNEMONIC_1: ${{ secrets.CI_MNEMONIC_1 }} + CI_MNEMONIC_2: ${{ secrets.CI_MNEMONIC_2 }} + CI_MNEMONIC_3: ${{ secrets.CI_MNEMONIC_3 }} + CI_BLOCKFROST_PREPROD_API_KEY: ${{ secrets.CI_BLOCKFROST_PREPROD_API_KEY }} + CI_NETWORK_ID: "0" + CI_WALLET_TYPES: "legacy,hierarchical,sdk" + CI_NUM_REQUIRED_SIGNERS: "2" + CI_TRANSFER_LOVELACE: ${{ github.event_name == 'workflow_dispatch' && inputs.transfer_lovelace || '2000000' }} + CI_DREP_ANCHOR_URL: ${{ secrets.CI_DREP_ANCHOR_URL }} + CI_DREP_ANCHOR_JSON: ${{ secrets.CI_DREP_ANCHOR_JSON }} + CI_STAKE_POOL_ID_HEX: ${{ secrets.CI_STAKE_POOL_ID_HEX }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate required secrets + shell: bash + run: | + missing=() + [[ -n "$CI_JWT_SECRET" ]] || missing+=("CI_JWT_SECRET") + [[ -n "$CI_MNEMONIC_1" ]] || missing+=("CI_MNEMONIC_1") + [[ -n "$CI_MNEMONIC_2" ]] || missing+=("CI_MNEMONIC_2") + [[ -n "$CI_MNEMONIC_3" ]] || missing+=("CI_MNEMONIC_3") + [[ -n "$CI_BLOCKFROST_PREPROD_API_KEY" ]] || missing+=("CI_BLOCKFROST_PREPROD_API_KEY") + [[ -n "$CI_DREP_ANCHOR_JSON" ]] || missing+=("CI_DREP_ANCHOR_JSON") + [[ -n "$CI_STAKE_POOL_ID_HEX" ]] || missing+=("CI_STAKE_POOL_ID_HEX") + if [[ "${#missing[@]}" -gt 0 ]]; then + echo "Missing required secrets: ${missing[*]}" + echo "Set these in repo settings before running the Playwright browser workflow." + exit 1 + fi + + - name: Build CI containers + shell: bash + run: docker compose -f docker-compose.playwright.yml build app bootstrap-runner playwright-runner + + - name: Start Postgres + App containers + shell: bash + run: docker compose -f docker-compose.playwright.yml up -d postgres app + + - name: Wait for app healthcheck + shell: bash + run: | + for i in {1..60}; do + status=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$(docker compose -f docker-compose.playwright.yml ps -q app)") + if [[ "$status" == "healthy" ]]; then + echo "App is healthy." + exit 0 + fi + sleep 2 + done + echo "App failed to become healthy in time." + docker compose -f docker-compose.playwright.yml ps + exit 1 + + - name: Run CI wallet bootstrap + shell: bash + run: | + docker compose -f docker-compose.playwright.yml --profile playwright \ + run --rm bootstrap-runner + + - name: Run Playwright ring transfer tests + shell: bash + run: | + docker compose -f docker-compose.playwright.yml --profile playwright \ + run --rm --no-deps playwright-runner + + - name: Dump container logs on failure + if: failure() + shell: bash + run: | + docker compose -f docker-compose.playwright.yml logs --no-color \ + | sed -E 's/(Bearer )[A-Za-z0-9._-]+/\1[REDACTED]/g' \ + | sed -E 's/("token"[[:space:]]*:[[:space:]]*")[^"]+(")/\1[REDACTED]\2/g' \ + | sed -E 's/("secret"[[:space:]]*:[[:space:]]*")[^"]+(")/\1[REDACTED]\2/g' \ + | sed -E 's/("mnemonic([[:alnum:]_-]*)?"[[:space:]]*:[[:space:]]*")[^"]+(")/\1[REDACTED]\3/gI' \ + | sed -E 's/("private([[:alnum:]_-]*)?key([[:alnum:]_-]*)?"[[:space:]]*:[[:space:]]*")[^"]+(")/\1[REDACTED]\3/gI' \ + | sed -E 's/("signing([[:alnum:]_-]*)?key([[:alnum:]_-]*)?"[[:space:]]*:[[:space:]]*")[^"]+(")/\1[REDACTED]\3/gI' \ + | sed -E 's/("seed([[:alnum:]_-]*)?"[[:space:]]*:[[:space:]]*")[^"]+(")/\1[REDACTED]\3/gI' \ + | sed -E 's/("xprv([[:alnum:]_-]*)?"[[:space:]]*:[[:space:]]*")[^"]+(")/\1[REDACTED]\3/gI' \ + | sed -E 's/(ed25519e?_sk[[:alnum:]_]+)/[REDACTED]/gI' \ + | sed -E 's/(xprv[[:alnum:]]+)/[REDACTED]/gI' \ + > docker-compose-playwright.log + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: docker-compose-playwright-logs + path: docker-compose-playwright.log + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: ci-artifacts/playwright-report/ + if-no-files-found: warn + + - name: Upload Playwright traces on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: ci-artifacts/playwright-traces/ + if-no-files-found: ignore + + - name: Tear down containers + if: always() + shell: bash + run: docker compose -f docker-compose.playwright.yml down -v --remove-orphans diff --git a/.github/workflows/trpc-integration-tests.yml b/.github/workflows/trpc-integration-tests.yml index bc7b2381..8a75c1f5 100644 --- a/.github/workflows/trpc-integration-tests.yml +++ b/.github/workflows/trpc-integration-tests.yml @@ -13,7 +13,7 @@ on: jobs: trpc-tests: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 services: postgres: @@ -46,8 +46,14 @@ jobs: node-version: 22 cache: npm + - name: Use repo npm version + run: npm install -g "$(node -p 'require("./package.json").packageManager')" && npm --version + - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts + + - name: Generate Prisma client + run: npx prisma generate - name: Run database migrations run: npx prisma migrate deploy diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index c6e8ca3d..bb06dec0 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -14,7 +14,7 @@ jobs: unit-tests: name: Transaction builder unit tests runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 steps: - name: Checkout repository @@ -25,8 +25,14 @@ jobs: with: node-version: 22 + - name: Use repo npm version + run: npm install -g "$(node -p 'require("./package.json").packageManager')" && npm --version + - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts + + - name: Generate Prisma client + run: npx prisma generate - name: Run transaction builder tests run: npm run test:ci -- --testPathPatterns="src/__tests__/tx-builders" diff --git a/.gitignore b/.gitignore index 2c85f24b..754530db 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ yarn-error.log* # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables .env .env*.local +.env.playwright # bot ref client config (contains secret) scripts/bot-ref/bot-config.json @@ -56,3 +57,6 @@ scripts/bot-ref/bot-wallet.json # CI artifacts ci-artifacts/ .ci-dist/ + +# Claude Code worktrees and local session state (never commit) +.claude/worktrees/ diff --git a/Dockerfile.ci b/Dockerfile.ci index 2389dea2..e3f0dcfd 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -24,7 +24,10 @@ ENV NODE_OPTIONS=--experimental-wasm-modules COPY package.json package-lock.json* ./ COPY prisma ./prisma COPY prisma.config.ts ./ -RUN npm ci +RUN npm install -g "$(node -p 'require("./package.json").packageManager')" \ + && npm --version +RUN npm ci --ignore-scripts +RUN npx prisma generate # Copy full source for containerized CI runs. COPY . . @@ -46,6 +49,15 @@ EXPOSE 3000 # --- Application image: production build served with `next start` --------------- FROM base AS app +# NEXT_PUBLIC_* vars are inlined into the client bundle by `next build`, so the +# browser-driven Playwright flow passes them as build args. +ARG NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD= +ARG NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET= +ARG NEXT_PUBLIC_NETWORK_ID=0 +ENV NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD=$NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD +ENV NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET=$NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET +ENV NEXT_PUBLIC_NETWORK_ID=$NEXT_PUBLIC_NETWORK_ID + # Build the production app so the smoke runs `next start` (not `next dev`) and # exercises the same output Vercel deploys. `next dev` mis-resolves the # @meshsdk/core-csl / whisky-evaluator WASM path at runtime, 500-ing tx routes. diff --git a/Dockerfile.playwright b/Dockerfile.playwright new file mode 100644 index 00000000..5e9fcd36 --- /dev/null +++ b/Dockerfile.playwright @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/playwright:v1.60.0-jammy + +WORKDIR /app + +# Install app dependencies (needed for @meshsdk/* imports in Phase 2 helpers). +COPY package.json package-lock.json* ./ +COPY prisma ./prisma +RUN npm ci --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 + +# Copy e2e suite and the CI framework types it imports. +COPY e2e/ ./e2e/ +COPY scripts/ci/framework/ ./scripts/ci/framework/ +COPY tsconfig*.json ./ diff --git a/ROADMAP.md b/ROADMAP.md index 6b2cbfd2..85730706 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,58 @@ --- -## Month 1 — May 2026 +## Delivered to date (May – July 2026) + +What the product can actually do today, as verified in the codebase on 2026-07-26. The per-month **Progress** tables below track plan-vs-actual; this section is the cumulative capability inventory, and it is the input that reshaped M4–M6. + +> **Caveat — delivered ≠ live.** Everything below is merged on `preprod`. `main` is 75 commits behind and the production database is four migrations behind, so a good share of this is not yet reachable on the production deployment. Closing that gap is the first item in August. + +### Governance + +- **In-app voting for multisig DReps** — Ekklesia/Hydra budget voting, DRep-registration detection, segmented ballot UX, proposal cards with DB-cached tallies ([#272](https://github.com/MeshJS/multisig/pull/272), [#279](https://github.com/MeshJS/multisig/pull/279), [#296](https://github.com/MeshJS/multisig/pull/296), [#297](https://github.com/MeshJS/multisig/pull/297), [#302](https://github.com/MeshJS/multisig/pull/302)). Closed the metadata hash-mismatch ([#122](https://github.com/MeshJS/multisig/issues/122)) five months early. +- **Public DRep vote-history explorer** — `/governance/drep` and `/governance/drep/[id]`, no wallet required. Full vote history with search + vote filter, CIP-100/CIP-136 rationales resolved from the anchor via IPFS, and a 9-column CSV export that resolves every rationale before writing. Served through a Koios proxy (`/api/governance/drepVotes`) because Blockfrost omits the proposal + rationale anchor ([#337](https://github.com/MeshJS/multisig/pull/337)–[#339](https://github.com/MeshJS/multisig/pull/339)). +- **Rationale drafting, IPFS reliability, ballot CSV import/export** ([#300](https://github.com/MeshJS/multisig/pull/300)), with ReDoS hardening in `extractCidPath` ([#315](https://github.com/MeshJS/multisig/pull/315)). + +### Bot platform — arrived ~4 months ahead of its M7 slot + +- **Human-in-the-loop onboarding**: `botRegister` → `botClaim` (owner approves a 30-min claim code with their own JWT) → `botPickupSecret` (one-time) → `botAuth`, plus self-service `botRotateSecret` and `botMe`. +- **Double opt-in authorization**: five scopes (`multisig:create|read|sign`, `governance:read`, `ballot:write`) on the key **and** a per-wallet grant (`WalletBotAccess`, `cosigner`/`observer`). Secrets stored as `JWT_SECRET`-peppered HMAC-SHA256; one bot is bound to one payment address at first auth. A bot can never move funds alone — the wallet's M-of-N threshold still gates submission. +- **27 `/api/v1/*` handlers accept bot JWTs**, including wallet creation, UTxO/pending-tx reads, `addTransaction`, `signTransaction` with auto-submit on threshold, server-built stake and DRep certificates, and the full Plutus proxy suite. +- **Ballot drafting by bots** (`botBallotsUpsert`, `botBallots`) — observer access is sufficient to draft ([#341](https://github.com/MeshJS/multisig/pull/341)–[#345](https://github.com/MeshJS/multisig/pull/345)). +- **Rate limiting + body-size caps** in `src/lib/security/requestGuards.ts`: 60/min default, 15/min strict on register/pickup/auth, 5/min on rotate, 40/min per bot id. +- **Management UI + audit**: `BotManagementCard` on `/user` and the wallets dashboard, a `bot` tRPC router for scopes/grants/revocation, and an append-only `AuditLog`. + +### Developer & agent surface — the M8 "API documentation and developer portal" item, already standing + +`/api-docs` (Swagger UI with a wallet-signature bearer-token generator), `/api/swagger` (1841-line OpenAPI 3.0 spec), `/llms.txt` (agent orientation incl. a self-contained bot quickstart), `/api/skill` (downloadable agent skill), `src/pages/api/v1/README.md` as the authoritative endpoint reference, and a reference client in `scripts/bot-ref/` ([#328](https://github.com/MeshJS/multisig/pull/328), [#346](https://github.com/MeshJS/multisig/pull/346)). + +### Notifications + +Resend-backed email channel with a real outbox: `NotificationDelivery` carries an idempotency key, attempt counter, `nextAttemptAt` backoff and nine statuses (including four distinct skip reasons), drained by `drainNotificationOutbox` via a token-authenticated `POST /api/notifications/drain`. Event types are `email.verify`, `signature.required`, `signature.reminder`. Per-wallet × per-signer settings UI on the wallet Info page, plus hashed-token email verification ([#322](https://github.com/MeshJS/multisig/pull/322), [#326](https://github.com/MeshJS/multisig/pull/326)). **Gap:** no scheduled workflow drains the outbox — `daily-balance-snapshots.yml` is the only cron in the repo. + +### Testing & CI + +- **Playwright E2E**: 11 spec files, ~54 tests, in `e2e/tests/` — wallet creation (legacy + SDK), ring transfers on real preprod, staking, proxy, DRep/ballot UI, bot management, notification settings, wallet access control, signing rejection, responsive smoke. Runs in Docker via `pr-playwright-browser.yml`, serialized against the v1 smoke job through a shared `ci-preprod-wallets` concurrency group ([#323](https://github.com/MeshJS/multisig/pull/323), [#335](https://github.com/MeshJS/multisig/pull/335), [#336](https://github.com/MeshJS/multisig/pull/336)). +- Real-chain smoke system closed ([#213](https://github.com/MeshJS/multisig/issues/213)); deploy-migrations on Node 22 + manual dispatch ([#319](https://github.com/MeshJS/multisig/pull/319)); RLS follow-up migration authored ([#332](https://github.com/MeshJS/multisig/pull/332)); worktree gitlink fix ([#333](https://github.com/MeshJS/multisig/pull/333)). + +### Platform + +Mesh 2.0 groundwork (Prisma 7.8 + Next 16, tx-builder hardfork upgrade, wallet ops consolidated behind one bridge with an ESLint guardrail); signing & auth reliability (bech32 normalization, `signData` arg order, core-cst witness/body-hash merge, stuck-"Loading…" recovery, cross-instance import, non-opaque wallet-session status codes); mobile foundations, skeleton/empty states, error toasts, pagination; landing + SEO + glass theme overhaul; on-chain wallet registration and discovery ([#340](https://github.com/MeshJS/multisig/pull/340)). + +### Landed ahead of schedule + +| Capability | Planned | Actually delivered | Effect on the plan | +|------------|---------|--------------------|--------------------| +| Governance metadata fix (#122) | M7 (Nov) | June | Closed | +| Wallet V2 — registration & discovery (#33) | M3 (Jul) | July ([#340](https://github.com/MeshJS/multisig/pull/340)) | On time; feeds the Discover page (#52), which moves up from M10 | +| Bot platform (SDK/reference client, scoped auth, ballot API) | M7 (Nov) | July | M7 reduces to **webhooks only** — no webhook code exists yet | +| API documentation & developer portal | M8 (Dec) | June–July | Done; M8 slot freed | +| Pending transactions on user's homepage (#125) | M7 (Nov) | Shipped — surfaced on the wallets dashboard | Issue still open; verify and close | +| Playwright E2E suite | Not scheduled | June–July | Becomes the safety net Document Sign-Off ships against | + +--- + +## Month 1 — April 2026 **Focus:** Establish foundations and fix critical blockers. @@ -42,7 +93,7 @@ Status of M1 tasks. Last updated 2026-04-23. --- -## Month 2 — June 2026 +## Month 2 — May 2026 **Focus:** Mesh 2.0 migration groundwork, signing/auth reliability, in-app governance voting, and platform UX + CI hardening. @@ -60,8 +111,8 @@ Status of M1 tasks. Last updated 2026-04-23. | Task | Issues | |------|--------| -| CI improvements — real-chain smoke system, deploy-migrations on Node 22, dependency/security hardening | #213 | -| Email notification service — signature-required emails via Resend, notification center + outbox/worker, per-wallet settings, email verification | | +| CI improvements — real-chain smoke system, Added transaction-builder unit tests and tRPC integration tests, Added authorization and persistence tests for transaction and proxy tRPC procedures. | #255 | +| Email notification service — signature-required emails via Resend, notification center + outbox/worker, per-wallet settings, email verification | #327 | ### Progress @@ -75,13 +126,13 @@ Mid-month snapshot. Last updated 2026-06-17. | IPFS + rationale + ballot CSV | Done | Reliable IPFS proxy, rationale caching, ballot CSV ([#300](https://github.com/MeshJS/multisig/pull/300)); ReDoS hardening in `extractCidPath` ([#315](https://github.com/MeshJS/multisig/pull/315)) | | Platform UX foundations | Done | Mobile foundations ([#287](https://github.com/MeshJS/multisig/pull/287)–[#291](https://github.com/MeshJS/multisig/pull/291)), skeleton/empty states ([#289](https://github.com/MeshJS/multisig/pull/289)), error toasts ([#292](https://github.com/MeshJS/multisig/pull/292)), pagination/labels/assets ([#293](https://github.com/MeshJS/multisig/pull/293)–[#295](https://github.com/MeshJS/multisig/pull/295)), landing + SEO + theme ([#298](https://github.com/MeshJS/multisig/pull/298)/[#299](https://github.com/MeshJS/multisig/pull/299)/[#308](https://github.com/MeshJS/multisig/pull/308)–[#318](https://github.com/MeshJS/multisig/pull/318)) | | CI improvements | Done | Real-chain smoke system closed ([#213](https://github.com/MeshJS/multisig/issues/213)); deploy-migrations moved to Node 22 + manual dispatch ([#319](https://github.com/MeshJS/multisig/pull/319)); pg pool cap ([#284](https://github.com/MeshJS/multisig/pull/284)); npm override for brace-expansion ReDoS ([#301](https://github.com/MeshJS/multisig/pull/301)) | -| Email notification service | In progress | Built on `feature/email-notification-center` (Resend email channel, notification center + outbox/worker, tRPC router, per-wallet settings UI, email verification, tests, plan doc); not yet merged to `preprod` | +| Email notification service | In progress | Built on `feature/email-notification-center` (Resend email channel, notification center + outbox/worker, tRPC router, per-wallet settings UI, email verification, tests, plan doc); ([#322](https://github.com/MeshJS/multisig/pull/322)) | **Carryover into July:** complete the Mesh 2.0 runtime cutover; land the Node-22 deploy-migrations fix on `main` and apply the pending `ProposalTally` migration to production (governance tallies error until it exists); review the Supabase RLS advisory on the seven `rls_enabled: false` tables. --- -## Month 3 — July 2026 +## Month 3 — June 2026 **Focus:** Mesh 2.0 cutover, on-chain wallet discovery (Wallet V2), and FROST research kickoff. @@ -97,49 +148,73 @@ Mid-month snapshot. Last updated 2026-06-17. | Task | Issues | |------|--------| -| Wallet V2 — on-chain registration and discovery — design the on-chain registration record + discovery index, define the data model, prototype lookup by signer/policy | #33 | +| Wallet V2 — on-chain registration and discovery — design the on-chain registration record + discovery index, define the data model, prototype lookup by signer/policy | #33 #349 | | CI/maintenance baseline — keep smoke + unit/tRPC suites green on Node 22, dependency/security updates | | +### Progress + +End-of-month snapshot. Last updated 2026-07-26. + +| Task | Status | Evidence | +|------|--------|----------| +| Mesh 2.0 runtime cutover | **Blocked upstream** | Re-checked 2026-07-26: npm latest for `@meshsdk/core`/`core-cst`/`core-csl` is still **1.9.1** — no 2.x has been published. Only `@meshsdk/react` has a 2.0 (2.0.0-beta.2), already in use. Readiness on our side is verified: all wallet ops funnel through the `useMeshWallet`/`useActiveWallet` bridge ([#278](https://github.com/MeshJS/multisig/pull/278)) and byte-preserving witness merge is implemented + regression-tested (`src/__tests__/mergeSignerWitnesses.test.ts`). When core 2.0 ships, the cutover is a single-layer change: bump `@meshsdk/*`, re-source the bridge wallet, fix the 2.0 deltas (`signData` arg order, `signTx(tx, partialSign)`, `getUtxos(): string[]`, removed `getDRep`/`getAssets`/`getLovelace`), drop the ESLint guardrail. **Demoted from a monthly task to a standing watch item** — it cannot be scheduled against an unpublished dependency | +| Production hardening follow-through | **Not done — regressed into a release gap** | The production database has applied **no migration since 2026-05-10** (`20260510170000_make_user_nostrkey_optional` is the newest row in `_prisma_migrations`). Four migrations are outstanding: `add_proposal_tally`, `add_notification_center`, `enable_rls_followup_tables`, `pending_bot_optional_address`. The Node-22 fix did land, but `deploy-migrations.yml` only fires on pushes to `main` that touch `prisma/migrations/**`, so the fix never re-triggered the failed June 17 run. Compounding it, `main` itself carries migrations only through `add_proposal_tally` — **`preprod` is 75 commits ahead of `main`**, so all of July's work is unreleased. Consequences in production today: governance tallies error (no `ProposalTally`), the notification center has no tables, address-less bot registration cannot work, and [#332](https://github.com/MeshJS/multisig/pull/332)'s RLS fix is merged but **not applied** | +| Supabase RLS advisory | **Open security exposure** | Verified against the production project 2026-07-26: 7 tables still have `rls_enabled: false` — `Contact`, `BotKey`, `BotUser`, `WalletBotAccess`, `PendingBot`, `BotClaimToken`, `AuditLog` — and are reachable by the `anon`/`authenticated` PostgREST roles. The remediation is already written (`20260706100000_enable_rls_followup_tables`); it is purely undeployed. Supabase additionally reports the Postgres version (`supabase-postgres-17.4.1.064`) has outstanding security patches, which is a dashboard-side upgrade | +| FROST research kickoff (#220) | Not started | Carried to August. Needs to start there to leave runway before the October go/no-go | +| CI/maintenance baseline | Watch item — unchanged | `pr-multisig-v1-smoke.yml` still `exit 1`s in its "Validate required CI secrets" step when secrets are absent, and dependabot-triggered runs never receive repo Actions secrets. Every dependabot PR is therefore red for systemic reasons, not because of the version bump — 7 are open, the oldest since 2026-06-15. The sibling `ci-smoke-preprod.yml` already has the skip-when-unconfigured guard to copy | +| Wallet V2 (#33) | Delivered | On-chain wallet registration + discovery shipped in [#340](https://github.com/MeshJS/multisig/pull/340) | +| Unplanned July delivery | Delivered | Bot platform, DRep vote-history explorer, Playwright E2E, and agent/API documentation all landed this month — see [Delivered to date](#delivered-to-date-may--july-2026) | + --- -## Month 4 — August 2026 +## Month 4 — July 2026 + +**Focus:** Close the production release gap, then start Document Sign-Off (see [Flagship feature](#flagship-feature--document-sign-off)). -**Focus:** Document Sign-Off MVP — build (see [Flagship feature](#flagship-feature--document-sign-off)). +Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date-may--july-2026)) freed the M7/M8 documentation and bot slots, and surfaced a release gap that outranks all feature work. **Quirin** | Task | Issues | |------|--------| -| Document Sign-Off MVP (build) — 5-table data model, four routes, CIP-8 signature enforcement, version-hash binding | | +| **Ship July to production** *(do this first)* — release `preprod` → `main` (75 commits), then dispatch "Deploy Database Migrations" and confirm all four outstanding migrations apply. Closes the RLS exposure on 7 tables, un-breaks governance tallies, and makes the notification center and address-less bot registration reachable in production | #332 | +| Migration-deploy reliability — make the release path self-verifying rather than path-filter dependent: run `prisma migrate status` as a post-deploy gate and alert on drift, so "merged" and "applied" cannot silently diverge again | #319 | +| Document Sign-Off MVP (build) — finalize PRD-001 (still `status: Draft`) first, then the 5-model Prisma schema, tRPC routes, CIP-8 signature enforcement, version-hash binding | | +| FROST research kickoff — survey Cardano-compatible FROST libraries + protocol readiness, draft the native-script vs threshold-Schnorr trade-off note, scope a PoC *(carryover from M3; must start here to leave runway for the October go/no-go)* | #220 | **Andre** | Task | Issues | |------|--------| -| Document Sign-Off MVP (build) — Documents section UI, six-state lifecycle, signer review screen | | +| Transaction visualization MVP (build) — Token-flow viz component, new tx building page that uses new component | | +| Unblock dependabot CI — port the skip-when-unconfigured guard from `ci-smoke-preprod.yml` into `pr-multisig-v1-smoke.yml`, then clear the 7 open dependency PRs (oldest open since 2026-06-15) | | +| Notification center follow-ups — gov-proposal improvements, Playwright coverage in CI, and a scheduled drain for the outbox (no cron currently runs `drainNotificationOutbox`) | #327 | --- -## Month 5 — September 2026 +## Month 5 — August 2026 -**Focus:** Document Sign-Off MVP — ship (8–10 wk effort completes). +**Focus:** Document Sign-Off MVP — ship (8–10 wk effort completes); discovery consolidation. **Quirin** | Task | Issues | |------|--------| -| Document Sign-Off MVP (ship) — proof export (JSON + PDF), verify route | | +| Document Sign-Off MVP (ship) — proof export (JSON + PDF), verify route. Ready = a pilot team runs all six user stories end-to-end without developer help (PRD-001's own bar)| | +| Test depth — extend the Playwright suite to cover the Sign-Off flows, plus transaction-builder & tRPC integration tests | #255 | **Andre** | Task | Issues | |------|--------| -| Document Sign-Off MVP (ship) — diffs where feasible, status grouping, polish | | +| Transaction visualization MVP (ship) — extend the tx visualizer to work with bot and display/build all tx types multisig is capable of doing | | +| Discover page — fold into the delivered Wallet V2 registration/discovery rather than building it standalone; add lookup by signer/policy *(moved up from M10)* | #52, #33 | +| Notification digests & deadline reminders — ballot-deadline and threshold-reached emails on the existing outbox (product work, infrastructure already exists) | | | Monthly report | | --- -## Month 6 — October 2026 +## Month 6 — September 2026 **Focus:** Document Sign-Off provenance, FROST findings, hardware wallets. @@ -154,52 +229,55 @@ Mid-month snapshot. Last updated 2026-06-17. | Task | Issues | |------|--------| -| Hardware wallet support — Ledger/Trezor | #44 | +| Hardware wallet support — Ledger/Trezor. **Scope the CIP-8 `signData` constraint during the M4–M5 Sign-Off build, not after** — Ledger/Trezor support for `signData` is limited, and Document Sign-Off approvals depend on it | #44 | +| UX papercut batch — full-address verification (#196), transaction pagination (#30), better 404 page (#22) | #196, #30, #22 | --- -## Month 7 — November 2026 +## Month 7 — October 2026 **Focus:** Governance polish, dApp connector, bot platform. +Revised 2026-07-26: the governance metadata fix closed in June, and the bot platform and developer portal shipped in July, so this month absorbs the work those slots were holding. + **Quirin** | Task | Issues | |------|--------| -| Governance metadata fix | #122 | | dApp connector — external dApps request multi-sig transactions | | +| Improved authentication — pairs naturally with the connector, since external dApp access and auth are the same problem surface | #135 | **Andre** | Task | Issues | |------|--------| -| Pending transactions on homepage | #125 | -| Bot platform v2 — SDK, webhooks, example bots | | +| Bot platform — webhooks. The rest of "v2" (scoped auth, reference client, example bots, OpenAPI) shipped in July; webhooks are the only unbuilt piece — no webhook code exists in `src/` today | | +| Multisig MCP server — expose the existing bot API as an MCP server so an agent can act as a wallet observer or ballot drafter. Small step from `/llms.txt` + `/api/skill` + the scoped bot JWT, and a genuine differentiator | | +| Verify and close pending-transactions-on-homepage (#125), already surfaced on the wallets dashboard | #125 | --- -## Month 8 — December 2026 +## Month 8 — November 2026 -**Focus:** Proxy voting, testing, developer experience. +**Focus:** Proxy voting, testing, backlog. **Quirin** | Task | Issues | |------|--------| | Proxy voting polish and documentation | | -| Transaction builder & tRPC integration tests | #255 | +| Collateral service for proxy usage — the last backlog item with no roadmap slot | #221 | **Andre** | Task | Issues | |------|--------| -| API documentation and developer portal | | | Backlog cleanup, dependency/security updates | | | Monthly report | | --- -## Month 9 — January 2027 +## Month 9 — December 2026 **Focus:** Document Sign-Off checkpoints, vesting, growth. @@ -214,13 +292,13 @@ Mid-month snapshot. Last updated 2026-06-17. | Task | Issues | |------|--------| -| User profiles and contacts | | +| User profiles and contacts — the `Contact` model and profile-image storage already exist; this is the UI and the social layer on top, not a from-scratch build | | --- -## Month 10 — February 2027 +## Month 10 — January 2027 -**Focus:** Invite flow and discovery. +**Focus:** Invite flow. **Quirin** @@ -232,11 +310,11 @@ Mid-month snapshot. Last updated 2026-06-17. | Task | Issues | |------|--------| -| Discover page — browse wallets, DAOs, governance | #52 | +| Open slot — the Discover page moved up to M5 to ride the delivered Wallet V2 discovery work. Reserve for spillover or pull forward from M11 | | --- -## Month 11 — March 2027 +## Month 11 — February 2027 **Focus:** Polish, wrap-up, and forward-looking research. @@ -256,7 +334,7 @@ Mid-month snapshot. Last updated 2026-06-17. --- -## Month 12 — April 2027 +## Month 12 — March 2027 **Focus:** Buffer / catch-up — absorb slippage from earlier months, finalize reporting, plan next cycle. @@ -332,16 +410,20 @@ Aggregated view of the 12-month roadmap split by contributor. Each task has a si - [M2] In-app governance voting — Ekklesia/Hydra budget voting, DRep-registration detection, ballot UX, DB-cached tallies (#122) - [M2] IPFS reliability + rationale caching + ballot CSV - [M2] Platform UX foundations — mobile, skeleton/empty states, error toasts, landing + SEO + theme -- [M3] Mesh 2.0 runtime cutover (carryover from M2) -- [M3] FROST research kickoff (#220) -- [M3] Production hardening follow-through — Node-22 migration CI on `main`, apply `ProposalTally`, RLS review (#319) -- [M4–5] Document Sign-Off MVP — data model, routes, CIP-8 enforcement, proof export +- [M3] Mesh 2.0 runtime cutover — ⏸ blocked upstream (no `@meshsdk/core` 2.x on npm); now a standing watch item, not a scheduled task +- [M3] Production hardening follow-through (#319) — ⚠️ not done; escalated into the M4 release-gap task +- [M4] **Ship July to production** — release `preprod` → `main`, dispatch migrations, close the RLS exposure (#332) +- [M4] Migration-deploy reliability — post-deploy `prisma migrate status` gate + drift alert (#319) +- [M4] FROST research kickoff (#220) — carryover from M3 +- [M4–5] Document Sign-Off MVP — finalize PRD-001, data model, routes, CIP-8 enforcement, proof export +- [M5] Test depth — Playwright coverage for Sign-Off, tx-builder & tRPC integration tests (#255) - [M6] Document Sign-Off v1 — Provenance (history, diff & rollback, audit export) - [M6] FROST research — deliver findings, PoC, go/no-go (#220) - [M7] Governance metadata fix (#122) — ✅ closed early in June - [M7] dApp connector — external dApps request multi-sig transactions +- [M7] Improved authentication (#135) - [M8] Proxy voting polish and documentation -- [M8] Transaction builder & tRPC integration tests (#255) +- [M8] Collateral service for proxy usage (#221) - [M9] Document Sign-Off v2 — Checkpoints (opt-in on-chain anchoring) - [M9] Vesting — time-locked multi-sig contracts (#81) - [M10] Invite flow (PR #67) @@ -355,14 +437,18 @@ Aggregated view of the 12-month roadmap split by contributor. Each task has a si - [M1] Handle external PR — capability-based metadata (PR #208) - [M2] CI improvements — real-chain smoke system, deploy-migrations on Node 22, dependency/security hardening (#213) - [M2] Email notification service — signature-required emails via Resend, notification center + outbox/worker, per-wallet settings, email verification -- [M3] Wallet V2 — on-chain registration and discovery (#33) +- [M3] Wallet V2 — on-chain registration and discovery (#33) — ✅ delivered in July (#340) - [M3] CI/maintenance baseline — keep suites green on Node 22, dependency/security updates +- [M4] Unblock dependabot CI — skip-when-unconfigured guard in `pr-multisig-v1-smoke.yml`, then clear the 7 open dependency PRs +- [M4] Notification center follow-ups — gov-proposal improvements, Playwright coverage, scheduled outbox drain (#327) - [M4–5] Document Sign-Off MVP — Documents UI, six-state lifecycle, signer review, diffs -- [M6] Hardware wallet support — Ledger/Trezor (#44) -- [M7] Pending transactions on homepage (#125) -- [M7] Bot platform v2 — SDK, webhooks, example bots -- [M8] API documentation and developer portal +- [M5] Discover page + lookup by signer/policy (#52, #33) — moved up from M10 +- [M5] Notification digests & deadline reminders +- [M6] Hardware wallet support — Ledger/Trezor (#44); CIP-8 `signData` constraint scoped during M4–M5 +- [M6] UX papercut batch — full-address verification (#196), tx pagination (#30), 404 page (#22) +- [M7] Bot platform — webhooks (the rest of "v2" shipped in July) +- [M7] Multisig MCP server — agent access over the existing bot API +- [M7] Verify and close pending transactions on homepage (#125) - [M8] Backlog cleanup, dependency/security updates - [M9] User profiles and contacts -- [M10] Discover page — browse wallets, DAOs, governance (#52) - [M11] Document Sign-Off v3 — Collaboration & standards (research) diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index 3197837c..5a226a85 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -78,6 +78,7 @@ services: CI_ROUTE_SCENARIOS: ${CI_ROUTE_SCENARIOS:-} CI_ROUTE_CHAIN_REPORT_PATH: ${CI_ROUTE_CHAIN_REPORT_PATH:-/artifacts/ci-route-chain-report.md} CI_CONTEXT_PATH: ${CI_CONTEXT_PATH:-/tmp/ci-wallet-context.json} + CI_RUN_WALLET_STATUS: ${CI_RUN_WALLET_STATUS:-false} CI_DREP_ANCHOR_URL: ${CI_DREP_ANCHOR_URL:-} CI_DREP_ANCHOR_JSON: ${CI_DREP_ANCHOR_JSON:-} CI_STAKE_POOL_ID_HEX: ${CI_STAKE_POOL_ID_HEX:-} @@ -94,7 +95,7 @@ services: sh -c " status=0; node .ci-dist/bootstrap.mjs || status=$$?; - if [ \"$$status\" -eq 0 ]; then node .ci-dist/wallet-status.mjs || status=$$?; fi; + if [ \"$$status\" -eq 0 ] && [ \"$${CI_RUN_WALLET_STATUS:-false}\" = \"true\" ]; then node .ci-dist/wallet-status.mjs || status=$$?; fi; if [ \"$$status\" -eq 0 ]; then node .ci-dist/route-chain.mjs || status=$$?; fi; rm -f \"${CI_CONTEXT_PATH:-/tmp/ci-wallet-context.json}\"; exit \"$$status\" diff --git a/docker-compose.playwright.yml b/docker-compose.playwright.yml new file mode 100644 index 00000000..1bdcf814 --- /dev/null +++ b/docker-compose.playwright.yml @@ -0,0 +1,157 @@ +services: + postgres: + image: postgres:14-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: multisig + volumes: + - postgres-playwright-data:/var/lib/postgresql/data + - ./docker/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 20 + networks: + - multisig-playwright-network + + app: + build: + context: . + dockerfile: Dockerfile.ci + target: app + # `next build` inlines NEXT_PUBLIC_* into the client bundle, so the + # browser flow needs them at build time, not just at runtime. If any of + # these change in .env.playwright, rebuild the app image. + args: + NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD: ${CI_BLOCKFROST_PREPROD_API_KEY:-} + # Preprod currently requires this public env var even when the test + # only uses network 0, so reuse the preprod key to satisfy validation. + NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET: ${CI_BLOCKFROST_PREPROD_API_KEY:-} + NEXT_PUBLIC_NETWORK_ID: ${CI_NETWORK_ID:-0} + environment: + NODE_ENV: test + NEXT_TELEMETRY_DISABLED: "1" + SKIP_ENV_VALIDATION: "true" + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/multisig + DIRECT_URL: postgresql://postgres:postgres@postgres:5432/multisig + JWT_SECRET: ${CI_JWT_SECRET} + CORS_ORIGINS: http://webapp:3000,http://localhost:3000 + NEXT_PUBLIC_NETWORK_ID: ${CI_NETWORK_ID:-0} + NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD: ${CI_BLOCKFROST_PREPROD_API_KEY:-} + NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET: ${CI_BLOCKFROST_PREPROD_API_KEY:-} + BLOCKFROST_API_KEY_PREPROD: ${CI_BLOCKFROST_PREPROD_API_KEY:-} + depends_on: + postgres: + condition: service_healthy + networks: + multisig-playwright-network: + # "app" matches the .app HSTS-preloaded gTLD — Chromium hard-redirects + # all http://app:* to https:// before the request leaves the browser. + # "webapp" is not on the preload list, so Playwright can reach it over HTTP. + aliases: + - webapp + # Serve the production build, like docker-compose.ci.yml. `next dev` cannot + # serve this app: Turbopack dev bundles @sidan-lab/whisky-js-nodejs despite + # serverExternalPackages (breaking its WASM path, 500-ing every SSR page), + # and webpack dev chokes on react-refresh's import.meta injection into the + # CJS @harmoniclabs/cbor dist under the transpiled @meshsdk/react. + command: > + sh -c " + echo 'Waiting for PostgreSQL to be ready...' && + until pg_isready -h postgres -p 5432 -U postgres; do sleep 1; done && + echo 'Running Prisma migrations...' && + npx prisma migrate deploy || npx prisma db push && + echo 'Starting application (production build)...' && + node_modules/.bin/next start --hostname 0.0.0.0 --port 3000 + " + healthcheck: + test: + - CMD-SHELL + - node -e "fetch('http://localhost:3000/api/swagger').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + interval: 5s + timeout: 5s + retries: 30 + + bootstrap-runner: + build: + context: . + dockerfile: Dockerfile.ci + target: base + environment: + NODE_ENV: test + NEXT_TELEMETRY_DISABLED: "1" + SKIP_ENV_VALIDATION: "true" + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/multisig + DIRECT_URL: postgresql://postgres:postgres@postgres:5432/multisig + API_BASE_URL: http://webapp:3000 + CI_NETWORK_ID: ${CI_NETWORK_ID:-0} + CI_NUM_REQUIRED_SIGNERS: ${CI_NUM_REQUIRED_SIGNERS:-2} + CI_JWT_SECRET: ${CI_JWT_SECRET} + CI_MNEMONIC_1: ${CI_MNEMONIC_1:-} + CI_MNEMONIC_2: ${CI_MNEMONIC_2:-} + CI_MNEMONIC_3: ${CI_MNEMONIC_3:-} + CI_BLOCKFROST_PREPROD_API_KEY: ${CI_BLOCKFROST_PREPROD_API_KEY:-} + CI_WALLET_TYPES: ${CI_WALLET_TYPES:-legacy,hierarchical,sdk} + CI_STAKE_POOL_ID_HEX: ${CI_STAKE_POOL_ID_HEX:-} + CI_CONTEXT_PATH: /artifacts/ci-wallet-context.json + depends_on: + app: + condition: service_healthy + networks: + - multisig-playwright-network + volumes: + - ./ci-artifacts:/artifacts + profiles: + - playwright + command: node .ci-dist/bootstrap.mjs + + playwright-runner: + build: + context: . + dockerfile: Dockerfile.playwright + # Three Chromium instances run in parallel (one per ring-transfer leg); + # Docker's default 64MB /dev/shm makes Chromium crash under that load. + shm_size: 1gb + environment: + APP_URL: http://webapp:3000 + PLAYWRIGHT_WORKERS: ${PLAYWRIGHT_WORKERS:-3} + CI_CONTEXT_PATH: /artifacts/ci-wallet-context.json + CI_JWT_SECRET: ${CI_JWT_SECRET} + CI_MNEMONIC_1: ${CI_MNEMONIC_1:-} + CI_MNEMONIC_2: ${CI_MNEMONIC_2:-} + CI_MNEMONIC_3: ${CI_MNEMONIC_3:-} + CI_BLOCKFROST_PREPROD_API_KEY: ${CI_BLOCKFROST_PREPROD_API_KEY:-} + CI_TRANSFER_LOVELACE: ${CI_TRANSFER_LOVELACE:-2000000} + # Fallback for specs when the bootstrap context predates stakePoolIdHex. + CI_STAKE_POOL_ID_HEX: ${CI_STAKE_POOL_ID_HEX:-} + CI_DREP_ANCHOR_URL: ${CI_DREP_ANCHOR_URL:-} + CI_DREP_ANCHOR_JSON: ${CI_DREP_ANCHOR_JSON:-} + PLAYWRIGHT_HTML_REPORT: /artifacts/playwright-report + PLAYWRIGHT_OUTPUT_DIR: /artifacts/playwright-traces + depends_on: + bootstrap-runner: + condition: service_completed_successfully + app: + condition: service_healthy + networks: + - multisig-playwright-network + volumes: + - ./ci-artifacts:/artifacts + # Mount e2e sources and CI framework at runtime so local edits are picked + # up immediately without rebuilding the image. Docker's BuildKit layer + # cache on Windows often fails to detect file changes inside COPY layers, + # so this is the reliable alternative. + - ./e2e:/app/e2e:ro + - ./scripts/ci/framework:/app/scripts/ci/framework:ro + profiles: + - playwright + command: npx playwright test --config=e2e/playwright.config.ts + +volumes: + postgres-playwright-data: + +networks: + multisig-playwright-network: + driver: bridge diff --git a/docs/notification-center-plan.md b/docs/notification-center-plan.md new file mode 100644 index 00000000..1ad88681 --- /dev/null +++ b/docs/notification-center-plan.md @@ -0,0 +1,446 @@ +# Notification Center Implementation Plan + +## Goal + +Build a reusable notification center that can send email notifications when a wallet signer needs to act, starting with "signature required" notifications for pending multisig transactions and signable datum payloads. + +The first channel will be email via Resend. A signer only receives email if they have provided and verified an email address, so notification delivery is opt-in and does not block wallet or transaction creation. + +## Current Codebase Context + +- Wallet signer data is currently stored as parallel arrays on `Wallet` and `NewWallet` in `prisma/schema.prisma`: `signersAddresses`, `signersStakeKeys`, `signersDRepKeys`, and `signersDescriptions`. +- User records in `User` are keyed by wallet `address` and currently include `stakeAddress`, `drepKeyHash`, optional `nostrKey`, and `discordId`, but no email field. +- Pending transaction creation happens in multiple places: + - `src/hooks/useTransaction.ts` for in-app transaction creation. + - `src/server/api/routers/transactions.ts` for tRPC create/import. + - `src/lib/server/createPendingMultisigTransaction.ts` for server/API/bot/proxy transaction builders. + - `src/pages/api/v1/addTransaction.ts` for external transaction submission. +- Pending datum/signable creation happens through: + - `src/server/api/routers/signable.ts`. + - `src/pages/api/v1/submitDatum.ts`. +- Existing reminders are Discord-only and client-triggered: + - `src/components/pages/wallet/transactions/transaction-card.tsx`. + - `src/components/pages/wallet/signing/signable-card.tsx`. + - `src/components/pages/wallet/new-transaction/index.tsx`. +- Existing observability uses append-only `AuditLog`; notification delivery should follow the same audit-friendly posture. + +## Design Principles + +- Reusable first: notification orchestration should not know about Resend directly. +- Server-owned delivery: do not send transactional notifications from React components. +- Non-blocking: transaction/signable creation should succeed even if notification dispatch fails. +- Idempotent: one event-recipient-channel combination should not send duplicate emails. +- Consent-aware: only verified or explicit opt-in email addresses receive messages. +- Email-client realistic: HTML emails should include a plain text fallback and avoid decorative assets in the first version. + +## Phase 1: Email Identity and Preferences + +### Data model + +Add signer notification metadata without adding more parallel arrays to `Wallet`. + +Recommended Prisma models: + +```prisma +model SignerNotificationProfile { + id String @id @default(cuid()) + address String @unique + email String? + emailNormalized String? + emailVerifiedAt DateTime? + emailOptIn Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([emailNormalized]) +} + +model NotificationPreference { + id String @id @default(cuid()) + address String + eventType String + channel String + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([address, eventType, channel]) + @@index([address]) +} +``` + +Optional later model if wallet-specific routing is needed: + +```prisma +model WalletSignerNotificationSetting { + walletId String + signerAddress String + eventType String + channel String + enabled Boolean @default(true) + + @@id([walletId, signerAddress, eventType, channel]) + @@index([signerAddress]) + @@index([walletId]) +} +``` + +Why not add `signersEmails String[]`: + +- The existing signer arrays already need index alignment. Adding another parallel array would make wallet updates and imports more fragile. +- Email belongs to a signer identity and can be reused across wallets. +- Verification, opt-in, and unsubscribe state do not belong in `Wallet`. + +### User model relationship + +Do not rely only on `User.email`. + +`User` is only present for onboarded wallet users. Some signers can exist in wallet arrays before they have joined or created a user record. `SignerNotificationProfile` lets the notification center resolve email by signer address even before a full `User` profile exists. + +If desired, add `email` and `emailVerifiedAt` to `User` too, but treat `SignerNotificationProfile` as the delivery source of truth and keep it synchronized when the current user changes email. + +### Email verification + +Add a simple verification token model: + +```prisma +model EmailVerificationToken { + id String @id @default(cuid()) + address String + emailNormalized String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([address]) + @@index([expiresAt]) +} +``` + +Flow: + +1. Signer enters an email in their user/profile page or during invite acceptance. +2. Server validates and normalizes it. +3. Server stores a pending token hash and sends a verification email. +4. Clicking `/api/notifications/email/verify?token=...` sets `emailVerifiedAt`. +5. Only verified emails are eligible for signature-required notifications. + +## Phase 2: Notification Outbox + +Add an outbox so notification creation and notification delivery are separate concerns. + +Recommended Prisma model: + +```prisma +model NotificationDelivery { + id String @id @default(cuid()) + eventType String + channel String + recipientAddress String + recipientEmail String? + resourceType String + resourceId String + walletId String? + idempotencyKey String @unique + subject String + payload Json + status String @default("pending") + provider String? + providerMessageId String? + attempts Int @default(0) + lastError String? + nextAttemptAt DateTime @default(now()) + sentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, nextAttemptAt]) + @@index([recipientAddress]) + @@index([walletId]) + @@index([resourceType, resourceId]) +} +``` + +Postgres notes: + +- Keep `idempotencyKey` unique so retries and concurrent trigger paths cannot duplicate sends. +- Index `status, nextAttemptAt` because the worker/drain endpoint will repeatedly query pending rows. +- Index recipient, wallet, and resource columns because the notification center UI will filter by those fields. +- Consider a partial index for pending rows in a SQL migration if Prisma does not express the exact desired index well: + +```sql +create index "NotificationDelivery_pending_idx" +on "NotificationDelivery" ("nextAttemptAt", "createdAt") +where "status" in ('pending', 'retrying'); +``` + +## Phase 3: Notification Library Layout + +Create a reusable module under `src/lib/notifications/`. + +Recommended structure: + +```text +src/lib/notifications/ + center.ts + events.ts + recipients.ts + outbox.ts + templates/ + shared.ts + signatureRequired.ts + verifyEmail.ts + channels/ + email/ + resend.ts + types.ts + worker.ts +``` + +Responsibilities: + +- `events.ts`: event names, payload types, and resource metadata. +- `recipients.ts`: resolve signer addresses to verified email profiles and preferences. +- `outbox.ts`: create idempotent `NotificationDelivery` rows. +- `center.ts`: public API used by wallet, transaction, signable, and future features. +- `templates/*`: pure functions that return `{ subject, html, text }`. +- `channels/email/resend.ts`: the only place that imports the Resend SDK. +- `worker.ts`: drains pending deliveries, handles retry/backoff, and records provider responses. + +Public API sketch: + +```ts +await notificationCenter.enqueueSignatureRequired({ + walletId, + walletName, + resourceType: "transaction", + resourceId: transaction.id, + requiredSignerAddresses, + alreadySignedAddresses, + createdByAddress, + actionUrl, + description, +}); +``` + +The reusable lower-level API should support future features: + +```ts +await notificationCenter.enqueue({ + eventType: "signature.required", + channel: "email", + recipientAddress, + resourceType, + resourceId, + walletId, + payload, +}); +``` + +## Phase 4: Resend Integration + +Install: + +```bash +npm install resend +``` + +Add server env vars in `src/env.js` and `.env.example`: + +```text +RESEND_API_KEY= +EMAIL_FROM="Mesh Multisig " +EMAIL_REPLY_TO= +NOTIFICATION_DRAIN_SECRET= +``` + +Implementation details: + +- Use `new Resend(env.RESEND_API_KEY)` inside `src/lib/notifications/channels/email/resend.ts`. +- Send with `from`, `to`, `subject`, `html`, and `text`. +- Store Resend's returned message id on `NotificationDelivery.providerMessageId`. +- Use outbox-level idempotency before provider calls. If the Resend SDK supports passing an HTTP idempotency header in the current version at implementation time, also pass the delivery idempotency key to Resend. +- Tag messages when possible with stable ASCII tags such as `event:signature_required`, `resource:transaction`, and `wallet:` for provider-side filtering. + +## Phase 5: Signature Required Triggering + +### Recipient resolver + +For each wallet action, compute: + +```ts +requiredSignerAddresses = + wallet.signersAddresses + .filter((address) => !signedAddresses.includes(address)) + .filter((address) => !rejectedAddresses.includes(address)) + .filter((address) => address !== createdByAddress); +``` + +Notes: + +- For `type === "any"` or `numRequiredSigners === 1`, there may be no pending notification because the transaction can submit immediately. +- For `atLeast`, notify unsigned signers until the threshold is met. After threshold completion and submission, do not send further reminders. +- For `all`, notify every unsigned signer. + +### Hook points + +Add notification enqueue calls after a pending row is successfully created. + +Primary hook points: + +- `src/lib/server/createPendingMultisigTransaction.ts`: notify for server-built pending transactions used by bot, proxy, staking, governance, and v1 flows. +- `src/server/api/routers/transactions.ts`: notify for tRPC-created and imported pending transactions. Longer term, consider routing all creation through the server helper so this is not duplicated. +- `src/pages/api/v1/addTransaction.ts`: if it does not route through the helper in every pending path, add notification enqueue after DB create. +- `src/server/api/routers/signable.ts`: notify for `createSignable` when `state === 0`. +- `src/pages/api/v1/submitDatum.ts`: notify for API-created `Signable` rows. + +Avoid adding new email logic to: + +- `src/components/pages/wallet/new-transaction/index.tsx` +- `src/components/pages/wallet/transactions/transaction-card.tsx` +- `src/components/pages/wallet/signing/signable-card.tsx` + +Those should eventually call a server "send reminder" mutation or endpoint if manual reminders remain. + +## Phase 6: Notification Center UI + +Add a notification/preferences section to `src/pages/user/index.tsx`. + +Minimum UI: + +- Email address field. +- Verification status. +- "Send verification email" action. +- Channel preferences: + - Signature required for transactions. + - Signature required for datum/signable payloads. +- Opt out action. + +Add a wallet-level notification center later under wallet info if needed: + +- Show which signers have email enabled without revealing full emails to other signers. +- Show delivery status for recent notifications: + - pending + - sent + - retrying + - failed + - skipped-no-email + - skipped-not-verified + - skipped-opted-out + +## Phase 7: Email Template Design + +Use HTML emails with table-safe layout and inline styles. + +Template requirements: + +- `subject`: clear and action-oriented, for example `Signature required: `. +- `text`: plain text fallback with wallet name, action link, and why the user is receiving it. +- `html`: branded transactional email with: + - wallet name + - resource type + - transaction/signable description if present + - signer progress, for example `1 of 3 signatures collected` + - CTA button to open `/wallets//transactions` or `/wallets//signing` + - unsubscribe/preferences link + +Keep the first version simple: + +- Use HTML and inline CSS only. +- Do not include animated SVGs or embedded SVG markup. +- Do not include JavaScript or external decorative assets. +- Keep all critical CTA content as readable HTML text/buttons. +- Test in Gmail, Apple Mail, and Outlook before production rollout. + +Suggested template files: + +```text +src/lib/notifications/templates/signatureRequired.ts +src/lib/notifications/templates/verifyEmail.ts +src/lib/notifications/templates/shared.ts +``` + +## Phase 8: Delivery Worker + +Start simple: + +- After enqueueing notifications, call a best-effort `drainNotificationOutbox({ limit: 10 })` server-side. +- Add `src/pages/api/notifications/drain.ts` protected by `NOTIFICATION_DRAIN_SECRET`. +- Configure a scheduled job later to call the drain endpoint every few minutes. + +Retry behavior: + +- Attempt 1 immediately. +- Retry after 5 minutes, 30 minutes, then 2 hours. +- Mark `failed` after a small max attempt count, for example 5. +- Store short error strings only; do not store provider secrets or full request bodies. + +## Phase 9: Manual Reminders + +Replace Discord-only client reminders with a server endpoint/mutation: + +```ts +api.notification.sendSignatureReminder.useMutation(...) +``` + +Rules: + +- Caller must be a wallet signer or owner. +- Recipient must be a wallet signer. +- Recipient must still need to sign. +- Apply rate limits per `walletId + resourceId + recipientAddress`. +- Enqueue `signature.reminder` using the same channel/template stack. + +Keep Discord as optional later channel if desired, but route it through the same notification center rather than calling `sendDiscordMessage` directly from components. + +## Phase 10: Testing + +Unit tests: + +- recipient resolution excludes already signed, rejected, and creator addresses. +- unverified email is skipped. +- opted-out signer is skipped. +- idempotency key prevents duplicate delivery rows. +- email templates escape dynamic values and include text fallback. + +Integration tests: + +- pending tRPC transaction creates notification deliveries. +- server-built pending transaction through `createPendingMultisigTransaction` creates notification deliveries. +- `submitDatum` creates signable notification deliveries. +- drain worker calls the Resend adapter once per pending delivery and records message ids. +- Resend failure moves row to retrying without breaking transaction creation. + +Manual QA: + +- Create a 2-of-3 wallet with one signer email verified and one missing email. +- Create a pending transaction. +- Confirm one email delivery and one skipped/no-email outcome. +- Sign with another signer until threshold is met. +- Confirm no more signature-required notifications are created. +- Open the email in Gmail, Apple Mail, and Outlook. + +## Phase 11: Rollout + +1. Add schema and env validation. +2. Add profile/preference UI and email verification. +3. Add outbox and Resend adapter behind feature flag `NOTIFICATIONS_EMAIL_ENABLED`. +4. Add transaction/signable enqueue hooks. +5. Enable drain endpoint in staging. +6. Send test notifications from staging domain. +7. Enable production for verified internal/test signers. +8. Remove or migrate client-side Discord reminder calls after email path is stable. + +## Open Questions + +- Should wallet creators be allowed to enter another signer's email, or should emails only be entered and verified by the signer themselves? +- Should emails be global per signer address or configurable per wallet? +- Should notification history be visible to all wallet signers or only to the recipient/current user? +- What production sending domain should be verified in Resend? +- Should Discord remain as a supported channel after email launches? + +## External References + +- Resend Node.js quickstart: https://resend.com/docs/send-with-nodejs +- Resend Send Email API: https://resend.com/docs/api-reference/emails/send-email diff --git a/e2e/PLAYWRIGHT_TEST_PLAN.md b/e2e/PLAYWRIGHT_TEST_PLAN.md new file mode 100644 index 00000000..82095bf3 --- /dev/null +++ b/e2e/PLAYWRIGHT_TEST_PLAN.md @@ -0,0 +1,349 @@ +# Playwright Test Plan + +This is the browser E2E coverage tracker for `e2e/tests`. The Docker Playwright +runner in `docker-compose.playwright.yml` runs every spec in that folder by default. + +## Live Now + +### `e2e/tests/ring-transfer.spec.ts` + +Status: live. + +The current suite covers the real preprod ring-transfer path: + +- CIP-0030 wallet injection +- signer wallet authentication +- transaction proposal through the UI +- pending transaction intermediate state +- second-signer approval +- threshold broadcast +- pending transaction cleanup +- `legacy`, `hierarchical`, and `sdk` wallet script types + +Live assertions: + +- Signer 0 proposes a transaction from `/wallets/{id}/transactions/new`. +- Pending transaction card is visible after creation. +- `/api/v1/pendingTransactions` shows the transaction is still pending. +- Only signer 0 is listed in `signedAddresses` after proposal. +- `rejectedAddresses` is empty after proposal. +- The proposer does not see a duplicate `Approve & Sign` action. +- Signer 1 sees the pending transaction and the `Approve & Sign` action. +- Before signer 1 signs, the pending API still shows only signer 0 has signed. +- Signer 1 signs, reaches the 2-of-3 threshold, and broadcasts on-chain. +- The test waits for `[data-testid="tx-broadcast-success"]`. +- The pending card is removed and the pending API no longer returns the transaction. + +The ring runs these legs in parallel: + +- `legacy -> hierarchical` +- `hierarchical -> sdk` +- `sdk -> legacy` + +This means hierarchical wallets are still covered as both a recipient wallet and a +source/spending wallet, even though Summon import UI coverage is out of scope. + +## Backlog + +Route-chain and unit tests already cover many API and transaction-builder paths, so +new Playwright tests should focus on flows where the browser, wallet connector, page +guards, form state, and user-facing UI can regress. + +## Important Wallet-Type Constraint + +Hierarchical wallets are Summon platform wallets that users import. They should not be covered by "create wallet from UI" tests, and the Summon import path is intentionally out of scope because it is already done and is not expected to receive future updates. + +Playwright coverage should treat wallet types as follows: + +| Wallet type | UI creation coverage | Additional Playwright coverage | Notes | +|---|---:|---:|---| +| `legacy` | Yes | Transaction/signing flows | Native multisig wallet created by this app | +| `sdk` | Yes | Transaction/signing, staking, governance flows | App-created SDK multisig wallet | +| `hierarchical` | No | Existing ring-transfer coverage only | Summon platform wallet; import-only in this app | + +## Phase 1: Highest-Value Browser Coverage + +### 1. Create Wallet UI + +Status: live in `e2e/tests/create-wallet-ui.spec.ts`. + +Goal: prove users can create app-native multisig wallets from the browser. + +Coverage: + +- Create a `legacy` wallet with three signers and a 2-of-3 threshold. +- Create an `sdk` wallet with three signers and a 2-of-3 threshold. +- Validate signer rows, threshold controls, and review screen. +- Confirm native script summary renders. +- Save wallet and confirm it appears on `/wallets`. + +Out of scope: + +- Do not create `hierarchical` wallets through the UI. Those are Summon import wallets. + +### 2. New Transaction Form Validation + +Status: live in `e2e/tests/new-transaction-validation.spec.ts`. + +Goal: prove transaction creation errors are caught before users submit broken transactions. + +Coverage: + +- Invalid recipient address. +- Empty recipient address. +- Zero amount. +- Negative amount. +- Amount greater than selected UTxO balance. +- Add and remove recipient rows. +- Selected UTxO count updates before submit. +- Create button disabled or guarded when required fields are invalid. + +Optional coverage: + +- CSV recipient import. +- "Send all" behavior. +- Multiple-recipient transaction proposal. + +## Phase 2: Failure And Access-Control Coverage + +### 3. Rejected Wallet Signing + +Status: live in `e2e/tests/rejected-signing.spec.ts`. + +Goal: prove wallet rejection is handled cleanly. + +Coverage: + +- Mock `signTx` rejection during transaction proposal. +- Confirm no pending transaction is created (no `createTransaction` request, + empty pending API, no pending card). +- Mock `signTx` rejection during transaction approval. +- Confirm no signature is added (no `updateTransaction` request, pending API + still shows only the proposer, `rejectedAddresses` stays empty). +- Confirm visible error feedback appears and the form/card recovers. + +Notes: + +- Both tests run against a throwaway 2-of-3 wallet created via tRPC so the + intentionally-stranded pending transaction can never trip ring-transfer's + clean-pending precondition on the bootstrap wallets. The throwaway wallet is + unfunded; the UTxO fetch is mocked and nothing is broadcast. + +### 4. Wallet Access Control + +Status: live in `e2e/tests/wallet-access-control.spec.ts`. + +Goal: prove page guards match wallet authorization. + +Coverage: + +- Authenticated signer can open wallets they belong to. +- Authenticated signer cannot open a wallet they do not belong to. +- Direct navigation to protected wallet pages redirects or shows access denied. +- Protected routes remain protected after browser reload. + +Candidate pages: + +- `/wallets/{walletId}` +- `/wallets/{walletId}/transactions` +- `/wallets/{walletId}/transactions/new` +- `/wallets/{walletId}/info` +- `/wallets/{walletId}/staking` +- `/wallets/{walletId}/governance` + +Notes: + +- The non-member is a real derived preprod address (all-zero-entropy test + mnemonic) with a valid injected session cookie, so the test exercises the + authenticated-but-unauthorized path: `wallet.getWallet` returns FORBIDDEN, + no wallet content renders on any candidate page, and the REST + `pendingTransactions` endpoint also denies the address. +- Unauthenticated direct navigation asserts the layout's public-landing + fallback (Connect Wallet visible, no wallet content). + +### 5. Responsive Smoke Tests + +Status: live in `e2e/tests/responsive-smoke.spec.ts`. + +Goal: catch browser layout regressions on common mobile sizes. + +Coverage (at 375x667 and 412x915): + +- Wallet list. +- Wallet detail. +- Transaction list. +- New transaction page (mobile card layout, reachable create button). +- Wallet connect entry point (connect button + wallet dropdown). + +Assertions: + +- Critical controls are visible. +- No horizontal document overflow. +- Primary action buttons are reachable. + +Not covered on mobile: + +- The auth modal and the signing flow modal/card. Both need either a live + handshake or a pending transaction; their behavior is exercised at desktop + size by ring-transfer and rejected-signing. Add mobile variants only if a + mobile-specific layout bug shows up there. + +## Phase 3: Product-Area Browser Coverage + +### 6. Staking UI + +Status: live in `e2e/tests/staking-ui.spec.ts`. + +Goal: prove the SDK staking pages work in the browser. + +Coverage: + +- Staking page loads staking info from the (mocked) account state. +- No staking actions are offered until a pool ID is entered (register/delegate + and deregister gating). +- Inactive stake exposes `RegisterAndDelegate`; active stake exposes + `Delegate` + `Deregister`. +- Register+delegate certificate proposal creates a pending transaction + (verified via `/api/v1/pendingTransactions`, then deleted). + +Notes: + +- Runs against a throwaway 2-of-3 wallet created with the CI signers' stake + keys, so the app classifies it as `sdk` and the staking page can derive a + stake address. UTxOs and `/accounts/{stake}` are mocked; propose-only, no + broadcast (full certificate broadcast stays in route-chain). +- Requires `CI_STAKE_POOL_ID_HEX` (hex pool ID; a bech32 `pool1...` value is + normalized in-test). Read from the bootstrap context when present, else from + the env var forwarded by `docker-compose.playwright.yml`. + +### 7. DRep And Governance UI + +Status: live in `e2e/tests/governance-drep-ui.spec.ts`. + +Goal: prove governance actions can be initiated from the browser. + +Coverage: + +- Governance page loads for an eligible (throwaway SDK) wallet with a derived + DRep ID. +- DRep management actions are gated on registration state: Register enabled, + Update and Retire disabled while the (mocked) DRep is unregistered. +- DRep register form validation (submit disabled until name, objectives, + motivations, and qualifications are filled from `CI_DREP_ANCHOR_JSON`). +- DRep update form validation (same gating on the update page). +- Active proposals list loads (mocked Blockfrost `/governance/proposals` + + metadata) and renders the proposal title. +- Ballot modal opens (via the `New` toggle), creates a ballot via + `ballot.create`, and lists it (deleted afterwards via tRPC). + +Notes: + +- Retire is exercised as a gated action only: an actually-retirable DRep needs + a real on-chain registration, which is route-chain territory. +- The DRep form values come from `CI_DREP_ANCHOR_JSON`; keep it as single-line + CIP-119 JSON so Docker Compose can forward it to the Playwright runner. +- Submitting a DRep registration certificate from the browser is intentionally + not covered. That path canonicalizes the CIP-119 anchor with jsonld + (URDNA2015), which calls `crypto.subtle`; the Web Crypto API is only exposed + in a secure context, and the Docker app is served over plain + `http://webapp:3000`, so the build throws `crypto.subtle not found`. Staking + certificate proposals do not use jsonld (hence staking-ui covers + propose-to-pending), and DRep certificate building/broadcast is covered in + route-chain (`scenario.drep-certificates`). + +### 8. Proxy UI + +Status: live in `e2e/tests/proxy-ui.spec.ts`. + +Goal: prove proxy controls are usable from the browser. + +Coverage: + +- Proxy control panel loads on the wallet info page and expands. +- Empty state offers first-proxy setup; the setup modal opens with the step + indicator, collateral notice, description field, and an enabled + `Start Proxy Setup` action for a connected wallet. +- Existing proxy state is displayed: a proxy row seeded via + `proxy.createProxy` renders with its description and the panel's proxy + count reflects it (deleted afterwards via tRPC). + +Notes: + +- Setup proposal creation is intentionally not driven from the browser: the + auth-token mint is a Plutus transaction needing real collateral and funded + inputs, and the full lifecycle already has broad route-chain coverage + (`scenario.proxy-full-lifecycle`). + +### 9. Bot Management UI + +Status: live in `e2e/tests/bot-management-ui.spec.ts`. + +Goal: prove users can manage bot credentials through the UI. + +Coverage: + +- Register a pending bot over REST (`/api/v1/botRegister`) and claim it in + the UI with the one-time claim code (enter code → review → success). +- Requested scopes are shown at review time; unrequested scopes cannot be + approved. +- Claimed bot appears in the user bot list with name, key ID, scopes, and the + bot payment address. +- Edit scopes through the dialog and confirm the badge list updates. +- Revoke the bot (native confirm accepted) and confirm it disappears. + +Notes: + +- The app's bot model is claim-based; the bot's API secret is delivered via + `botPickupSecret` to the bot itself and never rendered in the UI, so the + original "generated secret is shown once" item maps to the one-time claim + code flow. + +### 10. Notification Center + +Status: live in `e2e/tests/notification-settings-ui.spec.ts`. + +Goal: prove signature notifications help users find pending work. + +Coverage: + +- Email Notifications card loads for a wallet signer on the wallet info page. +- Saving an email persists it and flips the badge from `No email` to + `Not verified`. +- `Send verification email` unlocks once an email is saved and reports + queued/prepared depending on whether delivery is enabled in the + environment. +- Preference toggles (transaction signatures) persist across a full reload. + +Notes: + +- Signature notifications in this app are email-based (per-signer settings + + server-side outbox/worker); there is no in-app notification inbox, so the + original "click notification → land on transaction" item has no browser + surface. The email pipeline itself (outbox rows, worker, verify link) is + server-side coverage outside this suite. + +## Suggested Implementation Order + +1. Create wallet UI for `legacy` and `sdk`. +2. New transaction form validation. +3. Rejected wallet signing. +4. Wallet access control. +5. Responsive smoke tests. +6. Staking UI. +7. DRep and governance UI. +8. Proxy UI. +9. Bot management UI. +10. Notification center. + +Optional diagnostic coverage: + +- Add a small wallet connect/auth-only spec if wallet auth becomes flaky or hard to diagnose through ring-transfer failures. Ring transfer already exercises wallet injection and signer auth, so this is not required for baseline coverage. + +## Test Design Notes + +- Prefer browser tests for user-visible behavior, wallet connector behavior, page guards, and form state. +- Prefer route-chain tests for real-chain API coverage, full certificate broadcast flows, and expensive proxy lifecycle checks. +- Keep real-chain Playwright tests narrow. Use mocked or intercepted browser responses for validation-heavy UI tests when the chain itself is not the subject. +- Reuse the existing wallet fixture and bootstrap context where possible. +- Avoid adding new funded-wallet requirements unless the test truly needs real preprod UTxOs. +- Do not add new Summon/hierarchical import tests unless that flow starts receiving product changes again. diff --git a/e2e/RUNNING_LOCALLY.md b/e2e/RUNNING_LOCALLY.md new file mode 100644 index 00000000..90a9d62d --- /dev/null +++ b/e2e/RUNNING_LOCALLY.md @@ -0,0 +1,337 @@ +# Running the Playwright E2E Tests Locally + +The Playwright runner is the single local entry point for browser E2E tests in +`e2e/tests`. The suite includes the ring-transfer specs, which drive a real +Cardano preprod browser flow +(CIP-0030 wallet injection -> transaction propose -> multi-sign -> on-chain broadcast), +plus UI specs covering wallet creation, new-transaction validation, rejected +signing, staking, governance/DRep, proxies, bot management, notification +settings, wallet access control, and responsive smoke checks. + +Use the Docker flow below when you want the local run to match CI +(`.github/workflows/pr-playwright-browser.yml`). It starts Postgres, +starts the app, bootstraps the three CI wallets, then runs the full Playwright suite +against the app container. + +## Prerequisites + +| Requirement | Notes | +|---|---| +| Docker + Docker Compose | Manages Postgres, app, bootstrap runner, and Playwright runner | +| Three funded preprod mnemonics | Each wallet should hold at least 5 ADA for fees | +| Blockfrost preprod API key | From https://blockfrost.io | +| JWT secret | At least 32 characters; used as `JWT_SECRET` in the app | + +## 1. Create `.env.playwright` + +Create `.env.playwright` in the repo root. Do not commit it. + +```dotenv +CI_JWT_SECRET=your-jwt-secret-min-32-chars +CI_MNEMONIC_1="word1 word2 word3 ... word24" +CI_MNEMONIC_2="word1 word2 word3 ... word24" +CI_MNEMONIC_3="word1 word2 word3 ... word24" +CI_BLOCKFROST_PREPROD_API_KEY=preprodXXXXXXXXXXXXXXXXXX +CI_NETWORK_ID=0 +CI_NUM_REQUIRED_SIGNERS=2 +CI_WALLET_TYPES=legacy,hierarchical,sdk +CI_TRANSFER_LOVELACE=2000000 +# Hex (28-byte) preprod pool id — required by the staking-ui spec. +CI_STAKE_POOL_ID_HEX=f9c8e7275348d3b1a3596c94095f43307990cc5f800bbbb256298658 +``` + +Keep every value on a single line: `docker compose --env-file` cannot parse +multi-line values, and one malformed entry breaks the whole file. The browser +governance spec and route-chain runner both expect `CI_DREP_ANCHOR_JSON` to stay +single-line minified JSON if you keep it in this file. + +## 2. First Clean Run + +Run these commands in order from the repo root. + +### PowerShell + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build app bootstrap-runner +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build playwright-runner +docker compose -f docker-compose.playwright.yml --env-file .env.playwright up -d postgres app +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ps +``` + +Wait until `multisig-app-1` shows `healthy`. + +```powershell +New-Item -ItemType Directory -Force ci-artifacts | Out-Null +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ` + --profile playwright run --rm bootstrap-runner + +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ` + --profile playwright run --rm --no-deps playwright-runner +``` + +### Bash + +```bash +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build app bootstrap-runner +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build playwright-runner +docker compose -f docker-compose.playwright.yml --env-file .env.playwright up -d postgres app +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ps +``` + +Wait until `multisig-app-1` shows `healthy`. + +```bash +mkdir -p ci-artifacts +docker compose -f docker-compose.playwright.yml --env-file .env.playwright \ + --profile playwright run --rm bootstrap-runner + +docker compose -f docker-compose.playwright.yml --env-file .env.playwright \ + --profile playwright run --rm --no-deps playwright-runner +``` + +Bootstrap creates `ci-artifacts/ci-wallet-context.json`. The Playwright runner reads +that file, so bootstrap must run before the test runner. + +Do not continue after a failed or canceled image build. In particular, if the +`playwright-runner` image fails during `npm ci`, rebuild it after the registry +recovers before running tests; if the `app` build is canceled, rebuild `app` before +`up -d postgres app`. Otherwise Docker may start an older app image whose browser +bundle is missing the `.env.playwright` `NEXT_PUBLIC_*` values. + +Use `--no-deps` when running `playwright-runner` after bootstrap. Without it, Docker +Compose may try to run dependency services again, including bootstrap. + +By default, `playwright-runner` executes all specs under `e2e/tests` using +`e2e/playwright.config.ts`. As new Playwright specs are added, they should be runnable +through this same command unless they intentionally require a different setup. + +## 3. Rerun After Changes + +Pick the smallest path that matches what changed. + +| What changed? | Commands to run | +|---|---| +| Only `e2e/` or `scripts/ci/framework/` | Run only `playwright-runner`; these paths are volume-mounted | +| `src/`, `prisma/`, app env, or other app code | Rebuild `app`, restart `app`, then run `playwright-runner` | +| `package.json`, `package-lock.json`, `tsconfig*.json`, or `Dockerfile.playwright` | Rebuild `playwright-runner`, then run `playwright-runner` | +| Wallet context is stale, wrong, or DB data is dirty | Tear down with `down -v`, then repeat the first clean run | + +### Only test/framework changes + +Runs the full Playwright suite: + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ` + --profile playwright run --rm --no-deps playwright-runner +``` + +```bash +docker compose -f docker-compose.playwright.yml --env-file .env.playwright \ + --profile playwright run --rm --no-deps playwright-runner +``` + +### Run a focused spec + +Use this when iterating on one Playwright file while keeping the same Docker app, +database, wallet context, and artifact paths. + +PowerShell: + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ` + --profile playwright run --rm --no-deps playwright-runner ` + npx playwright test --config=e2e/playwright.config.ts e2e/tests/ring-transfer.spec.ts +``` + +Bash: + +```bash +docker compose -f docker-compose.playwright.yml --env-file .env.playwright \ + --profile playwright run --rm --no-deps playwright-runner \ + npx playwright test --config=e2e/playwright.config.ts e2e/tests/ring-transfer.spec.ts +``` + +Replace `e2e/tests/ring-transfer.spec.ts` with any spec path under `e2e/tests`. + +### App code changes + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build app +docker compose -f docker-compose.playwright.yml --env-file .env.playwright up -d app +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ` + --profile playwright run --rm --no-deps playwright-runner +``` + +```bash +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build app +docker compose -f docker-compose.playwright.yml --env-file .env.playwright up -d app +docker compose -f docker-compose.playwright.yml --env-file .env.playwright \ + --profile playwright run --rm --no-deps playwright-runner +``` + +### Runner dependency changes + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build playwright-runner +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ` + --profile playwright run --rm --no-deps playwright-runner +``` + +```bash +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build playwright-runner +docker compose -f docker-compose.playwright.yml --env-file .env.playwright \ + --profile playwright run --rm --no-deps playwright-runner +``` + +## Useful Commands + +### Check app health + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ps +``` + +```bash +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ps +``` + +### Poll app health automatically + +PowerShell: + +```powershell +do { + docker compose -f docker-compose.playwright.yml exec app ` + node -e "fetch('http://localhost:3000/api/swagger').then(r=>process.exit(r.ok?0:1))" 2>$null + if ($LASTEXITCODE -ne 0) { Write-Host "waiting..."; Start-Sleep 5 } +} until ($LASTEXITCODE -eq 0) +``` + +Bash: + +```bash +until docker compose -f docker-compose.playwright.yml exec app \ + node -e "fetch('http://localhost:3000/api/swagger').then(r=>process.exit(r.ok?0:1))"; \ + do echo "waiting..."; sleep 5; done +``` + +### View the HTML report + +Artifacts are written to `ci-artifacts/`. Failure traces, screenshots, and videos +land in `ci-artifacts/playwright-traces/`. + +```bash +npx playwright show-report ci-artifacts/playwright-report +``` + +### Tear down + +Use this when you want a fresh database and wallet context. + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright down -v --remove-orphans +``` + +```bash +docker compose -f docker-compose.playwright.yml --env-file .env.playwright down -v --remove-orphans +``` + +## Environment Variable Reference + +| Variable | Required | Description | +|---|---|---| +| `CI_JWT_SECRET` | Yes | Must equal the app's `JWT_SECRET`. Used to sign wallet-session cookies in the fast-auth path. | +| `CI_MNEMONIC_1` | Yes | 24-word mnemonic for signer 0, the proposer. | +| `CI_MNEMONIC_2` | Yes | 24-word mnemonic for signer 1. | +| `CI_MNEMONIC_3` | Yes | 24-word mnemonic for signer 2. | +| `CI_BLOCKFROST_PREPROD_API_KEY` | Yes | Blockfrost preprod API key, usually starting with `preprod`. | +| `CI_CONTEXT_PATH` | Yes in containers | Path where bootstrap writes and tests read `ci-wallet-context.json`; provided by Docker Compose. | +| `APP_URL` | No | Base URL of the running app; provided by Docker Compose for the runner. | +| `CI_TRANSFER_LOVELACE` | No | Lovelace sent per ring-transfer leg. Defaults to `2000000` (2 ADA). | +| `CI_STAKE_POOL_ID_HEX` | Yes for `staking-ui.spec.ts` | Hex (28-byte) preprod stake pool id. A bech32 `pool1...` value is normalized in-test, but bootstrap and route-chain expect hex. Forwarded to both the bootstrap and Playwright runners. | +| `CI_DREP_ANCHOR_URL` | No | Not read by any current spec; Docker Compose forwards it to the runner only for parity. Required separately by the route-chain CI runner. | +| `CI_DREP_ANCHOR_JSON` | Yes for `governance-drep-ui.spec.ts` | CIP-119 anchor JSON used to fill the DRep register/update validation forms. It must stay single-line JSON because `docker compose --env-file` cannot parse multi-line values. | +| `CI_NETWORK_ID` | No | `0` for preprod. Defaults to `0`. | +| `CI_NUM_REQUIRED_SIGNERS` | No | Signing threshold. Defaults to `2`. | +| `CI_WALLET_TYPES` | No | Comma-separated wallet types. Defaults to `legacy,hierarchical,sdk`. | +| `PLAYWRIGHT_WORKERS` | No | Number of parallel Playwright workers. Defaults to `3` (one per ring-transfer leg). Set to `1` for serial execution. | + +## How the Suite Works + +1. Bootstrap creates three multisig wallets (`legacy`, `hierarchical`, and `sdk`) in + the app DB and writes their wallet IDs, script addresses, and signer addresses to + `ci-wallet-context.json`. + +2. `global-setup.ts` validates env vars and caches the context JSON for the test run. + +3. Playwright runs every spec in `e2e/tests` unless you pass a focused spec path. + Specs should reuse the existing fixtures and bootstrap context when possible so the + Docker runner remains the one local place to exercise browser coverage. + +4. `ring-transfer.spec.ts` runs three legs in parallel, one Playwright worker + per leg. For each leg: + - Signer 0 proposes a transaction from `/wallets/{id}/transactions/new`. + - The test verifies the pending transaction is still below threshold and only the + proposer has signed before the second signer acts. + - The `window.cardano.meshci` mock intercepts `signTx` and bridges to + `MeshWallet.signTx` in Node.js using the corresponding mnemonic. + - Signer 1 signs from `/wallets/{id}/transactions`, reaching the 2-of-3 threshold + and broadcasting on-chain. + - The test waits for `[data-testid="tx-broadcast-success"]` and confirms the pending + transaction is cleared via `/api/v1/pendingTransactions`. + +5. Ring-transfer legs run in parallel. Each leg spends from a different multisig wallet + (legacy, hierarchical, sdk), so the legs never compete for the same UTxOs. + Each source wallet must independently hold enough ADA for its transfer plus + fees; if a previous run left a wallet short, the leg waits up to 5 minutes + for the concurrently running leg that refills it. Set `PLAYWRIGHT_WORKERS=1` + to fall back to serial execution. + +When adding new specs, keep their state isolated from ring-transfer where possible. +The default worker count is `3` because the ring legs are designed to run in parallel; +set `PLAYWRIGHT_WORKERS=1` for serial debugging or for a new spec that is not yet +parallel-safe. + +## Troubleshooting + +**`CI_CONTEXT_PATH must be set`** - bootstrap did not run before the Playwright runner. +Run bootstrap, then run `playwright-runner` with `--no-deps`. + +**`Missing required environment variables`** - one of the required env vars is missing. +Check `.env.playwright`. + +**`No legacy/hierarchical/sdk wallet found`** - the bootstrap context is stale or was +written by an older schema. Tear down with `down -v --remove-orphans`, then repeat the +first clean run. + +**`This address is already registered to another bot`** - the DB still has wallets from +a previous run. Tear down with `down -v --remove-orphans`, then repeat the first clean +run. + +**`utxo-selector[data-loaded="true"]` timeout** - the app could not fetch UTxOs from +Blockfrost. Confirm `CI_BLOCKFROST_PREPROD_API_KEY` is a valid preprod key and the +wallets have UTxOs. If the key changed, rebuild and restart the `app` service because +`next build` bakes `NEXT_PUBLIC_*` vars into the client bundle at image build time +(they are passed as Docker build args from `.env.playwright`). + +**Blank black page / no `Connect Wallet` button** - the browser bundle likely built +without required public env vars. Rebuild and restart `app`: + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright build app +docker compose -f docker-compose.playwright.yml --env-file .env.playwright up -d app +``` + +This can happen after a build log containing `RUN npm ci ... exit code: 146` followed +by `RUN npm run build CANCELED`: the runner image had a network install failure and +the app image build was canceled, so the next `up` reused an older app image. + +**`net::ERR_SSL_PROTOCOL_ERROR`** - `.app` is on Chromium's HSTS preload list, so +`http://app:*` is upgraded to HTTPS. The Compose file uses the `webapp` network alias +and the runner uses `http://webapp:3000`. Confirm both are still configured. + +**Wallet not found in connect modal** - `window.cardano.meshci` was not injected before +the page loaded. Check fixture order in `authFixture.ts`. + +**Transaction still pending after broadcast timeout** - preprod may be congested, or +the wallet may lack enough ADA for fees. Check wallet balances on preprod Cardanoscan. diff --git a/e2e/fixtures/authFixture.ts b/e2e/fixtures/authFixture.ts new file mode 100644 index 00000000..01a672dc --- /dev/null +++ b/e2e/fixtures/authFixture.ts @@ -0,0 +1,161 @@ +// Phase 3: Authentication fixture. +// Extends walletFixture with two strategies for establishing a valid session: +// +// authenticateAs(page, signerIndex) +// Drives the real wallet-connect → nonce → signData → session-cookie flow +// (Option B from the plan). Preferred for ring-transfer tests because it +// exercises the same code path real users take. Skips the reconnect when the +// signer index hasn't changed so a single-leg test only pays the auth cost once +// per signer switch, not per page navigation. +// +// authenticateDirect(page, signerIndex) +// Fast path: injects the mesh_wallet_session cookie directly without touching +// the UI. Requires CI_JWT_SECRET == the app's JWT_SECRET. Useful for tests +// that need an authenticated context but are not testing the auth flow itself. + +import { test as walletTest } from "./walletFixture"; +import type { Page } from "@playwright/test"; +import { loadContext } from "../helpers/contextLoader"; +import { + buildWalletSessionToken, + WALLET_SESSION_COOKIE, +} from "../helpers/authSession"; + +type AuthFixtures = { + authenticateAs: (page: Page, signerIndex: number) => Promise; + authenticateDirect: (page: Page, signerIndex: number) => Promise; +}; + +export const test = walletTest.extend({ + // Establishes an authenticated session for the given signer. + // + // CI path (CI_JWT_SECRET set): instead of driving the real nonce → signData → + // POST /api/auth/wallet-session handshake, inject a valid mesh_wallet_session + // cookie directly and seed Mesh's persisted connection so connect-wallet's + // auto-connect re-enables the injected mock on every navigation. This is the + // authenticated-context-without-the-auth-flow scenario authenticateDirect was + // built for, and it sidesteps two failures that make the real handshake + // unusable for the parallel ring-transfer legs: + // 1. Under `next start` the app issues its cookie with the Secure attribute, + // which the browser never resends over plain http://webapp:3000 — so + // every navigation arrives unauthenticated, the WalletAuthModal reopens, + // and the UTxO selector never loads. Our injected cookie is non-Secure. + // 2. The per-navigation re-auth that fires when no valid cookie is present + // runs getNonce → wallet-session unserialized across the three legs, which + // all authenticate as the same signer addresses backed by a single nonce + // row → "No nonce issued for this address" / 401. A pre-injected cookie + // keeps getWalletSession authorized, so no handshake ever runs. + // + // Fallback (no CI_JWT_SECRET, e.g. preprod): drive the real connect flow. + authenticateAs: async ({ injectWallet, connectWallet }, use) => { + let lastAuthenticatedIndex: number | null = null; + let persistSeeded = false; + + await use(async (page: Page, signerIndex: number) => { + // Always re-inject so the mocked wallet's addresses and mnemonic reflect + // the current signer. The bridge functions close over mutable state, so + // injectWallet does not need to register exposeFunction more than once. + await injectWallet(page, signerIndex); + + const jwtSecret = process.env.CI_JWT_SECRET; + if (jwtSecret) { + const ctx = loadContext(); + const signerAddress = ctx.signerAddresses[signerIndex]; + if (!signerAddress) { + throw new Error( + `No signer address at index ${signerIndex} in bootstrap context`, + ); + } + + // Seed Mesh's persisted connection once. connect-wallet's auto-connect + // reads this on each full-page load and re-enables the injected mock, so + // useAddress resolves and the layout renders the wallet page without any + // UI interaction. addInitScript runs before page scripts on every load. + if (!persistSeeded) { + await page.addInitScript(() => { + try { + localStorage.setItem( + "mesh-wallet-persist", + JSON.stringify({ walletName: "meshci" }), + ); + } catch { + // localStorage may be unavailable before first paint — ignored. + } + }); + persistSeeded = true; + } + + // Replace any prior signer's cookie with this signer's session. + // secure:false is essential — a Secure cookie is dropped over http and + // re-creates the unauthenticated-navigation failure this avoids. + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + const token = buildWalletSessionToken(signerAddress, jwtSecret); + await page.context().clearCookies({ name: WALLET_SESSION_COOKIE }); + await page.context().addCookies([ + { + name: WALLET_SESSION_COOKIE, + value: token, + url: appUrl, + httpOnly: true, + sameSite: "Lax", + secure: false, + // 7 days, matching createWalletSessionToken in walletSession.ts + expires: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + }, + ]); + lastAuthenticatedIndex = signerIndex; + return; + } + + // Fallback: drive the real UI connect flow. Skip the reconnect when the + // signer index is unchanged — the session cookie carries between navigations. + if (lastAuthenticatedIndex !== signerIndex) { + await connectWallet(page); + lastAuthenticatedIndex = signerIndex; + } + }); + }, + + // Fast path: injects mesh_wallet_session cookie without any UI interaction. + // CI_JWT_SECRET must be set and must equal the app's JWT_SECRET. + authenticateDirect: async ({ injectWallet }, use) => { + await use(async (page: Page, signerIndex: number) => { + const jwtSecret = process.env.CI_JWT_SECRET; + if (!jwtSecret) { + throw new Error( + "CI_JWT_SECRET must be set to use authenticateDirect(). " + + "Use authenticateAs() to drive the real connect flow instead.", + ); + } + + const ctx = loadContext(); + const signerAddress = ctx.signerAddresses[signerIndex]; + if (!signerAddress) { + throw new Error( + `No signer address at index ${signerIndex} in bootstrap context`, + ); + } + + // Inject the CIP-0030 wallet mock so the UI can interact with wallet APIs + // even though we're skipping the connect modal. + await injectWallet(page, signerIndex); + + const token = buildWalletSessionToken(signerAddress, jwtSecret); + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + await page.context().addCookies([ + { + name: WALLET_SESSION_COOKIE, + value: token, + domain: new URL(appUrl).hostname, + path: "/", + httpOnly: true, + sameSite: "Lax", + // 7 days, matching createWalletSessionToken in src/lib/auth/walletSession.ts + expires: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + }, + ]); + }); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/e2e/fixtures/walletFixture.ts b/e2e/fixtures/walletFixture.ts new file mode 100644 index 00000000..9e33bc1c --- /dev/null +++ b/e2e/fixtures/walletFixture.ts @@ -0,0 +1,269 @@ +// Phase 2: Playwright fixture for CIP-0030 wallet injection. +// Wraps each test with wallet mock setup: exposes Node.js bridge functions +// (signTx, getUtxos, signData, submitTx) via page.exposeFunction() and +// injects the window.cardano.meshci object via page.addInitScript(). + +import { test as base, expect, type Page } from "@playwright/test"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { loadContext } from "../helpers/contextLoader"; +import { buildCip30MockScript } from "../helpers/cip30Mock"; +import { signWithMnemonic, signDataWithMnemonic } from "../helpers/meshSign"; +import { getSignerUtxos } from "../helpers/blockfrostUtils"; +import { Address } from "@meshsdk/core-cst"; + +// CIP-30 getUsedAddresses/getChangeAddress/getRewardAddresses return HEX-encoded +// address bytes, not bech32. Mesh react 2.0's useAddress() parses them with a +// strict hex decoder (Cardano.Address.fromBytes(HexBlob(addr))); a bech32 string +// throws `Invalid string: "expected hex string"` and leaves useAddress() +// unresolved, so the layout falls back to the public homepage. Convert the +// bootstrap's bech32 addresses to hex before injecting them into the mock. +function addressToHex(bech32: string): string { + return Address.fromBech32(bech32).toBytes().toString(); +} + +type WalletFixtures = { + injectWallet: (page: Page, signerIndex: number) => Promise; + connectWallet: (page: Page) => Promise; +}; + +// Cross-worker mutex for the wallet-connect handshake. The app stores a single +// nonce row per address and rotates it on every /api/v1/getNonce call, and all +// parallel legs authenticate as the same signer addresses — two workers running +// the nonce → signData → wallet-session sequence concurrently overwrite each +// other's nonce and one gets a 401 "Invalid signature". Serializing just the +// handshake (~10s) keeps the multi-minute legs parallel. +const AUTH_LOCK_DIR = path.join(os.tmpdir(), "multisig-e2e-auth.lock"); +const AUTH_LOCK_STALE_MS = 150_000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function acquireAuthLock(): Promise { + const deadline = Date.now() + 300_000; + for (;;) { + try { + fs.mkdirSync(AUTH_LOCK_DIR); + return; + } catch { + // Held by another worker. Break stale locks left by a crashed process. + try { + const age = Date.now() - fs.statSync(AUTH_LOCK_DIR).mtimeMs; + if (age > AUTH_LOCK_STALE_MS) { + fs.rmdirSync(AUTH_LOCK_DIR); + continue; + } + } catch { + // Lock released between mkdir and stat — retry immediately. + continue; + } + if (Date.now() > deadline) { + throw new Error( + `Timed out waiting for the wallet-auth lock at ${AUTH_LOCK_DIR}`, + ); + } + await sleep(500); + } + } +} + +function releaseAuthLock(): void { + try { + fs.rmdirSync(AUTH_LOCK_DIR); + } catch { + // Already released (e.g. broken as stale by another worker). + } +} + +function getMnemonicForIndex(signerIndex: number): string { + const key = `CI_MNEMONIC_${signerIndex + 1}`; + const value = process.env[key]; + if (!value) { + throw new Error(`Environment variable ${key} is not set`); + } + return value; +} + +export const test = base.extend({ + injectWallet: async ({}, use) => { + // Per-test mutable bridge state. Closures below reference these variables + // so updating them before each bridge call switches the active signer. + let currentMnemonic = ""; + let currentSignerAddress = ""; + let bridgesRegistered = false; + + await use(async (page: Page, signerIndex: number) => { + const ctx = loadContext(); + const mnemonic = getMnemonicForIndex(signerIndex); + const signerAddress = ctx.signerAddresses[signerIndex]; + const stakeAddress = ctx.signerStakeAddresses?.[signerIndex] ?? ""; + + if (!signerAddress) { + throw new Error( + `No signer address at index ${signerIndex} in bootstrap context`, + ); + } + + // Update mutable state so all subsequent bridge calls use the new signer. + currentMnemonic = mnemonic; + currentSignerAddress = signerAddress; + + // Register bridge functions once per page. exposeFunction cannot be called + // twice with the same name, so all calls close over the mutable variables above. + if (!bridgesRegistered) { + await page.exposeFunction( + "__ci_signTx", + (cbor: string, partial: boolean) => + signWithMnemonic(currentMnemonic, cbor, partial), + ); + await page.exposeFunction("__ci_getUtxos", () => + getSignerUtxos(currentSignerAddress), + ); + await page.exposeFunction( + "__ci_signData", + (_addr: string, payload: string) => + // BrowserWallet.signData(nonce, address) maps to CIP-30 signData(addressBytesHex, nonce). + // _addr = hex-encoded address bytes (CIP-30 key selector), ignored here. + // payload = the nonce. The nonce is already a valid even-length hex string, + // so BrowserWallet passes it through unchanged. + signDataWithMnemonic( + currentMnemonic, + payload, + currentSignerAddress, + ), + ); + // submitTx is a no-op: actual broadcast happens via /api/v1/signTransaction. + await page.exposeFunction("__ci_submitTx", (_cbor: string) => + Promise.resolve("0".repeat(64)), + ); + bridgesRegistered = true; + } + + // Always add a fresh init script so the latest signer's addresses are present + // on the next navigation. Multiple addInitScript calls stack; the last script's + // assignment wins. + await page.addInitScript({ + content: buildCip30MockScript({ + walletName: "meshci", + usedAddresses: [addressToHex(signerAddress)], + changeAddress: addressToHex(signerAddress), + rewardAddresses: stakeAddress ? [addressToHex(stakeAddress)] : [], + }), + }); + }); + }, + + connectWallet: async ({}, use) => { + await use(async (page: Page) => { + // The nonce → signData → wallet-session sequence must not interleave + // with another worker authenticating the same signer address. + await acquireAuthLock(); + try { + await connectWalletFlow(page); + } finally { + releaseAuthLock(); + } + }); + }, +}); + +async function connectWalletFlow(page: Page): Promise { + // Navigate to the app root so the Connect Wallet button is rendered. + await page.goto("/"); + await page + .waitForLoadState("networkidle", { timeout: 30_000 }) + .catch(() => {}); + + // On signer switches, keep Mesh's persisted wallet connection and reload + // after clearing auth state. The latest injectWallet() init script wins on + // reload, so Mesh re-enables meshci against the new signer. Disconnecting + // leaves the app logged in but hides the wallet connector, making reconnect + // impossible from the normal signed-in header. + const hasPersistedMeshWallet = await page.evaluate(() => { + try { + const persisted = localStorage.getItem("mesh-wallet-persist"); + return !!persisted && persisted.includes("meshci"); + } catch { + return false; + } + }); + + // Clear the HttpOnly session cookie from Playwright. This keeps the fixture + // compatible with preprod, where /api/auth/wallet-session only accepts POST. + await page.context().clearCookies({ name: "mesh_wallet_session" }); + await page + .evaluate(() => sessionStorage.removeItem("mesh_session_checked")) + .catch(() => {}); + + // Register the response listener before clicking so we do not miss a fast response. + const sessionResponsePromise = page.waitForResponse( + (r) => + r.url().includes("/api/auth/wallet-session") && + r.request().method() === "POST", + { timeout: 60_000 }, + ); + + if (hasPersistedMeshWallet) { + await page.reload(); + await page + .waitForLoadState("networkidle", { timeout: 30_000 }) + .catch(() => {}); + } else { + // Open the Connect Wallet dropdown (first match = header button; the + // homepage also has a hero-section CTA with the same label). + const connectBtn = page + .getByRole("button", { name: /connect wallet/i }) + .first(); + await connectBtn.waitFor({ timeout: 10_000 }); + await connectBtn.click(); + await page.waitForSelector('[role="menu"]', { timeout: 5_000 }); + + // useWalletList polls window.cardano with a debounce before picking up + // the injected wallet. + const meshciItem = page.getByRole("menuitem", { name: "MeshCI" }); + await meshciItem.waitFor({ timeout: 5_000 }); + await meshciItem.click(); + } + + // The layout shows WalletAuthModal once the wallet is connected but has no + // session. autoAuthorize usually posts the session request itself; race that + // response against a bounded explicit click so a disabled button cannot + // strand the test until the outer test timeout. + const authDialog = page.getByRole("dialog", { + name: /authorize this wallet/i, + }); + const dialogAppeared = await authDialog + .waitFor({ state: "visible", timeout: 20_000 }) + .then(() => true) + .catch(() => false); + + if (dialogAppeared) { + const authorizeBtn = authDialog.getByRole("button", { + name: /^Authorize$/i, + }); + await Promise.race([ + sessionResponsePromise.then(() => undefined), + (async () => { + await expect(authorizeBtn).toBeEnabled({ timeout: 10_000 }); + await authorizeBtn.click(); + })(), + ]).catch(() => {}); + } + + // Wait for the resulting POST /api/auth/wallet-session and fail with + // response details instead of timing out on non-200 responses. + const sessionResponse = await sessionResponsePromise; + const sessionResponseBody = await sessionResponse.text().catch(() => ""); + expect( + sessionResponse.ok(), + `wallet-session failed ${sessionResponse.status()}: ${sessionResponseBody}`, + ).toBe(true); + + await page + .waitForSelector('[role="dialog"]', { state: "hidden", timeout: 30_000 }) + .catch(() => {}); +} + +export { expect }; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 00000000..880a0af2 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,35 @@ +import fs from "fs"; + +const REQUIRED_ENV_VARS = [ + "CI_MNEMONIC_1", + "CI_MNEMONIC_2", + "CI_MNEMONIC_3", + "CI_BLOCKFROST_PREPROD_API_KEY", +] as const; + +async function globalSetup() { + const contextPath = process.env.CI_CONTEXT_PATH; + if (!contextPath) { + throw new Error("CI_CONTEXT_PATH must be set before running Playwright tests"); + } + + const missing = REQUIRED_ENV_VARS.filter((k) => !process.env[k]); + if (missing.length > 0) { + throw new Error(`Missing required environment variables: ${missing.join(", ")}`); + } + + const raw = fs.readFileSync(contextPath, "utf8"); + const ctx = JSON.parse(raw) as { wallets?: Array<{ type: string }> }; + const wallets = ctx.wallets ?? []; + + for (const type of ["legacy", "hierarchical", "sdk"] as const) { + if (!wallets.some((w) => w.type === type)) { + throw new Error(`Bootstrap context is missing a ${type} wallet`); + } + } + + // Cache parsed context so tests can read it without re-hitting disk. + process.env.CI_CONTEXT_JSON = raw; +} + +export default globalSetup; diff --git a/e2e/helpers/apiHelpers.ts b/e2e/helpers/apiHelpers.ts new file mode 100644 index 00000000..f603c834 --- /dev/null +++ b/e2e/helpers/apiHelpers.ts @@ -0,0 +1,165 @@ +// Phase 2 (failure/access coverage): API-level helpers shared by the +// rejected-signing and wallet-access-control specs. +// +// - trpcMutate(): calls a tRPC mutation directly through the page's request +// context (shares the mesh_wallet_session cookie), bypassing the UI. Used to +// create throwaway wallets and clean up pending transactions. +// - createThrowawayWallet(): registers a fresh 2-of-3 wallet over the CI signer +// addresses. Rejection tests create pending transactions and must never do +// that on the bootstrap ring wallets — a leftover pending row would trip +// ring-transfer's expectNoPendingTransactions() on a parallel worker. +// - getPendingTransactionsRest(): REST read of pending transactions, mirroring +// the ring-transfer spec's verification path. + +import type { Page } from "@playwright/test"; +import jwt from "jsonwebtoken"; +import type { CIBootstrapContext } from "./contextLoader"; +const { sign } = jwt; + +export type PendingTransaction = { + id: string; + signedAddresses?: string[]; + rejectedAddresses?: string[]; + state?: number; +}; + +export type ThrowawayWallet = { + walletId: string; + /** Script (enterprise) address the app derives for this wallet. */ + address: string; +}; + +/** + * Calls a tRPC mutation via the page's cookie-authenticated request context. + * Input is wrapped in the superjson batch envelope the app's tRPC client uses. + */ +export async function trpcMutate( + page: Page, + procedure: string, + input: unknown, +): Promise { + const response = await page.request.post(`/api/trpc/${procedure}?batch=1`, { + headers: { "content-type": "application/json" }, + data: JSON.stringify({ "0": { json: input } }), + }); + const body = await response.text(); + if (!response.ok()) { + throw new Error(`${procedure} failed ${response.status()}: ${body}`); + } + type TrpcResultItem = { + result?: { data?: { json?: T } }; + error?: unknown; + }; + const parsed = JSON.parse(body) as TrpcResultItem | TrpcResultItem[]; + const item = Array.isArray(parsed) ? parsed[0] : parsed; + if (item?.error) { + throw new Error(`${procedure} error: ${JSON.stringify(item.error)}`); + } + return item?.result?.data?.json as T; +} + +/** + * Builds the same 2-of-3 atLeast native script buildWallet derives for a + * legacy wallet (payment key hashes in signer order, no stake credential), + * so the returned address matches what the app will display and query. + */ +export async function buildTwoOfThreeScript( + signerAddresses: string[], +): Promise<{ scriptCbor: string; address: string }> { + const { deserializeAddress, serializeNativeScript } = await import( + "@meshsdk/core" + ); + const nativeScript = { + type: "atLeast" as const, + required: 2, + scripts: signerAddresses.map((address) => ({ + type: "sig" as const, + keyHash: deserializeAddress(address).pubKeyHash, + })), + }; + const { scriptCbor, address } = serializeNativeScript( + nativeScript, + undefined, + 0, + ); + if (!scriptCbor) { + throw new Error("serializeNativeScript returned no scriptCbor"); + } + return { scriptCbor, address }; +} + +/** + * Creates an isolated legacy 2-of-3 wallet over the CI signer addresses via + * tRPC. The wallet needs no funding: specs that use it mock the UTxO fetch, + * and nothing is ever broadcast (threshold is never reached). + * + * With `withStakeKeys` the wallet also registers the CI signers' stake + * addresses, which classifies it as an SDK wallet in the app (role-2 keys): + * the staking and governance pages can then derive a stake address and + * staking script for it. The returned `address` is still the payment-only + * enterprise address; SDK wallets render their canonical stakeable address + * instead, so UTxO mocks for those specs must match any address. + */ +export async function createThrowawayWallet( + page: Page, + ctx: CIBootstrapContext, + name: string, + options: { withStakeKeys?: boolean } = {}, +): Promise { + const signers = ctx.signerAddresses.slice(0, 3); + if (signers.length < 3) { + throw new Error("Bootstrap context must provide at least 3 signer addresses"); + } + let signersStakeKeys: string[] | null = null; + if (options.withStakeKeys) { + const stakeAddresses = (ctx.signerStakeAddresses ?? []).slice(0, 3); + if (stakeAddresses.length < 3) { + throw new Error( + "Bootstrap context must provide 3 signer stake addresses (schemaVersion 3) for withStakeKeys", + ); + } + signersStakeKeys = stakeAddresses; + } + const { scriptCbor, address } = await buildTwoOfThreeScript(signers); + const wallet = await trpcMutate<{ id: string }>(page, "wallet.createWallet", { + name, + description: "Throwaway wallet for Playwright failure-path coverage", + signersAddresses: signers, + signersDescriptions: ["Signer 1", "Signer 2", "Signer 3"], + signersStakeKeys, + signersDRepKeys: null, + numRequiredSigners: 2, + scriptCbor, + type: "atLeast", + }); + if (!wallet?.id) { + throw new Error(`createWallet returned no wallet id: ${JSON.stringify(wallet)}`); + } + return { walletId: wallet.id, address }; +} + +/** Signs a short-lived REST bearer token for /api/v1 endpoints. */ +export function buildRestToken(address: string): string { + const jwtSecret = process.env.CI_JWT_SECRET; + if (!jwtSecret) { + throw new Error("CI_JWT_SECRET must be set to call /api/v1 endpoints"); + } + return sign({ address }, jwtSecret, { expiresIn: "1h" }); +} + +export async function getPendingTransactionsRest( + page: Page, + walletId: string, + signerAddress: string, +): Promise { + const response = await page.request.get( + `/api/v1/pendingTransactions?walletId=${encodeURIComponent(walletId)}&address=${encodeURIComponent(signerAddress)}`, + { headers: { Authorization: `Bearer ${buildRestToken(signerAddress)}` } }, + ); + if (!response.ok()) { + throw new Error( + `pendingTransactions failed ${response.status()}: ${await response.text().catch(() => "")}`, + ); + } + return (await response.json()) as PendingTransaction[]; +} diff --git a/e2e/helpers/authSession.ts b/e2e/helpers/authSession.ts new file mode 100644 index 00000000..025598a5 --- /dev/null +++ b/e2e/helpers/authSession.ts @@ -0,0 +1,31 @@ +// Phase 3: Builds a signed mesh_wallet_session cookie token for direct injection. +// The app uses a custom wallet session system (not next-auth) backed by a JWT +// signed with JWT_SECRET. This helper creates a matching token so e2e tests can +// bypass the wallet-connect UI flow when testing non-auth paths. +// +// Requires CI_JWT_SECRET == the app's JWT_SECRET env var. + +import jwt from "jsonwebtoken"; +const { sign } = jwt; + +export const WALLET_SESSION_COOKIE = "mesh_wallet_session"; + +export type WalletSessionPayload = { + wallets: string[]; + primaryWallet?: string | null; +}; + +/** + * Returns a signed JWT that the app accepts as a valid mesh_wallet_session cookie. + * Inject it via page.context().addCookies() to authenticate without the UI flow. + */ +export function buildWalletSessionToken( + address: string, + jwtSecret: string, +): string { + const payload: WalletSessionPayload = { + wallets: [address], + primaryWallet: address, + }; + return sign(payload, jwtSecret, { expiresIn: "7d" }); +} diff --git a/e2e/helpers/blockfrostUtils.ts b/e2e/helpers/blockfrostUtils.ts new file mode 100644 index 00000000..98541ade --- /dev/null +++ b/e2e/helpers/blockfrostUtils.ts @@ -0,0 +1,19 @@ +// Phase 2: Fetches UTxOs for a signer address via Blockfrost preprod REST. +// Used as the Node.js-side handler for window.__ci_getUtxos() bridge calls. +// Note: multisig script address UTxOs are fetched by the app itself; this +// supplies the connected wallet's own UTxOs for CIP-0030 contract compliance. + +export async function getSignerUtxos(address: string): Promise { + const apiKey = process.env.CI_BLOCKFROST_PREPROD_API_KEY?.trim(); + if (!apiKey || !address) { + return []; + } + try { + const { BlockfrostProvider } = await import("@meshsdk/core"); + const provider = new BlockfrostProvider(apiKey); + return await provider.fetchAddressUTxOs(address); + } catch { + // getUtxos() is not exercised by the ring transfer flow; swallow errors. + return []; + } +} diff --git a/e2e/helpers/cip30Mock.ts b/e2e/helpers/cip30Mock.ts new file mode 100644 index 00000000..be567f31 --- /dev/null +++ b/e2e/helpers/cip30Mock.ts @@ -0,0 +1,60 @@ +// Phase 2: Browser-injectable CIP-0030 wallet mock. +// Injects window.cardano[walletName] driven by Node.js bridge functions +// registered via page.exposeFunction() before page.goto(). +// +// IMPORTANT: per the CIP-0030 spec, getUsedAddresses / getUnusedAddresses / +// getChangeAddress / getRewardAddresses return HEX-encoded address bytes, not +// bech32. Mesh react 2.0's useAddress() resolves the address via +// getUsedAddressesBech32(), which runs Cardano.Address.fromBytes(HexBlob(addr)) — +// a strict hex parser that throws `Invalid string: "expected hex string"` on a +// bech32 string. That uncaught error leaves useAddress() unresolved, so the +// layout never sets userAddress and renders the public homepage instead of the +// wallet page. Always pass hex addresses here (see addressToHex in walletFixture). + +export type Cip30MockParams = { + walletName: string; + // HEX-encoded address bytes (CIP-30 wire format), NOT bech32. + usedAddresses: string[]; + changeAddress: string; + rewardAddresses: string[]; +}; + +export function buildCip30MockScript(params: Cip30MockParams): string { + return ` + (function() { + var params = ${JSON.stringify(params)}; + window.cardano = window.cardano || {}; + window.cardano[params.walletName] = { + name: 'MeshCI', + icon: 'data:image/svg+xml,', + apiVersion: '0.1.0', + isEnabled: async function() { return true; }, + enable: async function() { + return { + getBalance: async function() { + // CBOR integer 2000000 lovelace (2 ADA) — display only. + // 1a = uint32 tag; 001e8480 = 2000000 in hex. + return '1a001e8480'; + }, + getUsedAddresses: async function() { return params.usedAddresses; }, + getUnusedAddresses: async function() { return []; }, + getChangeAddress: async function() { return params.changeAddress; }, + getRewardAddresses: async function() { return params.rewardAddresses; }, + getUtxos: async function() { return await window.__ci_getUtxos(); }, + signTx: async function(cbor, partial) { + return await window.__ci_signTx(cbor, !!partial); + }, + signData: async function(addr, payload) { + return await window.__ci_signData(addr, payload); + }, + submitTx: async function(cbor) { + return await window.__ci_submitTx(cbor); + }, + getNetworkId: async function() { return 0; }, + getCollateral: async function() { return []; }, + }; + }, + }; + })(); + `; +} diff --git a/e2e/helpers/contextLoader.ts b/e2e/helpers/contextLoader.ts new file mode 100644 index 00000000..0f74ba88 --- /dev/null +++ b/e2e/helpers/contextLoader.ts @@ -0,0 +1,44 @@ +import fs from "fs"; + +export type CIWalletType = "legacy" | "hierarchical" | "sdk"; + +export type CIWalletContext = { + type: CIWalletType; + walletId: string; + walletAddress: string; + transactionId?: string; + signerAddresses: string[]; +}; + +export type CIBootstrapContext = { + schemaVersion: 3; + createdAt: string; + apiBaseUrl: string; + networkId: 0 | 1; + walletTypes: CIWalletType[]; + wallets: CIWalletContext[]; + signerAddresses: string[]; + signerStakeAddresses: string[]; + sdkStakeAddress?: string; + stakePoolIdHex?: string; +}; + +export function loadContext(): CIBootstrapContext { + const cached = process.env.CI_CONTEXT_JSON; + if (cached) { + return JSON.parse(cached) as CIBootstrapContext; + } + const contextPath = process.env.CI_CONTEXT_PATH; + if (!contextPath) { + throw new Error("CI_CONTEXT_PATH or CI_CONTEXT_JSON must be set"); + } + return JSON.parse(fs.readFileSync(contextPath, "utf8")) as CIBootstrapContext; +} + +export function getWallet(ctx: CIBootstrapContext, type: CIWalletType): CIWalletContext { + const wallet = ctx.wallets.find((w) => w.type === type); + if (!wallet) { + throw new Error(`No ${type} wallet found in bootstrap context`); + } + return wallet; +} diff --git a/e2e/helpers/meshSign.ts b/e2e/helpers/meshSign.ts new file mode 100644 index 00000000..6c0f9efd --- /dev/null +++ b/e2e/helpers/meshSign.ts @@ -0,0 +1,43 @@ +// Phase 2: Node.js MeshWallet signing bridge. +// Called from walletFixture as the handler for window.__ci_signTx() and +// window.__ci_signData() bridge calls from the browser. + +function parseMnemonic(str: string): string[] { + return str.trim().split(/\s+/).filter(Boolean); +} + +export async function signWithMnemonic( + mnemonic: string, + txCbor: string, + partial: boolean, +): Promise { + const { MeshWallet } = await import("@meshsdk/core"); + const wallet = new MeshWallet({ + networkId: 0, + key: { type: "mnemonic", words: parseMnemonic(mnemonic) }, + }); + await wallet.init(); + // The injected browser object is a CIP-30 wallet. CIP-30 signTx returns a + // TransactionWitnessSet, and Mesh BrowserWallet wraps that witness set into + // the full transaction. Returning a full transaction here makes BrowserWallet + // try to parse a transaction as a witness set, which fails with CBOR major + // type mismatch errors. + return wallet.signTx(txCbor, partial, false); +} + +export async function signDataWithMnemonic( + mnemonic: string, + dataToSign: string, + signingAddress: string, +): Promise<{ signature: string; key: string }> { + const { MeshWallet } = await import("@meshsdk/core"); + const wallet = new MeshWallet({ + networkId: 0, + key: { type: "mnemonic", words: parseMnemonic(mnemonic) }, + }); + await wallet.init(); + // MeshWallet.signData(payload, address): first arg is data to sign, second is the bech32 + // signing address. EmbeddedWallet uses the address to look up the correct private key. + const result = await wallet.signData(dataToSign, signingAddress); + return { signature: result.signature, key: result.key }; +} diff --git a/e2e/helpers/phase3Mocks.ts b/e2e/helpers/phase3Mocks.ts new file mode 100644 index 00000000..ce3f5735 --- /dev/null +++ b/e2e/helpers/phase3Mocks.ts @@ -0,0 +1,325 @@ +// Phase 3 (product-area browser coverage): shared browser-side mocks for the +// staking, governance, proxy, bot, and notification specs. +// +// These specs run against throwaway wallets that are never funded, so every +// chain read the pages perform is intercepted in the browser: +// - address UTxOs -> one large fake UTxO, echoing whatever address the +// page asked about (SDK wallets render their canonical +// stakeable address, which the specs never compute) +// - account state -> deterministic staking state (active / inactive) +// - governance -> fixed proposal list + metadata, no registered DRep +// - stake pool list -> empty (specs enter the pool id manually) +// +// Nothing is ever broadcast: the throwaway wallets have a 2-of-3 threshold and +// only signer 0 ever signs, so proposals stay pending and are deleted in +// cleanup. + +import type { Page } from "@playwright/test"; +import { loadContext, type CIBootstrapContext } from "./contextLoader"; +import { + getPendingTransactionsRest, + type PendingTransaction, +} from "./apiHelpers"; + +export const FAKE_UTXO_TX_HASH = "3".repeat(64); + +/** + * Serves one fake UTxO for every address the page asks about, echoing the + * requested address back so the UTxO always matches the address the app + * queried (throwaway SDK wallets display a stakeable address the spec never + * derives). Page 2+ returns [] so BlockfrostProvider pagination terminates. + * + * The default 600 ADA covers the DRep registration deposit (500 ADA), the + * stake registration deposit (2 ADA), and fees for every Phase 3 proposal. + */ +export async function mockWalletUtxos( + page: Page, + options: { lovelace?: string } = {}, +): Promise { + const lovelace = options.lovelace ?? "600000000"; + await page.route(/\/addresses\/[^/]+\/utxos/, async (route) => { + const url = new URL(route.request().url()); + const pageNumber = Number(url.searchParams.get("page") ?? "1"); + const segments = url.pathname.split("/"); + const address = decodeURIComponent( + segments[segments.indexOf("addresses") + 1] ?? "", + ); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + pageNumber === 1 + ? [ + { + address, + tx_hash: FAKE_UTXO_TX_HASH, + output_index: 0, + amount: [{ unit: "lovelace", quantity: lovelace }], + data_hash: null, + inline_datum: null, + reference_script_hash: null, + }, + ] + : [], + ), + }); + }); +} + +/** + * Mocks the Blockfrost transaction reads the token-flow visualization + * performs on expansion: + * - `/txs/{hash}/utxos` -> one input/output pair echoing the requested + * hash, with output 0 at the given address (this also resolves pending + * inputs that lack address/amount in their builder body) + * - `/txs/{hash}` -> minimal tx info with zero certificate / + * withdrawal / mint counts, so no follow-up detail reads fire + */ +export async function mockTxFlowReads( + page: Page, + address: string, + options: { lovelace?: string } = {}, +): Promise { + const lovelace = options.lovelace ?? "5000000"; + await page.route(/\/txs\/[0-9a-f]{64}\/utxos/, async (route) => { + const hash = /\/txs\/([0-9a-f]{64})\/utxos/.exec( + route.request().url(), + )?.[1]; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + hash, + inputs: [ + { + address, + amount: [{ unit: "lovelace", quantity: lovelace }], + tx_hash: "0".repeat(64), + output_index: 0, + data_hash: null, + collateral: false, + reference: false, + }, + ], + outputs: [ + { + address, + amount: [{ unit: "lovelace", quantity: lovelace }], + output_index: 0, + data_hash: null, + collateral: false, + }, + ], + }), + }); + }); + await page.route(/\/txs\/[0-9a-f]{64}$/, async (route) => { + const hash = /\/txs\/([0-9a-f]{64})$/.exec(route.request().url())?.[1]; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + hash, + block_time: 1700000000, + fee: "200000", + deposit: "0", + valid_contract: true, + withdrawal_count: 0, + delegation_count: 0, + stake_cert_count: 0, + pool_update_count: 0, + pool_retire_count: 0, + asset_mint_or_burn_count: 0, + redeemer_count: 0, + }), + }); + }); +} + +/** + * Mocks the Blockfrost `/accounts/{stakeAddress}` read the staking page uses. + * `active: false` exposes the RegisterAndDelegate action; `active: true` + * exposes Delegate + Deregister instead. + */ +export async function mockAccountState( + page: Page, + options: { active: boolean; poolId?: string | null; rewards?: string }, +): Promise { + await page.route(/\/accounts\/[^/]+$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + stake_address: "mocked", + active: options.active, + pool_id: options.poolId ?? null, + controlled_amount: "600000000", + rewards_sum: options.rewards ?? "0", + withdrawals_sum: "0", + reserves_sum: "0", + treasury_sum: "0", + drep_id: null, + }), + }); + }); +} + +/** Mocks `/pools/extended` so PoolSelector renders without live Blockfrost. */ +export async function mockPoolList(page: Page): Promise { + await page.route(/\/pools\/extended/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); +} + +export const MOCK_PROPOSAL_TX_HASH = "4".repeat(64); +export const MOCK_PROPOSAL_TITLE = "CI Mock Proposal"; + +/** + * Mocks the governance reads the governance page performs: + * - `/governance/proposals?...` -> a single mock info action + * - `/governance/proposals/{tx}/{idx}` -> minimal details + * - `.../metadata` -> metadata carrying MOCK_PROPOSAL_TITLE + * - `/governance/dreps/...` -> 404, i.e. the wallet DRep is not + * registered (Register enabled, + * Update/Retire disabled) + */ +export async function mockGovernanceState(page: Page): Promise { + await page.route(/\/governance\/(proposals|dreps)/, async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + + if (path.includes("/governance/dreps")) { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ status_code: 404, error: "Not Found", message: "" }), + }); + return; + } + + if (path.endsWith("/metadata")) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + tx_hash: MOCK_PROPOSAL_TX_HASH, + cert_index: 0, + governance_type: "info_action", + hash: "0".repeat(64), + url: "https://example.com/ci-mock-proposal.jsonld", + bytes: "", + json_metadata: { + body: { + title: MOCK_PROPOSAL_TITLE, + abstract: "Mock proposal served by the Playwright governance spec.", + motivation: "", + rationale: "", + references: [], + }, + authors: [], + }, + }), + }); + return; + } + + // Details for a single proposal: /governance/proposals/{tx_hash}/{cert_index} + if (/\/governance\/proposals\/[^/]+\/\d+$/.test(path)) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + tx_hash: MOCK_PROPOSAL_TX_HASH, + cert_index: 0, + governance_type: "info_action", + governance_description: { tag: "InfoAction" }, + deposit: "100000000", + return_address: "stake_test1mocked", + expiration: 999, + id: `gov_action1${"q".repeat(50)}`, + }), + }); + return; + } + + // Proposal list: page 1 has the mock proposal, later pages are empty. + const pageNumber = Number(url.searchParams.get("page") ?? "1"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + pageNumber === 1 + ? [ + { + tx_hash: MOCK_PROPOSAL_TX_HASH, + cert_index: 0, + governance_type: "info_action", + ratified_epoch: null, + enacted_epoch: null, + dropped_epoch: null, + expired_epoch: null, + expiration: 999, + }, + ] + : [], + ), + }); + }); +} + +/** + * Resolves the stake pool id (hex) for staking specs: prefer the bootstrap + * context (written by scripts/ci bootstrap from CI_STAKE_POOL_ID_HEX), fall + * back to the env var directly, and normalize a bech32 `pool1...` value to + * hex so either form works. + */ +export async function resolveStakePoolHex( + ctx: CIBootstrapContext, +): Promise { + const raw = (ctx.stakePoolIdHex ?? process.env.CI_STAKE_POOL_ID_HEX ?? "").trim(); + if (!raw) { + throw new Error( + "CI_STAKE_POOL_ID_HEX must be set (env or bootstrap context) for the staking spec", + ); + } + if (raw.startsWith("pool1")) { + const { deserializePoolId } = await import("@meshsdk/core"); + return deserializePoolId(raw); + } + return raw; +} + +/** + * Polls the REST pending-transactions endpoint until exactly one transaction + * is pending for the wallet, then returns it. Mirrors the rejected-signing + * spec: right after creation the rendered card may still be the optimistic + * `temp-...` React Query entry, so the DB id must come from the API. + */ +export async function waitForSinglePendingTransaction( + page: Page, + walletId: string, + signerAddress: string, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + // Transient request failures (socket hang up under load) just mean + // "poll again" — only the deadline is fatal. + const pending = await getPendingTransactionsRest( + page, + walletId, + signerAddress, + ).catch(() => null); + if (pending?.length === 1) return pending[0]!; + await page.waitForTimeout(2_000); + } + throw new Error( + "Proposed transaction never appeared in /api/v1/pendingTransactions", + ); +} + +export { loadContext }; diff --git a/e2e/playwright-report/index.html b/e2e/playwright-report/index.html new file mode 100644 index 00000000..375eb98d --- /dev/null +++ b/e2e/playwright-report/index.html @@ -0,0 +1,90 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..028f352a --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR ?? "test-results", + globalSetup: "./global-setup.ts", + timeout: 120_000, + // Playwright's 5s default is too tight when 3 workers hit the app cold — + // assertions that gate on tRPC round-trips false-fail under contention. + expect: { timeout: 15_000 }, + retries: 0, + // The ring-transfer legs spend from distinct wallets and run in parallel — + // one worker per leg. Default worker detection inside a container can + // resolve to 1, which would silently serialize them again. + workers: Number(process.env.PLAYWRIGHT_WORKERS ?? "3"), + reporter: [ + [ + "html", + { + open: "never", + outputFolder: process.env.PLAYWRIGHT_HTML_REPORT ?? "playwright-report", + }, + ], + ["line"], + ], + use: { + baseURL: process.env.APP_URL ?? "http://localhost:3000", + headless: true, + screenshot: "only-on-failure", + video: "retain-on-failure", + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/e2e/tests/bot-management-ui.spec.ts b/e2e/tests/bot-management-ui.spec.ts new file mode 100644 index 00000000..23470dc7 --- /dev/null +++ b/e2e/tests/bot-management-ui.spec.ts @@ -0,0 +1,130 @@ +// Phase 3 item 9: Bot management UI. +// +// Proves users can manage bot credentials through the /user page. The app's +// bot model is claim-based: a bot registers itself over REST +// (POST /api/v1/botRegister -> pendingBotId + one-time claim code) and the +// user claims it in the UI, approving its requested scopes. The bot's API +// secret is delivered to the bot via botPickupSecret, never shown in the UI, +// so "generated secret shown once" from the original plan maps to the +// one-time claim code + claim flow here. +// +// Coverage: +// - claim a freshly registered bot (ID + claim code -> review -> success) +// - the claimed bot appears in the bot list with name, key id, and scopes +// - the bot's payment address is visible after claiming +// - edit scopes through the dialog +// - revoke the bot and confirm it disappears +// +// The spec registers its own pending bot with a unique fake payment address, +// so it never collides with route-chain bots or other workers. + +import { test, expect } from "../fixtures/authFixture"; + +test.describe("bot management UI", () => { + test("claim, inspect, edit scopes, and revoke a bot", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + + await authenticateAs(page, 0); + + // Register a pending bot the way a real bot would, over REST. + const botName = `ci-playwright-bot-${Date.now()}-${test.info().workerIndex}`; + const paymentAddress = `addr_test1ciplaywrightbot${Date.now()}${test.info().workerIndex}`; + const registerResponse = await page.request.post("/api/v1/botRegister", { + headers: { "content-type": "application/json" }, + data: JSON.stringify({ + name: botName, + paymentAddress, + requestedScopes: ["multisig:read", "multisig:sign"], + }), + }); + expect( + registerResponse.ok(), + `botRegister failed ${registerResponse.status()}: ${await registerResponse.text().catch(() => "")}`, + ).toBe(true); + const { pendingBotId, claimCode } = (await registerResponse.json()) as { + pendingBotId: string; + claimCode: string; + }; + expect(pendingBotId).toBeTruthy(); + expect(claimCode).toBeTruthy(); + + await page.goto("/user"); + await expect(page.getByRole("heading", { name: "Bot accounts" })).toBeVisible({ + timeout: 60_000, + }); + + // Step 1: enter the bot id and claim code. + await page.getByRole("button", { name: "Claim a bot" }).click(); + await page.getByLabel("Bot ID").fill(pendingBotId); + await page.getByLabel("Claim code").fill(claimCode); + await page.getByRole("button", { name: "Next" }).click(); + + // Step 2: review shows the bot's identity and requested scopes. + await expect(page.getByText(botName)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Requested scopes")).toBeVisible(); + await expect(page.locator("#claim-scope-multisig\\:read")).toBeChecked(); + await expect(page.locator("#claim-scope-multisig\\:sign")).toBeChecked(); + // Scopes the bot did not request cannot be approved. + await expect(page.locator("#claim-scope-ballot\\:write")).toBeDisabled(); + + await page.getByRole("button", { name: "Claim bot" }).click(); + + // Step 3: success confirmation, then the bot shows up in the list. + await expect(page.getByText("Bot claimed successfully")).toBeVisible({ + timeout: 30_000, + }); + await page.getByRole("button", { name: "Done" }).click(); + + // Toasts also render as
  • and the claim toast contains the bot name, + // so anchor the row on its Edit scopes action. + const botRow = page + .locator("li", { hasText: botName }) + .filter({ has: page.getByRole("button", { name: "Edit scopes" }) }); + await expect(botRow).toBeVisible({ timeout: 30_000 }); + await expect(botRow.getByText("multisig:read")).toBeVisible({ + timeout: 30_000, + }); + await expect(botRow.getByText("multisig:sign")).toBeVisible(); + await expect(botRow.getByText("Key ID")).toBeVisible(); + // The payment address row appears once the bot user record is linked. + await expect(botRow.getByText("Bot address")).toBeVisible(); + + // Edit scopes: drop the sign scope, keep read. + await botRow.getByRole("button", { name: "Edit scopes" }).click(); + await expect( + page.getByRole("heading", { name: "Edit scopes" }), + ).toBeVisible(); + await page.locator("#edit-scope-multisig\\:sign").click(); + const updateScopesResponsePromise = page.waitForResponse( + (response) => + response.url().includes("bot.updateBotKeyScopes") && + response.request().method() === "POST", + { timeout: 60_000 }, + ); + await page.getByRole("button", { name: "Save", exact: true }).click(); + const updateScopesResponse = await updateScopesResponsePromise; + expect( + updateScopesResponse.ok(), + `bot.updateBotKeyScopes failed ${updateScopesResponse.status()}`, + ).toBe(true); + + await expect(botRow.getByText("multisig:read")).toBeVisible({ + timeout: 30_000, + }); + await expect(botRow.getByText("multisig:sign")).toHaveCount(0, { + timeout: 30_000, + }); + + // Revoke: the delete action asks for a native confirm() first. + page.once("dialog", (dialog) => void dialog.accept()); + // The delete button is icon-only; it is the row's only destructive action. + await botRow.locator("button.text-destructive").click(); + await expect(page.getByText("Bot revoked").first()).toBeVisible({ + timeout: 30_000, + }); + await expect(botRow).toHaveCount(0, { timeout: 30_000 }); + }); +}); diff --git a/e2e/tests/create-wallet-ui.spec.ts b/e2e/tests/create-wallet-ui.spec.ts new file mode 100644 index 00000000..2dff8a21 --- /dev/null +++ b/e2e/tests/create-wallet-ui.spec.ts @@ -0,0 +1,157 @@ +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import type { Page } from "@playwright/test"; + +type WalletCreationMode = "legacy" | "sdk"; + +async function waitForTrpc(page: Page, procedure: string): Promise { + const response = await page.waitForResponse( + (r) => r.url().includes(`/api/trpc/${procedure}`) && r.request().method() === "POST", + { timeout: 30_000 }, + ); + expect( + response.ok(), + `${procedure} failed ${response.status()}: ${await response.text().catch(() => "")}`, + ).toBe(true); +} + +async function saveSigner( + page: Page, + signer: { + address?: string; + name: string; + stakeKey?: string; + drepKey?: string; + clearStakeKey?: boolean; + }, +): Promise { + if (signer.address !== undefined) { + await page.getByLabel("Address").fill(signer.address); + } + await page.getByLabel(/Signer name/i).fill(signer.name); + if (signer.stakeKey !== undefined) { + await page.getByLabel(/Stake Key/i).fill(signer.stakeKey); + } + if (signer.drepKey !== undefined) { + await page.getByLabel(/DRep Key/i).fill(signer.drepKey); + } + if (signer.clearStakeKey) { + await page.getByLabel(/Stake Key/i).fill(""); + await page.getByLabel(/DRep Key/i).fill(""); + } + + const saveButton = page.getByRole("button", { name: /^Save$/ }); + await expect(saveButton).toBeEnabled({ timeout: 10_000 }); + const updatePromise = waitForTrpc(page, "wallet.updateNewWallet"); + await saveButton.click(); + await updatePromise; +} + +async function addSigner( + page: Page, + signer: { + address: string; + name: string; + stakeKey?: string; + drepKey?: string; + clearStakeKey?: boolean; + }, +): Promise { + await page.getByRole("button", { name: /add signer/i }).click(); + await saveSigner(page, signer); +} + +async function setThresholdToTwoOfThree(page: Page): Promise { + await page.getByRole("button", { name: "Edit" }).last().click(); + await page.getByRole("radio", { name: "2" }).click(); + const updatePromise = waitForTrpc(page, "wallet.updateNewWallet"); + await page.getByRole("button", { name: /^Save$/ }).click(); + await updatePromise; + await expect(page.getByText("2 of 3 signers must approve")).toBeVisible(); +} + +async function createWalletThroughUi( + page: Page, + mode: WalletCreationMode, +): Promise { + const ctx = loadContext(); + const suffix = `${mode}-${Date.now()}-${test.info().workerIndex}`; + const walletName = `E2E ${mode.toUpperCase()} ${suffix}`; + const clearStakeKey = mode === "legacy"; + + await page.goto("/wallets/new-wallet-flow/save"); + + await page.getByLabel("Name", { exact: true }).fill(walletName); + await page.getByLabel(/Description/i).fill(`Playwright ${mode} wallet creation coverage`); + await page.getByLabel(/Your name/i).fill("Signer 1"); + + const createDraftPromise = waitForTrpc(page, "wallet.createNewWallet"); + await page.getByRole("button", { name: /save & continue/i }).click(); + await createDraftPromise; + await expect(page).toHaveURL(/\/wallets\/new-wallet-flow\/create\/[^/]+$/); + + // Fresh page load: the signers table only renders after the user and + // getNewWallet queries resolve, which can exceed the default expect + // timeout while all workers hit the app cold. + await expect(page.locator("tbody tr").first()).toBeVisible({ timeout: 30_000 }); + await page.locator("tbody tr").first().locator("button").first().click(); + await saveSigner(page, { + name: "Signer 1", + stakeKey: clearStakeKey ? undefined : ctx.signerStakeAddresses[0], + clearStakeKey, + }); + + await addSigner(page, { + address: ctx.signerAddresses[1]!, + name: "Signer 2", + stakeKey: clearStakeKey ? undefined : ctx.signerStakeAddresses[1], + clearStakeKey, + }); + await addSigner(page, { + address: ctx.signerAddresses[2]!, + name: "Signer 3", + stakeKey: clearStakeKey ? undefined : ctx.signerStakeAddresses[2], + clearStakeKey, + }); + + await expect(page.locator("tbody tr")).toHaveCount(3); + + await setThresholdToTwoOfThree(page); + + await page.getByRole("button", { name: /advanced/i }).click(); + await expect(page.getByRole("heading", { name: "Native Script" })).toBeVisible(); + await expect(page.getByText("Payment Script")).toBeVisible(); + + const createWalletPromise = waitForTrpc(page, "wallet.createWallet"); + await page.getByRole("button", { name: /^Create$/ }).click(); + await createWalletPromise; + await expect(page).toHaveURL(/\/wallets\/new-wallet-flow\/ready\/[^/]+$/); + await expect(page.getByText("Wallet created successfully")).toBeVisible(); + + const walletsNavigationPromise = page.waitForURL(/\/wallets$/, { timeout: 30_000 }); + await page.getByRole("button", { name: /view all wallets/i }).click(); + await walletsNavigationPromise; + await expect(page.getByText(walletName)).toBeVisible({ timeout: 30_000 }); + + return walletName; +} + +test.describe("create wallet UI", () => { + test.describe.configure({ mode: "serial" }); + + test("creates a legacy 2-of-3 wallet from the browser", async ({ + page, + authenticateAs, + }) => { + await authenticateAs(page, 0); + await createWalletThroughUi(page, "legacy"); + }); + + test("creates an SDK 2-of-3 wallet from the browser", async ({ + page, + authenticateAs, + }) => { + await authenticateAs(page, 0); + await createWalletThroughUi(page, "sdk"); + }); +}); diff --git a/e2e/tests/governance-drep-ui.spec.ts b/e2e/tests/governance-drep-ui.spec.ts new file mode 100644 index 00000000..6ff83dc1 --- /dev/null +++ b/e2e/tests/governance-drep-ui.spec.ts @@ -0,0 +1,261 @@ +// Phase 3 item 7: DRep and governance UI. +// +// Proves governance actions can be initiated from the browser without +// touching the chain: +// - the governance page loads for an eligible wallet: DRep info card, +// DRep management actions gated on registration state, mocked proposal +// list renders +// - DRep register/update forms validate their required fields +// - the ballot modal opens, creates a ballot through tRPC, and lists it +// +// Runs against throwaway 2-of-3 SDK wallets (CI signer stake keys) so any +// created row can never trip ring-transfer's clean-pending precondition. +// UTxOs, account state, DRep status, and the proposal list are all mocked. +// +// Not covered here: actually submitting a DRep registration certificate from +// the browser. That path canonicalizes the CIP-119 anchor with jsonld +// (URDNA2015), which calls `crypto.subtle` — the Web Crypto API is only +// exposed in a secure context, and the Docker app is served over plain +// http://webapp:3000, so `crypto.subtle` is undefined and the build throws +// "crypto.subtle not found". Staking certificate proposals do not hit jsonld, +// which is why staking-ui covers propose-to-pending and this spec does not. +// DRep certificate building/broadcast stays in route-chain coverage +// (scripts/ci drep-certificates). + +import { test, expect } from "../fixtures/authFixture"; +import type { Page } from "@playwright/test"; +import { loadContext } from "../helpers/contextLoader"; +import { createThrowawayWallet, trpcMutate } from "../helpers/apiHelpers"; +import { mockWalletUtxos, mockGovernanceState, MOCK_PROPOSAL_TITLE } from "../helpers/phase3Mocks"; + +// DRepForm textareas in DOM order; the fields have no placeholders or testids. +const TEXTAREA_INDEX = { bio: 0, objectives: 1, motivations: 2, qualifications: 3 }; + +type CiDrepAnchorJson = { + body?: { + givenName?: unknown; + objectives?: unknown; + motivations?: unknown; + qualifications?: unknown; + }; +}; + +function requiredString(value: unknown, fieldName: string): string { + if (typeof value === "string" && value.trim()) return value; + throw new Error(`CI_DREP_ANCHOR_JSON body.${fieldName} must be a non-empty string`); +} + +function getCiDrepFormValues(): { + givenName: string; + objectives: string; + motivations: string; + qualifications: string; +} { + const raw = process.env.CI_DREP_ANCHOR_JSON; + if (!raw) { + throw new Error("CI_DREP_ANCHOR_JSON must be set for governance-drep-ui.spec.ts"); + } + + let parsed: CiDrepAnchorJson; + try { + parsed = JSON.parse(raw) as CiDrepAnchorJson; + } catch (error) { + throw new Error( + `CI_DREP_ANCHOR_JSON must be valid single-line JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const body = parsed.body; + if (!body) { + throw new Error("CI_DREP_ANCHOR_JSON must include a body object"); + } + + const givenName = requiredString(body.givenName, "givenName").replace(/\s+/g, ""); + if (!givenName) { + throw new Error("CI_DREP_ANCHOR_JSON body.givenName must contain non-whitespace characters"); + } + + return { + givenName, + objectives: requiredString(body.objectives, "objectives"), + motivations: requiredString(body.motivations, "motivations"), + qualifications: requiredString(body.qualifications, "qualifications"), + }; +} + +async function fillRequiredDrepFields(page: Page): Promise { + const drep = getCiDrepFormValues(); + await page.getByPlaceholder("name must be without spaces").fill(drep.givenName); + await page.locator("textarea").nth(TEXTAREA_INDEX.objectives).fill(drep.objectives); + await page.locator("textarea").nth(TEXTAREA_INDEX.motivations).fill(drep.motivations); + await page + .locator("textarea") + .nth(TEXTAREA_INDEX.qualifications) + .fill(drep.qualifications); +} + +test.describe("governance and DRep UI", () => { + test("governance page loads with DRep info, gated management actions, and proposals", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E gov-page ${Date.now()}-${test.info().workerIndex}`, + { withStakeKeys: true }, + ); + await mockWalletUtxos(page); + await mockGovernanceState(page); + + await page.goto(`/wallets/${wallet.walletId}/governance`); + + // DRep info card renders with a derived DRep ID. + await expect(page.getByRole("heading", { name: "DRep Information" })).toBeVisible({ + timeout: 60_000, + }); + await expect(page.locator("code", { hasText: /^drep/ })).toBeVisible({ + timeout: 30_000, + }); + + // Management actions are gated on registration state: the mocked DRep + // status is "not registered", so only Register is actionable. + await page.getByText("DRep Management").click(); + await expect( + page.getByRole("button", { name: "Register DRep", exact: true }), + ).toBeEnabled(); + await expect( + page.getByRole("button", { name: "Update DRep", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: /^Retire DRep/ }), + ).toBeDisabled(); + + // The mocked active-proposals list hydrates and renders. + await expect(page.getByText(MOCK_PROPOSAL_TITLE).first()).toBeVisible({ + timeout: 60_000, + }); + }); + + test("ballot modal opens, creates a ballot, and lists it", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E gov-ballot ${Date.now()}-${test.info().workerIndex}`, + { withStakeKeys: true }, + ); + await mockWalletUtxos(page); + await mockGovernanceState(page); + + await page.goto(`/wallets/${wallet.walletId}/governance`); + await expect(page.getByRole("heading", { name: "DRep Information" })).toBeVisible({ + timeout: 60_000, + }); + + await page.getByRole("button", { name: "Manage Ballots" }).click(); + await expect( + page.getByRole("heading", { name: "Manage Ballots" }), + ).toBeVisible(); + + // The create input is revealed by the "New" toggle. + await page.getByRole("button", { name: "New" }).click(); + + const ballotName = `CI ballot ${Date.now()}`; + const createResponsePromise = page.waitForResponse( + (response) => + response.url().includes("ballot.create") && + response.request().method() === "POST", + { timeout: 60_000 }, + ); + await page.getByPlaceholder("Enter ballot name...").fill(ballotName); + await page.getByRole("button", { name: "Create", exact: true }).click(); + const createResponse = await createResponsePromise; + expect( + createResponse.ok(), + `ballot.create failed ${createResponse.status()}`, + ).toBe(true); + + // Extract the created ballot id from the tRPC envelope for cleanup. + type TrpcEnvelope = { result?: { data?: { json?: { id?: string } } } }; + const createBody = (await createResponse.json()) as + | TrpcEnvelope + | TrpcEnvelope[]; + const createItem = Array.isArray(createBody) ? createBody[0] : createBody; + const ballotId = createItem?.result?.data?.json?.id; + + try { + await expect(page.getByText(ballotName).first()).toBeVisible({ + timeout: 30_000, + }); + } finally { + if (ballotId) { + await trpcMutate(page, "ballot.delete", { ballotId }).catch(() => {}); + } + } + }); + + test("DRep register and update forms validate required fields", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E gov-validate ${Date.now()}-${test.info().workerIndex}`, + { withStakeKeys: true }, + ); + await mockWalletUtxos(page); + await mockGovernanceState(page); + const drep = getCiDrepFormValues(); + + // Register form: submit stays disabled until every required field is set. + await page.goto(`/wallets/${wallet.walletId}/governance/register`); + const registerButton = page.getByRole("button", { + name: "Register DRep", + exact: true, + }); + await expect(registerButton).toBeVisible({ timeout: 60_000 }); + await expect(registerButton).toBeDisabled(); + + await page.getByPlaceholder("name must be without spaces").fill(drep.givenName); + await expect(registerButton).toBeDisabled(); + + await page.locator("textarea").nth(TEXTAREA_INDEX.objectives).fill(drep.objectives); + await page.locator("textarea").nth(TEXTAREA_INDEX.motivations).fill(drep.motivations); + await expect(registerButton).toBeDisabled(); + + await page + .locator("textarea") + .nth(TEXTAREA_INDEX.qualifications) + .fill(drep.qualifications); + await expect(registerButton).toBeEnabled(); + + // Update form: same required-field gating with the update action. + await page.goto(`/wallets/${wallet.walletId}/governance/update`); + const updateButton = page.getByRole("button", { + name: "Update DRep", + exact: true, + }); + await expect(updateButton).toBeVisible({ timeout: 60_000 }); + await expect(updateButton).toBeDisabled(); + await fillRequiredDrepFields(page); + await expect(updateButton).toBeEnabled(); + }); +}); diff --git a/e2e/tests/new-transaction-validation.spec.ts b/e2e/tests/new-transaction-validation.spec.ts new file mode 100644 index 00000000..04e09234 --- /dev/null +++ b/e2e/tests/new-transaction-validation.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from "../fixtures/authFixture"; +import { getWallet, loadContext } from "../helpers/contextLoader"; +import type { Page } from "@playwright/test"; + +const SMALL_UTXO = { + tx_hash: "1".repeat(64), + output_index: 0, + amount: [{ unit: "lovelace", quantity: "3000000" }], + data_hash: null, + inline_datum: null, + reference_script_hash: null, +}; + +async function mockUtxos(page: Page, address: string): Promise { + const encodedAddress = encodeURIComponent(address); + await page.route("**/addresses/*/utxos**", async (route) => { + const url = new URL(route.request().url()); + if ( + url.pathname.includes(`/addresses/${encodedAddress}/utxos`) || + url.pathname.includes(`/addresses/${address}/utxos`) + ) { + const pageNumber = Number(url.searchParams.get("page") ?? "1"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(pageNumber === 1 ? [SMALL_UTXO] : []), + }); + return; + } + + await route.fallback(); + }); +} + +async function expectValidation(page: Page, message: RegExp): Promise { + await expect(page.getByText(message).first()).toBeVisible(); + await expect(page.getByTestId("create-transaction-button")).toBeDisabled(); +} + +test("new transaction form validates recipients, amounts, and selected UTxOs", async ({ + page, + authenticateAs, +}) => { + const ctx = loadContext(); + const sourceWallet = getWallet(ctx, "legacy"); + const destinationWallet = getWallet(ctx, "sdk"); + + await authenticateAs(page, 0); + await mockUtxos(page, sourceWallet.walletAddress); + + await page.goto(`/wallets/${sourceWallet.walletId}/transactions/new`); + await page.waitForSelector('[data-testid="utxo-selector"][data-loaded="true"]', { + timeout: 30_000, + }); + + await expectValidation(page, /address is required/i); + + await page.getByTestId("recipient-address-input-0").fill("not-a-cardano-address"); + await page.getByTestId("amount-input-0").fill("1"); + await expectValidation(page, /address is invalid/i); + + await page.getByTestId("recipient-address-input-0").fill(destinationWallet.walletAddress); + await page.getByTestId("amount-input-0").fill("0"); + await expectValidation(page, /amount must be greater than zero/i); + + await page.getByTestId("amount-input-0").fill("-1"); + await expectValidation(page, /amount must be greater than zero/i); + + await page.getByTestId("amount-input-0").fill("999"); + await expectValidation(page, /exceeds the selected UTxO balance/i); + + await page.getByTestId("amount-input-0").fill("1"); + await expect(page.getByText("Input UTxOs (1)")).toBeVisible(); + await expect(page.getByTestId("create-transaction-button")).toBeEnabled(); + + await page.getByRole("button", { name: /add recipient/i }).click(); + await expect(page.locator('[data-testid^="recipient-address-input-"]:not([data-testid*="mobile"])')).toHaveCount(2); + await expectValidation(page, /Recipient 2: address is required/i); + + await page.locator("tbody tr").nth(1).locator("button").last().click(); + await expect(page.locator('[data-testid^="recipient-address-input-"]:not([data-testid*="mobile"])')).toHaveCount(1); + await expect(page.getByTestId("create-transaction-button")).toBeEnabled(); +}); diff --git a/e2e/tests/notification-settings-ui.spec.ts b/e2e/tests/notification-settings-ui.spec.ts new file mode 100644 index 00000000..f9cb20aa --- /dev/null +++ b/e2e/tests/notification-settings-ui.spec.ts @@ -0,0 +1,101 @@ +// Phase 3 item 10: Notification center. +// +// The app's signature notifications are email-based: each signer manages +// per-wallet email preferences on the wallet info page, and a server-side +// outbox/worker sends "signature required" emails whose links land on the +// wallet's transactions page. There is no in-app notification inbox, so the +// browser coverage targets the notification settings surface: +// - the Email Notifications card loads for a wallet signer +// - saving an email persists it and flips the badge to "Not verified" +// - the verification email action is offered once an email is saved +// (in test environments delivery is disabled and the API reports the +// email as "prepared" instead of "queued") +// - preference toggles persist across a reload +// +// The email delivery pipeline itself (outbox rows, worker, verify link) is +// server-side and covered outside the browser suite. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import { createThrowawayWallet } from "../helpers/apiHelpers"; +import { mockWalletUtxos } from "../helpers/phase3Mocks"; + +test.describe("notification settings UI", () => { + test("signer saves an email, sees verification state, and toggles persist", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E notifications ${Date.now()}-${test.info().workerIndex}`, + ); + await mockWalletUtxos(page); + + await page.goto(`/wallets/${wallet.walletId}/info`); + await expect( + page.getByRole("heading", { name: "Email Notifications" }), + ).toBeVisible({ timeout: 60_000 }); + + // Fresh wallet+signer pair: no email is stored yet. + await expect(page.getByText("No email", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + + // Save an email address. + const email = `ci-playwright-${Date.now()}@example.com`; + const emailInput = page.getByLabel("Email address"); + await expect(emailInput).toBeEnabled({ timeout: 30_000 }); + await emailInput.fill(email); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Notification settings saved").first()).toBeVisible({ + timeout: 30_000, + }); + + // Saved but unverified: badge flips and the verification action unlocks. + await expect(page.getByText("Not verified", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText(email)).toBeVisible(); + const sendVerification = page.getByRole("button", { + name: "Send verification email", + }); + await expect(sendVerification).toBeEnabled({ timeout: 30_000 }); + await sendVerification.click(); + await expect( + page.getByText(/Verification email (queued|prepared)/).first(), + ).toBeVisible({ timeout: 30_000 }); + + // Turn off transaction-signature emails; the change persists server-side. + const transactionsToggle = page.getByRole("switch", { + name: "Toggle transaction signature notifications", + }); + await expect(transactionsToggle).toBeEnabled({ timeout: 30_000 }); + await expect(transactionsToggle).toHaveAttribute("aria-checked", "true"); + await transactionsToggle.click(); + await expect(page.getByText("Notification settings saved").first()).toBeVisible({ + timeout: 30_000, + }); + + // Everything survives a full reload: email, badge, and the toggle. + await page.reload(); + await expect( + page.getByRole("heading", { name: "Email Notifications" }), + ).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText("Not verified", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByLabel("Email address")).toHaveValue(email, { + timeout: 30_000, + }); + await expect( + page.getByRole("switch", { + name: "Toggle transaction signature notifications", + }), + ).toHaveAttribute("aria-checked", "false", { timeout: 30_000 }); + }); +}); diff --git a/e2e/tests/proxy-ui.spec.ts b/e2e/tests/proxy-ui.spec.ts new file mode 100644 index 00000000..a7dd929e --- /dev/null +++ b/e2e/tests/proxy-ui.spec.ts @@ -0,0 +1,133 @@ +// Phase 3 item 8: Proxy UI. +// +// Proves the proxy control panel is usable from the browser: +// - the panel loads on the wallet info page and expands +// - with no proxies, the empty state offers first-proxy setup and the setup +// modal opens with its step flow and description field +// - an existing proxy row (seeded via tRPC) is displayed with its +// description and reflected in the panel's proxy count +// +// Full proxy setup (auth-token mint) is a Plutus transaction that needs real +// collateral and funded inputs; that lifecycle has broad route-chain coverage +// (scripts/ci proxy-full-lifecycle), so this spec stays with panel state and +// setup-flow visibility per the test-plan note. The seeded proxy uses fake +// chain identifiers — balance and DRep lookups are mocked. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import { createThrowawayWallet, trpcMutate } from "../helpers/apiHelpers"; +import { mockWalletUtxos, mockGovernanceState } from "../helpers/phase3Mocks"; + +const FAKE_PARAM_UTXO = JSON.stringify({ txHash: "5".repeat(64), outputIndex: 0 }); +const FAKE_AUTH_TOKEN_ID = "ab".repeat(28); + +test.describe("proxy UI", () => { + test("proxy panel shows empty state and opens the setup flow", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E proxy-empty ${Date.now()}-${test.info().workerIndex}`, + ); + await mockWalletUtxos(page); + await mockGovernanceState(page); + + await page.goto(`/wallets/${wallet.walletId}/info`); + await expect(page.getByRole("heading", { name: "Proxy Control" })).toBeVisible({ + timeout: 60_000, + }); + + // Expand the collapsed panel. + await page + .getByRole("button", { name: /Expand proxy control panel/ }) + .click(); + await expect(page.getByText("No Proxies Found")).toBeVisible({ + timeout: 30_000, + }); + + // The empty state leads into the setup modal with its step flow. + await page + .getByRole("button", { name: "Create Your First Proxy" }) + .click(); + await expect(page.getByText("Setup New Proxy")).toBeVisible(); + await expect(page.getByText("Ready to Setup Proxy")).toBeVisible(); + await expect(page.getByText("Collateral Required:")).toBeVisible(); + + const descriptionInput = page.getByPlaceholder( + "Enter a description for this proxy...", + ); + await descriptionInput.fill("CI proxy description"); + await expect(descriptionInput).toHaveValue("CI proxy description"); + + // With a connected wallet the setup action is available. + await expect( + page.getByRole("button", { name: "Start Proxy Setup" }), + ).toBeEnabled(); + + await page.keyboard.press("Escape"); + await expect(page.getByText("Ready to Setup Proxy")).toHaveCount(0); + }); + + test("existing proxy state is displayed in the panel", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E proxy-state ${Date.now()}-${test.info().workerIndex}`, + ); + await mockWalletUtxos(page); + await mockGovernanceState(page); + + const description = `CI seeded proxy ${Date.now()}`; + const proxy = await trpcMutate<{ id: string }>(page, "proxy.createProxy", { + walletId: wallet.walletId, + proxyAddress: `addr_test1ciproxymock${Date.now()}`, + authTokenId: FAKE_AUTH_TOKEN_ID, + paramUtxo: FAKE_PARAM_UTXO, + description, + }); + + try { + await page.goto(`/wallets/${wallet.walletId}/info`); + await expect(page.getByRole("heading", { name: "Proxy Control" })).toBeVisible({ + timeout: 60_000, + }); + + // The collapsed header already summarizes the proxy count + // (rendered as "1 proxy • N assets"). + await expect(page.getByText(/1 proxy •/).first()).toBeVisible({ + timeout: 30_000, + }); + + await page + .getByRole("button", { name: /Expand proxy control panel/ }) + .click(); + + // The seeded proxy renders with its description, and the panel still + // offers adding another proxy. + await expect(page.getByText(description)).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText("No Proxies Found")).toHaveCount(0); + await expect(page.getByText("Add New Proxy")).toBeVisible(); + } finally { + if (proxy?.id) { + await trpcMutate(page, "proxy.deleteProxy", { id: proxy.id }).catch( + () => {}, + ); + } + } + }); +}); diff --git a/e2e/tests/rejected-signing.spec.ts b/e2e/tests/rejected-signing.spec.ts new file mode 100644 index 00000000..cfa08e22 --- /dev/null +++ b/e2e/tests/rejected-signing.spec.ts @@ -0,0 +1,266 @@ +// Phase 2 item 3: Rejected wallet signing. +// +// Proves the app handles a wallet that refuses to sign: +// - signTx rejection during proposal → no pending transaction is created, +// the user sees an error toast, and the form stays usable. +// - signTx rejection during approval → no signature is added, the pending +// transaction is untouched, and the user sees an error toast. +// +// Both tests run against a throwaway 2-of-3 wallet created via tRPC over the +// CI signer addresses, never against the bootstrap ring wallets: the approval +// test intentionally leaves a transaction pending mid-test, and ring-transfer +// legs fail fast when their wallet has pending rows. The throwaway wallet is +// unfunded — the UTxO fetch is mocked and nothing ever reaches the chain +// (threshold is never met, so no broadcast path executes). + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import { mockTxFlowReads } from "../helpers/phase3Mocks"; +import { + createThrowawayWallet, + getPendingTransactionsRest, + trpcMutate, + type PendingTransaction, +} from "../helpers/apiHelpers"; +import type { Page } from "@playwright/test"; + +const FAKE_UTXO_TX_HASH = "1".repeat(64); + +// Serve one fake 5 ADA UTxO for every address the page asks about. The specs +// never broadcast, so the UTxO does not need to exist on-chain; page 2+ must +// return [] so BlockfrostProvider's pagination loop terminates. +async function mockAllUtxos(page: Page, walletAddress: string): Promise { + await page.route("**/addresses/*/utxos**", async (route) => { + const url = new URL(route.request().url()); + const pageNumber = Number(url.searchParams.get("page") ?? "1"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + pageNumber === 1 + ? [ + { + address: walletAddress, + tx_hash: FAKE_UTXO_TX_HASH, + output_index: 0, + amount: [{ unit: "lovelace", quantity: "5000000" }], + data_hash: null, + inline_datum: null, + reference_script_hash: null, + }, + ] + : [], + ), + }); + }); +} + +// Replaces the CIP-30 bridge's signTx with a rejecting stub on the live page. +// The injected wallet mock resolves window.__ci_signTx at call time, so this +// takes effect for the next signing attempt without a reload. +async function makeSignTxReject(page: Page): Promise { + await page.evaluate(() => { + (window as unknown as Record).__ci_signTx = () => + Promise.reject( + new Error("MeshCI mock: user declined to sign the transaction"), + ); + }); +} + +async function openNewTransactionForm( + page: Page, + walletId: string, + recipientAddress: string, +): Promise { + await page.goto(`/wallets/${walletId}/transactions/new`); + await page.waitForSelector( + '[data-testid="utxo-selector"][data-loaded="true"]', + { timeout: 60_000 }, + ); + await page.getByTestId("recipient-address-input-0").fill(recipientAddress); + await page.getByTestId("amount-input-0").fill("1"); + await expect(page.getByText("Input UTxOs (1)")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("create-transaction-button")).toBeEnabled(); +} + +function countRequests(page: Page, urlFragment: string): () => number { + let count = 0; + page.on("request", (request) => { + if (request.url().includes(urlFragment)) count += 1; + }); + return () => count; +} + +test.describe("rejected wallet signing", () => { + test("signTx rejection during proposal creates no pending transaction", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E reject-propose ${Date.now()}-${test.info().workerIndex}`, + ); + await mockAllUtxos(page, wallet.address); + + const createTransactionRequests = countRequests( + page, + "transaction.createTransaction", + ); + + await openNewTransactionForm(page, wallet.walletId, ctx.signerAddresses[2]!); + await makeSignTxReject(page); + await page.getByTestId("create-transaction-button").click(); + + // The rejection surfaces as a destructive toast titled "Error" and the + // form recovers: loading clears, no redirect to the transactions page. + await expect(page.getByText("Error", { exact: true }).first()).toBeVisible({ + timeout: 60_000, + }); + await expect(page.getByTestId("create-transaction-button")).toBeEnabled({ + timeout: 30_000, + }); + await expect(page).toHaveURL( + new RegExp(`/wallets/${wallet.walletId}/transactions/new$`), + ); + + // signTx rejected before the mutation, so it must never have been sent + // and nothing may be pending — in the UI or the database. + expect(createTransactionRequests()).toBe(0); + expect( + await getPendingTransactionsRest( + page, + wallet.walletId, + ctx.signerAddresses[0]!, + ), + ).toEqual([]); + + await page.goto(`/wallets/${wallet.walletId}/transactions`); + await expect(page.locator('[data-testid^="tx-card-"]')).toHaveCount(0); + }); + + test("signTx rejection during approval adds no signature", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E reject-approve ${Date.now()}-${test.info().workerIndex}`, + ); + await mockAllUtxos(page, wallet.address); + + // Signer 0 proposes for real (bridge signs with the mnemonic). Threshold + // is 2-of-3, so the transaction stays pending and nothing is broadcast. + await openNewTransactionForm(page, wallet.walletId, ctx.signerAddresses[2]!); + const createTransactionResponsePromise = page.waitForResponse( + (response) => + response.url().includes("transaction.createTransaction") && + response.request().method() === "POST", + { timeout: 90_000 }, + ); + await page.getByTestId("create-transaction-button").click(); + const createTransactionResponse = await createTransactionResponsePromise; + expect( + createTransactionResponse.ok(), + `createTransaction failed ${createTransactionResponse.status()}: ${await createTransactionResponse.text().catch(() => "")}`, + ).toBe(true); + + await expect(page).toHaveURL( + new RegExp(`/wallets/${wallet.walletId}/transactions$`), + { timeout: 30_000 }, + ); + + // Resolve the transaction ID from the REST API, not the rendered card: + // right after creation the card may still show the optimistic React Query + // entry whose ID is a client-side `temp-...` placeholder. + let pendingBefore: PendingTransaction | undefined; + const restDeadline = Date.now() + 30_000; + while (Date.now() < restDeadline) { + const pending = await getPendingTransactionsRest( + page, + wallet.walletId, + ctx.signerAddresses[0]!, + ); + if (pending.length === 1) { + pendingBefore = pending[0]; + break; + } + await page.waitForTimeout(2_000); + } + if (!pendingBefore) { + throw new Error( + "Proposed transaction never appeared in /api/v1/pendingTransactions", + ); + } + const transactionId = pendingBefore.id; + + try { + expect(pendingBefore.signedAddresses).toEqual([ctx.signerAddresses[0]!]); + expect(pendingBefore.state).toBe(0); + + // Signer 1 opens the pending transaction but their wallet refuses to sign. + await authenticateAs(page, 1); + const updateTransactionRequests = countRequests( + page, + "transaction.updateTransaction", + ); + await page.goto(`/wallets/${wallet.walletId}/transactions`); + const signButton = page.locator( + `[data-testid="sign-button-${transactionId}"]`, + ); + await expect(signButton).toBeVisible({ timeout: 30_000 }); + + // The pending card's token-flow section mounts lazily on first open; + // its chain reads (resolving input provenance) are mocked. + await mockTxFlowReads(page, wallet.address); + await page.getByTestId(`tx-flow-toggle-${transactionId}`).click(); + await expect( + page.getByTestId(`tx-flow-canvas-${transactionId}`), + ).toBeVisible({ timeout: 30_000 }); + await expect( + page.getByTestId(`tx-flow-node-txp:${transactionId}`), + ).toBeVisible({ timeout: 30_000 }); + // Collapse again so the sign flow below is unaffected. + await page.getByTestId(`tx-flow-toggle-${transactionId}`).click(); + + await makeSignTxReject(page); + await signButton.click(); + + await expect( + page.getByText("Error", { exact: true }).first(), + ).toBeVisible({ timeout: 60_000 }); + // The card recovers: the signing action is still offered, not consumed. + await expect(signButton).toBeEnabled({ timeout: 30_000 }); + + // No update mutation fired and the DB record is unchanged: still only + // the proposer's signature, nobody marked as rejected, still pending. + expect(updateTransactionRequests()).toBe(0); + const afterReject = await getPendingTransactionsRest( + page, + wallet.walletId, + ctx.signerAddresses[1]!, + ); + const pendingAfter = afterReject.find((tx) => tx.id === transactionId); + expect(pendingAfter?.signedAddresses).toEqual([ctx.signerAddresses[0]!]); + expect(pendingAfter?.rejectedAddresses ?? []).toEqual([]); + expect(pendingAfter?.state).toBe(0); + } finally { + // Remove the intentionally-stranded pending transaction so reruns start + // clean even though the wallet itself is throwaway. + await trpcMutate(page, "transaction.deleteTransaction", { + transactionId, + }).catch(() => {}); + } + }); +}); diff --git a/e2e/tests/responsive-smoke.spec.ts b/e2e/tests/responsive-smoke.spec.ts new file mode 100644 index 00000000..ff87a8bd --- /dev/null +++ b/e2e/tests/responsive-smoke.spec.ts @@ -0,0 +1,159 @@ +// Phase 2 item 5: Responsive smoke tests. +// +// Catches layout regressions on common mobile viewports for the core screens: +// wallet list, wallet detail, transaction list, new transaction form, and the +// wallet connect entry point. Each test asserts that the critical controls +// are visible/reachable and that the document does not overflow horizontally +// (wide content must scroll inside its own container, not the page). +// +// These are pure-UI checks against the bootstrap legacy wallet — no signing, +// no transaction creation, no chain writes. The new-transaction test mocks +// the UTxO fetch so it stays deterministic and off-chain. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext, getWallet } from "../helpers/contextLoader"; +import type { Page } from "@playwright/test"; + +const VIEWPORTS = [ + { name: "iPhone SE (375x667)", width: 375, height: 667 }, + { name: "Pixel 7 (412x915)", width: 412, height: 915 }, +]; + +const SMALL_UTXO = { + tx_hash: "1".repeat(64), + output_index: 0, + amount: [{ unit: "lovelace", quantity: "3000000" }], + data_hash: null, + inline_datum: null, + reference_script_hash: null, +}; + +async function mockUtxos(page: Page): Promise { + await page.route("**/addresses/*/utxos**", async (route) => { + const url = new URL(route.request().url()); + const pageNumber = Number(url.searchParams.get("page") ?? "1"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(pageNumber === 1 ? [SMALL_UTXO] : []), + }); + }); +} + +async function expectNoHorizontalOverflow( + page: Page, + label: string, +): Promise { + const { scrollWidth, clientWidth } = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + expect( + scrollWidth, + `${label}: document overflows horizontally (scrollWidth ${scrollWidth}px > viewport ${clientWidth}px)`, + ).toBeLessThanOrEqual(clientWidth + 1); +} + +for (const viewport of VIEWPORTS) { + test.describe(`responsive smoke @ ${viewport.name}`, () => { + test.use({ viewport: { width: viewport.width, height: viewport.height } }); + + test("wallet list shows primary actions without overflow", async ({ + page, + authenticateAs, + }) => { + await authenticateAs(page, 0); + await page.goto("/wallets"); + await expect( + page.getByRole("link", { name: "New Wallet" }), + ).toBeVisible({ timeout: 60_000 }); + await expectNoHorizontalOverflow(page, "wallet list"); + }); + + test("wallet detail shows signer content without overflow", async ({ + page, + authenticateAs, + }) => { + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + await authenticateAs(page, 0); + await page.goto(`/wallets/${wallet.walletId}`); + await expect( + page.getByText("Signers", { exact: true }).first(), + ).toBeVisible({ timeout: 60_000 }); + await expectNoHorizontalOverflow(page, "wallet detail"); + }); + + test("transaction list shows balance actions without overflow", async ({ + page, + authenticateAs, + }) => { + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + await authenticateAs(page, 0); + await page.goto(`/wallets/${wallet.walletId}/transactions`); + const depositButton = page + .getByRole("button", { name: "Deposit Funds" }) + .first(); + await expect(depositButton).toBeVisible({ timeout: 60_000 }); + // Primary actions must be reachable, though New Transaction may be + // disabled until the balance loads. + await expect( + page.getByRole("button", { name: "New Transaction" }).first(), + ).toBeVisible(); + await expectNoHorizontalOverflow(page, "transaction list"); + }); + + test("new transaction form is usable without overflow", async ({ + page, + authenticateAs, + }) => { + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + await authenticateAs(page, 0); + await mockUtxos(page); + await page.goto(`/wallets/${wallet.walletId}/transactions/new`); + await page.waitForSelector( + '[data-testid="utxo-selector"][data-loaded="true"]', + { timeout: 60_000 }, + ); + + // Below the sm breakpoint the desktop recipient table is hidden and the + // mobile card layout must expose the address input instead. + await expect( + page.locator('input[placeholder="addr1... or $handle"]:visible').first(), + ).toBeVisible(); + + // The primary action is reachable by scrolling the page vertically. + const createButton = page.getByTestId("create-transaction-button"); + await createButton.scrollIntoViewIfNeeded(); + await expect(createButton).toBeVisible(); + + await expectNoHorizontalOverflow(page, "new transaction form"); + }); + + test("wallet connect entry point works without overflow", async ({ + page, + injectWallet, + }) => { + // Unauthenticated: no session cookie, only the injected CIP-30 mock. + await injectWallet(page, 0); + await page.goto("/"); + const connectButton = page + .getByRole("button", { name: /connect wallet/i }) + .first(); + await expect(connectButton).toBeVisible({ timeout: 60_000 }); + await expectNoHorizontalOverflow(page, "landing page"); + + // The connect dropdown opens and lists the injected wallet. + await connectButton.click(); + await expect(page.locator('[role="menu"]')).toBeVisible({ + timeout: 10_000, + }); + await expect( + page.getByRole("menuitem", { name: "MeshCI" }), + ).toBeVisible({ timeout: 10_000 }); + await expectNoHorizontalOverflow(page, "connect wallet menu"); + }); + }); +} diff --git a/e2e/tests/ring-transfer.spec.ts b/e2e/tests/ring-transfer.spec.ts new file mode 100644 index 00000000..264b7084 --- /dev/null +++ b/e2e/tests/ring-transfer.spec.ts @@ -0,0 +1,511 @@ +// Phase 4: Full browser-driven ring transfer test. +// +// Three legs run in parallel (one worker each). Every leg spends from a +// different multisig wallet (legacy, hierarchical, sdk), so the legs never +// compete for the same UTxOs — incoming deposits from a concurrent leg do not +// invalidate a source wallet's existing inputs. Each source wallet must hold +// enough ADA on its own (transfer amount + ~0.5 ADA fees); bootstrap funds +// each with at least 5 ADA. Each leg: +// 1. Proposer (signer 0) creates the transaction via the UI — the proposer +// auto-signs during creation (1 of 2 required signatures). +// 2. Signer 1 opens the transactions page and clicks "Approve & Sign" — +// this reaches the 2-of-3 threshold and triggers an on-chain broadcast. +// 3. Test asserts the tx-broadcast-success indicator appears and the card +// disappears from pending. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext, getWallet } from "../helpers/contextLoader"; +import type { CIWalletType } from "../helpers/contextLoader"; +import type { Page } from "@playwright/test"; +import jwt from "jsonwebtoken"; +const { sign } = jwt; + +const LOVELACE = Number(process.env.CI_TRANSFER_LOVELACE ?? "2000000"); +// The new-transaction form amount input expects ADA (lovelace / 1_000_000) +const ADA_AMOUNT = String(LOVELACE / 1_000_000); +const MIN_SOURCE_LOVELACE = LOVELACE + 500_000; + +const LEGS: Array<{ name: string; srcType: CIWalletType; dstType: CIWalletType }> = [ + { name: "legacy → hierarchical", srcType: "legacy", dstType: "hierarchical" }, + { name: "hierarchical → sdk", srcType: "hierarchical", dstType: "sdk" }, + { name: "sdk → legacy", srcType: "sdk", dstType: "legacy" }, +]; + +type BlockfrostUtxo = { + tx_hash?: string; + output_index?: number; + amount?: Array<{ unit: string; quantity: string }>; + address?: string; + data_hash?: string | null; + inline_datum?: string | null; + reference_script_hash?: string | null; +}; + +type PendingTransaction = { + id: string; + signedAddresses?: string[]; + rejectedAddresses?: string[]; + state?: number; +}; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchBlockfrostUtxos(address: string): Promise { + const apiKey = process.env.CI_BLOCKFROST_PREPROD_API_KEY?.trim(); + if (!apiKey) { + throw new Error("CI_BLOCKFROST_PREPROD_API_KEY must be set"); + } + + const response = await fetch( + `https://cardano-preprod.blockfrost.io/api/v0/addresses/${encodeURIComponent(address)}/utxos?count=100&order=desc`, + { + headers: { + project_id: apiKey, + }, + }, + ); + + if (response.status === 404) { + return []; + } + if (!response.ok) { + throw new Error( + `Blockfrost UTxO fetch failed ${response.status}: ${await response.text().catch(() => "")}`, + ); + } + return (await response.json()) as BlockfrostUtxo[]; +} + +function totalLovelace(utxos: BlockfrostUtxo[]): number { + return utxos.reduce((sum, utxo) => { + const lovelace = utxo.amount?.find((asset) => asset.unit === "lovelace"); + return sum + Number(lovelace?.quantity ?? 0); + }, 0); +} + +// 300s timeout: when a prior run left a source wallet underfunded, the leg +// that refills it runs concurrently — broadcast plus preprod confirmation can +// take a few minutes, and this wait is what lets the ring self-heal. +async function waitForSpendableUtxos( + address: string, + minLovelace: number, + timeoutMs = 300_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastBalance = 0; + + while (Date.now() < deadline) { + const utxos = await fetchBlockfrostUtxos(address).catch(() => []); + lastBalance = totalLovelace(utxos); + if (utxos.length > 0 && lastBalance >= minLovelace) { + return utxos; + } + await sleep(10_000); + } + + throw new Error( + `Wallet ${address} did not expose spendable UTxOs >= ${minLovelace} lovelace after ${timeoutMs}ms; last balance was ${lastBalance}`, + ); +} + +async function mockBrowserUtxoFetch( + page: Page, + address: string, + utxos: BlockfrostUtxo[], + diagnostics: string[], +): Promise { + const encodedAddress = encodeURIComponent(address); + const addressPath = `/addresses/${encodedAddress}/utxos`; + const rawAddressPath = `/addresses/${address}/utxos`; + + // Blockfrost paginates UTxOs with ?page=N (100 per page) and BlockfrostProvider + // recurses page+1 until a page returns an empty array (see paginateUTxOs in + // @meshsdk/provider). Returning the full set on every page would never produce + // an empty page, so the app recurses forever (observed: page=9230+). Slice by + // page so page 1 holds the UTxOs and page 2+ returns [], terminating the loop. + const PAGE_SIZE = 100; + + await page.unroute("**/addresses/*/utxos**").catch(() => {}); + await page.route("**/addresses/*/utxos**", async (route) => { + const url = new URL(route.request().url()); + if ( + url.pathname === addressPath || + url.pathname === rawAddressPath || + url.pathname.endsWith(addressPath) || + url.pathname.endsWith(rawAddressPath) + ) { + const page = Number(url.searchParams.get("page") ?? "1"); + const start = (page - 1) * PAGE_SIZE; + const pageUtxos = utxos.slice(start, start + PAGE_SIZE); + diagnostics.push( + `utxo route fulfilled ${url.href} with ${pageUtxos.length} utxos (page ${page})`, + ); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(pageUtxos), + }); + return; + } + + await route.fallback(); + }); +} + +async function waitForUtxoSelectorLoaded( + page: Page, + newTransactionUrl: string, + diagnostics: string[], +): Promise { + for (let attempt = 1; attempt <= 3; attempt += 1) { + diagnostics.push(`loading new transaction page attempt ${attempt}`); + await page.goto(newTransactionUrl); + + const loaded = await page + .waitForSelector('[data-testid="utxo-selector"][data-loaded="true"]', { + timeout: 60_000, + }) + .then(() => true) + .catch(() => false); + + if (loaded) { + diagnostics.push(`utxo selector loaded on attempt ${attempt}`); + return; + } + + const state = await page + .evaluate(() => { + const selector = document.querySelector('[data-testid="utxo-selector"]'); + return { + href: window.location.href, + selectorExists: !!selector, + selectorLoaded: selector?.getAttribute("data-loaded") ?? null, + bodyText: document.body.innerText.slice(0, 2000), + }; + }) + .catch((error) => ({ evaluateError: String(error) })); + diagnostics.push(`attempt ${attempt} page state: ${JSON.stringify(state)}`); + } + + throw new Error( + `UTxO selector did not finish loading after 3 page loads\n${diagnostics.join("\n")}`, + ); +} + +async function waitForSelectedInputUtxos( + page: Page, + diagnostics: string[], +): Promise { + const selectedCount = await page + .waitForFunction( + () => { + const match = document.body.innerText.match(/Input UTxOs \((\d+)\)/); + const count = match ? Number(match[1]) : 0; + return count > 0 ? count : false; + }, + { timeout: 30_000 }, + ) + .then((handle) => handle.jsonValue() as Promise) + .catch(async () => { + await page + .getByRole("button", { name: /select multisig utxos/i }) + .click({ timeout: 2_000 }) + .catch(() => {}); + + const state = await page + .evaluate(() => ({ + href: window.location.href, + bodyText: document.body.innerText.slice(0, 4000), + })) + .catch((error) => ({ evaluateError: String(error) })); + + throw new Error( + `No input UTxOs were selected before submit.\n${diagnostics.join("\n")}\npage state: ${JSON.stringify(state)}`, + ); + }); + + diagnostics.push(`input utxos selected=${selectedCount}`); +} + +async function getPendingTransactions( + page: Page, + walletId: string, + signerAddress: string, +): Promise { + const jwtSecret = process.env.CI_JWT_SECRET; + if (!jwtSecret) { + throw new Error("CI_JWT_SECRET must be set to verify pending transactions"); + } + const token = sign({ address: signerAddress }, jwtSecret, { expiresIn: "1h" }); + const resp = await page.request.get( + `/api/v1/pendingTransactions?walletId=${encodeURIComponent(walletId)}&address=${encodeURIComponent(signerAddress)}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + if (!resp.ok()) { + throw new Error( + `pendingTransactions failed ${resp.status()}: ${await resp.text().catch(() => "")}`, + ); + } + return (await resp.json()) as PendingTransaction[]; +} + +async function expectNoPendingTransactions( + page: Page, + walletId: string, + signerAddress: string, +): Promise { + const transactions = await getPendingTransactions(page, walletId, signerAddress); + if (transactions.length > 0) { + throw new Error( + `Wallet ${walletId} has ${transactions.length} pending transaction(s) before this leg: ${transactions + .map((transaction) => transaction.id) + .join(", ")}. Reset the Playwright stack with docker compose -f docker-compose.playwright.yml --env-file .env.playwright down -v --remove-orphans, then rerun bootstrap.`, + ); + } +} + +async function expectPendingTransactionState( + page: Page, + walletId: string, + signerAddress: string, + txId: string, + expected: { + signedAddresses: string[]; + rejectedAddresses?: string[]; + state?: number; + }, +): Promise { + const transactions = await getPendingTransactions(page, walletId, signerAddress); + const transaction = transactions.find((pending) => pending.id === txId); + + expect( + transaction, + `Expected pending transaction ${txId} in wallet ${walletId}`, + ).toBeTruthy(); + + const signedAddresses = transaction!.signedAddresses ?? []; + expect( + signedAddresses.sort(), + `Unexpected signed addresses for pending transaction ${txId}`, + ).toEqual([...expected.signedAddresses].sort()); + + if (expected.rejectedAddresses) { + const rejectedAddresses = transaction!.rejectedAddresses ?? []; + expect( + rejectedAddresses.sort(), + `Unexpected rejected addresses for pending transaction ${txId}`, + ).toEqual([...expected.rejectedAddresses].sort()); + } + + if (expected.state !== undefined) { + expect(transaction!.state).toBe(expected.state); + } + + return transaction!; +} + +// Poll /api/v1/pendingTransactions until the given tx ID is no longer listed, +// indicating the broadcast was accepted and the DB record updated. +async function waitForTxCleared( + page: Page, + walletId: string, + signerAddress: string, + txId: string, + timeoutMs = 120_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const transactions = await getPendingTransactions(page, walletId, signerAddress); + if (!transactions.some((t) => t.id === txId)) return; + await page.waitForTimeout(5_000); + } + throw new Error( + `Transaction ${txId} still pending in wallet ${walletId} after ${timeoutMs}ms`, + ); +} + +test.describe("ring transfer", () => { + // Each leg has a distinct source wallet, so they can run on separate workers. + test.describe.configure({ mode: "parallel" }); + + + for (const leg of LEGS) { + test(`ring transfer: ${leg.name}`, async ({ page, authenticateAs }) => { + test.setTimeout(600_000); + + const ctx = loadContext(); + const srcWallet = getWallet(ctx, leg.srcType); + const dstWallet = getWallet(ctx, leg.dstType); + const diagnostics: string[] = []; + page.on("console", (message) => { + if (message.type() === "error" || message.type() === "warning") { + diagnostics.push(`browser console ${message.type()}: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + diagnostics.push(`browser pageerror: ${error.message}`); + }); + + // ── Step 1: Proposer (signer 0) creates the transaction ─────────────── + await authenticateAs(page, 0); + await expectNoPendingTransactions( + page, + srcWallet.walletId, + ctx.signerAddresses[0]!, + ); + + const sourceUtxos = await waitForSpendableUtxos( + srcWallet.walletAddress, + MIN_SOURCE_LOVELACE, + ); + diagnostics.push( + `source ${srcWallet.walletAddress} visible utxos=${sourceUtxos.length} lovelace=${totalLovelace(sourceUtxos)}`, + ); + await mockBrowserUtxoFetch( + page, + srcWallet.walletAddress, + sourceUtxos, + diagnostics, + ); + await waitForUtxoSelectorLoaded( + page, + `/wallets/${srcWallet.walletId}/transactions/new`, + diagnostics, + ); + + // Fill first recipient row (index 0) + await page.fill(`[data-testid="recipient-address-input-0"]`, dstWallet.walletAddress); + await page.fill(`[data-testid="amount-input-0"]`, ADA_AMOUNT); + await waitForSelectedInputUtxos(page, diagnostics); + + // The layout re-checks the wallet session on every full-page navigation. + // If the WalletAuthModal appears (session check returned unauthorized), wait for + // it to auto-close — with autoAuthorize=true and the wallet mock connected it + // signs and closes within a few seconds. waitForFunction returns immediately + // when no dialog is present so this adds negligible delay in the happy path. + await page.waitForFunction( + () => document.querySelectorAll('[role="dialog"][data-state="open"]').length === 0, + { timeout: 30_000 }, + ).catch(() => {}); + + // Submit — the hook calls activeWallet.signTx (bridges to meshSign) then + // calls createTransaction tRPC mutation and redirects to transactions page. + const createTransactionResponsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/trpc/transaction.createTransaction") && + response.request().method() === "POST", + { timeout: 90_000 }, + ); + await page.click('[data-testid="create-transaction-button"]'); + + const createTransactionResponse = await createTransactionResponsePromise; + expect( + createTransactionResponse.ok(), + `createTransaction failed ${createTransactionResponse.status()}: ${await createTransactionResponse.text().catch(() => "")}`, + ).toBe(true); + + const transactionsUrl = `/wallets/${srcWallet.walletId}/transactions`; + await page.goto(transactionsUrl); + await expect(page).toHaveURL(new RegExp(`/wallets/${srcWallet.walletId}/transactions$`)); + + // Extract the newly created transaction's ID from the first pending tx card + const txCard = page.locator('[data-testid^="tx-card-"]').first(); + await txCard.waitFor({ state: "visible", timeout: 30_000 }); + const cardTestId = await txCard.getAttribute("data-testid"); + const transactionId = cardTestId!.replace("tx-card-", ""); + expect(transactionId).toBeTruthy(); + + await expectPendingTransactionState( + page, + srcWallet.walletId, + ctx.signerAddresses[0]!, + transactionId, + { + signedAddresses: [ctx.signerAddresses[0]!], + rejectedAddresses: [], + state: 0, + }, + ); + + // The proposer auto-signed while creating the transaction, so they should + // see the pending card but no duplicate signing action. + await expect( + page.locator(`[data-testid="tx-card-${transactionId}"]`), + ).toBeVisible(); + await expect( + page.locator(`[data-testid="sign-button-${transactionId}"]`), + ).toHaveCount(0); + + // ── Step 2: Signer 1 signs → broadcast (threshold 2-of-3 now met) ───── + await authenticateAs(page, 1); + + await page.goto(`/wallets/${srcWallet.walletId}/transactions`); + + // Confirm the tx card is still pending (proposer signed, threshold not yet met) + await page.waitForSelector(`[data-testid="tx-card-${transactionId}"]`, { + timeout: 20_000, + }); + await expectPendingTransactionState( + page, + srcWallet.walletId, + ctx.signerAddresses[1]!, + transactionId, + { + signedAddresses: [ctx.signerAddresses[0]!], + rejectedAddresses: [], + state: 0, + }, + ); + await expect( + page.locator(`[data-testid="sign-button-${transactionId}"]`), + ).toBeVisible(); + + // Sign — this will call signTx(), reach the 2-of-3 threshold, submit on-chain, + // and set broadcastDone=true which renders the tx-broadcast-success indicator. + const updateTransactionResponsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/trpc/transaction.updateTransaction") && + response.request().method() === "POST", + { timeout: 90_000 }, + ); + await page.click(`[data-testid="sign-button-${transactionId}"]`); + + // Wait for the broadcast success indicator (appears when submitTxWithScriptRecovery succeeds) + await page.waitForSelector('[data-testid="tx-broadcast-success"]', { + timeout: 90_000, + }); + + const updateTransactionResponse = await updateTransactionResponsePromise; + expect( + updateTransactionResponse.ok(), + `updateTransaction failed ${updateTransactionResponse.status()}: ${await updateTransactionResponse.text().catch(() => "")}`, + ).toBe(true); + + // The pending tx card should be removed once the updateTransaction mutation + // invalidates getPendingTransactions and the list refetches. + await page + .waitForSelector(`[data-testid="tx-card-${transactionId}"]`, { + state: "detached", + timeout: 30_000, + }) + .catch(() => { + // If the card hasn't detached yet, also poll the REST API as a fallback + }); + + // REST-level verification — confirms the DB record is no longer pending + await waitForTxCleared( + page, + srcWallet.walletId, + ctx.signerAddresses[1]!, + transactionId, + 60_000, + ); + }); + } +}); diff --git a/e2e/tests/staking-ui.spec.ts b/e2e/tests/staking-ui.spec.ts new file mode 100644 index 00000000..cefe294a --- /dev/null +++ b/e2e/tests/staking-ui.spec.ts @@ -0,0 +1,168 @@ +// Phase 3 item 6: Staking UI. +// +// Proves the SDK staking page works in the browser without touching the chain: +// - the page loads staking state and gates actions on pool selection +// - inactive stake exposes RegisterAndDelegate; active stake exposes +// Delegate + Deregister +// - proposing a register+delegate certificate creates a pending transaction +// +// Runs against a throwaway 2-of-3 wallet created with the CI signers' stake +// keys (the app classifies it as an SDK wallet, so the staking page can derive +// a stake address and staking script). The wallet is unfunded: UTxOs and the +// account state are mocked, and the 2-of-3 threshold means the certificate +// proposal stays pending — nothing is broadcast. The stranded pending row is +// deleted in cleanup so reruns and parallel workers stay clean. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import { createThrowawayWallet, trpcMutate } from "../helpers/apiHelpers"; +import { + mockWalletUtxos, + mockAccountState, + mockPoolList, + resolveStakePoolHex, + waitForSinglePendingTransaction, +} from "../helpers/phase3Mocks"; + +test.describe("staking UI", () => { + test("staking page gates actions on pool selection and account state", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + const poolHex = await resolveStakePoolHex(ctx); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E staking-gates ${Date.now()}-${test.info().workerIndex}`, + { withStakeKeys: true }, + ); + await mockWalletUtxos(page); + await mockPoolList(page); + await mockAccountState(page, { active: false }); + + await page.goto(`/wallets/${wallet.walletId}/staking`); + + // Staking info loads from the mocked account state. + await expect(page.getByRole("heading", { name: "Staking Info", exact: true })).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText("Inactive", { exact: true })).toBeVisible(); + + // No staking actions are offered until a pool is chosen. + await expect(page.getByRole("heading", { name: "Staking Actions" })).toHaveCount(0); + + const poolInput = page.getByPlaceholder("Enter pool ID"); + await poolInput.fill(poolHex); + await expect(page.getByText("Selected Pool ID:")).toBeVisible(); + + // Inactive stake: only the combined register+delegate action is offered. + await expect(page.getByRole("heading", { name: "Staking Actions" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "RegisterAndDelegate" }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Deregister" })).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Delegate", exact: true }), + ).toHaveCount(0); + }); + + test("active stake exposes delegate and deregister actions", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + const poolHex = await resolveStakePoolHex(ctx); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E staking-active ${Date.now()}-${test.info().workerIndex}`, + { withStakeKeys: true }, + ); + await mockWalletUtxos(page); + await mockPoolList(page); + await mockAccountState(page, { active: true, poolId: poolHex }); + + await page.goto(`/wallets/${wallet.walletId}/staking`); + await expect(page.getByRole("heading", { name: "Staking Info", exact: true })).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText("Active", { exact: true })).toBeVisible(); + + await page.getByPlaceholder("Enter pool ID").fill(poolHex); + + await expect(page.getByRole("heading", { name: "Staking Actions" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Delegate", exact: true }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Deregister" })).toBeVisible(); + await expect( + page.getByRole("button", { name: "RegisterAndDelegate" }), + ).toHaveCount(0); + }); + + test("register & delegate certificate proposal creates a pending transaction", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + const poolHex = await resolveStakePoolHex(ctx); + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E staking-propose ${Date.now()}-${test.info().workerIndex}`, + { withStakeKeys: true }, + ); + await mockWalletUtxos(page); + await mockPoolList(page); + await mockAccountState(page, { active: false }); + + await page.goto(`/wallets/${wallet.walletId}/staking`); + await expect(page.getByRole("heading", { name: "Staking Info", exact: true })).toBeVisible({ timeout: 60_000 }); + await page.getByPlaceholder("Enter pool ID").fill(poolHex); + + // The action card's UTxO selector must auto-select the mocked UTxO before + // the certificate transaction can be built. + await page.waitForSelector( + '[data-testid="utxo-selector"][data-loaded="true"]', + { timeout: 60_000 }, + ); + + const createTransactionResponsePromise = page.waitForResponse( + (response) => + response.url().includes("transaction.createTransaction") && + response.request().method() === "POST", + { timeout: 120_000 }, + ); + await page.getByRole("button", { name: "RegisterAndDelegate" }).click(); + const createTransactionResponse = await createTransactionResponsePromise; + expect( + createTransactionResponse.ok(), + `createTransaction failed ${createTransactionResponse.status()}: ${await createTransactionResponse.text().catch(() => "")}`, + ).toBe(true); + + await expect(page.getByText("Stake Registered & Delegated").first()).toBeVisible({ + timeout: 30_000, + }); + + const pending = await waitForSinglePendingTransaction( + page, + wallet.walletId, + ctx.signerAddresses[0]!, + ); + try { + // Only the proposer signed; 2-of-3 keeps the certificate pending. + expect(pending.signedAddresses).toEqual([ctx.signerAddresses[0]!]); + expect(pending.state).toBe(0); + } finally { + await trpcMutate(page, "transaction.deleteTransaction", { + transactionId: pending.id, + }).catch(() => {}); + } + }); +}); diff --git a/e2e/tests/wallet-access-control.spec.ts b/e2e/tests/wallet-access-control.spec.ts new file mode 100644 index 00000000..4a705808 --- /dev/null +++ b/e2e/tests/wallet-access-control.spec.ts @@ -0,0 +1,223 @@ +// Phase 2 item 4: Wallet access control. +// +// Proves page guards match wallet authorization: +// - A signer can open the wallet pages of a wallet they belong to. +// - An authenticated non-member gets FORBIDDEN from wallet.getWallet and the +// wallet pages never render wallet content (the app keeps the loading +// skeleton and exposes nothing). +// - Unauthenticated direct navigation falls back to the public landing page. +// - The denial survives a browser reload. +// +// The non-member is a real derived preprod address (the standard all-zero +// entropy test mnemonic) with a valid injected session cookie — i.e. a fully +// authenticated user who simply is not a signer of the target wallet. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext, getWallet } from "../helpers/contextLoader"; +import { buildCip30MockScript } from "../helpers/cip30Mock"; +import { + buildWalletSessionToken, + WALLET_SESSION_COOKIE, +} from "../helpers/authSession"; +import { buildRestToken } from "../helpers/apiHelpers"; +import type { Page, Response } from "@playwright/test"; + +// Standard BIP-39 test vector (all-zero entropy): valid checksum, derives a +// real preprod address that is not a signer of any bootstrap wallet. +const NON_MEMBER_MNEMONIC = [...Array(23).fill("abandon"), "art"].join(" "); + +async function deriveNonMemberAddress(): Promise { + const { MeshWallet } = await import("@meshsdk/core"); + const wallet = new MeshWallet({ + networkId: 0, + key: { type: "mnemonic", words: NON_MEMBER_MNEMONIC.split(" ") }, + }); + await wallet.init(); + return wallet.getChangeAddress(); +} + +// Mirrors authFixture's CI path for an arbitrary address: injects the CIP-30 +// mock (with inert bridge stubs — no signing ever happens here), seeds Mesh's +// persisted connection so auto-connect resolves useAddress on every load, and +// installs a valid non-Secure session cookie for the address. +async function setupSessionFor(page: Page, address: string): Promise { + const jwtSecret = process.env.CI_JWT_SECRET!; + const { Address } = await import("@meshsdk/core-cst"); + const addressHex = Address.fromBech32(address).toBytes().toString(); + + await page.addInitScript({ + content: + ` + window.__ci_getUtxos = async function() { return []; }; + window.__ci_signTx = function() { return Promise.reject(new Error("signing not supported in access-control spec")); }; + window.__ci_signData = function() { return Promise.reject(new Error("signing not supported in access-control spec")); }; + window.__ci_submitTx = async function() { return "${"0".repeat(64)}"; }; + try { localStorage.setItem("mesh-wallet-persist", JSON.stringify({ walletName: "meshci" })); } catch (e) {} + ` + + buildCip30MockScript({ + walletName: "meshci", + usedAddresses: [addressHex], + changeAddress: addressHex, + rewardAddresses: [], + }), + }); + + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + await page.context().clearCookies({ name: WALLET_SESSION_COOKIE }); + await page.context().addCookies([ + { + name: WALLET_SESSION_COOKIE, + value: buildWalletSessionToken(address, jwtSecret), + url: appUrl, + httpOnly: true, + sameSite: "Lax", + secure: false, + expires: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + }, + ]); +} + +function waitForGetWallet(page: Page): Promise { + return page.waitForResponse( + (response) => response.url().includes("wallet.getWallet"), + { timeout: 60_000 }, + ); +} + +// Navigates and waits for the wallet.getWallet round-trip so absence +// assertions run after the app has actually resolved (and denied) the query. +async function gotoAndAwaitGetWallet(page: Page, path: string): Promise { + const responsePromise = waitForGetWallet(page); + await page.goto(path); + const response = await responsePromise; + return response.text().catch(() => ""); +} + +async function expectNoWalletContent(page: Page): Promise { + await expect(page.getByText("Signers", { exact: true })).toHaveCount(0); + await expectNoWalletActions(page); +} + +// Narrower variant for the public landing fallback, whose marketing copy +// legitimately contains the word "Signers". +async function expectNoWalletActions(page: Page): Promise { + await expect(page.getByRole("button", { name: "Deposit Funds" })).toHaveCount(0); + await expect(page.getByTestId("create-transaction-button")).toHaveCount(0); + await expect(page.locator('[data-testid^="tx-card-"]')).toHaveCount(0); +} + +test.describe("wallet access control", () => { + test("authenticated signer can open wallet pages they belong to", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + + await authenticateAs(page, 0); + + // Wallet detail (info) page renders the signer list. + await page.goto(`/wallets/${wallet.walletId}`); + await expect(page.getByText("Signers", { exact: true }).first()).toBeVisible( + { timeout: 60_000 }, + ); + + // Transactions page renders the balance card actions. + await page.goto(`/wallets/${wallet.walletId}/transactions`); + await expect( + page.getByRole("button", { name: "Deposit Funds" }).first(), + ).toBeVisible({ timeout: 60_000 }); + + // New transaction page renders the form for a member. + await page.goto(`/wallets/${wallet.walletId}/transactions/new`); + await expect(page.getByTestId("create-transaction-button")).toBeVisible({ + timeout: 60_000, + }); + + // Info route renders the same guarded content. + await page.goto(`/wallets/${wallet.walletId}/info`); + await expect(page.getByText("Signers", { exact: true }).first()).toBeVisible( + { timeout: 60_000 }, + ); + }); + + test("authenticated non-member cannot open wallet pages", async ({ page }) => { + test.skip( + !process.env.CI_JWT_SECRET, + "CI_JWT_SECRET is required to mint a session for a non-member address", + ); + test.setTimeout(240_000); + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + const nonMember = await deriveNonMemberAddress(); + expect(ctx.signerAddresses).not.toContain(nonMember); + + await setupSessionFor(page, nonMember); + + // Wallet detail: the server denies the wallet query and the page never + // shows wallet content. + const getWalletBody = await gotoAndAwaitGetWallet( + page, + `/wallets/${wallet.walletId}`, + ); + expect(getWalletBody).toMatch(/FORBIDDEN|Not a signer|UNAUTHORIZED/); + await expectNoWalletContent(page); + + // The denial holds across every guarded wallet route. + for (const path of [ + `/wallets/${wallet.walletId}/transactions`, + `/wallets/${wallet.walletId}/transactions/new`, + `/wallets/${wallet.walletId}/info`, + `/wallets/${wallet.walletId}/staking`, + `/wallets/${wallet.walletId}/governance`, + ]) { + await gotoAndAwaitGetWallet(page, path); + await expectNoWalletContent(page); + } + + // REST surface agrees: pending transactions are not readable either. + const restResponse = await page.request.get( + `/api/v1/pendingTransactions?walletId=${encodeURIComponent(wallet.walletId)}&address=${encodeURIComponent(nonMember)}`, + { headers: { Authorization: `Bearer ${buildRestToken(nonMember)}` } }, + ); + expect( + restResponse.ok(), + `expected pendingTransactions to deny non-member, got ${restResponse.status()}`, + ).toBe(false); + + // Protection persists after a reload. + const reloadResponsePromise = waitForGetWallet(page); + await page.reload(); + await reloadResponsePromise; + await expectNoWalletContent(page); + }); + + test("unauthenticated direct navigation does not expose wallet pages", async ({ + page, + injectWallet, + }) => { + test.setTimeout(120_000); + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + + // Wallet extension present but no session cookie and no persisted + // connection: the layout falls back to the public landing view (with the + // Connect Wallet entry point) instead of rendering the guarded wallet + // page. Without any injected wallet the header shows the UTXOS onboarding + // button instead, so inject the mock to model a logged-out extension user. + await injectWallet(page, 0); + await page.goto(`/wallets/${wallet.walletId}/transactions`); + await expect( + page.getByRole("button", { name: /connect wallet/i }).first(), + ).toBeVisible({ timeout: 60_000 }); + await expectNoWalletActions(page); + + // Still protected after a reload. + await page.reload(); + await expect( + page.getByRole("button", { name: /connect wallet/i }).first(), + ).toBeVisible({ timeout: 60_000 }); + await expectNoWalletActions(page); + }); +}); diff --git a/jest.config.mjs b/jest.config.mjs index 7baf28d7..7eec7937 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -9,6 +9,13 @@ import { shared, ESM_TESTS, INTEGRATION_GLOB } from './jest.shared.mjs'; */ export default { ...shared, + // Own cache directory. The two projects share one transform config but run + // under different module systems (this one without --experimental-vm-modules), + // so a cache entry produced by one is not valid for the other. Sharing jest's + // default cache dir let them clobber each other's entries for ESM packages in + // node_modules — surfacing as an intermittent "Cannot use import statement + // outside a module" from superjson, in whichever suite happened to lose. + cacheDirectory: '/node_modules/.cache/jest-cjs', testMatch: [ '**/__tests__/**/*.(test|spec).+(ts|tsx|js)', '**/*.(test|spec).+(ts|tsx|js)', diff --git a/jest.esm.config.mjs b/jest.esm.config.mjs index 32e9f3a2..52f65322 100644 --- a/jest.esm.config.mjs +++ b/jest.esm.config.mjs @@ -9,5 +9,7 @@ import { shared, ESM_TESTS } from './jest.shared.mjs'; */ export default { ...shared, + // Separate from the CJS project's cache — see the note in jest.config.mjs. + cacheDirectory: '/node_modules/.cache/jest-esm', testMatch: ESM_TESTS.map((name) => `/src/__tests__/${name}.test.ts`), }; diff --git a/jest.shared.mjs b/jest.shared.mjs index 2b7a8258..a046e627 100644 --- a/jest.shared.mjs +++ b/jest.shared.mjs @@ -8,11 +8,33 @@ * The two modes are mutually exclusive per file, so they run as separate jest * invocations (see jest.config.mjs for CJS and jest.esm.config.mjs for ESM), * both built from this shared base. + * + * ## Never pass `{ virtual: true }` to `jest.mock()` for a module that exists + * + * `transform` below matches TypeScript only, so **no transformer applies to + * `.js` files** — including ESM-only packages in node_modules that + * `transformIgnorePatterns` permits. `superjson` v2 is the live example: + * `"type": "module"`, a single ESM export, no CommonJS build. Loading it in the + * CJS project throws "Cannot use import statement outside a module", every + * time, with no cache or ordering involved. + * + * CJS tests therefore depend on their mocks actually applying, so that the real + * module (and its unloadable dependencies) is never reached. `virtual: true` is + * for modules with *no file on disk*; used on a real one it registers the mock + * under the bare specifier instead of the resolved path, and whether a given + * importer gets the mock then varies with resolution order — which shifts with + * test count and load. That was the cause of a ~1-in-8 intermittent failure in + * walletIds.bot.test.ts, whose `@/server/api/root` mock would occasionally miss + * and pull the real tRPC root, and superjson with it. + * + * `src/__tests__/jestMockHygiene.test.ts` fails the build if the pattern + * returns. */ export const ESM_TESTS = [ 'apiSecurity', 'botBallotsUpsert', 'governanceActiveProposals', + 'mcpConnections', 'og', 'pendingTransactions', 'reviewSignersCardKey', diff --git a/next.config.js b/next.config.js index 4d6eca67..fab7794d 100644 --- a/next.config.js +++ b/next.config.js @@ -92,6 +92,34 @@ const config = { "@sidan-lab/whisky-js-nodejs", ], + // OAuth discovery documents must live under /.well-known/, but Next ignores + // dot-directories inside pages/, so they cannot be files. Rewrites map the + // well-known paths onto real API routes. + // + // RFC 9728 defines a path-aware form for protected-resource metadata + // (/.well-known/oauth-protected-resource + the resource's path). Clients probe + // that first and fall back to the root form, so both are served. + async rewrites() { + return [ + { + source: '/.well-known/oauth-authorization-server', + destination: '/api/oauth/metadata/authorization-server', + }, + { + source: '/.well-known/oauth-authorization-server/:path*', + destination: '/api/oauth/metadata/authorization-server', + }, + { + source: '/.well-known/oauth-protected-resource', + destination: '/api/oauth/metadata/protected-resource', + }, + { + source: '/.well-known/oauth-protected-resource/:path*', + destination: '/api/oauth/metadata/protected-resource', + }, + ]; + }, + // Basic security headers applied to all routes. // NOTE: Content-Security-Policy and Strict-Transport-Security are intentionally // omitted — CSP would break inline scripts/styles and HSTS locks browsers to diff --git a/package-lock.json b/package-lock.json index 002958a7..d7e13efa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@meshsdk/core-cst": "^1.9.0", "@meshsdk/provider": "^1.9.0-beta.101", "@meshsdk/react": "^2.0.0-beta.2", + "@modelcontextprotocol/server": "^2.0.0", "@octokit/core": "^6.1.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", @@ -43,6 +44,7 @@ "@trpc/react-query": "^11.0.0-rc.446", "@trpc/server": "^11.0.0-rc.446", "@utxos/sdk": "^0.0.78", + "@xyflow/react": "^12.11.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cors": "^2.8.5", @@ -63,6 +65,7 @@ "react-dropzone": "^14.3.5", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "resend": "^6.12.4", "superjson": "^2.2.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-react": "^5.22.0", @@ -79,6 +82,7 @@ "@eslint/eslintrc": "^3.3.3", "@jest/globals": "^30.1.2", "@next/bundle-analyzer": "^16.2.6", + "@playwright/test": "1.60.0", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.17.7", "@types/cors": "^2.8.18", @@ -245,17 +249,6 @@ } } }, - "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==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@simplewebauthn/types": "^9.0.1" - } - }, "node_modules/@auth/prisma-adapter/node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -4366,6 +4359,49 @@ "@simplewebauthn/browser": "^13.0.0" } }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@monogrid/gainmap-js": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", @@ -4994,6 +5030,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -6739,15 +6791,6 @@ "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", "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.", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@sinclair/typebox": { "version": "0.34.49", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", @@ -6775,6 +6818,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -7755,6 +7804,55 @@ "@types/node": "*" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -8897,6 +8995,48 @@ "@simplewebauthn/browser": "^13.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/@zxing/text-encoding": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", @@ -10549,6 +10689,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -10865,6 +11011,37 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -10948,6 +11125,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-time": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", @@ -10972,6 +11158,34 @@ "node": ">=12" } }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, "node_modules/d3-tricontour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/d3-tricontour/-/d3-tricontour-1.1.0.tgz", @@ -10985,6 +11199,22 @@ "node": ">=12" } }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -12420,6 +12650,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -19616,6 +19852,52 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/point-in-polygon-hao": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz", @@ -19634,6 +19916,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -20896,6 +21184,27 @@ "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, + "node_modules/resend": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.4.tgz", + "integrity": "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -21563,6 +21872,16 @@ "node": ">=8" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stats-gl": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", diff --git a/package.json b/package.json index c2c0c07c..01262c4b 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test": "node scripts/run-tests.mjs", "test:cjs": "jest", "test:esm": "node --experimental-vm-modules node_modules/jest/bin/jest.js -c jest.esm.config.mjs --passWithNoTests", - "test:bot:unit": "node scripts/run-tests.mjs src/__tests__/botAuth.test.ts src/__tests__/botMe.test.ts src/__tests__/createWallet.bot.test.ts src/__tests__/walletIds.bot.test.ts src/__tests__/pendingTransactions.bot.test.ts src/__tests__/freeUtxos.bot.test.ts src/__tests__/addTransaction.bot.test.ts src/__tests__/nativeScript.bot.test.ts src/__tests__/governanceActiveProposals.test.ts src/__tests__/botBallotsUpsert.test.ts src/__tests__/signTransaction.bot.test.ts src/__tests__/submitDatum.bot.test.ts src/__tests__/resolveUtxoRefsFromChain.test.ts src/__tests__/resolveDRepAnchorFromUrl.test.ts src/__tests__/normalizePoolId.test.ts src/__tests__/createPendingMultisigTransaction.test.ts src/__tests__/proxyUtxos.test.ts src/__tests__/proxyTxBuilders.test.ts src/__tests__/proxySetup.bot.test.ts src/__tests__/proxyCleanup.bot.test.ts src/__tests__/proxyAccess.test.ts src/__tests__/proxySetupFinalization.test.ts src/__tests__/proxyCleanupFinalization.test.ts src/__tests__/proxyCiPreflight.test.ts src/__tests__/proxyCiOrphanAdoption.test.ts src/__tests__/proxyCiChainRecovery.test.ts src/__tests__/proxyBotSelection.test.ts src/__tests__/proxyCleanupRuntime.test.ts src/__tests__/ciSigningSelection.test.ts src/__tests__/ciScenarioManifest.test.ts", + "test:bot:unit": "node scripts/run-tests.mjs src/__tests__/botAuth.test.ts src/__tests__/botMe.test.ts src/__tests__/createWallet.bot.test.ts src/__tests__/walletIds.bot.test.ts src/__tests__/pendingTransactions.bot.test.ts src/__tests__/freeUtxos.bot.test.ts src/__tests__/addTransaction.bot.test.ts src/__tests__/nativeScript.bot.test.ts src/__tests__/governanceActiveProposals.test.ts src/__tests__/botBallotsUpsert.test.ts src/__tests__/signTransaction.bot.test.ts src/__tests__/submitDatum.bot.test.ts src/__tests__/resolveUtxoRefsFromChain.test.ts src/__tests__/resolveDRepAnchorFromUrl.test.ts src/__tests__/normalizePoolId.test.ts src/__tests__/createPendingMultisigTransaction.test.ts src/__tests__/proxyUtxos.test.ts src/__tests__/proxyTxBuilders.test.ts src/__tests__/proxySetup.bot.test.ts src/__tests__/proxyCleanup.bot.test.ts src/__tests__/proxyAccess.test.ts src/__tests__/proxySetupFinalization.test.ts src/__tests__/proxyCleanupFinalization.test.ts src/__tests__/proxyCiPreflight.test.ts src/__tests__/proxyCiOrphanAdoption.test.ts src/__tests__/proxyCiChainRecovery.test.ts src/__tests__/proxyBotSelection.test.ts src/__tests__/proxyCleanupRuntime.test.ts src/__tests__/ciSigningSelection.test.ts src/__tests__/ciScenarioManifest.test.ts src/__tests__/mcpTools.test.ts src/__tests__/mcpRoute.test.ts src/__tests__/oauthTokens.test.ts src/__tests__/oauthFlow.test.ts src/__tests__/oauthDecision.test.ts src/__tests__/oauthRefreshStore.test.ts src/__tests__/jestMockHygiene.test.ts src/__tests__/rationaleAnchor.test.ts src/__tests__/mcpConnections.test.ts", "test:bot:integration": "jest src/__tests__/botApi.integration.test.ts --runInBand", "test:bot": "npm run test:bot:unit && npm run test:bot:integration", "test:watch": "jest --watch", @@ -30,7 +30,8 @@ "test:ci": "node scripts/run-tests.mjs --ci --coverage --runInBand", "test:trpc": "jest -c jest.trpc.config.mjs --runInBand", "analyze": "ANALYZE=true npm run build", - "apply-project": "node scripts/apply-project-to-github.mjs" + "apply-project": "node scripts/apply-project-to-github.mjs", + "test:e2e": "playwright test --config=e2e/playwright.config.ts" }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", @@ -39,6 +40,7 @@ "@meshsdk/core-cst": "^1.9.0", "@meshsdk/provider": "^1.9.0-beta.101", "@meshsdk/react": "^2.0.0-beta.2", + "@modelcontextprotocol/server": "^2.0.0", "@octokit/core": "^6.1.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", @@ -67,6 +69,7 @@ "@trpc/react-query": "^11.0.0-rc.446", "@trpc/server": "^11.0.0-rc.446", "@utxos/sdk": "^0.0.78", + "@xyflow/react": "^12.11.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cors": "^2.8.5", @@ -87,6 +90,7 @@ "react-dropzone": "^14.3.5", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "resend": "^6.12.4", "superjson": "^2.2.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-react": "^5.22.0", @@ -103,6 +107,7 @@ "@eslint/eslintrc": "^3.3.3", "@jest/globals": "^30.1.2", "@next/bundle-analyzer": "^16.2.6", + "@playwright/test": "1.60.0", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.17.7", "@types/cors": "^2.8.18", @@ -134,7 +139,7 @@ "ct3aMetadata": { "initVersion": "7.37.0" }, - "packageManager": "npm@10.7.0", + "packageManager": "npm@11.14.1", "overrides": { "ip": "^2.0.1", "rimraf": "^6.1.2", diff --git a/prisma/migrations/20260617070000_add_notification_center/migration.sql b/prisma/migrations/20260617070000_add_notification_center/migration.sql new file mode 100644 index 00000000..9b7fb7f0 --- /dev/null +++ b/prisma/migrations/20260617070000_add_notification_center/migration.sql @@ -0,0 +1,97 @@ +-- CreateTable +CREATE TABLE "WalletSignerNotificationSetting" ( + "id" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "signerAddress" TEXT NOT NULL, + "email" TEXT, + "emailNormalized" TEXT, + "emailVerifiedAt" TIMESTAMP(3), + "emailOptIn" BOOLEAN NOT NULL DEFAULT true, + "notifyTransactionSignatures" BOOLEAN NOT NULL DEFAULT true, + "notifySignableSignatures" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WalletSignerNotificationSetting_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EmailVerificationToken" ( + "id" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "signerAddress" TEXT NOT NULL, + "emailNormalized" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "EmailVerificationToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NotificationDelivery" ( + "id" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "channel" TEXT NOT NULL, + "recipientAddress" TEXT NOT NULL, + "recipientEmail" TEXT, + "resourceType" TEXT NOT NULL, + "resourceId" TEXT NOT NULL, + "walletId" TEXT, + "idempotencyKey" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "provider" TEXT, + "providerMessageId" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sentAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NotificationDelivery_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletSignerNotificationSetting_walletId_signerAddress_key" ON "WalletSignerNotificationSetting"("walletId", "signerAddress"); + +-- CreateIndex +CREATE INDEX "WalletSignerNotificationSetting_walletId_idx" ON "WalletSignerNotificationSetting"("walletId"); + +-- CreateIndex +CREATE INDEX "WalletSignerNotificationSetting_signerAddress_idx" ON "WalletSignerNotificationSetting"("signerAddress"); + +-- CreateIndex +CREATE INDEX "WalletSignerNotificationSetting_emailNormalized_idx" ON "WalletSignerNotificationSetting"("emailNormalized"); + +-- CreateIndex +CREATE UNIQUE INDEX "EmailVerificationToken_tokenHash_key" ON "EmailVerificationToken"("tokenHash"); + +-- CreateIndex +CREATE INDEX "EmailVerificationToken_walletId_signerAddress_idx" ON "EmailVerificationToken"("walletId", "signerAddress"); + +-- CreateIndex +CREATE INDEX "EmailVerificationToken_expiresAt_idx" ON "EmailVerificationToken"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "NotificationDelivery_idempotencyKey_key" ON "NotificationDelivery"("idempotencyKey"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_status_nextAttemptAt_idx" ON "NotificationDelivery"("status", "nextAttemptAt"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_recipientAddress_idx" ON "NotificationDelivery"("recipientAddress"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_walletId_idx" ON "NotificationDelivery"("walletId"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_resourceType_resourceId_idx" ON "NotificationDelivery"("resourceType", "resourceId"); + +-- CreateIndex +CREATE INDEX "NotificationDelivery_pending_idx" +ON "NotificationDelivery" ("nextAttemptAt", "createdAt") +WHERE "status" IN ('pending', 'retrying'); diff --git a/prisma/migrations/20260706100000_enable_rls_followup_tables/migration.sql b/prisma/migrations/20260706100000_enable_rls_followup_tables/migration.sql new file mode 100644 index 00000000..bd8973a7 --- /dev/null +++ b/prisma/migrations/20260706100000_enable_rls_followup_tables/migration.sql @@ -0,0 +1,43 @@ +-- Enable Row Level Security (RLS) and deny-all policies for PostgREST roles +-- on tables created after 20251215090000_enable_rls_disable_postgrest. +-- Covers the Supabase security advisor's rls_disabled_in_public findings +-- (PendingBot, BotClaimToken, AuditLog, Contact, WalletBotAccess, BotKey, +-- BotUser) plus tables that land in the same deploy and would be flagged +-- next (ProposalTally, notification center tables). +-- +-- Same contract as the original migration: +-- - Enables RLS on each table unconditionally (skipping tables that don't exist) +-- - Only creates deny-all policies for `anon` and `authenticated` roles if they exist +-- - Prisma (service role / table owner) continues to bypass RLS + +DO $$ +DECLARE + tbl TEXT; +BEGIN + FOR tbl IN + SELECT unnest(ARRAY[ + 'PendingBot', 'BotClaimToken', 'BotUser', 'BotKey', 'WalletBotAccess', + 'Contact', 'AuditLog', 'ProposalTally', + 'WalletSignerNotificationSetting', 'EmailVerificationToken', 'NotificationDelivery' + ]) + LOOP + -- Skip tables that don't exist + IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = tbl) THEN + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + END IF; + END LOOP; +END $$; diff --git a/prisma/migrations/20260721060000_pending_bot_optional_address/migration.sql b/prisma/migrations/20260721060000_pending_bot_optional_address/migration.sql new file mode 100644 index 00000000..d37e1fb4 --- /dev/null +++ b/prisma/migrations/20260721060000_pending_bot_optional_address/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "PendingBot" ALTER COLUMN "paymentAddress" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "PendingBot" ADD COLUMN "botKeyId" TEXT; diff --git a/prisma/migrations/20260812090000_add_oauth_server_models/migration.sql b/prisma/migrations/20260812090000_add_oauth_server_models/migration.sql new file mode 100644 index 00000000..29850782 --- /dev/null +++ b/prisma/migrations/20260812090000_add_oauth_server_models/migration.sql @@ -0,0 +1,134 @@ +-- OAuth 2.1 authorization server tables, backing the MCP endpoint's +-- resource-server role. Codes, refresh tokens and client secrets are stored as +-- SHA-256 hashes only. + +-- CreateTable +CREATE TABLE "OAuthClient" ( + "id" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "isMetadataUrl" BOOLEAN NOT NULL DEFAULT false, + "clientName" TEXT NOT NULL, + "clientUri" TEXT, + "redirectUris" TEXT[], + "tokenEndpointAuthMethod" TEXT NOT NULL DEFAULT 'none', + "secretHash" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OAuthClient_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthAuthorizationCode" ( + "id" TEXT NOT NULL, + "codeHash" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "subjectAddress" TEXT NOT NULL, + "grantedAddresses" TEXT[], + "scopes" TEXT[], + "resource" TEXT NOT NULL, + "redirectUri" TEXT NOT NULL, + "codeChallenge" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OAuthAuthorizationCode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthRefreshToken" ( + "id" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "subjectAddress" TEXT NOT NULL, + "grantedAddresses" TEXT[], + "scopes" TEXT[], + "resource" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "revokedAt" TIMESTAMP(3), + "replacedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OAuthRefreshToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OAuthGrant" ( + "id" TEXT NOT NULL, + "subjectAddress" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "scopes" TEXT[], + "grantedAddresses" TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OAuthGrant_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthClient_clientId_key" ON "OAuthClient"("clientId"); + +-- CreateIndex +CREATE INDEX "OAuthClient_clientId_idx" ON "OAuthClient"("clientId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthAuthorizationCode_codeHash_key" ON "OAuthAuthorizationCode"("codeHash"); + +-- CreateIndex +CREATE INDEX "OAuthAuthorizationCode_clientId_idx" ON "OAuthAuthorizationCode"("clientId"); + +-- CreateIndex +CREATE INDEX "OAuthAuthorizationCode_expiresAt_idx" ON "OAuthAuthorizationCode"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthRefreshToken_tokenHash_key" ON "OAuthRefreshToken"("tokenHash"); + +-- CreateIndex +CREATE INDEX "OAuthRefreshToken_clientId_idx" ON "OAuthRefreshToken"("clientId"); + +-- CreateIndex +CREATE INDEX "OAuthRefreshToken_subjectAddress_idx" ON "OAuthRefreshToken"("subjectAddress"); + +-- CreateIndex +CREATE INDEX "OAuthRefreshToken_expiresAt_idx" ON "OAuthRefreshToken"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "OAuthGrant_subjectAddress_clientId_key" ON "OAuthGrant"("subjectAddress", "clientId"); + +-- CreateIndex +CREATE INDEX "OAuthGrant_subjectAddress_idx" ON "OAuthGrant"("subjectAddress"); + +-- Row Level Security. +-- +-- The 20251215090000 migration enabled RLS over a hardcoded table list, and +-- every table added since was silently left out of it. These four hold +-- authorization codes and client secrets, so they do not repeat that: RLS is +-- enabled here, at creation. Prisma connects as the service role and continues +-- to bypass RLS; this only denies the PostgREST roles. +DO $$ +DECLARE + tbl TEXT; +BEGIN + FOR tbl IN + SELECT unnest(ARRAY[ + 'OAuthClient', 'OAuthAuthorizationCode', 'OAuthRefreshToken', 'OAuthGrant' + ]) + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + END LOOP; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f753e5b2..b04f4f22 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -211,6 +211,67 @@ model Contact { @@index([address]) } +model WalletSignerNotificationSetting { + id String @id @default(cuid()) + walletId String + signerAddress String + email String? + emailNormalized String? + emailVerifiedAt DateTime? + emailOptIn Boolean @default(true) + notifyTransactionSignatures Boolean @default(true) + notifySignableSignatures Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([walletId, signerAddress]) + @@index([walletId]) + @@index([signerAddress]) + @@index([emailNormalized]) +} + +model EmailVerificationToken { + id String @id @default(cuid()) + walletId String + signerAddress String + emailNormalized String + tokenHash String @unique + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([walletId, signerAddress]) + @@index([expiresAt]) +} + +model NotificationDelivery { + id String @id @default(cuid()) + eventType String + channel String + recipientAddress String + recipientEmail String? + resourceType String + resourceId String + walletId String? + idempotencyKey String @unique + subject String + payload Json + status String @default("pending") + provider String? + providerMessageId String? + attempts Int @default(0) + lastError String? + nextAttemptAt DateTime @default(now()) + sentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, nextAttemptAt]) + @@index([recipientAddress]) + @@index([walletId]) + @@index([resourceType, resourceId]) +} + model BotKey { id String @id @default(cuid()) ownerAddress String // Human creator @@ -259,11 +320,14 @@ enum PendingBotStatus { model PendingBot { id String @id @default(cuid()) name String - paymentAddress String + // Optional: new bots normally register before they have a wallet; the + // address is bound to the BotUser at first botAuth instead. + paymentAddress String? stakeAddress String? requestedScopes String // JSON array of requested scopes status PendingBotStatus @default(UNCLAIMED) claimedBy String? // ownerAddress of the claiming human + botKeyId String? // BotKey minted at claim; lets pickup work without an address secretCipher String? // Encrypted secret (set on claim, cleared on pickup) pickedUp Boolean @default(false) expiresAt DateTime @@ -325,3 +389,95 @@ model ProposalTally { @@unique([network, proposalId]) } + +// --------------------------------------------------------------------------- +// OAuth 2.1 authorization server +// +// Backs the MCP endpoint (`/api/mcp`) acting as an OAuth 2.0 Resource Server. +// Secrets are never stored in plaintext: authorization codes, refresh tokens and +// client secrets are all persisted as SHA-256 hashes, mirroring the existing +// BotClaimToken pattern. +// --------------------------------------------------------------------------- + +model OAuthClient { + id String @id @default(cuid()) + + /// Either an opaque DCR-issued id, or — for a Client ID Metadata Document — + /// the https URL the metadata was fetched from. + clientId String @unique + /// True when clientId is a CIMD URL. CIMD clients are portable across + /// authorization servers and are re-validated against their document. + isMetadataUrl Boolean @default(false) + clientName String + clientUri String? + redirectUris String[] + /// "none" for public clients (the MCP norm), "client_secret_basic"/"post" otherwise. + tokenEndpointAuthMethod String @default("none") + /// SHA-256 of the client secret; null for public clients. + secretHash String? + createdAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + + @@index([clientId]) +} + +model OAuthAuthorizationCode { + id String @id @default(cuid()) + + /// SHA-256 of the code. The plaintext is only ever in the redirect URL. + codeHash String @unique + clientId String + /// Cardano address of the human who approved this grant. + subjectAddress String + /// Every wallet address the approval covers. + grantedAddresses String[] + scopes String[] + /// RFC 8707 audience. The issued token is bound to this and nothing else. + resource String + redirectUri String + /// PKCE. S256 only — `plain` is not accepted. + codeChallenge String + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([clientId]) + @@index([expiresAt]) +} + +model OAuthRefreshToken { + id String @id @default(cuid()) + + tokenHash String @unique + clientId String + subjectAddress String + grantedAddresses String[] + scopes String[] + resource String + expiresAt DateTime + revokedAt DateTime? + /// Set when this token has been rotated away. Presenting a token that already + /// has a successor indicates replay, and the whole chain is revoked. + replacedById String? + createdAt DateTime @default(now()) + + @@index([clientId]) + @@index([subjectAddress]) + @@index([expiresAt]) +} + +/// The durable record of "this user approved this client". One row per pair, so +/// re-consenting updates the grant rather than accumulating duplicates. +model OAuthGrant { + id String @id @default(cuid()) + + subjectAddress String + clientId String + scopes String[] + grantedAddresses String[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([subjectAddress, clientId]) + @@index([subjectAddress]) +} diff --git a/scripts/bot-ref/README.md b/scripts/bot-ref/README.md index 71c3f111..56191ce0 100644 --- a/scripts/bot-ref/README.md +++ b/scripts/bot-ref/README.md @@ -49,15 +49,15 @@ npm install ### 1. Register -> claim -> pickup -> auth -1. Bot self-registers and receives a claim code: +1. Bot self-registers and receives a claim code. A new bot registers **without** an address — it has no wallet yet; the address is bound later at its first `botAuth`: ```bash curl -sS -X POST http://localhost:3000/api/v1/botRegister \ -H "Content-Type: application/json" \ - -d '{"name":"Reference Bot","paymentAddress":"addr1_xxx","requestedScopes":["multisig:read","multisig:sign"]}' + -d '{"name":"Reference Bot","requestedScopes":["multisig:read","multisig:sign"]}' ``` -Response includes `pendingBotId` and `claimCode`. +Response includes `pendingBotId` and `claimCode`. (`paymentAddress` may still be included if the bot already controls a wallet.) 2. Human claims the bot in the app by entering `pendingBotId` and `claimCode`. @@ -69,7 +69,7 @@ curl -sS "http://localhost:3000/api/v1/botPickupSecret?pendingBotId= * BOT_CONFIG='{"baseUrl":"http://localhost:3000","botKeyId":"...","secret":"...","paymentAddress":"addr1_..."}' npx tsx bot-client.ts auth * npx tsx bot-client.ts walletIds @@ -51,19 +51,47 @@ function ensureSlash(url: string): string { return url.endsWith("/") ? url.slice(0, -1) : url; } -/** Authenticate with bot key + payment address; returns JWT. */ +/** + * fetch with polite 429 handling: honors Retry-After (falling back to + * exponential backoff starting at 5s, capped at 60s) and retries up to + * maxRetries times. Never tight-retry a 429 — the server's window is fixed + * and hammering it just keeps you blind. + */ +export async function fetchWithBackoff( + url: string, + init?: RequestInit, + maxRetries = 3, +): Promise { + let attempt = 0; + for (;;) { + const res = await fetch(url, init); + if (res.status !== 429 || attempt >= maxRetries) { + return res; + } + const retryAfter = Number(res.headers.get("retry-after")); + const fallback = Math.min(5_000 * 2 ** attempt, 60_000); + const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : fallback; + console.error(`429 rate-limited; waiting ${Math.round(waitMs / 1000)}s before retry ${attempt + 1}/${maxRetries}`); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + attempt++; + } +} + +/** Authenticate with the bot key; returns a ~1h JWT (re-run on 401). + * paymentAddress is only needed on the FIRST auth — it binds the bot's + * address; afterwards the server uses the bound address. */ export async function botAuth(config: BotConfig): Promise<{ token: string; botId: string }> { - if (!config.botKeyId || !config.secret || !config.paymentAddress) { - throw new Error("auth requires botKeyId, secret, and paymentAddress in config"); + if (!config.botKeyId || !config.secret) { + throw new Error("auth requires botKeyId and secret in config"); } const base = ensureSlash(config.baseUrl); - const res = await fetch(`${base}/api/v1/botAuth`, { + const res = await fetchWithBackoff(`${base}/api/v1/botAuth`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ botKeyId: config.botKeyId, secret: config.secret, - paymentAddress: config.paymentAddress, + ...(config.paymentAddress ? { paymentAddress: config.paymentAddress } : {}), }), }); if (!res.ok) { @@ -79,13 +107,15 @@ export async function registerBot( baseUrl: string, body: { name: string; - paymentAddress: string; requestedScopes: string[]; + // Omit for a new bot: it has no wallet yet, and the address is bound at + // the first botAuth. Only pass when the bot already controls a wallet. + paymentAddress?: string; stakeAddress?: string; }, ): Promise<{ pendingBotId: string; claimCode: string; claimExpiresAt: string }> { const base = ensureSlash(baseUrl); - const res = await fetch(`${base}/api/v1/botRegister`, { + const res = await fetchWithBackoff(`${base}/api/v1/botRegister`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -101,22 +131,71 @@ export async function registerBot( export async function pickupBotSecret( baseUrl: string, pendingBotId: string, -): Promise<{ botKeyId: string; secret: string; paymentAddress: string }> { +): Promise<{ botKeyId: string; secret: string; paymentAddress: string | null }> { const base = ensureSlash(baseUrl); - const res = await fetch( + const res = await fetchWithBackoff( `${base}/api/v1/botPickupSecret?pendingBotId=${encodeURIComponent(pendingBotId)}`, ); if (!res.ok) { const text = await res.text(); throw new Error(`botPickupSecret failed ${res.status}: ${text}`); } - return (await res.json()) as { botKeyId: string; secret: string; paymentAddress: string }; + return (await res.json()) as { botKeyId: string; secret: string; paymentAddress: string | null }; +} + +/** List governance ballots the bot can see on a wallet (ballot:write scope; observer grant is enough). */ +export async function getBotBallots( + baseUrl: string, + token: string, + walletId: string, +): Promise<{ ballots: Array<{ id: string; description: string | null; items: string[]; choices: string[]; rationaleComments: string[]; updatedAt: string }> }> { + const base = ensureSlash(baseUrl); + const res = await fetchWithBackoff( + `${base}/api/v1/botBallots?walletId=${encodeURIComponent(walletId)}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!res.ok) throw new Error(`botBallots failed ${res.status}: ${await res.text()}`); + return (await res.json()) as { ballots: Array<{ id: string; description: string | null; items: string[]; choices: string[]; rationaleComments: string[]; updatedAt: string }> }; +} + +/** Delete a governance ballot draft (e.g. stale test drafts). */ +export async function deleteBotBallot( + baseUrl: string, + token: string, + walletId: string, + ballotId: string, +): Promise<{ deleted: boolean; ballotId: string }> { + const base = ensureSlash(baseUrl); + const res = await fetchWithBackoff(`${base}/api/v1/botBallots`, { + method: "DELETE", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ walletId, ballotId }), + }); + if (!res.ok) throw new Error(`botBallots delete failed ${res.status}: ${await res.text()}`); + return (await res.json()) as { deleted: boolean; ballotId: string }; +} + +/** Rotate the bot key secret (invalidates the old one; the new secret is returned once). */ +export async function rotateBotSecret( + config: BotConfig, +): Promise<{ botKeyId: string; secret: string }> { + if (!config.botKeyId || !config.secret) { + throw new Error("rotateSecret requires botKeyId and secret in config"); + } + const base = ensureSlash(config.baseUrl); + const res = await fetchWithBackoff(`${base}/api/v1/botRotateSecret`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ botKeyId: config.botKeyId, secret: config.secret }), + }); + if (!res.ok) throw new Error(`botRotateSecret failed ${res.status}: ${await res.text()}`); + return (await res.json()) as { botKeyId: string; secret: string }; } /** 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)}`, { + const res = await fetchWithBackoff(`${base}/api/v1/walletIds?address=${encodeURIComponent(address)}`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error(`walletIds failed ${res.status}: ${await res.text()}`); @@ -131,7 +210,7 @@ export async function getPendingTransactions( address: string, ): Promise { const base = ensureSlash(baseUrl); - const res = await fetch( + const res = await fetchWithBackoff( `${base}/api/v1/pendingTransactions?walletId=${encodeURIComponent(walletId)}&address=${encodeURIComponent(address)}`, { headers: { Authorization: `Bearer ${token}` } }, ); @@ -147,7 +226,7 @@ export async function getFreeUtxos( address: string, ): Promise { const base = ensureSlash(baseUrl); - const res = await fetch( + const res = await fetchWithBackoff( `${base}/api/v1/freeUtxos?walletId=${encodeURIComponent(walletId)}&address=${encodeURIComponent(address)}`, { headers: { Authorization: `Bearer ${token}` } }, ); @@ -167,7 +246,7 @@ export async function getBotMe( ownerAddress: string; }> { const base = ensureSlash(baseUrl); - const res = await fetch(`${base}/api/v1/botMe`, { + const res = await fetchWithBackoff(`${base}/api/v1/botMe`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error(`botMe failed ${res.status}: ${await res.text()}`); @@ -192,7 +271,7 @@ export async function getOwnerInfo( bot: { botId: string; paymentAddress: string; displayName: string | null; botName: string } | null; }> { const base = ensureSlash(baseUrl); - const res = await fetch( + const res = await fetchWithBackoff( `${base}/api/v1/ownerInfo?walletId=${encodeURIComponent(walletId)}`, { headers: { Authorization: `Bearer ${token}` } }, ); @@ -223,7 +302,7 @@ export async function createWallet( }, ): Promise<{ walletId: string; address: string; name: string }> { const base = ensureSlash(baseUrl); - const res = await fetch(`${base}/api/v1/createWallet`, { + const res = await fetchWithBackoff(`${base}/api/v1/createWallet`, { method: "POST", headers: { "Content-Type": "application/json", @@ -252,7 +331,7 @@ export async function botStakeCertificate( }, ): Promise { const base = ensureSlash(baseUrl); - const res = await fetch(`${base}/api/v1/botStakeCertificate`, { + const res = await fetchWithBackoff(`${base}/api/v1/botStakeCertificate`, { method: "POST", headers: { "Content-Type": "application/json", @@ -281,7 +360,7 @@ export async function botDRepCertificate( }, ): Promise { const base = ensureSlash(baseUrl); - const res = await fetch(`${base}/api/v1/botDRepCertificate`, { + const res = await fetchWithBackoff(`${base}/api/v1/botDRepCertificate`, { method: "POST", headers: { "Content-Type": "application/json", @@ -299,7 +378,7 @@ async function main() { const config = await loadConfig(); const cmd = process.argv[2]; if (!cmd) { - console.error("Usage: bot-client.ts [args]"); + console.error("Usage: bot-client.ts [args]"); console.error(" register [scope1,scope2,...] [paymentAddress] - create pending bot + claim code"); console.error(" pickup - pickup botKeyId + secret after human claim"); console.error(" auth - authenticate and print token"); @@ -318,6 +397,8 @@ async function main() { if (cmd === "register") { const name = process.argv[3]; const scopesArg = process.argv[4] ?? "multisig:read"; + // Optional: a new bot registers without an address (it has no wallet yet) + // and binds one at its first auth. Passing an address is the exception. const paymentAddress = process.argv[5] ?? config.paymentAddress; if (!name) { @@ -325,11 +406,6 @@ async function main() { process.exit(1); } - if (!paymentAddress) { - console.error("paymentAddress is required for register (arg or config)."); - process.exit(1); - } - const requestedScopes = scopesArg .split(",") .map((s) => s.trim()) @@ -342,14 +418,41 @@ async function main() { const result = await registerBot(config.baseUrl, { name, - paymentAddress, requestedScopes, + ...(paymentAddress ? { paymentAddress } : {}), }); + if (!paymentAddress) { + console.error("Registered without an address — bind one at first auth (generate-bot-wallet.ts, then `auth`)."); + } console.log(JSON.stringify(result, null, 2)); console.error("Human must now claim this bot in UI using pendingBotId + claimCode."); return; } + if (cmd === "ballots") { + const walletId = process.argv[3]; + if (!walletId) { console.error("Usage: bot-client.ts ballots "); process.exit(1); } + const token = process.env.BOT_TOKEN ?? (await botAuth(config)).token; + console.log(JSON.stringify(await getBotBallots(config.baseUrl, token, walletId), null, 2)); + return; + } + + if (cmd === "deleteBallot") { + const walletId = process.argv[3]; + const ballotId = process.argv[4]; + if (!walletId || !ballotId) { console.error("Usage: bot-client.ts deleteBallot "); process.exit(1); } + const token = process.env.BOT_TOKEN ?? (await botAuth(config)).token; + console.log(JSON.stringify(await deleteBotBallot(config.baseUrl, token, walletId, ballotId), null, 2)); + return; + } + + if (cmd === "rotateSecret") { + const result = await rotateBotSecret(config); + console.error("Secret rotated. Update bot-config.json NOW — the old secret no longer works."); + console.log(JSON.stringify(result, null, 2)); + return; + } + if (cmd === "pickup") { const pendingBotId = process.argv[3]; if (!pendingBotId) { diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 3e945eaa..b6374fda 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -134,7 +134,9 @@ The manifest currently covers: `scenario.proxy-smoke` runs by default and performs authenticated `proxies` read checks plus negative validation checks that should fail before chain mutation. -`scenario.proxy-full-lifecycle` runs by default in PR smoke for `legacy`, `hierarchical`, and `sdk` wallets when present. The hierarchical coverage reuses the wallet already created for route-chain context and the ring transfer; it does not add a new bootstrap wallet path. It starts each eligible wallet type with three pre-hygiene steps before normal setup: chain recovery reconstructs missing `Proxy` rows from proxy auth tokens still visible at the current CI wallet address, row adoption reattaches valid rows from historical deterministic CI wallets, and hygiene cleans any active rows before the new lifecycle begins. It then runs UTxO shaping and a funding preflight that fetches fresh `freeUtxos`. The hardcoded lifecycle budget is 536 ADA per eligible wallet: 505 ADA DRep registration, 10 ADA initial proxy funding, 1 ADA planned proxy spend, and a 20 ADA fee buffer. Because collateral is reserved outside selected spend inputs, the practical minimum post-shape layout is at least 536 ADA selectable at the multisig wallet address plus a separate ADA-only bot payment-address collateral UTxO. The self-split path needs enough total ADA to leave that 536 ADA selectable budget, create a 6 ADA collateral output, and cover a 2 ADA self-split fee buffer. Adding hierarchical means default PR smoke needs that budget available for one more wallet. Proxy DRep registration uses `CI_DREP_ANCHOR_URL` as the on-chain anchor URL and sends an inline route-chain `anchorJson`; it does not use `CI_DREP_ANCHOR_JSON`. +`scenario.proxy-full-lifecycle` runs by default in PR smoke for `legacy`, `hierarchical`, and `sdk` wallets when present. The hierarchical coverage reuses the wallet already created for route-chain context and the ring transfer; it does not add a new bootstrap wallet path. Before the wallet branches run, route-chain verifies that each eligible branch has a unique `walletId` and `walletAddress`, ensures enough UTxO shape for the full lifecycle, and reserves one distinct ADA-only signer-0 collateral UTxO per branch at `bot.paymentAddress`. The branch lifecycles then run in parallel by default while each wallet type keeps its own steps sequential. Set `CI_PROXY_FULL_LIFECYCLE_PARALLEL=false` to run the same reservation-aware wallet chains serially. + +Each eligible wallet type starts with three pre-hygiene steps before normal setup: chain recovery reconstructs missing `Proxy` rows from proxy auth tokens still visible at the current CI wallet address, row adoption reattaches valid rows from historical deterministic CI wallets, and hygiene cleans any active rows before the new lifecycle begins. It then runs UTxO shaping and a funding preflight that fetches fresh `freeUtxos`. The hardcoded lifecycle budget is 536 ADA per eligible wallet: 505 ADA DRep registration, 10 ADA initial proxy funding, 1 ADA planned proxy spend, and a 20 ADA fee buffer. Because collateral is reserved outside selected spend inputs, the practical minimum post-shape layout is at least 536 ADA selectable at the multisig wallet address plus a separate ADA-only bot payment-address collateral UTxO reserved for that wallet branch. The self-split path needs enough total ADA to leave that 536 ADA selectable budget, create a 6 ADA collateral output, and cover a 2 ADA self-split fee buffer. Adding hierarchical means default PR smoke needs that budget available for one more wallet. Proxy DRep registration uses `CI_DREP_ANCHOR_URL` as the on-chain anchor URL and sends an inline route-chain `anchorJson`; it does not use `CI_DREP_ANCHOR_JSON`. The first full-lifecycle steps for each eligible wallet type are ordered as: @@ -145,13 +147,13 @@ The first full-lifecycle steps for each eligible wallet type are ordered as: 5. `v1.proxy.full.preflight.` 6. `v1.proxy.client-build-smoke.` (non-critical) -Step 6, `v1.proxy.client-build-smoke.`, is a non-submitting step that calls `buildProxySetupTx` from `src/lib/proxy/txBuilders.ts` directly with a real `MeshTxBuilder` and Blockfrost-resolved UTxOs. It fetches UTxOs at the wallet script address and the bot payment address from Blockfrost, selects a param UTxO (≥ 20 ADA) and an ADA-only collateral, builds the setup transaction, and calls `txBuilder.complete()` to run the Aiken evaluator against preprod. It asserts that the result is a non-empty CBOR hex string and discards it without calling `addTransaction` or any signing step. This catches blueprint schema regressions (`plutus.json` validator index changes), `applyParamsToScript`/`resolveScriptHash` failures, Aiken evaluator errors, and `MeshTxBuilder` API changes that would not be caught by the mock-builder unit tests in `src/__tests__/proxyTxBuilders.test.ts`. Marked non-critical so a slow Blockfrost evaluator response does not block the lifecycle steps that follow. +Step 6, `v1.proxy.client-build-smoke.`, is a non-submitting step that calls `buildProxySetupTx` from `src/lib/proxy/txBuilders.ts` directly with a real `MeshTxBuilder` and Blockfrost-resolved UTxOs. It fetches UTxOs at the wallet script address and the bot payment address from Blockfrost, selects a param UTxO (≥ 20 ADA) and the branch's reserved ADA-only collateral, builds the setup transaction, and calls `txBuilder.complete()` to run the Aiken evaluator against preprod. It asserts that the result is a non-empty CBOR hex string and discards it without calling `addTransaction` or any signing step. This catches blueprint schema regressions (`plutus.json` validator index changes), `applyParamsToScript`/`resolveScriptHash` failures, Aiken evaluator errors, and `MeshTxBuilder` API changes that would not be caught by the mock-builder unit tests in `src/__tests__/proxyTxBuilders.test.ts`. Marked non-critical so a slow Blockfrost evaluator response does not block the lifecycle steps that follow. Chain recovery is CI-only and evidence-based. It scans non-lovelace assets at the current bootstrap `walletAddress` (up to 25 asset candidates; excess are skipped), asks Blockfrost for each asset's mint transaction, tests the mint transaction inputs as candidate `paramUtxo` values with `deriveProxyScripts`, and only creates or reactivates a `Proxy` row when the derived `authTokenId` exactly matches the observed asset unit. This handles clean-database rebuilds where old proxy auth tokens and proxy DReps remain on-chain but the app has no `Proxy` rows. It cannot recover a proxy if the auth token is no longer discoverable at the current CI wallet address. -When preflight passes, each eligible wallet lifecycle creates its own proxy, finalizes the confirmed setup, exercises proxy spend, proxy DRep register/deregister, optional proxy voting when active governance proposals exist, then runs safe cleanup and asserts the proxy no longer appears in `GET /api/v1/proxies`. Proxy actions always use bot payment-address collateral that is distinct from selected wallet spend inputs; DRep registration selects an auth-token input plus additional wallet inputs when needed to meet the registration budget. The proposer/collateral owner is signer index 0 (`CI_MNEMONIC_1`), and signer index 1 (`CI_MNEMONIC_2`) broadcasts for the default threshold-2 proxy actions. After each broadcasted proxy action, the route-chain waits for the selected wallet inputs to disappear from fresh `freeUtxos` before proposing the next action. Cleanup may require two submitted transactions: a sweep transaction that empties the proxy address while preserving an auth token, followed by a burn transaction and cleanup finalization. If the initial cleanup call already returns a burn transaction, the optional burn proposal is skipped after that transaction is signed. Because this scenario runs on every PR, the default CI legacy, hierarchical, and SDK wallets must stay funded; one-UTxO shape problems are repaired by the self-split step, while true budget failures still fail the route-chain rather than skipping proxy lifecycle coverage. +When preflight passes, each eligible wallet lifecycle creates its own proxy, finalizes the confirmed setup, exercises proxy spend, proxy DRep register/deregister, optional proxy voting when active governance proposals exist, then runs safe cleanup and asserts the proxy no longer appears in `GET /api/v1/proxies`. Proxy actions always use the branch's reserved bot payment-address collateral, which is distinct from both selected wallet spend inputs and other parallel branches' collateral. DRep registration selects an auth-token input plus additional wallet inputs when needed to meet the registration budget. The proposer/collateral owner is signer index 0 (`CI_MNEMONIC_1`), and signer index 1 (`CI_MNEMONIC_2`) broadcasts for the default threshold-2 proxy actions. After each broadcasted proxy action, the route-chain waits for the selected wallet inputs to disappear from fresh `freeUtxos` before proposing the next action. Cleanup may require two submitted transactions: a sweep transaction that empties the proxy address while preserving an auth token, followed by a burn transaction and cleanup finalization. If the initial cleanup call already returns a burn transaction, the optional burn proposal is skipped after that transaction is signed. Because this scenario runs on every PR, the default CI legacy, hierarchical, and SDK wallets must stay funded; one-UTxO shape problems are repaired by the self-split step, while true budget failures still fail the route-chain rather than skipping proxy lifecycle coverage. -Runtime expectation: `scenario.proxy-smoke` is the quick, non-mutating proxy subset. `scenario.proxy-full-lifecycle` is a real-chain scenario with multiple broadcasts per eligible wallet and can dominate default PR smoke duration during slow preprod/Blockfrost periods. The GitHub Actions job timeout is intentionally higher than the nominal happy path to leave room for confirmation polling. +Runtime expectation: `scenario.proxy-smoke` is the quick, non-mutating proxy subset. `scenario.proxy-full-lifecycle` is a real-chain scenario with multiple broadcasts per eligible wallet and can dominate default PR smoke duration during slow preprod/Blockfrost periods. The expensive per-wallet lifecycles run in parallel by default after the isolation step, but the GitHub Actions job timeout is still intentionally higher than the nominal happy path to leave room for confirmation polling. For each tested wallet type, the `nativeScript` step stores decoded script payloads in step artifacts (`artifacts.nativeScripts`) and the list of script entry types (`artifacts.scriptTypes`) inside `ci-route-chain-report.md`, so script structure is visible during CI triage. @@ -195,7 +197,7 @@ Runs after the early discovery and ADA route-health checks, before request-heavy Runs when both `legacy` and `sdk` wallets are in context. Requires `CI_DREP_ANCHOR_URL`. -For each wallet type the scenario runs a pre-hygiene step followed by two sequential phases — register then retire — leaving the wallet in its pre-test DRep state: +For each wallet type the scenario runs a pre-hygiene step followed by two sequential phases — register then retire — leaving the wallet in its pre-test DRep state. The legacy and SDK wallet branches run in parallel because they spend from distinct multisig wallets, while each branch keeps its register-before-retire ordering: **Pre-hygiene step** — checks on-chain DRep state via `GET /api/v1/drepInfo`. If the DRep is already registered (e.g. from a previous incomplete run), it proposes a `retire` tx, signs with both signers, and waits for on-chain confirmation. If the broadcast is rejected with `DRepNotRegistered` or similar errors, the credential is treated as already clean (stale Blockfrost cache false-positive) and the step succeeds silently. @@ -206,7 +208,7 @@ For each wallet type the scenario runs a pre-hygiene step followed by two sequen 3. Signer 1 (`CI_MNEMONIC_2`, index 1) adds a payment-key witness, no broadcast. 4. Signer 2 (`CI_MNEMONIC_3`, index 2) adds a payment-key witness and broadcasts. 5. Assert the transaction is cleared from pending. -6. Poll `freeUtxos?fresh=true` until the spent inputs are no longer unspent on-chain (confirms block inclusion before the next phase). Up to 30 retries × 8 s = 4 minutes. +6. Poll `freeUtxos?fresh=true` until the spent inputs are no longer unspent on-chain (confirms block inclusion before the next phase). Up to 48 retries × 5 s = 4 minutes. 7. Repeat steps 1–6 with `action: "retire"`. **Why payment-key witnesses are sufficient for DRep cert:** @@ -247,11 +249,15 @@ Primary variables (in workflow/compose): - `CI_SIGN_WALLET_TYPE` (default `legacy`): which wallet type is used when `runSigningFlow` resolves a wallet for signing in ring-transfer steps. Overridden per leg in transfer scenarios. - `SIGN_BROADCAST` - `CI_ROUTE_SCENARIOS` (optional scenario id filter) -- `CI_TRANSFER_LOVELACE` (optional transfer amount) +- `CI_TRANSFER_LOVELACE` (optional transfer amount, default `2000000`) +- `CI_PROXY_FULL_LIFECYCLE_PARALLEL` (default `true`): set to `false` to run the per-wallet proxy full-lifecycle branches serially instead of in parallel. - `CI_DREP_ANCHOR_URL` (required by the default run for `scenario.drep-certificates` and `scenario.proxy-full-lifecycle`): the URL string stored in the on-chain anchor — passed as-is to the API, never fetched. - `CI_DREP_ANCHOR_JSON` (required by the default run for `scenario.drep-certificates`): the raw JSON content of the CIP-119 DRep metadata document. Parsed and sent as `anchorJson`; the API computes the anchor data hash server-side — no outbound fetch anywhere. Both vars are forwarded into the `ci-runner` container via `docker-compose.ci.yml`. - `CI_STAKE_POOL_ID_HEX` (**required** for `scenario.stake-certificates`): hex stake pool id stored in bootstrap context and used as `poolId` in the `register_and_delegate` certificate body. - `CI_HTTP_RETRIES` (default `6`), `CI_HTTP_RETRY_DELAY_MS` (default `1000`), `CI_HTTP_MAX_RETRY_DELAY_MS` (default `30000`): route-chain API retry controls for transient responses (`408`, `418`, `429`, `500`, `502`, `503`, `504`). Exponential backoff with `Retry-After` header support. Defaults are long enough to ride out the app's 60-second in-process rate-limit window without changing app behavior. +- `CI_RUN_WALLET_STATUS` (default `false`): when running the composed `ci-runner` command, set to `true` to print the optional pre-route wallet balance check. The route-chain report always collects end-of-run wallet balances, so the default CI path skips this extra Blockfrost lookup. + +Compose forwarding note: `docker-compose.ci.yml` only forwards the variables declared in the `ci-runner` service's `environment` block from the host shell. `CI_TRANSFER_LOVELACE`, `CI_HTTP_RETRIES`, `CI_HTTP_RETRY_DELAY_MS`, `CI_HTTP_MAX_RETRY_DELAY_MS`, and `CI_PROXY_FULL_LIFECYCLE_PARALLEL` are **not** in that block — setting them in your shell has no effect on a compose run. They fall back to their in-code defaults unless you pass them explicitly on the `run` command, e.g. `docker compose -f docker-compose.ci.yml run --rm -e CI_TRANSFER_LOVELACE=3000000 ci-runner ...`. Validation notes: @@ -322,12 +328,12 @@ Balance source: direct on-chain UTxO lookup per wallet address from bootstrap co ## Proxy Full Lifecycle UTxO Shaping -`scenario.proxy-full-lifecycle` needs a wallet script UTxO for proxy setup/spend and a separate key-address collateral UTxO at `bot.paymentAddress` for each eligible wallet type (`legacy`, `hierarchical`, `sdk`). When a funded wallet has enough ADA but lacks the required wallet/key UTxO shape, the route-chain now performs an idempotent self-split before the proxy preflight: +`scenario.proxy-full-lifecycle` needs a wallet script UTxO for proxy setup/spend and a separate key-address collateral UTxO at `bot.paymentAddress` for each eligible wallet type (`legacy`, `hierarchical`, `sdk`). Parallel mode requires one distinct signer-0 collateral UTxO per eligible wallet branch; the same signer address may be shared, but the same collateral ref is never intentionally shared. When a funded wallet has enough ADA but lacks the required wallet/key UTxO shape, the route-chain now performs an idempotent self-split before the proxy preflight: -- If fresh `freeUtxos` plus fresh `bot.paymentAddress` UTxOs already satisfy the lifecycle budget and key collateral shape, the shaping step is a no-op. +- If fresh `freeUtxos` plus fresh `bot.paymentAddress` UTxOs already satisfy the lifecycle budget and distinct key collateral shape, the shaping step is a no-op. - If wallet ADA is sufficient but the shape is not, the step submits a real preprod self-split through `/api/v1/addTransaction`, creating a 6 ADA collateral output at `bot.paymentAddress` and returning the rest as change to the wallet script address. The split requires the 536 ADA lifecycle budget plus the 6 ADA collateral output and a 2 ADA self-split fee buffer. - The self-split is signed by signer 1 and signer 2 using the existing `CI_MNEMONIC_2` / `CI_MNEMONIC_3` route-chain signing path, then waits for the original inputs to disappear from fresh `freeUtxos`. -- Server-built proxy transactions are persisted with no initial signed addresses. Because key-address collateral lives at `bot.paymentAddress`, proxy setup and action transactions first add signer index 0 (`CI_MNEMONIC_1`) as a real collateral witness, then signer index 1 (`CI_MNEMONIC_2`) broadcasts for the default threshold-2 wallet. +- Server-built proxy transactions are persisted with no initial signed addresses. Because key-address collateral lives at `bot.paymentAddress`, proxy setup and action transactions first add signer index 0 (`CI_MNEMONIC_1`) as a real collateral witness, then signer index 1 (`CI_MNEMONIC_2`) broadcasts for the default threshold-2 wallet. If a branch's reserved collateral ref is no longer available, the branch fails instead of selecting another branch's collateral. - Manual funding is still required when the wallet does not have enough total ADA for the proxy lifecycle budget plus the 6 ADA collateral output and fee buffer. Because the self-split is an on-chain transaction, it can add one confirmation wait per wallet type, but only when the current UTxO shape needs repair. @@ -376,7 +382,6 @@ $env:CI_MNEMONIC_3="..." $env:CI_BLOCKFROST_PREPROD_API_KEY="..." $env:CI_NETWORK_ID="0" $env:CI_WALLET_TYPES="legacy,hierarchical,sdk" -$env:CI_TRANSFER_LOVELACE="2000000" $env:SIGN_BROADCAST="true" $env:CI_DREP_ANCHOR_URL="https://..." # required for the default full flow; stored as on-chain anchor URL, never fetched $env:CI_STAKE_POOL_ID_HEX="..." # required for the default full flow (scenario.stake-certificates) @@ -417,20 +422,28 @@ docker compose -f docker-compose.ci.yml build app ci-runner docker compose -f docker-compose.ci.yml up -d postgres app ``` +One-shot CI-identical run (optional): this single command mirrors the GitHub workflow — bootstrap, optional wallet-status (`CI_RUN_WALLET_STATUS=true`), route-chain, and context-file cleanup in one container run. The context stays inside the container at `/tmp/ci-wallet-context.json` and is deleted at the end; the report is written to `.\ci-artifacts\ci-route-chain-report.md`. + +```powershell +docker compose -f docker-compose.ci.yml --profile ci-test run --rm ci-runner +``` + +The steps below run the same stages individually with a host-mounted context file, which is easier to debug and lets you rerun route-chain without re-bootstrapping. + Bootstrap wallets and write host-mounted artifacts: ```powershell docker compose -f docker-compose.ci.yml run --rm ` -e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json ` - ci-runner npx --yes tsx scripts/ci/cli/bootstrap.ts + ci-runner node .ci-dist/bootstrap.mjs ``` -Optional: confirm wallets are funded on-chain before running route-chain (uses `CI_CONTEXT_PATH` and `CI_BLOCKFROST_PREPROD_API_KEY`; same total-balance semantics as `walletBalanceSummary` in the route-chain report). Flags: `--json` (machine-readable summary only), `--strict` (exit with status 1 if balance collection fails). +Optional: confirm wallets are funded on-chain before running route-chain (uses `CI_CONTEXT_PATH` and `CI_BLOCKFROST_PREPROD_API_KEY`; same total-balance semantics as `walletBalanceSummary` in the route-chain report). Flags: `--json` (machine-readable summary only), `--strict` (exit with status 1 if balance collection fails). The composed `ci-runner` skips this by default; set `CI_RUN_WALLET_STATUS=true` if you want it included there. ```powershell docker compose -f docker-compose.ci.yml run --rm ` -e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json ` - ci-runner npx --yes tsx scripts/ci/cli/wallet-status.ts + ci-runner node .ci-dist/wallet-status.mjs ``` Run route-chain smoke scenarios: @@ -439,7 +452,7 @@ Run route-chain smoke scenarios: docker compose -f docker-compose.ci.yml run --rm ` -e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json ` -e CI_ROUTE_CHAIN_REPORT_PATH=/artifacts/ci-route-chain-report.md ` - ci-runner npx --yes tsx scripts/ci/cli/route-chain.ts + ci-runner node .ci-dist/route-chain.mjs ``` @@ -449,6 +462,12 @@ View generated report on host: Get-Content ".\ci-artifacts\ci-route-chain-report.md" ``` +When you are done, remove the CI containers and volume: + +```powershell +docker compose -f docker-compose.ci.yml down -v --remove-orphans +``` + ## Local execution (Linux/Bash, CI-like) From repo root: @@ -465,7 +484,6 @@ export CI_MNEMONIC_3="..." export CI_BLOCKFROST_PREPROD_API_KEY="..." export CI_NETWORK_ID="0" export CI_WALLET_TYPES="legacy,hierarchical,sdk" -export CI_TRANSFER_LOVELACE="2000000" export SIGN_BROADCAST="true" export CI_DREP_ANCHOR_URL="https://..." # required for the default full flow; stored as on-chain anchor URL, never fetched export CI_STAKE_POOL_ID_HEX="..." # required for the default full flow (scenario.stake-certificates) @@ -507,20 +525,28 @@ docker compose -f docker-compose.ci.yml build app ci-runner docker compose -f docker-compose.ci.yml up -d postgres app ``` +One-shot CI-identical run (optional): this single command mirrors the GitHub workflow — bootstrap, optional wallet-status (`CI_RUN_WALLET_STATUS=true`), route-chain, and context-file cleanup in one container run. The context stays inside the container at `/tmp/ci-wallet-context.json` and is deleted at the end; the report is written to `./ci-artifacts/ci-route-chain-report.md`. + +```bash +docker compose -f docker-compose.ci.yml --profile ci-test run --rm ci-runner +``` + +The steps below run the same stages individually with a host-mounted context file, which is easier to debug and lets you rerun route-chain without re-bootstrapping. + Bootstrap wallets and write host-mounted artifacts: ```bash docker compose -f docker-compose.ci.yml run --rm \ -e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json \ - ci-runner npx --yes tsx scripts/ci/cli/bootstrap.ts + ci-runner node .ci-dist/bootstrap.mjs ``` -Optional: confirm wallets are funded on-chain before running route-chain (uses `CI_CONTEXT_PATH` and `CI_BLOCKFROST_PREPROD_API_KEY`; same total-balance semantics as `walletBalanceSummary` in the route-chain report). Flags: `--json` (machine-readable summary only), `--strict` (exit with status 1 if balance collection fails). +Optional: confirm wallets are funded on-chain before running route-chain (uses `CI_CONTEXT_PATH` and `CI_BLOCKFROST_PREPROD_API_KEY`; same total-balance semantics as `walletBalanceSummary` in the route-chain report). Flags: `--json` (machine-readable summary only), `--strict` (exit with status 1 if balance collection fails). The composed `ci-runner` skips this by default; set `CI_RUN_WALLET_STATUS=true` if you want it included there. ```bash docker compose -f docker-compose.ci.yml run --rm \ -e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json \ - ci-runner npx --yes tsx scripts/ci/cli/wallet-status.ts + ci-runner node .ci-dist/wallet-status.mjs ``` Run route-chain smoke scenarios: @@ -529,7 +555,7 @@ Run route-chain smoke scenarios: docker compose -f docker-compose.ci.yml run --rm \ -e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json \ -e CI_ROUTE_CHAIN_REPORT_PATH=/artifacts/ci-route-chain-report.md \ - ci-runner npx --yes tsx scripts/ci/cli/route-chain.ts + ci-runner node .ci-dist/route-chain.mjs ``` View generated report on host: @@ -537,3 +563,9 @@ View generated report on host: ```bash cat ./ci-artifacts/ci-route-chain-report.md ``` + +When you are done, remove the CI containers and volume: + +```bash +docker compose -f docker-compose.ci.yml down -v --remove-orphans +``` diff --git a/scripts/ci/cli/bootstrap.ts b/scripts/ci/cli/bootstrap.ts index 1aecc1d7..bd2685cb 100644 --- a/scripts/ci/cli/bootstrap.ts +++ b/scripts/ci/cli/bootstrap.ts @@ -257,6 +257,36 @@ async function main() { }); } + // Hierarchical (nested all/atLeast) wallets are the app's "Summon" type: + // buildWallet resolves their address from rawImportBodies.multisig rather than + // rebuilding a flat script from the signer keys. The createWallet bot API stores + // the correct nested scriptCbor but not rawImportBodies, so the client would + // otherwise classify the wallet as legacy and derive the wrong (flat) address. + // Populate rawImportBodies here so the existing Summon path handles it. + if (walletType === "hierarchical") { + const created = await prisma.wallet.findUnique({ + where: { id: createWalletBody.walletId as string }, + select: { scriptCbor: true }, + }); + if (!created?.scriptCbor) { + throw new Error( + `createWallet (hierarchical) did not persist a scriptCbor for wallet ${createWalletBody.walletId}`, + ); + } + await prisma.wallet.update({ + where: { id: createWalletBody.walletId as string }, + data: { + rawImportBodies: { + multisig: { + address: createWalletBody.address as string, + payment_script: created.scriptCbor, + stake_script: null, + }, + }, + }, + }); + } + for (const bot of signerBots.slice(1)) { await prisma.walletBotAccess.upsert({ where: { diff --git a/scripts/ci/framework/http.ts b/scripts/ci/framework/http.ts index 96524914..71dd17a0 100644 --- a/scripts/ci/framework/http.ts +++ b/scripts/ci/framework/http.ts @@ -69,7 +69,7 @@ export async function requestJson(args: { retries?: number; retryDelayMs?: number; maxRetryDelayMs?: number; - retryStatuses?: number[]; + retryStatuses?: readonly number[]; }): Promise<{ status: number; data: T }> { const { url, @@ -109,7 +109,13 @@ export async function requestJson(args: { signal: controller.signal, }); - const data = (await response.json()) as T; + const text = await response.text(); + let data: T; + try { + data = (text ? JSON.parse(text) : null) as T; + } catch { + data = text as T; + } clearTimeout(timer); if (attempt <= retries && retryableStatuses.has(response.status)) { await sleep( diff --git a/scripts/ci/framework/runner.ts b/scripts/ci/framework/runner.ts index 7e665c0a..4daff7ad 100644 --- a/scripts/ci/framework/runner.ts +++ b/scripts/ci/framework/runner.ts @@ -7,6 +7,70 @@ function now(): number { return Date.now(); } +async function runStep(args: { + step: Scenario["steps"][number]; + ctx: CIBootstrapContext; +}): Promise { + const stepStart = now(); + const severity = args.step.severity ?? "critical"; + try { + const result = await args.step.execute(args.ctx); + return { + id: args.step.id, + description: args.step.description, + status: "passed", + severity, + message: result.message, + artifacts: result.artifacts, + durationMs: now() - stepStart, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + id: args.step.id, + description: args.step.description, + status: "failed", + severity, + message: "Step failed", + durationMs: now() - stepStart, + error: errorMessage, + }; + } +} + +async function runSerialSteps(args: { + steps: Scenario["steps"]; + ctx: CIBootstrapContext; + continueOnNonCriticalFailure: boolean; +}): Promise<{ reports: StepReport[]; failed: boolean; criticalFailed: boolean }> { + const reports: StepReport[] = []; + let failed = false; + let criticalFailed = false; + + for (const step of args.steps) { + const report = await runStep({ step, ctx: args.ctx }); + reports.push(report); + + if (report.status !== "failed") { + continue; + } + + const isCritical = report.severity === "critical"; + if (isCritical || !args.continueOnNonCriticalFailure) { + failed = true; + } + if (isCritical) { + criticalFailed = true; + break; + } + if (!args.continueOnNonCriticalFailure) { + break; + } + } + + return { reports, failed, criticalFailed }; +} + export async function runScenarios(args: { scenarios: Scenario[]; ctx: CIBootstrapContext; @@ -22,38 +86,45 @@ export async function runScenarios(args: { const steps: StepReport[] = []; let scenarioFailed = false; - for (const step of scenario.steps) { - const stepStart = now(); - const severity = step.severity ?? "critical"; - try { - const result = await step.execute(ctx); - steps.push({ - id: step.id, - description: step.description, - status: "passed", - severity, - message: result.message, - artifacts: result.artifacts, - durationMs: now() - stepStart, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - steps.push({ - id: step.id, - description: step.description, - status: "failed", - severity, - message: "Step failed", - durationMs: now() - stepStart, - error: errorMessage, - }); - scenarioFailed = true; - overallFailed = true; - if (severity === "critical") { - break; - } - if (!continueOnNonCriticalFailure) { - break; + const serial = await runSerialSteps({ + steps: scenario.steps, + ctx, + continueOnNonCriticalFailure, + }); + steps.push(...serial.reports); + scenarioFailed = serial.failed; + if (serial.failed) { + overallFailed = true; + } + + if (!serial.criticalFailed && scenario.parallelBranches?.length) { + const branchResults = await Promise.all( + scenario.parallelBranches.map(async (branch) => { + const result = await runSerialSteps({ + steps: branch.steps, + ctx, + continueOnNonCriticalFailure, + }); + return { + branch, + ...result, + }; + }), + ); + for (const branchResult of branchResults) { + steps.push( + ...branchResult.reports.map((report) => ({ + ...report, + artifacts: { + branchId: branchResult.branch.id, + branchDescription: branchResult.branch.description, + ...(report.artifacts ?? {}), + }, + })), + ); + if (branchResult.failed) { + scenarioFailed = true; + overallFailed = true; } } } diff --git a/scripts/ci/framework/types.ts b/scripts/ci/framework/types.ts index 909ee4f7..72a65753 100644 --- a/scripts/ci/framework/types.ts +++ b/scripts/ci/framework/types.ts @@ -54,6 +54,11 @@ export type Scenario = { id: string; description: string; steps: RouteStep[]; + parallelBranches?: Array<{ + id: string; + description: string; + steps: RouteStep[]; + }>; }; export type StepReport = { diff --git a/scripts/ci/scenarios/flows/signingFlow.ts b/scripts/ci/scenarios/flows/signingFlow.ts index f52e5630..16c47ad0 100644 --- a/scripts/ci/scenarios/flows/signingFlow.ts +++ b/scripts/ci/scenarios/flows/signingFlow.ts @@ -9,9 +9,11 @@ import { normalizeWalletTypeFromLabel } from "../../framework/walletType"; export type PendingTransactionForSigning = { id: string; txCbor?: string }; // signTransaction mutates the pending tx before broadcast. Retrying a 502 can -// turn the useful submission error into a duplicate-signature 409. +// turn the useful submission error into a duplicate-signature 409. A 429 is +// produced by request guards before mutation, so the CI flow can safely wait +// through the app's rate-limit window. export const SIGN_TRANSACTION_REQUEST_OPTIONS = { - retries: 0, + retryStatuses: [429], } as const; export function selectPendingTransactionForSigning( diff --git a/scripts/ci/scenarios/flows/utxoShapeFlow.ts b/scripts/ci/scenarios/flows/utxoShapeFlow.ts index 63275a75..45cc7760 100644 --- a/scripts/ci/scenarios/flows/utxoShapeFlow.ts +++ b/scripts/ci/scenarios/flows/utxoShapeFlow.ts @@ -94,8 +94,8 @@ async function pollUntilUtxosConsumed(args: { maxRetries?: number; retryDelayMs?: number; }): Promise<{ attempts: number }> { - const maxRetries = args.maxRetries ?? 30; - const retryDelayMs = args.retryDelayMs ?? 8000; + const maxRetries = args.maxRetries ?? 48; + const retryDelayMs = args.retryDelayMs ?? 5000; const spent = new Set(args.spentUtxoRefs.map(key)); for (let attempt = 0; attempt < maxRetries; attempt++) { if (attempt > 0) { @@ -177,6 +177,7 @@ async function buildSelfSplitTransaction(args: { export async function ensureProxyLifecycleUtxoShape(args: { ctx: CIBootstrapContext; walletType: CIWalletType; + minKeyCollateralCandidates?: number; }): Promise { const wallet = getWalletByType(args.ctx, args.walletType); if (!wallet) throw new Error(`Missing ${args.walletType} wallet`); @@ -198,6 +199,7 @@ export async function ensureProxyLifecycleUtxoShape(args: { const analysis = analyzeProxyFullLifecycleUtxoShape({ walletUtxos: utxos, collateralUtxos, + minKeyCollateralCandidates: args.minKeyCollateralCandidates, }); if (analysis.status === "pass") { return { @@ -216,7 +218,11 @@ export async function ensureProxyLifecycleUtxoShape(args: { `Proxy lifecycle self-split cannot leave ${formatAda(PROXY_LIFECYCLE_COLLATERAL_SPLIT_LOVELACE)} collateral plus enough selectable ADA. ${analysis.diagnostics}. Add at least ${formatAda(analysis.selfSplitRequiredLovelace - analysis.totalLovelace)} plus any desired safety margin before running proxy full lifecycle.`, ); } - assertProxyFullLifecyclePreflight({ walletUtxos: utxos, collateralUtxos }); + assertProxyFullLifecyclePreflight({ + walletUtxos: utxos, + collateralUtxos, + minKeyCollateralCandidates: args.minKeyCollateralCandidates, + }); } requireProxyShapeEnvironment(args.ctx, wallet.walletAddress, bot.paymentAddress); @@ -292,6 +298,7 @@ export async function ensureProxyLifecycleUtxoShape(args: { const shaped = assertProxyFullLifecyclePreflight({ walletUtxos: shapedUtxos, collateralUtxos: shapedCollateralUtxos, + minKeyCollateralCandidates: args.minKeyCollateralCandidates, }); return { diff --git a/scripts/ci/scenarios/proxyCollateralReservations.ts b/scripts/ci/scenarios/proxyCollateralReservations.ts new file mode 100644 index 00000000..80eff140 --- /dev/null +++ b/scripts/ci/scenarios/proxyCollateralReservations.ts @@ -0,0 +1,90 @@ +import type { CIWalletType } from "../framework/types"; +import { + COLLATERAL_REQUIRED_LOVELACE, + key, + parseLovelace, + type ScriptUtxo, + type UtxoRef, + toRef, +} from "./proxyLifecyclePreflight"; + +export type ProxyCollateralReservation = { + walletType: CIWalletType; + reservationKey: string; + collateralRef: UtxoRef; +}; + +export function isProxyLifecycleCollateralCandidate(utxo: ScriptUtxo): boolean { + return ( + parseLovelace(utxo) >= COLLATERAL_REQUIRED_LOVELACE && + utxo.output.amount.every((asset) => asset.unit === "lovelace") + ); +} + +export function sortCollateralCandidates(utxos: ScriptUtxo[]): ScriptUtxo[] { + return [...utxos].filter(isProxyLifecycleCollateralCandidate).sort((left, right) => { + const leftLovelace = parseLovelace(left); + const rightLovelace = parseLovelace(right); + if (leftLovelace < rightLovelace) return -1; + if (leftLovelace > rightLovelace) return 1; + if (left.input.txHash !== right.input.txHash) { + return left.input.txHash.localeCompare(right.input.txHash); + } + return left.input.outputIndex - right.input.outputIndex; + }); +} + +export function createProxyLifecycleReservationKey(walletType: CIWalletType): string { + return `scenario.proxy-full-lifecycle:${walletType}`; +} + +export function reserveProxyLifecycleCollateral(args: { + walletTypes: CIWalletType[]; + collateralUtxos: ScriptUtxo[]; +}): Map { + const candidates = sortCollateralCandidates(args.collateralUtxos); + if (candidates.length < args.walletTypes.length) { + throw new Error( + `Proxy full lifecycle parallel isolation requires ${args.walletTypes.length} distinct ADA-only signer-0 collateral UTxO(s), but only found ${candidates.length}`, + ); + } + + const reservations = new Map(); + const used = new Set(); + for (let i = 0; i < args.walletTypes.length; i += 1) { + const walletType = args.walletTypes[i]!; + const candidate = candidates.find((utxo) => !used.has(key(toRef(utxo)))); + if (!candidate) { + throw new Error(`No unreserved signer-0 collateral UTxO remains for ${walletType}`); + } + const collateralRef = toRef(candidate); + used.add(key(collateralRef)); + reservations.set(walletType, { + walletType, + reservationKey: createProxyLifecycleReservationKey(walletType), + collateralRef, + }); + } + + return reservations; +} + +export function requireReservedCollateralUtxo(args: { + collateralUtxos: ScriptUtxo[]; + reservedCollateralRef: UtxoRef; + context: string; +}): ScriptUtxo { + const reservedKey = key(args.reservedCollateralRef); + const collateral = args.collateralUtxos.find((utxo) => key(toRef(utxo)) === reservedKey); + if (!collateral) { + throw new Error( + `${args.context} reserved signer-0 collateral UTxO ${reservedKey} is not currently available`, + ); + } + if (!isProxyLifecycleCollateralCandidate(collateral)) { + throw new Error( + `${args.context} reserved signer-0 collateral UTxO ${reservedKey} is no longer an ADA-only collateral candidate`, + ); + } + return collateral; +} diff --git a/scripts/ci/scenarios/proxyLifecyclePreflight.ts b/scripts/ci/scenarios/proxyLifecyclePreflight.ts index c996c337..e2f18df8 100644 --- a/scripts/ci/scenarios/proxyLifecyclePreflight.ts +++ b/scripts/ci/scenarios/proxyLifecyclePreflight.ts @@ -76,12 +76,15 @@ export type ProxyLifecycleUtxoShapeAnalysis = { export type ProxyLifecycleUtxoShapeInput = { walletUtxos: ScriptUtxo[]; collateralUtxos: ScriptUtxo[]; + minKeyCollateralCandidates?: number; }; export function analyzeProxyFullLifecycleUtxoShape(args: { walletUtxos: ScriptUtxo[]; collateralUtxos: ScriptUtxo[]; + minKeyCollateralCandidates?: number; }): ProxyLifecycleUtxoShapeAnalysis { + const minKeyCollateralCandidates = Math.max(1, args.minKeyCollateralCandidates ?? 1); const lovelaces = args.walletUtxos.map(parseLovelace); const totalLovelace = lovelaces.reduce((sum, value) => sum + value, 0n); const largestUtxoLovelace = lovelaces.reduce( @@ -95,7 +98,7 @@ export function analyzeProxyFullLifecycleUtxoShape(args: { utxo.output.amount.every((asset) => asset.unit === "lovelace"), ); const hasSetupCandidate = setupCandidates > 0; - const hasKeyCollateral = keyCollateralCandidates.length > 0; + const hasKeyCollateral = keyCollateralCandidates.length >= minKeyCollateralCandidates; const drepRequiredLovelace = getProxyFullLifecycleRequiredLovelace(); const drepSelectableLovelace = totalLovelace; const requiredTotalLovelace = getProxyFullLifecycleRequiredLovelace(); @@ -103,7 +106,7 @@ export function analyzeProxyFullLifecycleUtxoShape(args: { drepRequiredLovelace + PROXY_LIFECYCLE_COLLATERAL_SPLIT_LOVELACE + SELF_SPLIT_FEE_BUFFER_LOVELACE; const diagnostics = `total=${formatAda(totalLovelace)}, largestUtxO=${formatAda(largestUtxoLovelace)}, ` + - `setupCandidates=${setupCandidates}, keyCollateralCandidates=${keyCollateralCandidates.length}, ` + + `setupCandidates=${setupCandidates}, keyCollateralCandidates=${keyCollateralCandidates.length}/${minKeyCollateralCandidates}, ` + `drepSelectable=${formatAda(drepSelectableLovelace)}, drepRequired=${formatAda(drepRequiredLovelace)}, ` + `required=${formatAda(requiredTotalLovelace)} ` + `(DRep register ${formatAda(DREP_REGISTER_REQUIRED_LOVELACE)} + ` + @@ -142,15 +145,21 @@ export function analyzeProxyFullLifecycleUtxoShape(args: { export function assertProxyFullLifecyclePreflight(args: { walletUtxos: ScriptUtxo[]; collateralUtxos: ScriptUtxo[]; + minKeyCollateralCandidates?: number; }): Omit< ProxyLifecycleUtxoShapeAnalysis, "status" | "diagnostics" | "selfSplitRequiredLovelace" | "hasSetupCandidate" | "hasKeyCollateral" > { + const minKeyCollateralCandidates = Math.max(1, args.minKeyCollateralCandidates ?? 1); const analysis = analyzeProxyFullLifecycleUtxoShape(args); - if (analysis.keyCollateralCandidates === 0) { + if (analysis.keyCollateralCandidates < minKeyCollateralCandidates) { + const collateralMessage = + minKeyCollateralCandidates === 1 + ? `no bot payment-address UTxO has at least ${formatAda(COLLATERAL_REQUIRED_LOVELACE)} for Plutus collateral` + : `expected ${minKeyCollateralCandidates} distinct bot payment-address collateral UTxO(s) with at least ${formatAda(COLLATERAL_REQUIRED_LOVELACE)}, found ${analysis.keyCollateralCandidates}`; throw new Error( - `Proxy full lifecycle preflight failed: no bot payment-address UTxO has at least ${formatAda(COLLATERAL_REQUIRED_LOVELACE)} for Plutus collateral. ${analysis.diagnostics}. Run proxy lifecycle UTxO shaping or fund the bot payment address before running proxy full lifecycle.`, + `Proxy full lifecycle preflight failed: ${collateralMessage}. ${analysis.diagnostics}. Run proxy lifecycle UTxO shaping or fund the bot payment address before running proxy full lifecycle.`, ); } if (analysis.setupCandidates === 0) { diff --git a/scripts/ci/scenarios/steps/certificates.ts b/scripts/ci/scenarios/steps/certificates.ts index b86f1d6a..bf051e4a 100644 --- a/scripts/ci/scenarios/steps/certificates.ts +++ b/scripts/ci/scenarios/steps/certificates.ts @@ -42,7 +42,7 @@ async function fetchUtxoRefs(args: { * the result. This confirms the cert tx has been included in a block and its * inputs are no longer unspent on-chain. * - * Preprod block time is ~20 s. We retry every 8 s for up to 4 minutes. + * Preprod block time is ~20 s. We retry every 5 s for up to 4 minutes. */ async function pollUntilUtxosConsumed(args: { ctx: CIBootstrapContext; @@ -54,8 +54,8 @@ async function pollUntilUtxosConsumed(args: { retryDelayMs?: number; }): Promise<{ attempts: number }> { const { ctx, walletId, token, botAddress, spentUtxoRefs } = args; - const maxRetries = args.maxRetries ?? 30; - const retryDelayMs = args.retryDelayMs ?? 8000; + const maxRetries = args.maxRetries ?? 48; + const retryDelayMs = args.retryDelayMs ?? 5000; const spentKeys = new Set(spentUtxoRefs.map((r) => `${r.txHash}:${r.outputIndex}`)); for (let attempt = 0; attempt < maxRetries; attempt++) { @@ -78,7 +78,7 @@ async function pollUntilUtxosConsumed(args: { } } throw new Error( - `Timed out after ${maxRetries} attempts (${(maxRetries * (args.retryDelayMs ?? 8000)) / 1000}s) waiting for cert tx inputs to be confirmed on-chain`, + `Timed out after ${maxRetries} attempts (${(maxRetries * (args.retryDelayMs ?? 5000)) / 1000}s) waiting for cert tx inputs to be confirmed on-chain`, ); } @@ -527,53 +527,60 @@ export function createScenarioDRepCertificates(): Scenario { id: "scenario.drep-certificates", description: "Register and retire DRep for legacy and SDK wallets, restoring pre-test state", - steps: [ - // Legacy: hygiene (deregister if already registered) - createDRepHygieneStep("legacy"), - // Legacy: register - ...createCertPhaseSteps({ - idPrefix: "v1.botDRepCertificate.legacy.register", - walletType: "legacy", - certEndpoint: "botDRepCertificate", - action: "register", - label: "DRep registration (legacy)", - runtime: legacyReg, - requireBroadcastSuccess: true, - buildExtraBody: () => buildDRepRegBody(), - }), - // Legacy: retire - ...createCertPhaseSteps({ - idPrefix: "v1.botDRepCertificate.legacy.retire", - walletType: "legacy", - certEndpoint: "botDRepCertificate", - action: "retire", - label: "DRep retirement (legacy)", - runtime: legacyRetire, - requireBroadcastSuccess: true, - }), - // SDK: hygiene (deregister if already registered) - createDRepHygieneStep("sdk"), - // SDK: register - ...createCertPhaseSteps({ - idPrefix: "v1.botDRepCertificate.sdk.register", - walletType: "sdk", - certEndpoint: "botDRepCertificate", - action: "register", - label: "DRep registration (sdk)", - runtime: sdkReg, - requireBroadcastSuccess: true, - buildExtraBody: () => buildDRepRegBody(), - }), - // SDK: retire - ...createCertPhaseSteps({ - idPrefix: "v1.botDRepCertificate.sdk.retire", - walletType: "sdk", - certEndpoint: "botDRepCertificate", - action: "retire", - label: "DRep retirement (sdk)", - runtime: sdkRetire, - requireBroadcastSuccess: true, - }), + steps: [], + parallelBranches: [ + { + id: "drep-certificates.legacy", + description: "DRep certificate lifecycle (legacy)", + steps: [ + createDRepHygieneStep("legacy"), + ...createCertPhaseSteps({ + idPrefix: "v1.botDRepCertificate.legacy.register", + walletType: "legacy", + certEndpoint: "botDRepCertificate", + action: "register", + label: "DRep registration (legacy)", + runtime: legacyReg, + requireBroadcastSuccess: true, + buildExtraBody: () => buildDRepRegBody(), + }), + ...createCertPhaseSteps({ + idPrefix: "v1.botDRepCertificate.legacy.retire", + walletType: "legacy", + certEndpoint: "botDRepCertificate", + action: "retire", + label: "DRep retirement (legacy)", + runtime: legacyRetire, + requireBroadcastSuccess: true, + }), + ], + }, + { + id: "drep-certificates.sdk", + description: "DRep certificate lifecycle (SDK)", + steps: [ + createDRepHygieneStep("sdk"), + ...createCertPhaseSteps({ + idPrefix: "v1.botDRepCertificate.sdk.register", + walletType: "sdk", + certEndpoint: "botDRepCertificate", + action: "register", + label: "DRep registration (sdk)", + runtime: sdkReg, + requireBroadcastSuccess: true, + buildExtraBody: () => buildDRepRegBody(), + }), + ...createCertPhaseSteps({ + idPrefix: "v1.botDRepCertificate.sdk.retire", + walletType: "sdk", + certEndpoint: "botDRepCertificate", + action: "retire", + label: "DRep retirement (sdk)", + runtime: sdkRetire, + requireBroadcastSuccess: true, + }), + ], + }, ], }; } diff --git a/scripts/ci/scenarios/steps/governance.ts b/scripts/ci/scenarios/steps/governance.ts index a2eb6f67..7a8b20bf 100644 --- a/scripts/ci/scenarios/steps/governance.ts +++ b/scripts/ci/scenarios/steps/governance.ts @@ -33,7 +33,7 @@ export function createScenarioGovernanceRoutes(ctx: CIBootstrapContext): Scenari sourceCount?: number; error?: string; }>({ - url: `${runCtx.apiBaseUrl}/api/v1/governanceActiveProposals?network=0&count=20&page=1&order=desc&details=false`, + url: `${runCtx.apiBaseUrl}/api/v1/governanceActiveProposals?network=0&count=20&page=1&order=desc&details=false&debug=true`, method: "GET", token, }); diff --git a/scripts/ci/scenarios/steps/proxyBot.ts b/scripts/ci/scenarios/steps/proxyBot.ts index 4f970779..3712a0ad 100644 --- a/scripts/ci/scenarios/steps/proxyBot.ts +++ b/scripts/ci/scenarios/steps/proxyBot.ts @@ -10,6 +10,11 @@ import { runSigningFlow } from "../flows/signingFlow"; import { ensureProxyLifecycleUtxoShape } from "../flows/utxoShapeFlow"; import { recoverProxyRowsFromChainForWalletType } from "../proxyChainRecovery"; import { adoptProxyOrphansForWalletType } from "../proxyOrphanAdoption"; +import { + requireReservedCollateralUtxo, + reserveProxyLifecycleCollateral, + type ProxyCollateralReservation, +} from "../proxyCollateralReservations"; import { getWalletByType } from "./helpers"; import { assertProxyFullLifecyclePreflight, @@ -195,7 +200,15 @@ function isAdaOnlyCollateral(utxo: ScriptUtxo): boolean { function selectSeparateCollateral( utxos: ScriptUtxo[], context: string, + reservedCollateralRef?: UtxoRef, ): ScriptUtxo { + if (reservedCollateralRef) { + return requireReservedCollateralUtxo({ + collateralUtxos: utxos, + reservedCollateralRef, + context, + }); + } const collateral = [...utxos] .filter(isAdaOnlyCollateral) .sort((left, right) => { @@ -216,12 +229,13 @@ function selectSeparateCollateral( export function selectSetupRefs(args: { walletUtxos: ScriptUtxo[]; collateralUtxos: ScriptUtxo[]; + reservedCollateralRef?: UtxoRef; }): { utxoRefs: UtxoRef[]; collateralRef: UtxoRef } { const setupUtxo = selectSetupUtxo(args.walletUtxos as unknown as UTxO[]); if (!setupUtxo) { throw new Error(`proxy setup requires a wallet UTxO with at least ${formatAda(SETUP_UTXO_REQUIRED_LOVELACE)}`); } - const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy setup"); + const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy setup", args.reservedCollateralRef); return { utxoRefs: [toRef(setupUtxo as unknown as ScriptUtxo)], collateralRef: toRef(collateral) }; } @@ -230,8 +244,9 @@ export function selectAuthTokenRefs(args: { collateralUtxos: ScriptUtxo[]; authTokenId: string; includeAllAuthTokens?: boolean; + reservedCollateralRef?: UtxoRef; }): { utxoRefs: UtxoRef[]; collateralRef: UtxoRef } { - const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy action"); + const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy action", args.reservedCollateralRef); if (args.includeAllAuthTokens) { // Cleanup path: include every auth-token UTxO const authTokenUtxos = args.walletUtxos.filter((utxo) => @@ -252,6 +267,7 @@ export function selectDRepRegisterRefs(args: { collateralUtxos: ScriptUtxo[]; authTokenId: string; requiredLovelace?: bigint; + reservedCollateralRef?: UtxoRef; }): { utxoRefs: UtxoRef[]; collateralRef: UtxoRef; selectedLovelace: bigint; requiredLovelace: bigint } { const requiredLovelace = args.requiredLovelace ?? DREP_REGISTER_REQUIRED_LOVELACE; const authTokenUtxo = selectAuthTokenUtxo(args.walletUtxos as unknown as UTxO[], args.authTokenId); @@ -262,7 +278,7 @@ export function selectDRepRegisterRefs(args: { `proxy DRep register requires ${formatAda(requiredLovelace)} in selected wallet inputs but only ${formatAda(selectedLovelace)} is available after reserving separate collateral. Fund or consolidate the CI wallet before running scenario.proxy-full-lifecycle.`, ); } - const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy DRep register"); + const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy DRep register", args.reservedCollateralRef); return { utxoRefs: selected.map((u) => toRef(u as unknown as ScriptUtxo)), collateralRef: toRef(collateral), @@ -277,6 +293,7 @@ export function selectAuthTokenRefsWithMinLovelace(args: { authTokenId: string; requiredLovelace: bigint; context: string; + reservedCollateralRef?: UtxoRef; }): { utxoRefs: UtxoRef[]; collateralRef: UtxoRef; selectedLovelace: bigint; requiredLovelace: bigint } { const authTokenUtxo = selectAuthTokenUtxo(args.walletUtxos as unknown as UTxO[], args.authTokenId); const selected = accumulateFundingUtxos(args.walletUtxos as unknown as UTxO[], authTokenUtxo, args.requiredLovelace); @@ -286,7 +303,7 @@ export function selectAuthTokenRefsWithMinLovelace(args: { `${args.context} requires ${formatAda(args.requiredLovelace)} in selected wallet inputs but only ${formatAda(selectedLovelace)} is available after reserving separate collateral. Fund or consolidate the CI wallet before running scenario.proxy-full-lifecycle.`, ); } - const collateral = selectSeparateCollateral(args.collateralUtxos, args.context); + const collateral = selectSeparateCollateral(args.collateralUtxos, args.context, args.reservedCollateralRef); return { utxoRefs: selected.map((u) => toRef(u as unknown as ScriptUtxo)), collateralRef: toRef(collateral), @@ -304,8 +321,8 @@ async function pollUntilUtxosConsumed(args: { maxRetries?: number; retryDelayMs?: number; }): Promise<{ attempts: number }> { - const maxRetries = args.maxRetries ?? 30; - const retryDelayMs = args.retryDelayMs ?? 8000; + const maxRetries = args.maxRetries ?? 48; + const retryDelayMs = args.retryDelayMs ?? 5000; const spent = new Set(args.spentUtxoRefs.map(key)); for (let attempt = 0; attempt < maxRetries; attempt++) { if (attempt > 0) { @@ -381,6 +398,7 @@ async function fetchProxyDRepInfo(args: { export async function runProxyFullLifecycleHygiene(args: { ctx: CIBootstrapContext; walletType: CIWalletType; + reservedCollateralRef?: UtxoRef; deps?: Partial; }): Promise<{ message: string; artifacts: Record }> { const deps = { ...defaultProxyLifecycleHygieneDeps, ...args.deps }; @@ -441,6 +459,7 @@ export async function runProxyFullLifecycleHygiene(args: { authTokenId: proxy.authTokenId, requiredLovelace: PROXY_ACTION_REQUIRED_LOVELACE + PROXY_ACTION_FEE_BUFFER_LOVELACE, context: "proxy hygiene DRep deregister", + reservedCollateralRef: args.reservedCollateralRef, }); const { requestRefs, selectionArtifacts } = splitProxyActionSelection(selection); const response = await deps.requestJson({ @@ -521,6 +540,7 @@ export async function runProxyFullLifecycleHygiene(args: { collateralUtxos, authTokenId: proxy.authTokenId, includeAllAuthTokens: true, + reservedCollateralRef: args.reservedCollateralRef, }); const response = await deps.requestJson({ url: `${args.ctx.apiBaseUrl}/api/v1/proxyCleanup`, @@ -813,6 +833,7 @@ export function requireSetupTxHash(runtime: { function createSetupLifecycleSteps(args: { walletType: CIWalletType; + getReservedCollateralRef?: () => UtxoRef | undefined; runtime: { setup?: ProxySetup; proxyId?: string; @@ -836,7 +857,11 @@ function createSetupLifecycleSteps(args: { fetchFreeUtxos({ ctx, walletId: wallet.walletId, token, address: bot.paymentAddress, fresh: true }), fetchKeyAddressUtxos({ ctx, address: bot.paymentAddress }), ]); - const refs = selectSetupRefs({ walletUtxos, collateralUtxos }); + const refs = selectSetupRefs({ + walletUtxos, + collateralUtxos, + reservedCollateralRef: args.getReservedCollateralRef?.(), + }); const response = await requestJson<{ transaction?: unknown; setup?: ProxySetup; error?: string }>({ url: `${ctx.apiBaseUrl}/api/v1/proxySetup`, method: "POST", @@ -964,7 +989,13 @@ function createProxyActionStep(args: { cleanupBurnTransactionId?: string; }; buildBody: (ctx: CIBootstrapContext, refs: ProxyActionRequestRefs) => Record | null; - selectRefs?: (args: { walletUtxos: ScriptUtxo[]; collateralUtxos: ScriptUtxo[]; authTokenId: string }) => ProxyActionSelection; + selectRefs?: (args: { + walletUtxos: ScriptUtxo[]; + collateralUtxos: ScriptUtxo[]; + authTokenId: string; + reservedCollateralRef?: UtxoRef; + }) => ProxyActionSelection; + getReservedCollateralRef?: () => UtxoRef | undefined; includeAllAuthTokens?: boolean; shouldSkip?: () => boolean; onSkip?: () => void; @@ -990,12 +1021,18 @@ function createProxyActionStep(args: { fetchKeyAddressUtxos({ ctx, address: bot.paymentAddress }), ]); const selection = - args.selectRefs?.({ walletUtxos, collateralUtxos, authTokenId: args.runtime.setup.authTokenId }) ?? + args.selectRefs?.({ + walletUtxos, + collateralUtxos, + authTokenId: args.runtime.setup.authTokenId, + reservedCollateralRef: args.getReservedCollateralRef?.(), + }) ?? selectAuthTokenRefs({ walletUtxos, collateralUtxos, authTokenId: args.runtime.setup.authTokenId, includeAllAuthTokens: args.includeAllAuthTokens, + reservedCollateralRef: args.getReservedCollateralRef?.(), }); const { requestRefs, selectionArtifacts } = splitProxyActionSelection(selection); args.runtime.actionTransactionId = undefined; @@ -1114,12 +1151,20 @@ function createWaitForActionConfirmationStep(args: { }; } -function createProxyFullLifecycleHygieneStep(walletType: CIWalletType): RouteStep { +function createProxyFullLifecycleHygieneStep( + walletType: CIWalletType, + getReservedCollateralRef?: () => UtxoRef | undefined, +): RouteStep { return { id: `v1.proxy.full.hygiene.${walletType}`, description: "Clean stale active proxy lifecycle rows before starting", severity: "critical", - execute: async (ctx) => runProxyFullLifecycleHygiene({ ctx, walletType }), + execute: async (ctx) => + runProxyFullLifecycleHygiene({ + ctx, + walletType, + reservedCollateralRef: getReservedCollateralRef?.(), + }), }; } @@ -1157,7 +1202,10 @@ function createProxyFullLifecycleAdoptionStep(walletType: CIWalletType): RouteSt }; } -function createProxyClientBuildSmokeStep(walletType: CIWalletType): RouteStep { +function createProxyClientBuildSmokeStep( + walletType: CIWalletType, + getReservedCollateralRef?: () => UtxoRef | undefined, +): RouteStep { return { id: `v1.proxy.client-build-smoke.${walletType}`, description: `Build proxy setup CBOR via client builder without submission (${walletType})`, @@ -1192,6 +1240,7 @@ function createProxyClientBuildSmokeStep(walletType: CIWalletType): RouteStep { const collateralScriptUtxo = selectSeparateCollateral( collateralUtxos as unknown as ScriptUtxo[], "proxy client build smoke", + getReservedCollateralRef?.(), ); const txBuilder = new MeshTxBuilder({ @@ -1225,7 +1274,32 @@ function createProxyClientBuildSmokeStep(walletType: CIWalletType): RouteStep { }; } -function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { +let proxyActiveProposalsCache: ActiveProposal[] | undefined; + +async function getCachedProxyActiveProposals(ctx: CIBootstrapContext): Promise { + if (proxyActiveProposalsCache) { + return proxyActiveProposalsCache; + } + + const bot = getDefaultBot(ctx); + const token = await authenticateBot({ ctx, bot }); + const response = await requestJson<{ proposals?: unknown[]; activeCount?: number; sourceCount?: number; error?: string }>({ + url: `${ctx.apiBaseUrl}/api/v1/governanceActiveProposals?network=0&count=20&page=1&order=desc&details=false`, + method: "GET", + token, + }); + if (response.status !== 200) { + throw new Error(`governanceActiveProposals failed (${response.status}): ${stringifyRedacted(response.data)}`); + } + + proxyActiveProposalsCache = getDeterministicActiveProposals(response.data, 1); + return proxyActiveProposalsCache; +} + +function createProxyFullLifecycleSteps( + walletType: CIWalletType, + getReservedCollateralRef?: () => UtxoRef | undefined, +): RouteStep[] { const runtime: { setup?: ProxySetup; proxyId?: string; @@ -1245,7 +1319,7 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { return [ createProxyFullLifecycleChainRecoveryStep(walletType), createProxyFullLifecycleAdoptionStep(walletType), - createProxyFullLifecycleHygieneStep(walletType), + createProxyFullLifecycleHygieneStep(walletType, getReservedCollateralRef), { id: `v1.proxy.full.utxoShape.${walletType}`, description: "Ensure proxy full-lifecycle wallet has separate setup and collateral UTxOs", @@ -1257,7 +1331,10 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { result.status === "already-shaped" ? `proxy full lifecycle UTxO shape already satisfied for ${walletType}` : `proxy full lifecycle UTxO self-split confirmed for ${walletType}`, - artifacts: result as unknown as Record, + artifacts: { + ...(result as unknown as Record), + reservedCollateralRef: getReservedCollateralRef?.(), + }, }; }, }, @@ -1284,6 +1361,14 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { walletUtxos, collateralUtxos, }); + const reservedCollateralRef = getReservedCollateralRef?.(); + if (reservedCollateralRef) { + requireReservedCollateralUtxo({ + collateralUtxos, + reservedCollateralRef, + context: `proxy full lifecycle preflight (${walletType})`, + }); + } return { message: `proxy full lifecycle preflight passed with ${formatAda(result.totalLovelace)} available and ${formatAda(result.requiredTotalLovelace)} required`, artifacts: { @@ -1294,18 +1379,20 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { drepSelectableLovelace: result.drepSelectableLovelace.toString(), drepRequiredLovelace: result.drepRequiredLovelace.toString(), requiredTotalLovelace: result.requiredTotalLovelace.toString(), + reservedCollateralRef, }, }; }, }, - createProxyClientBuildSmokeStep(walletType), - ...createSetupLifecycleSteps({ walletType, runtime }), + createProxyClientBuildSmokeStep(walletType, getReservedCollateralRef), + ...createSetupLifecycleSteps({ walletType, runtime, getReservedCollateralRef }), createProxyActionStep({ id: `v1.proxy.full.spend.propose.${walletType}`, description: "Build proxy spend transaction", walletType, endpoint: "proxySpend", runtime, + getReservedCollateralRef, buildBody: (runCtx) => ({ outputs: [{ address: getWalletByType(runCtx, walletType)?.walletAddress ?? "", unit: "lovelace", amount: PROXY_SPEND_LOVELACE.toString() }], description: "CI proxy spend", @@ -1324,12 +1411,14 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { walletType, endpoint: "proxyDRepCertificate", runtime, - selectRefs: ({ walletUtxos, collateralUtxos, authTokenId }) => { + getReservedCollateralRef, + selectRefs: ({ walletUtxos, collateralUtxos, authTokenId, reservedCollateralRef }) => { return selectDRepRegisterRefs({ walletUtxos, collateralUtxos, authTokenId, requiredLovelace: DREP_REGISTER_REQUIRED_LOVELACE + FULL_LIFECYCLE_FEE_BUFFER_LOVELACE, + reservedCollateralRef, }); }, buildBody: () => ({ @@ -1351,19 +1440,9 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { description: "Fetch active proposals for optional proxy vote", severity: "critical", execute: async (runCtx) => { - const bot = getDefaultBot(runCtx); - const token = await authenticateBot({ ctx: runCtx, bot }); - const response = await requestJson<{ proposals?: unknown[]; activeCount?: number; sourceCount?: number; error?: string }>({ - url: `${runCtx.apiBaseUrl}/api/v1/governanceActiveProposals?network=0&count=20&page=1&order=desc&details=false`, - method: "GET", - token, - }); - if (response.status !== 200) { - throw new Error(`governanceActiveProposals failed (${response.status}): ${stringifyRedacted(response.data)}`); - } - runtime.activeProposals = getDeterministicActiveProposals(response.data, 1); + runtime.activeProposals = await getCachedProxyActiveProposals(runCtx); return { - message: `selected ${runtime.activeProposals.length} active proposal(s) for optional proxy vote`, + message: `selected ${runtime.activeProposals.length} cached active proposal(s) for optional proxy vote`, artifacts: { selectedProposalIds: runtime.activeProposals.map((proposal) => proposal.proposalId) }, }; }, @@ -1374,13 +1453,15 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { walletType, endpoint: "proxyVote", runtime, - selectRefs: ({ walletUtxos, collateralUtxos, authTokenId }) => + getReservedCollateralRef, + selectRefs: ({ walletUtxos, collateralUtxos, authTokenId, reservedCollateralRef }) => selectAuthTokenRefsWithMinLovelace({ walletUtxos, collateralUtxos, authTokenId, requiredLovelace: PROXY_ACTION_REQUIRED_LOVELACE + PROXY_ACTION_FEE_BUFFER_LOVELACE, context: "proxy vote", + reservedCollateralRef, }), buildBody: () => { const proposal = runtime.activeProposals?.[0]; @@ -1405,13 +1486,15 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { walletType, endpoint: "proxyDRepCertificate", runtime, - selectRefs: ({ walletUtxos, collateralUtxos, authTokenId }) => + getReservedCollateralRef, + selectRefs: ({ walletUtxos, collateralUtxos, authTokenId, reservedCollateralRef }) => selectAuthTokenRefsWithMinLovelace({ walletUtxos, collateralUtxos, authTokenId, requiredLovelace: PROXY_ACTION_REQUIRED_LOVELACE + PROXY_ACTION_FEE_BUFFER_LOVELACE, context: "proxy DRep deregister", + reservedCollateralRef, }), buildBody: () => ({ action: "deregister", @@ -1431,6 +1514,7 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { walletType, endpoint: "proxyCleanup", runtime, + getReservedCollateralRef, includeAllAuthTokens: true, buildBody: () => ({ deactivateProxy: true, @@ -1450,6 +1534,7 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { walletType, endpoint: "proxyCleanup", runtime, + getReservedCollateralRef, includeAllAuthTokens: true, shouldSkip: () => shouldSkipCleanupBurnPropose(runtime), onSkip: () => { @@ -1534,6 +1619,73 @@ function createProxyFullLifecycleSteps(walletType: CIWalletType): RouteStep[] { ]; } +function validateUniqueProxyLifecycleWallets(ctx: CIBootstrapContext, walletTypes: CIWalletType[]): void { + const walletIds = new Map(); + const walletAddresses = new Map(); + + for (const walletType of walletTypes) { + const wallet = getWalletByType(ctx, walletType); + if (!wallet) throw new Error(`Missing ${walletType} wallet`); + + const existingWalletId = walletIds.get(wallet.walletId); + if (existingWalletId) { + throw new Error( + `Proxy full lifecycle parallel isolation failed: ${walletType} and ${existingWalletId} share walletId ${wallet.walletId}`, + ); + } + walletIds.set(wallet.walletId, walletType); + + const existingAddress = walletAddresses.get(wallet.walletAddress); + if (existingAddress) { + throw new Error( + `Proxy full lifecycle parallel isolation failed: ${walletType} and ${existingAddress} share walletAddress ${wallet.walletAddress}`, + ); + } + walletAddresses.set(wallet.walletAddress, walletType); + } +} + +function createProxyFullLifecycleIsolationStep(args: { + eligibleWalletTypes: CIWalletType[]; + reservations: Map; +}): RouteStep { + return { + id: "v1.proxy.full.parallelIsolation", + description: "Reserve distinct signer-0 collateral UTxOs for parallel proxy lifecycles", + severity: "critical", + execute: async (ctx) => { + validateUniqueProxyLifecycleWallets(ctx, args.eligibleWalletTypes); + + for (const walletType of args.eligibleWalletTypes) { + await ensureProxyLifecycleUtxoShape({ + ctx, + walletType, + minKeyCollateralCandidates: args.eligibleWalletTypes.length, + }); + } + + const bot = getDefaultBot(ctx); + const collateralUtxos = await fetchKeyAddressUtxos({ ctx, address: bot.paymentAddress }); + const reservations = reserveProxyLifecycleCollateral({ + walletTypes: args.eligibleWalletTypes, + collateralUtxos, + }); + args.reservations.clear(); + for (const [walletType, reservation] of reservations.entries()) { + args.reservations.set(walletType, reservation); + } + + return { + message: `reserved ${reservations.size} distinct signer-0 collateral UTxO(s) for proxy full lifecycle`, + artifacts: normalizeJsonArtifact({ + collateralAddress: bot.paymentAddress, + reservations: Array.from(reservations.values()), + }) as Record, + }; + }, + }; +} + export function createScenarioProxyFullLifecycle(ctx: CIBootstrapContext): Scenario { const eligibleWalletTypes = PROXY_FULL_LIFECYCLE_WALLET_TYPES.filter( (walletType) => @@ -1541,8 +1693,23 @@ export function createScenarioProxyFullLifecycle(ctx: CIBootstrapContext): Scena ctx.wallets.some((wallet) => wallet.type === walletType), ); + const reservations = new Map(); + const isParallelEnabled = boolFromEnv(process.env.CI_PROXY_FULL_LIFECYCLE_PARALLEL, true); + const getReservedCollateralRef = (walletType: CIWalletType) => + reservations.get(walletType)?.collateralRef; + const steps: RouteStep[] = eligibleWalletTypes.length - ? eligibleWalletTypes.flatMap((walletType) => createProxyFullLifecycleSteps(walletType)) + ? [ + createProxyFullLifecycleIsolationStep({ + eligibleWalletTypes, + reservations, + }), + ...(!isParallelEnabled + ? eligibleWalletTypes.flatMap((walletType) => + createProxyFullLifecycleSteps(walletType, () => getReservedCollateralRef(walletType)), + ) + : []), + ] : [ { id: "v1.proxy.full.precondition", @@ -1556,9 +1723,19 @@ export function createScenarioProxyFullLifecycle(ctx: CIBootstrapContext): Scena }, ]; + const parallelBranches = + eligibleWalletTypes.length && isParallelEnabled + ? eligibleWalletTypes.map((walletType) => ({ + id: `proxy-full-lifecycle.${walletType}`, + description: `Proxy full lifecycle (${walletType})`, + steps: createProxyFullLifecycleSteps(walletType, () => getReservedCollateralRef(walletType)), + })) + : undefined; + return { id: "scenario.proxy-full-lifecycle", description: "Proxy spend, governance, and cleanup lifecycle for legacy, hierarchical, and SDK wallets", steps, + parallelBranches, }; } diff --git a/src/__tests__/README.md b/src/__tests__/README.md index 84f0cd89..fd808db5 100644 --- a/src/__tests__/README.md +++ b/src/__tests__/README.md @@ -1,80 +1,76 @@ -# MultisigSDK Testing Framework +# Unit & Integration Tests -This directory contains comprehensive tests for the MultisigSDK functionality. +Jest tests for the multisig app live flat in this directory (plus `trpc/` and +`tx-builders/` subfolders). Playwright end-to-end specs live separately in +`e2e/` at the repo root. -## Test Structure +## Three jest projects -- `setup.ts` - Jest configuration and global test setup -- `testUtils.ts` - Mock data and helper functions for testing -- `multisigSDK.test.ts` - Tests for the MultisigWallet class -- `helpers.test.ts` - Tests for utility functions +The suite is mid-migration between two jest module systems, and the database +integration tests run separately again — so there are three configs, all built +from the shared base in `jest.shared.mjs`: -## Running Tests +| Project | Config | What runs | How | +|---|---|---|---| +| CJS (default) | `jest.config.mjs` | Every test here except the ESM list and `trpc/` | plain `jest` | +| ESM | `jest.esm.config.mjs` | The files named in `ESM_TESTS` in `jest.shared.mjs` | `node --experimental-vm-modules …` | +| tRPC integration | `jest.trpc.config.mjs` | `trpc/*.test.ts` | needs a real Postgres (`DATABASE_URL`); every suite is gated with `HAVE_DB ? describe : describe.skip` | -```bash -# Run all tests -npm test - -# Run tests in watch mode during development -npm run test:watch +A file is ESM-mode when it needs `jest.unstable_mockModule()` / `import.meta` / +ESM-only deps; CJS-mode when it uses hoisted `jest.mock()`. The two are mutually +exclusive per file — if you write a new test that needs ESM mocking, add its +basename to `ESM_TESTS` in `jest.shared.mjs`. -# Run tests with coverage report -npm run test:coverage +## Running -# Run tests for CI/CD (no watch mode) -npm run test:ci +```bash +npm test # scripts/run-tests.mjs → CJS project then ESM project +npm run test:cjs # CJS project only (plain jest; accepts jest CLI args) +npm run test:esm # ESM project only +npm run test:trpc # trpc/ integration tests (skipped without DATABASE_URL) +npm run test:coverage # unit run with coverage +npm run test:ci # CI mode (--ci --coverage --runInBand) +npm run test:bot # bot API unit subset + botApi.integration.test.ts +npm run test:e2e # Playwright (e2e/playwright.config.ts) ``` -## Test Coverage - -The tests cover: - -### MultisigWallet Class -- Constructor validation and key filtering/sorting -- Role-based key management (`getKeysByRole`) -- Script building for different roles (`buildScript`) -- Staking functionality detection (`stakingEnabled`) -- Stake credential hash computation -- JSON metadata generation (CIP-0146) -- Error handling for invalid inputs - -### Helper Functions -- `paymentKeyHash` - Extract payment key hash from addresses -- `stakeKeyHash` - Extract stake key hash from stake addresses -- `addressToNetwork` - Determine network from address format -- `checkValidAddress` - Validate Cardano addresses -- `checkValidStakeKey` - Validate stake addresses - -## Mock Data - -The `testUtils.ts` file provides: -- Mock key hashes for different roles (payment, stake, drep) -- Mock addresses for mainnet and testnet -- Mock MultisigKey arrays for testing -- Helper functions to create test wallets - -## Important Notes - -1. **Real Addresses**: Some tests use mock addresses. For production testing, use real Cardano addresses. - -2. **Network Dependencies**: Tests that interact with @meshsdk/core functions may require actual Cardano address formats. - -3. **Error Handling**: Tests verify both success paths and error conditions. - -4. **Lexicographic Sorting**: Tests verify that keys are properly sorted per CIP-1854. - -## Adding New Tests - -When adding new functionality to the MultisigSDK: - -1. Add corresponding test cases in the appropriate test file -2. Update mock data in `testUtils.ts` if needed -3. Ensure both success and error cases are covered -4. Run `npm run test:coverage` to verify coverage levels - -## CI/CD Integration - -The `test:ci` script is designed for continuous integration: -- Runs without watch mode -- Generates coverage reports -- Exits with appropriate status codes for build systems +Note: `npm test -- ` passes the pattern to both projects; for quick +iteration on one file `npx jest ` (CJS files) is usually enough. + +Lint is broken repo-wide — verify changes with `npm test` + `npm run typecheck` ++ `npm run build`, not `npm run lint`. + +## Environment & mocks + +- `setupEnv.cjs` (jest `setupFiles`) seeds dummy env vars before any module + loads; `setup.ts` (`setupFilesAfterEnv`) holds global setup. The tRPC project + additionally restores real timers via `trpc/realTimers.ts`. +- `@/env` is mapped by `moduleNameMapper` to `__mocks__/env.cjs` — a `Proxy` + over `process.env`, so every key stays in sync with the real `src/env.js` + automatically and tests can override values with `process.env.X = ...`. + **Do not hand-roll `jest.mock("@/env", ...)` with a fixed key list** — it + silently drops env keys the code under test reads. +- `testUtils.ts` provides shared fixtures: mock key hashes, real preprod test + addresses, and `MultisigKey` builders. +- `trpc/fixtures.ts` seeds/cleans DB rows for the integration suite; if a + router under test writes to a new table, extend `cleanupFixtures` too. + +## Integration suites + +- `trpc/` — router-level tests against a real Postgres. Run in their own CI + workflow (`trpc-integration-tests.yml`), excluded from the unit run. +- `botApi.integration.test.ts` — bot HTTP API integration; `describe.skip` + unless its integration env var is set. + +## Adding tests + +1. New CJS test: drop a `*.test.ts` file here — no config change needed. +2. Needs ESM-only mocking? Add the basename to `ESM_TESTS` in + `jest.shared.mjs` so it moves to the ESM project. +3. Router + DB behavior? Put it in `trpc/` and follow the + `seedWallet`/`cleanupFixtures` pattern. +4. Cover both success and failure paths; check coverage moved with + `npm run test:coverage`. Note `collectCoverageFrom` excludes + `src/pages/**`, `src/components/**/*.tsx`, `src/server/**`, and + `src/lib/security/**` — code there is only covered when a test imports it + directly, and it won't appear in the report. diff --git a/src/__tests__/addTransaction.bot.test.ts b/src/__tests__/addTransaction.bot.test.ts index 484f7cf3..b44c4de9 100644 --- a/src/__tests__/addTransaction.bot.test.ts +++ b/src/__tests__/addTransaction.bot.test.ts @@ -10,6 +10,7 @@ const enforceBodySizeMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, const verifyJwtMock: jest.Mock = jest.fn(); const isBotJwtMock: jest.Mock = jest.fn(); const assertBotWalletAccessMock: jest.Mock = jest.fn(); +const botHasScopeMock: jest.Mock = jest.fn(); const createTransactionMock: jest.Mock = jest.fn(); const transactionFromHexMock: jest.Mock = jest.fn(); @@ -17,30 +18,32 @@ jest.mock("@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsHeadersMock, 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/auth/botAccess", () => ({ + BotAccessError: class extends Error { constructor(public status: number, message: string) { super(message); } }, + botHasScope: botHasScopeMock, __esModule: true, assertBotWalletAccess: assertBotWalletAccessMock, -}), { virtual: true }); +})); jest.mock("@/utils/get-provider", () => ({ __esModule: true, getProvider: () => ({ submitTx: jest.fn() }), -}), { virtual: true }); +})); jest.mock("@meshsdk/core-csl", () => ({ __esModule: true, @@ -49,7 +52,7 @@ jest.mock("@meshsdk/core-csl", () => ({ from_hex: transactionFromHexMock, }, }, -}), { virtual: true }); +})); jest.mock("@/server/db", () => ({ __esModule: true, @@ -57,7 +60,7 @@ jest.mock("@/server/db", () => ({ transaction: { create: createTransactionMock }, wallet: { findUnique: jest.fn() }, }, -}), { virtual: true }); +})); let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; @@ -67,6 +70,7 @@ beforeAll(async () => { beforeEach(() => { jest.clearAllMocks(); + (botHasScopeMock as any).mockResolvedValue(true); applyRateLimitMock.mockReturnValue(true); applyBotRateLimitMock.mockReturnValue(true); enforceBodySizeMock.mockReturnValue(true); @@ -82,6 +86,21 @@ beforeEach(() => { }); describe("addTransaction bot API", () => { + it("rejects a scope-less bot with 403 before body validation runs", async () => { + (botHasScopeMock as any).mockResolvedValue(false); + const req = { + method: "POST", + headers: makeBearerAuth(), + // Deliberately invalid body: the scope gate must fire first. + body: {}, + } as unknown as NextApiRequest; + const res = createMockResponse(); + await handler(req, res); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: "Insufficient scope: multisig:sign required" }); + expect(assertBotWalletAccessMock).not.toHaveBeenCalled(); + }); + it("returns 403 when bot wallet access fails", async () => { (assertBotWalletAccessMock as any).mockRejectedValue(new Error("no access")); const req = { diff --git a/src/__tests__/addTransaction.test.ts b/src/__tests__/addTransaction.test.ts index 651b05b1..5282674d 100644 --- a/src/__tests__/addTransaction.test.ts +++ b/src/__tests__/addTransaction.test.ts @@ -12,8 +12,7 @@ jest.mock( __esModule: true, addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, cors: corsMock, - }), - { virtual: true }, + }) ); const verifyJwtMock = jest.fn<(token: string | undefined) => { address: string } | null>(); @@ -25,8 +24,7 @@ jest.mock( __esModule: true, verifyJwt: verifyJwtMock, isBotJwt: isBotJwtMock, - }), - { virtual: true }, + }) ); const applyRateLimitMock = jest.fn< @@ -46,8 +44,7 @@ jest.mock( applyRateLimit: applyRateLimitMock, applyBotRateLimit: applyBotRateLimitMock, enforceBodySize: enforceBodySizeMock, - }), - { virtual: true }, + }) ); const assertBotWalletAccessMock = jest.fn< @@ -59,8 +56,7 @@ jest.mock( () => ({ __esModule: true, assertBotWalletAccess: assertBotWalletAccessMock, - }), - { virtual: true }, + }) ); const dbTransactionCreateMock = jest.fn<(args: unknown) => Promise>(); @@ -76,8 +72,7 @@ jest.mock( () => ({ __esModule: true, db: dbMock, - }), - { virtual: true }, + }) ); const getProviderMock = jest.fn<(network: number) => { submitTx: (cbor: string) => unknown }>(); @@ -87,8 +82,7 @@ jest.mock( () => ({ __esModule: true, getProvider: getProviderMock, - }), - { virtual: true }, + }) ); const transactionFromHexMock = jest.fn<(hex: string) => { _parsed: true }>(); @@ -100,8 +94,7 @@ jest.mock( csl: { Transaction: { from_hex: transactionFromHexMock }, }, - }), - { virtual: true }, + }) ); // --- helpers ------------------------------------------------------------- diff --git a/src/__tests__/apiSecurity.test.ts b/src/__tests__/apiSecurity.test.ts index 35fd88ee..03edbeb6 100644 --- a/src/__tests__/apiSecurity.test.ts +++ b/src/__tests__/apiSecurity.test.ts @@ -5,7 +5,11 @@ import { applyRateLimit, enforceBodySize } from "@/lib/security/requestGuards"; import { createCaller } from "@/server/api/root"; const mockRes = () => { - const res: any = { statusCode: 200, body: null }; + const res: any = { statusCode: 200, body: null, headers: {} }; + res.setHeader = (name: string, value: string) => { + res.headers[name] = value; + return res; + }; res.status = (code: number) => { res.statusCode = code; return res; diff --git a/src/__tests__/botApi.integration.test.ts b/src/__tests__/botApi.integration.test.ts index 56abceb0..d71980fa 100644 --- a/src/__tests__/botApi.integration.test.ts +++ b/src/__tests__/botApi.integration.test.ts @@ -11,7 +11,7 @@ jest.mock("@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsHeadersMock, cors: corsMock, -}), { virtual: true }); +})); jest.mock("@/lib/security/requestGuards", () => ({ __esModule: true, @@ -19,7 +19,7 @@ jest.mock("@/lib/security/requestGuards", () => ({ applyBotRateLimit: () => true, applyStrictRateLimit: () => true, enforceBodySize: () => true, -}), { virtual: true }); +})); jest.mock("@/env", () => ({ __esModule: true, @@ -27,7 +27,7 @@ jest.mock("@/env", () => ({ DATABASE_URL: process.env.DATABASE_URL, NODE_ENV: "test", }, -}), { virtual: true }); +})); jest.mock("@meshsdk/core-cst", () => ({ __esModule: true, diff --git a/src/__tests__/botAuth.test.ts b/src/__tests__/botAuth.test.ts index 30bd3586..92af1436 100644 --- a/src/__tests__/botAuth.test.ts +++ b/src/__tests__/botAuth.test.ts @@ -11,27 +11,28 @@ const parseScopeMock = jest.fn<(scope: string) => string[]>(); const scopeIncludesMock = jest.fn<(scopes: string[], minScope: string) => boolean>(); const signMock: jest.Mock = jest.fn(); const findBotKeyMock: jest.Mock = jest.fn(); -const findBotUserByAddressMock: jest.Mock = jest.fn(); -const upsertBotUserMock: jest.Mock = jest.fn(); +const findBotUserMock: jest.Mock = jest.fn(); +const createBotUserMock: jest.Mock = jest.fn(); +const updateBotUserMock: jest.Mock = jest.fn(); jest.mock("@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsHeadersMock, cors: corsMock, -}), { virtual: true }); +})); jest.mock("@/lib/security/requestGuards", () => ({ __esModule: true, applyStrictRateLimit: applyStrictRateLimitMock, enforceBodySize: enforceBodySizeMock, -}), { virtual: true }); +})); jest.mock("@/lib/auth/botKey", () => ({ __esModule: true, verifyBotKeySecret: verifyBotKeySecretMock, parseScope: parseScopeMock, scopeIncludes: scopeIncludesMock, -}), { virtual: true }); +})); jest.mock("jsonwebtoken", () => ({ __esModule: true, @@ -46,11 +47,12 @@ jest.mock("@/server/db", () => ({ db: { botKey: { findUnique: findBotKeyMock }, botUser: { - findUnique: findBotUserByAddressMock, - upsert: upsertBotUserMock, + findUnique: findBotUserMock, + create: createBotUserMock, + update: updateBotUserMock, }, }, -}), { virtual: true }); +})); let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; @@ -59,6 +61,35 @@ beforeAll(async () => { ({ default: handler } = await import("../pages/api/v1/botAuth")); }); +const BOUND_ADDRESS = "addr_test1qpbot00000000000000000000000000000000000"; +const OTHER_ADDRESS = "addr_test1qpother0000000000000000000000000000000"; + +const boundBotUser = { + id: "bot-user-id", + botKeyId: "bot-key-id", + paymentAddress: BOUND_ADDRESS, + stakeAddress: null, +}; + +function authRequest(body: Record): NextApiRequest { + return { method: "POST", body: { botKeyId: "bot-key-id", secret: "secret", ...body } } as unknown as NextApiRequest; +} + +/** Route findUnique calls: by botKeyId → botUserForKey, by paymentAddress → botUserForAddress. */ +function mockBotUsers({ + forKey, + forAddress, +}: { + forKey: typeof boundBotUser | null; + forAddress?: typeof boundBotUser | null; +}) { + (findBotUserMock as any).mockImplementation(async (args: any) => { + if (args?.where?.botKeyId) return forKey; + if (args?.where?.paymentAddress) return forAddress ?? null; + return null; + }); +} + beforeEach(() => { jest.clearAllMocks(); applyStrictRateLimitMock.mockReturnValue(true); @@ -70,54 +101,91 @@ beforeEach(() => { signMock.mockReturnValue("signed-jwt"); (findBotKeyMock as any).mockResolvedValue({ id: "bot-key-id", + name: "Test Bot", keyHash: "hashed", scope: JSON.stringify(["multisig:read"]), }); - (findBotUserByAddressMock as any).mockResolvedValue(null); - (upsertBotUserMock as any).mockResolvedValue({ - id: "bot-user-id", - paymentAddress: "addr_test1qpbot00000000000000000000000000000000000", - }); + mockBotUsers({ forKey: null }); + (createBotUserMock as any).mockImplementation(async (args: any) => ({ id: "bot-user-id", ...args.data })); + (updateBotUserMock as any).mockImplementation(async (args: any) => ({ ...boundBotUser, ...args.data })); }); describe("botAuth API", () => { it("returns 401 for invalid bot secret", async () => { verifyBotKeySecretMock.mockReturnValue(false); - const req = { - method: "POST", - body: { - botKeyId: "bot-key-id", - secret: "wrong", - paymentAddress: "addr_test1qpbot00000000000000000000000000000000000", - }, - } as unknown as NextApiRequest; const res = createMockResponse(); - await handler(req, res); + await handler(authRequest({ secret: "wrong", paymentAddress: BOUND_ADDRESS }), res); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: "Invalid bot key" }); }); - it("returns token and botId for valid request", async () => { - const req = { - method: "POST", - body: { - botKeyId: "bot-key-id", - secret: "secret", - paymentAddress: "addr_test1qpbot00000000000000000000000000000000000", - }, - } as unknown as NextApiRequest; + it("first auth binds the supplied address and creates the BotUser", async () => { const res = createMockResponse(); - await handler(req, res); + await handler(authRequest({ paymentAddress: BOUND_ADDRESS }), res); - expect(upsertBotUserMock).toHaveBeenCalled(); - expect(signMock).toHaveBeenCalled(); + expect(createBotUserMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ paymentAddress: BOUND_ADDRESS, displayName: "Test Bot" }), + }), + ); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ - token: "signed-jwt", - botId: "bot-user-id", - }); + expect(res.json).toHaveBeenCalledWith({ token: "signed-jwt", botId: "bot-user-id" }); + }); + + it("first auth without an address is a 400 (address is required to bind)", async () => { + const res = createMockResponse(); + + await handler(authRequest({}), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(createBotUserMock).not.toHaveBeenCalled(); + expect(signMock).not.toHaveBeenCalled(); + }); + + it("subsequent auth works without an address and uses the bound one", async () => { + mockBotUsers({ forKey: boundBotUser }); + const res = createMockResponse(); + + await handler(authRequest({}), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(createBotUserMock).not.toHaveBeenCalled(); + const jwtPayload = (signMock.mock.calls[0] as unknown[])[0] as { address: string }; + expect(jwtPayload.address).toBe(BOUND_ADDRESS); + }); + + it("subsequent auth accepts a matching address", async () => { + mockBotUsers({ forKey: boundBotUser }); + const res = createMockResponse(); + + await handler(authRequest({ paymentAddress: BOUND_ADDRESS }), res); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("rejects a mismatched address instead of rebinding the identity (P0)", async () => { + mockBotUsers({ forKey: boundBotUser }); + const res = createMockResponse(); + + await handler(authRequest({ paymentAddress: OTHER_ADDRESS }), res); + + expect(res.status).toHaveBeenCalledWith(409); + expect(signMock).not.toHaveBeenCalled(); + expect(updateBotUserMock).not.toHaveBeenCalled(); + expect(createBotUserMock).not.toHaveBeenCalled(); + }); + + it("rejects first-auth binding to an address owned by another bot", async () => { + mockBotUsers({ forKey: null, forAddress: { ...boundBotUser, botKeyId: "someone-else" } }); + const res = createMockResponse(); + + await handler(authRequest({ paymentAddress: BOUND_ADDRESS }), res); + + expect(res.status).toHaveBeenCalledWith(409); + expect(createBotUserMock).not.toHaveBeenCalled(); + expect(signMock).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/botBallots.test.ts b/src/__tests__/botBallots.test.ts new file mode 100644 index 00000000..59811b80 --- /dev/null +++ b/src/__tests__/botBallots.test.ts @@ -0,0 +1,236 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMockResponse } from "./apiTestUtils"; + +const addCorsHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); +const applyRateLimitMock = jest.fn<() => boolean>(); +const applyBotRateLimitMock = jest.fn<() => boolean>(); +const enforceBodySizeMock = jest.fn<() => boolean>(); +const verifyJwtMock: jest.Mock = jest.fn(); +const isBotJwtMock: jest.Mock = jest.fn(); +const applyAddressRateLimitMock = jest.fn<() => boolean>(); +const assertWalletAccessMock: jest.Mock = jest.fn(); +const assertBotWalletAccessMock: jest.Mock = jest.fn(); +const findBotUserMock: jest.Mock = jest.fn(); +const ballotFindManyMock: jest.Mock = jest.fn(); +const ballotFindUniqueMock: jest.Mock = jest.fn(); +const ballotDeleteMock: jest.Mock = jest.fn(); + +class BotAccessErrorMock extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} + +jest.mock("@/lib/cors", () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsHeadersMock, + cors: corsMock, +})); + +jest.mock("@/lib/security/requestGuards", () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, + applyBotRateLimit: applyBotRateLimitMock, + applyAddressRateLimit: applyAddressRateLimitMock, + enforceBodySize: enforceBodySizeMock, +})); + +jest.mock("@/lib/verifyJwt", () => ({ + __esModule: true, + verifyJwt: verifyJwtMock, + isBotJwt: isBotJwtMock, +})); + +jest.mock("@/lib/security/rateLimit", () => ({ + __esModule: true, + getClientIP: () => "127.0.0.1", +})); + +jest.mock("@/server/api/auth", () => ({ + __esModule: true, + assertWalletAccess: assertWalletAccessMock, +})); + +jest.mock("@/lib/auth/botKey", () => ({ + __esModule: true, + parseScope: (scope: string) => JSON.parse(scope) as string[], + scopeIncludes: (scopes: string[], required: string) => scopes.includes(required), +})); + +jest.mock("@/lib/auth/botAccess", () => ({ + __esModule: true, + BotAccessError: BotAccessErrorMock, + assertBotWalletAccess: assertBotWalletAccessMock, +})); + +jest.mock("@/server/db", () => ({ + __esModule: true, + db: { + botUser: { findUnique: findBotUserMock }, + ballot: { + findMany: ballotFindManyMock, + findUnique: ballotFindUniqueMock, + delete: ballotDeleteMock, + }, + }, +})); + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/v1/botBallots")); +}); + +const GOV_BALLOT = { + id: "ballot-1", + walletId: "wallet-1", + description: "Advisory", + type: 1, + items: ["a".repeat(64) + "#0"], + itemDescriptions: ["Title"], + choices: ["Yes"], + anchorUrls: [""], + anchorHashes: [""], + rationaleComments: ["because"], + createdAt: new Date("2026-07-21T00:00:00Z"), + updatedAt: new Date("2026-07-21T00:00:00Z"), +}; + +function request(method: "GET" | "DELETE", opts: { query?: object; body?: object } = {}): NextApiRequest { + return { + method, + headers: { authorization: "Bearer token" }, + query: opts.query ?? {}, + body: opts.body ?? {}, + } as unknown as NextApiRequest; +} + +beforeEach(() => { + jest.clearAllMocks(); + applyRateLimitMock.mockReturnValue(true); + applyBotRateLimitMock.mockReturnValue(true); + applyAddressRateLimitMock.mockReturnValue(true); + (assertWalletAccessMock as any).mockResolvedValue({ id: "wallet-1" }); + enforceBodySizeMock.mockReturnValue(true); + corsMock.mockResolvedValue(undefined); + verifyJwtMock.mockReturnValue({ address: "addr_bot", botId: "bot-1", type: "bot" }); + isBotJwtMock.mockReturnValue(true); + (findBotUserMock as any).mockResolvedValue({ + id: "bot-1", + botKey: { scope: JSON.stringify(["ballot:write"]) }, + }); + (assertBotWalletAccessMock as any).mockResolvedValue({ + wallet: { id: "wallet-1", signersAddresses: [] }, + role: "observer", + }); + (ballotFindManyMock as any).mockResolvedValue([GOV_BALLOT]); + (ballotFindUniqueMock as any).mockResolvedValue(GOV_BALLOT); + (ballotDeleteMock as any).mockResolvedValue(GOV_BALLOT); +}); + +describe("botBallots API", () => { + it("lists governance ballots for a granted wallet (observer role)", async () => { + const res = createMockResponse(); + await handler(request("GET", { query: { walletId: "wallet-1" } }), res); + + expect(res.status).toHaveBeenCalledWith(200); + const body = (res.json as jest.Mock).mock.calls[0]?.[0] as { ballots: Array<{ id: string }> }; + expect(body.ballots).toHaveLength(1); + expect(body.ballots[0]?.id).toBe("ballot-1"); + expect(ballotFindManyMock).toHaveBeenCalledWith( + expect.objectContaining({ where: { walletId: "wallet-1", type: 1 } }), + ); + }); + + it("maps unknown wallet to 404", async () => { + (assertBotWalletAccessMock as any).mockRejectedValue(new BotAccessErrorMock(404, "Wallet not found")); + const res = createMockResponse(); + await handler(request("GET", { query: { walletId: "nope" } }), res); + expect(res.status).toHaveBeenCalledWith(404); + }); + + it("requires ballot:write scope", async () => { + (findBotUserMock as any).mockResolvedValue({ + id: "bot-1", + botKey: { scope: JSON.stringify(["multisig:read"]) }, + }); + const res = createMockResponse(); + await handler(request("GET", { query: { walletId: "wallet-1" } }), res); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it("deletes a governance ballot on the granted wallet", async () => { + const res = createMockResponse(); + await handler(request("DELETE", { body: { walletId: "wallet-1", ballotId: "ballot-1" } }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ deleted: true, ballotId: "ballot-1" }); + expect(ballotDeleteMock).toHaveBeenCalledWith({ where: { id: "ballot-1" } }); + }); + + it("refuses to delete a ballot belonging to another wallet", async () => { + (ballotFindUniqueMock as any).mockResolvedValue({ ...GOV_BALLOT, walletId: "other-wallet" }); + const res = createMockResponse(); + await handler(request("DELETE", { body: { walletId: "wallet-1", ballotId: "ballot-1" } }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(ballotDeleteMock).not.toHaveBeenCalled(); + }); + + it("404s a nonexistent ballot on delete", async () => { + (ballotFindUniqueMock as any).mockResolvedValue(null); + const res = createMockResponse(); + await handler(request("DELETE", { body: { walletId: "wallet-1", ballotId: "gone" } }), res); + expect(res.status).toHaveBeenCalledWith(404); + }); + + describe("human (non-bot) callers", () => { + const asHuman = () => { + verifyJwtMock.mockReturnValue({ address: "addr_test1qphuman" }); + isBotJwtMock.mockReturnValue(false); + }; + + it("lets a wallet signer read the ballots", async () => { + asHuman(); + const res = createMockResponse(); + await handler(request("GET", { query: { walletId: "wallet-1" } }), res); + + // Authorized by the shared signer-or-owner predicate, not the bot path. + expect(assertWalletAccessMock).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("does not require the ballot:write bot scope of a human", async () => { + asHuman(); + const res = createMockResponse(); + await handler(request("GET", { query: { walletId: "wallet-1" } }), res); + + // A human has no bot key, so the scope lookup must never run for them. + expect(findBotUserMock).not.toHaveBeenCalled(); + expect(applyBotRateLimitMock).not.toHaveBeenCalled(); + expect(applyAddressRateLimitMock).toHaveBeenCalled(); + }); + + it("returns 403 when the human is neither signer nor owner", async () => { + asHuman(); + (assertWalletAccessMock as any).mockRejectedValue( + Object.assign(new Error("Not authorized for this wallet"), { code: "FORBIDDEN" }), + ); + const res = createMockResponse(); + await handler(request("GET", { query: { walletId: "wallet-1" } }), res); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it("still gates bot callers on wallet access", async () => { + // Regression guard: the human branch must not weaken the bot branch. + const res = createMockResponse(); + await handler(request("GET", { query: { walletId: "wallet-1" } }), res); + expect(assertBotWalletAccessMock).toHaveBeenCalled(); + expect(assertWalletAccessMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/__tests__/botBallotsUpsert.test.ts b/src/__tests__/botBallotsUpsert.test.ts index 5b4cab4c..be6e27fc 100644 --- a/src/__tests__/botBallotsUpsert.test.ts +++ b/src/__tests__/botBallotsUpsert.test.ts @@ -8,7 +8,9 @@ const applyBotRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse const enforceBodySizeMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean>(); const verifyJwtMock = jest.fn<() => unknown>(); const isBotJwtMock = jest.fn<() => boolean>(); +const applyAddressRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, address: string) => boolean>(); const assertBotWalletAccessMock = jest.fn<() => Promise>(); +const assertWalletAccessMock = jest.fn<() => Promise>(); const findBotUserMock = jest.fn<() => Promise>(); const transactionMock = jest.fn<(cb: (tx: typeof txMock) => Promise) => Promise>(); const parseScopeMock = jest.fn<(scope: string) => string[]>(); @@ -42,10 +44,27 @@ jest.unstable_mockModule( __esModule: true, applyRateLimit: applyRateLimitMock, applyBotRateLimit: applyBotRateLimitMock, + applyAddressRateLimit: applyAddressRateLimitMock, enforceBodySize: enforceBodySizeMock, }), ); +jest.unstable_mockModule( + "@/lib/security/rateLimit", + () => ({ + __esModule: true, + getClientIP: () => "127.0.0.1", + }), +); + +jest.unstable_mockModule( + "@/server/api/auth", + () => ({ + __esModule: true, + assertWalletAccess: assertWalletAccessMock, + }), +); + jest.unstable_mockModule( "@/lib/verifyJwt", () => ({ @@ -76,6 +95,7 @@ jest.unstable_mockModule( jest.unstable_mockModule( "@/lib/auth/botAccess", () => ({ + BotAccessError: class extends Error { constructor(public status: number, message: string) { super(message); } }, __esModule: true, assertBotWalletAccess: assertBotWalletAccessMock, }), @@ -121,8 +141,10 @@ beforeAll(async () => { beforeEach(() => { jest.clearAllMocks(); + global.fetch = jest.fn(async () => ({ ok: true, status: 200 })) as never; applyRateLimitMock.mockReturnValue(true); applyBotRateLimitMock.mockReturnValue(true); + applyAddressRateLimitMock.mockReturnValue(true); enforceBodySizeMock.mockReturnValue(true); corsMock.mockResolvedValue(undefined); verifyJwtMock.mockReturnValue({ address: "addr_test1", botId: "bot-1", type: "bot" }); @@ -140,11 +162,37 @@ beforeEach(() => { id: "bot-1", botKey: { scope: JSON.stringify(["multisig:read", "ballot:write"]) }, }); - assertBotWalletAccessMock.mockResolvedValue({ wallet: { id: "wallet-1" }, role: "cosigner" }); + // Observer role suffices for ballot drafting (unsigned advisory rows). + assertBotWalletAccessMock.mockResolvedValue({ wallet: { id: "wallet-1", signersAddresses: ["addr_test1qexample"] }, role: "observer" }); transactionMock.mockImplementation(async (cb: any) => cb(txMock)); }); describe("botBallotsUpsert API", () => { + it("requests non-mutating wallet access (observer role is enough to draft)", async () => { + const req = { + method: "POST", + headers: { authorization: "Bearer token" }, + body: { + walletId: "wallet-1", + ballotName: "Advisory", + proposals: [{ proposalId: "a".repeat(64) + "#0", proposalTitle: "T", choice: "Yes" }], + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + // The access assertion must be called with mutating=false — cosigner must + // NOT be required for advisory drafts. + expect(assertBotWalletAccessMock as jest.Mock).toHaveBeenCalledWith( + expect.anything(), + "wallet-1", + expect.anything(), + false, + ); + expect(res.status).not.toHaveBeenCalledWith(403); + }); + it("rejects anchor fields in proposal payload", async () => { const req = { method: "POST", @@ -153,7 +201,7 @@ describe("botBallotsUpsert API", () => { walletId: "wallet-1", proposals: [ { - proposalId: "tx#0", + proposalId: "b".repeat(64) + "#0", proposalTitle: "Title", choice: "Yes", anchorUrl: "ipfs://should-not-be-allowed", @@ -181,7 +229,7 @@ describe("botBallotsUpsert API", () => { body: { walletId: "wallet-1", ballotName: "Gov", - proposals: [{ proposalId: "tx#0", proposalTitle: "Title", choice: "No" }], + proposals: [{ proposalId: "b".repeat(64) + "#0", proposalTitle: "Title", choice: "No" }], }, } as unknown as NextApiRequest; const res = createMockResponse(); @@ -193,4 +241,147 @@ describe("botBallotsUpsert API", () => { error: "Multiple ballots match ballotName; provide ballotId to disambiguate", }); }); + + it("rejects a proposalId whose txHash is not 64-hex", async () => { + const req = { + method: "POST", + headers: { authorization: "Bearer token" }, + body: { + walletId: "wallet-1", + ballotName: "Advisory", + proposals: [{ proposalId: "deadbeef#0", proposalTitle: "T", choice: "Yes" }], + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + await handler(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects proposalIds that do not exist on-chain, listing them", async () => { + global.fetch = jest.fn(async () => ({ ok: false, status: 404 })) as never; + const req = { + method: "POST", + headers: { authorization: "Bearer token" }, + body: { + walletId: "wallet-1", + ballotName: "Advisory", + proposals: [{ proposalId: "c".repeat(64) + "#0", proposalTitle: "T", choice: "Yes" }], + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + await handler(req, res); + expect(res.status).toHaveBeenCalledWith(400); + const body = (res.json as unknown as jest.Mock).mock.calls[0]?.[0] as any; + expect(body.proposalIds).toEqual(["c".repeat(64) + "#0"]); + }); + + it("fails open when the chain indexer is unavailable", async () => { + global.fetch = jest.fn(async () => { throw new Error("indexer down"); }) as never; + const fresh = { + id: "b-new", walletId: "wallet-1", type: 1, description: "Advisory", updatedAt: new Date(), + items: [], itemDescriptions: [], choices: [], anchorUrls: [], anchorHashes: [], rationaleComments: [], + }; + txMock.ballot.findMany.mockResolvedValue([]); + txMock.ballot.create.mockResolvedValue(fresh); + txMock.ballot.updateMany.mockResolvedValue({ count: 1 } as never); + txMock.ballot.findUnique.mockResolvedValue(fresh); + const req = { + method: "POST", + headers: { authorization: "Bearer token" }, + body: { + walletId: "wallet-1", + ballotName: "Advisory", + proposals: [{ proposalId: "d".repeat(64) + "#0", proposalTitle: "T", choice: "Yes" }], + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + await handler(req, res); + expect(res.status).toHaveBeenCalledWith(200); + }); + + describe("human (non-bot) callers", () => { + const asHuman = () => { + verifyJwtMock.mockReturnValue({ address: "addr_test1qphuman" }); + isBotJwtMock.mockReturnValue(false); + assertWalletAccessMock.mockResolvedValue({ id: "wallet-1" }); + }; + + const humanRequest = () => + ({ + method: "POST", + headers: { authorization: "Bearer token" }, + body: { + walletId: "wallet-1", + ballotName: "Gov", + proposals: [{ proposalId: "tx#0", proposalTitle: "Title", choice: "Yes" }], + }, + }) as unknown as NextApiRequest; + + it("authorizes a human via the shared signer-or-owner check", async () => { + asHuman(); + txMock.ballot.findMany.mockResolvedValue([]); + const res = createMockResponse(); + + await handler(humanRequest(), res); + + // The canonical predicate from @/server/api/auth, not a local copy, and + // not the bot-only path. + expect(assertWalletAccessMock).toHaveBeenCalled(); + expect(assertBotWalletAccessMock).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalledWith(403); + }); + + it("returns 403 when the human is neither signer nor owner", async () => { + asHuman(); + assertWalletAccessMock.mockRejectedValue( + Object.assign(new Error("Not authorized for this wallet"), { + code: "FORBIDDEN", + }), + ); + const res = createMockResponse(); + + await handler(humanRequest(), res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(transactionMock).not.toHaveBeenCalled(); + }); + + it("maps an unknown wallet to 404 rather than 403", async () => { + asHuman(); + assertWalletAccessMock.mockRejectedValue( + Object.assign(new Error("Wallet not found"), { code: "NOT_FOUND" }), + ); + const res = createMockResponse(); + + await handler(humanRequest(), res); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it("does not require the ballot:write bot scope of a human", async () => { + asHuman(); + // A human has no bot key at all; the scope lookup must never run for them. + txMock.ballot.findMany.mockResolvedValue([]); + const res = createMockResponse(); + + await handler(humanRequest(), res); + + expect(findBotUserMock).not.toHaveBeenCalled(); + expect(scopeIncludesMock).not.toHaveBeenCalled(); + }); + + it("still requires cosigner access for bot callers", async () => { + // Regression guard: the human branch must not weaken the bot branch. + assertBotWalletAccessMock.mockRejectedValue( + new Error("Bot observer cannot perform this action"), + ); + const res = createMockResponse(); + + await handler(humanRequest(), res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(assertWalletAccessMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/__tests__/botMe.test.ts b/src/__tests__/botMe.test.ts index 9e146990..58a9724c 100644 --- a/src/__tests__/botMe.test.ts +++ b/src/__tests__/botMe.test.ts @@ -14,26 +14,33 @@ jest.mock("@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsHeadersMock, cors: corsMock, -}), { virtual: true }); +})); jest.mock("@/lib/security/requestGuards", () => ({ __esModule: true, applyRateLimit: applyRateLimitMock, applyBotRateLimit: applyBotRateLimitMock, -}), { virtual: true }); +})); + +jest.mock("@/lib/auth/botAccess", () => ({ + __esModule: true, + getWalletAccessForBot: jest.fn(async () => [ + { walletId: "wallet-1", walletName: "drep.collective", role: "observer" }, + ]), +})); jest.mock("@/lib/verifyJwt", () => ({ __esModule: true, verifyJwt: verifyJwtMock, isBotJwt: isBotJwtMock, -}), { virtual: true }); +})); jest.mock("@/server/db", () => ({ __esModule: true, db: { botUser: { findUnique: findBotUserMock }, }, -}), { virtual: true }); +})); let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; @@ -84,6 +91,9 @@ describe("botMe API", () => { displayName: null, botName: "My Bot", ownerAddress: "addr_test1qphuman", + botWallets: [ + { walletId: "wallet-1", walletName: "drep.collective", role: "observer" }, + ], }); }); }); diff --git a/src/__tests__/botRegister.test.ts b/src/__tests__/botRegister.test.ts new file mode 100644 index 00000000..88f413a9 --- /dev/null +++ b/src/__tests__/botRegister.test.ts @@ -0,0 +1,143 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMockResponse } from "./apiTestUtils"; + +const addCorsHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); +const applyStrictRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean>(); +const enforceBodySizeMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean>(); + +const findBotUserMock: jest.Mock = jest.fn(); +const findPendingBotMock: jest.Mock = jest.fn(); +const createPendingBotMock: jest.Mock = jest.fn(); +const createClaimTokenMock: jest.Mock = jest.fn(); + +const txClient = { + pendingBot: { create: createPendingBotMock }, + botClaimToken: { create: createClaimTokenMock }, +}; + +jest.mock("@/lib/cors", () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsHeadersMock, + cors: corsMock, +})); + +jest.mock("@/lib/security/requestGuards", () => ({ + __esModule: true, + applyStrictRateLimit: applyStrictRateLimitMock, + enforceBodySize: enforceBodySizeMock, +})); + +jest.mock("@/server/db", () => ({ + __esModule: true, + db: { + botUser: { findUnique: findBotUserMock }, + pendingBot: { findFirst: findPendingBotMock }, + $transaction: (fn: (tx: typeof txClient) => unknown) => fn(txClient), + }, +})); + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + process.env.JWT_SECRET ??= "test-secret-for-bot-register"; + ({ default: handler } = await import("../pages/api/v1/botRegister")); +}); + +const VALID_ADDRESS = "addr_test1qpbotregisterfixture000000000000000000000000"; + +function registerRequest(body: Record): NextApiRequest { + return { method: "POST", headers: {}, body } as unknown as NextApiRequest; +} + +beforeEach(() => { + jest.clearAllMocks(); + applyStrictRateLimitMock.mockReturnValue(true); + enforceBodySizeMock.mockReturnValue(true); + corsMock.mockResolvedValue(undefined); + (findBotUserMock as any).mockResolvedValue(null); + (findPendingBotMock as any).mockResolvedValue(null); + createPendingBotMock.mockImplementation(async (args: any) => ({ id: "pending-1", ...args.data })); + (createClaimTokenMock as any).mockResolvedValue({ id: "token-1" }); +}); + +describe("botRegister API", () => { + it("registers a bot without a paymentAddress (the initial-registration path)", async () => { + const res = createMockResponse(); + + await handler( + registerRequest({ name: "Address-less Bot", requestedScopes: ["multisig:read"] }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(201); + // No address → no duplicate-address lookups. + expect(findBotUserMock).not.toHaveBeenCalled(); + expect(findPendingBotMock).not.toHaveBeenCalled(); + expect(createPendingBotMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ paymentAddress: null, name: "Address-less Bot" }), + }), + ); + const body = (res.json as jest.Mock).mock.calls[0]?.[0] as Record; + expect(body.pendingBotId).toBe("pending-1"); + expect(typeof body.claimCode).toBe("string"); + }); + + it("still registers with a paymentAddress and runs the duplicate checks", async () => { + const res = createMockResponse(); + + await handler( + registerRequest({ + name: "Wallet Bot", + paymentAddress: VALID_ADDRESS, + requestedScopes: ["multisig:read", "multisig:sign"], + }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(201); + expect(findBotUserMock).toHaveBeenCalledWith({ where: { paymentAddress: VALID_ADDRESS } }); + expect(createPendingBotMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ paymentAddress: VALID_ADDRESS }), + }), + ); + }); + + it("rejects a provided paymentAddress that is malformed", async () => { + const res = createMockResponse(); + + await handler( + registerRequest({ name: "Bad Address Bot", paymentAddress: "short", requestedScopes: ["multisig:read"] }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(400); + expect(createPendingBotMock).not.toHaveBeenCalled(); + }); + + it("rejects a provided paymentAddress that is already registered", async () => { + (findBotUserMock as any).mockResolvedValue({ id: "existing-bot" }); + const res = createMockResponse(); + + await handler( + registerRequest({ name: "Dup Bot", paymentAddress: VALID_ADDRESS, requestedScopes: ["multisig:read"] }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(409); + expect(createPendingBotMock).not.toHaveBeenCalled(); + }); + + it("still requires name and scopes", async () => { + const res1 = createMockResponse(); + await handler(registerRequest({ requestedScopes: ["multisig:read"] }), res1); + expect(res1.status).toHaveBeenCalledWith(400); + + const res2 = createMockResponse(); + await handler(registerRequest({ name: "Scopeless Bot", requestedScopes: [] }), res2); + expect(res2.status).toHaveBeenCalledWith(400); + }); +}); diff --git a/src/__tests__/botRotateSecret.test.ts b/src/__tests__/botRotateSecret.test.ts new file mode 100644 index 00000000..46e6ecd6 --- /dev/null +++ b/src/__tests__/botRotateSecret.test.ts @@ -0,0 +1,109 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMockResponse } from "./apiTestUtils"; + +const addCorsHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); +const applyStrictRateLimitMock = jest.fn<() => boolean>(); +const enforceBodySizeMock = jest.fn<() => boolean>(); +const verifyBotKeySecretMock = jest.fn<(secret: string, hash: string) => boolean>(); +const findBotKeyMock: jest.Mock = jest.fn(); +const updateBotKeyMock: jest.Mock = jest.fn(); +const auditMock: jest.Mock = jest.fn(); + +jest.mock("@/lib/cors", () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsHeadersMock, + cors: corsMock, +})); + +jest.mock("@/lib/security/requestGuards", () => ({ + __esModule: true, + applyStrictRateLimit: applyStrictRateLimitMock, + enforceBodySize: enforceBodySizeMock, +})); + +jest.mock("@/lib/auth/botKey", () => ({ + __esModule: true, + verifyBotKeySecret: verifyBotKeySecretMock, + generateBotKeySecret: () => "new-secret-hex", + hashBotKeySecret: (secret: string) => `hashed:${secret}`, +})); + +jest.mock("@/lib/observability/audit", () => ({ + __esModule: true, + audit: auditMock, +})); + +jest.mock("@/lib/security/rateLimit", () => ({ + __esModule: true, + getClientIP: () => "1.2.3.4", +})); + +jest.mock("@/server/db", () => ({ + __esModule: true, + db: { + botKey: { findUnique: findBotKeyMock, update: updateBotKeyMock }, + }, +})); + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/v1/botRotateSecret")); +}); + +function rotateRequest(body: Record): NextApiRequest { + return { method: "POST", headers: {}, body } as unknown as NextApiRequest; +} + +beforeEach(() => { + jest.clearAllMocks(); + applyStrictRateLimitMock.mockReturnValue(true); + enforceBodySizeMock.mockReturnValue(true); + corsMock.mockResolvedValue(undefined); + verifyBotKeySecretMock.mockReturnValue(true); + (findBotKeyMock as any).mockResolvedValue({ + id: "botkey-1", + ownerAddress: "addr_owner", + keyHash: "hashed:old-secret", + }); + (updateBotKeyMock as any).mockResolvedValue({ id: "botkey-1" }); + (auditMock as any).mockResolvedValue(undefined); +}); + +describe("botRotateSecret API", () => { + it("rotates the secret when the current one is proven", async () => { + const res = createMockResponse(); + await handler(rotateRequest({ botKeyId: "botkey-1", secret: "old-secret" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ botKeyId: "botkey-1", secret: "new-secret-hex" }); + expect(updateBotKeyMock).toHaveBeenCalledWith({ + where: { id: "botkey-1" }, + data: { keyHash: "hashed:new-secret-hex" }, + }); + }); + + it("rejects a wrong current secret without rotating", async () => { + verifyBotKeySecretMock.mockReturnValue(false); + const res = createMockResponse(); + await handler(rotateRequest({ botKeyId: "botkey-1", secret: "wrong" }), res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(updateBotKeyMock).not.toHaveBeenCalled(); + }); + + it("rejects an unknown botKeyId", async () => { + (findBotKeyMock as any).mockResolvedValue(null); + const res = createMockResponse(); + await handler(rotateRequest({ botKeyId: "nope", secret: "old-secret" }), res); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it("requires botKeyId and secret", async () => { + const res = createMockResponse(); + await handler(rotateRequest({ botKeyId: "botkey-1" }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); +}); diff --git a/src/__tests__/ciRunner.test.ts b/src/__tests__/ciRunner.test.ts new file mode 100644 index 00000000..14b8d5cb --- /dev/null +++ b/src/__tests__/ciRunner.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "@jest/globals"; +import { runScenarios } from "../../scripts/ci/framework/runner"; +import type { CIBootstrapContext, Scenario } from "../../scripts/ci/framework/types"; + +const ctx: CIBootstrapContext = { + schemaVersion: 3, + createdAt: "2026-04-29T00:00:00.000Z", + apiBaseUrl: "http://localhost:3000", + networkId: 0, + walletTypes: [], + wallets: [], + bots: [], + signerAddresses: [], + signerStakeAddresses: [], +}; + +describe("route-chain runner", () => { + it("reports non-critical step failures without failing the run by default", async () => { + const scenarios: Scenario[] = [ + { + id: "scenario.warning", + description: "warning scenario", + steps: [ + { + id: "step.warning", + description: "non-critical warning", + severity: "non-critical", + execute: async () => { + throw new Error("provider unavailable"); + }, + }, + { + id: "step.next", + description: "next step still runs", + execute: async () => ({ message: "ok" }), + }, + ], + }, + ]; + + const report = await runScenarios({ scenarios, ctx }); + + expect(report.status).toBe("passed"); + expect(report.scenarios[0]?.status).toBe("passed"); + expect(report.scenarios[0]?.steps.map((step) => step.status)).toEqual(["failed", "passed"]); + }); + + it("fails the run on critical step failures", async () => { + const scenarios: Scenario[] = [ + { + id: "scenario.critical", + description: "critical scenario", + steps: [ + { + id: "step.critical", + description: "critical failure", + severity: "critical", + execute: async () => { + throw new Error("boom"); + }, + }, + ], + }, + ]; + + const report = await runScenarios({ scenarios, ctx }); + + expect(report.status).toBe("failed"); + expect(report.scenarios[0]?.status).toBe("failed"); + }); + + it("runs parallel branches while preserving serial order inside each branch", async () => { + const events: string[] = []; + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const scenarios: Scenario[] = [ + { + id: "scenario.parallel", + description: "parallel scenario", + steps: [ + { + id: "step.prepare", + description: "prepare", + execute: async () => { + events.push("prepare"); + return { message: "prepared" }; + }, + }, + ], + parallelBranches: [ + { + id: "branch.a", + description: "A", + steps: [ + { + id: "a.1", + description: "a1", + execute: async () => { + events.push("a.1.start"); + await delay(20); + events.push("a.1.end"); + return { message: "a1" }; + }, + }, + { + id: "a.2", + description: "a2", + execute: async () => { + events.push("a.2"); + return { message: "a2" }; + }, + }, + ], + }, + { + id: "branch.b", + description: "B", + steps: [ + { + id: "b.1", + description: "b1", + execute: async () => { + events.push("b.1"); + return { message: "b1" }; + }, + }, + ], + }, + ], + }, + ]; + + const report = await runScenarios({ scenarios, ctx }); + + expect(report.status).toBe("passed"); + expect(events.indexOf("prepare")).toBeLessThan(events.indexOf("a.1.start")); + expect(events.indexOf("prepare")).toBeLessThan(events.indexOf("b.1")); + expect(events.indexOf("b.1")).toBeLessThan(events.indexOf("a.1.end")); + expect(events.indexOf("a.1.end")).toBeLessThan(events.indexOf("a.2")); + expect(report.scenarios[0]?.steps.map((step) => step.id)).toEqual([ + "step.prepare", + "a.1", + "a.2", + "b.1", + ]); + expect(report.scenarios[0]?.steps.find((step) => step.id === "a.1")?.artifacts).toMatchObject({ + branchId: "branch.a", + }); + }); + + it("fails the run when a parallel branch has a critical failure but reports other branches", async () => { + const scenarios: Scenario[] = [ + { + id: "scenario.parallel-fail", + description: "parallel failure", + steps: [], + parallelBranches: [ + { + id: "branch.fail", + description: "fail", + steps: [ + { + id: "fail.1", + description: "fail", + execute: async () => { + throw new Error("branch exploded"); + }, + }, + ], + }, + { + id: "branch.pass", + description: "pass", + steps: [ + { + id: "pass.1", + description: "pass", + execute: async () => ({ message: "ok" }), + }, + ], + }, + ], + }, + ]; + + const report = await runScenarios({ scenarios, ctx }); + + expect(report.status).toBe("failed"); + expect(report.scenarios[0]?.status).toBe("failed"); + expect(report.scenarios[0]?.steps.map((step) => step.id)).toEqual(["fail.1", "pass.1"]); + }); +}); diff --git a/src/__tests__/ciScenarioManifest.test.ts b/src/__tests__/ciScenarioManifest.test.ts index 0fae816f..9d8632be 100644 --- a/src/__tests__/ciScenarioManifest.test.ts +++ b/src/__tests__/ciScenarioManifest.test.ts @@ -60,6 +60,34 @@ describe("route-chain scenario manifest", () => { expect(scenarios.map((scenario) => scenario.id)).toEqual(["scenario.create-wallet"]); }); + it("runs legacy and SDK DRep lifecycles as independent parallel branches", () => { + const [scenario] = getScenarioManifest(mkContext(["legacy", "sdk"]), [ + "scenario.drep-certificates", + ]); + + expect(scenario?.steps).toEqual([]); + expect(scenario?.parallelBranches?.map((branch) => branch.id)).toEqual([ + "drep-certificates.legacy", + "drep-certificates.sdk", + ]); + expect(scenario?.parallelBranches?.[0]?.steps.map((step) => step.id)).toEqual([ + "v1.botDRepCertificate.legacy.hygiene", + "v1.botDRepCertificate.legacy.register.propose", + "v1.botDRepCertificate.legacy.register.pending", + "v1.botDRepCertificate.legacy.register.sign.signer1", + "v1.botDRepCertificate.legacy.register.sign.signer2", + "v1.botDRepCertificate.legacy.register.cleared", + "v1.botDRepCertificate.legacy.register.onchain", + "v1.botDRepCertificate.legacy.retire.propose", + "v1.botDRepCertificate.legacy.retire.pending", + "v1.botDRepCertificate.legacy.retire.sign.signer1", + "v1.botDRepCertificate.legacy.retire.sign.signer2", + "v1.botDRepCertificate.legacy.retire.cleared", + "v1.botDRepCertificate.legacy.retire.onchain", + ]); + expect(scenario?.parallelBranches?.[1]?.steps).toHaveLength(13); + }); + it("still fails clearly when ring transfer is requested without all wallet types", () => { expect(() => getScenarioManifest(mkContext(["legacy"]), ["scenario.real-transfer-and-sign"]), diff --git a/src/__tests__/ciSigningSelection.test.ts b/src/__tests__/ciSigningSelection.test.ts index 5cf729a1..be89e023 100644 --- a/src/__tests__/ciSigningSelection.test.ts +++ b/src/__tests__/ciSigningSelection.test.ts @@ -5,8 +5,8 @@ import { } from "../../scripts/ci/scenarios/flows/signingFlow"; describe("route-chain pending transaction selection", () => { - it("does not retry signTransaction after a witness may have been recorded", () => { - expect(SIGN_TRANSACTION_REQUEST_OPTIONS).toEqual({ retries: 0 }); + it("only retries signTransaction rate-limit responses", () => { + expect(SIGN_TRANSACTION_REQUEST_OPTIONS).toEqual({ retryStatuses: [429] }); }); it("selects the preferred transaction when present", () => { diff --git a/src/__tests__/cip146Discovery.test.ts b/src/__tests__/cip146Discovery.test.ts new file mode 100644 index 00000000..f7a30837 --- /dev/null +++ b/src/__tests__/cip146Discovery.test.ts @@ -0,0 +1,828 @@ +import { describe, expect, it } from "@jest/globals"; +import { + pubKeyAddress, + resolveNativeScriptHash, + resolvePaymentKeyHash, + resolveStakeKeyHash, + serializeAddressObj, + serializeNativeScript, + serializeRewardAddress, + type NativeScript, +} from "@meshsdk/core"; + +import { + buildImportFromRegistration, + buildSlotAddresses, + collectNativeScriptSigHashes, + deriveStakeCredentialFromKeys, + keyHashesToBaseAddress, + keyHashToEnterpriseAddress, + matchAddressesToSigSlots, + providerScriptJsonToNativeScript, + recoverRoleKeySets, + restoreStakeKeysFromAddresses, + verifyScriptCborAddress, +} from "../utils/cip146Discovery"; +import { + externalStakeCredential, + mockKeyHashes, + realTestAddresses, +} from "./testUtils"; + +const hashA = mockKeyHashes.payment1; +const hashB = mockKeyHashes.payment2; +const stakeCredentialHash = mockKeyHashes.stake1; + +const scriptJson = { + type: "atLeast", + required: 2, + scripts: [ + { type: "sig", keyHash: hashA }, + { type: "sig", keyHash: hashB }, + ], +}; + +function serializeExpected(stakeHash?: string) { + return serializeNativeScript( + providerScriptJsonToNativeScript(scriptJson), + stakeHash, + 0, + true, + ); +} + +describe("providerScriptJsonToNativeScript", () => { + it("maps sig/all/any/atLeast/after nodes", () => { + const converted = providerScriptJsonToNativeScript({ + type: "all", + scripts: [ + { type: "sig", keyHash: hashA }, + { type: "any", scripts: [{ type: "sig", keyHash: hashB }] }, + { type: "after", slot: 1234 }, + ], + }); + expect(converted).toEqual({ + type: "all", + scripts: [ + { type: "sig", keyHash: hashA }, + { type: "any", scripts: [{ type: "sig", keyHash: hashB }] }, + { type: "after", slot: "1234" }, + ], + }); + }); + + it("throws on unsupported input", () => { + expect(() => providerScriptJsonToNativeScript(null)).toThrow(); + expect(() => + providerScriptJsonToNativeScript({ type: "plutus" }), + ).toThrow(); + expect(() => providerScriptJsonToNativeScript({ type: "sig" })).toThrow(); + }); +}); + +describe("collectNativeScriptSigHashes", () => { + it("collects hashes in script order, deduplicated and lowercased", () => { + const script: NativeScript = { + type: "atLeast", + required: 1, + scripts: [ + { type: "sig", keyHash: hashB.toUpperCase() }, + { type: "sig", keyHash: hashA }, + { type: "sig", keyHash: hashB }, + ], + }; + expect(collectNativeScriptSigHashes(script)).toEqual([hashB, hashA]); + }); +}); + +describe("keyHashToEnterpriseAddress", () => { + it("produces an address that round-trips to the same payment key hash", () => { + const address = keyHashToEnterpriseAddress(hashA, 0); + expect(address.startsWith("addr_test1")).toBe(true); + expect(resolvePaymentKeyHash(address)).toBe(hashA); + }); +}); + +describe("keyHashesToBaseAddress", () => { + it("produces a base address that round-trips both key hashes", () => { + const address = keyHashesToBaseAddress(hashA, mockKeyHashes.stake1, 0); + expect(address.startsWith("addr_test1")).toBe(true); + expect(resolvePaymentKeyHash(address)).toBe(hashA); + expect(resolveStakeKeyHash(address)).toBe(mockKeyHashes.stake1); + }); +}); + +describe("buildImportFromRegistration", () => { + const userAddress = keyHashToEnterpriseAddress(hashA, 0); + const registration = { + tx_hash: "a".repeat(64), + json_metadata: { + types: [0], + name: ["My Long Wallet ", "Name"], + description: "Team funds", + participants: { + [hashA]: { name: "Alice" }, + [hashB]: { name: "Bob" }, + }, + }, + }; + + it("reconstructs an importable wallet verified against the on-chain address", () => { + const expected = serializeExpected(stakeCredentialHash); + const result = buildImportFromRegistration({ + registration, + candidate: { + address: expected.address, + scriptHash: "0".repeat(56), + stakeCredentialHash, + scriptJson, + }, + networkId: 0, + userAddress, + userPaymentKeyHash: hashA, + }); + + expect(result.error).toBeUndefined(); + const input = result.input!; + expect(input.name).toBe("My Long Wallet Name"); + expect(input.description).toBe("Team funds"); + expect(input.scriptType).toBe("atLeast"); + expect(input.numRequiredSigners).toBe(2); + expect(input.scriptCbor).toBe(expected.scriptCbor); + expect(input.stakeCredentialHash).toBe(stakeCredentialHash); + // User's own slot carries their real address; others get derived + // enterprise addresses that round-trip to the script's key hashes. + // (Stake set is unrecoverable here — types is [0] — so no base + // addresses are constructed.) + expect(input.signersAddresses).toContain(userAddress); + expect(input.signersAddresses).toContain( + keyHashToEnterpriseAddress(hashB, 0), + ); + for (const addr of input.signersAddresses) { + expect([hashA, hashB]).toContain(resolvePaymentKeyHash(addr)); + } + expect(input.signersDescriptions.sort()).toEqual(["Alice", "Bob"]); + }); + + it("recovers per-signer stake and dRep keys from the participants map", () => { + const stakeA = mockKeyHashes.stake1; + const stakeB = mockKeyHashes.stake2; + const realStakeCredential = deriveStakeCredentialFromKeys({ + stakeKeys: [stakeA, stakeB], + numRequiredSigners: 2, + scriptType: "atLeast", + networkId: 0, + })!; + const expected = serializeExpected(realStakeCredential); + const result = buildImportFromRegistration({ + registration: { + tx_hash: "a".repeat(64), + json_metadata: { + types: [0, 2, 3], + name: "Full wallet", + participants: { + [hashA]: { name: "Alice" }, + [hashB]: { name: "Bob" }, + [stakeA]: { name: "Alice" }, + [stakeB]: { name: "Bob" }, + [mockKeyHashes.drep1]: { name: "Alice" }, + }, + }, + }, + candidate: { + address: expected.address, + scriptHash: "0".repeat(56), + stakeCredentialHash: realStakeCredential, + scriptJson, + }, + networkId: 0, + userAddress, + userPaymentKeyHash: hashA, + }); + + expect(result.error).toBeUndefined(); + const input = result.input!; + const slotOfA = result.sigHashes!.indexOf(hashA); + const slotOfB = result.sigHashes!.indexOf(hashB); + expect(input.recovery?.stakeRestored).toBe(true); + expect(input.recovery?.drepRestored).toBe(true); + expect(input.recovery?.pairedByName).toBe(true); + // The user's own slot keeps their wallet-reported address; the + // co-signer's slot gets their CONSTRUCTED base address (payment + + // name-paired stake hash) — the string their wallet reports, so the + // wallet is visible to them without paste or invite. + expect(input.signersAddresses[slotOfA]).toBe(userAddress); + expect(input.signersAddresses[slotOfB]).toBe( + keyHashesToBaseAddress(hashB, stakeB, 0), + ); + expect(resolvePaymentKeyHash(input.signersAddresses[slotOfB]!)).toBe(hashB); + expect(resolveStakeKeyHash(input.signersAddresses[slotOfB]!)).toBe(stakeB); + expect(input.signersStakeKeys[slotOfA]).toBe( + serializeRewardAddress(stakeA, false, 0), + ); + expect(input.signersStakeKeys[slotOfB]).toBe( + serializeRewardAddress(stakeB, false, 0), + ); + expect(input.signersDRepKeys[slotOfA]).toBe(mockKeyHashes.drep1); + expect(input.signersDRepKeys[slotOfB]).toBe(""); + // The recovered stake keys reproduce the on-chain credential exactly. + expect( + deriveStakeCredentialFromKeys({ + stakeKeys: input.signersStakeKeys, + numRequiredSigners: input.numRequiredSigners, + scriptType: input.scriptType, + networkId: 0, + }), + ).toBe(realStakeCredential); + // External credential is still carried for draft-address assertions. + expect(input.stakeCredentialHash).toBe(realStakeCredential); + }); + + it("refuses to import when the reconstructed address differs", () => { + const result = buildImportFromRegistration({ + registration, + candidate: { + address: keyHashToEnterpriseAddress(hashB, 0), // wrong address + scriptHash: "0".repeat(56), + stakeCredentialHash, + scriptJson, + }, + networkId: 0, + userAddress, + userPaymentKeyHash: hashA, + }); + expect(result.input).toBeUndefined(); + expect(result.error).toMatch(/does not match/); + }); + + it("rejects candidates whose signers are not registration participants", () => { + const foreignScriptJson = { + type: "all", + scripts: [{ type: "sig", keyHash: mockKeyHashes.stake2 }], + }; + const expected = serializeNativeScript( + providerScriptJsonToNativeScript(foreignScriptJson), + undefined, + 0, + true, + ); + const result = buildImportFromRegistration({ + registration, + candidate: { + address: expected.address, + scriptHash: "0".repeat(56), + stakeCredentialHash: null, + scriptJson: foreignScriptJson, + }, + networkId: 0, + userAddress, + userPaymentKeyHash: hashA, + }); + expect(result.error).toMatch(/don't match the registration's participants/); + }); + + it("rejects users who are not payment signers", () => { + const expected = serializeExpected(stakeCredentialHash); + const result = buildImportFromRegistration({ + registration, + candidate: { + address: expected.address, + scriptHash: "0".repeat(56), + stakeCredentialHash, + scriptJson, + }, + networkId: 0, + userAddress: keyHashToEnterpriseAddress(mockKeyHashes.drep1, 0), + userPaymentKeyHash: mockKeyHashes.drep1, + }); + expect(result.error).toMatch(/not a payment signer/); + }); +}); + +describe("matchAddressesToSigSlots", () => { + const realHash = resolvePaymentKeyHash(realTestAddresses.address1); + const sigHashes = [realHash, hashA, hashB]; + const enterpriseB = keyHashToEnterpriseAddress(hashB, 0); + + it("assigns pasted addresses to slots by payment key hash, any order", () => { + const { assignments, errors } = matchAddressesToSigSlots({ + sigHashes, + pastedLines: [enterpriseB, realTestAddresses.address1], + networkId: 0, + }); + expect(errors).toEqual([]); + expect(assignments).toEqual({ + 0: realTestAddresses.address1, + 2: enterpriseB, + }); + }); + + it("rejects stake addresses, invalid lines, unknown signers and wrong network", () => { + const { assignments, errors } = matchAddressesToSigSlots({ + sigHashes, + pastedLines: [ + externalStakeCredential, + "not-an-address", + keyHashToEnterpriseAddress(mockKeyHashes.drep1, 0), + keyHashToEnterpriseAddress(hashA, 1), // mainnet address on preprod + ], + networkId: 0, + }); + expect(assignments).toEqual({}); + expect(errors.map((e) => e.reason)).toEqual([ + "stake-address", + "invalid", + "not-a-signer", + "wrong-network", + ]); + }); + + it("flags two different addresses resolving to the same slot", () => { + const { assignments, errors } = matchAddressesToSigSlots({ + sigHashes, + pastedLines: [ + realTestAddresses.address1, + keyHashToEnterpriseAddress(realHash, 0), + ], + networkId: 0, + }); + expect(assignments).toEqual({ 0: realTestAddresses.address1 }); + expect(errors).toEqual([ + { + line: keyHashToEnterpriseAddress(realHash, 0), + reason: "duplicate-slot", + }, + ]); + }); + + it("silently skips addresses matching a locked slot", () => { + const { assignments, errors } = matchAddressesToSigSlots({ + sigHashes, + lockedSlots: { 0: realTestAddresses.address1 }, + pastedLines: [realTestAddresses.address1], + networkId: 0, + }); + expect(assignments).toEqual({}); + expect(errors).toEqual([]); + }); +}); + +describe("buildSlotAddresses", () => { + const sigHashes = [hashA, hashB]; + + it("applies locked > assigned > fallback precedence", () => { + const result = buildSlotAddresses({ + sigHashes, + lockedSlots: { 0: "addr_test1locked" }, + assignments: { 0: "addr_test1assigned", 1: "addr_test1other" }, + networkId: 0, + fallback: "enterprise", + }); + expect(result).toEqual(["addr_test1locked", "addr_test1other"]); + }); + + it("slots recovered addresses between assignments and the fallback", () => { + const result = buildSlotAddresses({ + sigHashes: [hashA, hashB, mockKeyHashes.drep1], + lockedSlots: { 0: "addr_test1locked" }, + assignments: { 1: "addr_test1pasted" }, + recovered: { + 0: "addr_test1recovered0", // loses to locked + 1: "addr_test1recovered1", // loses to pasted assignment + 2: "addr_test1recovered2", // wins over the fallback + }, + networkId: 0, + fallback: "enterprise", + }); + expect(result).toEqual([ + "addr_test1locked", + "addr_test1pasted", + "addr_test1recovered2", + ]); + }); + + it("lets empty recovered entries fall through to the fallback", () => { + const result = buildSlotAddresses({ + sigHashes, + lockedSlots: {}, + assignments: {}, + recovered: { 0: "" }, + networkId: 0, + fallback: "enterprise", + }); + expect(result).toEqual([ + keyHashToEnterpriseAddress(hashA, 0), + keyHashToEnterpriseAddress(hashB, 0), + ]); + }); + + it("falls back to enterprise addresses or raw key hashes", () => { + const enterprise = buildSlotAddresses({ + sigHashes, + lockedSlots: {}, + assignments: {}, + networkId: 0, + fallback: "enterprise", + }); + expect(enterprise).toEqual([ + keyHashToEnterpriseAddress(hashA, 0), + keyHashToEnterpriseAddress(hashB, 0), + ]); + + const keyhash = buildSlotAddresses({ + sigHashes, + lockedSlots: {}, + assignments: {}, + networkId: 0, + fallback: "keyhash", + }); + expect(keyhash).toEqual([hashA, hashB]); + }); +}); + +describe("verifyScriptCborAddress", () => { + it("confirms a script CBOR that reproduces the expected address", () => { + const { address, scriptCbor } = serializeExpected(stakeCredentialHash); + expect( + verifyScriptCborAddress({ + scriptCbor: scriptCbor!, + stakeCredentialHash, + networkId: 0, + expectedAddress: address, + }), + ).toBe(true); + }); + + it("rejects a mismatched address and garbage CBOR", () => { + const { scriptCbor } = serializeExpected(stakeCredentialHash); + expect( + verifyScriptCborAddress({ + scriptCbor: scriptCbor!, + stakeCredentialHash: null, + networkId: 0, + expectedAddress: keyHashToEnterpriseAddress(hashA, 0), + }), + ).toBe(false); + expect( + verifyScriptCborAddress({ + scriptCbor: "deadbeef", + stakeCredentialHash, + networkId: 0, + expectedAddress: "addr_test1whatever", + }), + ).toBe(false); + }); +}); + +describe("deriveStakeCredentialFromKeys", () => { + const stakeHashA = mockKeyHashes.stake1; + const stakeHashB = mockKeyHashes.stake2; + const expectedHash = resolveNativeScriptHash({ + type: "atLeast", + required: 2, + scripts: [stakeHashA, stakeHashB] + .sort((a, b) => a.localeCompare(b)) + .map((keyHash) => ({ type: "sig", keyHash })), + }).toLowerCase(); + + it("derives the sorted role-2 script hash from 56-hex inputs", () => { + expect( + deriveStakeCredentialFromKeys({ + stakeKeys: [stakeHashA, stakeHashB], + numRequiredSigners: 2, + scriptType: "atLeast", + networkId: 0, + }), + ).toBe(expectedHash); + }); + + it("is order-independent and case-insensitive", () => { + expect( + deriveStakeCredentialFromKeys({ + stakeKeys: [stakeHashB.toUpperCase(), stakeHashA], + numRequiredSigners: 2, + scriptType: "atLeast", + networkId: 0, + }), + ).toBe(expectedHash); + }); + + it("accepts bech32 reward addresses", () => { + expect( + deriveStakeCredentialFromKeys({ + stakeKeys: [ + serializeRewardAddress(stakeHashA, false, 0), + serializeRewardAddress(stakeHashB, false, 0), + ], + numRequiredSigners: 2, + scriptType: "atLeast", + networkId: 0, + }), + ).toBe(expectedHash); + }); + + it("returns undefined for empty, blank or invalid entries", () => { + expect( + deriveStakeCredentialFromKeys({ + stakeKeys: [], + numRequiredSigners: 1, + scriptType: "atLeast", + networkId: 0, + }), + ).toBeUndefined(); + expect( + deriveStakeCredentialFromKeys({ + stakeKeys: [stakeHashA, ""], + numRequiredSigners: 2, + scriptType: "atLeast", + networkId: 0, + }), + ).toBeUndefined(); + expect( + deriveStakeCredentialFromKeys({ + stakeKeys: [stakeHashA, "not-a-stake-key"], + numRequiredSigners: 2, + scriptType: "atLeast", + networkId: 0, + }), + ).toBeUndefined(); + }); +}); + +describe("recoverRoleKeySets", () => { + // Deterministic distinct 56-hex key hashes. + const kh = (n: number) => n.toString(16).padStart(2, "0").repeat(28); + const p = [kh(0x10), kh(0x11), kh(0x12)]; // payment + const s = [kh(0x20), kh(0x21), kh(0x22)]; // stake + const d = [kh(0x30), kh(0x31), kh(0x32)]; // dRep + + const credentialOf = (stakeHashes: string[]) => + deriveStakeCredentialFromKeys({ + stakeKeys: stakeHashes, + numRequiredSigners: 2, + scriptType: "atLeast", + networkId: 0, + })!; + + const baseArgs = { + sigHashes: p, + numRequiredSigners: 2, + scriptType: "atLeast" as const, + networkId: 0, + }; + + const named = (entries: Array<[string, string]>) => + Object.fromEntries(entries.map(([hash, name]) => [hash, { name }])); + + it("recovers full 3x3 role sets paired by participant name", () => { + const result = recoverRoleKeySets({ + ...baseArgs, + participants: named([ + [p[0]!, "Signer 1"], + [p[1]!, "Signer 2"], + [p[2]!, "Signer 3"], + [s[0]!, "Signer 1"], + [s[1]!, "Signer 2"], + [s[2]!, "Signer 3"], + [d[0]!, "Signer 1"], + [d[1]!, "Signer 2"], + [d[2]!, "Signer 3"], + ]), + stakeCredentialHash: credentialOf(s), + registrationTypes: [0, 2, 3], + }); + + expect(result.stakeRestored).toBe(true); + expect(result.drepRestored).toBe(true); + expect(result.pairedByName).toBe(true); + expect(result.signersStakeKeys).toEqual( + s.map((hash) => serializeRewardAddress(hash, false, 0)), + ); + expect(result.signersDRepKeys).toEqual(d); + // Name-paired recovery also reconstructs each signer's real base + // address (payment hash + their own stake hash). + expect(result.signersBaseAddresses).toEqual( + p.map((hash, i) => keyHashesToBaseAddress(hash, s[i]!, 0)), + ); + for (const [i, address] of result.signersBaseAddresses.entries()) { + expect(resolvePaymentKeyHash(address)).toBe(p[i]); + expect(resolveStakeKeyHash(address)).toBe(s[i]); + } + }); + + it("recovers the real preprod shape: some signers have no dRep key", () => { + // 3 signers, 7 hashes: signer 1 registered payment+stake+dRep, the + // others payment+stake only (types still [0,3,2]). + const result = recoverRoleKeySets({ + ...baseArgs, + participants: named([ + [p[0]!, "Preprod 1"], + [p[1]!, "Preprod 2"], + [p[2]!, "Preprod 3"], + [s[0]!, "Preprod 1"], + [s[1]!, "Preprod 2"], + [s[2]!, "Preprod 3"], + [d[0]!, "Preprod 1"], + ]), + stakeCredentialHash: credentialOf(s), + registrationTypes: [0, 3, 2], + }); + + expect(result.stakeRestored).toBe(true); + expect(result.drepRestored).toBe(true); + expect(result.pairedByName).toBe(true); + expect(result.signersStakeKeys).toEqual( + s.map((hash) => serializeRewardAddress(hash, false, 0)), + ); + expect(result.signersDRepKeys).toEqual([d[0], "", ""]); + }); + + it("recovers exact SETS via combinatorial fallback when names are unusable", () => { + const participants = named([ + // duplicate names -> name groups cannot identify signers + ...p.map((h) => [h, "same"] as [string, string]), + ...s.map((h) => [h, "same"] as [string, string]), + ...d.map((h) => [h, ""] as [string, string]), + ]); + const result = recoverRoleKeySets({ + ...baseArgs, + participants, + stakeCredentialHash: credentialOf(s), + registrationTypes: [0, 2, 3], + }); + + expect(result.stakeRestored).toBe(true); + expect(result.pairedByName).toBe(false); + // Arbitrary pairing must never mint a base address. + expect(result.signersBaseAddresses).toEqual(["", "", ""]); + // Slot pairing is arbitrary but the sets are exact. + const recoveredStakeSet = result.signersStakeKeys.map((addr) => + deriveStakeCredentialFromKeys({ + stakeKeys: [addr], + numRequiredSigners: 1, + scriptType: "all", + networkId: 0, + }), + ); + expect(credentialOf(result.signersStakeKeys)).toBe(credentialOf(s)); + expect(recoveredStakeSet).toHaveLength(3); + expect(result.drepRestored).toBe(true); + expect([...result.signersDRepKeys].sort()).toEqual([...d].sort()); + }); + + it("does not restore anything when the stake credential matches no subset", () => { + const result = recoverRoleKeySets({ + ...baseArgs, + participants: named([ + ...p.map((h, i) => [h, `Signer ${i}`] as [string, string]), + ...s.map((h, i) => [h, `Signer ${i}`] as [string, string]), + ...d.map((h, i) => [h, `Signer ${i}`] as [string, string]), + ]), + stakeCredentialHash: "0".repeat(56), // foreign credential + registrationTypes: [0, 2, 3], + }); + expect(result.stakeRestored).toBe(false); + expect(result.drepRestored).toBe(false); + expect(result.signersStakeKeys).toEqual(["", "", ""]); + expect(result.signersDRepKeys).toEqual(["", "", ""]); + expect(result.signersBaseAddresses).toEqual(["", "", ""]); + }); + + it("recovers dRep keys without staking when the registration has no role 2", () => { + const result = recoverRoleKeySets({ + ...baseArgs, + participants: named([ + ...p.map((h, i) => [h, `Signer ${i + 1}`] as [string, string]), + ...d.map((h, i) => [h, `Signer ${i + 1}`] as [string, string]), + ]), + stakeCredentialHash: null, + registrationTypes: [0, 3], + }); + expect(result.stakeRestored).toBe(false); + expect(result.signersStakeKeys).toEqual(["", "", ""]); + expect(result.drepRestored).toBe(true); + expect(result.signersDRepKeys).toEqual(d); + expect(result.signersBaseAddresses).toEqual(["", "", ""]); + }); + + it("refuses to guess dRep keys when every hash is reused (no leftovers)", () => { + // types include 3 but participants hold only payment+stake hashes: + // the dRep keys must reuse other roles' hashes — ambiguous. + const result = recoverRoleKeySets({ + ...baseArgs, + participants: named([ + ...p.map((h, i) => [h, `Signer ${i + 1}`] as [string, string]), + ...s.map((h, i) => [h, `Signer ${i + 1}`] as [string, string]), + ]), + stakeCredentialHash: credentialOf(s), + registrationTypes: [0, 2, 3], + }); + expect(result.stakeRestored).toBe(true); + expect(result.drepRestored).toBe(false); + expect(result.signersDRepKeys).toEqual(["", "", ""]); + }); + + it("ignores dRep leftovers when the registration types exclude role 3", () => { + const result = recoverRoleKeySets({ + ...baseArgs, + participants: named([ + ...p.map((h, i) => [h, `Signer ${i + 1}`] as [string, string]), + ...s.map((h, i) => [h, `Signer ${i + 1}`] as [string, string]), + ]), + stakeCredentialHash: credentialOf(s), + registrationTypes: [0, 2], + }); + expect(result.stakeRestored).toBe(true); + expect(result.drepRestored).toBe(false); + expect(result.signersDRepKeys).toEqual(["", "", ""]); + }); + + it("bails gracefully on pathological participant counts", () => { + const participants = named( + Array.from({ length: 41 }, (_, i) => [kh(i + 1), `P${i}`]), + ); + const result = recoverRoleKeySets({ + ...baseArgs, + participants, + stakeCredentialHash: credentialOf(s), + registrationTypes: [0, 2, 3], + }); + expect(result.stakeRestored).toBe(false); + expect(result.drepRestored).toBe(false); + }); +}); + +describe("restoreStakeKeysFromAddresses", () => { + const stakeHashA = mockKeyHashes.stake1; + const stakeHashB = mockKeyHashes.stake2; + const baseA = serializeAddressObj(pubKeyAddress(hashA, stakeHashA), 0); + const baseB = serializeAddressObj(pubKeyAddress(hashB, stakeHashB), 0); + const expected = deriveStakeCredentialFromKeys({ + stakeKeys: [stakeHashA, stakeHashB], + numRequiredSigners: 2, + scriptType: "atLeast", + networkId: 0, + })!; + + const argsBase = { + numRequiredSigners: 2, + scriptType: "atLeast" as const, + networkId: 0, + }; + + it("restores stake keys when every base address matches the on-chain credential", () => { + const result = restoreStakeKeysFromAddresses({ + ...argsBase, + signersAddresses: [baseA, baseB], + expectedStakeCredentialHash: expected.toUpperCase(), + }); + expect(result.restored).toBe(true); + expect(result.stakeCredentialHash).toBeNull(); + expect(result.signersStakeKeys).toEqual([ + serializeRewardAddress(stakeHashA, false, 0), + serializeRewardAddress(stakeHashB, false, 0), + ]); + }); + + it("falls back when a slot has an enterprise (stake-less) address", () => { + const result = restoreStakeKeysFromAddresses({ + ...argsBase, + signersAddresses: [baseA, keyHashToEnterpriseAddress(hashB, 0)], + expectedStakeCredentialHash: expected, + }); + expect(result.restored).toBe(false); + expect(result.signersStakeKeys).toEqual(["", ""]); + expect(result.stakeCredentialHash).toBe(expected); + }); + + it("falls back when the derived hash does not match", () => { + const result = restoreStakeKeysFromAddresses({ + ...argsBase, + signersAddresses: [baseA, baseB], + expectedStakeCredentialHash: "0".repeat(56), + }); + expect(result.restored).toBe(false); + expect(result.stakeCredentialHash).toBe("0".repeat(56)); + }); + + it("falls back for script-stake base addresses and missing expectations", () => { + const scriptStakeBase = serializeAddressObj( + pubKeyAddress(hashA, stakeHashA, true), + 0, + ); + const withScriptStake = restoreStakeKeysFromAddresses({ + ...argsBase, + signersAddresses: [scriptStakeBase, baseB], + expectedStakeCredentialHash: expected, + }); + expect(withScriptStake.restored).toBe(false); + + const noExpectation = restoreStakeKeysFromAddresses({ + ...argsBase, + signersAddresses: [baseA, baseB], + expectedStakeCredentialHash: null, + }); + expect(noExpectation.restored).toBe(false); + expect(noExpectation.stakeCredentialHash).toBeNull(); + }); +}); diff --git a/src/__tests__/cip146Registration.test.ts b/src/__tests__/cip146Registration.test.ts new file mode 100644 index 00000000..4f8f6186 --- /dev/null +++ b/src/__tests__/cip146Registration.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "@jest/globals"; +import { MultisigWallet } from "../utils/multisigSDK"; +import { + buildLabel1854Metadata, + chunkMetadataString, + isRegistrationUpToDate, + joinMetadataString, + participantsMatchExactly, + truncateMetadataString, +} from "../utils/cip146Registration"; + +const hashA = "a".repeat(56); +const hashB = "b".repeat(56); +const hashC = "c".repeat(56); + +function makeWallet(name = "Team Wallet", description = "Shared funds") { + return new MultisigWallet( + name, + [ + { keyHash: hashA, role: 0, name: "Alice" }, + { keyHash: hashB, role: 0, name: "Bob" }, + ], + description, + 2, + 0, + ); +} + +describe("chunkMetadataString", () => { + it("returns the string unchanged when it fits in 64 bytes", () => { + expect(chunkMetadataString("short name")).toBe("short name"); + }); + + it("chunks long strings into <=64-byte pieces that round-trip", () => { + const long = "x".repeat(200); + const chunks = chunkMetadataString(long); + expect(Array.isArray(chunks)).toBe(true); + for (const chunk of chunks as string[]) { + expect(new TextEncoder().encode(chunk).length).toBeLessThanOrEqual(64); + } + expect(joinMetadataString(chunks)).toBe(long); + }); + + it("chunks by byte length without splitting multi-byte characters", () => { + // Each emoji is 4 UTF-8 bytes; 40 emoji = 160 bytes. + const emoji = "🚀".repeat(40); + const chunks = chunkMetadataString(emoji) as string[]; + expect(Array.isArray(chunks)).toBe(true); + for (const chunk of chunks) { + expect(new TextEncoder().encode(chunk).length).toBeLessThanOrEqual(64); + // No broken surrogate pairs: re-encoding must be lossless. + expect(joinMetadataString([chunk])).toBe(chunk); + } + expect(joinMetadataString(chunks)).toBe(emoji); + }); +}); + +describe("truncateMetadataString", () => { + it("truncates to at most 64 bytes on a character boundary", () => { + const long = "é".repeat(50); // 2 bytes each = 100 bytes + const truncated = truncateMetadataString(long); + expect(new TextEncoder().encode(truncated).length).toBeLessThanOrEqual(64); + expect(truncated).toBe("é".repeat(32)); + }); +}); + +describe("joinMetadataString", () => { + it("passes strings through and joins arrays", () => { + expect(joinMetadataString("abc")).toBe("abc"); + expect(joinMetadataString(["ab", "cd"])).toBe("abcd"); + expect(joinMetadataString(undefined)).toBe(""); + expect(joinMetadataString(42)).toBe(""); + }); +}); + +describe("buildLabel1854Metadata", () => { + it("builds the CIP-0146 payload from the wallet", () => { + const payload = buildLabel1854Metadata(makeWallet()); + expect(payload.types).toEqual([0]); + expect(payload.name).toBe("Team Wallet"); + expect(payload.description).toBe("Shared funds"); + expect(payload.participants).toEqual({ + [hashA]: { name: "Alice" }, + [hashB]: { name: "Bob" }, + }); + }); + + it("chunks long name/description and truncates participant names", () => { + const longName = "n".repeat(100); + const wallet = new MultisigWallet( + longName, + [{ keyHash: hashA, role: 0, name: "p".repeat(100) }], + "d".repeat(150), + 1, + 0, + ); + const payload = buildLabel1854Metadata(wallet); + expect(Array.isArray(payload.name)).toBe(true); + expect(joinMetadataString(payload.name)).toBe(longName); + expect(Array.isArray(payload.description)).toBe(true); + expect(payload.participants![hashA]!.name).toBe("p".repeat(64)); + }); + + it("omits empty name and description", () => { + const wallet = new MultisigWallet( + "", + [{ keyHash: hashA, role: 0, name: "Alice" }], + "", + 1, + 0, + ); + const payload = buildLabel1854Metadata(wallet); + expect(payload.name).toBeUndefined(); + expect(payload.description).toBeUndefined(); + }); +}); + +describe("participantsMatchExactly", () => { + const item = { + tx_hash: "1".repeat(64), + json_metadata: { + types: [0], + participants: { + [hashA.toUpperCase()]: { name: "Alice" }, + [hashB]: { name: "Bob" }, + }, + }, + }; + + it("matches an identical participant set case-insensitively", () => { + expect(participantsMatchExactly(item, [hashA, hashB])).toBe(true); + }); + + it("rejects subsets and supersets", () => { + expect(participantsMatchExactly(item, [hashA])).toBe(false); + expect(participantsMatchExactly(item, [hashA, hashB, hashC])).toBe(false); + }); + + it("rejects items without participants", () => { + expect( + participantsMatchExactly({ tx_hash: "2".repeat(64) }, [hashA]), + ).toBe(false); + }); +}); + +describe("isRegistrationUpToDate", () => { + it("recognizes matching on-chain metadata, including chunked strings", () => { + const wallet = makeWallet("A Very Long Wallet Name", "desc"); + const payload = buildLabel1854Metadata(wallet); + const item = { tx_hash: "3".repeat(64), json_metadata: payload }; + expect(isRegistrationUpToDate(item, wallet)).toBe(true); + }); + + it("detects a changed name", () => { + const wallet = makeWallet(); + const payload = buildLabel1854Metadata(wallet); + const item = { + tx_hash: "4".repeat(64), + json_metadata: { ...payload, name: "Old Name" }, + }; + expect(isRegistrationUpToDate(item, wallet)).toBe(false); + }); + + it("detects a changed participant name", () => { + const wallet = makeWallet(); + const item = { + tx_hash: "5".repeat(64), + json_metadata: { + types: [0], + name: "Team Wallet", + description: "Shared funds", + participants: { + [hashA]: { name: "Alice" }, + [hashB]: { name: "Robert" }, + }, + }, + }; + expect(isRegistrationUpToDate(item, wallet)).toBe(false); + }); +}); diff --git a/src/__tests__/claimBot.test.ts b/src/__tests__/claimBot.test.ts new file mode 100644 index 00000000..8f6f204b --- /dev/null +++ b/src/__tests__/claimBot.test.ts @@ -0,0 +1,105 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; + +/** + * Unit tests for performClaim's handling of address-less registrations: + * a PendingBot without a paymentAddress must not create a BotUser at claim + * time (that happens at the bot's first botAuth) and must report botId null. + */ + +let performClaim: typeof import("../lib/auth/claimBot").performClaim; +let sha256: (input: string) => string; + +const findPendingBotMock: jest.Mock = jest.fn(); +const createBotKeyMock: jest.Mock = jest.fn(); +const createBotUserMock: jest.Mock = jest.fn(); +const updatePendingBotMock: jest.Mock = jest.fn(); +const updateClaimTokenMock: jest.Mock = jest.fn(); + +const tx = { + pendingBot: { findUnique: findPendingBotMock, update: updatePendingBotMock }, + botKey: { create: createBotKeyMock }, + botUser: { create: createBotUserMock }, + botClaimToken: { update: updateClaimTokenMock }, +} as any; + +const CLAIM_CODE = "claim-code-0123456789abcdef"; + +function pendingBotFixture(overrides: Record = {}) { + return { + id: "pending-1", + name: "Test Bot", + paymentAddress: null, + stakeAddress: null, + requestedScopes: JSON.stringify(["multisig:read"]), + status: "UNCLAIMED", + expiresAt: new Date(Date.now() + 60_000), + claimToken: { + id: "token-1", + tokenHash: sha256(CLAIM_CODE), + attempts: 0, + expiresAt: new Date(Date.now() + 60_000), + consumedAt: null, + }, + ...overrides, + }; +} + +beforeAll(async () => { + process.env.JWT_SECRET ??= "test-secret-for-claim-bot"; + ({ performClaim } = await import("../lib/auth/claimBot")); + ({ sha256 } = await import("../lib/auth/botKey")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + createBotKeyMock.mockImplementation(async (args: any) => ({ id: "botkey-1", ...args.data })); + createBotUserMock.mockImplementation(async (args: any) => ({ id: "botuser-1", ...args.data })); + (updatePendingBotMock as any).mockResolvedValue({}); + (updateClaimTokenMock as any).mockResolvedValue({}); +}); + +describe("performClaim", () => { + it("claims an address-less registration without creating a BotUser", async () => { + (findPendingBotMock as any).mockResolvedValue(pendingBotFixture()); + + const result = await performClaim(tx, { + pendingBotId: "pending-1", + claimCode: CLAIM_CODE, + approvedScopes: null, + ownerAddress: "addr_test1owner", + }); + + expect(createBotUserMock).not.toHaveBeenCalled(); + expect(result.botId).toBeNull(); + expect(result.botKeyId).toBe("botkey-1"); + // The claim itself still completes: bot marked CLAIMED with a secret. + expect(updatePendingBotMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "CLAIMED" }), + }), + ); + }); + + it("still creates the BotUser when the registration carried an address", async () => { + (findPendingBotMock as any).mockResolvedValue( + pendingBotFixture({ paymentAddress: "addr_test1qpbotclaimfixture0000000000000000000" }), + ); + + const result = await performClaim(tx, { + pendingBotId: "pending-1", + claimCode: CLAIM_CODE, + approvedScopes: null, + ownerAddress: "addr_test1owner", + }); + + expect(createBotUserMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + paymentAddress: "addr_test1qpbotclaimfixture0000000000000000000", + displayName: "Test Bot", + }), + }), + ); + expect(result.botId).toBe("botuser-1"); + }); +}); diff --git a/src/__tests__/completeTxWithFreshCostModels.test.ts b/src/__tests__/completeTxWithFreshCostModels.test.ts index 16b6f7cf..ec9a4372 100644 --- a/src/__tests__/completeTxWithFreshCostModels.test.ts +++ b/src/__tests__/completeTxWithFreshCostModels.test.ts @@ -96,8 +96,7 @@ jest.mock( Transaction: MockTransaction, hash_script_data: hashScriptDataMock, }, - }), - { virtual: true }, + }) ); jest.mock( @@ -108,8 +107,7 @@ jest.mock( NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD: "preprod-key", NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET: "mainnet-key", }, - }), - { virtual: true }, + }) ); describe("refreshScriptDataHash", () => { @@ -118,6 +116,10 @@ describe("refreshScriptDataHash", () => { costModelValues.length = 0; setScriptDataHashMock.mockClear(); hashScriptDataMock.mockClear(); + delete process.env.BLOCKFROST_API_KEY_PREPROD; + delete process.env.BLOCKFROST_API_KEY_MAINNET; + delete process.env.CI_BLOCKFROST_PREPROD_API_KEY; + delete process.env.CI_BLOCKFROST_MAINNET_API_KEY; MockTransaction.configure({ redeemerCount: 0 }); }); @@ -205,4 +207,34 @@ describe("refreshScriptDataHash", () => { ), ).toThrow(/cost_models_raw/); }); + + it("uses server-side Blockfrost env names when completing transactions", async () => { + process.env.BLOCKFROST_API_KEY_PREPROD = "server-preprod-key"; + const fetchMock = jest.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ cost_models_raw: { PlutusV3: [10, 20] } }), + } as Response); + const { completeTxWithFreshCostModels } = await import( + "@/lib/completeTxWithFreshCostModels" + ); + + await expect( + completeTxWithFreshCostModels( + { + complete: async () => "unsigned-tx-hex", + } as never, + 0, + ), + ).resolves.toBe("unsigned-tx-hex"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://cardano-preprod.blockfrost.io/api/v0/epochs/latest/parameters", + { + headers: { + project_id: "server-preprod-key", + }, + }, + ); + fetchMock.mockRestore(); + }); }); diff --git a/src/__tests__/createPendingMultisigTransaction.test.ts b/src/__tests__/createPendingMultisigTransaction.test.ts index 3d51ec08..0b583409 100644 --- a/src/__tests__/createPendingMultisigTransaction.test.ts +++ b/src/__tests__/createPendingMultisigTransaction.test.ts @@ -6,7 +6,7 @@ const submitTxMock = jest.fn<(txCbor: string) => Promise>(); jest.mock("@/utils/get-provider", () => ({ __esModule: true, getProvider: () => ({ submitTx: submitTxMock }), -}), { virtual: true }); +})); let createPendingMultisigTransaction: typeof import("@/lib/server/createPendingMultisigTransaction").createPendingMultisigTransaction; diff --git a/src/__tests__/createWallet.bot.test.ts b/src/__tests__/createWallet.bot.test.ts index 5611e0ce..dfbf44ce 100644 --- a/src/__tests__/createWallet.bot.test.ts +++ b/src/__tests__/createWallet.bot.test.ts @@ -24,33 +24,33 @@ jest.mock("@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsHeadersMock, 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/auth/botKey", () => ({ __esModule: true, parseScope: parseScopeMock, scopeIncludes: scopeIncludesMock, -}), { virtual: true }); +})); jest.mock("@meshsdk/core", () => ({ __esModule: true, resolvePaymentKeyHash: resolvePaymentKeyHashMock, resolveStakeKeyHash: resolveStakeKeyHashMock, serializeNativeScript: serializeNativeScriptMock, -}), { virtual: true }); +})); jest.mock("@/utils/multisigSDK", () => ({ __esModule: true, @@ -59,7 +59,7 @@ jest.mock("@/utils/multisigSDK", () => ({ return getScriptMock(); } }, -}), { virtual: true }); +})); jest.mock("@/server/db", () => ({ __esModule: true, @@ -68,7 +68,7 @@ jest.mock("@/server/db", () => ({ wallet: { create: createWalletMock }, walletBotAccess: { upsert: upsertWalletAccessMock }, }, -}), { virtual: true }); +})); let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; diff --git a/src/__tests__/deriveBlockedUtxoRefs.test.ts b/src/__tests__/deriveBlockedUtxoRefs.test.ts new file mode 100644 index 00000000..4006d788 --- /dev/null +++ b/src/__tests__/deriveBlockedUtxoRefs.test.ts @@ -0,0 +1,48 @@ +import { deriveBlockedUtxoRefs } from "@/utils/blockedUtxoRefs"; + +function pendingTx(id: string, refs: { txHash: string; txIndex: number }[]) { + return { + id, + txJson: JSON.stringify({ inputs: refs.map((txIn) => ({ txIn })) }), + }; +} + +describe("deriveBlockedUtxoRefs", () => { + const HASH_A = "a".repeat(64); + const HASH_B = "b".repeat(64); + + it("collects input refs from every pending transaction", () => { + const refs = deriveBlockedUtxoRefs([ + pendingTx("tx-1", [{ txHash: HASH_A, txIndex: 0 }]), + pendingTx("tx-2", [{ txHash: HASH_B, txIndex: 3 }]), + ]); + expect(refs).toEqual([ + { hash: HASH_A, index: 0 }, + { hash: HASH_B, index: 3 }, + ]); + }); + + it("frees the excluded transaction's inputs, keeping the rest blocked", () => { + const refs = deriveBlockedUtxoRefs( + [ + pendingTx("editing", [{ txHash: HASH_A, txIndex: 0 }]), + pendingTx("other", [{ txHash: HASH_B, txIndex: 1 }]), + ], + "editing", + ); + expect(refs).toEqual([{ hash: HASH_B, index: 1 }]); + }); + + it("tolerates malformed txJson and missing input refs", () => { + const refs = deriveBlockedUtxoRefs([ + { id: "bad-json", txJson: "{not json" }, + { id: "no-inputs", txJson: JSON.stringify({}) }, + { + id: "partial-ref", + txJson: JSON.stringify({ inputs: [{ txIn: { txHash: HASH_A } }] }), + }, + pendingTx("good", [{ txHash: HASH_B, txIndex: 2 }]), + ]); + expect(refs).toEqual([{ hash: HASH_B, index: 2 }]); + }); +}); diff --git a/src/__tests__/drepContext.test.ts b/src/__tests__/drepContext.test.ts new file mode 100644 index 00000000..1c24d330 --- /dev/null +++ b/src/__tests__/drepContext.test.ts @@ -0,0 +1,53 @@ +import { deriveDrepVoteContext } from "@/lib/governance/drep-context"; +import { MultisigWallet, type MultisigKey } from "@/utils/multisigSDK"; + +const PAYMENT_HASH = "a".repeat(56); +const DREP_HASH = "b".repeat(56); + +function walletWith(keys: MultisigKey[]): MultisigWallet { + return new MultisigWallet("Test", keys, "", 1, 0); +} + +const APP_WALLET = { dRepId: "drep1fallback", scriptCbor: "82fallback" }; + +describe("deriveDrepVoteContext", () => { + test("uses the wallet's role-3 DRep script when present", () => { + const wallet = walletWith([ + { keyHash: PAYMENT_HASH, role: 0, name: "P" }, + { keyHash: DREP_HASH, role: 3, name: "D" }, + ]); + const ctx = deriveDrepVoteContext(wallet, APP_WALLET); + expect(ctx).toBeDefined(); + expect(ctx!.dRepId.startsWith("drep")).toBe(true); + expect(ctx!.drepScriptCbor).toBe(wallet.getDRepScript()); + // Role-3 derivation must not silently return the app-wallet fallback. + expect(ctx!.dRepId).not.toBe(APP_WALLET.dRepId); + }); + + test("falls back to the payment script when there are no DRep keys", () => { + const wallet = walletWith([{ keyHash: PAYMENT_HASH, role: 0, name: "P" }]); + const ctx = deriveDrepVoteContext(wallet, undefined); + expect(ctx).toBeDefined(); + expect(ctx!.drepScriptCbor).toBe(wallet.getDRepScript()); + }); + + test("legacy wallets without a MultisigWallet use the app wallet", () => { + expect(deriveDrepVoteContext(undefined, APP_WALLET)).toEqual({ + dRepId: "drep1fallback", + drepScriptCbor: "82fallback", + }); + }); + + test("keyless wallet with app fallback returns the fallback, without → undefined", () => { + const keyless = walletWith([]); + expect(deriveDrepVoteContext(keyless, APP_WALLET)).toEqual({ + dRepId: "drep1fallback", + drepScriptCbor: "82fallback", + }); + expect(deriveDrepVoteContext(keyless, undefined)).toBeUndefined(); + expect(deriveDrepVoteContext(undefined, undefined)).toBeUndefined(); + expect( + deriveDrepVoteContext(undefined, { dRepId: null, scriptCbor: "x" }), + ).toBeUndefined(); + }); +}); diff --git a/src/__tests__/drepVotes.test.ts b/src/__tests__/drepVotes.test.ts new file mode 100644 index 00000000..878d4457 --- /dev/null +++ b/src/__tests__/drepVotes.test.ts @@ -0,0 +1,210 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMockResponse } from "./apiTestUtils"; + +const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean>(); + +jest.mock("@/lib/security/requestGuards", () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, +})); + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/governance/drepVotes")); +}); + +const DREP_ID = "drep1ygqgayvx8yzsaj9hprja3l6jy3v4px9z3u8uvecuvm3f92ce7mckx"; + +const koiosVote = (overrides: Record = {}) => ({ + proposal_id: "gov_action1aaa", + proposal_tx_hash: "aa".repeat(32), + proposal_index: 0, + vote_tx_hash: "bb".repeat(32), + block_time: 1_784_521_956, + vote: "No", + meta_url: "ipfs://bafyexample", + meta_hash: "cc".repeat(32), + ...overrides, +}); + +/** fetch mock that routes by URL substring; unmatched URLs 404. */ +function mockKoios(routes: Record unknown)>) { + global.fetch = jest.fn(async (input: unknown) => { + const url = String(input); + for (const [needle, result] of Object.entries(routes)) { + if (url.includes(needle)) { + const body = typeof result === "function" ? result() : result; + return { + ok: true, + status: 200, + json: async () => body, + }; + } + } + return { ok: false, status: 404, json: async () => ({}) }; + }) as never; +} + +function votesRequest(query: Record = {}): NextApiRequest { + return { + method: "GET", + query: { drepId: DREP_ID, network: "1", ...query }, + } as unknown as NextApiRequest; +} + +beforeEach(() => { + jest.clearAllMocks(); + applyRateLimitMock.mockReturnValue(true); +}); + +describe("drepVotes API", () => { + it("rejects non-GET requests", async () => { + const res = createMockResponse(); + await handler({ method: "POST", query: {} } as unknown as NextApiRequest, res); + expect(res.status).toHaveBeenCalledWith(405); + }); + + it("rejects a malformed drepId", async () => { + const res = createMockResponse(); + await handler(votesRequest({ drepId: "not-a-drep;rm -rf" }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("rejects an unknown network", async () => { + const res = createMockResponse(); + await handler(votesRequest({ network: "9" }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("stops when rate limited", async () => { + applyRateLimitMock.mockReturnValue(false); + global.fetch = jest.fn() as never; + const res = createMockResponse(); + await handler(votesRequest(), res); + expect(global.fetch).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("joins votes with proposal titles and normalizes types", async () => { + mockKoios({ + "/drep_votes": [ + koiosVote({ block_time: 100, vote: "yes" }), + koiosVote({ + proposal_id: "gov_action1bbb", + block_time: 200, + vote: "Abstain", + meta_url: null, + meta_hash: null, + }), + ], + "/proposal_list": [ + { + proposal_id: "gov_action1aaa", + proposal_type: "TreasuryWithdrawals", + title: "Fund the treasury thing", + }, + ], + }); + const res = createMockResponse(); + + await handler(votesRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + const body = (res.json as jest.Mock).mock.calls[0]?.[0] as { + drepId: string; + votes: Array>; + }; + expect(body.drepId).toBe(DREP_ID); + expect(body.votes).toHaveLength(2); + // Sorted newest first regardless of upstream order. + expect(body.votes[0]).toMatchObject({ + proposalId: "gov_action1bbb", + vote: "Abstain", + metaUrl: null, + proposalTitle: null, + proposalType: null, + }); + expect(body.votes[1]).toMatchObject({ + proposalId: "gov_action1aaa", + vote: "Yes", + metaUrl: "ipfs://bafyexample", + proposalTitle: "Fund the treasury thing", + proposalType: "treasury_withdrawals", + }); + expect(res.setHeader).toHaveBeenCalledWith( + "Cache-Control", + expect.stringContaining("s-maxage"), + ); + }); + + it("targets preprod Koios for network 0", async () => { + mockKoios({ "/drep_votes": [] }); + const res = createMockResponse(); + + await handler(votesRequest({ network: "0" }), res); + + expect(String((global.fetch as jest.Mock).mock.calls[0]?.[0])).toContain( + "https://preprod.koios.rest/api/v1/drep_votes", + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("paginates when a page comes back full", async () => { + const fullPage = Array.from({ length: 500 }, (_, i) => + koiosVote({ proposal_id: `gov_action1page${i}`, block_time: i }), + ); + let votesCall = 0; + mockKoios({ + "/drep_votes": () => (votesCall++ === 0 ? fullPage : [koiosVote()]), + "/proposal_list": [], + }); + const res = createMockResponse(); + + await handler(votesRequest(), res); + + const voteUrls = (global.fetch as jest.Mock).mock.calls + .map((c) => String(c[0])) + .filter((u) => u.includes("/drep_votes")); + expect(voteUrls).toHaveLength(2); + expect(voteUrls[1]).toContain("offset=500"); + const body = (res.json as jest.Mock).mock.calls[0]?.[0] as { + votes: unknown[]; + }; + expect(body.votes).toHaveLength(501); + }); + + it("still returns votes when the title join fails", async () => { + global.fetch = jest.fn(async (input: unknown) => { + const url = String(input); + if (url.includes("/drep_votes")) { + return { ok: true, status: 200, json: async () => [koiosVote()] }; + } + return { ok: false, status: 500, json: async () => ({}) }; + }) as never; + const res = createMockResponse(); + + await handler(votesRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + const body = (res.json as jest.Mock).mock.calls[0]?.[0] as { + votes: Array>; + }; + expect(body.votes).toHaveLength(1); + expect(body.votes[0]).toMatchObject({ proposalTitle: null, proposalType: null }); + }); + + it("returns 502 when Koios is unreachable", async () => { + global.fetch = jest.fn(async () => ({ + ok: false, + status: 503, + json: async () => ({}), + })) as never; + const res = createMockResponse(); + + await handler(votesRequest(), res); + + expect(res.status).toHaveBeenCalledWith(502); + }); +}); diff --git a/src/__tests__/freeUtxos.bot.test.ts b/src/__tests__/freeUtxos.bot.test.ts index d22bae78..ff4de4dd 100644 --- a/src/__tests__/freeUtxos.bot.test.ts +++ b/src/__tests__/freeUtxos.bot.test.ts @@ -15,6 +15,7 @@ const buildMultisigWalletMock: jest.Mock = jest.fn(); const addressToNetworkMock: jest.Mock = jest.fn(); const getProviderMock: jest.Mock = jest.fn(); const cachedFetchAddressUTxOsMock: jest.Mock = jest.fn(); +const fetchAddressUTxOsMock: jest.Mock = jest.fn(); const serializeNativeScriptMock: jest.Mock = jest.fn(); const decodeNativeScriptFromCborMock: jest.Mock = jest.fn(); const decodedToNativeScriptMock: jest.Mock = jest.fn(); @@ -38,6 +39,7 @@ jest.mock("@/lib/verifyJwt", () => ({ })); jest.mock("@/lib/auth/botAccess", () => ({ + BotAccessError: class extends Error { constructor(public status: number, message: string) { super(message); } }, __esModule: true, getBotWalletAccess: getBotWalletAccessMock, assertBotWalletAccess: assertBotWalletAccessMock, @@ -117,7 +119,8 @@ beforeEach(() => { decodedToNativeScriptMock.mockReturnValue({ type: "all", scripts: [] }); serializeNativeScriptMock.mockReturnValue({ address: "addr_test1canonicalwalletscript" }); addressToNetworkMock.mockReturnValue(0); - getProviderMock.mockReturnValue({ get: jest.fn() }); + (fetchAddressUTxOsMock as any).mockResolvedValue([{ input: { txHash: "direct", outputIndex: 1 } }]); + getProviderMock.mockReturnValue({ get: jest.fn(), fetchAddressUTxOs: fetchAddressUTxOsMock }); (cachedFetchAddressUTxOsMock as any).mockResolvedValue([ { input: { txHash: "a", outputIndex: 0 } }, ]); @@ -149,6 +152,39 @@ describe("freeUtxos bot API", () => { expect(res.json).toHaveBeenCalledWith([{ input: { txHash: "a", outputIndex: 0 } }]); }); + it("falls back to direct provider fetch when cached UTxO lookup fails", async () => { + (cachedFetchAddressUTxOsMock as any).mockRejectedValue(new Error("incremental cache unavailable")); + const req = { + method: "GET", + headers: makeBearerAuth(), + query: { walletId: "wallet-1", address: BOT_TEST_ADDRESS }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(fetchAddressUTxOsMock).toHaveBeenCalledWith("addr_test1walletscript"); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([{ input: { txHash: "direct", outputIndex: 1 } }]); + }); + + it("returns an empty array when the provider has no UTxOs for the script address", async () => { + (cachedFetchAddressUTxOsMock as any).mockRejectedValue({ + response: { data: { status_code: 404 } }, + }); + const req = { + method: "GET", + headers: makeBearerAuth(), + query: { walletId: "wallet-1", address: BOT_TEST_ADDRESS }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([]); + }); + it("falls back to canonical scriptCbor when multisig wallet is unavailable", async () => { buildMultisigWalletMock.mockReturnValue(undefined); (assertBotWalletAccessMock as any).mockResolvedValue({ diff --git a/src/__tests__/governanceActiveProposals.test.ts b/src/__tests__/governanceActiveProposals.test.ts index 6a0c6d7a..8ae604c1 100644 --- a/src/__tests__/governanceActiveProposals.test.ts +++ b/src/__tests__/governanceActiveProposals.test.ts @@ -5,9 +5,11 @@ 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 applyAddressRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, address: string) => boolean>(); const verifyJwtMock = jest.fn<() => unknown>(); const isBotJwtMock = jest.fn<() => boolean>(); const findBotUserMock = jest.fn<() => Promise>(); +const getProviderMock = jest.fn(); const providerGetMock = jest.fn<(path: string) => Promise>(); const parseScopeMock = jest.fn<(scope: string) => string[]>(); const scopeIncludesMock = jest.fn<(scopes: string[], required: string) => boolean>(); @@ -28,6 +30,7 @@ jest.unstable_mockModule( __esModule: true, applyRateLimit: applyRateLimitMock, applyBotRateLimit: applyBotRateLimitMock, + applyAddressRateLimit: applyAddressRateLimitMock, }), ); @@ -73,9 +76,7 @@ jest.unstable_mockModule( "@/utils/get-provider", () => ({ __esModule: true, - getProvider: () => ({ - get: providerGetMock, - }), + getProvider: getProviderMock, }), ); @@ -108,6 +109,7 @@ beforeEach(() => { jest.clearAllMocks(); applyRateLimitMock.mockReturnValue(true); applyBotRateLimitMock.mockReturnValue(true); + applyAddressRateLimitMock.mockReturnValue(true); corsMock.mockResolvedValue(undefined); verifyJwtMock.mockReturnValue({ address: "addr_test1", botId: "bot-1", type: "bot" }); isBotJwtMock.mockReturnValue(true); @@ -125,6 +127,9 @@ beforeEach(() => { id: "bot-1", botKey: { scope: JSON.stringify(["multisig:read", "governance:read"]) }, }); + getProviderMock.mockReturnValue({ + get: providerGetMock, + }); }); describe("governanceActiveProposals API", () => { @@ -135,12 +140,12 @@ describe("governanceActiveProposals API", () => { await handler(req, res); expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: "Unauthorized - Missing token" }); + expect(res.json).toHaveBeenCalledWith({ error: "Unauthorized - Missing or malformed Authorization header (expected: Bearer )" }); }); it("returns only active proposals and tolerates metadata 404", async () => { providerGetMock.mockImplementation(async (path) => { - if (path.startsWith("governance/proposals?")) { + if (path.startsWith("/governance/proposals?")) { return [ { tx_hash: "tx-active", @@ -162,7 +167,7 @@ describe("governanceActiveProposals API", () => { }, ]; } - if (path === "governance/proposals/tx-active/0") { + if (path === "/governance/proposals/tx-active/0") { return { ratified_epoch: null, enacted_epoch: null, @@ -173,7 +178,7 @@ describe("governanceActiveProposals API", () => { return_address: "addr_test1...", }; } - if (path === "governance/proposals/tx-ratified/1") { + if (path === "/governance/proposals/tx-ratified/1") { return { ratified_epoch: 530, enacted_epoch: null, @@ -184,7 +189,7 @@ describe("governanceActiveProposals API", () => { return_address: "addr_test1...", }; } - if (path === "governance/proposals/tx-active/0/metadata") { + if (path === "/governance/proposals/tx-active/0/metadata") { throw JSON.stringify({ data: { error: "Not Found", @@ -220,4 +225,369 @@ describe("governanceActiveProposals API", () => { authors: [], }); }); + + it("returns an empty proposal list when Blockfrost has no governance proposals", async () => { + providerGetMock.mockImplementation(async (path) => { + if (path.startsWith("/governance/proposals?")) { + throw { + response: { + data: { + error: "Not Found", + message: "The requested component has not been found.", + status_code: 404, + }, + }, + }; + } + return null; + }); + + const req = { + method: "GET", + headers: { authorization: "Bearer token" }, + query: { network: "0", count: "20", page: "1", order: "desc", details: "false" }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + const payload = (res.json as unknown as jest.Mock).mock.calls[0]?.[0] as any; + expect(payload).toMatchObject({ + proposals: [], + activeCount: 0, + sourceCount: 0, + network: "0", + details: false, + }); + }); + + it("still returns active proposals when optional details and metadata fetches fail", async () => { + providerGetMock.mockImplementation(async (path) => { + if (path.startsWith("/governance/proposals?")) { + return [ + { + tx_hash: "tx-active", + cert_index: 0, + governance_type: "info_action", + enacted_epoch: null, + dropped_epoch: null, + expired_epoch: null, + ratified_epoch: null, + }, + ]; + } + if (path === "/governance/proposals/tx-active/0") { + throw { + response: { + status: 500, + data: { status_code: 500 }, + }, + }; + } + if (path === "/governance/proposals/tx-active/0/metadata") { + throw { + response: { + status: 500, + data: { status_code: 500 }, + }, + }; + } + return null; + }); + + const req = { + method: "GET", + headers: { authorization: "Bearer token" }, + query: { network: "0", count: "20", page: "1", order: "desc", details: "false" }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + const payload = (res.json as unknown as jest.Mock).mock.calls[0]?.[0] as any; + expect(payload.proposals).toHaveLength(1); + expect(payload.proposals[0]).toMatchObject({ + proposalId: "tx-active#0", + title: null, + status: "active", + }); + expect(payload.activeCount).toBe(1); + }); + + it("falls back to direct Blockfrost REST when provider list fetch fails without a status", async () => { + providerGetMock.mockImplementation(async (path) => { + if (path.startsWith("/governance/proposals?")) { + throw new Error("Internal Server Error"); + } + if (path === "/governance/proposals/tx-active/0") { + return { + ratified_epoch: null, + enacted_epoch: null, + dropped_epoch: null, + expired_epoch: null, + expiration: 999, + deposit: "1000000", + return_address: "addr_test1...", + }; + } + if (path === "/governance/proposals/tx-active/0/metadata") { + throw { status: 404 }; + } + return null; + }); + const originalKey = process.env.BLOCKFROST_API_KEY_PREPROD; + process.env.BLOCKFROST_API_KEY_PREPROD = "preprod-key"; + const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify([ + { + tx_hash: "tx-active", + cert_index: 0, + governance_type: "info_action", + enacted_epoch: null, + dropped_epoch: null, + expired_epoch: null, + ratified_epoch: null, + }, + ]), + { status: 200 }, + ), + ); + + try { + const req = { + method: "GET", + headers: { authorization: "Bearer token" }, + query: { network: "0", count: "20", page: "1", order: "desc", details: "false" }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(fetchSpy).toHaveBeenCalledWith( + "https://cardano-preprod.blockfrost.io/api/v0/governance/proposals?count=20&page=1&order=desc", + expect.objectContaining({ + headers: expect.objectContaining({ project_id: "preprod-key" }), + }), + ); + expect(res.status).toHaveBeenCalledWith(200); + const payload = (res.json as unknown as jest.Mock).mock.calls[0]?.[0] as any; + expect(payload.proposals).toHaveLength(1); + expect(payload.activeCount).toBe(1); + } finally { + if (originalKey === undefined) { + delete process.env.BLOCKFROST_API_KEY_PREPROD; + } else { + process.env.BLOCKFROST_API_KEY_PREPROD = originalKey; + } + fetchSpy.mockRestore(); + } + }); + + it("falls back to direct Blockfrost REST when provider construction fails", async () => { + getProviderMock.mockImplementation(() => { + throw new TypeError("Cannot read properties of undefined (reading 'slice')"); + }); + const originalKey = process.env.BLOCKFROST_API_KEY_PREPROD; + process.env.BLOCKFROST_API_KEY_PREPROD = "preprod-key"; + const fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + const urlString = String(url); + if (urlString.includes("/governance/proposals?")) { + return new Response( + JSON.stringify([ + { + tx_hash: "tx-active", + cert_index: 0, + governance_type: "info_action", + enacted_epoch: null, + dropped_epoch: null, + expired_epoch: null, + ratified_epoch: null, + }, + ]), + { status: 200 }, + ); + } + if (urlString.includes("/governance/proposals/tx-active/0/metadata")) { + return new Response(JSON.stringify({ error: "Not Found", status_code: 404 }), { + status: 404, + }); + } + if (urlString.includes("/governance/proposals/tx-active/0")) { + return new Response( + JSON.stringify({ + ratified_epoch: null, + enacted_epoch: null, + dropped_epoch: null, + expired_epoch: null, + expiration: 999, + deposit: "1000000", + return_address: "addr_test1...", + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({ error: "Unexpected path" }), { status: 500 }); + }); + + try { + const req = { + method: "GET", + headers: { authorization: "Bearer token" }, + query: { network: "0", count: "20", page: "1", order: "desc", details: "false" }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + const payload = (res.json as unknown as jest.Mock).mock.calls[0]?.[0] as any; + expect(payload.proposals).toHaveLength(1); + expect(payload.activeCount).toBe(1); + } finally { + if (originalKey === undefined) { + delete process.env.BLOCKFROST_API_KEY_PREPROD; + } else { + process.env.BLOCKFROST_API_KEY_PREPROD = originalKey; + } + fetchSpy.mockRestore(); + } + }); + + it("rejects out-of-bounds or malformed query params", async () => { + const cases: Array> = [ + { count: "0" }, + { count: "-5" }, + { count: "101" }, + { count: "abc" }, + { page: "0" }, + { details: "maybe" }, + { includeRatified: "nah" }, + ]; + for (const query of cases) { + const req = { + method: "GET", + headers: { authorization: "Bearer token" }, + query: { network: "1", ...query }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + await handler(req, res); + expect(res.status).toHaveBeenCalledWith(400); + } + }); + + it("includes ratified proposals only when includeRatified=true and reports currentEpoch", async () => { + providerGetMock.mockImplementation(async (path) => { + if (path === "/epochs/latest") { + return { epoch: 644 }; + } + if (path.startsWith("/governance/proposals?")) { + return [ + { tx_hash: "tx-active", cert_index: 0, governance_type: "info_action" }, + { tx_hash: "tx-ratified", cert_index: 1, governance_type: "treasury_withdrawals" }, + ]; + } + if (path === "/governance/proposals/tx-active/0") { + return { ratified_epoch: null, enacted_epoch: null, dropped_epoch: null, expired_epoch: null, expiration: 646 }; + } + if (path === "/governance/proposals/tx-ratified/1") { + return { ratified_epoch: 644, enacted_epoch: null, dropped_epoch: null, expired_epoch: null, expiration: 645 }; + } + return null; + }); + + const baseReq = { + method: "GET", + headers: { authorization: "Bearer token" }, + }; + + const resDefault = createMockResponse(); + await handler( + { ...baseReq, query: { network: "1" } } as unknown as NextApiRequest, + resDefault, + ); + const bodyDefault = (resDefault.json as unknown as jest.Mock).mock.calls[0]?.[0] as any; + expect(bodyDefault.proposals).toHaveLength(1); + expect(bodyDefault.currentEpoch).toBe(644); + expect(bodyDefault.includeRatified).toBe(false); + + const resRatified = createMockResponse(); + await handler( + { ...baseReq, query: { network: "1", includeRatified: "true" } } as unknown as NextApiRequest, + resRatified, + ); + const bodyRatified = (resRatified.json as unknown as jest.Mock).mock.calls[0]?.[0] as any; + expect(bodyRatified.proposals).toHaveLength(2); + const statuses = bodyRatified.proposals.map((p: any) => p.status).sort(); + expect(statuses).toEqual(["active", "ratified"]); + }); + + describe("human (non-bot) callers", () => { + const asHuman = () => { + verifyJwtMock.mockReturnValue({ address: "addr_test1qphuman" }); + isBotJwtMock.mockReturnValue(false); + providerGetMock.mockImplementation(async (path) => + path.startsWith("governance/proposals?") ? [] : null, + ); + }; + + const humanRequest = () => + ({ + method: "GET", + headers: { authorization: "Bearer token" }, + query: { network: "1", count: "10", page: "1", order: "desc" }, + }) as unknown as NextApiRequest; + + it("allows a human JWT through — this is public chain data", async () => { + asHuman(); + const res = createMockResponse(); + + await handler(humanRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("meters humans per address, not per bot", async () => { + asHuman(); + const res = createMockResponse(); + + await handler(humanRequest(), res); + + expect(applyAddressRateLimitMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "addr_test1qphuman", + ); + // The bot budget must not be charged for a caller that has no bot id. + expect(applyBotRateLimitMock).not.toHaveBeenCalled(); + }); + + it("returns 429 when a human exceeds the address budget", async () => { + asHuman(); + applyAddressRateLimitMock.mockReturnValue(false); + const res = createMockResponse(); + + await handler(humanRequest(), res); + + expect(res.status).not.toHaveBeenCalledWith(200); + }); + + it("still enforces the bot scope gate for bot callers", async () => { + // The human path must not have opened a hole in the bot path. + findBotUserMock.mockResolvedValue({ + id: "bot-1", + botKey: { scope: JSON.stringify(["multisig:read"]) }, + }); + const res = createMockResponse(); + + await handler(humanRequest(), res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: "Insufficient scope: governance:read required", + }); + }); + }); }); diff --git a/src/__tests__/governanceRationale.test.ts b/src/__tests__/governanceRationale.test.ts new file mode 100644 index 00000000..261f9a82 --- /dev/null +++ b/src/__tests__/governanceRationale.test.ts @@ -0,0 +1,213 @@ +import { hashDrepAnchor } from "@meshsdk/core"; + +import { + buildRationaleJsonLd, + findBallotRowForVote, + uploadRationale, +} from "@/lib/governance/rationale"; + +/** + * Expected CIP-100 document, copied verbatim from the ballot editor's + * `constructJsonLdFromComment` (governance/ballot/ballot.tsx) — the shape + * parity assertion, since that builder isn't exported. + */ +function expectedDoc(comment: string) { + return { + "@context": { + CIP100: + "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0100/README.md#", + hashAlgorithm: "CIP100:hashAlgorithm", + body: { + "@id": "CIP100:body", + "@context": { + references: { + "@id": "CIP100:references", + "@container": "@set", + "@context": { + GovernanceMetadata: "CIP100:GovernanceMetadataReference", + Other: "CIP100:OtherReference", + label: "CIP100:reference-label", + uri: "CIP100:reference-uri", + referenceHash: { + "@id": "CIP100:referenceHash", + "@context": { + hashDigest: "CIP100:hashDigest", + hashAlgorithm: "CIP100:hashAlgorithm", + }, + }, + }, + }, + comment: "CIP100:comment", + externalUpdates: { + "@id": "CIP100:externalUpdates", + "@context": { + title: "CIP100:update-title", + uri: "CIP100:uri", + }, + }, + }, + }, + authors: { + "@id": "CIP100:authors", + "@container": "@set", + "@context": { + name: "http://xmlns.com/foaf/0.1/name", + witness: { + "@id": "CIP100:witness", + "@context": { + witnessAlgorithm: "CIP100:witnessAlgorithm", + publicKey: "CIP100:publicKey", + signature: "CIP100:signature", + }, + }, + }, + }, + }, + authors: [], + body: { comment }, + hashAlgorithm: "blake2b-256", + }; +} + +describe("buildRationaleJsonLd", () => { + test("matches the ballot editor's CIP-100 document shape, trimmed", () => { + expect(buildRationaleJsonLd(" we support this ")).toEqual( + expectedDoc("we support this"), + ); + }); + + test("hash covers the exact 2-space serialization that gets pinned", () => { + const doc = buildRationaleJsonLd("reasoning"); + const hash = hashDrepAnchor(doc as object); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + // A round-trip through the pinned bytes re-hashes identically — proves + // the uploaded document verifies against the attached anchorDataHash. + const pinnedBytes = JSON.stringify(doc, null, 2); + expect(hashDrepAnchor(JSON.parse(pinnedBytes) as object)).toBe(hash); + }); +}); + +describe("uploadRationale", () => { + const realFetch = global.fetch; + afterEach(() => { + global.fetch = realFetch; + }); + + test("pins the exact hashed serialization and returns the anchor", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + global.fetch = jest.fn(async (url: any, init: any) => { + calls.push({ url: String(url), init }); + return { + ok: true, + json: async () => ({ url: "ipfs://newcid", cid: "newcid", id: "1" }), + } as Response; + }) as any; + + const anchor = await uploadRationale(" reasoning "); + + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("/api/pinata-storage/put"); + const body = JSON.parse(String(calls[0]!.init.body)); + expect(body.pathname).toMatch(/^rationale\/rationale-\d+\.jsonld$/); + const doc = buildRationaleJsonLd("reasoning"); + expect(body.value).toBe(JSON.stringify(doc, null, 2)); + expect(anchor).toEqual({ + anchorUrl: "ipfs://newcid", + anchorDataHash: hashDrepAnchor(doc as object), + }); + }); + + test("surfaces the API error message on failure", async () => { + global.fetch = jest.fn(async () => ({ + ok: false, + status: 502, + json: async () => ({ error: "Pinata upload failed" }), + })) as any; + await expect(uploadRationale("text")).rejects.toThrow( + /Rationale upload failed: Pinata upload failed/, + ); + }); + + test("rejects when the response has no URL", async () => { + global.fetch = jest.fn(async () => ({ + ok: true, + json: async () => ({}), + })) as any; + await expect(uploadRationale("text")).rejects.toThrow(/no URL returned/); + }); +}); + +describe("findBallotRowForVote", () => { + const GOV_HASH = "a".repeat(64); + const vote = { + govActionTxHash: GOV_HASH, + govActionIndex: 2, + anchor: { anchorUrl: "ipfs://old", anchorDataHash: "b".repeat(64) }, + }; + + test("matches by old anchor URL", () => { + const ballots = [ + { id: "b1", items: ["x#0"], anchorUrls: ["", "ipfs://old"], anchorHashes: [] }, + ]; + expect(findBallotRowForVote(ballots, vote)).toEqual({ + ballotId: "b1", + index: 1, + }); + }); + + test("matches by old anchor hash alone", () => { + const ballots = [ + { id: "b1", anchorUrls: [], anchorHashes: ["b".repeat(64)] }, + ]; + expect(findBallotRowForVote(ballots, vote)).toEqual({ + ballotId: "b1", + index: 0, + }); + }); + + test("empty-string ballot entries never match", () => { + const ballots = [ + { id: "b1", items: [], anchorUrls: [""], anchorHashes: [""] }, + ]; + const anchorlessBallotVote = { + ...vote, + anchor: { anchorUrl: "", anchorDataHash: "" }, + }; + expect( + findBallotRowForVote(ballots, anchorlessBallotVote), + ).toBeUndefined(); + }); + + test("anchor-less vote falls back to proposal id match", () => { + const ballots = [ + { id: "b1", items: ["other#1"] }, + { id: "b2", items: ["skip#0", `${GOV_HASH}#2`] }, + ]; + expect( + findBallotRowForVote(ballots, { + govActionTxHash: GOV_HASH, + govActionIndex: 2, + }), + ).toEqual({ ballotId: "b2", index: 1 }); + }); + + test("first anchor match wins over later proposal-id matches", () => { + const ballots = [ + { id: "b1", items: [`${GOV_HASH}#2`], anchorUrls: ["ipfs://old"] }, + { id: "b2", items: [`${GOV_HASH}#2`], anchorUrls: ["ipfs://old"] }, + ]; + expect(findBallotRowForVote(ballots, vote)).toEqual({ + ballotId: "b1", + index: 0, + }); + }); + + test("no match returns undefined", () => { + expect( + findBallotRowForVote([], { + govActionTxHash: GOV_HASH, + govActionIndex: 0, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/__tests__/jestMockHygiene.test.ts b/src/__tests__/jestMockHygiene.test.ts new file mode 100644 index 00000000..15096f95 --- /dev/null +++ b/src/__tests__/jestMockHygiene.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "@jest/globals"; +import { existsSync, readFileSync, readdirSync, statSync } from "fs"; +import { join } from "path"; + +/** + * Structural guard: `jest.mock(..., { virtual: true })` must not be used on a + * module that actually exists. + * + * `virtual: true` is for modules with no file on disk. Applied to a real module + * it registers the mock under the bare specifier rather than the resolved path, + * so whether a given importer receives the mock or the real module depends on + * resolution order — which varies with load and test-file count. + * + * That produced a genuine intermittent failure (~1 run in 8): when the + * `@/server/api/root` mock in walletIds.bot.test.ts missed, the real tRPC root + * loaded, which pulls in `superjson`. superjson v2 is ESM-only + * (`"type": "module"`, a single ESM export, no CommonJS build) and the CJS jest + * project has no transform matching `.js`, so loading it always throws + * "Cannot use import statement outside a module". The mock miss was the + * variable; the superjson failure was deterministic once it happened. + * + * This test is cheap insurance against the pattern being copy-pasted back in. + */ + +const TEST_DIR = join(process.cwd(), "src", "__tests__"); +const SRC_DIR = join(process.cwd(), "src"); + +function testFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...testFiles(full)); + } else if (entry.endsWith(".test.ts") || entry.endsWith(".test.tsx")) { + out.push(full); + } + } + return out; +} + +/** Mirrors the `^@/(.*)$` moduleNameMapper plus jest's extension resolution. */ +function resolvesToRealFile(specifier: string): boolean { + if (!specifier.startsWith("@/")) return false; + const base = join(SRC_DIR, specifier.slice(2)); + return ( + [".ts", ".tsx", ".js"].some((ext) => existsSync(base + ext)) || + existsSync(join(base, "index.ts")) + ); +} + +describe("jest.mock hygiene", () => { + const files = testFiles(TEST_DIR); + + it("finds the test files to scan", () => { + expect(files.length).toBeGreaterThan(20); + }); + + it("never marks a real module as virtual", () => { + const offenders: string[] = []; + + for (const file of files) { + const source = readFileSync(file, "utf8"); + if (!source.includes("virtual: true")) continue; + + // Split on jest.mock( so each fragment is one call's arguments, letting a + // `virtual: true` be attributed to the specifier it belongs to. + for (const call of source.split(/jest\.mock\(/).slice(1)) { + const specifier = call.match(/^\s*["']([^"']+)["']/)?.[1]; + if (!specifier) continue; + if (!/\{\s*virtual:\s*true\s*\}/.test(call)) continue; + if (resolvesToRealFile(specifier)) { + offenders.push(`${file.replace(`${process.cwd()}/`, "")} -> ${specifier}`); + } + } + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/src/__tests__/lookupMultisigWallet.test.ts b/src/__tests__/lookupMultisigWallet.test.ts new file mode 100644 index 00000000..680c8872 --- /dev/null +++ b/src/__tests__/lookupMultisigWallet.test.ts @@ -0,0 +1,133 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMockResponse } from "./apiTestUtils"; + +const addCorsHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); +const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>(); +const providerGetMock: jest.Mock = jest.fn(); + +jest.mock("@/lib/cors", () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsHeadersMock, + cors: corsMock, +})); + +jest.mock("@/lib/security/requestGuards", () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, +})); + +jest.mock("@/utils/get-provider", () => ({ + __esModule: true, + getProvider: () => ({ + get: providerGetMock, + }), +})); + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/v1/lookupMultisigWallet")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + applyRateLimitMock.mockReturnValue(true); + corsMock.mockResolvedValue(undefined); +}); + +describe("lookupMultisigWallet API", () => { + it("returns an empty result when metadata label 1854 is not found", async () => { + (providerGetMock as any).mockRejectedValue({ + response: { + data: { + error: "Not Found", + status_code: 404, + }, + }, + }); + const req = { + method: "GET", + headers: {}, + query: { + pubKeyHashes: "0123456789abcdef0123456789abcdef0123456789abcdef01234567", + network: "0", + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([]); + }); + + it("pages through the label index and filters by participant hash", async () => { + const targetHash = + "0123456789abcdef0123456789abcdef0123456789abcdef01234567"; + const otherHash = + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba98"; + const matching = { + tx_hash: "a".repeat(64), + json_metadata: { + types: [0], + name: "Match", + participants: { [targetHash]: { name: "Alice" } }, + }, + }; + const nonMatching = { + tx_hash: "b".repeat(64), + json_metadata: { + types: [0], + participants: { [otherHash]: { name: "Bob" } }, + }, + }; + // Full first page (100 items) forces a second-page fetch; the match + // living on page 2 verifies the handler paginates past page 1. + const firstPage = Array.from({ length: 100 }, () => nonMatching); + (providerGetMock as any) + .mockResolvedValueOnce(firstPage) + .mockResolvedValueOnce([matching]); + + const req = { + method: "GET", + headers: {}, + query: { pubKeyHashes: targetHash, network: "0" }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(providerGetMock).toHaveBeenCalledTimes(2); + expect(providerGetMock).toHaveBeenNthCalledWith( + 1, + "/metadata/txs/labels/1854?page=1&count=100", + ); + expect(providerGetMock).toHaveBeenNthCalledWith( + 2, + "/metadata/txs/labels/1854?page=2&count=100", + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([matching]); + }); + + it("stops after the first short page", async () => { + (providerGetMock as any).mockResolvedValueOnce([]); + const req = { + method: "GET", + headers: {}, + query: { + pubKeyHashes: "0123456789abcdef0123456789abcdef0123456789abcdef01234567", + network: "1", + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(providerGetMock).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([]); + }); +}); diff --git a/src/__tests__/mcpConnections.test.ts b/src/__tests__/mcpConnections.test.ts new file mode 100644 index 00000000..bc187389 --- /dev/null +++ b/src/__tests__/mcpConnections.test.ts @@ -0,0 +1,326 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +/** + * The MCP connections router — listing and revoking OAuth grants from the + * profile page. The revoke path is an authorization boundary: it must act only + * on grants belonging to the wallet session making the request. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +type AnyAsyncMock = jest.Mock<(...args: any[]) => any>; + +const grantFindMany = jest.fn() as AnyAsyncMock; +const grantFindUnique = jest.fn() as AnyAsyncMock; +const grantDelete = jest.fn() as AnyAsyncMock; +const grantUpdate = jest.fn() as AnyAsyncMock; +const auditFindMany = jest.fn() as AnyAsyncMock; +const walletFindUnique = jest.fn() as AnyAsyncMock; +const clientFindMany = jest.fn() as AnyAsyncMock; +const tokenFindMany = jest.fn() as AnyAsyncMock; +const tokenUpdateMany = jest.fn() as AnyAsyncMock; +const transaction = jest.fn() as AnyAsyncMock; +const auditMock = jest.fn() as AnyAsyncMock; + +// ESM-mode mocking: this suite imports the tRPC router, which pulls superjson — +// ESM-only, and unusable in the CJS jest project. Registered in ESM_TESTS. +jest.unstable_mockModule("@/lib/observability/audit", () => ({ + __esModule: true, + audit: auditMock, +})); + +const ADDR = "addr1qpuser"; +const OTHER = "addr1qpsomeoneelse"; + +function ctx(session: { primaryWallet?: string | null; sessionWallets?: string[] }) { + // protectedProcedure gates on a non-empty sessionWallets (src/server/api/trpc.ts), + // and the real context always sets both together, so mirror that here. + const sessionWallets = + session.sessionWallets ?? (session.primaryWallet ? [session.primaryWallet] : []); + return { + ...session, + sessionWallets, + db: { + oAuthGrant: { + findMany: grantFindMany, + findUnique: grantFindUnique, + delete: grantDelete, + update: grantUpdate, + }, + auditLog: { findMany: auditFindMany }, + wallet: { findUnique: walletFindUnique }, + oAuthClient: { findMany: clientFindMany }, + oAuthRefreshToken: { findMany: tokenFindMany, updateMany: tokenUpdateMany }, + $transaction: transaction, + }, + }; +} + +let caller: (c: unknown) => any; + +beforeEach(async () => { + jest.clearAllMocks(); + transaction.mockImplementation(async (ops: unknown[]) => [undefined, { count: 2 }]); + grantUpdate.mockResolvedValue({}); + auditFindMany.mockResolvedValue([]); + walletFindUnique.mockResolvedValue({ + id: "w1", signersAddresses: [ADDR], ownerAddress: ADDR, + }); + grantDelete.mockResolvedValue({}); + tokenUpdateMany.mockResolvedValue({ count: 2 }); + const { mcpRouter } = await import("@/server/api/routers/mcp"); + caller = (c) => (mcpRouter as any).createCaller(c); +}); + +describe("listConnections", () => { + it("returns nothing when the address has approved no clients", async () => { + grantFindMany.mockResolvedValue([]); + const out = await caller(ctx({ primaryWallet: ADDR })).listConnections({ + requesterAddress: ADDR, + }); + expect(out).toEqual([]); + // No point querying clients or tokens when there are no grants. + expect(clientFindMany).not.toHaveBeenCalled(); + }); + + it("joins client metadata and counts only live sessions", async () => { + grantFindMany.mockResolvedValue([ + { + id: "g1", + clientId: "https://claude.ai/oauth/x", + subjectAddress: ADDR, + scopes: ["wallets:read"], + grantedAddresses: [ADDR], + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-02"), + }, + ]); + clientFindMany.mockResolvedValue([ + { clientId: "https://claude.ai/oauth/x", clientName: "Claude Code", clientUri: null, isMetadataUrl: true }, + ]); + tokenFindMany.mockResolvedValue([ + { clientId: "https://claude.ai/oauth/x", expiresAt: new Date(Date.now() + 60_000) }, + // Expired: still unrevoked in the DB, but must not count as active. + { clientId: "https://claude.ai/oauth/x", expiresAt: new Date(Date.now() - 60_000) }, + ]); + + const [conn] = await caller(ctx({ primaryWallet: ADDR })).listConnections({ + requesterAddress: ADDR, + }); + + expect(conn).toMatchObject({ + clientName: "Claude Code", + isMetadataUrl: true, + scopes: ["wallets:read"], + activeSessions: 1, + }); + }); + + it("scopes the query to the session address", async () => { + grantFindMany.mockResolvedValue([]); + await caller(ctx({ primaryWallet: ADDR })).listConnections({ requesterAddress: ADDR }); + expect(grantFindMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { subjectAddress: ADDR } }), + ); + }); + + it("labels a client the AS has no record of", async () => { + grantFindMany.mockResolvedValue([ + { + id: "g1", clientId: "gone", subjectAddress: ADDR, scopes: [], grantedAddresses: [ADDR], + createdAt: new Date(), updatedAt: new Date(), + }, + ]); + clientFindMany.mockResolvedValue([]); + tokenFindMany.mockResolvedValue([]); + const [conn] = await caller(ctx({ primaryWallet: ADDR })).listConnections({ + requesterAddress: ADDR, + }); + expect(conn.clientName).toBe("Unknown client"); + }); +}); + +describe("revokeConnection", () => { + it("deletes the grant and revokes its refresh tokens", async () => { + grantFindUnique.mockResolvedValue({ id: "g1", scopes: ["wallets:read"] }); + + const out = await caller(ctx({ primaryWallet: ADDR })).revokeConnection({ + clientId: "c1", + requesterAddress: ADDR, + }); + + expect(out).toEqual({ ok: true, refreshTokensRevoked: 2 }); + // Both writes go through one transaction — a deleted grant with live + // refresh tokens would let the client silently keep renewing. + expect(transaction).toHaveBeenCalled(); + expect(tokenUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { subjectAddress: ADDR, clientId: "c1", revokedAt: null }, + }), + ); + }); + + it("refuses an address the wallet session does not hold", async () => { + // The body is attacker-controlled; only the session decides who you are. + await expect( + caller(ctx({ primaryWallet: ADDR })).revokeConnection({ + clientId: "c1", + requesterAddress: OTHER, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("refuses when there is no wallet session at all", async () => { + await expect( + caller(ctx({ primaryWallet: null, sessionWallets: [] })).revokeConnection({ + clientId: "c1", + requesterAddress: ADDR, + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + + it("looks the grant up by (subject, client), not client alone", async () => { + grantFindUnique.mockResolvedValue({ id: "g1", scopes: [] }); + await caller(ctx({ sessionWallets: [ADDR] })).revokeConnection({ + clientId: "c1", + requesterAddress: ADDR, + }); + expect(grantFindUnique).toHaveBeenCalledWith({ + where: { subjectAddress_clientId: { subjectAddress: ADDR, clientId: "c1" } }, + }); + }); + + it("404s on a grant that does not exist", async () => { + grantFindUnique.mockResolvedValue(null); + await expect( + caller(ctx({ primaryWallet: ADDR })).revokeConnection({ + clientId: "nope", + requesterAddress: ADDR, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); +}); + +describe("updateConnectionScopes", () => { + it("narrows a grant and keeps refresh tokens in step", async () => { + grantFindUnique.mockResolvedValue({ + id: "g1", scopes: ["wallets:read", "governance:read", "ballots:write"], + }); + + const out = await caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({ + clientId: "c1", + requesterAddress: ADDR, + scopes: ["wallets:read"], + }); + + expect(out).toEqual({ ok: true, scopes: ["wallets:read"] }); + // Grant and refresh tokens move together, or a refresh would re-widen it. + expect(transaction).toHaveBeenCalled(); + expect(tokenUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: { scopes: ["wallets:read"] } }), + ); + }); + + it("normalises to catalogue order regardless of input order", async () => { + grantFindUnique.mockResolvedValue({ id: "g1", scopes: [] }); + const out = await caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({ + clientId: "c1", + requesterAddress: ADDR, + scopes: ["ballots:write", "wallets:read"], + }); + expect(out.scopes).toEqual(["wallets:read", "ballots:write"]); + }); + + it("refuses to empty a grant — revoking is the honest action", async () => { + grantFindUnique.mockResolvedValue({ id: "g1", scopes: ["wallets:read"] }); + await expect( + caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({ + clientId: "c1", requesterAddress: ADDR, scopes: [], + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("refuses an address the session does not hold", async () => { + await expect( + caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({ + clientId: "c1", requesterAddress: OTHER, scopes: ["wallets:read"], + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("404s on a grant that does not exist", async () => { + grantFindUnique.mockResolvedValue(null); + await expect( + caller(ctx({ primaryWallet: ADDR })).updateConnectionScopes({ + clientId: "nope", requesterAddress: ADDR, scopes: ["wallets:read"], + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); +}); + +describe("wallet activity", () => { + const row = (over: Record = {}) => ({ + id: "a1", + actorAddress: ADDR, + outcome: "success", + reason: null, + createdAt: new Date("2026-08-13T10:00:00Z"), + metadata: { tool: "multisig_list_wallets", client: "https://claude.ai/x", scope: "wallets:read", readOnly: true, status: 200, durationMs: 12 }, + ...over, + }); + + it("groups calls by client with counts and failures", async () => { + auditFindMany.mockResolvedValue([ + row(), + row({ id: "a2", metadata: { ...row().metadata, tool: "multisig_list_free_utxos" } }), + row({ id: "a3", outcome: "denied" }), + ]); + + const [client] = await caller(ctx({ primaryWallet: ADDR })).walletClients({ + walletId: "w1", + }); + + expect(client).toMatchObject({ + client: "https://claude.ai/x", + calls: 3, + failures: 1, + tools: ["multisig_list_free_utxos", "multisig_list_wallets"], + }); + }); + + it("only reads MCP tool rows for this wallet", async () => { + await caller(ctx({ primaryWallet: ADDR })).walletClients({ walletId: "w1" }); + expect(auditFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + action: "mcp.tool.called", + resourceType: "wallet", + resourceId: "w1", + }, + }), + ); + }); + + it("filters the drill-down to one client", async () => { + auditFindMany.mockResolvedValue([ + row(), + row({ id: "a2", metadata: { ...row().metadata, client: "other-client" } }), + ]); + const rows = await caller(ctx({ primaryWallet: ADDR })).walletToolUsage({ + walletId: "w1", + client: "other-client", + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ client: "other-client" }); + }); + + it("refuses a wallet the caller is not a signer of", async () => { + walletFindUnique.mockResolvedValue({ + id: "w1", signersAddresses: [OTHER], ownerAddress: OTHER, + }); + await expect( + caller(ctx({ primaryWallet: ADDR })).walletClients({ walletId: "w1" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); diff --git a/src/__tests__/mcpRoute.test.ts b/src/__tests__/mcpRoute.test.ts new file mode 100644 index 00000000..3ed4b49b --- /dev/null +++ b/src/__tests__/mcpRoute.test.ts @@ -0,0 +1,483 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { MCP_SCOPES } from "@/lib/mcp/scopes"; + +/** + * Drives the real MCP protocol through the real route. + * + * The SDK is deliberately NOT mocked here — the things most likely to break are + * the transport wiring and the Node<->web bridge, and a mocked SDK would prove + * nothing about either. + */ + +const addCorsHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const applyStrictRateLimitMock = jest.fn<() => boolean>(); +const applyBotRateLimitMock = jest.fn<() => boolean>(); +const applyAddressRateLimitMock = jest.fn<() => boolean>(); +const enforceBodySizeMock = jest.fn<() => boolean>(); +const verifyJwtMock: jest.Mock = jest.fn(); +const isBotJwtMock: jest.Mock = jest.fn(); +const findBotUserMock: jest.Mock = jest.fn(); +const grantFindUniqueMock: jest.Mock = jest.fn(); +const auditCreateMock: jest.Mock = jest.fn(); + +jest.mock("@/lib/cors", () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsHeadersMock, + cors: jest.fn(), +})); + +jest.mock("@/lib/security/requestGuards", () => ({ + __esModule: true, + applyStrictRateLimit: applyStrictRateLimitMock, + applyBotRateLimit: applyBotRateLimitMock, + applyAddressRateLimit: applyAddressRateLimitMock, + enforceBodySize: enforceBodySizeMock, +})); + +jest.mock("@/lib/verifyJwt", () => ({ + __esModule: true, + verifyJwt: verifyJwtMock, + isBotJwt: isBotJwtMock, +})); + +jest.mock("@/server/db", () => ({ + __esModule: true, + db: { + botUser: { findUnique: findBotUserMock }, + oAuthGrant: { findUnique: grantFindUniqueMock }, + auditLog: { create: auditCreateMock }, + }, +})); + +const HUMAN_ADDRESS = "addr_test1qphuman000000000000000000000000000000"; + +type CapturedResponse = NextApiResponse & { + _status: number; + _headers: Record; + _chunks: string[]; + body: () => unknown; +}; + +function createResponse(): CapturedResponse { + const state = { + _status: 200, + _headers: {} as Record, + _chunks: [] as string[], + headersSent: false, + }; + + const res: Record = { + get _status() { + return state._status; + }, + get _headers() { + return state._headers; + }, + get _chunks() { + return state._chunks; + }, + get headersSent() { + return state.headersSent; + }, + setHeader(name: string, value: string) { + state._headers[name.toLowerCase()] = String(value); + return res; + }, + getHeader(name: string) { + return state._headers[name.toLowerCase()]; + }, + status(code: number) { + state._status = code; + return res; + }, + json(payload: unknown) { + state.headersSent = true; + state._chunks.push(JSON.stringify(payload)); + return res; + }, + writeHead(code: number, headers?: Record) { + state._status = code; + state.headersSent = true; + for (const [k, v] of Object.entries(headers ?? {})) { + state._headers[k.toLowerCase()] = String(v); + } + return res; + }, + write(chunk: Buffer | string) { + state._chunks.push(chunk.toString()); + return true; + }, + end(chunk?: Buffer | string) { + if (chunk) state._chunks.push(chunk.toString()); + state.headersSent = true; + return res; + }, + body() { + const raw = state._chunks.join(""); + if (!raw) return undefined; + // Legacy-era responses may arrive as an SSE frame rather than bare JSON. + const sse = raw.match(/^data: (.*)$/m); + try { + return JSON.parse(sse ? (sse[1] as string) : raw); + } catch { + return raw; + } + }, + }; + + return res as unknown as CapturedResponse; +} + +function createRequest(body: unknown, extraHeaders: Record = {}) { + return { + method: "POST", + url: "/api/mcp", + headers: { + host: "localhost:3000", + "content-type": "application/json", + accept: "application/json, text/event-stream", + authorization: "Bearer test-token", + ...extraHeaders, + }, + socket: { remoteAddress: "127.0.0.1" }, + query: {}, + body, + } as unknown as NextApiRequest; +} + +/** A modern-era (2026-07-28) request: envelope in the body, method in a header. */ +function modern(method: string, params: Record = {}, id = 1) { + const headers: Record = { "mcp-method": method }; + if (typeof params.name === "string") headers["mcp-name"] = params.name; + return { + headers, + body: { + jsonrpc: "2.0", + id, + method, + params: { + ...params, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + name: "jest", + version: "1.0.0", + }, + }, + }, + }, + }; +} + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/mcp/index")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + applyStrictRateLimitMock.mockReturnValue(true); + applyBotRateLimitMock.mockReturnValue(true); + applyAddressRateLimitMock.mockReturnValue(true); + enforceBodySizeMock.mockReturnValue(true); + verifyJwtMock.mockReturnValue({ address: HUMAN_ADDRESS }); + isBotJwtMock.mockReturnValue(false); + (auditCreateMock as any).mockResolvedValue({}); +}); + +describe("POST /api/mcp — transport", () => { + it("rejects GET with 405 (there are no sessions to resume)", async () => { + const req = createRequest(undefined); + (req as { method: string }).method = "GET"; + const res = createResponse(); + await handler(req, res); + expect(res._status).toBe(405); + }); + + it("rejects a browser-originated request", async () => { + // Origin present => a browser is driving this, a DNS-rebinding vector. + const req = createRequest(modern("tools/list").body, { + origin: "https://evil.example", + }); + const res = createResponse(); + await handler(req, res); + expect(res._status).toBe(403); + }); + + it("challenges an unauthenticated request per RFC 9728", async () => { + verifyJwtMock.mockReturnValue(null); + const { headers, body } = modern("tools/list"); + const res = createResponse(); + await handler(createRequest(body, headers), res); + + expect(res._status).toBe(401); + const challenge = res._headers["www-authenticate"] ?? ""; + expect(challenge).toMatch(/^Bearer /); + expect(challenge).toContain("resource_metadata="); + expect(challenge).toContain("/.well-known/oauth-protected-resource"); + // Clients request exactly the challenge's `scope`, not scopes_supported, so + // every grantable scope must appear here or its tools are unreachable in + // practice however the user connects. Leaving ballots:write out is what + // hid the two ballot tools from every real client; the consent screen, not + // this header, is where a scope gets withheld. + for (const scope of MCP_SCOPES) { + expect(challenge).toContain(scope); + } + }); + + it("serves tools/list on the modern protocol era", async () => { + const { headers, body } = modern("tools/list"); + const res = createResponse(); + await handler(createRequest(body, headers), res); + + expect(res._status).toBe(200); + const payload = res.body() as { + result?: { tools?: { name: string; inputSchema: unknown }[] }; + }; + const names = payload.result?.tools?.map((t) => t.name) ?? []; + expect(names).toContain("multisig_whoami"); + expect(names).toContain("ballot_upsert"); + // JSON Schema must survive the fromJsonSchema round-trip. + const whoami = payload.result?.tools?.find( + (t) => t.name === "multisig_whoami", + ); + expect(whoami?.inputSchema).toMatchObject({ type: "object" }); + }); + + it("serves the legacy 2025 initialize handshake", async () => { + const res = createResponse(); + await handler( + createRequest({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "jest", version: "1.0.0" }, + }, + }), + res, + ); + + expect(res._status).toBe(200); + const payload = res.body() as { + result?: { serverInfo?: { name: string }; protocolVersion?: string }; + }; + expect(payload.result?.serverInfo?.name).toBe("mesh-multisig"); + expect(payload.result?.protocolVersion).toBe("2025-06-18"); + }); + + it("survives two sequential requests", async () => { + // A transport is single-use. Hoisting it to module scope would fail here and + // ONLY here — the first request would look perfectly healthy. + const first = createResponse(); + const second = createResponse(); + const { headers, body } = modern("tools/list"); + + await handler(createRequest(body, headers), first); + await handler(createRequest(body, headers), second); + + expect(first._status).toBe(200); + expect(second._status).toBe(200); + expect(second.body()).toEqual(first.body()); + }); +}); + +describe("POST /api/mcp — per-principal metering", () => { + // The IP limit is keyed on a spoofable x-forwarded-for and is shared across + // everyone behind one address, so it is not a ceiling for any single caller. + // Every identity kind must also be metered on its own principal. + it("meters a non-bot caller by address", async () => { + const { headers, body } = modern("tools/list"); + await handler(createRequest(body, headers), createResponse()); + + expect(applyAddressRateLimitMock).toHaveBeenCalled(); + }); + + it("refuses the request when the address budget is exhausted", async () => { + applyAddressRateLimitMock.mockReturnValue(false); + const { headers, body } = modern("tools/list"); + const res = createResponse(); + await handler(createRequest(body, headers), res); + + // The guard writes its own 429; the MCP handler must not run afterwards. + expect(res.body()).toBeUndefined(); + }); + + it("meters a bot caller by botId instead", async () => { + isBotJwtMock.mockReturnValue(true); + verifyJwtMock.mockReturnValue({ + address: "addr_test1qpbot", + botId: "bot-1", + type: "bot", + }); + (findBotUserMock as any).mockResolvedValue({ + id: "bot-1", + paymentAddress: "addr_test1qpbot", + displayName: null, + botKey: { name: "Reader", scope: JSON.stringify(["multisig:read"]) }, + }); + + const { headers, body } = modern("tools/list"); + await handler(createRequest(body, headers), createResponse()); + + expect(applyBotRateLimitMock).toHaveBeenCalled(); + expect(applyAddressRateLimitMock).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/mcp — scope filtering", () => { + it("hides out-of-scope tools from a bot with only multisig:read", async () => { + isBotJwtMock.mockReturnValue(true); + verifyJwtMock.mockReturnValue({ + address: "addr_test1qpbot", + botId: "bot-1", + type: "bot", + }); + (findBotUserMock as any).mockResolvedValue({ + id: "bot-1", + paymentAddress: "addr_test1qpbot", + displayName: null, + botKey: { name: "Reader", scope: JSON.stringify(["multisig:read"]) }, + }); + + const { headers, body } = modern("tools/list"); + const res = createResponse(); + await handler(createRequest(body, headers), res); + + const payload = res.body() as { result?: { tools?: { name: string }[] } }; + const names = payload.result?.tools?.map((t) => t.name) ?? []; + expect(names).toContain("multisig_list_wallets"); + expect(names).not.toContain("ballot_upsert"); + expect(names).not.toContain("governance_list_active_proposals"); + }); +}); + +describe("POST /api/mcp — tools/call", () => { + it("answers multisig_whoami from the request context", async () => { + const { headers, body } = modern("tools/call", { + name: "multisig_whoami", + arguments: {}, + }); + const res = createResponse(); + await handler(createRequest(body, headers), res); + + expect(res._status).toBe(200); + const payload = res.body() as { + result?: { isError?: boolean; structuredContent?: Record }; + }; + expect(payload.result?.isError).toBe(false); + expect(payload.result?.structuredContent).toMatchObject({ + address: HUMAN_ADDRESS, + identityType: "wallet", + }); + }); + + it("rejects arguments that violate the tool's JSON Schema", async () => { + const { headers, body } = modern("tools/call", { + name: "multisig_list_pending_transactions", + arguments: { walletId: 42 }, + }); + const res = createResponse(); + await handler(createRequest(body, headers), res); + + const payload = res.body() as { + result?: { isError?: boolean; content?: { text: string }[] }; + }; + expect(payload.result?.isError).toBe(true); + // ajv, via fromJsonSchema — proves the schema is actually enforced rather + // than just advertised in tools/list. + expect(payload.result?.content?.[0]?.text).toContain( + "data/walletId must be string", + ); + }); + + it("returns the v1 handler's own error rather than throwing", async () => { + // A wallet the caller cannot see must come back as a readable tool error the + // model can act on, not a transport failure. + const { headers, body } = modern("tools/call", { + name: "multisig_list_wallets", + arguments: {}, + }); + const res = createResponse(); + await handler(createRequest(body, headers), res); + + expect(res._status).toBe(200); + const payload = res.body() as { result?: { isError?: boolean } }; + expect(typeof payload.result?.isError).toBe("boolean"); + }); +}); + +describe("POST /api/mcp — the stored grant is authoritative", () => { + // Access tokens are self-contained and live an hour. If the token's `scope` + // claim were trusted on its own, revoking a connection or removing a + // permission in the profile would not take effect until it expired. + const OAUTH_SUBJECT = "addr_test1qpoauth"; + + function oauthToken(scopes: string[]) { + const jwt = require("jsonwebtoken") as typeof import("jsonwebtoken"); + return jwt.sign( + { + sub: OAUTH_SUBJECT, + aud: "http://localhost:3000/api/mcp", + typ: "mcp_at", + cid: "https://claude.ai/x", + scope: scopes.join(" "), + addrs: [OAUTH_SUBJECT], + jti: "t1", + }, + process.env.JWT_SECRET as string, + { issuer: "http://localhost:3000", expiresIn: "1h" }, + ); + } + + const listWith = (token: string) => { + const { headers, body } = modern("tools/list"); + const res = createResponse(); + return handler( + createRequest(body, { ...headers, authorization: `Bearer ${token}` }), + res, + ).then(() => res); + }; + + it("401s when the grant has been revoked", async () => { + (grantFindUniqueMock as any).mockResolvedValue(null); + const res = await listWith(oauthToken(["wallets:read"])); + expect(res._status).toBe(401); + }); + + it("drops a permission removed from the grant, even though the token still claims it", async () => { + (grantFindUniqueMock as any).mockResolvedValue({ + scopes: ["wallets:read"], + grantedAddresses: [OAUTH_SUBJECT], + }); + + const res = await listWith(oauthToken(["wallets:read", "ballots:write"])); + + const payload = res.body() as { result?: { tools?: { name: string }[] } }; + const names = payload.result?.tools?.map((t) => t.name) ?? []; + expect(names).toContain("multisig_list_wallets"); + expect(names).not.toContain("ballot_upsert"); + }); + + it("never widens a token beyond what it was issued with", async () => { + // Grant widened after the token was minted: the token must not gain reach. + (grantFindUniqueMock as any).mockResolvedValue({ + scopes: ["wallets:read", "governance:read", "ballots:write"], + grantedAddresses: [OAUTH_SUBJECT], + }); + + const res = await listWith(oauthToken(["wallets:read"])); + + const payload = res.body() as { result?: { tools?: { name: string }[] } }; + const names = payload.result?.tools?.map((t) => t.name) ?? []; + expect(names).toContain("multisig_list_wallets"); + expect(names).not.toContain("ballot_upsert"); + expect(names).not.toContain("governance_open_proposals"); + }); +}); diff --git a/src/__tests__/mcpTools.test.ts b/src/__tests__/mcpTools.test.ts new file mode 100644 index 00000000..a7fe7eee --- /dev/null +++ b/src/__tests__/mcpTools.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "@jest/globals"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; + +import { MCP_TOOLS, toolsForScopes } from "@/lib/mcp/tools"; +import { MCP_SCOPES, isMcpScope, parseMcpScopes } from "@/lib/mcp/scopes"; +import { mcpScopesForBot } from "@/lib/mcp/auth"; +import { MCP_TOOL_SUMMARIES } from "@/data/mcp-tools"; +import { MCP_TOOL_ACTION } from "@/lib/mcp/server"; +import type { BotScope } from "@/lib/auth/botKey"; + +const V1_DIR = join(process.cwd(), "src", "pages", "api", "v1"); + +describe("MCP tool registry", () => { + it("has unique tool names", () => { + const names = MCP_TOOLS.map((t) => t.name); + expect(new Set(names).size).toBe(names.length); + }); + + it("keeps a stable, deterministic order", () => { + // tools/list order is caller-visible and feeds prompt caching, so it must + // not depend on object iteration or filesystem order. + expect(MCP_TOOLS.map((t) => t.name)).toEqual([ + "multisig_whoami", + "multisig_list_wallets", + "multisig_list_pending_transactions", + "multisig_list_free_utxos", + "multisig_list_proxies", + "multisig_proxy_drep_info", + "multisig_lookup_wallet", + "governance_list_active_proposals", + "governance_list_ballots", + "governance_vote_history", + "governance_open_proposals", + "ballot_upsert", + "ballot_publish_rationale", + ]); + }); + + it("only declares scopes from the published catalogue", () => { + for (const tool of MCP_TOOLS) { + expect(isMcpScope(tool.scope)).toBe(true); + } + }); + + it("points every wrapped tool at a v1 handler that exists", () => { + // A handler rename or deletion must break CI rather than 500 at runtime. + for (const tool of MCP_TOOLS) { + if (tool.v1Path === null) continue; + expect(existsSync(join(V1_DIR, tool.v1Path))).toBe(true); + } + }); + + it("exposes no tool that can sign, spend or broadcast", () => { + // The agreed boundary: agents may read, draft ballots, and publish a + // rationale to IPFS. Submitting a vote and signing stay with humans. Any + // further write tool must be a deliberate decision that updates this list, + // not a quiet registry addition. + const writable = MCP_TOOLS.filter((t) => !t.annotations.readOnlyHint); + expect(writable.map((t) => t.name)).toEqual([ + "ballot_upsert", + "ballot_publish_rationale", + ]); + // Neither write tool may be destructive: they add or replace drafts and + // anchors, they never remove a ballot or move value. + for (const tool of writable) { + expect(tool.annotations.destructiveHint).toBe(false); + } + + const forbidden = [ + "signTransaction", + "addTransaction", + "proxySpend", + "proxyVote", + "proxyCleanup", + "createWallet", + "botStakeCertificate", + "botDRepCertificate", + "submitDatum", + "exportWallet", + ]; + for (const tool of MCP_TOOLS) { + for (const banned of forbidden) { + expect(tool.v1Path ?? "").not.toContain(banned); + } + } + }); + + it("marks the ballot draft tool as non-destructive and idempotent", () => { + const ballot = MCP_TOOLS.find((t) => t.name === "ballot_upsert"); + expect(ballot?.annotations.destructiveHint).toBe(false); + expect(ballot?.annotations.idempotentHint).toBe(true); + }); + + it("gives every tool a closed input schema", () => { + // additionalProperties:false makes a mistyped argument fail loudly in ajv + // instead of being silently ignored. + for (const tool of MCP_TOOLS) { + expect(tool.inputSchema.type).toBe("object"); + expect(tool.inputSchema.additionalProperties).toBe(false); + expect(tool.description.length).toBeGreaterThan(20); + } + }); + + it("caps the governance page size", () => { + const tool = MCP_TOOLS.find( + (t) => t.name === "governance_list_active_proposals", + ); + const props = tool?.inputSchema.properties as Record< + string, + { maximum?: number } + >; + expect(props.count?.maximum).toBe(25); + }); +}); + +describe("scope filtering", () => { + it("hides tools the caller has no scope for", () => { + const names = toolsForScopes(["wallets:read"]).map((t) => t.name); + expect(names).toContain("multisig_list_wallets"); + expect(names).not.toContain("ballot_upsert"); + expect(names).not.toContain("governance_list_active_proposals"); + }); + + it("returns nothing for an empty scope set", () => { + expect(toolsForScopes([])).toHaveLength(0); + }); + + it("returns every tool for the full scope set", () => { + expect(toolsForScopes(MCP_SCOPES)).toHaveLength(MCP_TOOLS.length); + }); + + it("covers every tool with at least one grantable scope", () => { + const reachable = new Set( + MCP_SCOPES.flatMap((s) => toolsForScopes([s]).map((t) => t.name)), + ); + expect(reachable.size).toBe(MCP_TOOLS.length); + }); +}); + +describe("scope parsing", () => { + it("drops unknown scopes rather than failing", () => { + expect(parseMcpScopes("wallets:read bogus ballots:write")).toEqual([ + "wallets:read", + "ballots:write", + ]); + }); + + it("normalises to catalogue order regardless of input order", () => { + expect(parseMcpScopes("ballots:write wallets:read")).toEqual([ + "wallets:read", + "ballots:write", + ]); + }); + + it("treats empty input as no scopes", () => { + expect(parseMcpScopes("")).toEqual([]); + expect(parseMcpScopes(null)).toEqual([]); + }); +}); + +describe("bot scope projection", () => { + it("never grants MCP reach a bot key lacks over REST", () => { + expect(mcpScopesForBot([])).toEqual([]); + expect(mcpScopesForBot(["multisig:read"] as BotScope[])).toEqual([ + "wallets:read", + ]); + // multisig:sign must not imply any MCP scope — there is no signing surface. + expect(mcpScopesForBot(["multisig:sign"] as BotScope[])).toEqual([]); + }); + + it("maps the full bot scope set onto the full MCP set", () => { + const all = [ + "multisig:read", + "governance:read", + "ballot:write", + ] as BotScope[]; + expect(mcpScopesForBot(all)).toEqual([ + "wallets:read", + "governance:read", + "ballots:write", + ]); + }); +}); + +describe("published tool list (src/data/mcp-tools.ts)", () => { + // The landing page cannot import the real registry — it pulls the API + // handlers and the Mesh WASM with them — so the displayed list is a separate + // data file. This keeps the two honest. + it("lists exactly the registered tools, in the same order", () => { + expect(MCP_TOOL_SUMMARIES.map((t) => t.name)).toEqual( + MCP_TOOLS.map((t) => t.name), + ); + }); + + it("states the same scope the registry enforces", () => { + const actual = new Map(MCP_TOOLS.map((t) => [t.name, t.scope])); + for (const summary of MCP_TOOL_SUMMARIES) { + expect(summary.scope).toBe(actual.get(summary.name)); + } + }); + + it("gives every tool a blurb", () => { + for (const summary of MCP_TOOL_SUMMARIES) { + expect(summary.blurb.length).toBeGreaterThan(15); + } + }); +}); + +describe("audit action constant", () => { + // src/server/api/routers/mcp.ts hard-codes this string rather than importing + // it, because importing src/lib/mcp/server.ts would drag the MCP SDK and the + // whole tool registry into the tRPC bundle. If they drift, the wallet + // activity view silently returns nothing. + it("matches the literal the tRPC router queries on", () => { + const router = readFileSync( + join(process.cwd(), "src", "server", "api", "routers", "mcp.ts"), + "utf8", + ); + expect(router).toContain(`const MCP_TOOL_ACTION = "${MCP_TOOL_ACTION}"`); + }); +}); diff --git a/src/__tests__/mergeSignerWitnesses.test.ts b/src/__tests__/mergeSignerWitnesses.test.ts index baed18dc..d9f746d5 100644 --- a/src/__tests__/mergeSignerWitnesses.test.ts +++ b/src/__tests__/mergeSignerWitnesses.test.ts @@ -3,8 +3,11 @@ import { csl } from "@meshsdk/core-csl"; import { resolveTxHash } from "@meshsdk/core-cst"; import { - mergeSignerWitnesses, + addUniqueVkeyWitnessToTx, + createVkeyWitnessFromHex, + extractVkeyWitnesses, filterWitnessesToScripts, + mergeSignerWitnesses, } from "@/utils/txSignUtils"; function buildMinimalTxHex(): string { @@ -181,3 +184,98 @@ describe("filterWitnessesToScripts", () => { ); }); }); + +describe("extractVkeyWitnesses", () => { + it("reads vkeys from a full signed transaction payload", () => { + const txHex = buildMinimalTxHex(); + const signer = csl.PrivateKey.generate_ed25519(); + const sig = signer.sign(Buffer.from(resolveTxHash(txHex), "hex")); + const tx = csl.Transaction.from_hex(txHex); + const witnessSet = csl.TransactionWitnessSet.from_bytes( + tx.witness_set().to_bytes(), + ); + const vkeys = csl.Vkeywitnesses.new(); + vkeys.add(csl.Vkeywitness.new(csl.Vkey.new(signer.to_public()), sig)); + witnessSet.set_vkeys(vkeys); + const signedTxHex = csl.Transaction.new( + csl.TransactionBody.from_bytes(tx.body().to_bytes()), + witnessSet, + tx.auxiliary_data(), + ).to_hex(); + + const extracted = extractVkeyWitnesses(signedTxHex); + expect(extracted.len()).toBe(1); + expect( + Buffer.from(extracted.get(0).vkey().public_key().as_bytes()).toString("hex"), + ).toEqual(Buffer.from(signer.to_public().as_bytes()).toString("hex")); + }); + + it("falls back to parsing a witness-set-only payload (CIP-30 partial sign)", () => { + const signer = csl.PrivateKey.generate_ed25519(); + const payload = witnessSetHexFor(signer, "ab".repeat(32)); + + const extracted = extractVkeyWitnesses(payload); + expect(extracted.len()).toBe(1); + }); + + it("returns an empty set when the payload carries no vkeys", () => { + expect(extractVkeyWitnesses(buildMinimalTxHex()).len()).toBe(0); + expect( + extractVkeyWitnesses(csl.TransactionWitnessSet.new().to_hex()).len(), + ).toBe(0); + }); +}); + +describe("createVkeyWitnessFromHex", () => { + it("builds a witness whose key hash matches the public key", () => { + const signer = csl.PrivateKey.generate_ed25519(); + const bodyHash = "cd".repeat(32); + const signatureHex = signer.sign(Buffer.from(bodyHash, "hex")).to_hex(); + const keyHex = signer.to_public().to_hex(); + + const created = createVkeyWitnessFromHex(keyHex, signatureHex); + + expect(created.keyHashHex).toBe( + Buffer.from(signer.to_public().hash().to_bytes()).toString("hex"), + ); + expect(created.witness.signature().to_hex()).toBe(signatureHex); + expect( + created.publicKey.verify( + Buffer.from(bodyHash, "hex"), + created.signature, + ), + ).toBe(true); + }); +}); + +describe("addUniqueVkeyWitnessToTx", () => { + it("adds a new vkey witness while preserving the body bytes", () => { + const txHex = buildMinimalTxHex(); + const signer = csl.PrivateKey.generate_ed25519(); + const sig = signer.sign(Buffer.from(resolveTxHash(txHex), "hex")); + const witness = csl.Vkeywitness.new(csl.Vkey.new(signer.to_public()), sig); + + const result = addUniqueVkeyWitnessToTx(txHex, witness); + + expect(result.witnessAdded).toBe(true); + expect(result.vkeyWitnesses.len()).toBe(1); + expect(resolveTxHash(result.txHex)).toEqual(resolveTxHash(txHex)); + expect( + csl.Transaction.from_hex(result.txHex).witness_set().vkeys()?.len(), + ).toBe(1); + }); + + it("is a no-op when the same key hash is already witnessed", () => { + const txHex = buildMinimalTxHex(); + const signer = csl.PrivateKey.generate_ed25519(); + const sig = signer.sign(Buffer.from(resolveTxHash(txHex), "hex")); + const witness = csl.Vkeywitness.new(csl.Vkey.new(signer.to_public()), sig); + + const once = addUniqueVkeyWitnessToTx(txHex, witness); + const twice = addUniqueVkeyWitnessToTx(once.txHex, witness); + + expect(twice.witnessAdded).toBe(false); + expect(twice.txHex).toBe(once.txHex); + expect(twice.vkeyWitnesses.len()).toBe(1); + }); +}); diff --git a/src/__tests__/nativeScript.bot.test.ts b/src/__tests__/nativeScript.bot.test.ts index f0d55bc5..caed5c9f 100644 --- a/src/__tests__/nativeScript.bot.test.ts +++ b/src/__tests__/nativeScript.bot.test.ts @@ -15,43 +15,43 @@ jest.mock("@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsHeadersMock, cors: corsMock, -}), { virtual: true }); +})); jest.mock("@/lib/security/requestGuards", () => ({ __esModule: true, applyRateLimit: applyRateLimitMock, -}), { virtual: true }); +})); jest.mock("@/lib/verifyJwt", () => ({ __esModule: true, verifyJwt: verifyJwtMock, -}), { virtual: true }); +})); jest.mock("@/utils/common", () => ({ __esModule: true, buildMultisigWallet: buildMultisigWalletMock, -}), { virtual: true }); +})); jest.mock("@/server/api/root", () => ({ __esModule: true, createCaller: createCallerMock, -}), { virtual: true }); +})); jest.mock("@/server/db", () => ({ __esModule: true, db: {}, -}), { virtual: true }); +})); jest.mock("@/lib/security/rateLimit", () => ({ __esModule: true, getClientIP: () => "127.0.0.1", -}), { virtual: true }); +})); jest.mock("@/utils/nativeScriptUtils", () => ({ __esModule: true, decodeNativeScriptFromCbor: decodeNativeScriptFromCborMock, decodedToNativeScript: decodedToNativeScriptMock, -}), { virtual: true }); +})); let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; diff --git a/src/__tests__/normalizePoolId.test.ts b/src/__tests__/normalizePoolId.test.ts index ae050603..f0936253 100644 --- a/src/__tests__/normalizePoolId.test.ts +++ b/src/__tests__/normalizePoolId.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@jest/globals"; import { resolvePoolId } from "@meshsdk/core"; -import { normalizePoolIdForDelegation } from "@/lib/server/normalizePoolId"; +import { normalizePoolIdForDelegation } from "@/utils/normalizePoolId"; describe("normalizePoolIdForDelegation", () => { it("normalizes 56-char hex", () => { diff --git a/src/__tests__/notificationWorker.test.ts b/src/__tests__/notificationWorker.test.ts new file mode 100644 index 00000000..58110c14 --- /dev/null +++ b/src/__tests__/notificationWorker.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +jest.mock("@/lib/notifications/channels/email/resend", () => ({ + sendEmailViaResend: jest.fn(async () => ({ + provider: "resend", + messageId: "msg_1", + })), +})); + +import { + NOTIFICATION_EVENT_EMAIL_VERIFY, + NOTIFICATION_EVENT_SIGNATURE_REQUIRED, + NOTIFICATION_STATUS_PENDING, + NOTIFICATION_STATUS_SKIPPED_OPTED_OUT, +} from "@/lib/notifications/events"; +import { drainNotificationOutbox } from "@/lib/notifications/worker"; +import { sendEmailViaResend } from "@/lib/notifications/channels/email/resend"; + +const sendMock = sendEmailViaResend as jest.MockedFunction< + typeof sendEmailViaResend +>; + +function makeDelivery(overrides: Record) { + return { + id: "delivery_1", + eventType: NOTIFICATION_EVENT_SIGNATURE_REQUIRED, + channel: "email", + recipientAddress: "addr_1", + recipientEmail: "one@example.com", + resourceType: "transaction", + resourceId: "res_1", + walletId: "wallet_1", + subject: "Signature required", + payload: { html: "

    sign

    ", text: "sign" }, + status: NOTIFICATION_STATUS_PENDING, + attempts: 0, + nextAttemptAt: new Date(0), + ...overrides, + }; +} + +function makeSetting(overrides: Record) { + return { + walletId: "wallet_1", + signerAddress: "addr_1", + email: "one@example.com", + emailNormalized: "one@example.com", + emailVerifiedAt: new Date(), + emailOptIn: true, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + ...overrides, + }; +} + +function makeDb(deliveries: unknown[], settings: unknown[]) { + return { + notificationDelivery: { + findMany: jest.fn(async () => deliveries), + updateMany: jest.fn(async (_args: unknown) => ({ count: 1 })), + update: jest.fn( + async (args: { where: { id: string }; data: Record }) => ({ + id: args.where.id, + ...args.data, + }), + ), + }, + walletSignerNotificationSetting: { + findMany: jest.fn(async () => settings), + }, + }; +} + +describe("drainNotificationOutbox preference re-check", () => { + beforeEach(() => { + sendMock.mockClear(); + }); + + it("skips a queued signature email when the signer has since opted out", async () => { + const delivery = makeDelivery({ id: "delivery_opted_out" }); + const db = makeDb([delivery], [makeSetting({ emailOptIn: false })]); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).not.toHaveBeenCalled(); + expect(db.notificationDelivery.updateMany).toHaveBeenCalledWith({ + where: { id: "delivery_opted_out", status: NOTIFICATION_STATUS_PENDING }, + data: { status: NOTIFICATION_STATUS_SKIPPED_OPTED_OUT, lastError: null }, + }); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + id: "delivery_opted_out", + status: NOTIFICATION_STATUS_SKIPPED_OPTED_OUT, + }); + }); + + it("still sends when the signer remains fully eligible", async () => { + const delivery = makeDelivery({ id: "delivery_eligible" }); + const db = makeDb([delivery], [makeSetting({})]); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith({ + to: "one@example.com", + subject: "Signature required", + html: "

    sign

    ", + text: "sign", + }); + expect(results[0]).toMatchObject({ + id: "delivery_eligible", + status: "sent", + }); + }); + + it("sends verification emails regardless of opt-in state", async () => { + const delivery = makeDelivery({ + id: "delivery_verify", + eventType: NOTIFICATION_EVENT_EMAIL_VERIFY, + resourceType: "wallet", + }); + const db = makeDb( + [delivery], + [makeSetting({ emailOptIn: false, emailVerifiedAt: null })], + ); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(results[0]).toMatchObject({ + id: "delivery_verify", + status: "sent", + }); + }); + + it("skips per-type disabled deliveries for signable resources", async () => { + const delivery = makeDelivery({ + id: "delivery_signable", + resourceType: "signable", + }); + const db = makeDb( + [delivery], + [makeSetting({ notifySignableSignatures: false })], + ); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).not.toHaveBeenCalled(); + expect(results[0]).toMatchObject({ + id: "delivery_signable", + status: "skipped_disabled", + }); + }); + + it("treats a missing settings row as no email at send time", async () => { + const delivery = makeDelivery({ id: "delivery_missing" }); + const db = makeDb([delivery], []); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).not.toHaveBeenCalled(); + expect(results[0]).toMatchObject({ + id: "delivery_missing", + status: "skipped_no_email", + }); + }); +}); diff --git a/src/__tests__/notifications.test.ts b/src/__tests__/notifications.test.ts new file mode 100644 index 00000000..45cb9863 --- /dev/null +++ b/src/__tests__/notifications.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +import { + NOTIFICATION_STATUS_SKIPPED_DISABLED, + NOTIFICATION_STATUS_SKIPPED_NO_EMAIL, + NOTIFICATION_STATUS_SKIPPED_NOT_VERIFIED, + NOTIFICATION_STATUS_SKIPPED_OPTED_OUT, +} from "@/lib/notifications/events"; +import { resolveSignatureRecipients } from "@/lib/notifications/recipients"; +import { + maskAddress, + summarizeSignableSignatureContext, + summarizeTransactionSignatureContext, +} from "@/lib/notifications/signatureContext"; +import { renderSignatureRequiredEmail } from "@/lib/notifications/templates/signatureRequired"; + +describe("notification recipient resolution", () => { + it("returns only verified opted-in signers that still need to sign", async () => { + const db = { + walletSignerNotificationSetting: { + findMany: jest.fn(async () => [ + { + signerAddress: "addr_verified", + email: "Signer@Example.com", + emailNormalized: "signer@example.com", + emailVerifiedAt: new Date(), + emailOptIn: true, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + }, + { + signerAddress: "addr_unverified", + email: "unverified@example.com", + emailNormalized: "unverified@example.com", + emailVerifiedAt: null, + emailOptIn: true, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + }, + { + signerAddress: "addr_opted_out", + email: "out@example.com", + emailNormalized: "out@example.com", + emailVerifiedAt: new Date(), + emailOptIn: false, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + }, + { + signerAddress: "addr_disabled", + email: "disabled@example.com", + emailNormalized: "disabled@example.com", + emailVerifiedAt: new Date(), + emailOptIn: true, + notifyTransactionSignatures: false, + notifySignableSignatures: true, + }, + ]), + }, + }; + + const result = await resolveSignatureRecipients(db as any, { + walletId: "wallet_1", + signerAddresses: [ + "addr_creator", + "addr_signed", + "addr_rejected", + "addr_verified", + "addr_unverified", + "addr_opted_out", + "addr_disabled", + "addr_missing", + ], + resourceType: "transaction", + signedAddresses: ["addr_signed"], + rejectedAddresses: ["addr_rejected"], + creatorAddress: "addr_creator", + }); + + expect(result.eligible).toEqual([ + { + address: "addr_verified", + email: "Signer@Example.com", + emailNormalized: "signer@example.com", + }, + ]); + expect(result.skipped).toEqual( + expect.arrayContaining([ + { + address: "addr_unverified", + reason: NOTIFICATION_STATUS_SKIPPED_NOT_VERIFIED, + }, + { + address: "addr_opted_out", + reason: NOTIFICATION_STATUS_SKIPPED_OPTED_OUT, + }, + { + address: "addr_disabled", + reason: NOTIFICATION_STATUS_SKIPPED_DISABLED, + }, + { + address: "addr_missing", + reason: NOTIFICATION_STATUS_SKIPPED_NO_EMAIL, + }, + ]), + ); + }); + + it("applies the signable preference flag for signable resources", async () => { + const db = { + walletSignerNotificationSetting: { + findMany: jest.fn(async () => [ + { + signerAddress: "addr_signables_off", + email: "signables@example.com", + emailNormalized: "signables@example.com", + emailVerifiedAt: new Date(), + emailOptIn: true, + notifyTransactionSignatures: true, + notifySignableSignatures: false, + }, + ]), + }, + }; + + const result = await resolveSignatureRecipients(db as any, { + walletId: "wallet_1", + signerAddresses: ["addr_signables_off"], + resourceType: "signable", + }); + + expect(result.eligible).toEqual([]); + expect(result.skipped).toEqual([ + { + address: "addr_signables_off", + reason: NOTIFICATION_STATUS_SKIPPED_DISABLED, + }, + ]); + }); +}); + +describe("notification email templates", () => { + it("escapes dynamic values and returns html plus text bodies", () => { + const email = renderSignatureRequiredEmail({ + walletName: "", + resourceType: "transaction", + description: "", + signedCount: 1, + requiredCount: 2, + totalSigners: 3, + actionUrl: "https://example.com/sign?x=", + preferencesUrl: "https://example.com/preferences", + signatureContext: { + summary: "Send 50 ADA to addr_tes...3te2", + details: [{ label: "Outputs", value: "2 total outputs" }], + }, + }); + + expect(email.subject).toBe("Signature required: "); + expect(email.html).toContain("<Vault>"); + expect(email.html).toContain("<script>alert('x')</script>"); + expect(email.html).not.toContain("")).toBe(false); + expect(isAcceptableRedirectUri("vbscript:msgbox")).toBe(false); + expect(isAcceptableRedirectUri("file:///etc/passwd")).toBe(false); + expect(isAcceptableRedirectUri("not a url")).toBe(false); + }); + + it("rejects plaintext http to a non-loopback host", () => { + // Authorization codes must not travel in cleartext to an arbitrary host. + expect(isAcceptableRedirectUri("http://evil.example/cb")).toBe(false); + }); + + it("rejects a URI carrying a fragment", () => { + expect(isAcceptableRedirectUri("https://app.example/cb#frag")).toBe(false); + }); + + it("accepts https and loopback http", () => { + expect(isAcceptableRedirectUri("https://app.example/cb")).toBe(true); + expect(isAcceptableRedirectUri("http://127.0.0.1:8080/callback")).toBe(true); + expect(isAcceptableRedirectUri("http://localhost/callback")).toBe(true); + }); +}); + +describe("client id metadata documents", () => { + it("recognises an https URL with a path", () => { + expect(isMetadataUrlClientId("https://claude.ai/oauth/client-metadata")).toBe(true); + }); + + it("rejects non-https or path-less ids", () => { + expect(isMetadataUrlClientId("http://claude.ai/oauth/meta")).toBe(false); + expect(isMetadataUrlClientId("https://claude.ai")).toBe(false); + expect(isMetadataUrlClientId("mcp-1234")).toBe(false); + }); + + it("rejects a non-default port (SSRF port pinning) but accepts :443", () => { + // The CIMD fetch is an SSRF sink; an explicit port would let a client_id + // probe arbitrary services on any public host. + expect(isMetadataUrlClientId("https://claude.ai:8443/oauth/meta")).toBe(false); + // URL normalises ":443" to the default, so this stays acceptable. + expect(isMetadataUrlClientId("https://claude.ai:443/oauth/meta")).toBe(true); + }); +}); + +describe("access tokens", () => { + const mint = (over: Partial[0]> = {}) => + mintAccessToken({ + issuer: ISSUER, + resource: RESOURCE, + subject: "addr_test1qpuser", + clientId: "mcp-client", + scopes: ["wallets:read"], + addresses: ["addr_test1qpuser"], + ...over, + }).token; + + it("round-trips subject, scopes and addresses", () => { + const verified = verifyAccessToken(mint(), { issuer: ISSUER, resource: RESOURCE }); + expect(verified).toMatchObject({ + subject: "addr_test1qpuser", + clientId: "mcp-client", + scopes: ["wallets:read"], + addresses: ["addr_test1qpuser"], + }); + }); + + it("rejects a token minted for a different resource (RFC 8707)", () => { + // The MCP spec states this as a MUST: a token valid for another resource + // must not be accepted here, even with a good signature from this issuer. + const token = mint({ resource: "https://other.example/api/mcp" }); + expect(verifyAccessToken(token, { issuer: ISSUER, resource: RESOURCE })).toBeNull(); + }); + + it("rejects a token from a different issuer", () => { + const token = mint({ issuer: "https://evil.example" }); + expect(verifyAccessToken(token, { issuer: ISSUER, resource: RESOURCE })).toBeNull(); + }); + + describe("isolation from v1 bearer tokens", () => { + // Both families are signed with JWT_SECRET, so they must be distinguishable + // by claims alone or one could be replayed as the other. + it("is not accepted by the v1 JWT verifier", () => { + expect(verifyJwt(mint())).toBeNull(); + }); + + it("does not accept a v1 human JWT", () => { + const jwt = require("jsonwebtoken") as typeof import("jsonwebtoken"); + const v1Token = jwt.sign( + { address: "addr_test1qpuser" }, + process.env.JWT_SECRET as string, + { expiresIn: "1h" }, + ); + expect(verifyAccessToken(v1Token, { issuer: ISSUER, resource: RESOURCE })).toBeNull(); + }); + + it("does not accept a v1 bot JWT", () => { + const jwt = require("jsonwebtoken") as typeof import("jsonwebtoken"); + const botToken = jwt.sign( + { address: "addr_test1qpbot", botId: "bot-1", type: "bot" }, + process.env.JWT_SECRET as string, + { expiresIn: "1h" }, + ); + expect(verifyAccessToken(botToken, { issuer: ISSUER, resource: RESOURCE })).toBeNull(); + }); + + it("declares the discriminating type claim", () => { + expect(ACCESS_TOKEN_TYPE).toBe("mcp_at"); + }); + }); +}); diff --git a/src/__tests__/og.test.ts b/src/__tests__/og.test.ts index afa0a471..397e66a9 100644 --- a/src/__tests__/og.test.ts +++ b/src/__tests__/og.test.ts @@ -111,6 +111,28 @@ describe("og handler — SSRF defense", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("rejects an explicit non-default port even on an allowlisted host", async () => { + // Port pinning: without it, an allowlisted (or wildcarded) host could be + // used to probe arbitrary services, e.g. https://github.com:8443/. + envState.OG_ALLOWED_HOSTS = "github.com"; + const { default: handler } = await handlerPromise; + const { res, status, json } = makeRes(); + await handler(makeReq("https://github.com:8443/example"), res); + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringMatching(/port/i) })); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("accepts an explicit :443 (normalised to the default port)", async () => { + envState.OG_ALLOWED_HOSTS = "example.com"; + dnsLookupMock.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]); + fetchMock.mockResolvedValueOnce(new Response("", { status: 200 })); + const { default: handler } = await handlerPromise; + const { res, status } = makeRes(); + await handler(makeReq("https://example.com:443/page"), res); + expect(status).toHaveBeenCalledWith(200); + }); + it("rejects host not on the allowlist with 400", async () => { envState.OG_ALLOWED_HOSTS = "github.com,x.com"; const { default: handler } = await handlerPromise; diff --git a/src/__tests__/pendingTransactions.bot.test.ts b/src/__tests__/pendingTransactions.bot.test.ts index f2a45483..19cb9281 100644 --- a/src/__tests__/pendingTransactions.bot.test.ts +++ b/src/__tests__/pendingTransactions.bot.test.ts @@ -15,41 +15,42 @@ jest.mock("@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsHeadersMock, 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/auth/botAccess", () => ({ + BotAccessError: class extends Error { constructor(public status: number, message: string) { super(message); } }, __esModule: true, getBotWalletAccess: getBotWalletAccessMock, -}), { virtual: true }); +})); jest.mock("@/server/db", () => ({ __esModule: true, db: { transaction: { findMany: findPendingTransactionsMock }, }, -}), { virtual: true }); +})); jest.mock("@/server/api/root", () => ({ __esModule: true, createCaller: () => ({}), -}), { virtual: true }); +})); jest.mock("@/lib/security/rateLimit", () => ({ __esModule: true, getClientIP: () => "127.0.0.1", -}), { virtual: true }); +})); let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; diff --git a/src/__tests__/pendingTransactions.test.ts b/src/__tests__/pendingTransactions.test.ts index bef748cc..8a5cdafc 100644 --- a/src/__tests__/pendingTransactions.test.ts +++ b/src/__tests__/pendingTransactions.test.ts @@ -217,7 +217,7 @@ describe('pendingTransactions API route', () => { await handler(req, res); expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized - Missing token' }); + expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized - Missing or malformed Authorization header (expected: Bearer )' }); expect(verifyJwtMock).not.toHaveBeenCalled(); expect(createCallerMock).not.toHaveBeenCalled(); }); diff --git a/src/__tests__/proposalMetadata.test.ts b/src/__tests__/proposalMetadata.test.ts new file mode 100644 index 00000000..c3ee3786 --- /dev/null +++ b/src/__tests__/proposalMetadata.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it, jest } from "@jest/globals"; +import { + createProposalMetadataFallback, + fetchProposalMetadataWithFallback, + getAnchorUrls, + normalizeProposalMetadata, +} from "@/lib/governance/proposalMetadata"; + +const proposal = { + tx_hash: "tx-proposal", + cert_index: 0, + governance_type: "info_action", +}; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("proposal metadata helpers", () => { + it("normalizes usable Blockfrost metadata without extra fetching", async () => { + const provider = { + get: jest.fn(async () => ({ + tx_hash: "tx-proposal", + cert_index: 0, + hash: "hash", + url: "https://example.com/metadata.json", + bytes: "123", + json_metadata: { + body: { + title: "Proposal title", + abstract: "Proposal abstract", + motivation: "Motivation", + rationale: "Rationale", + references: [{ "@type": "Other", label: "Spec", uri: "https://example.com" }], + }, + authors: [{ name: "Ada" }], + }, + })), + }; + + const metadata = await fetchProposalMetadataWithFallback({ provider, proposal }); + + expect(provider.get).toHaveBeenCalledTimes(1); + expect(metadata).toMatchObject({ + tx_hash: "tx-proposal", + cert_index: 0, + governance_type: "info_action", + hash: "hash", + json_metadata: { + body: { + title: "Proposal title", + abstract: "Proposal abstract", + motivation: "Motivation", + rationale: "Rationale", + }, + authors: [{ name: "Ada" }], + }, + }); + }); + + it("hydrates metadata from a regular anchor URL", async () => { + const provider = { + get: jest.fn(async () => ({ + tx_hash: "tx-proposal", + cert_index: 0, + url: "https://example.com/anchor.json", + })), + }; + const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + body: { + title: "Anchor title", + abstract: "Anchor abstract", + }, + authors: [{ name: "Anchor author" }], + }), + { status: 200 }, + ), + ); + + const metadata = await fetchProposalMetadataWithFallback({ provider, proposal }); + + expect(fetchSpy).toHaveBeenCalledWith( + "https://example.com/anchor.json", + expect.objectContaining({ method: "GET" }), + ); + expect(metadata?.json_metadata.body.title).toBe("Anchor title"); + expect(metadata?.json_metadata.authors).toEqual([{ name: "Anchor author" }]); + }); + + it("tries IPFS gateway fallbacks until one returns usable JSON", async () => { + expect(getAnchorUrls("ipfs://cid/path.json")).toEqual([ + "https://ipfs.io/ipfs/cid/path.json", + "https://cloudflare-ipfs.com/ipfs/cid/path.json", + "https://dweb.link/ipfs/cid/path.json", + ]); + + const provider = { + get: jest.fn(async () => ({ + tx_hash: "tx-proposal", + cert_index: 0, + url: "ipfs://cid/path.json", + })), + }; + const fetchSpy = jest + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("not found", { status: 504 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ title: "Wrapped anchor title" }), { + status: 200, + }), + ); + + const metadata = await fetchProposalMetadataWithFallback({ provider, proposal }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(metadata?.json_metadata.body.title).toBe("Wrapped anchor title"); + }); + + it("returns fallback metadata without changing the ProposalMetadata shape", () => { + const metadata = createProposalMetadataFallback(proposal); + + expect(metadata).toEqual({ + tx_hash: "tx-proposal", + cert_index: 0, + governance_type: "info_action", + hash: "", + url: "", + bytes: "", + json_metadata: { + body: { + title: "Metadata could not be loaded.", + abstract: "tx-proposal#0", + motivation: "", + rationale: "", + references: [], + }, + authors: [], + }, + }); + }); + + it("normalizes missing or unusable metadata fields into safe defaults", () => { + const metadata = normalizeProposalMetadata({ json_metadata: { body: null } }, proposal); + + expect(metadata).toMatchObject({ + tx_hash: "tx-proposal", + cert_index: 0, + governance_type: "info_action", + json_metadata: { + body: { + title: "Metadata could not be loaded.", + abstract: "tx-proposal#0", + motivation: "", + rationale: "", + references: [], + }, + authors: [], + }, + }); + }); +}); diff --git a/src/__tests__/proposalTitles.test.ts b/src/__tests__/proposalTitles.test.ts new file mode 100644 index 00000000..ac5abc47 --- /dev/null +++ b/src/__tests__/proposalTitles.test.ts @@ -0,0 +1,43 @@ +import { ballotTitleMap } from "@/lib/governance/proposal-titles"; + +const PID_A = `${"a".repeat(64)}#0`; +const PID_B = `${"b".repeat(64)}#2`; + +describe("ballotTitleMap", () => { + test("zips items with their descriptions", () => { + const map = ballotTitleMap([ + { + items: [PID_A, PID_B], + itemDescriptions: ["Treasury Withdrawal Q3", "Hard Fork to v11"], + }, + ]); + expect(map.get(PID_A)).toBe("Treasury Withdrawal Q3"); + expect(map.get(PID_B)).toBe("Hard Fork to v11"); + }); + + test("first (newest) ballot wins on duplicate ids", () => { + const map = ballotTitleMap([ + { items: [PID_A], itemDescriptions: ["Newest title"] }, + { items: [PID_A], itemDescriptions: ["Older title"] }, + ]); + expect(map.get(PID_A)).toBe("Newest title"); + }); + + test("skips empty and whitespace-only titles", () => { + const map = ballotTitleMap([ + { items: [PID_A, PID_B], itemDescriptions: ["", " "] }, + { items: [PID_A], itemDescriptions: ["Fallback title"] }, + ]); + expect(map.get(PID_A)).toBe("Fallback title"); + expect(map.has(PID_B)).toBe(false); + }); + + test("tolerates undefined input and misaligned arrays", () => { + expect(ballotTitleMap(undefined).size).toBe(0); + const map = ballotTitleMap([ + { items: [PID_A, PID_B], itemDescriptions: ["Only one"] }, + ]); + expect(map.get(PID_A)).toBe("Only one"); + expect(map.has(PID_B)).toBe(false); + }); +}); diff --git a/src/__tests__/proxyAccess.test.ts b/src/__tests__/proxyAccess.test.ts index 611ba223..3bc91f9d 100644 --- a/src/__tests__/proxyAccess.test.ts +++ b/src/__tests__/proxyAccess.test.ts @@ -7,12 +7,13 @@ const getBotWalletAccessMock: jest.Mock = jest.fn(); jest.mock("@/lib/verifyJwt", () => ({ __esModule: true, isBotJwt: isBotJwtMock, -}), { virtual: true }); +})); jest.mock("@/lib/auth/botAccess", () => ({ + BotAccessError: class extends Error { constructor(public status: number, message: string) { super(message); } }, __esModule: true, getBotWalletAccess: getBotWalletAccessMock, -}), { virtual: true }); +})); const wallet = { id: "wallet-1", diff --git a/src/__tests__/proxyBotSelection.test.ts b/src/__tests__/proxyBotSelection.test.ts index f746c6de..af1f8314 100644 --- a/src/__tests__/proxyBotSelection.test.ts +++ b/src/__tests__/proxyBotSelection.test.ts @@ -42,6 +42,29 @@ describe("proxy bot UTxO selection", () => { expect(refs.collateralRef).toEqual({ txHash: "collateral", outputIndex: 0 }); }); + it("uses the reserved collateral ref instead of the first available collateral", () => { + const refs = selectSetupRefs({ + walletUtxos: [mkUtxo("20000000", "setup")], + collateralUtxos: [ + mkUtxo("6000000", "collateral-a", 0, undefined, "addr_test_signer_1"), + mkUtxo("6000000", "collateral-b", 1, undefined, "addr_test_signer_1"), + ], + reservedCollateralRef: { txHash: "collateral-b", outputIndex: 1 }, + }); + + expect(refs.collateralRef).toEqual({ txHash: "collateral-b", outputIndex: 1 }); + }); + + it("fails instead of falling back when the reserved collateral ref is unavailable", () => { + expect(() => + selectSetupRefs({ + walletUtxos: [mkUtxo("20000000", "setup")], + collateralUtxos: [mkUtxo("6000000", "collateral-a", 0, undefined, "addr_test_signer_1")], + reservedCollateralRef: { txHash: "collateral-b", outputIndex: 1 }, + }), + ).toThrow(/reserved signer-0 collateral UTxO collateral-b:1 is not currently available/); + }); + it("rejects setup when only wallet script UTxOs could act as collateral", () => { expect(() => selectSetupRefs({ diff --git a/src/__tests__/proxyCiPreflight.test.ts b/src/__tests__/proxyCiPreflight.test.ts index c83879c2..1d606988 100644 --- a/src/__tests__/proxyCiPreflight.test.ts +++ b/src/__tests__/proxyCiPreflight.test.ts @@ -14,6 +14,7 @@ import { } from "../../scripts/ci/scenarios/steps/proxyBot"; import type { CIBootstrapContext, CIWalletType } from "../../scripts/ci/framework/types"; import type { requestJson } from "../../scripts/ci/framework/http"; +import { reserveProxyLifecycleCollateral } from "../../scripts/ci/scenarios/proxyCollateralReservations"; type TestUtxo = Parameters[0]["walletUtxos"][number]; @@ -69,6 +70,16 @@ const mkContext = (walletTypes: CIWalletType[]): CIBootstrapContext => ({ signerStakeAddresses: ["stake_test_1", "stake_test_2", "stake_test_3"], }); +const scenarioStepIds = (scenario: ReturnType): string[] => [ + ...scenario.steps.map((step) => step.id), + ...(scenario.parallelBranches ?? []).flatMap((branch) => branch.steps.map((step) => step.id)), +]; + +const scenarioBranchStepIds = ( + scenario: ReturnType, + branchId: string, +): string[] => scenario.parallelBranches?.find((branch) => branch.id === branchId)?.steps.map((step) => step.id) ?? []; + describe("proxy full lifecycle preflight", () => { it("classifies an already usable UTxO shape as pass", () => { const analysis = analyzeProxyFullLifecycleUtxoShape({ @@ -208,9 +219,15 @@ describe("proxy scenario composition", () => { it("runs full lifecycle for legacy, hierarchical, and SDK wallets", () => { const scenario = createScenarioProxyFullLifecycle(mkContext(["legacy", "hierarchical", "sdk"])); - const stepIds = scenario.steps.map((step) => step.id); + const stepIds = scenarioStepIds(scenario); expect(PROXY_FULL_LIFECYCLE_WALLET_TYPES).toEqual(["legacy", "hierarchical", "sdk"]); + expect(scenario.steps.map((step) => step.id)).toContain("v1.proxy.full.parallelIsolation"); + expect(scenario.parallelBranches?.map((branch) => branch.id)).toEqual([ + "proxy-full-lifecycle.legacy", + "proxy-full-lifecycle.hierarchical", + "proxy-full-lifecycle.sdk", + ]); expect(stepIds).toContain("v1.proxy.full.recoverFromChain.legacy"); expect(stepIds).toContain("v1.proxy.full.adoptOrphans.legacy"); expect(stepIds).toContain("v1.proxy.full.hygiene.legacy"); @@ -266,7 +283,7 @@ describe("proxy scenario composition", () => { it("signs proxy lifecycle transactions with signer index 0 before the broadcaster", () => { const scenario = createScenarioProxyFullLifecycle(mkContext(["legacy"])); - const stepIds = scenario.steps.map((step) => step.id); + const stepIds = scenarioStepIds(scenario); const setupProposeIndex = stepIds.indexOf("v1.proxy.lifecycle.setup.propose.legacy"); expect(stepIds.slice(setupProposeIndex + 1, setupProposeIndex + 3)).toEqual([ @@ -283,6 +300,79 @@ describe("proxy scenario composition", () => { expect(stepIds).not.toContain("v1.proxy.full.spend.legacy.sign1"); }); + it("can disable proxy full lifecycle branch parallelism with an env flag", () => { + const previous = process.env.CI_PROXY_FULL_LIFECYCLE_PARALLEL; + process.env.CI_PROXY_FULL_LIFECYCLE_PARALLEL = "false"; + try { + const scenario = createScenarioProxyFullLifecycle(mkContext(["legacy", "sdk"])); + + expect(scenario.parallelBranches).toBeUndefined(); + expect(scenario.steps.map((step) => step.id)).toContain("v1.proxy.full.parallelIsolation"); + expect(scenario.steps.map((step) => step.id)).toContain("v1.proxy.full.recoverFromChain.legacy"); + expect(scenario.steps.map((step) => step.id)).toContain("v1.proxy.full.recoverFromChain.sdk"); + } finally { + if (previous === undefined) { + delete process.env.CI_PROXY_FULL_LIFECYCLE_PARALLEL; + } else { + process.env.CI_PROXY_FULL_LIFECYCLE_PARALLEL = previous; + } + } + }); + + it("keeps each proxy lifecycle branch internally ordered", () => { + const scenario = createScenarioProxyFullLifecycle(mkContext(["legacy", "sdk"])); + const stepIds = scenarioBranchStepIds(scenario, "proxy-full-lifecycle.legacy"); + + expect(stepIds).toEqual(expect.arrayContaining([ + "v1.proxy.full.recoverFromChain.legacy", + "v1.proxy.full.adoptOrphans.legacy", + "v1.proxy.full.hygiene.legacy", + "v1.proxy.full.utxoShape.legacy", + "v1.proxy.full.preflight.legacy", + ])); + expect(stepIds.indexOf("v1.proxy.full.recoverFromChain.legacy")).toBeLessThan( + stepIds.indexOf("v1.proxy.full.adoptOrphans.legacy"), + ); + expect(stepIds.indexOf("v1.proxy.full.adoptOrphans.legacy")).toBeLessThan( + stepIds.indexOf("v1.proxy.full.hygiene.legacy"), + ); + expect(stepIds.indexOf("v1.proxy.full.hygiene.legacy")).toBeLessThan( + stepIds.indexOf("v1.proxy.full.utxoShape.legacy"), + ); + }); + + it("reserves distinct signer-0 collateral UTxOs in deterministic order", () => { + const reservations = reserveProxyLifecycleCollateral({ + walletTypes: ["legacy", "hierarchical", "sdk"], + collateralUtxos: [ + mkCollateralUtxo("8000000", "b", 0), + mkCollateralUtxo("6000000", "a", 2), + mkCollateralUtxo("6000000", "a", 1), + ], + }); + + expect(reservations.get("legacy")?.collateralRef).toEqual({ txHash: "a", outputIndex: 1 }); + expect(reservations.get("hierarchical")?.collateralRef).toEqual({ txHash: "a", outputIndex: 2 }); + expect(reservations.get("sdk")?.collateralRef).toEqual({ txHash: "b", outputIndex: 0 }); + }); + + it("fails collateral reservation when not enough distinct candidates exist", () => { + expect(() => + reserveProxyLifecycleCollateral({ + walletTypes: ["legacy", "sdk"], + collateralUtxos: [mkCollateralUtxo("6000000", "a", 1)], + }), + ).toThrow(/requires 2 distinct ADA-only signer-0 collateral UTxO/); + }); + + it("detects duplicate wallet addresses before parallel execution", async () => { + const ctx = mkContext(["legacy", "sdk"]); + ctx.wallets[1]!.walletAddress = ctx.wallets[0]!.walletAddress; + const scenario = createScenarioProxyFullLifecycle(ctx); + + await expect(scenario.steps[0]?.execute(ctx)).rejects.toThrow(/share walletAddress/); + }); + it("fails clearly instead of using a setup transaction id as a txHash", () => { expect(() => requireSetupTxHash({ diff --git a/src/__tests__/proxyCleanup.bot.test.ts b/src/__tests__/proxyCleanup.bot.test.ts index c065d42a..ebab1e37 100644 --- a/src/__tests__/proxyCleanup.bot.test.ts +++ b/src/__tests__/proxyCleanup.bot.test.ts @@ -44,45 +44,45 @@ jest.mock("@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsHeadersMock, 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("@/server/db", () => ({ __esModule: true, db: {}, -}), { virtual: true }); +})); jest.mock("@/lib/server/v1WalletAuth", () => ({ __esModule: true, authorizeWalletSignerForV1Tx: authorizeWalletSignerForV1TxMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/proxyAccess", () => ({ __esModule: true, loadActiveProxyForWallet: loadActiveProxyForWalletMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/walletScriptAddress", () => ({ __esModule: true, resolveWalletScriptAddress: resolveWalletScriptAddressMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/resolveUtxoRefsFromChain", () => ({ __esModule: true, resolveUtxoRefsFromChain: resolveUtxoRefsFromChainMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/proxyUtxos", () => ({ __esModule: true, @@ -90,39 +90,39 @@ jest.mock("@/lib/server/proxyUtxos", () => ({ loadBlockedUtxoRefsForWallet: loadBlockedUtxoRefsForWalletMock, resolveCollateralRefFromChain: resolveCollateralRefFromChainMock, resolveSingleUtxoRefFromChain: resolveSingleUtxoRefFromChainMock, -}), { virtual: true }); +})); jest.mock("@/lib/proxy/utxoUtils", () => ({ __esModule: true, selectAuthTokenUtxo: selectAuthTokenUtxoMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/createPendingMultisigTransaction", () => ({ __esModule: true, createPendingMultisigTransaction: createPendingMultisigTransactionMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/completeTxWithFreshCostModels", () => ({ __esModule: true, completeTxWithFreshCostModels: completeTxWithFreshCostModelsMock, -}), { virtual: true }); +})); jest.mock("@/utils/get-provider", () => ({ __esModule: true, getProvider: () => ({ fetchAddressUTxOs: fetchAddressUTxOsMock }), -}), { virtual: true }); +})); jest.mock("@/utils/get-tx-builder", () => ({ __esModule: true, getTxBuilder: getTxBuilderMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/proxyTxBuilders", () => ({ __esModule: true, buildProxyCleanupSweepTx: buildProxyCleanupSweepTxMock, buildProxyCleanupTx: buildProxyCleanupTxMock, deriveProxyScripts: deriveProxyScriptsMock, -}), { virtual: true }); +})); let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; diff --git a/src/__tests__/proxyCleanupFinalization.test.ts b/src/__tests__/proxyCleanupFinalization.test.ts index 254b533d..fe17e37b 100644 --- a/src/__tests__/proxyCleanupFinalization.test.ts +++ b/src/__tests__/proxyCleanupFinalization.test.ts @@ -5,7 +5,7 @@ import type { UTxO } from "@meshsdk/core"; jest.mock("@/utils/get-provider", () => ({ __esModule: true, getProvider: jest.fn(), -}), { virtual: true }); +})); const proxy = { id: "proxy-1", diff --git a/src/__tests__/proxyDRepInfo.test.ts b/src/__tests__/proxyDRepInfo.test.ts index 5f33c700..dcb5a0d7 100644 --- a/src/__tests__/proxyDRepInfo.test.ts +++ b/src/__tests__/proxyDRepInfo.test.ts @@ -24,41 +24,41 @@ const proxy = { jest.mock("@/env", () => ({ __esModule: true, env: { BLOCKFROST_API_KEY_PREPROD: "preprod-key" }, -}), { virtual: true }); +})); jest.mock("@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsHeadersMock, 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("@/server/db", () => ({ __esModule: true, db: {}, -}), { virtual: true }); +})); jest.mock("@/lib/server/proxyAccess", () => ({ __esModule: true, authorizeProxyReadForV1: authorizeProxyReadForV1Mock, loadActiveProxyForWallet: loadActiveProxyForWalletMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/proxyTxBuilders", () => ({ __esModule: true, deriveProxyScripts: deriveProxyScriptsMock, -}), { virtual: true }); +})); let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; diff --git a/src/__tests__/proxySetup.bot.test.ts b/src/__tests__/proxySetup.bot.test.ts index 7ba631c7..b739d8f7 100644 --- a/src/__tests__/proxySetup.bot.test.ts +++ b/src/__tests__/proxySetup.bot.test.ts @@ -23,66 +23,66 @@ jest.mock("@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsHeadersMock, 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("@/server/db", () => ({ __esModule: true, db: {}, -}), { virtual: true }); +})); jest.mock("@/lib/server/v1WalletAuth", () => ({ __esModule: true, authorizeWalletSignerForV1Tx: authorizeWalletSignerForV1TxMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/walletScriptAddress", () => ({ __esModule: true, resolveWalletScriptAddress: resolveWalletScriptAddressMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/resolveUtxoRefsFromChain", () => ({ __esModule: true, resolveUtxoRefsFromChain: resolveUtxoRefsFromChainMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/proxyUtxos", () => ({ __esModule: true, resolveCollateralRefFromChain: resolveCollateralRefFromChainMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/createPendingMultisigTransaction", () => ({ __esModule: true, createPendingMultisigTransaction: createPendingMultisigTransactionMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/completeTxWithFreshCostModels", () => ({ __esModule: true, completeTxWithFreshCostModels: completeTxWithFreshCostModelsMock, -}), { virtual: true }); +})); jest.mock("@/utils/get-tx-builder", () => ({ __esModule: true, getTxBuilder: getTxBuilderMock, -}), { virtual: true }); +})); jest.mock("@/lib/server/proxyTxBuilders", () => ({ __esModule: true, DEFAULT_PROXY_SETUP_LOVELACE: "1000000", buildProxySetupTx: buildProxySetupTxMock, -}), { virtual: true }); +})); let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; diff --git a/src/__tests__/proxySetupFinalization.test.ts b/src/__tests__/proxySetupFinalization.test.ts index a5697392..8c401ff4 100644 --- a/src/__tests__/proxySetupFinalization.test.ts +++ b/src/__tests__/proxySetupFinalization.test.ts @@ -4,7 +4,7 @@ import type { UTxO } from "@meshsdk/core"; jest.mock("@/utils/get-provider", () => ({ __esModule: true, getProvider: jest.fn(), -}), { virtual: true }); +})); const setup = { proxyAddress: "addr_test_proxy", diff --git a/src/__tests__/rateLimitHeaders.test.ts b/src/__tests__/rateLimitHeaders.test.ts new file mode 100644 index 00000000..4111eca1 --- /dev/null +++ b/src/__tests__/rateLimitHeaders.test.ts @@ -0,0 +1,70 @@ +import { beforeAll, describe, expect, it, jest } from "@jest/globals"; +import { createMockResponse } from "./apiTestUtils"; + +/** + * The stress test showed clients getting 429s with no way to know how long to + * back off. These tests pin the rate-limit feedback contract: X-RateLimit-* + * on every guarded response and Retry-After on rejections — and that rejected + * requests don't extend the window. + */ + +let applyRateLimit: typeof import("../lib/security/requestGuards").applyRateLimit; + +beforeAll(async () => { + ({ applyRateLimit } = await import("../lib/security/requestGuards")); +}); + +function makeReq(ip: string) { + return { headers: { "x-forwarded-for": ip }, socket: {} } as any; +} + +function headerMap(res: ReturnType): Record { + const out: Record = {}; + for (const call of (res.setHeader as jest.Mock).mock.calls as [string, string][]) { + out[call[0]] = call[1]; + } + return out; +} + +describe("rate limit feedback headers", () => { + it("emits X-RateLimit-* on allowed requests and Retry-After on 429", () => { + const req = makeReq("10.9.9.1"); + const opts = { keySuffix: "test-headers", maxRequests: 2, windowMs: 60_000 }; + + const res1 = createMockResponse(); + expect(applyRateLimit(req, res1, opts)).toBe(true); + const h1 = headerMap(res1); + expect(h1["X-RateLimit-Limit"]).toBe("2"); + expect(h1["X-RateLimit-Remaining"]).toBe("1"); + expect(Number(h1["X-RateLimit-Reset"])).toBeGreaterThan(Date.now() / 1000); + + const res2 = createMockResponse(); + expect(applyRateLimit(req, res2, opts)).toBe(true); + expect(headerMap(res2)["X-RateLimit-Remaining"]).toBe("0"); + + const res3 = createMockResponse(); + expect(applyRateLimit(req, res3, opts)).toBe(false); + const h3 = headerMap(res3); + expect(res3.status).toHaveBeenCalledWith(429); + const retryAfter = Number(h3["Retry-After"]); + expect(retryAfter).toBeGreaterThanOrEqual(1); + expect(retryAfter).toBeLessThanOrEqual(60); + const body = (res3.json as jest.Mock).mock.calls[0]?.[0] as { retryAfterSeconds: number }; + expect(body.retryAfterSeconds).toBe(retryAfter); + }); + + it("does not extend the window for rejected requests", () => { + const req = makeReq("10.9.9.2"); + const opts = { keySuffix: "test-no-extend", maxRequests: 1, windowMs: 60_000 }; + + expect(applyRateLimit(req, createMockResponse(), opts)).toBe(true); + const rejected1 = createMockResponse(); + applyRateLimit(req, rejected1, opts); + const reset1 = headerMap(rejected1)["X-RateLimit-Reset"]; + + const rejected2 = createMockResponse(); + applyRateLimit(req, rejected2, opts); + // Same window end on both rejections — hammering doesn't push it out. + expect(headerMap(rejected2)["X-RateLimit-Reset"]).toBe(reset1); + }); +}); diff --git a/src/__tests__/rationaleAnchor.test.ts b/src/__tests__/rationaleAnchor.test.ts new file mode 100644 index 00000000..f95abedb --- /dev/null +++ b/src/__tests__/rationaleAnchor.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "@jest/globals"; +import { blake2b } from "@noble/hashes/blake2b"; + +import { + buildRationaleAnchor, + buildRationaleDocument, + serializeRationale, +} from "@/lib/server/rationaleAnchor"; + +/** + * The anchor is only useful if a verifier can fetch the pinned document, + * re-hash it, and get the hash recorded on-chain. Everything here defends that + * one property. + */ + +const blake256 = (s: string) => + Buffer.from(blake2b(Buffer.from(s, "utf8"), { dkLen: 32 })).toString("hex"); + +const base = { + summary: "Voting No: the budget is unfunded.", + rationaleStatement: "The proposal does not identify a funding source.", +}; + +describe("rationale document", () => { + it("carries the CIP-100/136 context and declares its hash algorithm", () => { + const doc = buildRationaleDocument(base) as Record; + expect(doc.hashAlgorithm).toBe("blake2b-256"); + expect(doc["@context"].CIP100).toContain("CIP-0100"); + expect(doc["@context"].CIP136).toContain("CIP-0136"); + expect(doc.body.summary).toBe(base.summary); + expect(doc.body.rationaleStatement).toBe(base.rationaleStatement); + }); + + it("truncates the summary to the 300-char CIP-136 limit", () => { + const doc = buildRationaleDocument({ + ...base, + summary: "x".repeat(500), + }) as Record; + expect(doc.body.summary).toHaveLength(300); + }); + + it("omits empty optional fields entirely", () => { + // An empty string is still a key, and every key changes the hash. + const doc = buildRationaleDocument({ + ...base, + precedentDiscussion: " ", + conclusion: "", + references: [{ label: "", uri: "" }], + }) as Record; + expect(doc.body).not.toHaveProperty("precedentDiscussion"); + expect(doc.body).not.toHaveProperty("conclusion"); + expect(doc.body).not.toHaveProperty("references"); + }); + + it("includes optional fields that carry content", () => { + const doc = buildRationaleDocument({ + ...base, + counterargumentDiscussion: "Some disagree.", + references: [{ label: "Thread", uri: "https://forum.example/1" }], + }) as Record; + expect(doc.body.counterargumentDiscussion).toBe("Some disagree."); + expect(doc.body.references).toEqual([ + { "@type": "Other", label: "Thread", uri: "https://forum.example/1" }, + ]); + }); +}); + +describe("serialization and hashing", () => { + it("serializes as two-space pretty JSON", () => { + // Not cosmetic: hashDrepAnchor hashes JSON.stringify(doc, null, 2), so the + // bytes pinned to IPFS must be exactly this form or the anchor will not + // verify against the fetched document. + const json = serializeRationale(buildRationaleDocument(base)); + expect(json).toContain('\n "hashAlgorithm"'); + expect(json).toBe(JSON.stringify(JSON.parse(json), null, 2)); + }); + + it("hashes exactly the bytes it returns for pinning", () => { + const anchor = buildRationaleAnchor( + base, + (doc) => blake256(JSON.stringify(doc, null, 2)), + "rationale-tx#0", + ); + // The invariant a verifier depends on. + expect(anchor.hash).toBe(blake256(anchor.json)); + }); + + it("is deterministic for identical input", () => { + const mk = () => + buildRationaleAnchor(base, (d) => blake256(JSON.stringify(d, null, 2)), "f"); + expect(mk().hash).toBe(mk().hash); + }); + + it("changes the hash when the rationale changes", () => { + const h = (input: typeof base) => + buildRationaleAnchor(input, (d) => blake256(JSON.stringify(d, null, 2)), "f").hash; + expect(h(base)).not.toBe(h({ ...base, rationaleStatement: "Different." })); + }); + + it("sanitises the filename and keeps a .jsonld extension", () => { + const anchor = buildRationaleAnchor( + base, + () => "deadbeef", + "rationale-aa11#0/../../etc/passwd", + ); + expect(anchor.filename).toMatch(/^[a-zA-Z0-9._-]+\.jsonld$/); + expect(anchor.filename).not.toContain("/"); + expect(anchor.filename).not.toContain("#"); + }); +}); diff --git a/src/__tests__/resolveExpectedPaymentScript.test.ts b/src/__tests__/resolveExpectedPaymentScript.test.ts new file mode 100644 index 00000000..133e5d81 --- /dev/null +++ b/src/__tests__/resolveExpectedPaymentScript.test.ts @@ -0,0 +1,147 @@ +import { serializeNativeScript, type NativeScript } from "@meshsdk/core"; + +import type { ScriptRecoveryWallet } from "@/types/txSign"; +import { resolveExpectedPaymentScriptCbor } from "@/utils/txSignUtils"; +import { mockKeyHashes } from "./testUtils"; + +/** + * Regression guard for the build-time script resolution added to the + * new-transaction form and the canvas builder: + * + * resolveExpectedPaymentScriptCbor(appWallet) ?? appWallet.scriptCbor + * + * must embed byte-identical script CBOR to the previous behavior (raw + * appWallet.scriptCbor) for every wallet shape `buildWallet` produces — + * app-created (legacy/SDK, no rawImportBodies) and Summon imports (where + * buildWallet already stores the address-matching script in scriptCbor via + * resolveSummonScriptCbors, whose selection logic this function mirrors). + */ + +function script(keyHash: string): NativeScript { + return { type: "all", scripts: [{ type: "sig", keyHash }] }; +} + +const a = serializeNativeScript(script(mockKeyHashes.payment1), undefined, 0, true); +const b = serializeNativeScript(script(mockKeyHashes.payment2), undefined, 0, true); +const c = serializeNativeScript(script(mockKeyHashes.stake1), undefined, 0, true); + +function wallet(overrides: Partial): ScriptRecoveryWallet { + return { + type: "all", + numRequiredSigners: 1, + signersAddresses: [], + scriptCbor: a.scriptCbor, + address: a.address, + ...overrides, + } as ScriptRecoveryWallet; +} + +function embeddedScript(appWallet: ScriptRecoveryWallet): string | undefined { + return resolveExpectedPaymentScriptCbor(appWallet) ?? appWallet.scriptCbor; +} + +describe("build-time script resolution preserves embedded CBOR", () => { + test("app-created wallet (no rawImportBodies), script matches address", () => { + const appWallet = wallet({}); + expect(embeddedScript(appWallet)).toBe(appWallet.scriptCbor); + }); + + test("app-created wallet whose scriptCbor does NOT match its address still embeds scriptCbor (no-op even for broken wallets)", () => { + const appWallet = wallet({ address: b.address }); + expect(embeddedScript(appWallet)).toBe(appWallet.scriptCbor); + }); + + test("Summon wallet: payment_script matches address — same pick buildWallet already stored", () => { + const appWallet = wallet({ + scriptCbor: a.scriptCbor, // buildWallet's resolveSummonScriptCbors pick + address: a.address, + rawImportBodies: { + multisig: { payment_script: a.scriptCbor, stake_script: b.scriptCbor }, + }, + }); + expect(embeddedScript(appWallet)).toBe(appWallet.scriptCbor); + }); + + test("Summon wallet with swapped scripts (address credential is the stake script) — same pick buildWallet already stored", () => { + const appWallet = wallet({ + scriptCbor: b.scriptCbor, // buildWallet swapped: stake_script matched + address: b.address, + rawImportBodies: { + multisig: { payment_script: a.scriptCbor, stake_script: b.scriptCbor }, + }, + }); + expect(embeddedScript(appWallet)).toBe(appWallet.scriptCbor); + }); + + test("Summon wallet where neither raw script matches the address falls back to scriptCbor", () => { + const appWallet = wallet({ + scriptCbor: a.scriptCbor, + address: c.address, + rawImportBodies: { + multisig: { payment_script: a.scriptCbor, stake_script: b.scriptCbor }, + }, + }); + expect(embeddedScript(appWallet)).toBe(appWallet.scriptCbor); + }); + + test("unparseable address falls back to scriptCbor", () => { + const appWallet = wallet({ + address: "not-an-address", + rawImportBodies: { + multisig: { payment_script: a.scriptCbor, stake_script: b.scriptCbor }, + }, + }); + expect(embeddedScript(appWallet)).toBe(appWallet.scriptCbor); + }); + + test("missing scriptCbor stays undefined-safe", () => { + const appWallet = wallet({ scriptCbor: undefined as unknown as string }); + expect(embeddedScript(appWallet)).toBeUndefined(); + }); +}); + +describe("stale-scriptCbor repair via runtime nativeScript", () => { + test("legacy wallet with stale DB scriptCbor embeds the runtime-rebuilt script that backs its address", () => { + // Address derives from nativeScript (payment1), but the DB row stored a + // different script — the shape that previously 400'd and needed + // submit-time recovery on every transaction. + const appWallet = wallet({ + address: a.address, + scriptCbor: b.scriptCbor, // stale + nativeScript: script(mockKeyHashes.payment1), + }); + expect(resolveExpectedPaymentScriptCbor(appWallet, 0)).toBe(a.scriptCbor); + }); + + test("healthy wallet keeps its matching scriptCbor even when nativeScript diverges", () => { + // scriptCbor matches the address, so the pre-existing check wins before + // the rebuilt-script candidate is ever considered — behavior unchanged. + const appWallet = wallet({ + address: a.address, + scriptCbor: a.scriptCbor, + nativeScript: script(mockKeyHashes.payment2), + }); + expect(resolveExpectedPaymentScriptCbor(appWallet, 0)).toBe(a.scriptCbor); + }); + + test("stale scriptCbor with a nativeScript that matches nothing still falls back to scriptCbor", () => { + const appWallet = wallet({ + address: c.address, + scriptCbor: a.scriptCbor, + nativeScript: script(mockKeyHashes.payment2), + }); + expect(resolveExpectedPaymentScriptCbor(appWallet, 0)).toBe(a.scriptCbor); + }); + + test("script CBOR is network-independent — same result for any network argument", () => { + const appWallet = wallet({ + address: a.address, + scriptCbor: b.scriptCbor, + nativeScript: script(mockKeyHashes.payment1), + }); + expect(resolveExpectedPaymentScriptCbor(appWallet, 0)).toBe( + resolveExpectedPaymentScriptCbor(appWallet, 1), + ); + expect(resolveExpectedPaymentScriptCbor(appWallet)).toBe(a.scriptCbor); + }); +}); diff --git a/src/__tests__/resolveRegistrationScript.test.ts b/src/__tests__/resolveRegistrationScript.test.ts new file mode 100644 index 00000000..07bb3fdd --- /dev/null +++ b/src/__tests__/resolveRegistrationScript.test.ts @@ -0,0 +1,166 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMockResponse } from "./apiTestUtils"; + +const addCorsHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); +const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>(); +const providerGetMock: jest.Mock = jest.fn(); +const deserializeAddressMock: jest.Mock = jest.fn(); + +jest.mock("@/lib/cors", () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsHeadersMock, + cors: corsMock, +})); + +jest.mock("@/lib/security/requestGuards", () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, +})); + +jest.mock("@/utils/get-provider", () => ({ + __esModule: true, + getProvider: () => ({ + get: providerGetMock, + }), +})); + +jest.mock("@meshsdk/core", () => ({ + __esModule: true, + deserializeAddress: (address: string) => deserializeAddressMock(address), +})); + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/v1/resolveRegistrationScript")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + applyRateLimitMock.mockReturnValue(true); + corsMock.mockResolvedValue(undefined); +}); + +const txHash = "a".repeat(64); +const scriptHash = "1".repeat(56); +const scriptAddress = "addr_test1scriptaddress"; +const keyAddress = "addr_test1keyaddress"; + +function makeRequest(query: Record): NextApiRequest { + return { + method: "GET", + headers: {}, + query, + } as unknown as NextApiRequest; +} + +describe("resolveRegistrationScript API", () => { + it("rejects an invalid txHash", async () => { + const res = createMockResponse(); + await handler(makeRequest({ txHash: "not-a-hash", network: "0" }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("resolves script candidates from the transaction's addresses", async () => { + (providerGetMock as any).mockImplementation(async (path: string) => { + if (path === `/txs/${txHash}/utxos`) { + return { + inputs: [{ address: scriptAddress }], + outputs: [{ address: scriptAddress }, { address: keyAddress }], + }; + } + if (path === `/scripts/${scriptHash}/json`) { + return { + json: { + type: "atLeast", + required: 2, + scripts: [ + { type: "sig", keyHash: "b".repeat(56) }, + { type: "sig", keyHash: "c".repeat(56) }, + ], + }, + }; + } + throw new Error(`Unexpected provider path: ${path}`); + }); + deserializeAddressMock.mockImplementation((address) => { + if (address === scriptAddress) { + return { + pubKeyHash: "", + scriptHash, + stakeCredentialHash: "", + stakeScriptCredentialHash: "2".repeat(56), + }; + } + return { + pubKeyHash: "d".repeat(56), + scriptHash: "", + stakeCredentialHash: "", + stakeScriptCredentialHash: "", + }; + }); + + const res = createMockResponse(); + await handler(makeRequest({ txHash, network: "0" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + txHash, + candidates: [ + { + address: scriptAddress, + scriptHash, + stakeCredentialHash: "2".repeat(56), + scriptJson: { + type: "atLeast", + required: 2, + scripts: [ + { type: "sig", keyHash: "b".repeat(56) }, + { type: "sig", keyHash: "c".repeat(56) }, + ], + }, + }, + ], + }); + // The script address appears in both inputs and outputs but must be + // resolved only once. + expect(providerGetMock).toHaveBeenCalledTimes(2); + }); + + it("skips scripts the provider cannot resolve", async () => { + (providerGetMock as any).mockImplementation(async (path: string) => { + if (path === `/txs/${txHash}/utxos`) { + return { inputs: [], outputs: [{ address: scriptAddress }] }; + } + throw { + response: { data: { error: "Not Found", status_code: 404 } }, + }; + }); + deserializeAddressMock.mockReturnValue({ + pubKeyHash: "", + scriptHash, + stakeCredentialHash: "", + stakeScriptCredentialHash: "", + }); + + const res = createMockResponse(); + await handler(makeRequest({ txHash, network: "1" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ txHash, candidates: [] }); + }); + + it("returns empty candidates when the transaction is unknown", async () => { + (providerGetMock as any).mockRejectedValue({ + response: { data: { error: "Not Found", status_code: 404 } }, + }); + + const res = createMockResponse(); + await handler(makeRequest({ txHash, network: "0" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ txHash, candidates: [] }); + }); +}); diff --git a/src/__tests__/seoFallback.test.ts b/src/__tests__/seoFallback.test.ts new file mode 100644 index 00000000..dc4414a4 --- /dev/null +++ b/src/__tests__/seoFallback.test.ts @@ -0,0 +1,91 @@ +import React from "react"; +import ReactDOMServer from "react-dom/server"; + +import SeoFallback from "@/components/ui/seo-fallback"; +import { buildJsonLd, DEFAULT_DESCRIPTION, INDEXABLE_ROUTES } from "@/lib/seo"; +import { buildLlmsTxt } from "@/pages/llms.txt"; + +/** + * These guard the "even a no-JS fetcher sees real content" behaviour: the SPA + * renders an empty body server-side, so SeoFallback (in