diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..a6d73960 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.next +node_modules +npm-debug.log* +.env*.local +/tmp +*.db diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml index 3af7e6c7..4b8f5afa 100644 --- a/.github/workflows/ui.yml +++ b/.github/workflows/ui.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version: 1.2.20 - run: cp .env.example .env - run: bun install --frozen-lockfile - run: bun run lint @@ -37,7 +37,7 @@ jobs: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version: 1.2.20 - run: cp .env.example .env - run: bun install --frozen-lockfile - run: bun run tsc --noEmit @@ -54,7 +54,7 @@ jobs: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: latest + bun-version: 1.2.20 - run: cp .env.example .env - run: bun install --frozen-lockfile - run: bun run build diff --git a/.gitignore b/.gitignore index e720bb65..75395bdb 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# vibenet host-specific secrets (FAUCET_PRIVATE_KEY etc.) +/docker/vibenet/env diff --git a/Dockerfile b/Dockerfile index 9e0457c2..98b95469 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,55 @@ -FROM oven/bun:1 AS deps +FROM oven/bun:1.2.20 AS deps WORKDIR /app -COPY ui/package.json ui/bun.lock ./ +# better-sqlite3 requires native compilation +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ \ + && rm -rf /var/lib/apt/lists/* +COPY package.json bun.lock ./ RUN bun install --frozen-lockfile -FROM oven/bun:1 AS builder +FROM oven/bun:1.2.20 AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules -COPY ./ui . +COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN bun run build -FROM node:20-alpine AS runner +FROM node:20-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 +# Match libc with the deps stage (oven/bun:1.2.20 is debian-based) so the +# native better-sqlite3 binary built in deps is loadable here. We still +# need to rebuild it against Node's V8 ABI below since bun's V8 ABI +# differs from Node 20's. +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs -RUN mkdir .next -RUN chown nextjs:nodejs .next +RUN mkdir -p .next /data +RUN chown nextjs:nodejs .next /data +# Full node_modules (not standalone) so indexer.mjs can resolve viem + better-sqlite3 +COPY --from=deps /app/node_modules ./node_modules +COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next COPY --from=builder /app/public ./public -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/scripts ./scripts +COPY --from=builder /app/docker/migrations ./docker/migrations + +# Rebuild better-sqlite3 against Node's V8 ABI (bun's V8 has a different +# NODE_MODULE_VERSION so the binary from deps won't load under `node`). +RUN cd node_modules/better-sqlite3 && npm rebuild better-sqlite3 \ + && cd /app + +RUN chown -R nextjs:nodejs node_modules && chmod +x scripts/start.sh USER nextjs @@ -35,4 +58,4 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -CMD ["node", "server.js"] +CMD ["sh", "scripts/start.sh"] diff --git a/Justfile b/Justfile new file mode 100644 index 00000000..955d95ac --- /dev/null +++ b/Justfile @@ -0,0 +1,184 @@ +# base/ui — Next.js app + vibenet devnet integration. +# +# All `vibe*` recipes compose this repo's docker/vibenet/ overlay on top +# of the base/base devnet (sibling repo at ../base). They use +# `--project-directory $(pwd)` so paths inside docker/vibenet/compose.yml +# resolve from this repo's root. +# +# Usage: +# just vibe — bring up vibenet against base/base main +# just vibe — checkout in ../base first +# just vibe-down — tear down, wipe volumes + chain state +# just vibe-ui — rebuild just the Next.js container +# (preserves chain state + explorer index) +# just vibe-logs [services] — tail logs +# just vibe-ps [services] — container status +# +# First-time: clones ../base/ if missing. Caddy TLS overlay (production) +# auto-enables when /etc/vibenet/tls/origin.crt exists. + +_ui_root := justfile_directory() +# base/base sibling. Computed at justfile parse time so all recipes share it. +_base_repo := justfile_directory() / '..' / 'base' + +# These two are referenced as ${VIBENET_UI_ROOT} / ${BASE_REPO_ROOT} inside +# docker/vibenet/compose.yml and caddy.yml so the bind-mount + build-context +# paths are absolute regardless of which directory `docker compose` is +# invoked from. +export VIBENET_UI_ROOT := _ui_root +export BASE_REPO_ROOT := _base_repo + +# Helper: compose args common to every vibe* recipe. +# We cd into base/base/etc/docker before invoking so that env_file directives +# inside base/base's docker-compose.yml (which point at the bare filename +# "devnet-env") resolve correctly. Paths in our docker/vibenet/compose.yml +# are still resolved relative to that file's own location (base/ui/docker/ +# vibenet/), so they work regardless of the cwd. +_compose := '\ + --env-file devnet-env \ + --env-file ' + _ui_root + '/docker/vibenet/env \ + -f docker-compose.yml \ + -f ' + _ui_root + '/docker/vibenet/compose.yml' + +# Default: list available recipes. +default: + @just --list + +# Bring up the full vibenet stack against base/base @ (default: main). +# Clones ../base as a sibling if missing. +vibe branch="main": + #!/usr/bin/env bash + set -euo pipefail + + if [ ! -d {{ _base_repo }} ]; then + echo "[vibe] base/base not found at {{ _base_repo }} — cloning..." + git clone git@github.com:base/base.git {{ _base_repo }} + fi + + echo "[vibe] base/base @ {{ branch }}" + git -C {{ _base_repo }} fetch --quiet origin {{ branch }} 2>/dev/null \ + || git -C {{ _base_repo }} fetch --quiet origin + git -C {{ _base_repo }} checkout --quiet {{ branch }} + git -C {{ _base_repo }} pull --ff-only --quiet 2>/dev/null || true + + if [ ! -f docker/vibenet/env ]; then + echo "ERROR: docker/vibenet/env missing. Copy from docker/vibenet/env.example." >&2 + exit 1 + fi + + # The vibenet UI footer surfaces the BASE/BASE branch/commit (that's + # what determines the devnet behavior — base/ui is just the surface). + export VIBENET_BRANCH="$(git -C {{ _base_repo }} rev-parse --abbrev-ref HEAD)" + export VIBENET_COMMIT="$(git -C {{ _base_repo }} rev-parse --short HEAD)" + + caddy_overlay= + if [ -f /etc/vibenet/tls/origin.crt ]; then + caddy_overlay="-f {{ _ui_root }}/docker/vibenet/caddy.yml" + echo "[vibe] Caddy TLS overlay enabled (/etc/vibenet/tls/origin.crt found)" + fi + + # Tear down anything from a previous run so we always start clean. + just vibe-down >/dev/null 2>&1 || true + + # Pre-build every image we need, THEN `up --no-build`. We can't use + # `up --build` because setup-l1 and setup-l2 both have build configs + # that emit the same `devnet-setup:local` image tag — `up --build` + # runs them in parallel and the second export hits + # "image devnet-setup:local: already exists" + # which kills the whole compose-up. base/base's own Justfile dodges + # this by pre-building everything and running `up --no-build`; we + # mirror that here. + echo "[vibe] pre-building images..." + + # 1) base devnet rust services (client / builder / consensus / batcher) + case "$(uname -m)" in + x86_64) platform_pair="linux-amd64" ;; + arm64|aarch64) platform_pair="linux-arm64" ;; + *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + ( cd {{ _base_repo }} \ + && PROFILE=dev PLATFORM_PAIR="$platform_pair" \ + docker buildx bake -f etc/docker/docker-bake.hcl devnet --load ) + + # 2) devnet-setup (the one that was racing — used by setup-l1 + setup-l2) + docker build -t devnet-setup:local \ + -f {{ _base_repo }}/etc/docker/Dockerfile.devnet \ + {{ _base_repo }} + + # 3) proxyd (Go service from base/base's etc/docker context) + docker build -t proxyd:local \ + -f {{ _base_repo }}/etc/docker/Dockerfile.proxyd \ + {{ _base_repo }}/etc/docker + + # 4) vibenet-setup (foundry deployer, lives in this repo) + docker build -t vibenet-setup:local {{ _ui_root }}/docker/vibenet/setup + + # 5) the Next.js app — skip if a pre-built image is pinned + if [ -z "${VIBENET_UI_IMAGE:-}" ]; then + docker build -t vibenet-ui:local {{ _ui_root }} + fi + + echo "[vibe] starting stack..." + cd {{ _base_repo }}/etc/docker && docker compose {{ _compose }} $caddy_overlay up -d --no-build + +# Stop vibenet and wipe all data, including named volumes (vibescan SQLite, +# rendered config, shared contracts.json). The chain state in ../base/.devnet +# also gets wiped so the next `just vibe` starts from a fresh genesis. +vibe-down: + #!/usr/bin/env bash + set -euo pipefail + caddy_overlay= + if [ -f /etc/vibenet/tls/origin.crt ]; then + caddy_overlay="-f {{ _ui_root }}/docker/vibenet/caddy.yml" + fi + cd {{ _base_repo }}/etc/docker && docker compose {{ _compose }} $caddy_overlay \ + down --remove-orphans -v 2>/dev/null || true + if [ -d {{ _base_repo }}/.devnet ]; then + docker run --rm -v "$(cd {{ _base_repo }} && pwd)/.devnet:/devnet" \ + alpine sh -c 'rm -rf /devnet/*' 2>/dev/null || true + fi + +# Rebuild just the Next.js UI container without resetting the chain. Block +# history, explorer index, and deployed contracts all survive. +vibe-ui: + #!/usr/bin/env bash + set -euo pipefail + if [ ! -f docker/vibenet/env ]; then + echo "ERROR: docker/vibenet/env missing." >&2 + exit 1 + fi + export VIBENET_BRANCH="$(git -C {{ _base_repo }} rev-parse --abbrev-ref HEAD)" + export VIBENET_COMMIT="$(git -C {{ _base_repo }} rev-parse --short HEAD)" + caddy_overlay= + if [ -f /etc/vibenet/tls/origin.crt ]; then + caddy_overlay="-f {{ _ui_root }}/docker/vibenet/caddy.yml" + fi + cd {{ _base_repo }}/etc/docker && docker compose {{ _compose }} $caddy_overlay \ + up -d --no-deps --build --force-recreate next-app + cd {{ _base_repo }}/etc/docker && docker compose {{ _compose }} $caddy_overlay \ + up -d --no-deps --force-recreate vibenet-config-renderer + +# Tail logs from vibenet containers (optionally filter by service names). +vibe-logs *services: + #!/usr/bin/env bash + set -euo pipefail + caddy_overlay= + if [ -f /etc/vibenet/tls/origin.crt ]; then + caddy_overlay="-f {{ _ui_root }}/docker/vibenet/caddy.yml" + fi + cd {{ _base_repo }}/etc/docker && docker compose {{ _compose }} $caddy_overlay logs -f {{ services }} + +# Container status (optionally filter by service names). +vibe-ps *services: + #!/usr/bin/env bash + set -euo pipefail + caddy_overlay= + if [ -f /etc/vibenet/tls/origin.crt ]; then + caddy_overlay="-f {{ _ui_root }}/docker/vibenet/caddy.yml" + fi + cd {{ _base_repo }}/etc/docker && docker compose {{ _compose }} $caddy_overlay ps {{ services }} + +# Run the Next.js app standalone against an already-running devnet (no Docker +# rebuild). Useful for hot-reload UI iteration. +dev: + npm run dev diff --git a/README.md b/README.md new file mode 100644 index 00000000..7502fe75 --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# base/ui + +Next.js app + the entire vibenet devnet integration. A single instance +serves three subdomains via host-based routing: + +| Subdomain | Pages served | +| -------------------------- | ------------------------------------------------ | +| `vibes.base.org` | Landing page (chain ID, contracts, "Add to wallet") | +| `faucet.vibes.base.org` | Faucet UI + drip API (ETH / USDV / NFV) | +| `explorer.vibes.base.org` | Block explorer (vibescan) | + +Subdomain routing happens in [`src/proxy.ts`](./src/proxy.ts), which reads +the `Host` header and rewrites internally to the matching `/faucet` or +`/explorer/*` path. The TIPS tool (separate product) is served at `/tips`. + +This repo owns the full vibenet product surface: UI, Caddy ingress, +demo contracts (USDV/NFV), the faucet, the block explorer, and the +docker compose orchestration. The only thing it depends on is the +sibling [base/base](https://github.com/base/base) repo for the core +devnet (L1 anvil, L2 base-client, batcher, builder, consensus). No +modifications to base/base are needed. + +--- + +## Running locally + +```bash +git clone git@github.com:base/ui.git +cd ui +cp docker/vibenet/env.example docker/vibenet/env + +just vibe # uses base/base @ main +just vibe my-base-branch # checkout that branch in ../base +``` + +`just vibe` clones the sibling `../base` repo if it doesn't exist and +checks out the requested branch before bringing up the stack. + +Once it's up: + +| URL | Service | +| ---------------------------------- | ------------------------------------------ | +| `http://localhost:18080/` | Landing page | +| `http://localhost:18080/faucet` | Faucet UI + drip API | +| `http://localhost:18080/explorer` | Block explorer | +| `http://localhost:18080/tips` | TIPS metering tool | +| `http://localhost:18080/api/*` | Faucet, explorer, config, contracts APIs | +| `ws://localhost:18081/` | WebSocket RPC (base-client direct) | +| `http://localhost:18082/` | HTTP RPC (proxyd) | + +Common recipes: + +```bash +just vibe-ui # rebuild only the Next.js container (chain state survives) +just vibe-down # tear everything down and wipe volumes + chain state +just vibe-logs [services] +just vibe-ps [services] +``` + +To skip cloning `base/base` source and use a pre-built UI image: + +```bash +VIBENET_UI_IMAGE=ghcr.io/base/ui:main just vibe +``` + +For the full vibenet ops + customization guide (faucet config, +contracts list, content edits, production TLS), see +[`docker/vibenet/README.md`](./docker/vibenet/README.md). + +--- + +## UI-only iteration (no Docker) + +When you're working on the React side and a devnet is already running +(either via `just vibe` above or some other source of an L2 RPC), you +can skip the container rebuild loop by running Next.js on your host: + +```bash +# Point at the running devnet's RPC +cat > .env.local <=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="], "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], @@ -407,44 +420,84 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], "next": ["next@16.0.7", "", { "dependencies": { "@next/env": "16.0.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.7", "@next/swc-darwin-x64": "16.0.7", "@next/swc-linux-arm64-gnu": "16.0.7", "@next/swc-linux-arm64-musl": "16.0.7", "@next/swc-linux-x64-gnu": "16.0.7", "@next/swc-linux-x64-musl": "16.0.7", "@next/swc-win32-arm64-msvc": "16.0.7", "@next/swc-win32-x64-msvc": "16.0.7", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A=="], + "node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "ox": ["ox@0.9.6", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.0.9", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-8SuCbHPvv2eZLYXrNmC0EC12rdzXQLdhnOMlHDW2wiCPLxBrOOJwX5L5E61by+UjTPOryqQiRSnjIKCI+GykKg=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "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=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], "react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="], "react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="], + "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=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "strnum": ["strnum@2.2.2", "", {}, "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], - "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "viem": ["viem@2.40.3", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.1.0", "isows": "1.0.7", "ox": "0.9.6", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-feYfEpbgjRkZYQpwcgxqkWzjxHI5LSDAjcGetHHwDRuX9BRQHUdV8ohrCosCYpdEhus/RknD3/bOd4qLYVPPuA=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], @@ -453,30 +506,20 @@ "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + "@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - - "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - - "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - - "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], } } diff --git a/docker/migrations/0001_init.sql b/docker/migrations/0001_init.sql new file mode 100644 index 00000000..9d1a5a56 --- /dev/null +++ b/docker/migrations/0001_init.sql @@ -0,0 +1,65 @@ +-- vibescan indexer schema (ported from crates/vibenet/explorer). +-- Everything derivable from RPC stays in the node; the only persisted state +-- is the cursor + the address-activity join index that plain JSON-RPC cannot +-- serve efficiently. + +CREATE TABLE IF NOT EXISTS cursor ( + id INTEGER PRIMARY KEY CHECK (id = 0), + last_indexed_block INTEGER NOT NULL, + last_indexed_hash TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS blocks ( + number INTEGER PRIMARY KEY, + hash TEXT NOT NULL UNIQUE, + timestamp INTEGER NOT NULL, + miner TEXT NOT NULL, + tx_count INTEGER NOT NULL, + gas_used INTEGER NOT NULL, + gas_limit INTEGER NOT NULL, + base_fee TEXT +); + +CREATE TABLE IF NOT EXISTS txs ( + hash TEXT PRIMARY KEY, + block_num INTEGER NOT NULL, + tx_index INTEGER NOT NULL, + from_addr TEXT NOT NULL, + to_addr TEXT, + value TEXT NOT NULL, + status INTEGER NOT NULL, + created TEXT +); +CREATE INDEX IF NOT EXISTS idx_txs_block_num ON txs (block_num DESC, tx_index DESC); + +-- address -> activity feed. role values: +-- 0 = sender, 1 = recipient, 2 = creator +-- 3 = erc20/721 log from, 4 = erc20/721 log to +CREATE TABLE IF NOT EXISTS address_activity ( + address TEXT NOT NULL, + block_num INTEGER NOT NULL, + tx_index INTEGER NOT NULL, + log_index INTEGER NOT NULL DEFAULT -1, + tx_hash TEXT NOT NULL, + role INTEGER NOT NULL, + token TEXT, + PRIMARY KEY (address, block_num, tx_index, log_index, role) +); +CREATE INDEX IF NOT EXISTS idx_activity_addr_block + ON address_activity (address, block_num DESC, tx_index DESC, log_index DESC); + +CREATE TABLE IF NOT EXISTS addresses ( + address TEXT PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS explorer_stats ( + id INTEGER PRIMARY KEY CHECK (id = 0), + blocks INTEGER NOT NULL, + txs INTEGER NOT NULL, + addresses INTEGER NOT NULL +); + +INSERT INTO explorer_stats (id, blocks, txs, addresses) +VALUES (0, 0, 0, 0) +ON CONFLICT(id) DO NOTHING; diff --git a/docker/vibenet-env.example b/docker/vibenet-env.example new file mode 100644 index 00000000..e5ca3149 --- /dev/null +++ b/docker/vibenet-env.example @@ -0,0 +1,12 @@ +# base/ui vibenet env — UI-specific config only. +# Infra secrets (chain IDs, faucet key, etc.) live in base/base etc/vibenet/vibenet-env. +# This file is passed as a second --env-file to docker compose and merges with it. + +# === Explorer === +VIBESCAN_DB_PATH=/data/vibescan.db +VIBESCAN_START_BLOCK=0 +VIBESCAN_BACKFILL_CONCURRENCY=16 + +# === Optional: surfaced in explorer footer === +# VIBESCAN_PUBLIC_RPC_URL=https://rpc.vibes.base.org +# VIBESCAN_PUBLIC_FAUCET_URL=https://faucet.vibes.base.org diff --git a/docker/vibenet/Caddyfile b/docker/vibenet/Caddyfile new file mode 100644 index 00000000..fad7638c --- /dev/null +++ b/docker/vibenet/Caddyfile @@ -0,0 +1,36 @@ +# Caddy TLS termination + subdomain routing for vibenet. +# Uses Cloudflare Origin CA certificate (not ACME) so Caddy never +# needs to reach Let's Encrypt — works even if the host isn't directly +# reachable from the internet (Cloudflare proxies traffic orange-cloud). + +(tls_cf) { + tls /etc/caddy/tls/origin.crt /etc/caddy/tls/origin.key +} + +# Landing page, faucet UI/API, and block explorer. +# Next.js proxy.ts reads the Host header and routes internally: +# vibes.base.org → serves / (landing) +# faucet.vibes.base.org → rewrites to /faucet +# explorer.vibes.base.org → rewrites to /explorer/* +vibes.base.org faucet.vibes.base.org explorer.vibes.base.org { + import tls_cf + reverse_proxy next-app:3000 +} + +# JSON-RPC (HTTP + WebSocket). +# proxyd handles HTTP; base-client handles WebSocket upgrades directly. +rpc.vibes.base.org { + import tls_cf + + # WebSocket RPC. base-client's WS port is fixed at 8546 in + # etc/docker/devnet-env (L2_CLIENT_WS_PORT) — hardcoded here because + # Caddy env interpolation needs the var passed into the caddy container, + # and this value never changes per deployment. + handle /ws { + reverse_proxy base-client:8546 + } + + handle { + reverse_proxy proxyd:8080 + } +} diff --git a/docker/vibenet/README.md b/docker/vibenet/README.md new file mode 100644 index 00000000..e2c50eb9 --- /dev/null +++ b/docker/vibenet/README.md @@ -0,0 +1,156 @@ +# vibenet + +A public, ephemeral devnet for showing off in-flight Base features. + +This directory holds everything `just vibe` needs to stand up the full +stack: + +- The Next.js app (defined in the repo root) — landing page, faucet UI, + block explorer +- `vibenet-setup` — one-shot Foundry container that sweeps anvil EOA + balances on L1 + L2 into the faucet, then deploys demo contracts + (USDV public-mint ERC-20, NFV public-mint ERC-721) +- `vibenet-config-renderer` — yq one-shot that renders `content.yaml` + into `config.json` for the UI +- `proxyd` — per-method JSON-RPC rate limits in front of the L2 +- `caddy` (production only) — TLS termination + subdomain routing + +The base devnet itself (L1 anvil, L2 base-client, batcher, builder, +consensus) comes from `../base` (the sibling base/base repo). + +## Public hostnames + +| Hostname | What it serves | +| -------------------------- | --------------------------- | +| `vibes.base.org` | Landing page | +| `rpc.vibes.base.org` | JSON-RPC + WebSocket | +| `explorer.vibes.base.org` | Block explorer | +| `faucet.vibes.base.org` | Faucet UI + drip API | + +The Next.js app reads the `Host` header and rewrites internally to the +right page — single container serves all three UI subdomains. + +## Running locally + +```bash +cd base/ui # this repo +cp docker/vibenet/env.example docker/vibenet/env +just vibe # uses base/base @ main +# or +just vibe my-feature-branch # checkout that branch in ../base first +``` + +If `../base` doesn't exist, `just vibe` clones it. + +Once up: + +| URL | Service | +| ---------------------------------- | --------------------------------------------- | +| `http://localhost:18080/` | Landing page | +| `http://localhost:18080/faucet` | Faucet UI + drip API | +| `http://localhost:18080/explorer` | Block explorer | +| `ws://localhost:18081/` | WebSocket RPC (base-client direct) | +| `http://localhost:18082/` | HTTP RPC (proxyd) | + +Override the bindings with `VIBENET_HOST_PORT` / `VIBENET_WS_HOST_PORT` / +`VIBENET_RPC_HOST_PORT` in `docker/vibenet/env`. + +To rebuild only the Next.js container without resetting the chain +(block history, explorer index, and deployed contracts all survive): + +```bash +just vibe-ui +``` + +To skip cloning `base/base` and pull a pre-built UI image instead: + +```bash +VIBENET_UI_IMAGE=ghcr.io/base/ui:main just vibe +``` + +## Faucet integration + +`FAUCET_ADDR` defaults to the first standard anvil account +(`0xf39Fd6e51...`), which the L1 anvil genesis already pre-funds with +10M ETH. On L2, the `deploy-contracts.sh` script sweeps the other +standard anvil EOAs' balances (~90K ETH total) into `FAUCET_ADDR` so +both chains have plenty of liquidity without any base/base +modifications. + +To use a different faucet address (e.g. a freshly generated key for +production), set `FAUCET_ADDR` + `FAUCET_PRIVATE_KEY` in +`docker/vibenet/env`. The L1 + L2 sweep will route ~90K ETH on each +chain to that address. + +Drip endpoints (all served by next-app): + +- `POST /api/vibenet/faucet/drip` — ETH (0.1 ETH by default) +- `POST /api/vibenet/faucet/drip-usdv` — calls `USDV.mint(addr, 1000e6)` +- `POST /api/vibenet/faucet/drip-nfv` — calls `NFV.mint(addr)` + +## Customizing what appears on the landing page + +Edit [`content.yaml`](./content.yaml). The `vibenet-config-renderer` +container reads it at startup, converts to JSON, and writes it to a +shared volume that the Next.js app serves at `/api/vibenet/config`. +After editing, run `just vibe-ui` to re-render. + +Fields: + +- `title`, `subtitle` — page header +- `features` — array of `{title, description, link?}` cards +- `branch`, `commit` — auto-overwritten by `just vibe` from `git rev-parse` + +## Customizing deployed contracts + +Edit [`setup/contracts.yaml`](./setup/contracts.yaml) and drop any new +Solidity sources into [`setup/contracts/src/`](./setup/contracts/src/). +Each entry is: + +```yaml +- name: myDemo # key in contracts.json + artifact: src/MyDemo.sol:MyDemo # forge target + args: ["0x1234...", "{{ usdv }}"] # optional; {{ }} resolves from + # previously-deployed entries +``` + +Deployed addresses are published at `/api/vibenet/contracts` and +surfaced on the landing page automatically. + +## Production deployment + +Cloudflare proxies public traffic (orange-cloud) to Caddy's `:443` TLS +listener. The Caddy overlay (`caddy.yml`) auto-activates when a +Cloudflare Origin CA certificate is installed at +`/etc/vibenet/tls/origin.{crt,key}` on the host. + +Generate the cert pair from the Cloudflare dashboard: +SSL/TLS → Origin Server → Create Certificate, covering +`vibes.base.org` and `*.vibes.base.org`. Then on the host: + +```bash +sudo install -d -m 0755 /etc/vibenet/tls +sudo tee /etc/vibenet/tls/origin.crt > /dev/null # paste cert +sudo tee /etc/vibenet/tls/origin.key > /dev/null # paste key +sudo chmod 0644 /etc/vibenet/tls/origin.crt +sudo chmod 0600 /etc/vibenet/tls/origin.key +``` + +## File map + +``` +docker/vibenet/ + README.md (this file) + env.example host env template — copy to ./env + compose.yml overlay on base/base's docker-compose.yml + caddy.yml prod-only: Caddy TLS gateway + Caddyfile prod-only: TLS + subdomain routing + content.yaml editable UI content (features list) + proxyd-ratelimit.toml per-method JSON-RPC rate limits + setup/ + Dockerfile build image for foundry-based deployer + contracts.yaml list of contracts to deploy + contracts/ foundry project: src/*.sol + deploy-contracts.sh entrypoint: sweeps L1+L2 anvil → faucet, + then deploys contracts.yaml entries +``` diff --git a/docker/vibenet/caddy.yml b/docker/vibenet/caddy.yml new file mode 100644 index 00000000..0cddb7fe --- /dev/null +++ b/docker/vibenet/caddy.yml @@ -0,0 +1,41 @@ +# Production TLS + subdomain routing overlay for vibenet. +# +# Activated automatically by `just vibe` when Cloudflare Origin CA +# certificates are present at /etc/vibenet/tls/origin.{crt,key}. +# +# Replaces the original docker-compose.public.yml nginx TLS overlay. +# Caddy is strictly simpler: subdomain routing and TLS termination in +# a single ~10-line Caddyfile with no template escaping required. +# +# Subdomain routing: +# vibes.base.org → next-app:3000 (landing, config, contracts) +# faucet.vibes.base.org → next-app:3000 (Next.js proxy.ts rewrites to /faucet) +# explorer.vibes.base.org → next-app:3000 (Next.js proxy.ts rewrites to /explorer) +# rpc.vibes.base.org → proxyd:8080 (HTTP JSON-RPC) +# rpc.vibes.base.org/ws → base-client:WS (WebSocket RPC upgrade) + +services: + caddy: + image: caddy:2.9-alpine + container_name: vibenet-caddy + ports: + - "${VIBENET_PUBLIC_BIND_ADDR:-0.0.0.0}:443:443" + - "${VIBENET_PUBLIC_BIND_ADDR:-0.0.0.0}:80:80" + volumes: + - ${VIBENET_UI_ROOT}/docker/vibenet/Caddyfile:/etc/caddy/Caddyfile:ro + - /etc/vibenet/tls/origin.crt:/etc/caddy/tls/origin.crt:ro + - /etc/vibenet/tls/origin.key:/etc/caddy/tls/origin.key:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + next-app: + condition: service_started + proxyd: + condition: service_healthy + mem_limit: 256m + memswap_limit: 256m + restart: unless-stopped + +volumes: + caddy-data: + caddy-config: diff --git a/docker/vibenet/compose.yml b/docker/vibenet/compose.yml new file mode 100644 index 00000000..0d9cb0ed --- /dev/null +++ b/docker/vibenet/compose.yml @@ -0,0 +1,172 @@ +# Vibenet overlay on top of base/base's etc/docker/docker-compose.yml. +# +# Run via the `just vibe` recipe in base/ui — it passes both files plus +# --project-directory set to the base/ui repo root, so all relative paths +# in THIS file resolve from there. +# +# Layers on: +# - next-app: Next.js app serving landing, faucet UI+API, and block +# explorer. Built from this repo (base/ui). +# - vibenet-setup: one-shot. Sweeps anvil balances -> faucet on L1 and +# L2, then deploys demo contracts and writes /shared/contracts.json. +# - vibenet-config-renderer: renders docker/vibenet/content.yaml -> +# config.json for the UI. +# - proxyd: per-method JSON-RPC rate limits. Built from base/base's +# Dockerfile.proxyd via the sibling repo. +# - base-client: WS port exposed directly (no proxy hop). +# +# Public TLS + subdomain routing can be layered with docker/vibenet/caddy.yml. + +services: + # --- Defense-in-depth memory caps on long-running core services ---------- + base-client: + mem_limit: 96g + memswap_limit: 96g + # Expose WebSocket RPC directly — no proxy hop needed. + ports: + - "127.0.0.1:${VIBENET_WS_HOST_PORT:-18081}:${L2_CLIENT_WS_PORT}" + base-client-cl: + mem_limit: 16g + memswap_limit: 16g + base-builder: + mem_limit: 24g + memswap_limit: 24g + base-builder-cl: + mem_limit: 16g + memswap_limit: 16g + base-batcher: + mem_limit: 8g + memswap_limit: 8g + + # --- Next.js app: landing page, faucet, block explorer ------------------- + # Built from this repo's Dockerfile. Set VIBENET_UI_IMAGE to skip the + # build and pull a pre-built image instead: + # VIBENET_UI_IMAGE=ghcr.io/base/ui:main just vibe + next-app: + image: ${VIBENET_UI_IMAGE:-vibenet-ui:local} + build: + context: ${VIBENET_UI_ROOT} + dockerfile: Dockerfile + container_name: vibenet-next-app + depends_on: + base-client: + condition: service_healthy + vibenet-config-renderer: + condition: service_completed_successfully + environment: + # Explorer indexer (background process inside the container) + - VIBESCAN_RPC_HTTP_URL=http://base-client:${L2_CLIENT_HTTP_PORT} + - VIBESCAN_RPC_WS_URL=ws://base-client:${L2_CLIENT_WS_PORT} + - VIBESCAN_CHAIN_ID=${L2_CHAIN_ID} + - VIBESCAN_DB_PATH=/data/vibescan.db + - VIBESCAN_START_BLOCK=${VIBESCAN_START_BLOCK:-0} + - VIBESCAN_BACKFILL_CONCURRENCY=${VIBESCAN_BACKFILL_CONCURRENCY:-16} + # Faucet signer + - VIBENET_FAUCET_RPC_URL=http://base-client:${L2_CLIENT_HTTP_PORT} + - VIBENET_FAUCET_CHAIN_ID=${L2_CHAIN_ID} + - VIBENET_FAUCET_PRIVATE_KEY=${FAUCET_PRIVATE_KEY} + - VIBENET_FAUCET_DRIP_WEI=${VIBENET_FAUCET_DRIP_WEI:-100000000000000000} + - VIBENET_FAUCET_IP_COOLDOWN_SECS=${VIBENET_FAUCET_IP_COOLDOWN_SECS:-3600} + - VIBENET_FAUCET_ADDR_COOLDOWN_SECS=${VIBENET_FAUCET_ADDR_COOLDOWN_SECS:-3600} + - VIBENET_FAUCET_USDV_DRIP_UNITS=${VIBENET_FAUCET_USDV_DRIP_UNITS:-1000000000} + # Rendered config + contract addresses (written by renderer/setup + # containers). VIBENET_CONTRACTS_PATH is read by both the faucet + # (to resolve USDV/NFV addresses before calling mint) and the + # /api/vibenet/contracts API route (to render the landing page list). + - VIBENET_CONFIG_PATH=/config/config.json + - VIBENET_CONTRACTS_PATH=/shared/contracts.json + # Branch/commit info surfaced in the landing page footer + - VIBENET_BRANCH=${VIBENET_BRANCH:-unknown} + - VIBENET_COMMIT=${VIBENET_COMMIT:-unknown} + ports: + # UI + faucet + explorer all on a single port for local dev. + # In production the caddy overlay routes subdomains to this port. + - "127.0.0.1:${VIBENET_HOST_PORT:-18080}:3000" + volumes: + - vibenet-shared:/shared:ro + - vibenet-config:/config:ro + - vibescan-data:/data + mem_limit: 1g + memswap_limit: 1g + restart: unless-stopped + + # --- One-shot contract deployer ------------------------------------------ + # Sweeps the standard anvil EOA balances on BOTH L1 and L2 to FAUCET_ADDR, + # then deploys the demo contracts. This avoids any need to modify the + # base/base devnet (no L1 genesis injection required). + vibenet-setup: + image: vibenet-setup:local + build: + context: ${VIBENET_UI_ROOT}/docker/vibenet/setup + dockerfile: Dockerfile + container_name: vibenet-setup + depends_on: + base-client: + condition: service_healthy + environment: + - L1_RPC_URL=http://l1-el:${L1_HTTP_PORT} + - L2_RPC_URL=http://base-client:${L2_CLIENT_HTTP_PORT} + - L2_CHAIN_ID=${L2_CHAIN_ID} + - FAUCET_ADDR=${FAUCET_ADDR} + - FAUCET_PRIVATE_KEY=${FAUCET_PRIVATE_KEY} + - OUT_FILE=/shared/contracts.json + - VIBENET_BRANCH=${VIBENET_BRANCH:-unknown} + - VIBENET_COMMIT=${VIBENET_COMMIT:-unknown} + volumes: + - vibenet-shared:/shared + - ${VIBENET_UI_ROOT}/docker/vibenet/setup/contracts.yaml:/setup/contracts.yaml:ro + entrypoint: ["/usr/local/bin/deploy-contracts.sh"] + + # --- Renders content.yaml into config.json for the UI -------------------- + vibenet-config-renderer: + image: mikefarah/yq:4.44.3 + container_name: vibenet-config-renderer + user: "0:0" + volumes: + - ${VIBENET_UI_ROOT}/docker/vibenet/content.yaml:/in/content.yaml:ro + - vibenet-config:/out + environment: + - VIBENET_BRANCH=${VIBENET_BRANCH:-unknown} + - VIBENET_COMMIT=${VIBENET_COMMIT:-unknown} + entrypoint: + - /bin/sh + - -c + - | + yq -o=json \ + 'with(.branch; . = strenv(VIBENET_BRANCH)) | + with(.commit; . = strenv(VIBENET_COMMIT))' \ + /in/content.yaml > /out/config.json + echo "Wrote /out/config.json" + restart: "no" + + # --- proxyd: per-method JSON-RPC rate limits ----------------------------- + # Built from base/base's Dockerfile.proxyd (requires the sibling repo). + proxyd: + image: proxyd:local + build: + context: ${BASE_REPO_ROOT}/etc/docker + dockerfile: Dockerfile.proxyd + container_name: vibenet-proxyd + depends_on: + base-client: + condition: service_healthy + ports: + # HTTP RPC exposed directly (WS goes straight to base-client above). + - "127.0.0.1:${VIBENET_RPC_HOST_PORT:-18082}:8080" + volumes: + - ${VIBENET_UI_ROOT}/docker/vibenet/proxyd-ratelimit.toml:/config/proxyd-ratelimit.toml:ro + command: ["/config/proxyd-ratelimit.toml"] + healthcheck: + test: ["CMD", "curl", "-sf", "-X", "POST", "-H", "Content-Type: application/json", "-H", "X-Forwarded-For: 127.0.0.1", "--data", "{\"jsonrpc\":\"2.0\",\"method\":\"eth_chainId\",\"params\":[],\"id\":1}", "http://localhost:8080"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 10s + mem_limit: 512m + memswap_limit: 512m + restart: unless-stopped + +volumes: + vibenet-shared: + vibenet-config: + vibescan-data: diff --git a/docker/vibenet/content.yaml b/docker/vibenet/content.yaml new file mode 100644 index 00000000..6b546a12 --- /dev/null +++ b/docker/vibenet/content.yaml @@ -0,0 +1,27 @@ +# Vibenet UI content + per-branch config. +# This file is mounted into the nginx container and exposed at: +# https://vibes.base.org/config.json +# +# Edit this file and re-run `just vibe` (or just restart nginx-gateway) to +# update what devs see. No rebuild of the UI is required. +# +# NOTE: The `branch` and `commit` fields are overwritten at container build +# time by etc/vibenet/scripts/render-config.sh. Do not rely on values set +# here for those two fields. + +title: "base vibenet" +subtitle: "An ephemeral Base devnet for trying out in-flight features." + +# Things unique to this devnet branch. Each item has a title, +# one-sentence description, and optional link. Keep the baseline vibenet +# tools as a single card; branch owners can add their feature-specific +# "what's new" cards above or below it. +features: + - title: "Vibenet faucet + explorer" + description: "Request test funds, browse blocks, inspect transactions, and follow token activity." + - title: "What's new on this vibe" + description: "Branch-specific updates will show up here when a vibe is deployed to preview what's upcoming or experimental." + +# Override injected at build time. Do not edit. +branch: "unknown" +commit: "unknown" diff --git a/docker/vibenet/env.example b/docker/vibenet/env.example new file mode 100644 index 00000000..6468ccb8 --- /dev/null +++ b/docker/vibenet/env.example @@ -0,0 +1,68 @@ +# Vibenet host-specific configuration. +# +# Copy this file to `etc/vibenet/vibenet-env` on the host and fill in the real +# values. Never commit the populated file. + +# === Public TLS listener (production only) === +# On the production host, Cloudflare proxies public traffic (orange-cloud) +# to Caddy's :443 listener. Caddy is activated by `just vibe` when +# /etc/vibenet/tls/origin.crt (a Cloudflare Origin CA certificate) is present. +# +# Generate the cert pair from the Cloudflare dashboard: +# SSL/TLS -> Origin Server -> Create Certificate +# Covering hostnames: vibes.base.org, *.vibes.base.org +# Then on the host: +# sudo install -d -m 0755 /etc/vibenet/tls +# sudo tee /etc/vibenet/tls/origin.crt > /dev/null # paste cert +# sudo tee /etc/vibenet/tls/origin.key > /dev/null # paste key +# sudo chmod 0644 /etc/vibenet/tls/origin.crt +# sudo chmod 0600 /etc/vibenet/tls/origin.key +# +# VIBENET_PUBLIC_BIND_ADDR is the host IP :443 is published on. Leave unset +# on laptops; set to the Cloudflare-reachable public IP on prod. +# VIBENET_PUBLIC_BIND_ADDR= + +# === UI image === +# By default `just vibe` builds the Next.js UI from the sibling base/ui repo +# at ../ui (relative to this repo). To use a pre-built image instead: +# VIBENET_UI_IMAGE=ghcr.io/base/ui:main + +# === Faucet === +# Address that funds every public drip. Prefunded with 10M ETH in the L1 +# genesis (setup-l1.sh) and topped up on L2 by vibenet-setup sweeping the +# standard anvil EOAs into it at boot. +# +# Generate a fresh key for each deployment: +# cast wallet new +# Local default: the first standard Anvil/Foundry account. Production hosts +# should replace this with a fresh generated key. +FAUCET_ADDR=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +FAUCET_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +# === Faucet rate limits === +# How much to drip per request, how often a given IP / address may drip. +# USDV drip is in *base units* (6 decimals); 1_000_000_000 = 1000 USDV. +# +# Cooldowns are namespaced per asset: a successful ETH drip does not put the +# caller into cooldown for USDV. In practice this means a fresh caller can get +# one ETH drip and one USDV drip back-to-back, then waits `*_COOLDOWN_SECS` +# before the next drip of either asset. Default is 1 hour for both. +VIBENET_FAUCET_DRIP_WEI=100000000000000000 +VIBENET_FAUCET_USDV_DRIP_UNITS=1000000000 +VIBENET_FAUCET_IP_COOLDOWN_SECS=3600 +VIBENET_FAUCET_ADDR_COOLDOWN_SECS=3600 + +# === Optional: contract list override path === +# Defaults to etc/vibenet/setup/contracts.yaml. Override on a per-branch basis +# to add demo-specific contracts without modifying the setup script. +VIBENET_CONTRACTS_YAML=etc/vibenet/setup/contracts.yaml + +# === Optional: local-dev host ports === +# Loopback-only bindings on `just vibe`. In production Caddy binds :443. +# VIBENET_HOST_PORT=18080 # Next.js app (landing / faucet / explorer) +# VIBENET_WS_HOST_PORT=18081 # WebSocket RPC (base-client direct) +# VIBENET_RPC_HOST_PORT=18082 # HTTP RPC (proxyd) + +# === Optional: explorer indexer tuning === +# VIBESCAN_START_BLOCK=0 +# VIBESCAN_BACKFILL_CONCURRENCY=16 diff --git a/docker/vibenet/proxyd-ratelimit.toml b/docker/vibenet/proxyd-ratelimit.toml new file mode 100644 index 00000000..b46fe8b0 --- /dev/null +++ b/docker/vibenet/proxyd-ratelimit.toml @@ -0,0 +1,76 @@ +# proxyd config for vibenet - per-method rate limits applied after the +# per-IP limits in the nginx gateway. +# routes eth_sendRawTransaction to the builder and +# everything else to the client. + +[server] +rpc_host = "0.0.0.0" +rpc_port = 8080 +max_body_size_bytes = 10485760 +max_concurrent_rpcs = 100 + +[backend] +response_timeout_seconds = 30 +max_response_size_bytes = 5242880 +max_retries = 3 +out_of_service_seconds = 10 + +[rate_limit] +base_rate = 50 +base_interval = "1s" + +[rate_limit.method_overrides] +[rate_limit.method_overrides.eth_sendRawTransaction] +limit = 20 +interval = "1s" +[rate_limit.method_overrides.eth_call] +limit = 100 +interval = "1s" +[rate_limit.method_overrides.eth_getLogs] +limit = 20 +interval = "1s" + +[backends] +[backends.builder] +rpc_url = "http://base-builder:7545" +max_rps = 100 + +[backends.client] +rpc_url = "http://base-client:8545" +max_rps = 200 + +[backend_groups] +[backend_groups.builder] +backends = ["builder"] + +[backend_groups.client] +backends = ["client"] + +[rpc_method_mappings] +eth_sendRawTransaction = "builder" +eth_chainId = "client" +eth_blockNumber = "client" +eth_syncing = "client" +eth_call = "client" +eth_estimateGas = "client" +eth_createAccessList = "client" +eth_gasPrice = "client" +eth_maxPriorityFeePerGas = "client" +eth_feeHistory = "client" +eth_getLogs = "client" +eth_getBalance = "client" +eth_getStorageAt = "client" +eth_getTransactionCount = "client" +eth_getCode = "client" +eth_getProof = "client" +eth_getBlockByHash = "client" +eth_getBlockByNumber = "client" +eth_getBlockTransactionCountByHash = "client" +eth_getBlockTransactionCountByNumber = "client" +eth_getTransactionByHash = "client" +eth_getTransactionByBlockHashAndIndex = "client" +eth_getTransactionByBlockNumberAndIndex = "client" +eth_getTransactionReceipt = "client" +net_version = "client" +web3_clientVersion = "client" +base_transactionStatus = "client" diff --git a/docker/vibenet/setup/Dockerfile b/docker/vibenet/setup/Dockerfile new file mode 100644 index 00000000..28054302 --- /dev/null +++ b/docker/vibenet/setup/Dockerfile @@ -0,0 +1,42 @@ +# Vibenet setup image: foundry (forge + cast) + yq + jq + bash. +# Pre-builds the demo contracts so container start only needs to broadcast. +# +# Built from this directory (docker/vibenet/setup/) as context. + +FROM ghcr.io/foundry-rs/foundry:stable AS builder + +USER root +WORKDIR /setup + +COPY contracts/foundry.toml /setup/contracts/foundry.toml +COPY contracts/src/ /setup/contracts/src/ + +RUN cd /setup/contracts && forge build --silent + +FROM ghcr.io/foundry-rs/foundry:stable + +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash bc curl ca-certificates jq \ + && rm -rf /var/lib/apt/lists/* +# yq has a static binary per-arch; pick the one that matches this container. +RUN set -eux; \ + case "$(uname -m)" in \ + x86_64) YQ_ARCH=amd64 ;; \ + aarch64|arm64) YQ_ARCH=arm64 ;; \ + *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; \ + esac; \ + curl -fsSL -o /usr/local/bin/yq \ + "https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_${YQ_ARCH}"; \ + chmod +x /usr/local/bin/yq + +WORKDIR /setup + +# Bring in the already-built artifacts + sources (forge create needs both). +COPY --from=builder /setup/contracts /setup/contracts + +COPY contracts.yaml /setup/contracts.yaml +COPY deploy-contracts.sh /usr/local/bin/deploy-contracts.sh +RUN chmod +x /usr/local/bin/deploy-contracts.sh + +ENTRYPOINT ["/usr/local/bin/deploy-contracts.sh"] diff --git a/docker/vibenet/setup/contracts.yaml b/docker/vibenet/setup/contracts.yaml new file mode 100644 index 00000000..de90e894 --- /dev/null +++ b/docker/vibenet/setup/contracts.yaml @@ -0,0 +1,30 @@ +# Contracts deployed by vibenet-setup after the L2 chain is live. +# +# Each entry names a contract, its forge artifact identifier +# (src/.sol:), and optional constructor args (templating: +# "{{ otherContract }}" resolves to a previously-deployed address). +# +# Output: every entry adds `{ name: address }` to /shared/contracts.json. +# +# To add a demo contract on a branch, drop a .sol file into +# etc/vibenet/setup/contracts/src/ and add an entry here. Re-run `just vibe`. +# +# TODO: add canonical ERC-4337 EntryPoint v0.7 / v0.8 deploys. These require +# pulling the audited bytecode (eth-infinitism/account-abstraction) and +# CREATE2-ing to the canonical address; leaving as follow-up work. +# +# TODO: add the Coinbase Smart Wallet factory. The factory bytecode lives in +# coinbase/smart-wallet and is CREATE2-deployable at a canonical address. +# Leaving as follow-up work so we don't block vibenet on pulling that repo +# into our docker build context. + +contracts: + # USDV (Vibe USD): public-mint ERC-20 with USDC-compatible 6 decimals. + # Replaces the older MockUSDC name so nobody mistakes it for real USDC. + - name: usdv + artifact: src/USDV.sol:USDV + args: [] + # NFV (Non-Fungible Vibe): public-mint ERC-721 for NFT-flow demos. + - name: nfv + artifact: src/NFV.sol:NFV + args: [] diff --git a/docker/vibenet/setup/contracts/foundry.toml b/docker/vibenet/setup/contracts/foundry.toml new file mode 100644 index 00000000..fe13484c --- /dev/null +++ b/docker/vibenet/setup/contracts/foundry.toml @@ -0,0 +1,7 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +solc_version = "0.8.26" +optimizer = true +optimizer_runs = 200 diff --git a/docker/vibenet/setup/contracts/src/NFV.sol b/docker/vibenet/setup/contracts/src/NFV.sol new file mode 100644 index 00000000..5bd6dfbf --- /dev/null +++ b/docker/vibenet/setup/contracts/src/NFV.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title NFV - Non-Fungible Vibe +/// @notice Minimal public-mint ERC-721 used on vibenet for demos. Anyone can +/// mint the next token to any address. Not audited; no metadata. +contract NFV { + string public constant name = "Non-Fungible Vibe"; + string public constant symbol = "NFV"; + + uint256 public nextTokenId; + + mapping(uint256 => address) private _owners; + mapping(address => uint256) private _balances; + mapping(uint256 => address) private _tokenApprovals; + mapping(address => mapping(address => bool)) private _operatorApprovals; + + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + function mint(address to) external returns (uint256 tokenId) { + tokenId = nextTokenId++; + _owners[tokenId] = to; + _balances[to] += 1; + emit Transfer(address(0), to, tokenId); + } + + function ownerOf(uint256 tokenId) external view returns (address) { + address owner = _owners[tokenId]; + require(owner != address(0), "nonexistent"); + return owner; + } + + function balanceOf(address owner) external view returns (uint256) { + require(owner != address(0), "zero"); + return _balances[owner]; + } + + function approve(address to, uint256 tokenId) external { + address owner = _owners[tokenId]; + require( + owner == msg.sender || _operatorApprovals[owner][msg.sender], + "not authorized" + ); + _tokenApprovals[tokenId] = to; + emit Approval(owner, to, tokenId); + } + + function getApproved(uint256 tokenId) external view returns (address) { + require(_owners[tokenId] != address(0), "nonexistent"); + return _tokenApprovals[tokenId]; + } + + function setApprovalForAll(address operator, bool approved) external { + _operatorApprovals[msg.sender][operator] = approved; + emit ApprovalForAll(msg.sender, operator, approved); + } + + function isApprovedForAll(address owner, address operator) external view returns (bool) { + return _operatorApprovals[owner][operator]; + } + + function transferFrom(address from, address to, uint256 tokenId) external { + require(_owners[tokenId] == from, "wrong from"); + require( + msg.sender == from + || _tokenApprovals[tokenId] == msg.sender + || _operatorApprovals[from][msg.sender], + "not authorized" + ); + _tokenApprovals[tokenId] = address(0); + _owners[tokenId] = to; + unchecked { + _balances[from] -= 1; + _balances[to] += 1; + } + emit Transfer(from, to, tokenId); + } + + function supportsInterface(bytes4 iid) external pure returns (bool) { + return iid == 0x80ac58cd // ERC-721 + || iid == 0x01ffc9a7; // ERC-165 + } +} diff --git a/docker/vibenet/setup/contracts/src/USDV.sol b/docker/vibenet/setup/contracts/src/USDV.sol new file mode 100644 index 00000000..33853ad7 --- /dev/null +++ b/docker/vibenet/setup/contracts/src/USDV.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title USDV - Vibe USD +/// @notice Public-mint ERC-20 used on vibenet as a stand-in for USDC. Anyone +/// can mint up to `MAX_MINT_PER_CALL` to any address; the faucet +/// service uses this to "drip" test dollars, but any caller may do +/// the same directly. Not audited - do not deploy to production. +/// @dev 6 decimals to match USDC so UIs and integrations can treat it as +/// a drop-in replacement. The symbol is USDV to make it obvious the +/// balance is worthless. +contract USDV { + string public constant name = "Vibe USD"; + string public constant symbol = "USDV"; + uint8 public constant decimals = 6; + uint256 public constant MAX_MINT_PER_CALL = 1_000_000 * 10 ** 6; + + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function mint(address to, uint256 amount) external { + require(amount <= MAX_MINT_PER_CALL, "mint too large"); + balanceOf[to] += amount; + totalSupply += amount; + emit Transfer(address(0), to, amount); + } + + function transfer(address to, uint256 amount) external returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + require(allowed >= amount, "allowance"); + if (allowed != type(uint256).max) { + allowance[from][msg.sender] = allowed - amount; + } + _transfer(from, to, amount); + return true; + } + + function _transfer(address from, address to, uint256 amount) internal { + require(balanceOf[from] >= amount, "balance"); + unchecked { + balanceOf[from] -= amount; + balanceOf[to] += amount; + } + emit Transfer(from, to, amount); + } +} diff --git a/docker/vibenet/setup/deploy-contracts.sh b/docker/vibenet/setup/deploy-contracts.sh new file mode 100755 index 00000000..7989227a --- /dev/null +++ b/docker/vibenet/setup/deploy-contracts.sh @@ -0,0 +1,178 @@ +#!/bin/bash +# Vibenet post-setup: prefunds the faucet on L1 + L2 by sweeping the +# standard anvil EOA balances, then deploys demo contracts and writes +# their addresses to /shared/contracts.json for the UI. +# +# Inputs (env): +# L1_RPC_URL JSON-RPC endpoint of the L1 anvil node. +# L2_RPC_URL JSON-RPC endpoint of the vibenet L2 client. +# L2_CHAIN_ID Numeric L2 chain id. +# FAUCET_ADDR Deployer EOA (must be the prefunded faucet). +# FAUCET_PRIVATE_KEY Private key for FAUCET_ADDR. +# OUT_FILE Where to write contracts.json. Default /shared/contracts.json. +# VIBENET_BRANCH Branch name, written into contracts.json for dev visibility. +# VIBENET_COMMIT Commit sha, written into contracts.json. +# +# The contract list comes from /setup/contracts.yaml. + +set -euo pipefail + +OUT_FILE="${OUT_FILE:-/shared/contracts.json}" +CONTRACTS_YAML="${CONTRACTS_YAML:-/setup/contracts.yaml}" +FORGE_ROOT="${FORGE_ROOT:-/setup/contracts}" + +: "${L1_RPC_URL:?L1_RPC_URL required}" +: "${L2_RPC_URL:?L2_RPC_URL required}" +: "${L2_CHAIN_ID:?L2_CHAIN_ID required}" +: "${FAUCET_PRIVATE_KEY:?FAUCET_PRIVATE_KEY required}" +: "${FAUCET_ADDR:?FAUCET_ADDR required}" + +# Wait for an RPC endpoint to respond to eth_chainId. +wait_for_rpc() { + local label="$1" + local url="$2" + echo "=== vibenet-setup: waiting for $label RPC at $url ===" + for i in $(seq 1 120); do + if curl -sf --max-time 2 -X POST -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' \ + "$url" | jq -e '.result' >/dev/null 2>&1; then + echo "$label RPC ready" + return + fi + sleep 1 + done + echo "ERROR: $label RPC at $url never came up" + exit 1 +} + +# Sweep the standard anvil EOAs on the given chain into FAUCET_ADDR. +# Anvil pre-funds each account with 10_000 ETH = 1e22 wei which overflows +# 64-bit bash arithmetic, so delegate the math to bc. +ANVIL_KEYS=( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" + "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6" + "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a" + "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba" + "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e" + "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356" + "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97" + "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" +) + +sweep_anvil_balances() { + local label="$1" + local url="$2" + echo "" + echo "=== sweeping anvil balances on $label -> $FAUCET_ADDR ===" + for key in "${ANVIL_KEYS[@]}"; do + addr=$(cast wallet address --private-key "$key") + if [ "${addr,,}" = "${FAUCET_ADDR,,}" ]; then + continue # skip the faucet itself if it's an anvil account + fi + bal=$(cast balance "$addr" --rpc-url "$url") + if [ "$bal" = "0" ]; then + continue + fi + gas_price=$(cast gas-price --rpc-url "$url") + # Pad the reserve generously (10x 21k) so EIP-1559 priority tips don't + # drop us below zero. + reserve=$(echo "$gas_price * 21000 * 10" | bc) + send=$(echo "$bal - $reserve" | bc) + if [ "$(echo "$send <= 0" | bc)" = "1" ]; then + continue + fi + echo " $label sweep $addr ($send wei)" + cast send --rpc-url "$url" --private-key "$key" \ + --value "$send" "$FAUCET_ADDR" >/dev/null \ + || echo " (sweep failed, continuing)" + done +} + +# Wait for both chains, then sweep on both. The L1 sweep replaces the +# previous setup-l1.sh genesis injection — it gives the faucet ~90K ETH +# on L1 instead of 10M, which is plenty for any deposit-flow demo. +wait_for_rpc L1 "$L1_RPC_URL" +wait_for_rpc L2 "$L2_RPC_URL" + +sweep_anvil_balances L1 "$L1_RPC_URL" +sweep_anvil_balances L2 "$L2_RPC_URL" + +echo "" +echo "=== building contracts ===" +cd "$FORGE_ROOT" +forge build --silent + +echo "" +echo "=== deploying contracts from $CONTRACTS_YAML ===" +mkdir -p "$(dirname "$OUT_FILE")" +# Start the output with metadata so the UI always has something to show even +# if deploys fail partway through. +echo "{\"_branch\":\"${VIBENET_BRANCH:-unknown}\",\"_commit\":\"${VIBENET_COMMIT:-unknown}\",\"faucetAddress\":\"${FAUCET_ADDR}\"}" \ + | jq '.' > "$OUT_FILE" + +count=$(yq '.contracts | length' "$CONTRACTS_YAML") +for i in $(seq 0 $((count - 1))); do + name=$(yq ".contracts[$i].name" "$CONTRACTS_YAML") + artifact=$(yq ".contracts[$i].artifact" "$CONTRACTS_YAML") + args_json=$(yq -o=json ".contracts[$i].args // []" "$CONTRACTS_YAML") + + # Resolve {{ otherContract }} templates from already-deployed addresses. + resolved_args=() + while IFS= read -r a; do + if [[ "$a" =~ ^\{\{[[:space:]]*(.+)[[:space:]]*\}\}$ ]]; then + ref="${BASH_REMATCH[1]}" + addr=$(jq -r --arg k "$ref" '.[$k] // empty' "$OUT_FILE") + if [ -z "$addr" ]; then + echo "ERROR: $name references {{ $ref }} but it hasn't been deployed yet" + exit 1 + fi + resolved_args+=("$addr") + else + resolved_args+=("$a") + fi + done < <(echo "$args_json" | jq -r '.[]') + + echo "-> deploying $name ($artifact) with args [${resolved_args[*]:-}]" + # forge create sometimes reads a stale pending nonce when deploys run back + # to back. Pin the nonce explicitly using the faucet's current tx count. + nonce_hex=$(cast rpc --rpc-url "$L2_RPC_URL" eth_getTransactionCount "$FAUCET_ADDR" pending | tr -d '"') + nonce=$((nonce_hex)) + # Retry on transient "nonce too low" / "already known" races. + for attempt in 1 2 3 4 5; do + if out=$(forge create "$artifact" \ + --rpc-url "$L2_RPC_URL" \ + --private-key "$FAUCET_PRIVATE_KEY" \ + --nonce "$nonce" \ + --broadcast \ + --json \ + ${resolved_args[@]:+--constructor-args "${resolved_args[@]}"} 2>&1); then + break + fi + echo " deploy attempt $attempt failed, retrying: ${out##*: }" + sleep 2 + nonce_hex=$(cast rpc --rpc-url "$L2_RPC_URL" eth_getTransactionCount "$FAUCET_ADDR" pending | tr -d '"') + nonce=$((nonce_hex)) + if [ "$attempt" = 5 ]; then + echo "ERROR: could not deploy $name: $out" + exit 1 + fi + done + addr=$(echo "$out" | jq -r '.deployedTo') + echo " $name = $addr" + + TMP=$(mktemp) + jq --arg k "$name" --arg v "$addr" '. + {($k): $v}' "$OUT_FILE" > "$TMP" + mv "$TMP" "$OUT_FILE" +done + +echo "" +echo "=== contracts.json ===" +cat "$OUT_FILE" +# nginx runs as the nginx user and needs read access. The shared volume is +# owned by root with the restrictive default umask of the foundry image, so +# loosen perms explicitly here. +chmod 0644 "$OUT_FILE" +echo "" +echo "vibenet-setup: done" diff --git a/next.config.ts b/next.config.ts index 68a6c64d..89cdbd2a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - output: "standalone", + // better-sqlite3 is a native module; exclude it from webpack bundling + serverExternalPackages: ["better-sqlite3"], }; export default nextConfig; diff --git a/package.json b/package.json index 54d105ce..e10cc4c0 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "3.940.0", + "better-sqlite3": "^12.10.0", "next": "16.0.7", "react": "19.2.1", "react-dom": "19.2.1", @@ -19,6 +20,7 @@ "devDependencies": { "@biomejs/biome": "2.3.8", "@tailwindcss/postcss": "4.1.17", + "@types/better-sqlite3": "^7.6.13", "@types/node": "20.19.25", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", diff --git a/scripts/indexer.mjs b/scripts/indexer.mjs new file mode 100644 index 00000000..86b4254f --- /dev/null +++ b/scripts/indexer.mjs @@ -0,0 +1,319 @@ +#!/usr/bin/env node +// Background block indexer for vibescan. +// Reads env: VIBESCAN_RPC_HTTP_URL, VIBESCAN_RPC_WS_URL, VIBESCAN_CHAIN_ID, +// VIBESCAN_DB_PATH, VIBESCAN_START_BLOCK, VIBESCAN_BACKFILL_CONCURRENCY + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; +import { createPublicClient, defineChain, http, webSocket } from "viem"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const DB_PATH = process.env.VIBESCAN_DB_PATH ?? "/data/vibescan.db"; +const RPC_HTTP = process.env.VIBESCAN_RPC_HTTP_URL ?? "http://localhost:8545"; +const RPC_WS = process.env.VIBESCAN_RPC_WS_URL ?? "ws://localhost:8546"; +const CHAIN_ID = Number(process.env.VIBESCAN_CHAIN_ID ?? "84538453"); +const START_BLOCK = BigInt(process.env.VIBESCAN_START_BLOCK ?? "0"); +const CONCURRENCY = Number(process.env.VIBESCAN_BACKFILL_CONCURRENCY ?? "16"); + +// keccak256("Transfer(address,address,uint256)") — same topic for ERC-20 and ERC-721 +const ERC20_TRANSFER = + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + +const chain = defineChain({ + id: CHAIN_ID, + name: "vibenet", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: [RPC_HTTP], webSocket: [RPC_WS] } }, +}); + +const httpClient = createPublicClient({ chain, transport: http(RPC_HTTP) }); + +// --- DB setup --- + +function openDb() { + const db = new Database(DB_PATH); + db.pragma("journal_mode = WAL"); + db.pragma("synchronous = NORMAL"); + const migrationPath = path.join( + __dirname, + "../docker/migrations/0001_init.sql", + ); + db.exec(readFileSync(migrationPath, "utf8")); + return db; +} + +const db = openDb(); + +const stmts = { + getCursor: db.prepare( + "SELECT last_indexed_block, last_indexed_hash FROM cursor WHERE id = 0", + ), + upsertCursor: db.prepare(` + INSERT INTO cursor (id, last_indexed_block, last_indexed_hash, updated_at) + VALUES (0, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + last_indexed_block = excluded.last_indexed_block, + last_indexed_hash = excluded.last_indexed_hash, + updated_at = excluded.updated_at + `), + insertBlock: db.prepare(` + INSERT OR IGNORE INTO blocks (number, hash, timestamp, miner, tx_count, gas_used, gas_limit, base_fee) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `), + insertTx: db.prepare(` + INSERT OR IGNORE INTO txs (hash, block_num, tx_index, from_addr, to_addr, value, status, created) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `), + insertActivity: db.prepare(` + INSERT OR IGNORE INTO address_activity (address, block_num, tx_index, log_index, tx_hash, role, token) + VALUES (?, ?, ?, ?, ?, ?, ?) + `), + insertAddress: db.prepare( + "INSERT OR IGNORE INTO addresses (address) VALUES (?)", + ), + updateStats: db.prepare(` + UPDATE explorer_stats SET + blocks = (SELECT COUNT(*) FROM blocks), + txs = (SELECT COUNT(*) FROM txs), + addresses = (SELECT COUNT(*) FROM addresses) + WHERE id = 0 + `), + getBlockHash: db.prepare("SELECT hash FROM blocks WHERE number = ?"), +}; + +function wipeDb() { + db.exec(` + DELETE FROM blocks; + DELETE FROM txs; + DELETE FROM address_activity; + DELETE FROM addresses; + DELETE FROM cursor; + UPDATE explorer_stats SET blocks=0, txs=0, addresses=0 WHERE id=0; + `); +} + +// --- Indexing logic --- + +async function processBlock(blockNum) { + const [block, receipts] = await Promise.all([ + httpClient.getBlock({ blockNumber: blockNum, includeTransactions: true }), + httpClient + .request({ + method: "eth_getBlockReceipts", + params: [`0x${blockNum.toString(16)}`], + }) + .catch(() => null), + ]); + + if (!block) return null; + + db.transaction(() => { + stmts.insertBlock.run( + Number(block.number), + block.hash, + Number(block.timestamp), + block.miner.toLowerCase(), + block.transactions.length, + Number(block.gasUsed), + Number(block.gasLimit), + block.baseFeePerGas != null + ? `0x${block.baseFeePerGas.toString(16)}` + : null, + ); + + for (let i = 0; i < block.transactions.length; i++) { + const tx = block.transactions[i]; + const receipt = receipts?.[i]; + const status = receipt ? (receipt.status === "0x1" ? 1 : 0) : 1; + const to = tx.to ? tx.to.toLowerCase() : null; + const from = tx.from.toLowerCase(); + + stmts.insertTx.run( + tx.hash, + Number(block.number), + i, + from, + to, + `0x${tx.value.toString(16)}`, + status, + to === null ? (receipt?.contractAddress?.toLowerCase() ?? null) : null, + ); + + // sender + stmts.insertActivity.run( + from, + Number(block.number), + i, + -1, + tx.hash, + 0, + null, + ); + stmts.insertAddress.run(from); + + // recipient or creator + if (to) { + stmts.insertActivity.run( + to, + Number(block.number), + i, + -1, + tx.hash, + 1, + null, + ); + stmts.insertAddress.run(to); + } else if (receipt?.contractAddress) { + const created = receipt.contractAddress.toLowerCase(); + stmts.insertActivity.run( + created, + Number(block.number), + i, + -1, + tx.hash, + 2, + null, + ); + stmts.insertAddress.run(created); + } + + // ERC-20/721 Transfer logs + if (receipt?.logs) { + for (const log of receipt.logs) { + if (log.topics[0] === ERC20_TRANSFER && log.topics.length >= 3) { + const token = log.address.toLowerCase(); + const logIdx = Number(log.logIndex); + const fromLog = `0x${log.topics[1].slice(26)}`; + const toLog = `0x${log.topics[2].slice(26)}`; + stmts.insertActivity.run( + fromLog, + Number(block.number), + i, + logIdx, + tx.hash, + 3, + token, + ); + stmts.insertActivity.run( + toLog, + Number(block.number), + i, + logIdx, + tx.hash, + 4, + token, + ); + stmts.insertAddress.run(fromLog); + stmts.insertAddress.run(toLog); + } + } + } + } + + stmts.upsertCursor.run( + Number(block.number), + block.hash, + Math.floor(Date.now() / 1000), + ); + stmts.updateStats.run(); + })(); + + return block; +} + +async function ensureGenesisConsistent() { + const stored = stmts.getBlockHash.get(0); + if (!stored) return; + const upstream = await httpClient.getBlock({ blockNumber: 0n }); + if (!upstream) return; + if (upstream.hash !== stored.hash) { + console.log( + `[indexer] genesis hash mismatch — chain reset detected, wiping DB`, + ); + wipeDb(); + } +} + +async function backfill(fromBlock, toBlock) { + console.log(`[indexer] backfilling blocks ${fromBlock}–${toBlock}`); + let i = fromBlock; + while (i <= toBlock) { + const batch = []; + for (let j = 0; j < CONCURRENCY && i + BigInt(j) <= toBlock; j++) { + batch.push(processBlock(i + BigInt(j))); + } + await Promise.all(batch); + i += BigInt(CONCURRENCY); + process.stdout.write(`\r[indexer] backfilled up to ${i - 1n}`); + } + console.log(`\n[indexer] backfill complete`); +} + +async function streamLive() { + console.log(`[indexer] subscribing to newHeads via ${RPC_WS}`); + + const wsClient = createPublicClient({ chain, transport: webSocket(RPC_WS) }); + + const unwatch = wsClient.watchBlocks({ + onBlock: async (block) => { + try { + await processBlock(block.number); + console.log(`[indexer] indexed block #${block.number}`); + } catch (e) { + console.error(`[indexer] error processing block #${block.number}:`, e); + } + }, + onError: (err) => { + console.error(`[indexer] subscription error:`, err); + }, + }); + + // Keep running; signal handler below handles cleanup + process.on("SIGTERM", () => { + unwatch(); + process.exit(0); + }); + process.on("SIGINT", () => { + unwatch(); + process.exit(0); + }); + await new Promise(() => {}); // block forever +} + +async function main() { + console.log(`[indexer] starting (chain=${CHAIN_ID}, rpc=${RPC_HTTP})`); + + // Wait for node to be reachable + for (let attempt = 0; attempt < 30; attempt++) { + try { + await httpClient.getChainId(); + break; + } catch { + console.log(`[indexer] waiting for node… (attempt ${attempt + 1}/30)`); + await new Promise((r) => setTimeout(r, 2000)); + } + } + + await ensureGenesisConsistent(); + + const cursor = stmts.getCursor.get(); + const resumeFrom = cursor + ? BigInt(cursor.last_indexed_block) + 1n + : START_BLOCK; + + const latest = await httpClient.getBlockNumber(); + + if (resumeFrom <= latest) { + await backfill(resumeFrom, latest); + } + + await streamLive(); +} + +main().catch((e) => { + console.error("[indexer] fatal:", e); + process.exit(1); +}); diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100644 index 00000000..e47f1774 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +# Start the background indexer (writes to SQLite, reads from node via RPC) +node scripts/indexer.mjs & + +# Start Next.js server in foreground so Docker tracks its exit code +exec npm start diff --git a/src/app/(vibenet)/explorer/_header.tsx b/src/app/(vibenet)/explorer/_header.tsx new file mode 100644 index 00000000..a642a662 --- /dev/null +++ b/src/app/(vibenet)/explorer/_header.tsx @@ -0,0 +1,107 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +const PROD_HOSTS = { + ui: "vibes.base.org", + faucet: "faucet.vibes.base.org", + explorer: "explorer.vibes.base.org", +}; + +function isProdHost(host: string) { + return host === PROD_HOSTS.ui || host.endsWith(".vibes.base.org"); +} + +function buildHomeUrl() { + if (typeof window === "undefined") return "/"; + return isProdHost(window.location.hostname) + ? `https://${PROD_HOSTS.ui}` + : "/"; +} + +function buildFaucetUrl() { + if (typeof window === "undefined") return "/faucet"; + return isProdHost(window.location.hostname) + ? `https://${PROD_HOSTS.faucet}` + : "/faucet"; +} + +function classifyQuery( + q: string, +): { kind: "block" | "tx" | "address"; value: string } | null { + const v = q.trim(); + if (!v) return null; + if (/^0x[a-fA-F0-9]{40}$/.test(v)) return { kind: "address", value: v }; + if (/^0x[a-fA-F0-9]{64}$/.test(v)) return { kind: "tx", value: v }; + if (/^\d+$/.test(v)) return { kind: "block", value: v }; + return null; +} + +export function ExplorerHeader() { + const router = useRouter(); + const [query, setQuery] = useState(""); + const [error, setError] = useState(null); + const [homeUrl, setHomeUrl] = useState("/"); + const [faucetUrl, setFaucetUrl] = useState("/faucet"); + + useEffect(() => { + setHomeUrl(buildHomeUrl()); + setFaucetUrl(buildFaucetUrl()); + }, []); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + const cls = classifyQuery(query); + if (!cls) { + setError("Enter a block #, hash, or address"); + return; + } + if (cls.kind === "address") { + router.push(`/explorer/address/${cls.value}`); + return; + } + if (cls.kind === "block") { + router.push(`/explorer/block/${cls.value}`); + return; + } + // 64-char hash: try tx first, fall back to block + try { + const res = await fetch(`/api/vibenet/explorer/tx/${cls.value}`); + if (res.ok) router.push(`/explorer/tx/${cls.value}`); + else router.push(`/explorer/block/${cls.value}`); + } catch { + router.push(`/explorer/block/${cls.value}`); + } + }; + + return ( +
+ +
+ ); +} diff --git a/src/app/(vibenet)/explorer/address/[addr]/page.tsx b/src/app/(vibenet)/explorer/address/[addr]/page.tsx new file mode 100644 index 00000000..4cda30c0 --- /dev/null +++ b/src/app/(vibenet)/explorer/address/[addr]/page.tsx @@ -0,0 +1,142 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +interface PageProps { + params: Promise<{ addr: string }>; +} + +interface ActivityRow { + tx_hash: string; + block_num: number; + log_index: number; + role: number; + token: string | null; +} + +interface AddressDetail { + address: string; + balance_wei: string; + nonce: number; + is_contract: boolean; + code_size: number; + activity: ActivityRow[]; +} + +const ROLE_LABELS: Record = { + 0: "sender", + 1: "recipient", + 2: "creator", + 3: "token-from", + 4: "token-to", +}; + +function shortHash(h: string, n = 12): string { + if (!h || h.length <= n + 4) return h; + return `${h.slice(0, n)}…${h.slice(-4)}`; +} + +function weiToEth(hex: string): string { + const n = BigInt(hex); + if (n === 0n) return "0 ETH"; + const eth = Number(n) / 1e18; + return `${eth.toFixed(6)} ETH`; +} + +export default function ExplorerAddressPage({ params }: PageProps) { + const [addr, setAddr] = useState(""); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + params.then((p) => setAddr(p.addr)); + }, [params]); + + useEffect(() => { + if (!addr) return; + setLoading(true); + fetch(`/api/vibenet/explorer/address/${addr}`) + .then((r) => r.json()) + .then(setData) + .catch(() => null) + .finally(() => setLoading(false)); + }, [addr]); + + return ( +
+

Address

+

+ {addr} +

+ + {loading &&

Loading…

} + + {data && ( + <> +
+
Type
+
+ {data.is_contract + ? `Contract (${data.code_size.toLocaleString()} bytes)` + : "EOA"} +
+
Balance
+
{weiToEth(data.balance_wei)}
+
Nonce
+
{data.nonce.toString()}
+
+ +

Activity

+ {data.activity.length === 0 ? ( +

No activity indexed yet.

+ ) : ( + + + + + + + + + + + {data.activity.map((row) => ( + + + + + + + ))} + +
BlockTxRoleDetail
+ + {row.block_num.toLocaleString()} + + + + + {shortHash(row.tx_hash, 12)} + + + {ROLE_LABELS[row.role] ?? row.role} + {row.token ? ( + <> + via{" "} + + + {shortHash(row.token, 8)} + + + + ) : ( + + )} +
+ )} + + )} +
+ ); +} diff --git a/src/app/(vibenet)/explorer/block/[hash]/page.tsx b/src/app/(vibenet)/explorer/block/[hash]/page.tsx new file mode 100644 index 00000000..f657dee1 --- /dev/null +++ b/src/app/(vibenet)/explorer/block/[hash]/page.tsx @@ -0,0 +1,131 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +interface PageProps { + params: Promise<{ hash: string }>; +} + +interface BlockDetail { + number: string; + hash: string; + parentHash: string; + timestamp: string; + miner: string; + gasUsed: string; + gasLimit: string; + baseFeePerGas: string | null; + transactions: string[]; +} + +export default function ExplorerBlockPage({ params }: PageProps) { + const [hash, setHash] = useState(""); + const [block, setBlock] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + params.then((p) => setHash(p.hash)); + }, [params]); + + useEffect(() => { + if (!hash) return; + setLoading(true); + fetch(`/api/vibenet/explorer/block/${hash}`) + .then(async (r) => { + if (!r.ok) + throw new Error( + r.status === 404 ? "Block not found" : "Failed to fetch block", + ); + return r.json(); + }) + .then(setBlock) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [hash]); + + const num = block ? Number.parseInt(block.number, 16) : null; + const ts = block + ? new Date(Number.parseInt(block.timestamp, 16) * 1000).toLocaleString() + : null; + + return ( +
+

Block {num !== null ? num.toLocaleString() : ""}

+ {block && ( +

+ {block.hash} +

+ )} + {loading &&

Loading…

} + {error &&
{error}
} + {block && ( + <> +
+
Number
+
{num?.toLocaleString()}
+
Timestamp
+
{ts}
+
Miner
+
+ + + {block.miner} + + +
+
Parent
+
+ {num && num > 0 ? ( + + + {block.parentHash} + + + ) : ( + {block.parentHash} + )} +
+
Gas Used
+
{Number.parseInt(block.gasUsed, 16).toLocaleString()}
+
Gas Limit
+
{Number.parseInt(block.gasLimit, 16).toLocaleString()}
+
Base Fee
+
+ {block.baseFeePerGas + ? `${Number.parseInt(block.baseFeePerGas, 16).toLocaleString()} wei` + : "—"} +
+
+ +

Transactions ({block.transactions.length})

+ {block.transactions.length === 0 ? ( +

No transactions in this block.

+ ) : ( + + + + + + + + + {block.transactions.map((txHash, i) => ( + + + + + ))} + +
#Hash
{i} + + {txHash} + +
+ )} + + )} +
+ ); +} diff --git a/src/app/(vibenet)/explorer/layout.tsx b/src/app/(vibenet)/explorer/layout.tsx new file mode 100644 index 00000000..a271a3f9 --- /dev/null +++ b/src/app/(vibenet)/explorer/layout.tsx @@ -0,0 +1,14 @@ +import { ExplorerHeader } from "./_header"; + +export default function ExplorerLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + + {children} + + ); +} diff --git a/src/app/(vibenet)/explorer/page.tsx b/src/app/(vibenet)/explorer/page.tsx new file mode 100644 index 00000000..00ac20f6 --- /dev/null +++ b/src/app/(vibenet)/explorer/page.tsx @@ -0,0 +1,243 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; + +interface BlockRow { + number: number; + hash: string; + timestamp: number; + tx_count: number; + gas_used: number; +} + +interface TxRow { + hash: string; + block_num: number; + from_addr: string; + to_addr: string | null; + value: string; + status: number; +} + +interface Stats { + blocks: number; + txs: number; + addresses: number; +} + +function timeAgo(ts: number): string { + const s = Math.floor(Date.now() / 1000 - ts); + if (s < 60) return `${s}s ago`; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + return `${Math.floor(s / 3600)}h ago`; +} + +function shortHash(h: string, n = 18) { + return `${h.slice(0, n)}…`; +} + +export default function ExplorerPage() { + const [blocks, setBlocks] = useState([]); + const [txs, setTxs] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + const [newKeys, setNewKeys] = useState>(new Set()); + const [statHighlight, setStatHighlight] = useState>(new Set()); + const seenBlocks = useRef>(new Set()); + const seenTxs = useRef>(new Set()); + const lastStats = useRef(null); + + useEffect(() => { + const load = async () => { + const [blocksRes, statsRes] = await Promise.all([ + fetch("/api/vibenet/explorer/blocks").then((r) => r.json()), + fetch("/api/vibenet/explorer/stats").then((r) => r.json()), + ]); + const nextBlocks: BlockRow[] = blocksRes.blocks ?? []; + const nextTxs: TxRow[] = blocksRes.txs ?? []; + const nextStats: Stats = statsRes; + + const isFirstLoad = seenBlocks.current.size === 0; + if (!isFirstLoad) { + const fresh = new Set(); + for (const b of nextBlocks) { + if (!seenBlocks.current.has(b.hash)) fresh.add(`block-${b.hash}`); + } + for (const t of nextTxs) { + if (!seenTxs.current.has(t.hash)) fresh.add(`tx-${t.hash}`); + } + if (fresh.size > 0) { + setNewKeys(fresh); + window.setTimeout(() => setNewKeys(new Set()), 1300); + } + const statChanges = new Set(); + if (lastStats.current) { + if (lastStats.current.blocks !== nextStats.blocks) + statChanges.add("blocks"); + if (lastStats.current.txs !== nextStats.txs) statChanges.add("txs"); + if (lastStats.current.addresses !== nextStats.addresses) + statChanges.add("addresses"); + } + if (statChanges.size > 0) { + setStatHighlight(statChanges); + window.setTimeout(() => setStatHighlight(new Set()), 1000); + } + } + seenBlocks.current = new Set(nextBlocks.map((b) => b.hash)); + seenTxs.current = new Set(nextTxs.map((t) => t.hash)); + lastStats.current = nextStats; + + setBlocks(nextBlocks); + setTxs(nextTxs); + setStats(nextStats); + setLoading(false); + }; + load().catch(() => setLoading(false)); + const t = setInterval(load, 5_000); + return () => clearInterval(t); + }, []); + + return ( +
+ {stats && ( +
+
+ Blocks + {stats.blocks.toLocaleString()} +
+
+ Transactions + {stats.txs.toLocaleString()} +
+
+ Addresses + {stats.addresses.toLocaleString()} +
+
+ )} + +
+
+

Latest Blocks

+ {loading ? ( +
Indexing…
+ ) : blocks.length === 0 ? ( +
No blocks yet
+ ) : ( + + + + + + + + + + {blocks.slice(0, 10).map((b) => ( + + + + + + ))} + +
BlockTxsAge
+ + {b.number.toLocaleString()} + + {b.tx_count} + {timeAgo(b.timestamp)} +
+ )} +
+ +
+

Latest Transactions

+ {loading ? ( +
Indexing…
+ ) : txs.length === 0 ? ( +
No transactions yet
+ ) : ( + + + + + + + + + + + {txs.slice(0, 10).map((tx) => ( + + + + + + + ))} + +
HashFromToBlock
+ + {shortHash(tx.hash, 12)} + + + + + {tx.from_addr.slice(0, 8)}… + + + + {tx.to_addr ? ( + + + {tx.to_addr.slice(0, 8)}… + + + ) : ( + (create) + )} + + + {tx.block_num.toLocaleString()} + +
+ )} +
+
+
+ ); +} diff --git a/src/app/(vibenet)/explorer/tx/[hash]/page.tsx b/src/app/(vibenet)/explorer/tx/[hash]/page.tsx new file mode 100644 index 00000000..dac3f8a4 --- /dev/null +++ b/src/app/(vibenet)/explorer/tx/[hash]/page.tsx @@ -0,0 +1,373 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +interface PageProps { + params: Promise<{ hash: string }>; +} + +interface LogEntry { + address: string; + topics: string[]; + data: string; + logIndex: number; +} + +interface TxDetail { + hash: string; + blockHash: string; + blockNumber: string; + timestamp: string | null; + from: string; + to: string | null; + value: string; + gas: string; + gasUsed: string | null; + gasPrice: string; + effectiveGasPrice: string | null; + fee: string | null; + type: number | string | null; + typeHex?: string | null; + nonce: string; + input: string; + transactionIndex: string; + status: "ok" | "fail" | "pending"; + contractAddress: string | null; + logs: LogEntry[]; +} + +const ERC20_TRANSFER_TOPIC = + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + +function fmtHexInt(hex: string | null): string { + if (!hex) return "—"; + try { + return Number.parseInt(hex, 16).toLocaleString(); + } catch { + return hex; + } +} + +function weiToEth(hex: string | null): string { + if (!hex) return "—"; + const n = BigInt(hex); + if (n === 0n) return "0 ETH"; + const eth = Number(n) / 1e18; + return `${eth.toFixed(6)} ETH`; +} + +function weiToGwei(hex: string | null): string { + if (!hex) return "—"; + const n = BigInt(hex); + const gwei = Number(n) / 1e9; + return `${gwei.toFixed(4)} Gwei`; +} + +function tsHumanAndAge( + hex: string | null, +): { human: string; age: string } | null { + if (!hex) return null; + const ts = Number.parseInt(hex, 16); + const date = new Date(ts * 1000); + const seconds = Math.max(0, Math.floor(Date.now() / 1000) - ts); + const age = + seconds < 60 + ? `${seconds}s ago` + : seconds < 3600 + ? `${Math.floor(seconds / 60)}m ago` + : seconds < 86400 + ? `${Math.floor(seconds / 3600)}h ago` + : `${Math.floor(seconds / 86400)}d ago`; + return { human: date.toLocaleString(), age }; +} + +function shortHash(h: string, n = 12): string { + if (!h || h.length <= n + 4) return h; + return `${h.slice(0, n)}…${h.slice(-4)}`; +} + +const TYPE_BY_HEX: Record = { + "0x0": "Legacy", + "0x1": "EIP-2930 (Access List)", + "0x2": "EIP-1559", + "0x3": "EIP-4844 (Blob)", + "0x4": "EIP-7702 (Set Code)", + "0x7e": "Deposit (OP-stack)", +}; + +const TYPE_BY_NAME: Record = { + legacy: { hex: "0x0", label: "Legacy" }, + eip2930: { hex: "0x1", label: "EIP-2930 (Access List)" }, + eip1559: { hex: "0x2", label: "EIP-1559" }, + eip4844: { hex: "0x3", label: "EIP-4844 (Blob)" }, + eip7702: { hex: "0x4", label: "EIP-7702 (Set Code)" }, + deposit: { hex: "0x7e", label: "Deposit (OP-stack)" }, +}; + +function txTypeLabel( + type: number | string | null, + typeHex: string | null, +): { hex: string; label: string } | null { + if (typeof type === "string" && TYPE_BY_NAME[type]) return TYPE_BY_NAME[type]; + if (typeof type === "number") { + const hex = `0x${type.toString(16)}`; + return { hex, label: TYPE_BY_HEX[hex] ?? "Unknown" }; + } + if (typeHex) { + return { hex: typeHex, label: TYPE_BY_HEX[typeHex] ?? "Unknown" }; + } + return null; +} + +function decodeErc20Transfer(log: LogEntry) { + if (log.topics[0] !== ERC20_TRANSFER_TOPIC || log.topics.length < 3) + return null; + return { + token: log.address, + from: `0x${log.topics[1].slice(26)}`, + to: `0x${log.topics[2].slice(26)}`, + amount: log.data && log.data !== "0x" ? BigInt(log.data).toString() : "0", + }; +} + +export default function ExplorerTxPage({ params }: PageProps) { + const [hash, setHash] = useState(""); + const [tx, setTx] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + params.then((p) => setHash(p.hash)); + }, [params]); + + useEffect(() => { + if (!hash) return; + setLoading(true); + fetch(`/api/vibenet/explorer/tx/${hash}`) + .then(async (r) => { + if (!r.ok) + throw new Error( + r.status === 404 + ? "Transaction not found" + : "Failed to fetch transaction", + ); + return r.json(); + }) + .then(setTx) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [hash]); + + const blockNum = tx ? Number.parseInt(tx.blockNumber, 16) : null; + const tsInfo = tx ? tsHumanAndAge(tx.timestamp) : null; + const inputBytes = + tx?.input && tx.input !== "0x" ? (tx.input.length - 2) / 2 : 0; + const selector = + tx?.input && tx.input.length >= 10 ? tx.input.slice(0, 10) : null; + + return ( +
+

Transaction

+ {loading &&

Loading…

} + {error &&
{error}
} + {tx && ( + <> +

+ {tx.hash} +

+ +
+
Block
+
+ + {blockNum?.toLocaleString()} + +
+ {tsInfo && ( + <> +
Timestamp
+
+ {tsInfo.human} ({tsInfo.age}) +
+ + )} +
Status
+
+ + {tx.status === "ok" + ? "success" + : tx.status === "fail" + ? "failed" + : "pending"} + +
+ {(() => { + const tt = txTypeLabel(tx.type, tx.typeHex ?? null); + if (!tt) return null; + return ( + <> +
Type
+
+ {tt.hex}{" "} + ({tt.label}) +
+ + ); + })()} +
From
+
+ + {tx.from} + +
+
To
+
+ {tx.to ? ( + + {tx.to} + + ) : tx.contractAddress ? ( + <> + contract created:{" "} + + + {tx.contractAddress} + + + + ) : ( + (contract create, not yet mined) + )} +
+
Value
+
{weiToEth(tx.value)}
+
Nonce
+
{Number.parseInt(tx.nonce, 16).toString()}
+ {tx.fee && ( + <> +
Fee
+
{weiToEth(tx.fee)}
+ + )} +
Gas limit
+
{fmtHexInt(tx.gas)}
+ {tx.gasUsed && ( + <> +
Gas used
+
+ {fmtHexInt(tx.gasUsed)} {(() => { + const used = Number.parseInt(tx.gasUsed, 16); + const limit = Number.parseInt(tx.gas, 16); + if (!limit) return null; + const pct = ((used / limit) * 100).toFixed(1); + return ({pct}% of limit); + })()} +
+ + )} + {tx.effectiveGasPrice && ( + <> +
Effective gas price
+
{weiToGwei(tx.effectiveGasPrice)}
+ + )} + {selector && ( + <> +
Selector
+
+ {selector} +
+ + )} +
Input
+
+ {inputBytes === 0 ? ( + (empty) + ) : ( + <> + {inputBytes} bytes +
+                    {tx.input}
+                  
+ + )} +
+
+ +

Logs ({tx.logs.length})

+ {tx.logs.length === 0 ? ( +

No logs emitted.

+ ) : ( + tx.logs.map((log) => { + const transfer = decodeErc20Transfer(log); + return ( +
+
+ #{log.logIndex} + + + {shortHash(log.address, 8)} + + + {transfer && Transfer} +
+ {transfer && ( +
+
Token
+
+ + + {shortHash(transfer.token, 8)} + + +
+
From
+
+ + + {transfer.from} + + +
+
To
+
+ + + {transfer.to} + + +
+
Amount
+
+ {transfer.amount}{" "} + raw token units +
+
+ )} +
    + {log.topics.map((t, i) => ( +
  • + {t} +
  • + ))} +
+ {log.data && log.data !== "0x" && ( + <> +
+ Data ({(log.data.length - 2) / 2} bytes) +
+
+                        {log.data}
+                      
+ + )} +
+ ); + }) + )} + + )} +
+ ); +} diff --git a/src/app/(vibenet)/faucet/page.tsx b/src/app/(vibenet)/faucet/page.tsx new file mode 100644 index 00000000..646664e4 --- /dev/null +++ b/src/app/(vibenet)/faucet/page.tsx @@ -0,0 +1,386 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface FaucetStatus { + address: string; + chain_id: number; + drip_wei: string; + balance_wei: string; + ip_cooldown_secs: number; + addr_cooldown_secs: number; + usdv_address?: string; + usdv_drip_units?: string; + nfv_address?: string; +} + +type Token = "eth" | "usdv" | "nfv"; + +interface DripBody { + tx_hash?: string; + to?: string; + amount_wei?: string; + usdv_address?: string; + nfv_address?: string; + error?: string; +} + +interface DripResult { + state: "pending" | "success" | "error"; + token?: Token; + message?: string; + body?: DripBody; +} + +const PROD_HOSTS = { + ui: "vibes.base.org", + faucet: "faucet.vibes.base.org", + explorer: "explorer.vibes.base.org", +}; + +function isProdHost(host: string) { + return host === PROD_HOSTS.ui || host.endsWith(".vibes.base.org"); +} + +function buildHomeUrl() { + if (typeof window === "undefined") return "/"; + return isProdHost(window.location.hostname) + ? `https://${PROD_HOSTS.ui}` + : "/"; +} + +function buildExplorerUrl() { + if (typeof window === "undefined") return "/explorer"; + return isProdHost(window.location.hostname) + ? `https://${PROD_HOSTS.explorer}` + : "/explorer"; +} + +function formatEth(wei: string) { + return (Number(BigInt(wei)) / 1e18).toFixed(4); +} + +function formatUsdv(units: string) { + const n = Number(BigInt(units)) / 1e6; + return n.toLocaleString(undefined, { maximumFractionDigits: 2 }); +} + +function shortAddress(value: string) { + if (!value || value.length <= 14) return value || ""; + return `${value.slice(0, 6)}…${value.slice(-4)}`; +} + +const ENDPOINTS: Record = { + eth: "/api/vibenet/faucet/drip", + usdv: "/api/vibenet/faucet/drip-usdv", + nfv: "/api/vibenet/faucet/drip-nfv", +}; + +const ASSET_LABEL: Record = { + eth: "ETH", + usdv: "USDV", + nfv: "NFV", +}; + +export default function FaucetPage() { + const [status, setStatus] = useState(null); + const [statusError, setStatusError] = useState(null); + const [address, setAddress] = useState(""); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); + const [homeUrl, setHomeUrl] = useState("/"); + const [explorerUrl, setExplorerUrl] = useState("/explorer"); + + useEffect(() => { + setHomeUrl(buildHomeUrl()); + setExplorerUrl(buildExplorerUrl()); + + const load = () => + fetch("/api/vibenet/faucet/status") + .then(async (r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then((s) => { + setStatus(s); + setStatusError(null); + }) + .catch((e) => setStatusError(String(e.message ?? e))); + load(); + const t = setInterval(load, 15_000); + return () => clearInterval(t); + }, []); + + const drip = async (token: Token) => { + if (!address.match(/^0x[a-fA-F0-9]{40}$/)) { + setResult({ state: "error", token, message: "Invalid address" }); + return; + } + setBusy(true); + setResult({ state: "pending", token }); + try { + const res = await fetch(ENDPOINTS[token], { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address }), + }); + const body: DripBody = await res.json().catch(() => ({})); + if (!res.ok) { + const reason = + body.error ?? + (res.status === 429 + ? "rate limited — wait a minute and try again" + : `HTTP ${res.status}`); + setResult({ state: "error", token, message: reason }); + } else { + setResult({ state: "success", token, body }); + } + } catch (err) { + setResult({ + state: "error", + token, + message: err instanceof Error ? err.message : String(err), + }); + } finally { + setBusy(false); + // Refresh balance after a drip + fetch("/api/vibenet/faucet/status") + .then((r) => r.json()) + .then(setStatus) + .catch(() => null); + } + }; + + const ethDripLabel = status + ? `Request ${formatEth(status.drip_wei)} ETH` + : "Request ETH"; + const usdvDripLabel = + status?.usdv_drip_units != null + ? `Request ${formatUsdv(status.usdv_drip_units)} USDV` + : "Request USDV"; + + return ( + <> +
+ + + +
+ +
+
+

Faucet

+

+ Drip testnet ETH or mint USDV (Vibe USD) / NFV (NFT) to any address. +

+ +
+
+ {statusError ? ( + + Could not load faucet status: {statusError} + + ) : !status ? ( + Loading status… + ) : ( +
+
+ ETH + + {formatEth(status.balance_wei)} ETH + +
+
+ USDV + + {status.usdv_address ? "Ready to print" : "Not deployed"} + +
+
+ NFV + + {status.nfv_address ? "Ready to mint" : "Not deployed"} + +
+
+ )} +
+ +
{ + e.preventDefault(); + drip("eth"); + }} + > + + setAddress(e.target.value)} + placeholder="0x…" + pattern="^0x[a-fA-F0-9]{40}$" + required + /> +
+ + + {status?.nfv_address && ( + + )} +
+
+ + {result && ( +
+ {result.state === "pending" && result.token && ( + + {result.token === "eth" + ? "Requesting ETH…" + : `Minting ${ASSET_LABEL[result.token]}…`} + + )} + {result.state === "error" && ( + + {result.token + ? `${ASSET_LABEL[result.token]} request failed` + : "Request failed"} + : {result.message} + + )} + {result.state === "success" && result.body && result.token && ( + <> +
+ {ASSET_LABEL[result.token]} request submitted +
+
+ Transaction{" "} + + {shortAddress(result.body.tx_hash ?? "")} + + {" → "} + + {shortAddress(result.body.to ?? "")} + + {result.token === "usdv" && result.body.usdv_address && ( + <> + {" via "} + + USDV + + + )} + {result.token === "nfv" && result.body.nfv_address && ( + <> + {" via "} + + NFV + + + )} +
+ + )} +
+ )} +
+
+
+ + + + ); +} diff --git a/src/app/(vibenet)/icon.svg b/src/app/(vibenet)/icon.svg new file mode 100644 index 00000000..95cde174 --- /dev/null +++ b/src/app/(vibenet)/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/(vibenet)/layout.tsx b/src/app/(vibenet)/layout.tsx new file mode 100644 index 00000000..6e18ae8c --- /dev/null +++ b/src/app/(vibenet)/layout.tsx @@ -0,0 +1,9 @@ +import "@/styles/vibenet.css"; + +export default function VibenetLayout({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} diff --git a/src/app/(vibenet)/page.tsx b/src/app/(vibenet)/page.tsx new file mode 100644 index 00000000..18213a86 --- /dev/null +++ b/src/app/(vibenet)/page.tsx @@ -0,0 +1,393 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createWalletClient, custom } from "viem"; + +declare global { + interface Window { + // biome-ignore lint/suspicious/noExplicitAny: EIP-1193 provider + ethereum?: any; + } +} + +interface Feature { + title: string; + description: string; + link?: string; +} + +interface VibeConfig { + title?: string; + subtitle?: string; + features?: Feature[]; + branch?: string; + commit?: string; +} + +const PROD_HOSTS = { + ui: "vibes.base.org", + rpc: "rpc.vibes.base.org", + faucet: "faucet.vibes.base.org", + explorer: "explorer.vibes.base.org", +}; + +function isProd() { + if (typeof window === "undefined") return false; + const h = window.location.hostname; + return h === PROD_HOSTS.ui || h.endsWith(".vibes.base.org"); +} + +// In production each subdomain is reachable on its own host. Locally we +// surface vibescan + faucet as path-routed siblings of the landing page. +function buildRpcUrl() { + if (isProd()) return `https://${PROD_HOSTS.rpc}`; + if (typeof window === "undefined") return "http://localhost:18082"; + const port = Number(window.location.port || 80); + return `${window.location.protocol}//${window.location.hostname}:${port + 2}`; +} + +function buildFaucetUrl() { + if (isProd()) return `https://${PROD_HOSTS.faucet}`; + return "/faucet"; +} + +function buildExplorerUrl() { + if (isProd()) return `https://${PROD_HOSTS.explorer}`; + return "/explorer"; +} + +const CONTRACT_LABELS: Record = { + faucetAddress: "Faucet", + usdv: "USDV (ERC-20)", + nfv: "NFV (ERC-721)", +}; + +const WATCHABLE_TOKENS: Record< + string, + { type: "ERC20"; symbol: string; decimals: number } +> = { + usdv: { type: "ERC20", symbol: "USDV", decimals: 6 }, +}; + +export default function VibeHomePage() { + const [config, setConfig] = useState({}); + const [contracts, setContracts] = useState | null>( + null, + ); + const [chainId, setChainId] = useState(""); + const [rpcUrl, setRpcUrl] = useState(""); + const [faucetUrl, setFaucetUrl] = useState(""); + const [explorerUrl, setExplorerUrl] = useState(""); + const [copied, setCopied] = useState(null); + const [walletStatus, setWalletStatus] = useState(""); + const [watchStatus, setWatchStatus] = useState>({}); + + useEffect(() => { + setRpcUrl(buildRpcUrl()); + setFaucetUrl(buildFaucetUrl()); + setExplorerUrl(buildExplorerUrl()); + + fetch("/api/vibenet/config") + .then((r) => r.json()) + .then(setConfig) + .catch(() => null); + + fetch("/api/vibenet/contracts") + .then((r) => r.json()) + .then(setContracts) + .catch(() => setContracts(null)); + + fetch("/api/vibenet/faucet/status") + .then((r) => r.json()) + .then((d: { chain_id?: number }) => { + if (d.chain_id) setChainId(String(d.chain_id)); + }) + .catch(() => null); + }, []); + + const copy = async (text: string, key: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(key); + setTimeout(() => setCopied(null), 1500); + } catch { + /* noop */ + } + }; + + const addToWallet = async () => { + if (!window.ethereum) { + setWalletStatus("No browser wallet detected on this page."); + return; + } + try { + const wallet = createWalletClient({ transport: custom(window.ethereum) }); + await wallet.addChain({ + chain: { + id: Number(chainId), + name: "base vibenet", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: [rpcUrl] } }, + blockExplorers: { + default: { name: "vibescan", url: explorerUrl }, + }, + }, + }); + setWalletStatus( + "Network added. Your wallet should now be on base vibenet.", + ); + } catch (err) { + const e = err as { shortMessage?: string; message?: string }; + setWalletStatus( + `Wallet did not add the network: ${e.shortMessage ?? e.message ?? String(err)}`, + ); + } + }; + + const watchAsset = async ( + address: string, + meta: (typeof WATCHABLE_TOKENS)[string], + ) => { + if (!window.ethereum) { + setWatchStatus((s) => ({ ...s, [address]: "No wallet detected" })); + setTimeout(() => setWatchStatus((s) => ({ ...s, [address]: "" })), 1500); + return; + } + try { + const wallet = createWalletClient({ transport: custom(window.ethereum) }); + await wallet.watchAsset({ + type: meta.type, + options: { + address: address as `0x${string}`, + symbol: meta.symbol, + decimals: meta.decimals, + }, + }); + setWatchStatus((s) => ({ ...s, [address]: `${meta.symbol} added` })); + } catch (err) { + const e = err as { shortMessage?: string }; + setWatchStatus((s) => ({ + ...s, + [address]: e.shortMessage ?? "Rejected", + })); + } finally { + setTimeout(() => setWatchStatus((s) => ({ ...s, [address]: "" })), 1800); + } + }; + + const features = config.features ?? []; + const contractEntries = contracts + ? Object.entries(contracts).filter( + ([k, v]) => + !k.startsWith("_") && + k !== "faucetAddress" && + typeof v === "string" && + /^0x[0-9a-fA-F]{40}$/.test(v), + ) + : null; + + return ( + <> +
+ + + +
+ +
+
+

{config.title ?? "base vibenet"}

+

+ {config.subtitle ?? + "An ephemeral Base devnet for trying out in-flight features."} +

+
+ +
+

Features

+ {features.length === 0 ? ( +

+ No branch-specific features declared for this vibe. +

+ ) : ( +
+ {features.map((f) => ( +
+
{f.title}
+ {f.description && ( +
{f.description}
+ )} + {f.link && ( + + Learn more → + + )} +
+ ))} +
+ )} +
+ +
+

Connect

+
+
+ Chain ID + +
+
+ RPC URL + +
+
+ Explorer + + {explorerUrl} + +
+

+ Public RPC access is IP rate limited. +

+
+ +
+ {walletStatus && ( +

+ {walletStatus} +

+ )} +
+
+ +
+

Deployed Contracts

+
+ {!contractEntries ? ( +

+ Loading… +

+ ) : contractEntries.length === 0 ? ( +

+ No contracts deployed on this vibe. +

+ ) : ( + contractEntries.map(([k, v]) => { + const meta = WATCHABLE_TOKENS[k]; + return ( +
+ + {CONTRACT_LABELS[k] ?? k} + + + {v} + + {meta ? ( + + ) : ( + + )} +
+ ); + }) + )} +
+
+
+ + + + ); +} diff --git a/src/app/api/vibenet/config/route.ts b/src/app/api/vibenet/config/route.ts new file mode 100644 index 00000000..c0e96e65 --- /dev/null +++ b/src/app/api/vibenet/config/route.ts @@ -0,0 +1,29 @@ +import { readFile } from "node:fs/promises"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +const CONFIG_PATH = process.env.VIBENET_CONFIG_PATH ?? "/config/config.json"; + +export async function GET() { + try { + const raw = await readFile(CONFIG_PATH, "utf8"); + return new NextResponse(raw, { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } catch { + return NextResponse.json( + { + title: "base vibenet", + subtitle: "An ephemeral Base devnet.", + features: [], + branch: "unknown", + commit: "unknown", + }, + { headers: { "Cache-Control": "no-store" } }, + ); + } +} diff --git a/src/app/api/vibenet/contracts/route.ts b/src/app/api/vibenet/contracts/route.ts new file mode 100644 index 00000000..079b5a9a --- /dev/null +++ b/src/app/api/vibenet/contracts/route.ts @@ -0,0 +1,23 @@ +import { readFile } from "node:fs/promises"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +const CONTRACTS_PATH = + process.env.VIBENET_CONTRACTS_PATH ?? "/shared/contracts.json"; + +export async function GET() { + try { + const raw = await readFile(CONTRACTS_PATH, "utf8"); + return new NextResponse(raw, { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } catch { + // contracts.json is written by vibenet-setup after chain is ready; + // return empty object during the boot window + return NextResponse.json({}, { headers: { "Cache-Control": "no-store" } }); + } +} diff --git a/src/app/api/vibenet/explorer/address/[addr]/route.ts b/src/app/api/vibenet/explorer/address/[addr]/route.ts new file mode 100644 index 00000000..ade546a3 --- /dev/null +++ b/src/app/api/vibenet/explorer/address/[addr]/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { createPublicClient, defineChain, http, isAddress } from "viem"; +import { getAddressActivity } from "@/lib/vibenet/db"; + +const rpcUrl = process.env.VIBESCAN_RPC_HTTP_URL ?? "http://localhost:8545"; +const chainId = Number(process.env.VIBESCAN_CHAIN_ID ?? "84538453"); + +function getClient() { + return createPublicClient({ + chain: defineChain({ + id: chainId, + name: "vibenet", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: [rpcUrl] } }, + }), + transport: http(rpcUrl), + }); +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ addr: string }> }, +) { + const { addr } = await params; + if (!isAddress(addr)) { + return NextResponse.json({ error: "Invalid address" }, { status: 400 }); + } + + try { + const client = getClient(); + const [balance, code, nonce, activity] = await Promise.all([ + client.getBalance({ address: addr }), + client.getCode({ address: addr }).catch(() => undefined), + client.getTransactionCount({ address: addr }).catch(() => 0), + Promise.resolve(getAddressActivity(addr)), + ]); + + const codeBytes = code && code !== "0x" ? (code.length - 2) / 2 : 0; + const isContract = codeBytes > 0; + + return NextResponse.json({ + address: addr, + balance_wei: `0x${balance.toString(16)}`, + nonce, + is_contract: isContract, + code_size: codeBytes, + activity, + }); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } +} diff --git a/src/app/api/vibenet/explorer/block/[hash]/route.ts b/src/app/api/vibenet/explorer/block/[hash]/route.ts new file mode 100644 index 00000000..d1835726 --- /dev/null +++ b/src/app/api/vibenet/explorer/block/[hash]/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { createPublicClient, defineChain, http } from "viem"; + +const rpcUrl = process.env.VIBESCAN_RPC_HTTP_URL ?? "http://localhost:8545"; +const chainId = Number(process.env.VIBESCAN_CHAIN_ID ?? "84538453"); + +function getClient() { + return createPublicClient({ + chain: defineChain({ + id: chainId, + name: "vibenet", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: [rpcUrl] } }, + }), + transport: http(rpcUrl), + }); +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ hash: string }> }, +) { + const { hash } = await params; + try { + const client = getClient(); + // Accepts block hash or block number + const block = + hash.startsWith("0x") && hash.length === 66 + ? await client.getBlock({ + blockHash: hash as `0x${string}`, + includeTransactions: false, + }) + : await client.getBlock({ + blockNumber: BigInt(hash), + includeTransactions: false, + }); + + if (!block) { + return NextResponse.json({ error: "Block not found" }, { status: 404 }); + } + // Serialize bigints as hex strings (matching go-ethereum RPC format) + return NextResponse.json({ + number: `0x${block.number?.toString(16)}`, + hash: block.hash, + parentHash: block.parentHash, + timestamp: `0x${block.timestamp.toString(16)}`, + miner: block.miner, + gasUsed: `0x${block.gasUsed.toString(16)}`, + gasLimit: `0x${block.gasLimit.toString(16)}`, + baseFeePerGas: block.baseFeePerGas + ? `0x${block.baseFeePerGas.toString(16)}` + : null, + transactions: block.transactions, + }); + } catch (e) { + const msg = String(e); + if (msg.includes("not found") || msg.includes("null")) { + return NextResponse.json({ error: "Block not found" }, { status: 404 }); + } + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/vibenet/explorer/blocks/route.ts b/src/app/api/vibenet/explorer/blocks/route.ts new file mode 100644 index 00000000..5cc53c81 --- /dev/null +++ b/src/app/api/vibenet/explorer/blocks/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { getRecentBlocks, getRecentTxs } from "@/lib/vibenet/db"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + return NextResponse.json({ + blocks: getRecentBlocks(20), + txs: getRecentTxs(20), + }); + } catch { + return NextResponse.json({ blocks: [], txs: [] }); + } +} diff --git a/src/app/api/vibenet/explorer/stats/route.ts b/src/app/api/vibenet/explorer/stats/route.ts new file mode 100644 index 00000000..c00f3f33 --- /dev/null +++ b/src/app/api/vibenet/explorer/stats/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { getStats } from "@/lib/vibenet/db"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + return NextResponse.json(getStats()); + } catch { + return NextResponse.json({ blocks: 0, txs: 0, addresses: 0 }); + } +} diff --git a/src/app/api/vibenet/explorer/tx/[hash]/route.ts b/src/app/api/vibenet/explorer/tx/[hash]/route.ts new file mode 100644 index 00000000..9ac30b72 --- /dev/null +++ b/src/app/api/vibenet/explorer/tx/[hash]/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from "next/server"; +import { createPublicClient, defineChain, http } from "viem"; + +const rpcUrl = process.env.VIBESCAN_RPC_HTTP_URL ?? "http://localhost:8545"; +const chainId = Number(process.env.VIBESCAN_CHAIN_ID ?? "84538453"); + +function getClient() { + return createPublicClient({ + chain: defineChain({ + id: chainId, + name: "vibenet", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: [rpcUrl] } }, + }), + transport: http(rpcUrl), + }); +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ hash: string }> }, +) { + const { hash } = await params; + try { + const client = getClient(); + const [tx, receipt, block] = await Promise.all([ + client.getTransaction({ hash: hash as `0x${string}` }), + client + .getTransactionReceipt({ hash: hash as `0x${string}` }) + .catch(() => null), + // We need the block timestamp for the tx detail page + client + .getTransaction({ hash: hash as `0x${string}` }) + .then((t) => + t.blockNumber + ? client.getBlock({ blockNumber: t.blockNumber }).catch(() => null) + : null, + ), + ]); + + if (!tx) { + return NextResponse.json( + { error: "Transaction not found" }, + { status: 404 }, + ); + } + + const gasUsed = receipt?.gasUsed + ? `0x${receipt.gasUsed.toString(16)}` + : null; + const effectiveGasPrice = receipt?.effectiveGasPrice + ? `0x${receipt.effectiveGasPrice.toString(16)}` + : null; + const fee = + receipt?.gasUsed && receipt?.effectiveGasPrice + ? `0x${(receipt.gasUsed * receipt.effectiveGasPrice).toString(16)}` + : null; + const contractAddress = receipt?.contractAddress ?? null; + + return NextResponse.json({ + hash: tx.hash, + blockHash: tx.blockHash, + blockNumber: tx.blockNumber ? `0x${tx.blockNumber.toString(16)}` : null, + timestamp: block?.timestamp ? `0x${block.timestamp.toString(16)}` : null, + from: tx.from, + to: tx.to, + value: `0x${tx.value.toString(16)}`, + gas: `0x${tx.gas.toString(16)}`, + gasPrice: tx.gasPrice ? `0x${tx.gasPrice.toString(16)}` : "0x0", + gasUsed, + effectiveGasPrice, + fee, + type: tx.type ?? null, + typeHex: tx.typeHex ?? (typeof tx.type === "string" ? tx.type : null), + nonce: `0x${tx.nonce.toString(16)}`, + input: tx.input, + transactionIndex: + tx.transactionIndex !== null + ? `0x${tx.transactionIndex.toString(16)}` + : null, + status: + receipt?.status === "success" + ? "ok" + : receipt?.status === "reverted" + ? "fail" + : "pending", + contractAddress, + logs: (receipt?.logs ?? []).map((log) => ({ + address: log.address, + topics: log.topics, + data: log.data, + logIndex: log.logIndex, + })), + }); + } catch (e) { + const msg = String(e); + if (msg.includes("not found") || msg.includes("null")) { + return NextResponse.json( + { error: "Transaction not found" }, + { status: 404 }, + ); + } + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/vibenet/faucet/drip-nfv/route.ts b/src/app/api/vibenet/faucet/drip-nfv/route.ts new file mode 100644 index 00000000..5c672c37 --- /dev/null +++ b/src/app/api/vibenet/faucet/drip-nfv/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { encodeFunctionData, isAddress, parseAbi } from "viem"; +import { + checkCooldown, + clientIp, + getAddrCooldownSecs, + getContractAddress, + getIpCooldownSecs, + getPublicClient, + getWalletClient, + recordDrip, +} from "@/lib/vibenet/faucet"; + +export const dynamic = "force-dynamic"; + +const MINT_ABI = parseAbi([ + "function mint(address to) external returns (uint256)", +]); + +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})); + const { address } = body as { address?: string }; + + if (!address || !isAddress(address)) { + return NextResponse.json({ error: "Invalid address" }, { status: 400 }); + } + + const nfvAddress = await getContractAddress("nfv"); + if (!nfvAddress) { + return NextResponse.json( + { error: "NFV contract not yet deployed. Try again shortly." }, + { status: 503 }, + ); + } + + const ip = clientIp(req); + const cooldownErr = checkCooldown( + "nfv", + ip, + address, + getIpCooldownSecs(), + getAddrCooldownSecs(), + ); + if (cooldownErr) { + return NextResponse.json({ error: cooldownErr }, { status: 429 }); + } + + try { + const wallet = getWalletClient(); + const publicClient = getPublicClient(); + const data = encodeFunctionData({ + abi: MINT_ABI, + functionName: "mint", + args: [address], + }); + const hash = await wallet.sendTransaction({ to: nfvAddress, data }); + await publicClient.waitForTransactionReceipt({ hash }); + recordDrip("nfv", ip, address); + return NextResponse.json({ + tx_hash: hash, + to: address, + nfv_address: nfvAddress, + }); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } +} diff --git a/src/app/api/vibenet/faucet/drip-usdv/route.ts b/src/app/api/vibenet/faucet/drip-usdv/route.ts new file mode 100644 index 00000000..007298ff --- /dev/null +++ b/src/app/api/vibenet/faucet/drip-usdv/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { encodeFunctionData, isAddress, parseAbi } from "viem"; +import { + checkCooldown, + clientIp, + getAddrCooldownSecs, + getContractAddress, + getIpCooldownSecs, + getPublicClient, + getUsdvDripUnits, + getWalletClient, + recordDrip, +} from "@/lib/vibenet/faucet"; + +export const dynamic = "force-dynamic"; + +const MINT_ABI = parseAbi([ + "function mint(address to, uint256 amount) external", +]); + +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})); + const { address } = body as { address?: string }; + + if (!address || !isAddress(address)) { + return NextResponse.json({ error: "Invalid address" }, { status: 400 }); + } + + const usdvAddress = await getContractAddress("usdv"); + if (!usdvAddress) { + return NextResponse.json( + { error: "USDV contract not yet deployed. Try again shortly." }, + { status: 503 }, + ); + } + + const ip = clientIp(req); + const cooldownErr = checkCooldown( + "usdv", + ip, + address, + getIpCooldownSecs(), + getAddrCooldownSecs(), + ); + if (cooldownErr) { + return NextResponse.json({ error: cooldownErr }, { status: 429 }); + } + + try { + const wallet = getWalletClient(); + const publicClient = getPublicClient(); + const data = encodeFunctionData({ + abi: MINT_ABI, + functionName: "mint", + args: [address, getUsdvDripUnits()], + }); + const hash = await wallet.sendTransaction({ to: usdvAddress, data }); + await publicClient.waitForTransactionReceipt({ hash }); + recordDrip("usdv", ip, address); + return NextResponse.json({ + tx_hash: hash, + to: address, + usdv_address: usdvAddress, + }); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } +} diff --git a/src/app/api/vibenet/faucet/drip/route.ts b/src/app/api/vibenet/faucet/drip/route.ts new file mode 100644 index 00000000..a8973196 --- /dev/null +++ b/src/app/api/vibenet/faucet/drip/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import { isAddress } from "viem"; +import { + checkCooldown, + clientIp, + getAddrCooldownSecs, + getDripWei, + getIpCooldownSecs, + getPublicClient, + getWalletClient, + recordDrip, +} from "@/lib/vibenet/faucet"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})); + const { address } = body as { address?: string }; + + if (!address || !isAddress(address)) { + return NextResponse.json({ error: "Invalid address" }, { status: 400 }); + } + + const ip = clientIp(req); + const cooldownErr = checkCooldown( + "eth", + ip, + address, + getIpCooldownSecs(), + getAddrCooldownSecs(), + ); + if (cooldownErr) { + return NextResponse.json({ error: cooldownErr }, { status: 429 }); + } + + try { + const wallet = getWalletClient(); + const publicClient = getPublicClient(); + const hash = await wallet.sendTransaction({ + to: address, + value: getDripWei(), + }); + await publicClient.waitForTransactionReceipt({ hash }); + recordDrip("eth", ip, address); + return NextResponse.json({ + tx_hash: hash, + amount_wei: getDripWei().toString(), + to: address, + }); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } +} diff --git a/src/app/api/vibenet/faucet/status/route.ts b/src/app/api/vibenet/faucet/status/route.ts new file mode 100644 index 00000000..8bdf1421 --- /dev/null +++ b/src/app/api/vibenet/faucet/status/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { + getAddrCooldownSecs, + getContractAddress, + getDripWei, + getFaucetAddress, + getIpCooldownSecs, + getPublicClient, + getUsdvDripUnits, +} from "@/lib/vibenet/faucet"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const address = getFaucetAddress(); + const client = getPublicClient(); + const balance = await client.getBalance({ address }); + const [usdvAddress, nfvAddress] = await Promise.all([ + getContractAddress("usdv"), + getContractAddress("nfv"), + ]); + + return NextResponse.json({ + address, + chain_id: client.chain.id, + drip_wei: getDripWei().toString(), + balance_wei: balance.toString(), + ip_cooldown_secs: getIpCooldownSecs(), + addr_cooldown_secs: getAddrCooldownSecs(), + ...(usdvAddress && { + usdv_address: usdvAddress, + usdv_drip_units: getUsdvDripUnits().toString(), + }), + ...(nfvAddress && { nfv_address: nfvAddress }), + }); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index bc9a9f21..70210617 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "TIPS", - description: "A beautiful UI for interacting with TIPS", + title: "base vibenet", + description: "An ephemeral Base devnet for trying out in-flight features.", }; export default function RootLayout({ diff --git a/src/app/block/[hash]/page.tsx b/src/app/tips/block/[hash]/page.tsx similarity index 98% rename from src/app/block/[hash]/page.tsx rename to src/app/tips/block/[hash]/page.tsx index c5d0a4a0..037a7cdf 100644 --- a/src/app/block/[hash]/page.tsx +++ b/src/app/tips/block/[hash]/page.tsx @@ -10,6 +10,7 @@ const BLOCK_EXPLORER_URL = process.env.NEXT_PUBLIC_BLOCK_EXPLORER_URL; // meterBundleResponse. The page receives this shape via JSON. // biome-ignore lint/suspicious/noExplicitAny: opaque metering JSON function metering(tx: BlockTransaction): any { + // biome-ignore lint/suspicious/noExplicitAny: opaque metering JSON return (tx as any).metering; } @@ -181,7 +182,7 @@ function TransactionRow({ ); if (hasBundle) { - return {content}; + return {content}; } return content; @@ -331,7 +332,7 @@ export default function BlockPage({ params }: PageProps) {
@@ -352,7 +353,7 @@ export default function BlockPage({ params }: PageProps) {
TIPS @@ -363,7 +364,7 @@ export default function BlockPage({ params }: PageProps) {
{Number(data.number) > 0 ? ( @@ -401,7 +402,7 @@ export default function BlockPage({ params }: PageProps) { )} diff --git a/src/app/bundles/[hash]/page.tsx b/src/app/tips/bundles/[hash]/page.tsx similarity index 99% rename from src/app/bundles/[hash]/page.tsx rename to src/app/tips/bundles/[hash]/page.tsx index 9ea8602a..57522888 100644 --- a/src/app/bundles/[hash]/page.tsx +++ b/src/app/tips/bundles/[hash]/page.tsx @@ -415,7 +415,7 @@ function TimelineEventDetails({
{event.event} Block #{event.data.block_number} @@ -542,7 +542,7 @@ export default function BundlePage({ params }: PageProps) {
@@ -563,7 +563,7 @@ export default function BundlePage({ params }: PageProps) {
TIPS diff --git a/src/app/page.tsx b/src/app/tips/page.tsx similarity index 99% rename from src/app/page.tsx rename to src/app/tips/page.tsx index 1b031193..80693324 100644 --- a/src/app/page.tsx +++ b/src/app/tips/page.tsx @@ -3,13 +3,13 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; +import type { BlockSummary, BlocksResponse } from "@/app/api/blocks/route"; +import type { RejectedTransactionsResponse } from "@/app/api/rejected/route"; import { formatRejectionReason, type MeterBundleResponse, type RejectedTransaction, } from "@/lib/s3"; -import type { BlockSummary, BlocksResponse } from "./api/blocks/route"; -import type { RejectedTransactionsResponse } from "./api/rejected/route"; type Tab = "blocks" | "rejected"; @@ -66,7 +66,7 @@ function SearchBar({ onError }: { onError: (error: string | null) => void }) { const result = await response.json(); if (result.bundle_ids && result.bundle_ids.length > 0) { - router.push(`/bundles/${result.bundle_ids[0]}`); + router.push(`/tips/bundles/${result.bundle_ids[0]}`); } else { onError("No bundle found for this transaction"); } @@ -112,7 +112,7 @@ function BlockRow({ block, index }: { block: BlockSummary; index: number }) { return ( diff --git a/src/app/txn/[hash]/page.tsx b/src/app/tips/txn/[hash]/page.tsx similarity index 97% rename from src/app/txn/[hash]/page.tsx rename to src/app/tips/txn/[hash]/page.tsx index 76f1dda3..e98211b0 100644 --- a/src/app/txn/[hash]/page.tsx +++ b/src/app/tips/txn/[hash]/page.tsx @@ -39,7 +39,7 @@ export default function TransactionRedirectPage({ params }: PageProps) { const result: TransactionHistoryResponse = await response.json(); if (result.bundle_ids && result.bundle_ids.length > 0) { - router.push(`/bundles/${result.bundle_ids[0]}`); + router.push(`/tips/bundles/${result.bundle_ids[0]}`); } else { setError("No bundle found for this transaction"); } diff --git a/src/lib/vibenet/db.ts b/src/lib/vibenet/db.ts new file mode 100644 index 00000000..d78bc5f7 --- /dev/null +++ b/src/lib/vibenet/db.ts @@ -0,0 +1,115 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import Database from "better-sqlite3"; + +const DB_PATH = process.env.VIBESCAN_DB_PATH ?? "/data/vibescan.db"; +const MIGRATION_PATH = path.join( + process.cwd(), + "docker/migrations/0001_init.sql", +); + +let _db: Database.Database | null = null; + +export function getDb(): Database.Database { + if (_db) return _db; + _db = new Database(DB_PATH); + _db.pragma("journal_mode = WAL"); + _db.pragma("synchronous = NORMAL"); + const migration = readFileSync(MIGRATION_PATH, "utf8"); + _db.exec(migration); + return _db; +} + +export interface BlockRow { + number: number; + hash: string; + timestamp: number; + miner: string; + tx_count: number; + gas_used: number; + gas_limit: number; + base_fee: string | null; +} + +export interface TxRow { + hash: string; + block_num: number; + tx_index: number; + from_addr: string; + to_addr: string | null; + value: string; + status: number; + created: string | null; +} + +export interface ActivityRow { + address: string; + block_num: number; + tx_index: number; + log_index: number; + tx_hash: string; + role: number; + token: string | null; +} + +export interface StatsRow { + blocks: number; + txs: number; + addresses: number; +} + +export function getStats(): StatsRow { + const db = getDb(); + return db + .prepare("SELECT blocks, txs, addresses FROM explorer_stats WHERE id = 0") + .get() as StatsRow; +} + +export function getRecentBlocks(limit = 20): BlockRow[] { + const db = getDb(); + return db + .prepare("SELECT * FROM blocks ORDER BY number DESC LIMIT ?") + .all(limit) as BlockRow[]; +} + +export function getRecentTxs(limit = 20): TxRow[] { + const db = getDb(); + return db + .prepare("SELECT * FROM txs ORDER BY block_num DESC, tx_index DESC LIMIT ?") + .all(limit) as TxRow[]; +} + +export function getBlockHash(number: number): string | null { + const db = getDb(); + const row = db + .prepare("SELECT hash FROM blocks WHERE number = ?") + .get(number) as { hash: string } | undefined; + return row?.hash ?? null; +} + +export function getAddressActivity(address: string, limit = 50): ActivityRow[] { + const db = getDb(); + return db + .prepare( + `SELECT * FROM address_activity + WHERE address = ? + ORDER BY block_num DESC, tx_index DESC, log_index DESC + LIMIT ?`, + ) + .all(address.toLowerCase(), limit) as ActivityRow[]; +} + +export function getCursor(): { + last_indexed_block: number; + last_indexed_hash: string; +} | null { + const db = getDb(); + return (db + .prepare( + "SELECT last_indexed_block, last_indexed_hash FROM cursor WHERE id = 0", + ) + .get() ?? null) as { + last_indexed_block: number; + last_indexed_hash: string; + } | null; +} diff --git a/src/lib/vibenet/faucet.ts b/src/lib/vibenet/faucet.ts new file mode 100644 index 00000000..fbad0594 --- /dev/null +++ b/src/lib/vibenet/faucet.ts @@ -0,0 +1,132 @@ +import { readFile } from "node:fs/promises"; +import { + type Address, + createPublicClient, + createWalletClient, + defineChain, + type Hex, + http, + parseEther, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +// Rate limiting: last drip timestamp per IP / per address, per asset. +// Module-level state — resets on container restart (intentional for devnet). +const ipCooldowns = new Map>(); // asset → ip → ts +const addrCooldowns = new Map>(); // asset → addr → ts + +function cooldownMap(store: Map>, asset: string) { + let m = store.get(asset); + if (!m) { + m = new Map(); + store.set(asset, m); + } + return m; +} + +export function checkCooldown( + asset: string, + ip: string, + address: string, + ipSecs: number, + addrSecs: number, +): string | null { + const now = Date.now() / 1000; + const ipMap = cooldownMap(ipCooldowns, asset); + const addrMap = cooldownMap(addrCooldowns, asset); + + const lastIp = ipMap.get(ip) ?? 0; + const lastAddr = addrMap.get(address.toLowerCase()) ?? 0; + + if (now - lastIp < ipSecs) { + const wait = Math.ceil(ipSecs - (now - lastIp)); + return `IP rate limited. Try again in ${wait}s.`; + } + if (now - lastAddr < addrSecs) { + const wait = Math.ceil(addrSecs - (now - lastAddr)); + return `Address rate limited. Try again in ${wait}s.`; + } + return null; +} + +export function recordDrip(asset: string, ip: string, address: string) { + const now = Date.now() / 1000; + cooldownMap(ipCooldowns, asset).set(ip, now); + cooldownMap(addrCooldowns, asset).set(address.toLowerCase(), now); +} + +function getChain() { + const chainId = Number(process.env.VIBENET_FAUCET_CHAIN_ID ?? "84538453"); + const rpcUrl = process.env.VIBENET_FAUCET_RPC_URL ?? "http://localhost:8545"; + return defineChain({ + id: chainId, + name: "vibenet", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: [rpcUrl] } }, + }); +} + +function getAccount() { + const pk = process.env.VIBENET_FAUCET_PRIVATE_KEY; + if (!pk) throw new Error("VIBENET_FAUCET_PRIVATE_KEY is not set"); + return privateKeyToAccount(pk as Hex); +} + +export function getPublicClient() { + const chain = getChain(); + return createPublicClient({ chain, transport: http() }); +} + +export function getWalletClient() { + const chain = getChain(); + return createWalletClient({ + account: getAccount(), + chain, + transport: http(), + }); +} + +export function getFaucetAddress(): Address { + return getAccount().address; +} + +export function getDripWei(): bigint { + return BigInt( + process.env.VIBENET_FAUCET_DRIP_WEI ?? String(parseEther("0.1")), + ); +} + +export function getUsdvDripUnits(): bigint { + return BigInt(process.env.VIBENET_FAUCET_USDV_DRIP_UNITS ?? "1000000000"); +} + +export function getIpCooldownSecs(): number { + return Number(process.env.VIBENET_FAUCET_IP_COOLDOWN_SECS ?? "3600"); +} + +export function getAddrCooldownSecs(): number { + return Number(process.env.VIBENET_FAUCET_ADDR_COOLDOWN_SECS ?? "3600"); +} + +export async function getContractAddress( + name: string, +): Promise
{ + const path = process.env.VIBENET_CONTRACTS_PATH ?? "/shared/contracts.json"; + try { + const raw = await readFile(path, "utf8"); + const contracts = JSON.parse(raw) as Record; + const addr = contracts[name]; + return addr ? (addr as Address) : null; + } catch { + return null; + } +} + +export function clientIp(req: Request): string { + return ( + req.headers.get("x-real-ip") ?? + req.headers.get("cf-connecting-ip") ?? + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + "unknown" + ); +} diff --git a/src/proxy.ts b/src/proxy.ts index 6759d1de..af7e3b55 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,25 +1,54 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +const DOMAIN = "vibes.base.org"; + export function proxy(request: NextRequest) { + const host = request.headers.get("host") ?? ""; + const path = request.nextUrl.pathname; + + // OPTIONS preflight — reply immediately if (request.method === "OPTIONS") { return new NextResponse(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }, }); } + // Subdomain routing — only rewrite page paths. Skip: + // - /api/* (resolves the same on every subdomain) + // - anything with a file extension (favicons, static assets, robots.txt) + // The Next.js icon.svg file convention generates a request to + // /icon.svg at the root, and we don't want to rewrite that to e.g. + // /faucet/icon.svg (which doesn't exist). + const isAsset = /\.[a-z0-9]+$/i.test(path); + if (!path.startsWith("/api") && !isAsset) { + // faucet.vibes.base.org/* → /faucet/* + if (host === `faucet.${DOMAIN}` && !path.startsWith("/faucet")) { + const url = request.nextUrl.clone(); + url.pathname = `/faucet${path === "/" ? "" : path}`; + return NextResponse.rewrite(url); + } + + // explorer.vibes.base.org/* → /explorer/* + if (host === `explorer.${DOMAIN}` && !path.startsWith("/explorer")) { + const url = request.nextUrl.clone(); + url.pathname = `/explorer${path === "/" ? "" : path}`; + return NextResponse.rewrite(url); + } + } + const response = NextResponse.next(); response.headers.set("Access-Control-Allow-Origin", "*"); - response.headers.set("Access-Control-Allow-Methods", "GET, OPTIONS"); + response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); response.headers.set("Access-Control-Allow-Headers", "Content-Type"); return response; } export const config = { - matcher: "/api/:path*", + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], }; diff --git a/src/styles/vibenet.css b/src/styles/vibenet.css new file mode 100644 index 00000000..f6b2abeb --- /dev/null +++ b/src/styles/vibenet.css @@ -0,0 +1,901 @@ +/* base vibenet — landing, faucet, explorer. + * + * Palette + type approximates docs.base.org: near-black canvas, Base Blue + * accent (#0052FF), thin borders, generous whitespace, system sans for + * prose and a mono stack for anything hex. + * + * Body styles are scoped to .vibenet-app so they don't bleed into + * non-vibenet routes (e.g. /tips). + */ + +.vibenet-app { + --bg: #0a0b0f; + --bg-elev: #0f121a; + --card: #12151d; + --card-hover: #161a24; + --border: #1f2330; + --border-strong: #2a3040; + --fg: #ffffff; + --fg-dim: #8a93a6; + --fg-dimmer: #5b6478; + --accent: #0052ff; + --accent-hover: #3d75ff; + --accent-dim: #002a80; + --ok: #4ade80; + --err: #f87171; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --sans: + -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", system-ui, + sans-serif; + --radius: 8px; + --radius-lg: 12px; + + min-height: 100vh; + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + line-height: 1.55; + font-size: 15px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.vibenet-app *, +.vibenet-app *::before, +.vibenet-app *::after { + box-sizing: border-box; +} + +.vibenet-app a { + color: var(--accent-hover); + text-decoration: none; +} +.vibenet-app a:hover { + color: #6e92ff; + text-decoration: underline; +} + +/* ---------- header / nav ------------------------------------------------ */ + +.vibenet-app .site-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + padding: 18px 28px; + border-bottom: 1px solid var(--border); + background: var(--bg); + position: sticky; + top: 0; + z-index: 10; + backdrop-filter: blur(6px); +} + +.vibenet-app .brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--fg); + font-weight: 700; + font-size: 17px; + letter-spacing: 0.01em; +} +.vibenet-app .brand:hover { + color: var(--fg); + text-decoration: none; +} + +.vibenet-app .brand-mark { + display: inline-block; + width: 20px; + height: 20px; + background: var(--accent); + border-radius: 4px; +} + +.vibenet-app .site-nav { + display: flex; + align-items: center; + gap: 1.25rem; + font-size: 14px; +} +.vibenet-app .site-nav a, +.vibenet-app .site-nav .current { + color: var(--fg-dim); + padding: 4px 2px; + border-bottom: 1px solid transparent; +} +.vibenet-app .site-nav a:hover { + color: var(--fg); + border-bottom-color: var(--accent); + text-decoration: none; +} +.vibenet-app .site-nav .current { + color: var(--fg); +} + +/* ---------- layout ------------------------------------------------------ */ + +.vibenet-app main { + max-width: 960px; + margin: 0 auto; + padding: 2.5rem 28px 3rem; +} +.vibenet-app main.wide { + max-width: 1200px; +} + +/* Only top-level sections get the spacing — sections inside grids + * (e.g. .two-col on the explorer home) align to the top of their cell. */ +.vibenet-app main > section + section { + margin-top: 2.5rem; +} +.vibenet-app main h2 { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 1rem; + letter-spacing: -0.01em; +} +.vibenet-app main h3 { + font-size: 1.05rem; + font-weight: 600; + margin: 1.5rem 0 0.5rem; +} + +.vibenet-app .muted { + color: var(--fg-dim); +} +.vibenet-app .small { + font-size: 13px; +} + +/* ---------- hero / chain card ------------------------------------------- */ + +.vibenet-app .hero { + margin-bottom: 0.5rem; +} + +.vibenet-app .page-title { + font-size: 2rem; + margin: 0 0 0.25rem; + letter-spacing: -0.02em; + font-weight: 700; +} + +.vibenet-app .subtitle { + margin: 0 0 1.25rem; + color: var(--fg-dim); + font-size: 1.05rem; +} + +.vibenet-app .chain-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.15rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.vibenet-app .chain-row { + display: grid; + grid-template-columns: 110px 1fr; + gap: 1rem; + align-items: center; + font-size: 14px; + min-height: 28px; +} + +.vibenet-app .chain-label { + color: var(--fg-dim); + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.08em; + font-weight: 600; +} + +.vibenet-app .chain-value { + font-family: var(--mono); + font-size: 13px; + color: var(--fg); + overflow: hidden; +} + +.vibenet-app code { + font-family: var(--mono); + background: transparent; + padding: 0; + color: var(--fg); +} + +.vibenet-app button.chain-value.copyable { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + background: transparent; + border: 1px solid transparent; + padding: 4px 8px; + margin: 0; + border-radius: 6px; + color: var(--fg); + text-align: left; + font-weight: 400; + cursor: pointer; + min-width: 0; + transition: + background 0.1s ease, + border-color 0.1s ease; +} +.vibenet-app button.chain-value.copyable:hover { + background: var(--bg-elev); + border-color: var(--border); +} +.vibenet-app button.chain-value.copyable.copied { + border-color: var(--ok); +} +.vibenet-app .copy-hint { + font-family: var(--sans); + font-size: 11px; + color: var(--fg-dimmer); + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0; + transition: + opacity 0.1s ease, + color 0.1s ease; + flex-shrink: 0; +} +.vibenet-app button.chain-value.copyable:hover .copy-hint, +.vibenet-app button.chain-value.copyable:focus-visible .copy-hint, +.vibenet-app button.chain-value.copyable.copied .copy-hint { + opacity: 1; +} +.vibenet-app button.chain-value.copyable.copied .copy-hint { + color: var(--ok); +} +.vibenet-app a.chain-value { + display: block; + padding: 4px 8px; + border: 1px solid transparent; + border-radius: 6px; +} + +.vibenet-app .truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; + min-width: 0; +} + +.vibenet-app .chain-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.4rem; +} + +.vibenet-app .notice { + margin: 0.25rem 0 0; + color: var(--fg-dim); +} + +/* ---------- features grid ----------------------------------------------- */ + +.vibenet-app .features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0.75rem; +} + +.vibenet-app .feature-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.1rem; + transition: + border-color 0.1s ease, + background 0.1s ease; + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.vibenet-app .feature-card:hover { + border-color: var(--border-strong); + background: var(--card-hover); +} +.vibenet-app .feature-card .feature-title { + font-weight: 600; + color: var(--fg); + font-size: 14px; +} +.vibenet-app .feature-card .feature-desc { + color: var(--fg-dim); + font-size: 13px; + line-height: 1.5; +} +.vibenet-app .feature-card a { + font-size: 13px; +} + +/* ---------- contracts list ---------------------------------------------- */ + +.vibenet-app .contracts-list { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.vibenet-app .contract-row { + display: grid; + grid-template-columns: 160px 1fr auto; + gap: 1rem; + padding: 0.75rem 1rem; + align-items: center; + border-bottom: 1px solid var(--border); +} +.vibenet-app .contract-row:last-child { + border-bottom: none; +} +.vibenet-app .contract-label { + color: var(--fg-dim); + font-size: 13px; + font-weight: 500; +} +.vibenet-app .contract-addr { + font-family: var(--mono); + font-size: 13px; + word-break: break-all; + color: var(--accent-hover); +} + +/* ---------- forms / buttons --------------------------------------------- */ + +.vibenet-app input, +.vibenet-app button, +.vibenet-app textarea, +.vibenet-app select { + font: inherit; +} + +.vibenet-app input[type="text"], +.vibenet-app input:not([type]) { + padding: 0.55rem 0.75rem; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-elev); + color: var(--fg); + font-family: var(--mono); + font-size: 13px; + width: 100%; +} +.vibenet-app input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(0, 82, 255, 0.25); +} + +.vibenet-app button { + cursor: pointer; + background: var(--accent); + color: #ffffff; + border: 1px solid var(--accent); + padding: 0.55rem 1rem; + border-radius: 6px; + font-weight: 600; + font-size: 14px; + transition: + background 0.1s ease, + border-color 0.1s ease; +} +.vibenet-app button:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); +} +.vibenet-app button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.vibenet-app button.secondary { + background: var(--card); + color: var(--fg); + border-color: var(--border); +} +.vibenet-app button.secondary:hover:not(:disabled) { + background: var(--card-hover); + border-color: var(--accent); +} +.vibenet-app button.small { + padding: 0.35rem 0.75rem; + font-size: 12px; +} + +/* ---------- faucet -------------------------------------------------------- */ + +.vibenet-app .faucet-status { + font-size: 13px; + line-height: 1.6; +} +.vibenet-app .faucet-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.75rem; +} +.vibenet-app .faucet-pill { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + padding: 0.75rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-elev); +} +.vibenet-app .faucet-pill-key { + color: var(--fg-dim); + font-family: var(--mono); +} +.vibenet-app .faucet-pill-value { + color: var(--fg); + font-family: var(--mono); + font-weight: 600; +} +.vibenet-app .faucet-footer-links { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} +.vibenet-app .address-chip { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.25rem 0.65rem; + background: var(--card); + color: var(--accent-hover); + font-size: 12px; +} +.vibenet-app .address-chip:hover { + border-color: var(--accent); + text-decoration: none; +} + +.vibenet-app .faucet-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 1rem; +} +.vibenet-app .faucet-form label { + font-size: 12px; + color: var(--fg-dim); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.vibenet-app .drip-buttons { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 0.5rem; +} +.vibenet-app .request-btn { + min-height: 48px; + padding: 0.75rem 1.2rem; + font-size: 15px; + flex: 1 1 220px; +} + +.vibenet-app .drip-result { + margin-top: 0.75rem; + border: 1px solid transparent; + border-radius: var(--radius); + padding: 0; +} +.vibenet-app .drip-result:empty { + display: none; +} +.vibenet-app .drip-result.pending, +.vibenet-app .drip-result.success, +.vibenet-app .drip-result.error { + padding: 0.8rem 0.9rem; + background: var(--bg-elev); + border-color: var(--border); +} +.vibenet-app .drip-result.pending { + color: var(--fg-dim); +} +.vibenet-app .drip-result.success { + border-color: rgba(74, 222, 128, 0.35); +} +.vibenet-app .drip-result.error { + border-color: rgba(248, 113, 113, 0.45); + color: var(--err); +} +.vibenet-app .drip-result-title { + font-weight: 600; + margin-bottom: 0.25rem; +} +.vibenet-app .drip-result-meta { + color: var(--fg-dim); + font-size: 13px; + word-break: break-all; +} +.vibenet-app .tx-link { + font-family: var(--mono); +} + +/* ---------- explorer ----------------------------------------------------- */ + +.vibenet-app .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} +.vibenet-app .stat { + background: var(--card); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: 6px; + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 4px; +} +.vibenet-app .stat .label { + color: var(--fg-dim); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.vibenet-app .stat .value { + font-family: var(--mono); + font-size: 20px; +} + +.vibenet-app .search { + display: flex; + gap: 8px; + flex: 1; + max-width: 460px; +} +.vibenet-app .search input { + width: 100%; + padding: 6px 10px; + font-size: 13px; +} +.vibenet-app .search button { + padding: 6px 14px; +} +@media (max-width: 720px) { + .vibenet-app .search { + display: none; + } +} + +.vibenet-app .two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} +@media (max-width: 900px) { + .vibenet-app .two-col { + grid-template-columns: 1fr; + } +} + +.vibenet-app table { + width: 100%; + border-collapse: collapse; + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} +.vibenet-app th, +.vibenet-app td { + text-align: left; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.vibenet-app th { + font-weight: 500; + color: var(--fg-dim); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + background: var(--bg-elev); +} +.vibenet-app tr:last-child td { + border-bottom: none; +} + +.vibenet-app code.addr { + font-size: 11.5px; + letter-spacing: -0.01em; + word-break: break-all; + overflow-wrap: anywhere; +} + +/* Link affordance inside tables: subtle dotted underline by default, + * solid + brighter on hover. Makes block numbers and tx hashes obviously + * clickable without screaming for attention. */ +.vibenet-app td a, +.vibenet-app .row-link { + color: var(--accent-hover); + text-decoration: underline; + text-decoration-color: rgba(61, 117, 255, 0.4); + text-decoration-thickness: 1px; + text-underline-offset: 3px; + transition: + color 0.1s ease, + text-decoration-color 0.1s ease; +} +.vibenet-app td a:hover, +.vibenet-app .row-link:hover { + color: #6e92ff; + text-decoration-color: #6e92ff; +} + +/* Hover affordance on whole rows in live tables */ +.vibenet-app .live-table tbody tr:hover { + background: var(--card-hover); +} + +/* New-row flash when a block / tx appears via polling refresh */ +.vibenet-app .live-row-new td { + animation: live-row-highlight 1.2s ease-out; +} +@keyframes live-row-highlight { + 0% { + background: rgba(0, 82, 255, 0.24); + } + 100% { + background: transparent; + } +} + +/* New-stat flash when a stat counter changes */ +.vibenet-app .live-stat-updated { + animation: live-stat-highlight 0.9s ease-out; +} +@keyframes live-stat-highlight { + 0% { + border-color: rgba(0, 82, 255, 0.9); + box-shadow: 0 0 0 2px rgba(0, 82, 255, 0.18); + } + 100% { + border-color: var(--border); + box-shadow: none; + } +} + +.vibenet-app .nowrap { + white-space: nowrap; +} + +/* Detail pages: dl with 160px label column + 1fr value column. + * Used on tx, address, and block detail pages. */ +.vibenet-app .detail { + display: grid; + grid-template-columns: 160px 1fr; + gap: 8px 16px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 16px 20px; + margin: 16px 0; +} +.vibenet-app .detail dt { + color: var(--fg-dim); +} +.vibenet-app .detail dd { + margin: 0; + overflow-wrap: anywhere; +} + +/* Subtitle / hash header under

*/ +.vibenet-app p.hash { + font-family: var(--mono); + color: var(--fg-dim); + overflow-wrap: anywhere; + margin: 4px 0 0; +} + +.vibenet-app .dim { + color: var(--fg-dim); +} + +/* Status pill colors used on tx detail */ +.vibenet-app .status-ok, +.vibenet-app .status-success { + color: #4ade80; +} +.vibenet-app .status-fail { + color: #f87171; +} +.vibenet-app .status-pending { + color: var(--fg-dim); +} + +/* Action button row above the detail block */ +.vibenet-app .button-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 8px 0 16px; +} +.vibenet-app .btn { + background: var(--card); + border: 1px solid var(--border); + color: var(--fg); + padding: 6px 12px; + border-radius: 4px; + font-size: 13px; + text-decoration: none; + transition: border-color 0.1s ease; + cursor: pointer; + display: inline-block; +} +.vibenet-app .btn:hover { + border-color: var(--accent); + color: var(--fg); + text-decoration: none; +} + +/* Expandable input field on the tx page */ +.vibenet-app .input-details summary { + cursor: pointer; + list-style: revert; + color: var(--fg); +} +.vibenet-app .input-details summary code { + color: var(--fg-dim); +} +.vibenet-app .input-details[open] summary { + margin-bottom: 8px; +} + +.vibenet-app pre.raw { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 14px; + margin: 8px 0; + font-family: var(--mono); + font-size: 12px; + line-height: 1.55; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + max-height: 70vh; +} + +/* Logs section on tx page */ +.vibenet-app .log { + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px 14px; + margin: 8px 0; +} +.vibenet-app .log-header { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} +.vibenet-app .log-index { + color: var(--fg-dim); + font-family: var(--mono); +} +.vibenet-app .log .topics { + list-style: none; + padding: 0; + margin: 8px 0; +} +.vibenet-app .log .topics li { + font-family: var(--mono); + font-size: 12px; + color: var(--fg-dim); + margin: 2px 0; + overflow-wrap: anywhere; +} +.vibenet-app .log .data { + font-size: 12px; + color: var(--fg-dim); + overflow-wrap: anywhere; +} +.vibenet-app .event-badge { + display: inline-flex; + align-items: center; + border: 1px solid rgba(0, 82, 255, 0.45); + background: rgba(0, 82, 255, 0.14); + color: var(--fg); + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.vibenet-app .empty { + padding: 24px; + text-align: center; + color: var(--fg-dim); + font-style: italic; + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; +} + +/* ---------- footer ------------------------------------------------------- */ + +.vibenet-app .site-footer { + max-width: 960px; + margin: 2rem auto 0; + padding: 1.5rem 28px 2rem; + border-top: 1px solid var(--border); + color: var(--fg-dim); + font-size: 13px; + display: flex; + gap: 1.5rem; + align-items: center; + flex-wrap: wrap; +} +.vibenet-app .site-footer code { + font-family: var(--mono); + font-size: 12px; + background: var(--bg-elev); + padding: 0.05em 0.35em; + border-radius: 3px; + border: 1px solid var(--border); +} +.vibenet-app .footer-spacer { + flex: 1; +} + +/* Brand link inside the footer: smaller than the header brand to match the + * 13px footer font. Same blue square mark, just scaled down. */ +.vibenet-app .site-footer .brand { + font-size: 13px; + font-weight: 600; + gap: 6px; +} +.vibenet-app .site-footer .brand .brand-mark { + width: 12px; + height: 12px; + border-radius: 3px; +} + +/* ---------- responsive --------------------------------------------------- */ + +@media (max-width: 640px) { + .vibenet-app main { + padding: 1.5rem 18px 2rem; + } + .vibenet-app .site-header { + padding: 14px 18px; + } + .vibenet-app .site-nav { + gap: 0.75rem; + } + .vibenet-app .chain-row { + grid-template-columns: 80px 1fr; + gap: 0.5rem; + font-size: 13px; + } + .vibenet-app .contract-row { + grid-template-columns: 1fr; + gap: 0.25rem; + } + .vibenet-app .page-title { + font-size: 1.6rem; + } +}