diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 252e8a56..382a61dc 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -65,6 +65,6 @@ Located in `src/types/index.ts`: - **Vite** (`vite.config.ts`) - Fast bundler with TypeScript, CSS, and asset loading - **GitHub Pages**: Set `GITHUB_PAGES=true` for `/explorer/` base path - **Environment Variables**: Injected via Vite's `define` option: - - `REACT_APP_COMMIT_HASH` - Git commit hash - - `REACT_APP_OPENSCAN_NETWORKS` - Comma-separated chain IDs to display - - `REACT_APP_ENVIRONMENT` - production/development + - `OPENSCAN_COMMIT_HASH` - Git commit hash + - `OPENSCAN_NETWORKS` - Comma-separated chain IDs to display + - `OPENSCAN_ENVIRONMENT` - production/development diff --git a/.claude/rules/commands.md b/.claude/rules/commands.md index d65e9750..1bdcd4be 100644 --- a/.claude/rules/commands.md +++ b/.claude/rules/commands.md @@ -50,9 +50,15 @@ npm run test:run # Run unit tests in watch mode npm run test -# Run e2e tests (Playwright) +# Run e2e tests (Playwright) — both `chromium` (live) and `mocked` projects npm run test:e2e +# Run a single spec file +npx playwright test e2e/tests/shared/errors.spec.ts + +# Run only the chromium project (skips hermetic `shared/mocked/` specs) +npx playwright test --project=chromium + # Run e2e tests with UI npm run test:e2e:ui @@ -87,7 +93,7 @@ Networks are defined in `src/config/networks.ts`. To control which networks are ```bash # Show only specific networks (comma-separated chain IDs) -REACT_APP_OPENSCAN_NETWORKS="1,31337" npm start +OPENSCAN_NETWORKS="1,31337" npm start # Show all networks (default) npm start diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a4d54452 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + # Keep third-party GitHub Actions pinned to SHAs up to date. Dependabot + # updates the pinned SHA and the trailing `# vX` tag comment together. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" diff --git a/.github/workflows/deploy-gh-pages.yml b/.github/workflows/deploy-gh-pages.yml index a543e6d6..63284900 100644 --- a/.github/workflows/deploy-gh-pages.yml +++ b/.github/workflows/deploy-gh-pages.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest diff --git a/.github/workflows/deploy-pr-preview.yml b/.github/workflows/deploy-pr-preview.yml deleted file mode 100644 index 84504055..00000000 --- a/.github/workflows/deploy-pr-preview.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Build and Deploy PR Preview - -on: - pull_request_target: - types: [opened, synchronize, reopened] - -permissions: - contents: read - pull-requests: write - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout PR head - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build Staging - run: ./scripts/build-staging.sh - - - name: Deploy to Netlify - id: netlify - uses: nwtgck/actions-netlify@v3 - with: - publish-dir: './dist' - production-deploy: false - github-token: ${{ secrets.GITHUB_TOKEN }} - deploy-message: "PR Preview #${{ github.event.pull_request.number }}" - alias: pr-${{ github.event.pull_request.number }} - enable-pull-request-comment: false - enable-commit-comment: false - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} - - - name: Comment PR with Preview URL - uses: actions/github-script@v7 - with: - script: | - const prNumber = ${{ github.event.pull_request.number }}; - const deployUrl = '${{ steps.netlify.outputs.deploy-url }}'; - const body = `šŸš€ **Preview:** ${deployUrl}\nšŸ“ **Commit:** \`${{ github.event.pull_request.head.sha }}\``; - - // Find existing comment - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - }); - - const botComment = comments.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('Preview:') - ); - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: body - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: body - }); - } diff --git a/.github/workflows/e2e-all.yml b/.github/workflows/e2e-all.yml index ddad65dd..320cd4a0 100644 --- a/.github/workflows/e2e-all.yml +++ b/.github/workflows/e2e-all.yml @@ -2,6 +2,8 @@ name: E2E Tests - All on: workflow_dispatch: + # Called by `e2e-nightly.yml`. + workflow_call: jobs: e2e-eth-mainnet: @@ -15,3 +17,15 @@ jobs: e2e-bitcoin: uses: ./.github/workflows/e2e-bitcoin.yml secrets: inherit + + e2e-shared: + uses: ./.github/workflows/e2e-shared.yml + secrets: inherit + + e2e-solana: + uses: ./.github/workflows/e2e-solana.yml + secrets: inherit + + e2e-testnets: + uses: ./.github/workflows/e2e-testnets.yml + secrets: inherit diff --git a/.github/workflows/e2e-bitcoin.yml b/.github/workflows/e2e-bitcoin.yml index 6290c347..0b6323a0 100644 --- a/.github/workflows/e2e-bitcoin.yml +++ b/.github/workflows/e2e-bitcoin.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest diff --git a/.github/workflows/e2e-eth-mainnet.yml b/.github/workflows/e2e-eth-mainnet.yml index 98eb0e88..f89d882c 100644 --- a/.github/workflows/e2e-eth-mainnet.yml +++ b/.github/workflows/e2e-eth-mainnet.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest diff --git a/.github/workflows/e2e-evm-networks.yml b/.github/workflows/e2e-evm-networks.yml index c309fca8..11242068 100644 --- a/.github/workflows/e2e-evm-networks.yml +++ b/.github/workflows/e2e-evm-networks.yml @@ -24,12 +24,16 @@ jobs: tests: "e2e/tests/evm-networks/bsc.spec.ts" - name: polygon tests: "e2e/tests/evm-networks/polygon.spec.ts" + - name: avalanche + tests: "e2e/tests/evm-networks/avalanche.spec.ts" + - name: l2-fields + tests: "e2e/tests/evm-networks/l2-fields.spec.ts" steps: - uses: actions/checkout@v4 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest diff --git a/.github/workflows/e2e-nightly.yml b/.github/workflows/e2e-nightly.yml new file mode 100644 index 00000000..617a19b4 --- /dev/null +++ b/.github/workflows/e2e-nightly.yml @@ -0,0 +1,22 @@ +name: E2E Tests - Nightly + +# Run the full e2e matrix once a day against live RPCs. Catches +# provider-side drift (method deprecations, response schema changes, +# stale data) between PR cycles without gating merges on cron-scheduled +# flakes. +# +# Failures here do not block merges — they're a signal to investigate. +# If a test starts failing consistently in nightly but passes on PR, the +# root cause is almost always upstream (provider change, chain upgrade), +# not the PR. + +on: + schedule: + # 06:00 UTC — after US-west engineers are offline, before EU starts. + - cron: "0 6 * * *" + workflow_dispatch: + +jobs: + e2e-all: + uses: ./.github/workflows/e2e-all.yml + secrets: inherit diff --git a/.github/workflows/e2e-shared.yml b/.github/workflows/e2e-shared.yml new file mode 100644 index 00000000..04bcf967 --- /dev/null +++ b/.github/workflows/e2e-shared.yml @@ -0,0 +1,62 @@ +name: E2E Tests - Shared (cross-network) + +on: + pull_request: + branches: [main] + workflow_call: + workflow_dispatch: + +jobs: + e2e-shared: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: + # Live (chromium project): cross-network specs that don't need + # hermetic RPC control. + - name: errors + tests: "e2e/tests/shared/errors.spec.ts" + - name: search + tests: "e2e/tests/shared/search.spec.ts" + - name: settings + tests: "e2e/tests/shared/settings.spec.ts" + - name: contract-interaction + tests: "e2e/tests/shared/contract-interaction.spec.ts" + - name: ai-and-worker + tests: "e2e/tests/shared/ai-and-worker.spec.ts" + - name: event-logs + tests: "e2e/tests/shared/event-logs.spec.ts" + + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + + - name: Build application + run: ./scripts/build-production.sh + + - name: Run Shared E2E tests (${{ matrix.shard.name }}) + run: bunx playwright test ${{ matrix.shard.tests }} --project=chromium + env: + CI: true + INFURA_API_KEY: ${{ secrets.INFURA_API_KEY }} + ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report-shared-${{ matrix.shard.name }} + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/e2e-solana.yml b/.github/workflows/e2e-solana.yml new file mode 100644 index 00000000..b4e899e2 --- /dev/null +++ b/.github/workflows/e2e-solana.yml @@ -0,0 +1,49 @@ +name: E2E Tests - Solana + +on: + pull_request: + branches: [main] + workflow_call: + workflow_dispatch: + +# Solana smoke specs discover their RPC endpoints through the +# `@openscan/metadata` CDN (same path the app uses), so no dedicated +# Solana RPC secret is required. If the metadata fetch is slow in CI the +# `buildRpcUrls` helper also seeds no-op from env vars; the default +# fallback is public RPC, which is rate-limited and may cause occasional +# retries — the test-level retry budget in `playwright.config.ts` +# absorbs this. + +jobs: + e2e-solana: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + + - name: Build application + run: ./scripts/build-production.sh + + - name: Run Solana E2E smoke + run: bunx playwright test e2e/tests/solana/ --project=chromium + env: + CI: true + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report-solana + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/e2e-testnets.yml b/.github/workflows/e2e-testnets.yml new file mode 100644 index 00000000..d1583ce7 --- /dev/null +++ b/.github/workflows/e2e-testnets.yml @@ -0,0 +1,50 @@ +name: E2E Tests - Testnets + +on: + pull_request: + branches: [main] + workflow_call: + workflow_dispatch: + +# Testnet smoke. RPCs are discovered via `@openscan/metadata` (same path +# the app uses) so no per-testnet secret is required. Public testnet +# endpoints can be rate-limited and intermittently slow — Playwright's +# per-test retry budget absorbs most of that. If a specific testnet +# starts systematically flaking, open a dedicated follow-up rather than +# hiding the signal by relaxing timeouts here. + +jobs: + e2e-testnets: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + + - name: Build application + run: ./scripts/build-production.sh + + - name: Run Testnet E2E smoke + run: bunx playwright test e2e/tests/testnets/ --project=chromium + env: + CI: true + INFURA_API_KEY: ${{ secrets.INFURA_API_KEY }} + ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report-testnets + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/hash-deploy-build.yml b/.github/workflows/hash-deploy-build.yml index 13c1f48e..6549d750 100644 --- a/.github/workflows/hash-deploy-build.yml +++ b/.github/workflows/hash-deploy-build.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest @@ -26,17 +26,29 @@ jobs: - name: Build Production run: ./scripts/build-production.sh - - name: Upload to Storacha - id: upload - uses: storacha/add-to-web3@v4 - with: - path_to_add: './dist' - secret_key: ${{ secrets.STORACHA_PRINCIPAL }} - proof: ${{ secrets.STORACHA_PROOF }} + - name: Install Kubo (IPFS CLI) + run: | + wget -q https://dist.ipfs.tech/kubo/v0.34.1/kubo_v0.34.1_linux-amd64.tar.gz + tar -xzf kubo_v0.34.1_linux-amd64.tar.gz + sudo cp kubo/ipfs /usr/local/bin/ + ipfs init --profile=badgerds + ipfs version + + - name: Generate IPFS Hash + id: ipfs + run: | + HASH=$(ipfs add -r -Q --chunker=size-262144 --raw-leaves=false ./dist) + HASH_V1=$(ipfs cid format -v 1 -b base32 $HASH) + + echo "hash_v0=$HASH" >> $GITHUB_OUTPUT + echo "hash_v1=$HASH_V1" >> $GITHUB_OUTPUT + + echo "IPFS Hash (v0): $HASH" + echo "IPFS Hash (v1): $HASH_V1" - name: Update IPFS Hash in Repo run: | - HASH="${{ steps.upload.outputs.cid }}" + HASH="${{ steps.ipfs.outputs.hash_v0 }}" # Create shields.io endpoint format JSON mkdir -p /tmp/ipfs-meta diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cc28b071..df2580f8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,6 +15,6 @@ jobs: with: persist-credentials: false - name: Setup Biome - uses: biomejs/setup-biome@v2 + uses: biomejs/setup-biome@4c91541eaada48f67d7dbd7833600ce162b68f51 # v2 - name: Run Biome run: biome ci . \ No newline at end of file diff --git a/.github/workflows/pr-preview-build.yml b/.github/workflows/pr-preview-build.yml new file mode 100644 index 00000000..333f583c --- /dev/null +++ b/.github/workflows/pr-preview-build.yml @@ -0,0 +1,45 @@ +name: Build PR Preview + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build Staging + run: ./scripts/build-staging.sh + + - name: Write PR metadata + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_SHA: ${{ github.event.pull_request.head.sha }} + run: | + mkdir -p pr-meta + printf '%s' "$PR_NUMBER" > pr-meta/pr-number + printf '%s' "$PR_SHA" > pr-meta/pr-sha + + - name: Upload preview artifact + uses: actions/upload-artifact@v4 + with: + name: pr-preview + path: | + dist + pr-meta + retention-days: 1 + if-no-files-found: error diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml new file mode 100644 index 00000000..cd02bfcd --- /dev/null +++ b/.github/workflows/pr-preview-deploy.yml @@ -0,0 +1,98 @@ +name: Deploy PR Preview + +on: + workflow_run: + workflows: ["Build PR Preview"] + types: [completed] + +permissions: + contents: read + pull-requests: write + actions: read + +jobs: + deploy: + runs-on: ubuntu-latest + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' + steps: + - name: Download preview artifact + uses: actions/download-artifact@v4 + with: + name: pr-preview + path: artifact + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Validate PR metadata + id: meta + run: | + set -euo pipefail + pr_number=$(cat artifact/pr-meta/pr-number) + pr_sha=$(cat artifact/pr-meta/pr-sha) + if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then + echo "Invalid PR number: $pr_number" >&2 + exit 1 + fi + if ! [[ "$pr_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid PR SHA: $pr_sha" >&2 + exit 1 + fi + echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" + echo "pr_sha=$pr_sha" >> "$GITHUB_OUTPUT" + + - name: Deploy to Netlify + id: netlify + uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3 + with: + publish-dir: artifact/dist + production-deploy: false + github-token: ${{ secrets.GITHUB_TOKEN }} + deploy-message: "PR Preview #${{ steps.meta.outputs.pr_number }}" + alias: pr-${{ steps.meta.outputs.pr_number }} + enable-pull-request-comment: false + enable-commit-comment: false + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + + - name: Comment PR with Preview URL + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ steps.meta.outputs.pr_number }} + PR_SHA: ${{ steps.meta.outputs.pr_sha }} + DEPLOY_URL: ${{ steps.netlify.outputs.deploy-url }} + with: + script: | + const prNumber = Number(process.env.PR_NUMBER); + const prSha = process.env.PR_SHA; + const deployUrl = process.env.DEPLOY_URL; + const body = `šŸš€ **Preview:** ${deployUrl}\nšŸ“ **Commit:** \`${prSha}\``; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Preview:') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body + }); + } diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 87819183..e70481b1 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -14,10 +14,10 @@ jobs: id-token: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest @@ -28,13 +28,13 @@ jobs: run: bash ./scripts/build-development.sh - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: "24" registry-url: "https://registry.npmjs.org" - name: Publish to npm - uses: JS-DevTools/npm-publish@v4 + uses: JS-DevTools/npm-publish@0fd2f4369c5d6bcfcde6091a7c527d810b9b5c3f # v4 with: registry: "https://registry.npmjs.org/" package: ./dist diff --git a/README.md b/README.md index b29d95e2..fb6ee43d 100644 --- a/README.md +++ b/README.md @@ -153,28 +153,76 @@ npm run lint:fix ### End-to-End Tests -The project uses Playwright for E2E testing against Ethereum mainnet data. +The project uses Playwright to test the explorer against live blockchain +data. Specs are organized by scope: + +``` +e2e/ + fixtures/ # shared helpers (networks.ts, assertions.ts, + # rpcMock.ts, localStorage.ts) and per-network + # fixture tables (mainnet.ts, arbitrum.ts, …) + pages/ # page objects (block, tx, address, blocks list, …) + tests/ + eth-mainnet/ # deep Ethereum mainnet coverage + evm-networks/ # per-chain specs (arbitrum, base, optimism, bsc, + # polygon, avalanche, l2-fields, x402-facilitator) + bitcoin/ # Bitcoin block / tx / address + solana/ # Solana mainnet smoke + testnets/ # Sepolia + L2 sepolias + Polygon Amoy + Fuji smoke + shared/ # cross-network specs (search, errors, settings, + # contract-interaction, ai-and-worker, event-logs) + shared/mocked/ # hermetic specs run under the `mocked` Playwright + # project (RPC stubbed via page.route) +``` ```bash -# Run all E2E tests +# Run all E2E tests (both `chromium` and `mocked` projects) npm run test:e2e -# Run tests with UI mode (for debugging) +# Run a single spec file +npx playwright test e2e/tests/shared/errors.spec.ts + +# UI mode (for debugging) npm run test:e2e:ui -# Run tests in debug mode +# Debug mode npm run test:e2e:debug ``` -**Test Coverage:** - -- **Block Page** - Pre/post London blocks, hash fields, navigation -- **Transaction Page** - Legacy and EIP-1559 transactions, from/to addresses, gas info -- **Address Page** - EOA balances, ENS names, ERC20/ERC721/ERC1155 contracts -- **Token Details** - NFT metadata, properties, token URI, collection info -- **Contract Interaction** - Verified contract functions, events, verification status - -Tests run automatically on every PR via GitHub Actions. +**Test coverage:** + +- Block / tx / address pages on Ethereum, Arbitrum, Optimism, Base, BSC, + Polygon, Avalanche (EVM) plus Bitcoin and Solana. +- L2-specific fields — Arbitrum `l1BlockNumber` / `sendCount` / `sendRoot` + and OP-stack `l1Fee` / `l1GasPrice` / `l1GasUsed`. +- Token details — ERC-20, ERC-721, ERC-1155 metadata. +- Verified contract interaction — read/write function sections. +- Cross-network — global search, error paths, settings persistence, AI + Analysis panel rendering, event-log decoding. +- Smoke coverage for the 6 EVM testnets registered in + `src/config/networks.json`. + +**CI triggers:** + +- `pull_request` → `main`: runs `e2e-eth-mainnet`, `e2e-evm-networks`, + `e2e-bitcoin`, `e2e-shared`, `e2e-solana`, `e2e-testnets`. +- Nightly (`e2e-nightly.yml`, 06:00 UTC): full matrix against live + RPCs to catch provider-side drift between PR cycles. Does not gate + merges. + +**Adding a new spec:** + +1. A new production network → add a row to + `e2e/fixtures/networks.ts`; add the shard name to the matching CI + workflow. +2. A cross-network feature (search, settings, …) → drop a spec into + `e2e/tests/shared/` and add a shard entry to + `.github/workflows/e2e-shared.yml`. +3. A network-specific feature (per-chain deep dive) → add to + `e2e/tests/evm-networks/.spec.ts` (live) or a new file, and + extend the evm-networks workflow matrix. +4. A hermetic / mocked test → drop it into `e2e/tests/shared/mocked/`; + Playwright's `mocked` project picks it up automatically. ## Configuration @@ -197,7 +245,7 @@ chmod +x .git/hooks/pre-commit ### Environment Variables -#### `REACT_APP_OPENSCAN_NETWORKS` +#### `OPENSCAN_NETWORKS` Controls which networks are displayed in the application. This is useful for limiting the explorer to specific chains. @@ -205,19 +253,19 @@ Controls which networks are displayed in the application. This is useful for lim **Default:** If not set, all supported networks are enabled. -**Note:** The Localhost network (31337) is only visible in development mode. To enable it in production/staging, explicitly include it in `REACT_APP_OPENSCAN_NETWORKS`. +**Note:** The Localhost network (31337) is only visible in development mode. To enable it in production/staging, explicitly include it in `OPENSCAN_NETWORKS`. **Examples:** ```bash # Show only Ethereum Mainnet and Localhost -REACT_APP_OPENSCAN_NETWORKS="1,31337" npm start +OPENSCAN_NETWORKS="1,31337" npm start # Show only Layer 2 networks -REACT_APP_OPENSCAN_NETWORKS="42161,10,8453" npm start +OPENSCAN_NETWORKS="42161,10,8453" npm start # Show only testnets -REACT_APP_OPENSCAN_NETWORKS="11155111,97" npm start +OPENSCAN_NETWORKS="11155111,97" npm start ``` The networks will be displayed in the order specified in the environment variable. diff --git a/bun.lock b/bun.lock index 45917fce..fdc6f9cb 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "openscan", "dependencies": { "@erc7730/sdk": "^0.1.3", - "@openscan/network-connectors": "1.6.0", + "@openscan/network-connectors": "1.7", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", "@tanstack/react-query": "^5.90.21", @@ -35,8 +35,8 @@ "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^5.1.2", "dotenv": "^17.2.3", - "happy-dom": "^20.1.0", - "vite": "^7.3.1", + "happy-dom": "^20.8.9", + "vite": "^7.3.2", "vitest": "^4.0.14", }, }, @@ -44,6 +44,20 @@ "overrides": { "@noble/curves": "^1.8.0", "@noble/hashes": "^1.8.0", + "axios": "^1.15.2", + "bn.js": "^5.2.3", + "brace-expansion": ">=1.1.13", + "defu": "^6.1.5", + "follow-redirects": "^1.15.12", + "h3": "^1.15.9", + "hono": "^4.12.16", + "lodash": "^4.17.24", + "minimatch": ">=3.1.4", + "picomatch": ">=2.3.2", + "postcss": "^8.5.10", + "rollup": "^4.59.0", + "socket.io-parser": "^4.2.6", + "yaml": "^2.8.3", }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], @@ -282,7 +296,7 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "@openscan/network-connectors": ["@openscan/network-connectors@1.6.0", "", {}, "sha512-f7Ox20+NIJ4DXzcbj0OyyuVjiHB0zjm1xv+yhzrYhh6TfW6bfHpq62+++oF6/5ud5VLptRXSkdaMrLcAOJL0vQ=="], + "@openscan/network-connectors": ["@openscan/network-connectors@1.7.0", "", {}, "sha512-eXW/r2AxWLEogm5eZBpqZ/BEunlG9fcCN5pf6piXncetEhf3soN1JLX5aSAOQ5fYKF3M0lIcnDB9uaPdq3n6nA=="], "@paulmillr/qr": ["@paulmillr/qr@0.2.1", "", {}, "sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ=="], @@ -332,55 +346,55 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="], "@safe-global/safe-apps-provider": ["@safe-global/safe-apps-provider@0.18.6", "", { "dependencies": { "@safe-global/safe-apps-sdk": "^9.1.0", "events": "^3.3.0" } }, "sha512-4LhMmjPWlIO8TTDC2AwLk44XKXaK6hfBTWyljDm0HQ6TWlOEijVWNrt2s3OCVMSxlXAcEzYfqyu1daHZooTC2Q=="], @@ -542,7 +556,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], + "@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], @@ -678,7 +692,7 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], + "axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="], "axios-retry": ["axios-retry@4.5.0", "", { "dependencies": { "is-retry-allowed": "^2.2.0" }, "peerDependencies": { "axios": "0.x || 1.x" } }, "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ=="], @@ -696,7 +710,7 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="], @@ -706,13 +720,13 @@ "big.js": ["big.js@6.2.2", "", {}, "sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ=="], - "bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="], + "bn.js": ["bn.js@5.2.3", "", {}, "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w=="], "borsh": ["borsh@0.7.0", "", { "dependencies": { "bn.js": "^5.2.0", "bs58": "^4.0.0", "text-encoding-utf-8": "^1.0.2" } }, "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA=="], "bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="], - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -778,15 +792,13 @@ "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], + "cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], @@ -830,7 +842,7 @@ "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], "delay": ["delay@5.0.0", "", {}, "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw=="], @@ -880,7 +892,7 @@ "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], @@ -972,7 +984,7 @@ "flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="], - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], @@ -1006,9 +1018,9 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "h3": ["h3@1.15.5", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg=="], + "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], - "happy-dom": ["happy-dom@20.3.2", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^4.5.0", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-5FLOM+dI9dCdR7pzcfqqDF/SNX3J8Qytc9iO+nnpfGR434Ynwz4O9d7NEWL1JJEAouFLGZGQsSmMpf90VHfi0A=="], + "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -1030,7 +1042,7 @@ "hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], - "hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], + "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], @@ -1172,7 +1184,7 @@ "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="], @@ -1300,7 +1312,7 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "mipd": ["mipd@0.0.7", "", { "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg=="], @@ -1402,7 +1414,7 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], "preact": ["preact@10.24.2", "", {}, "sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q=="], @@ -1420,7 +1432,7 @@ "proxy-compare": ["proxy-compare@2.6.0", "", {}, "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], @@ -1486,7 +1498,7 @@ "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - "rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="], + "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], "rpc-websockets": ["rpc-websockets@9.3.2", "", { "dependencies": { "@swc/helpers": "^0.5.11", "@types/uuid": "^8.3.4", "@types/ws": "^8.2.2", "buffer": "^6.0.3", "eventemitter3": "^5.0.1", "uuid": "^8.3.2", "ws": "^8.5.0" }, "optionalDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" } }, "sha512-VuW2xJDnl1k8n8kjbdRSWawPRkwaVqUQNjE1TdeTawf0y0abGhtVJFTXCLfgpgGDBkO/Fj6kny8Dc/nvOW78MA=="], @@ -1532,7 +1544,7 @@ "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], - "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], + "socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="], "sonic-boom": ["sonic-boom@2.8.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg=="], @@ -1638,7 +1650,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -1682,7 +1694,7 @@ "viem": ["viem@2.44.4", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.11.3", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-sJDLVl2EsS5Fo7GSWZME5CXEV7QRYkUJPeBw7ac+4XI3D4ydvMw/gjulTsT5pgqcpu70BploFnOAC6DLpan1Yg=="], - "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], "vitest": ["vitest@4.0.17", "", { "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17", "@vitest/pretty-format": "4.0.17", "@vitest/runner": "4.0.17", "@vitest/snapshot": "4.0.17", "@vitest/spy": "4.0.17", "@vitest/utils": "4.0.17", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.17", "@vitest/browser-preview": "4.0.17", "@vitest/browser-webdriverio": "4.0.17", "@vitest/ui": "4.0.17", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg=="], @@ -1730,7 +1742,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -1762,12 +1774,6 @@ "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], - "@jest/environment/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - - "@jest/fake-timers/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - - "@jest/types/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine": ["@metamask/json-rpc-engine@7.3.3", "", { "dependencies": { "@metamask/rpc-errors": "^6.2.1", "@metamask/safe-event-emitter": "^3.0.0", "@metamask/utils": "^8.3.0" } }, "sha512-dwZPq8wx9yV3IX2caLi9q9xZBw2XeIoYqdyihDDDpuHVCEiqadJLwqM3zy+uwf6F1QYQ65A8aOMQg1Uw7LMLNg=="], "@metamask/eth-json-rpc-provider/@metamask/utils": ["@metamask/utils@5.0.2", "", { "dependencies": { "@ethereumjs/tx": "^4.1.2", "@types/debug": "^4.1.7", "debug": "^4.3.4", "semver": "^7.3.8", "superstruct": "^1.0.3" } }, "sha512-yfmE79bRQtnMzarnKfX7AEJBwFTxvTyw3nBQlu/5rmGXrjAeAMltoGxO62TFurxrQAFMNa/fEjIHNvungZp0+g=="], @@ -1846,12 +1852,6 @@ "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], - "@types/connect/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - - "@types/graceful-fs/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - - "@types/ws/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - "@walletconnect/environment/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "@walletconnect/events/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -1876,8 +1876,6 @@ "@walletconnect/window-metadata/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "borsh/bs58": ["bs58@4.0.1", "", { "dependencies": { "base-x": "^3.0.2" } }, "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw=="], "cbw-sdk/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], @@ -1886,10 +1884,6 @@ "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "chrome-launcher/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - - "chromium-edge-launcher/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "duplexify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -1904,8 +1898,6 @@ "ethereum-cryptography/@scure/bip39": ["@scure/bip39@1.3.0", "", { "dependencies": { "@noble/hashes": "~1.4.0", "@scure/base": "~1.1.6" } }, "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ=="], - "ethers/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - "ethers/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], "extension-port-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -1922,24 +1914,12 @@ "jayson/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], - "jest-environment-node/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - - "jest-haste-map/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - "jest-message-util/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], - "jest-mock/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - - "jest-util/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - "jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], - "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "jest-validate/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], - "jest-worker/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "json-rpc-engine/@metamask/safe-event-emitter": ["@metamask/safe-event-emitter@2.0.0", "", {}, "sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q=="], @@ -1956,8 +1936,6 @@ "metro-symbolicate/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "ox/@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -2014,12 +1992,6 @@ "@coinbase/wallet-sdk/ox/@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], - "@jest/environment/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - - "@jest/fake-timers/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - - "@jest/types/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/rpc-errors": ["@metamask/rpc-errors@6.4.0", "", { "dependencies": { "@metamask/utils": "^9.0.0", "fast-safe-stringify": "^2.0.6" } }, "sha512-1ugFO1UoirU2esS3juZanS/Fo8C8XYocCuBpfZI5N7ECtoG+zu0wF+uWZASik6CkO6w9n/Iebt4iI4pT0vptpg=="], "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/utils": ["@metamask/utils@8.5.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.0.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "debug": "^4.3.4", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-I6bkduevXb72TIM9q2LRO63JSsF9EXduh3sBr9oybNX2hNNpr/j1tEjXrsG0Uabm4MJ1xkGAQEMwifvKZIkyxQ=="], @@ -2070,12 +2042,6 @@ "@solana/web3.js/bs58/base-x": ["base-x@3.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA=="], - "@types/connect/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - - "@types/graceful-fs/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - - "@types/ws/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - "@walletconnect/utils/viem/@scure/bip32": ["@scure/bip32@1.6.2", "", { "dependencies": { "@noble/curves": "~1.8.1", "@noble/hashes": "~1.7.1", "@scure/base": "~1.2.2" } }, "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw=="], "@walletconnect/utils/viem/@scure/bip39": ["@scure/bip39@1.5.4", "", { "dependencies": { "@noble/hashes": "~1.7.1", "@scure/base": "~1.2.4" } }, "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA=="], @@ -2090,10 +2056,6 @@ "borsh/bs58/base-x": ["base-x@3.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA=="], - "chrome-launcher/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - - "chromium-edge-launcher/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "eth-block-tracker/@metamask/utils/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -2104,26 +2066,14 @@ "ethereum-cryptography/@scure/bip39/@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="], - "ethers/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "jayson/@types/ws/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], - "jest-environment-node/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - - "jest-haste-map/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - "jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "jest-mock/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - - "jest-util/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - "jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "jest-worker/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "porto/ox/@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], @@ -2182,8 +2132,6 @@ "borsh/bs58/base-x/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "jayson/@types/ws/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], - "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "qrcode/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], diff --git a/e2e/fixtures/assertions.ts b/e2e/fixtures/assertions.ts new file mode 100644 index 00000000..4109ed1d --- /dev/null +++ b/e2e/fixtures/assertions.ts @@ -0,0 +1,17 @@ +import { expect, type Page } from "@playwright/test"; +import { DEFAULT_TIMEOUT } from "../helpers/wait"; + +/** Selector for the app footer — presence proves the tree mounted. */ +const FOOTER_SELECTOR = "footer, .app-footer, [role='contentinfo']"; + +/** + * Assert the React root is still mounted and the footer rendered. Used by + * smoke specs to catch the "white-screen crash" failure mode without + * coupling to network-specific UI copy. + */ +export async function expectStillMounted(page: Page): Promise { + await expect(page.locator("#root")).toBeVisible({ timeout: DEFAULT_TIMEOUT }); + await expect(page.locator(FOOTER_SELECTOR).first()).toBeVisible({ + timeout: DEFAULT_TIMEOUT * 2, + }); +} diff --git a/e2e/fixtures/assertionsL2.ts b/e2e/fixtures/assertionsL2.ts new file mode 100644 index 00000000..39f9385f --- /dev/null +++ b/e2e/fixtures/assertionsL2.ts @@ -0,0 +1,72 @@ +import { expect, type Page } from "@playwright/test"; +import { DEFAULT_TIMEOUT } from "../helpers/wait"; + +/** + * L2-specific assertion helpers. Each checks that the fields an L2 adapter + * exists to surface are actually rendered on the page. Selectors use the + * i18n-rendered English labels (from `src/locales/en/{transaction,block}.json`) + * which are the field labels the UI renders above their values. + * + * The app ships five English locales; run the suite with the default (en) + * so these matches succeed. + * + * Where each field is rendered (per `TransactionDisplay.tsx` and + * `BlockDisplay.tsx`): + * - Arbitrum tx: L1 Block Number (receipt field) + * - Arbitrum block: Send Count, Send Root + * - OP Stack tx: L1 Fee, L1 Gas Price, L1 Gas Used + * - Post-Dencun block: Blob Gas Used, Excess Blob Gas + * + * Assertions use a generous timeout (4Ɨ DEFAULT_TIMEOUT) because L2 RPCs + * on public endpoints are slower than mainnet and the fields only appear + * after the receipt fetch completes, not just the tx-by-hash. + */ + +const L2_ASSERTION_TIMEOUT = DEFAULT_TIMEOUT * 4; + +/** Arbitrum tx receipt includes `l1BlockNumber`. */ +export async function expectArbitrumTxL1Fields(page: Page): Promise { + await expect(page.getByText(/L1\s*Block\s*Number/i)).toBeVisible({ + timeout: L2_ASSERTION_TIMEOUT, + }); +} + +/** Arbitrum block exposes L2→L1 message fields: `sendCount`, `sendRoot`. */ +export async function expectArbitrumBlockFields(page: Page): Promise { + await expect(page.getByText(/Send\s*Count/i)).toBeVisible({ + timeout: L2_ASSERTION_TIMEOUT, + }); + await expect(page.getByText(/Send\s*Root/i)).toBeVisible({ + timeout: L2_ASSERTION_TIMEOUT, + }); +} + +/** + * OP Stack (Optimism / Base) tx surfaces the L1 fee breakdown. + * Match `L1 Fee` but not `L1 Fee Scalar` — they're separate rows. + */ +export async function expectOpStackTxL1Fee(page: Page): Promise { + await expect(page.getByText(/L1\s*Fee(?!\s*Scalar)/i).first()).toBeVisible({ + timeout: L2_ASSERTION_TIMEOUT, + }); + await expect(page.getByText(/L1\s*Gas\s*Price/i)).toBeVisible({ + timeout: L2_ASSERTION_TIMEOUT, + }); + await expect(page.getByText(/L1\s*Gas\s*Used/i)).toBeVisible({ + timeout: L2_ASSERTION_TIMEOUT, + }); +} + +/** + * Post-Dencun block with blob-carrying txs renders `blobGasUsed` and + * `excessBlobGas`. The BlockDisplay gates on `blobGasUsed > 0` so pick a + * block that actually includes a blob tx, not any post-fork block. + */ +export async function expectBlobFields(page: Page): Promise { + await expect(page.getByText(/Blob\s*Gas\s*Used/i)).toBeVisible({ + timeout: L2_ASSERTION_TIMEOUT, + }); + await expect(page.getByText(/Excess\s*Blob\s*Gas/i)).toBeVisible({ + timeout: L2_ASSERTION_TIMEOUT, + }); +} diff --git a/e2e/fixtures/localStorage.ts b/e2e/fixtures/localStorage.ts new file mode 100644 index 00000000..d2c4894d --- /dev/null +++ b/e2e/fixtures/localStorage.ts @@ -0,0 +1,121 @@ +import type { Page } from "@playwright/test"; + +/** + * Helpers for resetting and seeding the app's localStorage between tests. + * + * The explorer persists several things to localStorage that can bleed across + * tests when parallelism is on. The real keys (checked against + * `src/context/SettingsContext.tsx`, `src/utils/configExportImport.ts`, + * `src/hooks/useRpcAutoSync.ts`, and `src/utils/artifactsStorage.ts`) are: + * + * - `openScan_user_settings` — bundled UserSettings JSON (theme, + * rpcStrategy, apiKeys, …) + * - `openScan_language` — top-level language override + * - `OPENSCAN_RPC_URLS_V3` — custom RPC URL map per networkId + * - `OPENSCAN_ARTIFACTS_JSON_V1` — imported Hardhat Ignition artifacts + * - `openScan_lastRpcSyncTime` — RPC auto-sync timestamp + * - `openscan_cache` — generic in-app cache + * + * Any spec that exercises settings should call `clearAppState` in + * `beforeEach`. + */ + +const APP_STORAGE_KEYS = [ + "openScan_user_settings", + "openScan_language", + "OPENSCAN_RPC_URLS_V3", + "OPENSCAN_ARTIFACTS_JSON_V1", + "openScan_lastRpcSyncTime", + "openscan_cache", +]; + +/** The real settings storage key; fields live bundled inside this JSON blob. */ +export const SETTINGS_STORAGE_KEY = "openScan_user_settings"; + +/** + * Remove every known OpenScan localStorage entry. Call after the page has + * loaded at least once (so `localStorage` is accessible) — typically: + * + * test.beforeEach(async ({ page }) => { + * await page.goto("/"); + * await clearAppState(page); + * await page.reload(); + * }); + */ +export async function clearAppState(page: Page): Promise { + await page.evaluate((keys) => { + for (const key of keys) localStorage.removeItem(key); + }, APP_STORAGE_KEYS); +} + +/** Seed `openScan_language` before the app initializes. */ +export async function setLanguage(page: Page, code: string): Promise { + await page.addInitScript((lang) => { + localStorage.setItem("openScan_language", lang); + }, code); +} + +/** + * Seed an override into the bundled `openScan_user_settings` blob before the + * app initializes. Merges with any existing JSON so seeding `theme` doesn't + * wipe other fields a prior test set. + */ +export async function setUserSetting( + page: Page, + patch: Record, +): Promise { + const serialized = JSON.stringify(patch); + const key = SETTINGS_STORAGE_KEY; + await page.addInitScript( + ({ key, serialized }) => { + const raw = localStorage.getItem(key); + let existing: Record = {}; + if (raw) { + try { + existing = JSON.parse(raw); + } catch { + existing = {}; + } + } + const next = { ...existing, ...JSON.parse(serialized) }; + localStorage.setItem(key, JSON.stringify(next)); + }, + { key, serialized }, + ); +} + +export async function setTheme(page: Page, theme: "light" | "dark" | "auto"): Promise { + await setUserSetting(page, { theme }); +} + +export async function setRpcStrategy( + page: Page, + strategy: "fallback" | "parallel" | "race", +): Promise { + await setUserSetting(page, { rpcStrategy: strategy }); +} + +export async function readLocalStorage(page: Page, key: string): Promise { + return page.evaluate((k) => localStorage.getItem(k), key); +} + +/** Read a single field from the bundled `openScan_user_settings` blob. */ +export async function readUserSetting( + page: Page, + field: string, +): Promise { + const key = SETTINGS_STORAGE_KEY; + return page.evaluate( + ({ key, field }) => { + const raw = localStorage.getItem(key); + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as Record; + return parsed[field] as unknown; + } catch { + return undefined; + } + }, + { key, field }, + ) as Promise; +} diff --git a/e2e/fixtures/mainnet.ts b/e2e/fixtures/mainnet.ts index f9cefc38..1eda31db 100644 --- a/e2e/fixtures/mainnet.ts +++ b/e2e/fixtures/mainnet.ts @@ -173,16 +173,14 @@ export const MAINNET = { // No gasPrice field in Type 2 transactions }, - // USDC approval transaction - common ERC20 interaction (Type 2) - "0xc55e2b90168af6972193c1f86fa4d7d7b31a29c156665d15b9cd48618b5177ef": { - hash: "0xc55e2b90168af6972193c1f86fa4d7d7b31a29c156665d15b9cd48618b5177ef", - type: 2, - from: "0x28C6c06298d514Db089934071355E5743bf21d60", - to: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - blockNumber: 15537394, - status: "success" as const, - hasInputData: true, - }, + // (Removed) `0xc55e2b90168af69721…` was labeled "USDC approval (Type 2)" + // with blockNumber 15537394 — but on-chain this hash resolves to a 2016 + // Binance transfer at block 2,000,000 with `input: "0x"` (plain ETH + // transfer, no calldata). USDC didn't deploy until 2018. The metadata + // here was fabricated; any test that navigated to this hash would see + // the real, unrelated tx and fail. Removed to avoid misleading future + // contributors; use `TX_WITH_INPUT_DATA` (the Type 2 entry above) for + // specs that need a tx with calldata. // ============================================ // BLOB TRANSACTIONS (Type 3) - Post-Dencun (EIP-4844) diff --git a/e2e/fixtures/networks.ts b/e2e/fixtures/networks.ts new file mode 100644 index 00000000..a7b7b50c --- /dev/null +++ b/e2e/fixtures/networks.ts @@ -0,0 +1,277 @@ +/** + * Single source of truth for cross-network e2e specs. Each entry carries + * the minimum stable data a network-agnostic test needs: chain id, URL slug, + * adapter family, and a handful of canonical fixtures (block / tx / address + * / token) chosen for long-term stability. + * + * Network-specific specs keep their detailed per-field fixture files + * (`mainnet.ts`, `arbitrum.ts`, …) and import those directly. This table is + * for specs that iterate over many networks at once — search, errors, + * testnets smoke, settings, etc. + * + * Slug values match `src/config/networks.json` exactly. + * `urlPath` is what the existing specs put in the `/:networkId` route + * segment: chainId (stringified) for EVM, slug for Bitcoin / Solana. + */ + +export type AdapterFamily = + | "evm" + | "arbitrum" + | "optimism" + | "base" + | "polygon" + | "bnb" + | "bitcoin" + | "solana"; + +export interface NetworkFixture { + /** Numeric EVM chain id as a string, or CAIP-2 for non-EVM chains. */ + chainId: string; + /** The slug as declared in `src/config/networks.json`. */ + slug: string; + name: string; + family: AdapterFamily; + isTestnet: boolean; + /** What to put in the `/:networkId` URL segment — chainId for EVM, slug + * for Bitcoin / Solana. Matches existing per-network spec conventions. */ + urlPath: string; + /** Pinned historical block; never a "latest" number. */ + canonicalBlock: number | string; + /** Well-known tx hash that will never be pruned. */ + canonicalTxHash: string; + /** Foundation / treasury / canonical contract — balance may change, + * existence won't. */ + canonicalAddress: string; + /** Optional ERC-20 or ERC-721 contract used by token-page smoke tests. */ + canonicalToken?: string; + /** Optional ENS name that resolves on this network (mainnet only). */ + canonicalEns?: string; +} + +// ---------- Production EVM ---------- + +export const ETH_MAINNET: NetworkFixture = { + chainId: "1", + slug: "eth", + name: "Ethereum", + family: "evm", + isTestnet: false, + urlPath: "1", + canonicalBlock: 20_000_000, + canonicalTxHash: "0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060", + canonicalAddress: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", // vitalik.eth + canonicalToken: "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", // BAYC + canonicalEns: "vitalik.eth", +}; + +export const ARBITRUM: NetworkFixture = { + chainId: "42161", + slug: "arb", + name: "Arbitrum One", + family: "arbitrum", + isTestnet: false, + urlPath: "42161", + canonicalBlock: 200_000_000, + canonicalTxHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + canonicalAddress: "0x912CE59144191C1204E64559FE8253a0e49E6548", // ARB token + canonicalToken: "0x912CE59144191C1204E64559FE8253a0e49E6548", +}; + +export const OPTIMISM: NetworkFixture = { + chainId: "10", + slug: "op", + name: "Optimism", + family: "optimism", + isTestnet: false, + urlPath: "10", + canonicalBlock: 117_000_000, + canonicalTxHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + canonicalAddress: "0x4200000000000000000000000000000000000042", // OP token + canonicalToken: "0x4200000000000000000000000000000000000042", +}; + +export const BASE: NetworkFixture = { + chainId: "8453", + slug: "base", + name: "Base", + family: "base", + isTestnet: false, + urlPath: "8453", + canonicalBlock: 11_000_000, + canonicalTxHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + canonicalAddress: "0x4200000000000000000000000000000000000006", // WETH on Base + canonicalToken: "0x4200000000000000000000000000000000000006", +}; + +export const BSC: NetworkFixture = { + chainId: "56", + slug: "bsc", + name: "BNB Chain", + family: "bnb", + isTestnet: false, + urlPath: "56", + canonicalBlock: 40_000_000, + canonicalTxHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + canonicalAddress: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", // WBNB + canonicalToken: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", +}; + +export const POLYGON: NetworkFixture = { + chainId: "137", + slug: "polygon", + name: "Polygon", + family: "polygon", + isTestnet: false, + urlPath: "137", + canonicalBlock: 60_000_000, + canonicalTxHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + canonicalAddress: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", // WMATIC/POL + canonicalToken: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", +}; + +export const AVALANCHE: NetworkFixture = { + chainId: "43114", + slug: "avax", + name: "Avalanche", + family: "evm", + isTestnet: false, + urlPath: "43114", + canonicalBlock: 40_000_000, + canonicalTxHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + canonicalAddress: "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7", // WAVAX + canonicalToken: "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7", +}; + +// ---------- Production non-EVM ---------- + +export const BITCOIN: NetworkFixture = { + chainId: "bip122:000000000019d6689c085ae165831e93", + slug: "btc", + name: "Bitcoin", + family: "bitcoin", + isTestnet: false, + urlPath: "btc", + canonicalBlock: 481_824, // SegWit activation + canonicalTxHash: + "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", // first pizza tx + canonicalAddress: "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", // genesis coinbase +}; + +export const SOLANA: NetworkFixture = { + chainId: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + slug: "sol", + name: "Solana", + family: "solana", + isTestnet: false, + urlPath: "sol", + canonicalBlock: 250_000_000, // slot; re-pin per-spec if a specific slot needed + canonicalTxHash: + "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW", + canonicalAddress: "11111111111111111111111111111111", // system program +}; + +// ---------- EVM Testnets (metadata 1.2.1-alpha.0 + legacy) ---------- + +export const SEPOLIA: NetworkFixture = { + chainId: "11155111", + slug: "sepolia", + name: "Sepolia", + family: "evm", + isTestnet: true, + urlPath: "11155111", + canonicalBlock: 5_000_000, + canonicalTxHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + canonicalAddress: "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", // Sepolia WETH9 +}; + +export const ARB_SEPOLIA: NetworkFixture = { + chainId: "421614", + slug: "arb-sepolia", + name: "Arbitrum Sepolia", + family: "arbitrum", + isTestnet: true, + urlPath: "421614", + canonicalBlock: 100_000_000, + canonicalTxHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + canonicalAddress: "0x980B62Da83eFf3D4576C647993b0c1D7faf17c73", +}; + +export const OP_SEPOLIA: NetworkFixture = { + chainId: "11155420", + slug: "op-sepolia", + name: "Optimism Sepolia", + family: "optimism", + isTestnet: true, + urlPath: "11155420", + canonicalBlock: 20_000_000, + canonicalTxHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + canonicalAddress: "0x4200000000000000000000000000000000000006", +}; + +export const BASE_SEPOLIA: NetworkFixture = { + chainId: "84532", + slug: "base-sepolia", + name: "Base Sepolia", + family: "base", + isTestnet: true, + urlPath: "84532", + canonicalBlock: 15_000_000, + canonicalTxHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + canonicalAddress: "0x4200000000000000000000000000000000000006", +}; + +export const POLYGON_AMOY: NetworkFixture = { + chainId: "80002", + slug: "polygon-amoy", + name: "Polygon Amoy", + family: "polygon", + isTestnet: true, + urlPath: "80002", + canonicalBlock: 10_000_000, + canonicalTxHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + canonicalAddress: "0x0000000000000000000000000000000000000000", +}; + +export const AVAX_FUJI: NetworkFixture = { + chainId: "43113", + slug: "avax-fuji", + name: "Avalanche Fuji", + family: "evm", + isTestnet: true, + urlPath: "43113", + canonicalBlock: 30_000_000, + canonicalTxHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + canonicalAddress: "0x0000000000000000000000000000000000000000", +}; + +// ---------- Groupings ---------- + +export const EVM_PRODUCTION: NetworkFixture[] = [ + ETH_MAINNET, + ARBITRUM, + OPTIMISM, + BASE, + BSC, + POLYGON, + AVALANCHE, +]; + +export const EVM_TESTNETS: NetworkFixture[] = [ + SEPOLIA, + ARB_SEPOLIA, + OP_SEPOLIA, + BASE_SEPOLIA, + POLYGON_AMOY, + AVAX_FUJI, +]; + +export const ALL_PRODUCTION: NetworkFixture[] = [...EVM_PRODUCTION, BITCOIN, SOLANA]; + +export const L2_NETWORKS: NetworkFixture[] = [ARBITRUM, OPTIMISM, BASE, POLYGON]; + +export const ALL_NETWORKS: NetworkFixture[] = [...ALL_PRODUCTION, ...EVM_TESTNETS]; + +// Note on the placeholder `0x…0001` tx hashes above: smoke specs only rely on +// the page rendering without crashing. Specs that need a real payload should +// override the canonical tx with one pinned inside the network-specific +// fixture file (e.g. `arbitrum.ts`). diff --git a/e2e/fixtures/rpcMock.ts b/e2e/fixtures/rpcMock.ts new file mode 100644 index 00000000..b4700f25 --- /dev/null +++ b/e2e/fixtures/rpcMock.ts @@ -0,0 +1,145 @@ +import type { Page, Route } from "@playwright/test"; + +/** + * Playwright route helpers for simulating RPC-layer failures and serving + * canned JSON-RPC responses. + * + * Use these in the `mocked` Playwright project (see `playwright.config.ts`) + * where the test drives the app against a synthetic RPC endpoint rather than + * a live provider. This keeps strategy / error-path / worker-fallover tests + * deterministic. + * + * Typical usage: + * await mockJsonRpc(page, "https://mock-rpc.local/", { + * eth_blockNumber: () => "0x10", + * eth_chainId: () => "0x1", + * }); + */ + +type RpcMethodHandler = + | ((params: unknown[]) => unknown) + | { result: unknown } + | { error: { code: number; message: string } }; + +export interface MockOptions { + /** Return HTTP status instead of a JSON-RPC response. Overrides handlers. */ + httpStatus?: number; + /** Delay before responding, in ms. */ + delayMs?: number; + /** Abort the request entirely (simulates ECONNREFUSED). */ + abort?: boolean; +} + +/** + * Intercept all requests matching `urlPattern` and respond with canned JSON-RPC + * results keyed by method name. Methods not listed return + * `{ error: { code: -32601, message: "method not found" } }`. + */ +export async function mockJsonRpc( + page: Page, + urlPattern: string | RegExp, + handlers: Record = {}, + options: MockOptions = {}, +): Promise { + await page.route(urlPattern, async (route: Route) => { + if (options.abort) { + await route.abort("connectionrefused"); + return; + } + if (options.delayMs) { + await new Promise((r) => setTimeout(r, options.delayMs)); + } + if (options.httpStatus && options.httpStatus >= 400) { + await route.fulfill({ + status: options.httpStatus, + contentType: "application/json", + body: JSON.stringify({ error: `HTTP ${options.httpStatus}` }), + }); + return; + } + + const req = route.request(); + const postDataRaw = req.postData() ?? "{}"; + let parsed: { id?: number | string; method?: string; params?: unknown[] } = {}; + try { + parsed = JSON.parse(postDataRaw); + } catch { + // fall through — empty method + } + const { id = 1, method = "", params = [] } = parsed; + + const handler = handlers[method]; + let body: unknown; + if (!handler) { + body = { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `method ${method} not mocked` }, + }; + } else if (typeof handler === "function") { + body = { jsonrpc: "2.0", id, result: handler(params) }; + } else if ("result" in handler) { + body = { jsonrpc: "2.0", id, result: handler.result }; + } else { + body = { jsonrpc: "2.0", id, error: handler.error }; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); + }); +} + +/** + * Return an HTTP error (useful for worker fallover tests: Cloudflare 503 → + * Vercel primary). + */ +export async function mockHttpError( + page: Page, + urlPattern: string | RegExp, + status: number, +): Promise { + await page.route(urlPattern, async (route) => { + await route.fulfill({ + status, + contentType: "text/plain", + body: `mock ${status}`, + }); + }); +} + +/** Simulate rate-limiting on the first N requests, then succeed. */ +export async function mock429ThenSuccess( + page: Page, + urlPattern: string | RegExp, + failuresBeforeSuccess: number, + successBody: unknown, +): Promise { + let calls = 0; + await page.route(urlPattern, async (route) => { + calls += 1; + if (calls <= failuresBeforeSuccess) { + await route.fulfill({ + status: 429, + contentType: "application/json", + headers: { "retry-after": "0" }, + body: JSON.stringify({ error: "rate limited" }), + }); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(successBody), + }); + }); +} + +/** Abort every matching request — simulates total RPC outage. */ +export async function mockOffline(page: Page, urlPattern: string | RegExp): Promise { + await page.route(urlPattern, async (route) => { + await route.abort("connectionrefused"); + }); +} diff --git a/e2e/fixtures/test.ts b/e2e/fixtures/test.ts index 8a3649c1..bc8409d6 100644 --- a/e2e/fixtures/test.ts +++ b/e2e/fixtures/test.ts @@ -5,9 +5,9 @@ import { buildRpcUrls } from "../helpers/rpc"; const customRpcUrls = buildRpcUrls(); /** - * Custom test fixture that: - * 1. Injects Infura/Alchemy RPC URLs via localStorage (if API keys are set) - * 2. Increases timeout on retries + * Custom test fixture that injects Infura/Alchemy RPC URLs via localStorage + * when e2e secrets are present, so tests run against private endpoints + * instead of public rate-limited ones. * * RPC keys can be set via environment variables: * INFURA_API_KEY=your_key @@ -18,7 +18,6 @@ const customRpcUrls = buildRpcUrls(); export const test = base.extend({ page: async ({ page }, use) => { if (customRpcUrls) { - // Inject RPC URLs into localStorage before the app initializes const rpcJson = JSON.stringify(customRpcUrls); await page.addInitScript((json) => { localStorage.setItem("OPENSCAN_RPC_URLS_V3", json); @@ -28,12 +27,21 @@ export const test = base.extend({ }, }); -const BASE_TIMEOUT = 60000; -const TIMEOUT_INCREMENT = 20000; // 20 seconds per retry +// Fixed 60s timeout. A retry gets an extra 30s — enough slack for a cold +// provider round-trip without masking genuine flakiness behind an unbounded +// growth schedule. +const BASE_TIMEOUT = 60_000; +const RETRY_BONUS = 30_000; test.beforeEach(async ({}, testInfo) => { - const newTimeout = BASE_TIMEOUT + TIMEOUT_INCREMENT * testInfo.retry; - testInfo.setTimeout(newTimeout); + const budget = BASE_TIMEOUT + (testInfo.retry > 0 ? RETRY_BONUS : 0); + testInfo.setTimeout(budget); + if (testInfo.retry > 0) { + // Surface retries so flakiness is visible in the CI log. + console.warn( + `[e2e] retry ${testInfo.retry} for "${testInfo.title}" (timeout=${budget}ms)`, + ); + } }); export { expect } from "@playwright/test"; diff --git a/e2e/tests/eth-mainnet/blocks.spec.ts b/e2e/tests/eth-mainnet/blocks.spec.ts index ad8657be..16a400ea 100644 --- a/e2e/tests/eth-mainnet/blocks.spec.ts +++ b/e2e/tests/eth-mainnet/blocks.spec.ts @@ -7,12 +7,14 @@ test.describe("Blocks Page", () => { const blocksPage = new BlocksPage(page); await blocksPage.goto("1"); - // Wait for loader to disappear - await expect(blocksPage.loader).toBeHidden({ timeout: DEFAULT_TIMEOUT * 3 }); + // The Blocks component renders a skeleton table (no `.loader-container`) + // while loading, then swaps the whole tree for the data branch which is + // the one that mounts `.blocks-header-main`. Waiting on the loader is a + // no-op — anchor on the data-branch element with an RPC-sized budget. + await expect(blocksPage.blocksHeaderMain).toBeVisible({ timeout: DEFAULT_TIMEOUT * 3 }); // Verify header structure await expect(blocksPage.blocksHeader).toBeVisible(); - await expect(blocksPage.blocksHeaderMain).toBeVisible(); await expect(blocksPage.blockLabel).toBeVisible(); await expect(blocksPage.blockLabel).toHaveText("Ethereum Mainnet Blocks"); @@ -51,10 +53,11 @@ test.describe("Blocks Page", () => { const blocksPage = new BlocksPage(page); await blocksPage.goto("1"); - await expect(blocksPage.loader).toBeHidden({ timeout: DEFAULT_TIMEOUT * 3 }); + // `.loader-container` doesn't exist in this component (skeleton table + // only), so anchor on the data-branch element with an RPC-sized budget. + await expect(blocksPage.blocksHeaderMain).toBeVisible({ timeout: DEFAULT_TIMEOUT * 3 }); // Verify header main container has flex layout elements - await expect(blocksPage.blocksHeaderMain).toBeVisible(); await expect(blocksPage.blockLabel).toBeVisible(); // Verify divider is present @@ -94,7 +97,9 @@ test.describe("Blocks Page", () => { const blocksPage = new BlocksPage(page); await blocksPage.goto("1"); - await expect(blocksPage.loader).toBeHidden({ timeout: DEFAULT_TIMEOUT * 3 }); + // Wait for the data branch to mount; `.loader-container` doesn't exist + // on this page so waiting for it to hide is a no-op. + await expect(blocksPage.blocksHeaderMain).toBeVisible({ timeout: DEFAULT_TIMEOUT * 3 }); // On latest page, Latest and Newer should be disabled await expect(blocksPage.latestBtn).toBeDisabled(); @@ -103,14 +108,16 @@ test.describe("Blocks Page", () => { // Older should be enabled await expect(blocksPage.olderBtn).toBeEnabled(); - // Click Older button + // Click Older button and wait for the URL param to apply — this is the + // signal that the navigate has actually happened, rather than hoping + // the next render arrives within a default 5s poll. await blocksPage.olderBtn.click(); + await page.waitForURL(/fromBlock=/, { timeout: DEFAULT_TIMEOUT * 3 }); - // Wait for new blocks to load - await expect(blocksPage.loader).toBeHidden({ timeout: DEFAULT_TIMEOUT * 3 }); - - // Now Newer should be enabled - await expect(blocksPage.newerBtn).toBeEnabled(); + // After navigation, the Newer button must become enabled. The state + // transition depends on an RPC round-trip for the older page's data; + // give it a full RPC budget rather than the default 5s. + await expect(blocksPage.newerBtn).toBeEnabled({ timeout: DEFAULT_TIMEOUT * 3 }); }); test("navigates between block pages correctly", async ({ page }) => { diff --git a/e2e/tests/eth-mainnet/transaction.spec.ts b/e2e/tests/eth-mainnet/transaction.spec.ts index 08c67f15..d6740613 100644 --- a/e2e/tests/eth-mainnet/transaction.spec.ts +++ b/e2e/tests/eth-mainnet/transaction.spec.ts @@ -5,7 +5,14 @@ import { waitForTxContent, DEFAULT_TIMEOUT } from "../../helpers/wait"; // Transaction hash constants for readability const FIRST_ETH_TRANSFER = "0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060"; -const USDC_APPROVAL = "0xc55e2b90168af6972193c1f86fa4d7d7b31a29c156665d15b9cd48618b5177ef"; +// EIP-1559 (Type 2) tx at block 20,000,000 with real calldata — this hash is +// verified on-chain via `eth_getTransactionByHash` (input 242 chars, starts +// with 0x091a4fc4). Previously `USDC_APPROVAL` pointed to +// `0xc55e2b90168af69721…` which the fixture *claimed* was a Type 2 USDC +// approval, but on-chain that hash resolves to a 2016 Binance transfer with +// empty `input: "0x"` — USDC didn't deploy until 2018. Tests that expected +// input-data on that hash would fail against real RPCs. +const TX_WITH_INPUT_DATA = "0xbb4b3fc2b746877dce70862850602f1d19bd890ab4db47e6b7ee1da1fe578a0d"; test.describe("Transaction Page", () => { test("displays first ETH transfer with all details", async ({ page }, testInfo) => { @@ -79,14 +86,20 @@ test.describe("Transaction Page", () => { test("displays transaction with input data", async ({ page }, testInfo) => { const txPage = new TransactionPage(page); - const tx = MAINNET.transactions[USDC_APPROVAL]; + const tx = MAINNET.transactions[TX_WITH_INPUT_DATA]; await txPage.goto(tx.hash); const loaded = await waitForTxContent(page, testInfo); if (loaded) { - // Verify input data exists (shown as tab in TX Analyser) - await expect(page.locator("text=Input Data").first()).toBeVisible(); + // The "Input Data" tab lives inside `TxAnalyser`, which only mounts + // once `hasEvents || hasInputData || isSuperUser`. `hasInputData` is + // derived from the receipt, which arrives after `waitForTxContent` + // returns (that helper only waits for the basic tx-by-hash render). + // Give the receipt fetch a full RPC budget. + await expect(page.locator("text=Input Data").first()).toBeVisible({ + timeout: DEFAULT_TIMEOUT * 3, + }); } }); diff --git a/e2e/tests/eth-mainnet/txs.spec.ts b/e2e/tests/eth-mainnet/txs.spec.ts index ee6efaf1..8cb4ffbc 100644 --- a/e2e/tests/eth-mainnet/txs.spec.ts +++ b/e2e/tests/eth-mainnet/txs.spec.ts @@ -123,26 +123,30 @@ test.describe("Transactions Page", () => { const loaded = await waitForTxsContent(page, testInfo); if (loaded) { - // Navigate to older transactions and wait for URL change + // Navigate to older transactions and wait for URL change. Give the + // router + RPC a full multiplier budget — public RPCs under worker + // contention can exceed the 5s default. await txsPage.olderBtn.first().click(); - await page.waitForURL(/block=/, { timeout: DEFAULT_TIMEOUT }); + await page.waitForURL(/block=/, { timeout: DEFAULT_TIMEOUT * 3 }); // Wait for older page data to load (RPC-dependent) const olderLoaded = await waitForTxsContent(page, testInfo); if (olderLoaded) { // Verify transactions are displayed - await expect(txsPage.txTable).toBeVisible(); + await expect(txsPage.txTable).toBeVisible({ timeout: DEFAULT_TIMEOUT * 3 }); // Navigate back to latest - wait for button to be enabled first - await expect(txsPage.latestBtn.first()).toBeEnabled({ timeout: DEFAULT_TIMEOUT }); + await expect(txsPage.latestBtn.first()).toBeEnabled({ + timeout: DEFAULT_TIMEOUT * 3, + }); await txsPage.latestBtn.first().click(); - await page.waitForURL(/\/txs(?!\?)/, { timeout: DEFAULT_TIMEOUT }); + await page.waitForURL(/\/txs(?!\?)/, { timeout: DEFAULT_TIMEOUT * 3 }); // Wait for latest page data to load const latestLoaded = await waitForTxsContent(page, testInfo); if (latestLoaded) { // Verify we're back on latest transactions - await expect(txsPage.txTable).toBeVisible(); + await expect(txsPage.txTable).toBeVisible({ timeout: DEFAULT_TIMEOUT * 3 }); } } } diff --git a/e2e/tests/evm-networks/arbitrum.spec.ts b/e2e/tests/evm-networks/arbitrum.spec.ts index 849bf1f5..ee13beb8 100644 --- a/e2e/tests/evm-networks/arbitrum.spec.ts +++ b/e2e/tests/evm-networks/arbitrum.spec.ts @@ -257,17 +257,31 @@ test.describe("Arbitrum One - Transaction Page", () => { const loaded = await waitForTxContent(page, testInfo); if (loaded) { + // Arbitrum public RPCs are slower than mainnet; a field that hasn't + // rendered in 5s often renders in 10–15. Use the project-standard + // triple-multiplier budget instead of the default 5s. + const t = DEFAULT_TIMEOUT * 3; // Verify core transaction details - await expect(page.locator("text=Transaction Hash:")).toBeVisible(); - await expect(page.locator("text=Status:")).toBeVisible(); - await expect(page.locator("text=Block:")).toBeVisible(); - await expect(page.locator("text=From:")).toBeVisible(); - await expect(page.locator("text=To:")).toBeVisible(); - await expect(page.locator("text=Value:")).toBeVisible(); - - // Verify gas information - await expect(page.locator("text=Gas Limit")).toBeVisible(); - await expect(page.locator(".tx-label", { hasText: "Gas Price:" })).toBeVisible(); + await expect(page.locator("text=Transaction Hash:")).toBeVisible({ timeout: t }); + await expect(page.locator("text=Status:")).toBeVisible({ timeout: t }); + await expect(page.locator("text=Block:")).toBeVisible({ timeout: t }); + await expect(page.locator("text=From:")).toBeVisible({ timeout: t }); + await expect(page.locator("text=To:")).toBeVisible({ timeout: t }); + await expect(page.locator("text=Value:")).toBeVisible({ timeout: t }); + + // Verify gas information. Use an exact regex for "Gas Price:" — + // Arbitrum post-Nitro renders both `Gas Price:` and + // `Effective Gas Price:` (receipt vs. tx), so a `hasText: "Gas Price:"` + // substring filter matches two `.tx-label` spans and violates + // Playwright's strict single-element expectation. + await expect(page.locator("text=Gas Limit")).toBeVisible({ timeout: t }); + // FieldLabel renders `Gas Price:` + // so the span's combined textContent is `Gas Price:...` (with the + // tooltip's text appended). Use a start-anchored regex so it matches + // the "Gas Price" label but not "Effective Gas Price". + await expect(page.locator(".tx-label", { hasText: /^Gas Price:/ })).toBeVisible({ + timeout: t, + }); } }); diff --git a/e2e/tests/evm-networks/avalanche.spec.ts b/e2e/tests/evm-networks/avalanche.spec.ts new file mode 100644 index 00000000..cc534f51 --- /dev/null +++ b/e2e/tests/evm-networks/avalanche.spec.ts @@ -0,0 +1,40 @@ +import { test } from "../../fixtures/test"; +import { AVALANCHE } from "../../fixtures/networks"; +import { expectStillMounted } from "../../fixtures/assertions"; + +/** + * Avalanche C-Chain (43114) smoke. Avalanche is registered as a production + * network in `src/config/networks.json` and routed to the vanilla + * `EVMAdapter` in `adaptersFactory.ts`, but had zero e2e coverage — a silent + * regression here would only surface in bug reports. + * + * This smoke only asserts the page mounts. Network-specific field + * assertions (if any L2-like specialization is added later) belong in their + * own spec. + */ + +test.describe("Avalanche C-Chain smoke", () => { + test("block page renders", async ({ page }) => { + await page.goto(`/#/${AVALANCHE.urlPath}/block/${AVALANCHE.canonicalBlock}`); + await expectStillMounted(page); + }); + + test("address page renders for canonical WAVAX contract", async ({ page }) => { + await page.goto(`/#/${AVALANCHE.urlPath}/address/${AVALANCHE.canonicalAddress}`); + await expectStillMounted(page); + }); + + test("zero address page renders", async ({ page }) => { + await page.goto( + `/#/${AVALANCHE.urlPath}/address/0x0000000000000000000000000000000000000000`, + ); + await expectStillMounted(page); + }); + + test("placeholder tx hash renders without crashing", async ({ page }) => { + // Until a canonical Avalanche tx is pinned in a fixture, assert only + // the not-found path handles cleanly. + await page.goto(`/#/${AVALANCHE.urlPath}/tx/${AVALANCHE.canonicalTxHash}`); + await expectStillMounted(page); + }); +}); diff --git a/e2e/tests/evm-networks/l2-fields.spec.ts b/e2e/tests/evm-networks/l2-fields.spec.ts new file mode 100644 index 00000000..b93e8abc --- /dev/null +++ b/e2e/tests/evm-networks/l2-fields.spec.ts @@ -0,0 +1,94 @@ +import { test } from "../../fixtures/test"; +import { ARBITRUM } from "../../fixtures/arbitrum"; +import { OPTIMISM } from "../../fixtures/optimism"; +import { BASE } from "../../fixtures/base"; +import { + expectArbitrumBlockFields, + expectArbitrumTxL1Fields, + expectOpStackTxL1Fee, +} from "../../fixtures/assertionsL2"; +/** + * L2-specific field assertions. + * + * The whole point of having `ArbitrumAdapter`, `OptimismAdapter`, and + * `BaseAdapter` is to surface fields the vanilla `EVMAdapter` does not: + * + * - Arbitrum tx receipt → `l1BlockNumber` (+ `gasUsedForL1`) + * - Arbitrum block → `sendCount`, `sendRoot` (L2→L1 messages) + * - OP Stack tx → `l1Fee`, `l1GasPrice`, `l1GasUsed` + * (the fee users pay for L1 data posting) + * + * The research review flagged these as completely unasserted in the existing + * per-network specs — an adapter regression that silently dropped any of + * them would have been invisible. Each test below navigates to a pinned, + * post-upgrade transaction or block drawn from the per-network fixture + * tables and asserts the label renders. + * + * Tx hashes / block numbers re-use what's already curated in + * `e2e/fixtures/{arbitrum,optimism,base}.ts` (stable, post-upgrade data + * pre-committed to the repo). + */ + +// First fixture tx for each chain; both Arbitrum tx fixtures are post-Nitro +// so either exposes `l1BlockNumber` in the receipt. The first Optimism/Base +// tx already carries `l1Fee` in the fixture payload (see the `l1Fee` key in +// `e2e/fixtures/optimism.ts`), so we know the receipt has the OP-stack fee +// breakdown populated upstream. +const ARB_TX_HASH = Object.keys(ARBITRUM.transactions)[0]; +const OP_TX_HASH = Object.keys(OPTIMISM.transactions)[0]; +const BASE_TX_HASH = Object.keys(BASE.transactions)[0]; + +// Recent, high-activity Arbitrum block likely to contain L2→L1 messages. +// Drawn from existing Arbitrum block fixture keys. +const ARB_BLOCK = Object.keys(ARBITRUM.blocks)[0]; + +// The Arbitrum L1-field tests require an RPC that returns Arbitrum's +// extended tx receipt / block shape (`l1BlockNumber`, `sendCount`, +// `sendRoot`). Public RPCs and some provider variants strip these +// fields; local runs against `buildRpcUrls`-seeded Alchemy/Infura may +// or may not expose them depending on the endpoint. Mark as `fixme` +// until the dedicated Arbitrum RPC is confirmed in CI secrets and a +// conditional skip tied to that env var is wired up. +test.describe("Arbitrum L2 fields — transaction", () => { + test.fixme("post-Nitro tx exposes L1 Block Number", async ({ page }) => { + test.skip(!ARB_TX_HASH, "no Arbitrum tx fixture available"); + await page.goto(`/#/42161/tx/${ARB_TX_HASH}`); + await expectArbitrumTxL1Fields(page); + }); +}); + +test.describe("Arbitrum L2 fields — block", () => { + test.fixme("block exposes Send Count and Send Root", async ({ page }) => { + test.skip(!ARB_BLOCK, "no Arbitrum block fixture available"); + await page.goto(`/#/42161/block/${ARB_BLOCK}`); + await expectArbitrumBlockFields(page); + }); +}); + +test.describe("Optimism L2 fields — transaction", () => { + test("post-Bedrock tx exposes L1 Fee breakdown", async ({ page }) => { + test.skip(!OP_TX_HASH, "no Optimism tx fixture available"); + await page.goto(`/#/10/tx/${OP_TX_HASH}`); +await expectOpStackTxL1Fee(page); + }); +}); + +test.describe("Base L2 fields — transaction", () => { + test("post-Bedrock tx exposes L1 Fee breakdown", async ({ page }) => { + test.skip(!BASE_TX_HASH, "no Base tx fixture available"); + await page.goto(`/#/8453/tx/${BASE_TX_HASH}`); +await expectOpStackTxL1Fee(page); + }); +}); + +/** + * Post-Dencun blob field assertions (`blobGasUsed`, `excessBlobGas`) require + * pinning a block that actually contains a blob-carrying tx — the BlockDisplay + * component gates rendering on `blobGasUsed > 0`. Finding a stable, pinned + * blob-bearing block per chain is a research task; deferring to phase 4. + */ +test.describe("Blob fields (EIP-4844) — TODO phase 4", () => { + test.skip("Ethereum post-Dencun block with blobs exposes Blob Gas Used / Excess Blob Gas", async () => {}); + test.skip("Optimism post-Ecotone block with blobs exposes blob fields", async () => {}); + test.skip("Base post-Ecotone block with blobs exposes blob fields", async () => {}); +}); diff --git a/e2e/tests/shared/ai-and-worker.spec.ts b/e2e/tests/shared/ai-and-worker.spec.ts new file mode 100644 index 00000000..1865e331 --- /dev/null +++ b/e2e/tests/shared/ai-and-worker.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "../../fixtures/test"; +import { ETH_MAINNET } from "../../fixtures/networks"; +import { DEFAULT_TIMEOUT } from "../../helpers/wait"; + +/** + * AI Analysis panel + worker proxy reachability. + * + * Shallow coverage for now: + * - the panel DOM hooks exist on tx pages, + * - the "Analyze" button is wired (we don't actually invoke it to avoid + * burning Groq budget or coupling to live availability). + * + * The full worker-failover matrix (Cloudflare 5xx / 429 → Vercel) is + * deferred to the `mocked/rpc-strategy.spec.ts` placeholder which needs a + * full `OPENSCAN_RPC_URLS_V3` + `page.route` harness. + */ + +test.describe("AI Analysis panel", () => { + test("panel section renders on a tx page", async ({ page }) => { + const txHash = ETH_MAINNET.canonicalTxHash; + await page.goto(`/#/1/tx/${txHash}`); + // The AI panel is rendered unconditionally (gated only by super-user / + // feature flags inside the component). The `.ai-analysis-panel` class + // on a
is the stable hook. + await expect(page.locator("section.ai-analysis-panel")).toBeVisible({ + timeout: DEFAULT_TIMEOUT * 3, + }); + }); + + test("analyze button is present and enabled", async ({ page }) => { + const txHash = ETH_MAINNET.canonicalTxHash; + await page.goto(`/#/1/tx/${txHash}`); + const button = page.locator(".ai-analysis-button").first(); + await expect(button).toBeVisible({ timeout: DEFAULT_TIMEOUT * 3 }); + await expect(button).toBeEnabled(); + }); +}); diff --git a/e2e/tests/shared/contract-interaction.spec.ts b/e2e/tests/shared/contract-interaction.spec.ts new file mode 100644 index 00000000..1172238c --- /dev/null +++ b/e2e/tests/shared/contract-interaction.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "../../fixtures/test"; +import { DEFAULT_TIMEOUT } from "../../helpers/wait"; + +/** + * Contract interaction UI — read/write function lists on verified contracts. + * + * The existing per-network specs click individual read functions on BAYC and + * Rarible but never assert the two top-level section headers render. A + * regression in `ContractInteraction.tsx` that empties one list (e.g. ABI + * decoding breaks) would slip through — these smokes catch that. + * + * Behaviour: the address page loads with "Contract Details" collapsed; the + * spec expands it (matching what `eth-mainnet/address.spec.ts` does for + * BAYC) before asserting the Read/Write sections render. + * + * We don't submit any write transaction (wallet signing is out of scope for + * e2e), only assert the write-function form section renders. + */ + +// WETH9 — a verified, non-proxy ERC-20 with a small, stable ABI (deposit / +// withdraw / approve / transfer / transferFrom + the standard reads). USDC +// is also verified but is a proxy, which means `ContractInteraction.tsx` +// surfaces the proxy's tiny admin ABI rather than the full token ABI and +// no "Write Functions" section appears. +const WETH9_MAINNET = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; + +async function openContractDetails(page: import("@playwright/test").Page): Promise { + await page.goto(`/#/1/address/${WETH9_MAINNET}`); + // Wait specifically for the Contract Details header — not just any + // "Balance:" sentinel — so the click below can't race the header mount. + const header = page.locator("text=Contract Details").first(); + await expect(header).toBeVisible({ timeout: DEFAULT_TIMEOUT * 4 }); + await header.click(); +} + +test.describe("Contract interaction UI", () => { + test("verified ERC-20 renders Read Functions section", async ({ page }) => { + await openContractDetails(page); + await expect(page.locator("text=/Read Functions \\(\\d+\\)/")).toBeVisible({ + timeout: DEFAULT_TIMEOUT * 3, + }); + }); + + test("verified ERC-20 renders Write Functions section", async ({ page }) => { + await openContractDetails(page); + await expect(page.locator("text=/Write Functions \\(\\d+\\)/")).toBeVisible({ + timeout: DEFAULT_TIMEOUT * 3, + }); + }); + + // Unverified-contract coverage (contract has code but no public source) + // deferred to phase 6 — picking a stably-unverified contract on mainnet + // is a research task, and the zero-address fallback in errors.spec.ts + // already covers the EOA (no-code) path. +}); diff --git a/e2e/tests/shared/errors.spec.ts b/e2e/tests/shared/errors.spec.ts new file mode 100644 index 00000000..e5c07e7b --- /dev/null +++ b/e2e/tests/shared/errors.spec.ts @@ -0,0 +1,73 @@ +import { test } from "../../fixtures/test"; +import { + ETH_MAINNET, + ARBITRUM, + OPTIMISM, + BASE, + type NetworkFixture, +} from "../../fixtures/networks"; +import { expectStillMounted } from "../../fixtures/assertions"; + +/** + * Cross-network error-path smoke. Each test asserts the app root and footer + * stay mounted after we navigate to broken input — a "did the app hard-crash + * or silently redirect home?" guard. We deliberately avoid asserting on + * specific error copy so the suite doesn't rot when i18n strings change. + */ + +const EVM_SUBJECTS: NetworkFixture[] = [ETH_MAINNET, ARBITRUM, OPTIMISM, BASE]; + +test.describe("Error paths: invalid block numbers", () => { + for (const net of EVM_SUBJECTS) { + test(`${net.name} — non-existent block renders without crashing`, async ({ page }) => { + // A block number far beyond what any chain has produced. + await page.goto(`/#/${net.urlPath}/block/999999999999`); + await expectStillMounted(page); + }); + + test(`${net.name} — malformed block param renders without crashing`, async ({ page }) => { + await page.goto(`/#/${net.urlPath}/block/not-a-block`); + await expectStillMounted(page); + }); + } +}); + +test.describe("Error paths: invalid transaction hashes", () => { + // Valid 32-byte shape, but all-zeros will not exist on any live chain. + const ZERO_ISH_HASH = `0x${"0".repeat(63)}1`; + + for (const net of EVM_SUBJECTS) { + test(`${net.name} — non-existent tx hash renders without crashing`, async ({ page }) => { + await page.goto(`/#/${net.urlPath}/tx/${ZERO_ISH_HASH}`); + await expectStillMounted(page); + }); + + test(`${net.name} — malformed tx hash renders without crashing`, async ({ page }) => { + await page.goto(`/#/${net.urlPath}/tx/not-a-hash`); + await expectStillMounted(page); + }); + } +}); + +test.describe("Error paths: invalid addresses", () => { + for (const net of EVM_SUBJECTS) { + test(`${net.name} — malformed address renders without crashing`, async ({ page }) => { + await page.goto(`/#/${net.urlPath}/address/not-an-address`); + await expectStillMounted(page); + }); + + test(`${net.name} — zero address renders`, async ({ page }) => { + // Zero address is valid shape and must render like a normal address + // page (balance 0, no code), not an error state. + await page.goto(`/#/${net.urlPath}/address/0x0000000000000000000000000000000000000000`); + await expectStillMounted(page); + }); + } +}); + +test.describe("Error paths: unknown network", () => { + test("unknown `:networkId` segment falls back without crashing", async ({ page }) => { + await page.goto("/#/totally-fake-chain/block/1"); + await expectStillMounted(page); + }); +}); diff --git a/e2e/tests/shared/event-logs.spec.ts b/e2e/tests/shared/event-logs.spec.ts new file mode 100644 index 00000000..0905da4f --- /dev/null +++ b/e2e/tests/shared/event-logs.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from "../../fixtures/test"; +import { DEFAULT_TIMEOUT } from "../../helpers/wait"; + +/** + * Event logs rendering — stresses `EventLogsTab`. + * + * The research review flagged that nothing in the suite asserts event-log + * rows actually render. A regression that breaks decode (silent throw in + * the log row component, missing ABI, etc.) would slip through because the + * per-network specs only open tx pages and check header fields, not the + * nested detail tabs. + * + * Behaviour: `TxAnalyser` starts collapsed for non-super-users + * (`collapsed = !isSuperUser` in `TxAnalyser.tsx`). Clicking the Events tab + * button expands the panel; only then does `.tx-log` render. + * + * We don't stress-test 100+ logs here — pagination / virtualization specs + * deferred to phase 6. This commits the baseline: at least one event-log + * row appears for a known tx that emits events. + */ + +// EIP-1559 tx from block 20,000,000 — the `bb4b3fc2…` hash pinned in +// `e2e/fixtures/mainnet.ts` as the canonical Type 2 example. Well-indexed +// on every public RPC, has `hasInputData: true` in the fixture. +const LARGE_TX = "0xbb4b3fc2b746877dce70862850602f1d19bd890ab4db47e6b7ee1da1fe578a0d"; + +test.describe("Transaction event log rendering", () => { + test("tx detail page exposes the TxAnalyser with an Events tab", async ({ page }) => { + await page.goto(`/#/1/tx/${LARGE_TX}`); + // The analyser is null until receipt + tx are both fetched + // (`!isSuperUser && !hasEvents && !hasInputData → null`). Give the + // network fetch generous time, but do not mask systemic flakiness — + // a timeout here means the RPC path is broken, not the spec. + const eventsTab = page + .locator(".detail-panel-tab", { hasText: /^\s*Events\b/ }) + .first(); + await expect(eventsTab).toBeVisible({ timeout: DEFAULT_TIMEOUT * 6 }); + await eventsTab.click(); + // EventLogsTab renders each log as `.tx-log` (per + // `src/components/pages/evm/tx/analyser/EventLogsTab.tsx`). + const firstLog = page.locator(".tx-log").first(); + await expect(firstLog).toBeVisible({ timeout: DEFAULT_TIMEOUT * 4 }); + }); +}); diff --git a/e2e/tests/shared/mocked/nft-safety.spec.ts b/e2e/tests/shared/mocked/nft-safety.spec.ts new file mode 100644 index 00000000..ad16a9e1 --- /dev/null +++ b/e2e/tests/shared/mocked/nft-safety.spec.ts @@ -0,0 +1,24 @@ +import { test } from "../../../fixtures/test"; + +/** + * Regression test for the H-2 security fix (PR that added + * `src/utils/urlUtils.ts::toSafeExternalHref`). When NFT metadata contains + * hostile `external_url` / `animation_url` / `tokenUri` values + * (`javascript:…`, `data:text/html,…`, etc.), the token detail page must + * not render them as ``. The gating lives in + * `ERC721TokenDisplay.tsx` and `ERC1155TokenDisplay.tsx`. + * + * Placeholder in phase 1 — the hermetic version requires mocking + * 1. `eth_call` for `tokenURI(uint256)` on the token contract, + * 2. `eth_call` for `name` / `symbol` / `ownerOf` / `getApproved` + * (`fetchCollectionInfo` + `fetchTokenOwner`), + * 3. the HTTP GET of the metadata JSON at the resolved IPFS/HTTP URL. + * + * Phase 4 wires these together using the `rpcMock` helpers. + */ + +test.describe("NFT safe-href regression (H-2) — TODO phase 4", () => { + test.skip("javascript: external_url is not rendered as ", async () => {}); + test.skip("data:text/html animation_url is not rendered as ", async () => {}); + test.skip("vbscript: tokenUri is not rendered as ", async () => {}); +}); diff --git a/e2e/tests/shared/mocked/rpc-strategy.spec.ts b/e2e/tests/shared/mocked/rpc-strategy.spec.ts new file mode 100644 index 00000000..f944ed1e --- /dev/null +++ b/e2e/tests/shared/mocked/rpc-strategy.spec.ts @@ -0,0 +1,28 @@ +import { test } from "../../../fixtures/test"; + +/** + * Placeholder for the RPC strategy matrix (fallback / parallel / race) and + * the worker multi-platform failover path (Cloudflare → Vercel on 5xx / 429). + * + * Full implementation in phase 4 — requires: + * - seeding a custom RPC URL list via `OPENSCAN_RPC_URLS_V3` (two fake + * hostnames pointed at `page.route` handlers), + * - `setUserSetting(page, { rpcStrategy: … })` per test, + * - mock handlers for each JSON-RPC method the block/tx pages call + * (`eth_blockNumber`, `eth_getBlockByNumber`, `eth_gasPrice`, …), + * - an assertion that counts RPC calls per upstream to verify which + * handler won under each strategy. + */ + +test.describe("RPC strategy (fallback) — TODO phase 4", () => { + test.skip("secondary URL is used when primary returns 503", async () => {}); +}); + +test.describe("RPC strategy (parallel) — TODO phase 4", () => { + test.skip("both URLs are called and inconsistency UI surfaces when they disagree", async () => {}); +}); + +test.describe("Worker failover — TODO phase 4", () => { + test.skip("Cloudflare 503 falls over to Vercel", async () => {}); + test.skip("Cloudflare 429 with retry-after falls over to Vercel", async () => {}); +}); diff --git a/e2e/tests/shared/search.spec.ts b/e2e/tests/shared/search.spec.ts new file mode 100644 index 00000000..35856767 --- /dev/null +++ b/e2e/tests/shared/search.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from "../../fixtures/test"; +import { ETH_MAINNET, ARBITRUM, BASE, type NetworkFixture } from "../../fixtures/networks"; +import { DEFAULT_TIMEOUT } from "../../helpers/wait"; + +/** + * Global search: uses the navbar `search-form`. The `useSearch` hook derives + * the target chain from the current URL's first path segment, so each test + * lands on a network page first, then submits a query via the form. + * + * For EVM search, `useSearch` regex-matches the input and routes to: + * - `//tx/` for 64-hex-char values + * - `//address/` for 40-hex-char values + * - `//block/` for bare digits + * + * We assert only the URL change — the destination page's live RPC fetch is + * already covered by per-network specs. + */ + +const EVM_SUBJECTS: NetworkFixture[] = [ETH_MAINNET, ARBITRUM, BASE]; + +async function submitSearch( + page: import("@playwright/test").Page, + query: string, +): Promise { + // The navbar desktop form is `.search-form.hide-mobile` with `.search-input`. + const input = page.locator("form.search-form .search-input").first(); + await input.waitFor({ state: "visible", timeout: DEFAULT_TIMEOUT }); + await input.fill(query); + await input.press("Enter"); +} + +test.describe("Search: transaction hash", () => { + for (const net of EVM_SUBJECTS) { + test(`${net.name} — searching a 64-hex-char value routes to /tx/`, async ({ page }) => { + await page.goto(`/#/${net.urlPath}`); + const hash = `0x${"0".repeat(63)}1`; + await submitSearch(page, hash); + await expect(page).toHaveURL(new RegExp(`/${net.urlPath}/tx/${hash}`), { + timeout: DEFAULT_TIMEOUT, + }); + }); + } +}); + +test.describe("Search: address", () => { + for (const net of EVM_SUBJECTS) { + test(`${net.name} — searching a 40-hex-char value routes to /address/`, async ({ + page, + }) => { + await page.goto(`/#/${net.urlPath}`); + await submitSearch(page, net.canonicalAddress); + // useSearch lowercases nothing; match case-insensitively. + await expect(page).toHaveURL( + new RegExp(`/${net.urlPath}/address/${net.canonicalAddress}`, "i"), + { timeout: DEFAULT_TIMEOUT }, + ); + }); + } +}); + +test.describe("Search: block number", () => { + for (const net of EVM_SUBJECTS) { + test(`${net.name} — searching a bare integer routes to /block/`, async ({ page }) => { + await page.goto(`/#/${net.urlPath}`); + await submitSearch(page, "1"); + await expect(page).toHaveURL(new RegExp(`/${net.urlPath}/block/1(?:$|/|\\?)`), { + timeout: DEFAULT_TIMEOUT, + }); + }); + } +}); + +test.describe("Search: invalid input", () => { + test("malformed query surfaces an inline error and does not navigate", async ({ page }) => { + await page.goto(`/#/${ETH_MAINNET.urlPath}`); + const before = page.url(); + await submitSearch(page, "not-a-valid-thing"); + // Give the validation error path a moment, but do NOT wait for a URL + // change — the correct behavior is to stay on the same page. + await page.waitForTimeout(500); + expect(page.url()).toBe(before); + }); +}); diff --git a/e2e/tests/shared/settings.spec.ts b/e2e/tests/shared/settings.spec.ts new file mode 100644 index 00000000..5425d4b2 --- /dev/null +++ b/e2e/tests/shared/settings.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from "../../fixtures/test"; +import { + clearAppState, + readUserSetting, + setLanguage, + setRpcStrategy, + setTheme, + setUserSetting, + SETTINGS_STORAGE_KEY, +} from "../../fixtures/localStorage"; + +/** + * Settings persistence & hydration. These tests do not require any RPC + * traffic — they seed localStorage before navigation and assert the app + * reflects the seeded values (theme class on , settings JSON intact + * after reload, etc.). + */ + +test.describe("Settings persistence", () => { + test("theme=light seeded via localStorage applies `light-theme` body class", async ({ + page, + }) => { + await setTheme(page, "light"); + await page.goto("/"); + await expect(page.locator("body")).toHaveClass(/(^|\s)light-theme(\s|$)/); + }); + + test("theme=dark seeded via localStorage does not apply `light-theme` body class", async ({ + page, + }) => { + await setTheme(page, "dark"); + await page.goto("/"); + await expect(page.locator("body")).not.toHaveClass(/(^|\s)light-theme(\s|$)/); + }); + + test("rpcStrategy seeded before load is preserved by SettingsContext write-back", async ({ + page, + }) => { + // Seed once, navigate once. On mount SettingsContext reads the bundle, + // merges with DEFAULT_SETTINGS, and writes it back under the same key. + // The write-back must not drop the seeded `rpcStrategy`. + await setRpcStrategy(page, "parallel"); + await page.goto("/"); + // Wait a beat for the mount effect to fire and persist. + await page.waitForFunction( + () => { + const raw = localStorage.getItem("openScan_user_settings"); + if (!raw) return false; + try { + const parsed = JSON.parse(raw) as Record; + // DEFAULT_SETTINGS fields merged in → blob contains more than just + // the seed. That's the signal the app wrote back. + return "theme" in parsed && "rpcStrategy" in parsed; + } catch { + return false; + } + }, + { timeout: 5000 }, + ); + expect(await readUserSetting(page, "rpcStrategy")).toBe("parallel"); + }); + + test("language override is readable after load", async ({ page }) => { + await setLanguage(page, "es"); + await page.goto("/"); + const lang = await page.evaluate(() => localStorage.getItem("openScan_language")); + expect(lang).toBe("es"); + }); + + test("setUserSetting merges without clobbering sibling fields", async ({ page }) => { + // Seed two patches; the second must not drop `theme` from the first. + await setUserSetting(page, { theme: "light" }); + await setUserSetting(page, { rpcStrategy: "race" }); + await page.goto("/"); + expect(await readUserSetting(page, "theme")).toBe("light"); + expect(await readUserSetting(page, "rpcStrategy")).toBe("race"); + }); + + test("clearAppState removes the bundled settings blob", async ({ page }) => { + await setUserSetting(page, { theme: "light" }); + await page.goto("/"); + const beforeRaw = await page.evaluate( + (k) => localStorage.getItem(k), + SETTINGS_STORAGE_KEY, + ); + expect(beforeRaw).not.toBeNull(); + await clearAppState(page); + const afterRaw = await page.evaluate( + (k) => localStorage.getItem(k), + SETTINGS_STORAGE_KEY, + ); + expect(afterRaw).toBeNull(); + }); +}); diff --git a/e2e/tests/solana/smoke.spec.ts b/e2e/tests/solana/smoke.spec.ts new file mode 100644 index 00000000..dbb48385 --- /dev/null +++ b/e2e/tests/solana/smoke.spec.ts @@ -0,0 +1,44 @@ +import { test } from "../../fixtures/test"; +import { SOLANA } from "../../fixtures/networks"; +import { expectStillMounted } from "../../fixtures/assertions"; + +/** + * Solana mainnet smoke. Solana has a full adapter + * (`src/services/adapters/SolanaAdapter/SolanaAdapter.ts`), a dashboard, a + * slot/tx/account/validators page set, and is routed under the `/sol` + * slug — yet the existing e2e suite has no Solana coverage. + * + * These smokes only assert the page mounts and the footer renders. Deep + * field assertions are out of scope until the Solana-specific data + * contract is exercised enough to have a stable curated fixture (phase 4 + * will add a `solana.ts` fixture akin to `mainnet.ts`). + */ + +test.describe("Solana smoke", () => { + test("network landing page renders", async ({ page }) => { + await page.goto(`/#/${SOLANA.urlPath}`); + await expectStillMounted(page); + }); + + test("slots list renders", async ({ page }) => { + await page.goto(`/#/${SOLANA.urlPath}/slots`); + await expectStillMounted(page); + }); + + test("slot detail page for a pinned slot renders", async ({ page }) => { + await page.goto(`/#/${SOLANA.urlPath}/slot/${SOLANA.canonicalBlock}`); + await expectStillMounted(page); + }); + + test("system program account page renders", async ({ page }) => { + // The system program (11111…) is guaranteed to exist on any Solana + // cluster — safest possible fixture. + await page.goto(`/#/${SOLANA.urlPath}/account/${SOLANA.canonicalAddress}`); + await expectStillMounted(page); + }); + + test("validators page renders", async ({ page }) => { + await page.goto(`/#/${SOLANA.urlPath}/validators`); + await expectStillMounted(page); + }); +}); diff --git a/e2e/tests/testnets/smoke.spec.ts b/e2e/tests/testnets/smoke.spec.ts new file mode 100644 index 00000000..9214d6b3 --- /dev/null +++ b/e2e/tests/testnets/smoke.spec.ts @@ -0,0 +1,38 @@ +import { test } from "../../fixtures/test"; +import { EVM_TESTNETS } from "../../fixtures/networks"; +import { expectStillMounted } from "../../fixtures/assertions"; + +/** + * Smoke coverage for the EVM testnets registered in + * `src/config/networks.json`: Sepolia (11155111), Arbitrum Sepolia (421614), + * Optimism Sepolia (11155420), Base Sepolia (84532), Polygon Amoy (80002), + * Avalanche Fuji (43113). + * + * The five Sepolia-class testnets were added in metadata v1.2.1-alpha.0 + * (commit 22f5845) with adapter registrations but no e2e. Developers rely on + * these testnets for staging-level validation — an adapter regression on + * any one of them should not slip through to a mainnet release. + * + * Each testnet gets a block-page and an address-page smoke. Tx pages use + * placeholder hashes (canonicalTxHash defaults to `0x…0001`) — the goal is + * to verify the page renders, not that a specific tx payload displays. + */ + +for (const net of EVM_TESTNETS) { + test.describe(`${net.name} (${net.chainId}) smoke`, () => { + test("block page renders", async ({ page }) => { + await page.goto(`/#/${net.urlPath}/block/${net.canonicalBlock}`); + await expectStillMounted(page); + }); + + test("address page renders for canonical contract", async ({ page }) => { + await page.goto(`/#/${net.urlPath}/address/${net.canonicalAddress}`); + await expectStillMounted(page); + }); + + test("tx page renders without crashing", async ({ page }) => { + await page.goto(`/#/${net.urlPath}/tx/${net.canonicalTxHash}`); + await expectStillMounted(page); + }); + }); +} diff --git a/package.json b/package.json index 75eb1cc1..5f1eb8f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openscan", - "version": "1.2.5-alpha", + "version": "1.2.6-alpha", "private": true, "type": "module", "packageManager": "bun@1.1.0", @@ -9,11 +9,28 @@ }, "overrides": { "@noble/hashes": "^1.8.0", - "@noble/curves": "^1.8.0" + "@noble/curves": "^1.8.0", + "axios": "^1.15.2", + "vitest": { + "vite": "^7.3.2" + }, + "bn.js": "^5.2.3", + "brace-expansion": ">=1.1.13", + "defu": "^6.1.5", + "follow-redirects": "^1.15.12", + "h3": "^1.15.9", + "hono": "^4.12.16", + "lodash": "^4.17.24", + "minimatch": ">=3.1.4", + "picomatch": ">=2.3.2", + "postcss": "^8.5.10", + "rollup": "^4.59.0", + "socket.io-parser": "^4.2.6", + "yaml": "^2.8.3" }, "dependencies": { "@erc7730/sdk": "^0.1.3", - "@openscan/network-connectors": "1.6.0", + "@openscan/network-connectors": "1.7", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", "@tanstack/react-query": "^5.90.21", @@ -78,8 +95,8 @@ "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^5.1.2", "dotenv": "^17.2.3", - "happy-dom": "^20.1.0", - "vite": "^7.3.1", + "happy-dom": "^20.8.9", + "vite": "^7.3.2", "vitest": "^4.0.14" } } diff --git a/playwright.config.ts b/playwright.config.ts index 3cf792ea..4d66dc92 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,7 +17,22 @@ export default defineConfig({ screenshot: "only-on-failure", headless: true, }, - projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + // Default project — live RPC. Does not run the mocked suite. + testIgnore: ["**/shared/mocked/**"], + }, + { + // Hermetic suite: mocks RPC + worker traffic via `page.route`. Used for + // strategy / fallover / error-path / large-tx tests that must be + // deterministic. + name: "mocked", + use: { ...devices["Desktop Chrome"] }, + testMatch: ["**/shared/mocked/**/*.spec.ts"], + }, + ], webServer: { command: "npm run start", url: "http://localhost:3030", diff --git a/scripts/build-development.sh b/scripts/build-development.sh index 4611ec51..d9fd3d1f 100755 --- a/scripts/build-development.sh +++ b/scripts/build-development.sh @@ -15,7 +15,7 @@ rm -r dist || true # Build the app with only Hardhat network (31337) enabled echo "Building React app on commit $COMMIT_HASH" -NODE_ENV=development REACT_APP_ENVIRONMENT=development REACT_APP_COMMIT_HASH=$COMMIT_HASH REACT_APP_OPENSCAN_NETWORKS=31337 npm run build +NODE_ENV=development OPENSCAN_COMMIT_HASH=$COMMIT_HASH OPENSCAN_NETWORKS=31337 npm run build # Generate dist/package.json for npm publishing VERSION=$(node -p "require('./package.json').version") diff --git a/scripts/build-production.sh b/scripts/build-production.sh index b8421dca..d8307bdf 100755 --- a/scripts/build-production.sh +++ b/scripts/build-production.sh @@ -12,7 +12,7 @@ bun install --frozen-lockfile # Create .env file for production echo "Creating production environment file..." -echo "REACT_APP_ENVIRONMENT=production" > .env +echo "OPENSCAN_ENVIRONMENT=production" > .env # Get current commit hash COMMIT_HASH=$(git rev-parse HEAD) @@ -22,24 +22,31 @@ rm -rf dist || true # Build the app using Vite echo "Building React app on commit $COMMIT_HASH" -NODE_ENV=production REACT_APP_COMMIT_HASH=$COMMIT_HASH npm run build +NODE_ENV=production OPENSCAN_COMMIT_HASH=$COMMIT_HASH npm run build echo "Production build completed!" echo "Build output is in ./dist/" -# Get IPFS hash (ensure consistent chunking) -ipfs add -r --chunker=size-262144 --raw-leaves=false ./dist -HASH=$(ipfs add -r -Q --chunker=size-262144 --raw-leaves=false ./dist) -HASH_V1=$(ipfs cid format -v 1 -b base32 $HASH) - -echo "IPFS Hash (v0): $HASH" -echo "IPFS Hash (v1): $HASH_V1" -echo "" -echo "IPFS URLs:" -echo " - https://ipfs.io/ipfs/$HASH" -echo " - https://cloudflare-ipfs.com/ipfs/$HASH" -echo " - https://gateway.ipfs.io/ipfs/$HASH" -echo "" -echo "IPFS v1 URLs:" -echo " - https://$HASH_V1.ipfs.dweb.link" -echo " - https://$HASH_V1.ipfs.cf-ipfs.com" +# Generate IPFS hash if ipfs CLI (Kubo) is available +if command -v ipfs &> /dev/null; then + echo "" + echo "Generating IPFS hash..." + HASH=$(ipfs add -r -Q --chunker=size-262144 --raw-leaves=false ./dist) + HASH_V1=$(ipfs cid format -v 1 -b base32 $HASH) + + echo "IPFS Hash (v0): $HASH" + echo "IPFS Hash (v1): $HASH_V1" + echo "" + echo "IPFS URLs:" + echo " - https://ipfs.io/ipfs/$HASH" + echo " - https://cloudflare-ipfs.com/ipfs/$HASH" + echo " - https://gateway.ipfs.io/ipfs/$HASH" + echo "" + echo "IPFS v1 URLs:" + echo " - https://$HASH_V1.ipfs.dweb.link" + echo " - https://$HASH_V1.ipfs.cf-ipfs.com" +else + echo "" + echo "IPFS CLI (Kubo) not found. Skipping hash generation." + echo "Install Kubo to generate IPFS hashes: https://docs.ipfs.tech/install/" +fi diff --git a/scripts/build-staging.sh b/scripts/build-staging.sh index 9232aac4..bcbf4ad6 100755 --- a/scripts/build-staging.sh +++ b/scripts/build-staging.sh @@ -15,6 +15,6 @@ COMMIT_HASH=$(git rev-parse HEAD) # Build the app using Vite echo "Building React app on commit $COMMIT_HASH" -NODE_ENV=staging REACT_APP_COMMIT_HASH=$COMMIT_HASH npm run build +NODE_ENV=staging OPENSCAN_COMMIT_HASH=$COMMIT_HASH npm run build echo "Staging build completed!" echo "Build output is in ./dist/" diff --git a/scripts/run-test-env.sh b/scripts/run-test-env.sh index 46ebe8a9..10e02d65 100755 --- a/scripts/run-test-env.sh +++ b/scripts/run-test-env.sh @@ -86,7 +86,7 @@ echo "šŸ” Starting OpenScan (Ethereum Mainnet + hardhat only)..." cd "$OPENSCAN_DIR" # Start OpenScan - it will read .env.local on start -REACT_APP_OPENSCAN_NETWORKS="31337" npm start & +OPENSCAN_NETWORKS="31337" npm start & OPENSCAN_PID=$! # Wait for OpenScan to start diff --git a/src/App.tsx b/src/App.tsx index cfbb290b..0f2843fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,6 +44,14 @@ import { LazyRpcs, LazySearch, LazySettings, + LazySolanaAccount, + LazySolanaNetwork, + LazySolanaSlot, + LazySolanaSlots, + LazySolanaToken, + LazySolanaTx, + LazySolanaTxs, + LazySolanaValidators, LazySupporters, LazyTokenDetails, LazyTx, @@ -151,6 +159,33 @@ function AppContent() { } /> } /> } /> + {/* Solana Mainnet routes (must come before :networkId catch-all) */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Solana Devnet routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Solana Testnet routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* EVM network routes — validated */} }> } /> diff --git a/src/components/LazyComponents.tsx b/src/components/LazyComponents.tsx index 6d608e33..1777fd01 100644 --- a/src/components/LazyComponents.tsx +++ b/src/components/LazyComponents.tsx @@ -20,6 +20,16 @@ const BitcoinTransactionsPage = lazy(() => import("./pages/bitcoin/BitcoinTransa const BitcoinAddressPage = lazy(() => import("./pages/bitcoin/BitcoinAddressPage")); const BitcoinMempoolPage = lazy(() => import("./pages/bitcoin/BitcoinMempoolPage")); +// Lazy load page components - Solana +const SolanaNetwork = lazy(() => import("./pages/solana")); +const SolanaSlotsPage = lazy(() => import("./pages/solana/SolanaSlotsPage")); +const SolanaSlotPage = lazy(() => import("./pages/solana/SolanaSlotPage")); +const SolanaTransactionsPage = lazy(() => import("./pages/solana/SolanaTransactionsPage")); +const SolanaTransactionPage = lazy(() => import("./pages/solana/SolanaTransactionPage")); +const SolanaAccountPage = lazy(() => import("./pages/solana/SolanaAccountPage")); +const SolanaTokenPage = lazy(() => import("./pages/solana/SolanaTokenPage")); +const SolanaValidatorsPage = lazy(() => import("./pages/solana/SolanaValidatorsPage")); + // Lazy load page components - EVM const Chain = lazy(() => import("./pages/evm/network")); const Blocks = lazy(() => import("./pages/evm/blocks")); @@ -54,6 +64,14 @@ export const LazyBitcoinTx = withSuspense(BitcoinTransactionPage); export const LazyBitcoinTxs = withSuspense(BitcoinTransactionsPage); export const LazyBitcoinAddress = withSuspense(BitcoinAddressPage); export const LazyBitcoinMempool = withSuspense(BitcoinMempoolPage); +export const LazySolanaNetwork = withSuspense(SolanaNetwork); +export const LazySolanaSlots = withSuspense(SolanaSlotsPage); +export const LazySolanaSlot = withSuspense(SolanaSlotPage); +export const LazySolanaTxs = withSuspense(SolanaTransactionsPage); +export const LazySolanaTx = withSuspense(SolanaTransactionPage); +export const LazySolanaAccount = withSuspense(SolanaAccountPage); +export const LazySolanaToken = withSuspense(SolanaTokenPage); +export const LazySolanaValidators = withSuspense(SolanaValidatorsPage); export const LazyBlocks = withSuspense(Blocks); export const LazyBlock = withSuspense(Block); export const LazyTxs = withSuspense(Txs); @@ -91,6 +109,15 @@ export function preloadAllRoutes() { import("./pages/bitcoin/BitcoinTransactionsPage"); import("./pages/bitcoin/BitcoinAddressPage"); import("./pages/bitcoin/BitcoinMempoolPage"); + // Solana pages + import("./pages/solana"); + import("./pages/solana/SolanaSlotsPage"); + import("./pages/solana/SolanaSlotPage"); + import("./pages/solana/SolanaTransactionsPage"); + import("./pages/solana/SolanaTransactionPage"); + import("./pages/solana/SolanaAccountPage"); + import("./pages/solana/SolanaTokenPage"); + import("./pages/solana/SolanaValidatorsPage"); // EVM pages import("./pages/evm/network"); import("./pages/evm/blocks"); diff --git a/src/components/common/Footer.tsx b/src/components/common/Footer.tsx index a40b41db..0ad3ea64 100644 --- a/src/components/common/Footer.tsx +++ b/src/components/common/Footer.tsx @@ -16,17 +16,17 @@ const Footer: React.FC = ({ className = "" }) => { const { isSuperUser } = useSettings(); // Get commit hash from environment variable, fallback to 'development' - const commitHash = process.env.REACT_APP_COMMIT_HASH || "development"; + const commitHash = import.meta.env.OPENSCAN_COMMIT_HASH || "development"; // Format commit hash - show first 7 characters if it's a full hash const formattedCommitHash = commitHash.length > 7 ? commitHash.substring(0, 7) : commitHash; // Get version from environment variable or fallback - const appVersion = process.env.REACT_APP_VERSION || "0.1.0"; + const appVersion = import.meta.env.OPENSCAN_VERSION || "0.1.0"; // Get the GitHub repository URL from package.json or environment const repoUrl = - process.env.REACT_APP_GITHUB_REPO || "https://github.com/openscan-explorer/explorer"; + import.meta.env.OPENSCAN_GITHUB_REPO || "https://github.com/openscan-explorer/explorer"; // Determine footer version class based on environment const getVersionClass = () => { diff --git a/src/components/navbar/NetworkBlockIndicator.tsx b/src/components/navbar/NetworkBlockIndicator.tsx index a149279a..2049c45f 100644 --- a/src/components/navbar/NetworkBlockIndicator.tsx +++ b/src/components/navbar/NetworkBlockIndicator.tsx @@ -57,6 +57,15 @@ export function NetworkBlockIndicator({ className }: NetworkBlockIndicatorProps) setGasPrice(null); // Bitcoin doesn't have gas setIsLoading(false); } + } else if (network.type === "solana" && dataService?.isSolana()) { + // Fetch Solana current slot + const adapter = dataService.getSolanaAdapter(); + const slot = await adapter.getLatestSlot(); + if (isMounted) { + setBlockNumber(slot); + setGasPrice(null); // Solana doesn't have gas in the EVM sense + setIsLoading(false); + } } else if (network.type === "evm") { // Fetch EVM block number const urls = getRPCUrls(networkRpcKey, rpcUrls); diff --git a/src/components/pages/evm/address/displays/ERC721Display.tsx b/src/components/pages/evm/address/displays/ERC721Display.tsx index 62e8932e..38a4d941 100644 --- a/src/components/pages/evm/address/displays/ERC721Display.tsx +++ b/src/components/pages/evm/address/displays/ERC721Display.tsx @@ -16,6 +16,7 @@ import { logger } from "../../../../../utils/logger"; import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; import { AddressHeader } from "../shared"; +import CollectionTokenList from "../shared/CollectionTokenList"; import ContractInfoCard from "../shared/ContractInfoCard"; import ContractInfoCards from "../shared/ContractInfoCards"; import NFTCollectionInfoCard from "../shared/NFTCollectionInfoCard"; @@ -254,6 +255,12 @@ const ERC721Display: React.FC = ({ addressHash={addressHash} /> + + {/* Contract Info Card (includes Contract Details) */} { + if (hash.length <= chars * 2 + 3) return hash; + const prefix = hash.startsWith("0x") ? `0x${hash.slice(2, 2 + chars)}` : hash.slice(0, chars); + return `${prefix}...${hash.slice(-chars)}`; +}; + +const CollectionTokenList: React.FC = ({ + networkId, + addressHash, + totalSupply, +}) => { + const { t } = useTranslation("address"); + const { rpcUrls } = useContext(AppContext); + const [tokens, setTokens] = useState>([]); + const [loading, setLoading] = useState(true); + const [enumerable, setEnumerable] = useState(true); + + useEffect(() => { + const run = async () => { + setLoading(true); + setTokens([]); + setEnumerable(true); + + if (!totalSupply) { + setLoading(false); + return; + } + + const total = BigInt(totalSupply); + if (total === 0n) { + setLoading(false); + return; + } + + const rpcNetworkId = `eip155:${Number(networkId)}`; + const rpcUrlsForChain = rpcUrls[rpcNetworkId]; + const rpcUrl = Array.isArray(rpcUrlsForChain) ? rpcUrlsForChain[0] : rpcUrlsForChain; + if (!rpcUrl) { + setLoading(false); + return; + } + + const count = total < BigInt(TOKENS_PER_PAGE) ? Number(total) : TOKENS_PER_PAGE; + const indices: string[] = []; + for (let i = 0; i < count; i++) { + indices.push((total - 1n - BigInt(i)).toString()); + } + + const tokenIds = await Promise.all( + indices.map((idx) => fetchTokenByIndex(addressHash, idx, rpcUrl)), + ); + + if (tokenIds[0] === null) { + setEnumerable(false); + setLoading(false); + return; + } + + const resolved = tokenIds.filter((id): id is string => id !== null); + const owners = await Promise.all( + resolved.map((id) => fetchTokenOwner(addressHash, id, rpcUrl)), + ); + + setTokens(resolved.map((tokenId, i) => ({ tokenId, owner: owners[i] ?? null }))); + setLoading(false); + }; + + run(); + }, [networkId, addressHash, totalSupply, rpcUrls]); + + if (!totalSupply || !enumerable) return null; + + return ( +
+
{t("recentTokens")}
+
+ + + + + + + + + {loading + ? Array.from({ length: TOKENS_PER_PAGE }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder + + + + + )) + : tokens.map(({ tokenId, owner }) => ( + + + + + ))} + +
{t("tableTokenId")}{t("tableOwner")}
+ + + +
+ + #{tokenId} + + + {owner ? ( + + {truncateAddress(owner)} + + ) : ( + — + )} +
+
+
+ ); +}; + +export default CollectionTokenList; diff --git a/src/components/pages/evm/tokenDetails/ERC1155TokenDisplay.tsx b/src/components/pages/evm/tokenDetails/ERC1155TokenDisplay.tsx index 8ba4a669..23f596b0 100644 --- a/src/components/pages/evm/tokenDetails/ERC1155TokenDisplay.tsx +++ b/src/components/pages/evm/tokenDetails/ERC1155TokenDisplay.tsx @@ -12,6 +12,7 @@ import { getImageUrl, } from "../../../../utils/erc1155Metadata"; import { logger } from "../../../../utils/logger"; +import { toSafeExternalHref } from "../../../../utils/urlUtils"; import FieldLabel from "../../../common/FieldLabel"; import LoaderWithTimeout from "../../../common/LoaderWithTimeout"; @@ -155,6 +156,9 @@ const ERC1155TokenDetails: React.FC = () => { const tokenName = metadata?.name; const collectionName = collectionInfo?.name; const collectionSymbol = collectionInfo?.symbol; + const externalHref = toSafeExternalHref(metadata?.external_url); + const animationHref = toSafeExternalHref(metadata?.animation_url); + const tokenUriHref = toSafeExternalHref(tokenUri); return (
@@ -316,15 +320,15 @@ const ERC1155TokenDetails: React.FC = () => {
{/* Links Section */} - {(metadata?.external_url || metadata?.animation_url) && ( + {(externalHref || animationHref) && (
- Links + {t("links")}
{tokenUri} - {!tokenUri.startsWith("data:") && ( + {tokenUriHref && ( { const tokenName = metadata?.name; const collectionName = collectionInfo?.name; const collectionSymbol = collectionInfo?.symbol; + const externalHref = toSafeExternalHref(metadata?.external_url); + const animationHref = toSafeExternalHref(metadata?.animation_url); + const tokenUriHref = toSafeExternalHref(tokenUri); + + // BigInt for uint256 safety; totalSupply may be absent on non-enumerable contracts. + let prevTokenId: string | null = null; + let nextTokenId: string | null = null; + try { + const current = tokenId != null ? BigInt(tokenId) : null; + if (current !== null) { + if (current > 0n) prevTokenId = (current - 1n).toString(); + const totalSupplyBig = collectionInfo?.totalSupply + ? BigInt(collectionInfo.totalSupply) + : null; + if (totalSupplyBig === null || current + 1n < totalSupplyBig) { + nextTokenId = (current + 1n).toString(); + } + } + } catch {} return (
@@ -148,8 +168,38 @@ const ERC721TokenDisplay: React.FC = () => { )} )} - - {t("tokenID")}: {tokenId} + + {prevTokenId !== null && networkId && contractAddress ? ( + + ← + + ) : ( + + )} + + {t("tokenID")}: {tokenId} + + {nextTokenId !== null && networkId && contractAddress ? ( + + → + + ) : ( + + )}
@@ -298,15 +348,15 @@ const ERC721TokenDisplay: React.FC = () => { )} {/* Additional Details */} - {(metadata?.external_url || metadata?.animation_url) && ( + {(externalHref || animationHref) && (
{t("links")}
{tokenUri} - {!tokenUri.startsWith("data:") && ( + {tokenUriHref && (
- Step - PC - Opcode - Gas - Cost - Depth + {t("analyser.rawTraceColStep")} + + + + +
diff --git a/src/components/pages/home/index.tsx b/src/components/pages/home/index.tsx index 92ad9840..5db33b71 100644 --- a/src/components/pages/home/index.tsx +++ b/src/components/pages/home/index.tsx @@ -54,7 +54,7 @@ export default function Home() { const [showTestnets, setShowTestnets] = useState(false); const { featuredNetworks, productionNetworks, testnetNetworks } = useMemo(() => { - const isDevelopment = import.meta.env.VITE_ENVIRONMENT === "development"; + const isDevelopment = import.meta.env.OPENSCAN_ENVIRONMENT === "development"; const localhostChainId = 31337; // In development, treat localhost as a production network (show with other networks) diff --git a/src/components/pages/rpcs/RpcTestRow.tsx b/src/components/pages/rpcs/RpcTestRow.tsx index e5debebd..43a10e7d 100644 --- a/src/components/pages/rpcs/RpcTestRow.tsx +++ b/src/components/pages/rpcs/RpcTestRow.tsx @@ -2,6 +2,7 @@ import type React from "react"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import type { MetadataRpcEndpoint } from "../../../services/MetadataService"; +import { redactSensitiveUrl } from "../../../utils/urlUtils"; import type { RpcTestResult, RpcTestStatus } from "./useRpcLatencyTest"; interface RpcTestRowProps { @@ -45,32 +46,6 @@ function getProviderLabel(url: string, metadata: MetadataRpcEndpoint | undefined } } -function redactSensitiveUrl(rawUrl: string): string { - try { - const parsed = new URL(rawUrl); - - // Hide common credential query params - const sensitiveParamRegex = /key|token|secret|auth|signature|apikey|api_key|access_token/i; - for (const [key] of parsed.searchParams.entries()) { - if (sensitiveParamRegex.test(key)) { - parsed.searchParams.set(key, "***"); - } - } - - // Hide credential-like path segments (long, high-entropy tokens) - const segments = parsed.pathname.split("/").map((segment) => { - if (!segment) return segment; - const looksLikeToken = segment.length >= 24 && /[A-Za-z]/.test(segment) && /\d/.test(segment); - return looksLikeToken ? "***" : segment; - }); - parsed.pathname = segments.join("/"); - - return parsed.toString(); - } catch { - return rawUrl; - } -} - function getTruncatedUrl(url: string): string { const safeUrl = redactSensitiveUrl(url); try { diff --git a/src/components/pages/solana/SolanaAccountDisplay.tsx b/src/components/pages/solana/SolanaAccountDisplay.tsx new file mode 100644 index 00000000..f8c44d18 --- /dev/null +++ b/src/components/pages/solana/SolanaAccountDisplay.tsx @@ -0,0 +1,190 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { SolanaAccount, SolanaSignatureInfo } from "../../../types"; +import { formatSlotNumber, formatSol, shortenSolanaAddress } from "../../../utils/solanaUtils"; +import CopyButton from "../../common/CopyButton"; + +interface SolanaAccountDisplayProps { + account: SolanaAccount; + signatures: SolanaSignatureInfo[]; + networkId: string; +} + +const SolanaAccountDisplay: React.FC = React.memo( + ({ account, signatures, networkId }) => { + const { t } = useTranslation("solana"); + + const accountTypeLabel = account.executable ? t("account.program") : t("account.wallet"); + + return ( +
+
+
+ {t("account.title")} + {accountTypeLabel} +
+ + {/* Address — full width on top */} +
+
+ {t("account.address")}: + + {account.address} + + +
+
+ + {/* Two-column layout: account details | token holdings */} +
+ {/* Left column — account details */} +
+
+ {t("account.balance")}: + {formatSol(account.lamports)} +
+ +
+ {t("account.owner")}: + + + {shortenSolanaAddress(account.owner, 10, 10)} + + +
+ +
+ {t("account.executable")}: + + {account.executable ? t("account.yes") : t("account.no")} + +
+ +
+ {t("account.dataSize")}: + {account.space.toLocaleString()} bytes +
+ +
+ {t("account.rentEpoch")}: + {account.rentEpoch} +
+
+ + {/* Right column — token holdings */} +
+
+

+ {t("account.tokenHoldings")} + {account.tokenAccounts && account.tokenAccounts.length > 0 + ? ` (${account.tokenAccounts.length})` + : ""} +

+ {account.tokenAccounts && account.tokenAccounts.length > 0 ? ( +
+ + + + + + + + + {account.tokenAccounts.map((holding) => ( + + + + + ))} + +
{t("token.mint")}{t("token.amount")}
+ + {shortenSolanaAddress(holding.mint, 8, 8)} + + {holding.amount.uiAmountString}
+
+ ) : ( +
+

{t("account.noTokens")}

+
+ )} +
+
+
+ + {/* Recent Transactions */} +
+

+ {t("account.recentTransactions")}{" "} + {signatures.length > 0 ? `(${signatures.length})` : ""} +

+ {signatures.length > 0 ? ( +
+ + + + + + + + + + {signatures.map((sig) => ( + + + + + + ))} + +
{t("transaction.signature")}{t("transaction.status")}{t("transaction.slot")}
+ + {shortenSolanaAddress(sig.signature, 12, 12)} + + + {sig.err ? ( + + āœ— {t("transactions.failed")} + + ) : ( + + āœ“ {t("transactions.success")} + + )} + + + {formatSlotNumber(sig.slot)} + +
+
+ ) : ( +
+

{t("account.noTransactions")}

+
+ )} +
+
+
+ ); + }, +); + +SolanaAccountDisplay.displayName = "SolanaAccountDisplay"; + +export default SolanaAccountDisplay; diff --git a/src/components/pages/solana/SolanaAccountPage.tsx b/src/components/pages/solana/SolanaAccountPage.tsx new file mode 100644 index 00000000..724b097d --- /dev/null +++ b/src/components/pages/solana/SolanaAccountPage.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { SolanaAccount, SolanaSignatureInfo } from "../../../types"; +import { shortenSolanaAddress } from "../../../utils/solanaUtils"; +import Breadcrumb from "../../common/Breadcrumb"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; +import SolanaAccountDisplay from "./SolanaAccountDisplay"; + +export default function SolanaAccountPage() { + const { address } = useParams<{ address: string }>(); + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [account, setAccount] = useState(null); + const [signatures, setSignatures] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana() || !address) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchAccount = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const [accountResult, sigsResult] = await Promise.all([ + adapter.getAccount(address), + adapter.getSignaturesForAddress(address, { limit: 25 }).catch(() => []), + ]); + if (!cancelled) { + setAccount(accountResult.data); + setSignatures(sigsResult); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch account"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchAccount(); + return () => { + cancelled = true; + }; + }, [dataService, address]); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("account.title") }, + { label: address ? shortenSolanaAddress(address, 6, 6) : "" }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("account.title")} + + {address ? shortenSolanaAddress(address, 8, 8) : ""} + +
+
+ window.location.reload()} + /> +
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("account.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + return ( +
+ + {account ? ( + + ) : ( +
+
+

Account not found

+
+
+ )} +
+ ); +} diff --git a/src/components/pages/solana/SolanaBlocksTable.tsx b/src/components/pages/solana/SolanaBlocksTable.tsx new file mode 100644 index 00000000..4bace0fd --- /dev/null +++ b/src/components/pages/solana/SolanaBlocksTable.tsx @@ -0,0 +1,78 @@ +import type React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { SolanaBlock } from "../../../types"; +import { formatSlotNumber, shortenSolanaAddress } from "../../../utils/solanaUtils"; + +interface SolanaBlocksTableProps { + blocks: SolanaBlock[]; + loading: boolean; + networkId: string; +} + +function formatTimeAgo(blockTime: number | null): string { + if (blockTime === null) return "—"; + const seconds = Math.floor(Date.now() / 1000 - blockTime); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + return `${Math.floor(seconds / 3600)}h ago`; +} + +const SolanaBlocksTable: React.FC = ({ blocks, loading, networkId }) => { + const { t } = useTranslation("solana"); + + return ( +
+
+ +

{t("blocks.title")}

+ ↗ + + + {t("blocks.viewAll")} → + +
+ + {loading && blocks.length === 0 ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder +
+
+ +
+
+ ))} +
+ ) : blocks.length === 0 ? ( +
{t("blocks.noBlocks")}
+ ) : ( +
+ {blocks.map((block) => ( +
+
+ + #{formatSlotNumber(block.slot)} + + {formatTimeAgo(block.blockTime)} +
+
+ {block.transactionCount} txns +
+
+ + {shortenSolanaAddress(block.blockhash, 6, 6)} + +
+
+ ))} +
+ )} +
+ ); +}; + +export default SolanaBlocksTable; diff --git a/src/components/pages/solana/SolanaDashboardStats.tsx b/src/components/pages/solana/SolanaDashboardStats.tsx new file mode 100644 index 00000000..9aa1b6b0 --- /dev/null +++ b/src/components/pages/solana/SolanaDashboardStats.tsx @@ -0,0 +1,63 @@ +import type React from "react"; +import { useTranslation } from "react-i18next"; +import type { SolanaNetworkStats } from "../../../types"; +import { calculateEpochProgress, formatSlotNumber } from "../../../utils/solanaUtils"; + +interface SolanaDashboardStatsProps { + stats: SolanaNetworkStats | null; + solPrice: number | null; + loading: boolean; +} + +const SolanaDashboardStats: React.FC = ({ + stats, + solPrice, + loading, +}) => { + const { t } = useTranslation("solana"); + + const skeleton = (width: string) => ( + + ); + + const epochProgress = stats + ? calculateEpochProgress(stats.epochSlotIndex, stats.epochSlotsTotal) + : 0; + + return ( +
+
+
{t("dashboard.solPrice")}
+
+ {loading && solPrice === null + ? skeleton("80px") + : solPrice + ? `$${solPrice.toFixed(2)}` + : "—"} +
+
+ +
+
{t("dashboard.currentSlot")}
+
+ {loading && !stats ? skeleton("100px") : formatSlotNumber(stats?.currentSlot ?? 0)} +
+
+ {stats ? `${t("dashboard.blockHeight")}: ${formatSlotNumber(stats.blockHeight)}` : ""} +
+
+ +
+
{t("dashboard.epoch")}
+
+ {loading && !stats ? skeleton("60px") : (stats?.epoch ?? "—")} +
+
+ {stats ? `${epochProgress.toFixed(1)}% complete` : ""} +
+
+
+ ); +}; + +export default SolanaDashboardStats; diff --git a/src/components/pages/solana/SolanaSlotDisplay.tsx b/src/components/pages/solana/SolanaSlotDisplay.tsx new file mode 100644 index 00000000..005d656c --- /dev/null +++ b/src/components/pages/solana/SolanaSlotDisplay.tsx @@ -0,0 +1,183 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { SolanaBlock } from "../../../types"; +import { + formatBlockTime, + formatSlotNumber, + formatSol, + shortenSolanaAddress, +} from "../../../utils/solanaUtils"; +import CopyButton from "../../common/CopyButton"; + +interface SolanaSlotDisplayProps { + block: SolanaBlock; + networkId: string; +} + +const SolanaSlotDisplay: React.FC = React.memo(({ block, networkId }) => { + const [showTransactions, setShowTransactions] = useState(false); + const { t } = useTranslation("solana"); + + const totalRewards = block.rewards.reduce((sum, r) => sum + r.lamports, 0); + + return ( +
+
+
+
+ {block.slot > 0 && ( + + ← + + )} +
+ {t("block.title")} + #{formatSlotNumber(block.slot)} +
+ + → + + • + + {formatBlockTime(block.blockTime)} + +
+
+ +
+ {/* Block Hash */} +
+ {t("block.blockHash")}: + + {block.blockhash} + + +
+ + {/* Previous Blockhash */} +
+ {t("block.previousBlockhash")}: + {block.previousBlockhash} +
+ + {/* Parent Slot */} +
+ {t("block.parentSlot")}: + + + #{formatSlotNumber(block.parentSlot)} + + +
+ + {/* Block Height */} + {block.blockHeight !== null && ( +
+ {t("block.blockHeight")}: + {formatSlotNumber(block.blockHeight)} +
+ )} + + {/* Transaction count */} +
+ {t("block.transactionCount")}: + + {block.transactionCount.toLocaleString()}{" "} + transactions + +
+ + {/* Total rewards */} + {block.rewards.length > 0 && ( +
+ {t("block.rewards")}: + {formatSol(totalRewards)} +
+ )} +
+ + {/* Rewards breakdown */} + {block.rewards.length > 0 && ( +
+

{t("block.rewards")}

+
+ + + + + + + + + + {block.rewards.map((reward) => ( + + + + + + ))} + +
Pubkey{t("block.rewardType")}{t("block.amount")}
+ + {shortenSolanaAddress(reward.pubkey, 8, 8)} + + {reward.rewardType ?? "—"}{formatSol(reward.lamports)}
+
+
+ )} + + {/* Transactions */} + {block.signatures && block.signatures.length > 0 && ( +
+ + + {showTransactions && ( +
+
+ {block.signatures.map((sig, index) => ( +
+ {index} + + + {sig} + + +
+ ))} +
+
+ )} +
+ )} +
+
+ ); +}); + +SolanaSlotDisplay.displayName = "SolanaSlotDisplay"; + +export default SolanaSlotDisplay; diff --git a/src/components/pages/solana/SolanaSlotPage.tsx b/src/components/pages/solana/SolanaSlotPage.tsx new file mode 100644 index 00000000..06951ac6 --- /dev/null +++ b/src/components/pages/solana/SolanaSlotPage.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { DataWithMetadata, SolanaBlock } from "../../../types"; +import Breadcrumb from "../../common/Breadcrumb"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; +import SolanaSlotDisplay from "./SolanaSlotDisplay"; + +export default function SolanaSlotPage() { + const { filter } = useParams<{ filter: string }>(); + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [blockResult, setBlockResult] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana() || !filter) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchBlock = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const slot = Number(filter); + if (Number.isNaN(slot)) { + throw new Error(`Invalid slot: ${filter}`); + } + const result = await adapter.getBlock(slot); + if (!cancelled) setBlockResult(result); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch block"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchBlock(); + return () => { + cancelled = true; + }; + }, [dataService, filter]); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("blocks.blocksTitle"), to: `/${networkSlug}/slots` }, + { label: `${t("block.title")} #${filter}` }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("block.title")} + #{filter} +
+
+ window.location.reload()} + /> +
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("block.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + return ( +
+ + {blockResult?.data ? ( + + ) : ( +
+
+

{t("blocks.noBlocks")}

+
+
+ )} +
+ ); +} diff --git a/src/components/pages/solana/SolanaSlotsPage.tsx b/src/components/pages/solana/SolanaSlotsPage.tsx new file mode 100644 index 00000000..e338a9ff --- /dev/null +++ b/src/components/pages/solana/SolanaSlotsPage.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { SolanaBlock } from "../../../types"; +import { formatSlotNumber, shortenSolanaAddress } from "../../../utils/solanaUtils"; +import { logger } from "../../../utils/logger"; +import Breadcrumb from "../../common/Breadcrumb"; + +const BLOCKS_PER_PAGE = 25; + +function formatBlockTimeAgo(blockTime: number | null): string { + if (blockTime === null) return "—"; + const seconds = Math.floor(Date.now() / 1000 - blockTime); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +} + +export default function SolanaSlotsPage() { + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [blocks, setBlocks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana()) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchBlocks = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const result = await adapter.getLatestBlocks(BLOCKS_PER_PAGE); + if (!cancelled) setBlocks(result); + } catch (err) { + logger.error("Error fetching Solana blocks:", err); + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch blocks"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchBlocks(); + return () => { + cancelled = true; + }; + }, [dataService]); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("blocks.blocksTitle") }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("blocks.title")} +
+
+ + + + + + + + + + + {Array.from({ length: BLOCKS_PER_PAGE }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder + + + + + + + ))} + +
{t("blocks.slot")}{t("blocks.blockHash")}{t("blocks.time")}{t("blocks.txCount")}
+ + + + + + + +
+
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("blocks.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + return ( +
+ +
+
+
+ {t("blocks.title")} + • + Showing {blocks.length} most recent blocks +
+
+ +
+ + + + + + + + + + + {blocks.map((block) => ( + + + + + + + ))} + +
{t("blocks.slot")}{t("blocks.blockHash")}{t("blocks.time")}{t("blocks.txCount")}
+ + {formatSlotNumber(block.slot)} + + + + {shortenSolanaAddress(block.blockhash, 8, 8)} + + {formatBlockTimeAgo(block.blockTime)}{block.transactionCount.toLocaleString()}
+
+
+
+ ); +} diff --git a/src/components/pages/solana/SolanaTokenPage.tsx b/src/components/pages/solana/SolanaTokenPage.tsx new file mode 100644 index 00000000..9dbf8d4e --- /dev/null +++ b/src/components/pages/solana/SolanaTokenPage.tsx @@ -0,0 +1,190 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { SolanaTokenAmount, SolanaTokenLargestAccount } from "../../../types"; +import { shortenSolanaAddress } from "../../../utils/solanaUtils"; +import Breadcrumb from "../../common/Breadcrumb"; +import CopyButton from "../../common/CopyButton"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; + +export default function SolanaTokenPage() { + const { mint } = useParams<{ mint: string }>(); + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [supply, setSupply] = useState(null); + const [holders, setHolders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana() || !mint) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchToken = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const [supplyResult, holdersResult] = await Promise.all([ + adapter.getTokenSupply(mint), + adapter.getTokenLargestAccounts(mint), + ]); + if (!cancelled) { + setSupply(supplyResult); + setHolders(holdersResult); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch token"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchToken(); + return () => { + cancelled = true; + }; + }, [dataService, mint]); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("token.title") }, + { label: mint ? shortenSolanaAddress(mint, 6, 6) : "" }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("token.title")} +
+
+ window.location.reload()} + /> +
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("token.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + const totalSupplyNum = supply ? Number(supply.amount) : 0; + + return ( +
+ +
+
+
+ {t("token.title")} + SPL +
+ +
+
+ {t("token.mint")}: + + {mint} + {mint && } + +
+ {supply && ( + <> +
+ {t("token.totalSupply")}: + {supply.uiAmountString} +
+
+ {t("token.decimals")}: + {supply.decimals} +
+ + )} +
+ +
+

+ {t("token.topHolders")} {holders.length > 0 ? `(${holders.length})` : ""} +

+ {holders.length > 0 ? ( +
+ + + + + + + + + + + {holders.map((holder, idx) => { + const pct = + totalSupplyNum > 0 ? (Number(holder.amount) / totalSupplyNum) * 100 : 0; + return ( + + + + + + + ); + })} + +
{t("token.holderRank")}{t("token.holderAddress")}{t("token.amount")}{t("token.percentage")}
#{idx + 1} + + {shortenSolanaAddress(holder.address, 8, 8)} + + {holder.uiAmountString}{pct.toFixed(2)}%
+
+ ) : ( +
+

{t("token.noHolders")}

+
+ )} +
+
+
+
+ ); +} diff --git a/src/components/pages/solana/SolanaTransactionDisplay.tsx b/src/components/pages/solana/SolanaTransactionDisplay.tsx new file mode 100644 index 00000000..de6127a5 --- /dev/null +++ b/src/components/pages/solana/SolanaTransactionDisplay.tsx @@ -0,0 +1,245 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { SolanaTransaction } from "../../../types"; +import { + formatBlockTime, + formatSlotNumber, + formatSol, + shortenSolanaAddress, +} from "../../../utils/solanaUtils"; +import CopyButton from "../../common/CopyButton"; + +interface SolanaTransactionDisplayProps { + tx: SolanaTransaction; + networkId: string; +} + +const SolanaTransactionDisplay: React.FC = React.memo( + ({ tx, networkId }) => { + const { t } = useTranslation("solana"); + const [showLogs, setShowLogs] = useState(false); + const [showInner, setShowInner] = useState(false); + + return ( +
+
+
+
+
+ {t("transaction.title")} +
+ • + + {formatBlockTime(tx.blockTime)} + +
+ + {tx.status === "success" ? t("transactions.success") : t("transactions.failed")} + +
+ +
+ {/* Signature */} +
+ {t("transaction.signature")}: + + {tx.signature} + + +
+ + {/* Slot */} +
+ {t("transaction.slot")}: + + + #{formatSlotNumber(tx.slot)} + + +
+ + {/* Fee */} +
+ {t("transaction.fee")}: + {formatSol(tx.fee)} +
+ + {/* Compute units */} + {tx.computeUnitsConsumed !== undefined && ( +
+ {t("transaction.computeUnits")}: + {tx.computeUnitsConsumed.toLocaleString()} +
+ )} + + {/* Version */} + {tx.version !== undefined && ( +
+ {t("transaction.version")}: + {String(tx.version)} +
+ )} +
+ + {/* Account Keys */} + {tx.accountKeys.length > 0 && ( +
+

+ {t("transaction.accountKeys")} ({tx.accountKeys.length}) +

+
+ + + + + + + + + + + {tx.accountKeys.map((key, idx) => ( + + + + + + + ))} + +
#Pubkey{t("transaction.signer")}{t("transaction.writable")}
{idx} + + {shortenSolanaAddress(key.pubkey, 8, 8)} + + {key.signer ? "āœ“" : "—"}{key.writable ? "āœ“" : "—"}
+
+
+ )} + + {/* Instructions */} + {tx.instructions.length > 0 && ( +
+

+ {t("transaction.instructions")} ({tx.instructions.length}) +

+
+ {tx.instructions.map((ix, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: instructions are ordered +
+
+ {t("transaction.programId")}: + + + {shortenSolanaAddress(ix.programId, 10, 10)} + + +
+ {ix.accounts.length > 0 && ( +
+ {t("transaction.accounts")}: + + {ix.accounts.length} account{ix.accounts.length === 1 ? "" : "s"} + +
+ )} + {ix.data && ( +
+ {t("transaction.data")}: + + {ix.data.length > 80 ? `${ix.data.slice(0, 80)}…` : ix.data} + +
+ )} +
+ ))} +
+
+ )} + + {/* Inner Instructions */} + {tx.innerInstructions.length > 0 && ( +
+ + {showInner && ( +
+ {tx.innerInstructions.map((group) => ( +
+
+ Index: + {group.index} +
+ {group.instructions.map((ix, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: instructions are ordered +
+ {t("transaction.programId")}: + + {shortenSolanaAddress(ix.programId, 8, 8)} + +
+ ))} +
+ ))} +
+ )} +
+ )} + + {/* Logs */} + {tx.logMessages.length > 0 && ( +
+ + {showLogs && ( +
+ {tx.logMessages.map((msg, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: log messages are ordered +
+ + {i} + + {msg} +
+ ))} +
+ )} +
+ )} +
+
+ ); + }, +); + +SolanaTransactionDisplay.displayName = "SolanaTransactionDisplay"; + +export default SolanaTransactionDisplay; diff --git a/src/components/pages/solana/SolanaTransactionPage.tsx b/src/components/pages/solana/SolanaTransactionPage.tsx new file mode 100644 index 00000000..9213f1fc --- /dev/null +++ b/src/components/pages/solana/SolanaTransactionPage.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { DataWithMetadata, SolanaTransaction } from "../../../types"; +import { shortenSolanaAddress } from "../../../utils/solanaUtils"; +import Breadcrumb from "../../common/Breadcrumb"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; +import SolanaTransactionDisplay from "./SolanaTransactionDisplay"; + +export default function SolanaTransactionPage() { + const { filter: signature } = useParams<{ filter: string }>(); + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [txResult, setTxResult] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana() || !signature) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchTx = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const result = await adapter.getTransaction(signature); + if (!cancelled) setTxResult(result); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch transaction"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchTx(); + return () => { + cancelled = true; + }; + }, [dataService, signature]); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("transactions.txsTitle"), to: `/${networkSlug}/txs` }, + { label: signature ? shortenSolanaAddress(signature, 8, 8) : t("transaction.title") }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("transaction.title")} + + {signature ? shortenSolanaAddress(signature, 10, 10) : ""} + +
+
+ window.location.reload()} + /> +
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("transaction.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + return ( +
+ + {txResult?.data ? ( + + ) : ( +
+
+

{t("transactions.noTransactions")}

+
+
+ )} +
+ ); +} diff --git a/src/components/pages/solana/SolanaTransactionsPage.tsx b/src/components/pages/solana/SolanaTransactionsPage.tsx new file mode 100644 index 00000000..d08eae1a --- /dev/null +++ b/src/components/pages/solana/SolanaTransactionsPage.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { SolanaTransaction } from "../../../types"; +import { logger } from "../../../utils/logger"; +import { formatSlotNumber, formatSol, shortenSolanaAddress } from "../../../utils/solanaUtils"; +import Breadcrumb from "../../common/Breadcrumb"; + +const TXS_PER_PAGE = 30; +const SKELETON_ROWS = 15; + +export default function SolanaTransactionsPage() { + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana()) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchTxs = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const blocks = await adapter.getLatestBlocks(3); + const sigs: string[] = []; + for (const b of blocks) { + if (b.signatures) sigs.push(...b.signatures.slice(0, 15)); + if (sigs.length >= TXS_PER_PAGE) break; + } + const txResults = await Promise.all( + sigs.slice(0, TXS_PER_PAGE).map((s) => + adapter + .getTransaction(s) + .then((r) => r.data) + .catch(() => null), + ), + ); + const txs = txResults.filter((tx): tx is SolanaTransaction => tx !== null); + if (!cancelled) setTransactions(txs); + } catch (err) { + logger.error("Error fetching Solana transactions:", err); + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch transactions"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchTxs(); + return () => { + cancelled = true; + }; + }, [dataService]); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("transactions.txsTitle") }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("transactions.title")} +
+
+ + + + + + + + + + + {Array.from({ length: SKELETON_ROWS }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder + + + + + + + ))} + +
{t("transactions.signature")}{t("transactions.status")}{t("transactions.slot")}{t("transactions.fee")}
+ + + + + + + +
+
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("transactions.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + return ( +
+ +
+
+
+ {t("transactions.title")} + • + + Showing {transactions.length} most recent transactions + +
+
+ +
+ + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + ))} + +
{t("transactions.signature")}{t("transactions.status")}{t("transactions.slot")}{t("transactions.fee")}
+ + {shortenSolanaAddress(tx.signature, 12, 12)} + + + {tx.status === "success" ? ( + + āœ“ {t("transactions.success")} + + ) : ( + + āœ— {t("transactions.failed")} + + )} + + + {formatSlotNumber(tx.slot)} + + {formatSol(tx.fee)}
+
+
+
+ ); +} diff --git a/src/components/pages/solana/SolanaTransactionsTable.tsx b/src/components/pages/solana/SolanaTransactionsTable.tsx new file mode 100644 index 00000000..73f13b65 --- /dev/null +++ b/src/components/pages/solana/SolanaTransactionsTable.tsx @@ -0,0 +1,73 @@ +import type React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { SolanaTransaction } from "../../../types"; +import { formatSol, shortenSolanaAddress } from "../../../utils/solanaUtils"; + +interface SolanaTransactionsTableProps { + transactions: SolanaTransaction[]; + loading: boolean; + networkId: string; +} + +const SolanaTransactionsTable: React.FC = ({ + transactions, + loading, + networkId, +}) => { + const { t } = useTranslation("solana"); + + return ( +
+
+ +

{t("transactions.title")}

+ ↗ + + + {t("transactions.viewAll")} → + +
+ + {loading && transactions.length === 0 ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder +
+ +
+ ))} +
+ ) : transactions.length === 0 ? ( +
{t("transactions.noTransactions")}
+ ) : ( +
+ {transactions.map((tx) => ( +
+
+ + {shortenSolanaAddress(tx.signature, 8, 6)} + + + {tx.status === "success" ? t("transactions.success") : t("transactions.failed")} + +
+
+ {formatSol(tx.fee)} +
+
+ ))} +
+ )} +
+ ); +}; + +export default SolanaTransactionsTable; diff --git a/src/components/pages/solana/SolanaValidatorsPage.tsx b/src/components/pages/solana/SolanaValidatorsPage.tsx new file mode 100644 index 00000000..e0613e3a --- /dev/null +++ b/src/components/pages/solana/SolanaValidatorsPage.tsx @@ -0,0 +1,217 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import type { SolanaEpochInfo, SolanaValidator } from "../../../types"; +import { + calculateEpochProgress, + formatStake, + shortenSolanaAddress, +} from "../../../utils/solanaUtils"; +import Breadcrumb from "../../common/Breadcrumb"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; + +export default function SolanaValidatorsPage() { + const location = useLocation(); + const { t } = useTranslation("solana"); + + const networkSlug = location.pathname.split("/")[1] || "sol"; + const dataService = useDataService(networkSlug); + const networkConfig = getNetworkBySlug(networkSlug); + const networkLabel = networkConfig?.shortName || networkConfig?.name || networkSlug.toUpperCase(); + + const [current, setCurrent] = useState([]); + const [delinquent, setDelinquent] = useState([]); + const [epochInfo, setEpochInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isSolana()) { + setLoading(false); + return; + } + + let cancelled = false; + const fetchValidators = async () => { + setLoading(true); + setError(null); + try { + const adapter = dataService.getSolanaAdapter(); + const [voteAccounts, epoch] = await Promise.all([ + adapter.getVoteAccounts(), + adapter.getEpochInfo(), + ]); + if (!cancelled) { + const sortedCurrent = [...voteAccounts.current].sort( + (a, b) => b.activatedStake - a.activatedStake, + ); + setCurrent(sortedCurrent); + setDelinquent(voteAccounts.delinquent); + setEpochInfo(epoch); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to fetch validators"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchValidators(); + return () => { + cancelled = true; + }; + }, [dataService]); + + const totalStake = useMemo( + () => current.reduce((sum, v) => sum + v.activatedStake, 0), + [current], + ); + + const breadcrumbItems = [ + { label: "Home", to: "/" }, + { label: networkLabel, to: `/${networkSlug}` }, + { label: t("validators.title") }, + ]; + + if (loading) { + return ( +
+ +
+
+ {t("validators.title")} +
+
+ window.location.reload()} + /> +
+
+
+ ); + } + + if (error) { + return ( +
+ +
+
+ {t("validators.title")} +
+
+

Error: {error}

+
+
+
+ ); + } + + const epochProgress = epochInfo + ? calculateEpochProgress(epochInfo.slotIndex, epochInfo.slotsInEpoch) + : 0; + + const renderValidatorTable = (validators: SolanaValidator[]) => ( +
+ + + + + + + + + + + + + {validators.map((v, idx) => ( + + + + + + + + + ))} + +
#{t("validators.identity")}{t("validators.voteAccount")}{t("validators.stake")}{t("validators.commission")}{t("validators.lastVote")}
{idx + 1} + + {shortenSolanaAddress(v.nodePubkey, 6, 6)} + + + + {shortenSolanaAddress(v.votePubkey, 6, 6)} + + {formatStake(v.activatedStake)}{v.commission}%{v.lastVote.toLocaleString()}
+
+ ); + + return ( +
+ +
+
+ {t("validators.title")} +
+ + {epochInfo && ( +
+
+ {t("validators.currentEpoch")}: + {epochInfo.epoch} +
+
+ {t("validators.epochProgress")}: + {epochProgress.toFixed(2)}% +
+
+ {t("validators.totalStake")}: + {formatStake(totalStake)} +
+
+ {t("validators.validatorCount")}: + {current.length.toLocaleString()} +
+
+ )} + +
+

+ {t("validators.currentValidators")} ({current.length}) +

+ {current.length > 0 ? ( + renderValidatorTable(current) + ) : ( +
+

{t("validators.noValidators")}

+
+ )} +
+ + {delinquent.length > 0 && ( +
+

+ {t("validators.delinquentValidators")} ({delinquent.length}) +

+ {renderValidatorTable(delinquent)} +
+ )} +
+
+ ); +} diff --git a/src/components/pages/solana/index.tsx b/src/components/pages/solana/index.tsx new file mode 100644 index 00000000..299a18c4 --- /dev/null +++ b/src/components/pages/solana/index.tsx @@ -0,0 +1,89 @@ +import { useLocation } from "react-router-dom"; +import { useSolanaDashboard } from "../../../hooks/useSolanaDashboard"; +import { resolveNetwork } from "../../../utils/networkResolver"; +import { getAllNetworks } from "../../../config/networks"; +import type { NetworkConfig } from "../../../types"; +import SearchBox from "../../common/SearchBox"; +import SolanaDashboardStats from "./SolanaDashboardStats"; +import SolanaBlocksTable from "./SolanaBlocksTable"; +import SolanaTransactionsTable from "./SolanaTransactionsTable"; + +// Default Solana network config for fallback +const DEFAULT_SOLANA_NETWORK: NetworkConfig = { + type: "solana", + networkId: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + slug: "sol", + name: "Solana", + shortName: "Solana", + currency: "SOL", + color: "#9945FF", +}; + +export default function SolanaNetwork() { + const location = useLocation(); + + // Extract network slug from path + const pathSlug = location.pathname.split("/")[1] || "sol"; + const network = resolveNetwork(pathSlug, getAllNetworks()) || DEFAULT_SOLANA_NETWORK; + const dashboard = useSolanaDashboard(network); + + const networkName = network.name.toUpperCase(); + const networkColor = network.color || "#9945FF"; + + return ( +
+ ); +} diff --git a/src/config/networks.json b/src/config/networks.json index 248a8c19..98c90881 100644 --- a/src/config/networks.json +++ b/src/config/networks.json @@ -285,6 +285,198 @@ } ] }, + { + "type": "evm", + "networkId": "eip155:421614", + "slug": "arb-sepolia", + "name": "Arbitrum Sepolia", + "shortName": "Arb Sepolia", + "description": "Arbitrum testnet for developers", + "currency": "ETH", + "color": "#28A0F0", + "isTestnet": true, + "logo": "assets/networks/421614.svg", + "links": [ + { + "name": "Bridge", + "url": "https://bridge.arbitrum.io", + "description": "Bridge from Sepolia" + }, + { + "name": "Docs", + "url": "https://docs.arbitrum.io", + "description": "Developer documentation" + } + ] + }, + { + "type": "evm", + "networkId": "eip155:11155420", + "slug": "op-sepolia", + "name": "Optimism Sepolia", + "shortName": "OP Sepolia", + "description": "Optimism testnet for developers", + "currency": "ETH", + "color": "#FF0420", + "isTestnet": true, + "logo": "assets/networks/11155420.svg", + "links": [ + { + "name": "Bridge", + "url": "https://app.optimism.io/bridge", + "description": "Bridge from Sepolia" + }, + { + "name": "Docs", + "url": "https://docs.optimism.io", + "description": "Developer documentation" + } + ] + }, + { + "type": "evm", + "networkId": "eip155:84532", + "slug": "base-sepolia", + "name": "Base Sepolia", + "shortName": "Base Sepolia", + "description": "Base testnet for developers", + "currency": "ETH", + "color": "#0052FF", + "isTestnet": true, + "logo": "assets/networks/84532.svg", + "links": [ + { + "name": "Faucet", + "url": "https://www.coinbase.com/faucets/base-ethereum-sepolia-faucet", + "description": "Get testnet ETH" + }, + { + "name": "Docs", + "url": "https://docs.base.org", + "description": "Developer documentation" + } + ] + }, + { + "type": "evm", + "networkId": "eip155:80002", + "slug": "polygon-amoy", + "name": "Polygon Amoy", + "shortName": "Amoy", + "description": "Polygon testnet for developers", + "currency": "POL", + "color": "#8247E5", + "isTestnet": true, + "logo": "assets/networks/80002.svg", + "links": [ + { + "name": "Faucet", + "url": "https://faucet.polygon.technology", + "description": "Get testnet POL" + }, + { + "name": "Docs", + "url": "https://docs.polygon.technology", + "description": "Developer documentation" + } + ] + }, + { + "type": "evm", + "networkId": "eip155:43113", + "slug": "avax-fuji", + "name": "Avalanche Fuji", + "shortName": "Fuji", + "description": "Avalanche testnet for developers", + "currency": "AVAX", + "color": "#E84142", + "isTestnet": true, + "logo": "assets/networks/43113.svg", + "links": [ + { + "name": "Faucet", + "url": "https://faucet.avax.network", + "description": "Get testnet AVAX" + }, + { + "name": "Docs", + "url": "https://docs.avax.network", + "description": "Developer documentation" + } + ] + }, + { + "type": "solana", + "networkId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "slug": "sol", + "name": "Solana", + "shortName": "Solana", + "description": "High-performance blockchain with fast transactions and low fees", + "currency": "SOL", + "color": "#9945FF", + "isTestnet": false, + "logo": "assets/networks/solana.svg", + "links": [ + { + "name": "Website", + "url": "https://solana.com", + "description": "Official Solana website" + }, + { + "name": "Docs", + "url": "https://solana.com/docs", + "description": "Developer documentation" + }, + { + "name": "GitHub", + "url": "https://github.com/solana-labs", + "description": "Solana Labs GitHub" + } + ] + }, + { + "type": "solana", + "networkId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "slug": "sol-devnet", + "name": "Solana Devnet", + "shortName": "SOL Devnet", + "description": "Solana development network for testing", + "currency": "SOL", + "color": "#9945FF", + "isTestnet": true, + "logo": "assets/networks/solana.svg", + "links": [ + { + "name": "Faucet", + "url": "https://faucet.solana.com", + "description": "Get devnet SOL" + }, + { + "name": "Docs", + "url": "https://solana.com/docs", + "description": "Developer documentation" + } + ] + }, + { + "type": "solana", + "networkId": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", + "slug": "sol-testnet", + "name": "Solana Testnet", + "shortName": "SOL Testnet", + "description": "Solana testnet for validators and developers", + "currency": "SOL", + "color": "#9945FF", + "isTestnet": true, + "logo": "assets/networks/solana.svg", + "links": [ + { + "name": "Faucet", + "url": "https://faucet.solana.com", + "description": "Get testnet SOL" + } + ] + }, { "type": "bitcoin", "networkId": "bip122:000000000019d6689c085ae165831e93", diff --git a/src/config/networks.ts b/src/config/networks.ts index b4853235..26260654 100644 --- a/src/config/networks.ts +++ b/src/config/networks.ts @@ -92,18 +92,18 @@ export function getAllNetworks(): NetworkConfig[] { /** * Get the list of enabled networks based on environment variable - * REACT_APP_OPENSCAN_NETWORKS can be a comma-separated list of chain IDs, slugs, or network IDs + * OPENSCAN_NETWORKS can be a comma-separated list of chain IDs, slugs, or network IDs * If not set, all networks are enabled */ export function getEnabledNetworks(): NetworkConfig[] { const allNetworks = getAllNetworks(); - const envNetworks = process.env.REACT_APP_OPENSCAN_NETWORKS; + const envNetworks: string | undefined = import.meta.env.OPENSCAN_NETWORKS; const localhostChainId = 31337; - // VITE_ENVIRONMENT is injected via vite.config.ts define block based on NODE_ENV - const isDevelopment = import.meta.env.VITE_ENVIRONMENT === "development"; + // OPENSCAN_ENVIRONMENT is injected via vite.config.ts define block based on NODE_ENV + const isDevelopment = import.meta.env.OPENSCAN_ENVIRONMENT === "development"; - // Check if localhost is explicitly enabled in REACT_APP_OPENSCAN_NETWORKS + // Check if localhost is explicitly enabled in OPENSCAN_NETWORKS const isLocalhostExplicitlyEnabled = envNetworks ?.split(",") .map((id) => id.trim()) diff --git a/src/config/subdomains.ts b/src/config/subdomains.ts index 2bd520c4..65b892a0 100644 --- a/src/config/subdomains.ts +++ b/src/config/subdomains.ts @@ -14,7 +14,7 @@ export interface SubdomainConfig { const WEENUS_SEPOLIA_ADDRESS = "0x7E0987E5b3a30e3f2828572Bb659A548460a3003"; // Check if we're in development mode -const isDevelopment = import.meta.env.VITE_ENVIRONMENT === "development"; +const isDevelopment = import.meta.env.OPENSCAN_ENVIRONMENT === "development"; export const subdomainConfig: SubdomainConfig[] = [ // Network subdomains diff --git a/src/config/workerConfig.ts b/src/config/workerConfig.ts index 41558d4c..802c5c64 100644 --- a/src/config/workerConfig.ts +++ b/src/config/workerConfig.ts @@ -1,3 +1,85 @@ -/** Base URL for the OpenScan Cloudflare Worker proxy */ -export const OPENSCAN_WORKER_URL = - process.env.REACT_APP_OPENSCAN_WORKER_URL || "https://openscan-worker-proxy.openscan.workers.dev"; +/** Worker proxy URLs in failover order: Cloudflare → Vercel */ +export const WORKER_URLS: string[] = [ + import.meta.env.OPENSCAN_WORKER_URL || "https://openscan-worker-proxy.openscan.workers.dev", + "https://openscan-worker-proxy.vercel.app", +]; + +/** Primary worker URL — used for building default RPC endpoint URLs */ +export const OPENSCAN_WORKER_URL = WORKER_URLS[0] as string; + +/** Check whether a URL points to any of the OpenScan worker proxies */ +export function isWorkerProxyUrl(url: string): boolean { + return WORKER_URLS.some((baseUrl) => url.startsWith(baseUrl)); +} + +/** HTTP status codes that trigger immediate failover to the next worker */ +const FAILOVER_STATUSES = new Set([502, 503]); + +/** Per-request timeout — if a worker hangs, abort and try the next one */ +const REQUEST_TIMEOUT_MS = 15_000; + +/** Delay before retrying a 429'd request on the same worker */ +const RETRY_DELAY_MS = 3_000; + +/** + * Fetch with a timeout. Aborts the request if it exceeds REQUEST_TIMEOUT_MS. + */ +async function fetchWithTimeout(url: string, init?: RequestInit): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Fetch from the worker proxy with automatic failover across platforms. + * Tries each worker URL in order (Cloudflare → Vercel). + * + * Per worker: + * - Aborts after 15s if hanging (timeout → try next worker) + * - On 429 (rate limited): waits Retry-After header (or 3s) and retries once on the same worker + * - On 502/503: immediately fails over to the next worker + * - On network error: immediately fails over to the next worker + */ +export async function fetchWithWorkerFailover(path: string, init?: RequestInit): Promise { + let lastResponse: Response | undefined; + let lastError: Error | undefined; + + for (const baseUrl of WORKER_URLS) { + try { + const url = `${baseUrl}${path}`; + const response = await fetchWithTimeout(url, init); + + // 502/503 → immediately try next worker + if (FAILOVER_STATUSES.has(response.status)) { + lastResponse = response; + continue; + } + + // 429 → retry once on the same worker after a delay + if (response.status === 429) { + const retryAfter = response.headers.get("Retry-After"); + const delayMs = retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : RETRY_DELAY_MS; + await new Promise((r) => setTimeout(r, Math.min(delayMs, 10_000))); + + const retryResponse = await fetchWithTimeout(url, init); + if (!FAILOVER_STATUSES.has(retryResponse.status) && retryResponse.status !== 429) { + return retryResponse; + } + lastResponse = retryResponse; + continue; + } + + return response; + } catch (error) { + // Network error or timeout → try next worker + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + + if (lastResponse) return lastResponse; + throw lastError ?? new Error("All worker proxies failed"); +} diff --git a/src/hooks/useEtherscan.ts b/src/hooks/useEtherscan.ts index 6e4b86de..0113e71d 100644 --- a/src/hooks/useEtherscan.ts +++ b/src/hooks/useEtherscan.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { OPENSCAN_WORKER_URL } from "../config/workerConfig"; +import { fetchWithWorkerFailover } from "../config/workerConfig"; import { useSettings } from "../context/SettingsContext"; import { logger } from "../utils/logger"; import type { SourcifyContractDetails } from "./useSourcify"; @@ -111,8 +111,8 @@ export function useEtherscan( signal: controller.signal, }); } else { - // Proxy through OpenScan Worker (free, no key needed) - response = await fetch(`${OPENSCAN_WORKER_URL}/etherscan/verify`, { + // Proxy through OpenScan Worker (free, no key needed) with failover + response = await fetchWithWorkerFailover("/etherscan/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chainId: networkId, address }), diff --git a/src/hooks/useSolanaDashboard.ts b/src/hooks/useSolanaDashboard.ts new file mode 100644 index 00000000..4cb76f72 --- /dev/null +++ b/src/hooks/useSolanaDashboard.ts @@ -0,0 +1,118 @@ +/** + * Hook for fetching Solana network dashboard data with auto-refresh + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { NetworkConfig, SolanaBlock, SolanaNetworkStats, SolanaTransaction } from "../types"; +import { useDataService } from "./useDataService"; + +const REFRESH_INTERVAL = 10000; // 10 seconds (Solana slots ~400ms) +const BLOCKS_TO_FETCH = 10; + +export interface SolanaDashboardData { + stats: SolanaNetworkStats | null; + latestBlocks: SolanaBlock[]; + latestTransactions: SolanaTransaction[]; + solPrice: number | null; + loading: boolean; + loadingTransactions: boolean; + error: string | null; + lastUpdated: number | null; +} + +const initialState: SolanaDashboardData = { + stats: null, + latestBlocks: [], + latestTransactions: [], + solPrice: null, + loading: true, + loadingTransactions: true, + error: null, + lastUpdated: null, +}; + +export function useSolanaDashboard(network: NetworkConfig): SolanaDashboardData { + const dataService = useDataService(network); + const [data, setData] = useState(initialState); + const isFetchingRef = useRef(false); + + const fetchDashboardData = useCallback(async () => { + if (!dataService || !dataService.isSolana() || isFetchingRef.current) { + return; + } + + isFetchingRef.current = true; + + try { + const adapter = dataService.getSolanaAdapter(); + + // Fetch stats and latest blocks in parallel + const [statsResult, blocksResult] = await Promise.all([ + adapter.getNetworkStats(), + adapter.getLatestBlocks(BLOCKS_TO_FETCH), + ]); + + setData((prev) => ({ + ...prev, + stats: statsResult.data, + latestBlocks: blocksResult, + loading: false, + error: null, + lastUpdated: Date.now(), + })); + + // Latest transactions: extract signatures from the most recent block + const recentSignatures: string[] = []; + for (const block of blocksResult) { + if (block.signatures) { + recentSignatures.push(...block.signatures.slice(0, 10)); + if (recentSignatures.length >= 20) break; + } + } + + // Fetch full details for the first few signatures + const txPromises = recentSignatures.slice(0, 10).map((sig) => + adapter + .getTransaction(sig) + .then((r) => r.data) + .catch(() => null), + ); + const txResults = await Promise.all(txPromises); + const transactions = txResults.filter((tx): tx is SolanaTransaction => tx !== null); + + setData((prev) => ({ + ...prev, + latestTransactions: transactions, + loadingTransactions: false, + })); + } catch (err) { + setData((prev) => ({ + ...prev, + loading: false, + loadingTransactions: false, + error: err instanceof Error ? err.message : "Failed to fetch Solana dashboard data", + })); + } finally { + isFetchingRef.current = false; + } + }, [dataService]); + + // Initial fetch + useEffect(() => { + setData(initialState); + fetchDashboardData(); + }, [fetchDashboardData]); + + // Polling + useEffect(() => { + const intervalId = setInterval(() => { + fetchDashboardData(); + }, REFRESH_INTERVAL); + + return () => { + clearInterval(intervalId); + }; + }, [fetchDashboardData]); + + return data; +} diff --git a/src/i18n.ts b/src/i18n.ts index 021b03b8..c04990cd 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -9,6 +9,7 @@ import enDevtools from "./locales/en/devtools.json"; import enHome from "./locales/en/home.json"; import enNetwork from "./locales/en/network.json"; import enSettings from "./locales/en/settings.json"; +import enSolana from "./locales/en/solana.json"; import enTransaction from "./locales/en/transaction.json"; import enTokenDetails from "./locales/en/tokenDetails.json"; import enRpcs from "./locales/en/rpcs.json"; @@ -21,6 +22,7 @@ import esDevtools from "./locales/es/devtools.json"; import esHome from "./locales/es/home.json"; import esNetwork from "./locales/es/network.json"; import esSettings from "./locales/es/settings.json"; +import esSolana from "./locales/es/solana.json"; import esTransaction from "./locales/es/transaction.json"; import esTokenDetails from "./locales/es/tokenDetails.json"; import esRpcs from "./locales/es/rpcs.json"; @@ -33,6 +35,7 @@ import zhDevtools from "./locales/zh/devtools.json"; import zhHome from "./locales/zh/home.json"; import zhNetwork from "./locales/zh/network.json"; import zhSettings from "./locales/zh/settings.json"; +import zhSolana from "./locales/zh/solana.json"; import zhTransaction from "./locales/zh/transaction.json"; import zhTokenDetails from "./locales/zh/tokenDetails.json"; import zhTooltips from "./locales/zh/tooltips.json"; @@ -44,6 +47,7 @@ import jaDevtools from "./locales/ja/devtools.json"; import jaHome from "./locales/ja/home.json"; import jaNetwork from "./locales/ja/network.json"; import jaSettings from "./locales/ja/settings.json"; +import jaSolana from "./locales/ja/solana.json"; import jaTransaction from "./locales/ja/transaction.json"; import jaTokenDetails from "./locales/ja/tokenDetails.json"; import jaTooltips from "./locales/ja/tooltips.json"; @@ -55,6 +59,7 @@ import ptBRDevtools from "./locales/pt-BR/devtools.json"; import ptBRHome from "./locales/pt-BR/home.json"; import ptBRNetwork from "./locales/pt-BR/network.json"; import ptBRSettings from "./locales/pt-BR/settings.json"; +import ptBRSolana from "./locales/pt-BR/solana.json"; import ptBRTransaction from "./locales/pt-BR/transaction.json"; import ptBRTokenDetails from "./locales/pt-BR/tokenDetails.json"; import ptBRTooltips from "./locales/pt-BR/tooltips.json"; @@ -84,6 +89,7 @@ i18n tokenDetails: enTokenDetails, rpcs: enRpcs, tooltips: enTooltips, + solana: enSolana, }, es: { common: esCommon, @@ -97,6 +103,7 @@ i18n tokenDetails: esTokenDetails, rpcs: esRpcs, tooltips: esTooltips, + solana: esSolana, }, zh: { common: zhCommon, @@ -109,6 +116,7 @@ i18n network: zhNetwork, tokenDetails: zhTokenDetails, tooltips: zhTooltips, + solana: zhSolana, }, ja: { common: jaCommon, @@ -121,6 +129,7 @@ i18n network: jaNetwork, tokenDetails: jaTokenDetails, tooltips: jaTooltips, + solana: jaSolana, }, "pt-BR": { common: ptBRCommon, @@ -133,6 +142,7 @@ i18n network: ptBRNetwork, tokenDetails: ptBRTokenDetails, tooltips: ptBRTooltips, + solana: ptBRSolana, }, }, fallbackLng: "en", @@ -148,6 +158,7 @@ i18n "network", "rpcs", "tooltips", + "solana", ], interpolation: { escapeValue: false, diff --git a/src/i18next.d.ts b/src/i18next.d.ts index d1122d44..834ba065 100644 --- a/src/i18next.d.ts +++ b/src/i18next.d.ts @@ -5,6 +5,7 @@ import type devtools from "./locales/en/devtools.json"; import type home from "./locales/en/home.json"; import type network from "./locales/en/network.json"; import type settings from "./locales/en/settings.json"; +import type solana from "./locales/en/solana.json"; import type transaction from "./locales/en/transaction.json"; import type tokenDetails from "./locales/en/tokenDetails.json"; import type rpcs from "./locales/en/rpcs.json"; @@ -24,6 +25,7 @@ declare module "i18next" { tokenDetails: typeof tokenDetails; rpcs: typeof rpcs; tooltips: typeof tooltips; + solana: typeof solana; }; } } diff --git a/src/locales/en/address.json b/src/locales/en/address.json index d19f2549..1afa6ca4 100644 --- a/src/locales/en/address.json +++ b/src/locales/en/address.json @@ -32,6 +32,9 @@ "collection": "Collection", "tokenStandard": "Token Standard", "totalMinted": "Total Minted", + "recentTokens": "Recent Tokens", + "tableTokenId": "Token ID", + "tableOwner": "Owner", "metadataURI": "Metadata URI", "view": "View", "moreInfo": "More Info", diff --git a/src/locales/en/solana.json b/src/locales/en/solana.json new file mode 100644 index 00000000..671072c2 --- /dev/null +++ b/src/locales/en/solana.json @@ -0,0 +1,132 @@ +{ + "dashboard": { + "currentSlot": "Current Slot", + "blockHeight": "Block Height", + "epoch": "Epoch", + "epochProgress": "Epoch Progress", + "transactions": "Transactions", + "version": "Version", + "solPrice": "SOL Price", + "tps": "TPS" + }, + "blocks": { + "title": "Latest Blocks", + "viewAll": "View all", + "slot": "Slot", + "time": "Time", + "txCount": "Tx Count", + "leader": "Leader", + "rewards": "Rewards", + "blockHash": "Block Hash", + "noBlocks": "No blocks found", + "blocksTitle": "Blocks", + "loadMore": "Load more" + }, + "block": { + "title": "Block", + "slot": "Slot", + "blockHash": "Block Hash", + "previousBlockhash": "Previous Blockhash", + "parentSlot": "Parent Slot", + "blockHeight": "Block Height", + "blockTime": "Block Time", + "transactionCount": "Transaction Count", + "rewards": "Rewards", + "transactions": "Transactions", + "rewardType": "Reward Type", + "amount": "Amount", + "noTransactions": "No transactions in this block" + }, + "transactions": { + "title": "Latest Transactions", + "viewAll": "View all", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "fee": "Fee", + "time": "Time", + "noTransactions": "No transactions found", + "txsTitle": "Transactions", + "success": "Success", + "failed": "Failed" + }, + "transaction": { + "title": "Transaction", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "blockTime": "Block Time", + "fee": "Fee", + "computeUnits": "Compute Units Consumed", + "version": "Version", + "signers": "Signers", + "accountKeys": "Account Keys", + "instructions": "Instructions", + "innerInstructions": "Inner Instructions", + "logs": "Program Logs", + "tokenChanges": "Token Balance Changes", + "balanceChanges": "Balance Changes", + "programId": "Program", + "accounts": "Accounts", + "data": "Data", + "writable": "Writable", + "signer": "Signer", + "readonly": "Read-only", + "preBalance": "Before", + "postBalance": "After", + "noLogs": "No log messages" + }, + "account": { + "title": "Account", + "address": "Address", + "balance": "Balance", + "owner": "Owner", + "executable": "Executable", + "rentEpoch": "Rent Epoch", + "dataSize": "Data Size", + "tokenHoldings": "Token Holdings", + "recentTransactions": "Recent Transactions", + "noTokens": "No SPL token holdings", + "noTransactions": "No recent transactions", + "yes": "Yes", + "no": "No", + "program": "Program", + "wallet": "Wallet" + }, + "token": { + "title": "SPL Token", + "mint": "Mint", + "totalSupply": "Total Supply", + "decimals": "Decimals", + "topHolders": "Top Holders", + "holderRank": "Rank", + "holderAddress": "Address", + "amount": "Amount", + "percentage": "Percentage", + "noHolders": "No holders found" + }, + "validators": { + "title": "Validators", + "currentValidators": "Current Validators", + "delinquentValidators": "Delinquent Validators", + "totalStake": "Total Active Stake", + "validatorCount": "Validators", + "identity": "Identity", + "voteAccount": "Vote Account", + "stake": "Stake", + "commission": "Commission", + "lastVote": "Last Vote", + "epochCredits": "Epoch Credits", + "currentEpoch": "Current Epoch", + "epochProgress": "Epoch Progress", + "noValidators": "No validators found" + }, + "common": { + "loading": "Loading...", + "error": "Error", + "lamports": "lamports", + "sol": "SOL", + "slot": "Slot", + "copy": "Copy" + } +} diff --git a/src/locales/en/tokenDetails.json b/src/locales/en/tokenDetails.json index f4ebbc55..cc576521 100644 --- a/src/locales/en/tokenDetails.json +++ b/src/locales/en/tokenDetails.json @@ -29,6 +29,8 @@ "checkBalance": "Check Balance", "balance": "Balance", "detectingTokenType": "Detecting token type...", + "previousToken": "Previous token", + "nextToken": "Next token", "errors": { "error": "Error" } diff --git a/src/locales/en/tooltips.json b/src/locales/en/tooltips.json index eab9fe8b..4179d7c5 100644 --- a/src/locales/en/tooltips.json +++ b/src/locales/en/tooltips.json @@ -162,5 +162,14 @@ }, "settings": { "knowledgeLevel": "Controls how much explanatory help is shown throughout the explorer." + }, + "analyser": { + "rawTrace": { + "pc": "Program Counter — the byte offset of this opcode within the contract bytecode being executed.", + "opcode": "The EVM instruction executed at this step (e.g. PUSH1, CALL, SSTORE).", + "gasLeft": "Gas remaining in the current call frame before this opcode executes.", + "cost": "Gas consumed by this individual opcode. Opcodes like SSTORE and CALL are much more expensive than arithmetic.", + "depth": "Call stack depth. 1 is the top-level call; each internal CALL/DELEGATECALL/STATICCALL increases the depth by one." + } } } diff --git a/src/locales/en/transaction.json b/src/locales/en/transaction.json index 1a450b5d..59e04abf 100644 --- a/src/locales/en/transaction.json +++ b/src/locales/en/transaction.json @@ -192,6 +192,12 @@ "rawTraceSteps": "{{count}} opcode steps", "rawTracePrev": "Prev", "rawTraceNext": "Next", - "rawTracePage": "Page {{current}}/{{total}} ({{from}}–{{to}} of {{totalSteps}})" + "rawTracePage": "Page {{current}}/{{total}} ({{from}}–{{to}} of {{totalSteps}})", + "rawTraceColStep": "Step", + "rawTraceColPc": "PC", + "rawTraceColOpcode": "Opcode", + "rawTraceColGasLeft": "Gas Left", + "rawTraceColCost": "Cost", + "rawTraceColDepth": "Depth" } } diff --git a/src/locales/es/address.json b/src/locales/es/address.json index f5d6e89b..c7dc7080 100644 --- a/src/locales/es/address.json +++ b/src/locales/es/address.json @@ -32,6 +32,9 @@ "collection": "Colección", "tokenStandard": "EstĆ”ndar del token", "totalMinted": "Total minteado", + "recentTokens": "Tokens Recientes", + "tableTokenId": "ID del Token", + "tableOwner": "Propietario", "metadataURI": "URI de metadata", "view": "Ver", "moreInfo": "MĆ”s información", diff --git a/src/locales/es/solana.json b/src/locales/es/solana.json new file mode 100644 index 00000000..07f9339b --- /dev/null +++ b/src/locales/es/solana.json @@ -0,0 +1,132 @@ +{ + "dashboard": { + "currentSlot": "Slot Actual", + "blockHeight": "Altura del Bloque", + "epoch": "Ɖpoca", + "epochProgress": "Progreso de Ɖpoca", + "transactions": "Transacciones", + "version": "Versión", + "solPrice": "Precio SOL", + "tps": "TPS" + }, + "blocks": { + "title": "Últimos Bloques", + "viewAll": "Ver todos", + "slot": "Slot", + "time": "Hora", + "txCount": "NĆŗm. Tx", + "leader": "LĆ­der", + "rewards": "Recompensas", + "blockHash": "Hash del Bloque", + "noBlocks": "No se encontraron bloques", + "blocksTitle": "Bloques", + "loadMore": "Cargar mĆ”s" + }, + "block": { + "title": "Bloque", + "slot": "Slot", + "blockHash": "Hash del Bloque", + "previousBlockhash": "Hash del Bloque Anterior", + "parentSlot": "Slot Padre", + "blockHeight": "Altura del Bloque", + "blockTime": "Hora del Bloque", + "transactionCount": "NĆŗm. de Transacciones", + "rewards": "Recompensas", + "transactions": "Transacciones", + "rewardType": "Tipo de Recompensa", + "amount": "Cantidad", + "noTransactions": "Sin transacciones en este bloque" + }, + "transactions": { + "title": "Últimas Transacciones", + "viewAll": "Ver todas", + "signature": "Firma", + "status": "Estado", + "slot": "Slot", + "fee": "Comisión", + "time": "Hora", + "noTransactions": "No se encontraron transacciones", + "txsTitle": "Transacciones", + "success": "Ɖxito", + "failed": "Fallida" + }, + "transaction": { + "title": "Transacción", + "signature": "Firma", + "status": "Estado", + "slot": "Slot", + "blockTime": "Hora del Bloque", + "fee": "Comisión", + "computeUnits": "Unidades de Cómputo Consumidas", + "version": "Versión", + "signers": "Firmantes", + "accountKeys": "Claves de Cuenta", + "instructions": "Instrucciones", + "innerInstructions": "Instrucciones Internas", + "logs": "Logs del Programa", + "tokenChanges": "Cambios de Saldo de Tokens", + "balanceChanges": "Cambios de Saldo", + "programId": "Programa", + "accounts": "Cuentas", + "data": "Datos", + "writable": "Escribible", + "signer": "Firmante", + "readonly": "Solo lectura", + "preBalance": "Antes", + "postBalance": "DespuĆ©s", + "noLogs": "Sin mensajes de log" + }, + "account": { + "title": "Cuenta", + "address": "Dirección", + "balance": "Saldo", + "owner": "Propietario", + "executable": "Ejecutable", + "rentEpoch": "Ɖpoca de Renta", + "dataSize": "TamaƱo de Datos", + "tokenHoldings": "Tokens en Posesión", + "recentTransactions": "Transacciones Recientes", + "noTokens": "Sin tokens SPL", + "noTransactions": "Sin transacciones recientes", + "yes": "SĆ­", + "no": "No", + "program": "Programa", + "wallet": "Billetera" + }, + "token": { + "title": "Token SPL", + "mint": "Mint", + "totalSupply": "Suministro Total", + "decimals": "Decimales", + "topHolders": "Mayores Holders", + "holderRank": "Posición", + "holderAddress": "Dirección", + "amount": "Cantidad", + "percentage": "Porcentaje", + "noHolders": "No se encontraron holders" + }, + "validators": { + "title": "Validadores", + "currentValidators": "Validadores Activos", + "delinquentValidators": "Validadores Delincuentes", + "totalStake": "Stake Total Activo", + "validatorCount": "Validadores", + "identity": "Identidad", + "voteAccount": "Cuenta de Voto", + "stake": "Stake", + "commission": "Comisión", + "lastVote": "Último Voto", + "epochCredits": "CrĆ©ditos de Ɖpoca", + "currentEpoch": "Ɖpoca Actual", + "epochProgress": "Progreso de Ɖpoca", + "noValidators": "No se encontraron validadores" + }, + "common": { + "loading": "Cargando...", + "error": "Error", + "lamports": "lamports", + "sol": "SOL", + "slot": "Slot", + "copy": "Copiar" + } +} diff --git a/src/locales/es/tokenDetails.json b/src/locales/es/tokenDetails.json index 9455b441..e3373b96 100644 --- a/src/locales/es/tokenDetails.json +++ b/src/locales/es/tokenDetails.json @@ -29,6 +29,8 @@ "checkBalance": "Consultar Balance", "balance": "Balance", "detectingTokenType": "Detectando tipo de token...", + "previousToken": "Token anterior", + "nextToken": "Token siguiente", "errors": { "error": "Error" } diff --git a/src/locales/es/tooltips.json b/src/locales/es/tooltips.json index fbbe035e..694761ed 100644 --- a/src/locales/es/tooltips.json +++ b/src/locales/es/tooltips.json @@ -162,5 +162,14 @@ }, "settings": { "knowledgeLevel": "Controla cuĆ”nta ayuda explicativa se muestra en el explorador." + }, + "analyser": { + "rawTrace": { + "pc": "Contador de Programa (Program Counter) — el desplazamiento en bytes de este opcode dentro del bytecode del contrato en ejecución.", + "opcode": "La instrucción EVM ejecutada en este paso (por ejemplo PUSH1, CALL, SSTORE).", + "gasLeft": "Gas restante en el marco de llamada actual antes de que este opcode se ejecute.", + "cost": "Gas consumido por este opcode individual. Opcodes como SSTORE y CALL son mucho mĆ”s costosos que la aritmĆ©tica.", + "depth": "Profundidad de la pila de llamadas. 1 es la llamada de nivel superior; cada CALL/DELEGATECALL/STATICCALL interno aumenta la profundidad en uno." + } } } diff --git a/src/locales/es/transaction.json b/src/locales/es/transaction.json index 35c891c1..ed4f72f4 100644 --- a/src/locales/es/transaction.json +++ b/src/locales/es/transaction.json @@ -192,6 +192,12 @@ "rawTraceSteps": "{{count}} pasos de opcode", "rawTracePrev": "Anterior", "rawTraceNext": "Siguiente", - "rawTracePage": "PĆ”gina {{current}}/{{total}} ({{from}}–{{to}} de {{totalSteps}})" + "rawTracePage": "PĆ”gina {{current}}/{{total}} ({{from}}–{{to}} de {{totalSteps}})", + "rawTraceColStep": "Paso", + "rawTraceColPc": "PC", + "rawTraceColOpcode": "Opcode", + "rawTraceColGasLeft": "Gas Restante", + "rawTraceColCost": "Costo", + "rawTraceColDepth": "Profundidad" } } diff --git a/src/locales/ja/address.json b/src/locales/ja/address.json index f5989356..efb60087 100644 --- a/src/locales/ja/address.json +++ b/src/locales/ja/address.json @@ -31,6 +31,9 @@ "collection": "ć‚³ćƒ¬ć‚Æć‚·ćƒ§ćƒ³", "tokenStandard": "ćƒˆćƒ¼ć‚Æćƒ³ęØ™ęŗ–", "totalMinted": "ē·ćƒŸćƒ³ćƒˆę•°", + "recentTokens": "ęœ€čæ‘ć®ćƒˆćƒ¼ć‚Æćƒ³", + "tableTokenId": "ćƒˆćƒ¼ć‚Æćƒ³ID", + "tableOwner": "ć‚Ŗćƒ¼ćƒŠćƒ¼", "metadataURI": "ćƒ”ć‚æćƒ‡ćƒ¼ć‚æURI", "view": "蔨示", "moreInfo": "č©³ē“°ęƒ…å ±", diff --git a/src/locales/ja/solana.json b/src/locales/ja/solana.json new file mode 100644 index 00000000..671072c2 --- /dev/null +++ b/src/locales/ja/solana.json @@ -0,0 +1,132 @@ +{ + "dashboard": { + "currentSlot": "Current Slot", + "blockHeight": "Block Height", + "epoch": "Epoch", + "epochProgress": "Epoch Progress", + "transactions": "Transactions", + "version": "Version", + "solPrice": "SOL Price", + "tps": "TPS" + }, + "blocks": { + "title": "Latest Blocks", + "viewAll": "View all", + "slot": "Slot", + "time": "Time", + "txCount": "Tx Count", + "leader": "Leader", + "rewards": "Rewards", + "blockHash": "Block Hash", + "noBlocks": "No blocks found", + "blocksTitle": "Blocks", + "loadMore": "Load more" + }, + "block": { + "title": "Block", + "slot": "Slot", + "blockHash": "Block Hash", + "previousBlockhash": "Previous Blockhash", + "parentSlot": "Parent Slot", + "blockHeight": "Block Height", + "blockTime": "Block Time", + "transactionCount": "Transaction Count", + "rewards": "Rewards", + "transactions": "Transactions", + "rewardType": "Reward Type", + "amount": "Amount", + "noTransactions": "No transactions in this block" + }, + "transactions": { + "title": "Latest Transactions", + "viewAll": "View all", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "fee": "Fee", + "time": "Time", + "noTransactions": "No transactions found", + "txsTitle": "Transactions", + "success": "Success", + "failed": "Failed" + }, + "transaction": { + "title": "Transaction", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "blockTime": "Block Time", + "fee": "Fee", + "computeUnits": "Compute Units Consumed", + "version": "Version", + "signers": "Signers", + "accountKeys": "Account Keys", + "instructions": "Instructions", + "innerInstructions": "Inner Instructions", + "logs": "Program Logs", + "tokenChanges": "Token Balance Changes", + "balanceChanges": "Balance Changes", + "programId": "Program", + "accounts": "Accounts", + "data": "Data", + "writable": "Writable", + "signer": "Signer", + "readonly": "Read-only", + "preBalance": "Before", + "postBalance": "After", + "noLogs": "No log messages" + }, + "account": { + "title": "Account", + "address": "Address", + "balance": "Balance", + "owner": "Owner", + "executable": "Executable", + "rentEpoch": "Rent Epoch", + "dataSize": "Data Size", + "tokenHoldings": "Token Holdings", + "recentTransactions": "Recent Transactions", + "noTokens": "No SPL token holdings", + "noTransactions": "No recent transactions", + "yes": "Yes", + "no": "No", + "program": "Program", + "wallet": "Wallet" + }, + "token": { + "title": "SPL Token", + "mint": "Mint", + "totalSupply": "Total Supply", + "decimals": "Decimals", + "topHolders": "Top Holders", + "holderRank": "Rank", + "holderAddress": "Address", + "amount": "Amount", + "percentage": "Percentage", + "noHolders": "No holders found" + }, + "validators": { + "title": "Validators", + "currentValidators": "Current Validators", + "delinquentValidators": "Delinquent Validators", + "totalStake": "Total Active Stake", + "validatorCount": "Validators", + "identity": "Identity", + "voteAccount": "Vote Account", + "stake": "Stake", + "commission": "Commission", + "lastVote": "Last Vote", + "epochCredits": "Epoch Credits", + "currentEpoch": "Current Epoch", + "epochProgress": "Epoch Progress", + "noValidators": "No validators found" + }, + "common": { + "loading": "Loading...", + "error": "Error", + "lamports": "lamports", + "sol": "SOL", + "slot": "Slot", + "copy": "Copy" + } +} diff --git a/src/locales/ja/tokenDetails.json b/src/locales/ja/tokenDetails.json index 25c7338a..f4e6f38a 100644 --- a/src/locales/ja/tokenDetails.json +++ b/src/locales/ja/tokenDetails.json @@ -29,6 +29,8 @@ "checkBalance": "ę®‹é«˜ć‚’ē¢ŗčŖ", "balance": "ę®‹é«˜", "detectingTokenType": "ćƒˆćƒ¼ć‚Æćƒ³ć‚æć‚¤ćƒ—ć‚’ę¤œå‡ŗäø­...", + "previousToken": "å‰ć®ćƒˆćƒ¼ć‚Æćƒ³", + "nextToken": "ę¬”ć®ćƒˆćƒ¼ć‚Æćƒ³", "errors": { "error": "ć‚Øćƒ©ćƒ¼" } diff --git a/src/locales/ja/tooltips.json b/src/locales/ja/tooltips.json index db806742..ee9d41bc 100644 --- a/src/locales/ja/tooltips.json +++ b/src/locales/ja/tooltips.json @@ -162,5 +162,14 @@ }, "settings": { "knowledgeLevel": "ć‚Øć‚Æć‚¹ćƒ—ćƒ­ćƒ¼ćƒ©ćƒ¼å…Øä½“ć§č”Øē¤ŗć•ć‚Œć‚‹čŖ¬ę˜Žćƒ˜ćƒ«ćƒ—ć®é‡ć‚’åˆ¶å¾”ć—ć¾ć™ć€‚" + }, + "analyser": { + "rawTrace": { + "pc": "ćƒ—ćƒ­ć‚°ćƒ©ćƒ ć‚«ć‚¦ćƒ³ć‚æ — å®Ÿč”Œäø­ć®ć‚³ćƒ³ćƒˆćƒ©ć‚Æćƒˆćƒć‚¤ćƒˆć‚³ćƒ¼ćƒ‰å†…ć«ćŠć‘ć‚‹ć“ć®ć‚Ŗćƒšć‚³ćƒ¼ćƒ‰ć®ćƒć‚¤ćƒˆć‚Ŗćƒ•ć‚»ćƒƒćƒˆć€‚", + "opcode": "ć“ć®ć‚¹ćƒ†ćƒƒćƒ—ć§å®Ÿč”Œć•ć‚Œć‚‹EVM命令 (例: PUSH1态CALL态SSTORE)怂", + "gasLeft": "ć“ć®ć‚Ŗćƒšć‚³ćƒ¼ćƒ‰ćŒå®Ÿč”Œć•ć‚Œć‚‹å‰ć®ē¾åœØć®ć‚³ćƒ¼ćƒ«ćƒ•ćƒ¬ćƒ¼ćƒ ć§ę®‹ć£ć¦ć„ć‚‹ć‚¬ć‚¹ć€‚", + "cost": "ć“ć®å€‹åˆ„ć®ć‚Ŗćƒšć‚³ćƒ¼ćƒ‰ćŒę¶ˆč²»ć™ć‚‹ć‚¬ć‚¹ć€‚SSTORE悄CALLćŖć©ć®ć‚Ŗćƒšć‚³ćƒ¼ćƒ‰ćÆē®—č”“ę¼”ē®—ć‚ˆć‚Šć‚‚ćÆć‚‹ć‹ć«é«˜ä¾”ć§ć™ć€‚", + "depth": "ć‚³ćƒ¼ćƒ«ć‚¹ć‚æćƒƒć‚Æć®ę·±åŗ¦ć€‚1ćÆęœ€äøŠä½ć®å‘¼ć³å‡ŗć—ć§ć€å†…éƒØć®CALL/DELEGATECALL/STATICCALLć”ćØć«ę·±åŗ¦ćŒ1ćšć¤å¢—åŠ ć—ć¾ć™ć€‚" + } } } diff --git a/src/locales/ja/transaction.json b/src/locales/ja/transaction.json index 56efee86..45c50a78 100644 --- a/src/locales/ja/transaction.json +++ b/src/locales/ja/transaction.json @@ -192,6 +192,12 @@ "rawTraceSteps": "{{count}} ć‚Ŗćƒšć‚³ćƒ¼ćƒ‰ć‚¹ćƒ†ćƒƒćƒ—", "rawTracePrev": "å‰ćø", "rawTraceNext": "欔へ", - "rawTracePage": "ćƒšćƒ¼ć‚ø {{current}}/{{total}} ({{from}}–{{to}} / {{totalSteps}})" + "rawTracePage": "ćƒšćƒ¼ć‚ø {{current}}/{{total}} ({{from}}–{{to}} / {{totalSteps}})", + "rawTraceColStep": "ć‚¹ćƒ†ćƒƒćƒ—", + "rawTraceColPc": "PC", + "rawTraceColOpcode": "ć‚Ŗćƒšć‚³ćƒ¼ćƒ‰", + "rawTraceColGasLeft": "残ガス", + "rawTraceColCost": "ć‚³ć‚¹ćƒˆ", + "rawTraceColDepth": "深度" } } diff --git a/src/locales/pt-BR/address.json b/src/locales/pt-BR/address.json index ea026d06..0ecf780f 100644 --- a/src/locales/pt-BR/address.json +++ b/src/locales/pt-BR/address.json @@ -31,6 +31,9 @@ "collection": "Coleção", "tokenStandard": "PadrĆ£o do Token", "totalMinted": "Total Criado", + "recentTokens": "Tokens Recentes", + "tableTokenId": "ID do Token", + "tableOwner": "ProprietĆ”rio", "metadataURI": "URI de Metadados", "view": "Ver", "moreInfo": "Mais InformaƧƵes", diff --git a/src/locales/pt-BR/solana.json b/src/locales/pt-BR/solana.json new file mode 100644 index 00000000..671072c2 --- /dev/null +++ b/src/locales/pt-BR/solana.json @@ -0,0 +1,132 @@ +{ + "dashboard": { + "currentSlot": "Current Slot", + "blockHeight": "Block Height", + "epoch": "Epoch", + "epochProgress": "Epoch Progress", + "transactions": "Transactions", + "version": "Version", + "solPrice": "SOL Price", + "tps": "TPS" + }, + "blocks": { + "title": "Latest Blocks", + "viewAll": "View all", + "slot": "Slot", + "time": "Time", + "txCount": "Tx Count", + "leader": "Leader", + "rewards": "Rewards", + "blockHash": "Block Hash", + "noBlocks": "No blocks found", + "blocksTitle": "Blocks", + "loadMore": "Load more" + }, + "block": { + "title": "Block", + "slot": "Slot", + "blockHash": "Block Hash", + "previousBlockhash": "Previous Blockhash", + "parentSlot": "Parent Slot", + "blockHeight": "Block Height", + "blockTime": "Block Time", + "transactionCount": "Transaction Count", + "rewards": "Rewards", + "transactions": "Transactions", + "rewardType": "Reward Type", + "amount": "Amount", + "noTransactions": "No transactions in this block" + }, + "transactions": { + "title": "Latest Transactions", + "viewAll": "View all", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "fee": "Fee", + "time": "Time", + "noTransactions": "No transactions found", + "txsTitle": "Transactions", + "success": "Success", + "failed": "Failed" + }, + "transaction": { + "title": "Transaction", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "blockTime": "Block Time", + "fee": "Fee", + "computeUnits": "Compute Units Consumed", + "version": "Version", + "signers": "Signers", + "accountKeys": "Account Keys", + "instructions": "Instructions", + "innerInstructions": "Inner Instructions", + "logs": "Program Logs", + "tokenChanges": "Token Balance Changes", + "balanceChanges": "Balance Changes", + "programId": "Program", + "accounts": "Accounts", + "data": "Data", + "writable": "Writable", + "signer": "Signer", + "readonly": "Read-only", + "preBalance": "Before", + "postBalance": "After", + "noLogs": "No log messages" + }, + "account": { + "title": "Account", + "address": "Address", + "balance": "Balance", + "owner": "Owner", + "executable": "Executable", + "rentEpoch": "Rent Epoch", + "dataSize": "Data Size", + "tokenHoldings": "Token Holdings", + "recentTransactions": "Recent Transactions", + "noTokens": "No SPL token holdings", + "noTransactions": "No recent transactions", + "yes": "Yes", + "no": "No", + "program": "Program", + "wallet": "Wallet" + }, + "token": { + "title": "SPL Token", + "mint": "Mint", + "totalSupply": "Total Supply", + "decimals": "Decimals", + "topHolders": "Top Holders", + "holderRank": "Rank", + "holderAddress": "Address", + "amount": "Amount", + "percentage": "Percentage", + "noHolders": "No holders found" + }, + "validators": { + "title": "Validators", + "currentValidators": "Current Validators", + "delinquentValidators": "Delinquent Validators", + "totalStake": "Total Active Stake", + "validatorCount": "Validators", + "identity": "Identity", + "voteAccount": "Vote Account", + "stake": "Stake", + "commission": "Commission", + "lastVote": "Last Vote", + "epochCredits": "Epoch Credits", + "currentEpoch": "Current Epoch", + "epochProgress": "Epoch Progress", + "noValidators": "No validators found" + }, + "common": { + "loading": "Loading...", + "error": "Error", + "lamports": "lamports", + "sol": "SOL", + "slot": "Slot", + "copy": "Copy" + } +} diff --git a/src/locales/pt-BR/tokenDetails.json b/src/locales/pt-BR/tokenDetails.json index ad0415ec..a98766f8 100644 --- a/src/locales/pt-BR/tokenDetails.json +++ b/src/locales/pt-BR/tokenDetails.json @@ -29,6 +29,8 @@ "checkBalance": "Verificar Saldo", "balance": "Saldo", "detectingTokenType": "Detectando tipo de token...", + "previousToken": "Token anterior", + "nextToken": "Próximo token", "errors": { "error": "Erro" } diff --git a/src/locales/pt-BR/tooltips.json b/src/locales/pt-BR/tooltips.json index dfc399c5..0fefe9fa 100644 --- a/src/locales/pt-BR/tooltips.json +++ b/src/locales/pt-BR/tooltips.json @@ -162,5 +162,14 @@ }, "settings": { "knowledgeLevel": "Controla quanta ajuda explicativa Ć© exibida no explorador." + }, + "analyser": { + "rawTrace": { + "pc": "Contador de Programa (Program Counter) — o deslocamento em bytes deste opcode dentro do bytecode do contrato em execução.", + "opcode": "A instrução EVM executada neste passo (por exemplo PUSH1, CALL, SSTORE).", + "gasLeft": "GĆ”s restante no frame de chamada atual antes deste opcode ser executado.", + "cost": "GĆ”s consumido por este opcode individual. Opcodes como SSTORE e CALL sĆ£o muito mais caros do que aritmĆ©tica.", + "depth": "Profundidade da pilha de chamadas. 1 Ć© a chamada de nĆ­vel superior; cada CALL/DELEGATECALL/STATICCALL interno aumenta a profundidade em um." + } } } diff --git a/src/locales/pt-BR/transaction.json b/src/locales/pt-BR/transaction.json index c2a9e0c0..2ac6eb09 100644 --- a/src/locales/pt-BR/transaction.json +++ b/src/locales/pt-BR/transaction.json @@ -192,6 +192,12 @@ "rawTraceSteps": "{{count}} passos de opcode", "rawTracePrev": "Anterior", "rawTraceNext": "Próximo", - "rawTracePage": "PĆ”gina {{current}}/{{total}} ({{from}}–{{to}} de {{totalSteps}})" + "rawTracePage": "PĆ”gina {{current}}/{{total}} ({{from}}–{{to}} de {{totalSteps}})", + "rawTraceColStep": "Passo", + "rawTraceColPc": "PC", + "rawTraceColOpcode": "Opcode", + "rawTraceColGasLeft": "GĆ”s Restante", + "rawTraceColCost": "Custo", + "rawTraceColDepth": "Profundidade" } } diff --git a/src/locales/zh/address.json b/src/locales/zh/address.json index a8d76c5f..aa8089ae 100644 --- a/src/locales/zh/address.json +++ b/src/locales/zh/address.json @@ -31,6 +31,9 @@ "collection": "集合", "tokenStandard": "代币标准", "totalMinted": "ę€»é“øé€ é‡", + "recentTokens": "ęœ€čæ‘ēš„ä»£åø", + "tableTokenId": "代币 ID", + "tableOwner": "ꉀ꜉者", "metadataURI": "å…ƒę•°ę® URI", "view": "ęŸ„ēœ‹", "moreInfo": "ę›“å¤šäæ”ęÆ", diff --git a/src/locales/zh/solana.json b/src/locales/zh/solana.json new file mode 100644 index 00000000..671072c2 --- /dev/null +++ b/src/locales/zh/solana.json @@ -0,0 +1,132 @@ +{ + "dashboard": { + "currentSlot": "Current Slot", + "blockHeight": "Block Height", + "epoch": "Epoch", + "epochProgress": "Epoch Progress", + "transactions": "Transactions", + "version": "Version", + "solPrice": "SOL Price", + "tps": "TPS" + }, + "blocks": { + "title": "Latest Blocks", + "viewAll": "View all", + "slot": "Slot", + "time": "Time", + "txCount": "Tx Count", + "leader": "Leader", + "rewards": "Rewards", + "blockHash": "Block Hash", + "noBlocks": "No blocks found", + "blocksTitle": "Blocks", + "loadMore": "Load more" + }, + "block": { + "title": "Block", + "slot": "Slot", + "blockHash": "Block Hash", + "previousBlockhash": "Previous Blockhash", + "parentSlot": "Parent Slot", + "blockHeight": "Block Height", + "blockTime": "Block Time", + "transactionCount": "Transaction Count", + "rewards": "Rewards", + "transactions": "Transactions", + "rewardType": "Reward Type", + "amount": "Amount", + "noTransactions": "No transactions in this block" + }, + "transactions": { + "title": "Latest Transactions", + "viewAll": "View all", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "fee": "Fee", + "time": "Time", + "noTransactions": "No transactions found", + "txsTitle": "Transactions", + "success": "Success", + "failed": "Failed" + }, + "transaction": { + "title": "Transaction", + "signature": "Signature", + "status": "Status", + "slot": "Slot", + "blockTime": "Block Time", + "fee": "Fee", + "computeUnits": "Compute Units Consumed", + "version": "Version", + "signers": "Signers", + "accountKeys": "Account Keys", + "instructions": "Instructions", + "innerInstructions": "Inner Instructions", + "logs": "Program Logs", + "tokenChanges": "Token Balance Changes", + "balanceChanges": "Balance Changes", + "programId": "Program", + "accounts": "Accounts", + "data": "Data", + "writable": "Writable", + "signer": "Signer", + "readonly": "Read-only", + "preBalance": "Before", + "postBalance": "After", + "noLogs": "No log messages" + }, + "account": { + "title": "Account", + "address": "Address", + "balance": "Balance", + "owner": "Owner", + "executable": "Executable", + "rentEpoch": "Rent Epoch", + "dataSize": "Data Size", + "tokenHoldings": "Token Holdings", + "recentTransactions": "Recent Transactions", + "noTokens": "No SPL token holdings", + "noTransactions": "No recent transactions", + "yes": "Yes", + "no": "No", + "program": "Program", + "wallet": "Wallet" + }, + "token": { + "title": "SPL Token", + "mint": "Mint", + "totalSupply": "Total Supply", + "decimals": "Decimals", + "topHolders": "Top Holders", + "holderRank": "Rank", + "holderAddress": "Address", + "amount": "Amount", + "percentage": "Percentage", + "noHolders": "No holders found" + }, + "validators": { + "title": "Validators", + "currentValidators": "Current Validators", + "delinquentValidators": "Delinquent Validators", + "totalStake": "Total Active Stake", + "validatorCount": "Validators", + "identity": "Identity", + "voteAccount": "Vote Account", + "stake": "Stake", + "commission": "Commission", + "lastVote": "Last Vote", + "epochCredits": "Epoch Credits", + "currentEpoch": "Current Epoch", + "epochProgress": "Epoch Progress", + "noValidators": "No validators found" + }, + "common": { + "loading": "Loading...", + "error": "Error", + "lamports": "lamports", + "sol": "SOL", + "slot": "Slot", + "copy": "Copy" + } +} diff --git a/src/locales/zh/tokenDetails.json b/src/locales/zh/tokenDetails.json index 131210b3..c09a4865 100644 --- a/src/locales/zh/tokenDetails.json +++ b/src/locales/zh/tokenDetails.json @@ -29,6 +29,8 @@ "checkBalance": "ęŸ„čÆ¢ä½™é¢", "balance": "余额", "detectingTokenType": "ę£€ęµ‹ä»£åøē±»åž‹...", + "previousToken": "äøŠäø€äøŖä»£åø", + "nextToken": "下一个代币", "errors": { "error": "错误" } diff --git a/src/locales/zh/tooltips.json b/src/locales/zh/tooltips.json index 8ceaee51..82f8a71d 100644 --- a/src/locales/zh/tooltips.json +++ b/src/locales/zh/tooltips.json @@ -162,5 +162,14 @@ }, "settings": { "knowledgeLevel": "ęŽ§åˆ¶ę•“äøŖęµč§ˆå™Øäø­ę˜¾ē¤ŗēš„åø®åŠ©čÆ“ę˜Žę•°é‡ć€‚" + }, + "analyser": { + "rawTrace": { + "pc": "ēØ‹åŗč®”ę•°å™Ø — ę­¤ę“ä½œē åœØę­£åœØę‰§č”Œēš„åˆēŗ¦å­—čŠ‚ē äø­ēš„å­—čŠ‚åē§»é‡ć€‚", + "opcode": "ę­¤ę­„éŖ¤ę‰§č”Œēš„ EVM ęŒ‡ä»¤ (例如 PUSH1态CALL态SSTORE)怂", + "gasLeft": "å½“å‰č°ƒē”Øåø§åœØę­¤ę“ä½œē ę‰§č”Œä¹‹å‰å‰©ä½™ēš„ gas怂", + "cost": "ę­¤å•äøŖę“ä½œē ę¶ˆč€—ēš„ gas怂SSTORE 和 CALL ē­‰ę“ä½œē ęÆ”ē®—ęœÆčæē®—ę˜‚č“µå¾—å¤šć€‚", + "depth": "č°ƒē”Øę ˆę·±åŗ¦ć€‚1 ę˜Æé”¶å±‚č°ƒē”Øļ¼›ęÆäøŖå†…éƒØ CALL/DELEGATECALL/STATICCALL ä½æę·±åŗ¦å¢žåŠ äø€ć€‚" + } } } diff --git a/src/locales/zh/transaction.json b/src/locales/zh/transaction.json index 36722021..e8b97857 100644 --- a/src/locales/zh/transaction.json +++ b/src/locales/zh/transaction.json @@ -192,6 +192,12 @@ "rawTraceSteps": "{{count}} ę“ä½œē ę­„éŖ¤", "rawTracePrev": "äøŠäø€é”µ", "rawTraceNext": "下一锵", - "rawTracePage": "第 {{current}}/{{total}} 锵 ({{from}}–{{to}} / {{totalSteps}})" + "rawTracePage": "第 {{current}}/{{total}} 锵 ({{from}}–{{to}} / {{totalSteps}})", + "rawTraceColStep": "ę­„éŖ¤", + "rawTraceColPc": "PC", + "rawTraceColOpcode": "ę“ä½œē ", + "rawTraceColGasLeft": "剩余 Gas", + "rawTraceColCost": "ę¶ˆč€—", + "rawTraceColDepth": "深度" } } diff --git a/src/services/AIPromptTemplates.ts b/src/services/AIPromptTemplates.ts index 66129d0f..0d189f23 100644 --- a/src/services/AIPromptTemplates.ts +++ b/src/services/AIPromptTemplates.ts @@ -57,9 +57,24 @@ export function buildPrompt( return buildBitcoinBlockPrompt(config, context, promptContext); case "bitcoin_address": return buildBitcoinAddressPrompt(config, context, promptContext); + case "solana_transaction": + case "solana_block": + case "solana_account": + // Solana uses a generic prompt builder (no specialized builder yet) + return buildGenericSolanaPrompt(config, context, promptContext); } } +function buildGenericSolanaPrompt( + config: PromptConfig, + context: Record, + promptContext: PromptContext, +): PromptPair { + const system = buildSystemPrompt(config, promptContext, config.customRules); + const user = `${config.task}. Analyze the following Solana data:\n\n${JSON.stringify(context, null, 2)}`; + return { system, user }; +} + function languageInstruction(language?: string): string { if (!language || language === "en") return ""; const found = SUPPORTED_LANGUAGES.find((l) => l.code === language); @@ -173,6 +188,38 @@ const POWER_STABLE_CONFIGS: Record = { sections: ["Address Analysis", "Balance and UTXOs", "Activity", "Notable Aspects"], customRules: "Express amounts in BTC. Never use gas, wei, Gwei, or EVM terminology.", }, + solana_transaction: { + role: "Solana blockchain analyst", + conciseness: "6-8 sentences", + focusAreas: + "instructions, programs invoked, token transfers, fee and compute units, success/failure, and notable aspects", + audience: "senior Solana developer", + task: "Analyze this Solana transaction", + sections: ["Transaction Analysis", "Instructions", "Token Changes", "Notable Aspects"], + customRules: + "Express amounts in SOL (not lamports). Never use gas, wei, Gwei, or EVM terminology. Use lamports only for fee display alongside SOL.", + }, + solana_block: { + role: "Solana blockchain analyst", + conciseness: "3-5 sentences", + focusAreas: + "transaction count, slot number, block rewards, leader identity, and block utilization", + audience: "senior Solana developer", + task: "Analyze this Solana block (slot)", + sections: ["Block Analysis", "Rewards", "Notable Aspects"], + customRules: "Express amounts in SOL. Never use gas, wei, Gwei, or EVM terminology.", + }, + solana_account: { + role: "Solana blockchain analyst", + conciseness: "4-6 sentences", + focusAreas: + "account type (wallet/program/token), SOL balance, owner program, token holdings, and executable status", + audience: "senior Solana developer", + task: "Analyze this Solana account", + sections: ["Account Analysis", "Balance and Holdings", "Activity", "Notable Aspects"], + customRules: + "Express amounts in SOL. Identify if account is a program, system account, or token account. Never use gas, wei, Gwei, or EVM terminology.", + }, }; // --- Regular User Stable Configs (simpler prompts for non-super-users) --- @@ -240,6 +287,34 @@ const REGULAR_STABLE_CONFIGS: Record = { sections: ["Overview", "Balance"], customRules: "Express amounts in BTC. No EVM terminology.", }, + solana_transaction: { + role: "Solana educator", + conciseness: "4-6 sentences", + focusAreas: "what happened, who signed, programs called, and the fee paid", + audience: "general user", + task: "Explain this Solana transaction in simple, easy-to-understand language", + sections: ["What Happened", "Programs Used", "Fee Details"], + customRules: + "Use simple language. Avoid jargon. Express amounts in SOL. Never use gas, wei, or EVM terminology.", + }, + solana_block: { + role: "Solana educator", + conciseness: "2-3 sentences", + focusAreas: "what happened in this slot, how many transactions it included", + audience: "general user", + task: "Summarize this Solana block in simple terms", + sections: ["Block Summary", "Activity"], + customRules: "Express amounts in SOL. No EVM terminology.", + }, + solana_account: { + role: "Solana educator", + conciseness: "3-4 sentences", + focusAreas: "what this account is, its current SOL balance, and any token holdings", + audience: "general user", + task: "Provide a simple overview of this Solana account", + sections: ["Overview", "Balance"], + customRules: "Express amounts in SOL. No EVM terminology.", + }, }; // --- Latest Configs (initially copies of stable; experiment here) --- diff --git a/src/services/AIService.ts b/src/services/AIService.ts index 0d8c4753..680ecc70 100644 --- a/src/services/AIService.ts +++ b/src/services/AIService.ts @@ -1,3 +1,4 @@ +import { fetchWithWorkerFailover } from "../config/workerConfig"; import type { AIAnalysisResult, AIAnalysisType, AIProviderConfig, PromptVersion } from "../types"; import { logger } from "../utils/logger"; import { buildPrompt } from "./AIPromptTemplates"; @@ -100,7 +101,6 @@ export class AIService { user: string, analysisType: AIAnalysisType, ): Promise { - const url = `${this.provider.baseUrl}/ai/analyze`; const body = { type: analysisType, messages: [ @@ -109,12 +109,16 @@ export class AIService { ], }; - const response = await this.fetchWithRetry(url, { + const response = await fetchWithWorkerFailover("/ai/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); + if (!response.ok) { + this.handleErrorResponse(response.status); + } + const data = await response.json(); const content = data?.choices?.[0]?.message?.content; if (typeof content !== "string") { diff --git a/src/services/DataService.ts b/src/services/DataService.ts index 2d8ad997..6c8d5dab 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -1,24 +1,59 @@ -import { type SupportedChainId, ClientFactory, BitcoinClient } from "@openscan/network-connectors"; +import { + type SupportedChainId, + type SupportedSolanaChainId, + ArbitrumClient, + BaseClient, + BitcoinClient, + ClientFactory, + EthereumClient, + OptimismClient, + PolygonClient, +} from "@openscan/network-connectors"; import { AdapterFactory } from "./adapters/adaptersFactory"; import type { NetworkAdapter } from "./adapters/NetworkAdapter"; import type { BitcoinAdapter } from "./adapters/BitcoinAdapter/BitcoinAdapter"; +import type { SolanaAdapter } from "./adapters/SolanaAdapter/SolanaAdapter"; import type { NetworkConfig, RpcUrlsContextType } from "../types"; import { getRPCUrls } from "../config/rpcConfig"; import { getNetworkRpcKey, getChainIdFromNetwork } from "../utils/networkResolver"; +type EVMClientConfig = { + rpcUrls: string[]; + type: "fallback" | "parallel" | "race"; +}; + +type EVMTestnetClient = + | ArbitrumClient + | OptimismClient + | BaseClient + | PolygonClient + | EthereumClient; + +// EVM testnets not yet registered in @openscan/network-connectors ClientFactory. +// Mapped to their L1 family's client since they share the same JSON-RPC surface. +const EVM_TESTNET_CLIENTS: Record EVMTestnetClient> = { + 421614: (config) => new ArbitrumClient(config), + 11155420: (config) => new OptimismClient(config), + 84532: (config) => new BaseClient(config), + 80002: (config) => new PolygonClient(config), + 43113: (config) => new EthereumClient(config), +}; + /** - * DataService supports both EVM and Bitcoin networks + * DataService supports EVM, Bitcoin, and Solana networks * The adapter type varies based on network type */ export class DataService { /** * The network adapter - use this directly for EVM networks * For Bitcoin networks, use getBitcoinAdapter() instead + * For Solana networks, use getSolanaAdapter() instead */ networkAdapter: NetworkAdapter; private bitcoinAdapter?: BitcoinAdapter; - readonly networkType: "evm" | "bitcoin"; + private solanaAdapter?: SolanaAdapter; + readonly networkType: "evm" | "bitcoin" | "solana"; constructor( network: NetworkConfig, @@ -37,15 +72,23 @@ export class DataService { }); this.bitcoinAdapter = AdapterFactory.createBitcoinAdapter(network.networkId, bitcoinClient); // Create a placeholder adapter that throws for EVM methods - // This maintains type compatibility while ensuring Bitcoin networks use the right methods this.networkAdapter = null as unknown as NetworkAdapter; - } else { - // Create EVM client and adapter - const chainId = getChainIdFromNetwork(network) as SupportedChainId; - const networkClient = ClientFactory.createTypedClient(chainId, { + } else if (network.type === "solana") { + // Create Solana client and adapter via ClientFactory + const solanaChainId = network.networkId as SupportedSolanaChainId; + const solanaClient = ClientFactory.createTypedClient(solanaChainId, { rpcUrls, type: strategy, }); + this.solanaAdapter = AdapterFactory.createSolanaAdapter(network.networkId, solanaClient); + this.networkAdapter = null as unknown as NetworkAdapter; + } else { + // Create EVM client and adapter + const chainId = getChainIdFromNetwork(network) as SupportedChainId; + const clientConfig = { rpcUrls, type: strategy }; + const networkClient = + EVM_TESTNET_CLIENTS[chainId as number]?.(clientConfig) ?? + ClientFactory.createTypedClient(chainId, clientConfig); this.networkAdapter = AdapterFactory.createAdapter(chainId, networkClient); } } @@ -64,6 +107,13 @@ export class DataService { return this.networkType === "bitcoin"; } + /** + * Check if this is a Solana network service + */ + isSolana(): boolean { + return this.networkType === "solana"; + } + /** * Get the adapter as an EVM adapter (throws if not EVM) */ @@ -83,4 +133,14 @@ export class DataService { } return this.bitcoinAdapter; } + + /** + * Get the adapter as a Solana adapter (throws if not Solana) + */ + getSolanaAdapter(): SolanaAdapter { + if (!this.isSolana() || !this.solanaAdapter) { + throw new Error("Cannot get Solana adapter for non-Solana network"); + } + return this.solanaAdapter; + } } diff --git a/src/services/MetadataService.ts b/src/services/MetadataService.ts index a4439fdc..85660515 100644 --- a/src/services/MetadataService.ts +++ b/src/services/MetadataService.ts @@ -7,7 +7,7 @@ import networksData from "../config/networks.json"; import { logger } from "../utils/logger"; import { extractChainIdFromNetworkId } from "../utils/networkResolver"; -export const METADATA_VERSION = "1.1.2-alpha.0"; +export const METADATA_VERSION = "1.2.1-alpha.0"; const METADATA_BASE_URL = `https://cdn.jsdelivr.net/npm/@openscan/metadata@${METADATA_VERSION}/dist`; export interface NetworkLink { @@ -81,10 +81,18 @@ const BTC_NETWORK_SLUGS: Record = { "00000000da84f2bafbbc53dee25a72ae": "testnet4", }; +// Solana CAIP-2 chain IDs (first 32 chars of genesis hash) → friendly file names +const SOLANA_NETWORK_SLUGS: Record = { + "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "mainnet", + EtWTRABZaYq6iMfeYKouRu166VU2xqa1: "devnet", + "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": "testnet", +}; + /** * Parse a CAIP-2 networkId to determine the RPC file path * "eip155:{chainId}" → rpcs/evm/{chainId}.json * "bip122:{hash}" → rpcs/btc/{slug}.json (using genesis hash → slug mapping) + * "solana:{hash}" → rpcs/solana/{slug}.json (using genesis hash → slug mapping) */ function getRpcPathFromNetworkId(networkId: string): string | null { if (networkId.startsWith("eip155:")) { @@ -97,6 +105,12 @@ function getRpcPathFromNetworkId(networkId: string): string | null { if (!slug) return null; return `rpcs/btc/${slug}.json`; } + if (networkId.startsWith("solana:")) { + const hash = networkId.slice(7); + const slug = SOLANA_NETWORK_SLUGS[hash]; + if (!slug) return null; + return `rpcs/solana/${slug}.json`; + } return null; } diff --git a/src/services/adapters/ArbitrumAdapter/ArbitrumAdapter.ts b/src/services/adapters/ArbitrumAdapter/ArbitrumAdapter.ts index 78b65827..b037da64 100644 --- a/src/services/adapters/ArbitrumAdapter/ArbitrumAdapter.ts +++ b/src/services/adapters/ArbitrumAdapter/ArbitrumAdapter.ts @@ -23,7 +23,7 @@ import type { ArbitrumClient, EthereumClient } from "@openscan/network-connector export class ArbitrumAdapter extends NetworkAdapter { private client: ArbitrumClient; - constructor(networkId: 42161, client: ArbitrumClient) { + constructor(networkId: 42161 | 421614, client: ArbitrumClient) { super(networkId); this.client = client; this.initTxSearch(client as unknown as EthereumClient); diff --git a/src/services/adapters/BaseAdapter/BaseAdapter.ts b/src/services/adapters/BaseAdapter/BaseAdapter.ts index dd23c7f0..60939d41 100644 --- a/src/services/adapters/BaseAdapter/BaseAdapter.ts +++ b/src/services/adapters/BaseAdapter/BaseAdapter.ts @@ -22,7 +22,7 @@ import type { BaseClient, EthereumClient } from "@openscan/network-connectors"; export class BaseAdapter extends NetworkAdapter { private client: BaseClient; - constructor(networkId: 8453, client: BaseClient) { + constructor(networkId: 8453 | 84532, client: BaseClient) { super(networkId); this.client = client; this.initTxSearch(client as unknown as EthereumClient); diff --git a/src/services/adapters/OptimismAdapter/OptimismAdapter.ts b/src/services/adapters/OptimismAdapter/OptimismAdapter.ts index cb1935e4..fd629682 100644 --- a/src/services/adapters/OptimismAdapter/OptimismAdapter.ts +++ b/src/services/adapters/OptimismAdapter/OptimismAdapter.ts @@ -22,7 +22,7 @@ import type { OptimismClient, EthereumClient } from "@openscan/network-connector export class OptimismAdapter extends NetworkAdapter { private client: OptimismClient; - constructor(networkId: 10, client: OptimismClient) { + constructor(networkId: 10 | 11155420, client: OptimismClient) { super(networkId); this.client = client; this.initTxSearch(client as unknown as EthereumClient); diff --git a/src/services/adapters/PolygonAdapter/PolygonAdapter.ts b/src/services/adapters/PolygonAdapter/PolygonAdapter.ts index eaf106eb..942e305a 100644 --- a/src/services/adapters/PolygonAdapter/PolygonAdapter.ts +++ b/src/services/adapters/PolygonAdapter/PolygonAdapter.ts @@ -12,7 +12,7 @@ import { import { normalizeBlockNumber } from "../shared/normalizeBlockNumber"; import { mergeMetadata } from "../shared/mergeMetadata"; -import type { PolygonClient, SupportedChainId, EthereumClient } from "@openscan/network-connectors"; +import type { PolygonClient, EthereumClient } from "@openscan/network-connectors"; /** * Polygon blockchain service @@ -21,7 +21,7 @@ import type { PolygonClient, SupportedChainId, EthereumClient } from "@openscan/ export class PolygonAdapter extends NetworkAdapter { private client: PolygonClient; - constructor(networkId: SupportedChainId, client: PolygonClient) { + constructor(networkId: 137 | 80002, client: PolygonClient) { super(networkId); this.client = client; this.initTxSearch(client as unknown as EthereumClient); diff --git a/src/services/adapters/SolanaAdapter/SolanaAdapter.ts b/src/services/adapters/SolanaAdapter/SolanaAdapter.ts new file mode 100644 index 00000000..cf4d36eb --- /dev/null +++ b/src/services/adapters/SolanaAdapter/SolanaAdapter.ts @@ -0,0 +1,496 @@ +import type { + DataWithMetadata, + SolanaAccount, + SolanaBlock, + SolanaEpochInfo, + SolanaInnerInstruction, + SolanaInstruction, + SolanaLeaderSchedule, + SolanaNetworkStats, + SolanaReward, + SolanaSignatureInfo, + SolanaTokenAmount, + SolanaTokenHolding, + SolanaTokenLargestAccount, + SolanaTransaction, + SolanaValidator, +} from "../../../types"; +import type { SolanaClient, SolBlock, SolTransaction } from "@openscan/network-connectors"; + +// Not exported from the package — mirror the shape +interface SolParsedAccountKey { + pubkey: string; + writable: boolean; + signer: boolean; + source?: "transaction" | "lookupTable"; +} + +// SPL Token Program IDs +const TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; +const TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"; + +/** + * Solana blockchain adapter + * + * Follows the same pattern as BitcoinAdapter — standalone class, not extending NetworkAdapter. + */ +export class SolanaAdapter { + readonly networkId: string; + private client: SolanaClient; + + constructor(networkId: string, client: SolanaClient) { + this.networkId = networkId; + this.client = client; + } + + // ==================== CORE METHODS ==================== + + /** + * Get the current slot number + */ + async getLatestSlot(): Promise { + const result = await this.client.getSlot("finalized"); + return result.data ?? 0; + } + + /** + * Get network statistics + */ + async getNetworkStats(): Promise> { + const [slotResult, epochResult, versionResult, txCountResult] = await Promise.all([ + this.client.getSlot("finalized"), + this.client.getEpochInfo("finalized"), + this.client.getVersion(), + this.client.getTransactionCount("finalized"), + ]); + + const epochInfo = epochResult.data; + + const stats: SolanaNetworkStats = { + currentSlot: slotResult.data ?? 0, + blockHeight: epochInfo?.blockHeight ?? 0, + epoch: epochInfo?.epoch ?? 0, + epochSlotIndex: epochInfo?.slotIndex ?? 0, + epochSlotsTotal: epochInfo?.slotsInEpoch ?? 0, + transactionCount: txCountResult.data ?? 0, + version: versionResult.data?.["solana-core"] ?? "unknown", + }; + + return { + data: stats, + metadata: slotResult.metadata as DataWithMetadata["metadata"], + }; + } + + /** + * Get epoch info + */ + async getEpochInfo(): Promise { + const result = await this.client.getEpochInfo("finalized"); + const data = result.data; + if (!data) { + throw new Error("Failed to fetch epoch info"); + } + return { + epoch: data.epoch, + slotIndex: data.slotIndex, + slotsInEpoch: data.slotsInEpoch, + absoluteSlot: data.absoluteSlot, + blockHeight: data.blockHeight, + transactionCount: data.transactionCount, + }; + } + + /** + * Get the latest N blocks (slots with confirmed blocks) + */ + async getLatestBlocks(count = 10): Promise { + const currentSlot = await this.getLatestSlot(); + + // Get confirmed block slots in a range + const startSlot = Math.max(0, currentSlot - 100); + const slotsResult = await this.client.getBlocks(startSlot, currentSlot, "finalized"); + const slots = (slotsResult.data ?? []).slice(-count).reverse(); + + // Fetch block details in parallel + const blockResults = await Promise.all( + slots.map((slot) => + this.client + .getBlock(slot, { + encoding: "jsonParsed", + transactionDetails: "signatures", + rewards: true, + commitment: "finalized", + }) + .catch(() => null), + ), + ); + + const blocks: SolanaBlock[] = []; + for (let i = 0; i < slots.length; i++) { + const result = blockResults[i]; + if (!result?.data) continue; + + const blockData = result.data; + blocks.push(this.transformBlock(slots[i] ?? 0, blockData)); + } + + return blocks; + } + + /** + * Get a single block by slot number + */ + async getBlock(slot: number): Promise> { + const result = await this.client.getBlock(slot, { + encoding: "jsonParsed", + transactionDetails: "signatures", + rewards: true, + commitment: "finalized", + }); + + if (!result.data) { + throw new Error(`Block at slot ${slot} not found`); + } + + return { + data: this.transformBlock(slot, result.data), + metadata: result.metadata as DataWithMetadata["metadata"], + }; + } + + /** + * Get a transaction by signature + */ + async getTransaction(signature: string): Promise> { + const result = await this.client.getTransaction(signature, { + encoding: "jsonParsed", + commitment: "finalized", + maxSupportedTransactionVersion: 0, + }); + + if (!result.data) { + throw new Error(`Transaction ${signature} not found`); + } + + return { + data: this.transformTransaction(signature, result.data), + metadata: result.metadata as DataWithMetadata["metadata"], + }; + } + + /** + * Get account information + */ + async getAccount(pubkey: string): Promise> { + const [accountResult, tokenAccountsResult] = await Promise.all([ + this.client.getAccountInfo(pubkey, { commitment: "finalized", encoding: "jsonParsed" }), + this.getTokenAccountsByOwner(pubkey).catch(() => []), + ]); + + const accountInfo = accountResult.data?.value; + + const account: SolanaAccount = { + address: pubkey, + lamports: accountInfo?.lamports ?? 0, + owner: accountInfo?.owner ?? "11111111111111111111111111111111", + executable: accountInfo?.executable ?? false, + rentEpoch: accountInfo?.rentEpoch ?? 0, + space: accountInfo?.space ?? 0, + tokenAccounts: tokenAccountsResult, + }; + + return { + data: account, + metadata: accountResult.metadata as DataWithMetadata["metadata"], + }; + } + + // ==================== TOKEN METHODS ==================== + + /** + * Get SPL token accounts owned by an address + */ + async getTokenAccountsByOwner(owner: string): Promise { + // Fetch from both Token Program and Token-2022 in parallel + const [tokenResult, token2022Result] = await Promise.all([ + this.client + .getTokenAccountsByOwner( + owner, + { programId: TOKEN_PROGRAM_ID }, + { encoding: "jsonParsed", commitment: "finalized" }, + ) + .catch(() => null), + this.client + .getTokenAccountsByOwner( + owner, + { programId: TOKEN_2022_PROGRAM_ID }, + { encoding: "jsonParsed", commitment: "finalized" }, + ) + .catch(() => null), + ]); + + const holdings: SolanaTokenHolding[] = []; + + // biome-ignore lint/suspicious/noExplicitAny: RPC response varies + const processAccounts = (result: { data?: { value?: any[] } } | null) => { + if (!result?.data?.value) return; + for (const tokenAccount of result.data.value) { + // biome-ignore lint/suspicious/noExplicitAny: parsed data varies + const parsed = (tokenAccount.account?.data as any)?.parsed?.info; + if (!parsed) continue; + + holdings.push({ + mint: parsed.mint, + tokenAccount: tokenAccount.pubkey, + amount: { + amount: parsed.tokenAmount?.amount ?? "0", + decimals: parsed.tokenAmount?.decimals ?? 0, + uiAmount: parsed.tokenAmount?.uiAmount ?? null, + uiAmountString: parsed.tokenAmount?.uiAmountString ?? "0", + }, + }); + } + }; + + processAccounts(tokenResult); + processAccounts(token2022Result); + + return holdings; + } + + /** + * Get total supply for an SPL token + */ + async getTokenSupply(mint: string): Promise { + const result = await this.client.getTokenSupply(mint, "finalized"); + const value = result.data?.value; + return { + amount: value?.amount ?? "0", + decimals: value?.decimals ?? 0, + uiAmount: value?.uiAmount ?? null, + uiAmountString: value?.uiAmountString ?? "0", + }; + } + + /** + * Get the largest holders of an SPL token + */ + async getTokenLargestAccounts(mint: string): Promise { + const result = await this.client.getTokenLargestAccounts(mint, "finalized"); + const accounts = result.data?.value ?? []; + return accounts.map((a) => ({ + address: a.address, + amount: a.amount, + decimals: a.decimals, + uiAmount: a.uiAmount, + uiAmountString: a.uiAmountString, + })); + } + + // ==================== VALIDATOR METHODS ==================== + + /** + * Get vote accounts (validators) + */ + async getVoteAccounts(): Promise<{ + current: SolanaValidator[]; + delinquent: SolanaValidator[]; + }> { + const result = await this.client.getVoteAccounts({ commitment: "finalized" }); + const data = result.data; + if (!data) { + return { current: [], delinquent: [] }; + } + + const mapValidator = (v: { + votePubkey: string; + nodePubkey: string; + activatedStake: number; + epochVoteAccount: boolean; + commission: number; + lastVote: number; + epochCredits: [number, number, number][]; + rootSlot?: number; + }): SolanaValidator => ({ + votePubkey: v.votePubkey, + nodePubkey: v.nodePubkey, + activatedStake: v.activatedStake, + commission: v.commission, + lastVote: v.lastVote, + epochVoteAccount: v.epochVoteAccount, + epochCredits: v.epochCredits, + rootSlot: v.rootSlot, + }); + + return { + current: (data.current ?? []).map(mapValidator), + delinquent: (data.delinquent ?? []).map(mapValidator), + }; + } + + /** + * Get leader schedule for current epoch + */ + async getLeaderSchedule(): Promise { + const result = await this.client.getLeaderSchedule(null, { commitment: "finalized" }); + return result.data ?? {}; + } + + // ==================== ACCOUNT HISTORY ==================== + + /** + * Get confirmed signatures for transactions involving an address + */ + async getSignaturesForAddress( + address: string, + config?: { limit?: number; before?: string }, + ): Promise { + const result = await this.client.getSignaturesForAddress(address, { + limit: config?.limit ?? 20, + before: config?.before, + commitment: "finalized", + }); + + return (result.data ?? []).map((sig) => ({ + signature: sig.signature, + slot: sig.slot, + blockTime: sig.blockTime, + err: sig.err, + memo: sig.memo, + confirmationStatus: sig.confirmationStatus, + })); + } + + // ==================== UTILITY METHODS ==================== + + isSolana(): boolean { + return true; + } + + getNetworkId(): string { + return this.networkId; + } + + // ==================== PRIVATE TRANSFORM METHODS ==================== + + private transformBlock(slot: number, blockData: SolBlock): SolanaBlock { + return { + slot, + blockhash: blockData.blockhash, + previousBlockhash: blockData.previousBlockhash, + parentSlot: blockData.parentSlot, + blockHeight: blockData.blockHeight, + blockTime: blockData.blockTime, + transactionCount: blockData.signatures?.length ?? blockData.transactions?.length ?? 0, + rewards: (blockData.rewards ?? []).map( + (r): SolanaReward => ({ + pubkey: r.pubkey, + lamports: r.lamports, + postBalance: r.postBalance, + rewardType: r.rewardType, + commission: r.commission, + }), + ), + signatures: blockData.signatures, + }; + } + + private transformTransaction(signature: string, txData: SolTransaction): SolanaTransaction { + const meta = txData.meta; + const message = txData.transaction.message; + + // Parse account keys + const accountKeys = Array.isArray(message.accountKeys) + ? message.accountKeys.map((key) => { + if (typeof key === "string") { + return { pubkey: key, writable: false, signer: false }; + } + const parsed = key as SolParsedAccountKey; + return { + pubkey: parsed.pubkey, + writable: parsed.writable, + signer: parsed.signer, + }; + }) + : []; + + // Extract signers + const signers = accountKeys.filter((k) => k.signer).map((k) => k.pubkey); + if (signers.length === 0 && txData.transaction.signatures.length > 0) { + // First account key is always the fee payer / signer + const firstKey = accountKeys[0]; + if (firstKey) { + signers.push(firstKey.pubkey); + } + } + + // Transform instructions + const instructions: SolanaInstruction[] = message.instructions.map( + // biome-ignore lint/suspicious/noExplicitAny: instruction format varies + (ix: any): SolanaInstruction => ({ + programId: ix.programId ?? ix.program ?? "", + accounts: ix.accounts ?? [], + data: ix.data ?? "", + parsed: ix.parsed, + }), + ); + + // Transform inner instructions + const innerInstructions: SolanaInnerInstruction[] = (meta?.innerInstructions ?? []).map( + // biome-ignore lint/suspicious/noExplicitAny: inner instruction format varies + (group: any): SolanaInnerInstruction => ({ + index: group.index, + instructions: (group.instructions ?? []).map( + // biome-ignore lint/suspicious/noExplicitAny: instruction format varies + (ix: any): SolanaInstruction => ({ + programId: ix.programId ?? ix.program ?? "", + accounts: ix.accounts ?? [], + data: ix.data ?? "", + parsed: ix.parsed, + }), + ), + }), + ); + + return { + signature, + slot: txData.slot, + blockTime: txData.blockTime, + fee: meta?.fee ?? 0, + status: meta?.err ? "failed" : "success", + err: meta?.err ?? null, + signers, + accountKeys, + instructions, + innerInstructions, + logMessages: meta?.logMessages ?? [], + preBalances: meta?.preBalances ?? [], + postBalances: meta?.postBalances ?? [], + preTokenBalances: (meta?.preTokenBalances ?? []).map((tb) => ({ + accountIndex: tb.accountIndex, + mint: tb.mint, + owner: tb.owner, + uiTokenAmount: { + amount: tb.uiTokenAmount.amount, + decimals: tb.uiTokenAmount.decimals, + uiAmount: tb.uiTokenAmount.uiAmount, + uiAmountString: tb.uiTokenAmount.uiAmountString, + }, + })), + postTokenBalances: (meta?.postTokenBalances ?? []).map((tb) => ({ + accountIndex: tb.accountIndex, + mint: tb.mint, + owner: tb.owner, + uiTokenAmount: { + amount: tb.uiTokenAmount.amount, + decimals: tb.uiTokenAmount.decimals, + uiAmount: tb.uiTokenAmount.uiAmount, + uiAmountString: tb.uiTokenAmount.uiAmountString, + }, + })), + computeUnitsConsumed: meta?.computeUnitsConsumed, + version: txData.version, + }; + } +} diff --git a/src/services/adapters/adaptersFactory.ts b/src/services/adapters/adaptersFactory.ts index 956d85a1..faa3cbcc 100644 --- a/src/services/adapters/adaptersFactory.ts +++ b/src/services/adapters/adaptersFactory.ts @@ -7,6 +7,7 @@ import { PolygonAdapter } from "./PolygonAdapter/PolygonAdapter"; import { ArbitrumAdapter } from "./ArbitrumAdapter/ArbitrumAdapter"; import { HardhatAdapter } from "./HardhatAdapter/HardhatAdapter"; import { BitcoinAdapter } from "./BitcoinAdapter/BitcoinAdapter"; +import { SolanaAdapter } from "./SolanaAdapter/SolanaAdapter"; import type { ArbitrumClient, AvalancheClient, @@ -18,6 +19,7 @@ import type { HardhatClient, OptimismClient, PolygonClient, + SolanaClient, SupportedChainId, } from "@openscan/network-connectors"; @@ -27,7 +29,7 @@ export class AdapterFactory { * Create an EVM network adapter */ static createAdapter( - networkId: SupportedChainId, + networkId: SupportedChainId | number, client: | EthereumClient | OptimismClient @@ -43,19 +45,24 @@ export class AdapterFactory { case 1: case 11155111: case 43114: + case 43113: return new EVMAdapter(networkId, client as unknown as EthereumClient); case 31337: return new HardhatAdapter(client as HardhatClient); case 10: + case 11155420: return new OptimismAdapter(networkId, client as OptimismClient); case 56: case 97: return new BNBAdapter(networkId, client as BNBClient); case 137: + case 80002: return new PolygonAdapter(networkId, client as PolygonClient); case 8453: + case 84532: return new BaseAdapter(networkId, client as BaseClient); case 42161: + case 421614: return new ArbitrumAdapter(networkId, client as ArbitrumClient); default: throw new Error(`Unknown adapter for networkId: ${networkId}`); @@ -68,4 +75,11 @@ export class AdapterFactory { static createBitcoinAdapter(networkId: string, client: BitcoinClient): BitcoinAdapter { return new BitcoinAdapter(networkId, client); } + + /** + * Create a Solana network adapter + */ + static createSolanaAdapter(networkId: string, client: SolanaClient): SolanaAdapter { + return new SolanaAdapter(networkId, client); + } } diff --git a/src/styles/components.css b/src/styles/components.css index 706db337..8860a0f2 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -296,6 +296,12 @@ color: white; } +.block-nav-btn-disabled { + opacity: 0.35; + cursor: default; + pointer-events: none; +} + .burnt-fees { color: var(--color-warning); font-weight: 600; @@ -5838,6 +5844,12 @@ button.tx-section-header-toggle { gap: 8px; } +.erc721-token-nav { + display: inline-flex; + align-items: center; + gap: 6px; +} + .erc721-token-name { font-size: 1.25rem; font-weight: 600; @@ -6063,11 +6075,26 @@ button.tx-section-header-toggle { display: inline-block; font-size: 1.1rem; font-weight: 600; - color: var(--primary-color, var(--color-info)); + color: var(--color-primary); text-decoration: none; + transition: color 0.15s ease; } .nft-collection-link:hover { + color: var(--color-primary-hover); + text-decoration: underline; +} + +/* Inline address/link used in token detail rows (Owner, Contract, Approved, Collection) */ +.address-link { + color: var(--color-primary); + font-weight: 600; + text-decoration: none; + transition: color 0.15s ease; +} + +.address-link:hover { + color: var(--color-primary-hover); text-decoration: underline; } @@ -6097,17 +6124,23 @@ button.tx-section-header-toggle { display: inline-flex; align-items: center; padding: 8px 16px; - background: var(--color-info-alpha-10); - color: var(--color-info); + background: var(--color-info-alpha-15); + color: var(--badge-info-text, var(--color-info)); + border: 1px solid var(--color-info-alpha-30); border-radius: 6px; text-decoration: none; font-size: 0.9rem; font-weight: 500; - transition: background 0.2s ease; + transition: + background 0.2s ease, + border-color 0.2s ease, + color 0.2s ease; } .nft-link-button:hover { - background: var(--color-info-alpha-20); + background: var(--color-info-alpha-25, var(--color-info-alpha-20)); + border-color: var(--color-info-alpha-50, var(--color-info-alpha-40)); + color: var(--color-info-hover); } /* NFT Token URI Section */ @@ -6122,14 +6155,15 @@ button.tx-section-header-toggle { .nft-token-uri-code { flex: 1; padding: 12px; - background: rgba(0, 0, 0, 0.03); + background: var(--overlay-light-5); + border: 1px solid var(--border-primary); border-radius: 6px; font-family: "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", monospace; font-size: 0.85rem; line-height: 1.5; word-break: break-all; white-space: pre-wrap; - color: var(--text-primary, #1f2937); + color: var(--text-primary); overflow-x: auto; max-height: 200px; overflow-y: auto; @@ -7934,6 +7968,14 @@ button.tx-section-header-toggle { margin-bottom: 16px; } +.collection-token-list-card { + background: var(--color-surface); + border: 1px solid var(--color-primary-alpha-10); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + .nft-collection-display { display: inline-flex; align-items: center; diff --git a/src/styles/styles.css b/src/styles/styles.css index 1af9f89c..74704a7b 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -4392,6 +4392,11 @@ code { color: var(--color-warning); } +.block-status-failed { + background: var(--color-error-alpha-15); + color: var(--color-error); +} + .block-display-grid { display: flex; flex-direction: column; diff --git a/src/types/index.ts b/src/types/index.ts index 14025cf1..ff27f57b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,16 +4,16 @@ import type React from "react"; // ==================== NETWORK TYPES ==================== /** - * Network type - EVM or Bitcoin + * Network type - EVM, Bitcoin, or Solana */ -export type NetworkType = "evm" | "bitcoin"; +export type NetworkType = "evm" | "bitcoin" | "solana"; /** * All EVM chain IDs supported by the app. - * Maps directly to the connector library's SupportedChainId. - * When adding a new EVM network, add its chain ID to network-connectors first. + * Extends the connector library's SupportedChainId with testnet chain IDs + * that reuse their L1 family's client (not yet registered in network-connectors). */ -export type AppChainId = SupportedChainId; +export type AppChainId = SupportedChainId | 43113 | 421614 | 11155420 | 84532 | 80002; // ==================== CORE DOMAIN TYPES ==================== @@ -273,6 +273,198 @@ export interface BitcoinAddress { txids?: string[]; } +// ==================== SOLANA TYPES ==================== + +/** + * Solana network statistics + */ +export interface SolanaNetworkStats { + currentSlot: number; + blockHeight: number; + epoch: number; + epochSlotIndex: number; + epochSlotsTotal: number; + transactionCount: number; + version: string; +} + +/** + * Solana epoch info + */ +export interface SolanaEpochInfo { + epoch: number; + slotIndex: number; + slotsInEpoch: number; + absoluteSlot: number; + blockHeight: number; + transactionCount?: number; +} + +/** + * Solana block/slot data + */ +export interface SolanaBlock { + slot: number; + blockhash: string; + previousBlockhash: string; + parentSlot: number; + blockHeight: number | null; + blockTime: number | null; + transactionCount: number; + rewards: SolanaReward[]; + // Transaction signatures (for block list views) + signatures?: string[]; +} + +/** + * Solana block reward entry + */ +export interface SolanaReward { + pubkey: string; + lamports: number; + postBalance: number; + rewardType: "fee" | "rent" | "staking" | "voting" | null; + commission?: number | null; +} + +/** + * Solana transaction data + */ +export interface SolanaTransaction { + signature: string; + slot: number; + blockTime: number | null; + fee: number; + status: "success" | "failed"; + // biome-ignore lint/suspicious/noExplicitAny: error format varies + err: any; + signers: string[]; + accountKeys: SolanaAccountKey[]; + instructions: SolanaInstruction[]; + innerInstructions: SolanaInnerInstruction[]; + logMessages: string[]; + preBalances: number[]; + postBalances: number[]; + preTokenBalances: SolanaTokenBalance[]; + postTokenBalances: SolanaTokenBalance[]; + computeUnitsConsumed?: number; + version?: "legacy" | 0; +} + +/** + * Solana parsed account key with permissions + */ +export interface SolanaAccountKey { + pubkey: string; + writable: boolean; + signer: boolean; +} + +/** + * Solana instruction + */ +export interface SolanaInstruction { + programId: string; + accounts: string[]; + data: string; + // biome-ignore lint/suspicious/noExplicitAny: parsed instruction formats vary + parsed?: any; +} + +/** + * Solana inner instruction group + */ +export interface SolanaInnerInstruction { + index: number; + instructions: SolanaInstruction[]; +} + +/** + * Solana token balance (pre/post transaction) + */ +export interface SolanaTokenBalance { + accountIndex: number; + mint: string; + owner?: string; + uiTokenAmount: SolanaTokenAmount; +} + +/** + * Solana token amount + */ +export interface SolanaTokenAmount { + amount: string; + decimals: number; + uiAmount: number | null; + uiAmountString: string; +} + +/** + * Solana account data + */ +export interface SolanaAccount { + address: string; + lamports: number; + owner: string; + executable: boolean; + rentEpoch: number; + space: number; + // Token holdings (fetched separately) + tokenAccounts?: SolanaTokenHolding[]; +} + +/** + * Solana SPL token holding for an account + */ +export interface SolanaTokenHolding { + mint: string; + tokenAccount: string; + amount: SolanaTokenAmount; +} + +/** + * Solana token largest account holder + */ +export interface SolanaTokenLargestAccount { + address: string; + amount: string; + decimals: number; + uiAmount: number | null; + uiAmountString: string; +} + +/** + * Solana validator (vote account) + */ +export interface SolanaValidator { + votePubkey: string; + nodePubkey: string; + activatedStake: number; + commission: number; + lastVote: number; + epochVoteAccount: boolean; + epochCredits: [number, number, number][]; + rootSlot?: number; +} + +/** + * Solana signature info (for address transaction history) + */ +export interface SolanaSignatureInfo { + signature: string; + slot: number; + blockTime: number | null; + // biome-ignore lint/suspicious/noExplicitAny: error format varies + err: any; + memo: string | null; + confirmationStatus: "processed" | "confirmed" | "finalized" | null; +} + +/** + * Solana leader schedule + */ +export type SolanaLeaderSchedule = Record; + export interface Address { address: string; balance: string; @@ -490,7 +682,10 @@ export type AIAnalysisType = | "block" | "bitcoin_transaction" | "bitcoin_block" - | "bitcoin_address"; + | "bitcoin_address" + | "solana_transaction" + | "solana_block" + | "solana_account"; /** * Prompt version for AI analysis diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 47d5063e..c9cea56c 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1 +1 @@ -export const ENVIRONMENT = import.meta.env.VITE_ENVIRONMENT || "development"; +export const ENVIRONMENT = import.meta.env.OPENSCAN_ENVIRONMENT || "development"; diff --git a/src/utils/contractLookup.ts b/src/utils/contractLookup.ts index 1dc49757..3b2cb83b 100644 --- a/src/utils/contractLookup.ts +++ b/src/utils/contractLookup.ts @@ -1,4 +1,4 @@ -import { OPENSCAN_WORKER_URL } from "../config/workerConfig"; +import { fetchWithWorkerFailover } from "../config/workerConfig"; import { logger } from "./logger"; export interface ContractInfo { @@ -29,8 +29,8 @@ async function fetchEtherscanVerification( const url = `https://api.etherscan.io/v2/api?chainid=${chainId}&module=contract&action=getsourcecode&address=${address}&apikey=${etherscanKey}`; res = await fetch(url, { signal }); } else { - // Proxy through OpenScan Worker (free, no key needed) - res = await fetch(`${OPENSCAN_WORKER_URL}/etherscan/verify`, { + // Proxy through OpenScan Worker (free, no key needed) with failover + res = await fetchWithWorkerFailover("/etherscan/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ chainId, address }), diff --git a/src/utils/erc721Metadata.ts b/src/utils/erc721Metadata.ts index f51590dd..f034187d 100644 --- a/src/utils/erc721Metadata.ts +++ b/src/utils/erc721Metadata.ts @@ -265,6 +265,40 @@ export function getImageUrl(metadata: ERC721TokenMetadata): string | null { return ipfsToHttp(image); } +/** + * Fetch tokenByIndex (ERC721Enumerable). Returns null if the contract is not enumerable. + */ +export async function fetchTokenByIndex( + contractAddress: string, + index: string, + rpcUrl: string, +): Promise { + try { + // tokenByIndex(uint256) selector: 0x4f6ccce5 + const indexHex = BigInt(index).toString(16).padStart(64, "0"); + const data = `0x4f6ccce5${indexHex}`; + + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "eth_call", + params: [{ to: contractAddress, data }, "latest"], + id: 1, + }), + }); + + const result = await response.json(); + if (result.error || !result.result || result.result === "0x") return null; + + return BigInt(result.result).toString(); + } catch (error) { + logger.error("Failed to fetch tokenByIndex:", error); + return null; + } +} + export interface CollectionInfo { name?: string; symbol?: string; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index e95c7fd5..f98c1586 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,4 +1,5 @@ import { ENVIRONMENT } from "./constants"; +import { redactSensitiveUrlsInText } from "./urlUtils"; type LogLevel = "debug" | "info" | "warn" | "error"; @@ -17,17 +18,36 @@ const MIN_LOG_LEVEL: Record = { const minLevel = LOG_LEVELS[MIN_LOG_LEVEL[ENVIRONMENT] || "debug"]; +// Mask API keys embedded in URLs (e.g. Alchemy `/v2/`, Infura `/v3/`, +// `?apiKey=…`) before any log arg reaches the console. Users paste RPC URLs +// with embedded credentials; without this, any fetch error that logs the URL +// or response body leaks the key into console screenshots / bug reports. +function sanitizeArg(arg: unknown): unknown { + if (typeof arg === "string") return redactSensitiveUrlsInText(arg); + if (arg instanceof Error) { + const sanitized = new Error(redactSensitiveUrlsInText(arg.message)); + sanitized.name = arg.name; + if (arg.stack) sanitized.stack = redactSensitiveUrlsInText(arg.stack); + return sanitized; + } + return arg; +} + +function sanitizeArgs(args: unknown[]): unknown[] { + return args.map(sanitizeArg); +} + export const logger = { debug: (...args: unknown[]): void => { - if (LOG_LEVELS.debug >= minLevel) console.log("[DEBUG]", ...args); + if (LOG_LEVELS.debug >= minLevel) console.log("[DEBUG]", ...sanitizeArgs(args)); }, info: (...args: unknown[]): void => { - if (LOG_LEVELS.info >= minLevel) console.log("[INFO]", ...args); + if (LOG_LEVELS.info >= minLevel) console.log("[INFO]", ...sanitizeArgs(args)); }, warn: (...args: unknown[]): void => { - if (LOG_LEVELS.warn >= minLevel) console.warn("[WARN]", ...args); + if (LOG_LEVELS.warn >= minLevel) console.warn("[WARN]", ...sanitizeArgs(args)); }, error: (...args: unknown[]): void => { - if (LOG_LEVELS.error >= minLevel) console.error("[ERROR]", ...args); + if (LOG_LEVELS.error >= minLevel) console.error("[ERROR]", ...sanitizeArgs(args)); }, }; diff --git a/src/utils/networkResolver.ts b/src/utils/networkResolver.ts index 12135599..041361ae 100644 --- a/src/utils/networkResolver.ts +++ b/src/utils/networkResolver.ts @@ -71,6 +71,13 @@ export function isBitcoinNetwork(network: NetworkConfig): boolean { return network.type === "bitcoin"; } +/** + * Check if a network is a Solana network + */ +export function isSolanaNetwork(network: NetworkConfig): boolean { + return network.type === "solana"; +} + /** * Get the URL path segment for a network * Uses slug if available, otherwise chainId for EVM or networkId diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index 647498ff..552189ae 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -1,4 +1,4 @@ -import { OPENSCAN_WORKER_URL } from "../config/workerConfig"; +import { OPENSCAN_WORKER_URL, isWorkerProxyUrl } from "../config/workerConfig"; import { type MetadataRpcEndpoint, METADATA_VERSION } from "../services/MetadataService"; import type { RpcUrlsContextType } from "../types"; import { logger } from "./logger"; @@ -64,6 +64,24 @@ const BUILTIN_RPC_DEFAULTS: RpcUrlsContextType = { `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:43114`, `${OPENSCAN_WORKER_URL}/evm/ankr/eip155:43114`, ], + // Solana — public RPC endpoints (rate-limited; users should add their own for production use) + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": [ + "https://api.mainnet-beta.solana.com", + "https://solana-rpc.publicnode.com", + "https://solana.drpc.org", + "https://rpc.ankr.com/solana", + "https://solana.api.pocket.network", + ], + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": [ + "https://api.devnet.solana.com", + "https://solana-devnet-rpc.publicnode.com", + "https://solana-devnet.drpc.org", + "https://rpc.ankr.com/solana_devnet", + ], + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": [ + "https://api.testnet.solana.com", + "https://solana-testnet-rpc.publicnode.com", + ], }; interface MetadataRpcCache { @@ -189,12 +207,8 @@ export function saveRpcUrlsToStorage(map: RpcUrlsContextType): void { * Stored values override default for a network; missing networks fall back to defaults. * Keys are networkId strings (CAIP-2 format) */ -/** - * Check whether a URL points to the OpenScan worker proxy. - */ -export function isWorkerProxyUrl(url: string): boolean { - return OPENSCAN_WORKER_URL.length > 0 && url.startsWith(OPENSCAN_WORKER_URL); -} +// isWorkerProxyUrl is re-exported from workerConfig (checks all worker URLs) +export { isWorkerProxyUrl }; export function getEffectiveRpcUrls(options?: { excludeWorkerProxy?: boolean; diff --git a/src/utils/solanaUtils.ts b/src/utils/solanaUtils.ts new file mode 100644 index 00000000..f041b021 --- /dev/null +++ b/src/utils/solanaUtils.ts @@ -0,0 +1,88 @@ +/** + * Solana-specific utility functions + */ + +const LAMPORTS_PER_SOL = 1_000_000_000; + +/** + * Convert lamports to SOL string with appropriate decimals + */ +export function lamportsToSol(lamports: number): string { + if (lamports === 0) return "0"; + const sol = lamports / LAMPORTS_PER_SOL; + // Use up to 9 decimals, but trim trailing zeros + return sol.toFixed(9).replace(/\.?0+$/, ""); +} + +/** + * Format lamports as a human-readable SOL amount + */ +export function formatSol(lamports: number): string { + return `${lamportsToSol(lamports)} SOL`; +} + +/** + * Shorten a Solana address (base58 pubkey) for display + */ +export function shortenSolanaAddress(address: string, prefixLen = 4, suffixLen = 4): string { + if (!address || address.length <= prefixLen + suffixLen) return address; + return `${address.slice(0, prefixLen)}...${address.slice(-suffixLen)}`; +} + +/** + * Validate that a string looks like a Solana address (base58, 32-44 chars) + */ +export function isSolanaAddress(input: string): boolean { + if (!input) return false; + // Base58 alphabet (no 0, O, I, l) and length 32-44 + return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(input); +} + +/** + * Validate that a string looks like a Solana transaction signature (base58, 87-88 chars) + */ +export function isSolanaSignature(input: string): boolean { + if (!input) return false; + return /^[1-9A-HJ-NP-Za-km-z]{86,90}$/.test(input); +} + +/** + * Format slot number with commas + */ +export function formatSlotNumber(slot: number): string { + return slot.toLocaleString(); +} + +/** + * Get a transaction status string from the Solana err field + */ +export function getTransactionStatus(err: unknown): "success" | "failed" { + return err == null ? "success" : "failed"; +} + +/** + * Format a Solana block time (Unix seconds) to a relative time + */ +export function formatBlockTime(blockTime: number | null): string { + if (blockTime === null) return "Unknown"; + const date = new Date(blockTime * 1000); + return date.toLocaleString(); +} + +/** + * Calculate epoch progress percentage + */ +export function calculateEpochProgress(slotIndex: number, slotsInEpoch: number): number { + if (slotsInEpoch === 0) return 0; + return (slotIndex / slotsInEpoch) * 100; +} + +/** + * Format a stake amount (lamports) as SOL with M/B suffix for large amounts + */ +export function formatStake(lamports: number): string { + const sol = lamports / LAMPORTS_PER_SOL; + if (sol >= 1_000_000) return `${(sol / 1_000_000).toFixed(2)}M SOL`; + if (sol >= 1_000) return `${(sol / 1_000).toFixed(2)}K SOL`; + return `${sol.toFixed(2)} SOL`; +} diff --git a/src/utils/urlUtils.test.ts b/src/utils/urlUtils.test.ts new file mode 100644 index 00000000..2bf7d9dc --- /dev/null +++ b/src/utils/urlUtils.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + redactSensitiveUrl, + redactSensitiveUrlsInText, + rewriteIpfsUrl, + toSafeExternalHref, +} from "./urlUtils"; + +describe("rewriteIpfsUrl", () => { + it("rewrites ipfs:// to the public HTTPS gateway", () => { + expect(rewriteIpfsUrl("ipfs://QmHash/foo.png")).toBe("https://ipfs.io/ipfs/QmHash/foo.png"); + }); + + it("leaves http(s) and other schemes unchanged", () => { + expect(rewriteIpfsUrl("https://example.com/a")).toBe("https://example.com/a"); + expect(rewriteIpfsUrl("http://example.com/a")).toBe("http://example.com/a"); + expect(rewriteIpfsUrl("javascript:alert(1)")).toBe("javascript:alert(1)"); + }); +}); + +describe("toSafeExternalHref", () => { + it("accepts http:// and https:// URLs", () => { + expect(toSafeExternalHref("http://example.com")).toBe("http://example.com"); + expect(toSafeExternalHref("https://example.com/path?q=1")).toBe("https://example.com/path?q=1"); + }); + + it("rewrites ipfs:// to the HTTPS gateway", () => { + expect(toSafeExternalHref("ipfs://QmHash")).toBe("https://ipfs.io/ipfs/QmHash"); + }); + + it("rejects dangerous schemes", () => { + expect(toSafeExternalHref("javascript:alert(1)")).toBeNull(); + expect(toSafeExternalHref("JAVASCRIPT:alert(1)")).toBeNull(); + expect(toSafeExternalHref("data:text/html,")).toBeNull(); + expect(toSafeExternalHref("vbscript:msgbox")).toBeNull(); + expect(toSafeExternalHref("file:///etc/passwd")).toBeNull(); + }); + + it("rejects empty, non-string, and malformed input", () => { + expect(toSafeExternalHref("")).toBeNull(); + expect(toSafeExternalHref(undefined)).toBeNull(); + expect(toSafeExternalHref(null)).toBeNull(); + expect(toSafeExternalHref(123)).toBeNull(); + expect(toSafeExternalHref("not a url")).toBeNull(); + expect(toSafeExternalHref("/relative/path")).toBeNull(); + }); +}); + +describe("redactSensitiveUrl", () => { + it("masks long token-like path segments (Alchemy, Infura style)", () => { + expect( + redactSensitiveUrl("https://eth-mainnet.g.alchemy.com/v2/AbCdEf1234567890abcdef1234"), + ).toBe("https://eth-mainnet.g.alchemy.com/v2/***"); + expect( + redactSensitiveUrl("https://mainnet.infura.io/v3/0123456789abcdef0123456789abcdef"), + ).toBe("https://mainnet.infura.io/v3/***"); + }); + + it("masks sensitive query params", () => { + expect(redactSensitiveUrl("https://api.example.com/rpc?apiKey=super-secret")).toBe( + "https://api.example.com/rpc?apiKey=***", + ); + expect(redactSensitiveUrl("https://api.example.com/rpc?access_token=abc&foo=bar")).toBe( + "https://api.example.com/rpc?access_token=***&foo=bar", + ); + }); + + it("leaves short path segments and non-URLs unchanged", () => { + expect(redactSensitiveUrl("https://example.com/v2/short")).toBe("https://example.com/v2/short"); + expect(redactSensitiveUrl("not a url")).toBe("not a url"); + }); +}); + +describe("redactSensitiveUrlsInText", () => { + it("redacts URL substrings inside free-form text", () => { + const input = + "Fetch failed for https://eth-mainnet.g.alchemy.com/v2/AbCdEf1234567890abcdef1234 — retrying"; + expect(redactSensitiveUrlsInText(input)).toBe( + "Fetch failed for https://eth-mainnet.g.alchemy.com/v2/*** — retrying", + ); + }); + + it("redacts multiple URLs in a single string", () => { + const input = + "https://mainnet.infura.io/v3/0123456789abcdef0123456789abcdef and https://api.example.com/rpc?apiKey=secret-value-here"; + expect(redactSensitiveUrlsInText(input)).toBe( + "https://mainnet.infura.io/v3/*** and https://api.example.com/rpc?apiKey=***", + ); + }); + + it("returns the string unchanged when no URLs are present", () => { + expect(redactSensitiveUrlsInText("just a message")).toBe("just a message"); + }); +}); diff --git a/src/utils/urlUtils.ts b/src/utils/urlUtils.ts new file mode 100644 index 00000000..e35757c6 --- /dev/null +++ b/src/utils/urlUtils.ts @@ -0,0 +1,70 @@ +const IPFS_GATEWAY = "https://ipfs.io/ipfs/"; + +/** + * Rewrite an `ipfs://` URL to the public HTTPS gateway. Leaves other inputs + * unchanged. + */ +export function rewriteIpfsUrl(url: string): string { + return url.startsWith("ipfs://") ? url.replace("ipfs://", IPFS_GATEWAY) : url; +} + +/** + * Return `url` as a safe href (http:, https:, or a rewritten ipfs://) or null + * for anything else. Rejects javascript:, data:, vbscript:, file:, relative + * paths, and malformed input. + * + * Use for third-party URLs — NFT metadata, AI responses, IPFS documents — + * where the protocol is attacker-controllable. + */ +export function toSafeExternalHref(url: unknown): string | null { + if (typeof url !== "string" || url.length === 0) return null; + const resolved = rewriteIpfsUrl(url); + try { + const parsed = new URL(resolved); + return parsed.protocol === "http:" || parsed.protocol === "https:" ? resolved : null; + } catch { + return null; + } +} + +const SENSITIVE_PARAM_REGEX = /key|token|secret|auth|signature|apikey|api_key|access_token/i; + +/** + * Mask API keys and other high-entropy credentials embedded in a URL — both in + * query parameters (e.g. `?apiKey=...`) and in path segments (e.g. Alchemy's + * `/v2/` or Infura's `/v3/`). + * + * Returns the input unchanged if it does not parse as a URL. + */ +export function redactSensitiveUrl(rawUrl: string): string { + try { + const parsed = new URL(rawUrl); + + for (const [key] of parsed.searchParams.entries()) { + if (SENSITIVE_PARAM_REGEX.test(key)) { + parsed.searchParams.set(key, "***"); + } + } + + const segments = parsed.pathname.split("/").map((segment) => { + if (!segment) return segment; + const looksLikeToken = segment.length >= 24 && /[A-Za-z]/.test(segment) && /\d/.test(segment); + return looksLikeToken ? "***" : segment; + }); + parsed.pathname = segments.join("/"); + + return parsed.toString(); + } catch { + return rawUrl; + } +} + +const URL_IN_TEXT_REGEX = /https?:\/\/[^\s"'<>`]+/g; + +/** + * Redact every http(s) URL substring inside a free-form text value (log + * message, error message, stack frame). + */ +export function redactSensitiveUrlsInText(text: string): string { + return text.replace(URL_IN_TEXT_REGEX, (match) => redactSensitiveUrl(match)); +} diff --git a/vite.config.ts b/vite.config.ts index 7fc220a4..d0c6fdcc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -44,24 +44,15 @@ export default defineConfig({ }, }, }, + envPrefix: "OPENSCAN_", define: { - "process.env.REACT_APP_COMMIT_HASH": JSON.stringify( - process.env.REACT_APP_COMMIT_HASH || commitHash + "import.meta.env.OPENSCAN_COMMIT_HASH": JSON.stringify( + process.env.OPENSCAN_COMMIT_HASH || commitHash ), - "process.env.REACT_APP_GITHUB_REPO": JSON.stringify( - process.env.REACT_APP_GITHUB_REPO || - "https://github.com/openscan-explorer/explorer" + "import.meta.env.OPENSCAN_VERSION": JSON.stringify( + process.env.OPENSCAN_VERSION || appVersion ), - "process.env.REACT_APP_VERSION": JSON.stringify( - process.env.REACT_APP_VERSION || appVersion - ), - "process.env.REACT_APP_OPENSCAN_NETWORKS": JSON.stringify( - process.env.REACT_APP_OPENSCAN_NETWORKS || "" - ), - "process.env.REACT_APP_OPENSCAN_WORKER_URL": JSON.stringify( - process.env.REACT_APP_OPENSCAN_WORKER_URL || "https://openscan-worker-proxy.openscan.workers.dev" - ), - "import.meta.env.VITE_ENVIRONMENT": JSON.stringify( + "import.meta.env.OPENSCAN_ENVIRONMENT": JSON.stringify( process.env.NODE_ENV || "development" ), }, diff --git a/worker/.gitignore b/worker/.gitignore new file mode 100644 index 00000000..e985853e --- /dev/null +++ b/worker/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/worker/README.md b/worker/README.md new file mode 100644 index 00000000..16390abe --- /dev/null +++ b/worker/README.md @@ -0,0 +1,172 @@ +# OpenScan Worker Proxy + +Shared RPC proxy built with [Hono](https://hono.dev) that routes requests to blockchain RPC providers (Alchemy, Infura, dRPC, Ankr, OnFinality), the Etherscan API, Beacon API, and AI services (Groq). Includes CORS, rate limiting, and request validation. + +Deployed on multiple platforms for redundancy. If Cloudflare fails or hits rate limits the frontend automatically falls over to Vercel. A Deno Deploy entry point is also available if a third platform is needed in the future. + +## Architecture + +``` +worker/ + src/ + index.ts # Hono app — routes, middleware (shared by all platforms) + entry-deno.ts # Deno Deploy entry point + types.ts # Env interface, allowed methods/networks + middleware/ # CORS, rate limiting, request validation + routes/ # Route handlers (EVM, BTC, Beacon, AI, Etherscan) + api/ + index.ts # Vercel Edge Functions entry point + wrangler.toml # Cloudflare Workers config + deno.json # Deno Deploy config + vercel.json # Vercel config +``` + +All platforms share the same Hono app (`src/index.ts`). Each entry point bridges the platform's env var mechanism into Hono's `app.fetch(request, env)` — zero code duplication. + +## Routes + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/ai/analyze` | Groq AI analysis proxy | +| POST | `/etherscan/verify` | Etherscan V2 contract verification | +| GET | `/beacon/alchemy/:networkId/blob_sidecars/:slot` | Beacon API blob sidecars | +| POST | `/evm/alchemy/:networkId` | EVM RPC via Alchemy | +| POST | `/evm/infura/:networkId` | EVM RPC via Infura | +| POST | `/evm/drpc/:networkId` | EVM RPC via dRPC | +| POST | `/evm/ankr/:networkId` | EVM RPC via Ankr | +| POST | `/btc/alchemy` | Bitcoin RPC via Alchemy | +| POST | `/btc/drpc` | Bitcoin RPC via dRPC | +| POST | `/btc/ankr` | Bitcoin RPC via Ankr | +| POST | `/btc/onfinality/:networkId` | Bitcoin RPC via OnFinality | +| GET | `/health` | Health check | + +## Environment Variables + +All platforms require the same secrets: + +| Variable | Description | +|----------|-------------| +| `GROQ_API_KEY` | Groq AI API key for `/ai/analyze` | +| `ETHERSCAN_API_KEY` | Etherscan V2 API key for `/etherscan/verify` | +| `ALCHEMY_API_KEY` | Alchemy API key for `/evm/alchemy/*`, `/btc/alchemy`, `/beacon/*` | +| `INFURA_API_KEY` | Infura API key for `/evm/infura/*` | +| `DRPC_API_KEY` | dRPC API key for `/evm/drpc/*`, `/btc/drpc` | +| `ANKR_API_KEY` | Ankr API key for `/evm/ankr/*`, `/btc/ankr` | +| `ONFINALITY_BTC_API_KEY` | OnFinality API key for `/btc/onfinality/*` | +| `ALLOWED_ORIGINS` | Comma-separated allowed CORS origins. Entries prefixed with `re:` are anchored regex patterns matched against the full origin (e.g. `re:^https://(pr-\d+\|deploy-preview-\d+)--openscan\.netlify\.app$`). Other entries are exact matches. | +| `GROQ_MODEL` | AI model (default: `groq/compound`) | + +## Deployment + +### Prerequisites + +```bash +cd worker +npm install +``` + +### Cloudflare Workers (primary) + +**First-time setup:** + +```bash +# Login to Cloudflare +npx wrangler login + +# Add secrets (prompts for each value) +npx wrangler secret put GROQ_API_KEY +npx wrangler secret put ETHERSCAN_API_KEY +npx wrangler secret put ALCHEMY_API_KEY +npx wrangler secret put INFURA_API_KEY +npx wrangler secret put DRPC_API_KEY +npx wrangler secret put ANKR_API_KEY +npx wrangler secret put ONFINALITY_BTC_API_KEY +``` + +`ALLOWED_ORIGINS` and `GROQ_MODEL` are set in `wrangler.toml` as non-secret vars. + +**Deploy:** + +```bash +npx wrangler deploy +``` + +**Local dev:** + +```bash +npx wrangler dev +``` + +### Deno Deploy (optional, not currently active) + +Entry point and config are ready at `src/entry-deno.ts` and `deno.json`. To activate: + +1. Install `deployctl`: `deno install -Arf jsr:@deno/deployctl` +2. Create a project on [console.deno.com](https://console.deno.com) +3. Get an access token from your account settings +4. Add all env vars from the table above via the Deno console +5. Deploy: `deployctl deploy --project=openscan-worker-proxy src/entry-deno.ts --token=$DENO_DEPLOY_TOKEN` +6. Add the deployment URL to `WORKER_URLS` in `src/config/workerConfig.ts` + +**Local dev:** + +```bash +deno task dev +``` + +### Vercel Edge Functions (failover) + +**First-time setup:** + +```bash +# Install Vercel CLI +npm i -g vercel + +# Login to Vercel +vercel login + +# First deploy (creates the project) +vercel --yes + +# Add secrets (each command prompts for the value) +vercel env add GROQ_API_KEY production +vercel env add ETHERSCAN_API_KEY production +vercel env add ALCHEMY_API_KEY production +vercel env add INFURA_API_KEY production +vercel env add DRPC_API_KEY production +vercel env add ANKR_API_KEY production +vercel env add ONFINALITY_BTC_API_KEY production +vercel env add ALLOWED_ORIGINS production +``` + +**Deploy to production:** + +```bash +vercel --prod +``` + +**Verify:** + +```bash +curl https://openscan-worker-proxy.vercel.app/health +# {"status":"ok"} +``` + +## Frontend Failover + +The explorer frontend (`src/config/workerConfig.ts`) automatically tries each worker URL in order: + +1. **Cloudflare** — `https://openscan-worker-proxy.openscan.workers.dev` +2. **Vercel** — `https://openscan-worker-proxy.vercel.app` + +Falls through to the next platform on network errors, 429 (rate limited), 502, or 503 responses. + +## Development + +```bash +# Cloudflare (recommended for local dev) +npm run dev + +# Type check +npm run typecheck +``` diff --git a/worker/api/index.ts b/worker/api/index.ts new file mode 100644 index 00000000..f1e74bf4 --- /dev/null +++ b/worker/api/index.ts @@ -0,0 +1,34 @@ +/** + * Vercel Edge Functions entry point for the OpenScan worker proxy. + * + * Hono's `app.fetch(request, env)` accepts env bindings as the second argument, + * so all existing route handlers and middleware work unchanged — we just source + * the values from `process.env` instead of Cloudflare Worker bindings. + */ +import app from "../src/index"; +import type { Env } from "../src/types"; + +// Vercel Edge runtime provides process.env but the worker tsconfig +// only includes Cloudflare types — declare it locally to avoid adding +// @types/node as a dependency. +declare const process: { env: Record }; + +function getEnv(): Env { + return { + GROQ_API_KEY: process.env.GROQ_API_KEY ?? "", + ETHERSCAN_API_KEY: process.env.ETHERSCAN_API_KEY ?? "", + ALCHEMY_API_KEY: process.env.ALCHEMY_API_KEY ?? "", + INFURA_API_KEY: process.env.INFURA_API_KEY ?? "", + DRPC_API_KEY: process.env.DRPC_API_KEY ?? "", + ONFINALITY_BTC_API_KEY: process.env.ONFINALITY_BTC_API_KEY ?? "", + ANKR_API_KEY: process.env.ANKR_API_KEY ?? "", + ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS ?? "", + GROQ_MODEL: process.env.GROQ_MODEL ?? "groq/compound", + }; +} + +export const config = { runtime: "edge" }; + +export default function handler(request: Request) { + return app.fetch(request, getEnv()); +} diff --git a/worker/deno.json b/worker/deno.json new file mode 100644 index 00000000..1676924a --- /dev/null +++ b/worker/deno.json @@ -0,0 +1,13 @@ +{ + "unstable": ["sloppy-imports"], + "tasks": { + "dev": "deno run --allow-net --allow-env src/entry-deno.ts" + }, + "imports": { + "hono": "npm:hono@^4.7.0", + "hono/": "npm:hono@^4.7.0/" + }, + "compilerOptions": { + "strict": true + } +} diff --git a/worker/src/entry-deno.ts b/worker/src/entry-deno.ts new file mode 100644 index 00000000..2bc76a6f --- /dev/null +++ b/worker/src/entry-deno.ts @@ -0,0 +1,25 @@ +/** + * Deno Deploy entry point for the OpenScan worker proxy. + * + * Hono's `app.fetch(request, env)` accepts env bindings as the second argument, + * so all existing route handlers and middleware work unchanged — we just source + * the values from `Deno.env` instead of Cloudflare Worker bindings. + */ +import app from "./index"; +import type { Env } from "./types"; + +function getEnv(): Env { + return { + GROQ_API_KEY: Deno.env.get("GROQ_API_KEY") ?? "", + ETHERSCAN_API_KEY: Deno.env.get("ETHERSCAN_API_KEY") ?? "", + ALCHEMY_API_KEY: Deno.env.get("ALCHEMY_API_KEY") ?? "", + INFURA_API_KEY: Deno.env.get("INFURA_API_KEY") ?? "", + DRPC_API_KEY: Deno.env.get("DRPC_API_KEY") ?? "", + ONFINALITY_BTC_API_KEY: Deno.env.get("ONFINALITY_BTC_API_KEY") ?? "", + ANKR_API_KEY: Deno.env.get("ANKR_API_KEY") ?? "", + ALLOWED_ORIGINS: Deno.env.get("ALLOWED_ORIGINS") ?? "", + GROQ_MODEL: Deno.env.get("GROQ_MODEL") ?? "groq/compound", + }; +} + +Deno.serve((request) => app.fetch(request, getEnv())); diff --git a/worker/src/middleware/cors.ts b/worker/src/middleware/cors.ts index f5e25f6b..cd778946 100644 --- a/worker/src/middleware/cors.ts +++ b/worker/src/middleware/cors.ts @@ -3,20 +3,34 @@ import type { Env } from "../types"; /** * Check if an origin is allowed. - * Entries starting with "*" are suffix patterns on the hostname — e.g. - * "*--openscan.netlify.app" matches "https://pr-306--openscan.netlify.app". + * Entries prefixed with "re:" are anchored regex patterns matched against the + * full origin — e.g. "re:^https://(pr-\\d+|deploy-preview-\\d+)--openscan\\.netlify\\.app$". * All other entries are exact origin matches. + * + * Suffix globs like "*--openscan.netlify.app" are intentionally NOT supported: + * Netlify site names are globally unique but user-chosen, so any tenant can + * register a site name ending in "--openscan" and satisfy a bare suffix match. + * Always anchor preview patterns to the expected prefix form. */ +const regexCache = new Map(); + +function compilePattern(entry: string): RegExp | null { + if (regexCache.has(entry)) return regexCache.get(entry) ?? null; + let compiled: RegExp | null = null; + try { + compiled = new RegExp(entry.slice(3)); + } catch { + compiled = null; + } + regexCache.set(entry, compiled); + return compiled; +} + function isOriginAllowed(origin: string, allowed: string[]): boolean { for (const entry of allowed) { - if (entry.startsWith("*")) { - const suffix = entry.slice(1); // e.g. "--openscan.netlify.app" - try { - const { hostname } = new URL(origin); - if (hostname.endsWith(suffix)) { - return true; - } - } catch {} + if (entry.startsWith("re:")) { + const re = compilePattern(entry); + if (re?.test(origin)) return true; } else if (origin === entry) { return true; } diff --git a/worker/vercel.json b/worker/vercel.json new file mode 100644 index 00000000..18da4e3c --- /dev/null +++ b/worker/vercel.json @@ -0,0 +1,3 @@ +{ + "rewrites": [{ "source": "/(.*)", "destination": "/api" }] +} diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 5bcdb8da..c65e5163 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -3,7 +3,7 @@ main = "src/index.ts" compatibility_date = "2024-12-01" [vars] -ALLOWED_ORIGINS = "https://openscan.eth.link,https://openscan.eth.limo,https://openscan-explorer.github.io,https://openscan.netlify.app,*--openscan.netlify.app" +ALLOWED_ORIGINS = "https://openscan.eth.link,https://openscan.eth.limo,https://openscan-explorer.github.io,https://openscan.netlify.app,re:^https://(pr-\\d+|deploy-preview-\\d+)--openscan\\.netlify\\.app$" GROQ_MODEL = "groq/compound" # Secrets — set via `wrangler secret put `