From 3f63f6a186f33ba8c089170fa98e001109b559e0 Mon Sep 17 00:00:00 2001 From: Thanh Trinh Date: Sun, 28 Jun 2026 12:30:56 -0400 Subject: [PATCH] feat: support active evm signing flow Co-authored-by: Codex --- README.md | 131 +++++++-------- src/app/api/install-deps/route.ts | 32 ++-- .../upgrade-config/__tests__/route.test.ts | 64 +++---- src/app/api/upgrade-config/route.ts | 12 +- src/app/api/upgrades/__tests__/route.test.ts | 1 - src/app/api/upgrades/route.ts | 5 +- src/app/page.tsx | 111 ++++++++---- src/components/NetworkSelection.tsx | 81 +++++++-- src/components/NetworkSummary.tsx | 44 +++++ src/components/SigningConfirmation.tsx | 1 - src/components/UpgradeSelection.tsx | 41 +++-- src/components/UserSelection.tsx | 1 - src/components/ValidationResults.tsx | 9 +- src/components/index.ts | 2 + src/components/ui/StepIndicator.tsx | 5 +- src/lib/__tests__/deployments.test.ts | 132 ++++++++++++++- src/lib/__tests__/network-utils.test.ts | 9 + src/lib/__tests__/task-selection.test.ts | 68 ++++++++ src/lib/deployments.ts | 158 ++++++++++++------ src/lib/network-utils.ts | 7 + src/lib/task-selection.ts | 46 +++++ 21 files changed, 707 insertions(+), 253 deletions(-) create mode 100644 src/components/NetworkSummary.tsx create mode 100644 src/lib/__tests__/network-utils.test.ts create mode 100644 src/lib/__tests__/task-selection.test.ts create mode 100644 src/lib/network-utils.ts create mode 100644 src/lib/task-selection.ts diff --git a/README.md b/README.md index 535694c..9b6635e 100644 --- a/README.md +++ b/README.md @@ -32,60 +32,53 @@ 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/tasks//config//validations/` and appear in the normal signer UI flow. + +```text +contract-deployments/ +├─ task-signing-tool/ +├─ active/ +│ └─ evm/ +│ ├─ tasks/ +│ │ ├─ 2026-06-18-beryl-1/ +│ │ │ ├─ README.md +│ │ │ ├─ config/ +│ │ │ │ └─ mainnet/ +│ │ │ │ ├─ validations/ +│ │ │ │ ├─ signatures/ +│ │ │ │ ├─ network.env +│ │ │ │ └─ .env +│ │ └─ 2026-06-18-beryl-2/ +│ │ └─ config/ +│ ├─ script/ +│ │ └─ common/ +│ ├─ Makefile +│ ├─ README.md +│ └─ foundry.toml +└─ archive/ ``` -Key requirements and notes: +Notes: +- **Signer experience**: The UI lists active tasks first, then asks you to choose one of the task's ready-to-sign network 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`: - - "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. +- **Validation configs**: Place config files under `active/evm/tasks//config//validations/` with concise human-readable names, such as `coinbase-signer.json` or `security-council-signer.json`. +- **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/`, which is the same directory used for simulation. +- **Signature storage**: Store signatures in `active/evm/tasks//config//signatures/`. The signer tool excludes the nested `signatures/` directory from the signed tarball, so generating signatures does not change the attested payload. + +Generate task-origin signatures with: + +```bash +tsx task-signing-tool/scripts/genTaskOriginSig.ts sign \ + --task-folder active/evm \ + --signature-path active/evm/tasks/2026-06-18-beryl-1/config/mainnet/signatures +``` ### 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/tasks//config//README.md`, `active/evm/tasks//README.md`, or `active/evm/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 +93,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 the formatted task folder name. Examples @@ -156,7 +149,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/tasks//config//validations/` and are selected by user type: - `base-sc.json` for **Base SC** - `coinbase.json` for **Coinbase** @@ -196,7 +189,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/tasks//config//signatures/` (see **Task Origin Signing** below). Minimal example (`validations/base-sc.json`): @@ -247,7 +240,7 @@ Minimal example (`validations/base-sc.json`): ### Generate a validation file from a Foundry run -Use `scripts/genValidationFile.ts` to transform a Foundry run (which emits a `stateDiff.json` file in your task directory) into the validation JSON the app consumes. +Use `scripts/genValidationFile.ts` to transform a Foundry run (which emits a `stateDiff.json` file in `active/evm`) into the validation JSON the app consumes. Requirements: @@ -274,9 +267,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/tasks//config//validations/.json ``` Alternative (bun): @@ -286,9 +279,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/tasks//config//validations/.json ``` Example from a Makefile (variables expanded in your environment): @@ -297,15 +290,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/tasks/2026-06-18-beryl-1/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 +307,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/tasks//config//signatures/`: - **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 +315,7 @@ When task origin validation is enabled, three signature files must exist in `/config//signatures/`; the signer tool excludes nested `signatures/` directories from the deterministic tarball so signing does not change the attested payload. #### Requirements @@ -339,7 +332,7 @@ The script supports four commands: #### Flags -- `--task-folder, -t`: **Required.** Path to the task folder to sign/verify +- `--task-folder, -t`: **Required.** Path to the active EVM folder to sign/verify - `--signature-path, -p`: Directory to store/read signatures (defaults to task folder) - `--facilitator, -f`: Facilitator type: `base` or `security-council`. Omit to sign/verify as task creator - `--common-name, -c`: Common name for verification (required for task creator verification) @@ -359,8 +352,8 @@ npx tsx scripts/genTaskOriginSig.ts --help ```bash npm ci npx tsx scripts/genTaskOriginSig.ts sign \ - --task-folder // \ - --signature-path //signatures/ + --task-folder active/evm \ + --signature-path active/evm/tasks/2026-06-18-beryl-1/config/mainnet/signatures ``` **Sign as Base facilitator:** @@ -368,8 +361,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 \ + --signature-path active/evm/tasks/2026-06-18-beryl-1/config/mainnet/signatures \ --facilitator base ``` @@ -378,8 +371,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 \ + --signature-path active/evm/tasks/2026-06-18-beryl-1/config/mainnet/signatures \ --common-name alice@example.com ``` @@ -423,12 +416,12 @@ When you enable `--estimate-l2-gas`: ```bash npx tsx scripts/genValidationFile.ts \ --rpc-url https://mainnet.example \ - --workdir mainnet/2025-06-04-my-l2-deposit \ + --workdir active/evm \ --forge-cmd "forge script script/MyDeposit.s.sol:MyDeposit --sig 'run()' --sender 0xabc --json" \ --estimate-l2-gas \ --l2-rpc-url https://base-mainnet.example \ --l2-gas-buffer 25 \ - --out validations/base-sc.json + --out active/evm/tasks//config//validations/base-sc.json ``` #### Validation File Output diff --git a/src/app/api/install-deps/route.ts b/src/app/api/install-deps/route.ts index f15f8b8..e04b233 100644 --- a/src/app/api/install-deps/route.ts +++ b/src/app/api/install-deps/route.ts @@ -31,12 +31,11 @@ export async function POST(req: NextRequest) { } const actualNetwork = network.toLowerCase(); + const actualUpgradeId = String(upgradeId); 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) || !safePathPattern.test(actualUpgradeId)) { return NextResponse.json( { error: @@ -46,9 +45,9 @@ export async function POST(req: NextRequest) { ); } - // 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'); + const taskPath = path.join(upgradePath, 'tasks', actualUpgradeId); // Verify the resolved path is within the allowed directory let resolvedUpgradePath: string; @@ -59,12 +58,20 @@ export async function POST(req: NextRequest) { } const libPath = path.join(resolvedUpgradePath, 'lib'); + const resolvedTaskPath = assertWithinDir(taskPath, contractDeploymentsPath); - // 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 } + ); + } + + const taskPathExists = await pathExists(resolvedTaskPath); + if (!taskPathExists) { + return NextResponse.json( + { error: `Task folder not found: ${path.relative(contractDeploymentsPath, taskPath)}` }, { status: 404 } ); } @@ -72,11 +79,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}/${actualUpgradeId}; skipping.`); return NextResponse.json( { success: true, - message: `Dependencies already installed for ${actualNetwork}/${upgradeId}`, + message: `Dependencies already installed for ${actualNetwork}/${actualUpgradeId}`, libExists: true, installed: false, depsInstalled: false, @@ -88,17 +95,16 @@ export async function POST(req: NextRequest) { } console.log( - `Installing dependencies for ${actualNetwork}/${upgradeId} (cwd: ${resolvedUpgradePath})` + `Installing dependencies for ${actualNetwork}/${actualUpgradeId} (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}/${actualUpgradeId}`); // Verify that lib folder was created const libExistsAfterInstall = await pathExists(libPath); @@ -106,7 +112,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}/${actualUpgradeId}`, 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..e6b52a0 100644 --- a/src/app/api/upgrade-config/__tests__/route.test.ts +++ b/src/app/api/upgrade-config/__tests__/route.test.ts @@ -54,65 +54,52 @@ describe('GET /api/upgrade-config', () => { describe('missing parameters', () => { it('returns 400 when network is missing', async () => { - const res = await GET(createRequest({ upgradeId: 'test' })); + const res = await GET(createRequest({ upgradeId: '2026-06-18-beryl-1' })); 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); }); 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: '..', upgradeId: '2026-06-18-beryl-1' })); 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', upgradeId: '2026-06-18-beryl-1' })); 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' })); + const res = await GET( + createRequest({ network: 'main net', upgradeId: '2026-06-18-beryl-1' }) + ); 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 upgradeId contains "@"', async () => { - const res = await GET(createRequest({ network: 'mainnet', upgradeId: 'test@1' })); + it('returns 400 when network contains "." (single dot)', async () => { + const res = await GET(createRequest({ network: '.', upgradeId: '2026-06-18-beryl-1' })); 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' })); + it('returns 400 when upgradeId contains path separators', async () => { + const res = await GET(createRequest({ network: 'mainnet', upgradeId: 'foo/bar' })); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/invalid network or upgradeId/i); @@ -120,20 +107,17 @@ describe('GET /api/upgrade-config', () => { }); 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', upgradeId: '2026-06-18-beryl-1' }) + ); 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', upgradeId: '2026-06-18-beryl-1' })); expect(res.status).toBe(200); const body = await res.json(); expect(body.configOptions).toHaveLength(1); @@ -148,7 +132,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', upgradeId: '2026-06-18-beryl-1' })); expect(res.status).toBe(200); const body = await res.json(); expect(body.configOptions).toEqual([]); @@ -158,7 +142,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', upgradeId: '2026-06-18-beryl-1' })); expect(res.status).toBe(500); const body = await res.json(); expect(body.error).toMatch(/failed to fetch upgrade configuration/i); @@ -167,7 +151,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: '..', upgradeId: '2026-06-18-beryl-1' })); 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..bd833ed 100644 --- a/src/app/api/upgrade-config/route.ts +++ b/src/app/api/upgrade-config/route.ts @@ -23,7 +23,6 @@ export async function GET(req: NextRequest) { ); } - // Validate inputs to prevent path traversal attacks const safePathPattern = /^[a-zA-Z0-9_-]+$/; if (!safePathPattern.test(network) || !safePathPattern.test(upgradeId)) { return NextResponse.json( @@ -38,8 +37,12 @@ export async function GET(req: NextRequest) { const contractDeploymentsRoot = findContractDeploymentsRoot(); const validationsJoinedPath = path.join( contractDeploymentsRoot, - network, + 'active', + 'evm', + 'tasks', upgradeId, + 'config', + network, 'validations' ); @@ -89,10 +92,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, + `${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..537b81f 100644 --- a/src/app/api/upgrades/__tests__/route.test.ts +++ b/src/app/api/upgrades/__tests__/route.test.ts @@ -64,7 +64,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..f3849ec 100644 --- a/src/app/api/upgrades/route.ts +++ b/src/app/api/upgrades/route.ts @@ -20,9 +20,10 @@ 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) || b.id.localeCompare(a.id) + ), { status: 200 } ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 1890513..244a6eb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { + NetworkSelection, + NetworkSummary, UpgradeSelection, UserSelection, ValidationResults, @@ -12,13 +14,16 @@ import { } from '@/components'; import { PageShell, StepIndicator } from '@/components/ui'; import { NetworkType, ValidationData, Upgrade } from '@/lib/types'; +import { getUpgradeForNetwork, TaskOption } from '@/lib/task-selection'; import { ConfigOption } from '@/components/UserSelection'; import { LedgerSigningResult } from '@/lib/ledger-signing'; -type Step = 'upgrade' | 'user' | 'validation' | 'ledger' | 'signing'; +type Step = 'upgrade' | 'network' | 'user' | 'validation' | 'ledger' | 'signing'; +type StepStatus = 'current' | 'completed' | 'pending'; const STEP_LAYOUT_WIDTH: Record = { upgrade: 'max-w-4xl', + network: 'max-w-3xl', user: 'max-w-3xl', validation: 'max-w-[1200px]', ledger: 'max-w-4xl', @@ -29,18 +34,26 @@ export default function Home() { const [currentStep, setCurrentStep] = useState('upgrade'); const [selectedUser, setSelectedUser] = useState(); const [selectedNetwork, setSelectedNetwork] = useState(null); + const [availableNetworksForTask, setAvailableNetworksForTask] = useState([]); const [selectedUpgrade, setSelectedUpgrade] = useState(null); + const [selectedTaskOption, setSelectedTaskOption] = useState(null); const [validationData, setValidationData] = useState(null); const [signingData, setSigningData] = useState(null); const [userLedgerAccount, setUserLedgerAccount] = useState(0); - const resetSelectionsFrom = (step: 'upgrade' | 'user') => { + const resetSelectionsFrom = (step: 'upgrade' | 'network' | 'user') => { if (step === 'upgrade') { setSelectedNetwork(null); setSelectedUpgrade(null); + setSelectedTaskOption(null); + setAvailableNetworksForTask([]); } - if (step === 'upgrade' || step === 'user') { + if (step === 'network') { + setSelectedNetwork(null); + } + + if (step === 'upgrade' || step === 'network' || step === 'user') { setSelectedUser(undefined); setValidationData(null); setSigningData(null); @@ -48,9 +61,34 @@ export default function Home() { } }; - const handleUpgradeSelection = (upgrade: Upgrade) => { - setSelectedUpgrade(upgrade); - setSelectedNetwork(upgrade.network as NetworkType); + const handleUpgradeSelection = (taskOption: TaskOption) => { + setSelectedTaskOption(taskOption); + setSelectedUpgrade(taskOption.displayUpgrade); + setSelectedNetwork(null); + setSelectedUser(undefined); + setValidationData(null); + setSigningData(null); + setUserLedgerAccount(0); + setAvailableNetworksForTask(taskOption.networks); + setCurrentStep('network'); + }; + + const handleNetworkSelection = (network: NetworkType) => { + const networkUpgrade = selectedTaskOption + ? getUpgradeForNetwork(selectedTaskOption, network) + : undefined; + + if (!networkUpgrade) { + console.error(`No upgrade metadata found for ${network}`); + return; + } + + setSelectedUpgrade(networkUpgrade); + setSelectedNetwork(network); + setSelectedUser(undefined); + setValidationData(null); + setSigningData(null); + setUserLedgerAccount(0); setCurrentStep('user'); }; @@ -75,12 +113,24 @@ export default function Home() { setCurrentStep('upgrade'); }; + const handleGoToNetworkSelection = () => { + resetSelectionsFrom('network'); + setCurrentStep('network'); + }; + const handleGoToUserSelection = () => { resetSelectionsFrom('user'); setCurrentStep('user'); }; const canEditUpgrade = + currentStep === 'network' || + currentStep === 'user' || + currentStep === 'validation' || + currentStep === 'ledger' || + currentStep === 'signing'; + + const canEditNetwork = currentStep === 'user' || currentStep === 'validation' || currentStep === 'ledger' || @@ -99,16 +149,21 @@ export default function Home() { ? 'current' : ((selectedUpgrade ? 'completed' : 'pending') as 'current' | 'completed' | 'pending'), }, + { + id: 'network', + label: 'Select Network', + status: + currentStep === 'network' + ? 'current' + : ((selectedNetwork ? 'completed' : 'pending') as StepStatus), + }, { id: 'user', label: 'Select User', status: currentStep === 'user' ? 'current' - : ((selectedUser ? 'completed' : selectedUpgrade ? 'pending' : 'pending') as - | 'current' - | 'completed' - | 'pending'), + : ((selectedUser ? 'completed' : 'pending') as StepStatus), }, { id: 'validation', @@ -116,10 +171,7 @@ export default function Home() { status: currentStep === 'validation' ? 'current' - : ((validationData ? 'completed' : selectedUser ? 'pending' : 'pending') as - | 'current' - | 'completed' - | 'pending'), + : ((validationData ? 'completed' : 'pending') as StepStatus), }, { id: 'ledger', @@ -127,16 +179,12 @@ export default function Home() { status: currentStep === 'ledger' ? 'current' - : ((signingData ? 'completed' : validationData ? 'pending' : 'pending') as - | 'current' - | 'completed' - | 'pending'), + : ((signingData ? 'completed' : 'pending') as StepStatus), }, { id: 'signing', label: 'Confirmation', - status: - currentStep === 'signing' ? 'current' : ('pending' as 'current' | 'completed' | 'pending'), + status: currentStep === 'signing' ? 'current' : ('pending' as StepStatus), }, ]; @@ -152,18 +200,29 @@ export default function Home() { /> )} + {canEditNetwork && ( + + )} + {canEditUser && ( )} {currentStep === 'upgrade' && ( )} + {currentStep === 'network' && selectedUpgrade && ( + + )} + {currentStep === 'user' && selectedNetwork && selectedUpgrade && ( )} - {currentStep === 'validation' && ( + {currentStep === 'validation' && selectedUpgrade && ( )} @@ -198,7 +254,6 @@ export default function Home() { user={selectedUser} network={selectedNetwork || ''} selectedUpgrade={{ - id: selectedUpgrade?.id || '', name: selectedUpgrade?.name || '', }} signingData={signingData} diff --git a/src/components/NetworkSelection.tsx b/src/components/NetworkSelection.tsx index 8460ff5..10053b6 100644 --- a/src/components/NetworkSelection.tsx +++ b/src/components/NetworkSelection.tsx @@ -1,26 +1,75 @@ -import { Globe } from 'lucide-react'; -import { availableNetworks } from '@/lib/constants'; +import { Check, Globe } from 'lucide-react'; +import { formatNetworkName } from '@/lib/network-utils'; import { NetworkType } from '@/lib/types'; +import { Badge } from './ui/Badge'; +import { Card } from './ui/Card'; +import { SectionHeader } from './ui/SectionHeader'; interface NetworkSelectionProps { + selectedNetwork?: NetworkType | null; + networks: NetworkType[]; onSelect: (network: NetworkType) => void; } -export function NetworkSelection({ onSelect }: NetworkSelectionProps) { +export function NetworkSelection({ selectedNetwork, networks, onSelect }: NetworkSelectionProps) { + if (networks.length === 0) { + return ( +
+
+
+

No networks found

+

+ This task does not have any ready-to-sign network configs. +

+
+ ); + } + return ( -
-

Select Network

-
- {availableNetworks.map(option => ( - - ))} +
+ + +
+ {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..442a401 --- /dev/null +++ b/src/components/NetworkSummary.tsx @@ -0,0 +1,44 @@ +import { Globe } from 'lucide-react'; +import { formatNetworkName } from '@/lib/network-utils'; +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; +} + +export function NetworkSummary({ selectedNetwork, onChange }: NetworkSummaryProps) { + if (!selectedNetwork) return null; + + return ( + +
+
+
+
+
+

+ {formatNetworkName(selectedNetwork)} +

+ + {selectedNetwork} + +
+
+ {onChange && ( + + )} +
+
+ ); +} 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..ed48a9e 100644 --- a/src/components/UpgradeSelection.tsx +++ b/src/components/UpgradeSelection.tsx @@ -1,5 +1,6 @@ +import { groupUpgradesByTask, TaskOption } from '@/lib/task-selection'; import { TaskStatus, Upgrade, ExecutionLink } from '@/lib/types'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Card } from './ui/Card'; @@ -13,14 +14,12 @@ const STATUS_VARIANT_MAP: Record }; interface UpgradeSelectionProps { - selectedWallet: string | null; - selectedNetwork: string | null; - onSelect: (upgrade: Upgrade) => void; + selectedUpgradeId: string | null; + onSelect: (taskOption: TaskOption) => void; } export const UpgradeSelection: React.FC = ({ - selectedWallet, - selectedNetwork, + selectedUpgradeId, onSelect, }) => { const [upgradeOptions, setUpgradeOptions] = useState([]); @@ -65,6 +64,8 @@ export const UpgradeSelection: React.FC = ({ return () => abortControllerRef.current?.abort(); }, [fetchUpgrades]); + const taskOptions = useMemo(() => groupUpgradesByTask(upgradeOptions), [upgradeOptions]); + if (loading) { return (
@@ -105,7 +106,7 @@ export const UpgradeSelection: React.FC = ({ ); } - if (upgradeOptions.length === 0) { + if (taskOptions.length === 0) { return (
@@ -136,28 +137,32 @@ export const UpgradeSelection: React.FC = ({
- {upgradeOptions.map(option => { - const isSelected = selectedWallet === option.id && selectedNetwork === option.network; + {taskOptions.map(taskOption => { + const { displayUpgrade: option, networks } = taskOption; + const isSelected = selectedUpgradeId === option.id; const isExpanded = !!expandedCards[option.id]; return ( onSelect(option)} + onClick={() => onSelect(taskOption)} className="relative group" >
- - {option.network} - + {networks.map(network => ( + + {network} + + ))} {option.date}
diff --git a/src/components/UserSelection.tsx b/src/components/UserSelection.tsx index 6fe7226..3e1580f 100644 --- a/src/components/UserSelection.tsx +++ b/src/components/UserSelection.tsx @@ -22,7 +22,6 @@ export function UserSelection({ network, upgradeId, onSelect }: UserSelectionPro const [loadingUsers, setLoadingUsers] = useState(false); const [error, setError] = useState(''); - // Fetch available users when network and upgradeId change useEffect(() => { const resetState = () => { setAvailableUsers([]); diff --git a/src/components/ValidationResults.tsx b/src/components/ValidationResults.tsx index 2c1b6ac..c89dc27 100644 --- a/src/components/ValidationResults.tsx +++ b/src/components/ValidationResults.tsx @@ -105,10 +105,7 @@ const TaskOriginSkippedCard: React.FC = () => { interface ValidationResultsProps { userType: string; network: string; - selectedUpgrade: { - id: string; - name: string; - }; + upgradeId: string; onProceedToLedgerSigning: (validationResult: ValidationData) => void; } @@ -131,7 +128,7 @@ const getIcon = (iconName: string, size: number = 24, className?: string) => { export const ValidationResults: React.FC = ({ userType, network, - selectedUpgrade, + upgradeId, onProceedToLedgerSigning, }) => { const [currentIndex, setCurrentIndex] = useState(0); @@ -145,7 +142,7 @@ export const ValidationResults: React.FC = ({ runValidation, } = useValidationRunner({ network, - upgradeId: selectedUpgrade.id, + upgradeId, userType, }); 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 (