From 7fba4cd55d07b96b2b2b9d4814bf87bf2dd2920e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:30:44 +0200 Subject: [PATCH 01/93] feat(e2e): add Playwright end-to-end testing setup with PostgreSQL and wallet integration - Introduced Dockerfile for Playwright to set up the testing environment. - Created docker-compose configuration for orchestrating services including PostgreSQL and the application. - Added detailed instructions for running Playwright tests locally in RUNNING_LOCALLY.md. - Implemented authentication fixtures to handle wallet session management during tests. - Developed wallet fixture for CIP-0030 wallet injection and interaction with the application. - Added global setup for environment variable validation and context loading. - Implemented helper functions for signing transactions and fetching UTxOs from Blockfrost. - Created a comprehensive ring transfer test suite to validate multi-signature transactions. - Added configuration for Playwright tests including output directories and reporting. --- .github/workflows/pr-multisig-v1-smoke.yml | 8 +- .github/workflows/pr-playwright-browser.yml | 138 ++ .gitignore | 1 + Dockerfile.playwright | 16 + docker-compose.playwright.yml | 119 ++ e2e/RUNNING_LOCALLY.md | 248 +++ e2e/fixtures/authFixture.ts | 95 ++ e2e/fixtures/walletFixture.ts | 130 ++ e2e/global-setup.ts | 35 + e2e/helpers/authSession.ts | 30 + e2e/helpers/blockfrostUtils.ts | 19 + e2e/helpers/cip30Mock.ts | 49 + e2e/helpers/contextLoader.ts | 44 + e2e/helpers/meshSign.ts | 37 + e2e/playwright.config.ts | 32 + e2e/tests/ring-transfer.spec.ts | 125 ++ package-lock.json | 1460 ++++++++++++++--- package.json | 4 +- .../wallet/new-transaction/RecipientRow.tsx | 2 + .../pages/wallet/new-transaction/index.tsx | 1 + .../wallet/new-transaction/utxoSelector.tsx | 6 +- .../wallet/transactions/transaction-card.tsx | 18 +- src/pages/api/auth/wallet-session.ts | 6 + 23 files changed, 2378 insertions(+), 245 deletions(-) create mode 100644 .github/workflows/pr-playwright-browser.yml create mode 100644 Dockerfile.playwright create mode 100644 docker-compose.playwright.yml create mode 100644 e2e/RUNNING_LOCALLY.md create mode 100644 e2e/fixtures/authFixture.ts create mode 100644 e2e/fixtures/walletFixture.ts create mode 100644 e2e/global-setup.ts create mode 100644 e2e/helpers/authSession.ts create mode 100644 e2e/helpers/blockfrostUtils.ts create mode 100644 e2e/helpers/cip30Mock.ts create mode 100644 e2e/helpers/contextLoader.ts create mode 100644 e2e/helpers/meshSign.ts create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/ring-transfer.spec.ts diff --git a/.github/workflows/pr-multisig-v1-smoke.yml b/.github/workflows/pr-multisig-v1-smoke.yml index cb34aea2..5a8ca942 100644 --- a/.github/workflows/pr-multisig-v1-smoke.yml +++ b/.github/workflows/pr-multisig-v1-smoke.yml @@ -1,7 +1,9 @@ name: PR Multisig v1 Smoke on: - pull_request: + workflow_run: + workflows: ["PR Playwright Browser Tests"] + types: [completed] branches: - main - preprod @@ -29,7 +31,7 @@ on: jobs: multisig-v1-smoke: - if: github.repository == 'MeshJS/multisig' + if: ${{ always() && github.repository == 'MeshJS/multisig' }} runs-on: ubuntu-latest timeout-minutes: 120 env: @@ -52,6 +54,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} - name: Validate required CI secrets shell: bash diff --git a/.github/workflows/pr-playwright-browser.yml b/.github/workflows/pr-playwright-browser.yml new file mode 100644 index 00000000..bf105b50 --- /dev/null +++ b/.github/workflows/pr-playwright-browser.yml @@ -0,0 +1,138 @@ +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 + +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' }} + + 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") + 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: Pull base images (with retry) + shell: bash + run: | + for i in 1 2 3; do + docker pull node:20-alpine && break + echo "Pull attempt $i failed, retrying in 30s..." + sleep 30 + done + + - name: Build CI containers + shell: bash + run: docker compose -f docker-compose.playwright.yml build app 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 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/.gitignore b/.gitignore index 189ad92e..91a98f36 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 diff --git a/Dockerfile.playwright b/Dockerfile.playwright new file mode 100644 index 00000000..c69f78a6 --- /dev/null +++ b/Dockerfile.playwright @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/playwright:v1.50.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 + +# Install Chromium and its system dependencies. +RUN npx playwright install chromium --with-deps + +# 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/docker-compose.playwright.yml b/docker-compose.playwright.yml new file mode 100644 index 00000000..2c158323 --- /dev/null +++ b/docker-compose.playwright.yml @@ -0,0 +1,119 @@ +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 + 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} + NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD: ${CI_BLOCKFROST_PREPROD_API_KEY:-} + NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET: ${CI_BLOCKFROST_MAINNET_API_KEY:-} + BLOCKFROST_API_KEY_PREPROD: ${CI_BLOCKFROST_PREPROD_API_KEY:-} + depends_on: + postgres: + condition: service_healthy + networks: + - multisig-playwright-network + 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...' && + npm run dev -- --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 + 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://app: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_CONTEXT_PATH: /artifacts/ci-wallet-context.json + depends_on: + app: + condition: service_healthy + networks: + - multisig-playwright-network + volumes: + - ./ci-artifacts:/artifacts + profiles: + - playwright + command: npx --yes tsx scripts/ci/cli/bootstrap.ts + + playwright-runner: + build: + context: . + dockerfile: Dockerfile.playwright + environment: + APP_URL: http://app:3000 + 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} + 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 + profiles: + - playwright + command: npx playwright test --config=e2e/playwright.config.ts + +volumes: + postgres-playwright-data: + +networks: + multisig-playwright-network: + driver: bridge diff --git a/e2e/RUNNING_LOCALLY.md b/e2e/RUNNING_LOCALLY.md new file mode 100644 index 00000000..97103c15 --- /dev/null +++ b/e2e/RUNNING_LOCALLY.md @@ -0,0 +1,248 @@ +# Running the Playwright E2E Tests Locally + +The ring-transfer test suite drives a real Cardano **preprod** browser flow: +CIP-0030 wallet injection → transaction propose → multi-sign → on-chain broadcast. +It requires live preprod wallets with funded UTxOs and a Blockfrost preprod API key. + +--- + +## Prerequisites + +| Requirement | Notes | +|---|---| +| Docker + Docker Compose | Manages postgres, app, and Playwright runner | +| Three funded preprod mnemonics | Each wallet must hold ≥ 5 ADA to cover fees | +| Blockfrost preprod API key | From [blockfrost.io](https://blockfrost.io) | +| A JWT secret (≥ 32 chars) | Must match what the app uses as `JWT_SECRET` | + +--- + +## Running (PowerShell — Docker, matches CI) + +This replicates exactly what runs in CI. + +### 1. Create `.env.playwright` + +Create a `.env.playwright` file in the repo root (not committed): + +``` +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 +``` + +### 2. Build the images + +```powershell +docker compose -f docker-compose.playwright.yml build app playwright-runner +``` + +### 3. Start postgres and the app + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright up -d postgres app +``` + +Wait for the app to become healthy (the healthcheck polls `/api/swagger`): + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ps +# Repeat until app shows "healthy" +``` + +Or poll until healthy automatically: + +```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) +``` + +### 4. Run bootstrap (creates the three CI wallets in the DB) + +```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 +``` + +This writes `ci-artifacts/ci-wallet-context.json` — the shared context that both the Playwright runner and the ring-transfer test read. + +### 5. Run the Playwright tests + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ` + --profile playwright run --rm playwright-runner +``` + +Artifacts land in `ci-artifacts/`: +- `ci-artifacts/playwright-report/` — HTML report (`index.html`) +- `ci-artifacts/playwright-traces/` — video/trace on failure + +### 6. Tear down + +```powershell +docker compose -f docker-compose.playwright.yml --env-file .env.playwright down -v --remove-orphans +``` + +--- + +## Running (Bash — Docker, matches CI) + +This replicates exactly what runs in CI. + +### 1. Create `.env.playwright` + +Create a `.env.playwright` file in the repo root (not committed): + +``` +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 +``` + +### 2. Build the images + +```bash +docker compose -f docker-compose.playwright.yml build app playwright-runner +``` + +### 3. Start postgres and the app + +```bash +docker compose -f docker-compose.playwright.yml --env-file .env.playwright up -d postgres app +``` + +Wait for the app to become healthy (the healthcheck polls `/api/swagger`): + +```bash +docker compose -f docker-compose.playwright.yml --env-file .env.playwright ps +# Repeat until app shows "healthy" +``` + +Or poll until healthy automatically: + +```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 +``` + +### 4. Run bootstrap (creates the three CI wallets in the DB) + +```bash +mkdir -p ci-artifacts +docker compose -f docker-compose.playwright.yml --env-file .env.playwright \ + --profile playwright run --rm bootstrap-runner +``` + +This writes `ci-artifacts/ci-wallet-context.json` — the shared context that both the Playwright runner and the ring-transfer test read. + +### 5. Run the Playwright tests + +```bash +docker compose -f docker-compose.playwright.yml --env-file .env.playwright \ + --profile playwright run --rm playwright-runner +``` + +Artifacts land in `ci-artifacts/`: +- `ci-artifacts/playwright-report/` — HTML report (`index.html`) +- `ci-artifacts/playwright-traces/` — video/trace on failure + +### 6. Tear down + +```bash +docker compose -f docker-compose.playwright.yml --env-file .env.playwright down -v --remove-orphans +``` + +--- + +## Viewing the HTML Report + +After any run, open the report in your browser: + +```bash +npx playwright show-report ci-artifacts/playwright-report +# or, for a local run: +npx playwright show-report playwright-report +``` + +--- + +## 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 (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 (`preprod...`). | +| `CI_CONTEXT_PATH` | Yes | Path where bootstrap writes/test reads `ci-wallet-context.json`. | +| `APP_URL` | No | Base URL of the running app. Defaults to `http://localhost:3000`. | +| `CI_TRANSFER_LOVELACE` | No | Lovelace sent per ring-transfer leg. Defaults to `2000000` (2 ADA). | +| `CI_NETWORK_ID` | No (bootstrap only) | `0` for preprod. Defaults to `0`. | +| `CI_NUM_REQUIRED_SIGNERS` | No (bootstrap only) | Signing threshold. Defaults to `2`. | +| `CI_WALLET_TYPES` | No (bootstrap only) | Comma-separated wallet types. Defaults to `legacy,hierarchical,sdk`. | + +--- + +## How the Test Works + +1. **Bootstrap** creates three multisig wallets (legacy / hierarchical / 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. **ring-transfer.spec.ts** runs three sequential legs. For each leg: + - Signer 0 (proposer) navigates to `/wallets/{id}/transactions/new`, fills the + recipient address and ADA amount, and submits. The `window.cardano.meshci` mock + intercepts `signTx` and bridges to `MeshWallet.signTx` in the Node.js context + using the corresponding mnemonic. + - Signer 1 navigates to `/wallets/{id}/transactions`, clicks the sign button, which + reaches the 2-of-3 threshold and broadcasts the transaction on-chain. + - The test waits for `[data-testid="tx-broadcast-success"]` and confirms the pending + tx is cleared from the DB via `/api/v1/pendingTransactions`. + +4. Legs run **serially** (`test.describe.serial`) to avoid UTxO conflicts between legs. + +--- + +## Troubleshooting + +**`CI_CONTEXT_PATH must be set`** — bootstrap did not run before the Playwright runner. +Run steps 4 → 5 in order. + +**`Missing required environment variables`** — one of the four required env vars is +missing. Check your `.env.playwright` or shell export. + +**`No legacy/hierarchical/sdk wallet found`** — the bootstrap context is stale or +written by an older schema. Delete `ci-artifacts/ci-wallet-context.json` and re-run +bootstrap. + +**`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. + +**Wallet not found in connect modal** — the `window.cardano.meshci` object was not +injected before the page loaded. This usually means `injectWallet()` was not called +before `page.goto()`. Check fixture order in `authFixture.ts`. + +**Transaction still pending after broadcast timeout** — the preprod network may be +congested, or the wallet lacks sufficient ADA for fees. Check wallet balances via +[preprod.cardanoscan.io](https://preprod.cardanoscan.io). diff --git a/e2e/fixtures/authFixture.ts b/e2e/fixtures/authFixture.ts new file mode 100644 index 00000000..0664d048 --- /dev/null +++ b/e2e/fixtures/authFixture.ts @@ -0,0 +1,95 @@ +// 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({ + // Drives the real wallet-connect auth flow (Option B). + // Calls injectWallet to update the mocked signer, then connectWallet to run + // the full nonce → signData → POST /api/auth/wallet-session sequence. + // The resulting HttpOnly cookie persists across subsequent page navigations + // within the same Playwright browser context, so no re-auth is needed per page. + authenticateAs: async ({ injectWallet, connectWallet }, use) => { + let lastAuthenticatedIndex: number | null = null; + + 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); + + // Skip the UI connect flow if we're still on the same signer — the session + // cookie is still valid and the browser context carries it 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..e0ab2808 --- /dev/null +++ b/e2e/fixtures/walletFixture.ts @@ -0,0 +1,130 @@ +// 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, type Page } from "@playwright/test"; +import { loadContext } from "../helpers/contextLoader"; +import { buildCip30MockScript } from "../helpers/cip30Mock"; +import { signWithMnemonic, signDataWithMnemonic } from "../helpers/meshSign"; +import { getSignerUtxos } from "../helpers/blockfrostUtils"; + +type WalletFixtures = { + injectWallet: (page: Page, signerIndex: number) => Promise; + connectWallet: (page: Page) => Promise; +}; + +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) => + signDataWithMnemonic(currentMnemonic, addr, payload), + ); + // 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; they all run on + // each page load in order, so the last script's assignment wins. + await page.addInitScript({ + content: buildCip30MockScript({ + walletName: "meshci", + usedAddresses: [signerAddress], + changeAddress: signerAddress, + rewardAddresses: stakeAddress ? [stakeAddress] : [], + }), + }); + }); + }, + + connectWallet: async ({}, use) => { + await use(async (page: Page) => { + // Navigate to the app root so the Connect Wallet button is rendered + await page.goto("/"); + // Let React finish hydrating; ignore timeout (app may have live DB queries) + await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => {}); + + // If already connected, disconnect and clear the session cookie so the + // WalletAuthModal is shown again for the new signer. + const connectedBtn = page.getByRole("button", { name: /^connected to /i }); + if (await connectedBtn.count() > 0) { + await connectedBtn.click(); + await page.waitForSelector('[role="menu"]', { timeout: 5_000 }); + await page.getByRole("menuitem", { name: /^Disconnect$/i }).click(); + await page.waitForSelector('[role="menu"]', { state: "hidden", timeout: 5_000 }).catch(() => {}); + } + // Clear the HttpOnly session cookie via the app's own endpoint + await page.request.delete("/api/auth/wallet-session").catch(() => {}); + + // Open the Connect Wallet dropdown + const connectBtn = page.getByRole("button", { name: /connect wallet/i }); + await connectBtn.waitFor({ timeout: 10_000 }); + await connectBtn.click(); + await page.waitForSelector('[role="menu"]', { timeout: 5_000 }); + + // Wait for the MeshCI wallet to appear — useWalletList polls window.cardano + // with a 300ms debounce before the remount that picks up the injected wallet. + const meshciItem = page.getByRole("menuitem", { name: "MeshCI" }); + await meshciItem.waitFor({ timeout: 5_000 }); + await meshciItem.click(); + + // The WalletAuthModal opens with autoAuthorize=true. + // It calls wallet.signData(nonce, address) → window.__ci_signData → signDataWithMnemonic. + // Wait for the resulting POST /api/auth/wallet-session to succeed. + await page.waitForResponse( + (r) => + r.url().includes("/api/auth/wallet-session") && + r.request().method() === "POST", + { timeout: 30_000 }, + ); + + // Confirm the auth modal has closed before the test proceeds + await page + .waitForSelector('[role="dialog"]', { state: "hidden", timeout: 10_000 }) + .catch(() => {}); + }); + }, +}); + +export { expect } from "@playwright/test"; 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/authSession.ts b/e2e/helpers/authSession.ts new file mode 100644 index 00000000..5f868cbd --- /dev/null +++ b/e2e/helpers/authSession.ts @@ -0,0 +1,30 @@ +// 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 { sign } from "jsonwebtoken"; + +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..6836c1b6 --- /dev/null +++ b/e2e/helpers/cip30Mock.ts @@ -0,0 +1,49 @@ +// 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(). + +export type Cip30MockParams = { + walletName: string; + 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() { + // Minimal CBOR for a Value of 2 ADA — used for display only + return 'a200a1581c\\0041\\00a1\\00021a001e8480'; + }, + 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..b69b9d2f --- /dev/null +++ b/e2e/helpers/meshSign.ts @@ -0,0 +1,37 @@ +// 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(); + return wallet.signTx(txCbor, partial); +} + +export async function signDataWithMnemonic( + mnemonic: string, + address: string, + payload: 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) — note argument order differs from raw CIP-0030 + const result = await wallet.signData(payload, address); + return { signature: result.signature, key: result.key }; +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..6baffd08 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,32 @@ +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, + retries: 0, + 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/ring-transfer.spec.ts b/e2e/tests/ring-transfer.spec.ts new file mode 100644 index 00000000..ad16b16d --- /dev/null +++ b/e2e/tests/ring-transfer.spec.ts @@ -0,0 +1,125 @@ +// Phase 4: Full browser-driven ring transfer test. +// +// Three sequential legs share the same preprod wallets and run in order to +// avoid UTxO conflicts. 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"; + +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 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" }, +]; + +// 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, + txId: string, + timeoutMs = 120_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resp = await page.request.get( + `/api/v1/pendingTransactions?walletId=${walletId}`, + ); + if (resp.ok()) { + const data = (await resp.json()) as { transactions?: Array<{ id: string }> }; + if (!data.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.serial("ring transfer", () => { + for (const leg of LEGS) { + test(`ring transfer: ${leg.name}`, async ({ page, authenticateAs }) => { + const ctx = loadContext(); + const srcWallet = getWallet(ctx, leg.srcType); + const dstWallet = getWallet(ctx, leg.dstType); + + // ── Step 1: Proposer (signer 0) creates the transaction ─────────────── + await authenticateAs(page, 0); + + await page.goto(`/wallets/${srcWallet.walletId}/transactions/new`); + // networkidle captures the blockfrost UTxO fetch that goes through /api/blockfrost/... + await page.waitForLoadState("networkidle", { timeout: 60_000 }).catch(() => {}); + + // Wait for the UTxO selector to finish auto-loading and auto-selecting UTxOs + await page.waitForSelector('[data-testid="utxo-selector"][data-loaded="true"]', { + timeout: 60_000, + }); + + // 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); + + // Submit — the hook calls activeWallet.signTx (bridges to meshSign) then + // calls createTransaction tRPC mutation and redirects to transactions page. + await page.click('[data-testid="create-transaction-button"]'); + + await page.waitForURL(`**/${srcWallet.walletId}/transactions`, { + timeout: 90_000, + }); + await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => {}); + + // 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(); + + // ── Step 2: Signer 1 signs → broadcast (threshold 2-of-3 now met) ───── + await authenticateAs(page, 1); + + await page.goto(`/wallets/${srcWallet.walletId}/transactions`); + await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => {}); + + // Confirm the tx card is still pending (proposer signed, threshold not yet met) + await page.waitForSelector(`[data-testid="tx-card-${transactionId}"]`, { + timeout: 20_000, + }); + + // 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. + 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, + }); + + // 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, transactionId, 60_000); + }); + } +}); diff --git a/package-lock.json b/package-lock.json index dfadd9cc..6f90bf7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,6 +83,7 @@ "@eslint/eslintrc": "^3.3.3", "@jest/globals": "^30.1.2", "@next/bundle-analyzer": "^16.0.10", + "@playwright/test": "^1.50.0", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.17.7", "@types/busboy": "^1.5.4", @@ -116,6 +117,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -249,17 +251,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", @@ -1401,6 +1392,39 @@ "node": ">=6" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emurgo/cardano-message-signing-nodejs": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@emurgo/cardano-message-signing-nodejs/-/cardano-message-signing-nodejs-1.1.0.tgz", @@ -1819,88 +1843,554 @@ "node": ">=18.18.0" } }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.22" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, "engines": { - "node": ">=18" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-darwin-arm64": { + "node_modules/@img/sharp-win32-arm64": { "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ - "arm64" + "x64" ], - "license": "LGPL-3.0-or-later", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ - "darwin" + "win32" ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, "funding": { "url": "https://opencollective.com/libvips" } @@ -2395,6 +2885,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2416,6 +2907,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2425,12 +2917,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2752,17 +3246,6 @@ "node": ">=16.20.2" } }, - "node_modules/@meshsdk/core-cst/node_modules/@harmoniclabs/crypto": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@harmoniclabs/crypto/-/crypto-0.3.0.tgz", - "integrity": "sha512-UvmGQOLFVFhRIDYLpcWbPQLXl9advCt0h02Z/BtBuXtHiy35WRxKQ3njcUKI0v6zGITuvqQhsf6VOPMeekLdeA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@harmoniclabs/bitstream": "^1.0.0", - "@harmoniclabs/uint8array-utils": "^1.0.3" - } - }, "node_modules/@meshsdk/core-cst/node_modules/@harmoniclabs/plutus-data": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@harmoniclabs/plutus-data/-/plutus-data-1.2.6.tgz", @@ -2997,17 +3480,6 @@ "scalus": "^0.14.2" } }, - "node_modules/@meshsdk/provider/node_modules/@meshsdk/core-cst/node_modules/@harmoniclabs/crypto": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@harmoniclabs/crypto/-/crypto-0.3.0.tgz", - "integrity": "sha512-UvmGQOLFVFhRIDYLpcWbPQLXl9advCt0h02Z/BtBuXtHiy35WRxKQ3njcUKI0v6zGITuvqQhsf6VOPMeekLdeA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@harmoniclabs/bitstream": "^1.0.0", - "@harmoniclabs/uint8array-utils": "^1.0.3" - } - }, "node_modules/@meshsdk/provider/node_modules/@meshsdk/core-cst/node_modules/@harmoniclabs/uplc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@harmoniclabs/uplc/-/uplc-1.4.1.tgz", @@ -3370,6 +3842,25 @@ "uint8arrays": "^5.0.0" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@next/bundle-analyzer": { "version": "16.2.6", "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.6.tgz", @@ -3412,6 +3903,111 @@ "node": ">= 10" } }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@noble/ciphers": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", @@ -3455,6 +4051,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -3468,6 +4065,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -3477,6 +4075,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -3808,8 +4407,24 @@ "engines": { "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, - "funding": { - "url": "https://opencollective.com/pkgr" + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@playwright/test": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.0.tgz", + "integrity": "sha512-ZGNXbt+d65EGjBORQHuYKj+XhCewlwpnSd/EDuLPZGSiEWmgOJB5RmMCCYGy5aMfTs9wx61RivfDKi8H/hcMvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.50.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" } }, "node_modules/@polka/url": { @@ -3845,7 +4460,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -3858,14 +4473,14 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -3879,14 +4494,14 @@ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3", @@ -3898,7 +4513,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3" @@ -5408,15 +6023,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", @@ -5448,7 +6054,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@swagger-api/apidom-ast": { @@ -6340,6 +6946,17 @@ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -6679,6 +7296,7 @@ "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6688,7 +7306,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -7087,18 +7705,347 @@ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "license": "ISC" }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", "cpu": [ - "arm64" + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ] }, "node_modules/@use-gesture/core": { @@ -7483,12 +8430,14 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -7502,6 +8451,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -7520,6 +8470,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -8318,6 +9269,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8326,6 +9278,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bip174": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0.tgz", @@ -8468,6 +9430,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -8604,7 +9567,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -8706,6 +9669,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -8797,6 +9761,19 @@ "chacha-native": "^2.0.0" } }, + "node_modules/chacha-native": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/chacha-native/-/chacha-native-2.0.3.tgz", + "integrity": "sha512-93h+osfjhR2sMHAaapTLlL/COoBPEZ6upicPBQ4GfUyadoMb8t9/M0PKK8kC+F+DEA/Oy3Kg9w3HzY3J1foP3g==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.2.1", + "inherits": "^2.0.1", + "nan": "^2.4.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8868,7 +9845,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -8914,7 +9891,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -9052,14 +10029,14 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -9218,6 +10195,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -9590,7 +10568,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -9635,7 +10613,7 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/delaunator": { @@ -9669,7 +10647,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/detect-europe-js": { @@ -9761,12 +10739,14 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, "license": "Apache-2.0" }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, "license": "MIT" }, "node_modules/doctrine": { @@ -9810,7 +10790,7 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -9910,7 +10890,7 @@ "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -9948,7 +10928,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -10725,7 +11705,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/extend": { @@ -10738,7 +11718,7 @@ "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -10761,7 +11741,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -10913,6 +11893,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -10958,10 +11939,18 @@ "node": ">= 12" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -11145,6 +12134,21 @@ } } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -11338,7 +12342,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -11380,6 +12384,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -12178,6 +13183,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -12289,6 +13295,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12352,6 +13359,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -12400,6 +13408,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -13380,7 +14389,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -13714,6 +14723,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -13726,6 +14736,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -14264,6 +15275,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -14865,6 +15877,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -14878,6 +15891,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -15023,6 +16037,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -15030,6 +16045,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoassert": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-2.0.0.tgz", @@ -15373,7 +16395,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/node-gyp-build": { @@ -15418,6 +16440,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -18556,7 +19579,7 @@ "version": "0.6.6", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.2.2", @@ -18574,7 +19597,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/oauth": { @@ -18718,7 +19741,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/oidc-token-hash": { @@ -18779,13 +19802,6 @@ "node": ">=12.20.0" } }, - "node_modules/openapi-types": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT", - "peer": true - }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -19058,7 +20074,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/pbkdf2": { @@ -19120,7 +20136,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -19133,6 +20149,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -19145,6 +20162,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -19154,6 +20172,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -19232,7 +20251,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.2.4", @@ -19240,6 +20259,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.0.tgz", + "integrity": "sha512-+GinGfGTrd2IfX1TA4N2gNmeIksSb+IAe589ZH+FlmpV3MYTx6+buChGIuDLQwrGNCw2lWibqV50fU510N7S+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.50.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.0.tgz", + "integrity": "sha512-CXkSSlr4JaZs2tZHI40DsZUN/NIwgaUPsyLuOAaIZp2CyF2sN5MM5NJsyB188lFSSozFxQ5fPT4qM+f0tH/6wQ==", + "dev": 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==", + "dev": true, + "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", @@ -19262,6 +20328,7 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, "funding": [ { "type": "opencollective", @@ -19290,6 +20357,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -19307,6 +20375,7 @@ "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -19328,6 +20397,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -19353,6 +20423,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -19378,6 +20449,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -19391,6 +20463,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, "node_modules/potpack": { @@ -19574,7 +20647,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -19755,6 +20828,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -19823,7 +20897,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -20209,6 +21283,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -20230,7 +21305,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -20640,6 +21715,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -21487,6 +22563,7 @@ "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -21509,6 +22586,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -21707,6 +22785,7 @@ "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -21753,6 +22832,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -21777,6 +22857,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -21789,6 +22870,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -21805,6 +22887,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -21817,6 +22900,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -21826,6 +22910,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -21835,6 +22920,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -21847,6 +22933,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, "funding": [ { "type": "opencollective", @@ -21889,6 +22976,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -21901,6 +22989,7 @@ "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -21969,6 +23058,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -21978,6 +23068,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -22129,7 +23220,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -22139,6 +23230,7 @@ "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -22182,6 +23274,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -22329,6 +23422,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/ts-jest": { @@ -22643,6 +23737,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -23719,111 +24814,6 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/package.json b/package.json index 1d002968..3ad45cdf 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "test:ci": "jest --ci --coverage --watchAll=false", "test:trpc": "jest --testPathPatterns=\"src/__tests__/trpc\" --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", @@ -102,6 +103,7 @@ "@eslint/eslintrc": "^3.3.3", "@jest/globals": "^30.1.2", "@next/bundle-analyzer": "^16.0.10", + "@playwright/test": "^1.50.0", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.17.7", "@types/busboy": "^1.5.4", diff --git a/src/components/pages/wallet/new-transaction/RecipientRow.tsx b/src/components/pages/wallet/new-transaction/RecipientRow.tsx index cc116db4..bd7a52c1 100644 --- a/src/components/pages/wallet/new-transaction/RecipientRow.tsx +++ b/src/components/pages/wallet/new-transaction/RecipientRow.tsx @@ -114,6 +114,7 @@ function RecipientRow({ void handleAddressChange(e.target.value); }} className="flex-1" + data-testid={`recipient-address-input-${index}`} /> {getAddressLabel && recipientAddresses[index] && (() => { const addressLabel = getAddressLabel(recipientAddresses[index]!); @@ -153,6 +154,7 @@ function RecipientRow({ }} placeholder="" disabled={disableAdaAmountInput} + data-testid={`amount-input-${index}`} /> diff --git a/src/components/pages/wallet/new-transaction/index.tsx b/src/components/pages/wallet/new-transaction/index.tsx index 4ad5e17a..c64db9a6 100644 --- a/src/components/pages/wallet/new-transaction/index.tsx +++ b/src/components/pages/wallet/new-transaction/index.tsx @@ -1378,6 +1378,7 @@ export default function PageNewTransaction({ onSuccess }: { onSuccess?: () => vo disabled={loading} size="lg" className="h-11 w-full sm:h-12 sm:w-auto sm:min-w-[200px]" + data-testid="create-transaction-button" > {loading ? ( <> diff --git a/src/components/pages/wallet/new-transaction/utxoSelector.tsx b/src/components/pages/wallet/new-transaction/utxoSelector.tsx index 643a6818..e40ab875 100644 --- a/src/components/pages/wallet/new-transaction/utxoSelector.tsx +++ b/src/components/pages/wallet/new-transaction/utxoSelector.tsx @@ -397,7 +397,11 @@ export default function UTxOSelector({ }, [selectedUtxos, recipientAmounts, recipientAssets, walletAssetMetadata]); return ( -
+
(false); const [isSignersOpen, setIsSignersOpen] = useState(false); + const [broadcastDone, setBroadcastDone] = useState(false); const { toast } = useToast(); const ctx = api.useUtils(); const network = useSiteStore((state) => state.network); @@ -309,6 +310,7 @@ export default function TransactionCard({ }); txHash = submitResult.txHash; signedTx = submitResult.txHex; + setBroadcastDone(true); } updateTransaction({ @@ -510,7 +512,7 @@ export default function TransactionCard({ // the Transactions page still loads and the user can free locked UTxOs (#211). if (!txJson) { return ( - + Unreadable transaction @@ -583,7 +585,7 @@ export default function TransactionCard({ const pendingCount = signersCount - signedCount - rejectedCount; return ( - +
@@ -1033,13 +1035,17 @@ export default function TransactionCard({ !transaction.signedAddresses.includes(userAddress) && !transaction.rejectedAddresses.includes(userAddress) && ( -
); -} \ No newline at end of file +} diff --git a/src/lib/discord/sendDiscordMessage.ts b/src/lib/discord/sendDiscordMessage.ts index 30afcdd3..af00f478 100644 --- a/src/lib/discord/sendDiscordMessage.ts +++ b/src/lib/discord/sendDiscordMessage.ts @@ -2,6 +2,10 @@ export default async function sendDiscordMessage( discordIds: string[], message: string, ) { + if (discordIds.length === 0) { + return { ok: true, skipped: true }; + } + const response = await fetch("/api/discord/send-message", { method: "POST", headers: { diff --git a/src/lib/zustand/site.ts b/src/lib/zustand/site.ts index 0f94f278..d3b4fb6c 100644 --- a/src/lib/zustand/site.ts +++ b/src/lib/zustand/site.ts @@ -1,5 +1,10 @@ import { create } from "zustand"; +const configuredDefaultNetwork = Number( + process.env.NEXT_PUBLIC_DEFAULT_NETWORK ?? "1", +); +const defaultNetwork = configuredDefaultNetwork === 0 ? 0 : 1; + interface SiteState { network: number; setNetwork: (network: number) => void; @@ -13,7 +18,7 @@ interface SiteState { export const useSiteStore = create((set) => ({ // Default to mainnet (1). Testnet/preprod is 0. - network: 1, + network: defaultNetwork, setNetwork: (network: number) => set({ network }), randomState: 0, setRandomState: () => set({ randomState: Math.random() }), diff --git a/src/utils/common.ts b/src/utils/common.ts index 9bbfaca0..2a4578e1 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -72,6 +72,36 @@ function buildNativeScriptFromPaymentSigners( return nativeScript; } +function buildStoredPaymentScriptWallet( + wallet: DbWalletWithLegacy, + network: number, +): { nativeScript: NativeScript; scriptCbor: string; address: string } | undefined { + const scriptCbor = wallet.scriptCbor?.trim(); + if (!scriptCbor) { + return undefined; + } + + try { + const nativeScript = decodedToNativeScript( + decodeNativeScriptFromCbor(scriptCbor), + ); + return { + nativeScript, + scriptCbor, + address: serializeNativeScript( + nativeScript, + wallet.stakeCredentialHash ?? undefined, + network, + ).address, + }; + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.warn("buildWallet: failed to use stored scriptCbor", error); + } + return undefined; + } +} + function buildDRepIdFromScript(nativeScript: NativeScript): string { const dRepIdCip105 = resolveScriptHashDRepId( resolveNativeScriptHash(nativeScript), @@ -319,6 +349,19 @@ export function buildWallet( // Type 0 (Legacy): Build native script directly from payment keys in input order if (walletType === 'legacy') { + const storedScriptWallet = buildStoredPaymentScriptWallet(wallet, network); + if (storedScriptWallet) { + const dRepIdCip129 = buildDRepIdFromScript(storedScriptWallet.nativeScript); + + return { + ...wallet, + nativeScript: storedScriptWallet.nativeScript, + scriptCbor: storedScriptWallet.scriptCbor, + address: storedScriptWallet.address, + dRepId: dRepIdCip129, + } as Wallet; + } + const validScripts = buildPaymentSigScripts(wallet); if (validScripts.length === 0) { @@ -329,7 +372,7 @@ export function buildWallet( const nativeScript = buildNativeScriptFromPaymentSigners(wallet, validScripts); // Build address from payment script with external stake credential hash if available - // Legacy wallets can have external stake key hash but no individual stake keys + // Legacy wallets can have external stake key hash but no individual stake keys. const address = serializeNativeScript( nativeScript as NativeScript, wallet.stakeCredentialHash as undefined | string, // Use external stake credential hash if available diff --git a/tsconfig.json b/tsconfig.json index 010f0d82..82a6f56e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,6 +46,8 @@ ], "exclude": [ "node_modules", - "coverage" + "coverage", + "ci-artifacts", + "playwright-report" ] } From b30863fadf7d7980316e51b8491a985a1906e19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:54:09 +0200 Subject: [PATCH 04/93] chore: update Docker configurations and wallet handling - Added build arguments for Blockfrost API keys in docker-compose files to ensure consistent client bundle paths. - Updated Dockerfile to handle Blockfrost API keys as environment variables for production builds. - Modified CI workflow to allow insecure cookies for wallet sessions during testing. - Refactored wallet handling in various components to use the new useMeshWallet hook, ensuring compatibility with the latest wallet API. - Enhanced error handling in wallet session management to prevent issues with unconnected wallets. --- .github/workflows/pr-multisig-v1-smoke.yml | 12 +++++++++- Dockerfile.ci | 14 +++++++++++ docker-compose.ci.yml | 7 ++++++ docker-compose.playwright.yml | 20 ++++++++++++++-- e2e/RUNNING_LOCALLY.md | 3 ++- scripts/ci/README.md | 24 +++++++++++++------ .../common/modals/WalletAuthModal.tsx | 7 ++++-- .../source/instance-tab.tsx | 6 +++-- .../governance/hydra/HydraBudgetVote.tsx | 8 ++++--- src/lib/auth/walletSession.ts | 16 +++++++++---- src/pages/api-docs.tsx | 6 +++-- src/utils/get-provider.ts | 4 ++-- 12 files changed, 101 insertions(+), 26 deletions(-) diff --git a/.github/workflows/pr-multisig-v1-smoke.yml b/.github/workflows/pr-multisig-v1-smoke.yml index 5a8ca942..d2ecb203 100644 --- a/.github/workflows/pr-multisig-v1-smoke.yml +++ b/.github/workflows/pr-multisig-v1-smoke.yml @@ -31,7 +31,17 @@ on: jobs: multisig-v1-smoke: - if: ${{ always() && github.repository == 'MeshJS/multisig' }} + # workflow_run has secrets; never execute fork PR code in that context. + # workflow_dispatch is maintainer-triggered and may checkout github.sha. + if: >- + github.repository == 'MeshJS/multisig' && + ( + github.event_name == 'workflow_dispatch' || + ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository + ) + ) runs-on: ubuntu-latest timeout-minutes: 120 env: diff --git a/Dockerfile.ci b/Dockerfile.ci index 49941803..4e848756 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -49,6 +49,17 @@ 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 must pass them as build args (see +# docker-compose.playwright.yml). They default to empty for API-only smoke runs +# (docker-compose.ci.yml), which never load the client bundle. +ARG NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD= +ARG NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET= +ARG NEXT_PUBLIC_DEFAULT_NETWORK=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_DEFAULT_NETWORK=$NEXT_PUBLIC_DEFAULT_NETWORK + # 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. @@ -56,6 +67,9 @@ FROM base AS app # dummy DATABASE_URL keeps build-time module evaluation of the pg adapter happy. # (NODE_OPTIONS=--experimental-wasm-modules is inherited from the base stage.) ENV SKIP_ENV_VALIDATION=true +# Inlined into the client bundle: skips t3-env client validation in the browser, +# which would otherwise throw when optional CI builds omit NEXT_PUBLIC_* keys. +ENV NEXT_PUBLIC_SKIP_ENV_VALIDATION=true ENV NEXT_TELEMETRY_DISABLED=1 ENV DATABASE_URL=postgresql://build:build@localhost:5432/build RUN npm run build diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index 3197837c..12a6b7ad 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -21,6 +21,13 @@ services: context: . dockerfile: Dockerfile.ci target: app + # `next build` inlines NEXT_PUBLIC_* into the client bundle. API-only CI + # smoke reads Blockfrost via server-side BLOCKFROST_* at runtime, but pass + # build args when rebuilding so browser/client paths stay consistent. + args: + NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD: ${CI_BLOCKFROST_PREPROD_API_KEY:-} + NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET: ${CI_BLOCKFROST_MAINNET_API_KEY:-} + NEXT_PUBLIC_DEFAULT_NETWORK: ${CI_NETWORK_ID:-0} environment: NODE_ENV: test NEXT_TELEMETRY_DISABLED: "1" diff --git a/docker-compose.playwright.yml b/docker-compose.playwright.yml index 670d7aad..6d9431fc 100644 --- a/docker-compose.playwright.yml +++ b/docker-compose.playwright.yml @@ -20,6 +20,14 @@ services: 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:-} + NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET: ${CI_BLOCKFROST_MAINNET_API_KEY:-} + NEXT_PUBLIC_DEFAULT_NETWORK: ${CI_NETWORK_ID:-0} environment: NODE_ENV: test NEXT_TELEMETRY_DISABLED: "1" @@ -28,6 +36,9 @@ services: DATABASE_URL: postgresql://postgres:postgres@postgres:5432/multisig DIRECT_URL: postgresql://postgres:postgres@postgres:5432/multisig JWT_SECRET: ${CI_JWT_SECRET} + # The production build serves plain HTTP here; without this flag the + # wallet-session cookie is marked Secure and Chromium drops it. + WALLET_SESSION_ALLOW_INSECURE_COOKIE: "true" CORS_ORIGINS: http://webapp:3000,http://localhost:3000 NEXT_PUBLIC_DEFAULT_NETWORK: ${CI_NETWORK_ID:-0} NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD: ${CI_BLOCKFROST_PREPROD_API_KEY:-} @@ -43,14 +54,19 @@ services: # "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...' && - npm run dev -- --hostname 0.0.0.0 --port 3000 + echo 'Starting application (production build)...' && + node_modules/.bin/next start --hostname 0.0.0.0 --port 3000 " healthcheck: test: diff --git a/e2e/RUNNING_LOCALLY.md b/e2e/RUNNING_LOCALLY.md index 86daa159..036c38e6 100644 --- a/e2e/RUNNING_LOCALLY.md +++ b/e2e/RUNNING_LOCALLY.md @@ -239,7 +239,8 @@ 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.js bakes `NEXT_PUBLIC_*` vars into the client bundle at first compile. +`next build` bakes `NEXT_PUBLIC_*` vars into the client bundle at image build time +(they are passed as Docker build args from `.env.playwright`). **`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 diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 3e945eaa..187f2721 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -409,6 +409,14 @@ $env:CI_ROUTE_SCENARIOS="" Start a clean CI-like stack: +Set the CI env vars above **before** `up`. The `app` service only receives `CI_BLOCKFROST_PREPROD_API_KEY` and `CI_JWT_SECRET` when its container is created. If you set them after the first `up`, recreate app: + +```powershell +docker compose -f docker-compose.ci.yml up -d postgres app --force-recreate +``` + +`wallet-status` can pass while route-chain 500s on `freeUtxos` / `governanceActiveProposals` when the app started without Blockfrost keys — the ci-runner calls Blockfrost directly; those v1 routes call Blockfrost through the app. + If you changed local code or Dockerfiles, rebuild `app` and `ci-runner`; otherwise you can skip the `build` command for faster reruns. ```powershell @@ -419,10 +427,12 @@ docker compose -f docker-compose.ci.yml up -d postgres app Bootstrap wallets and write host-mounted artifacts: +Use the pre-bundled `.ci-dist/*.mjs` entrypoints (see `Dockerfile.ci`). Do not run these via `tsx` inside `ci-runner`: route-chain loads `@meshsdk/core-csl` / `whisky-evaluator` WASM, which `tsx` cannot load (`js_evaluate_tx_scripts` export errors). Rebuild `ci-runner` after CI script edits so esbuild refreshes `.ci-dist/`. + ```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). @@ -430,7 +440,7 @@ Optional: confirm wallets are funded on-chain before running route-chain (uses ` ```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 +449,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 ``` @@ -507,12 +517,12 @@ docker compose -f docker-compose.ci.yml build app ci-runner docker compose -f docker-compose.ci.yml up -d postgres app ``` -Bootstrap wallets and write host-mounted artifacts: +Bootstrap wallets and write host-mounted artifacts (use bundled `.ci-dist/*.mjs`; see PowerShell section above): ```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). @@ -520,7 +530,7 @@ Optional: confirm wallets are funded on-chain before running route-chain (uses ` ```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 +539,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: diff --git a/src/components/common/modals/WalletAuthModal.tsx b/src/components/common/modals/WalletAuthModal.tsx index 0f035e32..2274ed28 100644 --- a/src/components/common/modals/WalletAuthModal.tsx +++ b/src/components/common/modals/WalletAuthModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from "react"; -import { useWallet } from "@meshsdk/react"; +import useMeshWallet from "@/hooks/useMeshWallet"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { useToast } from "@/hooks/use-toast"; @@ -16,7 +16,10 @@ interface WalletAuthModalProps { } export function WalletAuthModal({ address, open, onClose, onAuthorized, autoAuthorize = false }: WalletAuthModalProps) { - const { wallet, connected } = useWallet(); + // useMeshWallet (not raw useWallet): react 2.0's wallet has an incompatible + // signData(addressBech32, data) signature and returns hex addresses, which + // breaks the nonce flow below. The hook re-exposes the 1.9 IWallet surface. + const { wallet, connected } = useMeshWallet(); const network = useSiteStore((state) => state.network); const netId = (network === 1 ? 1 : 0) as 0 | 1; const { wallet: utxosWallet, isEnabled: isUtxosEnabled } = useUTXOS(); diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/source/instance-tab.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/source/instance-tab.tsx index ee5f73d1..7be9304b 100644 --- a/src/components/pages/homepage/wallets/import-wallet-flow/source/instance-tab.tsx +++ b/src/components/pages/homepage/wallets/import-wallet-flow/source/instance-tab.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useWallet } from "@meshsdk/react"; +import useMeshWallet from "@/hooks/useMeshWallet"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -37,7 +37,9 @@ type Mode = * and lets them pick before signing. */ export default function InstanceTab({ flow }: Props) { - const { wallet, connected } = useWallet(); + // useMeshWallet (not raw useWallet): the nonce signing below needs 1.9's + // signData(payload, address); react 2.0's wallet has the arguments swapped. + const { wallet, connected } = useMeshWallet(); const { toast } = useToast(); const [urlInput, setUrlInput] = useState(""); const [busy, setBusy] = useState(false); diff --git a/src/components/pages/wallet/governance/hydra/HydraBudgetVote.tsx b/src/components/pages/wallet/governance/hydra/HydraBudgetVote.tsx index cb788684..ddf55f27 100644 --- a/src/components/pages/wallet/governance/hydra/HydraBudgetVote.tsx +++ b/src/components/pages/wallet/governance/hydra/HydraBudgetVote.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useWallet } from "@meshsdk/react"; +import useMeshWallet from "@/hooks/useMeshWallet"; import { Info, Loader, Check, X, ExternalLink } from "lucide-react"; import useAppWallet from "@/hooks/useAppWallet"; @@ -53,7 +53,9 @@ interface EkklesiaSignablePayload { export default function HydraBudgetVote() { const { appWallet } = useAppWallet(); const { multisigWallet } = useMultisigWallet(); - const { wallet, connected } = useWallet(); + // useMeshWallet (not raw useWallet): signDataHex below needs 1.9's + // signData(payload, address); react 2.0's wallet has the arguments swapped. + const { wallet, connected } = useMeshWallet(); const userAddress = useUserStore((s) => s.userAddress); const { toast } = useToast(); const ctx = api.useUtils(); @@ -103,7 +105,7 @@ export default function HydraBudgetVote() { */ const signDataHex = useCallback( async (dataHex: string): Promise => { - if (!connected) throw new Error("Wallet not connected"); + if (!connected || !wallet) throw new Error("Wallet not connected"); if (!userAddress) throw new Error("User address not found"); const sig = await wallet.signData(dataHex, userAddress); return { signature: sig.signature, key: sig.key }; diff --git a/src/lib/auth/walletSession.ts b/src/lib/auth/walletSession.ts index 88d39246..f3a0aa4a 100644 --- a/src/lib/auth/walletSession.ts +++ b/src/lib/auth/walletSession.ts @@ -31,10 +31,19 @@ export function getWalletSessionFromReq(req: NextApiRequest): WalletSessionPaylo return parseWalletSessionToken(raw); } +// `next start` always runs with NODE_ENV=production, but CI serves the +// production build over plain HTTP (http://webapp:3000), where Chromium +// silently drops `Secure` cookies. WALLET_SESSION_ALLOW_INSECURE_COOKIE lets +// the Playwright/CI stack opt out of the Secure attribute; it must never be +// set in a real deployment. +function isSecureCookieEnabled(): boolean { + if (process.env.WALLET_SESSION_ALLOW_INSECURE_COOKIE === "true") return false; + return env.NODE_ENV === "production"; +} + export function setWalletSessionCookie(res: NextApiResponse, payload: WalletSessionPayload) { const token = createWalletSessionToken(payload); - const secure = env.NODE_ENV === "production"; - const secureAttr = secure ? "; Secure" : ""; + const secureAttr = isSecureCookieEnabled() ? "; Secure" : ""; res.setHeader( "Set-Cookie", `${WALLET_SESSION_COOKIE}=${token}; Path=/; HttpOnly${secureAttr}; SameSite=Lax; Max-Age=${ @@ -44,8 +53,7 @@ export function setWalletSessionCookie(res: NextApiResponse, payload: WalletSess } export function clearWalletSessionCookie(res: NextApiResponse) { - const secure = env.NODE_ENV === "production"; - const secureAttr = secure ? "; Secure" : ""; + const secureAttr = isSecureCookieEnabled() ? "; Secure" : ""; res.setHeader( "Set-Cookie", `${WALLET_SESSION_COOKIE}=; Path=/; HttpOnly${secureAttr}; SameSite=Lax; Max-Age=0`, diff --git a/src/pages/api-docs.tsx b/src/pages/api-docs.tsx index 495c867c..761e3a7e 100644 --- a/src/pages/api-docs.tsx +++ b/src/pages/api-docs.tsx @@ -1,7 +1,7 @@ // src/pages/api-docs.tsx import dynamic from "next/dynamic"; import React, { useEffect, useState, useRef } from "react"; -import { useWallet } from "@meshsdk/react"; +import useMeshWallet from "@/hooks/useMeshWallet"; import { Key, Lightbulb, Copy, Check } from "lucide-react"; import Globe from "./globe"; @@ -13,7 +13,9 @@ const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false }); export const getServerSideProps = () => ({ props: {} }); export default function ApiDocs() { - const { wallet, connected } = useWallet(); + // useMeshWallet (not raw useWallet): the nonce flow below needs the 1.9 + // IWallet surface — bech32 addresses and signData(payload, address). + const { wallet, connected } = useMeshWallet(); const [isGeneratingToken, setIsGeneratingToken] = useState(false); const [generatedToken, setGeneratedToken] = useState(null); const [copied, setCopied] = useState(false); diff --git a/src/utils/get-provider.ts b/src/utils/get-provider.ts index 25218317..fdabc2ec 100644 --- a/src/utils/get-provider.ts +++ b/src/utils/get-provider.ts @@ -4,8 +4,8 @@ import { BlockfrostProvider } from "@meshsdk/core"; export function getProvider(network: number) { const key = network == 0 - ? env.NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD - : env.NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET; + ? env.BLOCKFROST_API_KEY_PREPROD ?? env.NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD + : env.BLOCKFROST_API_KEY_MAINNET ?? env.NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET; if (!key) throw new Error(`No Blockfrost API key configured for network ${network}`); return new BlockfrostProvider(key); } From fffa2100de236f7370940bd9a9fffa45124f0086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:01:44 +0200 Subject: [PATCH 05/93] Refactor code structure for improved readability and maintainability and added parallel playwright workers --- docker-compose.playwright.yml | 4 + e2e/RUNNING_LOCALLY.md | 11 +- e2e/fixtures/walletFixture.ts | 265 ++++++++++++++++++++----------- e2e/playwright-report/index.html | 90 +++++++++++ e2e/playwright.config.ts | 4 + e2e/tests/ring-transfer.spec.ts | 19 ++- 6 files changed, 296 insertions(+), 97 deletions(-) create mode 100644 e2e/playwright-report/index.html diff --git a/docker-compose.playwright.yml b/docker-compose.playwright.yml index 6d9431fc..ab01c0b9 100644 --- a/docker-compose.playwright.yml +++ b/docker-compose.playwright.yml @@ -111,8 +111,12 @@ services: 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:-} diff --git a/e2e/RUNNING_LOCALLY.md b/e2e/RUNNING_LOCALLY.md index 036c38e6..3da86b8d 100644 --- a/e2e/RUNNING_LOCALLY.md +++ b/e2e/RUNNING_LOCALLY.md @@ -200,6 +200,7 @@ docker compose -f docker-compose.playwright.yml --env-file .env.playwright down | `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 Test Works @@ -209,7 +210,8 @@ docker compose -f docker-compose.playwright.yml --env-file .env.playwright down 2. `global-setup.ts` validates env vars and caches the context JSON for the test run. -3. `ring-transfer.spec.ts` runs three sequential legs. For each leg: +3. `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 `window.cardano.meshci` mock intercepts `signTx` and bridges to `MeshWallet.signTx` in Node.js using the corresponding mnemonic. @@ -218,7 +220,12 @@ docker compose -f docker-compose.playwright.yml --env-file .env.playwright down - The test waits for `[data-testid="tx-broadcast-success"]` and confirms the pending transaction is cleared via `/api/v1/pendingTransactions`. -4. Legs run serially to avoid UTxO conflicts between legs. +4. 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. ## Troubleshooting diff --git a/e2e/fixtures/walletFixture.ts b/e2e/fixtures/walletFixture.ts index b1ab3e2c..48d5a64c 100644 --- a/e2e/fixtures/walletFixture.ts +++ b/e2e/fixtures/walletFixture.ts @@ -4,6 +4,9 @@ // 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"; @@ -14,6 +17,55 @@ type WalletFixtures = { 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]; @@ -38,7 +90,9 @@ export const test = base.extend({ const stakeAddress = ctx.signerStakeAddresses?.[signerIndex] ?? ""; if (!signerAddress) { - throw new Error(`No signer address at index ${signerIndex} in bootstrap context`); + throw new Error( + `No signer address at index ${signerIndex} in bootstrap context`, + ); } // Update mutable state so all subsequent bridge calls use the new signer. @@ -48,18 +102,26 @@ export const test = base.extend({ // 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_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), + 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) => @@ -84,91 +146,112 @@ export const test = base.extend({ connectWallet: async ({}, use) => { await use(async (page: Page) => { - // 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 via the app's own endpoint. - await page.request.delete("/api/auth/wallet-session").catch(() => {}); - // Clear the flag that prevents WalletAuthModal from showing when the layout - // detects a connected wallet without an authorized wallet session. - await page.evaluate(() => sessionStorage.removeItem("mesh_session_checked")); - - // 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 nonce → signData → wallet-session sequence must not interleave + // with another worker authenticating the same signer address. + await acquireAuthLock(); + try { + await connectWalletFlow(page); + } finally { + releaseAuthLock(); } - - // 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(() => {}); }); }, }); +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 via the app's own endpoint. + await page.request.delete("/api/auth/wallet-session").catch(() => {}); + // Clear the flag that prevents WalletAuthModal from showing when the layout + // detects a connected wallet without an authorized wallet session. + await page.evaluate(() => sessionStorage.removeItem("mesh_session_checked")); + + // 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/playwright-report/index.html b/e2e/playwright-report/index.html new file mode 100644 index 00000000..43bf771b --- /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 index 6baffd08..6a57fac7 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -6,6 +6,10 @@ export default defineConfig({ globalSetup: "./global-setup.ts", timeout: 120_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", diff --git a/e2e/tests/ring-transfer.spec.ts b/e2e/tests/ring-transfer.spec.ts index f9a229be..22b2bce9 100644 --- a/e2e/tests/ring-transfer.spec.ts +++ b/e2e/tests/ring-transfer.spec.ts @@ -1,7 +1,11 @@ // Phase 4: Full browser-driven ring transfer test. // -// Three sequential legs share the same preprod wallets and run in order to -// avoid UTxO conflicts. Each leg: +// 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" — @@ -74,10 +78,13 @@ function totalLovelace(utxos: BlockfrostUtxo[]): number { }, 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 = 180_000, + timeoutMs = 300_000, ): Promise { const deadline = Date.now() + timeoutMs; let lastBalance = 0; @@ -266,7 +273,11 @@ async function waitForTxCleared( ); } -test.describe.serial("ring transfer", () => { +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); From 451ced9aa894eccac19489ad70245f2b8d2ec309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:01:19 +0200 Subject: [PATCH 06/93] feat: implement email notification system for wallet signatures - Add notification center for handling signature required notifications. - Create email channel for sending notifications via Resend. - Define types for email messages and send results. - Implement event types and notification statuses for email notifications. - Create outbox for managing notification deliveries. - Resolve signature recipients based on notification settings. - Develop email templates for signature required and email verification notifications. - Implement worker to drain notification outbox and send emails. - Create API endpoints for draining notifications and verifying email addresses. - Add TRPC router for managing wallet signer notification settings and sending reminders. --- .env.example | 7 + .gitignore | 1 + docs/notification-center-plan.md | 446 ++++++++++++++++++ package-lock.json | 70 ++- package.json | 1 + .../migration.sql | 97 ++++ prisma/schema.prisma | 61 +++ src/__tests__/notifications.test.ts | 125 +++++ src/components/pages/wallet/info/index.tsx | 2 + .../info/wallet-notification-settings.tsx | 242 ++++++++++ src/env.js | 15 +- src/lib/notifications/center.ts | 194 ++++++++ .../notifications/channels/email/resend.ts | 44 ++ src/lib/notifications/channels/email/types.ts | 11 + src/lib/notifications/events.ts | 51 ++ src/lib/notifications/outbox.ts | 47 ++ src/lib/notifications/recipients.ts | 125 +++++ src/lib/notifications/templates/shared.ts | 74 +++ .../templates/signatureRequired.ts | 66 +++ .../notifications/templates/verifyEmail.ts | 39 ++ src/lib/notifications/worker.ts | 118 +++++ .../createPendingMultisigTransaction.ts | 32 +- src/pages/api/notifications/drain.ts | 45 ++ src/pages/api/notifications/email/verify.ts | 98 ++++ src/pages/api/v1/submitDatum.ts | 27 ++ src/server/api/root.ts | 2 + src/server/api/routers/notifications.ts | 330 +++++++++++++ src/server/api/routers/signable.ts | 21 +- src/server/api/routers/transactions.ts | 31 +- 29 files changed, 2397 insertions(+), 25 deletions(-) create mode 100644 docs/notification-center-plan.md create mode 100644 prisma/migrations/20260617070000_add_notification_center/migration.sql create mode 100644 src/__tests__/notifications.test.ts create mode 100644 src/components/pages/wallet/info/wallet-notification-settings.tsx create mode 100644 src/lib/notifications/center.ts create mode 100644 src/lib/notifications/channels/email/resend.ts create mode 100644 src/lib/notifications/channels/email/types.ts create mode 100644 src/lib/notifications/events.ts create mode 100644 src/lib/notifications/outbox.ts create mode 100644 src/lib/notifications/recipients.ts create mode 100644 src/lib/notifications/templates/shared.ts create mode 100644 src/lib/notifications/templates/signatureRequired.ts create mode 100644 src/lib/notifications/templates/verifyEmail.ts create mode 100644 src/lib/notifications/worker.ts create mode 100644 src/pages/api/notifications/drain.ts create mode 100644 src/pages/api/notifications/email/verify.ts create mode 100644 src/server/api/routers/notifications.ts diff --git a/.env.example b/.env.example index 6369a1d8..3a8a0bf2 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,10 @@ 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" +# NOTIFICATION_DRAIN_SECRET="your-notification-drain-secret" +# NOTIFICATIONS_EMAIL_ENABLED="false" diff --git a/.gitignore b/.gitignore index 2c85f24b..870d4384 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 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/package-lock.json b/package-lock.json index 002958a7..9c06738b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,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", @@ -245,17 +246,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", @@ -6739,15 +6729,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 +6756,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", @@ -12420,6 +12407,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", @@ -19634,6 +19627,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 +20895,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 +21583,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..808a7d37 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,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", 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/schema.prisma b/prisma/schema.prisma index f753e5b2..5bbc49bf 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 diff --git a/src/__tests__/notifications.test.ts b/src/__tests__/notifications.test.ts new file mode 100644 index 00000000..ac8803ef --- /dev/null +++ b/src/__tests__/notifications.test.ts @@ -0,0 +1,125 @@ +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 { 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, + }, + ]), + ); + }); +}); + +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", + }); + + expect(email.subject).toBe("Signature required: "); + expect(email.html).toContain("<Vault>"); + expect(email.html).toContain("<script>alert('x')</script>"); + expect(email.html).not.toContain(" - - - -
- - - \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 817fc93d..f3f8c710 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,6 @@ "@eslint/eslintrc": "^3.3.3", "@jest/globals": "^30.1.2", "@next/bundle-analyzer": "^16.2.6", - "@playwright/test": "^1.50.0", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.17.7", "@types/cors": "^2.8.18", @@ -107,7 +106,8 @@ "prisma": "^7.8.0", "tailwindcss": "^3.4.3", "ts-jest": "^29.4.4", - "typescript": "^5.5.3" + "typescript": "^5.5.3", + "@playwright/test": "1.60.0" } }, "node_modules/@alloc/quick-lru": { @@ -247,6 +247,17 @@ } } }, + "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", @@ -4985,22 +4996,6 @@ "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", @@ -6746,6 +6741,15 @@ "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", @@ -19626,52 +19630,6 @@ "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", @@ -24180,6 +24138,68 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "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/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" + } } } } diff --git a/package.json b/package.json index 3f1ae903..df20106b 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,6 @@ "@eslint/eslintrc": "^3.3.3", "@jest/globals": "^30.1.2", "@next/bundle-analyzer": "^16.2.6", - "@playwright/test": "^1.50.0", "@react-three/drei": "^9.122.0", "@react-three/fiber": "^8.17.7", "@types/cors": "^2.8.18", @@ -132,12 +131,13 @@ "prisma": "^7.8.0", "tailwindcss": "^3.4.3", "ts-jest": "^29.4.4", - "typescript": "^5.5.3" + "typescript": "^5.5.3", + "@playwright/test": "1.60.0" }, "ct3aMetadata": { "initVersion": "7.37.0" }, - "packageManager": "npm@11.14.1", + "packageManager": "npm@10.7.0", "overrides": { "ip": "^2.0.1", "rimraf": "^6.1.2", diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 187f2721..3e945eaa 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -409,14 +409,6 @@ $env:CI_ROUTE_SCENARIOS="" Start a clean CI-like stack: -Set the CI env vars above **before** `up`. The `app` service only receives `CI_BLOCKFROST_PREPROD_API_KEY` and `CI_JWT_SECRET` when its container is created. If you set them after the first `up`, recreate app: - -```powershell -docker compose -f docker-compose.ci.yml up -d postgres app --force-recreate -``` - -`wallet-status` can pass while route-chain 500s on `freeUtxos` / `governanceActiveProposals` when the app started without Blockfrost keys — the ci-runner calls Blockfrost directly; those v1 routes call Blockfrost through the app. - If you changed local code or Dockerfiles, rebuild `app` and `ci-runner`; otherwise you can skip the `build` command for faster reruns. ```powershell @@ -427,12 +419,10 @@ docker compose -f docker-compose.ci.yml up -d postgres app Bootstrap wallets and write host-mounted artifacts: -Use the pre-bundled `.ci-dist/*.mjs` entrypoints (see `Dockerfile.ci`). Do not run these via `tsx` inside `ci-runner`: route-chain loads `@meshsdk/core-csl` / `whisky-evaluator` WASM, which `tsx` cannot load (`js_evaluate_tx_scripts` export errors). Rebuild `ci-runner` after CI script edits so esbuild refreshes `.ci-dist/`. - ```powershell docker compose -f docker-compose.ci.yml run --rm ` -e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json ` - ci-runner node .ci-dist/bootstrap.mjs + ci-runner npx --yes tsx scripts/ci/cli/bootstrap.ts ``` 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). @@ -440,7 +430,7 @@ Optional: confirm wallets are funded on-chain before running route-chain (uses ` ```powershell docker compose -f docker-compose.ci.yml run --rm ` -e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json ` - ci-runner node .ci-dist/wallet-status.mjs + ci-runner npx --yes tsx scripts/ci/cli/wallet-status.ts ``` Run route-chain smoke scenarios: @@ -449,7 +439,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 node .ci-dist/route-chain.mjs + ci-runner npx --yes tsx scripts/ci/cli/route-chain.ts ``` @@ -517,12 +507,12 @@ docker compose -f docker-compose.ci.yml build app ci-runner docker compose -f docker-compose.ci.yml up -d postgres app ``` -Bootstrap wallets and write host-mounted artifacts (use bundled `.ci-dist/*.mjs`; see PowerShell section above): +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 node .ci-dist/bootstrap.mjs + ci-runner npx --yes tsx scripts/ci/cli/bootstrap.ts ``` 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). @@ -530,7 +520,7 @@ Optional: confirm wallets are funded on-chain before running route-chain (uses ` ```bash docker compose -f docker-compose.ci.yml run --rm \ -e CI_CONTEXT_PATH=/artifacts/ci-wallet-context.json \ - ci-runner node .ci-dist/wallet-status.mjs + ci-runner npx --yes tsx scripts/ci/cli/wallet-status.ts ``` Run route-chain smoke scenarios: @@ -539,7 +529,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 node .ci-dist/route-chain.mjs + ci-runner npx --yes tsx scripts/ci/cli/route-chain.ts ``` View generated report on host: diff --git a/src/components/common/cardano-objects/resolve-adahandle.tsx b/src/components/common/cardano-objects/resolve-adahandle.tsx index 870a757a..51ee040b 100644 --- a/src/components/common/cardano-objects/resolve-adahandle.tsx +++ b/src/components/common/cardano-objects/resolve-adahandle.tsx @@ -17,9 +17,6 @@ export const resolveAdaHandle = async ( index: number, value: string, ) => { - // AdaHandle lookup only supports mainnet; instantiate lazily so a missing - // mainnet key in preprod CI environments does not crash the page on load. - const provider = getProvider(1); try { const handleName = value.substring(1); if (handleName.length === 0) { diff --git a/src/components/common/overall-layout/layout.tsx b/src/components/common/overall-layout/layout.tsx index 28b3a70c..9285c98a 100644 --- a/src/components/common/overall-layout/layout.tsx +++ b/src/components/common/overall-layout/layout.tsx @@ -125,32 +125,9 @@ export default function RootLayout({ // State for wallet authorization modal const [showAuthModal, setShowAuthModal] = useState(false); const [checkingSession, setCheckingSession] = useState(false); - // hasCheckedSession is persisted in sessionStorage so it survives full-page navigations - // within the same browser tab. Without this, a new page load resets the flag and triggers - // a redundant session re-check that opens the WalletAuthModal even when already authorized. - const [hasCheckedSession, setHasCheckedSessionState] = useState(false); + const [hasCheckedSession, setHasCheckedSession] = useState(false); // Prevent duplicate checks const [showPostAuthLoading, setShowPostAuthLoading] = useState(false); // Show loading after authorization - // Restore hasCheckedSession from sessionStorage on mount (client-side only). - // This prevents the modal from appearing on every page navigation. - useEffect(() => { - if (typeof window !== "undefined" && sessionStorage.getItem("mesh_session_checked") === "1") { - setHasCheckedSessionState(true); - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // Keep sessionStorage in sync whenever hasCheckedSession changes. - const setHasCheckedSession = useCallback((checked: boolean) => { - setHasCheckedSessionState(checked); - if (typeof window !== "undefined") { - if (checked) { - sessionStorage.setItem("mesh_session_checked", "1"); - } else { - sessionStorage.removeItem("mesh_session_checked"); - } - } - }, []); - // Animated background preference (persisted to localStorage). Gate render on a // mounted flag so the server (which can't read localStorage) and the first // client paint agree, avoiding a hydration mismatch. diff --git a/src/components/pages/wallet/new-transaction/RecipientRow.tsx b/src/components/pages/wallet/new-transaction/RecipientRow.tsx index 34fc013b..10c32f09 100644 --- a/src/components/pages/wallet/new-transaction/RecipientRow.tsx +++ b/src/components/pages/wallet/new-transaction/RecipientRow.tsx @@ -118,7 +118,6 @@ function RecipientRow({ void handleAddressChange(e.target.value); }} className="flex-1" - data-testid={`recipient-address-input-${index}`} /> {getAddressLabel && recipientAddresses[index] && (() => { const addressLabel = getAddressLabel(recipientAddresses[index]!); @@ -159,7 +158,6 @@ function RecipientRow({ }} placeholder="" disabled={disableAdaAmountInput} - data-testid={`amount-input-${index}`} />
diff --git a/src/components/pages/wallet/new-transaction/index.tsx b/src/components/pages/wallet/new-transaction/index.tsx index d7d46b40..081f34a5 100644 --- a/src/components/pages/wallet/new-transaction/index.tsx +++ b/src/components/pages/wallet/new-transaction/index.tsx @@ -420,7 +420,6 @@ export default function PageNewTransaction({ onSuccess }: { onSuccess?: () => vo if (selectedUtxos.length === 0) { setError("Insufficient funds"); - setLoading(false); return; } @@ -1379,7 +1378,6 @@ export default function PageNewTransaction({ onSuccess }: { onSuccess?: () => vo disabled={loading} size="lg" className="h-11 w-full sm:h-12 sm:w-auto sm:min-w-[200px]" - data-testid="create-transaction-button" > {loading ? ( <> diff --git a/src/components/pages/wallet/new-transaction/utxoSelector.tsx b/src/components/pages/wallet/new-transaction/utxoSelector.tsx index 42c0f86d..643a6818 100644 --- a/src/components/pages/wallet/new-transaction/utxoSelector.tsx +++ b/src/components/pages/wallet/new-transaction/utxoSelector.tsx @@ -170,10 +170,6 @@ export default function UTxOSelector({ setIsInitialLoad(false); } catch (error) { console.error(`Failed to fetch UTxOs for Address ${address}:`, error); - setUtxos([]); - setSelectedUtxos([]); - setLoaded(true); - setIsInitialLoad(false); } finally { setLoading(false); } @@ -401,11 +397,7 @@ export default function UTxOSelector({ }, [selectedUtxos, recipientAmounts, recipientAssets, walletAssetMetadata]); return ( -
+
); -} +} \ No newline at end of file diff --git a/src/components/pages/wallet/transactions/transaction-card.tsx b/src/components/pages/wallet/transactions/transaction-card.tsx index 229b8110..cb1feb33 100644 --- a/src/components/pages/wallet/transactions/transaction-card.tsx +++ b/src/components/pages/wallet/transactions/transaction-card.tsx @@ -221,7 +221,6 @@ export default function TransactionCard({ }, [transaction.txJson]); const [loading, setLoading] = useState(false); const [isSignersOpen, setIsSignersOpen] = useState(false); - const [broadcastDone, setBroadcastDone] = useState(false); const { toast } = useToast(); const ctx = api.useUtils(); const network = useSiteStore((state) => state.network); @@ -447,7 +446,6 @@ export default function TransactionCard({ }); txHash = submitResult.txHash; signedTx = submitResult.txHex; - setBroadcastDone(true); } updateTransaction({ @@ -650,7 +648,7 @@ export default function TransactionCard({ // the Transactions page still loads and the user can free locked UTxOs (#211). if (!txJson) { return ( - + Unreadable transaction @@ -723,7 +721,7 @@ export default function TransactionCard({ const pendingCount = signersCount - signedCount - rejectedCount; return ( - +
@@ -1181,17 +1179,13 @@ export default function TransactionCard({ !transaction.signedAddresses.includes(userAddress) && !transaction.rejectedAddresses.includes(userAddress) && ( - {broadcastDone && ( -
- )} - +
+ ) : votes.length === 0 ? ( +

+ This DRep has not voted on any governance actions yet. +

+ ) : ( + <> + {/* Controls: filter chips, search, export */} +
+
+ {( + [ + ["All", votes.length], + ["Yes", counts.Yes], + ["No", counts.No], + ["Abstain", counts.Abstain], + ] as [VoteFilter, number][] + ).map(([value, count]) => ( + + ))} +
+
+
+ + setSearch(e.target.value)} + placeholder="Search proposals…" + className="h-8 w-48 pl-7 text-xs sm:w-56" + /> +
+ +
+
+ + {filtered.length === 0 ? ( +

+ No votes match the current filter. +

+ ) : ( +
    + {filtered.map((v) => ( + { + loadRationale(v).catch(() => { + // surfaced inline via the row's error state + }); + }} + /> + ))} +
+ )} + + )} + + ); +} + +function VoteRow({ + item, + network, + rationale, + onExpand, +}: { + item: DrepVoteHistoryItem; + network: number; + rationale: RationaleState | undefined; + onExpand: () => void; +}) { + const [open, setOpen] = useState(false); + const href = item.metaUrl ? anchorHref(item.metaUrl) : undefined; + + return ( +
  • +
    +
    +
    + {item.proposalType && ( + + )} + + {formatVoteDate(item.blockTime)} + +
    + + + {item.proposalTitle ?? item.proposalId} + + + +
    + +
    + + {item.metaUrl ? ( + { + setOpen(next); + if (next) onExpand(); + }} + className="mt-2" + > +
    + + + Rationale + {open ? ( + + ) : ( + + )} + + {href && ( + + + source + + )} +
    + + {rationale?.status === "done" ? ( + rationale.text ? ( +

    + {rationale.text} +

    + ) : ( +

    + No rationale text found in the anchor document. +

    + ) + ) : rationale?.status === "error" ? ( +

    + Could not load the rationale from its anchor. +

    + ) : ( + // Expanding always kicks off a load, so no-state means loading. +
    + + Loading rationale… +
    + )} +
    +
    + ) : ( +

    + No rationale attached to this vote. +

    + )} +
  • + ); +} diff --git a/src/pages/api/governance/drepVotes.ts b/src/pages/api/governance/drepVotes.ts new file mode 100644 index 00000000..01ffb22c --- /dev/null +++ b/src/pages/api/governance/drepVotes.ts @@ -0,0 +1,168 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { applyRateLimit } from "@/lib/security/requestGuards"; +import type { + DrepVoteHistoryItem, + DrepVoteHistoryResponse, +} from "@/types/governance"; + +/** + * DRep vote history, joined with proposal titles/types. + * + * Sourced from Koios rather than Blockfrost because Blockfrost's + * `/governance/dreps/{id}/votes` returns neither the proposal a vote was cast + * on nor the rationale anchor (meta_url/meta_hash) — both of which this page + * is about. Koios does not send CORS headers, so the browser can't call it + * directly; this route proxies it server-side (same pattern as + * /api/ipfs/resolve). No API key is required for the public Koios tier. + */ + +const KOIOS_BASES: Record = { + "0": "https://preprod.koios.rest/api/v1", // network 0 = preprod (see getProvider) + "1": "https://api.koios.rest/api/v1", +}; + +const TIMEOUT_MS = 15_000; +/** Koios public-tier page cap. */ +const PAGE_SIZE = 500; +/** Hard stop so a pathological upstream can't keep us looping. */ +const MAX_PAGES = 8; + +/** bech32 payload charset — also guarantees the id is URL-safe to embed. */ +const DREP_ID_RE = /^drep1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{6,120}$/; + +type KoiosDrepVote = { + proposal_id: string; + proposal_tx_hash: string; + proposal_index: number; + vote_tx_hash: string; + block_time: number; + vote: string; + meta_url: string | null; + meta_hash: string | null; +}; + +type KoiosProposalTitle = { + proposal_id: string; + proposal_type: string | null; + title: string | null; +}; + +async function koiosGet(url: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + throw new Error(`Koios responded ${res.status}`); + } + return (await res.json()) as T; + } finally { + clearTimeout(timeout); + } +} + +async function koiosGetAllPages(baseUrl: string): Promise { + const rows: T[] = []; + for (let page = 0; page < MAX_PAGES; page++) { + const sep = baseUrl.includes("?") ? "&" : "?"; + const pageRows = await koiosGet( + `${baseUrl}${sep}limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}`, + ); + if (!Array.isArray(pageRows)) { + throw new Error("Koios returned an unexpected payload"); + } + rows.push(...pageRows); + if (pageRows.length < PAGE_SIZE) break; + } + return rows; +} + +/** Koios reports PascalCase types; the UI chips key on Blockfrost snake_case. */ +function normalizeProposalType(type: string | null | undefined): string | null { + if (!type) return null; + return type.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase(); +} + +function normalizeVote(vote: string): DrepVoteHistoryItem["vote"] { + const v = vote.trim().toLowerCase(); + if (v === "yes") return "Yes"; + if (v === "no") return "No"; + return "Abstain"; +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== "GET") { + return res.status(405).json({ error: "Method not allowed" }); + } + if (!applyRateLimit(req, res, { keySuffix: "drepVotes" })) return; + + const drepId = String(req.query.drepId ?? "").trim().toLowerCase(); + if (!DREP_ID_RE.test(drepId)) { + return res.status(400).json({ error: "Invalid or missing drepId" }); + } + const base = KOIOS_BASES[String(req.query.network ?? "")]; + if (!base) { + return res.status(400).json({ error: "network must be 0 or 1" }); + } + + try { + const koiosVotes = await koiosGetAllPages( + `${base}/drep_votes?_drep_id=${encodeURIComponent(drepId)}&order=block_time.desc`, + ); + + // Join proposal titles/types in one paginated sweep. The full proposal + // list (title-only rows) is far smaller than filtering by the voted ids, + // which would blow past URL length limits for prolific DReps. + let titlesById = new Map(); + if (koiosVotes.length > 0) { + try { + // Order by proposal_id (stable + unique) for consistent pagination; + // proposal_list rejects ordering by columns outside the projection. + const titles = await koiosGetAllPages( + `${base}/proposal_list?select=proposal_id,proposal_type,title:meta_json->body->>title&order=proposal_id.asc`, + ); + titlesById = new Map(titles.map((t) => [t.proposal_id, t])); + } catch (error) { + // Titles are decoration — still return the votes without them. + console.warn("drepVotes: proposal title join failed:", error); + } + } + + const votes: DrepVoteHistoryItem[] = koiosVotes + .map((v) => { + const proposal = titlesById.get(v.proposal_id); + return { + proposalId: v.proposal_id, + proposalTxHash: v.proposal_tx_hash, + proposalIndex: v.proposal_index, + voteTxHash: v.vote_tx_hash, + blockTime: v.block_time, + vote: normalizeVote(v.vote), + metaUrl: v.meta_url ?? null, + metaHash: v.meta_hash ?? null, + proposalType: normalizeProposalType(proposal?.proposal_type), + proposalTitle: proposal?.title ?? null, + }; + }) + .sort((a, b) => b.blockTime - a.blockTime); + + // Votes only ever append; let the CDN absorb repeat visits. + res.setHeader( + "Cache-Control", + "public, s-maxage=300, stale-while-revalidate=3600", + ); + const body: DrepVoteHistoryResponse = { drepId, votes }; + return res.status(200).json(body); + } catch (error) { + console.error("drepVotes: failed to fetch from Koios:", error); + return res + .status(502) + .json({ error: "Could not fetch vote history from Koios" }); + } +} diff --git a/src/types/governance.ts b/src/types/governance.ts index 3cc72fd5..27a4e184 100644 --- a/src/types/governance.ts +++ b/src/types/governance.ts @@ -52,6 +52,32 @@ export type ProposalWithdrawal = { amount: string; }; +/** + * One on-chain vote cast by a DRep, joined with the proposal it voted on. + * Served by /api/governance/drepVotes (sourced from Koios, which — unlike + * Blockfrost — exposes the vote's rationale anchor and proposal link). + */ +export type DrepVoteHistoryItem = { + proposalId: string; + proposalTxHash: string; + proposalIndex: number; + voteTxHash: string; + /** Unix seconds of the block containing the vote. */ + blockTime: number; + vote: "Yes" | "No" | "Abstain"; + /** CIP-100/CIP-136 rationale anchor, when the DRep attached one. */ + metaUrl: string | null; + metaHash: string | null; + /** Snake_case governance action type (matches GovernanceTypeChip keys). */ + proposalType: string | null; + proposalTitle: string | null; +}; + +export type DrepVoteHistoryResponse = { + drepId: string; + votes: DrepVoteHistoryItem[]; +}; + export type BlockfrostDrepInfo = { drep_id: string; hex: string; From 10cfc26583ab6666de11ae1fd34aef950c8b67cf Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Mon, 20 Jul 2026 13:34:52 +0200 Subject: [PATCH 33/93] fix(governance): public DRep explorer no longer requires a connected wallet The DRep list and detail pages parked network on a "not yet known" sentinel (3) that only a connected wallet's getNetworkId() could clear, so anonymous visitors to the public /governance/drep pages hit the fetch effect's early-return and stared at an endless spinner. Extract the duplicated detection into a usePublicNetwork hook that defaults to mainnet and switches to the wallet's network once one connects, and drop the sentinel guards so data loads immediately. Co-Authored-By: Claude Fable 5 --- .../homepage/governance/drep/id/index.tsx | 27 +++----------- .../pages/homepage/governance/drep/index.tsx | 30 +++------------ src/hooks/usePublicNetwork.ts | 37 +++++++++++++++++++ 3 files changed, 47 insertions(+), 47 deletions(-) create mode 100644 src/hooks/usePublicNetwork.ts diff --git a/src/components/pages/homepage/governance/drep/id/index.tsx b/src/components/pages/homepage/governance/drep/id/index.tsx index 00e3d264..fc0e7218 100644 --- a/src/components/pages/homepage/governance/drep/id/index.tsx +++ b/src/components/pages/homepage/governance/drep/id/index.tsx @@ -8,7 +8,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { Loader } from "lucide-react"; import ActiveIndicator from "../activeIndicator"; import ScriptIndicator from "../scriptIndicator"; -import useMeshWallet from "@/hooks/useMeshWallet"; +import usePublicNetwork from "@/hooks/usePublicNetwork"; import RowLabelInfo from "@/components/common/row-label-info"; import { extractJsonLdValue } from "@/utils/jsonLdParser"; import { Button } from "@/components/ui/button"; @@ -17,33 +17,16 @@ import DelegateButton from "./delegateButton"; export default function DrepDetailPage() { const router = useRouter(); const { id } = router.query; - const { wallet, connected } = useMeshWallet(); const [drepInfo, setDrepInfo] = useState(null); const [drepMetadata, setDrepMetadata] = useState(null); const [loading, setLoading] = useState(true); - const [network, setNetwork] = useState(3); // Default to mainnet - - useEffect(() => { - async function fetchNetwork() { - if (connected && wallet) { - try { - const net = await wallet.getNetworkId(); - setNetwork(net); - } catch (error) { - setNetwork(1); - console.error("Error fetching network ID:", error); - } - } - } - - fetchNetwork(); - }, [connected, wallet]); - + // Mainnet for anonymous visitors, the wallet's network once connected. + const network = usePublicNetwork(); + useEffect(() => { - if (network === 3) return; // Prevent fetching if network is not set if (id) fetchDrepData(id as string); - }, [id, wallet, network]); + }, [id, network]); async function fetchDrepData(drepId: string) { setLoading(true); diff --git a/src/components/pages/homepage/governance/drep/index.tsx b/src/components/pages/homepage/governance/drep/index.tsx index d57d5ef6..52d7c1ab 100644 --- a/src/components/pages/homepage/governance/drep/index.tsx +++ b/src/components/pages/homepage/governance/drep/index.tsx @@ -4,7 +4,7 @@ import Pagination from "@/components/common/overall-layout/pagination"; import { getProvider } from "@/utils/get-provider"; import { BlockfrostDrepInfo, BlockfrostDrepMetadata } from "@/types/governance"; import Link from "next/link"; -import useMeshWallet from "@/hooks/useMeshWallet"; +import usePublicNetwork from "@/hooks/usePublicNetwork"; import DelegateButton from "./id/delegateButton"; import RowLabelInfo from "@/components/common/row-label-info"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -16,33 +16,15 @@ export default function DrepOverviewPage() { Array<{ details: BlockfrostDrepInfo; metadata: BlockfrostDrepMetadata | null }> >([]); const [loading, setLoading] = useState(true); - const { wallet, connected } = useMeshWallet(); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(25); const [order, setOrder] = useState<"asc" | "desc">("asc"); const [isLastPage, setIsLastPage] = useState(false); - const [network, setNetwork] = useState(3); // Default to mainnet + // Mainnet for anonymous visitors, the wallet's network once connected. + const network = usePublicNetwork(); - useEffect(() => { - async function fetchNetwork() { - if (connected && wallet) { - try { - const net = await wallet.getNetworkId(); - setNetwork(net); - } catch (error) { - setNetwork(1); - console.error("Error fetching network ID:", error); - } - } - } - - fetchNetwork(); - }, [connected, wallet]); - useEffect(() => { async function loadDrepList() { - if (network === 3) return; // Prevent fetching if network is not set - setLoading(true); const blockchainProvider = getProvider(network); @@ -73,10 +55,8 @@ export default function DrepOverviewPage() { } } - if (network !== null) { - loadDrepList(); - } - }, [currentPage, pageSize, order, network]); // Dependency now waits for network + loadDrepList(); + }, [currentPage, pageSize, order, network]); // Fetch DRep details const fetchDrepDetails = async (drepId: string) => { diff --git a/src/hooks/usePublicNetwork.ts b/src/hooks/usePublicNetwork.ts new file mode 100644 index 00000000..8dc9c5a1 --- /dev/null +++ b/src/hooks/usePublicNetwork.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; +import useMeshWallet from "@/hooks/useMeshWallet"; + +/** + * Network id (0 = preprod, 1 = mainnet) for public, no-login pages. + * + * Defaults to mainnet so anonymous visitors get data immediately; when a + * wallet connects, switches to that wallet's network. Public explorers used + * to park on a "not yet known" sentinel until a wallet reported its network, + * which left visitors without a wallet on an endless spinner. + */ +export default function usePublicNetwork(): number { + const { wallet, connected } = useMeshWallet(); + const [network, setNetwork] = useState(1); + + useEffect(() => { + let cancelled = false; + if (connected && wallet) { + wallet + .getNetworkId() + .then((net) => { + if (!cancelled) setNetwork(net); + }) + .catch((error) => { + console.error("Error fetching network ID:", error); + if (!cancelled) setNetwork(1); + }); + } else { + setNetwork(1); + } + return () => { + cancelled = true; + }; + }, [connected, wallet]); + + return network; +} From 970d0810d5bc2eef49f9aadbfaa53c5e459673e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:25:38 +0200 Subject: [PATCH 34/93] feat: add discover tab for CIP-0146 wallet import flow - Implemented DiscoverTab component for on-chain discovery of multisig wallets registered with the connected wallet's key hash. - Integrated API calls to fetch registration data and resolve registration scripts. - Added functionality to invite co-signers and handle pending imports. - Created ClaimSlotCard component for claiming signer slots in fixed-script drafts. - Developed useWalletRegistration hook to check wallet registration status. - Added API endpoint to resolve registration scripts from transaction hashes. - Introduced utility functions for CIP-0146 discovery and registration metadata handling. - Implemented invite URL generation for new wallet drafts. --- src/__tests__/cip146Discovery.test.ts | 343 +++++++++++++++ src/__tests__/cip146Registration.test.ts | 180 ++++++++ src/__tests__/lookupMultisigWallet.test.ts | 68 +++ .../resolveRegistrationScript.test.ts | 166 ++++++++ .../trpc/claimNewWalletSignerSlot.test.ts | 248 +++++++++++ .../source/assign-signers-panel.tsx | 209 +++++++++ .../source/discover-tab.tsx | 403 ++++++++++++++++++ .../import-wallet-flow/source/index.tsx | 27 +- .../homepage/wallets/invite/ClaimSlotCard.tsx | 59 +++ .../pages/homepage/wallets/invite/index.tsx | 140 +++++- .../create/ReviewSignersCard.tsx | 45 +- .../wallets/new-wallet-flow/create/index.tsx | 89 ++-- .../shared/useWalletFlowState.tsx | 83 +++- .../pages/wallet/info/card-info.tsx | 11 - src/components/pages/wallet/info/index.tsx | 4 + .../pages/wallet/info/register-wallet.tsx | 199 ++++++++- src/hooks/useWalletRegistration.ts | 66 +++ src/pages/api/v1/lookupMultisigWallet.ts | 21 +- src/pages/api/v1/resolveRegistrationScript.ts | 146 +++++++ src/server/api/routers/users.ts | 8 +- src/server/api/routers/wallets.ts | 178 +++++++- src/types/wallet.ts | 7 + src/utils/cip146Discovery.ts | 392 +++++++++++++++++ src/utils/cip146Registration.ts | 194 +++++++++ src/utils/inviteUrl.ts | 8 + 25 files changed, 3222 insertions(+), 72 deletions(-) create mode 100644 src/__tests__/cip146Discovery.test.ts create mode 100644 src/__tests__/cip146Registration.test.ts create mode 100644 src/__tests__/resolveRegistrationScript.test.ts create mode 100644 src/__tests__/trpc/claimNewWalletSignerSlot.test.ts create mode 100644 src/components/pages/homepage/wallets/import-wallet-flow/source/assign-signers-panel.tsx create mode 100644 src/components/pages/homepage/wallets/import-wallet-flow/source/discover-tab.tsx create mode 100644 src/components/pages/homepage/wallets/invite/ClaimSlotCard.tsx create mode 100644 src/hooks/useWalletRegistration.ts create mode 100644 src/pages/api/v1/resolveRegistrationScript.ts create mode 100644 src/utils/cip146Discovery.ts create mode 100644 src/utils/cip146Registration.ts create mode 100644 src/utils/inviteUrl.ts diff --git a/src/__tests__/cip146Discovery.test.ts b/src/__tests__/cip146Discovery.test.ts new file mode 100644 index 00000000..3e448ed1 --- /dev/null +++ b/src/__tests__/cip146Discovery.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from "@jest/globals"; +import { + resolvePaymentKeyHash, + serializeNativeScript, + type NativeScript, +} from "@meshsdk/core"; + +import { + buildImportFromRegistration, + buildSlotAddresses, + collectNativeScriptSigHashes, + keyHashToEnterpriseAddress, + matchAddressesToSigSlots, + providerScriptJsonToNativeScript, + 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("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. + expect(input.signersAddresses).toContain(userAddress); + for (const addr of input.signersAddresses) { + expect([hashA, hashB]).toContain(resolvePaymentKeyHash(addr)); + } + expect(input.signersDescriptions.sort()).toEqual(["Alice", "Bob"]); + }); + + 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("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); + }); +}); 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__/lookupMultisigWallet.test.ts b/src/__tests__/lookupMultisigWallet.test.ts index 51e59b0e..680c8872 100644 --- a/src/__tests__/lookupMultisigWallet.test.ts +++ b/src/__tests__/lookupMultisigWallet.test.ts @@ -62,4 +62,72 @@ describe("lookupMultisigWallet API", () => { 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__/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__/trpc/claimNewWalletSignerSlot.test.ts b/src/__tests__/trpc/claimNewWalletSignerSlot.test.ts new file mode 100644 index 00000000..d3cc2784 --- /dev/null +++ b/src/__tests__/trpc/claimNewWalletSignerSlot.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it, jest, beforeAll, beforeEach } from "@jest/globals"; + +import { makeAnonymousCtx, makeWalletCtx } from "./helpers"; +import { keyHashToEnterpriseAddress } from "@/utils/cip146Discovery"; +import { paymentKeyHash } from "@/utils/multisigSDK"; +import { mockKeyHashes } from "../testUtils"; + +jest.mock("@/env", () => ({ + __esModule: true, + env: { + DATABASE_URL: process.env.DATABASE_URL, + DIRECT_URL: process.env.DIRECT_URL, + NODE_ENV: "test", + }, +}), { virtual: true }); + +jest.mock("superjson", () => ({ + __esModule: true, + default: { + serialize: (value: unknown) => value, + deserialize: (value: unknown) => value, + }, +})); + +jest.mock("@/server/auth", () => ({ + __esModule: true, + getServerAuthSession: jest.fn(), +})); + +let createCaller: typeof import("@/server/api/root").createCaller; + +const claimHash = mockKeyHashes.payment1; +const otherHash = mockKeyHashes.payment2; +// A real bech32 address whose payment key hash equals claimHash — this is +// what the claiming user's session address looks like. +const claimerAddress = keyHashToEnterpriseAddress(claimHash, 0); + +const makeMockDb = () => ({ + newWallet: { + findUnique: jest.fn(), + updateMany: jest.fn(), + }, + user: { findUnique: jest.fn() }, + auditLog: { create: jest.fn() }, +}); + +const draft = (overrides: Record = {}) => ({ + id: "draft-1", + name: "Discovered wallet", + ownerAddress: "addr_test1importer", + signersAddresses: ["addr_test1importer", claimHash, otherHash], + signersStakeKeys: ["", "", ""], + signersDRepKeys: ["", "", ""], + signersDescriptions: ["Importer", "Alice", "Bob"], + stakeCredentialHash: mockKeyHashes.stake1, + rawImportBodies: { lockedSigners: true }, + ...overrides, +}); + +describe("claimNewWalletSignerSlot", () => { + beforeAll(async () => { + ({ createCaller } = await import("@/server/api/root")); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("throws UNAUTHORIZED without a session", async () => { + const mockDb = makeMockDb(); + const caller = createCaller(makeAnonymousCtx(mockDb) as any); + + await expect( + caller.wallet.claimNewWalletSignerSlot({ walletId: "draft-1" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mockDb.newWallet.findUnique).not.toHaveBeenCalled(); + }); + + it("throws NOT_FOUND when the draft does not exist", async () => { + const mockDb = makeMockDb(); + mockDb.newWallet.findUnique.mockResolvedValueOnce(null as never); + const caller = createCaller(makeWalletCtx(claimerAddress, mockDb) as any); + + await expect( + caller.wallet.claimNewWalletSignerSlot({ walletId: "missing" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("throws FORBIDDEN when the draft is not a locked-signers draft", async () => { + const mockDb = makeMockDb(); + mockDb.newWallet.findUnique.mockResolvedValueOnce( + draft({ rawImportBodies: null }) as never, + ); + const caller = createCaller(makeWalletCtx(claimerAddress, mockDb) as any); + + await expect( + caller.wallet.claimNewWalletSignerSlot({ walletId: "draft-1" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mockDb.newWallet.updateMany).not.toHaveBeenCalled(); + }); + + it("throws FORBIDDEN when the caller's key hash matches no placeholder", async () => { + const mockDb = makeMockDb(); + mockDb.newWallet.findUnique.mockResolvedValueOnce( + draft({ signersAddresses: ["addr_test1importer", otherHash] }) as never, + ); + const caller = createCaller(makeWalletCtx(claimerAddress, mockDb) as any); + + await expect( + caller.wallet.claimNewWalletSignerSlot({ walletId: "draft-1" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mockDb.newWallet.updateMany).not.toHaveBeenCalled(); + }); + + it("is a no-op when the caller has already claimed", async () => { + const mockDb = makeMockDb(); + const existing = draft({ + signersAddresses: ["addr_test1importer", claimerAddress, otherHash], + }); + mockDb.newWallet.findUnique.mockResolvedValueOnce(existing as never); + const caller = createCaller(makeWalletCtx(claimerAddress, mockDb) as any); + + const result = await caller.wallet.claimNewWalletSignerSlot({ + walletId: "draft-1", + }); + expect(result).toEqual(existing); + expect(mockDb.newWallet.updateMany).not.toHaveBeenCalled(); + }); + + it("claims exactly the matching slot and keeps arrays aligned", async () => { + const mockDb = makeMockDb(); + const original = draft(); + mockDb.newWallet.findUnique + .mockResolvedValueOnce(original as never) + .mockResolvedValueOnce("updated-draft" as never); + mockDb.user.findUnique.mockResolvedValueOnce({ + address: claimerAddress, + stakeAddress: "stake_test1claimer", + drepKeyHash: mockKeyHashes.drep1, + } as never); + mockDb.newWallet.updateMany.mockResolvedValueOnce({ count: 1 } as never); + + const caller = createCaller(makeWalletCtx(claimerAddress, mockDb) as any); + const result = await caller.wallet.claimNewWalletSignerSlot({ + walletId: "draft-1", + description: "Alice B", + }); + + expect(paymentKeyHash(claimerAddress)).toBe(claimHash); + expect(mockDb.newWallet.updateMany).toHaveBeenCalledWith({ + where: { + id: "draft-1", + signersAddresses: { equals: original.signersAddresses }, + }, + data: { + signersAddresses: ["addr_test1importer", claimerAddress, otherHash], + // stake key stays empty: the fixed on-chain script + external + // stake credential are authoritative + signersStakeKeys: ["", "", ""], + signersDRepKeys: ["", mockKeyHashes.drep1, ""], + signersDescriptions: ["Importer", "Alice B", "Bob"], + }, + }); + expect(result).toBe("updated-draft"); + }); + + it("claims the connected wallet's slot even when the session primary already occupies another slot", async () => { + // Multi-wallet session: the primary (most recently authorized) address + // is the draft owner, but the connected wallet's address is also in + // sessionWallets. The mutation must claim the placeholder instead of + // idempotently returning "owner is already a signer". + const mockDb = makeMockDb(); + const original = draft(); + mockDb.newWallet.findUnique + .mockResolvedValueOnce(original as never) + .mockResolvedValueOnce("updated-draft" as never); + mockDb.user.findUnique.mockResolvedValueOnce(null as never); + mockDb.newWallet.updateMany.mockResolvedValueOnce({ count: 1 } as never); + + const stalePrimaryCtx = { + db: mockDb, + session: null, + sessionAddress: "addr_test1importer", + sessionWallets: ["addr_test1importer", claimerAddress], + primaryWallet: "addr_test1importer", + ip: "198.51.100.200", + }; + const caller = createCaller(stalePrimaryCtx as any); + const result = await caller.wallet.claimNewWalletSignerSlot({ + walletId: "draft-1", + }); + + expect(mockDb.newWallet.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + signersAddresses: ["addr_test1importer", claimerAddress, otherHash], + }), + }), + ); + expect(result).toBe("updated-draft"); + }); + + it("rejects an explicit claiming address that is not in the session", async () => { + const mockDb = makeMockDb(); + const caller = createCaller(makeWalletCtx(claimerAddress, mockDb) as any); + + await expect( + caller.wallet.claimNewWalletSignerSlot({ + walletId: "draft-1", + address: "addr_test1somebodyelse", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(mockDb.newWallet.findUnique).not.toHaveBeenCalled(); + }); + + it("binds the claim to an explicit address from the session", async () => { + const mockDb = makeMockDb(); + const original = draft(); + mockDb.newWallet.findUnique + .mockResolvedValueOnce(original as never) + .mockResolvedValueOnce("updated-draft" as never); + mockDb.user.findUnique.mockResolvedValueOnce(null as never); + mockDb.newWallet.updateMany.mockResolvedValueOnce({ count: 1 } as never); + + const caller = createCaller(makeWalletCtx(claimerAddress, mockDb) as any); + const result = await caller.wallet.claimNewWalletSignerSlot({ + walletId: "draft-1", + address: claimerAddress, + }); + + expect(mockDb.user.findUnique).toHaveBeenCalledWith({ + where: { address: claimerAddress }, + }); + expect(result).toBe("updated-draft"); + }); + + it("throws CONFLICT when the signer list changed concurrently", async () => { + const mockDb = makeMockDb(); + mockDb.newWallet.findUnique.mockResolvedValueOnce(draft() as never); + mockDb.user.findUnique.mockResolvedValueOnce(null as never); + mockDb.newWallet.updateMany.mockResolvedValueOnce({ count: 0 } as never); + + const caller = createCaller(makeWalletCtx(claimerAddress, mockDb) as any); + await expect( + caller.wallet.claimNewWalletSignerSlot({ walletId: "draft-1" }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); +}); diff --git a/src/components/pages/homepage/wallets/import-wallet-flow/source/assign-signers-panel.tsx b/src/components/pages/homepage/wallets/import-wallet-flow/source/assign-signers-panel.tsx new file mode 100644 index 00000000..6fafd0be --- /dev/null +++ b/src/components/pages/homepage/wallets/import-wallet-flow/source/assign-signers-panel.tsx @@ -0,0 +1,209 @@ +import { useMemo, useState } from "react"; +import { ArrowLeft, CheckCircle2, UserCircle2 } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + buildSlotAddresses, + matchAddressesToSigSlots, + type DiscoveredImportInput, + type SlotAssignmentError, +} from "@/utils/cip146Discovery"; +import { getFirstAndLast } from "@/utils/strings"; + +export type PendingDiscoveryImport = { + groupKey: string; + input: DiscoveredImportInput; + /** script-order payment key hashes — one slot per signer */ + sigHashes: string[]; + userSlotIndex: number; + registrationTxHash: string; + expectedAddress: string; +}; + +const ERROR_COPY: Record = { + invalid: "is not a valid Cardano address.", + "stake-address": + "is a stake address — paste the signer's payment (base) address instead.", + "not-a-signer": + "does not belong to one of this wallet's registered signers.", + "duplicate-slot": + "resolves to the same signer slot as another pasted address.", + "wrong-network": "belongs to a different network.", +}; + +interface Props { + pending: PendingDiscoveryImport; + network: number; + userAddress: string; + busy?: boolean; + onContinue: (finalInput: DiscoveredImportInput) => void; + onBack: () => void; + /** When provided, renders the "Invite co-signers instead" action. */ + onInvite?: (assignments: Record) => void; +} + +/** + * Lets the importer assign co-signers' real wallet addresses to the + * discovered wallet's signer slots before the record is created. Wallet + * visibility is an exact address match, so slots left unknown (filled + * with derived placeholder addresses) won't surface the wallet for those + * co-signers — hence the paste (or invite) step. + */ +export default function AssignSignersPanel({ + pending, + network, + userAddress, + busy, + onContinue, + onBack, + onInvite, +}: Props) { + const [pasted, setPasted] = useState(""); + + const lockedSlots = useMemo( + () => ({ [pending.userSlotIndex]: userAddress }), + [pending.userSlotIndex, userAddress], + ); + + const pastedLines = useMemo(() => pasted.split(/\r?\n/), [pasted]); + + const { assignments, errors } = useMemo( + () => + matchAddressesToSigSlots({ + sigHashes: pending.sigHashes, + lockedSlots, + pastedLines, + networkId: network, + }), + [pending.sigHashes, lockedSlots, pastedLines, network], + ); + + const unknownCount = pending.sigHashes.filter( + (_, i) => lockedSlots[i] === undefined && assignments[i] === undefined, + ).length; + + function handleContinue() { + const signersAddresses = buildSlotAddresses({ + sigHashes: pending.sigHashes, + assignments, + lockedSlots, + networkId: network, + fallback: "enterprise", + }); + // Per-signer stake keys stay empty on purpose: the on-chain script is + // authoritative, and backfilling stake keys could flip the wallet's + // type classification and change the derived address. + onContinue({ ...pending.input, signersAddresses }); + } + + return ( +
    +
    +

    Assign signer addresses

    +

    + The chain only records signer key hashes. Paste your co-signers' + wallet addresses so the wallet shows up in their account too — + slots left unknown will use a derived placeholder address and + won't be visible to that signer. +

    +
    + +
    + {pending.sigHashes.map((hash, index) => { + const isUser = lockedSlots[index] !== undefined; + const assigned = assignments[index]; + const name = pending.input.signersDescriptions[index]; + return ( +
    + + {getFirstAndLast(hash, 10, 8)} + + {name && {name}} + + {isUser ? ( + <> + + + you + + + {getFirstAndLast(userAddress, 12, 8)} + + + ) : assigned ? ( + <> + + + {getFirstAndLast(assigned, 12, 8)} + + + ) : ( + + Unknown — will use a placeholder address + + )} + +
    + ); + })} +
    + +
    + +