diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8b3b142 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,38 @@ +# Docker build context excludes. +# +# NOTE: .sdk-src/ is deliberately NOT excluded here -- the sdk-builder stage +# in the Dockerfile needs it in the build context (see +# plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §4.4). +# TEMPORARY (this note) -- remove per that ADR's §5 once .sdk-src/ itself goes away. + +node_modules +.next +.next-partial-failure +out +# `**/.git` (not just `.git`): a .dockerignore pattern is matched against the +# whole path relative to the context root, so a bare `.git` excludes ONLY the +# top-level one. In CI the sdk is staged by `actions/checkout` (see +# .github/workflows/docker-*.yaml), which writes a real `.sdk-src/.git` -- +# without this pattern the entire agglayer/sdk history would be uploaded into +# the build context and baked into the sdk-builder layer (and, with +# docker-pr.yaml's `cache-to: type=gha,mode=max`, into the Actions cache). +# It also restores the parity that scripts/stage-sdk-src.sh documents: locally +# `git archive` yields tracked source with no `.git`, so CI must match. +**/.git +test-results +playwright-report +blob-report +coverage + +# Secrets / build-time env files. A-1 §6.3(b) binding constraint: never let a +# developer's local .env.local (or .env, .env.staging) leak into the image -- +# .env.local in particular can carry a real Reown project ID or devnet +# overrides. .env.production is the one exception: it is git-tracked, contains +# only a NEXT_PUBLIC_PROJECT_ID placeholder (verified), and `pnpm run +# build:production` copies it to .env.local *inside* the build, so it must be +# present in the build context for that RUN step to find it. +.env* +!.env.production + +*.tsbuildinfo +.DS_Store diff --git a/.env.example b/.env.example index 5731ff3..b5f16b0 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,39 @@ +# Optional, LOCAL DEV ONLY. Get a real id at https://cloud.reown.com. Leaving +# the placeholder (or empty) runs AppKit in degraded `basic` mode: +# injected-wallet connect fully works; WalletConnect-cloud features (wallet +# directory images, remote config) are skipped and a handful of benign +# 401/403 console lines from Reown endpoints remain. +# +# This is a dev-time convenience only -- it overrides config.json's +# `walletConnect.projectId` when set (same precedence as +# NEXT_PUBLIC_AGGKIT_PROXY below). For a Docker deployment, set the real +# project id in the mounted config.json instead (see docs/docker.md and +# docs/config.md) -- this env var is BUILD-TIME ONLY (Next.js inlines it into +# the JS bundle) and has NO EFFECT in a prebuilt container image, and +# build:production/.env.production deliberately never sets it, precisely so +# a published image is not stuck with whatever value happened to be present +# at build time. NEXT_PUBLIC_PROJECT_ID=YOUR_PROJECT_ID_HERE -# Optional: overrides config.json bridgeHubApiBaseUrl when set. -# NEXT_PUBLIC_BRIDGE_HUB_API=http://localhost:8080 +# Optional: overrides the active mode's config.json aggkitProxy when set (a +# single bare URL, not JSON -- fanned out to every non-L1 network in the mode). +# Used to inject the enclave's ephemeral proxy port for devnet bring-up. +# Build-time only: Next.js inlines NEXT_PUBLIC_* vars into the JS bundle at build +# time, so this has NO EFFECT in a prebuilt container image -- see docs/docker.md. +# In a container the mounted config.json is the only configuration mechanism. +# NEXT_PUBLIC_AGGKIT_PROXY=http://127.0.0.1:33518/aggkitapi # E2E only: Playwright reads this and injects the derived values automatically. # Never set NEXT_PUBLIC_E2E_PRIVATE_KEY directly. E2E_PRIVATE_KEY=0xYOUR_E2E_PRIVATE_KEY + +# E2E only, all optional -- defaults target the local Kurtosis `cdk` devnet +# (scripts/kurtosisDevnetEnv.mjs). Set E2E_BACKEND_MODE=testnet to instead +# run against real Sepolia/Bokuto testnet infrastructure. See +# app/constants/e2e.ts for the full default values per mode and +# README.md#testing for details. +# E2E_BACKEND_MODE=devnet +# E2E_FROM_CHAIN_ID=271828 +# E2E_ERC20_ADDRESS=0xYOUR_ERC20_ADDRESS # devnet: auto-resolved/deployed by Playwright globalSetup if unset +# E2E_NATIVE_BRIDGE_AMOUNT=0.001 +# E2E_ERC20_BRIDGE_AMOUNT=0.01 +# E2E_BRIDGE_SUCCESS_TIMEOUT_MS=60000 +# E2E_CLAIM_TIMEOUT_MS=150000 diff --git a/.env.production b/.env.production index 2397303..ed22a08 100644 --- a/.env.production +++ b/.env.production @@ -1 +1,18 @@ -NEXT_PUBLIC_PROJECT_ID=production-project-id-placeholder +# build:production copies this file over .env.local (see Dockerfile) so a +# production build never inherits a developer's local .env.local overrides +# (NEXT_PUBLIC_AGGKIT_PROXY, E2E_* vars, etc.) or a stale NEXT_PUBLIC_PROJECT_ID. +# +# Deliberately empty: NEXT_PUBLIC_PROJECT_ID must NOT be set here. The +# WalletConnect/Reown project id is a prod-required value that now lives in +# the RUNTIME config.json (walletConnect.projectId, see docs/config.md and +# entrypoint.sh) so it can be set per container instance without a rebuild. +# If this file set NEXT_PUBLIC_PROJECT_ID to anything (even the placeholder), +# that value would be inlined into the JS bundle at build time and would +# permanently override config.json's value in every container built from +# this image -- reintroducing the exact bug this file exists to avoid (see +# app/config.ts's resolveProjectIdOverride precedence comment). +# +# The Cloudflare Workers deploy path (wrangler, see wrangler.toml) is +# unaffected: deploy.yaml sets NEXT_PUBLIC_PROJECT_ID as a real shell +# environment variable before invoking this build, and Next.js's env loader +# never overrides an already-set process.env value with one from a .env file. diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 3ba47ba..89d5d85 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -37,4 +37,4 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_WORKER_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CF_WORKER_API_TOKEN }} NEXT_PUBLIC_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_PROJECT_ID }} - NEXT_PUBLIC_BRIDGE_HUB_API: ${{ secrets.NEXT_PUBLIC_BRIDGE_HUB_API }} + NEXT_PUBLIC_AGGKIT_PROXY: ${{ secrets.NEXT_PUBLIC_AGGKIT_PROXY }} diff --git a/.github/workflows/docker-pr.yaml b/.github/workflows/docker-pr.yaml new file mode 100644 index 0000000..92c6ddf --- /dev/null +++ b/.github/workflows/docker-pr.yaml @@ -0,0 +1,202 @@ +# PR-time image build + smoke test for agglayer-dev-ui. +# +# Closes plan gap P4 (plans/dev-ui-docker-ghcr-plan.md): before this +# workflow, nothing in CI built or smoke-tested the Docker image, so a +# broken Dockerfile or config contract only surfaced at deploy time. This +# workflow builds the image on every pull request (single arch, never +# pushed) and smoke-tests it, so a regression turns the PR red instead. +# +# Deliberately separate from docker-publish.yaml (the release/dispatch +# workflow): that workflow publishes multi-arch images to GHCR and must +# never run on untrusted PR heads with push credentials in scope. This +# workflow never logs in to a registry and never sets `push: true`. +# +# Per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §6.3, the +# "skip if blocked by sdk#28" escape hatch that the plan originally allowed +# for this step is WITHDRAWN: D-2 proved the image builds today without +# waiting on sdk#28 (§3, verified build EXIT=0), so this job always builds +# and smoke-tests -- no skip condition, no continue-on-error, no `if:` +# guard tied to sdk#28. +name: Docker PR Build and Smoke Test + +on: + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + IMAGE_TAG: agglayer-dev-ui:pr-smoke + # TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + # Pinned to a full 40-character commit SHA, never a branch name -- see + # docker-publish.yaml's identical env entry for the full rationale. + # Verified identical across local HEAD, `git ls-remote`, and + # `gh api repos/agglayer/sdk/pulls/28 --jq .head.sha` as of the D-2 ADR + # (2026-08-11). Keep in sync with docker-publish.yaml's SDK_REF; bump both + # together when sdk#28 moves, delete both entirely once the D-2 ADR §5 + # migration trigger fires. + SDK_REF: 5680d837b168cd3b250110660332aa110eb88aae + +jobs: + build-and-smoke-test: + name: Build (single-arch, no push) and smoke test + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout agglayer-dev-ui + uses: actions/checkout@v4 + with: + persist-credentials: false + + # TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + # D-2 ADR §4.1: a second, SHA-pinned checkout of the public + # agglayer/sdk repo, staged at .sdk-src/ inside the dev-ui build + # context so the Dockerfile's sdk-builder stage can compile it in + # place of the `file:../sdk` sibling checkout this repo's + # pnpm-workspace.yaml override expects locally. agglayer/sdk is + # public (`gh repo view agglayer/sdk --json isPrivate` -> + # {"isPrivate":false}), so the default GITHUB_TOKEN suffices -- no + # PAT, no new secret. Must run AFTER the primary checkout above: it + # writes into .sdk-src/ under the dev-ui workspace root that checkout + # just populated. + - name: Checkout agglayer/sdk (pinned, TEMPORARY) + uses: actions/checkout@v4 + with: + repository: agglayer/sdk + ref: ${{ env.SDK_REF }} + path: .sdk-src + persist-credentials: false + + # SDK_REF is duplicated (not shared) between this workflow and + # docker-publish.yaml -- GitHub Actions has no cross-workflow env. Left + # unguarded, a one-sided bump would make this PR job build and smoke-test + # a different @agglayer/sdk revision than the one docker-publish.yaml + # actually ships, so a green PR would prove nothing about the published + # image. This turns that silent drift into a red PR instead. + # TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + - name: Assert SDK_REF matches docker-publish.yaml + run: | + set -euo pipefail + publish_ref="$(grep -oE '^ SDK_REF: [0-9a-f]{40}$' .github/workflows/docker-publish.yaml | awk '{print $2}')" + if [ -z "$publish_ref" ]; then + echo "::error::could not read a 40-char SDK_REF from .github/workflows/docker-publish.yaml -- keep both workflows' SDK_REF entries in the documented 'SDK_REF: <40 hex chars>' form" >&2 + exit 1 + fi + if [ "$publish_ref" != "$SDK_REF" ]; then + echo "::error::SDK_REF drift: docker-pr.yaml pins '$SDK_REF' but docker-publish.yaml pins '$publish_ref'. Bump both together (D-2 ADR §5.2 items 7 and 8)." >&2 + exit 1 + fi + echo "SDK_REF in sync across both workflows: $SDK_REF" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # No push, no registry login: this is the deliberate boundary between + # this workflow and docker-publish.yaml. `load: true` pulls the built + # image into the local Docker daemon so the smoke-test steps below can + # `docker run` it. Single-arch (the runner's native linux/amd64) per + # the plan's acceptance criterion -- multi-arch + `load` is not + # supported by the default docker driver anyway. + - name: 'Build image (single-arch, push: false)' + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + push: false + load: true + # TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + # Feeds the Dockerfile's ARG SDK_REF (both stages), which stamps + # the LABEL org.agglayer.sdk.revision onto the built image. Not + # load-bearing for a PR build (nothing publishes this image), kept + # only so the build-arg contract matches docker-publish.yaml + # exactly and the label is present if anyone inspects the image + # locally. + build-args: | + SDK_REF=${{ env.SDK_REF }} + tags: ${{ env.IMAGE_TAG }} + cache-from: type=gha,scope=docker-pr + cache-to: type=gha,mode=max,scope=docker-pr + + - name: Smoke test image (HTTP 200, valid /config.json) + run: | + set -euo pipefail + + docker run -d --name devui-smoke -p 8080:80 "${{ env.IMAGE_TAG }}" + + ready="" + for _ in $(seq 1 30); do + if curl -fsS -o /dev/null http://localhost:8080/; then + ready=1 + break + fi + sleep 1 + done + if [ -z "$ready" ]; then + echo "::error::container never became ready on http://localhost:8080/ -- likely a broken Dockerfile, build, or entrypoint" >&2 + docker logs devui-smoke >&2 || true + exit 1 + fi + + root_status="$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/)" + if [ "$root_status" != "200" ]; then + echo "::error::expected HTTP 200 from /, got $root_status" >&2 + docker logs devui-smoke >&2 || true + exit 1 + fi + + config_status="$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/config.json)" + if [ "$config_status" != "200" ]; then + echo "::error::expected HTTP 200 from /config.json, got $config_status" >&2 + docker logs devui-smoke >&2 || true + exit 1 + fi + + config_body="$(curl -fsS http://localhost:8080/config.json)" + if ! echo "$config_body" | jq empty 2>/dev/null; then + echo "::error::config.json served by the container is not valid JSON -- config contract is broken" >&2 + echo "$config_body" >&2 + docker logs devui-smoke >&2 || true + exit 1 + fi + + echo "Smoke test passed: / -> $root_status, /config.json -> $config_status (valid JSON)" + docker logs devui-smoke + docker rm -f devui-smoke + + # Per C-2 (plans/dev-ui-docker-ghcr-plan.md line ~469): assert the + # app-config-error path too, not only "the container starts". This is + # the container-level analog of the browser-rendered + # data-test-id="app-config-error" screen (that gate lives in + # app/components/appConfigGate.tsx and is T-1's concern, requiring a + # browser) -- entrypoint.sh's own structural jq validation is the + # equivalent contract at the container boundary: a deliberately + # invalid mounted config.json must make the container refuse to start + # (exit non-zero), not silently serve a blank or broken page. Fast + # (a few seconds, no port, no polling loop) and legible (its own + # ::error on failure). + - name: Smoke test invalid mounted config (container must exit non-zero) + run: | + set -euo pipefail + + invalid_config="$RUNNER_TEMP/invalid-config.json" + echo '{"not":"a valid agglayer-dev-ui config"}' > "$invalid_config" + + set +e + docker run --rm \ + -v "$invalid_config:/etc/agglayer-dev-ui/config.json:ro" \ + "${{ env.IMAGE_TAG }}" + exit_code=$? + set -e + + if [ "$exit_code" -eq 0 ]; then + echo "::error::container exited 0 for a deliberately invalid mounted config.json -- entrypoint.sh's structural validation regressed (config contract broken)" >&2 + exit 1 + fi + + echo "Invalid-config smoke test passed: container correctly exited non-zero ($exit_code) instead of starting with a broken config" diff --git a/.github/workflows/docker-publish.yaml b/.github/workflows/docker-publish.yaml new file mode 100644 index 0000000..cb7a27e --- /dev/null +++ b/.github/workflows/docker-publish.yaml @@ -0,0 +1,274 @@ +# Builds and publishes ghcr.io/agglayer/agglayer-dev-ui. +# +# Triggers +# - `release: published` -> semver tags (see "Tagging scheme" below). +# - `workflow_dispatch` -> a single namespaced tag for an arbitrary +# branch/tag/sha, never touching semver or +# `latest`. +# +# Limitation (documented per plan acceptance criteria): the workflow_dispatch +# *event itself* -- i.e. which ref you pick in the "Run workflow" button, or +# pass to `gh workflow run --ref ` / the REST API -- must be an existing +# branch or tag. GitHub uses that ref to decide which version of this +# workflow file to execute, and its API rejects a bare commit SHA there. This +# is independent of the `ref` input below, which IS just a plain string +# forwarded to `actions/checkout` and therefore does accept a full commit SHA +# (as long as it is reachable with `fetch-depth: 0`, set below). Net effect: +# to publish an arbitrary unreleased commit, push it to a branch or tag +# first, dispatch the workflow against that ref, and pass whatever revision +# you want built (branch, tag, or full SHA) as the `ref` input. +# +# Tagging scheme +# release, non-prerelease `vX.Y.Z` / `X.Y.Z` -> X.Y.Z, X.Y, latest +# release, prerelease (GitHub flag or a +# semver `-suffix` such as `1.2.3-rc.1`) -> X.Y.Z(-suffix) only +# (never X.Y, never latest -- neither tag should ever point at a +# pre-release build) +# workflow_dispatch -> dispatch--- +# +# Collision guard (this is the deliberate X-1 attack target -- see +# plans/dev-ui-docker-ghcr-plan.md's X-1 item 7, "can a dispatch-built tag +# overwrite a release semver tag, or move latest?"): +# Every dispatch tag carries the literal, hardcoded prefix "dispatch-". +# Every tag this workflow ever writes on the release path either matches +# ^[0-9]+\.[0-9]+(\.[0-9]+)?(-[0-9A-Za-z.-]+)?$ (a version tag) or is the +# literal string "latest" -- both start with a digit or the letter 'l', +# never with "dispatch-". No value of the sanitized ref, short SHA, or run +# ID -- however it is crafted -- can turn a "dispatch-..." string into one +# that starts with a digit or equals "latest", because string +# concatenation with a fixed non-empty, non-numeric prefix cannot produce +# a string lacking that prefix. That is a structural guarantee, not a +# runtime check that clever input could bypass. The "compute image tags" +# step below additionally asserts this at runtime and fails the job if it +# is ever violated -- defense-in-depth against a future edit that +# accidentally weakens or removes the prefix, not the primary guarantee. +name: Build and Publish Docker Image + +on: + release: + types: [published] + workflow_dispatch: + inputs: + ref: + description: >- + Branch, tag, or full commit SHA to build and publish (forwarded + verbatim to actions/checkout). See the workflow file header for + why this is NOT the same as the ref the workflow_dispatch event + itself must be fired against (which cannot be a bare SHA). + required: true + default: main + type: string + +concurrency: + # Deliberately NOT `${{ github.ref }}`-scoped and NOT cancel-in-progress, + # unlike this repo's other workflows (deploy.yaml, e2e.yaml): a cancelled + # mid-push here could leave a partially written manifest or tag in GHCR. + # Overlapping runs (e.g. a release publish and a manual dispatch at the + # same time) queue and run strictly serially instead of racing or + # cancelling each other mid-push. + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + packages: write + +env: + REGISTRY_IMAGE: ghcr.io/${{ github.repository }} + # TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + # Pinned to a full 40-character commit SHA, never a branch name -- + # feat/aggkit-bridge-client is mutable and a branch ref would make + # published images irreproducible (D-2 ADR §3, "the reproducibility + # condition"). Verified identical across local HEAD, `git ls-remote`, and + # `gh api repos/agglayer/sdk/pulls/28 --jq .head.sha` as of the D-2 ADR + # (2026-08-11). Bump this one line when sdk#28 moves; delete it entirely + # once the D-2 ADR §5 migration trigger fires. + SDK_REF: 5680d837b168cd3b250110660332aa110eb88aae + +jobs: + build-and-publish: + name: Build, publish, and verify + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout agglayer-dev-ui + uses: actions/checkout@v4 + with: + # Release events already check out the tag that triggered the + # release; only override ref for workflow_dispatch. fetch-depth: 0 + # so an arbitrary SHA passed as the `ref` input is resolvable. + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} + fetch-depth: 0 + persist-credentials: false + + # TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + # D-2 ADR §4.1: a second, SHA-pinned checkout of the public + # agglayer/sdk repo, staged at .sdk-src/ inside the dev-ui build + # context so the Dockerfile's sdk-builder stage can compile it in + # place of the `file:../sdk` sibling checkout this repo's + # pnpm-workspace.yaml override expects locally. agglayer/sdk is + # public (`gh repo view agglayer/sdk --json isPrivate` -> + # {"isPrivate":false}), so the default GITHUB_TOKEN suffices -- no + # PAT, no new secret. Must run AFTER the primary checkout above: it + # writes into .sdk-src/ under the dev-ui workspace root that checkout + # just populated. + - name: Checkout agglayer/sdk (pinned, TEMPORARY) + uses: actions/checkout@v4 + with: + repository: agglayer/sdk + ref: ${{ env.SDK_REF }} + path: .sdk-src + persist-credentials: false + + - name: Resolve build metadata + id: meta + run: | + set -euo pipefail + echo "built_sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT" + + - name: Compute image tags + id: tags + env: + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} + DISPATCH_REF: ${{ inputs.ref }} + BUILT_SHA: ${{ steps.meta.outputs.built_sha }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + + if [ "$EVENT_NAME" = "release" ]; then + # --- release path: X.Y.Z, X.Y, latest (see header comment) ------- + VERSION="${RELEASE_TAG#v}" + + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::release tag '$RELEASE_TAG' is not a semver tag (expected X.Y.Z or vX.Y.Z, optionally with a -prerelease suffix); refusing to publish a non-semver tag to GHCR" >&2 + exit 1 + fi + + MAJOR_MINOR="$(echo "$VERSION" | cut -d. -f1,2)" + + if [ "$RELEASE_PRERELEASE" = "true" ] || [[ "$VERSION" == *-* ]]; then + TAGS="$REGISTRY_IMAGE:$VERSION" + else + # printf + separate args, NOT a literal multi-line quoted + # string -- the latter would bake this script's own YAML + # indentation in as leading whitespace on the continuation + # lines, corrupting the newline-separated tag list. + TAGS="$(printf '%s\n%s\n%s' \ + "$REGISTRY_IMAGE:$VERSION" \ + "$REGISTRY_IMAGE:$MAJOR_MINOR" \ + "$REGISTRY_IMAGE:latest")" + fi + else + # --- workflow_dispatch path: a namespaced, non-colliding tag ------ + # See the workflow file header comment for the full explanation + # of why the hardcoded "dispatch-" prefix alone guarantees no + # collision with the release-path namespace. + SAFE_REF="$(printf '%s' "$DISPATCH_REF" | tr -c 'A-Za-z0-9_.-' '-' | cut -c1-40)" + DISPATCH_TAG="dispatch-${SAFE_REF}-${BUILT_SHA}-${RUN_ID}" + + # Runtime assertion (defense-in-depth, not the primary + # guarantee -- see header comment). + if [[ "$DISPATCH_TAG" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]] || [ "$DISPATCH_TAG" = "latest" ]; then + echo "::error::computed dispatch tag '$DISPATCH_TAG' unexpectedly collides with the semver/latest namespace -- refusing to publish" >&2 + exit 1 + fi + + TAGS="$REGISTRY_IMAGE:$DISPATCH_TAG" + fi + + { + echo "tags<> "$GITHUB_OUTPUT" + + echo "Resolved tags:" + echo "$TAGS" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + # Enables an emulated linux/arm64 build on this amd64 ubuntu-latest + # runner. D-3 (plans/dev-ui-docker-ghcr/d3-ci-capabilities.md §1) + # could not confirm that the org's arm-runner-2204/amd-runner-2204 + # self-hosted runner pool (which agglayer/aggkit's own workflow + # targets) is reachable from agglayer-dev-ui -- every availability + # check (org-level runners, dev-ui repo-level runners) 403'd with + # this token's WRITE-only privileges, and aggkit's own repo-level + # runner list is empty (the labels are org-scoped, not + # repo-registered). Per D-3's explicit fallback guidance, this + # workflow uses ubuntu-latest + QEMU instead of assuming those + # runner labels resolve for this repo. A native per-arch + # runner + digest-merge job (aggkit's pattern) remains a faster, + # available upgrade once a human with org-admin access confirms + # agglayer-dev-ui's runner-group membership. + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + id: build + uses: docker/build-push-action@v6 + with: + # D-2 ADR §4.2: build context is the dev-ui repo root, exactly as + # `docker build .` from the repo root -- not a parent directory. + context: . + platforms: linux/amd64,linux/arm64 + push: true + # TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + # Feeds the Dockerfile's ARG SDK_REF (both stages), which stamps + # the LABEL org.agglayer.sdk.revision onto the published image. + build-args: | + SDK_REF=${{ env.SDK_REF }} + tags: ${{ steps.tags.outputs.tags }} + + - name: Verify published manifest + run: docker buildx imagetools inspect "${{ env.REGISTRY_IMAGE }}@${{ steps.build.outputs.digest }}" + + - name: Smoke test pushed image + run: | + set -euo pipefail + IMAGE_REF="${{ env.REGISTRY_IMAGE }}@${{ steps.build.outputs.digest }}" + + docker run -d --name devui-smoke -p 8080:80 "$IMAGE_REF" + + ready="" + for _ in $(seq 1 30); do + if curl -fsS -o /dev/null http://localhost:8080/; then + ready=1 + break + fi + sleep 1 + done + if [ -z "$ready" ]; then + echo "::error::container never became ready on http://localhost:8080/" >&2 + docker logs devui-smoke >&2 || true + exit 1 + fi + + root_status="$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/)" + if [ "$root_status" != "200" ]; then + echo "::error::expected HTTP 200 from /, got $root_status" >&2 + docker logs devui-smoke >&2 || true + exit 1 + fi + + config_status="$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/config.json)" + if [ "$config_status" != "200" ]; then + echo "::error::expected HTTP 200 from /config.json, got $config_status" >&2 + docker logs devui-smoke >&2 || true + exit 1 + fi + + echo "Smoke test passed: / -> $root_status, /config.json -> $config_status" + docker logs devui-smoke + docker rm -f devui-smoke diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 9d94ed7..cce747a 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -13,45 +13,354 @@ concurrency: permissions: contents: read +env: + # TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + # Same pin as docker-pr.yaml/docker-publish.yaml (SDK_REF drift across all + # three workflows would be silently misleading: a green e2e run would + # prove nothing about the @agglayer/sdk revision docker-pr.yaml/ + # docker-publish.yaml actually build against). Verified identical across + # local HEAD, `git ls-remote`, and + # `gh api repos/agglayer/sdk/pulls/28 --jq .head.sha` as of the D-2 ADR + # (2026-08-11). Bump this alongside the other two workflows' SDK_REF + # entries; delete entirely once the D-2 ADR §5 migration trigger fires. + SDK_REF: 5680d837b168cd3b250110660332aa110eb88aae + jobs: e2e: runs-on: ubuntu-latest - environment: production - timeout-minutes: 15 + # Budget: devnet compose boot-to-ready ~24s with images already local + # (kurtosis-cdk S10/S11), ~65s on a cold CI runner including the image + # pull; install+browsers a few minutes, preflight seconds, then the full + # bridge suite -- L2->L1 certificate settlement is the slow, variable + # leg (~3.5min typical per kurtosis-cdk plan measurements, retry counts + # vary run-to-run). 75 minutes pads generously over the observed happy + # path for a loaded runner. + timeout-minutes: 75 env: CI: true - E2E_PRIVATE_KEY: ${{ secrets.E2E_PRIVATE_KEY }} - NEXT_PUBLIC_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_PROJECT_ID }} - NEXT_PUBLIC_BRIDGE_HUB_API: ${{ secrets.NEXT_PUBLIC_BRIDGE_HUB_API }} + # Devnet is hermetic and vendored (tests/devnet/, see its README) -- + # every value below is either a well-known public devnet fixture (the + # E2E signer key is a public Kurtosis/Foundry devnet key, funded only + # on the ephemeral compose bundle this job brings up itself) or a + # literal computed from that same bundle's tests/devnet/summary.json. + # None of these are repository secrets: nothing here grants access to + # anything outside this job's own throwaway containers. + E2E_PRIVATE_KEY: '0x12d7de8621a77640c9241b2595ba78ce443d05e94090365ab3bb5e19df82c625' + NEXT_PUBLIC_PROJECT_ID: ci-e2e + # Single-proxy form (see playwright.config.ts and app/config.ts): every + # non-L1 network in the mode fans out from this ONE URL, fetch() + # differentiating networks via a `network_id` query param -- there is + # no more per-network map to set here. + NEXT_PUBLIC_AGGKIT_PROXY: 'http://127.0.0.1:8555/aggkitapi' + E2E_FROM_CHAIN_ID: '271828' + E2E_TO_CHAIN_ID: '20201' + E2E_L2_CHAIN_IDS: '20201,20202' + # From tests/devnet/summary.json's .erc20_address -- nonce-dependent, + # bump alongside tests/devnet/docker-compose.yml (see its README). + E2E_ERC20_ADDRESS: '0xe293A6b8F558422813499bb5C89B60adD8c54636' steps: - - uses: actions/checkout@v4 + # Checked out into a subdirectory (not repo root) so a sibling + # agglayer/sdk checkout below can sit next to it at `../sdk` -- + # exactly the relative shape pnpm-workspace.yaml's + # `'@agglayer/sdk': 'file:../sdk'` override expects locally. Every + # subsequent step below runs with `working-directory: dev-ui`. + - name: Checkout agglayer-dev-ui + uses: actions/checkout@v4 with: + path: dev-ui fetch-depth: 0 persist-credentials: false + # TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + # A second, SHA-pinned checkout of the public agglayer/sdk repo, + # staged as a TRUE sibling of dev-ui/ (path: sdk, not nested inside + # it) so pnpm-workspace.yaml's `file:../sdk` override resolves + # (dev-ui/../sdk == this checkout). This differs from + # docker-pr.yaml/docker-publish.yaml's `.sdk-src` staging: those stage + # sdk INSIDE the dev-ui Docker build context because a Docker build + # context can't reach outside itself, but a plain `pnpm install` has + # no such constraint, so this sibling layout matches what a local dev + # checkout actually looks like. agglayer/sdk is public + # (`gh repo view agglayer/sdk --json isPrivate` -> {"isPrivate":false}), + # so the default GITHUB_TOKEN suffices -- no PAT, no new secret. + - name: Checkout agglayer/sdk (pinned, TEMPORARY) + uses: actions/checkout@v4 + with: + repository: agglayer/sdk + ref: ${{ env.SDK_REF }} + path: sdk + persist-credentials: false + + # Mirrors docker-pr.yaml's parity check against docker-publish.yaml: + # left unguarded, a one-sided SDK_REF bump here would let this job + # install/test against a different @agglayer/sdk revision than the + # one actually published, so a green e2e run would prove nothing + # about the shipped image's SDK revision. + # TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + - name: Assert SDK_REF matches docker-publish.yaml + working-directory: dev-ui + run: | + set -euo pipefail + # `|| true` is load-bearing: under `set -e` a non-matching grep + # aborts the assignment and kills the step, making the friendly + # ::error:: branch below unreachable. + publish_ref="$(grep -oE '^ SDK_REF: [0-9a-f]{40}$' .github/workflows/docker-publish.yaml | awk '{print $2}' || true)" + if [ -z "$publish_ref" ]; then + echo "::error::could not read a 40-char SDK_REF from .github/workflows/docker-publish.yaml -- keep every workflow's SDK_REF entry in the documented 'SDK_REF: <40 hex chars>' form" >&2 + exit 1 + fi + if [ "$publish_ref" != "$SDK_REF" ]; then + echo "::error::SDK_REF drift: e2e.yaml pins '$SDK_REF' but docker-publish.yaml pins '$publish_ref'. Bump both together (see docker-pr.yaml's identical check)." >&2 + exit 1 + fi + echo "SDK_REF in sync with docker-publish.yaml: $SDK_REF" + + # TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + # @agglayer/sdk ships no dist/ in git (bun.lock only -- dist/ is + # gitignored, built via `bun run build` / tsup), so a fresh checkout is + # unbuildable TypeScript source until this runs: the sibling sdk/ + # `file:../sdk` override resolves the package.json entry (`main: + # ./dist/index.js`), not the source tree. Without this step Next's dev + # server fails every route with "Module not found: Can't resolve + # '@agglayer/sdk'" -- caught only by a genuine scratch-clone rehearsal + # (plans/dev-ui-ci-snapshot/s13-evidence/); a warm sibling sdk checkout + # on a dev box masks it because its dist/ is already built from earlier + # SDK development. Mirrors the Dockerfile's sdk-builder stage (`FROM + # oven/bun:1 ... RUN bun install --frozen-lockfile && bun run build`) + # bit-for-bit, just targeting this sibling checkout instead of a Docker + # build context. + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1' + + - name: Build @agglayer/sdk (pinned, TEMPORARY) + working-directory: sdk + env: + HUSKY: '0' + run: | + bun install --frozen-lockfile + bun run build + + # Checkout lives at dev-ui/ (see the restructuring above), so the + # default package_json_file: package.json (resolved against + # $GITHUB_WORKSPACE) doesn't exist -- point it at the checkout's own + # package.json (packageManager: pnpm@10.30.3) so version detection + # works. Same subdir pattern as actions/setup-node's + # node-version-file: dev-ui/.nvmrc below. - uses: pnpm/action-setup@v4 + with: + package_json_file: dev-ui/package.json - uses: actions/setup-node@v4 with: - node-version-file: .nvmrc + node-version-file: dev-ui/.nvmrc cache: pnpm + cache-dependency-path: dev-ui/pnpm-lock.yaml - - run: pnpm install --frozen-lockfile - - run: pnpm exec playwright install --with-deps chromium + # With the sibling sdk/ checkout above in place, pnpm-workspace.yaml's + # `file:../sdk` override resolves and this no longer fails with + # ENOENT scandir '/dev-ui/../sdk' the way the pre-restructure + # single-directory checkout did. + - name: Install dependencies + working-directory: dev-ui + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + working-directory: dev-ui + run: pnpm exec playwright install --with-deps chromium + + # --- Built-image path (T4/D1) ----------------------------------------- + # Everything above/below this block tests PR source served by + # `pnpm run dev` (see playwright.config.ts) -- it never builds or runs + # the actual shipped Docker image. These four steps close that gap by + # building the real image from this PR's checkout and running + # tests/container/ (playwright.container.config.ts) against it, before + # paying for devnet bring-up, so a broken image/container regression + # fails fast. + # + # tests/bridge/* CANNOT run against this built image and are not + # attempted here: `pnpm run build:production` (package.json:14, what + # the Dockerfile's app-builder stage runs) never sets + # NEXT_PUBLIC_E2E_ENABLED, so `IS_E2E_ENABLED` (app/constants/e2e.ts:6) + # is permanently baked to `false` in any image built this way, and + # `app/context/e2eAccount.ts:4-6`'s auto-injected E2E signer -- the + # mechanism every wallet-signing spec in tests/bridge/ relies on -- + # never exists in a built image. Only tests/container/'s wallet-free + # assertions (config loading, static rendering, error-gate behavior) + # are wired to run against it. See plans/snapshot-v2-aggkit-e2e/devui-under-test.md + # for the full analysis. + # + # TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + # A THIRD, SHA-pinned checkout of agglayer/sdk, this time staged inside + # the Docker build context (dev-ui/.sdk-src, gitignored) so the + # Dockerfile's sdk-builder stage can compile it -- mirrors + # docker-pr.yaml's `.sdk-src` step exactly, except nested under dev-ui/ + # because this job's primary checkout already lives at dev-ui/ (see the + # comment above the first checkout step). This is separate from and in + # addition to the sibling `sdk/` checkout above (that one feeds the + # local `pnpm-workspace.yaml` `file:../sdk` override for + # `pnpm install`/`next dev`; this one feeds the Docker build context, + # which cannot reach outside itself). + - name: Checkout agglayer/sdk (pinned, TEMPORARY, for image build) + uses: actions/checkout@v4 + with: + repository: agglayer/sdk + ref: ${{ env.SDK_REF }} + path: dev-ui/.sdk-src + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Reuses docker-pr.yaml's exact build-args/context/cache pattern + # (context: dev-ui because this job's checkout lives there), and its + # `docker-pr` GHA cache scope -- same Dockerfile + build-args, so this + # maximizes cache hits from docker-pr.yaml's own runs on the same + # branch/PR. `push: false` / `load: true`: never publish, just load the + # image into this runner's local daemon for the container suite below. + - name: Build image for container suite (single-arch, push false) + uses: docker/build-push-action@v6 + with: + context: dev-ui + platforms: linux/amd64 + push: false + load: true + build-args: | + SDK_REF=${{ env.SDK_REF }} + tags: agglayer-dev-ui:c1-test + cache-from: type=gha,scope=docker-pr + cache-to: type=gha,mode=max,scope=docker-pr + + - name: Run container suite (tests/container, against the built image) + working-directory: dev-ui + run: pnpm exec playwright test --config=playwright.container.config.ts + # --- end built-image path ----------------------------------------- + + # Vendored self-contained bundle -- tests/devnet/docker-compose.yml, + # see tests/devnet/README.md. No bind mounts/volumes, no kurtosis CLI, + # 9 public GHCR images (the aggkit bridge is a component of the main + # aggkit-00X process now, not a separate `aggkit-00X-bridge` service). + # Every image is pinned by DIGEST (immutable) -- re-publishing + # kurtosis-cdk's snapshot workflow can never silently change what this + # job tests against without a diff here. `agglayer-dev-ui-002` (the + # baked dev-ui, manual use only) sits behind the `devui` compose + # profile, so this plain `up -d --wait` never starts it. + # `--wait` blocks until every service's own healthcheck passes. + # Every E2E_* literal in this job's `env:` is hand-copied from + # tests/devnet/summary.json, and nothing in the repo reads that file + # programmatically -- so a bundle bump that updates the compose file and + # summary.json but forgets the workflow would leave the suite silently + # testing against stale values (a wrong ERC20 address makes globalSetup + # throw; a wrong chain id fails obscurely). Same drift class the SDK_REF + # check above already guards, so guard it the same way. + - name: Assert workflow literals match tests/devnet/summary.json + working-directory: dev-ui + run: | + set -euo pipefail + fail=0 + check() { + local label="$1" expected="$2" actual="$3" + if [ "$expected" != "$actual" ]; then + echo "::error::$label drift: e2e.yaml has '$actual', tests/devnet/summary.json has '$expected'. Bump them together (see tests/devnet/README.md)." >&2 + fail=1 + else + echo "ok: $label = $actual" + fi + } + s=tests/devnet/summary.json + check E2E_ERC20_ADDRESS "$(jq -er '.erc20_address' "$s")" "$E2E_ERC20_ADDRESS" + check E2E_FROM_CHAIN_ID "$(jq -er '.chain_ids.l1|tostring' "$s")" "$E2E_FROM_CHAIN_ID" + check E2E_TO_CHAIN_ID "$(jq -er '.chain_ids.l2_001|tostring' "$s")" "$E2E_TO_CHAIN_ID" + check E2E_L2_CHAIN_IDS "$(jq -er '[.chain_ids.l2_001,.chain_ids.l2_002]|map(tostring)|join(",")' "$s")" "$E2E_L2_CHAIN_IDS" + check E2E_PRIVATE_KEY "$(jq -er '.accounts.e2e_wallet.private_key' "$s")" "$E2E_PRIVATE_KEY" + check proxy_host_port "$(jq -er '.proxy.host_port|tostring' "$s")" "8555" + check NEXT_PUBLIC_AGGKIT_PROXY "$(jq -er '.aggkit_proxy.rest_url_via_proxy' "$s")" "$NEXT_PUBLIC_AGGKIT_PROXY" + # The vendored compose must pin the exact same 9 images the summary + # describes: every compose `image:` line is `@sha256:` + # (digest-pinned, see tests/devnet/README.md's "Tag scheme"), so + # comparing the full SORTED SET of digests in each file catches a + # missing/extra/swapped image, not just a drifted single value. + compose_digests=$(grep -oE 'sha256:[0-9a-f]{64}' tests/devnet/docker-compose.yml | sort -u) + summary_digests=$(jq -er '[.images.services[].digest] | sort | .[]' "$s") + compose_count=$(printf '%s\n' "$compose_digests" | grep -c .) + summary_count=$(printf '%s\n' "$summary_digests" | grep -c .) + if [ "$compose_count" -ne 9 ] || [ "$summary_count" -ne 9 ]; then + echo "::error::expected 9 image digests each side, got $compose_count in docker-compose.yml and $summary_count in summary.json" >&2 + fail=1 + elif [ "$compose_digests" != "$summary_digests" ]; then + echo "::error::tests/devnet/docker-compose.yml's pinned image digests do not match tests/devnet/summary.json's images.services[].digest set. Bump them together (see tests/devnet/README.md)." >&2 + diff <(echo "$compose_digests") <(echo "$summary_digests") >&2 || true + fail=1 + else + echo "ok: all 9 compose image digests match summary.json" + fi + [ "$fail" -eq 0 ] + + - name: Start vendored devnet + working-directory: dev-ui + run: docker compose -f tests/devnet/docker-compose.yml up -d --wait + + # Replicates kurtosisDevnetEnv.mjs's live-enclave probes against the + # fixed compose ports (no kurtosis CLI dependency) -- see + # scripts/devnetReady.mjs for the exact checks. + # + # Its sync-status checks do overlap the aggkit-proxy image's own baked + # healthcheck (which already asserts is_synced && is_active for network + # 0/1/2), so `up -d --wait` cannot return before those hold. The + # genuinely additive part is the six chainId / bridge-bytecode probes + # THROUGH haproxy: haproxy's own healthcheck only touches /aggkitapi, + # never /l1rpc, /l2rpc-001 or /l2rpc-002, so a misrouted proxy would + # otherwise not surface until a Playwright test failed obscurely. + # + # Timeout: boot-to-ready is ~24s once images are local, but the CI job + # measured ~65s including the ~2.9GB pull -- 300s pads for a loaded + # runner and a cold registry. + - name: Wait for devnet readiness + working-directory: dev-ui + run: node scripts/devnetReady.mjs --timeout-ms 300000 --interval-ms 3000 + + # config.json ships committed in testnet mode (see README.md#testing); + # this CI fixture flips appModes.default to devnet with the vendored + # bundle's fixed 127.0.0.1:8555 URLs already baked in. + - name: Configure devnet fixture + working-directory: dev-ui + run: | + cp config/config.ci.devnet.json config.json + pnpm run validate:config - name: Run Playwright preflight + working-directory: dev-ui run: pnpm exec playwright test tests/e2e/preflight.spec.ts - name: Run Playwright bridge tests + working-directory: dev-ui run: pnpm exec playwright test tests/bridge + # if: always() (not !cancelled()/on-failure) -- a hung devnet that + # causes the job to be cancelled on timeout is exactly the case where + # these logs matter most. + - name: Dump devnet logs + if: always() + working-directory: dev-ui + run: docker compose -f tests/devnet/docker-compose.yml logs --no-color > devnet-compose.log || true + + - name: Tear down devnet + if: always() + working-directory: dev-ui + run: docker compose -f tests/devnet/docker-compose.yml down -v || true + + # if: always(), matching the log dump above: a 75-minute timeout + # CANCELS the job, and `!cancelled()` would then skip this upload -- + # throwing away the devnet logs collected precisely for that case. - name: Upload Playwright artifacts uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} + if: always() with: name: playwright-report-${{ github.run_id }} path: | - playwright-report - test-results + dev-ui/playwright-report + dev-ui/playwright-report-container + dev-ui/test-results + dev-ui/devnet-compose.log retention-days: 30 if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 00f2429..6a3526d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,18 +13,36 @@ # testing /coverage /playwright-report/ +/playwright-report-container/ /test-results/ /.last-run.json /blob-report/ # next.js /.next/ +# Alternate dev distDir used by the partial-failure E2E project's dev server +# (see playwright.config.ts / next.config.ts NEXT_DIST_DIR). +/.next-partial-failure/ /out/ +# Generated from the committed root config.json by scripts/syncPublicConfig.mjs +# on every dev/build entry point -- never author this file directly, never +# commit it (see plans/dev-ui-docker-ghcr/a1-runtime-config-design.md §1). +/public/config.json # production /build /dist +# Docker build context staging for @agglayer/sdk (TEMPORARY -- remove per +# plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5). Populated by +# scripts/stage-sdk-src.sh locally, or a second actions/checkout in CI; must +# never be committed. +/.sdk-src/ + +# orchestration/evidence scratch (kept outside the repo at +# /home/brolygon/repos/plans//-evidence/ -- never commit here) +/plans/ + # misc .DS_Store *.pem diff --git a/CLAUDE.md b/CLAUDE.md index 3b591c4..808fe4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,16 +1,6 @@ -## Team Standards - -**Skip this fetch if team-standards.md is already loaded in your -context** (this happens when running from the apps-team-workspace). -Otherwise, **at the start of every session**, before doing any other -work, use the WebFetch tool to retrieve the Polygon Apps Team standards -from: - - -Follow all instructions in that document for the duration of this session. -If the fetch fails (network error, URL unreachable), inform the user that -team standards could not be loaded, then proceed with repo-specific rules -below. +## Team Standards — see `docs/team-standards.md` (vendored from the apps-team gist, +revision `8a356d52f61b67bd26aa78f3076a4134aac4e3b3`, 2026-08-10). Update by +re-vendoring deliberately — do not fetch live. --- @@ -23,7 +13,7 @@ with code in this repository. `agglayer-dev-ui` is a configurable, self-hosted bridging interface powered by the [Agglayer SDK](https://github.com/agglayer/sdk) and the -[Bridge Hub API](https://github.com/agglayer/agglayer-bridge-hub-api). +[aggkit bridge service](https://github.com/agglayer/aggkit) REST API. It is a Next.js 16 **static-export** app (`output: 'export'`) deployed to Cloudflare Workers via `wrangler`. There are no API routes, no middleware, no SSR. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a57a4a4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,119 @@ +# syntax=docker/dockerfile:1 +# +# Multi-stage build for agglayer-dev-ui: Node/pnpm build -> static export -> +# nginx:alpine runtime. Runtime-configurable via a mounted config.json (see +# entrypoint.sh) -- no Node, pnpm, source, or node_modules ship in the final +# image. +# +# Build from the repo root, identically locally and in CI: +# docker build -t agglayer-dev-ui . +# +# Locally, .sdk-src/ must be populated first (see scripts/stage-sdk-src.sh +# and plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §4.1): +# scripts/stage-sdk-src.sh +# docker build -t agglayer-dev-ui . + +# ============================================================================= +# Stage: sdk-builder +# TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 +# +# @agglayer/sdk's aggkit bridge APIs are not published yet (dev-ui's +# pnpm-workspace.yaml overrides '@agglayer/sdk' to 'file:../sdk'). This stage +# builds the sdk from tracked source staged at .sdk-src/ (git-archive'd from a +# sibling checkout locally, or a second actions/checkout in CI -- see the ADR) +# so that `pnpm install --frozen-lockfile` in the app-builder stage below can +# resolve that file: override without any sibling directory existing on the +# host/runner. Retire this stage entirely once a published @agglayer/sdk +# version above 1.0.0-beta.30 carries the aggkit APIs (ADR §5). +# ============================================================================= +FROM oven/bun:1 AS sdk-builder + +WORKDIR /sdk +COPY .sdk-src/ ./ +# Same reason as the app-builder stage below: agglayer/sdk also declares a +# husky `prepare` script. Set here too so this stage behaves identically +# whether or not a .git happened to reach the context. +ENV HUSKY=0 +RUN bun install --frozen-lockfile && bun run build + +# ============================================================================= +# Stage: app-builder +# ============================================================================= +FROM node:24-slim AS app-builder + +# Pin to pnpm 10.30.3 per package.json's packageManager field, on Node 24 per +# .nvmrc / package.json engines.node -- do not mirror whatever Node version +# happens to run the local shell. +RUN corepack enable && corepack prepare pnpm@10.30.3 --activate + +# TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 +# Container-side path /sdk is the load-bearing invariant: pnpm resolves the +# pnpm-workspace.yaml `'@agglayer/sdk': 'file:../sdk'` override relative to +# WORKDIR (/app below), so the built sdk must land one level up, at /sdk. +COPY --from=sdk-builder /sdk /sdk + +WORKDIR /app + +# Copy only the manifests needed to resolve the dependency graph first, so +# this (expensive) layer is cached across source-only changes. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ + +# Proven (D-1/D-2) to cleanly skip the `prepare` script's git-hooks install -- +# there is no .git directory in this build context, so husky would otherwise +# fail with ".git can't be found" noise. +ENV HUSKY=0 +RUN pnpm install --frozen-lockfile + +COPY . . + +# TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 +# Records which unreleased @agglayer/sdk commit is embedded in this image, so +# a published digest is self-describing. Fed via --build-arg SDK_REF=: +# in CI (W-1), the pinned SDK_REF workflow env; locally, the SHA printed by +# scripts/stage-sdk-src.sh. Declared here (not just in the runtime stage) +# purely for documentation proximity to the sdk-builder stage; the LABEL +# instruction that actually stamps the final image lives in the runtime +# stage below, since labels on intermediate build stages are not carried +# into the final image. +ARG SDK_REF=unknown + +# A-1 §6.3(a) binding constraint: the builder must NOT set or forward +# NEXT_PUBLIC_AGGKIT_PROXY. It is a build-time-only affordance for local dev / +# Cloudflare Workers builds (baked into the JS bundle by Next at build time); +# in a container the mounted config.json is the only configuration mechanism +# (see entrypoint.sh). build:production also copies .env.production over +# .env.local and deletes .env.staging -- .env.production is deliberately +# empty of NEXT_PUBLIC_* values (see its own header comment), so this build +# never bakes in a WalletConnect/Reown project id, an aggkit-proxy override, +# or any E2E value. The project id is a RUNTIME value instead: it comes from +# the mounted config.json's `walletConnect.projectId` field (see +# entrypoint.sh's structural validation and docs/config.md), settable per +# container instance with no rebuild. A container run with no real project +# id mounted (or the baked default's placeholder) runs Reown AppKit in the +# documented degraded `basic: true` mode -- see app/context/wallet.tsx. +RUN pnpm run build:production + +# ============================================================================= +# Stage: runtime +# ============================================================================= +FROM nginx:alpine AS runtime + +# TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 +ARG SDK_REF=unknown +LABEL org.agglayer.sdk.revision="${SDK_REF}" + +# jq is used by entrypoint.sh for a structural (non-schema) validation check +# of a mounted config.json. This runtime image has no Node, so the app's own +# Zod schema (config/configSchema.mjs) cannot run here -- see entrypoint.sh's +# header comment for the explicit limitations of the jq-based check. +RUN apk add --no-cache jq + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=app-builder /app/out /usr/share/nginx/html +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# See nginx.conf for the listen directive this matches. +EXPOSE 80 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index fe4b84d..3164724 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Agglayer Dev UI -The Agglayer Dev UI is a configurable, self-hosted bridging interface powered by the [Agglayer SDK](https://github.com/agglayer/sdk) and the [Bridge Hub API](https://github.com/agglayer/agglayer-bridge-hub-api). +The Agglayer Dev UI is a configurable, self-hosted bridging interface powered by the [Agglayer SDK](https://github.com/agglayer/sdk) and the [aggkit bridge service](https://github.com/agglayer/aggkit). ## Quickstart @@ -12,10 +12,25 @@ pnpm install 2) Configure the app: -Edit `config.json` at the project root to set chains, app modes, Bridge Hub API URL, and external links. See [`docs/config.md`](docs/config.md) for the full guide. +Edit `config.json` at the project root to set chains, app modes (each pointed at a single +`aggkitProxy` fronting every network in that mode — see +[`docs/config.md`](docs/config.md#aggkit-bridge-apis-aggkitproxy)), and external links. See +[`docs/config.md`](docs/config.md) for the full guide. For deploying the UI alongside +`aggkit-proxy` (DevOps-facing, incl. rollback), see [`docs/deployment.md`](docs/deployment.md). +To run the app as a self-hosted Docker container instead, see [`docs/docker.md`](docs/docker.md). -Set `NEXT_PUBLIC_PROJECT_ID` (WalletConnect project ID) in `.env.local`. -Optionally set `NEXT_PUBLIC_BRIDGE_HUB_API` to override `config.json` per environment: +`config.json`'s `walletConnect.projectId` field sets the WalletConnect/Reown project ID — +see [`docs/config.md`](docs/config.md#walletconnect--reown-walletconnectprojectid). It is +required but a placeholder is a valid value: leaving it at the checked-in +`YOUR_PROJECT_ID_HERE` (or empty) runs AppKit in a graceful degraded `basic` mode — +injected-wallet connect fully works, only WalletConnect-cloud features (wallet directory +images, remote config) are skipped. Get a real id at https://cloud.reown.com. For local +dev only, `.env.local`'s `NEXT_PUBLIC_PROJECT_ID` overrides it without editing +`config.json`; this override has no effect in a built Docker image (see +[`docs/docker.md`](docs/docker.md)), where `config.json` is the only way to set it, at +runtime, with no rebuild. +Optionally set `NEXT_PUBLIC_AGGKIT_PROXY` to override `config.json`'s `aggkitProxy` per +environment: ```bash cp .env.example .env.local @@ -39,10 +54,108 @@ Open `http://localhost:3000`. - `pnpm run typecheck` — TypeScript checks - `pnpm run test` — unit tests (Vitest) - `pnpm run test:e2e` — end-to-end tests (Playwright) +- `node scripts/kurtosisDevnetEnv.mjs [--enclave cdk]` — bring up devnet config from a running Kurtosis enclave (see below) + +### Kurtosis devnet bring-up + +Against a running Kurtosis `cdk` enclave (see the `kurtosis-cdk` repo, `bridge_ui_backend: aggkit` mode), this script eliminates manual port copying: + +```bash +node scripts/kurtosisDevnetEnv.mjs --enclave cdk +``` + +It resolves the enclave's ephemeral ports live (`kurtosis port print`), then: + +- writes `config.json`'s `chains` with `DEVNET_L1`, `DEVNET_L2_001`, `DEVNET_L2_002` (for 2-L2 enclaves) and `appModes.configs.devnet` with the live L1/L2 RPC URLs, chain ids (read via `eth_chainId`), and bridge address (verified deployed via `eth_getCode`); +- writes `.env.local` with `NEXT_PUBLIC_AGGKIT_PROXY` pointed at the enclave's CORS-safe haproxy proxy (a single URL, fanned out over every network at runtime, routed via `?network_id=`), `E2E_PRIVATE_KEY` set to a pre-funded devnet key, and for 2-L2 enclaves: `E2E_TO_CHAIN_ID` and `E2E_L2_CHAIN_IDS`. + +Re-run it after every enclave recreate (ports are ephemeral). It fails with a clear error if the named enclave doesn't exist. Only supports Kurtosis-based devnets. + +If your wallet shows a stuck/pending transaction or wrong balances after an enclave reset, see the kurtosis-cdk guide's [Troubleshooting § "After an enclave reset: recovering your wallet and UI"](https://github.com/0xPolygon/kurtosis-cdk/blob/feat/aggkit-bridge-ui-backend/docs/docs/advanced/aggkit-2l2-with-bridge-ui.md#after-an-enclave-reset-recovering-your-wallet-and-ui). ## Testing -Playwright E2E runs against real testnet infrastructure using a funded E2E wallet. +Playwright E2E defaults to the local Kurtosis `cdk` devnet (aggkit backend) +using a funded devnet key -- see "Kurtosis devnet bring-up" above. Set +`E2E_BACKEND_MODE=testnet` in `.env.local` to instead run against real +Sepolia/Bokuto testnet infrastructure (the previous default before the aggkit devnet backend). +All backend-specific values (chain ids, ERC20 address, bridge amounts, +timeouts) are resolved by `app/constants/e2e.ts` per mode -- see that file +for the exact defaults and overrides, and `.env.example` for the full list +of E2E-only env vars. + +### CI-devnet quick start (vendored compose, no Kurtosis toolchain) + +`.github/workflows/e2e.yaml` doesn't bring up a live Kurtosis enclave at all -- it +vendors a frozen, self-contained snapshot bundle (`tests/devnet/`, see +[`tests/devnet/README.md`](tests/devnet/README.md)) produced by +`0xPolygon/kurtosis-cdk`'s [anvil-flavor +snapshot](https://github.com/0xPolygon/kurtosis-cdk/blob/feat/aggkit-bridge-ui-backend/docs/docs/advanced/anvil-devnet-snapshot.md) +and brings it up with plain `docker compose`. This is the fastest way to get a real +bridging backend locally too -- it complements the live Kurtosis bring-up above rather +than replacing it (the live enclave is still the right tool when iterating on +kurtosis-cdk itself, e.g. testing a params-file change); the vendored bundle is for +"just run the suite" with nothing to build or configure: + +```bash +# Bring up all 9 services (anvil x3, agglayer, aggkit x2 -- each running the +# bridge as a component, not a separate service --, aggkit-proxy, haproxy), +# self-contained -- no bind mounts, no volumes, no Kurtosis CLI. Pulls 9 +# public images from GHCR on first run. The baked dev-ui container itself is +# NOT started (it's behind the `devui` compose profile, manual use only -- +# pass `--profile devui` to also bring it up). +docker compose -f tests/devnet/docker-compose.yml up -d --wait + +# Replicates kurtosisDevnetEnv.mjs's readiness probes against the fixed +# compose ports (chainId per route, bridge bytecode per chain, sync-status +# both-sides-synced across the 3 network ids). Boot-to-ready is ~24s once +# the images are local; the FIRST run also pulls ~2.9GB, so pass a longer +# timeout than the 120s default for a cold start. +node scripts/devnetReady.mjs --timeout-ms 300000 + +# Point config.json at the vendored bundle's fixed 127.0.0.1:8555 URLs +# (committed config.json ships in testnet mode -- see above). +cp config/config.ci.devnet.json config.json +pnpm run validate:config + +# playwright.config.ts THROWS unless these are set, and globalSetup falls +# back to deploying its own ERC20 via `sudo docker run` without +# E2E_ERC20_ADDRESS. Chain ids/ERC20 address are the exact literals +# .github/workflows/e2e.yaml uses -- all public devnet fixtures, never +# secrets. Keep them in sync with tests/devnet/summary.json (the workflow has +# a step that asserts this). NEXT_PUBLIC_AGGKIT_PROXY is this config surface +# cleanup's field -- .github/workflows/e2e.yaml (owned by a later step in this +# migration) still sets the retired NEXT_PUBLIC_AGGKIT_BRIDGE_APIS literal as +# of this writing; use NEXT_PUBLIC_AGGKIT_PROXY here regardless, since +# playwright.config.ts now requires it. +export E2E_PRIVATE_KEY='0x12d7de8621a77640c9241b2595ba78ce443d05e94090365ab3bb5e19df82c625' +export NEXT_PUBLIC_PROJECT_ID='ci-e2e' +export NEXT_PUBLIC_AGGKIT_PROXY='http://127.0.0.1:8555/aggkitapi' +export E2E_FROM_CHAIN_ID='271828' +export E2E_TO_CHAIN_ID='20201' +export E2E_L2_CHAIN_IDS='20201,20202' +export E2E_ERC20_ADDRESS="$(jq -r .erc20_address tests/devnet/summary.json)" + +# NOTE: playwright.config.ts calls loadEnvConfig(), so a stale .env.local +# left behind by a previous `scripts/kurtosisDevnetEnv.mjs` run OVERRIDES +# the values above and will silently point the suite at a dead ephemeral +# enclave port. Remove or rename it first if you have one. + +# Run the suite (same invocations e2e.yaml uses). +pnpm exec playwright test tests/e2e/preflight.spec.ts +pnpm exec playwright test tests/bridge + +# Revert config.json (never commit the devnet fixture over the committed +# testnet-mode config), then tear down. +git checkout -- config.json +docker compose -f tests/devnet/docker-compose.yml down -v +``` + +The vendored bundle is pinned by immutable tag and drifts from kurtosis-cdk's working +branch over time by design -- see [`tests/devnet/README.md`](tests/devnet/README.md) +for the regenerate-and-bump procedure (dispatch the kurtosis-cdk workflow, download the +new artifact, repoint the compose defaults at the published GHCR tag, bump +`E2E_ERC20_ADDRESS`). Security and ops notes: @@ -50,22 +163,145 @@ Security and ops notes: - Keep the E2E wallet balance minimal and treat it as disposable. - Never deploy with `NEXT_PUBLIC_E2E_ENABLED=true` in any public/shared environment. - Set only `E2E_PRIVATE_KEY` in `.env.local` for tests. Do not set `NEXT_PUBLIC_E2E_PRIVATE_KEY` directly. -- Testnet spend accumulates over CI runs; periodically top up the E2E wallet. +- Testnet-mode spend accumulates over CI runs; periodically top up the E2E wallet. Required `.env.local` variables for E2E: - `E2E_PRIVATE_KEY` -- `NEXT_PUBLIC_PROJECT_ID` -- `NEXT_PUBLIC_BRIDGE_HUB_API` +- `NEXT_PUBLIC_AGGKIT_PROXY` (devnet mode: written by `scripts/kurtosisDevnetEnv.mjs`) + +(`NEXT_PUBLIC_PROJECT_ID` is not used in E2E mode — `app/context/wallet.tsx` skips +`createAppKit` entirely under `NEXT_PUBLIC_E2E_ENABLED`, using a mocked wallet provider instead.) + +**2-L2 devnet E2E-specific variables** (both set by `kurtosisDevnetEnv.mjs` for 2-L2 enclaves): + +- `E2E_TO_CHAIN_ID` — destination chain ID for L2→L2 tests (e.g., `20202` for L2-2) +- `E2E_L2_CHAIN_IDS` — comma-separated list of all L2 chain IDs (e.g., `20201,20202`) + +Devnet mode also runs a Playwright `globalSetup` (`tests/e2e/globalSetup.ts`) +before any spec: it resolves a usable ERC20 for +`tests/bridge/erc20-approve-bridge.spec.ts`, reusing a known-good devnet +token if it's still live on the enclave, or deploying a fresh minimal ERC20 +(via `forge create`, dockerized the same way as the host's glibc-incompatible +`cast`/`forge` binaries) otherwise. Set `E2E_ERC20_ADDRESS` to skip this and +use a specific address instead. + +`tests/bridge/partial-failure.spec.ts` (the S8 partial-failure notice) is currently +**skipped**: it used to run under a second, isolated Playwright project with its own Next +dev server and an extra bogus network id injected into the now-retired +`NEXT_PUBLIC_AGGKIT_BRIDGE_APIS`. That per-network override was the only mechanism able to +point one specific network at a bad URL while leaving the rest of the mode alone; +`NEXT_PUBLIC_AGGKIT_PROXY`'s single-value fan-out cannot express that. See the spec file's +top comment for the full explanation and follow-up. + +### Devnet-Specific Tests + +**`tests/bridge/claim-autoclaim.spec.ts`** — L1→L2-1 auto-claim +- Devnet-only (skipped in testnet mode) +- Asserts the deposit reaches Completed (CLAIMED) state via external L1ToL2BridgeDetector +- This devnet's aggkit build auto-claims such deposits within seconds (~67s observed latency) +- See the file's top comment for why autoclaim is expected behavior here + +**`tests/bridge/l2-to-l2.spec.ts`** — L2-1→L2-2 auto-claim +- Devnet-only, 2-L2 mode only +- Asserts the deposit reaches Completed (CLAIMED) state via external L2ToLxBridgeDetector +- Timeout budget: `E2E_L2_TO_L2_CLAIM_TIMEOUT_MS` (300s default, see notes on certificate cadence below) +- **Certificate cadence note:** AggKit's aggsender enforces `MinimumNewCertificateInterval: 5m0s` between certificate windows. A deposit submitted just after a window closes can wait up to 5 minutes for the next one. The 300s timeout leaves **zero margin** against unlucky timing. If timeouts occur, budget 7–8 minutes instead, or keep L1 block production fast enough to stay ahead of L2 block height. + +**`tests/bridge/manual-claim.spec.ts`** — L2-1→L1 manual claim +- Devnet-only, required for both single-L2 and 2-L2 enclaves +- Now funds its own L1→L2-1 top-up deposit (via `claim-autoclaim.spec.ts` flow) before testing the L2→L1 withdrawal +- This makes the spec independent of run order and other specs' side effects +- Typical latency: ~3.9–8.3 minutes until "Ready to claim" (certificate settlement + L1 info tree sync) +- **Shared wallet state note:** The E2E wallet's balance and the enclave's transaction/certificate history accumulate across test runs on the same enclave. This spec now tops up its own balance, so isolation is preserved; future specs requiring absolute balance assertions should account for this accumulation. + +### E2E Specs by Mode + +| Spec | Mode | Assertion | +|------|------|-----------| +| smoke | all | Page loads, wallet connects | +| token-selector | all | Token selector shows symbol + balance | +| native-bridge | all | Native token bridge works | +| erc20-approve-bridge | all | ERC20 approval + bridge works | +| claim-autoclaim | devnet | L1→L2 autoclaim reaches Completed | +| l2-to-l2 | devnet (2-L2 only) | L2→L2 autoclaim reaches Completed | +| manual-claim | devnet | L2→L1 manual claim reaches Completed | +| partial-failure | devnet | UI partial-failure notice surfaces | +| preflight | devnet | Chain sync-status, E2E wallet funded | +| tracker | devnet | Bridge tracker progress bar + detail timeline render correctly | + +## Bridge Tracking + +Each non-`CLAIMED` transaction row polls aggkit's `tracker/v1` API (via the SDK's +`AggkitBridgeAggregator.getBridgeTracking`, `app/hooks/useBridgeTracking.ts`) every 5 seconds and +renders its step-by-step progress: + +- **`app/components/transactions/trackerProgressBar.tsx`** — a row of dots + connector lines in + the activity list, one dot per expected step of that bridge's route: 4 steps for L1→L2, 6 for + L2→L1, 7 for L2→L2. +- **`app/components/transactions/trackerDetail.tsx`** — the full timeline (same dots, plus label, + status, start/end dates, and a per-step result detail) shown in the transaction details modal. + +**Dot colors** (`DOT_CLASSES` in `trackerProgressBar.tsx`): + +| Status | Meaning | Style | +|---|---|---| +| `done` | Step complete | Filled green | +| `inProgress` | Step currently running | Filled blue, pulsing | +| `pending` | Step not started yet | Hollow grey ring | +| `error` | Step failed (tracker retries in the background) | Filled red | + +**Render rules:** +- Nothing renders while `all_steps` is `null` — either the tracker hasn't resolved the bridge's + route yet, or it has given up entirely (see the SDK's `getBridgeTracking` docs for terminal + semantics). +- Both the progress bar and the detail view have an explicit guard on `transaction.status === + 'CLAIMED'`: `useBridgeTracking` disables its query once a row is `CLAIMED`, but disabling a + react-query query doesn't clear already-cached data, so a row that transitions live from + non-`CLAIMED` → `CLAIMED` while mounted would otherwise keep showing its last-fetched, all-`done` + steps. The status check is the actual hide signal, not the presence of `data`. +- If the tracker gives up resolving the transaction at all (`tracking_status === 'error'` with + `bridge_status: null`), the detail view shows a "Tracking unavailable" info alert instead of a + timeline. +- **The tracker's `WaitingClaim` step and the row's `READY_TO_CLAIM` status (which gates the + "Claim tokens" button, `AggkitBridgeAggregator.toTransaction`) are driven by different + pipelines and are NOT synchronized** — see upstream + [aggkit#1786](https://github.com/agglayer/aggkit/issues/1786) (OPEN); measured live on a + devnet L2→L1 bridge, the tracker entered `WaitingClaim` at T+18s while the claim proof was not + actually servable until T+40.5s. The tracker's certificate-settlement resolver reads the settlement tx's + own L1 receipt directly; the row's status (and the claim mutation's own proof fetch, + `useClaimExecution.ts`) depend on aggkit's bridge-service completing its own, separate + L1-info-tree sync. The tracker routinely enters `WaitingClaim` some seconds to tens of seconds + before a claim is actually possible — this is expected, upstream (aggkit) behavior, not a + dev-ui bug, and the button's gating on `READY_TO_CLAIM` (not on the tracker step) is + intentionally the more conservative, correct source of truth. This is why `WaitingClaim`'s copy + says "Finalizing claim data…" rather than "Ready". + +**Step → copy mapping** (`app/utils/trackerSteps.ts`, keyed on the wire's `step_name`): + +| `step_name` | Label | +|---|---| +| `WaitingGERUpdate` | Waiting for the global exit root update on L1 | +| `WaitingLERUpdate` | Waiting for the local exit root update on `{source}` | +| `PendingInclusion` | Waiting for inclusion in an agglayer certificate | +| `CertificatePending` | Waiting for the certificate to settle | +| `WaitL1SettledGER` | Waiting for settlement to confirm on L1 | +| `WaitingGERInjection` | Waiting for the exit root to reach `{destination}` | +| `WaitingClaim` | Finalizing claim data for `{destination}` | +| `Claimed` | Claimed | + +Tooltips (progress bar) and step labels (detail view) fall back to "the source"/"the destination" +when chain metadata hasn't resolved yet. + +**Testids for E2E authors** (`data-test-id`, see `tests/bridge/models/bridge-page.ts`): -Hardcoded E2E test constants: +- `tracker-progress` — the progress bar container for a transaction row +- `tracker-step-` — one dot in the progress bar (`data-step` = `step_name`, `data-status` = + step status) +- `tracker-detail` — the tracker section of the transaction details modal +- `tracker-detail-step-` — one timeline entry in the detail view -- from chain: Sepolia (`11155111`) -- to chain: Bokuto (`737373`) -- ERC20 address: Sepolia USDC (`0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238`) -- ERC20 metadata (symbol, name, decimals) is fetched on-chain from the contract -- native bridge amount: `0.00001` -- ERC20 bridge amount: `0.01` +Covered by `tests/bridge/tracker.spec.ts`. ## Configuration diff --git a/app/__fixtures__/tracker.ts b/app/__fixtures__/tracker.ts new file mode 100644 index 0000000..05d5aea --- /dev/null +++ b/app/__fixtures__/tracker.ts @@ -0,0 +1,360 @@ +import type { AggkitTrackingData } from '@agglayer/sdk'; + +// Trimmed local copies of the aggkit tracker fixtures captured against the +// devnet enclave (see sdk/src/aggkit/__fixtures__/tracker_*.json in the +// sibling SDK repo) -- kept here so dev-ui's own unit tests (S9) don't reach +// across repos. Field values are the real captured ones; only comments/ +// whitespace differ from the source JSON. + +// tracking_status 'registered': all_steps still null, tracker hasn't +// resolved the route yet. +export const registeredFixture: AggkitTrackingData = { + tracking_status: 'registered', + network_id: 1, + tx_hash: '0xdeadbeef00000000000000000000000000000000000000000000000000000000', + bridge_status: null, + step_index: null, + all_steps: null, + error: { + error_type: 0, + error_type_string: 'transient', + retry_count: 1, + description: [ + 'network=1/tx=0xdeadbeef00000000000000000000000000000000000000000000000000000000 does not exist on the network' + ] + } +}; + +// L1->L2 mid-flight: 4 steps, step_index 2 (WaitingClaim) inProgress. +export const l1l2RunningFixture: AggkitTrackingData = { + tracking_status: 'running', + network_id: 0, + tx_hash: '0x64b65138996aae61811dac45f10c2baddbf0ab5aae9ef587766b92a23c85791e', + bridge_status: { + bridge_type: 'L1->L2', + block_number: 519, + log_index: 0, + block_timestamp: 1786113909, + event: { + leaf_type: 'Asset', + origin_network: 0, + origin_address: '0x0000000000000000000000000000000000000000', + destination_network: 1, + destination_address: '0xa0b4b0c6314b6b028adf7c787eca150add9e1ec0', + amount: '1000000000000000000', + deposit_count: 3 + } + }, + step_index: 2, + all_steps: [ + { + step_index: 0, + step_name: 'WaitingGERUpdate', + status: 'done', + start_date: '2026-08-07T14:45:14.940426998Z', + end_date: '2026-08-07T14:45:14.942687479Z', + result: { + l1_info_tree_index: 6, + ger: '0x6c670cb382e5202b19eae5ae3d61491f38c5d4806a4d154410d5370816fbf090', + mer: '0xaa7f2b3bcb3d6303a1af1d4b4322d197db525e63e4472ef50c352b316de9598b', + rer: '0x226608c15eee1d684ad841ee83dc549bc9c3f25ccff4a102d8065aeb90bc6c1c', + block_number: 519, + block_timestamp: 1786113909, + log_index: 2 + } + }, + { + step_index: 1, + step_name: 'WaitingGERInjection', + status: 'done', + start_date: '2026-08-07T14:45:14.942687479Z', + end_date: '2026-08-07T14:45:56.844525693Z', + result: { ger: '0x6c670cb382e5202b19eae5ae3d61491f38c5d4806a4d154410d5370816fbf090' } + }, + { + step_index: 2, + step_name: 'WaitingClaim', + status: 'inProgress', + start_date: '2026-08-07T14:45:56.844525693Z' + }, + { + step_index: 3, + step_name: 'Claimed', + status: 'pending' + } + ], + error: null +}; + +// L1->L2 finished: same route, all 4 steps done (terminal). +export const l1l2FinishedFixture: AggkitTrackingData = { + ...l1l2RunningFixture, + tracking_status: 'finished', + step_index: 3, + all_steps: [ + l1l2RunningFixture.all_steps![0], + l1l2RunningFixture.all_steps![1], + { + step_index: 2, + step_name: 'WaitingClaim', + status: 'done', + start_date: '2026-08-07T14:45:56.844525693Z', + end_date: '2026-08-07T14:46:06.844712083Z', + result: { + claim_tx: '0x178eed25e7a70d088367b81879bffb7fa800e3f23789d8a11bd05ae78505e3f3', + block_number: 909 + } + }, + { + step_index: 3, + step_name: 'Claimed', + status: 'done', + start_date: '2026-08-07T14:46:06.844712083Z', + end_date: '2026-08-07T14:46:06.844712083Z' + } + ] +}; + +// L2->L1 finished: 6 steps, all done -- carries a certificate id +// (PendingInclusion/CertificatePending) and a claim tx (WaitingClaim), used +// to assert the modal detail renders both result shapes. +export const l2l1FinishedFixture: AggkitTrackingData = { + tracking_status: 'finished', + network_id: 1, + tx_hash: '0xcfbdc931acce665da204150bc025cd76cdbe5566578abaa1ec4ef236fa5c8009', + bridge_status: { + bridge_type: 'L2->L1', + block_number: 826, + log_index: 0, + block_timestamp: 1786113875, + event: { + leaf_type: 'Asset', + origin_network: 0, + origin_address: '0x0000000000000000000000000000000000000000', + destination_network: 0, + destination_address: '0xa0b4b0c6314b6b028adf7c787eca150add9e1ec0', + amount: '50000000000000000', + deposit_count: 1 + } + }, + step_index: 5, + all_steps: [ + { + step_index: 0, + step_name: 'WaitingLERUpdate', + status: 'done', + start_date: '2026-08-07T14:44:45.774402045Z', + end_date: '2026-08-07T14:44:45.776405692Z', + result: { + network_id: 1, + ler: '0x3ba1af1eba0fbefdbd0b741efc3d805119a3b192663784bf8974f7dc27d3f41e', + block_number: 826 + } + }, + { + step_index: 1, + step_name: 'PendingInclusion', + status: 'done', + start_date: '2026-08-07T14:44:45.776405692Z', + end_date: '2026-08-07T14:44:45.776405692Z', + result: { + certificate_id: '0xfd92b4854c0364e0a9e8e3bade6bbcc0873a6be917321320d7e2f24e24f7131f', + new_ler: '0x3ba1af1eba0fbefdbd0b741efc3d805119a3b192663784bf8974f7dc27d3f41e', + previous_ler: '0xfd107fe3ba1c4de7139e4ca5d666ec90a7df9698c926f585611eac31ce13192f' + } + }, + { + step_index: 2, + step_name: 'CertificatePending', + status: 'done', + start_date: '2026-08-07T14:44:45.776405692Z', + end_date: '2026-08-07T14:45:06.844644141Z', + result: { + certificate_id: '0xfd92b4854c0364e0a9e8e3bade6bbcc0873a6be917321320d7e2f24e24f7131f', + status: 4, + status_string: 'Settled', + settlement_tx_hash: '0x1bf33df3df7e20de949cb8e8dd664c1a928a009d8af2692894a7df9fdc6a76e7' + } + }, + { + step_index: 3, + step_name: 'WaitL1SettledGER', + status: 'done', + start_date: '2026-08-07T14:45:06.844644141Z', + end_date: '2026-08-07T14:45:06.844644141Z', + result: { + tx_hash: '0x1bf33df3df7e20de949cb8e8dd664c1a928a009d8af2692894a7df9fdc6a76e7', + block_number: 511, + ger: '0xe95cc8832a43e15f02052ae8d436589fc0ad89643e4a7a0f1af7242016f173b7', + l1_info_tree_index: 5, + has_verify_batches_trusted_aggregator: true, + has_update_l1_info_tree: true, + has_update_l1_info_tree_v2: true + } + }, + { + step_index: 4, + step_name: 'WaitingClaim', + status: 'done', + start_date: '2026-08-07T14:45:06.844644141Z', + end_date: '2026-08-07T14:47:26.844119964Z', + result: { + claim_tx: '0x51d247094346142f780378bfb82a1e54b152db5d4035ec4e6937c531c47b0145', + block_number: 583 + } + }, + { + step_index: 5, + step_name: 'Claimed', + status: 'done', + start_date: '2026-08-07T14:47:26.844119964Z', + end_date: '2026-08-07T14:47:26.844119964Z' + } + ], + error: null +}; + +// L2->L2 mid-flight: 7 steps, step_index 4 (WaitingGERInjection) inProgress. +export const l2l2RunningFixture: AggkitTrackingData = { + tracking_status: 'running', + network_id: 1, + tx_hash: '0x66a20ab10e92748f7ee30f9a487e262a673b790df365bf3067a59c8b71fb2fe8', + bridge_status: { + bridge_type: 'L2->L2', + block_number: 1143, + log_index: 0, + block_timestamp: 1786114192, + event: { + leaf_type: 'Asset', + origin_network: 0, + origin_address: '0x0000000000000000000000000000000000000000', + destination_network: 2, + destination_address: '0x4e0ff24158eeac22ed9abfe3abbbda6d6a609fe0', + amount: '20000000000000000', + deposit_count: 2 + } + }, + step_index: 4, + all_steps: [ + { + step_index: 0, + step_name: 'WaitingLERUpdate', + status: 'done', + start_date: '2026-08-07T14:49:59.046296597Z', + end_date: '2026-08-07T14:49:59.048885846Z', + result: { + network_id: 1, + ler: '0x70790a490a3fd74bd69a3321fe08acda9ec621054d0a88b559992db0a625bbfb', + block_number: 1143 + } + }, + { + step_index: 1, + step_name: 'PendingInclusion', + status: 'done', + start_date: '2026-08-07T14:49:59.048885846Z', + end_date: '2026-08-07T14:49:59.048885846Z', + result: { + certificate_id: '0xe56cb2819d2eeaa33113b54ede35f061e334eb74f47b783443326127336e29c2', + new_ler: '0x70790a490a3fd74bd69a3321fe08acda9ec621054d0a88b559992db0a625bbfb', + previous_ler: '0x3ba1af1eba0fbefdbd0b741efc3d805119a3b192663784bf8974f7dc27d3f41e' + } + }, + { + step_index: 2, + step_name: 'CertificatePending', + status: 'done', + start_date: '2026-08-07T14:49:59.048885846Z', + end_date: '2026-08-07T14:50:06.843777138Z', + result: { + certificate_id: '0xe56cb2819d2eeaa33113b54ede35f061e334eb74f47b783443326127336e29c2', + status: 4, + status_string: 'Settled', + settlement_tx_hash: '0x9016f9365aca01c8da56e2b97d2b1f53e7758b5dd0ed02d303bab46f242ee5a0' + } + }, + { + step_index: 3, + step_name: 'WaitL1SettledGER', + status: 'done', + start_date: '2026-08-07T14:50:06.843777138Z', + end_date: '2026-08-07T14:50:06.843777138Z', + result: { + tx_hash: '0x9016f9365aca01c8da56e2b97d2b1f53e7758b5dd0ed02d303bab46f242ee5a0', + block_number: 663, + ger: '0x6989b12606017b91d6defe2184415b5071fb7004e8daee4b3b82efd5e54045ff', + l1_info_tree_index: 7, + has_verify_batches_trusted_aggregator: true, + has_update_l1_info_tree: true, + has_update_l1_info_tree_v2: true + } + }, + { + step_index: 4, + step_name: 'WaitingGERInjection', + status: 'inProgress', + start_date: '2026-08-07T14:50:06.843777138Z' + }, + { + step_index: 5, + step_name: 'WaitingClaim', + status: 'pending' + }, + { + step_index: 6, + step_name: 'Claimed', + status: 'pending' + } + ], + error: null +}; + +// SYNTHESIZED (not a captured fixture -- per S9 context pack, no captured +// fixture has a step-level error, so this is l2l2RunningFixture with step 4 +// (WaitingGERInjection) turned into a step-level `error`. Per aggkit +// v0.11.0-rc4 (bridgetracker/domain/tracking_data.go: TrackingStatus derives +// from the step at step_index, so a step in `error` makes tracking_status +// 'error'; API.md's WebSocket section spells out the same), the top-level +// tracking_status here is 'error' -- NOT 'running' -- while bridge_status +// stays populated. That populated bridge_status is exactly what +// distinguishes this retryable state from the tracker's giving-up terminal +// (tracking_status 'error' with bridge_status null, errorGiveupFixture +// below): per useBridgeTracking.ts's isTrackingTerminal, polling must +// continue here. +export const l2l2RunningStepErrorFixture: AggkitTrackingData = { + ...l2l2RunningFixture, + tracking_status: 'error', + all_steps: l2l2RunningFixture.all_steps!.map((step) => + step.step_index === 4 + ? { + ...step, + status: 'error' as const, + error: { + error_type: 0 as const, + error_type_string: 'transient' as const, + retry_count: 2, + description: ['ger injection not yet observed on destination network'] + } + } + : step + ) +}; + +// The giving-up terminal: tracker could not resolve the bridge at all +// (tx not found / not a bridge tx). bridge_status and all_steps stay null. +export const errorGiveupFixture: AggkitTrackingData = { + tracking_status: 'error', + network_id: 1, + tx_hash: '0xdeadbeef00000000000000000000000000000000000000000000000000000000', + bridge_status: null, + step_index: null, + all_steps: null, + error: { + error_type: 2, + error_type_string: 'exhausted', + retry_count: 5, + description: [ + 'network=1/tx=0xdeadbeef00000000000000000000000000000000000000000000000000000000 does not exist on the network' + ] + } +}; diff --git a/app/components/appConfigGate.test.tsx b/app/components/appConfigGate.test.tsx new file mode 100644 index 0000000..9a9de72 --- /dev/null +++ b/app/components/appConfigGate.test.tsx @@ -0,0 +1,131 @@ +import type { JsonConfig } from '@/app/types/config'; + +import { render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock only the fetch adapter -- everything downstream of it (normalization, +// the module store, AppModeProvider/WalletProvider/etc.) is the real +// implementation. This lets the "does not mount wallet providers" case below +// render the actual app/providers.tsx composition rather than a stand-in. +vi.mock('@/app/configLoader', () => ({ + fetchAppConfig: vi.fn() +})); + +import { AppConfigGate } from '@/app/components/appConfigGate'; +import { getAppConfig, isAppConfigReady, resetAppConfig } from '@/app/config'; +import { fetchAppConfig } from '@/app/configLoader'; +import { Providers } from '@/app/providers'; + +const mockedFetchAppConfig = vi.mocked(fetchAppConfig); + +// Same fixture shape used across the A-5 test files (design.md §4/§7). +const chain = (overrides: Partial = {}) => ({ + id: 1, + name: 'Chain', + rpcUrl: 'https://rpc.example', + explorerUrl: 'https://explorer.example', + currency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + iconUrl: 'https://icon.example/icon.svg', + networkId: 0, + isTestnet: true, + eta: 1, + ...overrides +}); + +const validConfig: JsonConfig = { + walletConnect: { projectId: 'test-project-id' }, + externalLinks: { privacyPolicy: '', termsOfUse: '', contactSupport: '' }, + chains: { + DEVNET_L1: chain({ id: 271828, name: 'Devnet L1', networkId: 0 }), + DEVNET_L2_001: chain({ id: 20201, name: 'Devnet L2-001', networkId: 1 }), + DEVNET_L2_002: chain({ id: 20202, name: 'Devnet L2-002', networkId: 2 }) + }, + appModes: { + default: 'devnet', + configs: { + devnet: { + label: 'Devnet', + bridgeAddress: '0xC8cbEBf950B9Df44d987c8619f092beA980fF038', + aggkitProxy: 'https://aggkit-proxy.example/aggkitapi', + chainKeys: ['DEVNET_L1', 'DEVNET_L2_001', 'DEVNET_L2_002'], + defaultFromChainKey: 'DEVNET_L1', + defaultToChainKey: 'DEVNET_L2_001' + } + } + } +} as JsonConfig; + +beforeEach(() => { + resetAppConfig(); + mockedFetchAppConfig.mockReset(); +}); + +describe('AppConfigGate — pending (A-5 item 5)', () => { + it('renders the designed loading placeholder and withholds children', () => { + // A promise that never settles during the test -- the gate's first (and, + // here, only) render. + mockedFetchAppConfig.mockReturnValue(new Promise(() => {})); + + const { container } = render( + +
+ + ); + + expect(container.querySelector('[data-test-id="app-config-loading"]')).toBeInTheDocument(); + expect(container.querySelector('[data-test-id="gated-child"]')).toBeNull(); + expect(container.querySelector('[data-test-id="app-config-error"]')).toBeNull(); + }); +}); + +describe('AppConfigGate — success', () => { + it('populates the app/config.ts store before mounting children', async () => { + mockedFetchAppConfig.mockResolvedValue(validConfig); + + const { container } = render( + +
+ + ); + + await waitFor(() => + expect(container.querySelector('[data-test-id="gated-child"]')).toBeInTheDocument() + ); + + expect(container.querySelector('[data-test-id="app-config-loading"]')).toBeNull(); + // The store is guaranteed populated by the time this child is visible -- + // this is the invariant every accessor in app/config.ts relies on. + expect(isAppConfigReady()).toBe(true); + expect(getAppConfig().defaultAppMode).toBe('devnet'); + }); +}); + +describe('AppConfigGate — failure, exercised through the real app/providers.tsx tree (A-5 item 5)', () => { + it('renders the designed error screen and mounts neither the gated children nor the wallet/app-mode providers', async () => { + mockedFetchAppConfig.mockRejectedValue( + new Error('config.json schema validation failed:\n- chains: configure at least one chain') + ); + + const { container } = render( + +
should never render
+
+ ); + + await waitFor(() => + expect(container.querySelector('[data-test-id="app-config-error"]')).toBeInTheDocument() + ); + + // The error text is the operator-facing diagnostic from parseConfigOrThrow. + expect(container.textContent).toContain('config.json schema validation failed:'); + expect(container.textContent).toContain('chains: configure at least one chain'); + + // AppModeProvider and WalletProvider are *inside* AppConfigGate's + // children in app/providers.tsx -- if the gate is doing its job, neither + // they nor the app's own children ever render, and the config store + // stays empty. + expect(container.querySelector('[data-test-id="marker"]')).toBeNull(); + expect(isAppConfigReady()).toBe(false); + }); +}); diff --git a/app/components/appConfigGate.tsx b/app/components/appConfigGate.tsx new file mode 100644 index 0000000..e090203 --- /dev/null +++ b/app/components/appConfigGate.tsx @@ -0,0 +1,92 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { Button } from '@/app/components/ui/button'; +import { Spinner } from '@/app/components/ui/spinner'; +import { initAppConfig } from '@/app/config'; +import { fetchAppConfig } from '@/app/configLoader'; +import { useEffect, useState } from 'react'; + +// Outermost node of app/providers.tsx (design.md §3). Fetches /config.json +// once per mount (plus once per explicit Retry) and populates app/config.ts's +// module store *before* any child renders, so every accessor +// (getExternalLinks, getAppModeConfig, ...) is safe to call unconditionally +// once children are reached. +// +// The first render on both server-prerender and client-hydration is always +// `pending` -- no config read, no `typeof window` branch, no Date.now()/ +// Math.random() -- so there is no hydration mismatch under `output: 'export'` +// (design.md §3.5). The fetch only ever happens in an effect, which never +// runs during prerender. +type ConfigLoadState = + | { status: 'pending' } + | { status: 'ready' } + | { status: 'error'; message: string }; + +const toConfigErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +export const ConfigLoadingScreen = () => ( +
+ +

Loading configuration…

+
+); + +interface ConfigErrorScreenProps { + message: string; + onRetry: () => void; +} + +export const ConfigErrorScreen = ({ message, onRetry }: ConfigErrorScreenProps) => ( +
+

Configuration failed to load

+
+      {message}
+    
+ +
+); + +export const AppConfigGate = ({ children }: { readonly children: ReactNode }) => { + const [attempt, setAttempt] = useState(0); + const [state, setState] = useState({ status: 'pending' }); + + useEffect(() => { + let cancelled = false; + setState({ status: 'pending' }); + + fetchAppConfig() + .then((configJson) => { + if (cancelled) return; + // Populate the store BEFORE flipping to 'ready' so every accessor is + // safe to call the instant children render. + initAppConfig(configJson); + setState({ status: 'ready' }); + }) + .catch((error: unknown) => { + if (cancelled) return; + setState({ status: 'error', message: toConfigErrorMessage(error) }); + }); + + return () => { + cancelled = true; + }; + }, [attempt]); + + if (state.status === 'pending') return ; + if (state.status === 'error') { + return setAttempt((n) => n + 1)} />; + } + + return <>{children}; +}; diff --git a/app/components/bridge/bridgeFromSection.tsx b/app/components/bridge/bridgeFromSection.tsx index 517bb19..1a96d8d 100644 --- a/app/components/bridge/bridgeFromSection.tsx +++ b/app/components/bridge/bridgeFromSection.tsx @@ -64,6 +64,7 @@ export const BridgeFromSection = ({ options={chainOptions} selectedValue={selectedChainId.toString()} onSelect={(option) => onSelectChain(Number(option.value))} + testId="from-chain-selector" /> onSelectChain(Number(option.value))} + testId="to-chain-selector" /> {hasDestinationAddress ? ( diff --git a/app/components/header/constants.ts b/app/components/header/constants.ts index dfda9ab..be85f70 100644 --- a/app/components/header/constants.ts +++ b/app/components/header/constants.ts @@ -1,4 +1,4 @@ -import { EXTERNAL_LINKS } from '@/app/config'; +import { getExternalLinks } from '@/app/config'; import { ROUTES } from '@/app/constants/routes'; interface NavItem { @@ -13,11 +13,14 @@ interface MenuLink { export const NAV_ITEMS: NavItem[] = [ { label: 'Bridge', path: ROUTES.ROOT }, - { label: 'Transactions', path: ROUTES.TRANSACTIONS }, + { label: 'Transactions', path: ROUTES.TRANSACTIONS } ]; -export const MENU_LINKS: MenuLink[] = [ - { label: 'Contact Support', href: EXTERNAL_LINKS.CONTACT_SUPPORT }, - { label: 'Privacy Policy', href: EXTERNAL_LINKS.PRIVACY_POLICY }, - { label: 'Terms of Use', href: EXTERNAL_LINKS.TERMS_OF_USE }, -]; +export const getMenuLinks = (): MenuLink[] => { + const externalLinks = getExternalLinks(); + return [ + { label: 'Contact Support', href: externalLinks.CONTACT_SUPPORT }, + { label: 'Privacy Policy', href: externalLinks.PRIVACY_POLICY }, + { label: 'Terms of Use', href: externalLinks.TERMS_OF_USE } + ]; +}; diff --git a/app/components/header/headerPopover.tsx b/app/components/header/headerPopover.tsx index 61efdba..d6edd4e 100644 --- a/app/components/header/headerPopover.tsx +++ b/app/components/header/headerPopover.tsx @@ -1,6 +1,6 @@ 'use client'; -import { MENU_LINKS } from '@/app/components/header/constants'; +import { getMenuLinks } from '@/app/components/header/constants'; import { ModeSwitch } from '@/app/components/modeSwitch'; import { useClickOutside } from '@/app/hooks/useClickOutside'; import { cn } from '@/app/utils/common'; @@ -38,7 +38,7 @@ export const HeaderPopover = ({ hasModeOptions }: HeaderPopoverProps) => {
)}
- {MENU_LINKS.map((item) => ( + {getMenuLinks().map((item) => (
- {MENU_LINKS.map((item) => ( + {getMenuLinks().map((item) => ( { .filter((value) => value !== mode) .map((value) => ({ value, - label: APP_MODE_CONFIG[value].label + label: getAppModeConfig()[value].label })), [enabledModes, mode] ); diff --git a/app/components/transactions/claimResultModal.tsx b/app/components/transactions/claimResultModal.tsx index 4ecc8c3..2bdc190 100644 --- a/app/components/transactions/claimResultModal.tsx +++ b/app/components/transactions/claimResultModal.tsx @@ -2,8 +2,8 @@ import { Button } from '@/app/components/ui/button'; import { Modal } from '@/app/components/ui/modal'; -import { EXTERNAL_LINKS } from '@/app/config'; -import { CircleCheck, CircleX, ExternalLink } from 'lucide-react'; +import { getExternalLinks } from '@/app/config'; +import { CircleCheck, CircleX, ExternalLink, Info } from 'lucide-react'; import Link from 'next/link'; type ClaimResultStatus = 'success' | 'error'; @@ -27,6 +27,17 @@ const isUserRejection = (message?: string): boolean => { ); }; +// useClaimExecution sets this exact message when its own pre-flight +// `bridge.isClaimed()` check (not a thrown exception) finds the deposit +// already settled -- e.g. raced by an external autoclaimer between the row +// rendering "Ready to claim" and the user's click. Unlike a thrown +// error's `.message` (RPC/viem internals, not meant for end users), this +// string is hand-authored specifically to be user-facing (see +// useClaimExecution.ts), so it's safe -- and more reassuring than the +// generic "contact support" copy -- to show verbatim instead of masking it. +const isAlreadyClaimed = (message?: string): boolean => + message === 'This deposit has already been claimed'; + export const ClaimResultModal = ({ open, onClose, @@ -39,11 +50,13 @@ export const ClaimResultModal = ({ const txExplorerUrl = explorerUrl && claimTxHash ? `${explorerUrl}/tx/${claimTxHash}` : undefined; const userRejected = isUserRejection(errorMessage); - const supportUrl = EXTERNAL_LINKS.CONTACT_SUPPORT; + const alreadyClaimed = isAlreadyClaimed(errorMessage); + const supportUrl = getExternalLinks().CONTACT_SUPPORT; const hasSupportUrl = !!supportUrl?.trim(); const getErrorMessage = () => { if (userRejected) return 'User rejected the request.'; + if (alreadyClaimed) return 'Your funds have already arrived at the destination address.'; if (!hasSupportUrl) return 'Something went wrong. Please try again.'; return ( @@ -73,7 +86,16 @@ export const ClaimResultModal = ({
)} - {status === 'error' && ( + {status === 'error' && alreadyClaimed && ( + <> + +
+

Already claimed

+

{getErrorMessage()}

+
+ + )} + {status === 'error' && !alreadyClaimed && ( <>
diff --git a/app/components/transactions/trackerDetail.test.tsx b/app/components/transactions/trackerDetail.test.tsx new file mode 100644 index 0000000..fd52325 --- /dev/null +++ b/app/components/transactions/trackerDetail.test.tsx @@ -0,0 +1,136 @@ +import type { Transaction } from '@/app/types/transaction'; + +import '@testing-library/jest-dom/vitest'; +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Full tracker timeline for the transaction details modal: renders +// nothing without tracking data, an info alert for +// the giving-up terminal, a warning alert for a step-level error (while +// still rendering the rest of the timeline -- the tracker retries these, it +// does not stop), and per-step result detail (certificate id, claim tx, +// GER/LER...) keyed on `step_name`. See trackerDetail.tsx. +vi.mock('@/app/context/appMode', () => ({ + useAppMode: vi.fn() +})); +vi.mock('@/app/hooks/useBridgeTracking', () => ({ + useBridgeTracking: vi.fn() +})); + +import { + errorGiveupFixture, + l1l2FinishedFixture, + l2l1FinishedFixture, + l2l2RunningStepErrorFixture +} from '@/app/__fixtures__/tracker'; +import { useAppMode } from '@/app/context/appMode'; +import { useBridgeTracking } from '@/app/hooks/useBridgeTracking'; +import { shortenAddress } from '@/app/utils/address'; + +import { TrackerDetail } from './trackerDetail'; + +const mockChains = [ + { id: 1, networkId: 0, name: 'Devnet L1' }, + { id: 2, networkId: 1, name: 'Devnet L2-001' }, + { id: 3, networkId: 2, name: 'Devnet L2-002' } +]; + +const makeTransaction = (overrides: Partial = {}): Transaction => + ({ + hubUID: 'tx-1', + txSender: '0x1', + fromAddress: '0x1', + receiverAddress: '0x1', + sourceNetwork: 1, + destinationNetwork: 0, + amount: '1', + status: 'READY_TO_CLAIM', + lastUpdatedAt: 0, + bridgeHash: '0x1', + metadata: '0x', + leafType: 'asset', + depositCount: 1, + transactionIndex: 0, + transactionHash: '0xabc', + blockNumber: 1, + originTokenAddress: '0x0', + originTokenNetwork: 0, + timestamp: 0, + leafIndex: 1, + ...overrides + }) as Transaction; + +const mockTracking = (data: unknown) => + vi + .mocked(useBridgeTracking) + .mockReturnValue({ data } as unknown as ReturnType); + +describe('TrackerDetail', () => { + beforeEach(() => { + vi.mocked(useAppMode).mockReturnValue({ chains: mockChains } as unknown as ReturnType< + typeof useAppMode + >); + }); + + it('renders nothing when there is no tracking data at all', () => { + mockTracking(undefined); + const { container } = render( + + ); + expect(container.querySelector('[data-test-id="tracker-detail"]')).not.toBeInTheDocument(); + }); + + it('renders an info alert for the giving-up terminal, with no step timeline', () => { + mockTracking(errorGiveupFixture); + const { container } = render(); + + expect(container.querySelector('[data-test-id="tracker-detail"]')).toBeInTheDocument(); + expect(screen.getByText('Tracking unavailable')).toBeInTheDocument(); + expect(screen.getByText('Tracking is unavailable for this transaction.')).toBeInTheDocument(); + expect( + container.querySelector('[data-test-id^="tracker-detail-step-"]') + ).not.toBeInTheDocument(); + }); + + it("renders certificate id + claim tx from a finished L2->L1 bridge's step results", () => { + mockTracking(l2l1FinishedFixture); + render(); + + // PendingInclusion's result: certificate id. + expect(screen.getByText('Certificate')).toBeInTheDocument(); + const certificateId = (l2l1FinishedFixture.all_steps![1].result as { certificate_id: string }) + .certificate_id; + expect(screen.getByText(shortenAddress(certificateId, 6))).toBeInTheDocument(); + + // WaitingClaim's result: claim tx. + expect(screen.getByText('Claim tx')).toBeInTheDocument(); + const claimTx = (l2l1FinishedFixture.all_steps![4].result as { claim_tx: string }).claim_tx; + expect(screen.getByText(shortenAddress(claimTx, 6))).toBeInTheDocument(); + }); + + // S10a regression (mirrors trackerProgressBar.test.tsx): a LIVE transition, + // not a row that loads already-CLAIMED (the first test above covers that, + // and it's also already gated by the caller -- transactionDetailsModal.tsx + // only mounts TrackerDetail while tx.status !== 'CLAIMED'). Here the mocked + // hook keeps returning the same finished fixture across the rerender, + // simulating a disabled react-query query still serving its last-cached + // `data`, so this only passes because the component checks + // `transaction.status` directly. + it('hides the timeline on a live CLAIMED transition even though the tracker cache is still warm', () => { + mockTracking(l1l2FinishedFixture); + const { container, rerender } = render(); + expect(container.querySelector('[data-test-id="tracker-detail"]')).toBeInTheDocument(); + + rerender(); + expect(container.querySelector('[data-test-id="tracker-detail"]')).not.toBeInTheDocument(); + }); + + it('renders a warning alert for a step-level error while the rest of the timeline still shows (non-terminal)', () => { + mockTracking(l2l2RunningStepErrorFixture); + const { container } = render(); + + expect(screen.getByText('transient error (retry 2)')).toBeInTheDocument(); + // The full 7-step timeline still renders -- a step error is not terminal. + expect(container.querySelectorAll('[data-test-id^="tracker-detail-step-"]')).toHaveLength(7); + }); +}); diff --git a/app/components/transactions/trackerDetail.tsx b/app/components/transactions/trackerDetail.tsx new file mode 100644 index 0000000..780844f --- /dev/null +++ b/app/components/transactions/trackerDetail.tsx @@ -0,0 +1,251 @@ +'use client'; + +import type { Transaction } from '@/app/types/transaction'; + +import { CopyText } from '@/app/components/copyText'; +import { DOT_CLASSES } from '@/app/components/transactions/trackerProgressBar'; +import { Alert } from '@/app/components/ui/alert'; +import { useAppMode } from '@/app/context/appMode'; +import { useBridgeTracking } from '@/app/hooks/useBridgeTracking'; +import { shortenAddress } from '@/app/utils/address'; +import { getChainByNetworkId } from '@/app/utils/chains'; +import { cn } from '@/app/utils/common'; +import { formatDateTime } from '@/app/utils/date'; +import { getTrackerStepLabel } from '@/app/utils/trackerSteps'; + +import type { + AggkitBridgeStepPath, + AggkitCertificateData, + AggkitPendingInclusionResult, + AggkitStepStatus, + AggkitWaitingClaimResult, + AggkitWaitingGERInjectionResult, + AggkitWaitingGERUpdateResult, + AggkitWaitingLERUpdateResult, + AggkitWaitL1SettledGERResult +} from '@agglayer/sdk'; + +interface TrackerDetailProps { + transaction: Transaction; +} + +const STATUS_LABEL_CLASSES: Record = { + done: 'text-green', + inProgress: 'text-blue', + pending: 'text-grey', + error: 'text-red' +}; + +const STATUS_COPY: Record = { + pending: 'Pending', + inProgress: 'In progress', + done: 'Done', + error: 'Error' +}; + +// `start_date`/`end_date` ship as ISO strings (see useBridgeTracking.ts's +// SDK deviation writeup) -- formatDateTime expects unix seconds, so convert. +const formatIsoDateTime = (iso: string): string => + formatDateTime(Math.floor(new Date(iso).getTime() / 1000)); + +// Truncated hash + copy, matching the pattern transactionDetailsModal.tsx +// already uses for the source/destination tx hashes. No explorer link here: +// per-step artifacts (GER/LER/certificate ids) don't have a per-row explorer +// deep-link today (non-goal per S8 context pack). +const HashValue = ({ value, chars = 6 }: { value: string; chars?: number }) => ( + + {shortenAddress(value, chars)} + + +); + +// Per-step `result` shape depends on `step_name` (AggkitBridgeStepResult +// union) -- see useBridgeTracking.ts / the SDK's AggkitBridgeStepPath doc +// comment for the full field-by-field breakdown this switches on. +const StepResultDetail = ({ step }: { step: AggkitBridgeStepPath }) => { + if (!step.result) return null; + + switch (step.step_name) { + case 'WaitingGERUpdate': + case 'WaitingGERInjection': { + const result = step.result as AggkitWaitingGERUpdateResult | AggkitWaitingGERInjectionResult; + return ( +
+ GER + +
+ ); + } + case 'WaitingLERUpdate': { + const result = step.result as AggkitWaitingLERUpdateResult; + return ( +
+ LER + + Block {result.block_number} +
+ ); + } + case 'PendingInclusion': { + const result = step.result as AggkitPendingInclusionResult; + return ( +
+ Certificate + +
+ ); + } + case 'CertificatePending': { + const result = step.result as AggkitCertificateData; + return ( +
+
+ Certificate status + {result.status_string} +
+ {result.settlement_tx_hash && ( +
+ Settlement tx + +
+ )} + {result.error && {result.error}} +
+ ); + } + case 'WaitL1SettledGER': { + const result = step.result as AggkitWaitL1SettledGERResult; + return ( +
+ Settlement tx + + Block {result.block_number} +
+ ); + } + case 'WaitingClaim': { + const result = step.result as AggkitWaitingClaimResult; + return ( +
+ Claim tx + + Block {result.block_number} +
+ ); + } + default: + return null; + } +}; + +// Full tracker picture for the transaction details modal: overall +// tracking status + bridge typology, +// then a vertical timeline of every `all_steps` entry with its label, +// status, dates, and per-step result detail. Mounts useBridgeTracking +// itself -- react-query dedupes the query key with the row's own poll +// (trackerProgressBar.tsx does the same), so opening the modal never starts +// a second poll for the same transaction. +// +// Renders nothing when there is no tracking data at all. This covers CLAIMED +// rows (the hook's query is `enabled: false` there, so `data` never +// populates -- no tracker section, no polling) and the brief window before +// the tracker has registered a freshly-sent bridge. +export const TrackerDetail = ({ transaction }: TrackerDetailProps) => { + const { chains } = useAppMode(); + const { data } = useBridgeTracking(transaction); + + // Explicit CLAIMED guard (S10a, mirrors trackerProgressBar.tsx): the + // caller (transactionDetailsModal.tsx) already gates mounting this + // component on `tx.status !== 'CLAIMED'`, which covers a fresh modal open + // on an already-completed row. This second guard is defense-in-depth for + // any other caller/path, and for the same reason trackerProgressBar.tsx + // needs one -- disabling useBridgeTracking's query on CLAIMED does not + // clear its already-cached `data`, so `data` alone is not a reliable + // "hide on CLAIMED" signal. + if (transaction.status === 'CLAIMED' || !data) return null; + + // Giving-up terminal (useBridgeTracking.ts's isTrackingTerminal): the + // tracker could not resolve this tx as a bridge at all. No steps exist. + if (data.tracking_status === 'error' && data.bridge_status === null) { + return ( +
+ +
+ ); + } + + const sourceName = getChainByNetworkId(chains, transaction.sourceNetwork)?.name; + const destinationName = getChainByNetworkId(chains, transaction.destinationNetwork)?.name; + const steps = data.all_steps; + const bridgeType = data.bridge_status?.bridge_type; + const leafType = data.bridge_status?.event.leaf_type; + + return ( +
+
+ Tracking status + + {data.tracking_status} + {bridgeType ? ` · ${bridgeType}` : ''} + {leafType ? ` · ${leafType}` : ''} + +
+ + {steps && steps.length > 0 && ( +
+ {steps.map((step, index) => ( +
+
+ + {index < steps.length - 1 &&
} +
+
+
+ + {getTrackerStepLabel(step.step_name, { sourceName, destinationName })} + + {STATUS_COPY[step.status]} +
+ {(step.start_date || step.end_date) && ( +
+ {step.start_date && `Started ${formatIsoDateTime(step.start_date)}`} + {step.start_date && step.end_date && ' · '} + {step.end_date && `Ended ${formatIsoDateTime(step.end_date)}`} +
+ )} +
+ +
+ {step.error && ( + + )} +
+
+ ))} +
+ )} +
+ ); +}; diff --git a/app/components/transactions/trackerProgressBar.test.tsx b/app/components/transactions/trackerProgressBar.test.tsx new file mode 100644 index 0000000..3d20299 --- /dev/null +++ b/app/components/transactions/trackerProgressBar.test.tsx @@ -0,0 +1,139 @@ +import type { Transaction } from '@/app/types/transaction'; + +import '@testing-library/jest-dom/vitest'; +import { render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Renders nothing when there's no resolved route yet (null `all_steps`) or +// when the polling hook is disabled (CLAIMED rows); otherwise one dot per +// expected step of the route, with per-step status reflected in +// `data-status`/`data-step` and the dot's fill class -- see +// trackerProgressBar.tsx and useBridgeTracking.ts. +vi.mock('@/app/context/appMode', () => ({ + useAppMode: vi.fn() +})); +vi.mock('@/app/hooks/useBridgeTracking', () => ({ + useBridgeTracking: vi.fn() +})); + +import { + l1l2FinishedFixture, + l1l2RunningFixture, + l2l2RunningFixture, + registeredFixture +} from '@/app/__fixtures__/tracker'; +import { useAppMode } from '@/app/context/appMode'; +import { useBridgeTracking } from '@/app/hooks/useBridgeTracking'; + +import { TrackerProgressBar } from './trackerProgressBar'; + +const mockChains = [ + { id: 1, networkId: 0, name: 'Devnet L1' }, + { id: 2, networkId: 1, name: 'Devnet L2-001' }, + { id: 3, networkId: 2, name: 'Devnet L2-002' } +]; + +const makeTransaction = (overrides: Partial = {}): Transaction => + ({ + hubUID: 'tx-1', + txSender: '0x1', + fromAddress: '0x1', + receiverAddress: '0x1', + sourceNetwork: 0, + destinationNetwork: 1, + amount: '1', + status: 'READY_TO_CLAIM', + lastUpdatedAt: 0, + bridgeHash: '0x1', + metadata: '0x', + leafType: 'asset', + depositCount: 1, + transactionIndex: 0, + transactionHash: '0xabc', + blockNumber: 1, + originTokenAddress: '0x0', + originTokenNetwork: 0, + timestamp: 0, + leafIndex: 1, + ...overrides + }) as Transaction; + +const mockTracking = (data: unknown) => + vi + .mocked(useBridgeTracking) + .mockReturnValue({ data } as unknown as ReturnType); + +describe('TrackerProgressBar', () => { + beforeEach(() => { + vi.mocked(useAppMode).mockReturnValue({ chains: mockChains } as unknown as ReturnType< + typeof useAppMode + >); + }); + + it('renders nothing while all_steps is null (registered, route not resolved yet)', () => { + mockTracking(registeredFixture); + const { container } = render(); + expect(container.querySelector('[data-test-id="tracker-progress"]')).not.toBeInTheDocument(); + }); + + it('renders nothing for a CLAIMED row (hook disabled, data undefined)', () => { + mockTracking(undefined); + const { container } = render( + + ); + expect(container.querySelector('[data-test-id="tracker-progress"]')).not.toBeInTheDocument(); + }); + + it('renders 4 dots for an L1->L2 mid-flight bridge, with per-step status attrs and fill classes', () => { + mockTracking(l1l2RunningFixture); + const { container } = render(); + + expect(container.querySelectorAll('[data-test-id^="tracker-step-"]')).toHaveLength(4); + + const doneDot = container.querySelector('[data-test-id="tracker-step-0"]'); + expect(doneDot).toHaveAttribute('data-step', 'WaitingGERUpdate'); + expect(doneDot).toHaveAttribute('data-status', 'done'); + expect(doneDot).toHaveClass('border-green', 'bg-green'); + + const inProgressDot = container.querySelector('[data-test-id="tracker-step-2"]'); + expect(inProgressDot).toHaveAttribute('data-step', 'WaitingClaim'); + expect(inProgressDot).toHaveAttribute('data-status', 'inProgress'); + expect(inProgressDot).toHaveClass('border-blue', 'bg-blue', 'animate-pulse'); + + const pendingDot = container.querySelector('[data-test-id="tracker-step-3"]'); + expect(pendingDot).toHaveAttribute('data-step', 'Claimed'); + expect(pendingDot).toHaveAttribute('data-status', 'pending'); + expect(pendingDot).toHaveClass('border-grey-light', 'bg-transparent'); + }); + + it('renders 7 dots for an L2->L2 mid-flight bridge', () => { + mockTracking(l2l2RunningFixture); + const { container } = render(); + expect(container.querySelectorAll('[data-test-id^="tracker-step-"]')).toHaveLength(7); + }); + + // S10a regression: a LIVE transition, not a row that loads already-CLAIMED + // (that's the 'hook disabled, data undefined' case above). Here the mocked + // hook keeps returning the same finished fixture across the rerender -- + // simulating react-query's real behavior of a disabled query still serving + // its last-cached `data` -- so this only passes because the component + // checks `transaction.status` directly rather than trusting `data`/`steps` + // alone. Without that guard, this rerender would still find the bar. + it('hides the bar on a live CLAIMED transition even though the tracker cache is still warm', () => { + mockTracking(l1l2FinishedFixture); + const { container, rerender } = render(); + expect(container.querySelectorAll('[data-test-id^="tracker-step-"]')).toHaveLength(4); + + rerender(); + expect(container.querySelector('[data-test-id="tracker-progress"]')).not.toBeInTheDocument(); + }); + + it('tooltip copy for the inProgress step names the destination chain and its status', () => { + mockTracking(l1l2RunningFixture); + const { container } = render(); + + const dot = container.querySelector('[data-test-id="tracker-step-2"]'); + const tooltip = dot?.parentElement?.querySelector('[role="tooltip"]'); + expect(tooltip).toHaveTextContent('Finalizing claim data for Devnet L2-001 — In progress'); + }); +}); diff --git a/app/components/transactions/trackerProgressBar.tsx b/app/components/transactions/trackerProgressBar.tsx new file mode 100644 index 0000000..88c1d4c --- /dev/null +++ b/app/components/transactions/trackerProgressBar.tsx @@ -0,0 +1,84 @@ +'use client'; + +import type { Transaction } from '@/app/types/transaction'; + +import { Tooltip } from '@/app/components/ui/tooltip'; +import { useAppMode } from '@/app/context/appMode'; +import { useBridgeTracking } from '@/app/hooks/useBridgeTracking'; +import { getChainByNetworkId } from '@/app/utils/chains'; +import { cn } from '@/app/utils/common'; +import { getTrackerStepTooltip } from '@/app/utils/trackerSteps'; + +import type { AggkitStepStatus } from '@agglayer/sdk'; + +interface TrackerProgressBarProps { + transaction: Transaction; +} + +// Dot fill per step status: done is +// filled green, inProgress is a highlighted blue that pulses, pending is a +// hollow ring, error is filled red. +export const DOT_CLASSES: Record = { + done: 'border-green bg-green', + inProgress: 'border-blue bg-blue animate-pulse', + pending: 'border-grey-light bg-transparent', + error: 'border-red bg-red' +}; + +// Renders the aggkit tracker's `all_steps` as a row of dots + connector +// lines, one dot per expected step of this bridge's route (4 for L1->L2, 6 +// for L2->L1, 7 for L2->L2 -- see useBridgeTracking.ts). Mounts the polling +// hook itself so callers just drop this in; it renders nothing while +// `all_steps` is still null (tracker hasn't resolved the route yet, or has +// given up) and nothing for CLAIMED rows (the hook disables its query for +// those, so `data` never populates). +export const TrackerProgressBar = ({ transaction }: TrackerProgressBarProps) => { + const { chains } = useAppMode(); + const { data } = useBridgeTracking(transaction); + const steps = data?.all_steps; + + // Explicit CLAIMED guard (S10a): useBridgeTracking disables its query once + // status is CLAIMED, but disabling a react-query query only stops future + // refetches -- it does NOT clear already-cached `data` for that query key. + // A row that transitions live from non-CLAIMED -> CLAIMED while mounted + // (rather than loading already-CLAIMED) keeps serving its last-fetched, + // fully-`done` `all_steps` from cache, so relying on `data`/`steps` alone + // does not actually hide the bar on that transition. Check `status` + // directly rather than depending on cache semantics. + if (transaction.status === 'CLAIMED' || !steps || steps.length === 0) return null; + + const sourceName = getChainByNetworkId(chains, transaction.sourceNetwork)?.name; + const destinationName = getChainByNetworkId(chains, transaction.destinationNetwork)?.name; + + return ( +
+ {steps.map((step, index) => ( +
+ + + + {index < steps.length - 1 && ( +
+ )} +
+ ))} +
+ ); +}; diff --git a/app/components/transactions/transactionDetailsModal/transactionDetailsModal.tsx b/app/components/transactions/transactionDetailsModal/transactionDetailsModal.tsx index 4462dd9..dd33f36 100644 --- a/app/components/transactions/transactionDetailsModal/transactionDetailsModal.tsx +++ b/app/components/transactions/transactionDetailsModal/transactionDetailsModal.tsx @@ -3,6 +3,7 @@ import type { ClaimStep, Transaction } from '@/app/types/transaction'; import { CopyText } from '@/app/components/copyText'; +import { TrackerDetail } from '@/app/components/transactions/trackerDetail'; import { TransactionDetailsHeader } from '@/app/components/transactions/transactionDetailsModal/transactionDetailsHeader'; import { Alert } from '@/app/components/ui/alert'; import { Button } from '@/app/components/ui/button'; @@ -152,6 +153,8 @@ export const TransactionDetailsModal = ({
+ {tx.status !== 'CLAIMED' && } + {isDifferentAddress && ( {claimStep === 'claiming' ? ( <> diff --git a/app/components/transactions/transactionFilters.test.tsx b/app/components/transactions/transactionFilters.test.tsx new file mode 100644 index 0000000..ec2a3e9 --- /dev/null +++ b/app/components/transactions/transactionFilters.test.tsx @@ -0,0 +1,31 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { TransactionFilters } from './transactionFilters'; + +// S10 regression: selecting "All transactions" mapped the status Dropdown's +// `null` sentinel to the string 'all' for display, but `onSelect` never +// mapped it back -- so `onFilterChange` received `status: 'all'`, which +// `services/transactions.ts` then compared against every real +// `TransactionStatus` value (never matching), rendering an empty list even +// though transactions existed. Clicking "Ready to claim" first (a non-null +// status) then "All transactions" reproduces the exact manual-repro path. +describe('TransactionFilters', () => { + it('selecting "All transactions" clears the status filter instead of sending the literal "all"', () => { + const onFilterChange = vi.fn(); + + render(); + + fireEvent.click(screen.getByText('Status')); + fireEvent.click(screen.getByText('Ready to claim')); + expect(onFilterChange).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'READY_TO_CLAIM' }) + ); + + fireEvent.click(screen.getByText('Ready to claim')); + fireEvent.click(screen.getByText('All transactions')); + + expect(onFilterChange).toHaveBeenLastCalledWith(expect.objectContaining({ status: undefined })); + }); +}); diff --git a/app/components/transactions/transactionFilters.tsx b/app/components/transactions/transactionFilters.tsx index a89b1ab..2fd8ad0 100644 --- a/app/components/transactions/transactionFilters.tsx +++ b/app/components/transactions/transactionFilters.tsx @@ -77,7 +77,9 @@ export const TransactionFilters = ({ clearable disabled={disabled} onClear={clearStatus} - onSelect={(option) => handleStatusChange((option.value as TransactionStatus) || null)} + onSelect={(option) => + handleStatusChange(option.value === 'all' ? null : (option.value as TransactionStatus)) + } className="min-w-35" />
diff --git a/app/components/transactions/transactionListItem.tsx b/app/components/transactions/transactionListItem.tsx index 370982f..bde6cf4 100644 --- a/app/components/transactions/transactionListItem.tsx +++ b/app/components/transactions/transactionListItem.tsx @@ -3,12 +3,14 @@ import type { ClaimStep, Transaction } from '@/app/types/transaction'; import { CopyText } from '@/app/components/copyText'; +import { TrackerProgressBar } from '@/app/components/transactions/trackerProgressBar'; import { TransactionETA } from '@/app/components/transactions/transactionEta'; import { TransactionStatusBadge } from '@/app/components/transactions/transactionStatusBadge'; import { BadgeImageFallback } from '@/app/components/ui/badgeImageFallback'; import { Button } from '@/app/components/ui/button'; import { useAppMode } from '@/app/context/appMode'; import { useTokens } from '@/app/context/token'; +import { useAutoclaimGate } from '@/app/hooks/useAutoclaimGate'; import { useTokenMetadata } from '@/app/hooks/useTokenMetadata'; import { shortenAddress } from '@/app/utils/address'; import { getChainByNetworkId } from '@/app/utils/chains'; @@ -37,6 +39,10 @@ export const TransactionListItem = ({ const destChain = getChainByNetworkId(chains, transaction.destinationNetwork); const isClaimable = transaction.status === 'READY_TO_CLAIM'; const isPending = transaction.status === 'BRIDGED' || transaction.status === 'LEAF_INCLUDED'; + // Per-route autoclaim grace period: 'no-autoclaim' shows the button now, + // 'waiting' shows a "claim manually now" hint while autoclaim is expected, + // 'overdue' shows the button plus a "taking longer than expected" note. + const autoclaimGate = useAutoclaimGate(transaction); const isNative = isNativeToken(transaction.originTokenAddress); @@ -72,6 +78,7 @@ export const TransactionListItem = ({ return (
onSelect?.(transaction)} + data-test-id={`transaction-row-${transaction.transactionHash}`} className={cn( 'rounded-2xl border border-border bg-surface shadow-sm transition hover:border-blue hover:shadow-md cursor-pointer' )} @@ -162,26 +169,52 @@ export const TransactionListItem = ({ {isPending && sourceChain?.eta && ( )} - {isClaimable && ( - - )} + {isClaimable && + (autoclaimGate === 'waiting' ? ( +

+ Waiting for auto claim,{' '} + +

+ ) : ( +
+ + {autoclaimGate === 'overdue' && ( +

+ Auto claim is taking more time than expected, you can claim manually instead +

+ )} +
+ ))} +
); diff --git a/app/components/transactions/transactionStatusBadge.tsx b/app/components/transactions/transactionStatusBadge.tsx index 8314a12..aa11def 100644 --- a/app/components/transactions/transactionStatusBadge.tsx +++ b/app/components/transactions/transactionStatusBadge.tsx @@ -47,6 +47,7 @@ export const TransactionStatusBadge = ({ status, className }: TransactionStatusB return (
({ + useWallet: vi.fn() +})); +vi.mock('@/app/context/appMode', () => ({ + useAppMode: vi.fn() +})); +vi.mock('@/app/context/refetch', () => ({ + useRefetch: vi.fn() +})); +vi.mock('@/app/hooks/useClaimExecution', () => ({ + useClaimExecution: vi.fn() +})); +vi.mock('@/app/hooks/useEnforceCorrectChain', () => ({ + useEnforceCorrectChain: vi.fn() +})); +vi.mock('@/app/hooks/useTransactions', () => ({ + TOTAL_REFETCH_TIME: 6500, + useTransactions: vi.fn() +})); +// Stubs the real list (which pulls in token-metadata queries and other +// unrelated context) with a minimal render that only proves the items made +// it to the view — the notice/error branching is what this suite verifies. +vi.mock('@/app/components/transactions/transactionList', () => ({ + TransactionList: ({ transactions }: { transactions: Transaction[] }) => ( +
{transactions.length} transaction(s)
+ ) +})); +// Both modals pull in TokenProvider/other contexts unrelated to S8 and are +// always rendered by TransactionsView (gated internally on `open`); stub +// them out since this suite only exercises the notice/error branching. +vi.mock('@/app/components/transactions/transactionDetailsModal/transactionDetailsModal', () => ({ + TransactionDetailsModal: () => null +})); +vi.mock('@/app/components/transactions/claimResultModal', () => ({ + ClaimResultModal: () => null +})); + +import { useAppMode } from '@/app/context/appMode'; +import { useRefetch } from '@/app/context/refetch'; +import { useWallet } from '@/app/context/walletContext'; +import { useClaimExecution } from '@/app/hooks/useClaimExecution'; +import { useEnforceCorrectChain } from '@/app/hooks/useEnforceCorrectChain'; +import { useTransactions } from '@/app/hooks/useTransactions'; + +import { TransactionsView } from './transactionsView'; + +const mockChains = [ + { id: 1, networkId: 0, name: 'Ethereum' }, + { id: 137, networkId: 137, name: 'Polygon zkEVM' }, + { id: 1101, networkId: 1101, name: 'Astar zkEVM' } +]; + +const makeTransaction = (hubUID: string): Transaction => + ({ + hubUID, + txSender: '0x1', + fromAddress: '0x1', + receiverAddress: '0x1', + sourceNetwork: 0, + destinationNetwork: 137, + amount: '1', + status: 'CLAIMED', + lastUpdatedAt: 0, + bridgeHash: '0x1', + metadata: '0x', + leafType: 'asset', + depositCount: 1, + transactionIndex: 0, + transactionHash: '0x1', + blockNumber: 1, + originTokenAddress: '0x0', + originTokenNetwork: 0, + timestamp: 0, + leafIndex: 1 + }) as Transaction; + +const renderView = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return render(, { wrapper }); +}; + +describe('TransactionsView partial-failure notice', () => { + beforeEach(() => { + vi.mocked(useWallet).mockReturnValue({ + address: '0xabc', + status: 'connected', + chainId: 1, + connect: vi.fn(), + disconnect: vi.fn(), + switchNetwork: vi.fn() + } as unknown as ReturnType); + + vi.mocked(useAppMode).mockReturnValue({ + defaultFromChainId: 1, + chains: mockChains, + bridgeAddress: '0xbridge' + } as unknown as ReturnType); + + vi.mocked(useRefetch).mockReturnValue({ + aggressiveRefetch: false, + triggerAggressiveRefetch: vi.fn(), + clearAggressiveRefetch: vi.fn() + }); + + vi.mocked(useClaimExecution).mockReturnValue({ + state: { isExecuting: false, currentStep: 'idle' }, + execute: vi.fn(), + reset: vi.fn() + } as unknown as ReturnType); + + vi.mocked(useEnforceCorrectChain).mockReturnValue(vi.fn()); + }); + + it('partial failure renders items + a notice naming the failed network by display name', () => { + vi.mocked(useTransactions).mockReturnValue({ + data: { + pages: [ + { + status: 'success', + data: [makeTransaction('tx-1'), makeTransaction('tx-2')], + pagination: { total: 2 }, + failedNetworks: [{ networkId: 1101, error: 'timeout' }] + } + ], + pageParams: [undefined] + }, + isLoading: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + error: null, + refetch: vi.fn(), + isRefetching: false, + failedNetworks: [{ networkId: 1101, error: 'timeout' }] + } as unknown as ReturnType); + + renderView(); + + // items are still shown despite the partial failure + expect(screen.getByTestId('transaction-list')).toHaveTextContent('2 transaction(s)'); + + // notice names the failed network by display name, not raw network id + expect(screen.getByText(/Astar zkEVM/)).toBeInTheDocument(); + expect(screen.queryByText(/1101/)).not.toBeInTheDocument(); + + // this is not the full "all networks failed" error state + expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument(); + }); + + it('all networks failing renders the existing full error state, not the partial notice', () => { + vi.mocked(useTransactions).mockReturnValue({ + data: undefined, + isLoading: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + error: new Error('AggkitBridgeAggregator.getActivity: all configured networks failed'), + refetch: vi.fn(), + isRefetching: false, + failedNetworks: [] + } as unknown as ReturnType); + + renderView(); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.queryByTestId('transaction-list')).not.toBeInTheDocument(); + expect(screen.queryByText(/temporarily unavailable/)).not.toBeInTheDocument(); + }); + + it('zero failures renders items with no notice', () => { + vi.mocked(useTransactions).mockReturnValue({ + data: { + pages: [ + { + status: 'success', + data: [makeTransaction('tx-1')], + pagination: { total: 1 }, + failedNetworks: [] + } + ], + pageParams: [undefined] + }, + isLoading: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + error: null, + refetch: vi.fn(), + isRefetching: false, + failedNetworks: [] + } as unknown as ReturnType); + + renderView(); + + expect(screen.getByTestId('transaction-list')).toHaveTextContent('1 transaction(s)'); + expect(screen.queryByText(/temporarily unavailable/)).not.toBeInTheDocument(); + expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument(); + }); +}); diff --git a/app/components/transactions/transactionsView.tsx b/app/components/transactions/transactionsView.tsx index fc991b6..9a60986 100644 --- a/app/components/transactions/transactionsView.tsx +++ b/app/components/transactions/transactionsView.tsx @@ -7,6 +7,7 @@ import { getTransactionInitialStatus } from '@/app/components/transactions/intia import { TransactionDetailsModal } from '@/app/components/transactions/transactionDetailsModal/transactionDetailsModal'; import { TransactionFilters } from '@/app/components/transactions/transactionFilters'; import { TransactionList } from '@/app/components/transactions/transactionList'; +import { Alert } from '@/app/components/ui/alert'; import { Button } from '@/app/components/ui/button'; import { Card } from '@/app/components/ui/card'; import { useAppMode } from '@/app/context/appMode'; @@ -58,7 +59,8 @@ export const TransactionsView = () => { fetchNextPage, error, refetch, - isRefetching + isRefetching, + failedNetworks } = useTransactions({ chainId: effectiveChainId, filters: queryFilters, @@ -97,6 +99,19 @@ export const TransactionsView = () => { const totalCount = data?.pages[0]?.pagination.total ?? 0; + // Partial fan-out failures: the aggregator still returns + // results from healthy networks, so this is surfaced as a non-blocking + // notice rather than the full error state (which is reserved for the case + // where every configured network failed and `error` is set below). + const failedNetworkNames = useMemo( + () => + (failedNetworks ?? []).map( + (failure) => getChainByNetworkId(chains, failure.networkId)?.name ?? 'Unknown network' + ), + [failedNetworks, chains] + ); + const hasPartialFailure = !error && failedNetworkNames.length > 0; + const destChain = claimExecution.state.destinationChainId ? getChainById(chains, claimExecution.state.destinationChainId) : undefined; @@ -172,6 +187,7 @@ export const TransactionsView = () => {
)} + {isConnected && hasPartialFailure && ( + + )} + {isConnected && !error && ( void; + testId?: string; } export const Dropdown = ({ @@ -34,7 +35,8 @@ export const Dropdown = ({ disabled, className, clearable = false, - onClear + onClear, + testId }: DropdownProps) => { const [open, setOpen] = useState(false); const containerRef = useRef(null); @@ -78,6 +80,7 @@ export const Dropdown = ({ disabled={disabled} onClick={() => setOpen((prev) => !prev)} className="flex flex-1 items-center justify-between gap-3 text-left cursor-pointer" + data-test-id={testId} > {selected?.icon} @@ -121,6 +124,7 @@ export const Dropdown = ({ 'flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-surface-muted transition-colors cursor-pointer', selectedValue === option.value && 'bg-surface-muted' )} + data-test-id={testId ? `${testId}-option-${option.value}` : undefined} > {option.icon}
diff --git a/app/components/ui/tooltip.tsx b/app/components/ui/tooltip.tsx new file mode 100644 index 0000000..ce3474b --- /dev/null +++ b/app/components/ui/tooltip.tsx @@ -0,0 +1,32 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { cn } from '@/app/utils/common'; + +interface TooltipProps { + content: ReactNode; + children: ReactNode; + className?: string; +} + +// CSS-only hover tooltip (no JS state, no new dependency): the trigger and +// bubble are siblings inside a `group`, and the bubble's visibility is +// driven entirely by `group-hover`/`group-focus-within`, so it also shows +// on keyboard focus of a focusable trigger. +export const Tooltip = ({ content, children, className }: TooltipProps) => ( + + {children} + + {content} + + +); diff --git a/app/config.test.ts b/app/config.test.ts new file mode 100644 index 0000000..b0efd25 --- /dev/null +++ b/app/config.test.ts @@ -0,0 +1,263 @@ +import type { JsonConfig } from '@/app/types/config'; + +import { + buildAppConfig, + getAppConfig, + getExternalLinks, + initAppConfig, + isAppConfigReady, + resetAppConfig +} from '@/app/config'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Same fixture shape as config/configLoader.test.mjs / configValidator.test.mjs +// (design.md §4/§7): a schema-valid, semantically-valid devnet config with +// three chains (one L1, two L2) and one enabled mode, using the single +// aggkitProxy field -- the only aggkit backend field the schema supports +// (the per-network aggkitBridgeApis map has been removed; see +// config/configSchema.mjs). buildAppConfig assumes its input is already +// schema-valid AND URL-normalized (that happens upstream in the loaders under +// test in configLoader.test.mjs/ts), so every URL here is absolute. +const chain = (overrides: Partial = {}) => ({ + id: 1, + name: 'Chain', + rpcUrl: 'https://rpc.example', + explorerUrl: 'https://explorer.example', + currency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + iconUrl: 'https://icon.example/icon.svg', + networkId: 0, + isTestnet: true, + eta: 1, + ...overrides +}); + +// `aggkitProxy: null` means "omit the field entirely" (the "not yet +// configured" escape hatch) -- distinct from an ordinary omitted argument, +// which defaults to a concrete proxy URL below. +const buildConfigJson = ( + aggkitProxy: string | null = 'https://aggkit-proxy.example/aggkitapi', + projectId = 'served-project-id' +): JsonConfig => + ({ + walletConnect: { projectId }, + externalLinks: { + privacyPolicy: 'https://privacy.example', + termsOfUse: 'https://terms.example', + contactSupport: 'https://support.example' + }, + chains: { + DEVNET_L1: chain({ id: 271828, name: 'Devnet L1', networkId: 0 }), + DEVNET_L2_001: chain({ id: 20201, name: 'Devnet L2-001', networkId: 1 }), + DEVNET_L2_002: chain({ id: 20202, name: 'Devnet L2-002', networkId: 2 }) + }, + appModes: { + default: 'devnet', + configs: { + devnet: { + label: 'Devnet', + bridgeAddress: '0xC8cbEBf950B9Df44d987c8619f092beA980fF038', + ...(aggkitProxy === null ? {} : { aggkitProxy }), + chainKeys: ['DEVNET_L1', 'DEVNET_L2_001', 'DEVNET_L2_002'], + defaultFromChainKey: 'DEVNET_L1', + defaultToChainKey: 'DEVNET_L2_001' + } + } + } + }) as JsonConfig; + +describe('buildAppConfig — loader success (A-5 item 1)', () => { + it('derives the expected chain registry and mode configs from a valid config', () => { + const resolved = buildAppConfig(buildConfigJson()); + + expect(resolved.defaultAppMode).toBe('devnet'); + expect(Object.keys(resolved.chainRegistry).sort()).toEqual( + ['DEVNET_L1', 'DEVNET_L2_001', 'DEVNET_L2_002'].sort() + ); + expect(resolved.chainRegistry.DEVNET_L1.app.id).toBe(271828); + expect(resolved.chainRegistry.DEVNET_L2_001.app.networkId).toBe(1); + + expect(resolved.appModeConfig.devnet.label).toBe('Devnet'); + expect(resolved.appModeConfig.devnet.chains).toHaveLength(3); + + // mainnet/testnet have no config.json entry -> disabled, empty shape. + expect(resolved.appModeConfig.mainnet.chains).toEqual([]); + expect(resolved.appModeConfig.testnet.chains).toEqual([]); + + expect(resolved.allWagmiChains).toHaveLength(3); + // defaultFromChainKey: 'DEVNET_L1' -> that chain's wagmi id. + expect(resolved.defaultWagmiChain.id).toBe(271828); + + expect(resolved.externalLinks).toEqual({ + PRIVACY_POLICY: 'https://privacy.example', + TERMS_OF_USE: 'https://terms.example', + CONTACT_SUPPORT: 'https://support.example' + }); + }); +}); + +describe('buildAppConfig — aggkitProxy fan-out', () => { + it('fans a single aggkitProxy value out to every non-L1 chain networkId in the mode', () => { + const resolved = buildAppConfig(buildConfigJson('https://aggkit-proxy.example/aggkitapi')); + + // Every downstream consumer (AggkitBridgeAggregator, + // app/utils/appMode.ts) reads this same resolved Record + // shape regardless of config.json only ever declaring one URL. + expect(resolved.appModeConfig.devnet.aggkitBridgeApis).toEqual({ + 1: 'https://aggkit-proxy.example/aggkitapi', + 2: 'https://aggkit-proxy.example/aggkitapi' + }); + }); + + it('never fans the proxy value out under the L1 networkId (0)', () => { + const resolved = buildAppConfig(buildConfigJson('https://aggkit-proxy.example/aggkitapi')); + + expect(resolved.appModeConfig.devnet.aggkitBridgeApis[0]).toBeUndefined(); + }); + + it('resolves to an empty map when the mode has no aggkitProxy configured', () => { + const resolved = buildAppConfig(buildConfigJson(null)); + + expect(resolved.appModeConfig.devnet.aggkitBridgeApis).toEqual({}); + }); +}); + +describe('buildAppConfig — NEXT_PUBLIC_AGGKIT_PROXY override precedence', () => { + afterEach(() => { + delete process.env.NEXT_PUBLIC_AGGKIT_PROXY; + }); + + it('overrides every non-L1 networkId with the single override URL', () => { + process.env.NEXT_PUBLIC_AGGKIT_PROXY = 'https://override.example/aggkitapi'; + + const resolved = buildAppConfig(buildConfigJson('https://aggkit-proxy.example/aggkitapi')); + + expect(resolved.appModeConfig.devnet.aggkitBridgeApis).toEqual({ + 1: 'https://override.example/aggkitapi', + 2: 'https://override.example/aggkitapi' + }); + }); + + it('falls back to the served aggkitProxy value when no override is set', () => { + const resolved = buildAppConfig(buildConfigJson('https://aggkit-proxy.example/aggkitapi')); + + expect(resolved.appModeConfig.devnet.aggkitBridgeApis).toEqual({ + 1: 'https://aggkit-proxy.example/aggkitapi', + 2: 'https://aggkit-proxy.example/aggkitapi' + }); + }); + + it('applies even when the served config has no aggkitProxy of its own', () => { + process.env.NEXT_PUBLIC_AGGKIT_PROXY = 'https://override.example/aggkitapi'; + + const resolved = buildAppConfig(buildConfigJson(null)); + + expect(resolved.appModeConfig.devnet.aggkitBridgeApis).toEqual({ + 1: 'https://override.example/aggkitapi', + 2: 'https://override.example/aggkitapi' + }); + }); + + it('throws APP_CONFIG_INVALID for a malformed override (not an absolute URL or relative path)', () => { + process.env.NEXT_PUBLIC_AGGKIT_PROXY = 'not a url'; + + expect(() => buildAppConfig(buildConfigJson('https://aggkit-proxy.example/aggkitapi'))).toThrow( + /APP_CONFIG_INVALID: NEXT_PUBLIC_AGGKIT_PROXY must be an absolute http\(s\) URL/ + ); + }); +}); + +// D0e: walletConnect.projectId is the runtime-configurable source (settable +// in a mounted config.json with no rebuild -- see entrypoint.sh/docs/docker.md); +// NEXT_PUBLIC_PROJECT_ID, when set, overrides it -- the same precedence rule +// already established for NEXT_PUBLIC_AGGKIT_PROXY above, kept only as a +// local-dev/Playwright convenience (see app/config.ts's +// resolveProjectIdOverride). +describe('buildAppConfig — walletConnect.projectId resolution', () => { + afterEach(() => { + delete process.env.NEXT_PUBLIC_PROJECT_ID; + }); + + it('resolves to the served config value when no env override is set', () => { + const resolved = buildAppConfig(buildConfigJson(undefined, 'served-project-id')); + + expect(resolved.walletConnect.projectId).toBe('served-project-id'); + }); + + it('NEXT_PUBLIC_PROJECT_ID overrides the served config value when set', () => { + process.env.NEXT_PUBLIC_PROJECT_ID = 'env-override-project-id'; + + const resolved = buildAppConfig(buildConfigJson(undefined, 'served-project-id')); + + expect(resolved.walletConnect.projectId).toBe('env-override-project-id'); + }); + + it('an empty/whitespace-only env override is ignored, falling back to the served value', () => { + process.env.NEXT_PUBLIC_PROJECT_ID = ' '; + + const resolved = buildAppConfig(buildConfigJson(undefined, 'served-project-id')); + + expect(resolved.walletConnect.projectId).toBe('served-project-id'); + }); +}); + +describe('the module store', () => { + beforeEach(() => { + resetAppConfig(); + }); + + it('is unready before initAppConfig and ready after', () => { + expect(isAppConfigReady()).toBe(false); + initAppConfig(buildConfigJson()); + expect(isAppConfigReady()).toBe(true); + }); + + it('serves accessors from the config passed to initAppConfig', () => { + initAppConfig(buildConfigJson()); + expect(getExternalLinks().CONTACT_SUPPORT).toBe('https://support.example'); + expect(getAppConfig().defaultAppMode).toBe('devnet'); + }); + + it('resetAppConfig clears the store back to not-loaded', () => { + initAppConfig(buildConfigJson()); + resetAppConfig(); + expect(isAppConfigReady()).toBe(false); + expect(() => getAppConfig()).toThrow(/APP_CONFIG_NOT_LOADED/); + }); +}); + +// A-5 item 6: importing app/config.ts must never throw at module-evaluation +// time, even though every accessor now depends on AppConfigGate having run +// first. Each test here works on a *fresh* module instance +// (vi.resetModules + a dynamic import) so it is independent of whatever the +// describe blocks above have already done to the shared singleton. +describe('regression guard: no module-scope config reads (A-5 item 6)', () => { + it('importing the module with no served config does not throw', async () => { + vi.resetModules(); + await expect(import('@/app/config')).resolves.toBeDefined(); + }); + + it('calling an accessor before init throws the documented APP_CONFIG_NOT_LOADED error', async () => { + vi.resetModules(); + const freshConfigModule = await import('@/app/config'); + + expect(freshConfigModule.isAppConfigReady()).toBe(false); + expect(() => freshConfigModule.getAppConfig()).toThrow( + /APP_CONFIG_NOT_LOADED: app config was read before AppConfigGate resolved it/ + ); + expect(() => freshConfigModule.getExternalLinks()).toThrow(/APP_CONFIG_NOT_LOADED/); + expect(() => freshConfigModule.getAppModeConfig()).toThrow(/APP_CONFIG_NOT_LOADED/); + expect(() => freshConfigModule.getDefaultAppMode()).toThrow(/APP_CONFIG_NOT_LOADED/); + expect(() => freshConfigModule.getAllWagmiChains()).toThrow(/APP_CONFIG_NOT_LOADED/); + expect(() => freshConfigModule.getDefaultWagmiChain()).toThrow(/APP_CONFIG_NOT_LOADED/); + }); + + it('a fresh module becomes ready once initAppConfig is called on it', async () => { + vi.resetModules(); + const freshConfigModule = await import('@/app/config'); + + freshConfigModule.initAppConfig(buildConfigJson()); + + expect(freshConfigModule.isAppConfigReady()).toBe(true); + expect(() => freshConfigModule.getAppConfig()).not.toThrow(); + }); +}); diff --git a/app/config.ts b/app/config.ts index 668be61..95390da 100644 --- a/app/config.ts +++ b/app/config.ts @@ -1,59 +1,133 @@ import type { AppChain, AppMode, AppModeConfig, EnabledAppModeConfig } from '@/app/types/appMode'; -import type { ChainEntry } from '@/app/types/config'; +import type { + AutoclaimConfig, + AutoclaimRouteConfig, + ChainEntry, + JsonAppModeConfig, + JsonConfig, + RouteType +} from '@/app/types/config'; import type { Chain } from 'wagmi/chains'; -import { - buildWagmiChain, - createChainEntry, - toNonEmptyChainArray, - toProofApiUrl -} from '@/app/utils/config'; -import rawJsonConfig from '@/config.json'; +import { buildWagmiChain, createChainEntry, toNonEmptyChainArray } from '@/app/utils/config'; import { APP_MODES } from '@/config/appModes.mjs'; -import { parseConfigOrThrow } from '@/config/configValidator.mjs'; +import { resolveAggkitProxyUrl } from '@/config/configLoader.mjs'; +import { aggkitProxySchema } from '@/config/configSchema.mjs'; -const configJson = parseConfigOrThrow(rawJsonConfig, { sourceName: 'config.json' }); +// Per-route autoclaim UX defaults. config.json's optional `autoclaim` block +// overrides these per route; any omitted route (or omitted waitForAutoclaimMs) +// falls back here. Wait periods are measured from when a deposit first becomes +// READY_TO_CLAIM (see useAutoclaimGate). Config-independent, so this stays a +// module-scope constant. +export const DEFAULT_AUTOCLAIM_CONFIG: AutoclaimConfig = { + l1_to_l2: { expectedAutoclaim: true, waitForAutoclaimMs: 60_000 }, + l2_to_l1: { expectedAutoclaim: false, waitForAutoclaimMs: 0 }, + l2_to_l2: { expectedAutoclaim: true, waitForAutoclaimMs: 120_000 } +}; -const resolveBridgeHubApiBaseUrl = (): string => { - const envOverride = process.env.NEXT_PUBLIC_BRIDGE_HUB_API?.trim(); - const configuredBaseUrl = - envOverride && envOverride.length > 0 ? envOverride : configJson.bridgeHubApiBaseUrl; +// Add custom RPC URLs on a per-chain basis as needed. Config-independent, so +// this stays a module-scope constant. +export const customRpcUrls: Record = { + // Example: + // 'eip155:1234': [{ url: 'https://rpc.example.org' }], +}; - try { - return new URL(configuredBaseUrl).toString().replace(/\/+$/, ''); - } catch { - throw new Error('APP_CONFIG_INVALID: NEXT_PUBLIC_BRIDGE_HUB_API must be a valid URL'); +export type ResolvedAppConfig = { + autoclaim: AutoclaimConfig; + externalLinks: Readonly<{ + PRIVACY_POLICY: string; + TERMS_OF_USE: string; + CONTACT_SUPPORT: string; + }>; + chainRegistry: Record; + defaultAppMode: AppMode; + appModeConfig: Record; + allWagmiChains: readonly [Chain, ...Chain[]]; + defaultWagmiChain: Chain; + walletConnect: Readonly<{ projectId: string }>; +}; + +// NEXT_PUBLIC_AGGKIT_PROXY is inlined by Next at build time, so it can only +// ever carry a build-environment value (dev / Cloudflare) -- it is +// structurally absent from a published image (design.md §6). window is only +// available once this runs in the browser (AppConfigGate's effect, or a +// Playwright-driven page); the Node bootstrap (tests/e2e/appConfig.ts) has no +// window, so a relative override value there resolves to `undefined` and +// resolveAggkitProxyUrl throws loudly rather than silently misresolving. +const resolveEnvOrigin = (): string | undefined => + typeof window === 'undefined' ? undefined : window.location.origin; + +// Devnet's aggkit REST port is ephemeral per enclave recreate (kurtosis assigns +// it at runtime); this env var lets a bring-up script inject the live proxy +// URL without editing config.json -- e.g. a devnet bring-up script overriding +// config.json's baked-in proxy URL with the live enclave's ephemeral port. +const resolveAggkitProxyOverride = (): string | undefined => { + const envOverride = process.env.NEXT_PUBLIC_AGGKIT_PROXY?.trim(); + if (!envOverride) return undefined; + + const parsed = aggkitProxySchema.safeParse(envOverride); + if (!parsed.success) { + throw new Error( + 'APP_CONFIG_INVALID: NEXT_PUBLIC_AGGKIT_PROXY must be an absolute http(s) URL or a ' + + 'single origin-relative path' + ); } + + const origin = resolveEnvOrigin(); + return resolveAggkitProxyUrl(parsed.data, origin, false); }; -const bridgeHubApiBaseUrl = resolveBridgeHubApiBaseUrl(); +// WalletConnect/Reown project id: config.json's walletConnect.projectId (a +// runtime value, settable per-container-instance -- see entrypoint.sh and +// docs/docker.md) is authoritative. NEXT_PUBLIC_PROJECT_ID, when non-empty, +// overrides it -- exactly the same precedence rule as +// resolveAggkitProxyOverride above (design.md §6.2's "build-time env +// overrides the served config" pattern), kept ONLY as a local-dev/Playwright +// convenience. build:production's .env.production deliberately does not set +// this var, so a published container image never has an override to fall +// back to and always reads config.json's value. +const resolveProjectIdOverride = (): string | undefined => { + const envOverride = process.env.NEXT_PUBLIC_PROJECT_ID?.trim(); + return envOverride ? envOverride : undefined; +}; -// Add custom RPC URLs on a per-chain basis as needed. -export const customRpcUrls: Record = { - // Example: - // 'eip155:1234': [{ url: 'https://rpc.example.org' }], +/** + * Builds the resolved, per-networkId aggkitBridgeApis map every downstream + * consumer (AggkitBridgeAggregator, app/utils/appMode.ts, ...) expects. This + * stays a Record at runtime -- fanned out from the mode's + * single `aggkitProxy` value across every non-L1 networkId its chains use -- + * even though config.json itself only ever declares one URL per mode; every + * downstream consumer keeps addressing aggkit per-network, it just never has + * to know the whole mode is actually behind one proxy. + */ +const buildAggkitBridgeApisMap = ( + modeConfigJson: JsonAppModeConfig, + nonL1NetworkIds: number[], + aggkitProxyOverride: string | undefined +): Record => { + const effectiveProxy = aggkitProxyOverride ?? modeConfigJson.aggkitProxy; + if (effectiveProxy === undefined) return {}; + + return Object.fromEntries(nonL1NetworkIds.map((networkId) => [networkId, effectiveProxy])); }; -export const EXTERNAL_LINKS = Object.freeze({ - PRIVACY_POLICY: configJson.externalLinks.privacyPolicy, - TERMS_OF_USE: configJson.externalLinks.termsOfUse, - CONTACT_SUPPORT: configJson.externalLinks.contactSupport -}); - -const CHAIN_REGISTRY: Record = Object.fromEntries( - Object.entries(configJson.chains).map(([chainKey, chainConfigJson]) => [ - chainKey, - createChainEntry({ - wagmi: buildWagmiChain(chainConfigJson), - icon: chainConfigJson.iconUrl, - networkId: chainConfigJson.networkId, - isTestnet: chainConfigJson.isTestnet, - eta: chainConfigJson.eta - }) - ]) -); - -export const DEFAULT_APP_MODE: AppMode = configJson.appModes.default; +const resolveAutoclaimConfig = (overrides: JsonConfig['autoclaim']): AutoclaimConfig => { + const safeOverrides = overrides ?? {}; + const resolveRoute = (route: RouteType): AutoclaimRouteConfig => { + const override = safeOverrides[route]; + if (!override) return DEFAULT_AUTOCLAIM_CONFIG[route]; + return { + expectedAutoclaim: override.expectedAutoclaim, + waitForAutoclaimMs: + override.waitForAutoclaimMs ?? DEFAULT_AUTOCLAIM_CONFIG[route].waitForAutoclaimMs + }; + }; + return { + l1_to_l2: resolveRoute('l1_to_l2'), + l2_to_l1: resolveRoute('l2_to_l1'), + l2_to_l2: resolveRoute('l2_to_l2') + }; +}; const toEnabledChains = (chains: AppChain[]): EnabledAppModeConfig['chains'] | undefined => { const [first, second, ...rest] = chains; @@ -61,18 +135,28 @@ const toEnabledChains = (chains: AppChain[]): EnabledAppModeConfig['chains'] | u return [first, second, ...rest]; }; -const buildModeConfig = (modeKey: string): AppModeConfig => { +const buildModeConfig = ( + modeKey: string, + configJson: JsonConfig, + chainRegistry: Record, + aggkitProxyOverride: string | undefined +): AppModeConfig => { const modeConfigJson = configJson.appModes.configs[modeKey]; if (!modeConfigJson) { - return { label: modeKey, bridgeAddress: '', proofApiUrl: '', chains: [] }; + return { label: modeKey, bridgeAddress: '', aggkitBridgeApis: {}, chains: [] }; } - const chains = modeConfigJson.chainKeys.map((chainKey) => CHAIN_REGISTRY[chainKey].app); + const chains = modeConfigJson.chainKeys.map((chainKey) => chainRegistry[chainKey].app); + // L1 (networkId 0) never keys an aggkitBridgeApis entry (design.md §1.2) -- + // only non-L1 networks get fanned out from this mode's aggkitProxy. + const nonL1NetworkIds = chains + .filter((chain) => chain.networkId !== 0) + .map((chain) => chain.networkId); const base = { label: modeConfigJson.label, bridgeAddress: modeConfigJson.bridgeAddress, - proofApiUrl: toProofApiUrl(bridgeHubApiBaseUrl, modeConfigJson.proofApiSuffix) + aggkitBridgeApis: buildAggkitBridgeApisMap(modeConfigJson, nonL1NetworkIds, aggkitProxyOverride) }; const enabledChains = toEnabledChains(chains); @@ -82,10 +166,10 @@ const buildModeConfig = (modeKey: string): AppModeConfig => { const [primaryChain, secondaryChain] = enabledChains; const defaultFromChainId = modeConfigJson.defaultFromChainKey - ? CHAIN_REGISTRY[modeConfigJson.defaultFromChainKey].app.id + ? chainRegistry[modeConfigJson.defaultFromChainKey].app.id : primaryChain.id; const defaultToChainId = modeConfigJson.defaultToChainKey - ? CHAIN_REGISTRY[modeConfigJson.defaultToChainKey].app.id + ? chainRegistry[modeConfigJson.defaultToChainKey].app.id : secondaryChain.id; return { @@ -96,25 +180,116 @@ const buildModeConfig = (modeKey: string): AppModeConfig => { }; }; -export const APP_MODE_CONFIG: Record = Object.fromEntries( - APP_MODES.map((mode) => [mode, buildModeConfig(mode)]) -) as Record; - -export const ALL_WAGMI_CHAINS: readonly [Chain, ...Chain[]] = toNonEmptyChainArray( - Object.values(CHAIN_REGISTRY).map((entry) => entry.wagmi) -); - -const getDefaultWagmiChain = (): Chain => { - const defaultModeConfig = APP_MODE_CONFIG[DEFAULT_APP_MODE]; +const resolveDefaultWagmiChain = ( + defaultAppMode: AppMode, + appModeConfig: Record, + allWagmiChains: readonly [Chain, ...Chain[]] +): Chain => { + const defaultModeConfig = appModeConfig[defaultAppMode]; const defaultFromChainId = 'defaultFromChainId' in defaultModeConfig ? defaultModeConfig.defaultFromChainId : undefined; const defaultChainId = defaultFromChainId ?? defaultModeConfig.chains[0]?.id; if (defaultChainId === undefined) { - return ALL_WAGMI_CHAINS[0]; + return allWagmiChains[0]; } - return ALL_WAGMI_CHAINS.find((chain) => chain.id === defaultChainId) ?? ALL_WAGMI_CHAINS[0]; + return allWagmiChains.find((chain) => chain.id === defaultChainId) ?? allWagmiChains[0]; +}; + +/** + * Pure function of a schema-valid, URL-normalized JsonConfig (see + * config/configLoader.mjs) plus process.env.NEXT_PUBLIC_AGGKIT_PROXY + * (unchanged precedence: build-time env overrides the served config, applied + * identically to every mode -- design.md §6.2). Exported separately from + * `initAppConfig` so tests can exercise the derivations and the precedence + * rule without touching the module store. + */ +export const buildAppConfig = (configJson: JsonConfig): ResolvedAppConfig => { + const aggkitProxyOverride = resolveAggkitProxyOverride(); + + const chainRegistry: Record = Object.fromEntries( + Object.entries(configJson.chains).map(([chainKey, chainConfigJson]) => [ + chainKey, + createChainEntry({ + wagmi: buildWagmiChain(chainConfigJson), + icon: chainConfigJson.iconUrl, + networkId: chainConfigJson.networkId, + isTestnet: chainConfigJson.isTestnet, + eta: chainConfigJson.eta + }) + ]) + ); + + const defaultAppMode: AppMode = configJson.appModes.default; + + const appModeConfig: Record = Object.fromEntries( + APP_MODES.map((mode) => [ + mode, + buildModeConfig(mode, configJson, chainRegistry, aggkitProxyOverride) + ]) + ) as Record; + + const allWagmiChains: readonly [Chain, ...Chain[]] = toNonEmptyChainArray( + Object.values(chainRegistry).map((entry) => entry.wagmi) + ); + + const defaultWagmiChain = resolveDefaultWagmiChain(defaultAppMode, appModeConfig, allWagmiChains); + + const projectId = resolveProjectIdOverride() ?? configJson.walletConnect.projectId; + + return { + autoclaim: resolveAutoclaimConfig(configJson.autoclaim), + externalLinks: Object.freeze({ + PRIVACY_POLICY: configJson.externalLinks.privacyPolicy, + TERMS_OF_USE: configJson.externalLinks.termsOfUse, + CONTACT_SUPPORT: configJson.externalLinks.contactSupport + }), + chainRegistry, + defaultAppMode, + appModeConfig, + allWagmiChains, + defaultWagmiChain, + walletConnect: Object.freeze({ projectId }) + }; +}; + +// ---- store ---- +// Populated once by AppConfigGate (browser) or tests/e2e/appConfig.ts (Node), +// before any consumer render/call can observe it. See design.md §7.1: a +// module store, not a React context -- app/utils/appMode.ts is a non-React +// pure module, and the same accessors must serve Node callers too. Config is +// immutable for the lifetime of the page (design.md §8): no re-fetch, no live +// reconfiguration. +let appConfig: ResolvedAppConfig | undefined; + +/** Builds, stores, and returns the resolved config. */ +export const initAppConfig = (configJson: JsonConfig): ResolvedAppConfig => { + appConfig = buildAppConfig(configJson); + return appConfig; +}; + +/** Tests only: clears the store so a fresh initAppConfig call can be asserted. */ +export const resetAppConfig = (): void => { + appConfig = undefined; +}; + +export const isAppConfigReady = (): boolean => appConfig !== undefined; + +export const getAppConfig = (): ResolvedAppConfig => { + if (!appConfig) { + throw new Error('APP_CONFIG_NOT_LOADED: app config was read before AppConfigGate resolved it'); + } + return appConfig; }; -export const DEFAULT_WAGMI_CHAIN: Chain = getDefaultWagmiChain(); +// ---- narrow accessors: one per pre-refactor export, so call sites are +// one-token edits (identifier -> accessor call) ---- +export const getAutoclaimConfig = (): AutoclaimConfig => getAppConfig().autoclaim; +export const getExternalLinks = (): ResolvedAppConfig['externalLinks'] => + getAppConfig().externalLinks; +export const getAppModeConfig = (): Record => getAppConfig().appModeConfig; +export const getDefaultAppMode = (): AppMode => getAppConfig().defaultAppMode; +export const getAllWagmiChains = (): readonly [Chain, ...Chain[]] => getAppConfig().allWagmiChains; +export const getDefaultWagmiChain = (): Chain => getAppConfig().defaultWagmiChain; +export const getWalletConnectProjectId = (): string => getAppConfig().walletConnect.projectId; diff --git a/app/configLoader.test.ts b/app/configLoader.test.ts new file mode 100644 index 0000000..f958269 --- /dev/null +++ b/app/configLoader.test.ts @@ -0,0 +1,149 @@ +import type { JsonConfig } from '@/app/types/config'; + +import { APP_CONFIG_URL, fetchAppConfig } from '@/app/configLoader'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Same fixture shape as config/configLoader.test.mjs and +// config/configValidator.test.mjs (design.md §4): a schema-valid, +// semantically-valid devnet config with three chains and one enabled mode, +// using the single aggkitProxy field. +const chain = (overrides: Partial = {}) => ({ + id: 1, + name: 'Chain', + rpcUrl: 'https://rpc.example', + explorerUrl: 'https://explorer.example', + currency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + iconUrl: 'https://icon.example/icon.svg', + networkId: 0, + isTestnet: true, + eta: 1, + ...overrides +}); + +const buildConfig = (aggkitProxy?: string): JsonConfig => + ({ + walletConnect: { projectId: 'test-project-id' }, + externalLinks: { privacyPolicy: '', termsOfUse: '', contactSupport: '' }, + chains: { + DEVNET_L1: chain({ id: 271828, name: 'Devnet L1', networkId: 0 }), + DEVNET_L2_001: chain({ id: 20201, name: 'Devnet L2-001', networkId: 1 }), + DEVNET_L2_002: chain({ id: 20202, name: 'Devnet L2-002', networkId: 2 }) + }, + appModes: { + default: 'devnet', + configs: { + devnet: { + label: 'Devnet', + bridgeAddress: '0xC8cbEBf950B9Df44d987c8619f092beA980fF038', + ...(aggkitProxy === undefined ? {} : { aggkitProxy }), + chainKeys: ['DEVNET_L1', 'DEVNET_L2_001', 'DEVNET_L2_002'], + defaultFromChainKey: 'DEVNET_L1', + defaultToChainKey: 'DEVNET_L2_001' + } + } + } + }) as JsonConfig; + +const mockFetchOnce = (impl: () => Promise> | Partial) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => impl()) + ); +}; + +const okResponse = (body: string): Partial => ({ + ok: true, + status: 200, + statusText: 'OK', + text: () => Promise.resolve(body) +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('fetchAppConfig — success (A-5 item 1)', () => { + it('yields the expected, URL-normalized config from a valid served payload', async () => { + const served = buildConfig('https://aggkit.example/1'); + mockFetchOnce(() => okResponse(JSON.stringify(served))); + + const result = await fetchAppConfig({ origin: 'https://app.example' }); + + expect(result.appModes.default).toBe('devnet'); + expect(Object.keys(result.chains)).toEqual(['DEVNET_L1', 'DEVNET_L2_001', 'DEVNET_L2_002']); + expect(result.appModes.configs.devnet.aggkitProxy).toBe('https://aggkit.example/1'); + expect(fetch).toHaveBeenCalledWith(APP_CONFIG_URL, { cache: 'no-store' }); + }); + + it('resolves a relative aggkitProxy value against the given origin (design.md §5)', async () => { + const served = buildConfig('/aggkitapi'); + mockFetchOnce(() => okResponse(JSON.stringify(served))); + + const result = await fetchAppConfig({ origin: 'https://app.example' }); + + expect(result.appModes.configs.devnet.aggkitProxy).toBe('https://app.example/aggkitapi'); + }); + + it('defaults the origin to window.location.origin when none is passed', async () => { + const served = buildConfig('/aggkitapi'); + mockFetchOnce(() => okResponse(JSON.stringify(served))); + + const result = await fetchAppConfig(); + + expect(result.appModes.configs.devnet.aggkitProxy).toBe(`${window.location.origin}/aggkitapi`); + }); +}); + +describe('fetchAppConfig — each failure mode surfaces a distinguishable error (A-5 item 2)', () => { + it('HTTP 404: throws APP_CONFIG_FETCH_FAILED with the status', async () => { + mockFetchOnce(() => ({ + ok: false, + status: 404, + statusText: 'Not Found', + text: () => Promise.resolve('') + })); + + await expect(fetchAppConfig()).rejects.toThrow( + /APP_CONFIG_FETCH_FAILED: GET \/config\.json returned 404 Not Found/ + ); + }); + + it('non-JSON body: throws APP_CONFIG_INVALID naming the parse failure', async () => { + mockFetchOnce(() => okResponse('not-json{')); + + await expect(fetchAppConfig()).rejects.toThrow( + /APP_CONFIG_INVALID: \/config\.json is not valid JSON/ + ); + }); + + it('schema violation: throws with the schema validation message', async () => { + mockFetchOnce(() => okResponse(JSON.stringify({}))); + + await expect(fetchAppConfig()).rejects.toThrow(/config\.json schema validation failed:/); + }); + + it('schema violation: throws for the removed aggkitBridgeApis map (unrecognized key)', async () => { + const served = buildConfig('https://aggkit.example/1'); + (served.appModes.configs.devnet as { aggkitBridgeApis?: unknown }).aggkitBridgeApis = { + 1: 'https://aggkit.example/1' + }; + mockFetchOnce(() => okResponse(JSON.stringify(served))); + + await expect(fetchAppConfig()).rejects.toThrow( + /config\.json schema validation failed:\n- appModes\.configs\.devnet: Unrecognized key: "aggkitBridgeApis"/ + ); + }); + + it('network error: throws APP_CONFIG_FETCH_FAILED naming the cause', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('connection refused'); + }) + ); + + await expect(fetchAppConfig()).rejects.toThrow( + /APP_CONFIG_FETCH_FAILED: GET \/config\.json failed: connection refused/ + ); + }); +}); diff --git a/app/configLoader.ts b/app/configLoader.ts new file mode 100644 index 0000000..d5fc311 --- /dev/null +++ b/app/configLoader.ts @@ -0,0 +1,50 @@ +import type { JsonConfig } from '@/app/types/config'; + +import { normalizeConfigOrThrow } from '@/config/configLoader.mjs'; + +// Root-absolute path. Documented limitation: this assumes the app is served +// at origin root (matches docs/deployment.md and wrangler.toml — next.config.ts +// sets no basePath). If one is ever added, this constant must be prefixed. +export const APP_CONFIG_URL = '/config.json'; + +/** + * Fetches and validates the runtime config served alongside this build. + * Each failure mode produces a distinguishable message (see A-1 design §4.2): + * non-OK HTTP, non-JSON body, schema violation, semantic violation, network + * error. Uses `cache: 'no-store'` as defence in depth alongside nginx's + * `Cache-Control: no-store` (R7), so a browser cache hit can never serve a + * previous container's config after a restart. + */ +export const fetchAppConfig = async (options?: { + url?: string; + origin?: string; +}): Promise => { + const url = options?.url ?? APP_CONFIG_URL; + const origin = options?.origin ?? window.location.origin; + + let response: Response; + try { + response = await fetch(url, { cache: 'no-store' }); + } catch (error) { + const cause = error instanceof Error ? error.message : String(error); + throw new Error(`APP_CONFIG_FETCH_FAILED: GET ${url} failed: ${cause}`); + } + + if (!response.ok) { + throw new Error( + `APP_CONFIG_FETCH_FAILED: GET ${url} returned ${response.status} ${response.statusText}` + ); + } + + const bodyText = await response.text(); + + let rawConfig: unknown; + try { + rawConfig = JSON.parse(bodyText); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown JSON parse error'; + throw new Error(`APP_CONFIG_INVALID: ${url} is not valid JSON: ${message}`); + } + + return normalizeConfigOrThrow(rawConfig, { sourceName: 'config.json', origin }) as JsonConfig; +}; diff --git a/app/constants/e2e.ts b/app/constants/e2e.ts index de836dd..4e20417 100644 --- a/app/constants/e2e.ts +++ b/app/constants/e2e.ts @@ -17,7 +17,145 @@ export const E2E_PRIVATE_KEY = IS_E2E_ENABLED ? (resolvedPrivateKey as Hex) : un export const E2E_WALLET_ADDRESS: Address | undefined = E2E_PRIVATE_KEY ? privateKeyToAccount(E2E_PRIVATE_KEY).address : undefined; -export const E2E_ERC20_ADDRESS: Address = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238'; -export const E2E_FROM_CHAIN_ID = 11155111; -export const E2E_NATIVE_BRIDGE_AMOUNT = '0.00001'; -export const E2E_ERC20_BRIDGE_AMOUNT = '0.01'; + +// E2E defaults to the local Kurtosis `cdk` enclave (aggkit backend) -- +// scripts/kurtosisDevnetEnv.mjs resolves that enclave's live ports into +// config.json/.env.local, and manual full-journey validation against it +// documents the timings these constants are tuned against (see the E2E +// timeout comments further down). Set E2E_BACKEND_MODE=testnet to instead +// run against real Sepolia/Bokuto testnet infrastructure (the previous +// default before the aggkit devnet backend) -- e.g. for a periodic canary run outside +// the devnet. +export type E2EBackendMode = 'devnet' | 'testnet'; + +const resolveBackendMode = (): E2EBackendMode => + normalizeEnvValue(process.env.E2E_BACKEND_MODE).toLowerCase() === 'testnet' + ? 'testnet' + : 'devnet'; + +export const E2E_BACKEND_MODE: E2EBackendMode = resolveBackendMode(); + +const parsePositiveInt = (value: string, fallback: number): number => { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +// Devnet L1 (config.json `chains.DEVNET_L1`, written by kurtosisDevnetEnv.mjs). +const DEVNET_FROM_CHAIN_ID = 271828; +// Sepolia -- only used as the testnet-mode default; irrelevant when +// E2E_BACKEND_MODE=devnet (the default). +const TESTNET_FROM_CHAIN_ID = 11155111; + +export const E2E_FROM_CHAIN_ID = (() => { + const envOverride = normalizeEnvValue(process.env.E2E_FROM_CHAIN_ID); + if (envOverride) return parsePositiveInt(envOverride, DEVNET_FROM_CHAIN_ID); + return E2E_BACKEND_MODE === 'testnet' ? TESTNET_FROM_CHAIN_ID : DEVNET_FROM_CHAIN_ID; +})(); + +// Devnet L2-1 (params-aggkit-l2l2-run1.yml l2_chain_id: 20201). +const DEVNET_TO_CHAIN_ID = 20201; +// Bokuto -- testnet-mode default only. +const TESTNET_TO_CHAIN_ID = 737373; + +export const E2E_TO_CHAIN_ID = (() => { + const envOverride = normalizeEnvValue(process.env.E2E_TO_CHAIN_ID); + if (envOverride) return parsePositiveInt(envOverride, DEVNET_TO_CHAIN_ID); + return E2E_BACKEND_MODE === 'testnet' ? TESTNET_TO_CHAIN_ID : DEVNET_TO_CHAIN_ID; +})(); + +// The full set of devnet L2 chain ids (L2-1, L2-2, ...), +// comma-separated by scripts/kurtosisDevnetEnv.mjs into E2E_L2_CHAIN_IDS. +// Used by tests/bridge/l2-to-l2.spec.ts to reach the second L2 +// (params-aggkit-l2l2-run2.yml l2_chain_id: 20202) without hardcoding a +// suffix that the discovery script (scripts/kurtosisDevnetEnv.mjs) could reassign. +// Devnet-only: testnet mode has a single L2 (Bokuto), so the fallback below +// has only one entry in testnet mode, which the L2->L2 spec's +// `E2E_BACKEND_MODE !== 'devnet'` skip guard relies on never being reached. +const DEVNET_L2_CHAIN_IDS = [DEVNET_TO_CHAIN_ID, 20202]; +const TESTNET_L2_CHAIN_IDS = [TESTNET_TO_CHAIN_ID]; + +export const E2E_L2_CHAIN_IDS: number[] = (() => { + const fallback = E2E_BACKEND_MODE === 'testnet' ? TESTNET_L2_CHAIN_IDS : DEVNET_L2_CHAIN_IDS; + const envOverride = normalizeEnvValue(process.env.E2E_L2_CHAIN_IDS); + if (!envOverride) return fallback; + const parsed = envOverride + .split(',') + .map((part) => parsePositiveInt(part.trim(), Number.NaN)) + .filter((value) => Number.isFinite(value) && value > 0); + return parsed.length >= 2 ? parsed : fallback; +})(); + +// Route-specific budgets. Cite the measurement in the code comment. +// +// L2->L2 send->claimed. Early samples were ~87s (S6, busy enclave) and ~2m11s +// (S3c, idle enclave), which is where the previous 300s budget came from. S14 +// measured ~5m30s on a longer-lived enclave and blew straight through it: the +// dominant term is not autoclaim but SOURCE-side settlement -- L2-1's aggsender +// must get a certificate covering the deposit's block settled on L1 before the +// deposit even appears in the L1 info tree (`/l1-info-tree-index` 500s with +// "not been included on the L1 Info Tree yet" until then). That wait grows with +// enclave age/load and with any certificate already in flight -- and +// l2-to-l2.spec.ts now funds its own L1->L2-1 top-up first, which deliberately +// puts one there. Sampled range is 87s..330s, a ~3.8x spread, so this is +// budgeted well above the worst observation rather than just over it. NOT +// related to `MinimumNewCertificateInterval: 5m0s`, which is a maximum-idle +// heartbeat and not a floor on certificate spacing. +const DEFAULT_L2_TO_L2_CLAIM_TIMEOUT_MS = 600_000; +// L2->L1 send->claim-proof-ready, conservative ~8m34s (S3c criterion 3) -> +// +~17%. S6's busier-enclave sample was <=4m21s. +const DEFAULT_PROOF_READY_TIMEOUT_MS = 600_000; + +export const E2E_L2_TO_L2_CLAIM_TIMEOUT_MS = parsePositiveInt( + normalizeEnvValue(process.env.E2E_L2_TO_L2_CLAIM_TIMEOUT_MS), + DEFAULT_L2_TO_L2_CLAIM_TIMEOUT_MS +); +export const E2E_PROOF_READY_TIMEOUT_MS = parsePositiveInt( + normalizeEnvValue(process.env.E2E_PROOF_READY_TIMEOUT_MS), + DEFAULT_PROOF_READY_TIMEOUT_MS +); + +// Sepolia USDC -- the one fixed, always-funded ERC20 available in testnet +// mode. +const TESTNET_ERC20_ADDRESS: Address = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238'; + +// A devnet ERC20 deployed once during manual validation ("S12 Test Token" / +// S12T, 18 decimals, minted to the funded E2E wallet +// 0xE34aaF64b29273B7D567FCFc40544c014EEe9970). Playwright's globalSetup (tests/e2e/globalSetup.ts) checks this +// address first (bytecode + balance still present) before deploying a fresh +// token, so repeat local runs against the same long-lived enclave don't +// redeploy every time. If the enclave was recreated since, the liveness +// check simply fails and globalSetup deploys a fresh one instead. +export const DEVNET_KNOWN_ERC20_CANDIDATE: Address = '0xE31D957c46DFFd0f6179c9DAb7779ccB725770ee'; + +// In devnet mode this resolves to `undefined` until Playwright's globalSetup +// sets process.env.E2E_ERC20_ADDRESS (before any spec file is loaded) -- +// see tests/e2e/globalSetup.ts. Specs that need it must guard against +// `undefined` rather than assume it's always set at import time. +export const E2E_ERC20_ADDRESS: Address | undefined = (() => { + const envOverride = normalizeEnvValue(process.env.E2E_ERC20_ADDRESS); + if (envOverride) return envOverride as Address; + return E2E_BACKEND_MODE === 'testnet' ? TESTNET_ERC20_ADDRESS : undefined; +})(); + +const DEFAULT_NATIVE_BRIDGE_AMOUNT = E2E_BACKEND_MODE === 'testnet' ? '0.00001' : '0.001'; + +export const E2E_NATIVE_BRIDGE_AMOUNT = + normalizeEnvValue(process.env.E2E_NATIVE_BRIDGE_AMOUNT) || DEFAULT_NATIVE_BRIDGE_AMOUNT; +export const E2E_ERC20_BRIDGE_AMOUNT = + normalizeEnvValue(process.env.E2E_ERC20_BRIDGE_AMOUNT) || '0.01'; + +// Timeouts tuned per backend. Devnet's ~1s block time and built-in aggkit +// autoclaim are both far faster than real Sepolia/Bokuto testnet +// infrastructure (measured on the devnet: deposit -> ready ~6-35s, ready -> +// claimed ~10-90s -- worst case budgeted below with margin). +const DEFAULT_BRIDGE_SUCCESS_TIMEOUT_MS = E2E_BACKEND_MODE === 'testnet' ? 120_000 : 60_000; +const DEFAULT_CLAIM_TIMEOUT_MS = E2E_BACKEND_MODE === 'testnet' ? 300_000 : 150_000; + +export const E2E_BRIDGE_SUCCESS_TIMEOUT_MS = parsePositiveInt( + normalizeEnvValue(process.env.E2E_BRIDGE_SUCCESS_TIMEOUT_MS), + DEFAULT_BRIDGE_SUCCESS_TIMEOUT_MS +); +export const E2E_CLAIM_TIMEOUT_MS = parsePositiveInt( + normalizeEnvValue(process.env.E2E_CLAIM_TIMEOUT_MS), + DEFAULT_CLAIM_TIMEOUT_MS +); diff --git a/app/context/aggLayerSdk.tsx b/app/context/aggLayerSdk.tsx index 47490bf..e287ac3 100644 --- a/app/context/aggLayerSdk.tsx +++ b/app/context/aggLayerSdk.tsx @@ -6,13 +6,18 @@ import type { PropsWithChildren } from 'react'; import { useAppMode } from '@/app/context/appMode'; import React, { createContext, useContext, useMemo } from 'react'; -import { AggLayerSDK, SDK_MODES } from '@agglayer/sdk'; +import { AggkitBridgeAggregator, AggLayerSDK, SDK_MODES } from '@agglayer/sdk'; type AggNative = ReturnType; const AggLayerNativeContext = createContext(null); -const toSdkChainConfig = (chain: AppChain, bridgeAddress: string, proofApiUrl: string) => ({ +// NATIVE's `ChainConfig.proofApiUrl` served the old bridge-hub `BridgeUtil` +// claim-proof path. The dev-ui never calls that path (it uses +// `buildClaimAsset(params)` with an externally-fetched proof, now sourced +// from `AggkitBridgeAggregator.getClaimInputs`), so `proofApiUrl` is simply +// omitted here rather than pointed at a real URL. +const toSdkChainConfig = (chain: AppChain, bridgeAddress: string) => ({ chainId: chain.id, networkId: chain.networkId, name: chain.name, @@ -24,7 +29,6 @@ const toSdkChainConfig = (chain: AppChain, bridgeAddress: string, proofApiUrl: s }, blockExplorer: chain.explorer ? { name: chain.name, url: chain.explorer } : undefined, bridgeAddress, - proofApiUrl, isTestnet: chain.isTestnet }); @@ -36,8 +40,16 @@ export const AggLayerSDKProvider: React.FC = ({ children }) = mode: [SDK_MODES.NATIVE], native: { defaultNetwork: config.defaultFromChainId, + // Registers each enabled mode's chains (incl. devnet's enclave L1/L2) + // into the SDK's shared chainRegistry singleton — this is also what + // makes `AggkitBridgeAggregator.getTokenMetadata()`'s native branch + // (`chainRegistry.getChainByNetworkId`) resolve devnet's L2 (networkId + // 1) correctly, and devnet's L1 (networkId 0) too: on a networkId + // collision, ChainRegistry.getChainByNetworkId prefers a + // consumer-registered chain (this one) over its own pre-registered + // Ethereum mainnet/Sepolia defaults, regardless of registration order. chains: config.chains.map((chain: AppChain) => - toSdkChainConfig(chain, config.bridgeAddress, config.proofApiUrl) + toSdkChainConfig(chain, config.bridgeAddress) ) } }); @@ -55,3 +67,31 @@ export const useAggNative = (): AggNative => { } return native; }; + +const AggkitAggregatorContext = createContext(null); + +// Sibling to AggLayerSDKProvider: fans out to one +// AggkitBridgeClient per configured L2 network (app/services/* consume this +// instead of calling the old bridge-hub REST endpoints directly). +export const AggkitAggregatorProvider: React.FC = ({ children }) => { + const { config } = useAppMode(); + + const aggregator = useMemo( + () => new AggkitBridgeAggregator({ networks: config.aggkitBridgeApis }), + [config] + ); + + return ( + + {children} + + ); +}; + +export const useAggkitAggregator = (): AggkitBridgeAggregator => { + const aggregator = useContext(AggkitAggregatorContext); + if (!aggregator) { + throw new Error('useAggkitAggregator must be used within AggkitAggregatorProvider'); + } + return aggregator; +}; diff --git a/app/context/appMode.tsx b/app/context/appMode.tsx index 91e7beb..495a4e8 100644 --- a/app/context/appMode.tsx +++ b/app/context/appMode.tsx @@ -3,7 +3,8 @@ import type { AppChain, AppMode, EnabledAppModeConfig } from '@/app/types/appMode'; import type { ReactNode } from 'react'; -import { APP_MODE_CONFIG, DEFAULT_APP_MODE } from '@/app/config'; +import { ConfigErrorScreen } from '@/app/components/appConfigGate'; +import { getAppModeConfig, getDefaultAppMode } from '@/app/config'; import { getEnabledModes, isEnabledModeConfig, isValidAppMode } from '@/app/utils/appMode'; import { StorageUtils, STORAGE_KEYS } from '@/app/utils/storage'; import { createContext, useCallback, useContext, useMemo, useSyncExternalStore } from 'react'; @@ -23,23 +24,12 @@ export const AppModeContext = createContext(null); const MODE_EVENT = 'app-mode-change' as const; -const enabledModes = getEnabledModes(); -const defaultMode = enabledModes.includes(DEFAULT_APP_MODE) - ? DEFAULT_APP_MODE - : (enabledModes[0] ?? DEFAULT_APP_MODE); - -const resolveStoredMode = (value: unknown): AppMode | null => { - if (!isValidAppMode(value)) return null; - if (!enabledModes.includes(value)) return null; - return value; -}; - -const getStoredMode = (): AppMode => { - if (typeof window === 'undefined') return defaultMode; - const storedMode = StorageUtils.getItem(STORAGE_KEYS.APP_MODE); - return resolveStoredMode(storedMode) ?? defaultMode; -}; - +// enabledModes/defaultMode used to be module-scope constants computed from +// the eager APP_MODE_CONFIG/DEFAULT_APP_MODE exports. Config is now read +// through accessors that require AppConfigGate to have resolved first, so +// these move into AppModeProvider's render (memoized -- config is immutable +// for the lifetime of the page, design.md §8, so this only ever computes +// once per mount). const subscribeToMode = (callback: () => void) => { if (typeof window === 'undefined') return () => {}; const handleStorage = (event: StorageEvent) => { @@ -81,37 +71,80 @@ const createAppModeContextValue = ({ }; export const AppModeProvider = ({ children }: { children: ReactNode }) => { - const mode = useSyncExternalStore(subscribeToMode, getStoredMode, () => defaultMode); + // Config is guaranteed loaded here: AppModeProvider only ever mounts inside + // AppConfigGate's 'ready' branch (app/providers.tsx). Memoized because + // config never changes for the lifetime of the page (design.md §8). + const enabledModes = useMemo(() => getEnabledModes(), []); + const defaultMode = useMemo(() => { + const configDefaultMode = getDefaultAppMode(); + return enabledModes.includes(configDefaultMode) + ? configDefaultMode + : (enabledModes[0] ?? configDefaultMode); + }, [enabledModes]); + + const resolveStoredMode = useCallback( + (value: unknown): AppMode | null => { + if (!isValidAppMode(value)) return null; + if (!enabledModes.includes(value)) return null; + return value; + }, + [enabledModes] + ); - const setMode = useCallback((nextMode: AppMode) => { - if (!enabledModes.includes(nextMode)) return; - if (typeof window === 'undefined') return; + const getStoredMode = useCallback((): AppMode => { + if (typeof window === 'undefined') return defaultMode; + const storedMode = StorageUtils.getItem(STORAGE_KEYS.APP_MODE); + return resolveStoredMode(storedMode) ?? defaultMode; + }, [defaultMode, resolveStoredMode]); - const current = getStoredMode(); - if (current === nextMode) return; + const getServerMode = useCallback((): AppMode => defaultMode, [defaultMode]); - const stored = StorageUtils.setItem(STORAGE_KEYS.APP_MODE, nextMode); - if (!stored) return; + const mode = useSyncExternalStore(subscribeToMode, getStoredMode, getServerMode); - window.dispatchEvent(new Event(MODE_EVENT)); - }, []); + const setMode = useCallback( + (nextMode: AppMode) => { + if (!enabledModes.includes(nextMode)) return; + if (typeof window === 'undefined') return; - const config = APP_MODE_CONFIG[mode]; - if (!isEnabledModeConfig(config)) { - throw new Error(`APP_MODE_DISABLED: ${mode}`); - } + const current = getStoredMode(); + if (current === nextMode) return; + + const stored = StorageUtils.setItem(STORAGE_KEYS.APP_MODE, nextMode); + if (!stored) return; - const value = useMemo( - () => - createAppModeContextValue({ - mode, - setMode, - enabledModes, - config - }), - [mode, setMode, config] + window.dispatchEvent(new Event(MODE_EVENT)); + }, + [enabledModes, getStoredMode] ); + const config = getAppModeConfig()[mode]; + + const value = useMemo(() => { + if (!isEnabledModeConfig(config)) return null; + return createAppModeContextValue({ + mode, + setMode, + enabledModes, + config + }); + }, [mode, setMode, enabledModes, config]); + + // Unreachable with a validated config (configValidator.mjs rejects any + // config where every mode has fewer than 2 chainKeys, and defaultMode above + // always resolves to an enabled mode when at least one exists) -- but + // rendered as a legible error screen rather than a white page or a thrown + // exception (design.md §10.1). Loading and outright config-fetch failure + // are both handled upstream by AppConfigGate; this is the third, residual + // "disabled" state. + if (!value) { + return ( + window.location.reload()} + /> + ); + } + return {children}; }; diff --git a/app/context/wallet.tsx b/app/context/wallet.tsx index c8c8b07..c8dab9e 100644 --- a/app/context/wallet.tsx +++ b/app/context/wallet.tsx @@ -1,12 +1,21 @@ 'use client'; +import type { ResolvedAppConfig } from '@/app/config'; import type { WalletContextValue } from '@/app/context/walletContext'; import type { ReactNode } from 'react'; +import type { Chain } from 'wagmi/chains'; -import { ALL_WAGMI_CHAINS, customRpcUrls, DEFAULT_WAGMI_CHAIN, EXTERNAL_LINKS } from '@/app/config'; +import { + customRpcUrls, + getAllWagmiChains, + getDefaultWagmiChain, + getExternalLinks, + getWalletConnectProjectId +} from '@/app/config'; import { IS_E2E_ENABLED } from '@/app/constants/e2e'; import { e2eWalletAddress } from '@/app/context/e2eAccount'; import { WalletContext } from '@/app/context/walletContext'; +import { isPlaceholderProjectId, resolveMetadataUrl } from '@/app/utils/reownConfig'; import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'; import { createAppKit, @@ -16,18 +25,9 @@ import { useWalletInfo } from '@reown/appkit/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useChainId, useChains, useSwitchChain, WagmiProvider } from 'wagmi'; -const projectId = process.env.NEXT_PUBLIC_PROJECT_ID!; -const queryClient = new QueryClient(); -const wagmiAdapter = new WagmiAdapter({ - ssr: true, - projectId, - customRpcUrls, - networks: [...ALL_WAGMI_CHAINS] -}); - const urlOrUndefined = (value: string): string | undefined => value.trim() === '' ? undefined : value; @@ -38,17 +38,67 @@ const walletIds = { RABBY: '18388be9ac2d02726dbac9777c96efaac06d744b2f6d580fccdd4127a6d01fd1' }; -if (!IS_E2E_ENABLED) { +// With no real WalletConnect Cloud project id configured (config.json's +// walletConnect.projectId left at the checked-in placeholder, or an empty +// value -- see app/config.ts's getWalletConnectProjectId), AppKit's own +// remote-config round trip to api.web3modal.org 403s (invalid projectId) on +// every load. The standing decision is to degrade gracefully rather than +// require a real id for local/dev use. `basic: true` is the narrowest +// documented AppKitOptions lever that does anything about this (see +// @reown/appkit's AppKitOptions.basic jsdoc): it skips AppKit's own +// `!options.basic` guard around fetching remote project config at init +// (eliminating the `/appkit/v1/config` 403 and its "[Reown Config] Failed to +// fetch remote project configuration" warning), and it trims the modal-open +// prefetch to skip network/connector image fetches (eliminating the +// `/public/getAssetImage/*` 403s). It does NOT touch wallet +// detection/connection: `ConnectionController.state.wcBasic` (what +// `basic: true` sets) is only read by the WalletConnect-explorer wallet list +// (recommended/featured/"All Wallets" screens, sourced from AppKit's own +// API) and by the unsupported-chain banner -- never by ConnectorController's +// EIP-6963/injected connector detection or the scaffold-ui Connect screen +// that renders it (verified against the installed +// @reown/appkit-controllers@1.8.19 / @reown/appkit-scaffold-ui sources; +// `wcBasic` does not appear anywhere in appkit-scaffold-ui). The +// injected-wallet connect flow this app actually relies on is therefore +// unaffected. Calls this can't reach (fetchUsage's unconditional +// `/appkit/v1/project-limits`, the featured/recommended `/getWallets` +// prefetch, WalletConnect's identity lookup, and AppKit's own +// mandatory-event analytics beacon) stay environmental/upstream. +// +// A real-shaped project id (anything other than the placeholder/empty) skips +// all of this and behaves exactly as before. + +// createAppKit is a global side effect that needs config values +// (chains/defaultChain/externalLinks/projectId) that are only available once +// AppConfigGate has resolved -- so it can no longer run at module scope. It +// runs instead inside WalletProvider's render (a useMemo, not a useEffect: +// AppKitWalletProvider's child hooks -- useAppKit/useAppKitAccount/ +// useWalletInfo -- run before this component's own effects would, so an +// effect-based init would leave them reading an uninitialized AppKit on the +// first child render). The module-scope flag makes it idempotent under +// StrictMode/concurrent-render double-invocation (design.md §2.2). +let appKitInitialized = false; + +const ensureAppKit = (params: { + chains: readonly [Chain, ...Chain[]]; + defaultChain: Chain; + externalLinks: ResolvedAppConfig['externalLinks']; + adapter: WagmiAdapter; + projectId: string; +}): void => { + if (appKitInitialized) return; + appKitInitialized = true; + createAppKit({ - adapters: [wagmiAdapter], - projectId, - networks: [...ALL_WAGMI_CHAINS], - defaultNetwork: DEFAULT_WAGMI_CHAIN, + adapters: [params.adapter], + projectId: params.projectId, + networks: [...params.chains], + defaultNetwork: params.defaultChain, customRpcUrls, metadata: { name: 'agglayer-dev-ui', description: 'Agglayer Dev UI', - url: 'https://dev-ui.agglayer.dev/', + url: resolveMetadataUrl(), icons: ['https://avatars.githubusercontent.com/u/179229932'] }, features: { @@ -61,15 +111,16 @@ if (!IS_E2E_ENABLED) { history: false, smartSessions: false }, + ...(isPlaceholderProjectId(params.projectId) ? { basic: true } : {}), themeMode: 'light', themeVariables: { '--w3m-accent': '#7b3fe4' }, - termsConditionsUrl: urlOrUndefined(EXTERNAL_LINKS.TERMS_OF_USE), - privacyPolicyUrl: urlOrUndefined(EXTERNAL_LINKS.PRIVACY_POLICY), + termsConditionsUrl: urlOrUndefined(params.externalLinks.TERMS_OF_USE), + privacyPolicyUrl: urlOrUndefined(params.externalLinks.PRIVACY_POLICY), featuredWalletIds: [walletIds.METAMASK] }); -} +}; const useCurrentChain = ({ status, chainId }: { status: string; chainId: number }) => { const chains = useChains(); @@ -89,6 +140,35 @@ const AppKitWalletProvider = ({ children }: { readonly children: ReactNode }) => const { switchChain } = useSwitchChain(); const currentChain = useCurrentChain({ status: status ?? 'disconnected', chainId }); + // On wallet connect, steer the wallet to the app's default source chain + // (getDefaultWagmiChain(), derived from the default app mode) instead of + // leaving it on whatever it connected with (e.g. Ethereum mainnet). This + // triggers the wallet's add/switch-network prompt at connect time rather + // than only when a bridge is initiated. Attempted once per connection so we + // don't fight a user who deliberately switches away or rejects the prompt. + // Safe to call getDefaultWagmiChain() here: this effect only ever runs + // after AppConfigGate has resolved (this component mounts behind the gate). + const hasAutoSwitched = useRef(false); + useEffect(() => { + if (status !== 'connected') { + hasAutoSwitched.current = false; + return; + } + if (hasAutoSwitched.current) { + return; + } + hasAutoSwitched.current = true; + const defaultChainId = getDefaultWagmiChain().id; + if (chainId === defaultChainId) { + return; + } + try { + switchChain({ chainId: defaultChainId }); + } catch (error) { + console.error('Failed to switch to the default network on connect', error); + } + }, [status, chainId, switchChain]); + const value = useMemo( () => ({ address: address ?? '', @@ -144,16 +224,54 @@ const LocalWalletProvider = ({ children }: { readonly children: ReactNode }) => return {children}; }; -const WalletProvider = ({ children }: { children: ReactNode }) => ( - - - {IS_E2E_ENABLED ? ( - {children} - ) : ( - {children} - )} - - -); +const WalletProvider = ({ children }: { children: ReactNode }) => { + const [queryClient] = useState(() => new QueryClient()); + + // WalletProvider only ever mounts behind AppConfigGate (app/providers.tsx), + // so getAllWagmiChains()/getDefaultWagmiChain()/getExternalLinks()/ + // getWalletConnectProjectId() are safe to call unconditionally in render. + // projectId is a runtime config value (config.json's walletConnect.projectId, + // env-overridable for local dev -- see app/config.ts), no longer a + // build-time module-scope constant, so it can only be read here. + const projectId = getWalletConnectProjectId(); + + const wagmiAdapter = useMemo( + () => + new WagmiAdapter({ + ssr: true, + projectId, + customRpcUrls, + networks: [...getAllWagmiChains()] + }), + [projectId] + ); + + // Render-phase init, not an effect -- see the comment on ensureAppKit above + // for why. wagmiAdapter is stable across re-renders (empty deps above), so + // this only ever runs once per mount; the module-scope appKitInitialized + // flag guards StrictMode's double-invocation. + useMemo(() => { + if (IS_E2E_ENABLED) return; + ensureAppKit({ + chains: getAllWagmiChains(), + defaultChain: getDefaultWagmiChain(), + externalLinks: getExternalLinks(), + adapter: wagmiAdapter, + projectId + }); + }, [wagmiAdapter, projectId]); + + return ( + + + {IS_E2E_ENABLED ? ( + {children} + ) : ( + {children} + )} + + + ); +}; export { WalletProvider }; diff --git a/app/hooks/useAutoclaimGate.ts b/app/hooks/useAutoclaimGate.ts new file mode 100644 index 0000000..bbeb571 --- /dev/null +++ b/app/hooks/useAutoclaimGate.ts @@ -0,0 +1,45 @@ +'use client'; + +import type { Transaction } from '@/app/types/transaction'; +import type { AutoclaimGate } from '@/app/utils/autoclaim'; + +import { getAutoclaimConfig } from '@/app/config'; +import { computeAutoclaimGate, getRouteType, recordReadyAt } from '@/app/utils/autoclaim'; +import { useEffect, useState } from 'react'; + +// Decides how the claim affordance should render for a READY_TO_CLAIM deposit, +// applying the per-route autoclaim grace period (config.json `autoclaim` -> +// getAutoclaimConfig()). The grace window is measured from when the deposit +// was first observed READY_TO_CLAIM (persisted in localStorage so it survives +// refreshes) and flips 'waiting' -> 'overdue' via a one-shot timer. +export const useAutoclaimGate = (transaction: Transaction): AutoclaimGate => { + const routeType = getRouteType(transaction.sourceNetwork, transaction.destinationNetwork); + const config = getAutoclaimConfig()[routeType]; + const isReadyToClaim = transaction.status === 'READY_TO_CLAIM'; + const active = isReadyToClaim && config.expectedAutoclaim; + + const [readyAt, setReadyAt] = useState(null); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!active) { + setReadyAt(null); + return; + } + setReadyAt(recordReadyAt(transaction.bridgeHash, Date.now())); + setNow(Date.now()); + }, [active, transaction.bridgeHash]); + + useEffect(() => { + if (!active || readyAt === null) return; + const remaining = readyAt + config.waitForAutoclaimMs - Date.now(); + if (remaining <= 0) { + setNow(Date.now()); + return; + } + const timeoutId = setTimeout(() => setNow(Date.now()), remaining); + return () => clearTimeout(timeoutId); + }, [active, readyAt, config.waitForAutoclaimMs]); + + return computeAutoclaimGate({ config, isReadyToClaim, readyAt, now }); +}; diff --git a/app/hooks/useBridgeExecution.ts b/app/hooks/useBridgeExecution.ts index 4bbff12..2195518 100644 --- a/app/hooks/useBridgeExecution.ts +++ b/app/hooks/useBridgeExecution.ts @@ -152,6 +152,9 @@ export const useBridgeExecution = (params: { fromChainId: number }) => { bridgeTxHash: localBridgeHash }); } catch (error) { + // The modal deliberately shows a generic message (see + // formatErrorMessage) — log the real error so failures are diagnosable. + console.error('[bridge-execution]', error); const message = error instanceof Error ? error.message : 'Transaction failed'; setState({ isExecuting: false, diff --git a/app/hooks/useBridgeTracking.test.tsx b/app/hooks/useBridgeTracking.test.tsx new file mode 100644 index 0000000..f642b38 --- /dev/null +++ b/app/hooks/useBridgeTracking.test.tsx @@ -0,0 +1,151 @@ +import type { Transaction } from '@/app/types/transaction'; +import type { ReactNode } from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// useBridgeTracking's whole job is deciding WHEN to +// stop polling -- this suite drives its `refetchInterval` end to end through +// real react-query, faking time to assert no further `getBridgeTracking` +// calls happen once terminal, and that calls keep coming for every +// non-terminal shape (including a step-level error, which the hook +// explicitly does not treat as terminal). +vi.mock('@/app/context/aggLayerSdk', () => ({ + useAggkitAggregator: vi.fn() +})); +vi.mock('@/app/context/appMode', () => ({ + useAppMode: vi.fn() +})); + +import { + errorGiveupFixture, + l1l2FinishedFixture, + l2l2RunningStepErrorFixture +} from '@/app/__fixtures__/tracker'; +import { useAggkitAggregator } from '@/app/context/aggLayerSdk'; +import { useAppMode } from '@/app/context/appMode'; + +import { useBridgeTracking } from './useBridgeTracking'; + +const makeTransaction = (status: Transaction['status'] = 'READY_TO_CLAIM'): Transaction => + ({ + hubUID: 'tx-1', + txSender: '0x1', + fromAddress: '0x1', + receiverAddress: '0x1', + sourceNetwork: 1, + destinationNetwork: 0, + amount: '1', + status, + lastUpdatedAt: 0, + bridgeHash: '0x1', + metadata: '0x', + leafType: 'asset', + depositCount: 1, + transactionIndex: 0, + transactionHash: '0xabc', + blockNumber: 1, + originTokenAddress: '0x0', + originTokenNetwork: 0, + timestamp: 0, + leafIndex: 1 + }) as Transaction; + +const renderTracking = (transaction: Transaction) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useBridgeTracking(transaction), { wrapper }); +}; + +const mockAggregator = (getBridgeTracking: ReturnType) => + vi + .mocked(useAggkitAggregator) + .mockReturnValue({ getBridgeTracking } as unknown as ReturnType); + +// Advances fake time and flushes the resulting react-query state update +// inside `act`, so React doesn't warn about an update outside of it. +const advance = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms)); + +describe('useBridgeTracking', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(useAppMode).mockReturnValue({ mode: 'devnet' } as unknown as ReturnType< + typeof useAppMode + >); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('stops polling once tracking_status is finished', async () => { + const getBridgeTracking = vi.fn().mockResolvedValue(l1l2FinishedFixture); + mockAggregator(getBridgeTracking); + + renderTracking(makeTransaction()); + await advance(0); + expect(getBridgeTracking).toHaveBeenCalledTimes(1); + + // Well past several poll intervals -- a terminal query must never + // refetch again. + await advance(20000); + expect(getBridgeTracking).toHaveBeenCalledTimes(1); + }); + + it('stops polling on the giving-up terminal (error + null bridge_status)', async () => { + const getBridgeTracking = vi.fn().mockResolvedValue(errorGiveupFixture); + mockAggregator(getBridgeTracking); + + renderTracking(makeTransaction()); + await advance(0); + expect(getBridgeTracking).toHaveBeenCalledTimes(1); + + await advance(20000); + expect(getBridgeTracking).toHaveBeenCalledTimes(1); + }); + + it("keeps polling through a step-level error (tracking_status 'error' but bridge_status populated)", async () => { + const getBridgeTracking = vi.fn().mockResolvedValue(l2l2RunningStepErrorFixture); + mockAggregator(getBridgeTracking); + + renderTracking(makeTransaction()); + await advance(0); + expect(getBridgeTracking).toHaveBeenCalledTimes(1); + + await advance(5000); + expect(getBridgeTracking).toHaveBeenCalledTimes(2); + + await advance(5000); + expect(getBridgeTracking).toHaveBeenCalledTimes(3); + }); + + it('keeps polling after the query hard-errors (react-query exhausts retries)', async () => { + const getBridgeTracking = vi.fn().mockRejectedValue(new Error('boom')); + mockAggregator(getBridgeTracking); + + renderTracking(makeTransaction()); + await advance(0); + expect(getBridgeTracking).toHaveBeenCalledTimes(1); + + // A hard query error (e.g. a transient proxy blip) must not permanently + // freeze this row's tracker -- polling should self-heal at the normal + // cadence rather than stop until remount. + await advance(5000); + expect(getBridgeTracking).toHaveBeenCalledTimes(2); + + await advance(5000); + expect(getBridgeTracking).toHaveBeenCalledTimes(3); + }); + + it('never queries a CLAIMED row (query disabled)', async () => { + const getBridgeTracking = vi.fn().mockResolvedValue(l1l2FinishedFixture); + mockAggregator(getBridgeTracking); + + renderTracking(makeTransaction('CLAIMED')); + await advance(20000); + expect(getBridgeTracking).not.toHaveBeenCalled(); + }); +}); diff --git a/app/hooks/useBridgeTracking.ts b/app/hooks/useBridgeTracking.ts new file mode 100644 index 0000000..68a8f7f --- /dev/null +++ b/app/hooks/useBridgeTracking.ts @@ -0,0 +1,77 @@ +'use client'; + +import type { Transaction } from '@/app/types/transaction'; + +import { useAggkitAggregator } from '@/app/context/aggLayerSdk'; +import { useAppMode } from '@/app/context/appMode'; +import { useQuery } from '@tanstack/react-query'; + +import type { AggkitTrackingData } from '@agglayer/sdk'; + +// Same cadence as useTransactions' PENDING_POLL_INTERVAL -- aggkit's tracker +// has no push/subscription, so this hook polls too. +const TRACKING_POLL_INTERVAL = 5000; + +// Load note: aggkit enforces no in-process rate limit +// (MaxRequestsPerIPAndSecond is unenforced in RESTConfig-backed sections -- +// aggkit #1783; the kurtosis template pins it to the upstream default 0). +// The load math below is therefore a capacity/courtesy note for the proxy +// and any fronting infra limit an operator adds, not a hard budget: this +// hook is only ever mounted per RENDERED, non-completed row (no background +// subscription list independent of what's in the list), so N such rows +// cost N requests / TRACKING_POLL_INTERVAL = N/5 req/s -- e.g. the initial +// 20-row page costs 4 req/s. CAVEAT: transactionList.tsx is not virtualized +// and its infinite scroll keeps every loaded page's rows mounted, so N +// accumulates by 20 per "load more"; together with the activity poll +// (which refetches EVERY loaded page each cycle), a list scrolled to ~8-9 +// pages of all-pending rows (~170+) is an unrealistically pending-heavy +// devnet history, so it is accepted rather than mitigated (virtualizing +// the list or capping polled rows would be the fix if it ever bites at +// scale); react-query's natural staggering (each row's query mounts at a +// slightly different time) also spreads the load within a second. +const isTrackingTerminal = (data: AggkitTrackingData | undefined): boolean => { + if (!data) return false; + if (data.tracking_status === 'finished') return true; + // The giving-up terminal: the tracker could not resolve the bridge at all + // (tx not found / not a bridge tx), reported as tracking_status 'error' + // with bridge_status still null. A step-level error inside an all_steps[i] + // entry ALSO reports tracking_status 'error' (aggkit derives it from the + // step at step_index -- bridgetracker/domain/tracking_data.go), but with + // bridge_status populated; the tracker retries those on its own, so the + // bridge_status null check below is what keeps polling through them. + if (data.tracking_status === 'error' && data.bridge_status === null) return true; + return false; +}; + +// Polls AggkitBridgeAggregator.getBridgeTracking for a +// single transaction row, keyed by its RECORDING network (sourceNetwork, +// routed correctly by the aggregator even for L1/networkId 0) and hash. +// Only terminal tracking states (finished, or gave up with bridge_status +// still null) stop polling -- plus CLAIMED, gated via `enabled` above. +// Transient/hard query errors (e.g. react-query exhausting its default +// retries on a proxy blip or rate-limit burst) do NOT stop polling: when +// status is 'error', query.state.data is the last successful data (or +// undefined pre-first-success), so refetchInterval falls through to +// isTrackingTerminal on that and keeps polling at the normal cadence, +// letting the row self-heal without a remount. Also keeps polling through +// step-level errors and through a tracker-side regression back to +// 'registered' (e.g. retention re-registration) -- this hook never throws +// on null/missing fields, it just returns whatever the API reports and +// lets consumers render accordingly (e.g. nothing while all_steps is still +// null). Accepted trade-off: a permanently-failing request (e.g. a +// hypothetical 400) would poll harmlessly every 5s rather than stop. +export const useBridgeTracking = (transaction: Transaction) => { + const { mode } = useAppMode(); + const aggregator = useAggkitAggregator(); + const { sourceNetwork, transactionHash, status } = transaction; + + return useQuery({ + queryKey: ['bridge-tracking', mode, sourceNetwork, transactionHash], + enabled: status !== 'CLAIMED', + queryFn: () => aggregator.getBridgeTracking(sourceNetwork, transactionHash), + staleTime: TRACKING_POLL_INTERVAL, + refetchInterval: (query) => { + return isTrackingTerminal(query.state.data) ? false : TRACKING_POLL_INTERVAL; + } + }); +}; diff --git a/app/hooks/useClaimExecution.ts b/app/hooks/useClaimExecution.ts index 256de7e..4898d12 100644 --- a/app/hooks/useClaimExecution.ts +++ b/app/hooks/useClaimExecution.ts @@ -8,11 +8,10 @@ import type { } from '@/app/types/transaction'; import type { Hex } from 'viem'; -import { useAggNative } from '@/app/context/aggLayerSdk'; -import { useAppMode } from '@/app/context/appMode'; +import { useAggkitAggregator, useAggNative } from '@/app/context/aggLayerSdk'; import { useWallet } from '@/app/context/walletContext'; import { useSenderAccount } from '@/app/hooks/useSenderAccount'; -import { fetchClaimProof } from '@/app/services/claimProof'; +import { toClaimProof } from '@/app/services/claimProof'; import { isValidEthereumAddress } from '@/app/utils/address'; import { buildClaimAssetParams, @@ -32,8 +31,8 @@ interface UseClaimExecutionParams { export const useClaimExecution = (params: UseClaimExecutionParams) => { const { bridgeAddress, onComplete } = params; const native = useAggNative(); + const aggregator = useAggkitAggregator(); const config = useConfig(); - const { mode } = useAppMode(); const { address } = useWallet(); const senderAccount = useSenderAccount(); const { sendTransactionAsync } = useSendTransaction(); @@ -76,6 +75,12 @@ export const useClaimExecution = (params: UseClaimExecutionParams) => { let localClaimHash: Hex | undefined; try { + // isClaimed's leafIndex is the local deposit index (deposit_count), + // NOT the L1-info-tree index used for the claim proof below — these + // are different quantities that only coincide by chance in a + // single-L2 devnet. resolveLeafIndex now always + // returns deposit_count; the proof's leaf index comes fresh from + // getClaimInputs, never from this row. const leafIndex = resolveLeafIndex(transaction); const bridge = native.bridge(bridgeAddress, destinationChainId); @@ -102,12 +107,12 @@ export const useClaimExecution = (params: UseClaimExecutionParams) => { return; } - const proof = await fetchClaimProof({ - mode, - sourceNetworkId: transaction.sourceNetwork, - leafIndex, + const { proof: rawProof } = await aggregator.getClaimInputs({ + originNetworkId: transaction.sourceNetwork, + destinationNetworkId: transaction.destinationNetwork, depositCount: transaction.depositCount }); + const proof = toClaimProof(rawProof); const claimParams = buildClaimAssetParams({ transaction, proof }); const claimTx = await bridge.buildClaimAsset(claimParams, walletAddress); @@ -161,7 +166,43 @@ export const useClaimExecution = (params: UseClaimExecutionParams) => { claimTxHash: localClaimHash }); } catch (error) { - const message = error instanceof Error ? error.message : 'Claim failed'; + // The pre-flight `isClaimed()` check above only rules out the deposit + // being claimed at the START of this call -- an external claimer + // (e.g. an autoclaimer racing the same READY_TO_CLAIM deposit) can + // still land its claim in the few hundred ms it takes us to fetch + // the proof and estimate gas, so our own claimAsset call reverts + // on-chain with the bridge's `AlreadyClaimed()` custom error (which + // viem's default estimateGas error decoding reports as a generic + // "Execution reverted for an unknown reason" -- confirmed live in S12 + // manual validation by replaying the exact revert selector, + // `0x646cf558`, against `AlreadyClaimed()`'s signature hash). + // + // A single immediate re-check of `isClaimed()` was NOT reliable in + // that same S12 session: it read back `false` immediately after the + // revert, while an independent `cast call isClaimed(...)` moments + // later against the same leafIndex/network read back `true`. Retrying + // with a short backoff gives the read a chance to catch up with the + // state the failed estimateGas call already observed, without + // pretending a single fast re-check is authoritative. + const bridgeClient = native.bridge(bridgeAddress, destinationChainId); + const isClaimedParams = { + leafIndex: resolveLeafIndex(transaction), + sourceBridgeNetwork: transaction.sourceNetwork + }; + let raceLostToAnotherClaimer = false; + for (const delayMs of [0, 400, 1000]) { + if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs)); + raceLostToAnotherClaimer = await bridgeClient + .isClaimed(isClaimedParams) + .catch(() => false); + if (raceLostToAnotherClaimer) break; + } + + const message = raceLostToAnotherClaimer + ? 'This deposit has already been claimed' + : error instanceof Error + ? error.message + : 'Claim failed'; const errorState = { message, txHash: localClaimHash }; setState({ isExecuting: false, @@ -180,7 +221,16 @@ export const useClaimExecution = (params: UseClaimExecutionParams) => { }); } }, - [address, bridgeAddress, config, mode, native, onComplete, sendTransactionAsync, senderAccount] + [ + address, + aggregator, + bridgeAddress, + config, + native, + onComplete, + sendTransactionAsync, + senderAccount + ] ); const reset = useCallback(() => { diff --git a/app/hooks/useEnforceCorrectChain.ts b/app/hooks/useEnforceCorrectChain.ts index 474279f..6165c7a 100644 --- a/app/hooks/useEnforceCorrectChain.ts +++ b/app/hooks/useEnforceCorrectChain.ts @@ -1,10 +1,15 @@ 'use client'; import { useCallback } from 'react'; -import { useChainId, useSwitchChain } from 'wagmi'; +import { useAccount, useSwitchChain } from 'wagmi'; export const useEnforceCorrectChain = () => { - const walletChainId = useChainId(); + // The connected wallet's actual chain (follows the connector's chainChanged + // events), NOT useChainId(): wagmi's store chain can diverge from a real + // extension wallet's per-dapp chain, which makes this guard skip a needed + // switch and the subsequent send throw ChainMismatch before the wallet is + // ever asked to sign. + const { chainId: walletChainId } = useAccount(); const { switchChainAsync } = useSwitchChain(); return useCallback( @@ -12,6 +17,6 @@ export const useEnforceCorrectChain = () => { if (walletChainId === targetId) return; await switchChainAsync({ chainId: targetId }); }, - [walletChainId, switchChainAsync], + [walletChainId, switchChainAsync] ); }; diff --git a/app/hooks/useReadyToClaimCount.test.tsx b/app/hooks/useReadyToClaimCount.test.tsx new file mode 100644 index 0000000..642c2fd --- /dev/null +++ b/app/hooks/useReadyToClaimCount.test.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// AggkitBridgeAggregator.getReadyToClaimCount (sdk/src/aggkit/aggregator.ts) +// silently drops per-network fan-out failures and only resolves the count +// built from healthy networks — it rejects only when every configured +// network fails. useReadyToClaimCount has no per-network breakdown to +// surface (unlike useTransactions' `failedNetworks`), so "tolerates partial +// failure" means: the query still succeeds with a numeric count and never +// enters an error state under partial failure. +vi.mock('@/app/context/aggLayerSdk', () => ({ + useAggkitAggregator: vi.fn() +})); +vi.mock('@/app/context/appMode', () => ({ + useAppMode: vi.fn() +})); + +import { useAggkitAggregator } from '@/app/context/aggLayerSdk'; +import { useAppMode } from '@/app/context/appMode'; + +import { useReadyToClaimCount } from './useReadyToClaimCount'; + +const wrapper = ({ children }: { children: ReactNode }) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +}; + +describe('useReadyToClaimCount', () => { + beforeEach(() => { + vi.mocked(useAppMode).mockReturnValue({ mode: 'mainnet' } as unknown as ReturnType< + typeof useAppMode + >); + }); + + it('badge tolerates partial network failure: resolves a count from healthy networks without erroring', async () => { + vi.mocked(useAggkitAggregator).mockReturnValue({ + getReadyToClaimCount: vi.fn().mockResolvedValue(3) + } as unknown as ReturnType); + + const { result } = renderHook(() => useReadyToClaimCount({ chainId: 1, address: '0xabc' }), { + wrapper + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toBe(3); + expect(result.current.isError).toBe(false); + }); +}); diff --git a/app/hooks/useReadyToClaimCount.ts b/app/hooks/useReadyToClaimCount.ts index bd00253..57222ee 100644 --- a/app/hooks/useReadyToClaimCount.ts +++ b/app/hooks/useReadyToClaimCount.ts @@ -1,13 +1,16 @@ 'use client'; -import type { TransactionStatus } from '@/app/types/transaction'; - +import { useAggkitAggregator } from '@/app/context/aggLayerSdk'; import { useAppMode } from '@/app/context/appMode'; -import { fetchTransactions } from '@/app/services/transactions'; import { useQuery } from '@tanstack/react-query'; -const READY_STATUS: TransactionStatus = 'READY_TO_CLAIM'; - +// A cheap, bounded count — one bridges+claims page per +// configured network (Tier 1) to build the unclaimed set, then +// `/l1-info-tree-index` probes bounded to that unclaimed set only (Tier 2). +// Never a full activity scan, and never rejects on a partial per-network +// failure (AggkitBridgeAggregator.getReadyToClaimCount silently excludes +// failed networks from the count rather than surfacing them — there is no +// per-network breakdown here, unlike getActivity's `failedNetworks`). export const useReadyToClaimCount = (params: { chainId?: number; address?: string; @@ -15,22 +18,19 @@ export const useReadyToClaimCount = (params: { }) => { const { chainId, address, enabled = true } = params; const { mode } = useAppMode(); + const aggregator = useAggkitAggregator(); return useQuery({ queryKey: ['ready-to-claim-count', mode, chainId, address], enabled: enabled && Boolean(chainId && address), queryFn: async () => { if (!chainId || !address) throw new Error('MISSING_PARAMS'); - const response = await fetchTransactions({ - mode, - filters: { - fromAddress: address, - status: READY_STATUS, - limit: 1 - } - }); - return response.pagination.total ?? 0; + return aggregator.getReadyToClaimCount({ fromAddress: address }); }, - staleTime: 30 * 1000 + staleTime: 30 * 1000, + // Poll steadily so the badge reflects deposits becoming claimable (aggkit + // has no push and status is derived per fetch). The count stays bounded + // (probes only the unclaimed set); 15s keeps it fresh without hammering the fan-out. + refetchInterval: 15 * 1000 }); }; diff --git a/app/hooks/useTokenMetadata.ts b/app/hooks/useTokenMetadata.ts index f987bc1..12067c7 100644 --- a/app/hooks/useTokenMetadata.ts +++ b/app/hooks/useTokenMetadata.ts @@ -1,8 +1,10 @@ 'use client'; +import { useAggkitAggregator } from '@/app/context/aggLayerSdk'; import { useAppMode } from '@/app/context/appMode'; import { fetchTokenMetadata } from '@/app/services/tokenMetadata'; import { isValidEthereumAddress } from '@/app/utils/address'; +import { getChainById } from '@/app/utils/chains'; import { useQuery } from '@tanstack/react-query'; export const useTokenMetadata = (params: { @@ -11,18 +13,20 @@ export const useTokenMetadata = (params: { enabled?: boolean; }) => { const { chainId, tokenAddress, enabled = false } = params; - const { mode } = useAppMode(); + const { mode, chains } = useAppMode(); + const aggregator = useAggkitAggregator(); const normalizedAddress = tokenAddress?.trim() ?? ''; const canFetch = isValidEthereumAddress(normalizedAddress); + const networkId = chainId ? getChainById(chains, chainId)?.networkId : undefined; return useQuery({ queryKey: ['token-metadata', mode, chainId, normalizedAddress], - enabled: enabled && canFetch, + enabled: enabled && canFetch && networkId !== undefined, staleTime: 5 * 60 * 1000, retry: 1, queryFn: async () => { - if (!normalizedAddress) throw new Error('MISSING_PARAMS'); - return fetchTokenMetadata(mode, normalizedAddress); + if (!normalizedAddress || networkId === undefined) throw new Error('MISSING_PARAMS'); + return fetchTokenMetadata({ aggregator, networkId, tokenAddress: normalizedAddress }); } }); }; diff --git a/app/hooks/useTransactions.ts b/app/hooks/useTransactions.ts index 4737e45..b0d50d2 100644 --- a/app/hooks/useTransactions.ts +++ b/app/hooks/useTransactions.ts @@ -3,14 +3,46 @@ import type { TransactionFilters, TransactionsResponse } from '@/app/types/transaction'; import type { InfiniteData } from '@tanstack/react-query'; +import { useAggkitAggregator } from '@/app/context/aggLayerSdk'; import { useAppMode } from '@/app/context/appMode'; import { fetchTransactions } from '@/app/services/transactions'; import { useInfiniteQuery } from '@tanstack/react-query'; import { useEffect, useMemo, useRef } from 'react'; +import type { AggkitFailedNetwork } from '@agglayer/sdk'; + +// Per-page `failedNetworks` are NOT aggregated across pages +// by the aggregator — each page only reports failures from its own fan-out. +// Dedupe by networkId across every loaded page so the UI can name each +// currently-unhealthy network once, regardless of how many pages mention it. +const aggregateFailedNetworks = ( + pages: TransactionsResponse[] | undefined +): AggkitFailedNetwork[] => { + const byNetworkId = new Map(); + for (const page of pages ?? []) { + for (const failure of page.failedNetworks ?? []) { + byNetworkId.set(failure.networkId, failure); + } + } + return Array.from(byNetworkId.values()); +}; + const REFETCH_INTERVALS = [500, 1000, 2000, 3000]; export const TOTAL_REFETCH_TIME = REFETCH_INTERVALS.reduce((acc, curr) => acc + curr, 0); +// Aggkit has no push/subscription and status is derived per fetch, so the +// activity view polls to stay live. Fast cadence while any loaded tx is still +// non-terminal (its spinner/status must advance); a slower idle cadence +// otherwise so newly-submitted/indexed deposits (e.g. an L2->L1 withdrawal that +// isn't indexed within the initial burst) still appear without a manual refresh +// or navigating away and back. Polling only runs while the page is mounted and +// the tab is focused (react-query default). +const PENDING_POLL_INTERVAL = 5000; +const IDLE_POLL_INTERVAL = 10000; + +const hasNonTerminalTransaction = (pages: TransactionsResponse[] | undefined): boolean => + (pages ?? []).some((page) => page.data.some((tx) => tx.status !== 'CLAIMED')); + export const useTransactions = (params: { chainId?: number; filters?: TransactionFilters; @@ -19,6 +51,7 @@ export const useTransactions = (params: { }) => { const { chainId, filters = {}, enabled = true, aggressiveRefetch = false } = params; const { mode } = useAppMode(); + const aggregator = useAggkitAggregator(); const filtersKey = useMemo(() => JSON.stringify(filters ?? {}), [filters]); const fetchCountRef = useRef(0); @@ -32,7 +65,7 @@ export const useTransactions = (params: { prevAggressiveRef.current = aggressiveRefetch; }, [aggressiveRefetch]); - return useInfiniteQuery< + const query = useInfiniteQuery< TransactionsResponse, Error, InfiniteData, @@ -44,7 +77,7 @@ export const useTransactions = (params: { queryFn: async ({ pageParam }) => { if (!chainId) throw new Error('MISSING_CHAIN_ID'); const data = await fetchTransactions({ - mode, + aggregator, filters: { ...filters, startAfter: pageParam @@ -60,13 +93,26 @@ export const useTransactions = (params: { initialPageParam: undefined, staleTime: 30 * 1000, refetchInterval: (query) => { - if (!aggressiveRefetch) return false; if (query.state.status === 'error') return false; + // Initial fast burst right after a user action (bridge submit) for snappy + // feedback while the deposit first appears / starts progressing. const count = fetchCountRef.current; - if (count >= REFETCH_INTERVALS.length) return false; + if (aggressiveRefetch && count < REFETCH_INTERVALS.length) { + return REFETCH_INTERVALS[count]; + } - return REFETCH_INTERVALS[count]; + // Then keep the view live: poll fast while any loaded tx is still + // non-terminal so its status (BRIDGED -> LEAF_INCLUDED -> READY_TO_CLAIM + // -> CLAIMED) advances, and poll at a slower idle cadence otherwise so a + // newly-appearing deposit still shows up on its own. + return hasNonTerminalTransaction(query.state.data?.pages) + ? PENDING_POLL_INTERVAL + : IDLE_POLL_INTERVAL; } }); + + const failedNetworks = useMemo(() => aggregateFailedNetworks(query.data?.pages), [query.data]); + + return { ...query, failedNetworks }; }; diff --git a/app/providers.tsx b/app/providers.tsx index d4eeb43..5e63aad 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; -import { AggLayerSDKProvider } from '@/app/context/aggLayerSdk'; +import { AppConfigGate } from '@/app/components/appConfigGate'; +import { AggkitAggregatorProvider, AggLayerSDKProvider } from '@/app/context/aggLayerSdk'; import { AppModeProvider } from '@/app/context/appMode'; import { RefetchProvider } from '@/app/context/refetch'; import { TokenProvider } from '@/app/context/token'; @@ -8,14 +9,18 @@ import { WalletProvider } from '@/app/context/wallet'; export const Providers = ({ children }: { children: ReactNode }) => { return ( - - - - - {children} - - - - + + + + + + + {children} + + + + + + ); }; diff --git a/app/services/claimProof.ts b/app/services/claimProof.ts index c65d44b..4b689f5 100644 --- a/app/services/claimProof.ts +++ b/app/services/claimProof.ts @@ -1,8 +1,15 @@ -import type { AppMode } from '@/app/types/appMode'; import type { Hex } from 'viem'; -import { getProofApiBaseUrl } from '@/app/utils/appMode'; +import { isHex } from 'viem'; +import type { AggkitClaimProof } from '@agglayer/sdk'; + +// Same shape `buildClaimAssetParams` (utils/transaction.ts) already expects — +// preserved so that function stays unchanged. The aggkit SDK +// returns these fields as plain `string`, not viem's `Hex` brand, so we +// narrow with `isHex` (a type guard, not a cast) at this one boundary rather +// than threading `as Hex` through call sites — see team standards on +// narrowing `unknown`/loosely-typed external data instead of casting it. export type ClaimProof = { proof_local_exit_root: Hex[]; proof_rollup_exit_root: Hex[]; @@ -19,45 +26,28 @@ export type ClaimProof = { }; }; -type ClaimProofResponse = { - status: 'success' | 'error'; - data?: ClaimProof; - error?: string; -}; - -const buildClaimProofUrl = (params: { - mode: AppMode; - sourceNetworkId: number; - leafIndex: number; - depositCount: number; -}): string => { - const url = new URL(`${getProofApiBaseUrl(params.mode)}/claim-proof`); - url.searchParams.set('sourceNetworkId', params.sourceNetworkId.toString()); - url.searchParams.set('leafIndex', params.leafIndex.toString()); - url.searchParams.set('depositCount', params.depositCount.toString()); - return url.toString(); +const toHex = (value: string, field: string): Hex => { + if (!isHex(value)) { + throw new Error(`CLAIM_PROOF_INVALID_HEX: ${field} is not a hex string`); + } + return value; }; -export const fetchClaimProof = async (params: { - mode: AppMode; - sourceNetworkId: number; - leafIndex: number; - depositCount: number; -}): Promise => { - const url = buildClaimProofUrl(params); - const res = await fetch(url, { - headers: { accept: 'application/json' } - }); +const toHexArray = (values: string[], field: string): Hex[] => + values.map((value, index) => toHex(value, `${field}[${index}]`)); - if (!res.ok) { - const msg = await res.text().catch(() => ''); - throw new Error(`CLAIM_PROOF_${res.status}: ${msg || 'Request failed'}`); - } - - const json: ClaimProofResponse = await res.json(); - if (json.status !== 'success' || !json.data) { - throw new Error(json.error || 'CLAIM_PROOF_INVALID_RESPONSE'); +export const toClaimProof = (proof: AggkitClaimProof): ClaimProof => ({ + proof_local_exit_root: toHexArray(proof.proof_local_exit_root, 'proof_local_exit_root'), + proof_rollup_exit_root: toHexArray(proof.proof_rollup_exit_root, 'proof_rollup_exit_root'), + l1_info_tree_leaf: { + block_num: proof.l1_info_tree_leaf.block_num, + block_pos: proof.l1_info_tree_leaf.block_pos, + l1_info_tree_index: proof.l1_info_tree_leaf.l1_info_tree_index, + previous_block_hash: proof.l1_info_tree_leaf.previous_block_hash, + timestamp: proof.l1_info_tree_leaf.timestamp, + mainnet_exit_root: toHex(proof.l1_info_tree_leaf.mainnet_exit_root, 'mainnet_exit_root'), + rollup_exit_root: toHex(proof.l1_info_tree_leaf.rollup_exit_root, 'rollup_exit_root'), + global_exit_root: toHex(proof.l1_info_tree_leaf.global_exit_root, 'global_exit_root'), + hash: toHex(proof.l1_info_tree_leaf.hash, 'hash') } - - return json.data; -}; +}); diff --git a/app/services/tokenMetadata.ts b/app/services/tokenMetadata.ts index 784cadf..2bc9712 100644 --- a/app/services/tokenMetadata.ts +++ b/app/services/tokenMetadata.ts @@ -1,8 +1,10 @@ -import type { AppMode } from '@/app/types/appMode'; - -import { getProofApiBaseUrl } from '@/app/utils/appMode'; -import { normalize } from '@/app/utils/format'; +import type { AggkitBridgeAggregator } from '@agglayer/sdk'; +// Thin wrapper over AggkitBridgeAggregator.getTokenMetadata. +// aggkit has no token-metadata endpoint; the aggregator composes it from +// /token-mappings (address resolution) + on-chain ERC20 reads, or the native +// currency for the zero address. Output shape matches this pre-existing +// TokenMetadata contract, which useTokenMetadata.ts consumes unchanged. export interface TokenMetadata { name: string; symbol: string; @@ -17,46 +19,16 @@ export interface TokenMetadata { wrappedTokenAddressV2?: string; } -interface TokenMetadataResponse { - status: string; - data?: TokenMetadata; - error?: string; -} - -export const fetchTokenMetadata = async ( - mode: AppMode, - tokenAddress: string -): Promise => { - const url = `${getProofApiBaseUrl(mode)}/token-metadata/${normalize(tokenAddress)}`; - const res = await fetch(url, { headers: { accept: 'application/json' } }); - - if (!res.ok) { - const msg = await res.text().catch(() => ''); - throw new Error(`TOKEN_METADATA_${res.status}: ${msg || 'Request failed'}`); - } - - const json: TokenMetadataResponse = await res.json(); - - if (json.status !== 'success' || !json.data) { - throw new Error(json.error || 'TOKEN_METADATA_INVALID_RESPONSE'); - } - - const { data } = json; - const requestedAddress = normalize(tokenAddress); - const matchedAddress = [ - data.tokenAddress, - data.originTokenAddress, - data.wrappedTokenAddressV1, - data.wrappedTokenAddressV2 - ].find((addr) => addr && normalize(addr) === requestedAddress); - - if (!matchedAddress) { - throw new Error('TOKEN_METADATA_MISSING_ADDRESS'); - } +export const fetchTokenMetadata = async (params: { + aggregator: AggkitBridgeAggregator; + networkId: number; + tokenAddress: string; +}): Promise => { + const { aggregator, networkId, tokenAddress } = params; + const metadata = await aggregator.getTokenMetadata(tokenAddress, networkId); return { - ...data, - tokenAddress: matchedAddress, - decimals: Number(data.decimals) + ...metadata, + decimals: Number(metadata.decimals) }; }; diff --git a/app/services/transactions.ts b/app/services/transactions.ts index 9c76a36..3bf89f8 100644 --- a/app/services/transactions.ts +++ b/app/services/transactions.ts @@ -1,71 +1,41 @@ -import type { AppMode } from '@/app/types/appMode'; -import type { TransactionsResponse, TransactionFilters } from '@/app/types/transaction'; - -import { APP_MODE_CONFIG } from '@/app/config'; -import { getProofApiBaseUrl } from '@/app/utils/appMode'; - -const getEnvironmentNetworkIds = (mode: AppMode): number[] => - APP_MODE_CONFIG[mode].chains.map((chain) => chain.networkId); - -const formatAllowedNetworkIds = ( - requestedIds: number[] | undefined, - allowedIds: number[] -): string | undefined => { - if (!requestedIds?.length) return undefined; - - const allowed = new Set(allowedIds); - const filtered = requestedIds.filter((id) => allowed.has(id)); - if (!filtered.length) return undefined; - - return [...new Set(filtered)].join(','); -}; - -const buildTransactionsUrl = (params: { mode: AppMode; filters?: TransactionFilters }): string => { - const url = new URL(`${getProofApiBaseUrl(params.mode)}/transactions`); - const allowedNetworkIds = getEnvironmentNetworkIds(params.mode); - const fallbackNetworkIds = - allowedNetworkIds.length > 0 ? [...new Set(allowedNetworkIds)].join(',') : undefined; - - const sourceNetworkIds = - formatAllowedNetworkIds(params.filters?.sourceNetworkIds, allowedNetworkIds) ?? - fallbackNetworkIds; - const destinationNetworkIds = - formatAllowedNetworkIds(params.filters?.destinationNetworkIds, allowedNetworkIds) ?? - fallbackNetworkIds; - - if (params.filters?.fromAddress) url.searchParams.set('fromAddress', params.filters.fromAddress); - if (sourceNetworkIds) url.searchParams.set('sourceNetworkIds', sourceNetworkIds); - if (destinationNetworkIds) url.searchParams.set('destinationNetworkIds', destinationNetworkIds); - if (params.filters?.updatedSince !== undefined) - url.searchParams.set('updatedSince', params.filters.updatedSince.toString()); - if (params.filters?.status) url.searchParams.set('status', params.filters.status); - if (params.filters?.order) url.searchParams.set('order', params.filters.order); - if (params.filters?.limit) url.searchParams.set('limit', params.filters.limit.toString()); - if (params.filters?.startAfter) url.searchParams.set('startAfter', params.filters.startAfter); - - return url.toString(); -}; - +import type { TransactionFilters, TransactionsResponse } from '@/app/types/transaction'; + +import type { AggkitBridgeAggregator } from '@agglayer/sdk'; + +// Thin wrapper over AggkitBridgeAggregator.getActivity. +// The old bridge-hub `/transactions` endpoint supported server-side +// sourceNetworkIds/destinationNetworkIds/updatedSince/status filtering; aggkit +// has no equivalents. sourceNetworkIds/ +// destinationNetworkIds/updatedSince are dropped (the aggregator always fans +// out across every configured network, and block_timestamp DESC sort already +// surfaces newest first); `status` is applied client-side below since it's +// still exposed as a UI filter (e.g. useReadyToClaimCount, transactionsView's +// status tabs). export const fetchTransactions = async (params: { - mode: AppMode; + aggregator: AggkitBridgeAggregator; filters?: TransactionFilters; }): Promise => { - const url = buildTransactionsUrl({ mode: params.mode, filters: params.filters }); - const res = await fetch(url, { - headers: { accept: 'application/json' } - }); + const { aggregator, filters = {} } = params; - if (!res.ok) { - const msg = await res.text().catch(() => ''); - throw new Error(`TRANSACTIONS_${res.status}: ${msg || 'Request failed'}`); + if (!filters.fromAddress) { + throw new Error('TRANSACTIONS_MISSING_FROM_ADDRESS'); } - const json: TransactionsResponse = await res.json(); + const page = await aggregator.getActivity({ + fromAddress: filters.fromAddress, + ...(filters.limit !== undefined ? { pageSize: filters.limit } : {}), + ...(filters.startAfter !== undefined ? { cursor: filters.startAfter } : {}), + ...(filters.order !== undefined ? { order: filters.order } : {}) + }); - const isSuccess = json.status === 'success'; - if (!isSuccess || !json.data) { - throw new Error(json.error || 'TRANSACTIONS_INVALID_RESPONSE'); - } + const data = filters.status + ? page.data.filter((transaction) => transaction.status === filters.status) + : page.data; - return json; + return { + status: 'success', + data, + pagination: page.pagination, + failedNetworks: page.failedNetworks + }; }; diff --git a/app/types/appMode.ts b/app/types/appMode.ts index e4e2b26..f55b75a 100644 --- a/app/types/appMode.ts +++ b/app/types/appMode.ts @@ -23,7 +23,9 @@ export type AppChain = { type BaseModeConfig = { label: string; bridgeAddress: string; - proofApiUrl: string; + // Map of L2 networkId -> aggkit REST base URL (no `/bridge/v1` suffix). + // May be empty for a mode with no aggkit backend configured yet. + aggkitBridgeApis: Record; }; export type DisabledAppModeConfig = BaseModeConfig & { diff --git a/app/types/config.ts b/app/types/config.ts index 0b60450..855f0a8 100644 --- a/app/types/config.ts +++ b/app/types/config.ts @@ -1,5 +1,6 @@ import type { AppChain } from '@/app/types/appMode'; import type { + autoclaimConfigSchema, jsonAppModeConfigSchema, jsonChainConfigSchema, jsonConfigSchema, @@ -27,3 +28,17 @@ export type JsonNativeCurrencyConfig = z.infer; export type JsonAppModeConfig = z.infer; export type JsonConfig = z.infer; + +// The three bridge route types, keyed by whether each side is L1 (networkId 0) +// or an L2 (any other networkId). Drives per-route autoclaim UX. +export type RouteType = 'l1_to_l2' | 'l2_to_l1' | 'l2_to_l2'; + +// Resolved (defaults applied) per-route autoclaim config; `waitForAutoclaimMs` +// is always present after resolution in app/config.ts. +export type AutoclaimRouteConfig = { + expectedAutoclaim: boolean; + waitForAutoclaimMs: number; +}; + +export type AutoclaimConfig = Record; +export type JsonAutoclaimConfig = z.infer; diff --git a/app/types/transaction.ts b/app/types/transaction.ts index ad6c39b..6b8d670 100644 --- a/app/types/transaction.ts +++ b/app/types/transaction.ts @@ -1,5 +1,7 @@ import type { Hex } from 'viem'; +import type { AggkitFailedNetwork } from '@agglayer/sdk'; + export type TransactionStatus = 'BRIDGED' | 'LEAF_INCLUDED' | 'READY_TO_CLAIM' | 'CLAIMED'; export interface Transaction { @@ -27,7 +29,6 @@ export interface Transaction { originTokenNetwork: number; timestamp: number; leafIndex: number; - leafIndexForProof?: number; } export interface TransactionsResponse { @@ -39,6 +40,10 @@ export interface TransactionsResponse { nextStartAfterCursor?: string; }; error?: string; + // Per-network fan-out failures from AggkitBridgeAggregator.getActivity. + // A network failing does not fail the whole page — its rows are simply + // absent. Consumed by the partial-failure notice in transactionsView.tsx. + failedNetworks?: AggkitFailedNetwork[]; } export interface TransactionFilters { diff --git a/app/utils/appMode.ts b/app/utils/appMode.ts index e08db25..222bb8b 100644 --- a/app/utils/appMode.ts +++ b/app/utils/appMode.ts @@ -1,6 +1,6 @@ import type { AppMode, AppModeConfig, EnabledAppModeConfig } from '@/app/types/appMode'; -import { APP_MODE_CONFIG } from '@/app/config'; +import { getAppModeConfig } from '@/app/config'; import { APP_MODES } from '@/config/appModes.mjs'; export const isValidAppMode = (value: unknown): value is AppMode => @@ -10,6 +10,7 @@ export const isEnabledModeConfig = (config: AppModeConfig): config is EnabledApp config.chains.length >= 2; export const getEnabledModes = (): AppMode[] => - APP_MODES.filter((mode) => isEnabledModeConfig(APP_MODE_CONFIG[mode])); + APP_MODES.filter((mode) => isEnabledModeConfig(getAppModeConfig()[mode])); -export const getProofApiBaseUrl = (mode: AppMode): string => APP_MODE_CONFIG[mode].proofApiUrl; +export const getAggkitBridgeApis = (mode: AppMode): Record => + getAppModeConfig()[mode].aggkitBridgeApis; diff --git a/app/utils/autoclaim.test.ts b/app/utils/autoclaim.test.ts new file mode 100644 index 0000000..e9f9d2b --- /dev/null +++ b/app/utils/autoclaim.test.ts @@ -0,0 +1,71 @@ +import type { AutoclaimRouteConfig } from '@/app/types/config'; + +import { computeAutoclaimGate, getRouteType } from '@/app/utils/autoclaim'; +import { describe, expect, it } from 'vitest'; + +describe('getRouteType', () => { + it('classifies L1 -> L2 (source is L1, destination is an L2)', () => { + expect(getRouteType(0, 1)).toBe('l1_to_l2'); + }); + + it('classifies L2 -> L1 (source is an L2, destination is L1)', () => { + // Includes the native-gas-token withdrawal case: recording network is the + // L2 even though origin_network would be 0. + expect(getRouteType(1, 0)).toBe('l2_to_l1'); + }); + + it('classifies L2 -> L2 (neither side is L1)', () => { + expect(getRouteType(1, 2)).toBe('l2_to_l2'); + }); +}); + +describe('computeAutoclaimGate', () => { + const withAutoclaim: AutoclaimRouteConfig = { + expectedAutoclaim: true, + waitForAutoclaimMs: 60_000 + }; + const noAutoclaim: AutoclaimRouteConfig = { + expectedAutoclaim: false, + waitForAutoclaimMs: 0 + }; + + it('is no-autoclaim when the route does not expect autoclaim', () => { + expect( + computeAutoclaimGate({ config: noAutoclaim, isReadyToClaim: true, readyAt: 1000, now: 1000 }) + ).toBe('no-autoclaim'); + }); + + it('is no-autoclaim when the deposit is not ready to claim', () => { + expect( + computeAutoclaimGate({ config: withAutoclaim, isReadyToClaim: false, readyAt: null, now: 0 }) + ).toBe('no-autoclaim'); + }); + + it('waits before the grace period has elapsed', () => { + expect( + computeAutoclaimGate({ + config: withAutoclaim, + isReadyToClaim: true, + readyAt: 1_000, + now: 1_000 + 59_999 + }) + ).toBe('waiting'); + }); + + it('waits when the ready timestamp is not yet recorded', () => { + expect( + computeAutoclaimGate({ config: withAutoclaim, isReadyToClaim: true, readyAt: null, now: 0 }) + ).toBe('waiting'); + }); + + it('is overdue once the grace period has elapsed', () => { + expect( + computeAutoclaimGate({ + config: withAutoclaim, + isReadyToClaim: true, + readyAt: 1_000, + now: 1_000 + 60_000 + }) + ).toBe('overdue'); + }); +}); diff --git a/app/utils/autoclaim.ts b/app/utils/autoclaim.ts new file mode 100644 index 0000000..f815a6c --- /dev/null +++ b/app/utils/autoclaim.ts @@ -0,0 +1,59 @@ +import type { AutoclaimRouteConfig, RouteType } from '@/app/types/config'; + +import { STORAGE_KEYS, StorageUtils } from '@/app/utils/storage'; + +// L1 is networkId 0; any other networkId is an L2. Note `sourceNetworkId` is the +// deposit's RECORDING network (see AggkitBridgeAggregator), which is what +// correctly distinguishes an L2 native-gas-token withdrawal (recorded on the L2, +// even though its origin_network is 0) from a genuine L1 origin. +export const getRouteType = ( + sourceNetworkId: number, + destinationNetworkId: number +): RouteType => { + const sourceIsL1 = sourceNetworkId === 0; + const destIsL1 = destinationNetworkId === 0; + if (sourceIsL1 && !destIsL1) return 'l1_to_l2'; + if (!sourceIsL1 && destIsL1) return 'l2_to_l1'; + return 'l2_to_l2'; +}; + +// Whether the manual "Claim tokens" button should show, and whether the autoclaim +// grace period is still running: +// - 'no-autoclaim': no autoclaim expected for this route -> show button now. +// - 'waiting': autoclaim expected and still within the grace window -> show a +// "waiting for auto claim, claim manually now" hint instead of the button. +// - 'overdue': grace window elapsed -> show the button plus a "taking longer +// than expected" note. +export type AutoclaimGate = 'no-autoclaim' | 'waiting' | 'overdue'; + +export const computeAutoclaimGate = (params: { + config: AutoclaimRouteConfig; + isReadyToClaim: boolean; + readyAt: number | null; + now: number; +}): AutoclaimGate => { + const { config, isReadyToClaim, readyAt, now } = params; + if (!isReadyToClaim || !config.expectedAutoclaim) return 'no-autoclaim'; + if (readyAt === null) return 'waiting'; + return now >= readyAt + config.waitForAutoclaimMs ? 'overdue' : 'waiting'; +}; + +type ReadyAtMap = Record; + +const readReadyAtMap = (): ReadyAtMap => + StorageUtils.getItem(STORAGE_KEYS.AUTOCLAIM_READY_AT, {}) ?? {}; + +export const getReadyAt = (bridgeHash: string): number | null => + readReadyAtMap()[bridgeHash] ?? null; + +// Records `timestampMs` as the first-observed READY_TO_CLAIM time for +// `bridgeHash` if none is stored yet, and returns the effective (existing or +// newly-stored) value so the grace period is stable across refreshes. +export const recordReadyAt = (bridgeHash: string, timestampMs: number): number => { + const map = readReadyAtMap(); + const existing = map[bridgeHash]; + if (existing !== undefined) return existing; + map[bridgeHash] = timestampMs; + StorageUtils.setItem(STORAGE_KEYS.AUTOCLAIM_READY_AT, map); + return timestampMs; +}; diff --git a/app/utils/config.ts b/app/utils/config.ts index f53243e..28323ad 100644 --- a/app/utils/config.ts +++ b/app/utils/config.ts @@ -30,8 +30,6 @@ export const toNonEmptyChainArray = (chains: Chain[]): readonly [Chain, ...Chain return [first, ...rest]; }; -export const toProofApiUrl = (baseUrl: string, suffix: string): string => `${baseUrl}/${suffix}/`; - export const buildWagmiChain = (config: JsonChainConfig): Chain => ({ id: config.id, name: config.name, diff --git a/app/utils/reownConfig.test.ts b/app/utils/reownConfig.test.ts new file mode 100644 index 0000000..366985a --- /dev/null +++ b/app/utils/reownConfig.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { isPlaceholderProjectId, PRODUCTION_METADATA_URL, resolveMetadataUrl } from './reownConfig'; + +describe('isPlaceholderProjectId', () => { + it('treats the checked-in .env.example/.env.local literal as a placeholder', () => { + expect(isPlaceholderProjectId('YOUR_PROJECT_ID_HERE')).toBe(true); + }); + + it('treats an empty string as a placeholder', () => { + expect(isPlaceholderProjectId('')).toBe(true); + }); + + it('treats a whitespace-only string as a placeholder', () => { + expect(isPlaceholderProjectId(' ')).toBe(true); + }); + + it('treats undefined/null as a placeholder', () => { + expect(isPlaceholderProjectId(undefined)).toBe(true); + expect(isPlaceholderProjectId(null)).toBe(true); + }); + + it('does not treat a real-shaped WalletConnect Cloud project id as a placeholder', () => { + expect(isPlaceholderProjectId('a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6')).toBe(false); + }); + + it('is case-sensitive (does not over-match near-miss strings)', () => { + expect(isPlaceholderProjectId('your_project_id_here')).toBe(false); + }); +}); + +describe('resolveMetadataUrl', () => { + it('uses the provided origin when given one', () => { + expect(resolveMetadataUrl('http://localhost:3000')).toBe('http://localhost:3000'); + }); + + it('falls back to the production URL for an empty origin', () => { + expect(resolveMetadataUrl('')).toBe(PRODUCTION_METADATA_URL); + }); + + it('falls back to the production URL for a whitespace-only origin', () => { + expect(resolveMetadataUrl(' ')).toBe(PRODUCTION_METADATA_URL); + }); + + it('reads window.location.origin when no origin argument is given (jsdom test environment)', () => { + // vitest's jsdom environment always defines `window`, so the SSR + // (`typeof window === 'undefined'`) branch itself can't run here -- that + // branch is exercised for real whenever this module is evaluated during + // Next.js SSR/static export, where `window` genuinely doesn't exist. + expect(resolveMetadataUrl()).toBe(window.location.origin); + }); +}); diff --git a/app/utils/reownConfig.ts b/app/utils/reownConfig.ts new file mode 100644 index 0000000..8f711a8 --- /dev/null +++ b/app/utils/reownConfig.ts @@ -0,0 +1,35 @@ +// Pure helpers for the Reown/AppKit bootstrap in app/context/wallet.tsx, +// split out here so the placeholder-id detection and metadata.url derivation +// can be unit tested without pulling in @reown/appkit or a DOM. + +// The production canonical URL, used both as the SSR-safe fallback for +// metadata.url and as the shape reference for +// what a "real" WalletConnect Cloud project id looks like (it is not a +// placeholder). +export const PRODUCTION_METADATA_URL = 'https://dev-ui.agglayer.dev/'; + +// .env.example / .env.local both ship this literal as the checked-in +// placeholder (with a TODO to replace it with a real +// https://cloud.reown.com project id). Treat it, and an empty/whitespace +// value, as "no real project id configured" -- degrade gracefully rather +// than requiring a real id. +const PLACEHOLDER_PROJECT_ID = 'YOUR_PROJECT_ID_HERE'; + +export const isPlaceholderProjectId = (projectId: string | undefined | null): boolean => { + const trimmed = (projectId ?? '').trim(); + return trimmed === '' || trimmed === PLACEHOLDER_PROJECT_ID; +}; + +// metadata.url should describe wherever the app is actually being served +// from -- hardcoding the production domain trips AppKit/WalletConnect's own +// "configured metadata.url differs from the actual page url" warning on +// every local/dev/preview origin. window.location +// is only available client-side; SSR/build-time evaluation (and any +// environment where origin can't be read) falls back to the production +// domain, which is always a truthful description of the deployed app. +export const resolveMetadataUrl = (origin?: string | null): string => { + const resolvedOrigin = + origin ?? (typeof window !== 'undefined' ? window.location.origin : undefined); + const trimmed = resolvedOrigin?.trim(); + return trimmed ? trimmed : PRODUCTION_METADATA_URL; +}; diff --git a/app/utils/storage.ts b/app/utils/storage.ts index d719b17..b532edf 100644 --- a/app/utils/storage.ts +++ b/app/utils/storage.ts @@ -1,3 +1,7 @@ +// Intentionally retained across the aggkit rebrand for localStorage +// backward-compatibility: this prefixes users' saved appMode/customTokens keys. +// Renaming it would orphan existing users' stored data, so a rename requires a +// one-time migration. const APP_PREFIX = 'bridge-hub-ui'; const getBrowserStorage = (): Storage | null => { @@ -12,6 +16,9 @@ const createStorageKey = (key: string): string => `${APP_PREFIX}:${key}`; export const STORAGE_KEYS = { APP_MODE: createStorageKey('appMode'), CUSTOM_TOKENS: createStorageKey('customTokens'), + // Map of bridgeHash -> epoch ms when the deposit was first observed + // READY_TO_CLAIM, so the autoclaim grace period survives refreshes. + AUTOCLAIM_READY_AT: createStorageKey('autoclaimReadyAt') } as const; export const StorageUtils = { @@ -44,5 +51,5 @@ export const StorageUtils = { } catch { return false; } - }, + } }; diff --git a/app/utils/trackerSteps.ts b/app/utils/trackerSteps.ts new file mode 100644 index 0000000..c0d70ca --- /dev/null +++ b/app/utils/trackerSteps.ts @@ -0,0 +1,66 @@ +import type { AggkitBridgeStep, AggkitStepStatus } from '@agglayer/sdk'; + +// Humanized copy for each step of the aggkit tracker's route (see +// useBridgeTracking). Keyed on `step_name`, the bare string aggkit ships +// on the wire (agglayer/aggkit#1781) -- see the SDK's AggkitBridgeStep +// union for the full deviation writeup. +// +// `sourceName`/`destinationName` are interpolated where the copy needs to +// name a specific chain; both are optional since the tracker bar can render +// before chain metadata resolves (falls back to "the source"/"the +// destination"). +interface TrackerStepLabelParams { + sourceName?: string; + destinationName?: string; +} + +const STEP_LABELS: Record string> = { + WaitingGERUpdate: () => 'Waiting for the global exit root update on L1', + WaitingLERUpdate: ({ sourceName }) => + `Waiting for the local exit root update on ${sourceName || 'the source'}`, + PendingInclusion: () => 'Waiting for inclusion in an agglayer certificate', + CertificatePending: () => 'Waiting for the certificate to settle', + WaitL1SettledGER: () => 'Waiting for settlement to confirm on L1', + WaitingGERInjection: ({ destinationName }) => + `Waiting for the exit root to reach ${destinationName || 'the destination'}`, + // Deliberately NOT "Ready" -- entering this step only means the tracker's + // fast path (a direct read of the settlement tx's own L1 receipt) has + // resolved; it does not mean aggkit's bridge-service has finished its own, + // separate L1-info-tree sync, which is what actually gates the "Claim + // tokens" button (status READY_TO_CLAIM) and what /claim-proof needs to + // serve a proof. Measured gap on a live devnet L2->L1 bridge: tracker + // entered this step at T+18s, the claim was not actually possible (proof + // not servable) until T+40.5s -- upstream aggkit#1786 (OPEN): + // https://github.com/agglayer/aggkit/issues/1786. Saying "Ready" here would + // read as a UI bug the moment a user notices the claim button hasn't + // appeared yet. + WaitingClaim: ({ destinationName }) => + `Finalizing claim data for ${destinationName || 'the destination'}`, + Claimed: () => 'Claimed' +}; + +export const getTrackerStepLabel = ( + stepName: AggkitBridgeStep, + params: TrackerStepLabelParams = {} +): string => STEP_LABELS[stepName](params); + +const STEP_STATUS_COPY: Record = { + pending: 'Pending', + inProgress: 'In progress', + done: 'Done', + error: 'Error' +}; + +// Tooltip body: label + status. `expected_duration` is folded in when +// present, but it has never been observed on the wire (see the SDK's +// AggkitBridgeStepPath doc comment), so the copy must read fine without it. +export const getTrackerStepTooltip = ( + stepName: AggkitBridgeStep, + status: AggkitStepStatus, + params: TrackerStepLabelParams = {}, + expectedDuration?: string +): string => { + const label = getTrackerStepLabel(stepName, params); + const durationSuffix = expectedDuration ? ` (~${expectedDuration})` : ''; + return `${label}${durationSuffix} — ${STEP_STATUS_COPY[status]}`; +}; diff --git a/app/utils/transaction.ts b/app/utils/transaction.ts index 15c6f55..6e0538c 100644 --- a/app/utils/transaction.ts +++ b/app/utils/transaction.ts @@ -37,8 +37,13 @@ export const mapTransactionRequest = (params: TransactionParams) => { }; }; -export const resolveLeafIndex = (tx: Transaction): number => - tx.leafIndexForProof != null ? tx.leafIndexForProof : tx.leafIndex; +// For `Bridge.isClaimed` ONLY — the contract's leafIndex arg is the local +// deposit index (`deposit_count`), NOT the L1-info-tree index aggkit's +// claim-proof needs. `tx.leafIndex` already carries +// `deposit_count` (see AggkitBridgeAggregator.toTransaction); the L1-info-tree +// index is a *separate*, freshly-probed value from +// `AggkitBridgeAggregator.getClaimInputs`, never read off the row here. +export const resolveLeafIndex = (tx: Transaction): number => tx.leafIndex; const GLOBAL_INDEX_MAINNET_FLAG = BigInt(2) ** BigInt(64); const GLOBAL_INDEX_NETWORK_OFFSET = BigInt(2) ** BigInt(32); diff --git a/config.json b/config.json index 74e981e..29ed55b 100644 --- a/config.json +++ b/config.json @@ -1,17 +1,36 @@ { - "bridgeHubApiBaseUrl": "http://localhost:8080", + "walletConnect": { + "projectId": "YOUR_PROJECT_ID_HERE" + }, "externalLinks": { "privacyPolicy": "https://polygon.technology/privacy-policy", "termsOfUse": "https://polygon.technology/terms-of-use", "contactSupport": "https://support.polygon.technology/support/home" }, + "autoclaim": { + "l1_to_l2": { + "expectedAutoclaim": true, + "waitForAutoclaimMs": 120000 + }, + "l2_to_l1": { + "expectedAutoclaim": false + }, + "l2_to_l2": { + "expectedAutoclaim": true, + "waitForAutoclaimMs": 300000 + } + }, "chains": { "MAINNET": { "id": 1, "name": "Ethereum", "rpcUrl": "https://eth.merkle.io", "explorerUrl": "https://etherscan.io", - "currency": { "name": "Ether", "symbol": "ETH", "decimals": 18 }, + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", "networkId": 0, "isTestnet": false, @@ -22,7 +41,11 @@ "name": "Katana", "rpcUrl": "https://rpc.katana.network", "explorerUrl": "https://katanascan.com", - "currency": { "name": "Ether", "symbol": "ETH", "decimals": 18 }, + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", "networkId": 20, "isTestnet": false, @@ -33,7 +56,11 @@ "name": "Forknet", "rpcUrl": "https://rpc-forknet.t.conduit.xyz", "explorerUrl": "https://forkscan.org/", - "currency": { "name": "Ether", "symbol": "ETH", "decimals": 18 }, + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, "iconUrl": "https://explorer-forknet.t.conduit.xyz/assets/configs/network_icon_dark.svg", "networkId": 22, "isTestnet": false, @@ -44,7 +71,11 @@ "name": "Sepolia", "rpcUrl": "https://ethereum-sepolia-rpc.publicnode.com", "explorerUrl": "https://sepolia.etherscan.io", - "currency": { "name": "Sepolia Ether", "symbol": "ETH", "decimals": 18 }, + "currency": { + "name": "Sepolia Ether", + "symbol": "ETH", + "decimals": 18 + }, "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", "networkId": 0, "isTestnet": true, @@ -55,11 +86,60 @@ "name": "Bokuto", "rpcUrl": "https://rpc-katana-bokuto.t.conduit.xyz", "explorerUrl": "https://bokuto.katanascan.com/", - "currency": { "name": "Ether", "symbol": "ETH", "decimals": 18 }, + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", "networkId": 37, "isTestnet": true, "eta": 180 + }, + "DEVNET_L1": { + "id": 271828, + "name": "Devnet L1", + "rpcUrl": "http://127.0.0.1:8555/l1rpc", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": true, + "eta": 1 + }, + "DEVNET_L2_001": { + "id": 20201, + "name": "Devnet L2-001", + "rpcUrl": "http://127.0.0.1:8555/l2rpc-001", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 1, + "isTestnet": true, + "eta": 1 + }, + "DEVNET_L2_002": { + "id": 20202, + "name": "Devnet L2-002", + "rpcUrl": "http://127.0.0.1:8555/l2rpc-002", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 2, + "isTestnet": true, + "eta": 1 } }, "appModes": { @@ -68,7 +148,7 @@ "mainnet": { "label": "Mainnet", "bridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", - "proofApiSuffix": "mainnet", + "aggkitProxy": "https://PLACEHOLDER-mainnet-aggkit-proxy", "chainKeys": ["MAINNET", "KATANA", "FORKNET"], "defaultFromChainKey": "MAINNET", "defaultToChainKey": "KATANA" @@ -76,16 +156,18 @@ "testnet": { "label": "Testnet", "bridgeAddress": "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582", - "proofApiSuffix": "testnet", + "aggkitProxy": "https://PLACEHOLDER-testnet-aggkit-proxy", "chainKeys": ["SEPOLIA", "BOKUTO"], "defaultFromChainKey": "SEPOLIA", "defaultToChainKey": "BOKUTO" }, "devnet": { "label": "Devnet", - "bridgeAddress": "0x1348947e282138d8f377b467F7D9c2EB0F335d1f", - "proofApiSuffix": "devnet", - "chainKeys": [] + "bridgeAddress": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038", + "aggkitProxy": "http://127.0.0.1:8555/aggkitapi", + "chainKeys": ["DEVNET_L1", "DEVNET_L2_001", "DEVNET_L2_002"], + "defaultFromChainKey": "DEVNET_L1", + "defaultToChainKey": "DEVNET_L2_001" } } } diff --git a/config/config.ci.devnet.json b/config/config.ci.devnet.json new file mode 100644 index 0000000..76ec84d --- /dev/null +++ b/config/config.ci.devnet.json @@ -0,0 +1,174 @@ +{ + "walletConnect": { + "projectId": "YOUR_PROJECT_ID_HERE" + }, + "externalLinks": { + "privacyPolicy": "https://polygon.technology/privacy-policy", + "termsOfUse": "https://polygon.technology/terms-of-use", + "contactSupport": "https://support.polygon.technology/support/home" + }, + "autoclaim": { + "l1_to_l2": { + "expectedAutoclaim": true, + "waitForAutoclaimMs": 120000 + }, + "l2_to_l1": { + "expectedAutoclaim": false + }, + "l2_to_l2": { + "expectedAutoclaim": true, + "waitForAutoclaimMs": 300000 + } + }, + "chains": { + "MAINNET": { + "id": 1, + "name": "Ethereum", + "rpcUrl": "https://eth.merkle.io", + "explorerUrl": "https://etherscan.io", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": false, + "eta": 20 + }, + "KATANA": { + "id": 747474, + "name": "Katana", + "rpcUrl": "https://rpc.katana.network", + "explorerUrl": "https://katanascan.com", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 20, + "isTestnet": false, + "eta": 180 + }, + "FORKNET": { + "id": 8338, + "name": "Forknet", + "rpcUrl": "https://rpc-forknet.t.conduit.xyz", + "explorerUrl": "https://forkscan.org/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://explorer-forknet.t.conduit.xyz/assets/configs/network_icon_dark.svg", + "networkId": 22, + "isTestnet": false, + "eta": 180 + }, + "SEPOLIA": { + "id": 11155111, + "name": "Sepolia", + "rpcUrl": "https://ethereum-sepolia-rpc.publicnode.com", + "explorerUrl": "https://sepolia.etherscan.io", + "currency": { + "name": "Sepolia Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": true, + "eta": 20 + }, + "BOKUTO": { + "id": 737373, + "name": "Bokuto", + "rpcUrl": "https://rpc-katana-bokuto.t.conduit.xyz", + "explorerUrl": "https://bokuto.katanascan.com/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 37, + "isTestnet": true, + "eta": 180 + }, + "DEVNET_L1": { + "id": 271828, + "name": "Devnet L1", + "rpcUrl": "http://127.0.0.1:8555/l1rpc", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": true, + "eta": 1 + }, + "DEVNET_L2_001": { + "id": 20201, + "name": "Devnet L2-001", + "rpcUrl": "http://127.0.0.1:8555/l2rpc-001", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 1, + "isTestnet": true, + "eta": 1 + }, + "DEVNET_L2_002": { + "id": 20202, + "name": "Devnet L2-002", + "rpcUrl": "http://127.0.0.1:8555/l2rpc-002", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 2, + "isTestnet": true, + "eta": 1 + } + }, + "appModes": { + "default": "devnet", + "configs": { + "mainnet": { + "label": "Mainnet", + "bridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "aggkitProxy": "https://PLACEHOLDER-mainnet-aggkit-proxy", + "chainKeys": ["MAINNET", "KATANA", "FORKNET"], + "defaultFromChainKey": "MAINNET", + "defaultToChainKey": "KATANA" + }, + "testnet": { + "label": "Testnet", + "bridgeAddress": "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582", + "aggkitProxy": "https://PLACEHOLDER-testnet-aggkit-proxy", + "chainKeys": ["SEPOLIA", "BOKUTO"], + "defaultFromChainKey": "SEPOLIA", + "defaultToChainKey": "BOKUTO" + }, + "devnet": { + "label": "Devnet", + "bridgeAddress": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038", + "aggkitProxy": "http://127.0.0.1:8555/aggkitapi", + "chainKeys": ["DEVNET_L1", "DEVNET_L2_001", "DEVNET_L2_002"], + "defaultFromChainKey": "DEVNET_L1", + "defaultToChainKey": "DEVNET_L2_001" + } + } + } +} diff --git a/config/configLoader.mjs b/config/configLoader.mjs new file mode 100644 index 0000000..6f1c6df --- /dev/null +++ b/config/configLoader.mjs @@ -0,0 +1,101 @@ +import { parseConfigOrThrow } from './configValidator.mjs'; + +/** + * @typedef {import('./configValidator.mjs').JsonConfig} JsonConfig + */ + +/** + * Resolves one origin-relative aggkit base URL (a mode's `aggkitProxy`). + * Absolute values pass through unchanged. See design.md §5.4 for the + * precedence rules encoded here. + * + * @param {string} value + * @param {string | undefined} origin + * @param {boolean} allowRelative + * @returns {string} + */ +export const resolveAggkitProxyUrl = (value, origin, allowRelative) => { + if (!value.startsWith('/')) return value; + + // Protocol-relative ("//host/path") is rejected here as well as in + // config/configSchema.mjs's relativeUrlPath regex. The schema is the primary + // guard, but this function is exported and is also called directly from + // app/config.ts's NEXT_PUBLIC_AGGKIT_PROXY path, so the same-origin + // property should not depend on every caller having validated first. It + // matters specifically on the `allowRelative && origin === undefined` branch + // below, which returns the value verbatim: `new URL('//evil.example', + // 'https://app.example').origin` is `https://evil.example`, so an + // unprefixed protocol-relative value is a cross-origin request, not a + // relative one. (When an origin IS supplied the concatenation below is + // already safe — `https://app.example` + `//evil.example` parses as a path.) + if (value.startsWith('//')) { + throw new Error( + `APP_CONFIG_INVALID: protocol-relative aggkitProxy URL "${value}" is not allowed` + ); + } + + if (origin === undefined) { + if (allowRelative) return value; + throw new Error(`APP_CONFIG_INVALID: relative aggkitProxy URL "${value}" requires an origin`); + } + + return origin.replace(/\/+$/, '') + value; +}; + +/** + * Resolves a mode config's `aggkitProxy` URL, if present, against the given + * origin. A mode with no `aggkitProxy` (not yet configured) passes through + * unchanged. + * + * @param {JsonConfig['appModes']['configs'][string]} modeConfig + * @param {string | undefined} origin + * @param {boolean} allowRelative + * @returns {JsonConfig['appModes']['configs'][string]} + */ +const resolveModeConfigAggkitUrls = (modeConfig, origin, allowRelative) => { + if (modeConfig.aggkitProxy === undefined) return modeConfig; + + return { + ...modeConfig, + aggkitProxy: resolveAggkitProxyUrl(modeConfig.aggkitProxy, origin, allowRelative) + }; +}; + +/** + * @param {JsonConfig} config + * @param {string | undefined} origin + * @param {boolean} allowRelative + * @returns {JsonConfig} + */ +const resolveAggkitProxiesInConfig = (config, origin, allowRelative) => { + const resolvedConfigs = Object.fromEntries( + Object.entries(config.appModes.configs).map(([modeKey, modeConfig]) => [ + modeKey, + resolveModeConfigAggkitUrls(modeConfig, origin, allowRelative) + ]) + ); + + return { + ...config, + appModes: { + ...config.appModes, + configs: resolvedConfigs + } + }; +}; + +/** + * The single validate-and-normalize path shared by every loader (browser + * fetch adapter, Node disk adapter, Playwright bootstrap, sync/validate + * scripts). Schema-valid AND URL-normalized on return. Never mutates its + * input. Zero Node APIs — safe to bundle client-side. + * + * @param {unknown} rawConfig + * @param {{ sourceName?: string, origin?: string, allowRelative?: boolean }} [options] + * @returns {JsonConfig} + */ +export const normalizeConfigOrThrow = (rawConfig, options = {}) => { + const { sourceName, origin, allowRelative = false } = options; + const parsedConfig = parseConfigOrThrow(rawConfig, { sourceName }); + return resolveAggkitProxiesInConfig(parsedConfig, origin, allowRelative); +}; diff --git a/config/configLoader.test.mjs b/config/configLoader.test.mjs new file mode 100644 index 0000000..cee563c --- /dev/null +++ b/config/configLoader.test.mjs @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeConfigOrThrow, resolveAggkitProxyUrl } from './configLoader.mjs'; + +// Same fixture shape as configValidator.test.mjs (design.md §4/§5): a +// schema-valid, semantically-valid devnet config with three chains and one +// enabled mode. Callers mutate `aggkitProxy` per test to exercise +// resolution/rejection of relative and protocol-relative URLs. +const chain = (overrides = {}) => ({ + id: 1, + name: 'Chain', + rpcUrl: 'https://rpc.example', + explorerUrl: 'https://explorer.example', + currency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + iconUrl: 'https://icon.example/icon.svg', + networkId: 0, + isTestnet: true, + eta: 1, + ...overrides +}); + +const buildConfig = (aggkitProxy) => ({ + walletConnect: { projectId: 'test-project-id' }, + externalLinks: { privacyPolicy: '', termsOfUse: '', contactSupport: '' }, + chains: { + DEVNET_L1: chain({ id: 271828, name: 'Devnet L1', networkId: 0 }), + DEVNET_L2_001: chain({ id: 20201, name: 'Devnet L2-001', networkId: 1 }), + DEVNET_L2_002: chain({ id: 20202, name: 'Devnet L2-002', networkId: 2 }) + }, + appModes: { + default: 'devnet', + configs: { + devnet: { + label: 'Devnet', + bridgeAddress: '0xC8cbEBf950B9Df44d987c8619f092beA980fF038', + ...(aggkitProxy === undefined ? {} : { aggkitProxy }), + chainKeys: ['DEVNET_L1', 'DEVNET_L2_001', 'DEVNET_L2_002'], + defaultFromChainKey: 'DEVNET_L1', + defaultToChainKey: 'DEVNET_L2_001' + } + } + } +}); + +describe('resolveAggkitProxyUrl (design.md §5.4)', () => { + it('passes an absolute URL through unchanged, regardless of origin', () => { + expect( + resolveAggkitProxyUrl('https://aggkit.example/aggkitapi', 'http://origin.example', false) + ).toBe('https://aggkit.example/aggkitapi'); + expect(resolveAggkitProxyUrl('https://aggkit.example/aggkitapi', undefined, false)).toBe( + 'https://aggkit.example/aggkitapi' + ); + }); + + it('resolves a relative path against the given origin', () => { + expect(resolveAggkitProxyUrl('/aggkitapi', 'http://origin.example', false)).toBe( + 'http://origin.example/aggkitapi' + ); + }); + + it('strips a trailing slash from the origin before concatenating', () => { + expect(resolveAggkitProxyUrl('/aggkitapi', 'http://origin.example/', false)).toBe( + 'http://origin.example/aggkitapi' + ); + }); + + it('leaves a relative path untouched when allowRelative is true and origin is undefined (validate-only paths)', () => { + expect(resolveAggkitProxyUrl('/aggkitapi', undefined, true)).toBe('/aggkitapi'); + }); + + it('throws loudly for a relative path with no origin and allowRelative false', () => { + expect(() => resolveAggkitProxyUrl('/aggkitapi', undefined, false)).toThrow( + /APP_CONFIG_INVALID: relative aggkitProxy URL "\/aggkitapi" requires an origin/ + ); + }); +}); + +describe('normalizeConfigOrThrow — URL normalization (design.md §5, A-5 item 3)', () => { + it('resolves a relative aggkitProxy value against the given origin', () => { + const result = normalizeConfigOrThrow(buildConfig('/aggkitapi'), { + sourceName: 'config.json', + origin: 'https://served-from.example' + }); + + expect(result.appModes.configs.devnet.aggkitProxy).toBe( + 'https://served-from.example/aggkitapi' + ); + }); + + it('passes an already-absolute aggkitProxy value through unchanged', () => { + const result = normalizeConfigOrThrow(buildConfig('https://aggkit.example/1'), { + sourceName: 'config.json', + origin: 'https://served-from.example' + }); + + expect(result.appModes.configs.devnet.aggkitProxy).toBe('https://aggkit.example/1'); + }); + + it('leaves a relative entry byte-for-byte when allowRelative is set and no origin is given (sync/validate scripts)', () => { + const raw = buildConfig('/aggkitapi'); + + const result = normalizeConfigOrThrow(raw, { sourceName: 'config.json', allowRelative: true }); + + expect(result.appModes.configs.devnet.aggkitProxy).toBe('/aggkitapi'); + }); + + it('never mutates its input', () => { + const raw = buildConfig('/aggkitapi'); + const before = JSON.parse(JSON.stringify(raw)); + + normalizeConfigOrThrow(raw, { + sourceName: 'config.json', + origin: 'https://served-from.example' + }); + + expect(raw).toEqual(before); + }); + + it('rejects a protocol-relative URL ("//evil.example") at the schema level -- the security-relevant case', () => { + const raw = buildConfig('//evil.example'); + + expect(() => + normalizeConfigOrThrow(raw, { + sourceName: 'config.json', + origin: 'https://served-from.example' + }) + ).toThrow( + /config\.json schema validation failed:\n- appModes\.configs\.devnet\.aggkitProxy: Invalid input/ + ); + }); + + // X-1 regression guard. zod's `.url()` only requires that a value parse as a + // URL, so before configSchema.mjs constrained the scheme it accepted + // `javascript:` and `data:` everywhere a URL was expected. config.json is + // mounted at container start, and `externalLinks.*` / `explorerUrl` reach + // `` and `window.open(...)` unmodified -- a `javascript:` value + // there was demonstrated to execute with the app's own origin (reading + // document.cookie and localStorage). Every URL field must stay http(s)-only. + it.each([ + [ + 'externalLinks.contactSupport', + (cfg) => (cfg.externalLinks.contactSupport = 'javascript:alert(1)') + ], + [ + 'externalLinks.privacyPolicy', + (cfg) => (cfg.externalLinks.privacyPolicy = 'data:text/html,') + ], + [ + 'chains.DEVNET_L1.explorerUrl', + (cfg) => (cfg.chains.DEVNET_L1.explorerUrl = 'javascript:alert(1)') + ], + ['chains.DEVNET_L1.iconUrl', (cfg) => (cfg.chains.DEVNET_L1.iconUrl = 'javascript:alert(1)')], + ['chains.DEVNET_L1.rpcUrl', (cfg) => (cfg.chains.DEVNET_L1.rpcUrl = 'file:///etc/passwd')], + [ + 'appModes.configs.devnet.aggkitProxy', + (cfg) => (cfg.appModes.configs.devnet.aggkitProxy = 'javascript:alert(1)') + ] + ])('rejects a non-http(s) URL scheme in %s', (_field, mutate) => { + const raw = buildConfig('https://aggkit.example/1'); + mutate(raw); + + expect(() => + normalizeConfigOrThrow(raw, { + sourceName: 'config.json', + origin: 'https://served-from.example' + }) + ).toThrow(/schema validation failed/); + }); + + it('still accepts plain http and https URLs', () => { + const raw = buildConfig('http://127.0.0.1:8555/aggkitapi'); + + expect(() => + normalizeConfigOrThrow(raw, { + sourceName: 'config.json', + origin: 'https://served-from.example' + }) + ).not.toThrow(); + }); + + it('rejects a bare relative path with no leading slash ("aggkitapi")', () => { + const raw = buildConfig('aggkitapi'); + + expect(() => normalizeConfigOrThrow(raw, { sourceName: 'config.json' })).toThrow( + /schema validation failed/ + ); + }); + + it('leaves aggkitProxy untouched (absent) when the field is omitted entirely', () => { + const result = normalizeConfigOrThrow(buildConfig(undefined), { + sourceName: 'config.json', + origin: 'https://served-from.example' + }); + + expect(result.appModes.configs.devnet.aggkitProxy).toBeUndefined(); + }); + + it('rejects the removed aggkitBridgeApis map as an unrecognized key', () => { + const raw = buildConfig('https://aggkit.example/1'); + raw.appModes.configs.devnet.aggkitBridgeApis = { 1: 'https://aggkit.example/1' }; + + expect(() => normalizeConfigOrThrow(raw, { sourceName: 'config.json' })).toThrow( + /config\.json schema validation failed:\n- appModes\.configs\.devnet: Unrecognized key: "aggkitBridgeApis"/ + ); + }); +}); + +// X-1: the protocol-relative rejection must not live only in the schema. +// resolveAggkitProxyUrl is exported and is called directly by app/config.ts's +// NEXT_PUBLIC_AGGKIT_PROXY path, so it carries its own guard -- otherwise any +// future caller that skips the schema would silently turn "//evil.example" +// into a cross-origin request. +describe('resolveAggkitProxyUrl — protocol-relative guard (X-1)', () => { + it('throws for "//host" even with an origin supplied', () => { + expect(() => resolveAggkitProxyUrl('//evil.example', 'https://app.example', false)).toThrow( + /protocol-relative/ + ); + }); + + it('throws for "//host" on the allowRelative branch, where the value would be returned verbatim', () => { + expect(() => resolveAggkitProxyUrl('//evil.example', undefined, true)).toThrow( + /protocol-relative/ + ); + }); +}); diff --git a/config/configLoaderNode.mjs b/config/configLoaderNode.mjs new file mode 100644 index 0000000..cfa17ec --- /dev/null +++ b/config/configLoaderNode.mjs @@ -0,0 +1,44 @@ +// The only file allowed to touch `node:fs` in the config-loading layer. Must +// never be imported from `app/` (that bundle is browser-only). +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { normalizeConfigOrThrow } from './configLoader.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const DEFAULT_CONFIG_PATH = path.join(REPO_ROOT, 'config.json'); + +/** + * @typedef {import('./configValidator.mjs').JsonConfig} JsonConfig + */ + +/** + * Reads and validates a config.json off disk. Defaults to the repo-root + * config.json, resolved from this module's own location (robust to the + * caller's `process.cwd()`). + * + * @param {{ configPath?: string, origin?: string, allowRelative?: boolean, sourceName?: string }} [options] + * @returns {JsonConfig} + */ +export const loadConfigFromDiskOrThrow = (options = {}) => { + const { + configPath = DEFAULT_CONFIG_PATH, + origin, + allowRelative = false, + sourceName = 'config.json' + } = options; + + const fileContent = fs.readFileSync(configPath, 'utf8'); + + let rawConfig; + try { + rawConfig = JSON.parse(fileContent); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown JSON parse error'; + throw new Error(`config.json parse failed: ${message}`); + } + + return normalizeConfigOrThrow(rawConfig, { sourceName, origin, allowRelative }); +}; diff --git a/config/configSchema.mjs b/config/configSchema.mjs index 352d72e..2e3d460 100644 --- a/config/configSchema.mjs +++ b/config/configSchema.mjs @@ -4,9 +4,59 @@ import { APP_MODES } from './appModes.mjs'; const modeEnum = z.enum(APP_MODES); const nonEmptyString = z.string().trim().min(1); -const urlString = z.string().url(); +// Every absolute URL in config.json must be http(s). zod's `.url()` only +// requires that the value parse as a URL, so on its own it accepts +// `javascript:...`, `data:...`, `file:...` and any other scheme. config.json is +// mounted at container start (see entrypoint.sh / docs/docker.md), so it is an +// untrusted input wherever someone other than the app owner can supply it, and +// several of these values reach navigation sinks unmodified — `externalLinks.*` +// and `explorerUrl` land in `` (app/components/header/constants.ts, +// bridgeSuccessView.tsx, claimResultModal.tsx) and in +// `window.open(...)` (app/components/transactions/transactionListItem.tsx:126, +// transactionDetailsModal.tsx:107,139, bridge/tokenSelectorManageView.tsx:168). +// A `javascript:` value there executes with the app's origin. Constrain the +// scheme here, at the single choke point every loader shares +// (config/configLoader.mjs's normalizeConfigOrThrow), rather than at each sink. +const isHttpUrl = (value) => { + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}; +const urlString = z + .string() + .url() + .refine(isHttpUrl, { message: 'must use the http or https scheme' }); const optionalUrlString = z.union([urlString, z.literal('')]); const addressString = z.string().regex(/^0x[a-fA-F0-9]{40}$/); +// An origin-relative path: exactly one leading slash. `//host` (protocol-relative) +// is deliberately rejected — it changes origin and would reintroduce the +// cross-origin/SSRF surface that relative URLs exist to remove. +const relativeUrlPath = z.string().regex(/^\/(?!\/)[^\s]*$/); +const aggkitBaseUrlString = z.union([urlString, relativeUrlPath]); +// A single URL fronting every network for a mode -- one multiplexing +// aggkit-proxy instance (PROXY + TRACKER components, see docs/deployment.md) +// distinguishing networks by the `?network_id=` query param rather than by +// host. Exported so app/config.ts can validate the NEXT_PUBLIC_AGGKIT_PROXY +// env override against the same shape, rather than re-declaring it. +// +// This used to be one of two mutually-exclusive fields on a mode config, the +// other being a per-network `aggkitBridgeApis` map (networkId -> aggkit REST +// base URL) for a mode whose networks were served by distinct per-network +// aggkit backends instead of one shared proxy. That map form has been +// removed from this schema: every mode this app ships now goes through one +// aggkit-proxy, so the per-network map no longer models anything this repo's +// own config.json needs. kurtosis-cdk's dev-ui config template +// (static_files/additional_services/bridge-ui/aggkit-dev-ui-config.json.tmpl) +// still generates the old map-form field as of this writing -- that template +// needs a follow-up migration (tracked separately, kurtosis-cdk-side) to emit +// `aggkitProxy` instead; until it lands, a config produced by that template +// would fail this schema's `.strict()` check (an unrecognized `aggkitBridgeApis` +// key). This is a deliberate, temporary, tracked cross-repo skew, not an +// oversight. +export const aggkitProxySchema = aggkitBaseUrlString; export const JsonNativeCurrencyConfigSchema = z .object({ @@ -34,16 +84,58 @@ export const jsonAppModeConfigSchema = z .object({ label: nonEmptyString, bridgeAddress: addressString, - proofApiSuffix: nonEmptyString, + // A single aggkit-proxy fronting every network in this mode, tracker + // included. May be omitted for a mode with no aggkit backend configured + // yet -- the documented "not yet configured" escape hatch (a disabled + // mode with fewer than two chainKeys, or one whose real proxy URL doesn't + // exist yet). + aggkitProxy: aggkitProxySchema.optional(), chainKeys: z.array(nonEmptyString), defaultFromChainKey: nonEmptyString.optional(), defaultToChainKey: nonEmptyString.optional() }) .strict(); +const routeAutoclaimSchema = z + .object({ + // Whether an autoclaim service is expected to claim this route on the user's + // behalf. When false, the manual "Claim tokens" button shows as soon as the + // deposit is READY_TO_CLAIM (legacy behavior). + expectedAutoclaim: z.boolean(), + // Grace period (milliseconds, measured from when the deposit first becomes + // READY_TO_CLAIM) to wait for the autoclaim service before surfacing the + // manual claim button. Only used when expectedAutoclaim is true. + waitForAutoclaimMs: z.number().int().min(0).optional() + }) + .strict(); + +// Per-route autoclaim UX config. Optional in config.json — app/config.ts applies +// per-route defaults for any route omitted here. +export const autoclaimConfigSchema = z + .object({ + l1_to_l2: routeAutoclaimSchema.optional(), + l2_to_l1: routeAutoclaimSchema.optional(), + l2_to_l2: routeAutoclaimSchema.optional() + }) + .strict(); + +// WalletConnect/Reown Cloud project id, read at RUNTIME from the served +// config.json -- this is what makes it settable in a prebuilt container +// image without a rebuild (a1-runtime-config-design.md §6.3; see +// app/config.ts's resolveProjectIdOverride and entrypoint.sh's structural +// check). Required (not `.optional()`) so a config.json missing this field +// fails validation loudly rather than silently falling back to `undefined` -- +// see README/docs/config.md for the placeholder value that reproduces the +// pre-existing graceful-degradation ("basic" AppKit mode) behavior. +export const walletConnectConfigSchema = z + .object({ + projectId: nonEmptyString + }) + .strict(); + export const jsonConfigSchema = z .object({ - bridgeHubApiBaseUrl: urlString, + autoclaim: autoclaimConfigSchema.optional(), externalLinks: z .object({ privacyPolicy: optionalUrlString, @@ -57,6 +149,7 @@ export const jsonConfigSchema = z default: modeEnum, configs: z.record(nonEmptyString, jsonAppModeConfigSchema) }) - .strict() + .strict(), + walletConnect: walletConnectConfigSchema }) .strict(); diff --git a/config/configValidator.mjs b/config/configValidator.mjs index d00ff3f..233a586 100644 --- a/config/configValidator.mjs +++ b/config/configValidator.mjs @@ -3,7 +3,10 @@ import { jsonConfigSchema } from './configSchema.mjs'; const APP_MODE_SET = new Set(APP_MODES); const MIN_ENABLED_MODE_CHAIN_COUNT = 2; -const DEFAULT_CHAIN_KEY_FIELDS = /** @type {const} */ (['defaultFromChainKey', 'defaultToChainKey']); +const DEFAULT_CHAIN_KEY_FIELDS = /** @type {const} */ ([ + 'defaultFromChainKey', + 'defaultToChainKey' +]); /** * @typedef {import('zod').infer} JsonConfig @@ -31,7 +34,7 @@ const getDuplicateChainIdErrors = (chainsByKey) => { const existingChainKey = firstChainKeyById.get(chainConfig.id); if (existingChainKey) { return [ - `chains.${chainKey}.id: duplicate chain id "${chainConfig.id}" (already used by "${existingChainKey}")`, + `chains.${chainKey}.id: duplicate chain id "${chainConfig.id}" (already used by "${existingChainKey}")` ]; } @@ -47,7 +50,10 @@ const getDuplicateChainIdErrors = (chainsByKey) => { const getUnsupportedModeKeyErrors = (modeConfigsByKey) => Object.keys(modeConfigsByKey) .filter((modeKey) => !APP_MODE_SET.has(modeKey)) - .map((modeKey) => `appModes.configs.${modeKey}: unsupported mode key; expected one of ${APP_MODES.join(', ')}`); + .map( + (modeKey) => + `appModes.configs.${modeKey}: unsupported mode key; expected one of ${APP_MODES.join(', ')}` + ); /** * @param {string} modeKey @@ -65,15 +71,24 @@ const getModeConfigErrors = (modeKey, modeConfig, chainsByKey) => { const missingModeChainKeyErrors = modeConfig.chainKeys .filter((chainKey) => !chainsByKey[chainKey]) - .map((chainKey) => `appModes.configs.${modeKey}.chainKeys: chain key "${chainKey}" does not exist in chains`); + .map( + (chainKey) => + `appModes.configs.${modeKey}.chainKeys: chain key "${chainKey}" does not exist in chains` + ); const invalidDefaultChainKeyErrors = DEFAULT_CHAIN_KEY_FIELDS.flatMap((fieldName) => { const configuredChainKey = modeConfig[fieldName]; if (!configuredChainKey || modeChainKeySet.has(configuredChainKey)) return []; - return [`appModes.configs.${modeKey}.${fieldName}: "${configuredChainKey}" must be listed in chainKeys`]; + return [ + `appModes.configs.${modeKey}.${fieldName}: "${configuredChainKey}" must be listed in chainKeys` + ]; }); - return [...duplicateModeChainKeyErrors, ...missingModeChainKeyErrors, ...invalidDefaultChainKeyErrors]; + return [ + ...duplicateModeChainKeyErrors, + ...missingModeChainKeyErrors, + ...invalidDefaultChainKeyErrors + ]; }; /** @@ -112,7 +127,7 @@ const validateSemantics = (config) => { ...unsupportedModeKeyErrors, ...missingEnabledModeError, ...missingDefaultModeConfigError, - ...modeConfigErrors, + ...modeConfigErrors ]; }; @@ -129,12 +144,16 @@ export const parseConfigOrThrow = (configJson, options = {}) => { const issuePath = formatZodPath(issue.path); return `${issuePath}: ${issue.message}`; }); - throw new Error(`${sourceName} schema validation failed:\n${lines.map((line) => `- ${line}`).join('\n')}`); + throw new Error( + `${sourceName} schema validation failed:\n${lines.map((line) => `- ${line}`).join('\n')}` + ); } const semanticErrors = validateSemantics(parsedConfig.data); if (semanticErrors.length > 0) { - throw new Error(`${sourceName} semantic validation failed:\n${semanticErrors.map((line) => `- ${line}`).join('\n')}`); + throw new Error( + `${sourceName} semantic validation failed:\n${semanticErrors.map((line) => `- ${line}`).join('\n')}` + ); } return parsedConfig.data; diff --git a/config/configValidator.test.mjs b/config/configValidator.test.mjs new file mode 100644 index 0000000..26da3f2 --- /dev/null +++ b/config/configValidator.test.mjs @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; + +import { parseConfigOrThrow } from './configValidator.mjs'; + +const chain = (overrides = {}) => ({ + id: 1, + name: 'Chain', + rpcUrl: 'https://rpc.example', + explorerUrl: 'https://explorer.example', + currency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + iconUrl: 'https://icon.example/icon.svg', + networkId: 0, + isTestnet: true, + eta: 1, + ...overrides +}); + +// Minimal config satisfying jsonConfigSchema + the pre-existing semantic +// checks (>=1 chain, an enabled mode with >=2 chainKeys, a config for the +// default mode). Callers mutate `chains`/`appModes.configs.devnet` per test. +// devnet uses the single-proxy `aggkitProxy` form -- the only form the schema +// accepts as of this config surface cleanup (the per-network `aggkitBridgeApis` +// map has been removed; see configSchema.mjs's comment on aggkitProxySchema). +const buildConfig = () => ({ + walletConnect: { projectId: 'test-project-id' }, + externalLinks: { privacyPolicy: '', termsOfUse: '', contactSupport: '' }, + chains: { + DEVNET_L1: chain({ id: 271828, name: 'Devnet L1', networkId: 0 }), + DEVNET_L2_001: chain({ id: 20201, name: 'Devnet L2-001', networkId: 1 }), + DEVNET_L2_002: chain({ id: 20202, name: 'Devnet L2-002', networkId: 2 }) + }, + appModes: { + default: 'devnet', + configs: { + devnet: { + label: 'Devnet', + bridgeAddress: '0xC8cbEBf950B9Df44d987c8619f092beA980fF038', + aggkitProxy: 'https://aggkit-proxy.example/aggkitapi', + chainKeys: ['DEVNET_L1', 'DEVNET_L2_001', 'DEVNET_L2_002'], + defaultFromChainKey: 'DEVNET_L1', + defaultToChainKey: 'DEVNET_L2_001' + } + } + } +}); + +describe('parseConfigOrThrow — aggkitProxy (the only supported aggkit backend field)', () => { + it('passes for a well-formed config using aggkitProxy', () => { + expect(() => parseConfigOrThrow(buildConfig())).not.toThrow(); + }); + + it('passes for a mode with aggkitProxy omitted entirely (the "not yet configured" escape hatch)', () => { + const config = buildConfig(); + delete config.appModes.configs.devnet.aggkitProxy; + + expect(() => parseConfigOrThrow(config)).not.toThrow(); + }); + + it('rejects the removed per-network aggkitBridgeApis map as an unrecognized key (.strict())', () => { + const config = buildConfig(); + // The map form used to be a genuinely supported, mutually-exclusive + // alternative to aggkitProxy. It has been removed from the schema + // entirely: this proves it, rather than assuming it, so a future + // accidental re-add would be caught here. + config.appModes.configs.devnet.aggkitBridgeApis = { + 1: 'https://aggkit.example/1', + 2: 'https://aggkit.example/2' + }; + + expect(() => parseConfigOrThrow(config)).toThrow(/Unrecognized key: "aggkitBridgeApis"/); + }); + + it('rejects a non-http(s) aggkitProxy URL scheme', () => { + const config = buildConfig(); + config.appModes.configs.devnet.aggkitProxy = 'javascript:alert(1)'; + + expect(() => parseConfigOrThrow(config)).toThrow(/schema validation failed/); + }); + + it('accepts an origin-relative aggkitProxy path', () => { + const config = buildConfig(); + config.appModes.configs.devnet.aggkitProxy = '/aggkitapi'; + + expect(() => parseConfigOrThrow(config)).not.toThrow(); + }); +}); + +// D0e: walletConnect.projectId is REQUIRED (not `.optional()`) so a +// config.json missing it fails loudly, matching the entrypoint.sh jq +// structural check's added requirement -- see docs/docker.md and +// app/config.ts's getWalletConnectProjectId. +describe('parseConfigOrThrow — walletConnect.projectId (required, D0e)', () => { + it('passes for a well-formed config with walletConnect.projectId set', () => { + expect(() => parseConfigOrThrow(buildConfig())).not.toThrow(); + }); + + it('rejects a config missing the walletConnect field entirely', () => { + const config = buildConfig(); + delete config.walletConnect; + + expect(() => parseConfigOrThrow(config)).toThrow(/walletConnect/); + }); + + it('rejects a config with an empty walletConnect.projectId', () => { + const config = buildConfig(); + config.walletConnect.projectId = ''; + + expect(() => parseConfigOrThrow(config)).toThrow(/walletConnect\.projectId/); + }); + + it('rejects a config with walletConnect.projectId of the wrong type', () => { + const config = buildConfig(); + config.walletConnect.projectId = 12345; + + expect(() => parseConfigOrThrow(config)).toThrow(/walletConnect\.projectId/); + }); + + it('rejects an unrecognized key under walletConnect (.strict())', () => { + const config = buildConfig(); + config.walletConnect.extraField = 'unexpected'; + + expect(() => parseConfigOrThrow(config)).toThrow(/Unrecognized key: "extraField"/); + }); +}); + +describe('parseConfigOrThrow — chainKeys / default-chain-key checks (unaffected by the aggkitProxy migration)', () => { + it('throws when chainKeys lists the same chain key twice', () => { + const config = buildConfig(); + config.appModes.configs.devnet.chainKeys.push('DEVNET_L1'); + + expect(() => parseConfigOrThrow(config)).toThrow( + /appModes\.configs\.devnet\.chainKeys: duplicate chain keys are not allowed/ + ); + }); + + it('throws when chainKeys references a chain key absent from chains', () => { + const config = buildConfig(); + config.appModes.configs.devnet.chainKeys.push('NONEXISTENT'); + + expect(() => parseConfigOrThrow(config)).toThrow( + /appModes\.configs\.devnet\.chainKeys: chain key "NONEXISTENT" does not exist in chains/ + ); + }); + + it('throws when defaultFromChainKey is not one of chainKeys', () => { + const config = buildConfig(); + config.appModes.configs.devnet.defaultFromChainKey = 'DEVNET_L2_002'; + config.appModes.configs.devnet.chainKeys = ['DEVNET_L1', 'DEVNET_L2_001']; + + expect(() => parseConfigOrThrow(config)).toThrow( + /appModes\.configs\.devnet\.defaultFromChainKey: "DEVNET_L2_002" must be listed in chainKeys/ + ); + }); +}); + +describe('parseConfigOrThrow — chains<->map and duplicate-networkId checks no longer guard anything (by design)', () => { + it('two non-L1 chains sharing a networkId is NOT rejected once neither uses the map form -- there is no check left to catch it', () => { + // This is the documented consequence of removing aggkitBridgeApis + // entirely (see docs/config.md): the chains<->map cross-check and the + // duplicate-networkId check only ever applied to the map form. With that + // form gone, nothing in this validator inspects networkId agreement for + // aggkitProxy mode at all -- a single proxy fronts every network by + // construction, so there is no per-chain key agreement left to check. + const config = buildConfig(); + config.chains.DEVNET_L2_002.networkId = 1; // same as DEVNET_L2_001 + + expect(() => parseConfigOrThrow(config)).not.toThrow(); + }); +}); diff --git a/docs/config.md b/docs/config.md index 632cd25..9d42937 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,25 +1,239 @@ # Configuration Guide -This app is configured through `config.json` at the project root. The JSON file is imported at build time and bundled with the app. The dev server hot-reloads changes to `config.json`. +This app is configured through `config.json` at the project root. The app fetches +`/config.json` **at runtime**, once per page load — it is not imported as a module or +bundled into the JS at build time. This is what lets a single built app (in particular, a +single Docker image — see [`docs/docker.md`](./docker.md)) be repointed at different +configuration without a rebuild. + +The dev server does **not** hot-reload changes to `config.json` — it never watches the +file, because nothing imports it anymore. The actual workflow is cheaper than the old +Next-rebuild-on-import behavior: after editing the root `config.json`, run + +```bash +node ./scripts/syncPublicConfig.mjs +``` + +in a second terminal (this validates the file and byte-copies it to the gitignored +`public/config.json`, which the dev server serves as a static file), then reload the +browser tab. No Next build/recompile is involved. `pnpm run dev` and `pnpm run build` +both already run this sync automatically before starting/building — you only need to run +it manually mid-session, after an edit, without restarting the dev server. + +**Devnet corollary:** `scripts/kurtosisDevnetEnv.mjs` writes the **root** `config.json` +only. `public/config.json` — and therefore what the dev server actually serves — stays +stale until the next `pnpm run dev` (which re-syncs on start) or a manual +`node ./scripts/syncPublicConfig.mjs`. If you already have `pnpm run dev` running when +you re-run `kurtosisDevnetEnv.mjs`, re-sync (or restart `pnpm run dev`) before reloading +the browser. + +**Config is read once per page load — there is no live reconfiguration.** The app fetches +`/config.json` a single time when it mounts (plus once per explicit user "Retry" after a +failed fetch), and never polls or re-fetches it afterward. To pick up a new +`config.json`, reload the page after the new file is being served (dev: after a sync; +container: after a restart with a different mount — see +[`docs/docker.md`](./docker.md)). This is a deliberate design constraint, not a gap: +`@agglayer/sdk`'s chain registry is an append-only singleton with no reset/clear method, +so silently re-initializing the app in place against different config (without a reload) +could leave stale chain data resident from the previous config. + +## WalletConnect / Reown: `walletConnect.projectId` + +A single required top-level field: + +```json +{ + "walletConnect": { + "projectId": "your-real-reown-cloud-project-id" + } +} +``` + +This is the [Reown Cloud](https://cloud.reown.com) (formerly WalletConnect Cloud) project +id used to initialize AppKit (`app/context/wallet.tsx`). It is **read from the runtime +config, not baked into the JS bundle** — this is what lets a single built Docker image be +repointed at a different project id per deployment, with no rebuild (see +[`docs/docker.md`](./docker.md)). + +**Required, but a placeholder is a valid value.** `walletConnect.projectId` must be +present and non-empty (a config.json missing it, or with an empty string, fails both the +app's Zod validation and the container's structural `jq` check — see +[Validation](#validation) below). The checked-in placeholder `YOUR_PROJECT_ID_HERE` +satisfies that requirement while still triggering the graceful degraded `basic: true` mode +described below — leaving the placeholder in place is a supported, working configuration; +an empty string or a missing field is not. + +If left at the placeholder `YOUR_PROJECT_ID_HERE` (or any other non-real-shaped/empty +value), AppKit runs in a degraded `basic` mode: injected-wallet connect fully works, only +WalletConnect-cloud features (wallet directory images, remote config) are skipped, along +with a handful of benign 401/403 console lines from Reown endpoints. Get a real id at +https://cloud.reown.com. + +### Environment override (local dev / Cloudflare only) + +`NEXT_PUBLIC_PROJECT_ID`, if set, overrides `walletConnect.projectId` — the same +precedence rule as `NEXT_PUBLIC_AGGKIT_PROXY` below (env wins over the served config when +both are present). This exists purely as a local-dev/Playwright convenience, so +`pnpm run dev` and the Playwright configs keep working without editing `config.json`. + +**This override is build-time only and has no effect in a prebuilt container image.** +`build:production`'s `.env.production` deliberately never sets `NEXT_PUBLIC_PROJECT_ID` +(see that file's own header comment), so a published Docker image never has an override to +fall back to and always reads the mounted `config.json`'s value — exactly the property the +two-configs-one-image proof in `docs/docker.md` demonstrates. The Cloudflare Workers +deploy path is unaffected: `deploy.yaml` sets `NEXT_PUBLIC_PROJECT_ID` as a real build-time +secret, which still takes effect there since that path has no runtime config bind mount at +all — the entire app config, including `walletConnect.projectId`, is decided once at build +time for that deployment target. Supporting files: - `config/configSchema.mjs` — shared Zod schema (single source of truth) - `config/configValidator.mjs` — schema + validator used by CLI and app startup +- `config/configLoader.mjs` — shared browser-safe validate-and-normalize path (also resolves a relative `aggkitProxy` URL — see below) +- `config/configLoaderNode.mjs` — Node-side loader (CLI scripts, `scripts/syncPublicConfig.mjs`) +- `app/configLoader.ts` — browser `fetch` adapter, used by the app's startup gate - `app/config.ts` — transforms JSON into typed objects - `app/types/config.ts` — type definitions - `app/utils/config.ts` — config utilities -## Bridge Hub API +## Aggkit Bridge APIs: `aggkitProxy` + +Each app mode's aggkit backend is configured with a single field: + +| Field | Shape | Use when | +|---|---|---| +| `aggkitProxy` | A single URL (or origin-relative path) | One `aggkit-proxy` instance (PROXY + TRACKER components, see [`docs/deployment.md`](./deployment.md)) fronts **every** network in the mode, multiplexing by the `network_id` query parameter. This is every shipped mode's shape — `mainnet`, `testnet`, and `devnet` all use `aggkitProxy` in the committed `config.json`. | -Set `bridgeHubApiBaseUrl` in `config.json`. The app appends `/{proofApiSuffix}/` per mode when building API requests. +The app appends `/bridge/v1` to whichever URL it resolves for a mode when making bridge +API requests. ```json { - "bridgeHubApiBaseUrl": "http://localhost:8080" + "appModes": { + "configs": { + "devnet": { + "aggkitProxy": "http://127.0.0.1:33460/aggkitapi" + } + } + } } ``` -If set, `NEXT_PUBLIC_BRIDGE_HUB_API` overrides `bridgeHubApiBaseUrl` for that build environment. +This is the correct shape whenever one `aggkit-proxy` instance multiplexes every network +in the mode (any number of L2s, including just one) — routing happens server-side, keyed +off the `network_id` query parameter, so the URL itself never varies per chain. Internally, +the app fans this single value out to every non-L1 chain's network ID before handing it to +the SDK, so every downstream consumer (the SDK aggregator, `app/utils/appMode.ts`) still +sees one URL per network — `aggkitProxy` only changes what you write in `config.json`, not +how the app calls aggkit. + +`aggkitProxy` may be omitted entirely to mark a mode "not yet configured" — see +[Validation](#validation) below. + +### Removed: the per-network `aggkitBridgeApis` map + +Earlier versions of this schema also accepted `aggkitBridgeApis` — an object mapping L2 +network IDs to distinct per-network aggkit REST base URLs, for a mode whose networks were +**not** behind one shared proxy — mutually exclusive with `aggkitProxy`. That field has +been **removed from the schema entirely** (`config/configSchema.mjs`'s `.strict()` check +now rejects it as an unrecognized key): every mode this app ships now goes through one +aggkit-proxy, so the per-network map no longer models anything this repo's own +`config.json` needs, and keeping a second, unused form around was pure surface area. + +**This is a deliberate, temporary, tracked cross-repo skew, not an oversight:** +kurtosis-cdk's dev-ui config template +(`static_files/additional_services/bridge-ui/aggkit-dev-ui-config.json.tmpl`) still +generates the old `aggkitBridgeApis` field as of this writing. A config produced by that +template will fail this schema's validation (`Unrecognized key: "aggkitBridgeApis"`) +until that template is migrated to emit `aggkitProxy` instead — a follow-up tracked +separately, kurtosis-cdk-side. + +If you have an existing `config.json` using `aggkitBridgeApis`, convert each entry to a +single `aggkitProxy` per mode. If a mode's networks genuinely have distinct, non-proxied +backends, there is currently no supported way to express that in this schema — front them +with an aggkit-proxy instance first (see [`docs/deployment.md`](./deployment.md)). + +### Environment overrides + +If set, `NEXT_PUBLIC_AGGKIT_PROXY` (a bare URL string) overrides every mode's `aggkitProxy` +value for that build environment: it is fanned out over every non-L1 network ID in every +app mode, exactly like a mode's own `aggkitProxy` field would be, and wins over the served +config.json when both are present. + +This merge happens at page-load time, inside the app's config bootstrap — not at build +time — but the *value* of the variable is still fixed at build time, because Next.js +inlines `NEXT_PUBLIC_*` variables into the JS bundle when it builds. + +**This override is build-time only and has no effect in a prebuilt container image.** +A published Docker image (see [`docs/docker.md`](./docker.md)) is built with it unset, so +the override is a structural no-op there — the mounted `config.json` is the *only* +configuration mechanism in a container. It remains useful for local dev and Cloudflare +Workers builds (see the Kurtosis setup below, and `.env.example`), where it is genuinely +evaluated at each build. + +### Relative `aggkitProxy` URLs + +An `aggkitProxy` value may be an absolute URL, or a single origin-relative path (exactly +one leading slash, e.g. `/aggkitapi`). A relative value is resolved against the page's own +origin (`window.location.origin` in the browser) the moment the config is loaded, so every +consumer downstream — the SDK, the tracker preflight check — only ever sees an absolute +URL. Protocol-relative values (`//host`) are deliberately rejected: they would change +origin, reintroducing the cross-origin surface that relative URLs exist to remove. This is +the single-origin reverse-proxy path described in [`docs/deployment.md`](./deployment.md) +(`/aggkitapi/* → aggkit-proxy`). + +A relative URL is **only** accepted for `aggkitProxy`. `rpcUrl`, `explorerUrl`, and +`iconUrl` on a chain, and every `externalLinks` value, remain absolute-URL-only — wallets +require an absolute RPC URL, and explorer/icon/external links are inherently cross-origin. + +### Kurtosis / Local Devnet Setup + +When running agglayer against a Kurtosis enclave (local devnet), the aggkit service is accessed through an enclave proxy. The proxy routes RPC calls by path and aggkit bridge calls by network ID query parameter. + +**Automated setup (recommended):** + +```bash +cd /path/to/agglayer-dev-ui + +# Run this after bringing up the enclave +node scripts/kurtosisDevnetEnv.mjs --enclave cdk [--l2-suffixes 001,002] [--proxy-service ] +``` + +This script automatically: +1. Discovers L2 suffixes from the enclave (defaults to `[001, 002]`, or override with `--l2-suffixes`) +2. Resolves all RPC URLs (L1 and both L2s) +3. Discovers the haproxy proxy service name (defaults to automatic discovery, or override with `--proxy-service`) +4. Writes `config.json` with `chains.DEVNET_L1`, `chains.DEVNET_L2_001`, `chains.DEVNET_L2_002`, and `appModes.configs.devnet.aggkitProxy` set to the enclave's live proxy URL +5. Writes `.env.local` with `NEXT_PUBLIC_AGGKIT_PROXY` set to that same proxy URL, as a fallback override that takes effect at runtime (see [Environment overrides](#environment-overrides) above) + +**Manual setup:** + +1. Get the proxy port: +```bash +kurtosis port print cdk agglayer-dev-ui-proxy-002 http +# Example output: 127.0.0.1:33460 +``` + +2. Set `NEXT_PUBLIC_AGGKIT_PROXY` to the proxy URL (matching `config.json`'s own `aggkitProxy` field, and what the automated setup above writes): +```bash +export NEXT_PUBLIC_AGGKIT_PROXY='http://127.0.0.1:33460/aggkitapi' +``` + +3. Ensure `config.json` has matching `chainKeys` for all configured networks. + +### RPC URL Semantics + +The haproxy proxy (`agglayer-dev-ui-proxy-00X`) routes RPC calls by path: + +| Path | Chain | RPC Endpoint | +|------|-------|--------------| +| `/l1rpc` | L1 | L1 EL RPC | +| `/l2rpc` | L2-1 | L2-1 RPC (back-compat alias, never "current") | +| `/l2rpc-001` | L2-1 | L2-1 RPC | +| `/l2rpc-002` | L2-2 | L2-2 RPC | +| `/aggkitapi` | All (via `?network_id=`) | AggKit proxy multiplexer | + +The dev-ui app automatically constructs these paths from the configured chains and `appModes.configs.devnet.chainKeys`; no manual URL construction is needed. ## External Links @@ -80,13 +294,13 @@ A mode is **enabled** only if `chainKeys` has at least two entries (bridging req ```json { "appModes": { - "default": "testnet", + "default": "devnet", "configs": { "mainnet": { "label": "Mainnet", - "bridgeAddress": "0x...", - "proofApiSuffix": "mainnet", - "chainKeys": ["MAINNET", "KATANA"], + "bridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "aggkitProxy": "https://mainnet-aggkit-proxy.example.com", + "chainKeys": ["MAINNET", "KATANA", "FORKNET"], "defaultFromChainKey": "MAINNET", "defaultToChainKey": "KATANA" } @@ -95,40 +309,105 @@ A mode is **enabled** only if `chainKeys` has at least two entries (bridging req } ``` +(This mirrors the committed `config.json`'s shape, minus the real values — its `mainnet` +and `testnet` `aggkitProxy` are `PLACEHOLDER-*` URLs, since no real aggkit-proxy is +deployed for them yet. See [Aggkit Bridge APIs](#aggkit-bridge-apis-aggkitproxy) above.) + ### Mode config fields | Field | Required | Description | |-------|----------|-------------| | `label` | Yes | Display label in the mode switcher | | `bridgeAddress` | Yes | Bridge contract address | -| `proofApiSuffix` | Yes | Path suffix appended to `bridgeHubApiBaseUrl` | +| `aggkitProxy` | Conditional | A single URL fronting every network in this mode via one multiplexing aggkit-proxy. May be omitted as the "not yet configured" escape hatch (see [Validation](#validation)). | | `chainKeys` | Yes | Array of chain keys from `chains` (need >= 2 to enable) | | `defaultFromChainKey` | No | Default source chain (defaults to first in `chainKeys`) | | `defaultToChainKey` | No | Default destination chain (defaults to second in `chainKeys`) | +### Testing Default: appModes.default + +The committed `config.json` ships with `appModes.default` set to `"testnet"`. Devnet +mode is opt-in: CI copies `config/config.ci.devnet.json` over it (see below), and +`scripts/kurtosisDevnetEnv.mjs` rewrites it in place for a live Kurtosis enclave. +Never commit a `config.json` left in `"devnet"` mode. + +### `config/config.ci.devnet.json` — the CI fixture + +`config/config.ci.devnet.json` is a **fixed-port** sibling of the committed `config.json`, used only by `.github/workflows/e2e.yaml`'s "Configure devnet fixture" step: + +```bash +cp config/config.ci.devnet.json config.json +pnpm run validate:config +``` + +It is **byte-identical to the committed `config.json` except for a single key**: +`appModes.default` is `"devnet"` instead of `"testnet"`. Everything else it relies on +is already in the committed file: + +- `chains` already carries the three fixed-URL entries — `DEVNET_L1` (chain id `271828`), `DEVNET_L2_001` (`20201`), `DEVNET_L2_002` (`20202`) — with `rpcUrl` pointed at `http://127.0.0.1:8555/l1rpc`, `/l2rpc-001`, `/l2rpc-002` respectively. `8555` is the vendored compose bundle's fixed haproxy port (`DEVNET_PROXY_PORT`, see [`tests/devnet/README.md`](../tests/devnet/README.md) and kurtosis-cdk's [Anvil-Flavor Devnet Snapshot](https://github.com/0xPolygon/kurtosis-cdk/blob/feat/aggkit-bridge-ui-backend/docs/docs/advanced/anvil-devnet-snapshot.md#the-bundle-contract) doc), never an ephemeral `kurtosis port print` value — this file is only valid against the fixed-port compose bundle, not a live Kurtosis enclave (use `scripts/kurtosisDevnetEnv.mjs` for that instead, which writes the committed `config.json` directly). +- `appModes.configs.devnet.chainKeys` is already `["DEVNET_L1", "DEVNET_L2_001", "DEVNET_L2_002"]`, `bridgeAddress` is the deterministic devnet bridge address `0xC8cbEBf950B9Df44d987c8619f092beA980fF038`, and `aggkitProxy` is `http://127.0.0.1:8555/aggkitapi` — the single aggkit-proxy origin fronting both L2s, selected per-network server-side via `?network_id=`. + +This file is committed and never overwrites `config.json` in git — the workflow copies it over the working tree's `config.json` inside the CI job only, and no step ever commits the result. Run `pnpm run validate:config -- config/config.ci.devnet.json` to validate it directly without copying it over `config.json` first. + ## Tokens On first load, the UI shows **only the native gas token** for each configured chain. Users can import additional tokens via the UI. Imported tokens are stored in local storage and can be removed. ## Environment Variables -Required: - -| Variable | Description | -|----------|-------------| -| `NEXT_PUBLIC_PROJECT_ID` | WalletConnect project ID | +Required: none. -Optional: +Optional — both are local-dev/Cloudflare-build-time overrides of a `config.json` value, +and **neither has any effect in a prebuilt container image** (see +[`docs/docker.md`](./docker.md)); the mounted `config.json` is the only configuration +mechanism there: | Variable | Description | |----------|-------------| -| `NEXT_PUBLIC_BRIDGE_HUB_API` | Overrides `bridgeHubApiBaseUrl` from `config.json` for the active environment | +| `NEXT_PUBLIC_PROJECT_ID` | Overrides `walletConnect.projectId`; optional, placeholder/empty → graceful degradation (see README and [WalletConnect / Reown](#walletconnect--reown-walletconnectprojectid) above). | +| `NEXT_PUBLIC_AGGKIT_PROXY` | Bare URL (or origin-relative path); fanned out over every non-L1 network ID in every app mode, overriding `aggkitProxy`. Used for ephemeral devnet proxies. | -Set it in `.env.local`: +Set them in `.env.local`: ```bash cp .env.example .env.local +# Optionally set NEXT_PUBLIC_PROJECT_ID to override config.json's walletConnect.projectId +# locally (see README for degraded-mode behavior) +# Optionally set NEXT_PUBLIC_AGGKIT_PROXY for devnet/kurtosis setups +``` + +## Migration from Bridge Hub API (Old Config) + +If you have an older `config.json` using `bridgeHubApiBaseUrl` and `proofApiSuffix`, update it to the current `aggkitProxy` format: + +**Old format (no longer supported):** +```json +{ + "bridgeHubApiBaseUrl": "http://localhost:8080", + "appModes": { + "configs": { + "mainnet": { + "proofApiSuffix": "mainnet" + } + } + } +} +``` + +**Current format:** +```json +{ + "appModes": { + "configs": { + "mainnet": { + "aggkitProxy": "http://localhost:8080" + } + } + } +} ``` +The validator will reject the old format with a clear error message pointing you to the current field. If your `config.json` instead uses the intermediate per-network `aggkitBridgeApis` map (from a version of this app between the Bridge Hub API era and this one), see [Removed: the per-network `aggkitBridgeApis` map](#removed-the-per-network-aggkitbridgeapis-map) above. + ## Validation Run config validation locally before opening a PR: @@ -137,8 +416,55 @@ Run config validation locally before opening a PR: pnpm run validate:config ``` +To vet a config file that is not the repo-root `config.json` — for example a candidate +file you are about to mount into the Docker image (see [`docs/docker.md`](./docker.md)) — +pass its path: + +```bash +pnpm run validate:config -- /path/to/your/config.json +``` + CI also runs this command before deployment. The app also validates config at startup through `config/configValidator.mjs`. +All absolute URLs in `config.json` must use the `http` or `https` scheme. Other schemes +(`javascript:`, `data:`, `file:`, …) are rejected: `externalLinks.*` and `explorerUrl` +are rendered into links and passed to `window.open`, so a non-http(s) scheme there would +be script execution in the app's origin. + +### History: the retired per-network cross-field checks + +Before the per-network `aggkitBridgeApis` map was removed from the schema (see +[Removed: the per-network `aggkitBridgeApis` map](#removed-the-per-network-aggkitbridgeapis-map) +above), the validator enforced three cross-field rules that applied only to a mode using +that map form: + +| Retired rule | Rejected when | +|---|---| +| Every non-L1 chain has a backend | A chain in `chainKeys` with `networkId !== 0` had no `aggkitBridgeApis` entry for that networkId | +| Every backend has a chain | An `aggkitBridgeApis` key matched no `networkId` among the mode's `chainKeys` | +| networkIds are unique | Two chains in `chainKeys` shared a `networkId` (L1 chains, `networkId 0`, were exempt) | + +These rules never applied to a mode using `aggkitProxy`: one proxy fronts every network in +the mode by construction, so per-chain key agreement is meaningless for it — this is +exactly why a multi-L2 devnet used to hand-duplicate one URL under every network ID in an +`aggkitBridgeApis` map before `aggkitProxy` existed. The "networkIds are unique" rule +mattered for the map form specifically because `networkId` — not the chain id — is what +keyed `aggkitBridgeApis` and what the SDK keys its per-network clients by; two chains +sharing a networkId would have collapsed onto one backend and merged one chain's +transactions into the other's, while still satisfying the other two map-form rules. + +**With every shipped mode (`mainnet`, `testnet`, `devnet`) now on `aggkitProxy`, and the map +form removed from the schema entirely, this validator no longer performs any per-chain +networkId-agreement check at all** — that is an inherent, by-design consequence of the +single-proxy model, not a gap: with one backend fronting every network in a mode, there is +no per-chain key agreement left to check. There is currently no equivalent check for +`aggkitProxy` mode, and none is planned — inventing a replacement would be checking +something that structurally cannot go wrong for a single shared proxy. + +If validation fails with errors like `Unrecognized key: "proofApiSuffix"`, +`Unrecognized key: "bridgeHubApiBaseUrl"`, or `Unrecognized key: "aggkitBridgeApis"`, see +the "Migration from Bridge Hub API" section above. + ## Checklist: add a chain 1. Add the chain entry to `chains` in `config.json`. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..df37f90 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,121 @@ +# Deploying the Bridge UI + aggkit-proxy (with bridge tracker) + +DevOps-facing guide. Each step links to the authoritative doc instead of restating it; only deployment-specific glue and known gotchas are spelled out here. + +**Component picture** — one `aggkit-proxy` per environment fans out to the per-chain aggkit bridge services and the agglayer gRPC endpoint; the Bridge UI is a Next.js app that talks only to the proxy through your reverse proxy, on a single origin: + +``` +browser → reverse proxy (TLS, single origin) + ├── / → bridge UI (Next.js) + └── /aggkitapi/* → aggkit-proxy :5577 + ├── PROXY component → per-chain aggkit bridge services (REST) + └── TRACKER component → agglayer gRPC + L1/L2 RPCs +``` + +Versions this guide is validated against: aggkit `v0.11.0-rc5` (image `ghcr.io/agglayer/aggkit:0.11.0-rc5`), dev-ui `feat/aggkit-backend`, sdk `feat/aggkit-bridge-client`. + +--- + +## 1. Prerequisites + +- Running aggkit stack (one aggkit node with `BRIDGE` component per L2, agglayer with gRPC exposed). Start here if you don't have one: [aggkit getting started](https://github.com/agglayer/aggkit/blob/v0.11.0-rc5/docs/getting_started.md), [bridge service component](https://github.com/agglayer/aggkit/blob/v0.11.0-rc5/docs/bridge_service.md). +- L1 + L2 RPC endpoints, the agglayer gRPC URL, and the **L1 GlobalExitRoot contract address** for your deployment (see the gotcha in step 2 below). +- Node 24 + pnpm for building the UI. + +## 2. Deploy aggkit-proxy (PROXY + TRACKER) + +Run the `aggkit-proxy` binary from the same image/release as your aggkit nodes: + +``` +aggkit-proxy run --cfg=/etc/aggkit-proxy/config.toml --components=proxy,tracker +``` + +Configuration — do not write it from scratch; adapt one of these: + +- Concepts + full `[Tracker]` key reference: [bridgetracker/API.md](https://github.com/agglayer/aggkit/blob/v0.11.0-rc5/docs/bridgetracker/API.md) and [common REST config](https://github.com/agglayer/aggkit/blob/v0.11.0-rc5/docs/common_config.md). (`docs/bridgetracker.md` does not exist in aggkit — `docs/bridgetracker/API.md` is the only tracker doc.) +- aggkit's own reference recipe (env-var driven): [`proxy/scripts/configuration_based_on_kurtosis.sh`](https://github.com/agglayer/aggkit/blob/v0.11.0-rc5/proxy/scripts/configuration_based_on_kurtosis.sh). +- A known-good, deployed template with comments explaining every choice: [kurtosis-cdk `aggkit-proxy/config.toml`](https://github.com/0xPolygon/kurtosis-cdk/blob/feat/aggkit-bridge-ui-backend/static_files/additional_services/aggkit-proxy/config.toml) (rendered by [`aggkit_proxy.star`](https://github.com/0xPolygon/kurtosis-cdk/blob/feat/aggkit-bridge-ui-backend/src/additional_services/aggkit_proxy.star)). + +**Gotchas:** + +1. `[Tracker].L1GlobalExitRootAddress` is **required; rc5 fails fast at startup if unset** (a zero/unset address now hard-errors at boot instead of silently stalling every L1→L2 bridge's tracking at its first step). Details: [agglayer/aggkit#1782](https://github.com/agglayer/aggkit/issues/1782) (fixed in rc5). +2. `[REST].MaxRequestsPerIPAndSecond` is **unenforced by design as of rc5, default 0** — don't size infra around it, and don't rely on it for protection; rate-limit at your reverse proxy instead. Details: [agglayer/aggkit#1783](https://github.com/agglayer/aggkit/issues/1783). + +Retention note: the tracker forgets terminal bridges after `RetentionPeriod` (default 10m) and re-registers them from scratch if queried again; raise it (the linked kurtosis template uses 30m) if humans will inspect finished bridges. + +## 3. Reverse-proxy routing + +The UI expects the proxy under a single path on the same origin (default `/aggkitapi`). Route `path_prefix /aggkitapi` → aggkit-proxy REST port, stripping nothing (the proxy serves `/bridge/v1/*` and `/tracker/v1/*` under it). Working haproxy example: the [kurtosis-cdk 2-L2 guide, HAProxy routes section](https://github.com/0xPolygon/kurtosis-cdk/blob/feat/aggkit-bridge-ui-backend/docs/docs/advanced/aggkit-2l2-with-bridge-ui.md). WebSocket (`/tracker/v1/ws`) is not used by the UI — no `timeout tunnel`/upgrade config needed. + +## 4. Deploy the Bridge UI + +Repo: [agglayer/agglayer-dev-ui](https://github.com/agglayer/agglayer-dev-ui/tree/feat/aggkit-backend). It consumes the proxy via [`@agglayer/sdk`](https://github.com/agglayer/sdk/tree/feat/aggkit-bridge-client) (`getBridgeTracking` et al.) — no direct chain indexing of its own beyond RPCs. + +Two deployment paths exist. Pick one: + +- **Container image** (recommended for self-hosting): `docker run` the published image + with your `config.json` bind-mounted. See [`docs/docker.md`](./docker.md) for the full + image contract, tags, and a copy-pasteable `docker run` example — including + `walletConnect.projectId`, the prod-required WalletConnect/Reown value, which (unlike + `NEXT_PUBLIC_AGGKIT_PROXY`) is read from the mounted `config.json` at runtime and can be + set per deployment with no rebuild. See that document's Status section for which tags + currently exist. +- **Cloudflare Workers**: the steps below. + +### Cloudflare Workers path + +1. Build/run: see [README §Quickstart](https://github.com/agglayer/agglayer-dev-ui/blob/feat/aggkit-backend/README.md#quickstart) (standard Next.js: `pnpm install && pnpm build && pnpm start`, Node 24). +2. Configure `config.json` — full schema: [docs/config.md](https://github.com/agglayer/agglayer-dev-ui/blob/feat/aggkit-backend/docs/config.md). The essentials per environment: chain list (RPC URLs, bridge contract addresses) and the mode's `aggkitProxy` set to your reverse proxy's `/aggkitapi` origin (this guide's whole component picture is one `aggkit-proxy` per environment — that is exactly what the single-URL `aggkitProxy` field models). +3. What the tracker UI does and how it polls (5s per pending row, stops on terminal states — relevant for capacity planning): [README §Bridge Tracking](https://github.com/agglayer/agglayer-dev-ui/blob/feat/aggkit-backend/README.md#bridge-tracking). + +### Container path + +1. Mount your `config.json` and run the image — see + [`docs/docker.md`](./docker.md#copy-pasteable-example) for the exact command. Config + schema is the same [docs/config.md](./config.md) either way; only the delivery + mechanism (bind-mount vs. Next.js env/build) differs. +2. The image serves the aggkit proxy configuration exactly as configured in the mounted + `config.json`'s `aggkitProxy` — point it at your reverse proxy's `/aggkitapi` + origin the same way you would for the Cloudflare Workers path, either as an absolute + URL or (if the container is itself behind a single-origin reverse proxy) an + origin-relative path like `/aggkitapi` (see [docs/config.md](./config.md#relative-aggkitproxy-urls)). + +## 5. Smoke test + +```bash +# proxy up, right version, tracker component live +curl -s https:///aggkitapi/tracker/v1/health + +# bridge services reachable through the proxy (per network id) +curl -s "https:///aggkitapi/bridge/v1/sync-status?network_id=1" + +# register + track a real bridge tx (source network id + its creating tx hash) +curl -s https:///aggkitapi/tracker/v1/network//tx/ +``` + +Expected response shapes: [bridgetracker API.md](https://github.com/agglayer/aggkit/blob/v0.11.0-rc5/docs/bridgetracker/API.md) — API.md was corrected in rc5 ([agglayer/aggkit#1781](https://github.com/agglayer/aggkit/issues/1781)); sdk [`src/aggkit/types.ts`](https://github.com/agglayer/sdk/blob/feat/aggkit-bridge-client/src/aggkit/types.ts) matches the wire format it documents. + +Functional check: send one bridge per direction you support and watch the row's progress bar complete (L1→L2 and L2→L2 autoclaim; L2→L1 parks at "Ready to claim" until claimed — note upstream [agglayer/aggkit#1786](https://github.com/agglayer/aggkit/issues/1786) (OPEN): the tracker's `WaitingClaim` step routinely precedes actual claimability by seconds to tens of seconds, so the UI intentionally gates the Claim button on its own `READY_TO_CLAIM` status rather than on the tracker step). + +## 6. Reference deployment (end-to-end, reproducible) + +The kurtosis-cdk 2-L2 devnet deploys this whole stack (aggkit rc5, proxy+tracker, haproxy, UI wiring) from one command and is the fastest way to see a working configuration to diff yours against: [aggkit 2-L2 with bridge UI guide](https://github.com/0xPolygon/kurtosis-cdk/blob/feat/aggkit-bridge-ui-backend/docs/docs/advanced/aggkit-2l2-with-bridge-ui.md). That guide's Troubleshooting section also covers enclave reset/wallet-nonce recovery and tracker failure-mode diagnosis (including #1786 above). + +## 7. Rollback + +Nothing here has a one-command in-place downgrade; each layer rolls back independently. + +- **aggkit-proxy / enclave (kurtosis-cdk)**: repin `aggkit_image` to the previous tag in both params files and recreate the enclave (`kurtosis enclave rm -f cdk` + the 2-run bring-up recipe) — enclave state is disposable by design, so there is no in-place downgrade. rc5→rc4 note: config is backward-compatible (rc5 changed only defaults/validation, not schema), so the committed `config.toml` template works unchanged on either version. +- **sdk**: not yet published for this feature (release is a manual `workflow_dispatch`, never automatic on merge). Rollback pre-merge is simply reverting the branch commits; rollback post-publish is `npm dist-tag`-ing the previous beta back to the consumer-facing tag, or pinning the previous version number in consumers directly. +- **dev-ui (Cloudflare Workers path)**: deployed via `wrangler deploy` on push-to-`main`. Rollback is `wrangler rollback` (reverts to the previously deployed Worker version) or redeploying the previous commit's build — the app is a static export with no server-side data migrations, so there is no data-compatibility concern either direction. +- **dev-ui (container image path)**: rollback is pinning a prior tag. Image tags are + immutable per publish (a given `X.Y.Z` is written once, at that release), so + redeploying with the previous version's tag — e.g. changing + `ghcr.io/agglayer/agglayer-dev-ui:X.Y.Z` to `:X.Y.Z-1` in whatever orchestrator/compose + file references it — is the entire rollback. No data migration concern either + direction: the container is stateless and the mounted `config.json` is reused + unchanged. See [`docs/docker.md`](./docker.md#rollback). + +--- + +*Branch links (`feat/aggkit-bridge-ui-backend`, `feat/aggkit-backend`, `feat/aggkit-bridge-client`) are pre-merge as of 2026-08-10; swap to `main` once the phase PRs land. aggkit links are pinned to tag `v0.11.0-rc5`.* diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..dc028a9 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,393 @@ +# Docker Image + +This is the consumption contract for the `agglayer-dev-ui` container image: what it is, +how to configure it, and how a new build gets published. It is written to stand alone — +you should not need any other document in this repo to run the image. + +## Status: images are published via `workflow_dispatch` + +Multiple images have been published to GHCR from this branch via `workflow_dispatch` +(see the "Tag scheme" table below for the `dispatch---` naming), and +have been verified pullable **anonymously** (no `docker login` needed) — the GHCR +package's visibility is public. A tagged, non-prerelease `release: published` event +(the "Cutting a release" path below) has not yet occurred against this repo, so no +semver or `latest` tag exists yet; only dispatch-tagged images do. + +## Image reference + +``` +ghcr.io/agglayer/agglayer-dev-ui: +``` + +### Tag scheme + +Tags are computed by the "Compute image tags" step in +`.github/workflows/docker-publish.yaml:129-189`: + +| Trigger | Tags produced | +|---|---| +| GitHub Release, non-prerelease, semver `X.Y.Z` (or `vX.Y.Z`) | `X.Y.Z`, `X.Y`, `latest` | +| GitHub Release, prerelease (either the GitHub "prerelease" flag, or a semver `-suffix` such as `1.2.3-rc.1`) | `X.Y.Z` (or `X.Y.Z-suffix`) only — never `X.Y`, never `latest` | +| `workflow_dispatch` | `dispatch---` | + +Every dispatch tag carries the literal `dispatch-` prefix, which cannot collide with a +semver tag or with `latest` — see the workflow's header comment +(`.github/workflows/docker-publish.yaml:29-44`) for why this is a structural guarantee, +not just a runtime check (the workflow also asserts it at runtime as defense in depth). + +## Ports + +The container listens on port **80** (`Dockerfile:111` `EXPOSE 80`, matching +`nginx.conf:4` `listen 80;`). + +```bash +docker run -p 8080:80 ghcr.io/agglayer/agglayer-dev-ui: +``` + +## Configuration: mounting `config.json` + +The image is configured entirely through a single JSON file, bind-mounted at: + +``` +/etc/agglayer-dev-ui/config.json +``` + +(`entrypoint.sh:36` `MOUNTED_CONFIG="/etc/agglayer-dev-ui/config.json"`.) The schema is +documented in [`docs/config.md`](./config.md). + +### Copy-pasteable example + +```bash +docker run -d \ + --name agglayer-dev-ui \ + -p 8080:80 \ + -v /path/to/your/config.json:/etc/agglayer-dev-ui/config.json:ro \ + ghcr.io/agglayer/agglayer-dev-ui: +``` + +Then open `http://localhost:8080`. + +### Mounting is effectively mandatory + +The image ships with a **baked-in default config** — the repo's own committed root +`config.json`, copied into the webroot at build time (`Dockerfile:106` +`COPY --from=app-builder /app/out /usr/share/nginx/html`). That default's `mainnet` and +`testnet` app modes point `aggkitProxy` at placeholder hosts +(`https://PLACEHOLDER-mainnet-aggkit-proxy`, `https://PLACEHOLDER-testnet-aggkit-proxy`) +that do not resolve. The baked default **passes +validation and starts the container, but does not work end-to-end** for any mode that +talks to a real aggkit backend. If you run the image with no `-v` mount, the entrypoint +prints a loud warning to this effect (`entrypoint.sh:89-97`) and serves the baked default +anyway — it does not refuse to start. Treat mounting a real `config.json` as a required +step, not an optional override. + +The mounted `config.json`'s `walletConnect.projectId` field (the Reown/WalletConnect +project ID) is also part of this contract — see +[docs/config.md](./config.md#walletconnect--reown-walletconnectprojectid). Unlike +`NEXT_PUBLIC_PROJECT_ID`, this value is **read at runtime, not baked into the JS bundle**: +`build:production`'s `.env.production` deliberately never sets that env var (see +`Dockerfile:80-93`), so every published image reads whatever `walletConnect.projectId` +value the operator mounts. Leaving it at the baked default's placeholder +(`YOUR_PROJECT_ID_HERE`) runs Reown AppKit in the documented degraded `basic: true` mode +(injected-wallet connect works, WalletConnect-cloud features are skipped); mounting a +`config.json` with a real project id from https://cloud.reown.com enables full AppKit +functionality, per container instance, with no rebuild. See "Proof: the same image, two +different project ids" below. + +### Precedence: mounted file vs. baked default + +There are exactly two states, decided once at container start by `entrypoint.sh:72-98`: + +1. **A regular file exists at `/etc/agglayer-dev-ui/config.json`.** It is validated + (see below). If valid, it is copied over the webroot's `config.json` + (`entrypoint.sh:78` `cp "$MOUNTED_CONFIG" "$WEBROOT_CONFIG"`) and served from then on. + If invalid, the container **exits immediately with a non-zero status** and never + starts nginx (`entrypoint.sh:75-77`). +2. **Nothing is mounted.** The baked-in default (the webroot's own `config.json`, copied + in at build time) is served unmodified, after printing the warning described above. + +There is no merge of the two, and no environment-variable-based configuration mechanism +at all in the container — the entrypoint does **not** read `NEXT_PUBLIC_AGGKIT_PROXY` or +any other env var to synthesize or override config (see +[`docs/config.md`](./config.md#environment-variables) for why that variable has no effect +here). The mounted file (or the baked default, if nothing is mounted) is the single, only +source of configuration — including each mode's `aggkitProxy` value (see +[docs/config.md](./config.md#aggkit-bridge-apis-aggkitproxy)). + +If the host path passed to `-v` does not exist, Docker creates an empty directory at the +container-side mount point rather than failing the `docker run` — this is a classic typo +failure mode. The entrypoint detects that case specifically (a path that exists but is +not a regular file) and fails loudly with a dedicated message rather than silently +falling through to the baked default (`entrypoint.sh:80-88`). + +### Proof: the same image, two different project ids + +This is the property that makes the image production-usable: **one built image, run +twice, with two mounted `config.json` files carrying different `walletConnect.projectId` +values, produces two different AppKit sessions** — not one frozen build-time value. +Verified (D0e) by running the identical image digest twice with disjoint project ids and +capturing the browser's own outgoing network requests (Reown AppKit forwards `projectId` +to its remote-config/RPC endpoints on init): + +``` +run A: mounted config.json walletConnect.projectId = "proof-project-id-AAA111" + -> https://api.web3modal.org/appkit/v1/config?projectId=proof-project-id-AAA111&... + -> https://rpc.walletconnect.org/v1/?...&projectId=proof-project-id-AAA111 + (zero requests contain "BBB222") + +run B: SAME image digest, mounted config.json walletConnect.projectId = "proof-project-id-BBB222" + -> https://api.web3modal.org/appkit/v1/config?projectId=proof-project-id-BBB222&... + -> https://rpc.walletconnect.org/v1/?...&projectId=proof-project-id-BBB222 + (zero requests contain "AAA111") +``` + +`docker inspect --format '{{.Image}}'` confirmed both containers ran the exact same image +content digest — this is one build, reconfigured twice at `docker run` time. Contrast +this with `NEXT_PUBLIC_AGGKIT_PROXY`, which genuinely has no effect in a container (see +above) because it is inlined at build time; `walletConnect.projectId` behaves oppositely +by design. + +The container also **fails loudly** (non-zero exit, no nginx start) when +`walletConnect.projectId` is missing or empty — see "Validation is structural only" below +and `entrypoint.sh:44-70`. + +### Validation is structural only — not a substitute for the app's schema + +The runtime (`nginx:alpine`) stage has no Node.js, so the app's real validator +(`config/configValidator.mjs`, a Zod schema) cannot run inside the container. The +entrypoint instead runs a **`jq`-based structural check** (`entrypoint.sh:44-70`), which +verifies only: + +1. The file is well-formed JSON (`jq empty`). +2. A small set of required top-level fields exist and have the right JSON *type*: + - `chains` is a non-empty object + - `appModes.default` is a string + - `appModes.configs` is a non-empty object **containing the key named by + `appModes.default`** + - `autoclaim` is an object + - `externalLinks` is an object + - `walletConnect.projectId` is a non-empty string + +**This check does not validate:** URL formats or reachability, individual chain object +shapes, `chainKeys` entries referencing chains that don't exist, `autoclaim`/`currency` +field types, or duplicate `networkId`s across chains — any of the semantic rules that +`config/configValidator.mjs` enforces. A config that is well-formed JSON with the right +top-level shape can pass this check and container startup, and then **fail at the +browser**, where the app's real Zod validation runs (behind `AppConfigGate`, rendering +its `data-test-id="app-config-error"` screen). Always run the repo's real validator +against a candidate `config.json` before mounting it — from a dev-ui checkout: + +```bash +pnpm run validate:config -- /path/to/your/config.json +``` + +(with no argument it validates the repo-root `config.json`). See +[`docs/config.md`](./config.md#validation). + +## Cache semantics + +Verified response headers from a running container (see +`plans/dev-ui-docker-ghcr/c2-runtime-config-proof.md` §2–3): + +| Path | `Cache-Control` | Why | +|---|---|---| +| `/config.json` | `no-store` | `nginx.conf:17-20`. A container restart with a different mounted `config.json` must be reflected on the very next request — nothing may cache a stale config, in the browser or in an intermediary proxy. Belt-and-braces with the app's own `fetch(..., { cache: 'no-store' })` (`app/configLoader.ts:27`). | +| `/_next/static/*` | `public, max-age=31536000, immutable` | `nginx.conf:25-28`. Next's static export content-hashes every asset under this path, so the path itself changes whenever the content does — safe to cache forever. | + +## Runtime config is read once per page load + +The app fetches `/config.json` exactly once, when it mounts, plus once per explicit user +"Retry" click after a failed fetch. There is no polling, no automatic re-fetch, and no +way to push new configuration into an already-loaded page. To apply a new +`config.json`, either restart the container (after replacing the mounted file) or simply +reload the browser tab if the container is already serving the new file. See +`plans/dev-ui-docker-ghcr/a1-runtime-config-design.md` §8 for the full rationale +(in short: `@agglayer/sdk`'s chain registry is an append-only singleton with no reset +path, so re-initializing the app in place without a reload/restart could leave stale +chain data resident). + +## Image size and startup time + +Measured (see `plans/dev-ui-docker-ghcr-plan.md:432`): image size **106MB** (29MB +compressed), and **286ms** from container start to the first HTTP 200 response. These +numbers are for the specific verification build described in that plan step and are not +re-measured on every publish; expect them to be representative, not exact, for any given +tag. + +## Building the image locally + +### Prerequisite: `.sdk-src/` + +**Temporary, until `@agglayer/sdk`'s aggkit bridge APIs are published to npm** — tracked +by `plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md` §5. This repo currently +depends on an unreleased `@agglayer/sdk` commit via a `file:../sdk` workspace override +(`pnpm-workspace.yaml`), which only works if a source tree exists at `../sdk` relative to +this repo, or — for Docker builds — staged into this repo's build context at +`./.sdk-src/`. In CI, that staging happens automatically via a second `actions/checkout` +of `agglayer/sdk` pinned to a commit SHA. Locally, you must populate it yourself before +`docker build` will work: + +```bash +scripts/stage-sdk-src.sh [path-to-sdk-checkout] # defaults to ../sdk +``` + +This copies only the **tracked** files from a sibling `agglayer/sdk` git checkout (via +`git archive`, not `cp -r`) into `.sdk-src/`, gitignored and rebuilt inside the image by +the Dockerfile's `sdk-builder` stage (`Dockerfile:29-37`). See `scripts/stage-sdk-src.sh` +for the exact mechanics and `plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md` §4 +for why this shape was chosen over the alternatives. + +**Removal trigger:** once a published `@agglayer/sdk` version above `1.0.0-beta.30` +carries the aggkit APIs, this entire prerequisite goes away. The exact 9-row edit +checklist — updating `package.json`, `pnpm-workspace.yaml`, `pnpm-lock.yaml`, deleting +the `sdk-builder` Dockerfile stage, the `.gitignore`/`.dockerignore` entries, the CI +checkout step, and the corresponding section of this document — is spelled out verbatim +in `plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md` §5.2. Every artifact this +prerequisite touches carries an inline `# TEMPORARY` comment pointing back at that +section, so `grep -rn "TEMPORARY -- remove per"` finds all of them. + +### Build + +```bash +scripts/stage-sdk-src.sh +docker build -t agglayer-dev-ui . +``` + +The build context is this repo's root — the same `docker build .` invocation works +identically locally and in CI (`plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md` +§4.2). No parent-directory context, no `-f` flag. + +## Release process + +### Prerequisite for either path: the workflow must be on `main` + +**`.github/workflows/docker-publish.yaml` must be merged to this repo's default branch +(`main`) before either a release or a `workflow_dispatch` publish can occur.** This is a +hard GitHub Actions rule, not specific to this repo: both the `release` and +`workflow_dispatch` triggers are only registered against a workflow file once that file +is present on the default branch — GitHub does not honor either trigger for a workflow +that exists only on a feature branch. + +As of this writing, `docker-publish.yaml` exists only on `feat/aggkit-backend` (not yet +merged to `main`), and this is exactly what the first real dispatch attempt hit: + +``` +$ gh workflow run docker-publish.yaml --repo agglayer/agglayer-dev-ui --ref feat/aggkit-backend +HTTP 404: workflow docker-publish.yaml not found on the default branch +(https://api.github.com/repos/agglayer/agglayer-dev-ui/actions/workflows/docker-publish.yaml) +``` + +Contrast this with W-2's **PR-triggered** run, which executed successfully on this same +branch (run `31535596365`, "Docker PR Build and Smoke Test", success in 2m21s) — a +`pull_request` trigger runs from the PR's own branch and does not require the workflow +file to be on `main`. `release` and `workflow_dispatch` are different: both require the +workflow file on the default branch to be dispatchable/registered at all, regardless of +which branch, tag, or SHA the triggered run itself then checks out or builds. + +### Cutting a release (the normal path) + +Publishing `ghcr.io/agglayer/agglayer-dev-ui:` happens automatically when a GitHub +Release is published against this repo (`release: published` in +`.github/workflows/docker-publish.yaml:47-49`). Cut a release the usual way (tag a +commit `vX.Y.Z` or `X.Y.Z`, publish a GitHub Release against it) and the workflow builds +and pushes the image with the tag set described above. Mark the release as a GitHub +"prerelease" (or use a semver `-suffix`, e.g. `1.2.3-rc.1`) if it should not move +`latest` or the `X.Y` tag. + +**This only works once `docker-publish.yaml` is merged to `main`** — see "Prerequisite +for either path" above. Publishing a GitHub Release while the workflow file exists only +on a feature branch will not trigger a build. + +### Building an arbitrary branch via dispatch + +You can also build and publish an image from any branch, tag, or full commit SHA that is +**already reachable in the repository** by running the workflow via +`workflow_dispatch`, passing the desired revision as the `ref` input. + +**Documented limitation: `workflow_dispatch` requires `docker-publish.yaml` to already be +merged to `main`.** See "Prerequisite for either path" above — this is a separate +constraint from the bare-SHA one below, and it is the one that actually blocked the +first real dispatch attempt (`HTTP 404: workflow docker-publish.yaml not found on the +default branch`). Merging the workflow file to `main` is required before any dispatch +can be fired at all, regardless of which branch/tag/SHA you intend to pass as the `ref` +input. + +**Documented limitation: `workflow_dispatch` cannot target a bare commit SHA.** The ref +you pick when firing the dispatch event itself (the branch selector in the "Run workflow" +button, or `--ref` on `gh workflow run` / the REST API) must be an existing branch or +tag — GitHub uses that ref to decide which version of the workflow file to run, and its +API rejects a bare SHA there. This is a separate thing from the workflow's own `ref` +input, which **is** just a string forwarded to `actions/checkout` and does accept a full +commit SHA (the checkout uses `fetch-depth: 0` so an arbitrary SHA is resolvable). See +`.github/workflows/docker-publish.yaml:9-19` for the full explanation. + +**Net effect:** to publish an unreleased commit that has no branch or tag yet, push it to +a branch (or tag it) first, then dispatch the workflow against that branch/tag, passing +whichever revision you actually want built (branch name, tag, or full SHA) as the `ref` +input. + +```bash +git push origin HEAD:my-temp-branch +gh workflow run docker-publish.yaml --ref my-temp-branch -f ref=my-temp-branch +``` + +Dispatch builds are tagged `dispatch---` and can never collide with a +semver tag or move `latest` (see "Tag scheme" above). + +## Rollback + +Because tags are immutable per publish (a given `X.Y.Z` is only ever written once, at +that release), rolling back is pinning a previous tag: + +```bash +docker run -d -p 8080:80 \ + -v /path/to/config.json:/etc/agglayer-dev-ui/config.json:ro \ + ghcr.io/agglayer/agglayer-dev-ui:X.Y.Z-1 +``` + +For a running deployment, this typically means changing the image tag in whatever +orchestrator/compose file references it and redeploying — there is no in-image rollback +mechanism, and none is needed: the container has no persistent state, so replacing the +image and reusing the same mounted `config.json` is sufficient. See +[`docs/deployment.md`](./deployment.md) for this alongside the Cloudflare Worker rollback +path. + +## The generated `public/config.json` artifact (background, for context) + +The image's webroot `config.json` ultimately comes from this repo's root `config.json` +via a generated, gitignored intermediate file, `public/config.json`, produced by +`node scripts/syncPublicConfig.mjs` and consumed by `next build`'s static-asset copy. +This is a build-time mechanism, not something the running container does — see +[`docs/config.md`](./config.md) for the full explanation of that pipeline and its +implications for local development. + +## Known deviations from `docs/team-standards.md` + +This image and its publish workflow deliberately diverge from a few conventions in +`docs/team-standards.md`. Recorded here per that document's expectation that deviations +be explained, not silently made: + +- **GHCR instead of GCP Artifact Registry** (`docs/team-standards.md:114,168,210`). The + team standard's Dockerfile/release conventions route through the shared + `docker-release-trigger.yml` → `apps-docker-release.yml` pipeline, which publishes to + GCP Artifact Registry as part of the dry-dock/Kargo deployment path. This repo instead + publishes directly to `ghcr.io/agglayer/agglayer-dev-ui` via its own + `.github/workflows/docker-publish.yaml`. Rationale: `agglayer-dev-ui` is a + self-hosted, operator-run artifact (like `ghcr.io/agglayer/aggkit`), not a GCP-deployed + Polygon Labs backend service — there is no dry-dock stage or Kargo project for it, and + GHCR is where its sibling `agglayer/*` images already live. +- **No `.changeset`** (`docs/team-standards.md:80-90`). This repo is `private: true` and + never published to npm (see `CLAUDE.md`'s "Other known deferrals"), so the changeset + machinery the team standard requires for workspace-package PRs and Docker release tags + does not apply here — there is no npm package whose version a changeset would bump, and + the image tag is instead derived directly from the GitHub Release tag (see "Tag + scheme" above). +- **Workflow logic inline, not under `.github/scripts/`** (deviates from + `docs/team-standards.md:105-109`, "Keep workflow YAML thin"). The tag-computation and + smoke-test logic in `docker-publish.yaml` lives inline in `run:` steps rather than in a + separate script file. Accepted as a one-workflow-file exception; the logic is under 70 + lines total and heavily commented in place. +- **`concurrency.cancel-in-progress: false`**, unlike this repo's `deploy.yaml`/ + `e2e.yaml` (`.github/workflows/docker-publish.yaml:62-70`). A cancelled build mid-push + could leave a partially-written manifest or tag in GHCR; overlapping runs (e.g. a + release publish racing a manual dispatch) queue and run strictly serially instead. diff --git a/docs/team-standards.md b/docs/team-standards.md new file mode 100644 index 0000000..888f672 --- /dev/null +++ b/docs/team-standards.md @@ -0,0 +1,868 @@ + + +# Polygon Apps Team Standards + +## Reference Implementation + +`apps-team-ts-template` is the canonical reference for all dev tooling +config. When setting up or reviewing any repo, diff its config files +against the template's equivalents. + +## Code Quality + +- **Named exports only** — `export const` / `export function`. Never + `export default`. Exceptions: `.tsx` files and config files requiring + default export (`eslint.config.js`, `vite.config.ts`, + `commitlint.config.js`, etc.). + +- **No non-null assertions (`!`)** — narrow the type instead (`if` guard, + `?? fallback`, destructuring default). Applies to test files too. + +- **No type escape hatches in production code** — `as any`, `: any`, + `as unknown as X`, `// @ts-ignore`, `// @ts-expect-error`, or + `/* eslint-disable @typescript-eslint/no-explicit-any */` in any file + under `src/`. If the type system seems wrong, the type is the bug: + fix the upstream signature, or refactor so the call site has the + information it needs. Cast chains hide real type-system tension — + exactly the kind of tension that catches bugs before runtime. Test + files have a small allowance for mocking ergonomics (stub helpers, + spy wrappers), but any production-code `any` is a review-blocker. + If a third-party library forces a cast, isolate it in a single + helper at the boundary with a precise typed wrapper above it. + - **Narrow `unknown` by validating, not casting.** Untyped data — + `JSON.parse()` output, request bodies, external API responses — is + `unknown`; reach for a `as MyType` cast and you assert a shape the + runtime never checked. Parse it with a Zod schema instead: validation + and the typed result come from the same call (`schema.parse(raw)` + returns `MyType`), so there is no cast and no unchecked assumption. + This is the sanctioned way out of an `as` — see Validation, Parsing, + and Coercion, and `apps-team-ops/docs/best-practices/type-safety.md`. + +- **Chai assertion style** — Vitest's `expect` is Chai. Strip no-op + chains (`.to`, `.be`, `.is`, `.that`, `.and`, `.at`, `.with`, `.have`, + `.has`). Use `.property()` / `.nested.property()` for object fields — + never dereference directly. Don't prefix `.greaterThan()`, `.least()`, + `.below()` etc. with `.a('number')` — they already validate the type. + See `apps-team-ops/docs/best-practices/testing.md`. + +- **Params objects over positional arguments** — for any function with + two or more parameters, especially when one is optional or boolean, + prefer `fn({ a, b, c })` over `fn(a, b, c)`. Single-parameter + functions, framework-imposed signatures, and well-established two-arg + conventions (Node-style `(err, result)` callbacks, `(prev, curr)` + reducers, `(a, b) => number` comparators) keep positional args. See + `apps-team-ops/docs/best-practices/function-signatures.md`. + +## Git and GitHub + +- **Never open a pull request without explicit user permission.** Do not run + `gh pr create` unless the user has specifically asked for it. +- **Always create PRs in draft mode.** Use `gh pr create --draft`. After + CI checks pass, mark the PR ready with `gh pr ready`. This avoids + spamming CODEOWNERS with review requests before checks are green. +- **Never commit without explicit user permission.** Do not run `git commit` + unless the user has specifically asked for it. +- **Keep PR descriptions current.** When new commits are pushed to a branch + that already has an open PR, update the PR description to reflect what was + added. A PR description that no longer matches the branch content misleads + reviewers and makes the merge history untrustworthy. This applies after + every push — not just the first one. +- **Maintain linear history — never merge trunk into feature branches.** + When a feature branch falls behind `main`, rebase it (`git rebase + origin/main`) rather than merging `main` into it. If a rebase conflict + is genuinely complex, discuss with the team before merging trunk as a + last resort. +- **Include a changeset whenever a PR touches workspace package files** + in a changeset-managed repo (check for `.changeset/config.json`). The + CI gate runs `pnpm exec changeset status` and only fails when a file + inside a workspace package directory has changed without a covering + changeset; files outside every package — `.github/`, root configs, + top-level docs — pass without one. `pnpm exec changeset add` for + shippable changes; `--empty` for non-shippable changes inside a + package (chore in a published package, intermediate refactor with no + consumer impact). Commit the changeset in the same commit as the code + — the changelog records that commit's hash. See + `apps-team-ops/docs/best-practices/changesets.md`. + +## GitHub Repository Settings + +- **Auto-delete branch on merge** — All repositories must have + "Automatically delete head branches" enabled. +- **Branch protection on `main`** — All repositories must have branch + protection enabled on `main` requiring at least one approving review + and code owner reviews. `enforce_admins` should be `false` so the + team lead can push directly when needed. +- **CODEOWNERS** — All repositories must have a `.github/CODEOWNERS` + file defining the team as required reviewers. + +## GitHub Actions + +- **Keep workflow YAML thin — put logic in scripts.** Any non-trivial + shell logic (more than a single command) must live under + `.github/scripts/` or `.github/actions//` so it can be run and + debugged locally. YAML steps are just invocations. Never split logic + across many steps sharing state via `$GITHUB_OUTPUT`. + +- **Explicit secrets on reusable workflow calls.** Never + `secrets: inherit` — always pass by name so it's clear what credentials + are granted. Reference: + `apps-team-ts-template/.github/workflows/docker-release-trigger.yml`. +- **Delete superseded workflows in the same PR.** There is no such thing + as a "disabled for now" workflow file — if it exists, it fires. +- **Always define explicit `permissions` on every workflow.** Never rely + on default token permissions. Least privilege at workflow level (or + per-job if jobs need different scopes). `permissions: {}` if the + workflow needs no repo access. +- All repositories must have a CI workflow on PRs to `main` running at minimum `pnpm run lint`, plus tests if a test suite exists. +- **CI uses the shared composite action.** `ci-trigger.yml` calls + `0xPolygon/pipelines/.github/actions/ci@main` in a + `runs-on: ubuntu-latest` job. Env vars composed from secrets in the + trigger's `job.env:` block reach `pnpm test` automatically — no + extra passing mechanism. See + `apps-team-ts-template/.github/workflows/ci-trigger.yml`. +- **Prefer `actions/github-script` over shell for GitHub API calls.** + Inline `gh api | jq` is brittle. Use `actions/github-script` with a + `.cjs` helper under `.github/scripts/` — authenticated Octokit, + `github.paginate()`, testable locally. Exception: composite actions + called cross-repo must compile the helper with `ncc` into a committed + `dist/` bundle, since raw `.github/scripts/` isn't accessible from the + calling repo's workspace. + +## Shared Workflows + +All team repositories consume shared GitHub Actions workflows from +**`0xPolygon/pipelines`** rather than maintaining inline copies. Apps Team +workflows carry an `apps-` prefix on the filename to namespace them +alongside other pipelines hosted there (`gcp_pipeline_release_image.yaml`, +`ecs_deploy_docker_taskdef.yaml`); composite action names are unprefixed. +`pipelines` is public, so private and public consumers both call it +directly. + +Consuming repos have thin **trigger files** (`-trigger.yml`) that +call the shared workflow +(`uses: 0xPolygon/pipelines/.github/workflows/apps-.yml@main`) or +composite action (`uses: 0xPolygon/pipelines/.github/actions/@main`). +Canonical trigger files live in `apps-team-ts-template/.github/workflows/` +— copy when setting up a new repo. + +### Trigger file permissions + +A trigger file's top-level `permissions:` must be a superset of every scope +the called workflow's jobs declare. Mismatches produce `startup_failure` at +workflow start before any step runs. Check the called workflow's job-level +`permissions:` block and mirror in the trigger. Canonical trigger files +have the correct permissions already. + +### Required workflows + +| Condition | Trigger file | Shared workflow | +|-----------|-------------|-----------------| +| All repos | `ci-trigger.yml` | `.github/actions/ci` composite action | +| All repos | `changeset-check-trigger.yml` | `apps-changeset-check.yml` | +| Repos using changesets | `npm-release-trigger.yml` | `apps-npm-release.yml` | +| Repos with a Dockerfile | `docker-release-trigger.yml` | `apps-docker-release.yml` | +| All repos | `claude-code-review-trigger.yml` | `apps-claude-code-review.yml` | +| All repos | `claude-trigger.yml` | `apps-claude.yml` | +| Repos with Slack notifications | `pr-notifications-trigger.yml` | `apps-pr-labeler.yml` + `apps-slack-merge-notify.yml` | + +## Node.js + +- **Never create RPC clients or provider objects inside request handlers + or retry loops.** Create once, reuse for the process lifetime. Per-request + creation causes OOMKill under load as socket buffers and libuv handles + outpace the GC. Use a process-level singleton or a cache keyed by + connection parameters. Cache `Promise` (not the resolved value) so + concurrent callers share a single in-flight initialisation; evict on + failure so the next caller retries: + ```ts + const cache = new Map>(); + function getClient(url: string): Promise { + let p = cache.get(url); + if (!p) { + p = createClient(url).catch(err => { cache.delete(url); throw err; }); + cache.set(url, p); + } + return p; + } + ``` + See `apps-team-ops/docs/best-practices/backend.md`. + +- **Use a static provider for services that never subscribe to events or + filters.** No block-polling timer, no cached chain state — safe as a + long-lived singleton regardless of call volume (one-off or polling + `getLogs` in a loop). The qualifier is the absence of + filters/subscriptions, not call frequency. + - ethers v5: `new StaticJsonRpcProvider(url)` + - ethers v6: `new JsonRpcProvider(url, Network.from(chainId), { staticNetwork: true })` + + See `apps-team-ops/docs/best-practices/backend.md`. + +- **Never sleep between retries that switch to a different endpoint.** + Back-off before retrying the *same* endpoint is fine; sleeping before + trying a different healthy endpoint holds in-flight request objects in + memory for no reason. + +## Dockerfile + +All repositories that ship a Docker image must follow these conventions. + +- **Base image**: Use `node:-bookworm-slim` where `` matches + `.nvmrc`. Prefer `bookworm-slim` over `alpine` for glibc compatibility with + native modules. +- **Non-root user**: The container must not run as root. Create a system user + and switch before `ENTRYPOINT`. +- **apt-get hygiene**: Never silence `apt-get update` with `|| :` — let it fail + hard. Always pass `--no-install-recommends` and clean up package lists in the + same `RUN` layer. +- **`pnpm deploy` for the runtime bundle** — single-package and monorepos + both. Never hand-copy `node_modules`, `src/`, or `package.json` into the + runtime stage. Use `--ignore-scripts` on install; if a dependency truly + needs a postinstall (native addon), add it to `onlyBuiltDependencies` + in `pnpm-workspace.yaml` rather than dropping `--ignore-scripts`. +- **Docker integration test at release** — trigger file has `test` (via + the `docker-test` composite action) and `release (needs: test)` jobs. + Services with runtime env vars declare them in `job.env:` and pass names + via `test_vars`. See + `apps-team-ts-template/packages/example-rest-api/Dockerfile`. + +## Infrastructure + +- Deployment config for all services lives in the **dry-dock** repository + (Kargo/Argo CD pipelines, k8s YAMLs). +- Production env vars for GCP-deployed services are the canonical source for + service-to-service wiring: + `dry-dock/source/web-apps/common/stages/production/.yaml` + (`applications/web-apps/` contains ArgoCD ApplicationSet templates, not env vars) +- Services span three GitHub orgs: **0xPolygon**, **AggLayer**, **maticnetwork** +- **OIDC registration for new deployable repos** — every repo deploying + via the Docker release pipeline must be added to the `github_repos` + list for `shared-prod-oidc-sa` in + `polygon-infrastructure/google-cloud/landing-zone/service_accounts.tf`. + Without it, the Docker release workflow can't authenticate to GCP + Artifact Registry. Open the PR before the first release of any new + service. +- **`gcpHealthCheckRequestPath` is required whenever a dry-dock stage + enables `access.internal` or `access.external`.** The common chart's + `HealthCheckPolicy` template gates on this value; if absent, no + policy renders, GCP LB falls back to a TCP probe on Service port 80 + (nothing listens), every pod is marked unhealthy, and the gateway + returns `no healthy upstream` for every request. Set it to the + service's liveness path (typically `/health-check`) in the same + commit that adds the `access.*` block — the two are not independent. + Has bitten us twice (dry-dock#975 l2-spol-rebalancer, + dry-dock#989 lst-indexer). See + `apps-team-ops/docs/best-practices/ci-cd/service-deployment.md`. + +## Monitoring + +- **`/service-status` for Datadog Synthetics** — Services that expose operational + metrics for Datadog Synthetics monitoring must name that endpoint `/service-status`. + Use `/health-check` for the Kubernetes liveness probe only; never repurpose it for + operational metrics. See `apps-team-ops/docs/best-practices/service-health-monitoring.md`. + +## Release Verification + +Post-release rollout verification is driven by the workspace `verify-release` +skill, backed by one profile per deployed service at +`apps-team-ops/docs/runbooks//verify-release.md` (endpoints, version +probe, health and `/service-status` body shapes, smoke command, quirks). + +- **Keep the verify-release profile current with the service.** When a change + alters a service's deployment surface — `/health-check` or `/service-status` + response shape, routes or endpoints, version-probe path, runtime env or + secrets, or smoke command — update that service's profile in the same PR. A + stale profile makes release verification confidently wrong. If a deployed + service has no profile yet, add one (use an existing profile as the template). +- **Don't restate promotion topology in the profile.** Whether a service is + dev-auto/manual-prod or direct-to-prod is discovered from its dry-dock Kargo + project (`kargo/projects/web-apps/.yaml`, via the registry node's + `deployment.k8sGcp.releasePipelines`) — the profile points at it, never + copies it. + +## Secrets Management + +- **Use GCP Secret Manager** for all service secrets, injected via External + Secrets Operator (`secretsFrom` + templated `extraEnv` in dry-dock stage + files). See + `apps-team-ops/docs/runbooks/1password-to-gcp-secrets-migration.md`. +- **RPC URLs by cluster:** + - Production (`prj-polygonlabs-webapps-prod`): internal eRPC proxy + `http://erpc.erpc.svc.cluster.local/internal/evm/?token=` + - Development (`prj-polygonlabs-webapps-dev`): public endpoint + `https://rpc.polygon.tools/internal/evm/?token=` (dev + cluster has no access to the internal service) +- **Secret naming**: `-` + (e.g. `proof-generation-api-ethereum-rpc` for `ETHEREUM_RPC`). + +## Frontend Architecture + +- **New frontends: Vite.** Only use Next.js if there is a concrete, + documented need for SSR or Next.js-specific features (ISR, middleware, + API routes). "Might need SSR later" is not sufficient. +- **Existing Next.js apps** — don't rewrite working apps to adopt Vite. + Static-export (`output: 'export'`) Next.js apps with no SSR/API routes + are migration candidates when the next significant change lands. +- Reference: `apps-team-ts-template/packages/example-frontend/`. + +## Wallet Integration + +New Polygon frontends requiring wallet connectivity use +**`@0xsequence/connect`** (Sequence Connect / Trails Ecosystem Wallet). + +- Use wagmi hooks directly (`useConnection`, `useBalance`, + `useSwitchChain`) — don't wrap them in a custom wallet context. +- **Don't use Reown (AppKit/WalletConnect)** for new projects. Existing + `@reown/appkit` apps migrate when the next significant frontend change + lands. +- Sequence env vars use the `VITE_SEQUENCE_*` prefix and are validated in + `src/env.ts` via `@t3-oss/env-core`. +- **Wallet adapter peer deps** — wagmi requires an explicit peer dep for + each adapter enabled in Sequence Connect config. Missing peers cause + silent runtime connection failures. Follow + `apps-team-ts-template/packages/example-frontend`. +- **Sequence v3 provider config** — on connect, detect + `connector.id === 'sequence-v3-wallet'` and call + `setUseWalletTransactionForSend(true)` on the provider. Use wagmi's + `useConnectionEffect` and a type guard — never `(provider as any)`. +- **SCW UX gating for Sequence v3** — Sequence v3 deploys bytecode but + behaves like an EOA for transaction submission. SCW-specific UX + (informational modals, "please verify execution" messages) must + exclude it: + `if (isSmartContractWallet && !isSequenceWallet) { /* SCW UX */ }`. + Also exclude EIP-7702 delegated EOAs (bytecode starting `0xef0100`). +- **Permit flow exception** — `eth_signTypedData_v4` from Sequence v3 + returns an ERC-1271 signature; OpenZeppelin's `ERC20Permit` reverts + via `ecrecover`. Route Sequence v3 *and* all SCWs through `approve` + + direct call: + `if (isSmartContractWallet || isSequenceWallet) { /* approve flow */ }`. + (OR, not AND — Sequence needs the approve path unconditionally.) +- Reference: `apps-team-ts-template/packages/example-frontend/`. + Rationale and common mistakes: + `apps-team-ops/docs/best-practices/wallet-integration.md`. + +## Documentation + +All written documentation — READMEs, code comments, CLAUDE.md files, PR +descriptions, inline JSDoc — answers **why**, not just **what**. + +- **Explain motivation, not mechanics.** Readers can see what the code + does; they can't see the constraint that shaped it, the alternative + rejected, or the problem it solves. +- **No shadow documentation.** Comments that restate the signature are + worse than nothing — they drift silently. Delete them. +- **AI-drafted docs need human review.** LLMs default to summarising + *what*. Add the context the model can't infer: design decisions, + failure modes, historical constraints. +- **Useful "why" includes:** the problem and who has it, alternatives + considered, non-obvious constraints, ordering requirements, why a + config value is what it is. + +See `apps-team-ops/docs/best-practices/documentation.md`. + +## CLAUDE.md Authoring + +**Never duplicate sources of truth.** If information can be read from +a file, discovered by searching the repo, or derived from code, do not +copy it into CLAUDE.md. Copies drift and become silently wrong. + +This includes but is not limited to: +- File and directory listings (use glob to discover them) +- Environment variable names or values (read the env schema) +- Route paths or API endpoints (read the route definitions) +- Script names and flags (read `package.json`) +- Dependency lists (read `package.json` or lock files) +- Configuration values (read the config files) + +Instead, tell Claude _where_ to look — not _what it will find there_. +Reference the file or directory path; never enumerate its contents. + +## Testing + +All repos use **Vitest** for backend and frontend. `expect()` is Chai — the +assertion rules in Code Quality apply. + +- Import `describe`/`it`/`expect`/`beforeAll`/`afterAll`/`beforeEach`/ + `afterEach` from `"vitest"`. Never install `chai` separately. +- Shared state: `let foo!: Type` in `describe` scope; assign in `beforeAll()`. +- Cleanup in `afterAll()` — not `process.on("exit")`, which breaks teardown + ordering on failure. +- Timeouts via options on `describe`/`it`: + `describe('name', { timeout: 30000 }, () => { ... })`. Never in config + files or CLI flags. +- Every repo with tests has `vitest.config.ts` at the right level (repo + root, or per-package in a monorepo). + +## Validation, Parsing, and Coercion + +- **Prefer Zod** for all data validation, parsing, and coercion. Use + Zod schemas for request/response bodies, configuration, external API + responses, and any data crossing a trust boundary. +- **REST APIs — OpenAPI-first design**: Define Zod schemas first, then + derive OpenAPI specs from them using `@asteasolutions/zod-to-openapi`. + The Zod schema is the source of truth; the OpenAPI spec is generated, + not hand-written. Serve interactive API docs using + `@scalar/express-api-reference` mounted at `/docs`. +- **Client codegen**: generated REST clients use + [`@hey-api/openapi-ts`](https://heyapi.dev/openapi-ts) with the + registry-driven [`@polygonlabs/zod-to-openapi-heyapi`][heyapi-plugin] + plugin. The plugin emits + `import { } from ''` per schema and a + `parseAsync` response transformer per operation, so the client imports + the **actual** Zod runtime values the backend validates against — + codecs (off-the-wire-format ↔ runtime-type pairs from + [`@polygonlabs/zod-codecs`][zod-codecs]) round-trip end-to-end. Schema + exports must be named exports whose binding equals the registry name — + the plugin's codegen-time audit fails the build on mismatches. See + `apps-team-ts-template/packages/example-client` for the canonical + implementation; the plugin README documents `schemasFrom` resolution + and the package.json `imports` alias pattern. +- **Don't use orval, openapi-typescript, or `@hey-api/zod`** for new + clients. They re-derive types from the OpenAPI spec, which loses + codecs, refinements, and branded types — the client validates a + superset of what the backend accepts and the two copies drift the + moment a constraint changes. +- Don't use Zod to re-implement constraints that a host framework + already provides (e.g., yargs `type: "number"`, yargs `choices`). + Use Zod for validation the framework can't express natively. + +[heyapi-plugin]: https://www.npmjs.com/package/@polygonlabs/zod-to-openapi-heyapi +[zod-codecs]: https://www.npmjs.com/package/@polygonlabs/zod-codecs + +## Environment Validation + +All services validate env vars at startup using `@t3-oss/env-core` with Zod. +Define the schema in `src/env.ts` and import from there — never read +`process.env` directly elsewhere. + +- **Lazy `getEnv()` pattern** — wrap `createEnv()` in `buildEnv()`, export + a memoised `getEnv()`. Never `export const env = createEnv(...)` at + module scope. Deferring validation lets test suites using `TEST_BASE_URL` + import the app graph without every service env var set. +- **`emptyStringAsUndefined: true`** in `createEnv()`. Treats `FOO=` as + missing rather than the empty string. +- **Boolean env vars** — use `BooleanOrBooleanStringSchema` from the + canonical + [`src/env.ts`](https://github.com/0xPolygon/apps-team-ts-template/blob/main/packages/example-rest-api/src/env.ts). + Never `.transform((v) => v === 'true')` — accepts only the literal + `"true"` and silently treats `"1"` / `"yes"` / `"on"` as false. +- **`dotenvx` for local dev only** — `"dev": "dotenvx run -- node src/index.ts"`. + Don't add `dotenv` to runtime `dependencies` or call `import 'dotenv/config'` + in source — production env vars come from External Secrets Operator. +- **`NODE_ENV=production` for every deployed service**, including the dev + cluster. Libraries (React, Express, webpack) check `=== 'production'` + exactly; arbitrary values silently opt out of production hardening. For + environment-specific behaviour use purpose-specific vars (`LOG_LEVEL`, + `SENTRY_ENVIRONMENT`). Locally, `NODE_ENV` may be `development` or `test` + per the runner. + +## ESLint + +All repos use **`@polygonlabs/apps-team-lint`** (`eslint@^10.0.0` is a +required peer dep). Keep both in sync with `apps-team-ts-template`. +`eslint.config.js` wraps its config array with `defineConfig` from +`'eslint/config'` and composes: + +| Export | Purpose | Options | +|--------|---------|---------| +| `recommended(options?)` | Ignores, parser, import sorting, import-x, core rules, Prettier compat | `{ globals?: 'node' \| 'browser' \| Record }` | +| `typescript(options?)` | TS-ESLint rules, type-aware linting, TS resolver | `{ tsconfigRootDir?: string }` — required in monorepo per-package configs (pass `import.meta.dirname`); omit for single-package repos | +| `frontend()` | `.tsx` default-export exemption, React/JSX rules | None (browser globals now via `recommended({ globals: 'browser' })`) | + +`javascript()` has been removed — do not import or call it. Repo-specific +overrides (extra ignores, file patterns) go in the same file. + +### `lint` and `lint:ts` script conventions + +Repos running multiple linters (ESLint, markdownlint, Prettier) via +`concurrently`: + +- **`lint:ts` is raw `eslint .`** — no `&& tsc --noEmit`. Typecheck is the + `typecheck` script. +- **`lint` invokes `lint:ts`** (directly or via `concurrently`) so ESLint + always runs as part of the top-level lint gate. + +Single-linter repos use `"lint": "eslint ."`. Never use +`pnpm -r run lint` (monorepo recursion silently skips packages) or omit +ESLint. + +### Monorepo ESLint structure + +Each workspace package has its own `eslint.config.js`. The root +`eslint.config.js` is a thin safety net for root-level files. All configs +use the same `defineConfig` pattern. Root `package.json` lint script is +`"lint": "eslint ."` — per-file config discovery handles packages. + +### TypeScript project references — three-tier `tsconfig` + +TypeScript monorepos follow the Nx three-tier `tsconfig` pattern: + +- **`tsconfig.base.json`** at the repo root owns every shared + `compilerOptions` entry — extends `@tsconfig/node24` + `@tsconfig/node-ts` + and adds `composite: true`, `declarationMap`, `emitDeclarationOnly`, + `customConditions: ["@polygonlabs/source"]`, `noUncheckedSideEffectImports`. + Any repo-wide strictness tightening lands here once. +- **Root `tsconfig.json`** is a solution-style hub: `extends` + `./tsconfig.base.json`, sets `files: []`, and lists `references` + pointing at each package directory. +- **Per-package hub `tsconfig.json`** carries only `references` to + `./tsconfig.lib.json` and `./tsconfig.spec.json`, with `files: []` + and `include: []`. +- **Per-package `tsconfig.lib.json`** owns source build / typecheck: + `rootDir: src`, `outDir: dist`, `emitDeclarationOnly: false`, + `include: ["src/**/*.ts"]`. Published library packages override + `customConditions: []` so build-time resolution of workspace deps + goes through the depended-upon package's published `dist/.d.ts` + rather than the source condition. Cross-package `references` point + at the depended-upon package's `tsconfig.lib.json` (not its hub). +- **Per-package `tsconfig.spec.json`** owns tests and non-source files + (vitest configs, codegen configs, top-level scripts). Adds vitest + types, references `./tsconfig.lib.json`, sets + `rewriteRelativeImportExtensions: false` + + `allowImportingTsExtensions: true` so tests can keep reaching the + package's own `src/` via relative `.ts` imports without TS2878 — + the spec's `outDir` (`out-tsc/`) is throwaway and never consumed at + runtime. + +Per-package `package.json` scripts use `tsc -b`: + +- `"typecheck": "tsc -b"` walks the hub graph; emits to gitignored + `dist/` and `out-tsc/` directories. (`tsc -b --noEmit` is incompatible + with composite project references — TS6310 — so emit-and-discard is + the supported flow.) +- Library builds use `tsc -b tsconfig.lib.json` so the build emits only + the library payload, not the spec output. + +**Tests reach internals via relative paths, not via `exports`.** Don't +add subpath `exports` entries to a package solely so a test can import +an internal — the published surface is for consumers, and the spec +config's rewrite override is what lets test code keep using +`from '../src/foo.ts'`. Cross-PACKAGE imports (between different npm +packages) must still go through public `exports` — that rule is +unchanged. + +Composite-mode declaration emit surfaces a TS2742 / TS7056 class of +portability diagnostic on any exported value whose inferred type isn't +portably nameable via its source package's public surface — Express +factories, viem `Client.extend(actions)` intersections, and +`defineConfig`-style default exports from tsup-bundled packages have +all hit this. The fix is the same every time: name the type explicitly +at the consumer boundary so TS doesn't have to discover a portable +name through the producer's internal chain; don't reach for `any` when +the producer ships a helper type that satisfies the annotation. +Catalogue with the canonical fix for each case lives in +`apps-team-ops/docs/best-practices/build-tooling.md` under "Failure +mode 3"; re-check it whenever a new three-tier rollout surfaces a +fresh hit. + +Each per-package `eslint.config.js` still passes +`tsconfigRootDir: import.meta.dirname` to `typescript()` so +`typescript-eslint` finds the right tsconfig when ESLint runs from the +repo root. It also adds `{ ignores: ['out-tsc/**'] }` — under flat-config +rules the per-package config overrides the root config's ignores, +so the entry must be repeated per package. + +#### Library build scripts: `build` / `build:clean` / `prepublishOnly` + +Every published library declares the same three-script split: + +```jsonc +{ + "build": "pnpm run typecheck && tsc -b tsconfig.lib.json", + "build:clean": "pnpm run typecheck && rm -rf dist out-tsc *.tsbuildinfo && tsc -b tsconfig.lib.json", + "prepublishOnly": "pnpm run build" +} +``` + +- `build` is the dev-iteration path and the publish path. +- `build:clean` exists for local recovery only (interrupted publish, + `git stash pop`, `src/` rename leaving orphan `dist/` outputs, etc.). + **Do not** point `prepublishOnly` at `build:clean` — the `rm` step + races with parallel `prepublishOnly` typechecks during `changesets + publish` and breaks the wave with `TS2307` errors. CI publishes run + on fresh checkouts so the rm has nothing to clean anyway. +- `tsc -b --force` is **not** a substitute for `build:clean` — it + re-emits but doesn't remove orphaned outputs without a `src/` + counterpart. +- `tsup`-emit packages are exempt — `clean: true` already wipes `dist/`. + +Rationale (failure modes, publish-incident history, publish-wave +race): see `apps-team-ops/docs/best-practices/build-tooling.md`. + +`tsconfig.build.json` no longer exists in the migrated shape; replace +it with `tsconfig.lib.json` whenever you encounter it. + +## Prettier + +All repos use identical Prettier settings matching the template's +`.prettierrc.json`. No per-repo overrides. + +## Tooling + +- **Runtime: Node.js** — All repositories use Node as the runtime. + Do not use Bun. +- **Node 24 runs TypeScript natively.** No transpiler, no `ts-node`, no + `tsx`. `node src/index.ts` directly. `@tsconfig/node-ts` enforces + `erasableSyntaxOnly: true` (no `enum`, no namespaces, no constructor + parameter properties). Don't add `--experimental-strip-types`. +- **`noUncheckedSideEffectImports: true` in `tsconfig.base.json`.** + With `moduleResolution: "bundler"`, a missing side-effect import + (`import './sentry'`) goes undetected until the Docker build fails. + Set once in the repo-root `tsconfig.base.json` so every package's + `tsconfig.lib.json` / `tsconfig.spec.json` inherits it. Bundler-style + frontend lib configs that extend the base still inherit this — no + per-package override needed. +- **Package manager: pnpm.** Every `package.json` declares + `"packageManager": "pnpm@"` so corepack pins it. Lockfile is + `pnpm-lock.yaml` — never commit `package-lock.json` or `bun.lockb`. +- **Use pnpm scripts** (`pnpm run lint`, `pnpm run format`), not + prettier/eslint directly. +- **CLI argument parsing: `yargs`** using its idiomatic builder/handler + API. `choices` for enums. Zod in `coerce` for constraints yargs can't + express natively (`.positive()`, `.url()`). Let yargs handle type + coercion (`type: "number"`) — don't re-wrap in `z.coerce`. +- **Interactive prompts: `inquirer`.** Exception: `readline.prompt()` for + one-line prompts in utility scripts. + +## Logging + +- **Use `@polygonlabs/logger`** for all services. Never + `@polygonlabs/servercore`'s `Logger` class. +- **`src/logger.ts` exports the factory, not a singleton.** Re-export + `createLogger` and `Logger` from `@polygonlabs/logger`. Nothing at module + load — no top-level `await`, no `getEnv()` call, no mutable binding. +- **Create the logger at the entry point, inject it.** Call `await createLogger()` + once in `startServer.ts` / `index.ts`; pass into services via constructor + arguments. Never `createLogger()` at module scope outside an entrypoint. +- **HTTP services: use `@polygonlabs/express`.** Mount `setupLogger(logger)` + before any route. Call `getLogger()` to reach the request-scoped logger — + never `req.log`, never `declare module 'express-serve-static-core'`, never + thread `logger` through route factories. See + `apps-team-ts-template/packages/example-rest-api/src/index.ts`. The + `getLogger()` priming gotcha for test files that never mount Express is + documented in `@polygonlabs/express`'s README. +- **Ethers fetch errors are sanitised automatically.** `@polygonlabs/logger` + v2.1+ strips RPC tokens from every `{ err }` log call — handlers, cron + ticks, `unhandledRejection`, startup. No per-call code change needed. +- **Test helpers are entrypoints.** A test helper calling `await createLogger()` + at module scope is the test suite's entrypoint — `.env.test` is already + loaded before Vitest imports it. This is the one sanctioned exception to + the entrypoint rule. +- **Expose `PRETTY_LOGS`** via `BooleanSchema.default(false)` in `env.ts`, + pass to `createLogger` at the entry point. `PRETTY_LOGS=true` in local + `.env`. +- **`logger.warn` for retried failures; `logger.error` for terminal failures.** + `warn` doesn't reach Sentry; `error` does. Decide at the outermost retry + boundary (cron catch, `setError` action, consumer restart) — never at the + inner throw site. +- **Log levels:** + - `debug` — periodic polling, every-tick state reads + - `info` — meaningful actions (TX submitted/confirmed, service started) + - `warn` — transient failures that will be retried + - `error` — terminal failures; Sentry fires +- **Flat context, no nesting.** Pass context directly in the merge object. + Never under `data`. No `location` / `function` fields — the message + identifies the call site. +- **Pass errors as `{ err }`** — not `{ error }`, not `err.message`. The + pino serialiser activates only on the `err` key. +- **Log once — never log before rethrowing.** If a function rethrows, it + must not log. Wrap with `VError` to attach inner-scope context as `info`; + the outer boundary logs once. + +See `apps-team-ops/docs/best-practices/logging.md`. + +## Error Handling + +- **Use `@polygonlabs/verror`** for all cross-boundary error wrapping. +- **Constructor: message first.** + ```ts + throw new VError('Human-readable description', { cause: originalError, info?: { ...context } }); + ``` + The message describes what the code was attempting; `cause` carries the + original error. +- **Wrap, don't log, inside handlers.** Functions calling external systems + (RPC, Firestore, HTTP) wrap caught errors with `VError` and rethrow. + Never log before rethrowing — the log happens once at the boundary. +- **`info` only when non-empty.** Include when there are values a developer + needs for investigation (tx hashes, block numbers). Omit — don't pass + `info: {}` — when there's no useful context. +- **`serializeError(err)` for persistence.** Use from `@polygonlabs/verror`. + Store `{ ...serializeError(err), stack: err.stack }`. +- **Persist error fields as records, not strings.** Schema: + `z.record(z.string(), z.unknown()).nullable()`. Coerce pre-existing + string values with a `.transform` for backward compatibility. + +- **VError info is extracted automatically.** Don't manually spread + `VError.info(err)` into the merge object — `@polygonlabs/logger` v2 merges + the full cause-chain info into `err.info` when `{ err }` is passed. + Datadog: `@err.info.`. The old `@error_info.*` queries no longer + match. +- **Use `WError` at REST API boundaries.** `WError` hides the cause chain + from the client's message while preserving it for logs. `VError` inside + the service; switch to `WError` at the outermost HTTP layer. +- **Use `createErrorHandler()` and `notFoundHandler` from `@polygonlabs/express`** + as the global 404 and error middleware. The handler respects the author's + wrapper choice (VError/WError/HTTPError) and only URL-strips the message + via `sanitiseEthersFetchError` before responding. Never hand-roll + per-service equivalents. + +See `apps-team-ops/docs/best-practices/error-handling.md`. + +## Release Management + +- **Changeset bodies are user-facing changelog entries.** Markdown — headers, + bullets, inline code. Lead with user-visible outcome, not implementation + mechanism. No commit-type prefixes (`feat:`, `fix:`). See + `apps-team-ops/docs/best-practices/changesets.md`. +- **First line of a changeset body must be plain prose, not a heading.** + Changesets prefixes each entry with `- :`; a heading renders + as `- abc1234: ## My heading` (broken). Write a plain-text opener, + headings from line 2: + ```markdown + Add /ready readiness probe and fix staleness detection in /service-status + + ## Breaking changes + + `networks[name].lastUpdateMs` renamed to `lastPollMs` ... + ``` +- **Preferred tool: `@changesets/cli`.** Reference: + `apps-team-ts-template`. +- **Legacy: Lerna.** Migrate to changesets when opportunity arises — not + an immediate requirement. +- **No default publish access.** Never set `access` in + `.changeset/config.json`. Every published package declares its own + `"publishConfig": { "access": "public" }` (or `"restricted"`). +- **Include `MIGRATION.md` in published packages.** Every `package.json` + with `"publishConfig": { "access": "public" }` lists `"MIGRATION.md"` + in `"files"` — npm only publishes files explicitly listed (plus a + small default set). +- **Changelogs and tags for all packages** — `privatePackages.version: true` + and `privatePackages.tag: true` in `.changeset/config.json`. Tags mark + the exact commit deployed; essential for rollbacks and CD triggers. +- **Replace workspace protocol at release** — set + `bumpVersionsWithWorkspaceProtocolOnly: false` in + `.changeset/config.json` so `workspace:*` deps are replaced with real + semver at Version Packages time. Required for Docker builds at release + tags to install the npm-published version. See Monorepo Structure. +- **Signed release commits** — `release.yml` uses `commitMode: github-api` + so version-bump commits are signed by GitHub's GPG key (required by + branch protection). +- **PR gate** — `changeset-check.yml` runs + `pnpm exec changeset status --since=origin/main` and comments when no + changeset is found. Skips `changeset-release/*` branches. +- **`ci:publish` script** — `pnpm exec changeset publish` (publishes + public packages and tags private ones). Never `pnpm publish` directly — + fails on private packages and lacks the tagging needed to trigger the + Docker release pipeline. +- **Docker release pipeline** — Any repo with a `Dockerfile` has a + `docker-release-trigger.yml` triggering on changeset version tags + (`@[0-9]*`), with `test` (via `docker-test` composite action) + and `release (needs: test)` jobs. Push-triggered deploys must migrate; + delete the old workflow in the same PR. Reference: + `apps-team-ts-template`. + +## Supply Chain Security + +Every repository must enable pnpm's built-in supply chain protections in +`pnpm-workspace.yaml` — the template's version has the exact required values. +At minimum, configure: + +- **`blockExoticSubdeps`** — prevent transitive dependencies from + non-registry sources (git URLs, tarballs) +- **`minimumReleaseAge`** — refuse to install packages published too + recently (protects against malicious publications) +- **`minimumReleaseAgeExclude`** — exempt internal npm scopes so + newly published internal packages can be installed immediately. + The excluded scopes must match the ESLint internal import pattern + (`internalPattern` in `@polygonlabs/apps-team-lint`): + `@polygonlabs/*`, `@maticnetwork/*`, `@agglayer/*`, + `@0xsequence/*`, `@0xtrails/*` +- **`trustPolicy`** — prevent trust-level downgrades between versions + +Repos that need specific packages to run build scripts should use +`onlyBuiltDependencies` (allowlist) or `ignoredBuiltDependencies` +(suppress warnings) in the same file. + +## Monorepo Structure + +Multi-package repos use **pnpm workspaces**: + +- Workspace packages declared in `pnpm-workspace.yaml`, not `package.json`. + Root `package.json` must NOT have a `"workspaces"` field. +- Root `package.json` contains only `devDependencies` for repo-level + tooling (linting, TypeScript, Husky). No root `dependencies`. +- Each package has its own `package.json` declaring all its deps + explicitly. Never rely on hoisting — each package must work installed + in isolation. +- No nested `package.json` outside the root and individual workspace + packages. + +### Workspace dependency resolution for Docker builds + +Monorepos with deployable services must ensure Docker builds at a release +tag install the **npm-published** version of workspace library packages — +not local source with unreleased changes. + +- **Root `.npmrc`** — `link-workspace-packages=false`. Only `workspace:*` + protocol deps are linked locally; semver ranges resolve from npm. +- **`.changeset/config.json`** — `"bumpVersionsWithWorkspaceProtocolOnly": false`. + Changesets replaces `workspace:*` with real semver in the Version + Packages PR. + +Lifecycle: `workspace:*` during dev → changesets replaces with `^x.y.z` +at release → Docker build at the release tag uses +`pnpm install --frozen-lockfile` against npm. + +Developer convention: `workspace:*` when co-developing packages in the +same PR; leave as semver otherwise. + +### Workspace library exports — build-free local development + +Workspace library packages consumed by services in the same monorepo use +the `@polygonlabs/source` custom-condition export pattern: + +```json +"exports": { + ".": { + "@polygonlabs/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } +} +``` + +- `tsconfig.base.json`: `"customConditions": ["@polygonlabs/source"]` + (inherited by every package's `tsconfig.lib.json` / `tsconfig.spec.json`). + Library `tsconfig.lib.json` overrides `customConditions: []` so build-time + resolution of workspace deps reads their published `dist/.d.ts`. +- Service `dev` scripts: `node --conditions @polygonlabs/source --watch src/index.ts` +- Dockerfile: `pnpm run build` before `pnpm deploy` +- Root `build` script: `pnpm -r --if-present run build` (topological + order handles library → service build order automatically) +- Service packages must not have a `build` script unless they emit + compiled output — `build: "tsc --noEmit"` is a fake build; put it in + `typecheck`. Don't add `prelint` / `pretypecheck` / `predev` hooks. +- `publishConfig.exports` omits the `@polygonlabs/source` condition so + published npm packages don't expose internal source. + +See `apps-team-ops/docs/best-practices/docker.md`. + +## Repository Conventions + +- **`.nvmrc`** — matches the template version. +- **Conventional commits** — `type(optional-scope): description`, enforced + by `@commitlint/config-conventional` (via `@polygonlabs/apps-team-lint`) + through a Husky `commit-msg` hook. Claude uses this format too. Valid + types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, + `refactor`, `release`, `revert`, `style`, `test`. +- **Pre-commit hook** — Husky + `lint-staged` (not `pnpm run format`) so + only staged files are formatted and re-staged. Config in + `.lintstagedrc.js` matching the template. Never `--no-verify`. +- **Pre-push hook** — changeset repos have `.husky/pre-push` matching the + template. Runs `changeset status --since=origin/` to catch + a missing changeset before CI does. Skips `changeset-release/*` and the + base branch. diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..a3102d4 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,103 @@ +#!/bin/sh +# Container entrypoint for agglayer-dev-ui. +# +# Contract (a1-runtime-config-design.md §6.3, binding): +# - If a config.json is bind-mounted at MOUNTED_CONFIG, validate it and +# copy it over the baked-in webroot config.json, then start nginx. +# - If nothing is mounted, serve the baked-in default and warn LOUDLY -- +# the baked default (the repo's committed config.json) carries +# https://PLACEHOLDER-* aggkit URLs, so it validates but does not work +# end-to-end. +# - This script does NOT synthesize config from environment variables. +# No envsubst, no templating, no AGGKIT_API_URL-style knobs. The mounted +# file (or the baked default) is the only configuration mechanism. +# +# Validation approach and its explicit limits: +# This is the nginx:alpine runtime stage -- there is no Node here, so the +# app's real validator (config/configSchema.mjs, a Zod schema) cannot run. +# We use `jq` instead to do two things: +# 1. Confirm the mounted file is well-formed JSON (`jq empty`). +# 2. Confirm a small set of required top-level fields exist and have the +# right JSON *type* (object/string), including that +# appModes.configs contains the key named by appModes.default. +# This is a STRUCTURAL check, NOT a substitute for full schema validation. +# It will NOT catch: malformed URLs, wrong chain object shapes, dangling +# chainKeys references into a nonexistent chain, wrong autoclaim/currency +# field types, duplicate networkIds, or any of the other semantic rules +# `config/configValidator.mjs` enforces. A config that passes this check +# can still fail at the browser's AppConfigGate (which does run the real +# Zod validation) and render the error screen. Operators should still run +# the real validator from a dev-ui checkout against a candidate config.json +# before mounting it into this image: +# pnpm run validate:config -- /path/to/your/config.json +set -eu + +WEBROOT_CONFIG="/usr/share/nginx/html/config.json" +MOUNTED_CONFIG="/etc/agglayer-dev-ui/config.json" + +log() { + printf '%s\n' "$*" >&2 +} + +# Runs the structural checks described above against $1. Returns non-zero +# and prints a diagnostic on the first failure. +validate_config() { + file="$1" + + if ! jq empty "$file" 2>/tmp/agglayer-dev-ui-jq-error; then + log "config validation failed: $file is not valid JSON:" + cat /tmp/agglayer-dev-ui-jq-error >&2 + return 1 + fi + + if ! jq -e ' + (.chains? | type == "object") and ((.chains | length) > 0) and + (.appModes? | type == "object") and + (.appModes.default? | type == "string") and + (.appModes.configs? | type == "object") and ((.appModes.configs | length) > 0) and + (.appModes.configs[.appModes.default]? != null) and + (.autoclaim? | type == "object") and + (.externalLinks? | type == "object") and + (.walletConnect? | type == "object") and + (.walletConnect.projectId? | type == "string") and + ((.walletConnect.projectId | length) > 0) + ' "$file" >/dev/null 2>/tmp/agglayer-dev-ui-jq-error + then + log "config validation failed: $file is missing required top-level fields, or they have the wrong shape." + log "Expected: chains (non-empty object), appModes.default (string), appModes.configs (non-empty object containing the appModes.default key), autoclaim (object), externalLinks (object), walletConnect.projectId (non-empty string)." + log "Note: this is a structural check only -- it does not validate individual field values (see this script's header comment)." + return 1 + fi + + return 0 +} + +if [ -f "$MOUNTED_CONFIG" ]; then + log "agglayer-dev-ui: found mounted config at $MOUNTED_CONFIG" + if ! validate_config "$MOUNTED_CONFIG"; then + log "agglayer-dev-ui: FATAL - mounted config at $MOUNTED_CONFIG failed validation. Refusing to start." + exit 1 + fi + cp "$MOUNTED_CONFIG" "$WEBROOT_CONFIG" + log "agglayer-dev-ui: serving mounted config from $MOUNTED_CONFIG" +elif [ -e "$MOUNTED_CONFIG" ]; then + # Docker creates an empty directory at the container-side bind-mount + # path when the host source path doesn't exist. This is the classic + # "-v ./typo-config.json:/etc/..." mistake -- fail loudly instead of + # silently falling through to the baked default. + log "agglayer-dev-ui: FATAL - $MOUNTED_CONFIG exists but is not a regular file." + log "This usually means the host path in your -v/--mount bind mount does not exist," + log "and Docker created an empty directory there instead of mounting your file." + exit 1 +else + log "==============================================================================" + log "WARNING: agglayer-dev-ui: no config mounted at $MOUNTED_CONFIG" + log "WARNING: serving the BAKED-IN DEFAULT config.json, which contains placeholder" + log "WARNING: aggkit URLs (https://PLACEHOLDER-*) and DOES NOT WORK end-to-end." + log "WARNING: mount a real config.json at $MOUNTED_CONFIG to configure this deployment," + log "WARNING: e.g. docker run -v /path/to/your/config.json:$MOUNTED_CONFIG:ro ..." + log "WARNING: see docs/config.md for the schema." + log "==============================================================================" +fi + +exec nginx -g 'daemon off;' diff --git a/eslint.config.mjs b/eslint.config.mjs index de55513..eb5f60e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,5 +6,20 @@ export default defineConfig([ ...recommended({ globals: 'browser' }), ...typescript({ tsconfigRootDir: import.meta.dirname }), ...frontend(), - globalIgnores(['.next/**', 'out/**', 'build/**', 'dist/**', 'next-env.d.ts']) + globalIgnores([ + '.next/**', + '.next-partial-failure/**', + 'out/**', + 'build/**', + 'dist/**', + 'next-env.d.ts', + // Vendored agglayer/sdk source staged for the Docker build by + // scripts/stage-sdk-src.sh (gitignored, not ours to lint). Without this, + // simply following docs/docker.md's documented local build prerequisite + // turns `pnpm run lint` -- and therefore `pnpm run check` -- red with + // dozens of errors in someone else's repo. Flat config, unlike eslintrc, + // does not skip dot-directories by default. + // TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 + '.sdk-src/**' + ]) ]); diff --git a/next.config.ts b/next.config.ts index ce2df3f..294b4e1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,15 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'export', + // Next 16 holds a per-distDir `flock` lock (`/dev/lock`) and refuses + // to start a second `next dev` that resolves to the same distDir. The E2E + // suite runs two dev servers concurrently from this one directory (the shared + // chromium server on :3000 and the partial-failure project's server on :3100 + // -- see playwright.config.ts), so the second one must resolve to a distinct + // distDir or Next aborts it with "Another next dev server is already running". + // Only the partial-failure webServer sets NEXT_DIST_DIR; production + // build/export leaves it unset and keeps the default `.next`. + distDir: process.env.NEXT_DIST_DIR || '.next', images: { unoptimized: true, remotePatterns: [ diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..3524b72 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,38 @@ +# Static-export routing for the Next.js `output: 'export'` build (next.config.ts). +# Listens on port 80 (see Dockerfile EXPOSE). +server { + listen 80; + listen [::]:80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # R7: the served config document must never be cached by a browser or an + # intermediary proxy. A container restart with a different mounted + # config.json must be reflected on the very next request -- entrypoint.sh + # already guards against a stale file on disk, this guards against a + # stale response in a cache. Belt-and-braces with app/configLoader.ts's + # `cache: 'no-store'` fetch option (see a1-runtime-config-design.md §3.6). + location = /config.json { + add_header Cache-Control "no-store" always; + try_files /config.json =404; + } + + # Next's static export fingerprints every asset under _next/static/ by + # content hash (the path changes whenever the content does), so these are + # safe to cache forever. + location /_next/static/ { + add_header Cache-Control "public, max-age=31536000, immutable" always; + try_files $uri =404; + } + + # Next's static export writes clean-URL routes as ".html" (e.g. + # out/transactions.html) rather than "/index.html", so try_files + # must probe the .html form before falling back to a directory index. + location / { + try_files $uri $uri.html $uri/ =404; + } + + error_page 404 /404.html; +} diff --git a/package.json b/package.json index c686d8c..baf853a 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ }, "scripts": { "prepare": "husky", - "dev": "next dev", - "build": "next build", - "build:production": "cp .env.production .env.local && rm -f .env.staging && next build", + "dev": "node ./scripts/syncPublicConfig.mjs && next dev", + "build": "node ./scripts/syncPublicConfig.mjs && next build", + "build:production": "cp .env.production .env.local && rm -f .env.staging && node ./scripts/syncPublicConfig.mjs && next build", "start": "next start", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -24,7 +24,7 @@ "check": "pnpm run validate:config && pnpm run lint && pnpm run typecheck && pnpm run test" }, "dependencies": { - "@agglayer/sdk": "^1.0.0-beta.29", + "@agglayer/sdk": "1.0.0-snapshot-5680d83", "@reown/appkit": "^1.8.15", "@reown/appkit-adapter-wagmi": "^1.8.15", "@tanstack/react-query": "^5.90.16", diff --git a/playwright.config.ts b/playwright.config.ts index 05c5e34..d94f3f7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,6 +8,7 @@ loadEnvConfig(process.cwd(), true); const e2ePrivateKey = normalizeEnvValue(process.env.E2E_PRIVATE_KEY); const projectId = normalizeEnvValue(process.env.NEXT_PUBLIC_PROJECT_ID); +const aggkitProxy = normalizeEnvValue(process.env.NEXT_PUBLIC_AGGKIT_PROXY); if (!isHexPrivateKey(e2ePrivateKey)) { throw new Error('Playwright E2E env invalid: set E2E_PRIVATE_KEY to a valid private key.'); @@ -17,6 +18,13 @@ if (!projectId) { throw new Error('Playwright E2E env invalid: set NEXT_PUBLIC_PROJECT_ID.'); } +if (!aggkitProxy) { + throw new Error( + 'Playwright E2E env invalid: set NEXT_PUBLIC_AGGKIT_PROXY ' + + '(scripts/kurtosisDevnetEnv.mjs writes this for devnet mode; see README.md#testing).' + ); +} + const e2eWalletAddress = privateKeyToAccount(e2ePrivateKey).address; process.env.NEXT_PUBLIC_E2E_PRIVATE_KEY = e2ePrivateKey; @@ -25,10 +33,22 @@ process.env.NEXT_PUBLIC_PROJECT_ID = projectId; process.env.NEXT_PUBLIC_E2E_ENABLED = 'true'; +const commonE2EEnv = { + NEXT_PUBLIC_E2E_ENABLED: 'true', + NEXT_PUBLIC_E2E_WALLET_ADDRESS: e2eWalletAddress, + NEXT_PUBLIC_E2E_PRIVATE_KEY: e2ePrivateKey, + NEXT_PUBLIC_PROJECT_ID: projectId +}; + export default defineConfig({ // Look for test files in the "tests" directory, relative to this configuration file. testDir: 'tests', + // Resolves/deploys the devnet ERC20 used by + // tests/bridge/erc20-approve-bridge.spec.ts before any spec file runs; a + // no-op in testnet mode. See tests/e2e/globalSetup.ts. + globalSetup: './tests/e2e/globalSetup.ts', + // Fail the build on CI if you accidentally left test.only in the source code. forbidOnly: !!process.env.CI, @@ -57,16 +77,11 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] } } ], - // Run your local dev server before starting the tests. + // Run your local dev server(s) before starting the tests. webServer: [ { command: 'pnpm run dev', - env: { - NEXT_PUBLIC_E2E_ENABLED: 'true', - NEXT_PUBLIC_E2E_WALLET_ADDRESS: e2eWalletAddress, - NEXT_PUBLIC_E2E_PRIVATE_KEY: e2ePrivateKey, - NEXT_PUBLIC_PROJECT_ID: projectId - }, + env: commonE2EEnv, url: 'http://localhost:3000', // Always restart so the dev server picks up E2E-specific public env values. reuseExistingServer: !process.env.CI diff --git a/playwright.container.config.ts b/playwright.container.config.ts new file mode 100644 index 0000000..3a17d98 --- /dev/null +++ b/playwright.container.config.ts @@ -0,0 +1,59 @@ +import { defineConfig, devices } from '@playwright/test'; + +// T-1: a SEPARATE Playwright config for tests that exercise the real built +// container artifact (agglayer-dev-ui:c1-test, produced by C-1) instead of +// `next dev`. Every other spec in this repo runs against `next dev` via +// playwright.config.ts's `webServer` array -- until this file, the static +// export produced by the Docker build had never been driven by a browser at +// all. +// +// Deliberately NOT folded into playwright.config.ts: +// - playwright.config.ts throws at module-load time unless +// E2E_PRIVATE_KEY / NEXT_PUBLIC_PROJECT_ID / NEXT_PUBLIC_AGGKIT_PROXY +// are set (see its top-level checks) -- requirements that make sense for +// a wallet-driving devnet suite, but have nothing to do with "does the +// container start and render its mounted config". This config has none +// of those requirements, so it can run in CI without any devnet secrets. +// - Playwright's top-level `webServer` array starts unconditionally for +// the whole run regardless of which `--project` is selected; reusing +// playwright.config.ts's array would mean every run of this suite also +// spins up two `next dev` servers it doesn't need. +// - Container lifecycle here is per-spec (each spec starts/stops its own +// named container against a fixture config via tests/container/docker.ts) +// rather than Playwright's own `webServer`, because `docker run -d` +// detaches and exits immediately -- it is not the kind of long-lived +// foreground process `webServer.command` expects to supervise. +// +// Specs under tests/container/ skip cleanly (not fail) when Docker is +// unavailable or the C-1 image hasn't been built locally -- see +// tests/container/docker.ts's containerTestsUnavailableReason(). No devnet +// is required at all: every fixture config.json under +// tests/container/fixtures/ uses reachable-format-but-unused RPC URLs, and +// no spec here connects a wallet or submits a transaction. +export default defineConfig({ + testDir: 'tests/container', + testMatch: [/.*\.spec\.ts$/], + + fullyParallel: false, + // Run serially: specs share a small, deliberately non-overlapping set of + // host ports and container names (see each spec file), and only one + // container needs to be up at a time. + workers: 1, + + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + + reporter: [['list'], ['html', { outputFolder: 'playwright-report-container', open: 'never' }]], + + use: { + testIdAttribute: 'data-test-id', + trace: 'on-first-retry' + }, + + projects: [ + { + name: 'container', + use: { ...devices['Desktop Chrome'] } + } + ] +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0f6285..5a3fda3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,14 +6,15 @@ settings: overrides: '@wagmi/connectors': ^5.9.9 + '@agglayer/sdk': 1.0.0-snapshot-5680d83 importers: .: dependencies: '@agglayer/sdk': - specifier: ^1.0.0-beta.29 - version: 1.0.0-beta.30(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + specifier: 1.0.0-snapshot-5680d83 + version: 1.0.0-snapshot-5680d83(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) '@reown/appkit': specifier: ^1.8.15 version: 1.8.19(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.3))(utf-8-validate@5.0.10)(zod@4.4.3) @@ -135,8 +136,8 @@ packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} - '@agglayer/sdk@1.0.0-beta.30': - resolution: {integrity: sha512-acBCorlW/OepXECKiHWAzvxVFFQLH7hiOtOMx5gxrN2iYD29ielZBfuy8YazBjCXfntb8bqR4TOEZEm5dAhg7A==} + '@agglayer/sdk@1.0.0-snapshot-5680d83': + resolution: {integrity: sha512-UCT6M41438FE4MagX/wsJehLM44JnPrNn26ak/4C+8qKMlbuJZng5NTpoGXr+mmMD7CBtjC4wFl0NjFtuVRAPA==} '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} @@ -4748,7 +4749,7 @@ snapshots: '@adraffy/ens-normalize@1.11.1': {} - '@agglayer/sdk@1.0.0-beta.30(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)': + '@agglayer/sdk@1.0.0-snapshot-5680d83(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)': dependencies: viem: 2.49.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c5c0285..fd5958d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,14 @@ minimumReleaseAgeExclude: - '@0xtrails/*' overrides: '@wagmi/connectors': ^5.9.9 + # feat/aggkit-backend: previously linked the local SDK build (branch + # feat/aggkit-bridge-client in ../sdk) instead of a published version, + # because CI needed the bridge tracker API (agglayer/sdk#28) before it was + # published. That API is now published as 1.0.0-snapshot-5680d83 (see + # package.json), so this override just re-states the same pin pnpm would + # already resolve from the dependency specifier — kept explicit so a future + # bump to package.json's version doesn't silently drift from this override. + '@agglayer/sdk': '1.0.0-snapshot-5680d83' trustPolicy: no-downgrade trustPolicyExclude: - 'undici-types@6.21.0' diff --git a/scripts/devnetReady.mjs b/scripts/devnetReady.mjs new file mode 100644 index 0000000..b8a9481 --- /dev/null +++ b/scripts/devnetReady.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node +// CI readiness gate for the vendored anvil devnet bundle (tests/devnet/, +// wired up by S13) -- and equally usable against any devnet exposing the +// same haproxy contract (e.g. a local Kurtosis `cdk` enclave with the +// bridge_ui haproxy on its default port). +// +// Usage: +// node scripts/devnetReady.mjs [--base-url http://127.0.0.1:8555] +// [--timeout-ms 120000] [--interval-ms 2000] +// +// Ports/routes are FIXED (no `kurtosis port print`, no `kurtosis enclave +// inspect` -- no Kurtosis CLI dependency at all), because this gate targets +// the CI-vendored docker-compose bundle (S13), whose haproxy is always +// published on a known host port, not a live enclave's ephemeral ports. +// scripts/kurtosisDevnetEnv.mjs remains the tool for the latter. +// +// Replicates the same three checks kurtosisDevnetEnv.mjs performs against a +// live enclave (chainId per route, bridge bytecode per chain, sync-status +// per network), upgraded to also assert `is_active` per the dev-ui contract +// table (plans/dev-ui-ci-snapshot-plan.md §1 "The contract the snapshot must +// satisfy") and preflight.spec.ts's assertSyncStatusOk -- `is_synced` alone +// is not sufficient; a network can be synced but not actively processing. +// +// Every check retries on failure until --timeout-ms elapses, because the +// services behind haproxy (aggkit, aggkit-proxy, the three anvils) can still +// be warming up for a few seconds after `docker compose up --wait` reports +// containers healthy (container healthy != aggkit fully synced). On timeout, +// prints one line per check with its last-seen failure so a human/CI log +// can tell "nothing is listening at all" apart from "L2-002 never +// synced" apart from "wrong bridge address" at a glance, then exits 1. + +import { setTimeout as sleep } from 'node:timers/promises'; + +// kurtosis-cdk's fixed network_id convention (also asserted live by +// kurtosisDevnetEnv.mjs): L1 is always network_id 0; each L2's network_id is +// Number(deployment_suffix) (-001 -> 1, -002 -> 2). +const L1_NETWORK_ID = 0; + +// Deterministic (CREATE2) bridge contract address, identical on every +// chain -- see the dev-ui contract table (plans/dev-ui-ci-snapshot-plan.md +// §1) and config.json's committed `appModes.configs.devnet.bridgeAddress`. +const BRIDGE_ADDRESS = '0xC8cbEBf950B9Df44d987c8619f092beA980fF038'; + +// One CORS-safe haproxy origin fronts every route below (contract table +// §1). `/aggkitapi` is shared by every network -- `?network_id=` on the +// request picks the network, not the host/path. +const ROUTES = [ + { path: '/l1rpc', label: 'L1', networkId: L1_NETWORK_ID, expectedChainId: 271828 }, + { path: '/l2rpc-001', label: 'L2-001', networkId: 1, expectedChainId: 20201 }, + { path: '/l2rpc-002', label: 'L2-002', networkId: 2, expectedChainId: 20202 } +]; +const AGGKIT_API_PATH = '/aggkitapi'; + +const DEFAULT_BASE_URL = 'http://127.0.0.1:8555'; +const DEFAULT_TIMEOUT_MS = 120_000; +const DEFAULT_INTERVAL_MS = 2_000; +// Per-attempt network timeout, independent of the overall retry deadline -- +// bounds how long one hung fetch can block a single retry cycle, so a +// half-open connection can't itself eat the whole --timeout-ms budget in one +// attempt. +const REQUEST_TIMEOUT_MS = 5_000; + +const parseArgs = (argv) => { + let baseUrl = DEFAULT_BASE_URL; + let timeoutMs = DEFAULT_TIMEOUT_MS; + let intervalMs = DEFAULT_INTERVAL_MS; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--base-url') { + baseUrl = argv[++i]; + } else if (arg.startsWith('--base-url=')) { + baseUrl = arg.slice('--base-url='.length); + } else if (arg === '--timeout-ms') { + timeoutMs = Number.parseInt(argv[++i], 10); + } else if (arg.startsWith('--timeout-ms=')) { + timeoutMs = Number.parseInt(arg.slice('--timeout-ms='.length), 10); + } else if (arg === '--interval-ms') { + intervalMs = Number.parseInt(argv[++i], 10); + } else if (arg.startsWith('--interval-ms=')) { + intervalMs = Number.parseInt(arg.slice('--interval-ms='.length), 10); + } else if (arg === '--help' || arg === '-h') { + process.stdout.write( + 'Usage: node scripts/devnetReady.mjs [--base-url http://127.0.0.1:8555] ' + + '[--timeout-ms 120000] [--interval-ms 2000]\n' + ); + process.exit(0); + } else { + throw new Error(`Unrecognized argument: ${arg}`); + } + } + if (!baseUrl) throw new Error('--base-url requires a value'); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('--timeout-ms must be a positive integer'); + } + if (!Number.isFinite(intervalMs) || intervalMs <= 0) { + throw new Error('--interval-ms must be a positive integer'); + } + return { baseUrl: baseUrl.replace(/\/+$/, ''), timeoutMs, intervalMs }; +}; + +/** fetch with a per-attempt timeout, so one hung request can't itself consume the whole retry budget. */ +const fetchWithTimeout = async (url, init = {}) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +}; + +const rpcCall = async (url, method, params = []) => { + const response = await fetchWithTimeout(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) + }); + if (!response.ok) { + throw new Error(`RPC call ${method} to ${url} failed: HTTP ${response.status}`); + } + const body = await response.json(); + if (body.error) { + throw new Error( + `RPC call ${method} to ${url} returned an error: ${JSON.stringify(body.error)}` + ); + } + return body.result; +}; + +/** + * Retries `attempt()` until it resolves, or `timeoutMs` elapses, whichever + * comes first. On timeout, throws the LAST error `attempt()` raised (so the + * failure message reflects the current state of the world, not the first + * transient hiccup) tagged with the check's own `label`. + */ +const retryUntilReady = async ({ label, timeoutMs, intervalMs, attempt }) => { + const deadline = Date.now() + timeoutMs; + let lastError; + // Always try at least once, even if timeoutMs is very small -- a + // deliberately tiny --timeout-ms (e.g. for a fast-fail smoke check) should + // still get one real attempt rather than failing on the clock alone. + for (;;) { + try { + await attempt(); + return; + } catch (error) { + lastError = error; + if (Date.now() >= deadline) { + throw new Error( + `not ready after ${timeoutMs}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}` + ); + } + await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now()))); + } + } +}; + +const assertChainIdMatches = async (rpcUrl, label, expectedChainId) => { + const hex = await rpcCall(rpcUrl, 'eth_chainId'); + const chainId = Number.parseInt(hex, 16); + if (chainId !== expectedChainId) { + throw new Error( + `${label} RPC ${rpcUrl} reports chainId ${chainId}, expected ${expectedChainId}` + ); + } +}; + +const assertBridgeContractDeployed = async (rpcUrl, label) => { + const code = await rpcCall(rpcUrl, 'eth_getCode', [BRIDGE_ADDRESS, 'latest']); + if (!code || code === '0x') { + throw new Error( + `No bytecode found at bridge address ${BRIDGE_ADDRESS} on ${label} RPC ${rpcUrl}` + ); + } +}; + +/** + * `sync-status?network_id=N` must report BOTH sides synced AND active -- + * `is_synced` alone (the older kurtosisDevnetEnv.mjs check) misses a network + * that caught up once but isn't actively processing. Mirrors + * tests/e2e/preflight.spec.ts's assertSyncStatusOk exactly, since that's the + * real gate this script exists to predict. + */ +const assertNetworkSynced = async (aggkitApiUrl, networkId, label) => { + const url = `${aggkitApiUrl}/bridge/v1/sync-status?network_id=${networkId}`; + const response = await fetchWithTimeout(url); + if (!response.ok) { + throw new Error( + `sync-status for ${label} (network_id=${networkId}) failed: HTTP ${response.status} at ${url}` + ); + } + const body = await response.json(); + const l1Ok = body?.l1_info?.is_synced === true && body?.l1_info?.is_active === true; + const l2Ok = body?.l2_info?.is_synced === true && body?.l2_info?.is_active === true; + if (!l1Ok || !l2Ok) { + throw new Error( + `sync-status for ${label} (network_id=${networkId}) not fully synced+active: ${JSON.stringify(body)}` + ); + } +}; + +const main = async () => { + const { baseUrl, timeoutMs, intervalMs } = parseArgs(process.argv.slice(2)); + const aggkitApiUrl = `${baseUrl}${AGGKIT_API_PATH}`; + + process.stdout.write( + `Waiting for devnet readiness at ${baseUrl} (timeout ${timeoutMs}ms, poll every ${intervalMs}ms)...\n` + ); + + const checks = []; + for (const route of ROUTES) { + const rpcUrl = `${baseUrl}${route.path}`; + checks.push({ + label: `${route.label} chainId`, + run: () => assertChainIdMatches(rpcUrl, route.label, route.expectedChainId) + }); + checks.push({ + label: `${route.label} bridge bytecode`, + run: () => assertBridgeContractDeployed(rpcUrl, route.label) + }); + } + // network_id 0 (L1) is not itself a ROUTES entry. The aggkit-proxy DOES + // read network_id -- routing per network is its whole job -- but in this + // bundle its BridgeURLs map sends BOTH 0 and 1 to the same upstream + // (aggkit-001, which now serves the bridge REST API as one of its + // components), and each aggkit instance reports its own L1+L2 status + // regardless. So this probe hits the same upstream as network_id=1 below; + // its value is confirming the aggkit backing route 1 is reachable at all, + // not that L1 has an independent syncer. + checks.push({ + label: 'sync-status network_id=0 (L1)', + run: () => assertNetworkSynced(aggkitApiUrl, 0, 'L1') + }); + for (const route of ROUTES.filter((r) => r.networkId !== L1_NETWORK_ID)) { + checks.push({ + label: `sync-status network_id=${route.networkId} (${route.label})`, + run: () => assertNetworkSynced(aggkitApiUrl, route.networkId, route.label) + }); + } + + const results = await Promise.allSettled( + checks.map(({ label, run }) => retryUntilReady({ label, timeoutMs, intervalMs, attempt: run })) + ); + + const failures = results + .map((result, index) => ({ result, label: checks[index].label })) + .filter(({ result }) => result.status === 'rejected'); + + if (failures.length > 0) { + process.stderr.write( + `\nDevnet not ready -- ${failures.length}/${checks.length} check(s) failed:\n` + ); + for (const { label, result } of failures) { + process.stderr.write(` - ${label}: ${result.reason.message}\n`); + } + process.stderr.write( + `\nIs the devnet compose bundle up (docker compose -f tests/devnet/docker-compose.yml up -d --wait)? ` + + `Is haproxy actually published on ${baseUrl}? If ports differ, pass --base-url.\n` + ); + process.exitCode = 1; + return; + } + + process.stdout.write(`\nDevnet ready: all ${checks.length} checks passed against ${baseUrl}.\n`); +}; + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/kurtosisDevnetEnv.mjs b/scripts/kurtosisDevnetEnv.mjs new file mode 100644 index 0000000..bb06fad --- /dev/null +++ b/scripts/kurtosisDevnetEnv.mjs @@ -0,0 +1,444 @@ +#!/usr/bin/env node +// Turns a running Kurtosis `cdk` enclave into a ready `.env.local` + +// `config.json` devnet config for this app, so `pnpm dev` shows the devnet +// chains and loads activity without any manual port copying. +// +// Usage: +// node scripts/kurtosisDevnetEnv.mjs [--enclave cdk] [--l2-suffixes 001,002] [--proxy-service ] +// +// What it does: +// 1. Verifies the enclave exists (fails loudly otherwise), capturing the +// `kurtosis enclave inspect` output once. +// 2. Discovers the enclave's topology from that single inspect output +// instead of hardcoding service names -- the number of +// L2s and the haproxy instance name both vary per bring-up: +// - L2 deployment suffixes: unique `aggkit--bridge` matches. +// - haproxy (browser entrypoint) service: the `agglayer-dev-ui-proxy-*` +// match. Both are overridable via `--l2-suffixes`/`--proxy-service` +// for enclaves discovery can't handle (e.g. two haproxy instances). +// 3. Resolves the haproxy port and, for L1 and every discovered L2 (via its +// `/l1rpc` / `/l2rpc-` haproxy route -- never the direct EL +// service, which has no CORS and is what the browser actually uses): +// reads `eth_chainId` (so chain ids are authoritative, not hardcoded -- +// the committed config.json previously carried a stale +// `DEVNET_L2.id: 2151908`), checks the bridge contract has code +// deployed, and verifies `sync-status?network_id=` reports both +// `is_synced` -- turning the "deployment_suffix -> networkId" naming +// convention into a verified fact per run rather than an assumption. +// 4. Writes config.json's devnet chains (DEVNET_L1 / DEVNET_L2_ for +// each discovered L2) + appModes.configs.devnet with the live values, +// deleting any stale DEVNET_L2_* (or the legacy single-L2 DEVNET_L2) key +// not written this run, then re-validates the whole file with the same +// schema/validator the app uses at startup. +// 5. Writes .env.local with NEXT_PUBLIC_AGGKIT_PROXY (the live proxy URL), +// E2E_PRIVATE_KEY, and E2E_{FROM,TO}_CHAIN_ID / E2E_L2_CHAIN_IDS, +// preserving any NEXT_PUBLIC_PROJECT_ID already present. +// +// This script only supports Kurtosis-based devnets (matches this repo's +// `cdk` enclave shape). It does not touch mainnet/testnet mode config, and it +// never writes `autoclaim` (a top-level, mode-independent config.json key -- +// a devnet script writing it would silently retune mainnet/testnet). + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { format, resolveConfig } from 'prettier'; + +import { parseConfigOrThrow } from '../config/configValidator.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const CONFIG_JSON_PATH = path.join(REPO_ROOT, 'config.json'); +const ENV_LOCAL_PATH = path.join(REPO_ROOT, '.env.local'); + +// kurtosis-cdk's fixed network_id convention: L1 is always network_id 0; each +// L2's network_id is `Number(deployment_suffix)` (`-001` -> 1, `-002` -> 2). +// This is a deployment parameter, not something re-derivable by querying a +// running service -- so it must be *verified*, not just assumed, +// which is what assertNetworkSynced does per discovered suffix below. +const L1_NETWORK_ID = 0; + +// The bridge contract is deployed at a deterministic (CREATE2) address by +// kurtosis-cdk, identical on L1 and every L2 and stable across enclave +// recreates (verified live below via eth_getCode rather than trusted +// blindly, on every chain -- not just L1). +const BRIDGE_ADDRESS = '0xC8cbEBf950B9Df44d987c8619f092beA980fF038'; + +// Funded devnet key for E2E use, on EVERY chain -- not one key per chain. +// `l2_admin` is funded 100100 ETH on both L2-1 and L2-2 by the enclave +// bring-up, and per-chain nonce spaces mean the same key on two +// different chain ids cannot collide. Distinct per-chain protocol-role keys +// are deliberately NOT used here: overriding `l2_admin`/`l2_sequencer` per +// chain breaks rollup-2 creation (RollupManager admin role +// + CREATE2 bridge address). +const E2E_PRIVATE_KEY = '0x12d7de8621a77640c9241b2595ba78ce443d05e94090365ab3bb5e19df82c625'; + +const PROXY_PORT_ID = 'http'; + +const parseArgs = (argv) => { + let enclave = 'cdk'; + let l2SuffixesRaw; + let proxyService; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--enclave') { + enclave = argv[++i]; + } else if (arg.startsWith('--enclave=')) { + enclave = arg.slice('--enclave='.length); + } else if (arg === '--l2-suffixes') { + l2SuffixesRaw = argv[++i]; + } else if (arg.startsWith('--l2-suffixes=')) { + l2SuffixesRaw = arg.slice('--l2-suffixes='.length); + } else if (arg === '--proxy-service') { + proxyService = argv[++i]; + } else if (arg.startsWith('--proxy-service=')) { + proxyService = arg.slice('--proxy-service='.length); + } + } + if (!enclave) throw new Error('--enclave requires a value'); + + const l2Suffixes = l2SuffixesRaw + ? l2SuffixesRaw + .split(',') + .map((suffix) => suffix.trim()) + .filter(Boolean) + : undefined; + + return { enclave, l2Suffixes, proxyService }; +}; + +const runKurtosis = (args) => execFileSync('kurtosis', args, { encoding: 'utf8' }).trim(); + +/** + * Verifies the enclave exists and returns the raw `kurtosis enclave inspect` + * stdout, so discovery can regex the SAME output instead of making + * separate calls per candidate service. + */ +const assertEnclaveExists = (enclave) => { + try { + return execFileSync('kurtosis', ['enclave', 'inspect', enclave], { encoding: 'utf8' }); + } catch (error) { + const detail = error.stderr || error.stdout || error.message; + throw new Error( + `Kurtosis enclave "${enclave}" was not found (or the Kurtosis engine is unreachable).\n` + + `Start it first (see the kurtosis-cdk guide docs/docs/advanced/aggkit-2l2-with-bridge-ui.md, 0xPolygon/kurtosis-cdk#929), or pass the correct name with --enclave.\n\n` + + `Underlying error:\n${detail}` + ); + } +}; + +/** Unique, sorted `aggkit--bridge` deployment suffixes. */ +const discoverL2Suffixes = (inspectOutput) => { + const suffixes = [ + ...new Set([...inspectOutput.matchAll(/\baggkit-(\d{3})-bridge\b/g)].map((match) => match[1])) + ].sort(); + if (suffixes.length === 0) { + throw new Error( + `No "aggkit--bridge" services found in \`kurtosis enclave inspect\` output.\n` + + `Is this a kurtosis-cdk aggkit bridge-UI enclave (params-aggkit-l2l2 args files)? Pass --l2-suffixes to override discovery.` + ); + } + return suffixes; +}; + +/** The haproxy browser entrypoint, discovered rather than hardcoded -- its numeric suffix depends on which bring-up run deployed bridge_ui. */ +const discoverProxyService = (inspectOutput) => { + const matches = [ + ...new Set( + [...inspectOutput.matchAll(/\bagglayer-dev-ui-proxy-\d{3}\b/g)].map((match) => match[0]) + ) + ]; + if (matches.length === 0) { + throw new Error( + 'No "agglayer-dev-ui-proxy-" haproxy service found in `kurtosis enclave inspect` output.\n' + + 'Pass --proxy-service to override discovery.' + ); + } + if (matches.length > 1) { + throw new Error( + `Multiple haproxy services found (${matches.join(', ')}); pass --proxy-service to disambiguate.` + ); + } + return matches[0]; +}; + +const normalizeUrl = (raw) => + raw.startsWith('http://') || raw.startsWith('https://') ? raw : `http://${raw}`; + +const resolvePort = (enclave, service, portId) => { + let raw; + try { + raw = runKurtosis(['port', 'print', enclave, service, portId]); + } catch (error) { + const detail = error.stderr || error.stdout || error.message; + throw new Error( + `Failed to resolve port "${portId}" on service "${service}" in enclave "${enclave}".\n` + + `Is this the "aggkit" bridge_ui_backend enclave (params-aggkit-l2l2-run1/run2.yml)? ` + + `A differently-shaped enclave will not have this service topology.\n\nUnderlying error:\n${detail}` + ); + } + return normalizeUrl(raw); +}; + +const rpcCall = async (url, method, params = []) => { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) + }); + if (!response.ok) { + throw new Error(`RPC call ${method} to ${url} failed: HTTP ${response.status}`); + } + const body = await response.json(); + if (body.error) { + throw new Error( + `RPC call ${method} to ${url} returned an error: ${JSON.stringify(body.error)}` + ); + } + return body.result; +}; + +const fetchChainId = async (rpcUrl) => { + const hex = await rpcCall(rpcUrl, 'eth_chainId'); + return Number.parseInt(hex, 16); +}; + +/** Bridge-contract-deployed check, run on every chain (not just L1) -- the CREATE2 address is deterministic and identical on every chain. */ +const assertBridgeContractDeployed = async (rpcUrl, chainLabel) => { + const code = await rpcCall(rpcUrl, 'eth_getCode', [BRIDGE_ADDRESS, 'latest']); + if (!code || code === '0x') { + throw new Error( + `No bytecode found at bridge address ${BRIDGE_ADDRESS} on ${chainLabel} RPC ${rpcUrl}. ` + + `The enclave may not be fully initialized yet, or the bridge address changed.` + ); + } +}; + +/** + * Turns "networkId = Number(deployment_suffix)" from an + * assumed convention into a verified fact per run, via the same + * `sync-status?network_id=N` probe the SDK's aggregator/preflight use. + * Fails loudly, naming the offending networkId, rather + * than silently writing a config that would 404/502 for one network. + */ +const assertNetworkSynced = async (proxyBaseUrl, networkId, chainLabel) => { + const url = `${proxyBaseUrl}/aggkitapi/bridge/v1/sync-status?network_id=${networkId}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `sync-status check for ${chainLabel} (network_id=${networkId}) failed: HTTP ${response.status} at ${url}` + ); + } + const body = await response.json(); + if (!body?.l1_info?.is_synced || !body?.l2_info?.is_synced) { + throw new Error( + `sync-status for ${chainLabel} (network_id=${networkId}) reports not fully synced: ${JSON.stringify(body)}. ` + + `Either the enclave isn't ready yet, or "networkId = Number(deployment_suffix)" doesn't hold here.` + ); + } +}; + +const upsertConfigJsonDevnet = async ({ l1RpcUrl, l1ChainId, l2Chains, aggkitBridgeApiUrl }) => { + const raw = fs.readFileSync(CONFIG_JSON_PATH, 'utf8'); + const configJson = JSON.parse(raw); + + // Delete any chains.DEVNET_* key not written this run -- + // specifically the legacy single-L2 `DEVNET_L2` key and any + // `DEVNET_L2_` from a previous run whose suffix set has since + // changed (e.g. a 3rd L2 removed) -- so a stale entry can never linger and + // be referenced by a mode config. + for (const chainKey of Object.keys(configJson.chains)) { + if (chainKey === 'DEVNET_L2' || /^DEVNET_L2_\d+$/.test(chainKey)) { + delete configJson.chains[chainKey]; + } + } + + configJson.chains.DEVNET_L1 = { + id: l1ChainId, + name: 'Devnet L1', + rpcUrl: l1RpcUrl, + // No block explorer is deployed for this enclave (bridge_ui + bridge_spammer + // only); kurtosis-cdk itself uses this same + // placeholder for "no real explorer configured" (input_parser.star). + explorerUrl: 'https://explorer.private/', + currency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + iconUrl: + 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg', + networkId: L1_NETWORK_ID, + isTestnet: true, + eta: 1 + }; + + for (const l2 of l2Chains) { + configJson.chains[l2.chainKey] = { + id: l2.chainId, + name: `Devnet L2-${l2.suffix}`, + rpcUrl: l2.rpcUrl, + explorerUrl: 'https://explorer.private/', + currency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + iconUrl: + 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg', + networkId: l2.networkId, + isTestnet: true, + eta: 1 + }; + } + + configJson.appModes.configs.devnet = { + label: 'Devnet', + bridgeAddress: BRIDGE_ADDRESS, + // One multiplexing aggkit-proxy instance fronts every network, + // distinguished by the `?network_id=` query param on each request, not by + // host -- so it is a single URL (`aggkitProxy`). Also kept in sync here as + // a fallback; NEXT_PUBLIC_AGGKIT_PROXY (written to .env.local below) is + // the value that actually takes effect at runtime per the S7 config + // design (env override wins over config.json in app/config.ts). + aggkitProxy: aggkitBridgeApiUrl, + chainKeys: ['DEVNET_L1', ...l2Chains.map((l2) => l2.chainKey)], + defaultFromChainKey: 'DEVNET_L1', + // Always the lowest discovered suffix (l2Chains is sorted ascending). + defaultToChainKey: l2Chains[0].chainKey + }; + + // Make devnet the default app mode locally so the wallet (Reown/wagmi) targets + // the enclave chains instead of the committed default (testnet -> Sepolia). The + // Reown appkit fixes its defaultNetwork from DEFAULT_APP_MODE at module load + // (app/context/wallet.tsx), so without this a wallet connect tries to add + // Sepolia. This mutation is local-only (this script is the devnet-prep tool); + // the committed config.json keeps default: 'testnet' for production (S15). + configJson.appModes.default = 'devnet'; + + // Fail loudly before writing anything if this would produce an invalid + // config.json (same schema + semantic validation the app runs at startup). + parseConfigOrThrow(configJson, { sourceName: 'config.json (kurtosisDevnetEnv.mjs preview)' }); + + // Format through the repo's own prettier config (not a bare + // JSON.stringify) so the script's output matches exactly what the + // pre-commit hook's `prettier --write` would produce. Otherwise every run + // reformats untouched objects/arrays (e.g. short `chainKeys` arrays + // collapse under prettier's printWidth but not under JSON.stringify), + // which would pollute `git diff config.json` with formatting noise beyond + // the documented local-only mutations (S9 acceptance criterion). + const prettierConfig = (await resolveConfig(CONFIG_JSON_PATH)) ?? {}; + const formatted = await format(JSON.stringify(configJson, null, 2), { + ...prettierConfig, + filepath: CONFIG_JSON_PATH + }); + + fs.writeFileSync(CONFIG_JSON_PATH, formatted); +}; + +const extractEnvValue = (envContent, key) => { + const match = envContent.match(new RegExp(`^${key}=(.*)$`, 'm')); + return match ? match[1].trim() : undefined; +}; + +const upsertEnvLocal = ({ l1ChainId, l2Chains, aggkitBridgeApiUrl }) => { + const existing = fs.existsSync(ENV_LOCAL_PATH) ? fs.readFileSync(ENV_LOCAL_PATH, 'utf8') : ''; + const existingProjectId = extractEnvValue(existing, 'NEXT_PUBLIC_PROJECT_ID'); + const projectId = + existingProjectId && existingProjectId !== 'YOUR_PROJECT_ID_HERE' + ? existingProjectId + : 'YOUR_PROJECT_ID_HERE'; + + // These three make app/constants/e2e.ts's hardcoded + // devnet fallbacks never actually decide anything against a live enclave. + // E2E_TO_CHAIN_ID / the first entry of E2E_L2_CHAIN_IDS is always the + // lowest discovered suffix (l2Chains is sorted ascending). + const e2eFromChainId = l1ChainId; + const e2eToChainId = l2Chains[0].chainId; + const e2eL2ChainIds = l2Chains.map((l2) => l2.chainId).join(','); + + const lines = [ + '# Generated by scripts/kurtosisDevnetEnv.mjs -- re-run after every enclave recreate', + '# (ports are ephemeral -- they change on every enclave recreate). Do not hand-edit the values below,', + '# they will be overwritten on the next run.', + '', + projectId === 'YOUR_PROJECT_ID_HERE' + ? '# TODO: set a real WalletConnect project id (https://cloud.reown.com) for wallet-connect features.' + : '# NEXT_PUBLIC_PROJECT_ID preserved from existing .env.local', + `NEXT_PUBLIC_PROJECT_ID=${projectId}`, + '', + '# Overrides config.json devnet.aggkitProxy with the live enclave proxy URL -- one URL,', + '# fanned out to every L2 networkId by app/config.ts; the SDK client appends /bridge/v1.', + `NEXT_PUBLIC_AGGKIT_PROXY=${aggkitBridgeApiUrl}`, + '', + '# Funded devnet key (kurtosis-cdk l2_admin key, funded on every L2) for E2E use.', + '# E2E only: Playwright reads this and injects the derived NEXT_PUBLIC_* values', + '# automatically. Never set NEXT_PUBLIC_E2E_PRIVATE_KEY directly.', + `E2E_PRIVATE_KEY=${E2E_PRIVATE_KEY}`, + `E2E_FROM_CHAIN_ID=${e2eFromChainId}`, + `E2E_TO_CHAIN_ID=${e2eToChainId}`, + `E2E_L2_CHAIN_IDS=${e2eL2ChainIds}`, + '' + ]; + + fs.writeFileSync(ENV_LOCAL_PATH, lines.join('\n')); +}; + +const main = async () => { + const { + enclave, + l2Suffixes: l2SuffixesOverride, + proxyService: proxyServiceOverride + } = parseArgs(process.argv.slice(2)); + + const inspectOutput = assertEnclaveExists(enclave); + + const l2Suffixes = l2SuffixesOverride ?? discoverL2Suffixes(inspectOutput); + const proxyServiceName = proxyServiceOverride ?? discoverProxyService(inspectOutput); + + const proxyBaseUrl = resolvePort(enclave, proxyServiceName, PROXY_PORT_ID); + // Every chain's RPC is read through its haproxy route, never a direct EL + // service port: the browser/wallet needs CORS (direct geth/op-reth ports + // have none), and reading through the SAME URL that ends up in config.json + // makes this check authoritative for what the app will actually use, + // rather than a separate direct-service probe that could pass while + // haproxy itself is misconfigured. + const l1RpcUrl = `${proxyBaseUrl}/l1rpc`; + + const l1ChainId = await fetchChainId(l1RpcUrl); + await assertBridgeContractDeployed(l1RpcUrl, 'L1'); + await assertNetworkSynced(proxyBaseUrl, L1_NETWORK_ID, 'L1'); + + const l2Chains = []; + for (const suffix of l2Suffixes) { + const networkId = Number.parseInt(suffix, 10); + const rpcUrl = `${proxyBaseUrl}/l2rpc-${suffix}`; + const chainLabel = `L2-${suffix}`; + const chainId = await fetchChainId(rpcUrl); + await assertBridgeContractDeployed(rpcUrl, chainLabel); + await assertNetworkSynced(proxyBaseUrl, networkId, chainLabel); + l2Chains.push({ suffix, networkId, chainKey: `DEVNET_L2_${suffix}`, chainId, rpcUrl }); + } + + const aggkitBridgeApiUrl = `${proxyBaseUrl}/aggkitapi`; + + await upsertConfigJsonDevnet({ l1RpcUrl, l1ChainId, l2Chains, aggkitBridgeApiUrl }); + upsertEnvLocal({ l1ChainId, l2Chains, aggkitBridgeApiUrl }); + + process.stdout.write( + [ + `Enclave: ${enclave}`, + `Proxy service (browser entrypoint): ${proxyServiceName} -> ${proxyBaseUrl}`, + ` -> NEXT_PUBLIC_AGGKIT_PROXY: ${aggkitBridgeApiUrl} (fanned out to networkIds {${l2Chains.map((l2) => l2.networkId).join(', ')}})`, + `L1 (DEVNET_L1, networkId ${L1_NETWORK_ID}): ${l1RpcUrl} (chainId ${l1ChainId}; bridge deployed; sync-status OK)`, + ...l2Chains.map( + (l2) => + `L2-${l2.suffix} (${l2.chainKey}, networkId ${l2.networkId}): ${l2.rpcUrl} (chainId ${l2.chainId}; bridge deployed; sync-status OK)` + ), + `Bridge address: ${BRIDGE_ADDRESS} (verified: bytecode present on every chain)`, + `Wrote: ${path.relative(REPO_ROOT, CONFIG_JSON_PATH)} (chains.DEVNET_L1/${l2Chains.map((l2) => l2.chainKey).join('/')}, appModes.configs.devnet)`, + `Wrote: ${path.relative(REPO_ROOT, ENV_LOCAL_PATH)}`, + '', + 'Run `pnpm dev` and open /transactions -- devnet is the default app mode.' + ].join('\n') + '\n' + ); +}; + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/stage-sdk-src.sh b/scripts/stage-sdk-src.sh new file mode 100755 index 0000000..3605b99 --- /dev/null +++ b/scripts/stage-sdk-src.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# TEMPORARY -- remove per plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §5 +# +# Populates .sdk-src/ (gitignored) with a tracked-files-only snapshot of an +# agglayer/sdk checkout, so `docker build .` can stage it into the +# `sdk-builder` stage (see Dockerfile). This is the local-dev equivalent of +# what CI does with a second `actions/checkout` of agglayer/sdk pinned to +# SDK_REF (plans/dev-ui-docker-ghcr/d2-adr-dependency-strategy.md §4.1) -- +# both payloads are `git archive` snapshots of tracked source only, so the +# local and CI Docker build inputs are byte-comparable. +# +# Deliberately NOT `cp -r ../sdk`: that would drag in node_modules/ and a +# possibly-stale gitignored dist/ into the Docker build context (see the ADR +# §2 "Rejected options" and §3). +# +# Usage: +# scripts/stage-sdk-src.sh [path-to-sdk-checkout] # defaults to ../sdk +# +# Then build normally from the repo root: +# docker build --build-arg SDK_REF="$(git -C ../sdk rev-parse HEAD)" -t agglayer-dev-ui . +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SDK_CHECKOUT="${1:-$REPO_ROOT/../sdk}" +DEST_DIR="$REPO_ROOT/.sdk-src" + +if [ ! -d "$SDK_CHECKOUT/.git" ]; then + echo "error: '$SDK_CHECKOUT' is not a git checkout (expected a sibling agglayer/sdk clone)" >&2 + echo "usage: $0 [path-to-sdk-checkout]" >&2 + exit 1 +fi + +rm -rf "$DEST_DIR" +mkdir -p "$DEST_DIR" +git -C "$SDK_CHECKOUT" archive --format=tar HEAD | tar -x -C "$DEST_DIR" + +sdk_head="$(git -C "$SDK_CHECKOUT" rev-parse HEAD)" +echo "Staged $SDK_CHECKOUT@$sdk_head (tracked files only, no node_modules/dist) -> $DEST_DIR" +echo "SDK_REF=$sdk_head" diff --git a/scripts/syncPublicConfig.mjs b/scripts/syncPublicConfig.mjs new file mode 100644 index 0000000..1a5c2f3 --- /dev/null +++ b/scripts/syncPublicConfig.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Validates the repo-root config.json, then byte-copies it to +// public/config.json (gitignored). Idempotent, safe to run repeatedly. +// +// Why this exists (see plans/dev-ui-docker-ghcr/a1-runtime-config-design.md §1): +// the app now fetches /config.json at runtime instead of importing config.json +// as a module, so it needs a copy under public/ for `next dev` to serve and for +// `next build` (output: 'export') to carry into out/. Validation runs BEFORE +// the copy so an invalid root config.json never leaves a stale-but-valid +// public/config.json standing, and never publishes an invalid one. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { loadConfigFromDiskOrThrow } from '../config/configLoaderNode.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const SOURCE_PATH = path.join(REPO_ROOT, 'config.json'); +const DESTINATION_PATH = path.join(REPO_ROOT, 'public', 'config.json'); + +/** + * @returns {{ source: string, destination: string }} + */ +export const syncPublicConfig = () => { + // allowRelative + no origin: this is a shape check, not a resolution pass. + // A relative aggkitProxy value must survive the copy verbatim so the + // browser can resolve it against its own origin at runtime (design.md §1.4, + // §5.4). This also means the copy must be a byte-for-byte fs.copyFileSync, + // never a re-serialize -- reformatting would still be semantically + // equivalent JSON, but C-2 depends on the mounted file and the repo file + // being byte-identical in format. + loadConfigFromDiskOrThrow({ configPath: SOURCE_PATH, allowRelative: true }); + + fs.mkdirSync(path.dirname(DESTINATION_PATH), { recursive: true }); + fs.copyFileSync(SOURCE_PATH, DESTINATION_PATH); + + return { source: SOURCE_PATH, destination: DESTINATION_PATH }; +}; + +const run = () => { + const { source, destination } = syncPublicConfig(); + process.stdout.write( + `Synced ${path.relative(REPO_ROOT, source)} -> ${path.relative(REPO_ROOT, destination)}\n` + ); +}; + +try { + run(); +} catch (error) { + const message = error instanceof Error ? error.message : 'Unknown sync error'; + process.stderr.write(`${message}\n`); + process.exitCode = 1; +} diff --git a/scripts/validateConfig.mjs b/scripts/validateConfig.mjs index 6d58d67..e0fc9da 100644 --- a/scripts/validateConfig.mjs +++ b/scripts/validateConfig.mjs @@ -1,23 +1,24 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -import { parseConfigOrThrow } from '../config/configValidator.mjs'; +import { loadConfigFromDiskOrThrow } from '../config/configLoaderNode.mjs'; const run = () => { - const configPath = path.resolve(process.cwd(), 'config.json'); - const fileContent = fs.readFileSync(configPath, 'utf8'); - - let configJson; - try { - configJson = JSON.parse(fileContent); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown JSON parse error'; - throw new Error(`config.json parse failed: ${message}`); - } + // Optional path argument, so the same command can vet a *candidate* config + // before it is mounted into the container -- which is what docs/docker.md + // and entrypoint.sh tell operators to do, and which the container's own + // entrypoint cannot do for them (the nginx:alpine runtime has no Node, so + // it can only run the structural `jq` check). With no argument this keeps + // its previous behaviour: validate the repo-root config.json. + // pnpm run validate:config # repo-root config.json + // pnpm run validate:config -- ./my-config.json # a candidate file + // pnpm forwards the `--` separator itself, so drop it rather than treating + // it as a filename. + const configPath = process.argv.slice(2).find((arg) => arg !== '--'); - parseConfigOrThrow(configJson, { sourceName: 'config.json' }); + // allowRelative: true -- this is a standalone shape check with no serving + // origin available, so a relative aggkitProxy value must be accepted + // as-is rather than requiring (or attempting) resolution. + loadConfigFromDiskOrThrow({ allowRelative: true, ...(configPath ? { configPath } : {}) }); - process.stdout.write('config.json validation passed\n'); + process.stdout.write(`${configPath ?? 'config.json'} validation passed\n`); }; try { diff --git a/tests/bridge/claim-autoclaim.spec.ts b/tests/bridge/claim-autoclaim.spec.ts new file mode 100644 index 0000000..7f5b46d --- /dev/null +++ b/tests/bridge/claim-autoclaim.spec.ts @@ -0,0 +1,84 @@ +import { + E2E_BACKEND_MODE, + E2E_CLAIM_TIMEOUT_MS, + E2E_FROM_CHAIN_ID, + E2E_NATIVE_BRIDGE_AMOUNT, + E2E_TO_CHAIN_ID +} from '@/app/constants/e2e'; +import { expect, test } from '@playwright/test'; + +import { BridgePage } from './models/bridge-page'; + +// BUILT-IN AUTOCLAIM: this devnet's +// aggkit image (the `feat-autoclaim-l2-lx` tag) auto-claims L1->L2 deposits +// externally -- independent of the dev-ui and independent of +// bridge-spammer-001 -- typically within ~10-90s of the deposit reaching +// READY_TO_CLAIM. +// +// L1->L2 autoclaim regression only; the manual claim path is asserted in +// manual-claim.spec.ts on the L2->L1 route, which is non-autoclaiming by +// configuration (config.json autoclaim.l2_to_l1.expectedAutoclaim: false -- +// no [[AutoClaim.Claimers]] targets NetworkID=0 on either aggkit instance) +// rather than by race. This spec asserts the one reachable, +// deterministic outcome on the L1->L2 route: the deposit progresses +// BRIDGED -> ... -> CLAIMED ("Completed") entirely on its own, without this +// test ever clicking a claim button. +// +// Devnet-only: real Sepolia/Bokuto testnet infrastructure has no such +// autoclaimer, so this is skipped rather than asserting a behavior testnet +// mode doesn't have. +test.skip( + E2E_BACKEND_MODE !== 'devnet', + 'Built-in autoclaim is a devnet-only (aggkit feat-autoclaim-l2-lx image) behavior; see the comment above.' +); + +test('L1→L2 deposit reaches Completed via built-in aggkit autoclaim (no manual claim click)', async ({ + page +}) => { + test.setTimeout(E2E_CLAIM_TIMEOUT_MS + 60_000); + + const bridgePage = new BridgePage({ page }); + + await bridgePage.navigate(); + await bridgePage.connectWallet(); + // Explicit chain-pair selection rather than relying on config.json's + // defaultFromChainKey/defaultToChainKey (E2E_* env vars are Node-only and + // never change the app's own default pair). + await bridgePage.selectChainPair(E2E_FROM_CHAIN_ID, E2E_TO_CHAIN_ID); + await bridgePage.fillAmount(E2E_NATIVE_BRIDGE_AMOUNT); + await bridgePage.submitBridge(); + await bridgePage.waitForTransactionModal(); + await bridgePage.waitForBridgeSuccess(); + + const explorerHref = await bridgePage.bridgeSuccessExplorerLink.getAttribute('href'); + const transactionHash = explorerHref?.match(/0x[a-fA-F0-9]{64}$/)?.[0]; + if (!transactionHash) { + throw new Error('E2E: could not read the bridge transaction hash from the success view'); + } + + await bridgePage.bridgeSuccessCta.click(); + + const row = bridgePage.getTransactionRow(transactionHash); + await expect(row).toBeVisible(); + + // useTransactions' own aggressive-refetch burst (app/hooks/useTransactions.ts, + // TOTAL_REFETCH_TIME) only lasts ~6.5s after submission -- far shorter than + // the observed ready-to-claimed window, so this test drives its own + // poll-and-refresh loop rather than relying on that burst or a fixed sleep. + await expect + .poll( + async () => { + await bridgePage.refreshActivity(); + return row + .getByText('Completed') + .isVisible() + .catch(() => false); + }, + { + message: 'Waiting for the built-in aggkit autoclaim to reach Completed (CLAIMED)', + timeout: E2E_CLAIM_TIMEOUT_MS, + intervals: [5_000] + } + ) + .toBe(true); +}); diff --git a/tests/bridge/console-hygiene.spec.ts b/tests/bridge/console-hygiene.spec.ts new file mode 100644 index 0000000..7fc9a27 --- /dev/null +++ b/tests/bridge/console-hygiene.spec.ts @@ -0,0 +1,277 @@ +import type { ConsoleMessage, Page, Request, Response } from '@playwright/test'; + +import { + E2E_BACKEND_MODE, + E2E_BRIDGE_SUCCESS_TIMEOUT_MS, + E2E_FROM_CHAIN_ID, + E2E_NATIVE_BRIDGE_AMOUNT, + E2E_TO_CHAIN_ID +} from '@/app/constants/e2e'; +import { expect, test } from '@playwright/test'; + +import { BridgePage } from './models/bridge-page'; + +// Regression test for the S6 interactive console-error QA triage. That triage drove the real +// AppKit UI (no E2E bypass) through the full 11-phase bridge journey and +// classified every console error/warning + failed request observed. This +// spec turns "no NEW console noise" into a property CI can enforce: it +// drives a *lighter* core journey (same devnet, same aggkit backend, +// Playwright's own E2E-bypassed wallet -- see the note below), collects +// every console error/warning and failed/error-status request for the +// whole run, and asserts nothing appears outside an explicit allowlist +// where every entry cites the triage row that classified it +// environmental/upstream. +// +// IMPORTANT caveat: +// playwright.config.ts forces NEXT_PUBLIC_E2E_ENABLED=true for every +// webServer it launches, and app/context/wallet.tsx skips +// `createAppKit(...)` entirely under that flag (IS_E2E_ENABLED), using a +// mocked LocalWalletProvider instead of the real Reown/AppKit widget. That +// means triage rows 3-11 and 16 (all Reown/AppKit/WalletConnect noise) are +// NOT actually exercised by this spec today -- their allowlist entries below +// are precautionary/documentary (they match nothing right now, but keep this +// spec correct if that E2E bypass is ever narrowed or a future change makes +// AppKit initialize during Playwright runs). What this spec DOES exercise +// live, every run, is aggkit's own polling noise (rows 1-2) and the app's +// own request traffic -- that's where a real regression would show up. +// Verifying the Reown/AppKit degradation itself (does row 3/4's +// fixed-by-degradation half of the fix actually suppress those calls +// against the real widget) requires an interactive session, not Playwright +// -- flagged for S9's full interactive re-run. +test.skip( + E2E_BACKEND_MODE !== 'devnet', + 'Console hygiene depends on devnet-specific aggkit polling behavior (rows 1-2); see the comment above.' +); + +type CapturedIssue = { + kind: 'console' | 'network'; + level: 'error' | 'warning'; + text: string; + url: string; +}; + +type AllowlistEntry = { + // Cites the triage row (or "dev-mode" for browser/Next.js-standard noise + // not tabled with its own row number) that classified this as + // environmental/upstream, not a dev-ui bug. + row: string; + note: string; + matches: (issue: CapturedIssue) => boolean; +}; + +const urlIncludes = (issue: CapturedIssue, needle: string) => issue.url.includes(needle); +const textIncludes = (issue: CapturedIssue, needle: string) => issue.text.includes(needle); + +const ALLOWLIST: AllowlistEntry[] = [ + { + row: 'triage row 1', + note: 'aggkit /l1-info-tree-index 500 ("not yet included") -- documented, intentional not-ready polling contract; SDK treats it as null, not suppressible from dev-ui/SDK (browser logs any non-2xx fetch regardless of JS handling).', + matches: (issue) => urlIncludes(issue, '/bridge/v1/l1-info-tree-index') + }, + { + row: 'triage row 2', + note: 'aggkit /injected-l1-info-leaf 404 ("GER not yet injected") -- same not-ready polling contract as row 1.', + matches: (issue) => urlIncludes(issue, '/bridge/v1/injected-l1-info-leaf') + }, + { + row: 'triage row 3', + note: "Reown/AppKit remote-config+asset fetches to api.web3modal.org (config, project-limits, getWallets, getAssetImage) with a placeholder projectId. `basic: true` degrades 2 of 4; the other 2 have no public AppKit option that gates them without disabling wallet options entirely. Currently inert under this spec's E2E bypass -- see the file-level comment.", + matches: (issue) => urlIncludes(issue, 'api.web3modal.org') + }, + { + row: 'triage row 4', + note: '"[Reown Config] Failed to fetch remote project configuration" / "Failed to fetch usage" -- AppKit\'s own console.warn wrappers around row 3\'s calls. Currently inert under this spec\'s E2E bypass.', + matches: (issue) => + textIncludes(issue, '[Reown Config] Failed to fetch remote project configuration') || + textIncludes(issue, 'Failed to fetch usage') + }, + { + row: 'triage row 5', + note: "WalletConnect identity lookup (rpc.walletconnect.org) 401 with a placeholder projectId -- fires on every account sync regardless of AppKit options, documented-unsuppressible. Currently inert under this spec's E2E bypass.", + matches: (issue) => urlIncludes(issue, 'rpc.walletconnect.org') + }, + { + row: 'triage row 6', + note: "WalletConnect analytics batch (pulse.walletconnect.org) -- AppKit's MANDATORY_EVENTS bypass features.analytics:false by design, documented-unsuppressible. Currently inert under this spec's E2E bypass.", + matches: (issue) => urlIncludes(issue, 'pulse.walletconnect.org') + }, + { + row: 'triage row 8', + note: " attribute width/height errors from @phosphor-icons/webcomponents, bundled transitively by @reown/appkit UI. Currently inert under this spec's E2E bypass.", + matches: (issue) => textIncludes(issue, ' attribute') + }, + { + row: 'triage row 9', + note: 'w3m-footer / w3m-router-container "scheduled an update" Lit diagnostic, internal to @reown/appkit-ui. Currently inert under this spec\'s E2E bypass.', + matches: (issue) => + textIncludes(issue, 'scheduled an update') && + (textIncludes(issue, 'w3m-footer') || textIncludes(issue, 'w3m-router-container')) + }, + { + row: 'triage row 10', + note: "fonts.reown.com preloaded-but-unused warnings from @reown/appkit-ui's initializeTheming. Currently inert under this spec's E2E bypass.", + matches: (issue) => urlIncludes(issue, 'fonts.reown.com') + }, + { + row: 'triage row 11', + note: '"Lit is in dev mode" -- Lit\'s own dev-mode self-check, bundled via @reown/appkit-ui, absent under a production build. Currently inert under this spec\'s E2E bypass.', + matches: (issue) => textIncludes(issue, 'Lit is in dev mode') + }, + { + row: 'dev-mode (triage row 15)', + note: "HEAD prefetch requests to the app's own routes, cancelled by a subsequent navigation -- standard Next.js router prefetch-cancellation behavior.", + matches: (issue) => issue.kind === 'network' && textIncludes(issue, 'ERR_ABORTED') + }, + { + row: 'triage row 16', + note: "Coinbase Wallet SDK analytics beacon (cca-lite.coinbase.com), bundled transitively via @reown/appkit-adapter-wagmi's default connector set. Excluding the connector is a product decision outside this audit's scope.", + matches: (issue) => urlIncludes(issue, 'cca-lite.coinbase.com') + }, + { + // Not part of the original console-noise triage -- discovered while authoring this spec, + // documented here instead since it's an artifact of the shared E2E + // devnet wallet's history, not something the S6 triage journey (a + // different, native-only wallet) ever exercised. Root-caused via a CDP + // Network.requestWillBeSent + live-DOM probe: this wallet has an older, + // already-`Completed` ERC20 bridge transaction (from a prior + // erc20-approve-bridge.spec.ts run against this same persistent + // enclave+wallet) whose origin token ("E2E", the test-only contract + // tests/e2e/globalSetup.ts deploys) has no local token-list entry and no + // hosted icon on Polygon's asset CDN. transactionListItem.tsx's + // `getTokenLogoBySymbol` fallback (app/utils/tokens.ts) then requests + // `https://assets.polygon.technology/tokenAssets/e2e.svg`, which 404s / + // gets ORB-blocked. This is a real (if minor) gap in the CDN-fallback's + // handling of unhosted symbols, but it's orthogonal to every S8 work + // item -- it existed before this spec and isn't touched by any change in + // this commit. Flagged in the S8 report for a future pass rather than + // fixed here (out of this step's chartered scope). + row: 'discovered-during-S8 (not a triage row)', + note: 'assets.polygon.technology token-icon CDN 404/ORB for the test-only "E2E" ERC20 symbol on an already-completed historical transaction in the shared E2E wallet -- pre-existing, unrelated to any S8 change.', + matches: (issue) => urlIncludes(issue, 'assets.polygon.technology/tokenAssets/') + } +]; + +const classifyIssue = (issue: CapturedIssue): AllowlistEntry | undefined => + ALLOWLIST.find((entry) => entry.matches(issue)); + +const attachCollectors = (page: Page) => { + const consoleIssues: CapturedIssue[] = []; + const networkIssues: CapturedIssue[] = []; + + const onConsole = (msg: ConsoleMessage) => { + const type = msg.type(); + if (type !== 'error' && type !== 'warning') return; + consoleIssues.push({ + kind: 'console', + level: type, + text: msg.text(), + url: msg.location().url + }); + }; + + const onResponse = (response: Response) => { + if (response.status() < 400) return; + networkIssues.push({ + kind: 'network', + level: 'error', + text: `HTTP ${response.status()} ${response.statusText()}`, + url: response.url() + }); + }; + + const onRequestFailed = (request: Request) => { + networkIssues.push({ + kind: 'network', + level: 'error', + text: request.failure()?.errorText ?? 'request failed', + url: request.url() + }); + }; + + page.on('console', onConsole); + page.on('response', onResponse); + page.on('requestfailed', onRequestFailed); + + return { + consoleIssues, + networkIssues, + detach: () => { + page.off('console', onConsole); + page.off('response', onResponse); + page.off('requestfailed', onRequestFailed); + } + }; +}; + +// Renders each unclassified issue with enough context (level, url, text) to +// triage a genuine new regression without re-running the spec. +const formatUnexpected = (issues: CapturedIssue[]): string => + issues + .map( + (issue, index) => + `${index + 1}. [${issue.kind}/${issue.level}] ${issue.text}\n ${issue.url}` + ) + .join('\n'); + +test('core journey produces no console errors/warnings outside the documented allowlist', async ({ + page +}) => { + test.setTimeout(E2E_BRIDGE_SUCCESS_TIMEOUT_MS + 60_000); + + const { consoleIssues, networkIssues, detach } = attachCollectors(page); + const bridgePage = new BridgePage({ page }); + + try { + // load -> connect -> transactions page -> open/close details modal. + // A quick native bridge guarantees a transaction row exists (rather than + // depending on whatever history the shared E2E wallet happens to already + // have on the live enclave), and its still-pending status keeps + // TrackerDetail mounted when the modal opens (transactionDetailsModal.tsx + // only mounts it while status !== 'CLAIMED') -- exercising that + // component's console behavior too, not just the row list's. + await bridgePage.navigate(); + await bridgePage.connectWallet(); + await bridgePage.selectChainPair(E2E_FROM_CHAIN_ID, E2E_TO_CHAIN_ID); + await bridgePage.fillAmount(E2E_NATIVE_BRIDGE_AMOUNT); + await bridgePage.submitBridge(); + await bridgePage.waitForTransactionModal(); + await bridgePage.waitForBridgeSuccess(); + + const explorerHref = await bridgePage.bridgeSuccessExplorerLink.getAttribute('href'); + const transactionHash = explorerHref?.match(/0x[a-fA-F0-9]{64}$/)?.[0]; + if (!transactionHash) { + throw new Error( + 'console-hygiene: could not read the bridge transaction hash from the success view' + ); + } + + await bridgePage.bridgeSuccessCta.click(); + await expect(bridgePage.getTransactionRow(transactionHash)).toBeVisible(); + + await bridgePage.openTransactionDetails(transactionHash); + await expect(bridgePage.trackerDetail).toBeVisible(); + + await bridgePage.closeTransactionDetailsModal(); + await expect(bridgePage.trackerDetail).toHaveCount(0); + } finally { + detach(); + } + + const allIssues = [...consoleIssues, ...networkIssues]; + const unexpectedErrors = allIssues.filter( + (issue) => issue.level === 'error' && !classifyIssue(issue) + ); + + // Warnings are asserted against the same allowlist rather than + // errors-only: every warning-producing source in this repo's dependency + // tree (rows 4, 9, 10, 11) is already enumerated above from the S6/S8 + // triage work, so there's no flaky/unknown warning source to carve out -- + // an unclassified warning is just as much a signal of a new regression as + // an unclassified error. + const unexpectedWarnings = allIssues.filter( + (issue) => issue.level === 'warning' && !classifyIssue(issue) + ); + + expect(unexpectedErrors, formatUnexpected(unexpectedErrors)).toEqual([]); + expect(unexpectedWarnings, formatUnexpected(unexpectedWarnings)).toEqual([]); +}); diff --git a/tests/bridge/erc20-approve-bridge.spec.ts b/tests/bridge/erc20-approve-bridge.spec.ts index 3d90685..fc1b607 100644 --- a/tests/bridge/erc20-approve-bridge.spec.ts +++ b/tests/bridge/erc20-approve-bridge.spec.ts @@ -1,6 +1,12 @@ import type { Erc20Metadata } from '@/tests/e2e/erc20Metadata'; -import { E2E_ERC20_ADDRESS, E2E_ERC20_BRIDGE_AMOUNT, E2E_FROM_CHAIN_ID } from '@/app/constants/e2e'; +import { + E2E_BRIDGE_SUCCESS_TIMEOUT_MS, + E2E_ERC20_ADDRESS, + E2E_ERC20_BRIDGE_AMOUNT, + E2E_FROM_CHAIN_ID, + E2E_TO_CHAIN_ID +} from '@/app/constants/e2e'; import { fetchErc20Metadata } from '@/tests/e2e/erc20Metadata'; import { expect, test } from '@playwright/test'; @@ -9,11 +15,24 @@ import { BridgePage } from './models/bridge-page'; let erc20: Erc20Metadata; test.beforeAll(async () => { + // In devnet mode this address is resolved/deployed by Playwright's + // globalSetup (tests/e2e/globalSetup.ts) before any spec file loads; in + // testnet mode it's the fixed Sepolia USDC address. Either way it must be + // set by the time this spec runs. + if (!E2E_ERC20_ADDRESS) { + throw new Error( + 'E2E_ERC20_ADDRESS is not set. Check tests/e2e/globalSetup.ts logs (devnet mode) or ' + + 'set E2E_ERC20_ADDRESS explicitly.' + ); + } erc20 = await fetchErc20Metadata(E2E_ERC20_ADDRESS); }); test('bridges ERC20 with approval step', async ({ page }) => { - test.setTimeout(180_000); + test.setTimeout(E2E_BRIDGE_SUCCESS_TIMEOUT_MS + 60_000); + if (!E2E_ERC20_ADDRESS) { + throw new Error('E2E_ERC20_ADDRESS is not set (see beforeAll above for how it should be set).'); + } const bridgePage = new BridgePage({ page }); await bridgePage.seedCustomToken({ @@ -26,6 +45,9 @@ test('bridges ERC20 with approval step', async ({ page }) => { await bridgePage.navigate(); await bridgePage.connectWallet(); + // Explicit chain-pair selection rather than relying on config.json's + // defaultFromChainKey/defaultToChainKey. + await bridgePage.selectChainPair(E2E_FROM_CHAIN_ID, E2E_TO_CHAIN_ID); await bridgePage.openTokenSelector(); await bridgePage.selectToken(erc20.symbol); await bridgePage.fillAmount(E2E_ERC20_BRIDGE_AMOUNT); diff --git a/tests/bridge/l2-to-l2.spec.ts b/tests/bridge/l2-to-l2.spec.ts new file mode 100644 index 0000000..dced02a --- /dev/null +++ b/tests/bridge/l2-to-l2.spec.ts @@ -0,0 +1,127 @@ +import { + E2E_BACKEND_MODE, + E2E_CLAIM_TIMEOUT_MS, + E2E_FROM_CHAIN_ID, + E2E_L2_CHAIN_IDS, + E2E_L2_TO_L2_CLAIM_TIMEOUT_MS, + E2E_NATIVE_BRIDGE_AMOUNT +} from '@/app/constants/e2e'; +import { expect, test } from '@playwright/test'; + +import { BridgePage } from './models/bridge-page'; + +// L2->L2: devnet-only, needs a second L2 (L2-2, network +// id 2) that testnet mode doesn't have -- E2E_L2_CHAIN_IDS falls back to a +// single-entry array in testnet mode (app/constants/e2e.ts), so this guard +// also protects against that array being too short. +test.skip( + E2E_BACKEND_MODE !== 'devnet' || E2E_L2_CHAIN_IDS.length < 2, + 'L2->L2 requires a second devnet L2 (E2E_L2_CHAIN_IDS); not available in testnet mode.' +); + +const [fromChainId, toChainId] = E2E_L2_CHAIN_IDS; + +test('L2-1→L2-2 native bridge reaches Completed via built-in aggkit autoclaim (no manual claim click)', async ({ + page +}) => { + // L2->L2 leg: E2E_L2_TO_L2_CLAIM_TIMEOUT_MS -- see + // app/constants/e2e.ts for the measured range this is sized against; the + // dominant term is L2-1's source-side certificate settlement, not autoclaim. + // +60s for everything else in the journey (connect, fill, submit, navigate). + // Plus one E2E_CLAIM_TIMEOUT_MS + 60s for the L1->L2-1 top-up below (the same + // budget claim-autoclaim.spec.ts and manual-claim.spec.ts use for that same + // round trip). + test.setTimeout(E2E_L2_TO_L2_CLAIM_TIMEOUT_MS + 60_000 + E2E_CLAIM_TIMEOUT_MS + 60_000); + + const bridgePage = new BridgePage({ page }); + + await bridgePage.navigate(); + await bridgePage.connectWallet(); + + // Top-up (S14 review finding): the L2-1->L2-2 native bridge below spends + // L2-1's LocalBalanceTree credit for origin-network-0 native ETH, exactly as + // manual-claim.spec.ts's L2-1->L1 withdrawal does. manual-claim was given its + // own top-up in S12 after it failed deterministically when it ran right after + // this spec; this spec was left depending on claim-autoclaim.spec.ts having + // credited the tree first, which holds only because "claim-autoclaim" sorts + // before "l2-to-l2". Running this spec alone on a fresh enclave, under a -g + // filter, under --shard, or after any rename that reorders the suite would + // revert with LocalBalanceTreeUnderflow -- surfacing as an opaque + // waitForBridgeSuccess timeout. Fund the credit here instead. + await bridgePage.fundLocalBalanceTree({ + fromChainId: E2E_FROM_CHAIN_ID, + toChainId: fromChainId, + amount: E2E_NATIVE_BRIDGE_AMOUNT, + claimTimeoutMs: E2E_CLAIM_TIMEOUT_MS + }); + + // fundLocalBalanceTree leaves the browser on the transactions route, so the + // bridge form's chain selectors aren't on the page -- navigate back before + // driving the L2->L2 leg's own chain-pair selection. + await bridgePage.navigate(); + await bridgePage.connectWallet(); + + // Non-`NEXT_PUBLIC_` env vars aren't inlined into the app bundle, so the + // app's own default chain pair (config.json's defaultFromChainKey/ + // defaultToChainKey) never reflects E2E_L2_CHAIN_IDS -- every route not + // matching that default must click both selectors explicitly. + await bridgePage.selectChainPair(fromChainId, toChainId); + await bridgePage.fillAmount(E2E_NATIVE_BRIDGE_AMOUNT); + await bridgePage.submitBridge(); + await bridgePage.waitForTransactionModal(); + await bridgePage.waitForBridgeSuccess(); + + const explorerHref = await bridgePage.bridgeSuccessExplorerLink.getAttribute('href'); + const transactionHash = explorerHref?.match(/0x[a-fA-F0-9]{64}$/)?.[0]; + if (!transactionHash) { + throw new Error('E2E: could not read the bridge transaction hash from the success view'); + } + + await bridgePage.bridgeSuccessCta.click(); + + const row = bridgePage.getTransactionRow(transactionHash); + await expect(row).toBeVisible(); + + // config.json's l2_to_l2 autoclaim grace is 5 minutes measured from first + // READY_TO_CLAIM, but the SDK's destination GER-injection gate means + // READY_TO_CLAIM now fires only after GER injection, so the real + // READY->CLAIMED distance is seconds -- the "waiting for auto claim" note + // (autoclaim-waiting-note) may only be visible for one or two poll cycles + // before autoclaim wins the race, or not at all if it's caught between + // refreshes. That sighting is therefore recorded as a best-effort + // annotation, not a hard assertion -- the one deterministic, non-flaky + // outcome this spec asserts is the final Completed state, reached without + // this test ever clicking a claim button. + let observedWaitingNote = false; + + await expect + .poll( + async () => { + await bridgePage.refreshActivity(); + if (!observedWaitingNote) { + observedWaitingNote = await row + .getByTestId('autoclaim-waiting-note') + .isVisible() + .catch(() => false); + } + return row + .getByText('Completed') + .isVisible() + .catch(() => false); + }, + { + message: 'Waiting for the built-in aggkit autoclaim to reach Completed (CLAIMED)', + timeout: E2E_L2_TO_L2_CLAIM_TIMEOUT_MS, + intervals: [5_000] + } + ) + .toBe(true); + + test.info().annotations.push({ + type: 'autoclaim-waiting-note-observed', + description: String(observedWaitingNote) + }); + + // No manual claim click anywhere above -- the L2-1->L2-2 deposit reached + // Completed entirely via built-in autoclaim. +}); diff --git a/tests/bridge/manual-claim.spec.ts b/tests/bridge/manual-claim.spec.ts new file mode 100644 index 0000000..f967930 --- /dev/null +++ b/tests/bridge/manual-claim.spec.ts @@ -0,0 +1,132 @@ +import { + E2E_BACKEND_MODE, + E2E_CLAIM_TIMEOUT_MS, + E2E_FROM_CHAIN_ID, + E2E_NATIVE_BRIDGE_AMOUNT, + E2E_PROOF_READY_TIMEOUT_MS, + E2E_TO_CHAIN_ID +} from '@/app/constants/e2e'; +import { expect, test } from '@playwright/test'; + +import { BridgePage } from './models/bridge-page'; + +// L2->L1 manual claim: config.json's +// autoclaim.l2_to_l1.expectedAutoclaim is false -- no +// `[[AutoClaim.Claimers]]` targets NetworkID=0 on either aggkit instance, +// so a deposit on this route sits READY_TO_CLAIM +// indefinitely until claimed by hand. That non-autoclaiming-by-configuration +// property (rather than a race that a browser-driven click could lose, as +// on the L1->L2 route -- see claim-autoclaim.spec.ts) is exactly what makes +// this route the one place a real "click Claim tokens" UI test can be +// written deterministically. +// +// Devnet-only: the L1<->L2 pair in claim-autoclaim.spec.ts / native-bridge +// etc. covers testnet mode; this route depends on the devnet-specific +// autoclaim config above. +test.skip( + E2E_BACKEND_MODE !== 'devnet', + 'L2->L1 non-autoclaiming behavior is devnet-specific config (config.json autoclaim.l2_to_l1); see the comment above.' +); + +test('L2-1→L1 native withdrawal requires a manual claim click to reach Completed', async ({ + page +}) => { + // Ready budget: E2E_PROOF_READY_TIMEOUT_MS (600s ← conservative idle-enclave + // measurement ~8m34s; a busier enclave sampled <=4m21s). Claim-confirm + // budget: E2E_CLAIM_TIMEOUT_MS (150s, existing devnet claim-confirmation + // budget). +60s the rest of the + // journey (60s "stays ready" observation window included in that slack). + // Plus one extra E2E_CLAIM_TIMEOUT_MS + 60s budget for the L1->L2 top-up + // deposit below (same budget claim-autoclaim.spec.ts uses for that same + // round trip). + test.setTimeout( + E2E_PROOF_READY_TIMEOUT_MS + E2E_CLAIM_TIMEOUT_MS + 60_000 + E2E_CLAIM_TIMEOUT_MS + 60_000 + ); + + const bridgePage = new BridgePage({ page }); + + await bridgePage.navigate(); + await bridgePage.connectWallet(); + + // Top-up (S12 red-run finding): the L2-1->L1 native withdrawal below spends + // L2-1's LocalBalanceTree credit for origin-network-0 native ETH -- see + // BridgePage.fundLocalBalanceTree for why that credit must be funded here and + // not inherited from whichever spec ran earlier. This spec deterministically + // failed whenever it ran right after l2-to-l2.spec.ts in the same enclave + // (their credit/debit netted to exactly zero); confirmed live via + // `cast 4byte-decode` against the eth_estimateGas revert data during S12 + // triage. l2-to-l2.spec.ts funds its own credit the same way as of S14. + await bridgePage.fundLocalBalanceTree({ + fromChainId: E2E_FROM_CHAIN_ID, + toChainId: E2E_TO_CHAIN_ID, + amount: E2E_NATIVE_BRIDGE_AMOUNT, + claimTimeoutMs: E2E_CLAIM_TIMEOUT_MS + }); + + // fundLocalBalanceTree ends on the transactions route (bridgeSuccessCta -> + // router.push(ROUTES.TRANSACTIONS)), so from-chain-selector (the bridge form, + // on '/') isn't on the page here. Navigate back before driving the withdrawal + // leg's own chain-pair selection. + await bridgePage.navigate(); + await bridgePage.connectWallet(); + + // L2-1 -> L1: the reverse of the L1->L2 default pair, so both selectors + // must be clicked explicitly. + await bridgePage.selectChainPair(E2E_TO_CHAIN_ID, E2E_FROM_CHAIN_ID); + await bridgePage.fillAmount(E2E_NATIVE_BRIDGE_AMOUNT); + await bridgePage.submitBridge(); + await bridgePage.waitForTransactionModal(); + await bridgePage.waitForBridgeSuccess(); + + const explorerHref = await bridgePage.bridgeSuccessExplorerLink.getAttribute('href'); + const transactionHash = explorerHref?.match(/0x[a-fA-F0-9]{64}$/)?.[0]; + if (!transactionHash) { + throw new Error('E2E: could not read the bridge transaction hash from the success view'); + } + + await bridgePage.bridgeSuccessCta.click(); + + const row = bridgePage.getTransactionRow(transactionHash); + await expect(row).toBeVisible(); + const status = bridgePage.getTransactionStatus(transactionHash); + + await expect + .poll( + async () => { + await bridgePage.refreshActivity(); + return status.textContent().catch(() => null); + }, + { + message: 'Waiting for the deposit to reach Ready to claim', + timeout: E2E_PROOF_READY_TIMEOUT_MS, + intervals: [5_000] + } + ) + .toContain('Ready to claim'); + + // The core assertion this route exists to make: unlike L1->L2 (which a + // built-in autoclaimer usually beats a UI click to), this deposit must NOT + // auto-claim -- it should still read Ready to claim after a full minute. + for (let elapsedMs = 0; elapsedMs < 60_000; elapsedMs += 10_000) { + await page.waitForTimeout(10_000); + await bridgePage.refreshActivity(); + await expect(status).toContainText('Ready to claim'); + } + + await bridgePage.clickClaim(transactionHash); + + await expect(page.getByRole('heading', { name: 'Claim successful' })).toBeVisible({ + timeout: E2E_CLAIM_TIMEOUT_MS + }); + // Proof the claim transaction hash is present (ClaimResultModal only + // renders this link when `claimTxHash` is set -- claimResultModal.tsx). + await expect(page.getByRole('link', { name: /view on explorer/i })).toBeVisible(); + + // exact: true -- otherwise this substring-matches both the Modal's own + // "Close modal" icon button and this CTA's "Close" text (strict-mode + // violation: two elements resolve). + await page.getByRole('button', { name: 'Close', exact: true }).click(); + + await bridgePage.refreshActivity(); + await expect(status).toContainText('Completed'); +}); diff --git a/tests/bridge/models/bridge-page.ts b/tests/bridge/models/bridge-page.ts index 3af86ae..498d329 100644 --- a/tests/bridge/models/bridge-page.ts +++ b/tests/bridge/models/bridge-page.ts @@ -1,9 +1,24 @@ import type { Token } from '@/app/types/token'; import type { Locator, Page } from '@playwright/test'; +import { E2E_BRIDGE_SUCCESS_TIMEOUT_MS } from '@/app/constants/e2e'; import { STORAGE_KEYS } from '@/app/utils/storage'; +import { loadAppConfigForNode } from '@/tests/e2e/appConfig'; import { expect } from '@playwright/test'; +// Chain names shown by the from/to selectors come from config.json (via +// BridgeFromSection/BridgeToSection's chainOptions -> chain.name), so this +// looks the display name up by chainId rather than hardcoding it here -- +// see assertChainPair below. +const getChainNameById = (chainId: number): string => { + const { allWagmiChains } = loadAppConfigForNode(); + const chain = allWagmiChains.find((candidate) => candidate.id === chainId); + if (!chain) { + throw new Error(`E2E: chain ${chainId} is not configured in config.json's chains.`); + } + return chain.name; +}; + class BridgePage { private readonly page: Page; readonly bridgeCard: Locator; @@ -19,6 +34,15 @@ class BridgePage { readonly bridgeSuccessView: Locator; readonly bridgeSuccessExplorerLink: Locator; readonly bridgeSuccessCta: Locator; + readonly transactionsRefreshButton: Locator; + readonly fromChainSelector: Locator; + readonly toChainSelector: Locator; + // Tracker UX (S6-S9 / S10 context pack): trackerDetail/closeModalButton are + // page-scoped rather than row-scoped -- the details Modal renders via + // createPortal(document.body) (modal.tsx), so it's not a DOM descendant of + // the transaction row that triggers it. + readonly trackerDetail: Locator; + readonly closeModalButton: Locator; constructor({ page }: { page: Page }) { this.page = page; @@ -35,6 +59,11 @@ class BridgePage { this.bridgeSuccessView = page.getByTestId('bridge-success-view'); this.bridgeSuccessExplorerLink = page.getByTestId('bridge-success-explorer-link'); this.bridgeSuccessCta = page.getByTestId('bridge-success-go-to-transactions'); + this.transactionsRefreshButton = page.getByTestId('transactions-refresh'); + this.fromChainSelector = page.getByTestId('from-chain-selector'); + this.toChainSelector = page.getByTestId('to-chain-selector'); + this.trackerDetail = page.getByTestId('tracker-detail'); + this.closeModalButton = page.getByRole('button', { name: 'Close modal' }); } async navigate(): Promise { @@ -63,9 +92,9 @@ class BridgePage { await this.transactionModal.waitFor(); } - async waitForBridgeSuccess(): Promise { + async waitForBridgeSuccess(timeoutMs: number = E2E_BRIDGE_SUCCESS_TIMEOUT_MS): Promise { await expect(this.transactionModalHeadline).toContainText('Transaction successful', { - timeout: 120_000 + timeout: timeoutMs }); await this.bridgeSuccessView.waitFor(); } @@ -120,6 +149,156 @@ class BridgePage { getBridgeStep(step: 'approve' | 'bridge') { return this.page.getByTestId(`bridge-step-${step}`); } + + getTransactionRow(transactionHash: string) { + return this.page.getByTestId(`transaction-row-${transactionHash}`); + } + + async refreshActivity(): Promise { + await this.transactionsRefreshButton.click(); + } + + // The destination dropdown excludes the currently-selected source + // (createChainOptions(chains, excludeChainId), bridgeCard.tsx) and + // selectFromChain auto-swaps the to-chain if you pick the current to-chain + // (useBridge.ts's selectFromChain) -- so callers must select the from-chain + // before the to-chain, which selectChainPair below enforces. + async selectFromChain(chainId: number): Promise { + await this.fromChainSelector.click(); + await this.page.getByTestId(`from-chain-selector-option-${chainId}`).click(); + } + + async selectToChain(chainId: number): Promise { + await this.toChainSelector.click(); + await this.page.getByTestId(`to-chain-selector-option-${chainId}`).click(); + } + + async assertChainPair(fromChainId: number, toChainId: number): Promise { + await expect(this.fromChainSelector).toContainText(getChainNameById(fromChainId)); + await expect(this.toChainSelector).toContainText(getChainNameById(toChainId)); + } + + async selectChainPair(fromChainId: number, toChainId: number): Promise { + await this.selectFromChain(fromChainId); + await this.selectToChain(toChainId); + await this.assertChainPair(fromChainId, toChainId); + } + + getTransactionStatus(transactionHash: string): Locator { + return this.getTransactionRow(transactionHash).getByTestId('transaction-status'); + } + + // trackerProgressBar.tsx: row-scoped -- renders nothing while all_steps is + // null and nothing for CLAIMED rows (useBridgeTracking disables its query + // there), so absence of this locator is itself meaningful (S7's chosen + // "bar disappears on CLAIMED" behavior). + getTrackerBar(transactionHash: string): Locator { + return this.getTransactionRow(transactionHash).getByTestId('tracker-progress'); + } + + getTrackerStep(transactionHash: string, index: number): Locator { + return this.getTrackerBar(transactionHash).getByTestId(`tracker-step-${index}`); + } + + // transactionDetailsModal.tsx's TrackerDetail mounts only while + // tx.status !== 'CLAIMED' -- opening the modal after a row completes will + // never show `trackerDetail`. `transaction-status` is a plain span with no + // click stopPropagation (unlike the claim/external-link buttons elsewhere + // in the row), so clicking it reliably reaches the row's own onSelect. + async openTransactionDetails(transactionHash: string): Promise { + await this.getTransactionStatus(transactionHash).click(); + } + + async closeTransactionDetailsModal(): Promise { + await this.closeModalButton.click(); + } + + getTrackerDetailStep(index: number): Locator { + return this.trackerDetail.getByTestId(`tracker-detail-step-${index}`); + } + + getClaimButton(transactionHash: string): Locator { + return this.getTransactionRow(transactionHash).getByTestId('claim-tokens-button'); + } + + getClaimManuallyNowButton(transactionHash: string): Locator { + return this.getTransactionRow(transactionHash).getByTestId('claim-manually-now-button'); + } + + async clickClaim(transactionHash: string): Promise { + await this.getClaimButton(transactionHash).click(); + } + + /** + * Bridges L1 -> L2 and waits for the deposit to autoclaim, crediting the + * destination L2's per-origin `LocalBalanceTree` by `amount`. + * + * Any spec whose subject is an L2-SOURCED transfer of a token that did not + * originate on that L2 (native ETH originates on L1, network 0) must call + * this first. `bridgeAsset` decrements `AgglayerBridgeL2`'s + * `LocalBalanceTree[originNetwork][token]` before releasing funds + * (`contracts/sovereignChains/AgglayerBridgeL2.sol` + * `_decreaseLocalBalanceTree`), and that tree is credited ONLY by a *claimed* + * inbound deposit (`_increaseLocalBalanceTree`) -- never by the L2's genesis + * native allocation, which bypasses the bridge entirely. Without a credit the + * transfer reverts `LocalBalanceTreeUnderflow(originNetwork, originToken, + * amount, available)` inside `eth_estimateGas`, which surfaces only as a + * `waitForBridgeSuccess` timeout rather than a legible error. + * + * Funding the credit here, rather than inheriting one from whichever spec + * happened to run earlier, is what makes such a spec independent of suite + * order, of sharding, of `-g` filters and of accumulated enclave state. + * + * Leaves the browser on the transactions route; callers driving a further + * chain-pair selection must `navigate()` + `connectWallet()` again. + */ + async fundLocalBalanceTree({ + fromChainId, + toChainId, + amount, + claimTimeoutMs + }: { + fromChainId: number; + toChainId: number; + amount: string; + claimTimeoutMs: number; + }): Promise { + await this.selectChainPair(fromChainId, toChainId); + await this.fillAmount(amount); + await this.submitBridge(); + await this.waitForTransactionModal(); + await this.waitForBridgeSuccess(); + + const explorerHref = await this.bridgeSuccessExplorerLink.getAttribute('href'); + const transactionHash = explorerHref?.match(/0x[a-fA-F0-9]{64}$/)?.[0]; + if (!transactionHash) { + throw new Error( + 'E2E: could not read the top-up deposit transaction hash from the success view' + ); + } + + await this.bridgeSuccessCta.click(); + + const row = this.getTransactionRow(transactionHash); + await expect(row).toBeVisible(); + + await expect + .poll( + async () => { + await this.refreshActivity(); + return row + .getByText('Completed') + .isVisible() + .catch(() => false); + }, + { + message: `Waiting for the ${fromChainId}->${toChainId} top-up deposit to reach Completed (CLAIMED)`, + timeout: claimTimeoutMs, + intervals: [5_000] + } + ) + .toBe(true); + } } export { BridgePage }; diff --git a/tests/bridge/native-bridge.spec.ts b/tests/bridge/native-bridge.spec.ts index 4b50631..2842a99 100644 --- a/tests/bridge/native-bridge.spec.ts +++ b/tests/bridge/native-bridge.spec.ts @@ -1,14 +1,23 @@ -import { E2E_NATIVE_BRIDGE_AMOUNT } from '@/app/constants/e2e'; +import { + E2E_BRIDGE_SUCCESS_TIMEOUT_MS, + E2E_FROM_CHAIN_ID, + E2E_NATIVE_BRIDGE_AMOUNT, + E2E_TO_CHAIN_ID +} from '@/app/constants/e2e'; import { expect, test } from '@playwright/test'; import { BridgePage } from './models/bridge-page'; test('bridges native token', async ({ page }) => { - test.setTimeout(180_000); + test.setTimeout(E2E_BRIDGE_SUCCESS_TIMEOUT_MS + 60_000); const bridgePage = new BridgePage({ page }); await bridgePage.navigate(); await bridgePage.connectWallet(); + // Explicit chain-pair selection rather than relying on config.json's + // defaultFromChainKey/defaultToChainKey -- keeps this spec + // independent of whatever the devnet bring-up script wrote as the default. + await bridgePage.selectChainPair(E2E_FROM_CHAIN_ID, E2E_TO_CHAIN_ID); await bridgePage.fillAmount(E2E_NATIVE_BRIDGE_AMOUNT); await bridgePage.submitBridge(); await bridgePage.waitForTransactionModal(); diff --git a/tests/bridge/partial-failure.spec.ts b/tests/bridge/partial-failure.spec.ts new file mode 100644 index 0000000..ca168e6 --- /dev/null +++ b/tests/bridge/partial-failure.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from '@playwright/test'; + +import { BridgePage } from './models/bridge-page'; + +// RESTORED (D0d, config surface cleanup follow-up): this spec used to run +// under a dedicated "partial-failure" Playwright project (see git history) +// that booted its own Next dev server on a separate port with an extra +// bogus network (999, unresolvable) injected into the retired +// NEXT_PUBLIC_AGGKIT_BRIDGE_APIS per-network JSON-map override -- that env +// var's per-network map was the only mechanism able to point one specific +// network at a bad URL while leaving the rest of the mode alone. +// +// NEXT_PUBLIC_AGGKIT_BRIDGE_APIS (and the per-network aggkitBridgeApis config +// surface it overrode) has been removed: every mode now goes through a single +// aggkitProxy, and NEXT_PUBLIC_AGGKIT_PROXY's fan-out applies the SAME value +// to every non-L1 network in the mode by construction, so there is no config +// knob left to make just one network fail without a real (or mock) backend +// that itself behaves differently per `?network_id=`. +// +// The devnet's aggkit proxy IS that kind of backend from the browser's point +// of view: every AggkitBridgeAggregator call is a plain browser `fetch()` to +// the SAME base URL (config.json's devnet aggkitProxy, +// http://127.0.0.1:8555/aggkitapi) with a `network_id` query parameter +// distinguishing networks (@agglayer/sdk's AggkitApiClient#getBridges / +// #getClaims). Playwright's page.route can intercept that fetch and fail it +// for exactly one real, configured network (DEVNET_L2_002, networkId 2) +// while every other network's requests -- including L2_002's own /claims +// call D, which targets network_id=0 -- pass through untouched, because +// fetchNetworkFanout's Promise.all rejects the WHOLE per-network fan-out the +// moment any one of its four legs rejects (call A's /bridges?network_id=2 is +// enough). This reproduces the exact contract the old fixture exercised -- +// one network's fan-out fails, the rest of the mode still resolves -- without +// needing a fake/unresolvable network id. +// +// One deliberate coverage difference from the old fixture: network 2 is a +// REAL configured chain (Devnet L2-002), not an unregistered one, so the +// notice names it by its real display name rather than falling back to +// transactionsView.tsx's `getChainByNetworkId(...) ?? 'Unknown network'` +// branch. That one-line fallback has no dedicated coverage after this +// change; it is trivial enough (an inline default for a display name) that +// this is judged an acceptable, explicit gap rather than a reason to block +// restoring the rest of this spec's coverage. +test('activity page surfaces a partial-failure notice for the unreachable network while the healthy network still renders', async ({ + page +}) => { + test.setTimeout(60_000); + + // Fail every /bridges call for networkId 2 (Devnet L2-002) so that + // network's fan-out (fetchNetworkFanout) rejects, while networkId 1 + // (Devnet L2-001) and L1-scoped calls (network_id=0) are left alone. + // Matching on the exact `network_id` query value (not `network_ids`, + // the plural list param used by the L1-origin-bridges leg) keeps this + // from also intercepting network 1's or L1's own traffic against the + // same proxy URL. + await page.route( + (url) => url.pathname.endsWith('/bridges') && url.searchParams.get('network_id') === '2', + (route) => + route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'partial-failure fixture: network 2 unavailable' }) + }) + ); + + const bridgePage = new BridgePage({ page }); + + await page.goto('/transactions'); + await bridgePage.connectWallet(); + + // Network 2's client exhausts its retries before the aggregator gives + // up on it (the partial fan-out failure contract) -- allow + // enough time for that backoff plus the warning banner to render. + await expect(page.getByText(/some networks are temporarily unavailable/i)).toBeVisible({ + timeout: 45_000 + }); + // Scoped to the banner's own message text (not a bare /devnet l2-002/i + // search): an earlier spec may have already bridged funds to/from + // Devnet L2-002, in which case a transaction row elsewhere on the page + // also renders that chain name and a loose match hits both, failing + // Playwright's strict-mode single-element requirement. + await expect(page.getByText(/couldn't load activity from.*devnet l2-002/i)).toBeVisible(); + + // Network 1's query must still RESOLVE despite network 2 failing -- that is + // the partial-failure contract: one bad network degrades to a notice instead + // of rejecting the whole fan-out and leaving the list stuck loading. + // + // S12 asserted this via the "Total transactions:" summary line, but that line + // is gated on `totalCount > 0` (transactionsView.tsx) over a fan-out filtered + // to the shared E2E wallet -- so it silently required some earlier spec to + // have bridged first, and this spec failed on a fresh enclave or when run as + // the only project. transactionList.tsx renders exactly one of three + // branches: a loading spinner, the "No transactions found" empty state, or the + // populated list. Accepting either settled branch asserts "resolved, not + // hanging" without depending on accumulated history. + await expect( + page.getByText(/total transactions:/i).or(page.getByText(/no transactions found/i)) + ).toBeVisible(); +}); diff --git a/tests/bridge/smoke.spec.ts b/tests/bridge/smoke.spec.ts index 44e15d9..8384e9d 100644 --- a/tests/bridge/smoke.spec.ts +++ b/tests/bridge/smoke.spec.ts @@ -1,4 +1,4 @@ -import { E2E_WALLET_ADDRESS } from '@/app/constants/e2e'; +import { E2E_FROM_CHAIN_ID, E2E_TO_CHAIN_ID, E2E_WALLET_ADDRESS } from '@/app/constants/e2e'; import { shortenAddress } from '@/app/utils/address'; import { expect, test } from '@playwright/test'; @@ -19,4 +19,9 @@ test('connects wallet and displays the correct address', async ({ page }) => { await bridgePage.connectWallet(); await expect(bridgePage.walletConnectedBadge).toContainText(shortenAddress(E2E_WALLET_ADDRESS!)); + + // Explicit chain-pair selection rather than relying on config.json's + // defaultFromChainKey/defaultToChainKey -- also smoke-tests + // the chain selectors themselves. + await bridgePage.selectChainPair(E2E_FROM_CHAIN_ID, E2E_TO_CHAIN_ID); }); diff --git a/tests/bridge/token-selector.spec.ts b/tests/bridge/token-selector.spec.ts index 0f96c0a..3fde42d 100644 --- a/tests/bridge/token-selector.spec.ts +++ b/tests/bridge/token-selector.spec.ts @@ -1,30 +1,32 @@ import type { Token } from '@/app/types/token'; -import { E2E_PRIVATE_KEY } from '@/app/constants/e2e'; +import { E2E_FROM_CHAIN_ID, E2E_PRIVATE_KEY, E2E_TO_CHAIN_ID } from '@/app/constants/e2e'; import { formatTokenBalance } from '@/app/utils/tokens'; -import { getE2EFromChainRpcUrl } from '@/tests/e2e/testnetRpc'; +import { getE2EFromChain, getE2EFromChainRpcUrl } from '@/tests/e2e/chainRpc'; import { expect, test } from '@playwright/test'; import { createPublicClient, http } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; -import { sepolia } from 'viem/chains'; import { BridgePage } from './models/bridge-page'; const createClient = () => createPublicClient({ - chain: sepolia, + chain: getE2EFromChain(), transport: http(getE2EFromChainRpcUrl()) }); -const buildNativeToken = (): Token => ({ - chainId: sepolia.id, - address: '0x0000000000000000000000000000000000000000', - decimals: 18, - symbol: sepolia.nativeCurrency.symbol, - name: sepolia.nativeCurrency.name, - logoURI: '', - isNative: true -}); +const buildNativeToken = (): Token => { + const chain = getE2EFromChain(); + return { + chainId: chain.id, + address: '0x0000000000000000000000000000000000000000', + decimals: chain.nativeCurrency.decimals, + symbol: chain.nativeCurrency.symbol, + name: chain.nativeCurrency.name, + logoURI: '', + isNative: true + }; +}; test('token selector shows token symbol and native balance when wallet is connected', async ({ page @@ -32,19 +34,23 @@ test('token selector shows token symbol and native balance when wallet is connec const bridgePage = new BridgePage({ page }); const account = privateKeyToAccount(E2E_PRIVATE_KEY!); const client = createClient(); + const chain = getE2EFromChain(); await bridgePage.navigate(); await bridgePage.connectWallet(); + // Explicit chain-pair selection rather than relying on config.json's + // defaultFromChainKey/defaultToChainKey. + await bridgePage.selectChainPair(E2E_FROM_CHAIN_ID, E2E_TO_CHAIN_ID); await bridgePage.openTokenSelector(); const rawBalance = await client.getBalance({ address: account.address }); const expectedBalance = formatTokenBalance(buildNativeToken(), rawBalance); - const tokenRow = bridgePage.getTokenRow(sepolia.nativeCurrency.symbol); + const tokenRow = bridgePage.getTokenRow(chain.nativeCurrency.symbol); await expect(tokenRow).toBeVisible(); - await expect(tokenRow).toContainText(sepolia.nativeCurrency.symbol); + await expect(tokenRow).toContainText(chain.nativeCurrency.symbol); - const balance = bridgePage.getTokenBalance(sepolia.nativeCurrency.symbol); + const balance = bridgePage.getTokenBalance(chain.nativeCurrency.symbol); await expect(balance).toBeVisible(); await expect(balance).toHaveText(expectedBalance); }); diff --git a/tests/bridge/tracker.spec.ts b/tests/bridge/tracker.spec.ts new file mode 100644 index 0000000..c31f51a --- /dev/null +++ b/tests/bridge/tracker.spec.ts @@ -0,0 +1,336 @@ +import { + E2E_BACKEND_MODE, + E2E_CLAIM_TIMEOUT_MS, + E2E_FROM_CHAIN_ID, + E2E_L2_CHAIN_IDS, + E2E_L2_TO_L2_CLAIM_TIMEOUT_MS, + E2E_NATIVE_BRIDGE_AMOUNT, + E2E_TO_CHAIN_ID +} from '@/app/constants/e2e'; +import { expect, test } from '@playwright/test'; + +import { BridgePage } from './models/bridge-page'; + +// Bridge tracker UX (S6-S9 landed feature): the +// useBridgeTracking hook polls aggkit's tracker API and +// trackerProgressBar.tsx / trackerDetail.tsx render its `all_steps`. Like +// the other route-specific specs in this directory, this depends on +// devnet-specific infrastructure -- the tracker component +// (`aggkit-proxy-001 --components=proxy,tracker`) that +// testnet mode doesn't run, and the fixed step counts/latencies below are +// devnet fixtures (see app/__fixtures__/tracker.ts). +test.skip( + E2E_BACKEND_MODE !== 'devnet', + 'Bridge tracker UX (aggkit tracker polling) is devnet-specific; see the comment above.' +); + +// step_name order per route -- captured from the live enclave fixtures +// (app/__fixtures__/tracker.ts), which ship the full ordered `all_steps` +// array (later entries `pending`) as soon as the tracker resolves the route, +// well before any step actually starts. +const L1_TO_L2_STEP_NAMES = ['WaitingGERUpdate', 'WaitingGERInjection', 'WaitingClaim', 'Claimed']; +const L2_TO_L2_STEP_NAMES = [ + 'WaitingLERUpdate', + 'PendingInclusion', + 'CertificatePending', + 'WaitL1SettledGER', + 'WaitingGERInjection', + 'WaitingClaim', + 'Claimed' +]; + +// Human-readable label text per step_name (getTrackerStepLabel, +// app/utils/trackerSteps.ts), in the L2->L2 step order above. Loose on the +// interpolated chain name (`.+`) so these don't depend on config.json's +// exact devnet chain display name (regenerated per enclave bring-up by +// scripts/kurtosisDevnetEnv.mjs). +const L2_TO_L2_STEP_LABEL_PATTERNS = [ + /Waiting for the local exit root update on .+/, + /Waiting for inclusion in an agglayer certificate/, + /Waiting for the certificate to settle/, + /Waiting for settlement to confirm on L1/, + /Waiting for the exit root to reach .+/, + /Finalizing claim data for .+/, + /Claimed/ +]; + +test('L1→L2 tracker progress bar shows 4 steps, advances, then disappears on Completed', async ({ + page +}) => { + // Merged bar-appear + at-least-one-done-dot poll, then a final wait for + // Completed -- both bounded by E2E_CLAIM_TIMEOUT_MS (the same devnet + // send->claimed budget claim-autoclaim.spec.ts uses for this exact route). + // +90s for everything else in the journey (connect, fill, submit, + // navigate, the modal-free assertions in between). + test.setTimeout(2 * E2E_CLAIM_TIMEOUT_MS + 90_000); + + const bridgePage = new BridgePage({ page }); + + await bridgePage.navigate(); + await bridgePage.connectWallet(); + await bridgePage.selectChainPair(E2E_FROM_CHAIN_ID, E2E_TO_CHAIN_ID); + await bridgePage.fillAmount(E2E_NATIVE_BRIDGE_AMOUNT); + await bridgePage.submitBridge(); + await bridgePage.waitForTransactionModal(); + await bridgePage.waitForBridgeSuccess(); + + const explorerHref = await bridgePage.bridgeSuccessExplorerLink.getAttribute('href'); + const transactionHash = explorerHref?.match(/0x[a-fA-F0-9]{64}$/)?.[0]; + if (!transactionHash) { + throw new Error('E2E: could not read the bridge transaction hash from the success view'); + } + + await bridgePage.bridgeSuccessCta.click(); + + const row = bridgePage.getTransactionRow(transactionHash); + await expect(row).toBeVisible(); + + const trackerBar = bridgePage.getTrackerBar(transactionHash); + + // Single poll loop covering both "the bar renders with its 4 dots" and + // "at least one dot reaches done" -- the tracker needs its own poll cycle + // to resolve `all_steps` (registered -> running), so the bar may not exist + // for the first refresh or two. Per-step mid-flight status strings are + // recorded as annotations rather than asserted: L1->L2 can autoclaim in + // ~35s against this hook's own 5s poll interval (useBridgeTracking.ts), and + // per S7 the bar disappears entirely the instant the row reaches CLAIMED -- + // a fast-enough autoclaim can beat this poll to ever observing a 'done' + // dot, which is recorded rather than failed. + const capturedStepNames: string[] = []; + const observedStatusSnapshots: string[] = []; + let sawDoneDot = false; + let completedBeforeDoneDot = false; + + await expect + .poll( + async () => { + await bridgePage.refreshActivity(); + + const barVisible = await trackerBar.isVisible().catch(() => false); + if (!barVisible) { + completedBeforeDoneDot = await row + .getByText('Completed') + .isVisible() + .catch(() => false); + return completedBeforeDoneDot; + } + + const dots = await Promise.all( + L1_TO_L2_STEP_NAMES.map((_, index) => { + const dot = bridgePage.getTrackerStep(transactionHash, index); + return Promise.all([dot.getAttribute('data-step'), dot.getAttribute('data-status')]); + }) + ); + if (capturedStepNames.length === 0) { + capturedStepNames.push(...dots.map(([step]) => step ?? '')); + } + const statuses = dots.map(([, status]) => status ?? ''); + observedStatusSnapshots.push(statuses.join(',')); + if (statuses.some((status) => status === 'done')) { + sawDoneDot = true; + return true; + } + return false; + }, + { + message: "Waiting for the tracker bar to render and at least one dot to reach 'done'", + timeout: E2E_CLAIM_TIMEOUT_MS, + intervals: [5_000] + } + ) + .toBe(true); + + // Route shape: exactly 4 dots, in the fixed step_name order the tracker + // resolves them in (this is the acceptance-criterion assertion, not a + // timing-sensitive one -- the full ordered array is present from the + // first non-null `all_steps` response, see the comment above). + expect(capturedStepNames).toEqual(L1_TO_L2_STEP_NAMES); + await expect(bridgePage.getTrackerStep(transactionHash, L1_TO_L2_STEP_NAMES.length)).toHaveCount( + 0 + ); + + test.info().annotations.push({ + type: 'tracker-step-status-snapshots', + description: + observedStatusSnapshots.join(' | ') || '(row reached Completed before any snapshot)' + }); + test.info().annotations.push({ + type: 'tracker-completed-before-done-dot-observed', + description: String(completedBeforeDoneDot) + }); + expect(sawDoneDot || completedBeforeDoneDot).toBe(true); + + // Terminal assertion (S7's chosen behavior): once the row reaches + // Completed, useBridgeTracking's query is disabled (status === 'CLAIMED') + // and trackerProgressBar.tsx renders nothing -- the bar must be gone, not + // just stale. (No-op wait if completedBeforeDoneDot already made this + // true above.) + await expect + .poll( + async () => { + await bridgePage.refreshActivity(); + return row + .getByText('Completed') + .isVisible() + .catch(() => false); + }, + { + message: 'Waiting for the row to reach Completed (CLAIMED)', + timeout: E2E_CLAIM_TIMEOUT_MS, + intervals: [5_000] + } + ) + .toBe(true); + + await expect(trackerBar).toHaveCount(0); +}); + +test('L2-1→L2-2 tracker: 7-step bar, modal detail mid-flight, disappears on Completed', async ({ + page +}) => { + // L2->L2 requires a second devnet L2 -- same guard l2-to-l2.spec.ts uses. + test.skip( + E2E_BACKEND_MODE !== 'devnet' || E2E_L2_CHAIN_IDS.length < 2, + 'L2->L2 requires a second devnet L2 (E2E_L2_CHAIN_IDS); not available in testnet mode.' + ); + + // Same budget composition as l2-to-l2.spec.ts: E2E_L2_TO_L2_CLAIM_TIMEOUT_MS + // for the tracked L2->L2 send->claimed leg (this route's long window is + // exactly why the mid-flight modal assertion below lives on this test + // rather than the fast L1->L2 one), plus one E2E_CLAIM_TIMEOUT_MS round + // trip for the L1->L2-1 top-up, plus 120s slack for the rest of the + // journey (connect, fill, submit, navigate, modal open/close). + test.setTimeout(E2E_L2_TO_L2_CLAIM_TIMEOUT_MS + E2E_CLAIM_TIMEOUT_MS + 120_000); + + const [fromChainId, toChainId] = E2E_L2_CHAIN_IDS; + + const bridgePage = new BridgePage({ page }); + + await bridgePage.navigate(); + await bridgePage.connectWallet(); + + // Top-up: the L2-1->L2-2 native bridge below spends L2-1's + // LocalBalanceTree credit for origin-network-0 native ETH -- same + // dependency l2-to-l2.spec.ts documents and funds for itself (its own + // BridgePage.fundLocalBalanceTree doc comment has the full writeup). Fund + // it here too so this spec doesn't depend on suite order. + await bridgePage.fundLocalBalanceTree({ + fromChainId: E2E_FROM_CHAIN_ID, + toChainId: fromChainId, + amount: E2E_NATIVE_BRIDGE_AMOUNT, + claimTimeoutMs: E2E_CLAIM_TIMEOUT_MS + }); + + // fundLocalBalanceTree leaves the browser on the transactions route. + await bridgePage.navigate(); + await bridgePage.connectWallet(); + + await bridgePage.selectChainPair(fromChainId, toChainId); + await bridgePage.fillAmount(E2E_NATIVE_BRIDGE_AMOUNT); + await bridgePage.submitBridge(); + await bridgePage.waitForTransactionModal(); + await bridgePage.waitForBridgeSuccess(); + + const explorerHref = await bridgePage.bridgeSuccessExplorerLink.getAttribute('href'); + const transactionHash = explorerHref?.match(/0x[a-fA-F0-9]{64}$/)?.[0]; + if (!transactionHash) { + throw new Error('E2E: could not read the bridge transaction hash from the success view'); + } + + await bridgePage.bridgeSuccessCta.click(); + + const row = bridgePage.getTransactionRow(transactionHash); + await expect(row).toBeVisible(); + + const trackerBar = bridgePage.getTrackerBar(transactionHash); + + // Wait for the bar to resolve its 7 dots, capturing the step order on + // first sighting -- see the L1->L2 test above for why this needs its own + // poll rather than a single refreshActivity(). + const capturedStepNames: string[] = []; + + await expect + .poll( + async () => { + await bridgePage.refreshActivity(); + if (!(await trackerBar.isVisible().catch(() => false))) return false; + + const stepNames = await Promise.all( + L2_TO_L2_STEP_NAMES.map((_, index) => + bridgePage.getTrackerStep(transactionHash, index).getAttribute('data-step') + ) + ); + capturedStepNames.length = 0; + capturedStepNames.push(...stepNames.map((name) => name ?? '')); + return true; + }, + { + message: 'Waiting for the tracker to resolve the L2->L2 route and render its 7 dots', + // This route's dominant latency term (source-side certificate + // settlement, see E2E_L2_TO_L2_CLAIM_TIMEOUT_MS's comment in + // app/constants/e2e.ts) is downstream of route resolution, so + // resolution itself should land well inside this budget. + timeout: E2E_L2_TO_L2_CLAIM_TIMEOUT_MS, + intervals: [5_000] + } + ) + .toBe(true); + + expect(capturedStepNames).toEqual(L2_TO_L2_STEP_NAMES); + await expect(bridgePage.getTrackerStep(transactionHash, L2_TO_L2_STEP_NAMES.length)).toHaveCount( + 0 + ); + + // Mid-flight modal detail check: transactionDetailsModal.tsx only mounts + // TrackerDetail while tx.status !== 'CLAIMED', so this must happen now, + // while the bar above is still visible -- L2->L2's multi-minute window + // (vs. L1->L2's ~35-67s) is exactly what makes this reliable here rather + // than on the fast route. TrackerDetail shares its react-query cache key + // with the row's own poll (trackerDetail.tsx's doc comment), so `data` is + // already warm from the bar above -- no extra wait needed for it to + // populate. + await bridgePage.openTransactionDetails(transactionHash); + await expect(bridgePage.trackerDetail).toBeVisible(); + + for (const [index, pattern] of L2_TO_L2_STEP_LABEL_PATTERNS.entries()) { + await expect(bridgePage.getTrackerDetailStep(index)).toContainText(pattern); + } + await expect(bridgePage.getTrackerDetailStep(L2_TO_L2_STEP_LABEL_PATTERNS.length)).toHaveCount(0); + + // Close before resuming refreshActivity()-driven polling below -- the + // modal's full-viewport overlay would otherwise intercept that click. + await bridgePage.closeTransactionDetailsModal(); + await expect(bridgePage.trackerDetail).toHaveCount(0); + + // Record whatever step is in progress at this point as an annotation + // (race-prone -- not asserted) rather than silently discarding it. + const midFlightStatuses = await Promise.all( + L2_TO_L2_STEP_NAMES.map((_, index) => + bridgePage.getTrackerStep(transactionHash, index).getAttribute('data-status') + ) + ); + test.info().annotations.push({ + type: 'tracker-mid-flight-statuses-at-modal-check', + description: midFlightStatuses.join(',') + }); + + await expect + .poll( + async () => { + await bridgePage.refreshActivity(); + return row + .getByText('Completed') + .isVisible() + .catch(() => false); + }, + { + message: 'Waiting for the L2-1->L2-2 deposit to reach Completed (CLAIMED)', + timeout: E2E_L2_TO_L2_CLAIM_TIMEOUT_MS, + intervals: [5_000] + } + ) + .toBe(true); + + // Terminal assertion (S7's chosen behavior): bar disappears on CLAIMED. + await expect(trackerBar).toHaveCount(0); +}); diff --git a/tests/container/container-app.spec.ts b/tests/container/container-app.spec.ts new file mode 100644 index 0000000..40c0c26 --- /dev/null +++ b/tests/container/container-app.spec.ts @@ -0,0 +1,240 @@ +import type { ConsoleMessage, Page, Request, Response } from '@playwright/test'; + +import path from 'node:path'; + +import { expect, test } from '@playwright/test'; + +import { + containerTestsUnavailableReason, + getContainerImageDigest, + getImageDigest, + removeContainer, + runContainer, + waitForHttpResponse +} from './docker'; + +// T-1: exercises the REAL agglayer-dev-ui:c1-test image (built by C-1) via a +// real Chromium session, rather than `next dev`. This is the first time the +// static export produced by the Docker build has been driven by a browser at +// all -- see plans/dev-ui-docker-ghcr/c2-runtime-config-proof.md §4, whose +// browser-proof technique (drive the running container with Playwright, +// gate on AppConfigGate's test-ids, read the chain-selector text) this file +// turns into a permanent, repeatable spec instead of a throwaway script. +test.skip( + () => Boolean(containerTestsUnavailableReason()), + containerTestsUnavailableReason() ?? '' +); + +const HOST_PORT = 19180; +const CONTAINER_NAME = 't1-container-app'; +const BASE_URL = `http://127.0.0.1:${HOST_PORT}`; +const CONFIG_A_PATH = path.resolve(__dirname, 'fixtures', 'config-a.json'); + +// config-a.json's devnet mode -- see tests/container/fixtures/config-a.json. +// Hardcoded rather than read via tests/e2e/appConfig.ts's loadAppConfigForNode +// deliberately: that helper reads the REPO ROOT config.json, not this +// directory's fixture, and asserting against the fixture's own known values +// is what actually proves the mounted file (not some other config) drove the +// render. +const CONFIG_A_FROM_CHAIN_NAME = 'Devnet L1'; +const CONFIG_A_TO_CHAIN_NAME = 'Devnet L2-001'; + +type CapturedIssue = { + kind: 'console' | 'network'; + level: 'error' | 'warning'; + text: string; + url: string; +}; + +type AllowlistEntry = { + // Cites the triage row from tests/bridge/console-hygiene.spec.ts's own + // ALLOWLIST that classified this noise as environmental/upstream -- + // reusing that spec's allowlist idiom rather than inventing a new + // classification scheme. + row: string; + note: string; + matches: (issue: CapturedIssue) => boolean; +}; + +const urlIncludes = (issue: CapturedIssue, needle: string) => issue.url.includes(needle); +const textIncludes = (issue: CapturedIssue, needle: string) => issue.text.includes(needle); + +// Unlike tests/bridge/console-hygiene.spec.ts (which runs under +// playwright.config.ts's NEXT_PUBLIC_E2E_ENABLED=true bypass, so +// app/context/wallet.tsx never calls the real createAppKit()), this +// container was built by `pnpm run build:production` with no E2E flag -- +// the exact same build a real deployment ships. So rows 3/4/10/16, which +// console-hygiene.spec.ts documents as "currently inert" precaution-only +// entries, are LIVE here: this spec is the first one in the repo that +// actually observes AppKit's real degraded-mode (`basic: true`, placeholder +// NEXT_PUBLIC_PROJECT_ID -- see Dockerfile's build:production comment and +// a1-runtime-config-design.md §6.3) network chatter. Empirically captured by +// running this exact container+fixture combination locally (see this step's +// feedback pack) rather than guessed. +const ALLOWLIST: AllowlistEntry[] = [ + { + row: 'triage row 3 (console-hygiene.spec.ts)', + note: "Reown/AppKit remote-config fetch to api.web3modal.org with the image's baked placeholder projectId (YOUR_PROJECT_ID_HERE) -- 403 is expected for a placeholder id. LIVE in this spec (not inert), because this container's build has no E2E bypass.", + matches: (issue) => urlIncludes(issue, 'api.web3modal.org') + }, + { + row: 'triage row 4 (console-hygiene.spec.ts)', + note: '"Failed to fetch usage" -- AppKit\'s own console.warn wrapper around row 3\'s 403. LIVE in this spec.', + matches: (issue) => textIncludes(issue, 'Failed to fetch usage') + }, + { + row: 'triage row 10 (console-hygiene.spec.ts)', + note: "fonts.reown.com preloaded-but-unused warning from @reown/appkit-ui's initializeTheming. The browser reports this as a bare console.warn (its location is the page document, not the font URL), so this matches on message text rather than issue.url. LIVE in this spec.", + matches: (issue) => textIncludes(issue, 'fonts.reown.com') + }, + { + row: 'triage row 15 / dev-mode (console-hygiene.spec.ts)', + note: "HEAD/navigation prefetch requests to the app's own routes (e.g. /transactions), cancelled by Next.js router prefetch-cancellation -- standard behavior, not specific to E2E bypass.", + matches: (issue) => + issue.kind === 'network' && textIncludes(issue, 'ERR_ABORTED') && urlIncludes(issue, BASE_URL) + }, + { + row: 'triage row 16 (console-hygiene.spec.ts)', + note: "Coinbase Wallet SDK analytics beacon (cca-lite.coinbase.com), bundled transitively via @reown/appkit-adapter-wagmi's default connector set. LIVE in this spec.", + matches: (issue) => urlIncludes(issue, 'cca-lite.coinbase.com') + }, + { + row: 'X-1: transport-level failure reaching a NON-app origin', + note: + 'Any endpoint this page dials that is not the container itself is an environment ' + + 'property, not an app defect: the mounted fixture config points rpcUrl at the ' + + "kurtosis devnet's ephemeral host port (http://127.0.0.1:/l1rpc) and iconUrl " + + 'at raw.githubusercontent.com, and AppKit pulls fonts from fonts.reown.com. X-1 ' + + 'measured this: with the devnet down but egress up, 8 issues went unclassified ' + + '(ERR_CONNECTION_REFUSED on the L1 rpcUrl); with all egress blocked, 27 went ' + + 'unclassified (fonts.reown.com and raw.githubusercontent.com — note row 10 matches ' + + 'on message TEXT, and a blocked request\'s text is "net::ERR_NAME_NOT_RESOLVED" ' + + 'with the host only in the URL, so it did not catch them). Without this entry the ' + + 'spec passes only on a machine that happens to have a live enclave AND outbound ' + + 'egress, which contradicts this suite\'s "no devnet required" contract and would ' + + 'make it red on a CI runner. Deliberately scoped: app-origin failures (BASE_URL) ' + + 'and every console/JS error still fail the test.', + matches: (issue) => + !urlIncludes(issue, BASE_URL) && + /net::ERR_(NAME_NOT_RESOLVED|CONNECTION_REFUSED|CONNECTION_TIMED_OUT|CONNECTION_RESET|INTERNET_DISCONNECTED|ADDRESS_UNREACHABLE|NETWORK_CHANGED|ABORTED|EMPTY_RESPONSE|PROXY_CONNECTION_FAILED)\b/.test( + issue.text + ) + }, + { + row: 'X-1: Coinbase Analytics SDK fetch rejection (companion to row 16)', + note: + 'The same cca-lite.coinbase.com beacon as row 16, but surfaced as a JS-level ' + + 'console error from the SDK\'s own catch handler ("Analytics SDK: TypeError: ' + + 'Failed to fetch") whose reported location is , so neither the host ' + + 'matcher nor the transport matcher above sees it. Only appears when egress is ' + + 'blocked entirely (X-1 measured it on a fully network-isolated simulation); ' + + "matched narrowly on the SDK's own message prefix.", + matches: (issue) => + textIncludes(issue, 'Analytics SDK:') && textIncludes(issue, 'Failed to fetch') + } +]; + +const classifyIssue = (issue: CapturedIssue): AllowlistEntry | undefined => + ALLOWLIST.find((entry) => entry.matches(issue)); + +const formatUnexpected = (issues: CapturedIssue[]): string => + issues + .map( + (issue, index) => + `${index + 1}. [${issue.kind}/${issue.level}] ${issue.text}\n ${issue.url}` + ) + .join('\n'); + +const attachCollectors = (page: Page) => { + const issues: CapturedIssue[] = []; + + const onConsole = (msg: ConsoleMessage) => { + const type = msg.type(); + if (type !== 'error' && type !== 'warning') return; + issues.push({ kind: 'console', level: type, text: msg.text(), url: msg.location().url }); + }; + const onResponse = (response: Response) => { + if (response.status() < 400) return; + issues.push({ + kind: 'network', + level: 'error', + text: `HTTP ${response.status()} ${response.statusText()}`, + url: response.url() + }); + }; + const onRequestFailed = (request: Request) => { + issues.push({ + kind: 'network', + level: 'error', + text: request.failure()?.errorText ?? 'request failed', + url: request.url() + }); + }; + + page.on('console', onConsole); + page.on('response', onResponse); + page.on('requestfailed', onRequestFailed); + + return { + issues, + detach: () => { + page.off('console', onConsole); + page.off('response', onResponse); + page.off('requestfailed', onRequestFailed); + } + }; +}; + +test.describe('container: real built image, mounted config', () => { + test.beforeAll(async () => { + removeContainer(CONTAINER_NAME); + runContainer({ name: CONTAINER_NAME, hostPort: HOST_PORT, configPath: CONFIG_A_PATH }); + await waitForHttpResponse(`${BASE_URL}/config.json`); + }); + + test.afterAll(() => { + removeContainer(CONTAINER_NAME); + }); + + test('same image digest is running as the C-1 artifact under test (no rebuild)', () => { + const runningDigest = getContainerImageDigest(CONTAINER_NAME); + const imageDigest = getImageDigest(); + expect(runningDigest).toBe(imageDigest); + }); + + test('renders the app and reflects the mounted config.json chains, with no unexpected console noise', async ({ + page + }) => { + const { issues, detach } = attachCollectors(page); + + try { + await page.goto(BASE_URL); + + // AppConfigGate: must leave 'pending' and never land on 'error'. + await expect(page.getByTestId('app-config-loading')).toHaveCount(0, { timeout: 15_000 }); + await expect(page.getByTestId('app-config-error')).toHaveCount(0); + + await expect(page.getByTestId('bridge-card')).toBeVisible(); + + // Wallet-free assertion points (no connect, no signature) -- the + // chain names come straight from the MOUNTED config.json's + // defaultFromChainKey/defaultToChainKey, not from any repo-root file + // baked into the image (the image's baked default has different, + // placeholder-URL chains entirely -- see entrypoint.sh's header + // comment and a1-runtime-config-design.md §6.3). + await expect(page.getByTestId('from-chain-selector')).toContainText(CONFIG_A_FROM_CHAIN_NAME); + await expect(page.getByTestId('to-chain-selector')).toContainText(CONFIG_A_TO_CHAIN_NAME); + + // Let any async post-load noise (AppKit init, font preload timers) + // surface before asserting the allowlist -- mirrors + // console-hygiene.spec.ts's approach of only asserting after the + // interaction under test has fully settled. + await page.waitForTimeout(4000); + } finally { + detach(); + } + + const unexpected = issues.filter((issue) => !classifyIssue(issue)); + expect(unexpected, formatUnexpected(unexpected)).toEqual([]); + }); +}); diff --git a/tests/container/container-config-swap.spec.ts b/tests/container/container-config-swap.spec.ts new file mode 100644 index 0000000..1dd0658 --- /dev/null +++ b/tests/container/container-config-swap.spec.ts @@ -0,0 +1,90 @@ +import path from 'node:path'; + +import { expect, test } from '@playwright/test'; + +import { + containerTestsUnavailableReason, + getContainerImageDigest, + getImageDigest, + removeContainer, + runContainer, + waitForHttpResponse +} from './docker'; + +// T-1's browser-level assertion of the runtime-config contract (per the +// step's acceptance criteria): "container with config A -> UI reflects A; +// restart the same image with config B -> UI reflects B." This is the +// browser-driven counterpart to +// plans/dev-ui-docker-ghcr/c2-runtime-config-proof.md, which proved the same +// property at the docker-inspect/curl level (§0-§3) and once, ad hoc, at the +// browser level (§4) with a throwaway script. This file makes that browser +// proof a permanent, repeatable spec using this repo's own fixtures. +test.skip( + () => Boolean(containerTestsUnavailableReason()), + containerTestsUnavailableReason() ?? '' +); + +const HOST_PORT = 19181; +const CONTAINER_NAME = 't1-container-swap'; +const BASE_URL = `http://127.0.0.1:${HOST_PORT}`; + +const CONFIG_A_PATH = path.resolve(__dirname, 'fixtures', 'config-a.json'); +const CONFIG_B_PATH = path.resolve(__dirname, 'fixtures', 'config-b.json'); + +// See tests/container/fixtures/config-{a,b}.json. Deliberately disjoint +// chain names/ids between the two fixtures so a match against the wrong one +// is impossible to miss. +const CONFIG_A_NAMES = { from: 'Devnet L1', to: 'Devnet L2-001' }; +const CONFIG_B_NAMES = { from: 'T1 Fixture Prime', to: 'T1 Fixture Secunda' }; + +test.afterAll(() => { + removeContainer(CONTAINER_NAME); +}); + +test('restarting the same image with a different mounted config changes what the browser renders', async ({ + page +}) => { + const imageDigestUnderTest = getImageDigest(); + + // --- Run 1: config A --- + removeContainer(CONTAINER_NAME); + runContainer({ name: CONTAINER_NAME, hostPort: HOST_PORT, configPath: CONFIG_A_PATH }); + await waitForHttpResponse(`${BASE_URL}/config.json`); + + const digestRunA = getContainerImageDigest(CONTAINER_NAME); + expect(digestRunA, 'run A must be the exact image under test').toBe(imageDigestUnderTest); + + await page.goto(BASE_URL); + await expect(page.getByTestId('app-config-error')).toHaveCount(0); + await expect(page.getByTestId('from-chain-selector')).toContainText(CONFIG_A_NAMES.from); + await expect(page.getByTestId('to-chain-selector')).toContainText(CONFIG_A_NAMES.to); + + // --- "Restart the same image" with config B. The operational contract + // (a1-runtime-config-design.md §8: "read once per page load ... restart + // the container to apply a new configuration") is a full container + // restart, not a live reload -- so this removes and re-runs the + // container, from the SAME image tag/digest, with a different bind mount. + // This is the exact mechanism C-2 verified never triggers a rebuild. + removeContainer(CONTAINER_NAME); + runContainer({ name: CONTAINER_NAME, hostPort: HOST_PORT, configPath: CONFIG_B_PATH }); + await waitForHttpResponse(`${BASE_URL}/config.json`); + + const digestRunB = getContainerImageDigest(CONTAINER_NAME); + expect(digestRunB, 'run B must be the SAME image digest as run A (no rebuild)').toBe(digestRunA); + + // A hard navigation (not client-side routing) is required: AppConfigGate + // reads config exactly once per page load (design.md §8, "R12 — explicit + // non-support statement") by design, so reusing the same Page/tab without + // reloading would only prove the browser cache, not the new container. + await page.goto(BASE_URL, { waitUntil: 'load' }); + await page.reload({ waitUntil: 'load' }); + + await expect(page.getByTestId('app-config-error')).toHaveCount(0); + await expect(page.getByTestId('from-chain-selector')).toContainText(CONFIG_B_NAMES.from); + await expect(page.getByTestId('to-chain-selector')).toContainText(CONFIG_B_NAMES.to); + + // And the previous config's chain names must be gone, not just "B's names + // are present somewhere" -- guards against a stale-cache false pass. + await expect(page.getByTestId('from-chain-selector')).not.toContainText(CONFIG_A_NAMES.from); + await expect(page.getByTestId('to-chain-selector')).not.toContainText(CONFIG_A_NAMES.to); +}); diff --git a/tests/container/container-invalid-config.spec.ts b/tests/container/container-invalid-config.spec.ts new file mode 100644 index 0000000..9df41d8 --- /dev/null +++ b/tests/container/container-invalid-config.spec.ts @@ -0,0 +1,149 @@ +import path from 'node:path'; + +import { expect, test } from '@playwright/test'; + +import { + containerTestsUnavailableReason, + getContainerLogs, + getContainerState, + removeContainer, + runContainer, + waitForContainerExit, + waitForHttpResponse +} from './docker'; + +// T-1's negative test: an invalid mounted config must surface the intended +// failure, not a blank page. Per +// plans/dev-ui-docker-ghcr/c2-runtime-config-proof.md §6 and +// a1-runtime-config-design.md §6.3/entrypoint.sh's header comment, there are +// TWO distinct invalid-config failure modes with different blast radii: +// +// 1. jq-STRUCTURALLY-valid but Zod-schema-invalid: entrypoint.sh's jq +// check only looks at top-level shape (chains is a non-empty object, +// appModes.default/configs agree, autoclaim/externalLinks are +// objects) -- it does not validate individual field values. Such a +// config passes the container and nginx starts, and the failure +// surfaces only in the BROWSER, at AppConfigGate's real Zod validator. +// This is the case this step's acceptance criteria calls out by name +// ("jq-valid but schema-invalid config passes the container and fails +// in the browser at the gate") -- covered by the first test below. +// 2. jq-STRUCTURALLY-invalid: entrypoint.sh's own check fails, the +// container never starts nginx and exits 1 immediately. Covered +// separately (not a browser test) by the second test below, per the +// step's "if practical, note the container-fatal case separately". +test.skip( + () => Boolean(containerTestsUnavailableReason()), + containerTestsUnavailableReason() ?? '' +); + +const SCHEMA_INVALID_CONFIG_PATH = path.resolve( + __dirname, + 'fixtures', + 'config-invalid-schema.json' +); +const STRUCTURAL_INVALID_CONFIG_PATH = path.resolve( + __dirname, + 'fixtures', + 'config-invalid-structural.json' +); + +test.describe('browser-visible case: jq-valid, Zod-invalid config', () => { + const HOST_PORT = 19182; + const CONTAINER_NAME = 't1-container-invalid-schema'; + const BASE_URL = `http://127.0.0.1:${HOST_PORT}`; + + test.beforeAll(async () => { + removeContainer(CONTAINER_NAME); + // tests/container/fixtures/config-invalid-schema.json is config-a.json + // with chains.DEVNET_L1.rpcUrl set to "not-a-url" -- entrypoint.sh's jq + // check never inspects nested chain fields, so this file passes the + // container's structural gate (verified independently while authoring + // this fixture: `jq -e ''` exits 0 + // against this file) and nginx starts normally. + runContainer({ + name: CONTAINER_NAME, + hostPort: HOST_PORT, + configPath: SCHEMA_INVALID_CONFIG_PATH + }); + await waitForHttpResponse(`${BASE_URL}/config.json`); + }); + + test.afterAll(() => { + removeContainer(CONTAINER_NAME); + }); + + test('container starts and serves the invalid file unmodified (proves the failure is browser-side, not container-side)', () => { + const state = getContainerState(CONTAINER_NAME); + expect(state.status).toBe('running'); + }); + + test('the browser gate renders app-config-error, not a blank page, and never mounts the wallet UI', async ({ + page + }) => { + await page.goto(BASE_URL); + + // Must resolve to the error state, not hang on loading nor render + // children. + await expect(page.getByTestId('app-config-error')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('app-config-loading')).toHaveCount(0); + + const errorText = await page.getByTestId('app-config-error').innerText(); + // config/configValidator.mjs's parseConfigOrThrow message shape -- + // asserting on it (not just "an error screen exists") proves this is + // the REAL Zod schema failure this fixture was built to trigger, not + // some unrelated fetch/network error rendering the same test-id. + expect(errorText).toContain('schema validation failed'); + expect(errorText).toContain('rpcUrl'); + + // AppConfigGate's early return means AppModeProvider/WalletProvider + // never mount (a1-runtime-config-design.md §3.4) -- assert that + // directly rather than only inferring it from the error screen's + // presence: the header (and its connect-wallet control) lives inside + // , below the gate. + await expect(page.getByTestId('header-desktop')).toHaveCount(0); + await expect(page.getByTestId('connect-wallet')).toHaveCount(0); + await expect(page.getByTestId('bridge-card')).toHaveCount(0); + + // The gate's Retry button is present and re-runs the same (still + // invalid) fetch -- clicking it must re-land on the same error state, + // not crash or silently succeed. + await page.getByTestId('app-config-retry').click(); + await expect(page.getByTestId('app-config-error')).toBeVisible({ timeout: 15_000 }); + }); +}); + +test.describe('container-fatal case: jq-structurally-invalid config (noted separately, not a browser test)', () => { + const HOST_PORT = 19183; + const CONTAINER_NAME = 't1-container-invalid-structural'; + + test.afterAll(() => { + removeContainer(CONTAINER_NAME); + }); + + test('container refuses to start, exits 1, and never listens', async () => { + removeContainer(CONTAINER_NAME); + // tests/container/fixtures/config-invalid-structural.json sets + // appModes.default to a mode with no matching appModes.configs entry -- + // exactly the structural rule entrypoint.sh's jq expression + // `(.appModes.configs[.appModes.default]? != null)` enforces. Verified + // independently while authoring this fixture that jq's structural check + // exits 1 against this file (same technique + // c2-runtime-config-proof.md §1/§6 used for its own config-3-invalid.json). + runContainer({ + name: CONTAINER_NAME, + hostPort: HOST_PORT, + configPath: STRUCTURAL_INVALID_CONFIG_PATH + }); + + const state = await waitForContainerExit(CONTAINER_NAME, 10_000); + expect(state.status).toBe('exited'); + expect(state.exitCode).toBe(1); + + const logs = getContainerLogs(CONTAINER_NAME); + expect(logs).toContain('FATAL'); + expect(logs).toContain('Refusing to start'); + // nginx's own startup notice must never appear -- proves the failure + // happened before `exec nginx`, not as a crash after it started. + expect(logs).not.toContain('nginx/'); + }); +}); diff --git a/tests/container/docker.ts b/tests/container/docker.ts new file mode 100644 index 0000000..5978676 --- /dev/null +++ b/tests/container/docker.ts @@ -0,0 +1,186 @@ +import { execFileSync, spawnSync } from 'node:child_process'; + +// Shared helpers for the T-1 container E2E specs (tests/container/*.spec.ts). +// These specs exercise the REAL built artifact -- the agglayer-dev-ui:c1-test +// image produced by C-1 -- rather than `next dev`, which is what every other +// Playwright spec in this repo runs against (see playwright.config.ts). They +// are driven by playwright.container.config.ts, a separate config file so +// this suite never requires the devnet-only env vars +// (E2E_PRIVATE_KEY/NEXT_PUBLIC_PROJECT_ID/NEXT_PUBLIC_AGGKIT_PROXY) that +// playwright.config.ts hard-requires at module scope, and never starts the +// `next dev` webServers -- this suite's only dependency is Docker + the +// prebuilt image. + +export const IMAGE_TAG = 'agglayer-dev-ui:c1-test'; +export const MOUNTED_CONFIG_PATH = '/etc/agglayer-dev-ui/config.json'; + +/** True when the Docker daemon is reachable from this host. */ +export const isDockerAvailable = (): boolean => { + try { + execFileSync('docker', ['info'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +}; + +/** True when the exact image under test (built by C-1) is present locally. */ +export const isTestImageAvailable = (): boolean => { + try { + const out = execFileSync('docker', ['images', '-q', IMAGE_TAG], { encoding: 'utf8' }); + return out.trim().length > 0; + } catch { + return false; + } +}; + +/** + * One combined gate for `test.skip(...)` at the top of every spec in this + * directory: skips cleanly (not a failure) whenever Docker isn't reachable or + * the C-1 image hasn't been built, exactly like this repo's existing + * `E2E_BACKEND_MODE`-based skip idiom (see console-hygiene.spec.ts / + * l2-to-l2.spec.ts) does for devnet-only specs. + */ +export const containerTestsUnavailableReason = (): string | undefined => { + if (!isDockerAvailable()) { + return 'Docker is not available on this host (docker info failed) -- skipping container E2E specs.'; + } + if (!isTestImageAvailable()) { + return `Image ${IMAGE_TAG} is not present locally (build it per plans/dev-ui-docker-ghcr/c1 before running this suite) -- skipping container E2E specs.`; + } + return undefined; +}; + +/** The full image content digest, for asserting "same image, no rebuild" across runs. */ +export const getImageDigest = (): string => + execFileSync('docker', ['inspect', '--format', '{{.Id}}', IMAGE_TAG], { + encoding: 'utf8' + }).trim(); + +export const getContainerImageDigest = (containerName: string): string => + execFileSync('docker', ['inspect', '--format', '{{.Image}}', containerName], { + encoding: 'utf8' + }).trim(); + +export type ContainerState = { + status: string; + exitCode: number; +}; + +export const getContainerState = (containerName: string): ContainerState => { + const out = execFileSync( + 'docker', + ['inspect', '--format', '{{.State.Status}}|{{.State.ExitCode}}', containerName], + { encoding: 'utf8' } + ).trim(); + const [status, exitCodeRaw] = out.split('|'); + return { status, exitCode: Number.parseInt(exitCodeRaw, 10) }; +}; + +const getContainerLogsOnce = (containerName: string): string => { + // `docker logs` writes each line to whichever stream the container wrote + // it to -- entrypoint.sh's log() helper writes everything (including the + // FATAL line) to stderr, via `printf '%s\n' "$*" >&2`. execFileSync's + // return value is stdout ONLY; on a zero exit code (which `docker logs` + // always has, even for an exited container) any stderr content is + // silently discarded rather than returned -- it would otherwise inherit + // straight through to this process's own stderr. spawnSync captures both + // streams regardless of exit code, so stdout+stderr are concatenated here + // to get the container's full combined log, matching what an operator + // running `docker logs` at a terminal would see. + const result = spawnSync('docker', ['logs', containerName], { encoding: 'utf8' }); + return `${result.stdout ?? ''}${result.stderr ?? ''}`; +}; + +/** + * `docker logs` immediately after a container reaches `Status: exited` can + * occasionally observe the log stream before dockerd's logging driver has + * finished flushing (a real race, not this repo's code) -- poll briefly + * rather than accept a possibly-truncated read. + */ +export const getContainerLogs = (containerName: string, timeoutMs = 5_000): string => { + const deadline = Date.now() + timeoutMs; + let logs = getContainerLogsOnce(containerName); + while (logs.trim().length === 0 && Date.now() < deadline) { + const remaining = deadline - Date.now(); + const waitMs = Math.min(200, Math.max(remaining, 0)); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs); + logs = getContainerLogsOnce(containerName); + } + return logs; +}; + +/** + * Starts a detached container of IMAGE_TAG named `name`, publishing its + * port-80 nginx to the host at `hostPort`, with `configPath` bind-mounted + * (read-only) at the entrypoint's expected MOUNTED_CONFIG_PATH. Mirrors + * exactly the `docker run` invocation C-2 verified + * (plans/dev-ui-docker-ghcr/c2-runtime-config-proof.md §2-3). + * + * Does NOT wait for readiness -- callers that expect the container to reach + * a listening nginx should follow with waitForHttpOk; callers testing the + * container-fatal path (an invalid mount) should instead inspect + * getContainerState after a short delay. + */ +export const runContainer = ({ + name, + hostPort, + configPath +}: { + name: string; + hostPort: number; + configPath: string; +}): void => { + execFileSync('docker', [ + 'run', + '-d', + '--name', + name, + '-p', + `${hostPort}:80`, + '-v', + `${configPath}:${MOUNTED_CONFIG_PATH}:ro`, + IMAGE_TAG + ]); +}; + +/** Removes a container by name, ignoring "already gone" errors. */ +export const removeContainer = (name: string): void => { + try { + execFileSync('docker', ['rm', '-f', name], { stdio: 'ignore' }); + } catch { + // already removed / never created -- fine for cleanup idempotency. + } +}; + +/** Polls `url` until it returns an HTTP response (any status) or times out. */ +export const waitForHttpResponse = async (url: string, timeoutMs = 20_000): Promise => { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + await fetch(url, { cache: 'no-store' }); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw new Error( + `Timed out waiting for ${url} to respond after ${timeoutMs}ms. Last error: ${String(lastError)}` + ); +}; + +/** Waits until `docker inspect` reports the container has exited (Status === "exited"). */ +export const waitForContainerExit = async ( + containerName: string, + timeoutMs = 10_000 +): Promise => { + const deadline = Date.now() + timeoutMs; + let state = getContainerState(containerName); + while (state.status !== 'exited' && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 200)); + state = getContainerState(containerName); + } + return state; +}; diff --git a/tests/container/fixtures/config-a.json b/tests/container/fixtures/config-a.json new file mode 100644 index 0000000..68ee586 --- /dev/null +++ b/tests/container/fixtures/config-a.json @@ -0,0 +1,174 @@ +{ + "walletConnect": { + "projectId": "YOUR_PROJECT_ID_HERE" + }, + "externalLinks": { + "privacyPolicy": "https://polygon.technology/privacy-policy", + "termsOfUse": "https://polygon.technology/terms-of-use", + "contactSupport": "https://support.polygon.technology/support/home" + }, + "autoclaim": { + "l1_to_l2": { + "expectedAutoclaim": true, + "waitForAutoclaimMs": 120000 + }, + "l2_to_l1": { + "expectedAutoclaim": false + }, + "l2_to_l2": { + "expectedAutoclaim": true, + "waitForAutoclaimMs": 300000 + } + }, + "chains": { + "MAINNET": { + "id": 1, + "name": "Ethereum", + "rpcUrl": "https://eth.merkle.io", + "explorerUrl": "https://etherscan.io", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": false, + "eta": 20 + }, + "KATANA": { + "id": 747474, + "name": "Katana", + "rpcUrl": "https://rpc.katana.network", + "explorerUrl": "https://katanascan.com", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 20, + "isTestnet": false, + "eta": 180 + }, + "FORKNET": { + "id": 8338, + "name": "Forknet", + "rpcUrl": "https://rpc-forknet.t.conduit.xyz", + "explorerUrl": "https://forkscan.org/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://explorer-forknet.t.conduit.xyz/assets/configs/network_icon_dark.svg", + "networkId": 22, + "isTestnet": false, + "eta": 180 + }, + "SEPOLIA": { + "id": 11155111, + "name": "Sepolia", + "rpcUrl": "https://ethereum-sepolia-rpc.publicnode.com", + "explorerUrl": "https://sepolia.etherscan.io", + "currency": { + "name": "Sepolia Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": true, + "eta": 20 + }, + "BOKUTO": { + "id": 737373, + "name": "Bokuto", + "rpcUrl": "https://rpc-katana-bokuto.t.conduit.xyz", + "explorerUrl": "https://bokuto.katanascan.com/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 37, + "isTestnet": true, + "eta": 180 + }, + "DEVNET_L1": { + "id": 271828, + "name": "Devnet L1", + "rpcUrl": "http://127.0.0.1:33015/l1rpc", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": true, + "eta": 1 + }, + "DEVNET_L2_001": { + "id": 20201, + "name": "Devnet L2-001", + "rpcUrl": "http://127.0.0.1:33015/l2rpc-001", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 1, + "isTestnet": true, + "eta": 1 + }, + "DEVNET_L2_002": { + "id": 20202, + "name": "Devnet L2-002", + "rpcUrl": "http://127.0.0.1:33015/l2rpc-002", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 2, + "isTestnet": true, + "eta": 1 + } + }, + "appModes": { + "default": "devnet", + "configs": { + "mainnet": { + "label": "Mainnet", + "bridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "aggkitProxy": "https://PLACEHOLDER-mainnet-aggkit-proxy", + "chainKeys": ["MAINNET", "KATANA", "FORKNET"], + "defaultFromChainKey": "MAINNET", + "defaultToChainKey": "KATANA" + }, + "testnet": { + "label": "Testnet", + "bridgeAddress": "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582", + "aggkitProxy": "https://PLACEHOLDER-testnet-aggkit-proxy", + "chainKeys": ["SEPOLIA", "BOKUTO"], + "defaultFromChainKey": "SEPOLIA", + "defaultToChainKey": "BOKUTO" + }, + "devnet": { + "label": "Devnet", + "bridgeAddress": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038", + "aggkitProxy": "http://127.0.0.1:33015/aggkitapi", + "chainKeys": ["DEVNET_L1", "DEVNET_L2_001", "DEVNET_L2_002"], + "defaultFromChainKey": "DEVNET_L1", + "defaultToChainKey": "DEVNET_L2_001" + } + } + } +} diff --git a/tests/container/fixtures/config-b.json b/tests/container/fixtures/config-b.json new file mode 100644 index 0000000..704905f --- /dev/null +++ b/tests/container/fixtures/config-b.json @@ -0,0 +1,174 @@ +{ + "walletConnect": { + "projectId": "YOUR_PROJECT_ID_HERE" + }, + "externalLinks": { + "privacyPolicy": "https://polygon.technology/privacy-policy", + "termsOfUse": "https://polygon.technology/terms-of-use", + "contactSupport": "https://support.polygon.technology/support/home" + }, + "autoclaim": { + "l1_to_l2": { + "expectedAutoclaim": true, + "waitForAutoclaimMs": 120000 + }, + "l2_to_l1": { + "expectedAutoclaim": false + }, + "l2_to_l2": { + "expectedAutoclaim": true, + "waitForAutoclaimMs": 300000 + } + }, + "chains": { + "MAINNET": { + "id": 1, + "name": "Ethereum", + "rpcUrl": "https://eth.merkle.io", + "explorerUrl": "https://etherscan.io", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": false, + "eta": 20 + }, + "KATANA": { + "id": 747474, + "name": "Katana", + "rpcUrl": "https://rpc.katana.network", + "explorerUrl": "https://katanascan.com", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 20, + "isTestnet": false, + "eta": 180 + }, + "FORKNET": { + "id": 8338, + "name": "Forknet", + "rpcUrl": "https://rpc-forknet.t.conduit.xyz", + "explorerUrl": "https://forkscan.org/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://explorer-forknet.t.conduit.xyz/assets/configs/network_icon_dark.svg", + "networkId": 22, + "isTestnet": false, + "eta": 180 + }, + "SEPOLIA": { + "id": 11155111, + "name": "Sepolia", + "rpcUrl": "https://ethereum-sepolia-rpc.publicnode.com", + "explorerUrl": "https://sepolia.etherscan.io", + "currency": { + "name": "Sepolia Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": true, + "eta": 20 + }, + "BOKUTO": { + "id": 737373, + "name": "Bokuto", + "rpcUrl": "https://rpc-katana-bokuto.t.conduit.xyz", + "explorerUrl": "https://bokuto.katanascan.com/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 37, + "isTestnet": true, + "eta": 180 + }, + "DEVNET_L1": { + "id": 900101, + "name": "T1 Fixture Prime", + "rpcUrl": "http://127.0.0.1:33015/l1rpc", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": true, + "eta": 1 + }, + "DEVNET_L2_001": { + "id": 900102, + "name": "T1 Fixture Secunda", + "rpcUrl": "http://127.0.0.1:33015/l2rpc-001", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 1, + "isTestnet": true, + "eta": 1 + }, + "DEVNET_L2_002": { + "id": 900103, + "name": "T1 Fixture Tertia", + "rpcUrl": "http://127.0.0.1:33015/l2rpc-002", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 2, + "isTestnet": true, + "eta": 1 + } + }, + "appModes": { + "default": "devnet", + "configs": { + "mainnet": { + "label": "Mainnet", + "bridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "aggkitProxy": "https://PLACEHOLDER-mainnet-aggkit-proxy", + "chainKeys": ["MAINNET", "KATANA", "FORKNET"], + "defaultFromChainKey": "MAINNET", + "defaultToChainKey": "KATANA" + }, + "testnet": { + "label": "Testnet", + "bridgeAddress": "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582", + "aggkitProxy": "https://PLACEHOLDER-testnet-aggkit-proxy", + "chainKeys": ["SEPOLIA", "BOKUTO"], + "defaultFromChainKey": "SEPOLIA", + "defaultToChainKey": "BOKUTO" + }, + "devnet": { + "label": "Devnet", + "bridgeAddress": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038", + "aggkitProxy": "/aggkitapi", + "chainKeys": ["DEVNET_L1", "DEVNET_L2_001", "DEVNET_L2_002"], + "defaultFromChainKey": "DEVNET_L1", + "defaultToChainKey": "DEVNET_L2_001" + } + } + } +} diff --git a/tests/container/fixtures/config-invalid-schema.json b/tests/container/fixtures/config-invalid-schema.json new file mode 100644 index 0000000..a04be1a --- /dev/null +++ b/tests/container/fixtures/config-invalid-schema.json @@ -0,0 +1,174 @@ +{ + "walletConnect": { + "projectId": "YOUR_PROJECT_ID_HERE" + }, + "externalLinks": { + "privacyPolicy": "https://polygon.technology/privacy-policy", + "termsOfUse": "https://polygon.technology/terms-of-use", + "contactSupport": "https://support.polygon.technology/support/home" + }, + "autoclaim": { + "l1_to_l2": { + "expectedAutoclaim": true, + "waitForAutoclaimMs": 120000 + }, + "l2_to_l1": { + "expectedAutoclaim": false + }, + "l2_to_l2": { + "expectedAutoclaim": true, + "waitForAutoclaimMs": 300000 + } + }, + "chains": { + "MAINNET": { + "id": 1, + "name": "Ethereum", + "rpcUrl": "https://eth.merkle.io", + "explorerUrl": "https://etherscan.io", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": false, + "eta": 20 + }, + "KATANA": { + "id": 747474, + "name": "Katana", + "rpcUrl": "https://rpc.katana.network", + "explorerUrl": "https://katanascan.com", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 20, + "isTestnet": false, + "eta": 180 + }, + "FORKNET": { + "id": 8338, + "name": "Forknet", + "rpcUrl": "https://rpc-forknet.t.conduit.xyz", + "explorerUrl": "https://forkscan.org/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://explorer-forknet.t.conduit.xyz/assets/configs/network_icon_dark.svg", + "networkId": 22, + "isTestnet": false, + "eta": 180 + }, + "SEPOLIA": { + "id": 11155111, + "name": "Sepolia", + "rpcUrl": "https://ethereum-sepolia-rpc.publicnode.com", + "explorerUrl": "https://sepolia.etherscan.io", + "currency": { + "name": "Sepolia Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": true, + "eta": 20 + }, + "BOKUTO": { + "id": 737373, + "name": "Bokuto", + "rpcUrl": "https://rpc-katana-bokuto.t.conduit.xyz", + "explorerUrl": "https://bokuto.katanascan.com/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 37, + "isTestnet": true, + "eta": 180 + }, + "DEVNET_L1": { + "id": 271828, + "name": "Devnet L1", + "rpcUrl": "not-a-url", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": true, + "eta": 1 + }, + "DEVNET_L2_001": { + "id": 20201, + "name": "Devnet L2-001", + "rpcUrl": "http://127.0.0.1:33015/l2rpc-001", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 1, + "isTestnet": true, + "eta": 1 + }, + "DEVNET_L2_002": { + "id": 20202, + "name": "Devnet L2-002", + "rpcUrl": "http://127.0.0.1:33015/l2rpc-002", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 2, + "isTestnet": true, + "eta": 1 + } + }, + "appModes": { + "default": "devnet", + "configs": { + "mainnet": { + "label": "Mainnet", + "bridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "aggkitProxy": "https://PLACEHOLDER-mainnet-aggkit-proxy", + "chainKeys": ["MAINNET", "KATANA", "FORKNET"], + "defaultFromChainKey": "MAINNET", + "defaultToChainKey": "KATANA" + }, + "testnet": { + "label": "Testnet", + "bridgeAddress": "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582", + "aggkitProxy": "https://PLACEHOLDER-testnet-aggkit-proxy", + "chainKeys": ["SEPOLIA", "BOKUTO"], + "defaultFromChainKey": "SEPOLIA", + "defaultToChainKey": "BOKUTO" + }, + "devnet": { + "label": "Devnet", + "bridgeAddress": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038", + "aggkitProxy": "http://127.0.0.1:33015/aggkitapi", + "chainKeys": ["DEVNET_L1", "DEVNET_L2_001", "DEVNET_L2_002"], + "defaultFromChainKey": "DEVNET_L1", + "defaultToChainKey": "DEVNET_L2_001" + } + } + } +} diff --git a/tests/container/fixtures/config-invalid-structural.json b/tests/container/fixtures/config-invalid-structural.json new file mode 100644 index 0000000..6977a57 --- /dev/null +++ b/tests/container/fixtures/config-invalid-structural.json @@ -0,0 +1,174 @@ +{ + "walletConnect": { + "projectId": "YOUR_PROJECT_ID_HERE" + }, + "externalLinks": { + "privacyPolicy": "https://polygon.technology/privacy-policy", + "termsOfUse": "https://polygon.technology/terms-of-use", + "contactSupport": "https://support.polygon.technology/support/home" + }, + "autoclaim": { + "l1_to_l2": { + "expectedAutoclaim": true, + "waitForAutoclaimMs": 120000 + }, + "l2_to_l1": { + "expectedAutoclaim": false + }, + "l2_to_l2": { + "expectedAutoclaim": true, + "waitForAutoclaimMs": 300000 + } + }, + "chains": { + "MAINNET": { + "id": 1, + "name": "Ethereum", + "rpcUrl": "https://eth.merkle.io", + "explorerUrl": "https://etherscan.io", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": false, + "eta": 20 + }, + "KATANA": { + "id": 747474, + "name": "Katana", + "rpcUrl": "https://rpc.katana.network", + "explorerUrl": "https://katanascan.com", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 20, + "isTestnet": false, + "eta": 180 + }, + "FORKNET": { + "id": 8338, + "name": "Forknet", + "rpcUrl": "https://rpc-forknet.t.conduit.xyz", + "explorerUrl": "https://forkscan.org/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://explorer-forknet.t.conduit.xyz/assets/configs/network_icon_dark.svg", + "networkId": 22, + "isTestnet": false, + "eta": 180 + }, + "SEPOLIA": { + "id": 11155111, + "name": "Sepolia", + "rpcUrl": "https://ethereum-sepolia-rpc.publicnode.com", + "explorerUrl": "https://sepolia.etherscan.io", + "currency": { + "name": "Sepolia Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": true, + "eta": 20 + }, + "BOKUTO": { + "id": 737373, + "name": "Bokuto", + "rpcUrl": "https://rpc-katana-bokuto.t.conduit.xyz", + "explorerUrl": "https://bokuto.katanascan.com/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 37, + "isTestnet": true, + "eta": 180 + }, + "DEVNET_L1": { + "id": 271828, + "name": "Devnet L1", + "rpcUrl": "http://127.0.0.1:33015/l1rpc", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/ethereum.svg", + "networkId": 0, + "isTestnet": true, + "eta": 1 + }, + "DEVNET_L2_001": { + "id": 20201, + "name": "Devnet L2-001", + "rpcUrl": "http://127.0.0.1:33015/l2rpc-001", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 1, + "isTestnet": true, + "eta": 1 + }, + "DEVNET_L2_002": { + "id": 20202, + "name": "Devnet L2-002", + "rpcUrl": "http://127.0.0.1:33015/l2rpc-002", + "explorerUrl": "https://explorer.private/", + "currency": { + "name": "Ether", + "symbol": "ETH", + "decimals": 18 + }, + "iconUrl": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/chains/katana.svg", + "networkId": 2, + "isTestnet": true, + "eta": 1 + } + }, + "appModes": { + "default": "nonexistent-mode", + "configs": { + "mainnet": { + "label": "Mainnet", + "bridgeAddress": "0x2a3DD3EB832aF982ec71669E178424b10Dca2EDe", + "aggkitProxy": "https://PLACEHOLDER-mainnet-aggkit-proxy", + "chainKeys": ["MAINNET", "KATANA", "FORKNET"], + "defaultFromChainKey": "MAINNET", + "defaultToChainKey": "KATANA" + }, + "testnet": { + "label": "Testnet", + "bridgeAddress": "0x528e26b25a34a4A5d0dbDa1d57D318153d2ED582", + "aggkitProxy": "https://PLACEHOLDER-testnet-aggkit-proxy", + "chainKeys": ["SEPOLIA", "BOKUTO"], + "defaultFromChainKey": "SEPOLIA", + "defaultToChainKey": "BOKUTO" + }, + "devnet": { + "label": "Devnet", + "bridgeAddress": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038", + "aggkitProxy": "http://127.0.0.1:33015/aggkitapi", + "chainKeys": ["DEVNET_L1", "DEVNET_L2_001", "DEVNET_L2_002"], + "defaultFromChainKey": "DEVNET_L1", + "defaultToChainKey": "DEVNET_L2_001" + } + } + } +} diff --git a/tests/devnet/README.md b/tests/devnet/README.md new file mode 100644 index 0000000..0ae55c4 --- /dev/null +++ b/tests/devnet/README.md @@ -0,0 +1,209 @@ +# Vendored devnet snapshot (e2e CI backend) + +This directory is a **vendored copy** of the anvil-aggkit devnet snapshot +bundle produced by `0xPolygon/kurtosis-cdk`'s `snapshot-devui.yml` workflow +(`.github/workflows/snapshot-devui.yml` in that repo, branch +`feat/aggkit-bridge-ui-backend`). See that repo's [Anvil-Flavor Devnet +Snapshot](https://github.com/0xPolygon/kurtosis-cdk/blob/feat/aggkit-bridge-ui-backend/docs/docs/advanced/anvil-devnet-snapshot.md) +doc for the bundle's topology, the `summary.json` field reference, the +compose port table, and the restore hazards (`anvil --load-state` tip-state +semantics, the `settlement_free`/`historical_states` publish gates, the +timestamp seam) that this bundle was produced under. (That doc used to live +at `anvil-devui-snapshot.md`; a stub remains at the old path redirecting +here.) + +This directory contains two files taken from that workflow's published +artifact (plus this README). Both are **byte-identical** to what the +publish run produced -- unlike the v1 bundle, `docker-compose.yml` needs no +local edit, because the artifact itself is already pinned at the published +GHCR digests (see "Tag scheme" below). + +- `docker-compose.yml` — self-contained (no bind mounts, no volumes); every + service's chain state, config and keystores are baked into its image. + `.github/workflows/e2e.yaml` brings it up with + `docker compose -f tests/devnet/docker-compose.yml up -d --wait`. +- `summary.json` — a machine-readable description of the bundle (chain ids, + contract addresses, the funded E2E wallet, the seeded ERC20, image names, + digests and human-readable sizes). Several `E2E_*` values in + `.github/workflows/e2e.yaml` are copied from it; the workflow's "Assert + workflow literals match tests/devnet/summary.json" step fails the job if + they drift. `.erc20_address` in particular is **nonce-dependent** and can + differ on every regenerated snapshot. `summary.json` does **not** carry + `historical_states`, so only half of the producer-side publish gate is + verifiable from the vendored bundle; the other half is enforced in + kurtosis-cdk's workflow before publishing. Its `compose.image_prefix_env` / + `compose.image_tag_env` fields (`SNAPSHOT_IMAGE_PREFIX` / + `SNAPSHOT_IMAGE_TAG`) describe kurtosis-cdk's general compose-variant + contract, not this specific vendored file -- this copy is hard-pinned by + digest and has no override env vars (see below). + +## Topology (9 services, not 11) + +The aggkit bridge is now a **component** of the main aggkit process, not a +separate service: `aggkit-001` and `aggkit-002` each run +`--components=aggsender,aggoracle,autoclaim,bridge`, exposing aggkit RPC +(`5576`), bridge REST (`5577`) and pprof (`6060`) on one container. There is +no `aggkit-00X-bridge` service anymore. `agglayer-dev-ui-002` (the baked +dev-ui itself, `:8557`, "manual use") is inert for CI and sits behind the +`devui` compose profile — a plain `up`/`up -d --wait` never starts it; use +`docker compose -f tests/devnet/docker-compose.yml --profile devui up -d` +to bring it up for local/manual debugging. `agglayer-dev-ui-proxy-002` +(`:8555`, "THE dev-ui CI origin") stays in the default set and is the only +endpoint the suite needs. + +## Tag scheme: immutable, digest-pinned + +Every `image:` line in `docker-compose.yml` is of the form: + +```yaml +image: ghcr.io/0xpolygon/kurtosis-cdk-snapshot-@sha256: # - +``` + +The `@sha256:` is what Docker actually resolves — immutable, and +independently re-verifiable (`docker buildx imagetools inspect +ghcr.io/0xpolygon/kurtosis-cdk-snapshot-@sha256:`). The +trailing `# -` comment (e.g. +`0.11.0-rc5-1786700247`) is a human-readable tag carrying the same digest — +provenance for a reader, not something Docker resolves. Re-running +kurtosis-cdk's publish workflow can never silently move what this file +pulls, unlike the old `snapshot-` tag scheme (a tag containing a commit +sha is still a mutable tag). There is deliberately no +`SNAPSHOT_IMAGE_PREFIX`/`SNAPSHOT_IMAGE_TAG` override anymore — bumping the +bundle means editing this file (step 4 below), not exporting an env var. + +## Why vendor instead of pulling live + +dev-ui's e2e workflow has no way to bring up a kurtosis enclave itself (no +Kurtosis CLI, no `sequencer_type: anvil` knowledge, etc. — that all lives in +kurtosis-cdk). Vendoring a frozen, self-contained bundle means dev-ui CI only +needs `docker compose` and 9 public GHCR pulls; it never depends on +kurtosis-cdk's toolchain, params files, or CI being green at PR time. + +The trade-off: this bundle **drifts** from kurtosis-cdk's `main`/working +branch over time (new aggkit/agglayer releases, contract changes, etc.). Bump +it deliberately using the procedure below — do not let it silently rot for +months, but also do not auto-pull `snapshot-latest-devui` (that tag moves +under you and breaks the "pin exact versions in CI" contract). + +## Regenerate-and-bump procedure + +1. **Dispatch the kurtosis-cdk snapshot workflow** (needs `publish: true` to + actually push images — the default is a dry run): + + ```bash + gh workflow run snapshot-devui.yml \ + --repo 0xPolygon/kurtosis-cdk \ + --ref feat/aggkit-bridge-ui-backend \ + -f publish=true + ``` + +2. **Wait for it to go green** (poll, don't use `gh run watch`): + + ```bash + gh run list --repo 0xPolygon/kurtosis-cdk --workflow snapshot-devui.yml --limit 1 \ + --json databaseId,status,conclusion + # once status == completed: + gh run view --repo 0xPolygon/kurtosis-cdk --json conclusion,headSha + ``` + +3. **Download the artifact.** With the digest-capture step (post-push, added + alongside the tag scheme above), the published artifact's + `docker-compose.yml` is already digest-pinned at GHCR — there is no more + "repoint the compose defaults at the published tag" step: + + ```bash + gh run download --repo 0xPolygon/kurtosis-cdk \ + --name --dir /tmp/devui-snapshot + ``` + +4. **Copy the two files into this directory, verbatim, overwriting what's + here** — no `sed`, no hand-editing image references: + + ```bash + cp /tmp/devui-snapshot/docker-compose.yml tests/devnet/docker-compose.yml + cp /tmp/devui-snapshot/summary.json tests/devnet/summary.json + ``` + + Then update this file's header-comment provenance line (the + `fc160450b55e64332436f11c091c61130c64030f` / publish-run-URL pair at the + top of `docker-compose.yml`) to match the new run, and re-confirm every + `image:` line resolves to `ghcr.io/0xpolygon/kurtosis-cdk-snapshot-@sha256:`: + + ```bash + grep -n 'image:' tests/devnet/docker-compose.yml + ``` + +5. **Bump `E2E_ERC20_ADDRESS`** in `.github/workflows/e2e.yaml`'s job `env:` + block to the new `summary.json`'s `.erc20_address`: + + ```bash + # -e so a missing/null erc20_address fails loudly instead of printing + # the literal string "null" for you to paste into the workflow. + jq -er .erc20_address tests/devnet/summary.json + ``` + + This value is nonce-dependent (the ERC20 is deployed fresh by the + kurtosis-cdk fixture-seeding step every time the enclave is rebuilt) — it + can change between snapshots. Forgetting this step makes + `globalSetup.ts`'s `E2E_ERC20_ADDRESS` override check fail fast + (`erc20-approve-bridge.spec.ts` needs a *usable* — bytecode + non-zero + balance for the E2E wallet — ERC20 at that address). Also re-check + `E2E_FROM_CHAIN_ID` / `E2E_TO_CHAIN_ID` / `E2E_L2_CHAIN_IDS` and + `NEXT_PUBLIC_AGGKIT_PROXY` against `summary.json`'s `.chain_ids` and + `.aggkit_proxy.rest_url_via_proxy` — the workflow's literals-assertion + step (below) checks all of these, not just the ERC20 address. + +6. **Verify the new bundle pulls anonymously and boots clean**, from this + repo root: + + ```bash + docker logout ghcr.io + docker compose -f tests/devnet/docker-compose.yml pull + docker compose -f tests/devnet/docker-compose.yml up -d --wait + node scripts/devnetReady.mjs --timeout-ms 300000 + docker compose -f tests/devnet/docker-compose.yml down -v + ``` + + Also confirm the `devui` profile still opts in the baked dev-ui container + without breaking the default set: + + ```bash + docker compose -f tests/devnet/docker-compose.yml --profile devui up -d --wait + docker compose -f tests/devnet/docker-compose.yml --profile devui down -v + ``` + +7. **Run the actual specs locally** against the new bundle before committing + (at minimum preflight; ideally the full `tests/bridge` suite once — see + the main README's "Testing" section for the full env var list this + workflow's `env:` block mirrors). If the built-image path in `e2e.yaml` + changed too, also rebuild and rerun `tests/container/` locally (see the + main README/`e2e.yaml` for the `docker build` + `playwright.container.config.ts` + invocation). + +8. **Commit `tests/devnet/docker-compose.yml`, `tests/devnet/summary.json`, + and the literal bumps in `.github/workflows/e2e.yaml` together, in the + same commit.** These must never drift independently. The workflow's + "Assert workflow literals match tests/devnet/summary.json" step is the + automated backstop: it compares the `E2E_*` literals and the compose + file's full set of pinned image digests against `summary.json` and fails + the job on any mismatch, so a half-done bump is caught in CI rather than + surfacing as an obscure `globalSetup.ts` failure. + +## Notes + +- All 9 images (`anvil-001`, `l2-anvil-001`, `l2-anvil-002`, `agglayer`, + `aggkit-001`, `aggkit-002`, `aggkit-proxy-001`, `agglayer-dev-ui-002`, + `agglayer-dev-ui-proxy-002`) are public on GHCR under + `ghcr.io/0xpolygon/kurtosis-cdk-snapshot-` and pull with no + credentials. If a future org policy change makes a new package default + private, see the "GHCR PACKAGE VISIBILITY" note at the top of + kurtosis-cdk's `.github/workflows/snapshot-devui.yml` for the one-time fix. +- The bundle is captured **settlement-free** (no bridge/certificate activity + at capture time — `summary.json`'s `settlement_free: true`); this is a hard + requirement, not an optimization. agglayer/aggkit's internal databases are + not part of the snapshot, so restoring chain state that already contains + in-flight bridge/certificate activity is unsound. Never hand-vendor a + bundle whose `summary.json` doesn't say `settlement_free: true`. +- Every key in `summary.json` (including `accounts.e2e_wallet.private_key` + and the funded-account list) is a public, well-known Kurtosis/Foundry + devnet key. Nothing sensitive is published by vendoring this file. diff --git a/tests/devnet/docker-compose.yml b/tests/devnet/docker-compose.yml new file mode 100644 index 0000000..bc02542 --- /dev/null +++ b/tests/devnet/docker-compose.yml @@ -0,0 +1,245 @@ +# Self-contained devnet snapshot -- flavor: anvil-aggkit +# +# Vendored from 0xPolygon/kurtosis-cdk (branch feat/aggkit-bridge-ui-backend) +# @ fc160450b55e64332436f11c091c61130c64030f. See ./README.md for the full +# regenerate-and-bump procedure -- do not hand-edit an image reference below +# without following it (E2E_ERC20_ADDRESS in .github/workflows/e2e.yaml is +# nonce-dependent per snapshot and MUST be bumped in lockstep if it changes). +# +# Publish run this bundle came from: +# https://github.com/0xPolygon/kurtosis-cdk/actions/runs/31787941750 +# +# Enclave: cdk +# +# v2 bundle (this file): 9 services, not 11 -- the aggkit bridge is now a +# component of the main aggkit-00X process (rpc 5576 + bridge REST 5577 + +# pprof 6060 on one container) instead of a separate `aggkit-00X-bridge` +# service. Every image below is pinned by DIGEST (immutable); the +# human-readable `-` tag is kept only as a +# trailing comment for provenance. Unlike the v1 bundle, there is no more +# SNAPSHOT_IMAGE_PREFIX/SNAPSHOT_IMAGE_TAG override -- re-running the +# kurtosis-cdk publish workflow can never silently move what this file +# resolves to; bumping requires editing this file (see ./README.md). +# +# This file is the ENTIRE bundle: every service's state, config and keystores +# are baked into its image, so `docker compose up -d --wait` works in an +# otherwise empty directory (all 9 images are public and pull anonymously). +# There are deliberately no bind mounts and no volumes -- restarting from +# scratch always replays the captured state. +# +# The only endpoint dev-ui CI needs is the haproxy CORS origin on +# ${DEVNET_PROXY_PORT:-8555}, serving /l1rpc, /l2rpc-00X and /aggkitapi. +# Everything else is published for debugging and can be overridden or +# removed. `agglayer-dev-ui-002` (the baked dev-ui itself, :8557) is behind +# the `devui` compose profile and is NOT started by a plain `up`/`up -d +# --wait` -- CI never uses it (see ./README.md); bring it up with +# `docker compose --profile devui up -d` for manual/local debugging. + +services: + anvil-001: + image: ghcr.io/0xpolygon/kurtosis-cdk-snapshot-anvil-001@sha256:006932fc49ce501c8d6f8c3f4ac3b5873ec14b59b101b4f4e9db02b169e6c0c9 # v1.5.1-1786700247 + hostname: anvil-001 + ports: + - '${L1_RPC_PORT:-8545}:8545' # L1 JSON-RPC (debug) + restart: unless-stopped + healthcheck: + test: ['CMD', '/bin/sh', '/snapshot/healthcheck.sh'] + interval: 3s + timeout: 10s + retries: 40 + start_period: 5s + + l2-anvil-001: + image: ghcr.io/0xpolygon/kurtosis-cdk-snapshot-l2-anvil-001@sha256:e9bbeb7f9a76a4ea725f194059c6b23d49c65e89a8a00241d6df3b687c8ccbb8 # v1.5.1-1786700247 + hostname: l2-anvil-001 + ports: + - '${L2_001_HTTP_PORT:-11545}:8545' # L2 chain 20201 JSON-RPC (debug) + restart: unless-stopped + healthcheck: + test: ['CMD', '/bin/sh', '/snapshot/healthcheck.sh'] + interval: 3s + timeout: 10s + retries: 40 + start_period: 5s + + l2-anvil-002: + image: ghcr.io/0xpolygon/kurtosis-cdk-snapshot-l2-anvil-002@sha256:3555d50518f6f72d5811edd759293ba205ac192c04192695afc046c2cb595ef0 # v1.5.1-1786700247 + hostname: l2-anvil-002 + ports: + - '${L2_002_HTTP_PORT:-12545}:8545' # L2 chain 20202 JSON-RPC (debug) + restart: unless-stopped + healthcheck: + test: ['CMD', '/bin/sh', '/snapshot/healthcheck.sh'] + interval: 3s + timeout: 10s + retries: 40 + start_period: 5s + + agglayer: + image: ghcr.io/0xpolygon/kurtosis-cdk-snapshot-agglayer@sha256:5a47d3778657ba618ff7dfc99dfd55a3863097d5fff4f960a0740d4d0ae80073 # 0.6.0-rc.8-1786700247 + hostname: agglayer + entrypoint: ['/usr/local/bin/agglayer'] + command: ['run', '--cfg', '/etc/agglayer/config.toml'] + environment: + - RUST_BACKTRACE=1 + ports: + - '${AGGLAYER_GRPC_PORT:-4443}:4443' # gRPC + - '${AGGLAYER_READRPC_PORT:-4444}:4444' # read RPC + - '${AGGLAYER_ADMIN_PORT:-4446}:4446' # admin API + - '${AGGLAYER_METRICS_PORT:-9092}:9092' # prometheus + depends_on: + anvil-001: + condition: service_healthy + l2-anvil-001: + condition: service_healthy + l2-anvil-002: + condition: service_healthy + restart: unless-stopped + healthcheck: + # kurtosis-cdk K7b: was `/bin/sh /snapshot/healthcheck.sh` (a busybox + # wget against agglayer's OWN prometheus metrics endpoint, :9092). That + # endpoint can start answering before agglayer's gRPC listener (:4443) + # actually binds -- a Rust async multi-service binary has no obligation + # to bring both up in lockstep -- so `service_healthy` could fire + # early. aggkit-00X's aggsender races that gRPC bind against its own + # claim-syncer autostart on startup; losing the race does not error + # transiently, it WEDGES claim-syncer initialization permanently + # ("cannot set next required block to 0, it must be >= the first block + # in DB" retries forever, so the aggsender's certificate loop for that + # network never starts). This probe is a genuine TCP-connect against + # the gRPC port itself -- bash's builtin /dev/tcp (no curl/wget/nc + # needed), confirmed present in this same upstream agglayer image. + test: ['CMD', 'bash', '-c', 'exec 3<>/dev/tcp/127.0.0.1/4443'] + interval: 2s + timeout: 3s + retries: 60 + start_period: 10s + + aggkit-001: + image: ghcr.io/0xpolygon/kurtosis-cdk-snapshot-aggkit-001@sha256:6497f44d1a81a8f5a00b2a8d719103b8c1b8200c5eb6e2ff5083d33e9431f982 # 0.11.0-rc5-1786700247 + hostname: aggkit-001 + entrypoint: ['/usr/local/bin/aggkit'] + command: + ['run', '--cfg=/etc/aggkit/config.toml', '--components=aggsender,aggoracle,autoclaim,bridge'] + ports: + - '${L2_001_AGGKIT_RPC_PORT:-11576}:5576' # JSON-RPC (debug) + - '${L2_001_AGGKIT_REST_PORT:-11577}:5577' # bridge REST API + depends_on: + anvil-001: + condition: service_healthy + l2-anvil-001: + condition: service_healthy + agglayer: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ['CMD', '/snapshot/busybox', 'sh', '/snapshot/healthcheck.sh'] + interval: 3s + timeout: 10s + retries: 60 + start_period: 10s + + aggkit-002: + image: ghcr.io/0xpolygon/kurtosis-cdk-snapshot-aggkit-002@sha256:aa93b661b6fe8e26fe06633d6e00398090a7f1d4b455cc26f8f75ce4e301bb88 # 0.11.0-rc5-1786700247 + hostname: aggkit-002 + entrypoint: ['/usr/local/bin/aggkit'] + command: + ['run', '--cfg=/etc/aggkit/config.toml', '--components=aggsender,aggoracle,autoclaim,bridge'] + ports: + - '${L2_002_AGGKIT_RPC_PORT:-12576}:5576' # JSON-RPC (debug) + - '${L2_002_AGGKIT_REST_PORT:-12577}:5577' # bridge REST API + depends_on: + anvil-001: + condition: service_healthy + l2-anvil-002: + condition: service_healthy + agglayer: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ['CMD', '/snapshot/busybox', 'sh', '/snapshot/healthcheck.sh'] + interval: 3s + timeout: 10s + retries: 60 + start_period: 10s + + aggkit-proxy-001: + image: ghcr.io/0xpolygon/kurtosis-cdk-snapshot-aggkit-proxy-001@sha256:cd5209a27d5bb2b03bca8fe8e1cf7923381e4eadaf00108162a0523d4fcefc8c # 0.11.0-rc5-1786700247 + hostname: aggkit-proxy-001 + entrypoint: ['/usr/local/bin/aggkit-proxy'] + command: ['run', '--cfg=/etc/aggkit-proxy/config.toml', '--components=proxy,tracker'] + ports: + - '${AGGKIT_PROXY_PORT:-8556}:8080' # bridge + tracker REST + depends_on: + agglayer: + condition: service_healthy + aggkit-001: + condition: service_healthy + aggkit-002: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ['CMD', '/snapshot/busybox', 'sh', '/snapshot/healthcheck.sh'] + interval: 3s + timeout: 10s + retries: 60 + start_period: 10s + + agglayer-dev-ui-002: + image: ghcr.io/0xpolygon/kurtosis-cdk-snapshot-agglayer-dev-ui-002@sha256:3961d9fab267d64a6115464dc6513238fd705a8cc8fa826ca8ba31d65de7629e # dispatch-feat-aggkit-backend-8563dd4ba87-1786700247 + hostname: agglayer-dev-ui-002 + # NOT started by a plain `docker compose up` / `up -d --wait` -- CI never + # uses this container (only bare `/` through haproxy's default_backend + # does, and nothing CI-relevant hits bare `/`). Bring it up for + # manual/local debugging with `docker compose --profile devui up -d`. + profiles: ['devui'] + ports: + - '${DEVUI_PORT:-8557}:80' # dev-ui (manual use, --profile devui) + restart: unless-stopped + healthcheck: + test: ['CMD', '/bin/sh', '/snapshot/healthcheck.sh'] + interval: 3s + timeout: 10s + retries: 40 + start_period: 5s + + agglayer-dev-ui-proxy-002: + image: ghcr.io/0xpolygon/kurtosis-cdk-snapshot-agglayer-dev-ui-proxy-002@sha256:310e3b235599ac25687f9bf1a0b9458705472fd9ca993447663d647866a83332 # 3.2-bookworm-1786700247 + hostname: agglayer-dev-ui-proxy-002 + ports: + - '${DEVNET_PROXY_PORT:-8555}:80' # THE dev-ui CI origin + depends_on: + aggkit-proxy-001: + condition: service_healthy + anvil-001: + condition: service_healthy + l2-anvil-001: + condition: service_healthy + l2-anvil-002: + condition: service_healthy + restart: unless-stopped + # The image's baked /snapshot/healthcheck.sh probes BOTH + # /aggkitapi/bridge/v1/sync-status AND bare / (routed to + # agglayer-dev-ui-002 by haproxy.cfg's default_backend). Since + # agglayer-dev-ui-002 is profile-gated off by default, bare / has no live + # backend and that baked script would fail forever, hanging + # `docker compose up -d --wait`. This compose-level override fully + # replaces the image's HEALTHCHECK and probes only the route CI actually + # needs, reusing the busybox binary already baked into this image. + healthcheck: + test: + [ + 'CMD', + '/snapshot/busybox', + 'wget', + '-q', + '-O', + '/dev/null', + 'http://127.0.0.1:80/aggkitapi/bridge/v1/sync-status?network_id=1' + ] + interval: 3s + timeout: 10s + retries: 60 + start_period: 10s + +# No volumes and no bind mounts: all state and config is baked into the images. diff --git a/tests/devnet/summary.json b/tests/devnet/summary.json new file mode 100644 index 0000000..31be58e --- /dev/null +++ b/tests/devnet/summary.json @@ -0,0 +1,608 @@ +{ + "snapshot_name": "cdk-20260814-093315", + "enclave": "cdk", + "flavor": "anvil-aggkit", + "created_at": "2026-08-14T09:33:46Z", + "captured_at": "2026-08-14T09:33:24Z", + "settlement_free": true, + "agglayer_certificates_at_capture": [ + { + "network_id": 1, + "latest_known_certificate_height": "none", + "status": "none" + }, + { + "network_id": 2, + "latest_known_certificate_height": "none", + "status": "none" + } + ], + "erc20_address": "0xe293A6b8F558422813499bb5C89B60adD8c54636", + "chain_ids": { + "l1": 271828, + "l2_001": 20201, + "l2_002": 20202 + }, + "network_ids": { + "l1": 0, + "l2_001": 1, + "l2_002": 2 + }, + "proxy": { + "service": "agglayer-dev-ui-proxy-002", + "host_port": 8555, + "host_port_env": "DEVNET_PROXY_PORT", + "base_url": "http://127.0.0.1:8555", + "cors": "Access-Control-Allow-Origin: * , OPTIONS answered 204 by haproxy itself", + "routes": [ + { + "path": "/l1rpc", + "url": "http://127.0.0.1:8555/l1rpc", + "kind": "json-rpc", + "upstream": "anvil-001:8545", + "chain_id": 271828, + "network_id": 0 + }, + { + "path": "/l2rpc-001", + "url": "http://127.0.0.1:8555/l2rpc-001", + "kind": "json-rpc", + "upstream": "l2-anvil-001:8545", + "chain_id": 20201, + "network_id": 1 + }, + { + "path": "/l2rpc-002", + "url": "http://127.0.0.1:8555/l2rpc-002", + "kind": "json-rpc", + "upstream": "l2-anvil-002:8545", + "chain_id": 20202, + "network_id": 2 + }, + { + "path": "/l2rpc", + "url": "http://127.0.0.1:8555/l2rpc", + "kind": "json-rpc", + "upstream": "l2-anvil-001:8545", + "chain_id": 20201, + "network_id": 1, + "note": "back-compat alias for the first L2" + }, + { + "path": "/aggkitapi", + "url": "http://127.0.0.1:8555/aggkitapi", + "kind": "rest", + "upstream": "aggkit-proxy-001:8080", + "note": "aggkit-proxy bridge + tracker REST API (path prefix stripped upstream)" + }, + { + "path": "/", + "url": "http://127.0.0.1:8555/", + "kind": "http", + "upstream": "agglayer-dev-ui-002:80", + "note": "default backend: the dev-ui" + } + ] + }, + "aggkit_proxy": { + "service": "aggkit-proxy-001", + "components": "proxy,tracker", + "rest_url": "http://127.0.0.1:8556", + "internal_rest_url": "http://aggkit-proxy-001:8080", + "rest_url_via_proxy": "http://127.0.0.1:8555/aggkitapi", + "bridge_api": "http://127.0.0.1:8555/aggkitapi/bridge/v1", + "tracker_api": "http://127.0.0.1:8555/aggkitapi/tracker/v1", + "sync_status_url": "http://127.0.0.1:8555/aggkitapi/bridge/v1/sync-status?network_id=" + }, + "agglayer": { + "service": "agglayer", + "grpc": "http://agglayer:4443", + "read_rpc": "http://agglayer:4444", + "admin": "http://agglayer:4446", + "metrics": "http://agglayer:9092/metrics" + }, + "dev_ui": { + "service": "agglayer-dev-ui-002", + "image": "ghcr.io/agglayer/agglayer-dev-ui:dispatch-feat-aggkit-backend-8563dd4ba876-31732860787", + "url": "http://127.0.0.1:8557", + "url_via_proxy": "http://127.0.0.1:8555/", + "config_path": "/etc/agglayer-dev-ui/config.json" + }, + "networks": { + "l1": { + "service": "anvil-001", + "chain_id": 271828, + "network_id": 0, + "block_number_at_capture": 236, + "rpc": { + "internal": "http://anvil-001:8545", + "external": "http://127.0.0.1:8545", + "via_proxy": "http://127.0.0.1:8555/l1rpc", + "host_port_env": "L1_RPC_PORT" + }, + "contracts": { + "bridge": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038", + "global_exit_root_v2": "0x1f7ad7caA53e35b4f0D138dC5CBF91aC108a2674", + "rollup_manager": "0x6c6c009cC348976dB4A908c92B24433d4F6edA43", + "pol_token": "0xEdE9cf798E0fE25D35469493f43E88FeA4a5da0E" + } + }, + "l2": { + "001": { + "prefix": "001", + "service": "l2-anvil-001", + "chain_id": 20201, + "network_id": 1, + "block_number_at_capture": 141, + "rpc": { + "internal": "http://l2-anvil-001:8545", + "external": "http://127.0.0.1:11545", + "via_proxy": "http://127.0.0.1:8555/l2rpc-001", + "host_port_env": "L2_001_HTTP_PORT" + }, + "aggkit": { + "service": "aggkit-001", + "components": "aggsender,aggoracle,autoclaim,bridge", + "rpc": { + "internal": "http://aggkit-001:5576", + "external": "http://127.0.0.1:11576" + }, + "rest_api": { + "internal": "http://aggkit-001:5577", + "external": "http://127.0.0.1:11577", + "host_port_env": "L2_001_AGGKIT_REST_PORT" + } + }, + "contracts": { + "sovereign_bridge": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038", + "global_exit_root": "0xa40d5f56745a118d0906a34e69aec8c0db1cb8fa", + "sovereign_rollup_l1": "0x414e9E227e4b589aF92200508aF5399576530E4e" + } + }, + "002": { + "prefix": "002", + "service": "l2-anvil-002", + "chain_id": 20202, + "network_id": 2, + "block_number_at_capture": 29, + "rpc": { + "internal": "http://l2-anvil-002:8545", + "external": "http://127.0.0.1:12545", + "via_proxy": "http://127.0.0.1:8555/l2rpc-002", + "host_port_env": "L2_002_HTTP_PORT" + }, + "aggkit": { + "service": "aggkit-002", + "components": "aggsender,aggoracle,autoclaim,bridge", + "rpc": { + "internal": "http://aggkit-002:5576", + "external": "http://127.0.0.1:12576" + }, + "rest_api": { + "internal": "http://aggkit-002:5577", + "external": "http://127.0.0.1:12577", + "host_port_env": "L2_002_AGGKIT_REST_PORT" + } + }, + "contracts": { + "sovereign_bridge": "0xC8cbEBf950B9Df44d987c8619f092beA980fF038", + "global_exit_root": "0xa40d5f56745a118d0906a34e69aec8c0db1cb8fa", + "sovereign_rollup_l1": "0x5D1A491A416feEbf8C123A558ec28A239960bd0E" + } + } + } + }, + "accounts": { + "e2e_wallet": { + "address": "0xE34aaF64b29273B7D567FCFc40544c014EEe9970", + "private_key": "0x12d7de8621a77640c9241b2595ba78ce443d05e94090365ab3bb5e19df82c625", + "description": "dev-ui e2e signer; funded natively on all chains and holder of erc20_address" + }, + "funded": [ + { + "address": "0x8943545177806ED17B9F23F0a21ee5948eCaa776", + "private_key": "0xbcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31", + "mnemonic_index": 0, + "funded_on": ["l1"], + "balance_at_capture": "0x334902bc112e08dadf81230" + }, + { + "address": "0xE25583099BA105D9ec0A67f5Ae86D90e50036425", + "private_key": "0x39725efee3fb28614de3bacaffe4cc4bd8c436257e2c8bb887c4b5c4be45e76d", + "mnemonic_index": 1, + "funded_on": ["l1"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0x614561D2d143621E126e87831AEF287678B442b8", + "private_key": "0x53321db7c1e331d93a11a41d16f004d7ff63972ec8ec7c25db329728ceeb1710", + "mnemonic_index": 2, + "funded_on": ["l1"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0xf93Ee4Cf8c6c40b329b0c0626F28333c132CF241", + "private_key": "0xab63b23eb7941c1251757e24b3d2350d2bc05c3c388d06f8fe6feafefb1e8c70", + "mnemonic_index": 3, + "funded_on": ["l1"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0x802dCbE1B1A97554B4F50DB5119E37E8e7336417", + "private_key": "0x5d2344259f42259f82d2c140aa66102ba89b57b4883ee441a8b312622bd42491", + "mnemonic_index": 4, + "funded_on": ["l1"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0xAe95d8DA9244C37CaC0a3e16BA966a8e852Bb6D6", + "private_key": "0x27515f805127bebad2fb9b183508bdacb8c763da16f54e0678b16e8f28ef3fff", + "mnemonic_index": 5, + "funded_on": ["l1"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0x2c57d1CFC6d5f8E4182a56b4cf75421472eBAEa4", + "private_key": "0x7ff1a4c1d57e5e784d327c4c7651e952350bc271f156afb3d00d20f5ef924856", + "mnemonic_index": 6, + "funded_on": ["l1"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0x741bFE4802cE1C4b5b00F9Df2F5f179A1C89171A", + "private_key": "0x3a91003acaf4c21b3953d94fa4a6db694fa69e5242b2e37be05dd82761058899", + "mnemonic_index": 7, + "funded_on": ["l1"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0xc3913d4D8bAb4914328651C2EAE817C8b78E1f4c", + "private_key": "0xbb1d0f125b4fb2bb173c318cdead45468474ca71474e2247776b2b4c0fa2d3f5", + "mnemonic_index": 8, + "funded_on": ["l1"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0x65D08a056c17Ae13370565B04cF77D2AfA1cB9FA", + "private_key": "0x850643a0224065ecce3882673c21f56bcf6eef86274cc21cadff15930b59fc8c", + "mnemonic_index": 9, + "funded_on": ["l1"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "mnemonic_index": 0, + "funded_on": ["l2-001", "l2-002"], + "balance_at_capture": "0x33b2e0bd5cda9021e483e80" + }, + { + "address": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "private_key": "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "mnemonic_index": 1, + "funded_on": ["l2-001", "l2-002"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "private_key": "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", + "mnemonic_index": 2, + "funded_on": ["l2-001", "l2-002"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0x90F79bf6EB2c4f870365E785982E1f101E93b906", + "private_key": "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", + "mnemonic_index": 3, + "funded_on": ["l2-001", "l2-002"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", + "private_key": "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", + "mnemonic_index": 4, + "funded_on": ["l2-001", "l2-002"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", + "private_key": "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", + "mnemonic_index": 5, + "funded_on": ["l2-001", "l2-002"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0x976EA74026E726554dB657fA54763abd0C3a0aa9", + "private_key": "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", + "mnemonic_index": 6, + "funded_on": ["l2-001", "l2-002"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955", + "private_key": "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", + "mnemonic_index": 7, + "funded_on": ["l2-001", "l2-002"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f", + "private_key": "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", + "mnemonic_index": 8, + "funded_on": ["l2-001", "l2-002"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + }, + { + "address": "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720", + "private_key": "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6", + "mnemonic_index": 9, + "funded_on": ["l2-001", "l2-002"], + "balance_at_capture": "0x33b2e3c9fd0803ce8000000" + } + ], + "operational": [ + { + "network_id": 1, + "address": "0x5b06837A43bdC3dD9F114558DAf4B26ed49842Ed", + "role": "certificate/proof signer (aggsender)", + "private_key": "(encrypted in keystore -- see keystores[])" + }, + { + "network_id": 2, + "address": "0x5b06837A43bdC3dD9F114558DAf4B26ed49842Ed", + "role": "certificate/proof signer (aggsender)", + "private_key": "(encrypted in keystore -- see keystores[])" + } + ], + "keystores": [ + { + "service": "agglayer", + "path_in_image": "/etc/agglayer/aggregator.keystore", + "role": "agglayer settlement signer" + }, + { + "service": "aggkit-001", + "path_in_image": "/etc/aggkit/sequencer.keystore", + "role": "sequencer / aggsender signer" + }, + { + "service": "aggkit-001", + "path_in_image": "/etc/aggkit/aggoracle.keystore", + "role": "aggoracle + autoclaim signer" + }, + { + "service": "aggkit-001", + "path_in_image": "/etc/aggkit/sovereignadmin.keystore", + "role": "sovereign admin" + }, + { + "service": "aggkit-002", + "path_in_image": "/etc/aggkit/sequencer.keystore", + "role": "sequencer / aggsender signer" + }, + { + "service": "aggkit-002", + "path_in_image": "/etc/aggkit/aggoracle.keystore", + "role": "aggoracle + autoclaim signer" + }, + { + "service": "aggkit-002", + "path_in_image": "/etc/aggkit/sovereignadmin.keystore", + "role": "sovereign admin" + } + ], + "mnemonics": { + "l1": "giant issue aisle success illegal bike spike question tent bar rely arctic volcano long crawl hungry vocal artwork sniff fantasy very lucky have athlete", + "l2": "test test test test test test test test test test test junk" + } + }, + "fixtures": { + "seeded_at": "2026-08-14T09:33:18Z", + "l1_chain_id": 271828, + "e2e_wallet": "0xE34aaF64b29273B7D567FCFc40544c014EEe9970", + "e2e_private_key": "0x12d7de8621a77640c9241b2595ba78ce443d05e94090365ab3bb5e19df82c625", + "e2e_wallet_l1_native_balance_hex": "0x1a78435caa8f2b5974a02", + "erc20": { + "address": "0xe293A6b8F558422813499bb5C89B60adD8c54636", + "name": "Agglayer E2E Token", + "symbol": "E2E", + "decimals": 18, + "initial_supply": "1000000000000000000000", + "holder_balance_hex": "0x00000000000000000000000000000000000000000000003635c9adc5dea00000", + "deploy_tx": "0x0346596dc0358da85a84b966ab7be3fe8f71d5d29950417fe347ef437f637125", + "reused": false, + "foundry_image": "ghcr.io/foundry-rs/foundry:v1.5.1" + } + }, + "images": { + "tag": "cdk-20260814-093324-fc160450b55e", + "image_prefix": "snapshot-", + "busybox_image": "busybox:1.36-musl", + "self_contained": true, + "built_at": "2026-08-14T09:33:37Z", + "services": { + "anvil-001": { + "name": "snapshot-anvil-001:cdk-20260814-093324-fc160450b55e", + "base_image": "ghcr.io/foundry-rs/foundry:v1.5.1", + "size": "475MB", + "tag": "v1.5.1-1786700247", + "digest": "sha256:006932fc49ce501c8d6f8c3f4ac3b5873ec14b59b101b4f4e9db02b169e6c0c9" + }, + "l2-anvil-001": { + "name": "snapshot-l2-anvil-001:cdk-20260814-093324-fc160450b55e", + "base_image": "ghcr.io/foundry-rs/foundry:v1.5.1", + "size": "440MB", + "tag": "v1.5.1-1786700247", + "digest": "sha256:e9bbeb7f9a76a4ea725f194059c6b23d49c65e89a8a00241d6df3b687c8ccbb8" + }, + "l2-anvil-002": { + "name": "snapshot-l2-anvil-002:cdk-20260814-093324-fc160450b55e", + "base_image": "ghcr.io/foundry-rs/foundry:v1.5.1", + "size": "422MB", + "tag": "v1.5.1-1786700247", + "digest": "sha256:3555d50518f6f72d5811edd759293ba205ac192c04192695afc046c2cb595ef0" + }, + "agglayer": { + "name": "snapshot-agglayer:cdk-20260814-093324-fc160450b55e", + "base_image": "ghcr.io/agglayer/agglayer:0.6.0-rc.8", + "size": "3.15GB", + "tag": "0.6.0-rc.8-1786700247", + "digest": "sha256:5a47d3778657ba618ff7dfc99dfd55a3863097d5fff4f960a0740d4d0ae80073" + }, + "aggkit-001": { + "name": "snapshot-aggkit-001:cdk-20260814-093324-fc160450b55e", + "base_image": "ghcr.io/agglayer/aggkit:0.11.0-rc5", + "size": "365MB", + "tag": "0.11.0-rc5-1786700247", + "digest": "sha256:6497f44d1a81a8f5a00b2a8d719103b8c1b8200c5eb6e2ff5083d33e9431f982" + }, + "aggkit-002": { + "name": "snapshot-aggkit-002:cdk-20260814-093324-fc160450b55e", + "base_image": "ghcr.io/agglayer/aggkit:0.11.0-rc5", + "size": "365MB", + "tag": "0.11.0-rc5-1786700247", + "digest": "sha256:aa93b661b6fe8e26fe06633d6e00398090a7f1d4b455cc26f8f75ce4e301bb88" + }, + "aggkit-proxy-001": { + "name": "snapshot-aggkit-proxy-001:cdk-20260814-093324-fc160450b55e", + "base_image": "ghcr.io/agglayer/aggkit:0.11.0-rc5", + "size": "365MB", + "tag": "0.11.0-rc5-1786700247", + "digest": "sha256:cd5209a27d5bb2b03bca8fe8e1cf7923381e4eadaf00108162a0523d4fcefc8c" + }, + "agglayer-dev-ui-proxy-002": { + "name": "snapshot-agglayer-dev-ui-proxy-002:cdk-20260814-093324-fc160450b55e", + "base_image": "europe-west2-docker.pkg.dev/prj-polygonlabs-devtools-dev/virtual/haproxy:3.2-bookworm", + "size": "108MB", + "tag": "3.2-bookworm-1786700247", + "digest": "sha256:310e3b235599ac25687f9bf1a0b9458705472fd9ca993447663d647866a83332" + }, + "agglayer-dev-ui-002": { + "name": "snapshot-agglayer-dev-ui-002:cdk-20260814-093324-fc160450b55e", + "base_image": "ghcr.io/agglayer/agglayer-dev-ui:dispatch-feat-aggkit-backend-8563dd4ba876-31732860787", + "size": "72.2MB", + "tag": "dispatch-feat-aggkit-backend-8563dd4ba87-1786700247", + "digest": "sha256:3961d9fab267d64a6115464dc6513238fd705a8cc8fa826ca8ba31d65de7629e" + } + } + }, + "compose": { + "file": "docker-compose.yml", + "self_contained": true, + "bind_mounts": 0, + "volumes": 0, + "image_prefix_env": "SNAPSHOT_IMAGE_PREFIX", + "image_tag_env": "SNAPSHOT_IMAGE_TAG", + "host_ports": { + "anvil-001": [ + { + "env": "L1_RPC_PORT", + "host_default": 8545, + "container": 8545, + "description": "L1 JSON-RPC (debug)" + } + ], + "l2-anvil-001": [ + { + "env": "L2_001_HTTP_PORT", + "host_default": 11545, + "container": 8545, + "description": "L2 JSON-RPC (debug)" + } + ], + "aggkit-001": [ + { + "env": "L2_001_AGGKIT_RPC_PORT", + "host_default": 11576, + "container": 5576, + "description": "aggkit JSON-RPC (debug)" + }, + { + "env": "L2_001_AGGKIT_REST_PORT", + "host_default": 11577, + "container": 5577, + "description": "aggkit bridge REST API" + } + ], + "l2-anvil-002": [ + { + "env": "L2_002_HTTP_PORT", + "host_default": 12545, + "container": 8545, + "description": "L2 JSON-RPC (debug)" + } + ], + "aggkit-002": [ + { + "env": "L2_002_AGGKIT_RPC_PORT", + "host_default": 12576, + "container": 5576, + "description": "aggkit JSON-RPC (debug)" + }, + { + "env": "L2_002_AGGKIT_REST_PORT", + "host_default": 12577, + "container": 5577, + "description": "aggkit bridge REST API" + } + ], + "agglayer": [ + { + "env": "AGGLAYER_GRPC_PORT", + "host_default": 4443, + "container": 4443, + "description": "agglayer gRPC" + }, + { + "env": "AGGLAYER_READRPC_PORT", + "host_default": 4444, + "container": 4444, + "description": "agglayer read RPC" + }, + { + "env": "AGGLAYER_ADMIN_PORT", + "host_default": 4446, + "container": 4446, + "description": "agglayer admin API" + }, + { + "env": "AGGLAYER_METRICS_PORT", + "host_default": 9092, + "container": 9092, + "description": "agglayer prometheus" + } + ], + "aggkit-proxy-001": [ + { + "env": "AGGKIT_PROXY_PORT", + "host_default": 8556, + "container": 8080, + "description": "aggkit-proxy bridge + tracker REST" + } + ], + "agglayer-dev-ui-002": [ + { + "env": "DEVUI_PORT", + "host_default": 8557, + "container": 80, + "description": "dev-ui (manual use)" + } + ], + "agglayer-dev-ui-proxy-002": [ + { + "env": "DEVNET_PROXY_PORT", + "host_default": 8555, + "container": 80, + "description": "CORS origin used by dev-ui CI" + } + ] + } + }, + "notes": { + "erc20": "erc20_address is nonce-dependent and does NOT match dev-ui DEVNET_KNOWN_ERC20_CANDIDATE. Pass it as E2E_ERC20_ADDRESS so globalSetup skips its own deploy.", + "self_contained": "State, config and keystores are baked into the images. The compose file is the only file needed.", + "settlement_free": "A bundle with settlement_free == false must not be published: agglayer/aggkit internal databases are not captured, so restoring chain state that already contains bridge activity is unsound.", + "restore_adaptation": "aggkit rollupCreationBlockNumber / RollupCreationBlockL1 are repointed at the L1 snapshot block when the images are built: a restored anvil serves state only at the snapshot block and later. See snapshot/scripts/build-images.sh.", + "keys": "All keys here are public kurtosis devnet keys. Nothing sensitive is published." + } +} diff --git a/tests/e2e/appConfig.ts b/tests/e2e/appConfig.ts new file mode 100644 index 0000000..2f00155 --- /dev/null +++ b/tests/e2e/appConfig.ts @@ -0,0 +1,59 @@ +import type { ResolvedAppConfig } from '@/app/config'; +import type { JsonConfig } from '@/app/types/config'; + +import fs from 'node:fs'; +import path from 'node:path'; + +import { initAppConfig } from '@/app/config'; +import { normalizeConfigOrThrow } from '@/config/configLoader.mjs'; + +// Node-side bootstrap for Playwright specs/helpers that need the resolved app +// config outside the browser (which is normally populated by +// app/components/appConfigGate.tsx). Memoized and explicitly called -- never +// relied on as an import side effect (design.md §4.4): preflight.spec.ts +// reads config at module scope, and ESLint's import-ordering rule controls +// the relative evaluation order of sibling imports, so a side-effect import +// would be an ordering landmine. +// +// Deliberately reads + parses config.json itself and calls the shared, +// browser-safe config/configLoader.mjs directly, rather than delegating to +// config/configLoaderNode.mjs's loadConfigFromDiskOrThrow (design.md §4.4's +// literal suggestion). Reason (discovered empirically, not in design.md): +// Playwright's own require-hook-based TS/.mjs transform cannot handle a +// statically-imported .mjs module that references `import.meta` +// (configLoaderNode.mjs's DEFAULT_CONFIG_PATH is derived from +// `import.meta.url`) -- it throws `ReferenceError: exports is not defined in +// ES module scope` the moment such a module is required from a transformed +// .ts file, independent of any alias/path used to reach it (reproduced in an +// isolated minimal playwright.config.ts with no other project code +// involved). config/configLoader.mjs has zero `import.meta`/Node-builtin +// usage and imports fine the same way, so the fix is to keep the disk read +// here (this file is plain CJS-compiled TS, so plain `__dirname` is safe) +// and reuse only the shared normalize/validate path. +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const CONFIG_PATH = path.join(REPO_ROOT, 'config.json'); + +let cachedConfig: ResolvedAppConfig | undefined; + +export const loadAppConfigForNode = (): ResolvedAppConfig => { + if (cachedConfig) return cachedConfig; + + const fileContent = fs.readFileSync(CONFIG_PATH, 'utf8'); + + let rawConfig: unknown; + try { + rawConfig = JSON.parse(fileContent); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown JSON parse error'; + throw new Error(`config.json parse failed: ${message}`); + } + + const origin = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + const configJson = normalizeConfigOrThrow(rawConfig, { + sourceName: 'config.json', + origin + }) as JsonConfig; + + cachedConfig = initAppConfig(configJson); + return cachedConfig; +}; diff --git a/tests/e2e/chainRpc.ts b/tests/e2e/chainRpc.ts new file mode 100644 index 0000000..420cc47 --- /dev/null +++ b/tests/e2e/chainRpc.ts @@ -0,0 +1,30 @@ +import type { Chain } from 'wagmi/chains'; + +import { E2E_FROM_CHAIN_ID } from '@/app/constants/e2e'; +import { loadAppConfigForNode } from '@/tests/e2e/appConfig'; + +// Resolves the E2E "from" chain generically from config.json's chain +// registry (allWagmiChains spans every chain defined there, regardless of +// which app mode is currently active) instead of hardcoding a single +// testnet chain object -- this lets the same helper serve both devnet +// (DEVNET_L1, id 271828) and testnet (Sepolia, id 11155111) mode. +export const getE2EFromChain = (): Chain => { + const { allWagmiChains } = loadAppConfigForNode(); + const chain = allWagmiChains.find((candidate) => candidate.id === E2E_FROM_CHAIN_ID); + if (!chain) { + throw new Error( + `E2E_RPC_MISSING: chain ${E2E_FROM_CHAIN_ID} is not configured in config.json's chains. ` + + 'Run scripts/kurtosisDevnetEnv.mjs (devnet mode) or check E2E_FROM_CHAIN_ID (testnet mode).' + ); + } + return chain; +}; + +export const getE2EFromChainRpcUrl = (): string => { + const chain = getE2EFromChain(); + const rpcUrl = chain.rpcUrls.default.http[0]; + if (!rpcUrl) { + throw new Error(`E2E_RPC_MISSING: chain ${E2E_FROM_CHAIN_ID} has no configured rpcUrl`); + } + return rpcUrl; +}; diff --git a/tests/e2e/erc20Metadata.ts b/tests/e2e/erc20Metadata.ts index 0f86c11..5ec0ca4 100644 --- a/tests/e2e/erc20Metadata.ts +++ b/tests/e2e/erc20Metadata.ts @@ -1,8 +1,7 @@ import type { Address } from 'viem'; -import { getE2EFromChainRpcUrl } from '@/tests/e2e/testnetRpc'; +import { getE2EFromChain, getE2EFromChainRpcUrl } from '@/tests/e2e/chainRpc'; import { createPublicClient, erc20Abi, http } from 'viem'; -import { sepolia } from 'viem/chains'; export interface Erc20Metadata { symbol: string; @@ -12,7 +11,7 @@ export interface Erc20Metadata { export const fetchErc20Metadata = async (address: Address): Promise => { const client = createPublicClient({ - chain: sepolia, + chain: getE2EFromChain(), transport: http(getE2EFromChainRpcUrl()) }); diff --git a/tests/e2e/globalSetup.ts b/tests/e2e/globalSetup.ts new file mode 100644 index 0000000..8f39edc --- /dev/null +++ b/tests/e2e/globalSetup.ts @@ -0,0 +1,217 @@ +import type { FullConfig } from '@playwright/test'; +import type { Address } from 'viem'; + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + DEVNET_KNOWN_ERC20_CANDIDATE, + E2E_BACKEND_MODE, + E2E_PRIVATE_KEY, + E2E_WALLET_ADDRESS +} from '@/app/constants/e2e'; +import { normalizeEnvValue } from '@/app/utils/e2eEnv'; +import { getE2EFromChain, getE2EFromChainRpcUrl } from '@/tests/e2e/chainRpc'; +import { createPublicClient, erc20Abi, http, isAddress } from 'viem'; + +// A minimal, self-mintable ERC20 -- deployed fresh via `forge create` (docker +// wrapper, same pattern S12 manual validation used for the host's +// glibc-incompatible cast/forge) only when neither an explicit +// E2E_ERC20_ADDRESS override nor the known S12 devnet token +// (app/constants/e2e.ts DEVNET_KNOWN_ERC20_CANDIDATE) is still live on this +// enclave. Standard erc20Abi-compatible surface (name/symbol/decimals/ +// balanceOf/allowance/approve/transfer/transferFrom) -- enough for the +// bridge's approve+bridgeAsset flow. +const E2E_TOKEN_SOURCE = `// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract E2EToken { + string public name = "Agglayer E2E Token"; + string public symbol = "E2E"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(uint256 initialSupply) { + totalSupply = initialSupply; + balanceOf[msg.sender] = initialSupply; + emit Transfer(address(0), msg.sender, initialSupply); + } + + function transfer(address to, uint256 amount) external returns (bool) { + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + allowance[from][msg.sender] -= amount; + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + return true; + } +} +`; + +const DOCKER_FOUNDRY_IMAGE = 'ghcr.io/foundry-rs/foundry:latest'; +// 1000 tokens at 18 decimals -- comfortably more than E2E_ERC20_BRIDGE_AMOUNT +// will ever bridge across a full test run. +const INITIAL_SUPPLY = '1000000000000000000000'; + +const runForgeCreate = (workDir: string, rpcUrl: string, privateKey: string): string => { + // NOTE: no `--skip-simulation`. foundry:latest has moved to 1.7.x, whose + // `forge create` no longer accepts that flag; its variadic + // `--constructor-args` then swallows the stray token and forge aborts with + // "Constructor argument count mismatch: expected 1 but got 2". Letting the + // deploy simulate before broadcasting is harmless on this fast devnet L1. + const forgeCmd = + `cd /workspace && forge create src/E2EToken.sol:E2EToken ` + + `--rpc-url ${rpcUrl} --private-key ${privateKey} --broadcast ` + + `--constructor-args ${INITIAL_SUPPLY}`; + + // Same docker-wrapped foundry invocation S12 manual validation established + // for this host (host `cast`/`forge` are glibc-incompatible -- Debian 11, + // glibc 2.31, binaries need 2.32+). The image's entrypoint is a bare + // `/bin/sh -c` with no default args, so the whole command must be a single + // quoted string, not split across argv entries. + // + // Run the container as the *host* uid:gid (not the image's default uid 1000 + // `foundry` user): fs.mkdtempSync creates $workDir mode 0700 owned by the + // host user, so a different in-container uid can't even traverse into the + // bind-mounted /workspace ("cd: can't cd to /workspace" / "Permission + // denied"). Matching uids also means forge's output + solc cache are written + // back owned by the host user, so the finally-block fs.rmSync cleanup works. + // HOME=/workspace gives forge a writable home for its svm/solc install. + const uid = process.getuid?.() ?? 0; + const gid = process.getgid?.() ?? 0; + return execFileSync( + 'sudo', + [ + 'docker', + 'run', + '--rm', + '--network', + 'host', + '--user', + `${uid}:${gid}`, + '-e', + 'HOME=/workspace', + '-v', + `${workDir}:/workspace`, + DOCKER_FOUNDRY_IMAGE, + forgeCmd + ], + { encoding: 'utf8' } + ); +}; + +const deployE2EErc20 = (rpcUrl: string, privateKey: string): Address => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2e-erc20-')); + try { + fs.writeFileSync(path.join(workDir, 'foundry.toml'), '[profile.default]\nsrc = "src"\nout = "out"\n'); + fs.mkdirSync(path.join(workDir, 'src')); + fs.writeFileSync(path.join(workDir, 'src', 'E2EToken.sol'), E2E_TOKEN_SOURCE); + + const output = runForgeCreate(workDir, rpcUrl, privateKey); + const match = output.match(/Deployed to:\s*(0x[a-fA-F0-9]{40})/); + if (!match) { + throw new Error( + `E2E global setup: could not parse a deployed address from forge create's output:\n${output}` + ); + } + return match[1] as Address; + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } +}; + +/** + * Ensures a usable devnet ERC20 exists for tests/bridge/erc20-approve-bridge.spec.ts + * before any spec file runs, and sets process.env.E2E_ERC20_ADDRESS so + * app/constants/e2e.ts resolves it (Playwright's worker processes inherit + * process.env as set by globalSetup at the point workers are forked -- the + * same idiom Playwright's own docs use for injecting an auth token from + * globalSetup into tests). + * + * No-op in testnet mode: that mode uses the fixed, always-funded Sepolia + * USDC address (app/constants/e2e.ts TESTNET_ERC20_ADDRESS internal + * default), which needs no bring-up. + */ +const globalSetup = async (_config: FullConfig): Promise => { + if (E2E_BACKEND_MODE !== 'devnet') { + return; + } + + if (!E2E_PRIVATE_KEY || !E2E_WALLET_ADDRESS) { + throw new Error('E2E global setup: E2E_PRIVATE_KEY did not resolve to a funded wallet.'); + } + const privateKey = E2E_PRIVATE_KEY; + const walletAddress = E2E_WALLET_ADDRESS; + + const chain = getE2EFromChain(); + const rpcUrl = getE2EFromChainRpcUrl(); + const publicClient = createPublicClient({ chain, transport: http(rpcUrl) }); + + const isUsable = async (address: Address): Promise => { + const bytecode = await publicClient.getCode({ address }).catch(() => undefined); + if (!bytecode || bytecode === '0x') return false; + const balance = await publicClient + .readContract({ address, abi: erc20Abi, functionName: 'balanceOf', args: [walletAddress] }) + .catch(() => BigInt(0)); + return balance > BigInt(0); + }; + + const explicitOverride = normalizeEnvValue(process.env.E2E_ERC20_ADDRESS); + if (explicitOverride) { + if (!isAddress(explicitOverride)) { + throw new Error(`E2E global setup: E2E_ERC20_ADDRESS "${explicitOverride}" is not a valid address.`); + } + if (!(await isUsable(explicitOverride))) { + throw new Error( + `E2E global setup: E2E_ERC20_ADDRESS override ${explicitOverride} has no bytecode, or a ` + + `zero balance for the funded E2E wallet, on chain ${chain.id} (${rpcUrl}).` + ); + } + process.stdout.write(`[e2e globalSetup] Using explicit E2E_ERC20_ADDRESS override: ${explicitOverride}\n`); + return; + } + + if (await isUsable(DEVNET_KNOWN_ERC20_CANDIDATE)) { + process.env.E2E_ERC20_ADDRESS = DEVNET_KNOWN_ERC20_CANDIDATE; + process.stdout.write( + `[e2e globalSetup] Reusing known devnet ERC20 ${DEVNET_KNOWN_ERC20_CANDIDATE} (still live, funded).\n` + ); + return; + } + + process.stdout.write( + '[e2e globalSetup] No usable devnet ERC20 found (enclave likely recreated) -- deploying a fresh one...\n' + ); + const deployed = deployE2EErc20(rpcUrl, privateKey); + if (!(await isUsable(deployed))) { + throw new Error(`E2E global setup: freshly deployed ERC20 ${deployed} is not usable after deployment.`); + } + process.env.E2E_ERC20_ADDRESS = deployed; + process.stdout.write(`[e2e globalSetup] Deployed fresh devnet ERC20 at ${deployed}\n`); +}; + +// Playwright's `globalSetup` config option requires the target module's +// default export to be the setup function -- an external API constraint, +// not a style choice. +// eslint-disable-next-line import-x/no-default-export +export default globalSetup; diff --git a/tests/e2e/preflight.spec.ts b/tests/e2e/preflight.spec.ts index 061d242..1886367 100644 --- a/tests/e2e/preflight.spec.ts +++ b/tests/e2e/preflight.spec.ts @@ -1,41 +1,115 @@ import { - E2E_ERC20_ADDRESS, + E2E_BACKEND_MODE, E2E_FROM_CHAIN_ID, E2E_PRIVATE_KEY, E2E_WALLET_ADDRESS } from '@/app/constants/e2e'; -import { getE2EFromChainRpcUrl } from '@/tests/e2e/testnetRpc'; +import { loadAppConfigForNode } from '@/tests/e2e/appConfig'; +import { getE2EFromChain, getE2EFromChainRpcUrl } from '@/tests/e2e/chainRpc'; import { expect, test } from '@playwright/test'; -import { createPublicClient, erc20Abi, http } from 'viem'; +import { createPublicClient, http } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; -import { sepolia } from 'viem/chains'; -const createClient = () => - createPublicClient({ - chain: sepolia, - transport: http(getE2EFromChainRpcUrl()) - }); +// Like the other devnet-specific specs in this suite (tracker.spec.ts, +// console-hygiene.spec.ts, manual-claim.spec.ts, claim-autoclaim.spec.ts), +// this depends on a live Kurtosis `cdk` devnet enclave (or, in CI, the +// vendored devnet bundle): it hits aggkit's sync-status endpoint and checks +// the E2E wallet's on-chain balance against config.json's default app mode. +// Without this gate, a contributor who has switched to +// `E2E_BACKEND_MODE=testnet` (no local devnet running -- see README +// "Testing") would still have this spec try to reach devnet-only +// infrastructure and fail instead of skip. +test.skip( + E2E_BACKEND_MODE !== 'devnet', + 'Preflight checks (aggkit sync-status, devnet wallet funding) are devnet-specific; see the comment above.' +); + +// The active app mode's aggkitBridgeApis is the runtime map fanned out from +// config.json's aggkitProxy (any NEXT_PUBLIC_AGGKIT_PROXY env override +// already merged in -- see app/config.ts) -- reusing it here (rather than +// re-deriving it ourselves) keeps this preflight check pointed at exactly +// what the app itself will call. With the 2-L2 topology this has one entry +// per L2 networkId; devnet gives both the same aggkit-proxy URL, but each is +// iterated separately below so a single dead per-network backend behind the +// proxy is caught per-network rather than assumed identical. +const { appModeConfig, defaultAppMode } = loadAppConfigForNode(); +const aggkitBridgeApiEntries = Object.entries(appModeConfig[defaultAppMode].aggkitBridgeApis); +if (aggkitBridgeApiEntries.length === 0) { + throw new Error( + `E2E preflight: no aggkit backend configured for app mode "${defaultAppMode}". ` + + 'Run scripts/kurtosisDevnetEnv.mjs (devnet mode) or set NEXT_PUBLIC_AGGKIT_PROXY.' + ); +} + +type SyncStatusBody = { + l1_info?: { is_synced?: boolean; is_active?: boolean }; + l2_info?: { is_synced?: boolean; is_active?: boolean }; +}; -test('testnet preflight: funded wallet and rpc are available', async () => { - expect(E2E_PRIVATE_KEY).toMatch(/^0x[0-9a-fA-F]{64}$/); +const assertSyncStatusOk = (body: SyncStatusBody): void => { + // The SDK's AggkitSyncStatus shape (aggkit types.go), verified live + // against the devnet. + expect(body.l1_info).toBeDefined(); + expect(body.l2_info).toBeDefined(); + expect(body.l1_info?.is_synced).toBe(true); + expect(body.l1_info?.is_active).toBe(true); + expect(body.l2_info?.is_synced).toBe(true); + expect(body.l2_info?.is_active).toBe(true); +}; - const client = createClient(); - const chainId = await client.getChainId(); - const account = privateKeyToAccount(E2E_PRIVATE_KEY!); - const expectedAddress = E2E_WALLET_ADDRESS!; +test.describe('preflight: funded wallet and RPC are reachable', () => { + test('funded E2E wallet has a native balance on the configured "from" chain', async () => { + expect(E2E_PRIVATE_KEY).toMatch(/^0x[0-9a-fA-F]{64}$/); - expect(chainId).toBe(E2E_FROM_CHAIN_ID); - expect(account.address.toLowerCase()).toBe(expectedAddress.toLowerCase()); + const client = createPublicClient({ + chain: getE2EFromChain(), + transport: http(getE2EFromChainRpcUrl()) + }); + const chainId = await client.getChainId(); + const account = privateKeyToAccount(E2E_PRIVATE_KEY!); + const expectedAddress = E2E_WALLET_ADDRESS!; - const nativeBalance = await client.getBalance({ address: account.address }); - expect(nativeBalance).toBeGreaterThan(BigInt(0)); + expect(chainId).toBe(E2E_FROM_CHAIN_ID); + expect(account.address.toLowerCase()).toBe(expectedAddress.toLowerCase()); - const tokenBalance = await client.readContract({ - address: E2E_ERC20_ADDRESS, - abi: erc20Abi, - functionName: 'balanceOf', - args: [account.address] + const nativeBalance = await client.getBalance({ address: account.address }); + expect(nativeBalance).toBeGreaterThan(BigInt(0)); }); +}); + +// Replaces the old direct-RPC-only preflight: the backend is now aggkit's +// bridge REST API (fronted by the Kurtosis enclave's CORS-safe proxy in +// devnet mode), not a Bridge Hub. A +// healthy RPC alone no longer implies the app can load activity; these +// checks confirm every configured aggkit network is reachable and synced. +// +// No standalone "health endpoint" check: `GET {baseUrl}/` 404s through +// aggkit-proxy (haproxy strips the `/aggkitapi` prefix and aggkit-proxy only +// registers `ANY /bridge/v1/*any` -- verified live against the devnet +// proxy). `sync-status?network_id=N` is the canonical per-network liveness +// probe instead. +test.describe('preflight: aggkit backend is reachable and synced', () => { + for (const [networkIdString, baseUrl] of aggkitBridgeApiEntries) { + test(`aggkit sync-status reports network ${networkIdString} synced and active`, async ({ + request + }) => { + const response = await request.get( + `${baseUrl}/bridge/v1/sync-status?network_id=${networkIdString}` + ); + expect(response.ok()).toBeTruthy(); + assertSyncStatusOk((await response.json()) as SyncStatusBody); + }); + } - expect(tokenBalance).toBeGreaterThan(BigInt(0)); + // L1 (network_id 0) isn't itself a key of aggkitBridgeApis (that map is + // keyed by L2 networkId) but is reachable through any of + // the same URLs -- GetSyncStatusHandler never reads network_id, it always + // reports its own instance's L1+L2 status. One check + // suffices. + test('aggkit sync-status reports network 0 (L1) synced and active', async ({ request }) => { + const [, baseUrl] = aggkitBridgeApiEntries[0]; + const response = await request.get(`${baseUrl}/bridge/v1/sync-status?network_id=0`); + expect(response.ok()).toBeTruthy(); + assertSyncStatusOk((await response.json()) as SyncStatusBody); + }); }); diff --git a/tests/e2e/testnetRpc.ts b/tests/e2e/testnetRpc.ts deleted file mode 100644 index 6fd0ec0..0000000 --- a/tests/e2e/testnetRpc.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { APP_MODE_CONFIG } from '@/app/config'; -import { E2E_FROM_CHAIN_ID } from '@/app/constants/e2e'; - -export const getE2EFromChainRpcUrl = (): string => { - for (const config of Object.values(APP_MODE_CONFIG)) { - const chain = config.chains.find((chain) => chain.id === E2E_FROM_CHAIN_ID); - if (chain) return chain.rpcUrl; - } - - throw new Error(`E2E_RPC_MISSING: chain ${E2E_FROM_CHAIN_ID} is not configured in any app mode`); -}; diff --git a/tsconfig.json b/tsconfig.json index 3a13f90..ed22096 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,7 +23,9 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, "include": [ @@ -28,7 +34,11 @@ "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", - "**/*.mts" + "**/*.mts", + ".next-partial-failure/types/**/*.ts", + ".next-partial-failure/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index 372c5a1..2b994c5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,12 +1,29 @@ +import path from 'node:path'; + import react from '@vitejs/plugin-react'; import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [react()], + // Mirrors tsconfig.json's `@/*` -> `./*` path mapping (Next.js resolves + // this natively; Vite/Vitest does not without an explicit alias). Needed + // so tests can import app code the same way the app itself does — the + // only pre-existing test (app/utils/address.test.ts) sidestepped this by + // using a relative import, which isn't viable once a hook/component under + // test pulls in its own `@/app/...` dependencies (S8). + resolve: { + alias: { + '@': path.resolve(__dirname, '.') + } + }, test: { environment: 'jsdom', globals: true, - include: ['app/**/*.{test,spec}.{ts,tsx}', 'src/**/*.{test,spec}.{ts,tsx}'], + include: [ + 'app/**/*.{test,spec}.{ts,tsx}', + 'src/**/*.{test,spec}.{ts,tsx}', + 'config/**/*.{test,spec}.mjs' + ], exclude: ['node_modules/**', '.next/**', 'out/**', 'tests/**'] } }); diff --git a/wrangler.toml b/wrangler.toml index f71e900..1aa3a10 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,4 +1,4 @@ -name = "bridge-hub-ui" +name = "agglayer-dev-ui" compatibility_date = "2024-07-04" placement = { mode = "smart" } workers_dev = false