diff --git a/.gitignore b/.gitignore index ddcbb911d..56e72a82f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,5 +49,16 @@ coverage/ .cache/ .parcel-cache/ +# Foundry build artifacts (staking-dashboard mock contracts) +staking-dashboard/contracts/out/ +staking-dashboard/cache/ + +# Multi-rollup test outputs (generated by deploy/seed scripts) +deploy-output.json +test-data.json + +# direnv +.envrc + # AI .claude \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..78af80435 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,126 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository layout + +This is a monorepo for the Aztec Staking Dashboard, made up of two independent yarn workspaces plus shared data and infra: + +- `staking-dashboard/` — React 19 + Vite + TypeScript frontend (RainbowKit / wagmi / viem). Talks to Ethereum via RPC and to the indexer via REST. +- `atp-indexer/` — Ponder-based blockchain indexer with a Hono REST API on top. Indexes ATP factories, the staking registry, the rollup, and dynamic ATP/Staker contracts created at runtime. +- `providers/` and `providers-testnet/` — One JSON file per provider (name, logo, self-stake addresses, etc.). These are aggregated into the indexer at build time by `atp-indexer/scripts/aggregate-providers*.ts` and served from `/api/providers`. +- `scripts/logging.sh` — Shared bash logging helpers sourced by both `bootstrap.sh` scripts. +- `terraform/` — Shared infra; each workspace also has its own `terraform/` directory used by its `bootstrap.sh deploy-*` actions. +- `db-schemas.json` — Maps the active Ponder DB schema name per environment (e.g. `atp-indexer-prod-v20`). Bumped when the schema changes. + +There is no root `package.json`. Run `yarn` inside `staking-dashboard/` or `atp-indexer/` separately. Both use yarn 1.22 and require Node >=18.14 (frontend prefers v20+/v22). + +## Common commands + +### Frontend (`cd staking-dashboard`) + +```bash +yarn install +yarn dev # Vite dev server on http://localhost:5173 +yarn build # tsc -b && vite build (validates required VITE_* env vars) +yarn lint # eslint . +yarn type-check # tsc --noEmit +yarn format # prettier write (skips src/contracts/abis/**) +``` + +The Vite build is gated on a list of required `VITE_*` env vars (see `vite.config.ts`). It will throw with a list of missing variables rather than producing a broken bundle. + +The path alias `@/*` resolves to `staking-dashboard/src/*`. + +### Frontend bootstrap script + +`./bootstrap.sh ` wraps env-file generation, install, and `yarn dev`/`yarn build`. Common actions: + +- `./bootstrap.sh dev` — uses dev (anvil 31337) network addresses and the local indexer +- `./bootstrap.sh sepolia` — uses Sepolia + the public Sepolia indexer +- `./bootstrap.sh dev-testnet` / `dev-prod` — local dev pointing at a deployed indexer +- `./bootstrap.sh docker` — builds and runs the frontend container (sepolia env) +- `./bootstrap.sh deploy-testnet` / `deploy-prod` — Terraform + S3 + CloudFront deploy (requires AWS creds, `WALLETCONNECT_PROJECT_ID`, and `RPC_URL`/`TESTNET_RPC_URL`) + +### Indexer (`cd atp-indexer`) + +```bash +yarn install +yarn bootstrap # aggregate providers/*.json -> src/api/data/providers.json +yarn bootstrap-testnet # same, but reads providers-testnet/ +yarn codegen # ponder codegen (regenerate types) +yarn dev # rm -rf .ponder generated && ponder dev (port 42068 by default) +yarn start # ponder start (production) +yarn serve # ponder serve (API only) +yarn test # jest +yarn test -- path/to/file.test.ts # run a single test file +yarn test -- -t "name" # run tests by name +yarn typecheck # tsc --noEmit +yarn compare # tsx scripts/compare-databases.ts (compare two DB snapshots) +``` + +### Indexer bootstrap script + +`./bootstrap.sh [environment]`: + +- `./bootstrap.sh dev` — generates `.env` for the local anvil (31337) chain, runs `yarn bootstrap`, `yarn codegen`, and `yarn dev` +- `./bootstrap.sh build [testnet|prod]` — install + provider aggregate (chooses `bootstrap` vs `bootstrap-testnet` from the env arg) + codegen +- `./bootstrap.sh deploy-testnet` / `deploy-prod` — Terraform deploy. Reads `RPC_URL`/`TESTNET_RPC_URL`, `AWS_ACCOUNT`, `AWS_REGION`, plus contract addresses. Use `DRY_RUN=true` to write a plan to `terraform-plans/` instead of applying. + +`testnet` deploys land in the shared `dev` cluster but use a separate Terraform state file. + +## Architecture + +### High-level dataflow + +``` +Browser → React frontend ─┬─ RPC (viem/wagmi) ──→ Ethereum (mainnet/sepolia/anvil) + └─ REST API ─────────→ atp-indexer (Hono) ──→ Postgres / pglite + ↑ Ponder syncs events from the same chain +``` + +The frontend reads on-chain state directly through wagmi/viem and uses the indexer for aggregations that would be too expensive to compute in the browser (provider lists, historical staking events, ATP listings by beneficiary, etc.). + +### Indexer + +Ponder configuration lives in `atp-indexer/ponder.config.ts`. The indexer tracks four ATP factories (Genesis, Auction, MATP, LATP), the `StakingRegistry`, and the `Rollup`. ATP child contracts are discovered via Ponder's `factory()` pattern using the `ATPCreated` event from any of the four factories. Staker contracts are not factory-emitted — they are derived via `getStaker()`, so handlers in `src/events/staker/` filter events against known ATP positions. + +- Schema: `atp-indexer/ponder.schema.ts` (drizzle-style `onchainTable` definitions, indices, and `relations`). +- Event handlers: `atp-indexer/src/events/{atp,atp-factory,rollup,staker,staking-registry}/`. +- API: Hono app in `src/api/index.ts` mounting routes from `src/api/routes/{health,providers,staking,atp}.routes.ts` under `/api/*`. CORS is open by default; rate limiting is gated on `RATE_LIMIT_ENABLED`. +- Config: `src/config/index.ts` is a lazy `Proxy` over a Zod-validated `process.env`. Always import `config` from there rather than reading `process.env` directly. Supported chains: mainnet (1), sepolia (11155111), holesky (17000), anvil (31337). +- Database: Postgres if `POSTGRES_CONNECTION_STRING` is set (with `sslmode=require|no-verify` honored), otherwise pglite. The schema name in production is pinned by `db-schemas.json`. +- Provider data: `providers/*.json` (mainnet) and `providers-testnet/*.json` (testnet) are aggregated into `src/api/data/providers.json` by `yarn bootstrap` / `yarn bootstrap-testnet`. **Adding or editing a provider requires re-running bootstrap** before `yarn dev` will pick up the change. Use `providers/_example.json` as the schema reference. + +### Frontend + +- Entry: `src/main.tsx` → `src/App.tsx` → `src/routes/AppRoutes.tsx`. Routing uses `react-router-dom` v7. +- Pages live under `src/pages/{ATP,Governance,NotFound,Providers,RegisterValidator,StakePortal}`. The default route (`/`) is `MyPositionPage`. +- **Governance is currently disabled** — `/governance/*` redirects to `/` in `AppRoutes.tsx`, and the UI surfaces external governance frontends via `ExternalGovernanceModal`. Don't re-enable the in-app governance route without an explicit ask. +- Contracts: `src/contracts/index.ts` builds a typed `contracts` object from Zod-validated `import.meta.env` (`VITE_*_ADDRESS`). ABIs live under `src/contracts/abis/` — these are excluded from prettier formatting. +- Hooks: `src/hooks/` is grouped per contract domain (`atp/`, `atpFactory/`, `governance/`, `staking/`, `rewards/`, etc.) plus top-level cross-cutting hooks (`useTransactionManager`, `useSafeApp`, `useStakingSteps`, …). +- Wallet: RainbowKit + wagmi configured in `src/wagmi.ts`. Safe wallet is supported via `@safe-global/*` packages and the `useSafeApp`/`SafeWarningModal` flow. +- Styling: Tailwind v4 via `@tailwindcss/vite` plus Radix primitives. UI primitives live in `src/components/ui/`. `components.json` is the shadcn config. + +### Configuration model + +Both frontend and indexer accept contract addresses three ways, in this priority order: + +1. Environment variables (`VITE_*_ADDRESS` for the frontend, bare `*_ADDRESS` for the indexer) — preferred for CI/CD +2. `CONTRACT_ADDRESSES_FILE=/path/to/contract_addresses.json` +3. `contract_addresses.json` in the workspace root + +`bootstrap.sh` in each workspace handles loading from the JSON file via `jq` and writing the appropriate env file. The frontend env vars are `VITE_`-prefixed; the indexer's are not. + +## Deployment notes + +- CI/CD lives in `.github/workflows/{build,deploy-staking-dashboard,deploy-indexer}.yaml`. +- Frontend is deployed to S3 + CloudFront. Both staging and prod have **red/green** deployments (see `*-green` actions in the bootstrap scripts and the hardcoded CloudFront domains in `update_env_file`). +- Indexer is deployed to ECS via Terraform. `testnet` deploys reuse the shared `dev` ECS cluster. `prod` uses its own. Each deploy bumps an ECR image tag and force-redeploys both the API and indexer ECS services. +- **Schema bumps**: When `ponder.schema.ts` changes in a way that requires a fresh DB, bump the version in `db-schemas.json`. Recent example: `beba7cc chore: bump prod db schema to v20`. + +## Conventions + +- Commit messages follow Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`...) — see `CONTRIBUTING.md` and recent `git log`. +- Both workspaces are TypeScript-strict; prefer `viem`/`wagmi` types over hand-rolled `Address`/`Hex` types. +- Don't reformat files under `staking-dashboard/src/contracts/abis/**` — they are intentionally excluded from prettier and changes there should be regenerated, not hand-edited. diff --git a/staking-dashboard/.tool-versions b/staking-dashboard/.tool-versions new file mode 100644 index 000000000..7d48ef57c --- /dev/null +++ b/staking-dashboard/.tool-versions @@ -0,0 +1,2 @@ +yarn 1.22.22 +nodejs 22.22.2 diff --git a/staking-dashboard/contracts/mocks/MockStakingRegistry.sol b/staking-dashboard/contracts/mocks/MockStakingRegistry.sol new file mode 100644 index 000000000..fe06c49f8 --- /dev/null +++ b/staking-dashboard/contracts/mocks/MockStakingRegistry.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.27; + +/// @notice Minimal mock of the staking dashboard's StakingRegistry. +/// Implements the getters the frontend reads (ROLLUP_REGISTRY, STAKING_ASSET, +/// PULL_SPLIT_FACTORY) plus provider registration so the Ponder indexer +/// can index ProviderRegistered events and populate the providers list. +contract MockStakingRegistry { + address public immutable ROLLUP_REGISTRY; + address public immutable STAKING_ASSET; + address public immutable PULL_SPLIT_FACTORY; + uint256 public nextProviderIdentifier; + + struct ProviderConfig { + address providerAdmin; + uint16 providerTakeRate; + address providerRewardsRecipient; + } + + mapping(uint256 => ProviderConfig) public providerConfigurations; + + event ProviderRegistered( + uint256 indexed providerIdentifier, + address indexed providerAdmin, + uint16 indexed providerTakeRate + ); + + constructor(address _rollupRegistry, address _stakingAsset, address _pullSplitFactory) { + ROLLUP_REGISTRY = _rollupRegistry; + STAKING_ASSET = _stakingAsset; + PULL_SPLIT_FACTORY = _pullSplitFactory; + } + + /// @notice Register a test provider. Emits ProviderRegistered for Ponder to index. + function registerProvider( + uint256 _providerIdentifier, + address _providerAdmin, + uint16 _takeRate, + address _rewardsRecipient + ) external { + providerConfigurations[_providerIdentifier] = ProviderConfig({ + providerAdmin: _providerAdmin, + providerTakeRate: _takeRate, + providerRewardsRecipient: _rewardsRecipient + }); + nextProviderIdentifier = _providerIdentifier + 1; + emit ProviderRegistered(_providerIdentifier, _providerAdmin, _takeRate); + } +} diff --git a/staking-dashboard/foundry.toml b/staking-dashboard/foundry.toml new file mode 100644 index 000000000..96f307785 --- /dev/null +++ b/staking-dashboard/foundry.toml @@ -0,0 +1,5 @@ +[profile.default] +src = "contracts" +out = "contracts/out" +libs = [] +solc = "0.8.27" diff --git a/staking-dashboard/scripts/multi-rollup-test/README.md b/staking-dashboard/scripts/multi-rollup-test/README.md new file mode 100644 index 000000000..44de48872 --- /dev/null +++ b/staking-dashboard/scripts/multi-rollup-test/README.md @@ -0,0 +1,184 @@ +# Multi-Rollup Integration Test + +End-to-end test environment for verifying multi-rollup support (Issue #57). Deploys **real Aztec L1 contracts** to a local anvil chain with 2 rollup versions registered in the same Registry, then seeds reward state so the dashboard can discover and display per-rollup rewards. + +## What it does + +1. **Deploys the full Aztec L1 stack** via `DeployAztecL1Contracts.s.sol` from `aztec-packages/l1-contracts/` — Registry, GSE, Governance, Rollup v1, mock verifier, all the real contracts +2. **Deploys a second rollup** via `DeployRollupForUpgrade.s.sol` with a different `GENESIS_ARCHIVE_ROOT` (producing a different version hash) +3. **Registers rollup v2** in the Registry using `anvil_impersonateAccount` to impersonate Governance (which owns the Registry after handover) +4. **Deploys MockStakingRegistry** — the only mock contract. The real StakingRegistry isn't in `aztec-packages`; this one just returns `ROLLUP_REGISTRY()` and `STAKING_ASSET()` +5. **Seeds test state** via `anvil_setStorageAt` — sets sequencer rewards and `isRewardsClaimable` on both rollups using the real contract's namespaced storage layout +6. **Mints fee tokens** to both rollup contracts so `claimSequencerRewards` can actually transfer tokens +7. **Writes config files** (`contract_addresses.json`, `deploy-output.json`, `test-data.json`) for the frontend + +## Prerequisites + +- **Node.js 22+** and **yarn 1.22** (check `.tool-versions`) +- **Foundry** (forge, anvil, cast) — install via `curl -L https://foundry.paradigm.xyz | bash && foundryup` +- **yq** — `brew install yq` (needed to load network defaults) +- **aztec-packages** repo — set `AZTEC_PACKAGES_DIR` env var (auto-set by `.envrc` if aztec-packages is at `../aztec-packages/` relative to the staking-dashboard repo root) +- Frontend dependencies installed: `cd staking-dashboard && yarn install` +- MockStakingRegistry compiled: `cd staking-dashboard && forge build` + +## Quick start + +```bash +# 1. Start anvil +anvil --port 8545 & + +# 2. Deploy contracts (takes ~2 min on first run due to Solidity compilation) +cd staking-dashboard +bash scripts/multi-rollup-test/deploy-multi-rollup.sh + +# 3. Seed rewards + mint fee tokens +npx tsx scripts/multi-rollup-test/seed-multi-rollup.ts + +# 4. Start frontend (reads contract_addresses.json via .env) +# The .env should already exist from the deploy script, or run: +./bootstrap.sh dev +# Then: +yarn dev +``` + +The frontend will be at `http://localhost:5173` with `VITE_ROLLUP_ADDRESS` pointing to rollup v1 (the old one), so `useRollupRegistry` will detect `isStale = true`. + +## What to test + +### Without wallet connection +- **Registry discovery**: the dashboard silently discovers 2 rollup versions via `useRollupRegistry` +- **Indexer disclaimer**: on `/providers`, the disclaimer "Historical statistics reflect the configured rollup only..." should appear (requires `rollups.length > 1`) + +### With wallet connection (MetaMask + anvil) +Add anvil network to MetaMask: RPC `http://localhost:8545`, Chain ID `31337`. Import anvil account 0: `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80`. + +**Important — pre-populate coinbase address first.** The Claimable Rewards section is gated behind `hasStakedPositions`, which requires ATP staking events from the indexer. Since our test uses placeholder ATP factory addresses, no staking events are indexed and the section won't appear without this step. + +Paste in the browser DevTools console (the seed script prints this exact line): +```js +localStorage.setItem('rewards_coinbase_addresses_0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', '["0x70997970c51812dc3a010c7d01b50e0d17dc79c8"]'); location.reload(); +``` + +Then: +1. Navigate to **Positions** (`/my-position`) +2. Expand the **Claimable Rewards** card — should show **15 STK** total +3. Click **Claim All Rewards** — should show 2 tasks: + - `0x7099...79C8 (rollup v{V1})` — **5 STK** + - `0x7099...79C8 (rollup v{V2})` — **10 STK** +4. Each task targets a different rollup contract address in the transaction + +**Before claiming**: reset MetaMask nonce — Settings → Advanced → **Clear activity tab data**. The deploy script creates many transactions, and MetaMask's cached nonce will be stale. + +## Test data matrix + +| Data Point | Rollup v1 (old, configured) | Rollup v2 (canonical) | +|---|---|---| +| `getVersion()` | auto-computed | different (different genesis) | +| `isRewardsClaimable()` | true | true | +| `getSequencerRewards(coinbaseA)` | 5e18 | 10e18 | +| `getSequencerRewards(coinbaseB)` | 3e18 | 0 | + +- **coinbaseA**: `0x70997970C51812dc3A010C7d01b50e0d17dc79C8` (anvil account 1) +- **coinbaseB**: `0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC` (anvil account 2) + +## File outputs + +| File | Purpose | +|------|---------| +| `deploy-output.json` | All deployed contract addresses + versions | +| `contract_addresses.json` | Format expected by `bootstrap.sh` — consumed by frontend `.env` generation | +| `test-data.json` | Expected values for assertions (rewards, versions, addresses) | + +## Architecture + +``` +anvil (port 8545) + ├── Real Registry (from aztec-packages) + │ ├── Rollup v1 (version A) — VITE_ROLLUP_ADDRESS points here (deliberately stale) + │ └── Rollup v2 (version B) — canonical + ├── Real GSE, Governance, RewardDistributor, MockVerifier + ├── MockStakingRegistry → points to real Registry + real staking token + └── Real TestERC20 tokens (STK + FEE) + +Frontend (port 5173) + └── useRollupRegistry() discovers Registry → enumerates 2 rollups + ├── useCoinbaseRewardsAcrossRollups() reads rewards from both + └── useIsRewardsClaimableAcrossRollups() checks claimability on each +``` + +## Gotchas and troubleshooting + +### MetaMask nonce errors ("nonce too low") +**Symptom**: Transactions fail with "Nonce provided for the transaction (N) is lower than the current nonce". + +**Cause**: MetaMask caches nonces per account. After restarting anvil or redeploying, the chain nonce resets but MetaMask's cache is stale. + +**Fix**: MetaMask → Settings → Advanced → **Clear activity tab data**. This resets the nonce cache. + +### `claimSequencerRewards` reverts with `ERC20InsufficientBalance` +**Symptom**: Claim simulation fails. Error shows the Rollup contract has 0 balance of a token. + +**Cause**: The real Rollup contract pays rewards by transferring **fee asset** (not staking asset) from its own balance. The seed script sets reward amounts in storage but doesn't give the Rollup any tokens to actually pay out. + +**Fix**: The seed script now mints fee tokens to both rollup contracts. If you see this error, re-run: `npx tsx scripts/multi-rollup-test/seed-multi-rollup.ts` + +### Multicall3 not deployed (wagmi `useReadContracts` fails silently) +**Symptom**: `useRollupRegistry` hook returns `rollups: []` even though `canonical` is populated. The stale banner may work but per-rollup features don't. + +**Cause**: wagmi's `useReadContracts` uses Multicall3 (`0xcA11bde05977b3631167028862bE2a173976CA11`). Anvil doesn't deploy it by default. The `forge script --broadcast` command deploys it during the Aztec L1 deploy, but if anvil was restarted after deployment, it's gone. + +**Fix**: The deploy script includes a Phase 0 that deploys Multicall3 via `anvil_setCode` using bytecode from the compiled `l1-contracts/out/Multicall3.sol/Multicall3.json`. If you restart anvil, re-run the deploy script. + +### l1-contracts won't compile — missing `HonkVerifier.sol` +**Symptom**: `forge build` fails in `aztec-packages/l1-contracts/` with "Source not found: generated/HonkVerifier.sol". + +**Cause**: The real HonkVerifier is generated from noir-projects circuit compilation. We use MockVerifier at runtime so the real one isn't needed, but the import still exists in test files. + +**Fix**: The deploy script creates a placeholder automatically. If compiling manually: +```bash +cd aztec-packages/l1-contracts +mkdir -p generated +cat > generated/HonkVerifier.sol << 'EOF' +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.27; +import {IVerifier} from "@aztec/core/interfaces/IVerifier.sol"; +contract HonkVerifier is IVerifier { + function verify(bytes calldata, bytes32[] calldata) external pure override returns (bool) { return true; } +} +EOF +yq -o json 'explode(.) | ."l1-contracts" // {}' ../spartan/environments/network-defaults.yml > generated/default.json +forge build +``` + +### Indexer shows "No Staking Positions Available" +**Symptom**: The Positions Overview with Claimable Rewards doesn't appear because `hasStakedPositions` is false. + +**Cause**: The indexer watches for events from ATP factory contracts. Our test uses placeholder addresses for ATP factories, so no staking events are indexed. + +**Fix**: Either run the indexer (it will still respond to API calls, just with empty data), or pre-populate coinbase addresses via localStorage as described above. The Claimable Rewards card renders once you have coinbase addresses saved, but the `hasStakedPositions` guard on the Positions Overview section may hide it. + +### Storage slot calculation for rewards +The Rollup contract uses **namespaced storage** (ERC-7201 pattern). Reward data lives at: + +``` +base = keccak256("aztec.reward.storage") // NOT abi.encode — raw UTF-8 bytes + +RewardStorage layout (relative to base): + slot 0: mapping(address => uint256) sequencerRewards + slot 1: mapping(Epoch => EpochRewards) epochRewards + slot 2: mapping(address => BitMap) proverClaimed + slot 3-4: RewardConfig struct + slot 5: CompressedTimestamp earliestRewardsClaimableTimestamp + bool isRewardsClaimable +``` + +For a mapping entry: `keccak256(abi.encode(address, base + 0))`. +For `isRewardsClaimable`: set bit at byte offset 4 (after the 4-byte CompressedTimestamp) at slot `base + 5`. + +Common mistake: using `keccak256(abi.encode("aztec.reward.storage"))` (ABI-encoded string with offset+length) instead of `keccak256("aztec.reward.storage")` (raw bytes). The former produces a different hash and silently writes to the wrong slot. + +### Governance owns the Registry — can't register rollup v2 directly +**Symptom**: `DeployRollupForUpgrade` deploys rollup v2 but doesn't auto-register it because `registry.owner() != deployer`. + +**Cause**: `DeployAztecL1Contracts` transfers Registry ownership to Governance in `_handoverToGovernance()`. After that, only Governance can call `registry.addRollup()`. + +**Fix**: The deploy script uses `anvil_impersonateAccount` to impersonate the Governance contract and call `addRollup`. This only works on anvil — on real networks you'd need to go through the governance proposal flow. diff --git a/staking-dashboard/scripts/multi-rollup-test/deploy-multi-rollup.sh b/staking-dashboard/scripts/multi-rollup-test/deploy-multi-rollup.sh new file mode 100755 index 000000000..5ec1668d6 --- /dev/null +++ b/staking-dashboard/scripts/multi-rollup-test/deploy-multi-rollup.sh @@ -0,0 +1,226 @@ +#!/bin/bash +set -euo pipefail + +# Deploy real Aztec L1 contracts with 2 rollup versions on local anvil. +# Uses DeployAztecL1Contracts for v1, DeployRollupForUpgrade for v2, +# then anvil_impersonateAccount to register v2 in the Registry. +# +# Required env vars: +# AZTEC_PACKAGES_DIR — path to aztec-packages repo root +# +# Optional env vars: +# ANVIL_PORT — anvil port (default: 8545) +# DEPLOYER_PK — deployer private key (default: anvil account 0) + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +STAKING_ROOT="$SCRIPT_DIR/../.." + +# Resolve L1 contracts directory +if [ -z "${AZTEC_PACKAGES_DIR:-}" ]; then + echo "ERROR: AZTEC_PACKAGES_DIR is not set." + echo "Set it to the aztec-packages repo root, e.g.:" + echo " export AZTEC_PACKAGES_DIR=/path/to/aztec-packages" + exit 1 +fi +L1_ROOT="$AZTEC_PACKAGES_DIR/l1-contracts" +if [ ! -d "$L1_ROOT/src" ]; then + echo "ERROR: $L1_ROOT/src not found. Is AZTEC_PACKAGES_DIR correct?" + exit 1 +fi + +# Clean stale output files (prevents race conditions with seed/frontend scripts) +rm -f "$STAKING_ROOT/deploy-output.json" "$STAKING_ROOT/test-data.json" + +ANVIL_PORT="${ANVIL_PORT:-8545}" +L1_RPC_URL="http://127.0.0.1:$ANVIL_PORT" +DEPLOYER_PK="${DEPLOYER_PK:-0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80}" + +echo "=== Loading devnet defaults ===" +source "$L1_ROOT/scripts/load_network_defaults.sh" devnet 2>/dev/null + +export L1_RPC_URL +export ROLLUP_DEPLOYMENT_PRIVATE_KEY="$DEPLOYER_PK" +export REAL_VERIFIER=false + +# Ensure l1-contracts are compiled +echo "=== Ensuring l1-contracts are compiled ===" +cd "$L1_ROOT" +mkdir -p generated +if [ ! -f generated/HonkVerifier.sol ]; then + cat > generated/HonkVerifier.sol << 'SOLEOF' +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.27; +import {IVerifier} from "@aztec/core/interfaces/IVerifier.sol"; +contract HonkVerifier is IVerifier { + function verify(bytes calldata, bytes32[] calldata) external pure override returns (bool) { return true; } +} +SOLEOF +fi +if [ ! -f generated/default.json ]; then + yq -o json 'explode(.) | ."l1-contracts" // {}' "$AZTEC_PACKAGES_DIR/spartan/environments/network-defaults.yml" > generated/default.json 2>/dev/null || true +fi +forge build 2>/dev/null + +# Clean stale broadcasts +rm -rf broadcast/ + +# ====================================== +# Phase 0: Deploy Multicall3 (required by wagmi useReadContracts) +# ====================================== +echo "" +echo "=== Phase 0: Deploying Multicall3 ===" +MULTICALL3_BYTECODE=$(jq -r '.deployedBytecode.object' "$L1_ROOT/out/Multicall3.sol/Multicall3.json" 2>/dev/null) +if [ -n "$MULTICALL3_BYTECODE" ] && [ "$MULTICALL3_BYTECODE" != "null" ]; then + cast rpc anvil_setCode "0xcA11bde05977b3631167028862bE2a173976CA11" "$MULTICALL3_BYTECODE" --rpc-url "$L1_RPC_URL" > /dev/null + echo " Multicall3 deployed at 0xcA11bde05977b3631167028862bE2a173976CA11" +else + echo " WARNING: Multicall3 bytecode not found — wagmi useReadContracts may fail" +fi + +# ====================================== +# Phase 1: Deploy full L1 stack + rollup v1 +# ====================================== +echo "" +echo "=== Phase 1: Deploying L1 contracts + Rollup v1 ===" +node "$L1_ROOT/scripts/forge_broadcast.js" \ + script/deploy/DeployAztecL1Contracts.s.sol:DeployAztecL1Contracts \ + --rpc-url "$L1_RPC_URL" \ + --private-key "$DEPLOYER_PK" \ + --json > /tmp/deploy_v1.jsonl + +DEPLOY_JSON=$(head -1 /tmp/deploy_v1.jsonl | jq -r '.logs[0]' | sed 's/JSON DEPLOY RESULT: //') + +REGISTRY=$(echo "$DEPLOY_JSON" | jq -r '.registryAddress') +ROLLUP_V1=$(echo "$DEPLOY_JSON" | jq -r '.rollupAddress') +STAKING_ASSET=$(echo "$DEPLOY_JSON" | jq -r '.stakingAssetAddress') +FEE_ASSET=$(echo "$DEPLOY_JSON" | jq -r '.feeAssetAddress') +GOVERNANCE=$(echo "$DEPLOY_JSON" | jq -r '.governanceAddress') +GSE_ADDR=$(echo "$DEPLOY_JSON" | jq -r '.gseAddress') +REWARD_DIST=$(echo "$DEPLOY_JSON" | jq -r '.rewardDistributorAddress') +V1_VERSION=$(echo "$DEPLOY_JSON" | jq -r '.rollupVersion') + +echo " Registry: $REGISTRY" +echo " Rollup v1: $ROLLUP_V1 (version: $V1_VERSION)" +echo " Staking Asset: $STAKING_ASSET" +echo " Governance: $GOVERNANCE" + +# ====================================== +# Phase 2: Deploy rollup v2 with different genesis +# ====================================== +echo "" +echo "=== Phase 2: Deploying Rollup v2 ===" + +# Different genesis → different version hash +export GENESIS_ARCHIVE_ROOT="0x$(openssl rand -hex 32)" +export REGISTRY_ADDRESS="$REGISTRY" + +node "$L1_ROOT/scripts/forge_broadcast.js" \ + script/deploy/DeployRollupForUpgrade.s.sol:DeployRollupForUpgrade \ + --rpc-url "$L1_RPC_URL" \ + --private-key "$DEPLOYER_PK" \ + --json > /tmp/deploy_v2.jsonl + +DEPLOY_V2_JSON=$(head -1 /tmp/deploy_v2.jsonl | jq -r '.logs[0]' | sed 's/JSON DEPLOY RESULT: //') +ROLLUP_V2=$(echo "$DEPLOY_V2_JSON" | jq -r '.rollupAddress') +V2_VERSION=$(echo "$DEPLOY_V2_JSON" | jq -r '.rollupVersion') + +echo " Rollup v2: $ROLLUP_V2 (version: $V2_VERSION)" + +# ====================================== +# Phase 3: Register rollup v2 in Registry via anvil impersonation +# ====================================== +echo "" +echo "=== Phase 3: Registering Rollup v2 in Registry ===" + +# Governance owns the Registry after handover. Impersonate it on anvil. +cast rpc anvil_impersonateAccount "$GOVERNANCE" --rpc-url "$L1_RPC_URL" > /dev/null +cast rpc anvil_setBalance "$GOVERNANCE" "0xDE0B6B3A7640000" --rpc-url "$L1_RPC_URL" > /dev/null + +cast send "$REGISTRY" "addRollup(address)" "$ROLLUP_V2" \ + --from "$GOVERNANCE" --rpc-url "$L1_RPC_URL" --unlocked > /dev/null + +cast rpc anvil_stopImpersonatingAccount "$GOVERNANCE" --rpc-url "$L1_RPC_URL" > /dev/null + +# Also register v2 in GSE +cast rpc anvil_impersonateAccount "$GOVERNANCE" --rpc-url "$L1_RPC_URL" > /dev/null +cast send "$GSE_ADDR" "addRollup(address)" "$ROLLUP_V2" \ + --from "$GOVERNANCE" --rpc-url "$L1_RPC_URL" --unlocked > /dev/null 2>&1 || true +cast rpc anvil_stopImpersonatingAccount "$GOVERNANCE" --rpc-url "$L1_RPC_URL" > /dev/null + +# Verify +NUM_VERSIONS=$(cast call "$REGISTRY" "numberOfVersions()(uint256)" --rpc-url "$L1_RPC_URL") +echo " Registered rollup versions: $NUM_VERSIONS" + +CANONICAL=$(cast call "$REGISTRY" "getCanonicalRollup()(address)" --rpc-url "$L1_RPC_URL") +echo " Canonical rollup: $CANONICAL" + +# ====================================== +# Phase 4: Deploy MockStakingRegistry +# ====================================== +echo "" +echo "=== Phase 4: Deploying MockStakingRegistry ===" +cd "$STAKING_ROOT" +forge build 2>/dev/null + +MOCK_BYTECODE=$(jq -r '.bytecode.object' contracts/out/MockStakingRegistry.sol/MockStakingRegistry.json) +ENCODED_ARGS=$(cast abi-encode "constructor(address,address,address)" "$REGISTRY" "$STAKING_ASSET" "0x0000000000000000000000000000000000000000") +DEPLOY_DATA="${MOCK_BYTECODE}${ENCODED_ARGS:2}" + +MOCK_TX=$(cast send \ + --private-key "$DEPLOYER_PK" \ + --rpc-url "$L1_RPC_URL" \ + --create "$DEPLOY_DATA" \ + --json 2>/dev/null) +MOCK_SR_ADDR=$(echo "$MOCK_TX" | jq -r '.contractAddress') +echo " MockStakingRegistry: $MOCK_SR_ADDR" + +# ====================================== +# Phase 5: Write output files +# ====================================== +echo "" +echo "=== Writing output files ===" + +cat > "$STAKING_ROOT/deploy-output.json" << EOJSON +{ + "registryAddress": "$REGISTRY", + "rollupV1Address": "$ROLLUP_V1", + "rollupV1Version": "$V1_VERSION", + "rollupV2Address": "$ROLLUP_V2", + "rollupV2Version": "$V2_VERSION", + "stakingAssetAddress": "$STAKING_ASSET", + "feeAssetAddress": "$FEE_ASSET", + "mockStakingRegistryAddress": "$MOCK_SR_ADDR", + "governanceAddress": "$GOVERNANCE", + "gseAddress": "$GSE_ADDR", + "rewardDistributorAddress": "$REWARD_DIST", + "rpcUrl": "$L1_RPC_URL" +} +EOJSON + +echo " deploy-output.json written" + +cat > "$STAKING_ROOT/contract_addresses.json" << EOJSON +{ + "atpFactory": "0x0000000000000000000000000000000000000001", + "atpFactoryAuction": "0x0000000000000000000000000000000000000002", + "atpRegistry": "0x0000000000000000000000000000000000000003", + "atpRegistryAuction": "0x0000000000000000000000000000000000000004", + "stakingRegistry": "$MOCK_SR_ADDR", + "rollupAddress": "$ROLLUP_V1", + "atpWithdrawableAndClaimableStaker": "0x0000000000000000000000000000000000000005", + "genesisSequencerSale": "0x0000000000000000000000000000000000000006", + "governanceAddress": "$GOVERNANCE", + "gseAddress": "$GSE_ADDR" +} +EOJSON + +echo " contract_addresses.json written (VITE_ROLLUP_ADDRESS = rollup v1, deliberately stale)" + +echo "" +echo "=== Deployment complete ===" +echo " Rollup v1 (old/configured): $ROLLUP_V1 (v$V1_VERSION)" +echo " Rollup v2 (canonical): $ROLLUP_V2 (v$V2_VERSION)" +echo " Registry: $REGISTRY ($NUM_VERSIONS versions)" +echo " MockStakingRegistry: $MOCK_SR_ADDR" +echo "" +echo "Next: npx tsx scripts/multi-rollup-test/seed-multi-rollup.ts" diff --git a/staking-dashboard/scripts/multi-rollup-test/seed-multi-rollup.ts b/staking-dashboard/scripts/multi-rollup-test/seed-multi-rollup.ts new file mode 100644 index 000000000..1e37ba745 --- /dev/null +++ b/staking-dashboard/scripts/multi-rollup-test/seed-multi-rollup.ts @@ -0,0 +1,338 @@ +/** + * Seed multi-rollup test state on anvil. + * + * Reads deploy-output.json (from deploy-multi-rollup.sh), then: + * 1. Sets sequencer rewards via anvil_setStorageAt on both rollups + * 2. Sets isRewardsClaimable = true via anvil_setStorageAt + * 3. Writes contract_addresses.json (already done by deploy script, verified here) + * 4. Writes test-data.json for Chrome MCP assertions + * + * Usage: npx tsx scripts/seed-multi-rollup.ts + */ + +import { + createPublicClient, + createTestClient, + createWalletClient, + http, + keccak256, + encodeAbiParameters, + pad, + toHex, + numberToHex, + stringToHex, + parseAbi, + type Address, + type Hex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { foundry } from "viem/chains"; +import { readFileSync, writeFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, "../.."); + +// Read deploy output +const deployOutput = JSON.parse( + readFileSync(resolve(ROOT, "deploy-output.json"), "utf-8") +); + +const rpcUrl = deployOutput.rpcUrl || "http://127.0.0.1:8545"; + +const publicClient = createPublicClient({ + chain: foundry, + transport: http(rpcUrl), +}); + +const testClient = createTestClient({ + chain: foundry, + mode: "anvil", + transport: http(rpcUrl), +}); + +// Anvil account 0 for minting +const DEPLOYER_PK = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex; +const account = privateKeyToAccount(DEPLOYER_PK); +const walletClient = createWalletClient({ + account, + chain: foundry, + transport: http(rpcUrl), +}); + +const erc20Abi = parseAbi([ + "function mint(address _to, uint256 _amount) external", +]); + +// Test addresses (anvil defaults) +const COINBASE_A = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" as Address; +const COINBASE_B = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" as Address; + +// ============================================================================ +// Storage layout for RewardLib (namespaced storage) +// +// Base slot: keccak256("aztec.reward.storage") +// +// RewardStorage struct layout (relative to base): +// slot 0: mapping(address => uint256) sequencerRewards +// slot 1: mapping(Epoch => EpochRewards) epochRewards +// slot 2: mapping(address => BitMap) proverClaimed +// slot 3: RewardConfig.rewardDistributor (20 bytes) + RewardConfig.sequencerBps (4 bytes) +// slot 4: RewardConfig.booster (20 bytes) + RewardConfig.checkpointReward (12 bytes) +// slot 5: CompressedTimestamp earliestRewardsClaimableTimestamp + bool isRewardsClaimable +// ============================================================================ + +// In Solidity: keccak256("aztec.reward.storage") hashes raw UTF-8 bytes, NOT abi.encode +const REWARD_STORAGE_BASE = keccak256(stringToHex("aztec.reward.storage")); + +// sequencerRewards mapping is at slot 0 relative to base +const SEQUENCER_REWARDS_SLOT = BigInt(REWARD_STORAGE_BASE); + +// isRewardsClaimable is at slot 5 relative to base +// It's packed with earliestRewardsClaimableTimestamp +const IS_CLAIMABLE_SLOT = BigInt(REWARD_STORAGE_BASE) + 5n; + +/** + * Compute the storage slot for a mapping entry: keccak256(abi.encode(key, mappingSlot)) + */ +function mappingSlot(key: Address, baseSlot: bigint): Hex { + return keccak256( + encodeAbiParameters( + [{ type: "address" }, { type: "uint256" }], + [key, baseSlot] + ) + ); +} + +async function setStorageSlot( + contract: Address, + slot: Hex, + value: Hex +): Promise { + await testClient.setStorageAt({ + address: contract, + index: slot, + value: pad(value, { size: 32 }), + }); +} + +// Rollup ABI for verification reads +const rollupAbi = [ + { + type: "function", + name: "getSequencerRewards", + inputs: [{ type: "address" }], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "isRewardsClaimable", + inputs: [], + outputs: [{ type: "bool" }], + stateMutability: "view", + }, + { + type: "function", + name: "getVersion", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, +] as const; + +async function main() { + const rollupV1 = deployOutput.rollupV1Address as Address; + const rollupV2 = deployOutput.rollupV2Address as Address; + + console.log(`\nSeeding multi-rollup test state...`); + console.log(` Rollup v1: ${rollupV1}`); + console.log(` Rollup v2: ${rollupV2}`); + console.log(` RPC: ${rpcUrl}\n`); + + // ======================================== + // Set sequencer rewards on both rollups + // ======================================== + console.log("Setting sequencer rewards..."); + + // Rollup v1: coinbaseA = 5 TST, coinbaseB = 3 TST + await setStorageSlot( + rollupV1, + mappingSlot(COINBASE_A, SEQUENCER_REWARDS_SLOT), + numberToHex(5n * 10n ** 18n, { size: 32 }) + ); + await setStorageSlot( + rollupV1, + mappingSlot(COINBASE_B, SEQUENCER_REWARDS_SLOT), + numberToHex(3n * 10n ** 18n, { size: 32 }) + ); + + // Rollup v2: coinbaseA = 10 TST + await setStorageSlot( + rollupV2, + mappingSlot(COINBASE_A, SEQUENCER_REWARDS_SLOT), + numberToHex(10n * 10n ** 18n, { size: 32 }) + ); + + // ======================================== + // Set isRewardsClaimable = true on both + // ======================================== + console.log("Setting isRewardsClaimable = true..."); + + // isRewardsClaimable is a bool packed at the end of slot 5. + // The CompressedTimestamp is packed before it. We need to set the bool + // without clobbering the timestamp. Since we just want the bool to be true, + // we can set the entire slot to have the bool bit set. + // CompressedTimestamp is bytes4 (offset 0), isRewardsClaimable is bool (offset 4) + // Actually, let's read the current value and OR the bool in. + for (const rollup of [rollupV1, rollupV2]) { + const currentVal = await publicClient.getStorageAt({ + address: rollup, + slot: numberToHex(IS_CLAIMABLE_SLOT, { size: 32 }), + }); + // Set the 5th byte (offset 4) to 0x01 for true + // Storage is right-aligned for simple types, so bool at offset 4 means byte 27 from left + // Actually in Solidity packed storage, lower-offset items are at lower bytes. + // CompressedTimestamp (4 bytes, offset 0) occupies bytes 0-3 from right + // bool (1 byte, offset 4) occupies byte 4 from right + const currentBigInt = BigInt(currentVal || "0x0"); + const withClaimable = currentBigInt | (1n << 32n); // Set bit at byte offset 4 + await setStorageSlot( + rollup, + numberToHex(IS_CLAIMABLE_SLOT, { size: 32 }), + numberToHex(withClaimable, { size: 32 }) + ); + } + + // ======================================== + // Mint fee tokens to rollups (claimSequencerRewards transfers fee asset from rollup balance) + // ======================================== + const feeAsset = deployOutput.feeAssetAddress as Address | undefined; + if (feeAsset) { + console.log("Minting fee tokens to rollups (needed for claim payouts)..."); + const mintAmount = 1000n * 10n ** 18n; + for (const rollup of [rollupV1, rollupV2]) { + const hash = await walletClient.writeContract({ + address: feeAsset, + abi: erc20Abi, + functionName: "mint", + args: [rollup, mintAmount], + }); + await publicClient.waitForTransactionReceipt({ hash }); + } + console.log(" Minted 1000 FEE to each rollup"); + } else { + console.log("WARNING: feeAssetAddress not in deploy-output.json, claims may fail"); + } + + // ======================================== + // Verify + // ======================================== + console.log("\nVerifying..."); + + const v1RewardsA = await publicClient.readContract({ + address: rollupV1, + abi: rollupAbi, + functionName: "getSequencerRewards", + args: [COINBASE_A], + }); + console.log( + ` Rollup v1 rewards for coinbaseA: ${v1RewardsA} (expected: ${5n * 10n ** 18n})` + ); + if (v1RewardsA !== 5n * 10n ** 18n) { + console.error(" ERROR: Reward mismatch!"); + process.exit(1); + } + + const v1RewardsB = await publicClient.readContract({ + address: rollupV1, + abi: rollupAbi, + functionName: "getSequencerRewards", + args: [COINBASE_B], + }); + console.log( + ` Rollup v1 rewards for coinbaseB: ${v1RewardsB} (expected: ${3n * 10n ** 18n})` + ); + + const v2RewardsA = await publicClient.readContract({ + address: rollupV2, + abi: rollupAbi, + functionName: "getSequencerRewards", + args: [COINBASE_A], + }); + console.log( + ` Rollup v2 rewards for coinbaseA: ${v2RewardsA} (expected: ${10n * 10n ** 18n})` + ); + + const v1Claimable = await publicClient.readContract({ + address: rollupV1, + abi: rollupAbi, + functionName: "isRewardsClaimable", + }); + console.log(` Rollup v1 isRewardsClaimable: ${v1Claimable} (expected: true)`); + + const v2Claimable = await publicClient.readContract({ + address: rollupV2, + abi: rollupAbi, + functionName: "isRewardsClaimable", + }); + console.log(` Rollup v2 isRewardsClaimable: ${v2Claimable} (expected: true)`); + + // Read versions for test data + const v1Version = await publicClient.readContract({ + address: rollupV1, + abi: rollupAbi, + functionName: "getVersion", + }); + const v2Version = await publicClient.readContract({ + address: rollupV2, + abi: rollupAbi, + functionName: "getVersion", + }); + + // ======================================== + // Write test-data.json + // ======================================== + const testData = { + ...deployOutput, + rollupV1Version: v1Version.toString(), + rollupV2Version: v2Version.toString(), + coinbaseA: COINBASE_A, + coinbaseB: COINBASE_B, + rollupV1Rewards: { + [COINBASE_A]: (5n * 10n ** 18n).toString(), + [COINBASE_B]: (3n * 10n ** 18n).toString(), + }, + rollupV2Rewards: { + [COINBASE_A]: (10n * 10n ** 18n).toString(), + }, + rewardsClaimable: { + [rollupV1]: v1Claimable, + [rollupV2]: v2Claimable, + }, + }; + + writeFileSync( + resolve(ROOT, "test-data.json"), + JSON.stringify(testData, null, 2) + ); + console.log("\nWrote test-data.json"); + + // Print localStorage setup for the browser + const DEPLOYER_ADDR = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; + const lsKey = `rewards_coinbase_addresses_${DEPLOYER_ADDR}`; + const lsValue = JSON.stringify([COINBASE_A.toLowerCase()]); + console.log("\nSeeding complete!"); + console.log("\n--- Browser setup (paste in DevTools console) ---"); + console.log( + `localStorage.setItem('${lsKey}', '${lsValue}'); location.reload();` + ); + console.log("---"); +} + +main().catch((err) => { + console.error(`\nError: ${err.message}\n`); + process.exit(1); +}); diff --git a/staking-dashboard/scripts/multi-rollup-test/seed-providers.ts b/staking-dashboard/scripts/multi-rollup-test/seed-providers.ts new file mode 100644 index 000000000..b366ed547 --- /dev/null +++ b/staking-dashboard/scripts/multi-rollup-test/seed-providers.ts @@ -0,0 +1,94 @@ +/** + * Seed test providers by calling registerProvider() on the MockStakingRegistry. + * This emits ProviderRegistered events that Ponder indexes, populating the + * /api/providers endpoint. + * + * Usage: npx tsx scripts/multi-rollup-test/seed-providers.ts + * + * Prerequisites: + * - Anvil running with deployed MockStakingRegistry + * - deploy-output.json exists (from deploy-multi-rollup.sh) + * - Indexer running (will pick up events in real-time) + */ + +import { + createPublicClient, + createWalletClient, + http, + parseAbi, + type Address, + type Hex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { foundry } from "viem/chains"; +import { readFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, "../.."); +const INDEXER_ROOT = resolve(ROOT, "../atp-indexer"); + +// Read deploy output +const deployOutput = JSON.parse( + readFileSync(resolve(ROOT, "deploy-output.json"), "utf-8") +); +const rpcUrl = deployOutput.rpcUrl || "http://127.0.0.1:8545"; +const mockSR = deployOutput.mockStakingRegistryAddress as Address; + +// Read provider metadata to know which IDs to register +const providersJson = JSON.parse( + readFileSync(resolve(INDEXER_ROOT, "src/api/data/providers.json"), "utf-8") +) as Array<{ providerId: number; providerName: string }>; + +const DEPLOYER_PK = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex; +const account = privateKeyToAccount(DEPLOYER_PK); + +const publicClient = createPublicClient({ + chain: foundry, + transport: http(rpcUrl), +}); + +const walletClient = createWalletClient({ + account, + chain: foundry, + transport: http(rpcUrl), +}); + +const registerAbi = parseAbi([ + "function registerProvider(uint256 _providerIdentifier, address _providerAdmin, uint16 _takeRate, address _rewardsRecipient) external", +]); + +async function main() { + // Register first 10 providers from metadata + const toRegister = providersJson.slice(0, 10); + + console.log(`\nRegistering ${toRegister.length} test providers on MockStakingRegistry...`); + console.log(` MockStakingRegistry: ${mockSR}`); + console.log(` RPC: ${rpcUrl}\n`); + + for (const p of toRegister) { + const hash = await walletClient.writeContract({ + address: mockSR, + abi: registerAbi, + functionName: "registerProvider", + args: [ + BigInt(p.providerId), + account.address, // providerAdmin + 500, // 5% take rate (basis points / 100) + account.address, // rewardsRecipient + ], + }); + await publicClient.waitForTransactionReceipt({ hash }); + console.log(` Registered provider ${p.providerId} (${p.providerName})`); + } + + console.log(`\nDone! The indexer should pick up ProviderRegistered events automatically.`); + console.log(`Check: curl http://localhost:42068/api/providers | jq '.providers | length'`); +} + +main().catch((err) => { + console.error(`\nError: ${err.message}\n`); + process.exit(1); +}); diff --git a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx index f036b4d82..de74d59ba 100644 --- a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx +++ b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx @@ -27,7 +27,7 @@ interface ATPStakingOverviewProps { * Shows staked positions, stakeable amounts, and claimable rewards */ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOverviewProps) => { - const { symbol, decimals, isLoading: isLoadingTokenDetails } = useStakingAssetTokenDetails() + const { symbol, decimals } = useStakingAssetTokenDetails() const { totalStaked, @@ -42,7 +42,6 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv delegationBreakdown, erc20DelegationBreakdown, erc20DirectStakeBreakdown, - isLoading: isLoadingAggregated, refetch: refetchAggregatedData, } = useAggregatedStakingData() @@ -55,7 +54,6 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv totalValidatorCount, totalStakeableAmount, activationThreshold, - isLoading: isLoadingStakeable, } = useMultipleStakeableAmounts(atpData) // Check if rewards are claimable @@ -125,10 +123,15 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv } }, []) - // Show skeleton while loading or if required data is missing - if (isLoadingAggregated || isLoadingStakeable || isLoadingTokenDetails || decimals === undefined || symbol === undefined || activationThreshold === undefined) { + // Ref-cached to survive refetches without unmounting modals or losing type narrowing. + const resolvedRef = useRef<{ decimals: number; symbol: string; activationThreshold: bigint } | null>(null) + if (decimals !== undefined && symbol !== undefined && activationThreshold !== undefined) { + resolvedRef.current = { decimals, symbol, activationThreshold } + } + if (!resolvedRef.current) { return } + const { decimals: resolvedDecimals, symbol: resolvedSymbol, activationThreshold: resolvedActivationThreshold } = resolvedRef.current return ( @@ -144,8 +147,8 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv totalClaimable={totalClaimable} isExpanded={isTotalAllocationExpanded} onToggle={() => setIsTotalAllocationExpanded(!isTotalAllocationExpanded)} - decimals={decimals} - symbol={symbol} + decimals={resolvedDecimals} + symbol={resolvedSymbol} /> {/* Total Staked Section */} @@ -156,21 +159,21 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv totalDelegated={combinedTotalDelegated} isExpanded={isTotalStakedExpanded} onToggle={() => setIsTotalStakedExpanded(!isTotalStakedExpanded)} - decimals={decimals} - symbol={symbol} + decimals={resolvedDecimals} + symbol={resolvedSymbol} /> {/* Stakeable Amount Section - includes ATP stakeable + ERC20 wallet balance (rounded) */} setIsStakeableExpanded(!isStakeableExpanded)} - decimals={decimals} - symbol={symbol} + decimals={resolvedDecimals} + symbol={resolvedSymbol} /> {/* Total Rewards Section */} @@ -182,8 +185,8 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv isRewardsClaimable={isRewardsClaimable} isExpanded={isTotalRewardsExpanded} onToggle={() => setIsTotalRewardsExpanded(!isTotalRewardsExpanded)} - decimals={decimals} - symbol={symbol} + decimals={resolvedDecimals} + symbol={resolvedSymbol} delegationBreakdown={delegationBreakdown} coinbaseBreakdown={coinbaseBreakdown} onClaimSuccess={() => { @@ -202,8 +205,8 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv erc20DelegationBreakdown={erc20DelegationBreakdown} erc20DirectStakeBreakdown={erc20DirectStakeBreakdown} atpData={atpData} - decimals={decimals} - symbol={symbol} + decimals={resolvedDecimals} + symbol={resolvedSymbol} onATPClick={(atp) => setSelectedATP(atp)} /> )} diff --git a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewClaimableRewards.tsx b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewClaimableRewards.tsx index d057fb0dc..608b75a42 100644 --- a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewClaimableRewards.tsx +++ b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewClaimableRewards.tsx @@ -4,6 +4,7 @@ import { TooltipIcon } from "@/components/Tooltip" import { formatTokenAmount, formatTokenAmountFull } from "@/utils/atpFormatters" import { ManageRewardsAddressesModal } from "@/components/RewardsManagement" import { ClaimAllRewardsModal } from "@/components/ClaimAllRewardsModal" +import { useTermsModal } from "@/contexts/TermsModalContext" import type { DelegationBreakdown } from "@/hooks/atp/useAggregatedStakingData" import type { CoinbaseBreakdown } from "@/hooks/rewards/rewardsTypes" @@ -28,6 +29,7 @@ export const ATPStakingOverviewClaimableRewards = forwardRef { const [isManageModalOpen, setIsManageModalOpen] = useState(false) const [isClaimAllModalOpen, setIsClaimAllModalOpen] = useState(false) + const { requireTermsAcceptance } = useTermsModal() // Combined total rewards (delegation + self-stake) const combinedTotalRewards = totalRewards + selfStakeRewards @@ -111,13 +113,14 @@ export const ATPStakingOverviewClaimableRewards = forwardRef - {/* Claim All Button */} + {/* Claim All Button — no longer gated by the configured rollup's claimability, + since each per-task rollup gates its own claim and engine surfaces per-task errors. */} {/* Transaction Info */} - {hasRewards && isRewardsClaimable && ( + {hasRewards && (

This will require multiple transactions. Each claim will prompt for approval.

diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx index 19d09f749..bc7f51538 100644 --- a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx @@ -3,11 +3,13 @@ import { createPortal } from "react-dom" import { Icon } from "@/components/Icon" import { CopyButton } from "@/components/CopyButton" import { formatTokenAmount } from "@/utils/atpFormatters" +import { validateAddress } from "@/utils/validateAddress" +import { RollupRewardRow } from "./RollupRewardRow" import { debounce } from "@/utils/debounce" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" -import { useSequencerRewards } from "@/hooks/rollup/useSequencerRewards" import { useClaimSequencerRewards } from "@/hooks/rollup/useClaimSequencerRewards" -import { useIsRewardsClaimable } from "@/hooks/rollup/useIsRewardsClaimable" +import { useIsRewardsClaimableAcrossRollups } from "@/hooks/rollup/useIsRewardsClaimableAcrossRollups" +import { useCoinbaseRewardsAcrossRollups } from "@/hooks/rewards/useCoinbaseRewardsAcrossRollups" import { useAlert } from "@/contexts/AlertContext" import type { ATPData } from "@/hooks/atp" import type { Address } from "viem" @@ -43,11 +45,26 @@ export const ClaimSelfStakeRewardsModal = ({ const [hasCheckedRewards, setHasCheckedRewards] = useState(false) const [isDebouncing, setIsDebouncing] = useState(false) + const isValidAddress = validateAddress(coinbaseAddress) + const coinbasesForQuery = useMemo( + () => (isValidAddress ? [coinbaseAddress as Address] : []), + [coinbaseAddress, isValidAddress], + ) + + // Per-rollup reward reads const { - rewards, + coinbaseBreakdown, + totalCoinbaseRewards, isLoading: isLoadingRewards, - refetch: checkRewards - } = useSequencerRewards(coinbaseAddress) + refetch: checkRewards, + } = useCoinbaseRewardsAcrossRollups(coinbasesForQuery) + + // Per-rollup claimability check + const rollupAddressesInBreakdown = useMemo( + () => coinbaseBreakdown.map((row) => row.rollupAddress), + [coinbaseBreakdown], + ) + const { isClaimable: isClaimableForRollup } = useIsRewardsClaimableAcrossRollups(rollupAddressesInBreakdown) const { claimRewards, @@ -58,8 +75,6 @@ export const ClaimSelfStakeRewardsModal = ({ reset } = useClaimSequencerRewards() - const { isRewardsClaimable } = useIsRewardsClaimable() - // Create debounced check function that manages debouncing state const debouncedCheckRewards = useMemo( () => debounce(() => { @@ -72,7 +87,7 @@ export const ClaimSelfStakeRewardsModal = ({ // Auto-check rewards when valid address is entered (debounced) useEffect(() => { - if (coinbaseAddress.length === 42 && coinbaseAddress.startsWith('0x')) { + if (validateAddress(coinbaseAddress)) { setIsDebouncing(true) debouncedCheckRewards() } else { @@ -81,20 +96,21 @@ export const ClaimSelfStakeRewardsModal = ({ } }, [coinbaseAddress, debouncedCheckRewards]) - const handleClaim = () => { - claimRewards(coinbaseAddress as Address) + const handleClaim = (rollupAddress: Address) => { + claimRewards(coinbaseAddress as Address, rollupAddress) } - // Handle success + // On success: reset and refresh breakdown useEffect(() => { if (isSuccess) { onSuccess?.() - onClose() - setCoinbaseAddress("") - setHasCheckedRewards(false) reset() + if (coinbaseAddress) { + debouncedCheckRewards() + } } - }, [isSuccess, onSuccess, onClose, reset]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- coinbaseAddress is read but not a reactive trigger + }, [isSuccess, onSuccess, reset, debouncedCheckRewards]) // Handle errors useEffect(() => { @@ -118,8 +134,6 @@ export const ClaimSelfStakeRewardsModal = ({ } } - const isValidAddress = coinbaseAddress.length === 42 && coinbaseAddress.startsWith('0x') - if (!isOpen) return null return createPortal( @@ -206,44 +220,45 @@ export const ClaimSelfStakeRewardsModal = ({ {/* Rewards Display */} {hasCheckedRewards && !isLoadingRewards && !isDebouncing && ( - <> - {rewards !== undefined ? ( -
-
- Available Rewards -
-
- {decimals && symbol ? formatTokenAmount(rewards, decimals, symbol) : '-'} +
+ {coinbaseBreakdown.length > 0 ? ( +
+
+
+ Available Rewards +
+
+ Total: + {decimals && symbol ? formatTokenAmount(totalCoinbaseRewards, decimals, symbol) : '-'} + +
- {rewards === 0n && ( -

- No rewards available for this coinbase address -

- )} + {coinbaseBreakdown.map((row) => ( + + ))}
) : ( -
-
- Coinbase Not Found -
-
- Cannot find rewards for this coinbase address. Please verify the address is correct. -
-
- )} - - {/* Rewards Not Claimable Warning */} - {isRewardsClaimable === false && ( -
-
- Rewards Currently Locked -
-
- All rewards are currently locked by the network protocol (rollup). Claiming will be enabled once the protocol unlocks rewards. +
+
+ Available Rewards
+

+ No rewards found for this coinbase address on any known rollup. +

)} - +
)} {/* Error Display */} @@ -262,27 +277,7 @@ export const ClaimSelfStakeRewardsModal = ({ onClick={handleClose} className="px-6 py-3 border border-parchment/30 text-parchment font-oracle-standard font-bold text-sm uppercase tracking-wider hover:bg-parchment/10 transition-all" > - Cancel - -
diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/RollupRewardRow.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/RollupRewardRow.tsx new file mode 100644 index 000000000..919db9866 --- /dev/null +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/RollupRewardRow.tsx @@ -0,0 +1,62 @@ +import type { Address } from "viem" +import { formatTokenAmount } from "@/utils/atpFormatters" + +interface RollupRewardRowProps { + rollupAddress: Address + rollupVersion: bigint | undefined + rewards: bigint + decimals: number + symbol: string + isClaimable: boolean + isBusy: boolean + isPending: boolean + onClaim: (rollupAddress: Address) => void +} + +export const RollupRewardRow = ({ + rollupAddress, + rollupVersion, + rewards, + decimals, + symbol, + isClaimable, + isBusy, + isPending, + onClaim, +}: RollupRewardRowProps) => ( +
+
+ {rollupVersion !== undefined ? ( + + Rollup v{rollupVersion.toString()} + + ) : ( + + Configured rollup + + )} +
+ {formatTokenAmount(rewards, decimals, symbol)} +
+
+ +
+) diff --git a/staking-dashboard/src/components/IndexerRollupDisclaimer/IndexerRollupDisclaimer.tsx b/staking-dashboard/src/components/IndexerRollupDisclaimer/IndexerRollupDisclaimer.tsx new file mode 100644 index 000000000..20913e152 --- /dev/null +++ b/staking-dashboard/src/components/IndexerRollupDisclaimer/IndexerRollupDisclaimer.tsx @@ -0,0 +1,19 @@ +import { Icon } from "@/components/Icon" +import { useRollupRegistry } from "@/hooks/rollup/useRollupRegistry" + +/** Disclaimer shown on provider pages when multiple rollups exist. */ +export const IndexerRollupDisclaimer = () => { + const { rollups, isLoading } = useRollupRegistry() + + if (isLoading || rollups.length <= 1) return null + + return ( +
+ + + Historical statistics reflect the configured rollup only and may not include data from + older or canonical rollups. + +
+ ) +} diff --git a/staking-dashboard/src/components/IndexerRollupDisclaimer/index.ts b/staking-dashboard/src/components/IndexerRollupDisclaimer/index.ts new file mode 100644 index 000000000..1d5ec99c9 --- /dev/null +++ b/staking-dashboard/src/components/IndexerRollupDisclaimer/index.ts @@ -0,0 +1 @@ +export { IndexerRollupDisclaimer } from "./IndexerRollupDisclaimer" diff --git a/staking-dashboard/src/components/Registration/RegistrationStake.tsx b/staking-dashboard/src/components/Registration/RegistrationStake.tsx index 80bfc289d..d91470991 100644 --- a/staking-dashboard/src/components/Registration/RegistrationStake.tsx +++ b/staking-dashboard/src/components/Registration/RegistrationStake.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react" import { useRollupData } from "@/hooks/rollup/useRollupData" +import { useRollupRegistry } from "@/hooks/rollup/useRollupRegistry" import { useERC20TokenDetails } from "@/hooks/erc20/useERC20TokenDetails" import { useATPStakingStepsContext, ATPStakingStepsWithTransaction, buildConditionalDependencies } from "@/contexts/ATPStakingStepsContext" import { useTransactionCart } from "@/contexts/TransactionCartContext" @@ -29,7 +30,9 @@ interface RegistrationStakeProps { export const RegistrationStake = ({ onComplete }: RegistrationStakeProps) => { const { formData, handlePrevStep } = useATPStakingStepsContext() const { selectedAtp, uploadedKeystores, transactionType } = formData - const { activationThreshold, version: rollupVersion, isLoading: isLoadingRollup } = useRollupData() + // Use canonical rollup for registration + const { canonical: canonicalRollup, isLoading: isLoadingRegistry } = useRollupRegistry() + const { activationThreshold, version: rollupVersion, isLoading: isLoadingRollup } = useRollupData(canonicalRollup?.address) const { symbol, decimals, isLoading: isLoadingToken } = useERC20TokenDetails(selectedAtp?.token!) const { addTransaction, checkTransactionInQueue, isSafe, openCart } = useTransactionCart() const { showAlert } = useAlert() @@ -170,7 +173,7 @@ export const RegistrationStake = ({ onComplete }: RegistrationStakeProps) => { onComplete() } - const isLoading = isLoadingRollup || isLoadingToken + const isLoading = isLoadingRollup || isLoadingToken || isLoadingRegistry return (
diff --git a/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx b/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx index e6828b659..953191f24 100644 --- a/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx +++ b/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx @@ -4,6 +4,7 @@ import { Icon } from "@/components/Icon" import { StepIndicator } from "@/components/StepIndicator" import { SuccessAlert } from "@/components/SuccessAlert" import { useRollupData } from "@/hooks/rollup/useRollupData" +import { useRollupRegistry } from "@/hooks/rollup/useRollupRegistry" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry/useStakingAssetTokenDetails" import { useAllowance } from "@/hooks/erc20/useAllowance" import { useApproveRollup } from "@/hooks/erc20/useApproveRollup" @@ -41,7 +42,10 @@ export const WalletDirectStakingFlow = ({ onComplete, }: WalletDirectStakingFlowProps) => { const { address } = useAccount() - const { activationThreshold, isLoading: isLoadingRollup } = useRollupData() + // Target canonical rollup for deposits + const { canonical: canonicalRollup, isLoading: isLoadingRegistry } = useRollupRegistry() + const targetRollupAddress = canonicalRollup?.address ?? contracts.rollup.address + const { activationThreshold, isLoading: isLoadingRollup } = useRollupData(targetRollupAddress) const { stakingAssetAddress, symbol, decimals, isLoading: isLoadingToken } = useStakingAssetTokenDetails() const { addTransaction, openCart, transactions, checkTransactionInQueue } = useTransactionCart() const { showAlert } = useAlert() @@ -70,18 +74,16 @@ export const WalletDirectStakingFlow = ({ return activationThreshold * BigInt(stakeCount) }, [activationThreshold, stakeCount]) - // Check current allowance for Rollup contract const { allowance, isLoading: isLoadingAllowance, refetch: refetchAllowance } = useAllowance({ tokenAddress: stakingAssetAddress, owner: address, - spender: contracts.rollup.address, + spender: targetRollupAddress, }) const hasEnoughAllowance = allowance !== undefined && allowance >= totalAmount - // Hooks for building transactions - const approveHook = useApproveRollup(stakingAssetAddress) - const depositHook = useWalletDirectStake() + const approveHook = useApproveRollup(stakingAssetAddress, targetRollupAddress) + const depositHook = useWalletDirectStake(targetRollupAddress) // Track transactions in the queue const approvalTx = useMemo(() => { @@ -342,7 +344,7 @@ export const WalletDirectStakingFlow = ({ setShowSuccessAlert(false) } - const isLoading = isLoadingRollup || isLoadingToken || isLoadingAllowance + const isLoading = isLoadingRollup || isLoadingToken || isLoadingAllowance || isLoadingRegistry const canProceedToStep2 = uploadedKeystores.length > 0 && validatorRunningConfirmed && !uploadError const canProceedToStep3 = hasEnoughAllowance || isApprovalInQueue || isApprovalCompleted diff --git a/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx b/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx index 47f3862e7..364838590 100644 --- a/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx +++ b/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx @@ -1,22 +1,32 @@ +import { useMemo, useState } from "react" import { Icon } from "@/components/Icon" import { CopyButton } from "@/components/CopyButton" import { formatTokenAmountFull } from "@/utils/atpFormatters" import { useClaimCoinbaseRewards, useRemoveCoinbaseAddress } from "@/hooks/rewards" +import { useIsRewardsClaimableAcrossRollups } from "@/hooks/rollup" import type { CoinbaseBreakdown } from "@/hooks/rewards" import type { Address } from "viem" interface CoinbaseAddressListProps { + /** + * Reward breakdown produced by `useCoinbaseRewardsAcrossRollups` (or its compatibility + * wrapper `useMultipleCoinbaseRewards`). May contain multiple entries for the same + * coinbase address — one per rollup that holds a non-zero balance. + */ coinbaseBreakdown: CoinbaseBreakdown[] decimals: number symbol: string + /** + * Configured-rollup claimability flag, kept for backwards compatibility. Used as a fallback + * for rows whose specific rollup hasn't loaded yet. The component itself fetches per-rollup + * `isRewardsClaimable()` for every distinct rollup it sees in the breakdown so each row's + * button reflects its own rollup's state. + */ isRewardsClaimable: boolean isLoading?: boolean onRefetch?: () => void } -/** - * Display list of coinbase addresses with their rewards - */ export const CoinbaseAddressList = ({ coinbaseBreakdown, decimals, @@ -27,15 +37,23 @@ export const CoinbaseAddressList = ({ }: CoinbaseAddressListProps) => { const { removeCoinbaseAddress, isPending: isRemoving } = useRemoveCoinbaseAddress() const claimRewards = useClaimCoinbaseRewards() + const [claimingRowKey, setClaimingRowKey] = useState(null) + const isClaiming = claimRewards.isPending || claimRewards.isConfirming + + const rollupAddressesInBreakdown = useMemo( + () => coinbaseBreakdown.map((item) => item.rollupAddress), + [coinbaseBreakdown], + ) + const { isClaimable: isClaimableForRollup } = useIsRewardsClaimableAcrossRollups(rollupAddressesInBreakdown) const handleRemove = async (address: Address) => { await removeCoinbaseAddress(address) onRefetch?.() } - const handleClaim = async (address: Address) => { - await claimRewards.claimRewards(address) - onRefetch?.() + const handleClaim = (address: Address, rollupAddress: Address) => { + setClaimingRowKey(`${address}-${rollupAddress}`) + claimRewards.claimRewards(address, rollupAddress) } if (isLoading) { @@ -60,9 +78,13 @@ export const CoinbaseAddressList = ({ return (
- {coinbaseBreakdown.map((item) => ( + {coinbaseBreakdown.map((item) => { + const perRollupClaimable = isClaimableForRollup(item.rollupAddress) + const rowIsClaimable = perRollupClaimable ?? isRewardsClaimable + + return (
@@ -73,6 +95,14 @@ export const CoinbaseAddressList = ({ {item.address.slice(0, 10)}...{item.address.slice(-8)} + {item.rollupVersion !== undefined && ( + + Rollup v{item.rollupVersion.toString()} + + )}
{/* Rewards */} @@ -98,15 +128,18 @@ export const CoinbaseAddressList = ({
{/* Claim Button */} - {item.rewards > 0n && ( + {item.rewards > 0n && (() => { + const rowKey = `${item.address}-${item.rollupAddress}` + const isThisRowClaiming = claimingRowKey === rowKey && isClaiming + return (
- {isRewardsClaimable ? ( + {rowIsClaimable ? (
)}
- )} + ) + })()}
- ))} + ) + })}
) } diff --git a/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx b/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx index 25ef5dcb9..9f0851f32 100644 --- a/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx +++ b/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx @@ -50,9 +50,8 @@ export const ManageRewardsAddressesModal = ({ const addCoinbaseAddress = useAddCoinbaseAddress() - // Get rewards for all coinbase addresses const { - coinbaseBreakdown, + allCoinbaseBreakdown, isLoading: isLoadingCoinbaseRewards, refetch: refetchCoinbaseRewards } = useMultipleCoinbaseRewards(coinbaseAddresses as Address[]) @@ -206,7 +205,7 @@ export const ManageRewardsAddressesModal = ({ Your Coinbase Addresses
{ refetchStatus() onWithdrawSuccess?.() diff --git a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletWithdrawalActions.tsx b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletWithdrawalActions.tsx index 51dd4d0ba..c955f6c53 100644 --- a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletWithdrawalActions.tsx +++ b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletWithdrawalActions.tsx @@ -69,6 +69,9 @@ interface WalletWithdrawalActionsProps { actualUnlockTime?: bigint withdrawalDelayDays?: number onSuccess?: () => void + /** The rollup contract the stake actually lives on. Stranded stakes pass the old rollup here + * so `initiateWithdraw` / `finalizeWithdraw` target the correct contract. */ + rollupAddress?: Address } /** @@ -83,17 +86,20 @@ export const WalletWithdrawalActions = ({ actualUnlockTime, withdrawalDelayDays, onSuccess, + rollupAddress, }: WalletWithdrawalActionsProps) => { const { showAlert } = useAlert() const isExiting = status === SequencerStatus.EXITING + // Pass the per-stake rollup so stranded stakes call initiateWithdraw/finalizeWithdraw on the + // correct rollup contract, not the configured one. const { initiateWithdraw, isPending: isInitiatingWithdraw, isConfirming: isConfirmingInitiate, isSuccess: isInitiateSuccess, error: initiateError, - } = useWalletInitiateWithdraw() + } = useWalletInitiateWithdraw(rollupAddress) const { finalizeWithdraw, @@ -101,7 +107,7 @@ export const WalletWithdrawalActions = ({ isConfirming: isConfirmingFinalize, isSuccess: isFinalizeSuccess, error: finalizeError, - } = useFinalizeWithdraw() + } = useFinalizeWithdraw(rollupAddress) const canInitiateUnstake = status === SequencerStatus.VALIDATING || status === SequencerStatus.ZOMBIE diff --git a/staking-dashboard/src/contracts/abis/RollupRegistry.ts b/staking-dashboard/src/contracts/abis/RollupRegistry.ts new file mode 100644 index 000000000..81fd54f93 --- /dev/null +++ b/staking-dashboard/src/contracts/abis/RollupRegistry.ts @@ -0,0 +1,124 @@ +export const RollupRegistryAbi = [ + { + "type": "function", + "name": "getCanonicalRollup", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IHaveVersion" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRollup", + "inputs": [ + { + "name": "_chainId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IHaveVersion" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "numberOfVersions", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getVersion", + "inputs": [ + { + "name": "_index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getGovernance", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRewardDistributor", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IRewardDistributor" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "CanonicalRollupUpdated", + "inputs": [ + { + "name": "instance", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "version", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RewardDistributorUpdated", + "inputs": [ + { + "name": "rewardDistributor", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + } +] as const diff --git a/staking-dashboard/src/contracts/index.ts b/staking-dashboard/src/contracts/index.ts index 5dac2bd64..fc4fe2089 100644 --- a/staking-dashboard/src/contracts/index.ts +++ b/staking-dashboard/src/contracts/index.ts @@ -9,6 +9,7 @@ import { GenesisSequencerSale } from "./abis/GenesisSequencerSale"; import { ATPWithdrawableAndClaimableStakerAbi } from "./abis/ATPWithdrawableAndClaimableStaker"; import { GovernanceAbi } from "./abis/Governance"; import { GSEAbi } from "./abis/GSE"; +import { RollupRegistryAbi } from "./abis/RollupRegistry"; // Define a reusable schema for Ethereum addresses const addressSchema = z @@ -75,6 +76,9 @@ const contracts = { address: env.VITE_GSE_ADDRESS, abi: GSEAbi, }, + rollupRegistry: { + abi: RollupRegistryAbi, + }, } as const; export { contracts }; diff --git a/staking-dashboard/src/hooks/erc20/useApproveRollup.ts b/staking-dashboard/src/hooks/erc20/useApproveRollup.ts index bd90f8161..6ca60df6c 100644 --- a/staking-dashboard/src/hooks/erc20/useApproveRollup.ts +++ b/staking-dashboard/src/hooks/erc20/useApproveRollup.ts @@ -5,11 +5,17 @@ import { contracts } from "@/contracts" import type { RawTransaction } from "@/contexts/TransactionCartContext" /** - * Hook for approving ERC20 tokens for the Rollup contract to spend - * Used for wallet-based direct staking (own validator registration) - * @param tokenAddress - The ERC20 token contract address + * Hook for approving ERC20 tokens for a Rollup contract to spend. + * Used for wallet-based direct staking (own validator registration). + * + * @param tokenAddress - The ERC20 token contract address + * @param rollupAddress - Optional rollup contract that will be the approval spender. Defaults + * to the configured rollup. Registration flows should pass the + * canonical rollup so the approval matches whichever rollup the + * deposit lands on. */ -export function useApproveRollup(tokenAddress?: Address) { +export function useApproveRollup(tokenAddress?: Address, rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const write = useWriteContract() const receipt = useWaitForTransactionReceipt({ @@ -29,7 +35,7 @@ export function useApproveRollup(tokenAddress?: Address) { abi: ERC20Abi, address: tokenAddress, functionName: "approve", - args: [contracts.rollup.address, amount], + args: [targetRollup, amount], }) }, @@ -46,7 +52,7 @@ export function useApproveRollup(tokenAddress?: Address) { data: encodeFunctionData({ abi: ERC20Abi, functionName: "approve", - args: [contracts.rollup.address, amount], + args: [targetRollup, amount], }), value: 0n, } diff --git a/staking-dashboard/src/hooks/rewards/rewardsTypes.ts b/staking-dashboard/src/hooks/rewards/rewardsTypes.ts index 23b0d8588..80e61a312 100644 --- a/staking-dashboard/src/hooks/rewards/rewardsTypes.ts +++ b/staking-dashboard/src/hooks/rewards/rewardsTypes.ts @@ -1,12 +1,25 @@ import type { Address } from 'viem' /** - * Represents a coinbase address saved by the user for tracking self-stake rewards + * Represents the rewards a coinbase address has accumulated on a single rollup. + * + * The dashboard fans out reward queries across all known rollups (canonical + historical), + * so a user with one coinbase address may produce multiple `CoinbaseBreakdown` entries — + * one per rollup that holds a non-zero balance for that coinbase. The `rollupAddress` / + * `rollupVersion` fields are how UIs disambiguate the rows and how the claim engine + * routes each `claimSequencerRewards` call to the correct rollup contract. + * + * For backwards compatibility with single-rollup deployments, callers querying only the + * configured rollup populate `rollupAddress` with `contracts.rollup.address`. */ export interface CoinbaseBreakdown { address: Address rewards: bigint source: 'manual' + /** The rollup contract these rewards live on. */ + rollupAddress: Address + /** Optional rollup version for display (e.g. "v3"). Resolved from the registry when available. */ + rollupVersion?: bigint } /** diff --git a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts index 4b9c90aa1..8b0897451 100644 --- a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts +++ b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from "react" +import { useEffect, useCallback, useRef, useMemo, useReducer } from "react" import { useAccount } from "wagmi" import { useClaimSplitRewards } from "@/hooks/splits/useClaimSplitRewards" import { useClaimSequencerRewards } from "@/hooks/rollup/useClaimSequencerRewards" @@ -12,6 +12,8 @@ import type { SplitData } from "@/hooks/splits/types" import type { DelegationBreakdown } from "@/hooks/atp/useAggregatedStakingData" import type { CoinbaseBreakdown } from "./rewardsTypes" +// ── Types ────────────────────────────────────────────────────────────── + export type ClaimTaskStatus = 'pending' | 'processing' | 'completed' | 'error' | 'skipped' export type ClaimTaskType = 'delegation' | 'coinbase' @@ -22,31 +24,128 @@ export interface ClaimTask { estimatedRewards: bigint status: ClaimTaskStatus error?: Error - // Delegation-specific data splitContract?: Address splitData?: SplitData providerTakeRate?: number - // Coinbase-specific data coinbaseAddress?: Address - // Sub-step tracking for delegations + /** Rollup contract this task targets for claiming. */ + rollupAddress?: Address + rollupVersion?: bigint currentSubStep?: 'claiming' | 'distributing' | 'withdrawing' } +// ── State machine ────────────────────────────────────────────────────── + +type Phase = 'idle' | 'ready_to_trigger' | 'waiting_for_result' | 'advancing' + +interface EngineState { + tasks: ClaimTask[] + currentIndex: number | null + phase: Phase + error: Error | null +} + +type Action = + | { type: 'START'; tasks: ClaimTask[] } + | { type: 'TRIGGERED' } + | { type: 'TASK_COMPLETED' } + | { type: 'TASK_FAILED'; error: Error } + | { type: 'UPDATE_SUBSTEP'; subStep: string } + | { type: 'ADVANCED' } + | { type: 'CANCEL' } + | { type: 'RESET' } + | { type: 'RETRY' } + +const initialState: EngineState = { + tasks: [], + currentIndex: null, + phase: 'idle', + error: null, +} + +function reducer(state: EngineState, action: Action): EngineState { + switch (action.type) { + case 'START': + return { tasks: action.tasks, currentIndex: 0, phase: 'ready_to_trigger', error: null } + + case 'TRIGGERED': + return { + ...state, + phase: 'waiting_for_result', + tasks: state.tasks.map((t, i) => + i === state.currentIndex ? { ...t, status: 'processing' as const } : t + ), + } + + case 'TASK_COMPLETED': + return { + ...state, + phase: 'advancing', + tasks: state.tasks.map((t, i) => + i === state.currentIndex ? { ...t, status: 'completed' as const } : t + ), + } + + case 'TASK_FAILED': + return { + ...state, + phase: 'advancing', + error: action.error, + tasks: state.tasks.map((t, i) => + i === state.currentIndex ? { ...t, status: 'error' as const, error: action.error } : t + ), + } + + case 'UPDATE_SUBSTEP': + return { + ...state, + tasks: state.tasks.map((t, i) => + i === state.currentIndex + ? { ...t, currentSubStep: action.subStep as ClaimTask['currentSubStep'] } + : t + ), + } + + case 'ADVANCED': { + const nextIndex = state.currentIndex! + 1 + if (nextIndex < state.tasks.length) { + return { ...state, currentIndex: nextIndex, phase: 'ready_to_trigger' } + } + return { ...state, currentIndex: null, phase: 'idle' } + } + + case 'CANCEL': + return { ...state, currentIndex: null, phase: 'idle' } + + case 'RESET': + return initialState + + case 'RETRY': { + const retried = state.tasks.map(t => + t.status === 'error' ? { ...t, status: 'pending' as const, error: undefined } : t + ) + const firstPending = retried.findIndex(t => t.status === 'pending') + if (firstPending === -1) return state + return { tasks: retried, currentIndex: firstPending, phase: 'ready_to_trigger', error: null } + } + + default: + return state + } +} + +// ── Return type ──────────────────────────────────────────────────────── + interface UseClaimAllRewardsReturn { - // Actions startClaiming: (delegations: DelegationBreakdown[], coinbases: CoinbaseBreakdown[]) => void cancelClaiming: () => void retryFailed: () => void reset: () => void - - // State tasks: ClaimTask[] currentTask: ClaimTask | null currentTaskIndex: number | null isProcessing: boolean progressPercent: number - - // Results isSuccess: boolean isError: boolean error: Error | null @@ -54,343 +153,208 @@ interface UseClaimAllRewardsReturn { failedTasks: ClaimTask[] } -/** - * Hook to orchestrate claiming rewards from multiple delegation splits and coinbase addresses - * Processes tasks sequentially: delegations first (3 steps each), then coinbases (1 step each) - */ +// ── Hook ─────────────────────────────────────────────────────────────── + export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { const { address: userAddress } = useAccount() const { stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() - // Task queue state - const [tasks, setTasks] = useState([]) - const [currentTaskIndex, setCurrentTaskIndex] = useState(null) - const [isProcessing, setIsProcessing] = useState(false) - const [error, setError] = useState(null) - const [hasTriggeredClaim, setHasTriggeredClaim] = useState(false) + const [state, dispatch] = useReducer(reducer, initialState) + const currentTask = state.currentIndex !== null ? state.tasks[state.currentIndex] : null - // Track if we were cancelled - const cancelledRef = useRef(false) - - // Get current task - const currentTask = currentTaskIndex !== null ? tasks[currentTaskIndex] : null - - // Get current task's addresses + // ── Delegation balance hooks (driven by currentTask) ───────────── const currentSplitContract = currentTask?.type === 'delegation' ? currentTask.splitContract : undefined const currentCoinbase = currentTask?.type === 'coinbase' ? currentTask.coinbaseAddress : undefined + const currentTaskRollup = currentTask?.rollupAddress - // Fetch balances for current task (for delegations) - extract refetch functions const { warehouseAddress, isLoading: isLoadingWarehouse } = useSplitsWarehouse(currentSplitContract) - const { rewards: rollupBalance, isLoading: isLoadingRollup, refetch: refetchRollup } = useSequencerRewards(currentSplitContract || currentCoinbase || '') - const { balance: splitContractBalance, isLoading: isLoadingSplitBalance, refetch: refetchSplitContract } = useERC20Balance(tokenAddress, currentSplitContract) - const { balance: warehouseBalance, isLoading: isLoadingWarehouseBalance, refetch: refetchWarehouse } = useWarehouseBalance(warehouseAddress, userAddress, tokenAddress) + const { rewards: rollupBalance, isLoading: isLoadingRollup, refetch: refetchRollup } = + useSequencerRewards(currentSplitContract || currentCoinbase || '', currentTaskRollup) + const { balance: splitContractBalance, isLoading: isLoadingSplitBalance, refetch: refetchSplitContract } = + useERC20Balance(tokenAddress, currentSplitContract) + const { balance: warehouseBalance, isLoading: isLoadingWarehouseBalance, refetch: refetchWarehouse } = + useWarehouseBalance(warehouseAddress, userAddress, tokenAddress) const isLoadingBalances = currentTask?.type === 'delegation' ? (isLoadingWarehouse || isLoadingRollup || isLoadingSplitBalance || isLoadingWarehouseBalance) : isLoadingRollup - // Memoize balances object to prevent effect re-runs on every render const balances = useMemo(() => ({ - rollupBalance, - splitContractBalance, - warehouseBalance, - refetchRollup, - refetchSplitContract, - refetchWarehouse + rollupBalance, splitContractBalance, warehouseBalance, + refetchRollup, refetchSplitContract, refetchWarehouse }), [rollupBalance, splitContractBalance, warehouseBalance, refetchRollup, refetchSplitContract, refetchWarehouse]) - // Use existing hooks for claiming + // ── Claim hooks ────────────────────────────────────────────────── const delegationClaimHook = useClaimSplitRewards( currentSplitContract, currentTask?.splitData || { recipients: [], allocations: [], totalAllocation: 0n, distributionIncentive: 0 }, - tokenAddress, - userAddress, - balances + tokenAddress, userAddress, balances ) - const coinbaseClaimHook = useClaimSequencerRewards() - /** - * Build SplitData from delegation info - */ - const buildSplitData = useCallback((delegation: DelegationBreakdown, user: Address): SplitData => { - const totalAllocation = 10000n - const providerAllocation = BigInt(delegation.providerTakeRate) - const userAllocation = totalAllocation - providerAllocation - - return { - recipients: [delegation.providerRewardsRecipient as Address, user], - allocations: [providerAllocation, userAllocation], - totalAllocation, - distributionIncentive: 0 - } - }, []) + // Stable refs for calling inside effects without dep issues + const delegationRef = useRef(delegationClaimHook) + delegationRef.current = delegationClaimHook + const coinbaseRef = useRef(coinbaseClaimHook) + coinbaseRef.current = coinbaseClaimHook - /** - * Start claiming all rewards - */ - const startClaiming = useCallback((delegations: DelegationBreakdown[], coinbases: CoinbaseBreakdown[]) => { - if (!userAddress || (!delegations.length && !coinbases.length)) return + // ── Effect 1: TRIGGER — start the claim for the current task ───── + useEffect(() => { + if (state.phase !== 'ready_to_trigger' || state.currentIndex === null) return + const task = state.tasks[state.currentIndex] + if (!task) return + if (task.type === 'delegation' && isLoadingBalances) return - cancelledRef.current = false + dispatch({ type: 'TRIGGERED' }) - // Build task list: delegations first, then coinbases - const newTasks: ClaimTask[] = [ - ...delegations.map((delegation): ClaimTask => ({ - id: `delegation-${delegation.splitContract}`, - type: 'delegation', - displayName: delegation.providerName || `Provider ${delegation.providerId}`, - estimatedRewards: delegation.rewards, - status: 'pending', - splitContract: delegation.splitContract as Address, - splitData: buildSplitData(delegation, userAddress), - providerTakeRate: delegation.providerTakeRate - })), - ...coinbases.map((coinbase): ClaimTask => ({ - id: `coinbase-${coinbase.address}`, - type: 'coinbase', - displayName: `${coinbase.address.slice(0, 6)}...${coinbase.address.slice(-4)}`, - estimatedRewards: coinbase.rewards, - status: 'pending', - coinbaseAddress: coinbase.address - })) - ] + if (task.type === 'delegation') { + delegationRef.current.claim() + } else if (task.type === 'coinbase' && task.coinbaseAddress) { + coinbaseRef.current.claimRewards(task.coinbaseAddress, task.rollupAddress) + } + }, [state.phase, state.currentIndex, state.tasks, isLoadingBalances]) - // Filter out tasks with no rewards - const tasksWithRewards = newTasks.filter(task => task.estimatedRewards > 0n) + // ── Effect 2: RESULT — watch hooks for success or error ────────── + useEffect(() => { + if (state.phase !== 'waiting_for_result' || !currentTask) return - if (tasksWithRewards.length === 0) { - setError(new Error('No rewards to claim')) - return - } + const isSuccess = currentTask.type === 'delegation' + ? delegationClaimHook.isSuccess && delegationClaimHook.claimStep === 'idle' + : coinbaseClaimHook.isSuccess - setTasks(tasksWithRewards) - setCurrentTaskIndex(0) - setIsProcessing(true) - setError(null) - setHasTriggeredClaim(false) + const isError = currentTask.type === 'delegation' + ? delegationClaimHook.isError + : coinbaseClaimHook.isError - // Reset hooks - delegationClaimHook.reset() - coinbaseClaimHook.reset() - }, [userAddress, buildSplitData, delegationClaimHook, coinbaseClaimHook]) + const hookError = currentTask.type === 'delegation' + ? delegationClaimHook.error + : coinbaseClaimHook.error - /** - * Cancel claiming - stops processing but keeps completed - */ - const cancelClaiming = useCallback(() => { - cancelledRef.current = true - setIsProcessing(false) - setCurrentTaskIndex(null) - setHasTriggeredClaim(false) - delegationClaimHook.reset() - coinbaseClaimHook.reset() - }, [delegationClaimHook, coinbaseClaimHook]) - - /** - * Retry failed tasks - */ - const retryFailed = useCallback(() => { - const failedTasks = tasks.filter(t => t.status === 'error') - if (failedTasks.length === 0) return - - // Reset failed tasks to pending - setTasks(prev => prev.map(t => - t.status === 'error' ? { ...t, status: 'pending' as const, error: undefined } : t - )) - - // Find first pending task - const firstPendingIndex = tasks.findIndex(t => t.status === 'pending' || t.status === 'error') - if (firstPendingIndex !== -1) { - cancelledRef.current = false - setCurrentTaskIndex(firstPendingIndex) - setIsProcessing(true) - setError(null) - setHasTriggeredClaim(false) + if (isSuccess) { + dispatch({ type: 'TASK_COMPLETED' }) + } else if (isError && hookError) { + dispatch({ type: 'TASK_FAILED', error: hookError as Error }) } - }, [tasks]) + }, [ + state.phase, currentTask, + delegationClaimHook.isSuccess, delegationClaimHook.isError, + delegationClaimHook.error, delegationClaimHook.claimStep, + coinbaseClaimHook.isSuccess, coinbaseClaimHook.isError, coinbaseClaimHook.error, + ]) - /** - * Reset all state - */ - const reset = useCallback(() => { - cancelledRef.current = false - setTasks([]) - setCurrentTaskIndex(null) - setIsProcessing(false) - setError(null) - setHasTriggeredClaim(false) - delegationClaimHook.reset() - coinbaseClaimHook.reset() - }, [delegationClaimHook, coinbaseClaimHook]) - - /** - * Start claim for current task when ready - */ + // ── Effect 3: ADVANCE — delay, reset hooks, move to next task ──── useEffect(() => { - if (!isProcessing || currentTaskIndex === null || hasTriggeredClaim || cancelledRef.current) return - - const task = tasks[currentTaskIndex] - if (!task || task.status !== 'pending') return - - // Wait for balances to load for delegations - if (task.type === 'delegation' && isLoadingBalances) return + if (state.phase !== 'advancing') return - // Mark task as processing - setTasks(prev => prev.map((t, i) => - i === currentTaskIndex ? { ...t, status: 'processing' as const } : t - )) - setHasTriggeredClaim(true) + const timeout = setTimeout(() => { + delegationRef.current.reset() + coinbaseRef.current.reset() + dispatch({ type: 'ADVANCED' }) + }, 500) - // Start the appropriate claim - if (task.type === 'delegation') { - delegationClaimHook.claim() - } else if (task.type === 'coinbase' && task.coinbaseAddress) { - coinbaseClaimHook.claimRewards(task.coinbaseAddress) - } - }, [isProcessing, currentTaskIndex, tasks, hasTriggeredClaim, isLoadingBalances, delegationClaimHook, coinbaseClaimHook]) + return () => clearTimeout(timeout) + }, [state.phase]) - /** - * Update sub-step for delegation tasks - */ + // ── Effect 4: SUBSTEP — update delegation sub-step display ─────── useEffect(() => { - if (!currentTask || currentTask.type !== 'delegation' || !isProcessing) return + if (state.phase !== 'waiting_for_result') return + if (!currentTask || currentTask.type !== 'delegation') return const subStep = delegationClaimHook.claimStep if (subStep !== 'idle') { - setTasks(prev => prev.map((t, i) => - i === currentTaskIndex ? { ...t, currentSubStep: subStep as 'claiming' | 'distributing' | 'withdrawing' } : t - )) + dispatch({ type: 'UPDATE_SUBSTEP', subStep }) } - }, [delegationClaimHook.claimStep, currentTaskIndex, currentTask?.type, isProcessing]) - - /** - * Handle task completion and move to next - */ - useEffect(() => { - if (!isProcessing || currentTaskIndex === null || !hasTriggeredClaim || cancelledRef.current) return + }, [state.phase, currentTask, delegationClaimHook.claimStep]) - const task = tasks[currentTaskIndex] - if (!task || task.status !== 'processing') return + // ── Actions ────────────────────────────────────────────────────── - let isComplete = false - - // Check completion based on task type - if (task.type === 'delegation') { - isComplete = delegationClaimHook.isSuccess && delegationClaimHook.claimStep === 'idle' - } else if (task.type === 'coinbase') { - isComplete = coinbaseClaimHook.isSuccess + const buildSplitData = useCallback((delegation: DelegationBreakdown, user: Address): SplitData => { + const totalAllocation = 10000n + const providerAllocation = BigInt(delegation.providerTakeRate) + return { + recipients: [delegation.providerRewardsRecipient as Address, user], + allocations: [providerAllocation, totalAllocation - providerAllocation], + totalAllocation, + distributionIncentive: 0 } + }, []) - if (isComplete) { - // Mark task as completed - setTasks(prev => prev.map((t, i) => - i === currentTaskIndex ? { ...t, status: 'completed' as const } : t - )) - - // Reset hooks for next task - delegationClaimHook.reset() - coinbaseClaimHook.reset() - - // Small delay before moving to next task - const timeoutId = setTimeout(() => { - if (cancelledRef.current) return - - // Move to next task - const nextIndex = currentTaskIndex + 1 - if (nextIndex < tasks.length) { - setCurrentTaskIndex(nextIndex) - setHasTriggeredClaim(false) - } else { - // All done - setIsProcessing(false) - setCurrentTaskIndex(null) - } - }, 500) - - return () => clearTimeout(timeoutId) - } - }, [ - isProcessing, - currentTaskIndex, - tasks, - hasTriggeredClaim, - delegationClaimHook.isSuccess, - delegationClaimHook.claimStep, - coinbaseClaimHook.isSuccess, - delegationClaimHook, - coinbaseClaimHook - ]) + const resetHooks = useCallback(() => { + delegationRef.current.reset() + coinbaseRef.current.reset() + }, []) - /** - * Handle errors - */ - useEffect(() => { - if (!isProcessing || currentTaskIndex === null) return + const startClaiming = useCallback((delegations: DelegationBreakdown[], coinbases: CoinbaseBreakdown[]) => { + if (!userAddress || (!delegations.length && !coinbases.length)) return - const task = tasks[currentTaskIndex] - if (!task || task.status !== 'processing') return + const newTasks: ClaimTask[] = [ + ...delegations.map((d): ClaimTask => ({ + id: `delegation-${d.splitContract}`, + type: 'delegation', + displayName: d.providerName || `Provider ${d.providerId}`, + estimatedRewards: d.rewards, + status: 'pending', + splitContract: d.splitContract as Address, + splitData: buildSplitData(d, userAddress), + providerTakeRate: d.providerTakeRate + })), + ...coinbases.map((c): ClaimTask => ({ + id: `coinbase-${c.address}-${c.rollupAddress}`, + type: 'coinbase', + displayName: c.rollupVersion !== undefined + ? `${c.address.slice(0, 6)}...${c.address.slice(-4)} (rollup v${c.rollupVersion})` + : `${c.address.slice(0, 6)}...${c.address.slice(-4)}`, + estimatedRewards: c.rewards, + status: 'pending', + coinbaseAddress: c.address, + rollupAddress: c.rollupAddress, + rollupVersion: c.rollupVersion, + })) + ].filter(t => t.estimatedRewards > 0n) - let taskError: Error | null = null + if (newTasks.length === 0) return - if (task.type === 'delegation' && delegationClaimHook.isError) { - taskError = delegationClaimHook.error as Error - } else if (task.type === 'coinbase' && coinbaseClaimHook.isError) { - taskError = coinbaseClaimHook.error as Error - } + resetHooks() + dispatch({ type: 'START', tasks: newTasks }) + }, [userAddress, buildSplitData, resetHooks]) - if (taskError) { - // Mark task as failed - setTasks(prev => prev.map((t, i) => - i === currentTaskIndex ? { ...t, status: 'error' as const, error: taskError } : t - )) + const cancelClaiming = useCallback(() => { + resetHooks() + dispatch({ type: 'CANCEL' }) + }, [resetHooks]) - // Stop processing on error - setIsProcessing(false) - setError(taskError) - setHasTriggeredClaim(false) + const retryFailed = useCallback(() => { + resetHooks() + dispatch({ type: 'RETRY' }) + }, [resetHooks]) - // Reset hooks - delegationClaimHook.reset() - coinbaseClaimHook.reset() - } - }, [ - isProcessing, - currentTaskIndex, - tasks, - delegationClaimHook.isError, - delegationClaimHook.error, - coinbaseClaimHook.isError, - coinbaseClaimHook.error, - delegationClaimHook, - coinbaseClaimHook - ]) + const reset = useCallback(() => { + resetHooks() + dispatch({ type: 'RESET' }) + }, [resetHooks]) - // Calculate progress - const completedTasks = tasks.filter(t => t.status === 'completed') - const failedTasks = tasks.filter(t => t.status === 'error') - const progressPercent = tasks.length > 0 - ? Math.round((completedTasks.length / tasks.length) * 100) - : 0 + // ── Derived state ──────────────────────────────────────────────── - // Determine overall success/error state - const isSuccess = tasks.length > 0 && completedTasks.length === tasks.length - const isError = failedTasks.length > 0 + const completedTasks = state.tasks.filter(t => t.status === 'completed') + const failedTasks = state.tasks.filter(t => t.status === 'error') + const doneTasks = completedTasks.length + failedTasks.length + const isProcessing = state.phase !== 'idle' + const isAllDone = state.tasks.length > 0 && !isProcessing && doneTasks === state.tasks.length return { startClaiming, cancelClaiming, retryFailed, reset, - tasks, + tasks: state.tasks, currentTask, - currentTaskIndex, + currentTaskIndex: state.currentIndex, isProcessing, - progressPercent, - isSuccess, - isError, - error, + progressPercent: state.tasks.length > 0 ? Math.round((doneTasks / state.tasks.length) * 100) : 0, + isSuccess: isAllDone && completedTasks.length > 0, + isError: isAllDone && failedTasks.length > 0, + error: state.error, completedTasks, - failedTasks + failedTasks, } } diff --git a/staking-dashboard/src/hooks/rewards/useClaimCoinbaseRewards.ts b/staking-dashboard/src/hooks/rewards/useClaimCoinbaseRewards.ts index 72c9fd6ef..886e6f969 100644 --- a/staking-dashboard/src/hooks/rewards/useClaimCoinbaseRewards.ts +++ b/staking-dashboard/src/hooks/rewards/useClaimCoinbaseRewards.ts @@ -2,17 +2,23 @@ import { useClaimSequencerRewards } from "@/hooks/rollup/useClaimSequencerReward import type { Address } from "viem" /** - * Hook to claim rewards for a coinbase address - * This is a wrapper around useClaimSequencerRewards for consistency + * Hook to claim rewards for a coinbase address. + * This is a wrapper around useClaimSequencerRewards for consistency. * * Claim flow for self-stake (coinbase) rewards is 1 step: * 1. Call claimSequencerRewards(coinbaseAddress) - rewards go directly to coinbase + * + * @param rollupAddress - Optional default rollup contract to claim from. Defaults to the + * configured rollup. The returned `claimRewards` also accepts an + * optional per-call rollup override so callers iterating over + * multiple rollups can re-use a single hook instance. */ -export function useClaimCoinbaseRewards() { - const claimSequencerRewards = useClaimSequencerRewards() +export function useClaimCoinbaseRewards(rollupAddress?: Address) { + const claimSequencerRewards = useClaimSequencerRewards(rollupAddress) return { - claimRewards: (coinbaseAddress: Address) => claimSequencerRewards.claimRewards(coinbaseAddress), + claimRewards: (coinbaseAddress: Address, overrideRollup?: Address) => + claimSequencerRewards.claimRewards(coinbaseAddress, overrideRollup), reset: claimSequencerRewards.reset, txHash: claimSequencerRewards.txHash, error: claimSequencerRewards.error, diff --git a/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts b/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts new file mode 100644 index 000000000..d983964db --- /dev/null +++ b/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts @@ -0,0 +1,86 @@ +import { useMemo } from "react" +import { useReadContracts } from "wagmi" +import type { Address } from "viem" +import { contracts } from "@/contracts" +import { useRollupRegistry } from "@/hooks/rollup/useRollupRegistry" +import type { CoinbaseBreakdown } from "./rewardsTypes" + +/** + * Multicalls `getSequencerRewards(coinbase)` across every registry-discovered rollup. + * Returns one `CoinbaseBreakdown` per `(coinbase, rollup)` pair with a non-zero balance. + */ +export function useCoinbaseRewardsAcrossRollups(coinbaseAddresses: Address[]) { + const { rollups, isLoading: isLoadingRegistry, error: registryError } = useRollupRegistry() + + const effectiveRollups = useMemo(() => { + if (rollups.length > 0) return rollups + return [{ version: undefined as bigint | undefined, address: contracts.rollup.address }] + }, [rollups]) + + const pairs = useMemo(() => { + const out: Array<{ rollupAddress: Address; rollupVersion: bigint | undefined; coinbase: Address }> = [] + for (const rollup of effectiveRollups) { + for (const coinbase of coinbaseAddresses) { + out.push({ rollupAddress: rollup.address, rollupVersion: rollup.version, coinbase }) + } + } + return out + }, [effectiveRollups, coinbaseAddresses]) + + const { data, isLoading, isError, error, refetch } = useReadContracts({ + contracts: pairs.length > 0 + ? pairs.map( + (p) => + ({ + address: p.rollupAddress, + abi: contracts.rollup.abi, + functionName: "getSequencerRewards", + args: [p.coinbase], + }) as const, + ) + : undefined, + query: { + enabled: pairs.length > 0, + refetchInterval: 30 * 1000, + }, + }) + + const allCoinbaseBreakdown = useMemo(() => { + if (!data || pairs.length === 0) return [] + const out: CoinbaseBreakdown[] = [] + for (let i = 0; i < pairs.length; i++) { + const pair = pairs[i] + const result = data[i] + const rewards = + result?.status === "success" ? ((result.result as bigint | undefined) ?? 0n) : 0n + out.push({ + address: pair.coinbase, + rewards, + source: "manual", + rollupAddress: pair.rollupAddress, + rollupVersion: pair.rollupVersion, + }) + } + return out + }, [data, pairs]) + + const coinbaseBreakdown = useMemo( + () => allCoinbaseBreakdown.filter((item) => item.rewards > 0n), + [allCoinbaseBreakdown], + ) + + const totalCoinbaseRewards = useMemo( + () => coinbaseBreakdown.reduce((total, item) => total + item.rewards, 0n), + [coinbaseBreakdown], + ) + + return { + allCoinbaseBreakdown, + coinbaseBreakdown, + totalCoinbaseRewards, + isLoading: isLoading || isLoadingRegistry, + isError: !!isError || !!registryError, + error: error ?? registryError, + refetch, + } +} diff --git a/staking-dashboard/src/hooks/rewards/useMultipleCoinbaseRewards.ts b/staking-dashboard/src/hooks/rewards/useMultipleCoinbaseRewards.ts index dce6c4cf8..8f08581f7 100644 --- a/staking-dashboard/src/hooks/rewards/useMultipleCoinbaseRewards.ts +++ b/staking-dashboard/src/hooks/rewards/useMultipleCoinbaseRewards.ts @@ -1,53 +1,15 @@ -import { useReadContracts } from "wagmi" -import { contracts } from "@/contracts" import type { Address } from "viem" -import type { CoinbaseBreakdown } from "./rewardsTypes" +import { useCoinbaseRewardsAcrossRollups } from "./useCoinbaseRewardsAcrossRollups" /** - * Hook to fetch rewards for multiple coinbase addresses - * Uses batched contract calls for efficiency + * Hook to fetch rewards for multiple coinbase addresses. + * + * This is a thin compatibility wrapper around {@link useCoinbaseRewardsAcrossRollups} + * which fans the read out across every rollup discovered via the Aztec governance + * Registry. The returned `coinbaseBreakdown` may contain multiple entries for the same + * coinbase address — one per rollup that has a non-zero balance — so the rewards UI + * naturally surfaces stranded balances on older rollups. */ export function useMultipleCoinbaseRewards(coinbaseAddresses: Address[]) { - // Build contract calls for each coinbase address - const contractCalls = coinbaseAddresses.map(address => ({ - address: contracts.rollup.address, - abi: contracts.rollup.abi, - functionName: "getSequencerRewards" as const, - args: [address] - })) - - const { data, isLoading, isError, error, refetch } = useReadContracts({ - contracts: contractCalls, - query: { - enabled: coinbaseAddresses.length > 0, - refetchInterval: 30 * 1000 // Auto-refresh every 30 seconds - } - }) - - // Parse results into CoinbaseBreakdown objects - const coinbaseBreakdown: CoinbaseBreakdown[] = coinbaseAddresses.map((address, index) => { - const result = data?.[index] - const rewards = (result?.status === "success" ? result.result as bigint : 0n) ?? 0n - - return { - address, - rewards, - source: "manual" as const - } - }) - - // Calculate total rewards - const totalCoinbaseRewards = coinbaseBreakdown.reduce( - (total, item) => total + item.rewards, - 0n - ) - - return { - coinbaseBreakdown, - totalCoinbaseRewards, - isLoading, - isError, - error, - refetch - } + return useCoinbaseRewardsAcrossRollups(coinbaseAddresses) } diff --git a/staking-dashboard/src/hooks/rollup/index.ts b/staking-dashboard/src/hooks/rollup/index.ts index d35471714..449c9f638 100644 --- a/staking-dashboard/src/hooks/rollup/index.ts +++ b/staking-dashboard/src/hooks/rollup/index.ts @@ -3,6 +3,7 @@ export { useActivationThresholdFormatted } from "./useActivationThresholdFormatt export { useSequencerRewards } from "./useSequencerRewards"; export { useClaimSequencerRewards } from "./useClaimSequencerRewards"; export { useIsRewardsClaimable } from "./useIsRewardsClaimable"; +export { useIsRewardsClaimableAcrossRollups } from "./useIsRewardsClaimableAcrossRollups"; export { useEjectionThreshold } from "./useEjectionThreshold"; export { useStakeHealth } from "./useStakeHealth"; export type { StakeHealth } from "./useStakeHealth"; @@ -10,3 +11,7 @@ export { useFinalizeWithdraw } from "./useFinalizeWithdraw"; export { useWalletInitiateWithdraw } from "./useWalletInitiateWithdraw"; export { useWalletDirectStake } from "./useWalletDirectStake"; export { useSequencerStatus, SequencerStatus, getStatusLabel } from "./useSequencerStatus"; +export { useRollupRegistry } from "./useRollupRegistry"; +export type { RollupInstance } from "./useRollupRegistry"; +export { useAttesterStakeLocation } from "./useAttesterStakeLocation"; +export type { AttesterStakeLocation } from "./useAttesterStakeLocation"; diff --git a/staking-dashboard/src/hooks/rollup/useActivationThresholdFormatted.ts b/staking-dashboard/src/hooks/rollup/useActivationThresholdFormatted.ts index cbbcf9e11..c0bd81b61 100644 --- a/staking-dashboard/src/hooks/rollup/useActivationThresholdFormatted.ts +++ b/staking-dashboard/src/hooks/rollup/useActivationThresholdFormatted.ts @@ -1,16 +1,19 @@ import { useReadContract } from "wagmi" +import type { Address } from "viem" import { contracts } from "../../contracts" import { useStakingAssetTokenDetails } from "../stakingRegistry/useStakingAssetTokenDetails" import { formatTokenAmount } from "@/utils/atpFormatters" /** - * Hook to get formatted activation threshold with token details - * Fetches activation threshold directly from rollup contract and formats with staking asset token details + * Hook to get formatted activation threshold with token details. + * + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. */ -export function useActivationThresholdFormatted() { +export function useActivationThresholdFormatted(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const { data: activationThreshold, isLoading: isLoadingThreshold, error: thresholdError } = useReadContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "getActivationThreshold", query: { staleTime: Infinity, diff --git a/staking-dashboard/src/hooks/rollup/useAttesterStakeLocation.ts b/staking-dashboard/src/hooks/rollup/useAttesterStakeLocation.ts new file mode 100644 index 000000000..3d6aae06c --- /dev/null +++ b/staking-dashboard/src/hooks/rollup/useAttesterStakeLocation.ts @@ -0,0 +1,78 @@ +import { useMemo } from "react" +import { useReadContracts } from "wagmi" +import type { Address } from "viem" +import { contracts } from "@/contracts" +import { useRollupRegistry } from "./useRollupRegistry" + +/** + * Locates the rollup where a given attester's stake actually lives by multicalling + * `getAttesterView` across all registry-discovered rollups. Prefers the canonical rollup. + */ + +const STATUS_NONE = 0 + +export interface AttesterStakeLocation { + rollupAddress: Address + rollupVersion: bigint | undefined + /** Status from `getAttesterView` on the resolved rollup. */ + status: number +} + +export function useAttesterStakeLocation(attesterAddress: Address | undefined) { + const { rollups, canonical, isLoading: isLoadingRegistry, error: registryError } = useRollupRegistry() + + // Fall back to the configured rollup when registry discovery hasn't produced anything yet so + // single-rollup deployments and the loading window keep working. + const effectiveRollups = useMemo(() => { + if (rollups.length > 0) return rollups + return [{ version: undefined as bigint | undefined, address: contracts.rollup.address }] + }, [rollups]) + + const { data, isLoading, error } = useReadContracts({ + contracts: attesterAddress + ? effectiveRollups.map( + (r) => + ({ + address: r.address, + abi: contracts.rollup.abi, + functionName: "getAttesterView", + args: [attesterAddress], + }) as const, + ) + : undefined, + query: { + enabled: !!attesterAddress && effectiveRollups.length > 0, + }, + }) + + const location = useMemo(() => { + if (!data || effectiveRollups.length === 0) return undefined + const matches: AttesterStakeLocation[] = [] + for (let i = 0; i < effectiveRollups.length; i++) { + const rollup = effectiveRollups[i] + const result = data[i] + if (result?.status !== "success") continue + const view = result.result as { status: number } | undefined + if (!view || view.status === STATUS_NONE) continue + matches.push({ + rollupAddress: rollup.address, + rollupVersion: rollup.version, + status: view.status, + }) + } + if (matches.length === 0) return undefined + // Prefer the canonical rollup if the attester is active on it. + if (canonical) { + const onCanonical = matches.find((m) => m.rollupAddress.toLowerCase() === canonical.address.toLowerCase()) + if (onCanonical) return onCanonical + } + // Otherwise return the latest non-canonical match (registry order = oldest → newest, so last is latest). + return matches[matches.length - 1] + }, [data, effectiveRollups, canonical]) + + return { + location, + isLoading: isLoading || isLoadingRegistry, + error: error ?? registryError, + } +} diff --git a/staking-dashboard/src/hooks/rollup/useAttesterView.ts b/staking-dashboard/src/hooks/rollup/useAttesterView.ts index edd441645..a68631697 100644 --- a/staking-dashboard/src/hooks/rollup/useAttesterView.ts +++ b/staking-dashboard/src/hooks/rollup/useAttesterView.ts @@ -3,11 +3,18 @@ import type { Address } from "viem" import { contracts } from "@/contracts" /** - * Hook to get comprehensive attester/sequencer information including status, balance, and exit details + * Hook to get comprehensive attester/sequencer information including status, balance, and exit details. + * + * @param attesterAddress - The attester address to query + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. + * An attester active on rollup v3 but not v4 will appear with status NONE + * on the canonical rollup — pass the specific rollup address to detect + * stranded stakes on older rollups. */ -export function useAttesterView(attesterAddress: Address | undefined) { +export function useAttesterView(attesterAddress: Address | undefined, rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const { data, isLoading, error, refetch } = useReadContract({ - address: contracts.rollup.address, + address: targetRollup, abi: contracts.rollup.abi, functionName: "getAttesterView", args: attesterAddress ? [attesterAddress] : undefined, diff --git a/staking-dashboard/src/hooks/rollup/useClaimSequencerRewards.ts b/staking-dashboard/src/hooks/rollup/useClaimSequencerRewards.ts index 3698a4fe9..d30308f6e 100644 --- a/staking-dashboard/src/hooks/rollup/useClaimSequencerRewards.ts +++ b/staking-dashboard/src/hooks/rollup/useClaimSequencerRewards.ts @@ -3,9 +3,14 @@ import { contracts } from "@/contracts" import type { Address } from "viem" /** - * Hook to claim sequencer rewards to a specified coinbase address + * Hook to claim sequencer rewards to a specified coinbase address. + * + * @param rollupAddress - Optional rollup contract to claim from. Defaults to the configured + * rollup. Callers that need to claim across multiple rollups should pass + * the specific rollup address each call lives on. */ -export function useClaimSequencerRewards() { +export function useClaimSequencerRewards(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const write = useWriteContract() const receipt = useWaitForTransactionReceipt({ @@ -13,10 +18,10 @@ export function useClaimSequencerRewards() { }) return { - claimRewards: (coinbaseAddress: Address) => { + claimRewards: (coinbaseAddress: Address, overrideRollup?: Address) => { return write.writeContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: overrideRollup ?? targetRollup, functionName: "claimSequencerRewards", args: [coinbaseAddress] }) diff --git a/staking-dashboard/src/hooks/rollup/useEjectionThreshold.ts b/staking-dashboard/src/hooks/rollup/useEjectionThreshold.ts index 1cb44c253..47e1f1190 100644 --- a/staking-dashboard/src/hooks/rollup/useEjectionThreshold.ts +++ b/staking-dashboard/src/hooks/rollup/useEjectionThreshold.ts @@ -1,15 +1,19 @@ import { useReadContract } from "wagmi" +import type { Address } from "viem" import { contracts } from "../../contracts" /** - * Hook to get the local ejection threshold from the rollup contract - * This is the minimum effective balance required to stay as a validator - * Below this threshold, validators are ejected from the active set + * Hook to get the local ejection threshold from a rollup contract. + * This is the minimum effective balance required to stay as a validator; + * below this threshold, validators are ejected from the active set. + * + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. */ -export function useEjectionThreshold() { +export function useEjectionThreshold(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const { data, isLoading, error, refetch } = useReadContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "getLocalEjectionThreshold", query: { staleTime: Infinity, diff --git a/staking-dashboard/src/hooks/rollup/useFinalizeWithdraw.ts b/staking-dashboard/src/hooks/rollup/useFinalizeWithdraw.ts index cf07c27d3..616d4ec5c 100644 --- a/staking-dashboard/src/hooks/rollup/useFinalizeWithdraw.ts +++ b/staking-dashboard/src/hooks/rollup/useFinalizeWithdraw.ts @@ -3,15 +3,18 @@ import { contracts } from "@/contracts" import type { Address } from "viem" /** - * Hook to finalize withdrawal from the rollup + * Hook to finalize withdrawal from a rollup. * * This hook calls the Rollup contract directly instead of going through the staker contract. * The staker contract has a bug where it calls `finaliseWithdraw` (British spelling) but * the actual Rollup contract uses `finalizeWithdraw` (American spelling). * + * @param rollupAddress - Optional rollup contract to finalize on. Defaults to the configured rollup. + * * @returns Hook with finalizeWithdraw function and transaction status */ -export function useFinalizeWithdraw() { +export function useFinalizeWithdraw(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const write = useWriteContract() const receipt = useWaitForTransactionReceipt({ @@ -19,10 +22,10 @@ export function useFinalizeWithdraw() { }) return { - finalizeWithdraw: (attesterAddress: Address) => { + finalizeWithdraw: (attesterAddress: Address, overrideRollup?: Address) => { return write.writeContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: overrideRollup ?? targetRollup, functionName: "finalizeWithdraw", args: [attesterAddress] }) diff --git a/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts index b166f90a2..2bb1d0cca 100644 --- a/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts +++ b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts @@ -1,13 +1,17 @@ import { useReadContract } from "wagmi" import { contracts } from "@/contracts" +import type { Address } from "viem" /** - * Hook to check if rewards are claimable from the rollup contract + * Hook to check if rewards are claimable from a specific rollup contract. + * + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. */ -export function useIsRewardsClaimable() { +export function useIsRewardsClaimable(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const query = useReadContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "isRewardsClaimable" }) diff --git a/staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts new file mode 100644 index 000000000..733d0ee21 --- /dev/null +++ b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts @@ -0,0 +1,61 @@ +import { useMemo } from "react" +import { useReadContracts } from "wagmi" +import type { Address } from "viem" +import { contracts } from "@/contracts" + +/** + * Multicalls `isRewardsClaimable()` across a list of rollup contracts. + * Returns a Map keyed by lowercased rollup address; `undefined` means still loading. + */ +export function useIsRewardsClaimableAcrossRollups(rollupAddresses: Address[]) { + const uniqueAddresses = useMemo(() => { + const seen = new Set() + const out: Address[] = [] + for (const a of rollupAddresses) { + const key = a.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(a) + } + return out + }, [rollupAddresses]) + + const { data, isLoading, error } = useReadContracts({ + contracts: uniqueAddresses.length > 0 + ? uniqueAddresses.map( + (address) => + ({ + address, + abi: contracts.rollup.abi, + functionName: "isRewardsClaimable", + }) as const, + ) + : undefined, + query: { + enabled: uniqueAddresses.length > 0, + }, + }) + + const claimableByRollup = useMemo(() => { + const map = new Map() + if (!data) return map + for (let i = 0; i < uniqueAddresses.length; i++) { + const result = data[i] + if (result?.status === "success") { + map.set(uniqueAddresses[i].toLowerCase(), result.result as boolean) + } + } + return map + }, [data, uniqueAddresses]) + + const isClaimable = (rollupAddress: Address): boolean | undefined => { + return claimableByRollup.get(rollupAddress.toLowerCase()) + } + + return { + claimableByRollup, + isClaimable, + isLoading, + error, + } +} diff --git a/staking-dashboard/src/hooks/rollup/useRollupData.ts b/staking-dashboard/src/hooks/rollup/useRollupData.ts index 187be78f4..7c8d524e3 100644 --- a/staking-dashboard/src/hooks/rollup/useRollupData.ts +++ b/staking-dashboard/src/hooks/rollup/useRollupData.ts @@ -1,13 +1,18 @@ import { useReadContract } from "wagmi"; +import type { Address } from "viem"; import { contracts } from "../../contracts"; /** - * Hook to get rollup data including version and activation threshold + * Hook to get rollup data including version and activation threshold. + * + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. */ -export function useRollupData() { +export function useRollupData(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address; + const rollupVersionQuery = useReadContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "getVersion", query: { staleTime: Infinity, @@ -17,7 +22,7 @@ export function useRollupData() { const activationThresholdQuery = useReadContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "getActivationThreshold", query: { staleTime: Infinity, diff --git a/staking-dashboard/src/hooks/rollup/useRollupRegistry.ts b/staking-dashboard/src/hooks/rollup/useRollupRegistry.ts new file mode 100644 index 000000000..51a0e3847 --- /dev/null +++ b/staking-dashboard/src/hooks/rollup/useRollupRegistry.ts @@ -0,0 +1,161 @@ +import { useMemo } from "react" +import { useReadContract, useReadContracts } from "wagmi" +import { contracts } from "@/contracts" +import type { Address } from "viem" + +/** + * One discovered rollup instance from the Aztec governance Registry. + */ +export interface RollupInstance { + version: bigint + address: Address +} + +/** + * Discovers all rollup instances from the Aztec governance Registry. + * Enumerates versions via `numberOfVersions()` + `getVersion(i)` + `getRollup(version)`. + * `isStale` is true when the configured rollup differs from the canonical one. + */ +export function useRollupRegistry() { + const registryAddressQuery = useReadContract({ + abi: contracts.stakingRegistry.abi, + address: contracts.stakingRegistry.address, + functionName: "ROLLUP_REGISTRY", + query: { + staleTime: Infinity, + gcTime: Infinity, + }, + }) + + const registryAddress = registryAddressQuery.data as Address | undefined + + const headerQuery = useReadContracts({ + contracts: registryAddress + ? [ + { + abi: contracts.rollupRegistry.abi, + address: registryAddress, + functionName: "numberOfVersions", + } as const, + { + abi: contracts.rollupRegistry.abi, + address: registryAddress, + functionName: "getCanonicalRollup", + } as const, + ] + : undefined, + query: { + enabled: !!registryAddress, + staleTime: Infinity, + gcTime: Infinity, + }, + }) + + const numberOfVersions = headerQuery.data?.[0].result as bigint | undefined + const canonicalAddress = headerQuery.data?.[1].result as Address | undefined + + const versionIndexes = useMemo(() => { + if (!numberOfVersions) return [] + const out: bigint[] = [] + for (let i = 0n; i < numberOfVersions; i++) out.push(i) + return out + }, [numberOfVersions]) + + const versionsQuery = useReadContracts({ + contracts: + registryAddress && versionIndexes.length > 0 + ? versionIndexes.map( + (i) => + ({ + abi: contracts.rollupRegistry.abi, + address: registryAddress, + functionName: "getVersion", + args: [i], + }) as const, + ) + : undefined, + query: { + enabled: !!registryAddress && versionIndexes.length > 0, + staleTime: Infinity, + gcTime: Infinity, + }, + }) + + const versions = useMemo(() => { + if (!versionsQuery.data) return [] as bigint[] + return versionsQuery.data + .map((entry) => entry.result as bigint | undefined) + .filter((v): v is bigint => v !== undefined) + }, [versionsQuery.data]) + + const rollupsQuery = useReadContracts({ + contracts: + registryAddress && versions.length > 0 + ? versions.map( + (version) => + ({ + abi: contracts.rollupRegistry.abi, + address: registryAddress, + functionName: "getRollup", + args: [version], + }) as const, + ) + : undefined, + query: { + enabled: !!registryAddress && versions.length > 0, + staleTime: Infinity, + gcTime: Infinity, + }, + }) + + const rollups = useMemo(() => { + if (!rollupsQuery.data || versions.length === 0) return [] + const out: RollupInstance[] = [] + for (let i = 0; i < versions.length; i++) { + const address = rollupsQuery.data[i]?.result as Address | undefined + if (!address) continue + out.push({ version: versions[i], address }) + } + return out + }, [rollupsQuery.data, versions]) + + const canonical = useMemo(() => { + if (!canonicalAddress) return undefined + const match = rollups.find((r) => r.address.toLowerCase() === canonicalAddress.toLowerCase()) + if (match) return match + return rollups.length > 0 ? rollups[rollups.length - 1] : undefined + }, [canonicalAddress, rollups]) + + const configuredAddress = contracts.rollup.address + const configured = useMemo(() => { + return rollups.find((r) => r.address.toLowerCase() === configuredAddress.toLowerCase()) + }, [rollups, configuredAddress]) + + const isStale = + !!canonical && + canonical.address.toLowerCase() !== configuredAddress.toLowerCase() + + const isLoading = + registryAddressQuery.isLoading || + headerQuery.isLoading || + versionsQuery.isLoading || + rollupsQuery.isLoading + + const error = + registryAddressQuery.error || + headerQuery.error || + versionsQuery.error || + rollupsQuery.error || + null + + return { + registryAddress, + rollups, + canonical, + configured, + configuredAddress, + isStale, + isLoading, + error, + } +} diff --git a/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts b/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts index b78488a57..01fee8e91 100644 --- a/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts +++ b/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts @@ -3,12 +3,16 @@ import { contracts } from "@/contracts" import type { Address } from "viem" /** - * Hook to get sequencer rewards for a specific coinbase address + * Hook to get sequencer rewards for a specific coinbase address. + * + * @param coinbaseAddress - The coinbase address to query rewards for + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. */ -export function useSequencerRewards(coinbaseAddress: string) { +export function useSequencerRewards(coinbaseAddress: string, rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const query = useReadContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "getSequencerRewards", args: coinbaseAddress ? [coinbaseAddress as Address] : undefined, query: { diff --git a/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts b/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts index c4559935b..b8bbcefa2 100644 --- a/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts +++ b/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts @@ -38,13 +38,14 @@ export function getStatusLabel(status: number | undefined): string { } /** - * Hook to get sequencer status information + * Hook to get sequencer status information. * @param sequencerAddress - The address of the sequencer + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. * @returns Sequencer status, label, and related information */ -export function useSequencerStatus(sequencerAddress: Address | undefined) { +export function useSequencerStatus(sequencerAddress: Address | undefined, rollupAddress?: Address) { const { status, effectiveBalance, exit, isLoading, error, refetch } = - useAttesterView(sequencerAddress); + useAttesterView(sequencerAddress, rollupAddress); // Query the governance withdrawal to get the REAL unlock time const { withdrawal, isLoading: isLoadingWithdrawal } = useGovernanceWithdrawal(exit?.withdrawalId); diff --git a/staking-dashboard/src/hooks/rollup/useStakeHealth.ts b/staking-dashboard/src/hooks/rollup/useStakeHealth.ts index f6ffccdae..f35423cf5 100644 --- a/staking-dashboard/src/hooks/rollup/useStakeHealth.ts +++ b/staking-dashboard/src/hooks/rollup/useStakeHealth.ts @@ -28,15 +28,15 @@ const SLASH_AMOUNT = 2000n * 10n ** 18n * - isAtRisk: healthPercentage < 50 (has been slashed significantly) * - isCritical: effectiveBalance <= ejectionThreshold (imminent ejection) */ -export function useStakeHealth(attesterAddress: Address | undefined) { +export function useStakeHealth(attesterAddress: Address | undefined, rollupAddress?: Address) { const { effectiveBalance, status, isLoading: isLoadingAttester, error: attesterError, refetch: refetchAttester } = - useAttesterView(attesterAddress) + useAttesterView(attesterAddress, rollupAddress) const { ejectionThreshold, isLoading: isLoadingEjection, error: ejectionError, refetch: refetchEjection } = - useEjectionThreshold() + useEjectionThreshold(rollupAddress) const { activationThreshold, isLoading: isLoadingActivation, error: activationError } = - useActivationThresholdFormatted() + useActivationThresholdFormatted(rollupAddress) const isLoading = isLoadingAttester || isLoadingEjection || isLoadingActivation const error = attesterError || ejectionError || activationError diff --git a/staking-dashboard/src/hooks/rollup/useWalletDirectStake.ts b/staking-dashboard/src/hooks/rollup/useWalletDirectStake.ts index 138acc803..80ed21bfa 100644 --- a/staking-dashboard/src/hooks/rollup/useWalletDirectStake.ts +++ b/staking-dashboard/src/hooks/rollup/useWalletDirectStake.ts @@ -5,11 +5,14 @@ import type { RawTransaction } from "@/contexts/TransactionCartContext" import type { G1Point, G2Point } from "@/hooks/staker/types" /** - * Hook for direct ERC20 staking via Rollup.deposit() - * This is for wallet-based direct staking (own validator registration) - * User calls Rollup.deposit() directly with their BLS keys + * Hook for direct ERC20 staking via Rollup.deposit(). + * This is for wallet-based direct staking (own validator registration). + * User calls Rollup.deposit() directly with their BLS keys. + * + * @param rollupAddress - Optional rollup contract to deposit into. Defaults to the configured rollup. */ -export function useWalletDirectStake() { +export function useWalletDirectStake(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const write = useWriteContract() const receipt = useWaitForTransactionReceipt({ @@ -36,7 +39,7 @@ export function useWalletDirectStake() { ) => write.writeContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "deposit", args: [ attester, @@ -70,7 +73,7 @@ export function useWalletDirectStake() { signature: G1Point, moveWithRollup: boolean, ): RawTransaction => ({ - to: contracts.rollup.address, + to: targetRollup, data: encodeFunctionData({ abi: contracts.rollup.abi, functionName: "deposit", diff --git a/staking-dashboard/src/hooks/rollup/useWalletInitiateWithdraw.ts b/staking-dashboard/src/hooks/rollup/useWalletInitiateWithdraw.ts index 2708f651f..ad9fec490 100644 --- a/staking-dashboard/src/hooks/rollup/useWalletInitiateWithdraw.ts +++ b/staking-dashboard/src/hooks/rollup/useWalletInitiateWithdraw.ts @@ -3,25 +3,28 @@ import { contracts } from "@/contracts" import type { Address } from "viem" /** - * Hook to initiate withdrawal from the rollup for wallet (ERC20) stakes + * Hook to initiate withdrawal from a rollup for wallet (ERC20) stakes. * * For direct ERC20 staking, the user is the withdrawer and calls initiateWithdraw * directly on the Rollup contract. This is different from ATP staking where * withdrawals are initiated through the staker contract. * + * @param rollupAddress - Optional rollup contract to withdraw from. Defaults to the configured rollup. + * * @returns Hook with initiateWithdraw function and transaction status */ -export function useWalletInitiateWithdraw() { +export function useWalletInitiateWithdraw(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const { data: hash, writeContract, isPending, error, reset } = useWriteContract() const { isLoading: isConfirming, isSuccess, isError: receiptError } = useWaitForTransactionReceipt({ hash, }) - const initiateWithdraw = (attesterAddress: Address, recipientAddress: Address) => { + const initiateWithdraw = (attesterAddress: Address, recipientAddress: Address, overrideRollup?: Address) => { return writeContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: overrideRollup ?? targetRollup, functionName: "initiateWithdraw", args: [attesterAddress, recipientAddress], }) diff --git a/staking-dashboard/src/pages/ATP/MyPositionPage.tsx b/staking-dashboard/src/pages/ATP/MyPositionPage.tsx index 22b2f7fa9..bedba7d23 100644 --- a/staking-dashboard/src/pages/ATP/MyPositionPage.tsx +++ b/staking-dashboard/src/pages/ATP/MyPositionPage.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react" +import { useState, useMemo, useRef } from "react" import { useAccount } from "wagmi" import { WalletConnectGuard } from "@/components/WalletConnectGuard" import { PageHeader } from "@/components/PageHeader" @@ -17,6 +17,7 @@ import { useERC20Balance } from "@/hooks/erc20" import { useActivationThresholdFormatted } from "@/hooks/rollup/useActivationThresholdFormatted" import { useATP } from "@/hooks/useATP" import { useAggregatedStakingData } from "@/hooks/atp/useAggregatedStakingData" +import { useCoinbaseAddresses } from "@/hooks/rewards" /** * My Position page for ATP (Aztec Token Positions) @@ -40,13 +41,17 @@ export default function MyPositionPage() { useActivationThresholdFormatted() const { atpData } = useATP() const { totalErc20Staked, directStakeBreakdown, delegationBreakdown, erc20DelegationBreakdown, erc20DirectStakeBreakdown, refetch } = useAggregatedStakingData() + const { coinbaseAddresses } = useCoinbaseAddresses() - // Check if user has any staked positions (ATP vaults or ERC20 wallet stakes) - const hasStakedPositions = + const hasPositionsNow = directStakeBreakdown.length > 0 || delegationBreakdown.length > 0 || erc20DelegationBreakdown.length > 0 || - erc20DirectStakeBreakdown.length > 0 + erc20DirectStakeBreakdown.length > 0 || + (coinbaseAddresses && coinbaseAddresses.length > 0) + const hadPositionsRef = useRef(false) + if (hasPositionsNow) hadPositionsRef.current = true + const hasStakedPositions = hadPositionsRef.current // Calculate stakeable amount (rounded down to nearest activation threshold multiple) const walletStakeableAmount = useMemo(() => { diff --git a/staking-dashboard/src/pages/Providers/StakingProviderDetailPage.tsx b/staking-dashboard/src/pages/Providers/StakingProviderDetailPage.tsx index f0d009882..c75edaf14 100644 --- a/staking-dashboard/src/pages/Providers/StakingProviderDetailPage.tsx +++ b/staking-dashboard/src/pages/Providers/StakingProviderDetailPage.tsx @@ -4,6 +4,7 @@ import { ProviderStakingFlow } from "@/components/Provider/ProviderStakingFlow"; import { ProviderSequencerList } from "@/components/Provider/ProviderSequencerList"; import { ProviderDetailSkeleton } from "@/components/Provider/ProviderDetailSkeleton"; import { PageHeader } from "@/components/PageHeader"; +import { IndexerRollupDisclaimer } from "@/components/IndexerRollupDisclaimer"; import { useProviderDetail } from "@/hooks/providers/useProviderDetail"; import { Link } from "react-router-dom"; import { applyHeroItalics } from "@/utils/typographyUtils"; @@ -60,6 +61,8 @@ export default function StakingProviderDetailPage() { provider={provider} /> + + ); } diff --git a/staking-dashboard/src/pages/Providers/StakingProvidersPage.tsx b/staking-dashboard/src/pages/Providers/StakingProvidersPage.tsx index 194946440..0be1d3cc4 100644 --- a/staking-dashboard/src/pages/Providers/StakingProvidersPage.tsx +++ b/staking-dashboard/src/pages/Providers/StakingProvidersPage.tsx @@ -1,6 +1,7 @@ import { Link } from "react-router-dom" import { Icon } from "@/components/Icon" import { DecentralizationDisclaimer } from "@/components/DecentralizationDisclaimer" +import { IndexerRollupDisclaimer } from "@/components/IndexerRollupDisclaimer" import { PageHeader } from "@/components/PageHeader" import { Pagination } from "@/components/Pagination" import { ProviderSearch } from "@/components/Provider/ProviderSearch" @@ -121,6 +122,8 @@ export default function StakingProvidersPage() { totalItems={allProviders.length} /> + + {disclaimerProvider && (