diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b564b09b7..cd7e80b24 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -52,7 +52,6 @@ jobs: VITE_ATP_REGISTRY_ADDRESS: "0x0000000000000000000000000000000000000003" VITE_ATP_REGISTRY_AUCTION_ADDRESS: "0x0000000000000000000000000000000000000004" VITE_STAKING_REGISTRY_ADDRESS: "0x0000000000000000000000000000000000000005" - VITE_ROLLUP_ADDRESS: "0x0000000000000000000000000000000000000006" VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS: "0x0000000000000000000000000000000000000007" VITE_ATP_WITHDRAWABLE_STAKER_ADDRESS: "0x0000000000000000000000000000000000000007" VITE_ATP_WITHDRAWABLE_AND_CLAIMABLE_STAKER_ADDRESS: "0x0000000000000000000000000000000000000007" diff --git a/.github/workflows/deploy-indexer.yaml b/.github/workflows/deploy-indexer.yaml index 8ffca00fc..3b553f47c 100644 --- a/.github/workflows/deploy-indexer.yaml +++ b/.github/workflows/deploy-indexer.yaml @@ -61,10 +61,11 @@ jobs: ATP_REGISTRY_ADDRESS: ${{ vars.ATP_REGISTRY_ADDRESS }} ATP_REGISTRY_AUCTION_ADDRESS: ${{ vars.ATP_REGISTRY_AUCTION_ADDRESS }} STAKING_REGISTRY_ADDRESS: ${{ vars.STAKING_REGISTRY_ADDRESS }} - ROLLUP_ADDRESS: ${{ vars.ROLLUP_ADDRESS }} + REGISTRY_ADDRESS: ${{ vars.REGISTRY_ADDRESS }} START_BLOCK: ${{ vars.ATP_FACTORY_DEPLOYMENT_BLOCK }} MATP_FACTORY_START_BLOCK: ${{ vars.ATP_FACTORY_MATP_DEPLOYMENT_BLOCK }} LATP_FACTORY_START_BLOCK: ${{ vars.ATP_FACTORY_LATP_DEPLOYMENT_BLOCK }} + REGISTRY_START_BLOCK: ${{ vars.REGISTRY_DEPLOYMENT_BLOCK }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/deploy-staking-dashboard.yaml b/.github/workflows/deploy-staking-dashboard.yaml index 538827576..b98b5a7b8 100644 --- a/.github/workflows/deploy-staking-dashboard.yaml +++ b/.github/workflows/deploy-staking-dashboard.yaml @@ -62,7 +62,6 @@ jobs: VITE_ATP_REGISTRY_ADDRESS: ${{ vars.ATP_REGISTRY_ADDRESS }} VITE_ATP_REGISTRY_AUCTION_ADDRESS: ${{ vars.ATP_REGISTRY_AUCTION_ADDRESS }} VITE_STAKING_REGISTRY_ADDRESS: ${{ vars.STAKING_REGISTRY_ADDRESS }} - VITE_ROLLUP_ADDRESS: ${{ vars.ROLLUP_ADDRESS }} VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS: ${{ vars.ATP_WITHDRAWABLE_AND_CLAIMABLE_STAKER_ADDRESS }} VITE_ATP_WITHDRAWABLE_STAKER_ADDRESS: ${{ vars.ATP_WITHDRAWABLE_AND_CLAIMABLE_STAKER_ADDRESS }} VITE_ATP_WITHDRAWABLE_AND_CLAIMABLE_STAKER_ADDRESS: ${{ vars.ATP_WITHDRAWABLE_AND_CLAIMABLE_STAKER_ADDRESS }} diff --git a/.gitignore b/.gitignore index ddcbb911d..a2c4c9008 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,10 @@ yarn-error.log* # Contract addresses (should be provided via env vars) contract_addresses.json +# Multi-rollup test environment outputs (regenerated by deploy/seed scripts) +scripts/multi-rollup-test/deploy-output.json +scripts/multi-rollup-test/test-data.json + # Test coverage coverage/ diff --git a/README.md b/README.md index 88385e515..a2a94dcb6 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,8 @@ Example `contract_addresses.json`: "atpRegistry": "0x...", "atpRegistryAuction": "0x...", "stakingRegistry": "0x...", - "rollupAddress": "0x...", + "registryAddress": "0x...", + "registryDeploymentBlock": "12345678", "atpWithdrawableAndClaimableStaker": "0x...", "genesisSequencerSale": "0x...", "governanceAddress": "0x...", @@ -103,6 +104,8 @@ Example `contract_addresses.json`: } ``` +The canonical rollup is no longer a separate configuration value, the indexer and frontend both resolve it dynamically from `Registry.getCanonicalRollup()`. Rollup upgrades (new `addRollup()` calls on the Registry) are picked up automatically: the indexer continues indexing every historical rollup via Ponder's factory pattern on the `CanonicalRollupUpdated` event, and the frontend re-resolves on every page load. + For production contract addresses, see the [Aztec documentation](https://docs.aztec.network/) or contact the Aztec team. ## Project Structure diff --git a/atp-indexer/.env.example b/atp-indexer/.env.example index 54b65d1d7..337d5f52c 100644 --- a/atp-indexer/.env.example +++ b/atp-indexer/.env.example @@ -13,13 +13,17 @@ ATP_FACTORY_AUCTION_ADDRESS=0x... ATP_FACTORY_MATP_ADDRESS=0x... ATP_FACTORY_LATP_ADDRESS=0x... STAKING_REGISTRY_ADDRESS=0x... -ROLLUP_ADDRESS=0x... +# Aztec Registry, used as factory source so the current AND all historical +# rollups are indexed automatically. On mainnet: 0x35b22e09Ee0390539439E24f06Da43D83f90e298 +REGISTRY_ADDRESS=0x... # Indexer Settings START_BLOCK=0 # Per-factory start blocks (optional, for efficiency - set to deployment block of each factory) MATP_FACTORY_START_BLOCK=0 LATP_FACTORY_START_BLOCK=0 +# Registry deployment block, factory backfill starts here (mainnet: 23786827) +REGISTRY_START_BLOCK=0 # Application NODE_ENV=development diff --git a/atp-indexer/bootstrap.sh b/atp-indexer/bootstrap.sh index 5a49f957a..06ee47ad3 100755 --- a/atp-indexer/bootstrap.sh +++ b/atp-indexer/bootstrap.sh @@ -50,10 +50,11 @@ get_contract_addresses() { ATP_REGISTRY_AUCTION_ADDRESS="${ATP_REGISTRY_AUCTION_ADDRESS:-}" ATP_FACTORY_AUCTION_ADDRESS="${ATP_FACTORY_AUCTION_ADDRESS:-}" STAKING_REGISTRY_ADDRESS="${STAKING_REGISTRY_ADDRESS:-}" - ROLLUP_ADDRESS="${ROLLUP_ADDRESS:-}" + REGISTRY_ADDRESS="${REGISTRY_ADDRESS:-}" START_BLOCK="${START_BLOCK:-0}" MATP_FACTORY_START_BLOCK="${MATP_FACTORY_START_BLOCK:-0}" LATP_FACTORY_START_BLOCK="${LATP_FACTORY_START_BLOCK:-0}" + REGISTRY_START_BLOCK="${REGISTRY_START_BLOCK:-0}" return 0 fi @@ -82,14 +83,16 @@ get_contract_addresses() { # other STAKING_REGISTRY_ADDRESS=$(cat $contract_addresses_file | jq -r '.stakingRegistry') - ROLLUP_ADDRESS=$(cat $contract_addresses_file | jq -r '.rollupAddress') + REGISTRY_ADDRESS=$(cat $contract_addresses_file | jq -r '.registryAddress') # For dev environment, use 0 to catch all events # For other environments, use atpFactoryDeploymentBlock as the starting point if [ "$environment" = "dev" ]; then START_BLOCK=0 + REGISTRY_START_BLOCK=0 else START_BLOCK=$(cat $contract_addresses_file | jq -r '.atpFactoryDeploymentBlock // 0') + REGISTRY_START_BLOCK=$(cat $contract_addresses_file | jq -r '.registryDeploymentBlock // 0') fi return 0 fi @@ -159,10 +162,11 @@ ATP_FACTORY_AUCTION_ADDRESS=${ATP_FACTORY_AUCTION_ADDRESS} ATP_FACTORY_MATP_ADDRESS=${ATP_FACTORY_MATP_ADDRESS} ATP_FACTORY_LATP_ADDRESS=${ATP_FACTORY_LATP_ADDRESS} STAKING_REGISTRY_ADDRESS=${STAKING_REGISTRY_ADDRESS} -ROLLUP_ADDRESS=${ROLLUP_ADDRESS} +REGISTRY_ADDRESS=${REGISTRY_ADDRESS} # Ponder settings START_BLOCK=${START_BLOCK} +REGISTRY_START_BLOCK=${REGISTRY_START_BLOCK} # API PORT=${PORT} @@ -198,7 +202,8 @@ ATP_FACTORY_AUCTION_ADDRESS=${ATP_FACTORY_AUCTION_ADDRESS} ATP_FACTORY_MATP_ADDRESS=${ATP_FACTORY_MATP_ADDRESS} ATP_FACTORY_LATP_ADDRESS=${ATP_FACTORY_LATP_ADDRESS} STAKING_REGISTRY_ADDRESS=${STAKING_REGISTRY_ADDRESS} -ROLLUP_ADDRESS=${ROLLUP_ADDRESS} +REGISTRY_ADDRESS=${REGISTRY_ADDRESS} +REGISTRY_START_BLOCK=${REGISTRY_START_BLOCK} # Indexer settings START_BLOCK=${DEFAULT_START_BLOCK} @@ -355,6 +360,7 @@ function deploy() { START_BLOCK="${START_BLOCK:-0}" MATP_FACTORY_START_BLOCK="${MATP_FACTORY_START_BLOCK:-0}" LATP_FACTORY_START_BLOCK="${LATP_FACTORY_START_BLOCK:-0}" + REGISTRY_START_BLOCK="${REGISTRY_START_BLOCK:-0}" # Initialize Terraform with the S3 backend (cd terraform && terraform init \ @@ -376,7 +382,8 @@ function deploy() { -var=atp_factory_matp_address=$ATP_FACTORY_MATP_ADDRESS \ -var=atp_factory_latp_address=$ATP_FACTORY_LATP_ADDRESS \ -var=staking_registry_address=$STAKING_REGISTRY_ADDRESS \ - -var=rollup_address=$ROLLUP_ADDRESS \ + -var=registry_address=$REGISTRY_ADDRESS \ + -var=registry_start_block=$REGISTRY_START_BLOCK \ -var=start_block=$START_BLOCK \ -var=matp_factory_start_block=$MATP_FACTORY_START_BLOCK \ -var=latp_factory_start_block=$LATP_FACTORY_START_BLOCK \ @@ -487,8 +494,8 @@ case $ACTION in echo " ATP_FACTORY_ADDRESS, ATP_FACTORY_AUCTION_ADDRESS" echo " ATP_FACTORY_MATP_ADDRESS, ATP_FACTORY_LATP_ADDRESS" echo " ATP_REGISTRY_ADDRESS, ATP_REGISTRY_AUCTION_ADDRESS" - echo " STAKING_REGISTRY_ADDRESS, ROLLUP_ADDRESS" - echo " START_BLOCK (optional, defaults to 0)" + echo " STAKING_REGISTRY_ADDRESS, REGISTRY_ADDRESS" + echo " START_BLOCK, REGISTRY_START_BLOCK (optional, defaults to 0)" echo "" echo " For production contract addresses, contact the Aztec team." ;; diff --git a/atp-indexer/ponder.config.ts b/atp-indexer/ponder.config.ts index 83326702b..93e8880f8 100644 --- a/atp-indexer/ponder.config.ts +++ b/atp-indexer/ponder.config.ts @@ -6,6 +6,7 @@ import { STAKING_REGISTRY_ABI, ROLLUP_ABI, STAKER_ABI, + REGISTRY_ABI, } from "./src/abis"; // ATPCreated event signature for factory pattern @@ -13,6 +14,13 @@ const ATPCreatedEvent = parseAbiItem( "event ATPCreated(address indexed beneficiary, address indexed atp, uint256 allocation)" ); +// Aztec Registry emits this on every rollup upgrade. Used as a factory source +// so every canonical rollup, historical and future, is indexed without a +// config change. +const CanonicalRollupUpdatedEvent = parseAbiItem( + "event CanonicalRollupUpdated(address indexed instance, uint256 indexed version)" +); + // Per-factory start blocks for efficient indexing const FACTORY_START_BLOCKS = { genesis: config.START_BLOCK || 0, @@ -111,14 +119,39 @@ export default createConfig({ }, /** - * Rollup Contract - * Handles validator deposits and tracks validator queue + * Aztec Registry + * Tracked as a regular contract so we can index CanonicalRollupUpdated + * events into a rollup_version table, served from /api/rollups so the + * frontend doesn't need its own Registry RPC calls. The same event is + * also used as a factory source for the Rollup contract below. + */ + Registry: { + chain: config.networkName, + abi: REGISTRY_ABI, + address: config.REGISTRY_ADDRESS as `0x${string}`, + startBlock: config.REGISTRY_START_BLOCK, + }, + + /** + * Rollup Contract (dynamic, sourced from the Aztec Registry) + * Handles validator deposits and tracks validator queue. + * + * Uses Ponder's factory pattern on the Registry's `CanonicalRollupUpdated` + * event so every rollup address the Registry has ever announced (historical + * versions and any future upgrade) is indexed automatically. Handlers in + * src/events/rollup/ fire for events from any of these addresses, so + * rewards/withdrawals/slashing on older rollups keep being tracked even + * after an upgrade. */ Rollup: { chain: config.networkName, abi: ROLLUP_ABI, - address: config.ROLLUP_ADDRESS as `0x${string}`, - startBlock: config.START_BLOCK, + address: factory({ + address: config.REGISTRY_ADDRESS as `0x${string}`, + event: CanonicalRollupUpdatedEvent, + parameter: "instance", + }), + startBlock: config.REGISTRY_START_BLOCK, }, /** diff --git a/atp-indexer/ponder.schema.ts b/atp-indexer/ponder.schema.ts index d9200aee5..f36f0bee9 100644 --- a/atp-indexer/ponder.schema.ts +++ b/atp-indexer/ponder.schema.ts @@ -425,3 +425,23 @@ export const tokensWithdrawnToBeneficiary = onchainTable("tokens_withdrawn_to_be atpAddressIdx: index().on(table.atpAddress), stakerAddressIdx: index().on(table.stakerAddress), })); + +/** + * RollupVersion + * Every rollup the Aztec Registry has made canonical via addRollup(). + * Populated from CanonicalRollupUpdated events; the latest row (by blockNumber) + * is the current canonical rollup. Used by /api/rollups so the frontend doesn't + * have to make its own Registry RPC calls at boot, and so historical rollup + * addresses are available for future cross-rollup claim flows. + */ +export const rollupVersion = onchainTable("rollup_version", (t) => ({ + version: t.bigint().primaryKey(), + address: t.hex().notNull(), + blockNumber: t.bigint().notNull(), + txHash: t.hex().notNull(), + logIndex: t.integer().notNull(), + timestamp: t.bigint().notNull(), +}), (table) => ({ + addressIdx: index().on(table.address), + blockNumberIdx: index().on(table.blockNumber), +})); diff --git a/atp-indexer/src/abis/index.ts b/atp-indexer/src/abis/index.ts index c0e0cf741..d2de6b721 100644 --- a/atp-indexer/src/abis/index.ts +++ b/atp-indexer/src/abis/index.ts @@ -40,3 +40,10 @@ export { TokensWithdrawnToBeneficiaryEventAbi, STAKER_ABI, } from './staker.abi'; + +// Registry ABIs (Aztec governance Registry: source of canonical rollup upgrades) +export { + CanonicalRollupUpdatedEventAbi, + REGISTRY_FUNCTIONS, + REGISTRY_ABI, +} from './registry.abi'; diff --git a/atp-indexer/src/abis/registry.abi.ts b/atp-indexer/src/abis/registry.abi.ts new file mode 100644 index 000000000..fbd1e3e81 --- /dev/null +++ b/atp-indexer/src/abis/registry.abi.ts @@ -0,0 +1,40 @@ +/** + * Aztec Registry Contract ABI + * + * The Registry tracks the canonical rollup instance over time. When a new + * rollup is deployed (e.g. on upgrade), `addRollup()` is called which emits + * `CanonicalRollupUpdated`. The indexer uses this event as a factory source + * so every rollup (past, present, and future) is indexed automatically. + */ + +export const CanonicalRollupUpdatedEventAbi = { + type: 'event', + name: 'CanonicalRollupUpdated', + inputs: [ + { name: 'instance', type: 'address', indexed: true }, + { name: 'version', type: 'uint256', indexed: true }, + ], + anonymous: false, +} as const; + +export const REGISTRY_FUNCTIONS = [ + { + type: 'function', + name: 'getCanonicalRollup', + inputs: [], + outputs: [{ name: '', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'numberOfVersions', + inputs: [], + outputs: [{ name: '', type: 'uint256' }], + stateMutability: 'view', + }, +] as const; + +export const REGISTRY_ABI = [ + CanonicalRollupUpdatedEventAbi, + ...REGISTRY_FUNCTIONS, +] as const; diff --git a/atp-indexer/src/api/handlers/atp/details.ts b/atp-indexer/src/api/handlers/atp/details.ts index 2835ebe10..3100df41f 100644 --- a/atp-indexer/src/api/handlers/atp/details.ts +++ b/atp-indexer/src/api/handlers/atp/details.ts @@ -3,7 +3,7 @@ import { db } from 'ponder:api'; import { eq, desc, sql, or } from 'drizzle-orm'; import { normalizeAddress, checksumAddress } from '../../../utils/address'; import { getActivationThreshold } from '../../../utils/rollup'; -import { config } from '../../../config'; +import { getCanonicalRollupAddress } from '../../utils/canonical-rollup'; import { getPublicClient } from '../../../utils/viem-client'; import type { ATPDetailsResponse } from '../../types/atp.types'; import { fetchFailedDeposits, markStakesWithFailedDeposits } from '../../../utils/failed-deposits'; @@ -148,7 +148,8 @@ export async function handleATPDetails(c: Context): Promise { const validStakingOpsCount = markedDelegations.filter(isActiveStake).length; // Calculate total staked - const activationThreshold = await getActivationThreshold(config.ROLLUP_ADDRESS, client); + const rollupAddress = await getCanonicalRollupAddress(client); + const activationThreshold = await getActivationThreshold(rollupAddress, client); const totalStaked = BigInt(activationThreshold) * (BigInt(validDirectStakesCount) + BigInt(validStakingOpsCount)); // Query slashed table to get total slashed per attester address diff --git a/atp-indexer/src/api/handlers/provider/details.ts b/atp-indexer/src/api/handlers/provider/details.ts index 7bf48e63d..dd2812dc4 100644 --- a/atp-indexer/src/api/handlers/provider/details.ts +++ b/atp-indexer/src/api/handlers/provider/details.ts @@ -6,7 +6,7 @@ import { getProviderMetadata } from '../../../utils/provider-metadata'; import type { ProviderDetailsResponse } from '../../types/provider.types'; import { fetchFailedDeposits, filterValidStakes } from '../../../utils/failed-deposits'; import { getActivationThreshold } from '../../../utils/rollup'; -import { config } from '../../../config'; +import { getCanonicalRollupAddress } from '../../utils/canonical-rollup'; import { getPublicClient } from '../../../utils/viem-client'; import { provider, @@ -26,8 +26,9 @@ export async function handleProviderDetails(c: Context): Promise { const id = c.req.param('id'); const client = getPublicClient(); + const rollupAddress = await getCanonicalRollupAddress(client); const [activationThreshold, providerData, allAtpDelegationsCount, allErc20DelegationsCount, allDirectStakesCount, allFailedDepositCount] = await Promise.all([ - getActivationThreshold(config.ROLLUP_ADDRESS, client), + getActivationThreshold(rollupAddress, client), db.select().from(provider).where(eq(provider.providerIdentifier, id)).limit(1), db.select({ count: count() }).from(stakedWithProvider), db.select({ count: count() }).from(erc20StakedWithProvider), diff --git a/atp-indexer/src/api/handlers/provider/list.ts b/atp-indexer/src/api/handlers/provider/list.ts index 4c8a1a8fd..750325a11 100644 --- a/atp-indexer/src/api/handlers/provider/list.ts +++ b/atp-indexer/src/api/handlers/provider/list.ts @@ -6,7 +6,7 @@ import { getAllProviderMetadata } from '../../../utils/provider-metadata'; import type { ProviderListResponse } from '../../types/provider.types'; import { fetchFailedDeposits, markStakesWithFailedDeposits } from '../../../utils/failed-deposits'; import { getActivationThreshold } from '../../../utils/rollup'; -import { config } from '../../../config'; +import { getCanonicalRollupAddress } from '../../utils/canonical-rollup'; import { getPublicClient } from '../../../utils/viem-client'; import { provider, @@ -27,8 +27,9 @@ export async function handleProviderList(c: Context): Promise { // Get provider list of IDs from JSON const providerIds = Array.from(metadata.keys()); + const rollupAddress = await getCanonicalRollupAddress(client); const [activationThreshold, dbProviders, atpDelegations, erc20Delegations, allDirectStakes] = await Promise.all([ - getActivationThreshold(config.ROLLUP_ADDRESS, client), + getActivationThreshold(rollupAddress, client), db.select().from(provider).where(inArray(provider.providerIdentifier, providerIds)), db.select({ providerIdentifier: stakedWithProvider.providerIdentifier, diff --git a/atp-indexer/src/api/handlers/rollup/list.ts b/atp-indexer/src/api/handlers/rollup/list.ts new file mode 100644 index 000000000..c1eb10956 --- /dev/null +++ b/atp-indexer/src/api/handlers/rollup/list.ts @@ -0,0 +1,46 @@ +import type { Context } from 'hono'; +import { db } from 'ponder:api'; +import { asc } from 'drizzle-orm'; +import { rollupVersion } from 'ponder:schema'; +import { checksumAddress } from '../../../utils/address'; +import type { RollupListResponse } from '../../types/rollup.types'; + +/** + * Handle GET /api/rollups + * Returns the current canonical rollup plus every historical rollup the + * Registry has ever made canonical. Lets the frontend (and other clients) + * avoid making their own Registry RPC calls at boot, and gives the UI what + * it needs for future cross-rollup flows (e.g. claiming unclaimed rewards + * from a previous rollup). + * + * Source of truth: the rollup_version table, populated from + * Registry.CanonicalRollupUpdated events. + */ +export async function handleRollupList(c: Context): Promise { + try { + const rows = await db + .select() + .from(rollupVersion) + .orderBy(asc(rollupVersion.blockNumber)); + + const versions = rows.map(r => ({ + version: r.version.toString(), + address: checksumAddress(r.address), + blockNumber: Number(r.blockNumber), + timestamp: Number(r.timestamp), + })); + + const canonical = versions.length > 0 + ? versions[versions.length - 1].address + : null; + + const response: RollupListResponse = { canonical, versions }; + return c.json(response); + } catch (error) { + console.error('Error fetching rollup versions:', error); + return c.json({ + error: 'Failed to fetch rollup versions', + message: error instanceof Error ? error.message : String(error), + }, 500); + } +} diff --git a/atp-indexer/src/api/handlers/staking/summary.ts b/atp-indexer/src/api/handlers/staking/summary.ts index 5adb35c84..80221c53f 100644 --- a/atp-indexer/src/api/handlers/staking/summary.ts +++ b/atp-indexer/src/api/handlers/staking/summary.ts @@ -2,7 +2,7 @@ import type { Context } from 'hono'; import { db } from 'ponder:api'; import { count } from 'drizzle-orm'; import { getActivationThreshold, calculateAPR } from '../../../utils/rollup'; -import { config } from '../../../config'; +import { getCanonicalRollupAddress } from '../../utils/canonical-rollup'; import { getPublicClient } from '../../../utils/viem-client'; import type { StakingSummaryResponse } from '../../types/staking.types'; import { @@ -23,7 +23,7 @@ import { export async function handleStakingSummary(c: Context): Promise { try { const client = getPublicClient(); - const rollupAddress = config.ROLLUP_ADDRESS; + const rollupAddress = await getCanonicalRollupAddress(client); // Data model explanation: // - `deposit` table: ALL Rollup:Deposit events (validator registrations on-chain) diff --git a/atp-indexer/src/api/index.ts b/atp-indexer/src/api/index.ts index 40a61972f..7d669275a 100644 --- a/atp-indexer/src/api/index.ts +++ b/atp-indexer/src/api/index.ts @@ -6,6 +6,7 @@ import { healthRoutes } from './routes/health.routes'; import { providerRoutes } from './routes/provider.routes'; import { stakingRoutes } from './routes/staking.routes'; import { atpRoutes } from './routes/atp.routes'; +import { rollupRoutes } from './routes/rollup.routes'; import { config } from '../config'; /** @@ -32,6 +33,7 @@ app.route('/api/health', healthRoutes); app.route('/api/providers', providerRoutes); app.route('/api/staking', stakingRoutes); app.route('/api/atp', atpRoutes); +app.route('/api/rollups', rollupRoutes); app.notFound((c) => { return c.json({ error: 'Not found' }, 404); diff --git a/atp-indexer/src/api/routes/rollup.routes.ts b/atp-indexer/src/api/routes/rollup.routes.ts new file mode 100644 index 000000000..ca0c9bf6d --- /dev/null +++ b/atp-indexer/src/api/routes/rollup.routes.ts @@ -0,0 +1,12 @@ +import { Hono } from 'hono'; +import { handleRollupList } from '../handlers/rollup/list'; +import { pollingLimiter } from '../middleware/rate-limit'; + +export const rollupRoutes = new Hono(); + +/** + * GET /api/rollups + * List the current canonical rollup + every historical rollup the Registry + * has ever made canonical. + */ +rollupRoutes.get('/', pollingLimiter, handleRollupList); diff --git a/atp-indexer/src/api/types/index.ts b/atp-indexer/src/api/types/index.ts index 549ab31e9..e9d45a7d4 100644 --- a/atp-indexer/src/api/types/index.ts +++ b/atp-indexer/src/api/types/index.ts @@ -5,4 +5,5 @@ export * from './provider.types'; export * from './atp.types'; export * from './staking.types'; +export * from './rollup.types'; export * from './common.types'; diff --git a/atp-indexer/src/api/types/rollup.types.ts b/atp-indexer/src/api/types/rollup.types.ts new file mode 100644 index 000000000..08ff9475b --- /dev/null +++ b/atp-indexer/src/api/types/rollup.types.ts @@ -0,0 +1,15 @@ +/** + * Rollup API Response Types + */ + +export interface RollupVersionRow { + version: string; // Registry version id (uint256, stringified) + address: string; // Checksummed rollup address + blockNumber: number; // Block at which this rollup became canonical + timestamp: number; // Block timestamp (unix seconds) +} + +export interface RollupListResponse { + canonical: string | null; // Latest canonical rollup address (null before first event is indexed) + versions: RollupVersionRow[]; // Every rollup ever made canonical, oldest first +} diff --git a/atp-indexer/src/api/utils/canonical-rollup.ts b/atp-indexer/src/api/utils/canonical-rollup.ts new file mode 100644 index 000000000..307b2931b --- /dev/null +++ b/atp-indexer/src/api/utils/canonical-rollup.ts @@ -0,0 +1,31 @@ +import { db } from "ponder:api"; +import { rollupVersion } from "ponder:schema"; +import { desc } from "drizzle-orm"; +import type { Address, PublicClient } from "viem"; +import { getCanonicalRollupFromRegistry } from "../../utils/rollup"; + +/** + * Resolve the current canonical rollup address for API handlers. + * + * Preferred path: read the most recent row from the rollup_version table, + * which is populated by the Registry:CanonicalRollupUpdated handler. That's + * the indexer's own source of truth for canonical rollup upgrades, so there's + * no reason to re-query the chain on every API request. + * + * Fallback path: live Registry RPC call (cached 60s). Only reached during the + * brief window after a fresh sync when the indexer hasn't yet processed the + * first CanonicalRollupUpdated event. + */ +export async function getCanonicalRollupAddress(client: PublicClient): Promise
{ + const latest = await db + .select() + .from(rollupVersion) + .orderBy(desc(rollupVersion.blockNumber)) + .limit(1); + + if (latest.length > 0) { + return latest[0].address as Address; + } + + return getCanonicalRollupFromRegistry(client); +} diff --git a/atp-indexer/src/config/index.ts b/atp-indexer/src/config/index.ts index 97111f554..5a28ee5ab 100644 --- a/atp-indexer/src/config/index.ts +++ b/atp-indexer/src/config/index.ts @@ -51,12 +51,13 @@ const configSchema = z.object({ ATP_FACTORY_MATP_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'), ATP_FACTORY_LATP_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'), STAKING_REGISTRY_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'), - ROLLUP_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'), + REGISTRY_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'), // Indexer settings START_BLOCK: z.string().transform(Number).refine(n => n >= 0, 'START_BLOCK must be non-negative').default('0'), MATP_FACTORY_START_BLOCK: z.string().transform(Number).refine(n => n >= 0, 'MATP_FACTORY_START_BLOCK must be non-negative').optional(), LATP_FACTORY_START_BLOCK: z.string().transform(Number).refine(n => n >= 0, 'LATP_FACTORY_START_BLOCK must be non-negative').optional(), + REGISTRY_START_BLOCK: z.string().transform(Number).refine(n => n >= 0, 'REGISTRY_START_BLOCK must be non-negative').default('0'), // Application NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), diff --git a/atp-indexer/src/events/registry/canonical-rollup-updated.ts b/atp-indexer/src/events/registry/canonical-rollup-updated.ts new file mode 100644 index 000000000..c57ad4ff1 --- /dev/null +++ b/atp-indexer/src/events/registry/canonical-rollup-updated.ts @@ -0,0 +1,27 @@ +import { ponder } from "ponder:registry"; +import { rollupVersion } from "ponder:schema"; +import { normalizeAddress } from "../../utils/address"; + +/** + * Handle CanonicalRollupUpdated event from the Aztec Registry. + * Every canonical rollup upgrade goes through Registry.addRollup(), which + * emits this event. We record it so /api/rollups can expose current + history + * without the frontend or API handlers making their own Registry RPC calls. + */ +ponder.on("Registry:CanonicalRollupUpdated", async ({ event, context }) => { + const { instance, version } = event.args; + const { db } = context; + + await db.insert(rollupVersion).values({ + version, + address: normalizeAddress(instance) as `0x${string}`, + blockNumber: event.block.number, + txHash: event.transaction.hash, + logIndex: event.log.logIndex, + timestamp: event.block.timestamp, + }); + + console.log( + `Canonical rollup updated: version ${version}, instance ${instance}` + ); +}); diff --git a/atp-indexer/src/utils/rollup.ts b/atp-indexer/src/utils/rollup.ts index 5f5864bb0..e891abd5f 100644 --- a/atp-indexer/src/utils/rollup.ts +++ b/atp-indexer/src/utils/rollup.ts @@ -1,4 +1,4 @@ -import { ROLLUP_ABI, ROLLUP_FUNCTIONS } from "../abis"; +import { ROLLUP_ABI, ROLLUP_FUNCTIONS, REGISTRY_ABI } from "../abis"; import { config } from "../config"; import type { PublicClient, Address } from 'viem'; @@ -7,6 +7,35 @@ let cachedAPR: number | null = null; let cacheTimestamp = 0; const CACHE_TTL = 0.5 * 60 * 1000; // 30s +// Canonical rollup address cache. Upgrades are rare; a short TTL means we +// pick up a new canonical rollup on the next request after an upgrade +// without an indexer restart. +let cachedCanonicalRollup: Address | null = null; +let cachedCanonicalRollupAt = 0; +const CANONICAL_ROLLUP_TTL = 60 * 1000; // 60s + +/** + * Get the current canonical rollup address directly from the Registry via RPC. + * This is the fallback path. API consumers should prefer the indexed + * rollup_version table (see src/api/utils/canonical-rollup.ts). Used when the + * indexer hasn't yet processed the first CanonicalRollupUpdated event. + */ +export async function getCanonicalRollupFromRegistry(client: PublicClient): Promise
{ + if (cachedCanonicalRollup && Date.now() - cachedCanonicalRollupAt < CANONICAL_ROLLUP_TTL) { + return cachedCanonicalRollup; + } + + const rollup = await client.readContract({ + address: config.REGISTRY_ADDRESS as Address, + abi: REGISTRY_ABI, + functionName: 'getCanonicalRollup', + }) as Address; + + cachedCanonicalRollup = rollup; + cachedCanonicalRollupAt = Date.now(); + return rollup; +} + /** * Get activation threshold from Rollup contract */ diff --git a/atp-indexer/terraform/app.tf b/atp-indexer/terraform/app.tf index 9966e6893..a0bcc1f16 100644 --- a/atp-indexer/terraform/app.tf +++ b/atp-indexer/terraform/app.tf @@ -259,7 +259,8 @@ locals { { name = "ATP_FACTORY_AUCTION_ADDRESS", value = var.atp_factory_auction_address }, { name = "ATP_FACTORY_MATP_ADDRESS", value = var.atp_factory_matp_address }, { name = "ATP_FACTORY_LATP_ADDRESS", value = var.atp_factory_latp_address }, - { name = "ROLLUP_ADDRESS", value = var.rollup_address }, + { name = "REGISTRY_ADDRESS", value = var.registry_address }, + { name = "REGISTRY_START_BLOCK", value = var.registry_start_block }, { name = "POLLING_INTERVAL", value = var.polling_interval }, { name = "MAX_RETRIES", value = var.max_retries }, { name = "PARALLEL_BATCHES", value = var.parallel_batches }, @@ -288,7 +289,8 @@ locals { { name = "ATP_FACTORY_AUCTION_ADDRESS", value = var.atp_factory_auction_address }, { name = "ATP_FACTORY_MATP_ADDRESS", value = var.atp_factory_matp_address }, { name = "ATP_FACTORY_LATP_ADDRESS", value = var.atp_factory_latp_address }, - { name = "ROLLUP_ADDRESS", value = var.rollup_address }, + { name = "REGISTRY_ADDRESS", value = var.registry_address }, + { name = "REGISTRY_START_BLOCK", value = var.registry_start_block }, { name = "POLLING_INTERVAL", value = var.polling_interval }, { name = "MAX_RETRIES", value = var.max_retries }, { name = "PARALLEL_BATCHES", value = var.parallel_batches }, diff --git a/atp-indexer/terraform/variables.tf b/atp-indexer/terraform/variables.tf index b176b13ca..97efe9533 100644 --- a/atp-indexer/terraform/variables.tf +++ b/atp-indexer/terraform/variables.tf @@ -144,8 +144,13 @@ variable "staking_registry_address" { type = string } -variable "rollup_address" { - description = "Rollup contract address" +variable "registry_address" { + description = "Aztec Registry contract address. The indexer uses it as a factory source to pick up all rollup upgrades automatically." + type = string +} + +variable "registry_start_block" { + description = "Block number the Registry was deployed at (factory backfill starts here)." type = string } diff --git a/scripts/multi-rollup-test/README.md b/scripts/multi-rollup-test/README.md new file mode 100644 index 000000000..218550458 --- /dev/null +++ b/scripts/multi-rollup-test/README.md @@ -0,0 +1,249 @@ +# Multi-rollup test environment + +End-to-end local environment for testing rollup-upgrade scenarios. Stands up +**real** Aztec L1 contracts on a local anvil chain with two rollup versions +registered in the same Registry, then deploys the **real** StakingRegistry + +ATP factories from `ignition-contracts`. No mocks: every contract is the +exact production bytecode, so storage layouts, ABIs, and event signatures +match testnet/mainnet behaviour. + +The intended consumer is the indexer + dashboard pair: point both at this +anvil and the Registry's `CanonicalRollupUpdated` events flow through to +`/api/rollups`, ATP staking events flow through to `/api/atp` and +`/api/providers`, and the dashboard sees the same shape of data it would in +production, but with two rollups instead of one. + +## What it does + +1. **Phase 0**: `anvil_setCode` to deploy Multicall3 at its canonical + address. wagmi's `useReadContracts` requires it; anvil doesn't deploy it + by default. +2. **Phase 1**: runs `aztec-packages/l1-contracts/script/deploy/DeployAztecL1Contracts.s.sol` + against anvil. Deploys Registry, GSE, Governance, RewardDistributor, + MockVerifier, TestERC20s (staking + fee assets), Rollup v1. +3. **Phase 2**: runs `DeployRollupForUpgrade.s.sol` with a fresh random + `GENESIS_ARCHIVE_ROOT`, producing a Rollup v2 with a different version hash. +4. **Phase 3**: `anvil_impersonateAccount` Governance (which owns the Registry + after handover) and calls `Registry.addRollup(v2)`. Also best-effort + registers v2 in the GSE. +5. **Phase 4**: deploys the real `StakingRegistry`, `PullSplitFactory`, + `ATPFactory`, and `ATPWithdrawableAndClaimableStaker` from + `ignition-contracts/`, wired together with the real staking asset and + Registry from Phase 1. Configures `setMinter` and registers the staker + implementation with the ATP registry. +6. **Output**: writes `deploy-output.json` (every address, useful for the + seed scripts and assertions) and `contract_addresses.json` (the format + `atp-indexer/bootstrap.sh` and `staking-dashboard/bootstrap.sh` already + read). + +`seed-multi-rollup.ts` then writes sequencer reward state via +`anvil_setStorageAt` (using the Rollup's ERC-7201 namespaced storage layout) +and mints fee tokens to both rollups so `claimSequencerRewards` can actually +pay out. `seed-providers.ts` registers a handful of providers on the real +StakingRegistry so the indexer's `/api/providers` endpoint isn't empty. + +## Prerequisites + +- **Node.js 20+** and **yarn 1.22** +- **Foundry** (`forge`, `anvil`, `cast`): `curl -L https://foundry.paradigm.xyz | bash && foundryup` +- **yq**: `brew install yq` (used to load network defaults) +- **`aztec-packages`** repo cloned somewhere; set `AZTEC_PACKAGES_DIR` (auto-detected if it sits at `../aztec-packages` relative to this repo) +- **`ignition-contracts`** repo cloned somewhere; set `IGNITION_CONTRACTS_DIR` (auto-detected at `../ignition-contracts`) +- Both `aztec-packages/l1-contracts` and `ignition-contracts` need to compile + cleanly with `forge build` (the deploy script runs both) + +## Quick start + +```bash +# Terminal 1: anvil +anvil --port 8545 + +# Terminal 2: deploy contracts (~2 min on a cold compile, ~30s warm) +bash staking-dashboard/scripts/multi-rollup-test/deploy-multi-rollup.sh + +# Seed rewards + fee tokens +npx tsx staking-dashboard/scripts/multi-rollup-test/seed-multi-rollup.ts + +# (optional) seed providers so /api/providers returns data +npx tsx staking-dashboard/scripts/multi-rollup-test/seed-providers.ts +``` + +After deploy, the script prints two `export` lines that point both bootstraps +at the generated `contract_addresses.json`. Copy them into the terminals +running the indexer and dashboard: + +```bash +export CONTRACT_ADDRESSES_FILE=$(pwd)/staking-dashboard/scripts/multi-rollup-test/contract_addresses.json +export RPC_URL=http://127.0.0.1:8545 + +# Terminal 3: indexer +cd atp-indexer && ./bootstrap.sh dev + +# Terminal 4: dashboard +cd staking-dashboard && ./bootstrap.sh dev +``` + +Verify the indexer picked up both rollups: + +```bash +curl http://localhost:42068/api/rollups | jq +# { +# "canonical": "0x...", <- Rollup v2 +# "versions": [ +# {"version": "...", "address": "0x...", ...}, <- Rollup v1 +# {"version": "...", "address": "0x...", ...} <- Rollup v2 +# ] +# } +``` + +## Test data matrix + +| Data point | Rollup v1 | Rollup v2 | +|---|---|---| +| `getVersion()` | auto from genesis | different hash (different genesis archive root) | +| `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` | Every deployed address + chain id + RPC URL. Consumed by both seed scripts. | +| `contract_addresses.json` | Schema expected by `bootstrap.sh` in both apps. Consumed via `CONTRACT_ADDRESSES_FILE`. | +| `test-data.json` | What `seed-multi-rollup.ts` actually wrote, useful for assertion-driven tests. | + +## Architecture + +``` +anvil (port 8545) + ├── Real Registry (from aztec-packages) + │ ├── Rollup v1 (older) + │ └── Rollup v2 (canonical) + ├── Real GSE, Governance, RewardDistributor, MockVerifier + ├── Real TestERC20 (staking asset) + TestERC20 (fee asset) + ├── Real PullSplitFactory (Splits v2) + ├── Real StakingRegistry (from ignition-contracts) + └── Real ATPFactory + ATPRegistry + ATPWithdrawableAndClaimableStaker + +Indexer (port 42068) + └── Indexes from REGISTRY_DEPLOYMENT_BLOCK + ├── Registry:CanonicalRollupUpdated → rollup_version table → /api/rollups + ├── Rollup events (factory pattern across both rollups) + ├── StakingRegistry events (provider registrations, stakes) + └── ATP events (positions, operator updates) + +Dashboard (port 5173) + └── Boot-time fetch of /api/rollups; canonical = v2 +``` + +## Gotchas + +### MetaMask "nonce too low" after restarting anvil + +**Symptom**: transactions fail with "Nonce provided for the transaction (N) is lower than the current nonce". + +**Cause**: MetaMask caches the per-account nonce. After restarting anvil or +re-running the deploy script, the on-chain nonce resets but MetaMask doesn't +know. + +**Fix**: MetaMask → Settings → Advanced → **Clear activity tab data**. + +### `claimSequencerRewards` reverts with `ERC20InsufficientBalance` + +**Symptom**: simulating the claim fails because the Rollup contract has 0 +balance of the fee asset. + +**Cause**: rewards are paid out by transferring **fee asset** (not staking +asset) from the Rollup's own balance. Setting the reward storage slot to N +doesn't give the Rollup any tokens. + +**Fix**: `seed-multi-rollup.ts` mints fee tokens to both rollups. If you see +the error after a fresh deploy, re-run `seed-multi-rollup.ts`. + +### Multicall3 not deployed → wagmi `useReadContracts` returns nothing + +**Symptom**: per-rollup hooks silently return empty data. + +**Cause**: wagmi's `useReadContracts` calls Multicall3 at the canonical +address `0xcA11bde05977b3631167028862bE2a173976CA11`. Anvil doesn't deploy +it by default. + +**Fix**: Phase 0 of the deploy script `anvil_setCode`s the Multicall3 +deployedBytecode in. If you restart anvil, re-run the deploy script. + +### `aztec-packages/l1-contracts` won't compile (missing `HonkVerifier.sol`) + +**Symptom**: `forge build` fails with "Source not found: generated/HonkVerifier.sol". + +**Cause**: `HonkVerifier.sol` is generated from noir circuit compilation. We +use `MockVerifier` at runtime so the real one isn't needed, but the import +path still has to resolve. + +**Fix**: Phase 0 of the deploy script writes a no-op placeholder if it +doesn't exist. If compiling `aztec-packages/l1-contracts` manually: + +```bash +cd $AZTEC_PACKAGES_DIR/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 +``` + +### Storage slot calculation for rewards (ERC-7201) + +The Rollup uses **namespaced storage** (ERC-7201). Reward data lives at: + +``` +base = keccak256("aztec.reward.storage") // raw UTF-8 bytes, NOT abi.encode + +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: bytes4 earliestRewardsClaimableTimestamp || bool isRewardsClaimable +``` + +For a mapping entry: `keccak256(abi.encode(addressKey, base + 0))`. For +`isRewardsClaimable`: set bit at byte offset 4 (after the 4-byte timestamp) +of slot `base + 5`. + +Common mistake: `keccak256(abi.encode("aztec.reward.storage"))` (encoded +string with offset+length prefix) instead of `keccak256("aztec.reward.storage")` +(raw bytes). The two produce different hashes and the former silently writes +to the wrong slot. + +### Governance owns the Registry, direct `addRollup` reverts + +**Symptom**: deploying rollup v2 succeeds but `Registry.addRollup(v2)` reverts +with an ownership error from the deployer EOA. + +**Cause**: `DeployAztecL1Contracts` transfers Registry ownership to Governance +in `_handoverToGovernance()`. After that, only Governance can call +`addRollup()`. + +**Fix**: Phase 3 of the deploy script uses `anvil_impersonateAccount` to act +as Governance for the registration call. This only works on anvil; on a +real network you'd go through the governance proposal flow. + +### Indexer shows empty `/api/rollups` until backfill catches up + +**Symptom**: dashboard fails to boot with "Indexer has not yet recorded a +canonical rollup" right after a fresh deploy. + +**Cause**: the indexer only populates `rollup_version` after it indexes the +first `CanonicalRollupUpdated` event, which lives at the Registry deployment +block. There's a brief window where backfill hasn't caught up. + +**Fix**: wait for the indexer to backfill past the Registry deployment block +(usually seconds against a local anvil). Refresh the dashboard. diff --git a/scripts/multi-rollup-test/deploy-multi-rollup.sh b/scripts/multi-rollup-test/deploy-multi-rollup.sh new file mode 100755 index 000000000..c9ea41b7a --- /dev/null +++ b/scripts/multi-rollup-test/deploy-multi-rollup.sh @@ -0,0 +1,366 @@ +#!/bin/bash +# Deploy a complete real-contract multi-rollup test environment to local anvil. +# +# Stands up the same Aztec L1 stack used in production (Registry, Governance, +# GSE, Rollup v1, RewardDistributor, MockVerifier, TestERC20s) plus a second +# canonical rollup, then deploys the real StakingRegistry and ATP factories +# from the ignition-contracts repo. No mocks: every contract is the production +# bytecode, so storage layouts, ABIs, and event signatures match exactly what +# the indexer and frontend see on testnet/mainnet. +# +# Required env vars: +# AZTEC_PACKAGES_DIR path to aztec-packages repo +# IGNITION_CONTRACTS_DIR path to ignition-contracts repo +# +# Optional env vars: +# ANVIL_PORT (default 8545) +# DEPLOYER_PK (default anvil account 0) +# +# Output: scripts/multi-rollup-test/contract_addresses.json + deploy-output.json +# Both consumed by the indexer/frontend bootstrap.sh via CONTRACT_ADDRESSES_FILE. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$SCRIPT_DIR/../.." +OUT_DIR="$SCRIPT_DIR" + +AZTEC_PACKAGES_DIR="${AZTEC_PACKAGES_DIR:-}" +IGNITION_CONTRACTS_DIR="${IGNITION_CONTRACTS_DIR:-}" + +# Auto-detect sibling repos if env vars not set +if [ -z "$AZTEC_PACKAGES_DIR" ] && [ -d "$REPO_ROOT/../aztec-packages/l1-contracts" ]; then + AZTEC_PACKAGES_DIR="$(cd "$REPO_ROOT/../aztec-packages" && pwd)" +fi +if [ -z "$IGNITION_CONTRACTS_DIR" ] && [ -d "$REPO_ROOT/../ignition-contracts/src" ]; then + IGNITION_CONTRACTS_DIR="$(cd "$REPO_ROOT/../ignition-contracts" && pwd)" +fi + +[ -n "$AZTEC_PACKAGES_DIR" ] || { echo "ERROR: AZTEC_PACKAGES_DIR not set and ../aztec-packages not found"; exit 1; } +[ -n "$IGNITION_CONTRACTS_DIR" ] || { echo "ERROR: IGNITION_CONTRACTS_DIR not set and ../ignition-contracts not found"; exit 1; } +[ -d "$AZTEC_PACKAGES_DIR/l1-contracts/src" ] || { echo "ERROR: $AZTEC_PACKAGES_DIR/l1-contracts/src not found"; exit 1; } +[ -d "$IGNITION_CONTRACTS_DIR/src/staking-registry" ] || { echo "ERROR: $IGNITION_CONTRACTS_DIR/src/staking-registry not found"; exit 1; } + +L1_ROOT="$AZTEC_PACKAGES_DIR/l1-contracts" +ANVIL_PORT="${ANVIL_PORT:-8545}" +L1_RPC_URL="http://127.0.0.1:$ANVIL_PORT" +DEPLOYER_PK="${DEPLOYER_PK:-0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80}" +DEPLOYER_ADDR=$(cast wallet address --private-key "$DEPLOYER_PK") + +# Sanity: anvil reachable +cast block-number --rpc-url "$L1_RPC_URL" > /dev/null 2>&1 || { + echo "ERROR: anvil not reachable at $L1_RPC_URL. Start it first: anvil --port $ANVIL_PORT"; exit 1; +} + +rm -f "$OUT_DIR/deploy-output.json" "$OUT_DIR/contract_addresses.json" + +echo "=== Loading devnet defaults ===" +# shellcheck disable=SC1091 +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 + +# ============================================================ +# Phase 0: l1-contracts compile prep + Multicall3 +# ============================================================ +echo "" +echo "=== Phase 0: Preparing l1-contracts + Multicall3 ===" +cd "$L1_ROOT" +mkdir -p generated + +# HonkVerifier is generated from circuits at proper builds. We use MockVerifier +# at runtime, but the import still has to resolve at compile time. +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 || echo "{}" > generated/default.json +fi + +# foundry.toml pins solc to a local binary (./solc-0.8.30) that's normally +# fetched by aztec-packages' own bootstrap.sh. If it isn't there, fall back +# to forge's built-in svm so this script works against a fresh checkout. +SOLC_PATH=$(grep '^solc = ' foundry.toml 2>/dev/null | sed 's/.*"\.\/\(.*\)"/\1/' || true) +if [ -n "$SOLC_PATH" ] && [ ! -f "./$SOLC_PATH" ]; then + SOLC_VERSION=${SOLC_PATH#solc-} + echo " ./$SOLC_PATH missing, fetching solc $SOLC_VERSION via svm" + mkdir -p "$HOME/.svm" + forge build --use "$SOLC_VERSION" src/core/libraries/ConstantsGen.sol > /dev/null 2>&1 || true + SVM_BIN="$HOME/.svm/$SOLC_VERSION/solc-$SOLC_VERSION" + if [ -f "$SVM_BIN" ]; then + cp "$SVM_BIN" "./$SOLC_PATH" + else + echo " ERROR: failed to obtain solc $SOLC_VERSION via svm"; exit 1 + fi +fi + +forge build > /dev/null + +# Multicall3 deploy via setCode. wagmi useReadContracts depends on it; anvil +# does not deploy it by default. +MULTICALL3_BYTECODE=$(jq -r '.deployedBytecode.object' "$L1_ROOT/out/Multicall3.sol/Multicall3.json" 2>/dev/null || true) +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 " WARN: Multicall3 bytecode not found; wagmi useReadContracts may fail" +fi + +rm -rf broadcast/ + +# ============================================================ +# Phase 1: full L1 stack + rollup v1 +# ============================================================ +echo "" +echo "=== Phase 1: Deploying L1 stack + Rollup v1 ===" + +forge script script/deploy/DeployAztecL1Contracts.s.sol:DeployAztecL1Contracts \ + --rpc-url "$L1_RPC_URL" \ + --private-key "$DEPLOYER_PK" \ + --broadcast > /tmp/deploy_v1.log 2>&1 + +# Pull the JSON result line emitted by the deploy script (search anywhere in the log) +DEPLOY_JSON=$(grep -A1 "JSON DEPLOY RESULT:" /tmp/deploy_v1.log | tail -1) +[ -n "$DEPLOY_JSON" ] || DEPLOY_JSON=$(grep "JSON DEPLOY RESULT:" /tmp/deploy_v1.log | 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') + +[ -n "$REGISTRY" ] && [ "$REGISTRY" != "null" ] || { echo "ERROR: failed to parse Registry address from deploy output"; cat /tmp/deploy_v1.log; exit 1; } + +# Capture Registry deployment block (needed by the indexer factory backfill). +REGISTRY_BLOCK=$(cast code --rpc-url "$L1_RPC_URL" "$REGISTRY" > /dev/null && \ + cast block-number --rpc-url "$L1_RPC_URL") +# Refine: walk backwards to find the actual deploy block. Simpler: use current +# block number minus a small buffer, since anvil started fresh. +REGISTRY_BLOCK=$(cast logs --rpc-url "$L1_RPC_URL" --address "$REGISTRY" --from-block 0 \ + 'CanonicalRollupUpdated(address,uint256)' --json 2>/dev/null \ + | jq -r '.[0].blockNumber' | sed 's/0x//' | tr '[:lower:]' '[:upper:]' \ + | xargs -I{} printf '%d\n' "0x{}" 2>/dev/null || echo "0") + +echo " Registry: $REGISTRY (deploy block ~$REGISTRY_BLOCK)" +echo " Rollup v1: $ROLLUP_V1 (version $V1_VERSION)" +echo " Staking asset: $STAKING_ASSET" +echo " Fee asset: $FEE_ASSET" +echo " Governance: $GOVERNANCE" + +# ============================================================ +# Phase 2: rollup v2 with a different genesis +# ============================================================ +echo "" +echo "=== Phase 2: Deploying Rollup v2 ===" + +# Different genesis archive root → different version hash → addRollup() accepts it +export GENESIS_ARCHIVE_ROOT="0x$(openssl rand -hex 32)" +export REGISTRY_ADDRESS="$REGISTRY" + +forge script script/deploy/DeployRollupForUpgrade.s.sol:DeployRollupForUpgrade \ + --rpc-url "$L1_RPC_URL" \ + --private-key "$DEPLOYER_PK" \ + --broadcast > /tmp/deploy_v2.log 2>&1 + +DEPLOY_V2_JSON=$(grep -A1 "JSON DEPLOY RESULT:" /tmp/deploy_v2.log | tail -1) +[ -n "$DEPLOY_V2_JSON" ] || DEPLOY_V2_JSON=$(grep "JSON DEPLOY RESULT:" /tmp/deploy_v2.log | 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 (Governance owns Registry post-handover) +# ============================================================ +echo "" +echo "=== Phase 3: Registering Rollup v2 in Registry ===" +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 +# Best-effort GSE registration; older GSE versions may not have addRollup +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 + +NUM_VERSIONS=$(cast call "$REGISTRY" "numberOfVersions()(uint256)" --rpc-url "$L1_RPC_URL") +CANONICAL=$(cast call "$REGISTRY" "getCanonicalRollup()(address)" --rpc-url "$L1_RPC_URL") +echo " Versions registered: $NUM_VERSIONS" +echo " Canonical rollup: $CANONICAL" + +# ============================================================ +# Phase 4: real StakingRegistry + ATP factories from ignition-contracts +# ============================================================ +echo "" +echo "=== Phase 4: Deploying real StakingRegistry + ATP factories ===" +cd "$IGNITION_CONTRACTS_DIR" +forge build > /dev/null + +# 4a: SplitsWarehouse (Splits v2 dependency). Constructor: (nativeName, nativeSymbol) +echo " Deploying SplitsWarehouse..." +SPLITS_WAREHOUSE=$(forge create \ + lib/splits-contracts-monorepo/packages/splits-v2/src/SplitsWarehouse.sol:SplitsWarehouse \ + --rpc-url "$L1_RPC_URL" --private-key "$DEPLOYER_PK" --broadcast \ + --constructor-args "Ether" "ETH" 2>/dev/null \ + | awk '/Deployed to:/ {print $3}') +[ -n "$SPLITS_WAREHOUSE" ] || { echo "ERROR: SplitsWarehouse deploy failed"; exit 1; } +echo " SplitsWarehouse: $SPLITS_WAREHOUSE" + +# 4b: PullSplitFactory. Constructor: (splitsWarehouse) +echo " Deploying PullSplitFactory..." +PULL_SPLIT_FACTORY=$(forge create \ + lib/splits-contracts-monorepo/packages/splits-v2/src/splitters/pull/PullSplitFactory.sol:PullSplitFactory \ + --rpc-url "$L1_RPC_URL" --private-key "$DEPLOYER_PK" --broadcast \ + --constructor-args "$SPLITS_WAREHOUSE" 2>/dev/null \ + | awk '/Deployed to:/ {print $3}') +[ -n "$PULL_SPLIT_FACTORY" ] || { echo "ERROR: PullSplitFactory deploy failed"; exit 1; } +echo " PullSplitFactory: $PULL_SPLIT_FACTORY" + +# 4c: real StakingRegistry. Constructor: (stakingAsset, pullSplitFactory, registry) +echo " Deploying StakingRegistry..." +STAKING_REGISTRY=$(forge create \ + src/staking-registry/StakingRegistry.sol:StakingRegistry \ + --rpc-url "$L1_RPC_URL" --private-key "$DEPLOYER_PK" --broadcast \ + --constructor-args "$STAKING_ASSET" "$PULL_SPLIT_FACTORY" "$REGISTRY" 2>/dev/null \ + | awk '/Deployed to:/ {print $3}') +[ -n "$STAKING_REGISTRY" ] || { echo "ERROR: StakingRegistry deploy failed"; exit 1; } +echo " StakingRegistry: $STAKING_REGISTRY" + +# 4d: ATPFactory + its 3 library deps. ATPFactory dispatches to LATPFactory / +# MATPFactory / NCATPFactory deployment libs via DELEGATECALL, so they have to +# be deployed first and linked at compile time. +deploy_lib() { + local path="$1" + local addr + addr=$(forge create "$path" --rpc-url "$L1_RPC_URL" --private-key "$DEPLOYER_PK" --broadcast \ + | awk '/Deployed to:/ {print $3}') + [ -n "$addr" ] || { echo "ERROR: deploy $path failed"; exit 1; } + echo "$addr" +} +echo " Deploying ATPFactory libraries..." +LATP_LIB=$(deploy_lib src/token-vaults/deployment-factories/LATPFactory.sol:LATPFactory) +MATP_LIB=$(deploy_lib src/token-vaults/deployment-factories/MATPFactory.sol:MATPFactory) +NCATP_LIB=$(deploy_lib src/token-vaults/deployment-factories/NCATPFactory.sol:NCATPFactory) +echo " LATPFactory lib: $LATP_LIB" +echo " MATPFactory lib: $MATP_LIB" +echo " NCATPFactory lib: $NCATP_LIB" + +echo " Deploying ATPFactory..." +ATP_FACTORY=$(forge create \ + src/token-vaults/ATPFactory.sol:ATPFactory \ + --rpc-url "$L1_RPC_URL" --private-key "$DEPLOYER_PK" --broadcast \ + --libraries "src/token-vaults/deployment-factories/LATPFactory.sol:LATPFactory:$LATP_LIB" \ + --libraries "src/token-vaults/deployment-factories/MATPFactory.sol:MATPFactory:$MATP_LIB" \ + --libraries "src/token-vaults/deployment-factories/NCATPFactory.sol:NCATPFactory:$NCATP_LIB" \ + --constructor-args "$DEPLOYER_ADDR" "$STAKING_ASSET" "31536000" "31536000" \ + | awk '/Deployed to:/ {print $3}') +[ -n "$ATP_FACTORY" ] || { echo "ERROR: ATPFactory deploy failed"; exit 1; } +echo " ATPFactory: $ATP_FACTORY" + +# 4e: derive ATPRegistry from ATPFactory; configure executable timestamp + minter +ATP_REGISTRY=$(cast call "$ATP_FACTORY" "getRegistry()(address)" --rpc-url "$L1_RPC_URL") +echo " ATPRegistry: $ATP_REGISTRY (from factory)" +cast send "$ATP_FACTORY" "setMinter(address,bool)" "$DEPLOYER_ADDR" true \ + --rpc-url "$L1_RPC_URL" --private-key "$DEPLOYER_PK" > /dev/null +cast send "$ATP_REGISTRY" "setExecuteAllowedAt(uint256)" 1 \ + --rpc-url "$L1_RPC_URL" --private-key "$DEPLOYER_PK" > /dev/null + +# 4f: ATPWithdrawableAndClaimableStaker. Constructor: +# (stakingAsset, rollupRegistry, stakingRegistry, withdrawalTimestamp) +echo " Deploying ATPWithdrawableAndClaimableStaker..." +NOW=$(cast block --rpc-url "$L1_RPC_URL" latest --json | jq -r '.timestamp' | xargs printf '%d') +EXEC_AT=$((NOW + 86400)) +ATP_STAKER=$(forge create \ + src/staking/ATPWithdrawableAndClaimableStaker.sol:ATPWithdrawableAndClaimableStaker \ + --rpc-url "$L1_RPC_URL" --private-key "$DEPLOYER_PK" --broadcast \ + --constructor-args "$STAKING_ASSET" "$REGISTRY" "$STAKING_REGISTRY" "$EXEC_AT" 2>/dev/null \ + | awk '/Deployed to:/ {print $3}') +[ -n "$ATP_STAKER" ] || { echo "ERROR: ATPWithdrawableAndClaimableStaker deploy failed"; exit 1; } +echo " ATPWithdrawableAndClaimableStaker: $ATP_STAKER" + +cast send "$ATP_REGISTRY" "registerStakerImplementation(address)" "$ATP_STAKER" \ + --rpc-url "$L1_RPC_URL" --private-key "$DEPLOYER_PK" > /dev/null + +# ============================================================ +# Phase 5: write outputs consumed by indexer + frontend bootstrap +# ============================================================ +echo "" +echo "=== Phase 5: Writing output files ===" + +cat > "$OUT_DIR/deploy-output.json" << EOJSON +{ + "rpcUrl": "$L1_RPC_URL", + "chainId": 31337, + "registryAddress": "$REGISTRY", + "registryDeploymentBlock": "$REGISTRY_BLOCK", + "rollupV1Address": "$ROLLUP_V1", + "rollupV1Version": "$V1_VERSION", + "rollupV2Address": "$ROLLUP_V2", + "rollupV2Version": "$V2_VERSION", + "stakingAssetAddress": "$STAKING_ASSET", + "feeAssetAddress": "$FEE_ASSET", + "governanceAddress": "$GOVERNANCE", + "gseAddress": "$GSE_ADDR", + "rewardDistributorAddress": "$REWARD_DIST", + "pullSplitFactoryAddress": "$PULL_SPLIT_FACTORY", + "stakingRegistryAddress": "$STAKING_REGISTRY", + "atpFactoryAddress": "$ATP_FACTORY", + "atpRegistryAddress": "$ATP_REGISTRY", + "atpWithdrawableAndClaimableStakerAddress": "$ATP_STAKER", + "splitsWarehouseAddress": "$SPLITS_WAREHOUSE" +} +EOJSON +echo " $OUT_DIR/deploy-output.json" + +# contract_addresses.json: schema consumed by atp-indexer/bootstrap.sh and +# staking-dashboard/bootstrap.sh. ATP*Auction and ATPFactoryMATP/LATP are not +# deployed in this env; reuse the genesis ATPFactory address as a placeholder +# so the indexer still boots (factory backfill will index the same events twice +# in the worst case). +cat > "$OUT_DIR/contract_addresses.json" << EOJSON +{ + "atpFactory": "$ATP_FACTORY", + "atpFactoryAuction": "$ATP_FACTORY", + "atpFactoryMatp": "$ATP_FACTORY", + "atpFactoryLatp": "$ATP_FACTORY", + "atpRegistry": "$ATP_REGISTRY", + "atpRegistryAuction": "$ATP_REGISTRY", + "stakingRegistry": "$STAKING_REGISTRY", + "registryAddress": "$REGISTRY", + "registryDeploymentBlock": "$REGISTRY_BLOCK", + "atpFactoryDeploymentBlock": "$REGISTRY_BLOCK", + "atpWithdrawableAndClaimableStaker": "$ATP_STAKER", + "genesisSequencerSale": "0x0000000000000000000000000000000000000000", + "governanceAddress": "$GOVERNANCE", + "gseAddress": "$GSE_ADDR" +} +EOJSON +echo " $OUT_DIR/contract_addresses.json" + +echo "" +echo "=== Deployment complete ===" +echo " Registry: $REGISTRY ($NUM_VERSIONS rollup versions)" +echo " Rollup v1 (older): $ROLLUP_V1 (version $V1_VERSION)" +echo " Rollup v2 (canonical): $ROLLUP_V2 (version $V2_VERSION)" +echo "" +echo "Next steps:" +echo " 1. Seed rewards + fee tokens: npx tsx $SCRIPT_DIR/seed-multi-rollup.ts" +echo " 2. Seed providers (optional): npx tsx $SCRIPT_DIR/seed-providers.ts" +echo " 3. Point indexer at this env:" +echo " export CONTRACT_ADDRESSES_FILE=$OUT_DIR/contract_addresses.json" +echo " export RPC_URL=$L1_RPC_URL" +echo " cd atp-indexer && ./bootstrap.sh dev" +echo " 4. Point frontend at the indexer + this env:" +echo " export CONTRACT_ADDRESSES_FILE=$OUT_DIR/contract_addresses.json" +echo " cd staking-dashboard && ./bootstrap.sh dev" diff --git a/scripts/multi-rollup-test/seed-multi-rollup.ts b/scripts/multi-rollup-test/seed-multi-rollup.ts new file mode 100644 index 000000000..8c14b5ccc --- /dev/null +++ b/scripts/multi-rollup-test/seed-multi-rollup.ts @@ -0,0 +1,159 @@ +/** + * Seed multi-rollup test state on local anvil. + * + * After deploy-multi-rollup.sh has stood up the contracts, this script: + * 1. Writes sequencer reward amounts via anvil_setStorageAt on both rollups + * (uses the real Rollup's ERC-7201 namespaced reward storage layout) + * 2. Flips isRewardsClaimable to true on both rollups + * 3. Mints fee tokens to each rollup so claimSequencerRewards() can pay out + * 4. Writes test-data.json describing what was seeded (handy for assertions) + * + * Run from repo root: + * npx tsx staking-dashboard/scripts/multi-rollup-test/seed-multi-rollup.ts + */ + +import { + createPublicClient, + createTestClient, + createWalletClient, + http, + keccak256, + encodeAbiParameters, + pad, + 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 SCRIPT_DIR = __dirname; + +const deployOutput = JSON.parse( + readFileSync(resolve(SCRIPT_DIR, "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) }); + +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"]); + +// Anvil default accounts 1 and 2; convenient as test coinbases. +const COINBASE_A = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" as Address; +const COINBASE_B = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" as Address; + +// Rollup uses ERC-7201 namespaced storage. Base slot is the keccak256 hash of +// the raw UTF-8 string (NOT the abi-encoded string, which produces a different +// hash). RewardStorage layout relative to base: +// slot 0: mapping(address => uint256) sequencerRewards +// slot 5: bytes4 earliestRewardsClaimableTimestamp || bool isRewardsClaimable +const REWARD_STORAGE_BASE = keccak256(stringToHex("aztec.reward.storage")); +const SEQUENCER_REWARDS_SLOT = BigInt(REWARD_STORAGE_BASE); +const IS_CLAIMABLE_SLOT = BigInt(REWARD_STORAGE_BASE) + 5n; + +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 }) }); +} + +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; + const feeAsset = deployOutput.feeAssetAddress as Address; + + console.log(`\nSeeding multi-rollup test state`); + console.log(` Rollup v1: ${rollupV1}`); + console.log(` Rollup v2: ${rollupV2}`); + console.log(` RPC: ${rpcUrl}\n`); + + console.log("Setting sequencer rewards..."); + 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 })); + await setStorageSlot(rollupV2, mappingSlot(COINBASE_A, SEQUENCER_REWARDS_SLOT), numberToHex(10n * 10n ** 18n, { size: 32 })); + + // Flip isRewardsClaimable without clobbering the packed timestamp at offset 0. + console.log("Setting isRewardsClaimable = true on both rollups..."); + for (const rollup of [rollupV1, rollupV2]) { + const currentVal = await publicClient.getStorageAt({ + address: rollup, + slot: numberToHex(IS_CLAIMABLE_SLOT, { size: 32 }), + }); + const withClaimable = BigInt(currentVal || "0x0") | (1n << 32n); // bool sits at byte offset 4 (after the 4-byte timestamp) + await setStorageSlot(rollup, numberToHex(IS_CLAIMABLE_SLOT, { size: 32 }), numberToHex(withClaimable, { size: 32 })); + } + + // claimSequencerRewards transfers the fee asset from the rollup's own balance. + // Without this mint the claim tx reverts with ERC20InsufficientBalance. + console.log("Minting fee tokens to both rollups 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`); + + console.log("\nVerifying..."); + const v1RewardsA = await publicClient.readContract({ address: rollupV1, abi: rollupAbi, functionName: "getSequencerRewards", args: [COINBASE_A] }); + const v1RewardsB = await publicClient.readContract({ address: rollupV1, abi: rollupAbi, functionName: "getSequencerRewards", args: [COINBASE_B] }); + const v2RewardsA = await publicClient.readContract({ address: rollupV2, abi: rollupAbi, functionName: "getSequencerRewards", args: [COINBASE_A] }); + const v1Claimable = await publicClient.readContract({ address: rollupV1, abi: rollupAbi, functionName: "isRewardsClaimable" }); + const v2Claimable = await publicClient.readContract({ address: rollupV2, abi: rollupAbi, functionName: "isRewardsClaimable" }); + const v1Version = await publicClient.readContract({ address: rollupV1, abi: rollupAbi, functionName: "getVersion" }); + const v2Version = await publicClient.readContract({ address: rollupV2, abi: rollupAbi, functionName: "getVersion" }); + + console.log(` v1 rewards A: ${v1RewardsA} (expected ${5n * 10n ** 18n})`); + console.log(` v1 rewards B: ${v1RewardsB} (expected ${3n * 10n ** 18n})`); + console.log(` v2 rewards A: ${v2RewardsA} (expected ${10n * 10n ** 18n})`); + console.log(` v1 isRewardsClaimable: ${v1Claimable}, v2 isRewardsClaimable: ${v2Claimable}`); + if (v1RewardsA !== 5n * 10n ** 18n || v2RewardsA !== 10n * 10n ** 18n || !v1Claimable || !v2Claimable) { + console.error(" Verification mismatch: storage slot calculation may be off"); + process.exit(1); + } + + 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(SCRIPT_DIR, "test-data.json"), JSON.stringify(testData, null, 2)); + console.log(`\nWrote ${resolve(SCRIPT_DIR, "test-data.json")}`); + + // Frontend gating: the Claimable Rewards section requires the user to have + // saved coinbase addresses. Rather than walking the UI, paste this into + // DevTools to populate localStorage and reload. + const DEPLOYER_ADDR = account.address.toLowerCase(); + const lsKey = `rewards_coinbase_addresses_${DEPLOYER_ADDR}`; + const lsValue = JSON.stringify([COINBASE_A.toLowerCase()]); + console.log("\nBrowser setup (paste in DevTools console):"); + console.log(` localStorage.setItem('${lsKey}', '${lsValue}'); location.reload();`); +} + +main().catch((err) => { console.error(`\nError: ${err.message}\n`); process.exit(1); }); diff --git a/scripts/multi-rollup-test/seed-providers.ts b/scripts/multi-rollup-test/seed-providers.ts new file mode 100644 index 000000000..6569fb20e --- /dev/null +++ b/scripts/multi-rollup-test/seed-providers.ts @@ -0,0 +1,82 @@ +/** + * Register a handful of test providers on the real StakingRegistry so the + * indexer's /api/providers endpoint returns non-empty data. + * + * The real StakingRegistry assigns provider IDs sequentially via + * `nextProviderIdentifier` (starting at 1), so the IDs we end up with are + * 1..N rather than the production IDs. That's fine for testing; the goal is + * to populate the table, not to match production. + * + * Run from repo root: + * npx tsx staking-dashboard/scripts/multi-rollup-test/seed-providers.ts + */ + +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 SCRIPT_DIR = __dirname; +const REPO_ROOT = resolve(SCRIPT_DIR, "../.."); +const INDEXER_ROOT = resolve(REPO_ROOT, "atp-indexer"); + +const deployOutput = JSON.parse(readFileSync(resolve(SCRIPT_DIR, "deploy-output.json"), "utf-8")); +const rpcUrl = deployOutput.rpcUrl || "http://127.0.0.1:8545"; +const stakingRegistry = deployOutput.stakingRegistryAddress as Address; + +// Use provider names from the indexer metadata so the dashboard renders +// recognisable labels for the seeded providers. +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) }); + +// Real StakingRegistry signature: registerProvider returns the assigned id. +const stakingRegistryAbi = parseAbi([ + "function registerProvider(address _providerAdmin, uint16 _providerTakeRate, address _providerRewardsRecipient) external returns (uint256)", + "function nextProviderIdentifier() external view returns (uint256)", +]); + +async function main() { + const toRegister = providersJson.slice(0, 10); + + console.log(`\nRegistering ${toRegister.length} providers on StakingRegistry`); + console.log(` StakingRegistry: ${stakingRegistry}`); + console.log(` RPC: ${rpcUrl}\n`); + + const startId = await publicClient.readContract({ + address: stakingRegistry, abi: stakingRegistryAbi, functionName: "nextProviderIdentifier", + }); + + for (const [i, p] of toRegister.entries()) { + const expectedId = startId + BigInt(i); + const hash = await walletClient.writeContract({ + address: stakingRegistry, + abi: stakingRegistryAbi, + functionName: "registerProvider", + args: [account.address, 500, account.address], // admin, 5% take rate (bps/100), rewards recipient + }); + await publicClient.waitForTransactionReceipt({ hash }); + console.log(` Registered provider id=${expectedId} (label: ${p.providerName})`); + } + + console.log(`\nDone. Once the indexer catches up:`); + console.log(` 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/.env.example b/staking-dashboard/.env.example index 54e1dabf0..87d4e0c68 100644 --- a/staking-dashboard/.env.example +++ b/staking-dashboard/.env.example @@ -7,7 +7,6 @@ VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS=0x0000000000000000000000000000000000000 VITE_ATP_WITHDRAWABLE_STAKER_ADDRESS=0x0000000000000000000000000000000000000000 VITE_ATP_WITHDRAWABLE_AND_CLAIMABLE_STAKER_ADDRESS=0x0000000000000000000000000000000000000000 VITE_STAKING_REGISTRY_ADDRESS=0x0000000000000000000000000000000000000000 -VITE_ROLLUP_ADDRESS=0x0000000000000000000000000000000000000000 VITE_GENESIS_SEQUENCER_SALE_ADDRESS=0x0000000000000000000000000000000000000000 VITE_GOVERNANCE_ADDRESS=0x0000000000000000000000000000000000000000 VITE_GSE_ADDRESS=0x0000000000000000000000000000000000000000 diff --git a/staking-dashboard/bootstrap.sh b/staking-dashboard/bootstrap.sh index bf300d340..2244070fd 100755 --- a/staking-dashboard/bootstrap.sh +++ b/staking-dashboard/bootstrap.sh @@ -56,7 +56,6 @@ load_contract_addresses() { VITE_ATP_REGISTRY_ADDRESS=$(cat $contract_addresses_file | jq -r '.atpRegistry') VITE_ATP_REGISTRY_AUCTION_ADDRESS=$(cat $contract_addresses_file | jq -r '.atpRegistryAuction') VITE_STAKING_REGISTRY_ADDRESS=$(cat $contract_addresses_file | jq -r '.stakingRegistry') - VITE_ROLLUP_ADDRESS=$(cat $contract_addresses_file | jq -r '.rollupAddress') VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS=$(cat $contract_addresses_file | jq -r '.atpWithdrawableAndClaimableStaker') VITE_ATP_WITHDRAWABLE_STAKER_ADDRESS=$(cat $contract_addresses_file | jq -r '.atpWithdrawableAndClaimableStaker') VITE_ATP_WITHDRAWABLE_AND_CLAIMABLE_STAKER_ADDRESS=$(cat $contract_addresses_file | jq -r '.atpWithdrawableAndClaimableStaker') @@ -154,9 +153,6 @@ function update_env_file() { log_step "Updating VITE_STAKING_REGISTRY_ADDRESS: $VITE_STAKING_REGISTRY_ADDRESS" update_env_var $env_file "VITE_STAKING_REGISTRY_ADDRESS" "$VITE_STAKING_REGISTRY_ADDRESS" - log_step "Updating VITE_ROLLUP_ADDRESS: $VITE_ROLLUP_ADDRESS" - update_env_var $env_file "VITE_ROLLUP_ADDRESS" "$VITE_ROLLUP_ADDRESS" - log_step "Updating VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS: $VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS" update_env_var $env_file "VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS" "$VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS" @@ -261,7 +257,6 @@ VITE_ATP_FACTORY_AUCTION_ADDRESS=$VITE_ATP_FACTORY_AUCTION_ADDRESS VITE_ATP_REGISTRY_ADDRESS=$VITE_ATP_REGISTRY_ADDRESS VITE_ATP_REGISTRY_AUCTION_ADDRESS=$VITE_ATP_REGISTRY_AUCTION_ADDRESS VITE_STAKING_REGISTRY_ADDRESS=$VITE_STAKING_REGISTRY_ADDRESS -VITE_ROLLUP_ADDRESS=$VITE_ROLLUP_ADDRESS VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS=$VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS VITE_ATP_WITHDRAWABLE_STAKER_ADDRESS=$VITE_ATP_WITHDRAWABLE_STAKER_ADDRESS VITE_GENESIS_SEQUENCER_SALE_ADDRESS=$VITE_GENESIS_SEQUENCER_SALE_ADDRESS @@ -316,8 +311,6 @@ function update_env_file_deploy() { update_env_var "$WEBSITE_ROOT/.env.$environment" "VITE_ATP_REGISTRY_AUCTION_ADDRESS" $VITE_ATP_REGISTRY_AUCTION_ADDRESS log_step "Updating VITE_STAKING_REGISTRY_ADDRESS" update_env_var "$WEBSITE_ROOT/.env.$environment" "VITE_STAKING_REGISTRY_ADDRESS" $VITE_STAKING_REGISTRY_ADDRESS - log_step "Updating VITE_ROLLUP_ADDRESS" - update_env_var "$WEBSITE_ROOT/.env.$environment" "VITE_ROLLUP_ADDRESS" $VITE_ROLLUP_ADDRESS log_step "Updating VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS" update_env_var "$WEBSITE_ROOT/.env.$environment" "VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS" $VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS log_step "Updating VITE_ATP_WITHDRAWABLE_STAKER_ADDRESS" @@ -544,7 +537,8 @@ case $ACTION in echo " \"atpRegistry\": \"0x...\"," echo " \"atpRegistryAuction\": \"0x...\"," echo " \"stakingRegistry\": \"0x...\"," - echo " \"rollupAddress\": \"0x...\"," + echo " \"registryAddress\": \"0x...\"," + echo " \"registryDeploymentBlock\": \"12345678\"," echo " \"atpWithdrawableAndClaimableStaker\": \"0x...\"," echo " \"genesisSequencerSale\": \"0x...\"," echo " \"governanceAddress\": \"0x...\"," diff --git a/staking-dashboard/src/contracts/index.ts b/staking-dashboard/src/contracts/index.ts index 5dac2bd64..dd363e800 100644 --- a/staking-dashboard/src/contracts/index.ts +++ b/staking-dashboard/src/contracts/index.ts @@ -24,7 +24,6 @@ const contractEnvSchema = z.object({ VITE_ATP_REGISTRY_ADDRESS: addressSchema, VITE_ATP_REGISTRY_AUCTION_ADDRESS: addressSchema, VITE_STAKING_REGISTRY_ADDRESS: addressSchema, - VITE_ROLLUP_ADDRESS: addressSchema, VITE_GENESIS_SEQUENCER_SALE_ADDRESS: addressSchema.optional(), VITE_ATP_WITHDRAWABLE_AND_CLAIMABLE_STAKER_ADDRESS: addressSchema, VITE_GOVERNANCE_ADDRESS: addressSchema, @@ -34,6 +33,61 @@ const contractEnvSchema = z.object({ // Validate eagerly at startup const env = contractEnvSchema.parse(import.meta.env); +/** + * A rollup version record as returned by GET /api/rollups. + * Ordered oldest first in the API response. + */ +export interface RollupVersion { + version: string; + address: Address; + blockNumber: number; + timestamp: number; +} + +// The canonical rollup is resolved at boot from the indexer's /api/rollups +// endpoint (backed by the Registry:CanonicalRollupUpdated event). We also +// cache the full version history for future cross-rollup flows (e.g. +// claiming rewards from an old rollup). Callers still read +// `contracts.rollup.address` synchronously; `initRollupVersions()` is +// awaited in main.tsx before React renders so the getter always returns a +// real address. +let _canonicalRollupAddress: Address | null = null; +let _rollupVersions: RollupVersion[] = []; + +export async function initRollupVersions(): Promise
{ + const apiHost = import.meta.env.VITE_API_HOST; + if (!apiHost) { + throw new Error("VITE_API_HOST must be set to resolve the canonical rollup"); + } + + const res = await fetch(`${apiHost}/api/rollups`); + if (!res.ok) { + throw new Error(`/api/rollups returned ${res.status}`); + } + const body = (await res.json()) as { + canonical: string | null; + versions: RollupVersion[]; + }; + + if (!body.canonical || body.versions.length === 0) { + throw new Error( + "Indexer has not yet recorded a canonical rollup; the Registry:CanonicalRollupUpdated event has not been processed. Wait for the indexer to catch up past the Registry deployment block." + ); + } + + _rollupVersions = body.versions; + _canonicalRollupAddress = body.canonical as Address; + return _canonicalRollupAddress; +} + +/** + * All rollup versions the Registry has ever made canonical, oldest first. + * Empty until `initRollupVersions()` resolves. + */ +export function getRollupVersions(): readonly RollupVersion[] { + return _rollupVersions; +} + const contracts = { atpFactory: { address: env.VITE_ATP_FACTORY_ADDRESS, @@ -56,7 +110,14 @@ const contracts = { abi: StakingRegistryAbi, }, rollup: { - address: env.VITE_ROLLUP_ADDRESS, + get address(): Address { + if (!_canonicalRollupAddress) { + throw new Error( + "Canonical rollup address not initialized: initRollupVersions() must be awaited before app render" + ); + } + return _canonicalRollupAddress; + }, abi: RollupAbi, }, genesisSequencerSale: { diff --git a/staking-dashboard/src/main.tsx b/staking-dashboard/src/main.tsx index 9de63495c..1977347e6 100644 --- a/staking-dashboard/src/main.tsx +++ b/staking-dashboard/src/main.tsx @@ -16,9 +16,16 @@ import { AlertProvider } from "./contexts/AlertContext.tsx"; import { TermsModalProvider } from "./contexts/TermsModalContext.tsx"; import { CustomAvatar } from "./components/CustomAvatar/CustomAvatar.tsx"; import { Alert } from "./components/Alert"; +import { initRollupVersions } from "./contracts"; const queryClient = new QueryClient(); +// Resolve the canonical rollup (and historical rollup versions) from the +// indexer's /api/rollups endpoint before rendering, so every downstream hook +// can read contracts.rollup.address synchronously. Rollup upgrades no longer +// require a code change: just a page reload. +await initRollupVersions(); + createRoot(document.getElementById("root")!).render( diff --git a/staking-dashboard/vite.config.ts b/staking-dashboard/vite.config.ts index 105bac0d6..cd1d2d37d 100644 --- a/staking-dashboard/vite.config.ts +++ b/staking-dashboard/vite.config.ts @@ -17,7 +17,6 @@ export default defineConfig(({ mode, command }) => { 'VITE_ATP_NON_WITHDRAWABLE_STAKER_ADDRESS', 'VITE_ATP_WITHDRAWABLE_STAKER_ADDRESS', 'VITE_STAKING_REGISTRY_ADDRESS', - 'VITE_ROLLUP_ADDRESS', 'VITE_ATP_WITHDRAWABLE_AND_CLAIMABLE_STAKER_ADDRESS', 'VITE_SAFE_API_KEY', 'VITE_RPC_URL',