From d746230df4a919305592fb3714c27a3dd955994a Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Fri, 19 Jun 2026 19:37:18 -0400 Subject: [PATCH 01/11] feat: support contract ops task layout Co-authored-by: Codex --- README.md | 122 +++++++------- __tests__/genTaskOriginSig.test.ts | 4 + scripts/genTaskOriginSig.ts | 3 + src/app/api/install-deps/route.ts | 31 ++-- .../upgrade-config/__tests__/route.test.ts | 63 ++------ src/app/api/upgrade-config/route.ts | 17 +- src/app/api/upgrades/__tests__/route.test.ts | 2 - src/app/api/upgrades/route.ts | 3 +- src/app/api/validate/__tests__/route.test.ts | 3 - src/app/api/validate/route.ts | 8 +- src/app/page.tsx | 17 +- src/components/SigningConfirmation.tsx | 1 - src/components/UpgradeSelection.tsx | 10 +- src/components/UserSelection.tsx | 12 +- src/components/ValidationResults.tsx | 6 - src/hooks/useValidationRunner.ts | 11 +- src/lib/__tests__/deployments.test.ts | 80 ++++++++- src/lib/deployments.ts | 153 +++++++++++------- src/lib/types/shared.ts | 1 - src/lib/validation-service.ts | 63 +++++--- 20 files changed, 333 insertions(+), 277 deletions(-) diff --git a/README.md b/README.md index 535694c..8353c18 100644 --- a/README.md +++ b/README.md @@ -32,60 +32,58 @@ git clone https://github.com/base/task-signing-tool.git ### Expected Directory Layout -Place this repository at the root of your task repository. Network folders (e.g., `mainnet`, `sepolia`, `zeronet`) must live alongside it, and each task must be a date-prefixed folder inside a network folder. - -```12:40:root-of-your-task-repo -contract-deployments/ # your task repo root (example) -├─ task-signing-tool/ # this repo cloned here -│ ├─ src/ -│ └─ ... -├─ mainnet/ # network directory -│ ├─ 2025-06-04-upgrade-foo/ # task directory (YYYY-MM-DD-task-name) -│ │ ├─ README.md # optional, used for status parsing -│ │ ├─ validations/ -│ │ │ ├─ base-sc.json # config for "Base SC" user type -│ │ │ ├─ coinbase.json # config for "Coinbase" user type -│ │ │ └─ op.json # config for "OP" user type -│ │ └─ foundry-project/ # directory where you run Foundry scripts -│ │ └─ ... -│ ├─ 2025-07-12-upgrade-bar/ -│ │ └─ ... -│ └─ signatures/ # signatures directory (separate from tasks) -│ ├─ 2025-06-04-upgrade-foo/ # matches task directory name -│ │ ├─ creator-signature.json -│ │ ├─ base-facilitator-signature.json -│ │ └─ base-sc-facilitator-signature.json -│ └─ 2025-07-12-upgrade-bar/ -│ └─ ... -├─ sepolia/ -│ ├─ 2025-05-10-upgrade-baz/ -│ │ └─ ... -│ └─ signatures/ -│ └─ 2025-05-10-upgrade-baz/ -│ └─ ... -└─ zeronet/ - ├─ 2025-08-01-upgrade-qux/ - │ └─ ... - └─ signatures/ - └─ 2025-08-01-upgrade-qux/ - └─ ... +Place this repository at the root of your task repository. Tasks are discovered from +`active/evm/config//validations/` and appear in the normal signer UI flow. + +```text +contract-deployments/ +├─ task-signing-tool/ +├─ active/ +│ └─ evm/ +│ ├─ config/ +│ │ ├─ mainnet/ +│ │ │ ├─ validations/ +│ │ │ └─ .env +│ │ ├─ sepolia/ +│ │ │ ├─ validations/ +│ │ │ └─ .env +│ │ ├─ zeronet/ +│ │ │ ├─ validations/ +│ │ │ └─ .env +│ │ └─ signatures/ +│ │ └─ mainnet/ +│ ├─ script/ +│ │ └─ common/ +│ ├─ FACILITATOR.md +│ ├─ Makefile +│ ├─ README.md +│ └─ foundry.toml +└─ archive/ ``` -Key requirements and notes: +Notes: +- **Signer experience**: The UI remains unchanged. The current task is listed as a normal task for each network with validation configs. - **Networks**: Supported networks are listed in `src/lib/constants.ts` and currently include `mainnet`, `sepolia`, `sepolia-alpha`, and `zeronet`. -- **Task folder naming**: Task directories must begin with a date prefix, `YYYY-MM-DD-`, for example `2025-06-04-upgrade-foo`. The UI lists only folders matching that pattern. -- **Validation configs**: For each task, place config files under `validations/` named by user type in kebab-case plus `.json`: +- **Validation configs**: Place config files under `active/evm/config//validations/` named by user type in kebab-case plus `.json`: - "Base SC" → `base-sc.json` - "Coinbase" → `coinbase.json` - "OP" → `op.json` -- **Task origin signatures**: By default, tasks require three signature files stored in `/signatures//`: `creator-signature.json`, `base-facilitator-signature.json`, and `base-sc-facilitator-signature.json`. Signatures are stored separately from task directories to avoid changing the tarball content. Use the `genTaskOriginSig.ts` script to generate these. Set `skipTaskOriginValidation: true` in the validation config to opt out. -- **Script execution**: The tool executes Foundry from the task directory root (`//`). Ensure your Foundry project or script context is available under that path; the tool will run the `cmd` specified in your validation config. Temporary outputs like `temp-script-output.txt` will be written there. -- **Optional README parsing**: If `//README.md` exists, the tool may parse it to display status and execution links. +- **Script execution**: Validation commands run from `active/evm/`, so validation `cmd` values should reference scripts relative to that directory. +- **Task origin verification**: Task-origin signatures are verified over `active/evm/config/mainnet/` only. +- **Signature storage**: Store signatures outside the signed directory, for example `active/evm/config/signatures/mainnet/`, so generating signatures does not change the tarball content. + +Generate task-origin signatures with: + +```bash +tsx task-signing-tool/scripts/genTaskOriginSig.ts sign \ + --task-folder active/evm/config/mainnet \ + --signature-path active/evm/config/signatures/mainnet +``` ### Task README structure -When present, each task's `README.md` is parsed to populate the UI. Place it at `//README.md` and follow these rules: +When present, `active/evm/README.md` or `active/evm/config//README.md` is parsed to populate the UI. Follow these rules: - **Status line (required for status display)**: Include a line containing `Status:` within the first 20 lines. Recognized values are `PENDING`, `READY TO SIGN`, and `EXECUTED` (case-insensitive). If `EXECUTED` is not present and `READY TO SIGN` is not present, the status is treated as `PENDING`. Supported formats: - **Single-line with link (any one of these)**: @@ -100,7 +98,7 @@ When present, each task's `README.md` is parsed to populate the UI. Place it at - **Title (optional)**: You may start with a `#` title. It is ignored for parsing the description fallback, but is fine for readability. -- **Task name display**: The UI derives the display name from the folder slug after the date (e.g., `2025-06-04-upgrade-system-config` → `Upgrade System Config`). Choose meaningful slugs. +- **Task name display**: The UI uses the first `#` heading when present, otherwise it falls back to ` Task`. Examples @@ -156,7 +154,7 @@ Executed upgrade; links include both onchain transaction and governance proposal ### Validation file structure -Validation configs live under each task directory at `//validations/` and are selected by user type: +Validation configs live under `active/evm/config//validations/` and are selected by user type: - `base-sc.json` for **Base SC** - `coinbase.json` for **Coinbase** @@ -196,7 +194,7 @@ Notes: - Sorting is not required; the tool sorts by address and storage slot for comparison. - The tool reads `rpcUrl` and `ledgerId` directly from this file. -- When task origin validation is enabled, three signature files must exist in `/signatures//` (see **Task Origin Signing** below). +- When task origin validation is enabled, three signature files must exist in `active/evm/config/signatures/mainnet/` (see **Task Origin Signing** below). Minimal example (`validations/base-sc.json`): @@ -274,9 +272,9 @@ General usage (tsx): npm ci npx tsx scripts/genValidationFile.ts \ --rpc-url https://mainnet.example \ - --workdir / \ + --workdir active/evm \ --forge-cmd "forge script : --sig 'run()' --sender 0xabc..." \ - --out //validations/.json + --out active/evm/config//validations/.json ``` Alternative (bun): @@ -286,9 +284,9 @@ Alternative (bun): npm ci bun run scripts/genValidationFile.ts \ --rpc-url https://mainnet.example \ - --workdir / \ + --workdir active/evm \ --forge-cmd "forge script : --sig 'run()' --sender 0xabc..." \ - --out //validations/.json + --out active/evm/config//validations/.json ``` Example from a Makefile (variables expanded in your environment): @@ -297,15 +295,15 @@ Example from a Makefile (variables expanded in your environment): cd "$SIGNER_TOOL_PATH" && \ npm ci && \ bun run scripts/genValidationFile.ts --rpc-url "$L1_RPC_URL" \ - --workdir .. \ + --workdir ../active/evm \ --forge-cmd 'forge script --rpc-url "$L1_RPC_URL" SwapOwner --sig "sign()" --sender "$SENDER"' \ - --out ../validations/test.json + --out ../active/evm/config/mainnet/validations/test.json ``` Notes: - Quote the entire `--forge-cmd` so that inner quotes for `--sig` are preserved by your shell. On macOS/Linux, prefer single quotes around the whole command and double quotes inside for signatures/addresses. -- `--workdir` typically points to the task directory (e.g., `mainnet/2025-06-04-upgrade-foo`). If you keep this repo inside the task repo root, `..` will refer to the task directory when running from `task-signing-tool/`. +- `--workdir` should point to `active/evm` so generated validation commands match the app's simulation cwd. - If `--out` is omitted, the JSON is printed to stdout. ### Task Origin Signing @@ -314,7 +312,7 @@ Use `scripts/genTaskOriginSig.ts` to sign task folders for origin validation. Ta #### Signature files -When task origin validation is enabled, three signature files must exist in `/signatures//`: +When task origin validation is enabled, three signature files must exist in `active/evm/config/signatures/mainnet/`: - **Task Creator**: `creator-signature.json` — common name is the email of the task author (e.g., `alice@example.com`) - **Base Facilitator**: `base-facilitator-signature.json` — common name is `base-facilitators` (group) @@ -322,7 +320,7 @@ When task origin validation is enabled, three signature files must exist in `// \ - --signature-path //signatures/ + --task-folder active/evm/config/mainnet \ + --signature-path active/evm/config/signatures/mainnet ``` **Sign as Base facilitator:** @@ -368,8 +366,8 @@ npx tsx scripts/genTaskOriginSig.ts sign \ ```bash npm ci npx tsx scripts/genTaskOriginSig.ts sign \ - --task-folder // \ - --signature-path //signatures/ \ + --task-folder active/evm/config/mainnet \ + --signature-path active/evm/config/signatures/mainnet \ --facilitator base ``` @@ -378,8 +376,8 @@ npx tsx scripts/genTaskOriginSig.ts sign \ ```bash npm ci npx tsx scripts/genTaskOriginSig.ts verify \ - --task-folder // \ - --signature-path //signatures/ \ + --task-folder active/evm/config/mainnet \ + --signature-path active/evm/config/signatures/mainnet \ --common-name alice@example.com ``` diff --git a/__tests__/genTaskOriginSig.test.ts b/__tests__/genTaskOriginSig.test.ts index a4cb4af..2ebec0c 100644 --- a/__tests__/genTaskOriginSig.test.ts +++ b/__tests__/genTaskOriginSig.test.ts @@ -1,4 +1,5 @@ import path from 'path'; +import fs from 'fs/promises'; import { fileURLToPath } from 'url'; import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; import { @@ -14,6 +15,7 @@ const VALID_TASK_FOLDER = path.join(FIXTURES_DIR, 'valid-task'); const VALID_SIGNATURES_DIR = path.join(FIXTURES_DIR, 'signatures/valid'); const INVALID_SIGNATURES_DIR = path.join(FIXTURES_DIR, 'signatures/invalid'); const MISSING_SIGNATURES_DIR = path.join(FIXTURES_DIR, 'signatures/missing'); +const VALID_TASK_TARBALL = path.resolve(process.cwd(), 'valid-task.tar'); // Task creator email const TASK_CREATOR_EMAIL = 'alexis.williams.1@coinbase.com'; @@ -32,6 +34,7 @@ describe('verifyTaskOrigin', () => { afterEach(() => { process.exitCode = originalExitCode; jest.restoreAllMocks(); + return fs.rm(VALID_TASK_TARBALL, { force: true }); }); describe('valid signatures', () => { @@ -134,6 +137,7 @@ describe('verifyAllSignatures', () => { afterEach(() => { process.exitCode = originalExitCode; jest.restoreAllMocks(); + return fs.rm(VALID_TASK_TARBALL, { force: true }); }); it('validates successfully when all 3 signatures are valid', async () => { diff --git a/scripts/genTaskOriginSig.ts b/scripts/genTaskOriginSig.ts index 7bf7354..4c456a4 100644 --- a/scripts/genTaskOriginSig.ts +++ b/scripts/genTaskOriginSig.ts @@ -44,6 +44,9 @@ function printUsage(): void { --facilitator, -f Facilitator type: "base" or "security-council" (used for 'sign' and 'verify' commands, omit this flag to sign/verify as task creator) --common-name, -c Common name for task creator (required when not using --facilitator in 'verify' and 'verify-all' commands) --help, -h Show this help message + + Example: + tsx scripts/genTaskOriginSig.ts sign --task-folder ../active/evm/config/mainnet --signature-path ../active/evm/config/signatures/mainnet `; console.log(msg); } diff --git a/src/app/api/install-deps/route.ts b/src/app/api/install-deps/route.ts index f15f8b8..9156ff8 100644 --- a/src/app/api/install-deps/route.ts +++ b/src/app/api/install-deps/route.ts @@ -21,11 +21,11 @@ const pathExists = async (targetPath: string) => { export async function POST(req: NextRequest) { try { const json = await req.json(); - const { network, upgradeId, forceInstall } = json; + const { network, forceInstall } = json; - if (!network || !upgradeId) { + if (!network) { return NextResponse.json( - { error: 'Missing required parameters: network and upgradeId are required' }, + { error: 'Missing required parameter: network is required' }, { status: 400 } ); } @@ -33,22 +33,19 @@ export async function POST(req: NextRequest) { const actualNetwork = network.toLowerCase(); const shouldForceInstall = Boolean(forceInstall); - // Validate inputs to prevent path traversal attacks - // Only allow alphanumeric characters, hyphens, and underscores const safePathPattern = /^[a-zA-Z0-9_-]+$/; - if (!safePathPattern.test(actualNetwork) || !safePathPattern.test(upgradeId)) { + if (!safePathPattern.test(actualNetwork)) { return NextResponse.json( { error: - 'Invalid network or upgradeId: only alphanumeric characters, hyphens, and underscores are allowed', + 'Invalid network: only alphanumeric characters, hyphens, and underscores are allowed', }, { status: 400 } ); } - // Construct the path to the upgrade folder and lib subdirectory const contractDeploymentsPath = findContractDeploymentsRoot(); - const upgradePath = path.join(contractDeploymentsPath, actualNetwork, upgradeId); + const upgradePath = path.join(contractDeploymentsPath, 'active', 'evm'); // Verify the resolved path is within the allowed directory let resolvedUpgradePath: string; @@ -60,11 +57,10 @@ export async function POST(req: NextRequest) { const libPath = path.join(resolvedUpgradePath, 'lib'); - // Check if the upgrade folder exists const upgradePathExists = await pathExists(resolvedUpgradePath); if (!upgradePathExists) { return NextResponse.json( - { error: `Upgrade folder not found: ${network}/${upgradeId}` }, + { error: `Task folder not found: ${path.relative(contractDeploymentsPath, upgradePath)}` }, { status: 404 } ); } @@ -72,11 +68,11 @@ export async function POST(req: NextRequest) { const libExistsBeforeInstall = await pathExists(libPath); if (!shouldForceInstall && libExistsBeforeInstall) { - console.log(`Deps already installed for ${actualNetwork}/${upgradeId}; skipping.`); + console.log(`Deps already installed for ${actualNetwork}; skipping.`); return NextResponse.json( { success: true, - message: `Dependencies already installed for ${actualNetwork}/${upgradeId}`, + message: `Dependencies already installed for ${actualNetwork}`, libExists: true, installed: false, depsInstalled: false, @@ -87,18 +83,15 @@ export async function POST(req: NextRequest) { ); } - console.log( - `Installing dependencies for ${actualNetwork}/${upgradeId} (cwd: ${resolvedUpgradePath})` - ); + console.log(`Installing dependencies for ${actualNetwork} (cwd: ${resolvedUpgradePath})`); - // Run make deps in the upgrade folder const { stdout, stderr } = await execAsync('make deps', { cwd: resolvedUpgradePath, timeout: INSTALL_DEPS_TIMEOUT_MS, env: process.env, }); - console.log(`Dependencies installed for ${actualNetwork}/${upgradeId}`); + console.log(`Dependencies installed for ${actualNetwork}`); // Verify that lib folder was created const libExistsAfterInstall = await pathExists(libPath); @@ -106,7 +99,7 @@ export async function POST(req: NextRequest) { return NextResponse.json( { success: true, - message: `Dependencies installed successfully for ${actualNetwork}/${upgradeId}`, + message: `Dependencies installed successfully for ${actualNetwork}`, libExists: libExistsAfterInstall, installed: true, depsInstalled: true, diff --git a/src/app/api/upgrade-config/__tests__/route.test.ts b/src/app/api/upgrade-config/__tests__/route.test.ts index 385f8d5..8bb5273 100644 --- a/src/app/api/upgrade-config/__tests__/route.test.ts +++ b/src/app/api/upgrade-config/__tests__/route.test.ts @@ -54,86 +54,53 @@ describe('GET /api/upgrade-config', () => { describe('missing parameters', () => { it('returns 400 when network is missing', async () => { - const res = await GET(createRequest({ upgradeId: 'test' })); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toMatch(/missing required parameters/i); - }); - - it('returns 400 when upgradeId is missing', async () => { - const res = await GET(createRequest({ network: 'mainnet' })); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toMatch(/missing required parameters/i); - }); - - it('returns 400 when both are missing', async () => { const res = await GET(createRequest({})); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toMatch(/missing required parameters/i); + expect(body.error).toMatch(/missing required parameter/i); }); }); describe('path traversal prevention', () => { it('returns 400 when network contains ".."', async () => { - const res = await GET(createRequest({ network: '..', upgradeId: 'test' })); + const res = await GET(createRequest({ network: '..' })); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toMatch(/invalid network or upgradeId/i); + expect(body.error).toMatch(/invalid network/i); }); it('returns 400 when network contains "/"', async () => { - const res = await GET(createRequest({ network: 'foo/bar', upgradeId: 'test' })); + const res = await GET(createRequest({ network: 'foo/bar' })); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toMatch(/invalid network or upgradeId/i); - }); - - it('returns 400 when upgradeId contains ".."', async () => { - const res = await GET(createRequest({ network: 'mainnet', upgradeId: '..' })); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toMatch(/invalid network or upgradeId/i); + expect(body.error).toMatch(/invalid network/i); }); it('returns 400 when network contains spaces', async () => { - const res = await GET(createRequest({ network: 'main net', upgradeId: 'test' })); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.error).toMatch(/invalid network or upgradeId/i); - }); - - it('returns 400 when upgradeId contains "@"', async () => { - const res = await GET(createRequest({ network: 'mainnet', upgradeId: 'test@1' })); + const res = await GET(createRequest({ network: 'main net' })); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toMatch(/invalid network or upgradeId/i); + expect(body.error).toMatch(/invalid network/i); }); it('returns 400 when network contains "." (single dot)', async () => { - const res = await GET(createRequest({ network: '.', upgradeId: 'test' })); + const res = await GET(createRequest({ network: '.' })); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toMatch(/invalid network or upgradeId/i); + expect(body.error).toMatch(/invalid network/i); }); }); describe('valid safe characters', () => { - it('accepts hyphens (e.g., "op-mainnet")', async () => { - const res = await GET(createRequest({ network: 'op-mainnet', upgradeId: 'test' })); - expect(res.status).toBe(200); - }); - - it('accepts underscores (e.g., "upgrade_1")', async () => { - const res = await GET(createRequest({ network: 'mainnet', upgradeId: 'upgrade_1' })); + it('accepts hyphens in network names', async () => { + const res = await GET(createRequest({ network: 'op-mainnet' })); expect(res.status).toBe(200); }); }); describe('happy path', () => { it('returns 200 with config options', async () => { - const res = await GET(createRequest({ network: 'mainnet', upgradeId: 'test' })); + const res = await GET(createRequest({ network: 'mainnet' })); expect(res.status).toBe(200); const body = await res.json(); expect(body.configOptions).toHaveLength(1); @@ -148,7 +115,7 @@ describe('GET /api/upgrade-config', () => { it('returns 200 with empty array on ENOENT', async () => { const enoentError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); mockReaddir.mockRejectedValue(enoentError); - const res = await GET(createRequest({ network: 'mainnet', upgradeId: 'test' })); + const res = await GET(createRequest({ network: 'mainnet' })); expect(res.status).toBe(200); const body = await res.json(); expect(body.configOptions).toEqual([]); @@ -158,7 +125,7 @@ describe('GET /api/upgrade-config', () => { describe('post-validation', () => { it('returns 500 on unexpected fs errors', async () => { mockReaddir.mockRejectedValue(new Error('Unexpected disk error')); - const res = await GET(createRequest({ network: 'mainnet', upgradeId: 'test' })); + const res = await GET(createRequest({ network: 'mainnet' })); expect(res.status).toBe(500); const body = await res.json(); expect(body.error).toMatch(/failed to fetch upgrade configuration/i); @@ -167,7 +134,7 @@ describe('GET /api/upgrade-config', () => { describe('does not call fs', () => { it('readdir not called when validation rejects', async () => { - await GET(createRequest({ network: '..', upgradeId: 'test' })); + await GET(createRequest({ network: '..' })); expect(mockReaddir).not.toHaveBeenCalled(); expect(mockFindContractDeploymentsRoot).not.toHaveBeenCalled(); }); diff --git a/src/app/api/upgrade-config/route.ts b/src/app/api/upgrade-config/route.ts index 5036e65..6cc493a 100644 --- a/src/app/api/upgrade-config/route.ts +++ b/src/app/api/upgrade-config/route.ts @@ -14,22 +14,20 @@ const toDisplayName = (name: string) => export async function GET(req: NextRequest) { const url = new URL(req.url); const network = url.searchParams.get('network'); - const upgradeId = url.searchParams.get('upgradeId'); - if (!network || !upgradeId) { + if (!network) { return NextResponse.json( - { error: 'Missing required parameters: network and upgradeId are required' }, + { error: 'Missing required parameter: network is required' }, { status: 400 } ); } - // Validate inputs to prevent path traversal attacks const safePathPattern = /^[a-zA-Z0-9_-]+$/; - if (!safePathPattern.test(network) || !safePathPattern.test(upgradeId)) { + if (!safePathPattern.test(network)) { return NextResponse.json( { error: - 'Invalid network or upgradeId: only alphanumeric characters, hyphens, and underscores are allowed', + 'Invalid network: only alphanumeric characters, hyphens, and underscores are allowed', }, { status: 400 } ); @@ -38,8 +36,10 @@ export async function GET(req: NextRequest) { const contractDeploymentsRoot = findContractDeploymentsRoot(); const validationsJoinedPath = path.join( contractDeploymentsRoot, + 'active', + 'evm', + 'config', network, - upgradeId, 'validations' ); @@ -89,10 +89,9 @@ export async function GET(req: NextRequest) { ); console.log( - 'Found %d config options for %s/%s:', + 'Found %d config options for %s:', configOptions.length, network, - upgradeId, configOptions.map(c => c.displayName) ); diff --git a/src/app/api/upgrades/__tests__/route.test.ts b/src/app/api/upgrades/__tests__/route.test.ts index a539be5..12ea19d 100644 --- a/src/app/api/upgrades/__tests__/route.test.ts +++ b/src/app/api/upgrades/__tests__/route.test.ts @@ -26,7 +26,6 @@ describe('GET /api/upgrades', () => { if (network === NetworkType.Zeronet) { return [ { - id: '2025-08-01-upgrade-qux', name: 'Upgrade Qux', description: 'Zeronet upgrade', date: '2025-08-01', @@ -64,7 +63,6 @@ describe('GET /api/upgrades', () => { expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: '2025-08-01-upgrade-qux', network: NetworkType.Zeronet, status: TaskStatus.ReadyToSign, }), diff --git a/src/app/api/upgrades/route.ts b/src/app/api/upgrades/route.ts index 4621aa2..c45d1c4 100644 --- a/src/app/api/upgrades/route.ts +++ b/src/app/api/upgrades/route.ts @@ -20,9 +20,8 @@ export function GET(req: NextRequest) { network: network, })); }); - // Sort by date (most recent first) return NextResponse.json( - allReadyToSignTasks.sort((a, b) => b.id.localeCompare(a.id)), + allReadyToSignTasks.sort((a, b) => b.date.localeCompare(a.date)), { status: 200 } ); } diff --git a/src/app/api/validate/__tests__/route.test.ts b/src/app/api/validate/__tests__/route.test.ts index 44f8985..129e9d8 100644 --- a/src/app/api/validate/__tests__/route.test.ts +++ b/src/app/api/validate/__tests__/route.test.ts @@ -33,7 +33,6 @@ describe('POST /api/validate', () => { it('accepts zeronet as a supported network', async () => { const res = await POST( createRequest({ - upgradeId: '2025-08-01-upgrade-qux', network: 'zeronet', userType: 'base-sc', }) @@ -41,7 +40,6 @@ describe('POST /api/validate', () => { expect(res.status).toBe(200); expect(mockValidateUpgrade).toHaveBeenCalledWith({ - upgradeId: '2025-08-01-upgrade-qux', network: NetworkType.Zeronet, taskConfigFileName: 'base-sc', }); @@ -50,7 +48,6 @@ describe('POST /api/validate', () => { it('rejects unsupported networks and lists zeronet in supported values', async () => { const res = await POST( createRequest({ - upgradeId: '2025-08-01-upgrade-qux', network: 'hoodi', userType: 'base-sc', }) diff --git a/src/app/api/validate/route.ts b/src/app/api/validate/route.ts index 7a17e09..d16b6ca 100644 --- a/src/app/api/validate/route.ts +++ b/src/app/api/validate/route.ts @@ -5,23 +5,20 @@ import { NetworkType } from '@/lib/types'; export async function POST(req: NextRequest) { try { const json = await req.json(); - const { upgradeId, network, userType } = json; + const { network, userType } = json; if ( - typeof upgradeId !== 'string' || typeof network !== 'string' || typeof userType !== 'string' || - !upgradeId.trim() || !network.trim() || !userType.trim() ) { return NextResponse.json( - { message: 'Missing required parameters: upgradeId, network, and userType are required' }, + { message: 'Missing required parameters: network and userType are required' }, { status: 400 } ); } - const trimmedUpgradeId = upgradeId.trim(); const trimmedUserType = userType.trim(); const normalizedNetwork = network.trim().toLowerCase(); @@ -37,7 +34,6 @@ export async function POST(req: NextRequest) { } const validationResult = await validateUpgrade({ - upgradeId: trimmedUpgradeId, network: normalizedNetwork as NetworkType, taskConfigFileName: trimmedUserType, }); diff --git a/src/app/page.tsx b/src/app/page.tsx index 1890513..a2d46fe 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -157,29 +157,17 @@ export default function Home() { )} {currentStep === 'upgrade' && ( - + )} {currentStep === 'user' && selectedNetwork && selectedUpgrade && ( - + )} {currentStep === 'validation' && ( )} @@ -198,7 +186,6 @@ export default function Home() { user={selectedUser} network={selectedNetwork || ''} selectedUpgrade={{ - id: selectedUpgrade?.id || '', name: selectedUpgrade?.name || '', }} signingData={signingData} diff --git a/src/components/SigningConfirmation.tsx b/src/components/SigningConfirmation.tsx index b867361..7647b02 100644 --- a/src/components/SigningConfirmation.tsx +++ b/src/components/SigningConfirmation.tsx @@ -10,7 +10,6 @@ interface SigningConfirmationProps { user?: ConfigOption; network: string; selectedUpgrade: { - id: string; name: string; }; signingData?: LedgerSigningResult | null; diff --git a/src/components/UpgradeSelection.tsx b/src/components/UpgradeSelection.tsx index af5ca38..45188d9 100644 --- a/src/components/UpgradeSelection.tsx +++ b/src/components/UpgradeSelection.tsx @@ -13,13 +13,11 @@ const STATUS_VARIANT_MAP: Record }; interface UpgradeSelectionProps { - selectedWallet: string | null; selectedNetwork: string | null; onSelect: (upgrade: Upgrade) => void; } export const UpgradeSelection: React.FC = ({ - selectedWallet, selectedNetwork, onSelect, }) => { @@ -137,12 +135,12 @@ export const UpgradeSelection: React.FC = ({
{upgradeOptions.map(option => { - const isSelected = selectedWallet === option.id && selectedNetwork === option.network; - const isExpanded = !!expandedCards[option.id]; + const isSelected = selectedNetwork === option.network; + const isExpanded = !!expandedCards[option.network]; return ( onSelect(option)} @@ -214,7 +212,7 @@ export const UpgradeSelection: React.FC = ({ - ))} +
+ + +
+ {networks.map(network => { + const isSelected = selectedNetwork === network; + + return ( + onSelect(network)} + className="relative group" + > +
+
+
+
+
+

+ {formatNetworkName(network)} +

+ + {network} + +
+
+ + {isSelected && ( +
+
+ )} +
+
+ ); + })}
); diff --git a/src/components/NetworkSummary.tsx b/src/components/NetworkSummary.tsx new file mode 100644 index 0000000..3f4a6c6 --- /dev/null +++ b/src/components/NetworkSummary.tsx @@ -0,0 +1,49 @@ +import { Globe } from 'lucide-react'; +import { NetworkType } from '@/lib/types'; +import { Badge } from './ui/Badge'; +import { Button } from './ui/Button'; +import { Card } from './ui/Card'; + +interface NetworkSummaryProps { + selectedNetwork: NetworkType | null; + onChange?: () => void; +} + +const formatNetworkName = (network: NetworkType) => + network + .split('-') + .map(segment => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(' '); + +export function NetworkSummary({ selectedNetwork, onChange }: NetworkSummaryProps) { + if (!selectedNetwork) return null; + + return ( + +
+
+
+
+
+

+ {formatNetworkName(selectedNetwork)} +

+ + {selectedNetwork} + +
+
+ {onChange && ( + + )} +
+
+ ); +} diff --git a/src/components/UpgradeSelection.tsx b/src/components/UpgradeSelection.tsx index 4c668fa..dd0eeb1 100644 --- a/src/components/UpgradeSelection.tsx +++ b/src/components/UpgradeSelection.tsx @@ -1,5 +1,5 @@ -import { TaskStatus, Upgrade, ExecutionLink } from '@/lib/types'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { TaskStatus, Upgrade, ExecutionLink, NetworkType } from '@/lib/types'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Card } from './ui/Card'; @@ -14,13 +14,11 @@ const STATUS_VARIANT_MAP: Record interface UpgradeSelectionProps { selectedUpgradeId: string | null; - selectedNetwork: string | null; - onSelect: (upgrade: Upgrade) => void; + onSelect: (upgrade: Upgrade, networks: NetworkType[]) => void; } export const UpgradeSelection: React.FC = ({ selectedUpgradeId, - selectedNetwork, onSelect, }) => { const [upgradeOptions, setUpgradeOptions] = useState([]); @@ -65,6 +63,32 @@ export const UpgradeSelection: React.FC = ({ return () => abortControllerRef.current?.abort(); }, [fetchUpgrades]); + const taskOptions = useMemo(() => { + const grouped = new Map(); + + for (const upgrade of upgradeOptions) { + const existing = grouped.get(upgrade.id); + const network = upgrade.network as NetworkType; + + if (existing) { + if (!existing.networks.includes(network)) { + existing.networks.push(network); + } + continue; + } + + grouped.set(upgrade.id, { + upgrade, + networks: [network], + }); + } + + return Array.from(grouped.values()).map(option => ({ + ...option, + networks: option.networks.sort((a, b) => a.localeCompare(b)), + })); + }, [upgradeOptions]); + if (loading) { return (
@@ -105,7 +129,7 @@ export const UpgradeSelection: React.FC = ({ ); } - if (upgradeOptions.length === 0) { + if (taskOptions.length === 0) { return (
@@ -136,28 +160,31 @@ export const UpgradeSelection: React.FC = ({
- {upgradeOptions.map(option => { - const isSelected = selectedUpgradeId === option.id && selectedNetwork === option.network; + {taskOptions.map(({ upgrade: option, networks }) => { + const isSelected = selectedUpgradeId === option.id; const isExpanded = !!expandedCards[option.id]; return ( onSelect(option)} + onClick={() => onSelect(option, networks)} className="relative group" >
- - {option.network} - + {networks.map(network => ( + + {network} + + ))} {option.date}
diff --git a/src/components/index.ts b/src/components/index.ts index 9085936..7ad9fb5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,8 @@ export { ComparisonCard } from './ComparisonCard'; export { HighlightedText } from './HighlightedText'; export { LedgerSigning } from './LedgerSigning'; +export { NetworkSelection } from './NetworkSelection'; +export { NetworkSummary } from './NetworkSummary'; export { SelectionSummary } from './SelectionSummary'; export { SigningConfirmation } from './SigningConfirmation'; export { UpgradeSelection } from './UpgradeSelection'; diff --git a/src/components/ui/StepIndicator.tsx b/src/components/ui/StepIndicator.tsx index e7c26d7..df4c578 100644 --- a/src/components/ui/StepIndicator.tsx +++ b/src/components/ui/StepIndicator.tsx @@ -12,7 +12,10 @@ export const StepIndicator = ({ steps }: StepIndicatorProps) => { return (