diff --git a/docs/docs-operate/operators/reference/changelog/v4.md b/docs/docs-operate/operators/reference/changelog/v4.md index de6d28118215..dc8e0cce3d35 100644 --- a/docs/docs-operate/operators/reference/changelog/v4.md +++ b/docs/docs-operate/operators/reference/changelog/v4.md @@ -70,6 +70,30 @@ The `getL2Tips()` RPC endpoint now returns a restructured response with addition - Replace `tips.latest` with `tips.proposed` - For `checkpointed`, `proven`, and `finalized` tips, access block info via `.block` (e.g., `tips.proven.block.number`) +### Setup phase allow list requires function selectors + +The transaction setup phase allow list now enforces function selectors, restricting which specific functions can run during setup on whitelisted contracts. Previously, any public function on a whitelisted contract or class was permitted. + +The semantics of the environment variable `TX_PUBLIC_SETUP_ALLOWLIST` have changed: + +**v3.x:** + +```bash +--txPublicSetupAllowList ($TX_PUBLIC_SETUP_ALLOWLIST) +``` + +The variable fully **replaced** the hardcoded defaults. Format allowed entries without selectors: `I:address`, `C:classId`. + +**v4.0.0:** + +```bash +--txPublicSetupAllowListExtend ($TX_PUBLIC_SETUP_ALLOWLIST) +``` + +The variable now **extends** the hardcoded defaults (which are always present). Selectors are now mandatory. Format: `I:address:selector,C:classId:selector`. + +**Migration**: If you were using `TX_PUBLIC_SETUP_ALLOWLIST`, ensure all entries include function selectors. Note the variable now adds to defaults rather than replacing them. If you were not setting this variable, no action is needed — the hardcoded defaults now include the correct selectors automatically. + ## Removed features ## New features @@ -137,6 +161,10 @@ Transaction submission via RPC now returns structured rejection codes when a tra **Impact**: Improved developer experience — callers can now programmatically handle specific rejection reasons. +### Setup allow list extendable via network config + +The setup phase allow list can now be extended via the network configuration JSON (`txPublicSetupAllowListExtend` field). This allows network operators to distribute additional allowed setup functions to all nodes without requiring code changes. The local environment variable takes precedence over the network-json value. + ## Changed defaults ## Troubleshooting diff --git a/scripts/find_missing_backports.sh b/scripts/find_missing_backports.sh new file mode 100755 index 000000000000..2a023871b7f6 --- /dev/null +++ b/scripts/find_missing_backports.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Find failed backports and determine which have been manually resolved vs missed. +# +# Usage: ./scripts/find_missing_backports.sh [--since YYYY-MM-DD] [--branch TARGET] +# +# Requires: gh, jq + +REPO="AztecProtocol/aztec-packages" +SINCE="2026-02-22" +TARGET_BRANCH="v4" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --since) + SINCE="$2" + shift 2 + ;; + --branch) + TARGET_BRANCH="$2" + shift 2 + ;; + *) + echo "Usage: $0 [--since YYYY-MM-DD] [--branch TARGET]" >&2 + exit 1 + ;; + esac +done + +STAGING_BRANCH="backport-to-${TARGET_BRANCH}-staging" +LABEL="backport-to-${TARGET_BRANCH}" + +command -v gh >/dev/null 2>&1 || { echo "Error: 'gh' CLI not found." >&2; exit 1; } +command -v jq >/dev/null 2>&1 || { echo "Error: 'jq' not found." >&2; exit 1; } + +echo "=== Finding Missing $TARGET_BRANCH Backports ===" +echo "Repo: $REPO" +echo "Target branch: $TARGET_BRANCH" +echo "Since: $SINCE" +echo "" + +# --------------------------------------------------------------------------- +# Step 1: Get all merged PRs with $LABEL label since $SINCE +# --------------------------------------------------------------------------- +echo "Step 1: Fetching merged PRs with '$LABEL' label since $SINCE ..." + +ALL_PRS=$(gh pr list --repo "$REPO" \ + --label "$LABEL" \ + --state merged \ + --search "merged:>=$SINCE" \ + --json number,title \ + --limit 200) + +TOTAL_COUNT=$(echo "$ALL_PRS" | jq 'length') +echo " Found $TOTAL_COUNT merged PRs with $LABEL label." + +if [[ "$TOTAL_COUNT" -eq 0 ]]; then + echo "No PRs found. Nothing to do." + exit 0 +fi + +# --------------------------------------------------------------------------- +# Step 2: Filter to only those with a failed cherry-pick comment +# --------------------------------------------------------------------------- +echo "" +echo "Step 2: Checking each PR for failed cherry-pick comments ..." + +FAILED_PRS=() +FAILED_TITLES=() + +while IFS= read -r line; do + pr_number=$(echo "$line" | jq -r '.number') + pr_title=$(echo "$line" | jq -r '.title') + + # Fetch comments and look for the failure marker + # Both old ("Please backport manually") and new ("Dispatching ClaudeBox") variants + # share the prefix "❌ Failed to cherry-pick" + has_failure=$(gh api "repos/$REPO/issues/$pr_number/comments" \ + --paginate \ + --jq '.[].body' 2>/dev/null \ + | grep -c "❌ Failed to cherry-pick" || true) + + if [[ "$has_failure" -gt 0 ]]; then + FAILED_PRS+=("$pr_number") + FAILED_TITLES+=("$pr_title") + echo " #$pr_number - FAILED - $pr_title" + fi +done < <(echo "$ALL_PRS" | jq -c '.[]') + +echo "" +echo " ${#FAILED_PRS[@]} PRs had failed cherry-picks out of $TOTAL_COUNT total." + +if [[ ${#FAILED_PRS[@]} -eq 0 ]]; then + echo "No failed backports found. All clean!" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Step 3: Gather backported PR numbers from staging PRs and staging branch +# --------------------------------------------------------------------------- +echo "" +echo "Step 3: Checking staging branch ($STAGING_BRANCH -> $TARGET_BRANCH) for backported commits ..." + +BACKPORTED_PR_NUMS=() + +# 3a: Get commits from staging PRs (open and merged) +STAGING_PRS=$(gh pr list --repo "$REPO" \ + --base "$TARGET_BRANCH" \ + --head "$STAGING_BRANCH" \ + --state all \ + --json number \ + --limit 50) + +STAGING_PR_COUNT=$(echo "$STAGING_PRS" | jq 'length') +echo " Found $STAGING_PR_COUNT staging PR(s)." + +for staging_pr in $(echo "$STAGING_PRS" | jq -r '.[].number'); do + echo " Fetching commits from staging PR #$staging_pr ..." + + # gh api paginates at 30 commits per page by default + COMMIT_MESSAGES=$(gh api "repos/$REPO/pulls/$staging_pr/commits" \ + --paginate \ + --jq '.[].commit.message' 2>/dev/null || true) + + # Extract PR numbers from commit messages: look for (#XXXX) pattern + while IFS= read -r pr_ref; do + if [[ -n "$pr_ref" ]]; then + BACKPORTED_PR_NUMS+=("$pr_ref") + fi + done < <(echo "$COMMIT_MESSAGES" | grep -oP '\(#\K[0-9]+(?=\))' | sort -u) +done + +# 3b: Also check commits on the staging branch directly (covers commits not yet in a PR) +echo " Checking branch commits via compare API ($TARGET_BRANCH...$STAGING_BRANCH) ..." +BRANCH_COMMITS=$(gh api "repos/$REPO/compare/${TARGET_BRANCH}...${STAGING_BRANCH}" \ + --jq '.commits[].commit.message' 2>/dev/null || true) + +if [[ -n "$BRANCH_COMMITS" ]]; then + while IFS= read -r pr_ref; do + if [[ -n "$pr_ref" ]]; then + BACKPORTED_PR_NUMS+=("$pr_ref") + fi + done < <(echo "$BRANCH_COMMITS" | grep -oP '\(#\K[0-9]+(?=\))' | sort -u) +fi + +# Deduplicate +BACKPORTED_PR_NUMS=($(printf '%s\n' "${BACKPORTED_PR_NUMS[@]}" | sort -u)) + +echo " Found ${#BACKPORTED_PR_NUMS[@]} unique PR references in staging commits." + +# --------------------------------------------------------------------------- +# Step 4: Cross-reference and produce report +# --------------------------------------------------------------------------- +echo "" +echo "==============================================" +echo " BACKPORT STATUS REPORT (since $SINCE)" +echo "==============================================" +echo "" + +RESOLVED=() +RESOLVED_TITLES=() +MISSING=() +MISSING_TITLES=() + +for i in "${!FAILED_PRS[@]}"; do + pr_num="${FAILED_PRS[$i]}" + pr_title="${FAILED_TITLES[$i]}" + + found=0 + for backported in "${BACKPORTED_PR_NUMS[@]}"; do + if [[ "$backported" == "$pr_num" ]]; then + found=1 + break + fi + done + + if [[ "$found" -eq 1 ]]; then + RESOLVED+=("$pr_num") + RESOLVED_TITLES+=("$pr_title") + else + MISSING+=("$pr_num") + MISSING_TITLES+=("$pr_title") + fi +done + +if [[ ${#RESOLVED[@]} -gt 0 ]]; then + echo "RESOLVED (${#RESOLVED[@]}):" + echo "---" + for i in "${!RESOLVED[@]}"; do + echo " ✅ #${RESOLVED[$i]} - ${RESOLVED_TITLES[$i]}" + echo " https://github.com/$REPO/pull/${RESOLVED[$i]}" + done + echo "" +fi + +if [[ ${#MISSING[@]} -gt 0 ]]; then + echo "MISSING (${#MISSING[@]}):" + echo "---" + for i in "${!MISSING[@]}"; do + echo " ❌ #${MISSING[$i]} - ${MISSING_TITLES[$i]}" + echo " https://github.com/$REPO/pull/${MISSING[$i]}" + done + echo "" +else + echo "🎉 All failed backports have been resolved!" + echo "" +fi + +echo "==============================================" +echo "Summary: ${#FAILED_PRS[@]} failed, ${#RESOLVED[@]} resolved, ${#MISSING[@]} missing" +echo "==============================================" diff --git a/spartan/environments/network-defaults.yml b/spartan/environments/network-defaults.yml index a4e5f06f9b13..d6763682e6ed 100644 --- a/spartan/environments/network-defaults.yml +++ b/spartan/environments/network-defaults.yml @@ -120,7 +120,7 @@ slasher: &slasher # Rounds after which an offense expires. SLASH_OFFENSE_EXPIRATION_ROUNDS: 4 # Maximum size of slashing payload. - SLASH_MAX_PAYLOAD_SIZE: 50 + SLASH_MAX_PAYLOAD_SIZE: 80 # Rounds to look back when executing slashes. SLASH_EXECUTE_ROUNDS_LOOK_BACK: 4 # Penalty for slashing validators of a valid pruned epoch. diff --git a/spartan/eth-devnet/values.yaml b/spartan/eth-devnet/values.yaml index a4263fa49ba5..1a9c213fb80b 100644 --- a/spartan/eth-devnet/values.yaml +++ b/spartan/eth-devnet/values.yaml @@ -3,7 +3,7 @@ fullnameOverride: "" images: reth: - image: ghcr.io/paradigmxyz/reth:v1.6.0 + image: ghcr.io/paradigmxyz/reth:v1.11.1 pullPolicy: IfNotPresent geth: image: ethereum/client-go:v1.15.5 diff --git a/spartan/metrics/grafana/dashboards/aztec_validators.json b/spartan/metrics/grafana/dashboards/aztec_validators.json index 61e21af920f0..bcbb3c659543 100644 --- a/spartan/metrics/grafana/dashboards/aztec_validators.json +++ b/spartan/metrics/grafana/dashboards/aztec_validators.json @@ -3141,6 +3141,268 @@ ], "title": "HA Cleanup Operations", "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 105 + }, + "id": 700, + "panels": [], + "title": "Attester Epoch Participation", + "type": "row" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "${data_source}" + }, + "description": "The current epoch number, which represents the total number of epochs elapsed since genesis.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 0, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 4, + "x": 0, + "y": 106 + }, + "id": 701, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.2.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "max(aztec_validator_current_epoch{k8s_namespace_name=\"$namespace\", service_instance_id=~\"$service_instance\"})", + "instant": true, + "legendFormat": "Current Epoch", + "range": false, + "refId": "A" + } + ], + "title": "Current Epoch", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "${data_source}" + }, + "description": "Cumulative number of epochs in which each attester successfully submitted at least one attestation.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Epochs attested", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "decimals": 0, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 20, + "x": 4, + "y": 106 + }, + "id": 702, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.2.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "aztec_validator_attested_epoch_count{k8s_namespace_name=\"$namespace\", service_instance_id=~\"$service_instance\"}", + "legendFormat": "{{k8s_pod_name}} / {{aztec_attester_address}}", + "range": true, + "refId": "A" + } + ], + "title": "Attested Epochs per Attester", + "type": "timeseries" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "${data_source}" + }, + "description": "Fraction of total epochs in which each attester successfully participated (attested epochs ÷ current epoch × 100%).", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 50 + }, + { + "color": "yellow", + "value": 75 + }, + { + "color": "green", + "value": 90 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 114 + }, + "id": 703, + "options": { + "displayMode": "gradient", + "minVizHeight": 10, + "minVizWidth": 0, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "valueMode": "color" + }, + "pluginVersion": "11.2.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${data_source}" + }, + "editorMode": "code", + "expr": "aztec_validator_attested_epoch_count{k8s_namespace_name=\"$namespace\", service_instance_id=~\"$service_instance\"} / on() group_left() max by() (aztec_validator_current_epoch{k8s_namespace_name=\"$namespace\", service_instance_id=~\"$service_instance\"}) * 100", + "instant": true, + "legendFormat": "{{k8s_pod_name}} / {{aztec_attester_address}}", + "range": false, + "refId": "A" + } + ], + "title": "Attester Participation Rate", + "type": "bargauge" } ], "refresh": "30s", @@ -3245,6 +3507,6 @@ "timezone": "", "title": "Validator node overview", "uid": "aztec-validators", - "version": 8, + "version": 9, "weekStart": "" } diff --git a/spartan/scripts/deploy_network.sh b/spartan/scripts/deploy_network.sh index 3f84610e781b..dc40910b6de2 100755 --- a/spartan/scripts/deploy_network.sh +++ b/spartan/scripts/deploy_network.sh @@ -107,6 +107,7 @@ PROVER_FAILED_PROOF_STORE=${PROVER_FAILED_PROOF_STORE:-} SEQ_MIN_TX_PER_BLOCK=${SEQ_MIN_TX_PER_BLOCK:-0} SEQ_MAX_TX_PER_BLOCK=${SEQ_MAX_TX_PER_BLOCK:-8} SEQ_BLOCK_DURATION_MS=${SEQ_BLOCK_DURATION_MS:-} +SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT=${SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT:-} SEQ_BUILD_CHECKPOINT_IF_EMPTY=${SEQ_BUILD_CHECKPOINT_IF_EMPTY:-} SEQ_ENFORCE_TIME_TABLE=${SEQ_ENFORCE_TIME_TABLE:-} SEQ_SKIP_CHECKPOINT_PUBLISH_PERCENT=${SEQ_SKIP_CHECKPOINT_PUBLISH_PERCENT:-0} @@ -531,6 +532,7 @@ VALIDATOR_HA_REPLICAS = ${VALIDATOR_HA_REPLICAS} SEQ_MIN_TX_PER_BLOCK = ${SEQ_MIN_TX_PER_BLOCK} SEQ_MAX_TX_PER_BLOCK = ${SEQ_MAX_TX_PER_BLOCK} SEQ_BLOCK_DURATION_MS = ${SEQ_BLOCK_DURATION_MS:-null} +SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT = ${SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT:-null} SEQ_BUILD_CHECKPOINT_IF_EMPTY = ${SEQ_BUILD_CHECKPOINT_IF_EMPTY:-null} SEQ_ENFORCE_TIME_TABLE = ${SEQ_ENFORCE_TIME_TABLE:-null} SEQ_SKIP_CHECKPOINT_PUBLISH_PERCENT = ${SEQ_SKIP_CHECKPOINT_PUBLISH_PERCENT} diff --git a/spartan/terraform/deploy-aztec-infra/main.tf b/spartan/terraform/deploy-aztec-infra/main.tf index bf07026f31d8..74021428bac8 100644 --- a/spartan/terraform/deploy-aztec-infra/main.tf +++ b/spartan/terraform/deploy-aztec-infra/main.tf @@ -213,6 +213,7 @@ locals { "validator.node.env.SEQ_MIN_TX_PER_BLOCK" = var.SEQ_MIN_TX_PER_BLOCK "validator.node.env.SEQ_MAX_TX_PER_BLOCK" = var.SEQ_MAX_TX_PER_BLOCK "validator.node.env.SEQ_BLOCK_DURATION_MS" = var.SEQ_BLOCK_DURATION_MS + "validator.node.env.SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT" = var.SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT "validator.node.env.SEQ_BUILD_CHECKPOINT_IF_EMPTY" = var.SEQ_BUILD_CHECKPOINT_IF_EMPTY "validator.node.env.SEQ_ENFORCE_TIME_TABLE" = var.SEQ_ENFORCE_TIME_TABLE "validator.node.env.P2P_TX_POOL_DELETE_TXS_AFTER_REORG" = var.P2P_TX_POOL_DELETE_TXS_AFTER_REORG diff --git a/spartan/terraform/deploy-aztec-infra/variables.tf b/spartan/terraform/deploy-aztec-infra/variables.tf index fadca17716b5..2a0c9a8398b9 100644 --- a/spartan/terraform/deploy-aztec-infra/variables.tf +++ b/spartan/terraform/deploy-aztec-infra/variables.tf @@ -369,6 +369,13 @@ variable "SEQ_BLOCK_DURATION_MS" { default = null } +variable "SEQ_L1_PUBLISHING_TIME_ALLOWANCE_IN_SLOT" { + description = "Time allocated for publishing to L1, in seconds" + type = string + nullable = true + default = null +} + variable "SEQ_BUILD_CHECKPOINT_IF_EMPTY" { description = "Have sequencer build and publish an empty checkpoint if there are no txs" type = string diff --git a/spartan/terraform/deploy-eth-devnet/values/eth-devnet.yaml b/spartan/terraform/deploy-eth-devnet/values/eth-devnet.yaml index 92cde8c823d6..a57c105553f9 100644 --- a/spartan/terraform/deploy-eth-devnet/values/eth-devnet.yaml +++ b/spartan/terraform/deploy-eth-devnet/values/eth-devnet.yaml @@ -3,7 +3,7 @@ fullnameOverride: "" images: reth: - image: ghcr.io/paradigmxyz/reth:v1.6.0 + image: ghcr.io/paradigmxyz/reth:v1.11.1 pullPolicy: IfNotPresent geth: image: ethereum/client-go:v1.15.5 diff --git a/spartan/terraform/deploy-ethereum-nodes/variables.tf b/spartan/terraform/deploy-ethereum-nodes/variables.tf index 60314f96000f..89f563f6a850 100644 --- a/spartan/terraform/deploy-ethereum-nodes/variables.tf +++ b/spartan/terraform/deploy-ethereum-nodes/variables.tf @@ -48,7 +48,7 @@ variable "lighthouse_p2p_port" { variable "reth_image" { description = "Reth Docker image" type = string - default = "ghcr.io/paradigmxyz/reth:v1.9.3" + default = "ghcr.io/paradigmxyz/reth:v1.11.1" } variable "reth_chart_version" { diff --git a/yarn-project/archiver/src/archiver-store.test.ts b/yarn-project/archiver/src/archiver-store.test.ts index d5cd45f3a3fa..950b90008ea8 100644 --- a/yarn-project/archiver/src/archiver-store.test.ts +++ b/yarn-project/archiver/src/archiver-store.test.ts @@ -432,4 +432,116 @@ describe('Archiver Store', () => { expect(result).toEqual([]); }); }); + + describe('rollbackTo', () => { + beforeEach(() => { + publicClient.getBlock.mockImplementation( + (args: { blockNumber?: bigint } = {}) => + Promise.resolve({ number: args.blockNumber ?? 0n, hash: `0x${'0'.repeat(64)}` }) as any, + ); + }); + + it('rejects rollback to a block that is not at a checkpoint boundary', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + // Checkpoint 1: 3 blocks (1, 2, 3). Checkpoint 2: 3 blocks (4, 5, 6). + const testCheckpoints = await makeChainedCheckpoints(2, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 3, + }); + await archiverStore.addCheckpoints(testCheckpoints); + + // Block 1 is not at a checkpoint boundary (checkpoint 1 ends at block 3) + await expect(archiver.rollbackTo(BlockNumber(1))).rejects.toThrow( + /not at a checkpoint boundary.*Use block 3 to roll back to this checkpoint.*or block 0 to roll back to the previous one/, + ); + + // Block 2 is also not at a checkpoint boundary + await expect(archiver.rollbackTo(BlockNumber(2))).rejects.toThrow( + /not at a checkpoint boundary.*Use block 3 to roll back to this checkpoint.*or block 0 to roll back to the previous one/, + ); + }); + + it('allows rollback to the last block of a checkpoint and updates sync points', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + // Checkpoint 1: 3 blocks (1, 2, 3), L1 block 10. Checkpoint 2: 3 blocks (4, 5, 6), L1 block 20. + const testCheckpoints = await makeChainedCheckpoints(2, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 3, + }); + await archiverStore.addCheckpoints(testCheckpoints); + + // Block 3 is the last block of checkpoint 1 — should succeed + await archiver.rollbackTo(BlockNumber(3)); + + expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + + // Verify sync points are set to checkpoint 1's L1 block number (10) + const synchPoint = await archiverStore.getSynchPoint(); + expect(synchPoint.blocksSynchedTo).toEqual(10n); + expect(synchPoint.messagesSynchedTo?.l1BlockNumber).toEqual(10n); + }); + + it('includes correct boundary info in error for mid-checkpoint rollback', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + // Checkpoint 1: 2 blocks (1, 2). Checkpoint 2: 3 blocks (3, 4, 5). + const checkpoints1 = await makeChainedCheckpoints(1, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 2, + }); + const checkpoints2 = await makeChainedCheckpoints(1, { + previousArchive: checkpoints1[0].checkpoint.blocks.at(-1)!.archive, + startCheckpointNumber: CheckpointNumber(2), + startBlockNumber: 3, + startL1BlockNumber: 20, + blocksPerCheckpoint: 3, + }); + await archiverStore.addCheckpoints([...checkpoints1, ...checkpoints2]); + + // Block 3 is the first of checkpoint 2 (spans 3-5) + // Should suggest block 5 (end of this checkpoint) or block 2 (end of previous) + await expect(archiver.rollbackTo(BlockNumber(3))).rejects.toThrow( + /Checkpoint 2 spans blocks 3 to 5.*Use block 5 to roll back to this checkpoint.*or block 2 to roll back to the previous one/, + ); + }); + + it('rolls back proven checkpoint number when target is before proven block', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + // Checkpoint 1: blocks 1-2, Checkpoint 2: blocks 3-4, Checkpoint 3: blocks 5-6 + const testCheckpoints = await makeChainedCheckpoints(3, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 2, + }); + await archiverStore.addCheckpoints(testCheckpoints); + + // Mark checkpoint 2 as proven + await archiverStore.setProvenCheckpointNumber(CheckpointNumber(2)); + expect(await archiver.getProvenCheckpointNumber()).toEqual(CheckpointNumber(2)); + + // Roll back to block 2 (end of checkpoint 1), which is before proven block 4 + await archiver.rollbackTo(BlockNumber(2)); + + expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getProvenCheckpointNumber()).toEqual(CheckpointNumber(1)); + }); + + it('preserves proven checkpoint number when target is after proven block', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(new Fr(GENESIS_ARCHIVE_ROOT), 1); + // Checkpoint 1: blocks 1-2, Checkpoint 2: blocks 3-4, Checkpoint 3: blocks 5-6 + const testCheckpoints = await makeChainedCheckpoints(3, { + previousArchive: genesisArchive, + blocksPerCheckpoint: 2, + }); + await archiverStore.addCheckpoints(testCheckpoints); + + // Mark checkpoint 1 as proven + await archiverStore.setProvenCheckpointNumber(CheckpointNumber(1)); + expect(await archiver.getProvenCheckpointNumber()).toEqual(CheckpointNumber(1)); + + // Roll back to block 4 (end of checkpoint 2), which is after proven block 2 + await archiver.rollbackTo(BlockNumber(4)); + + expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(2)); + expect(await archiver.getProvenCheckpointNumber()).toEqual(CheckpointNumber(1)); + }); + }); }); diff --git a/yarn-project/archiver/src/archiver.ts b/yarn-project/archiver/src/archiver.ts index 4aec6f3c9e69..de82a0482186 100644 --- a/yarn-project/archiver/src/archiver.ts +++ b/yarn-project/archiver/src/archiver.ts @@ -399,7 +399,6 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra } public async rollbackTo(targetL2BlockNumber: BlockNumber): Promise { - // TODO(pw/mbps): This still assumes 1 block per checkpoint const currentBlocks = await this.getL2Tips(); const currentL2Block = currentBlocks.proposed.number; const currentProvenBlock = currentBlocks.proven.block.number; @@ -411,8 +410,25 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra if (!targetL2Block) { throw new Error(`Target L2 block ${targetL2BlockNumber} not found`); } - const targetL1BlockNumber = targetL2Block.l1.blockNumber; const targetCheckpointNumber = targetL2Block.checkpointNumber; + + // Rollback operates at checkpoint granularity: the target block must be the last block of its checkpoint. + const checkpointData = await this.store.getCheckpointData(targetCheckpointNumber); + if (checkpointData) { + const lastBlockInCheckpoint = BlockNumber(checkpointData.startBlock + checkpointData.blockCount - 1); + if (targetL2BlockNumber !== lastBlockInCheckpoint) { + const previousCheckpointBoundary = + checkpointData.startBlock > 1 ? BlockNumber(checkpointData.startBlock - 1) : BlockNumber(0); + throw new Error( + `Target L2 block ${targetL2BlockNumber} is not at a checkpoint boundary. ` + + `Checkpoint ${targetCheckpointNumber} spans blocks ${checkpointData.startBlock} to ${lastBlockInCheckpoint}. ` + + `Use block ${lastBlockInCheckpoint} to roll back to this checkpoint, ` + + `or block ${previousCheckpointBoundary} to roll back to the previous one.`, + ); + } + } + + const targetL1BlockNumber = targetL2Block.l1.blockNumber; const targetL1Block = await this.publicClient.getBlock({ blockNumber: targetL1BlockNumber, includeTransactions: false, @@ -431,13 +447,14 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra await this.store.setCheckpointSynchedL1BlockNumber(targetL1BlockNumber); await this.store.setMessageSynchedL1Block({ l1BlockNumber: targetL1BlockNumber, l1BlockHash: targetL1BlockHash }); if (targetL2BlockNumber < currentProvenBlock) { - this.log.info(`Clearing proven L2 block number`); - await this.updater.setProvenCheckpointNumber(CheckpointNumber.ZERO); + this.log.info(`Rolling back proven L2 checkpoint to ${targetCheckpointNumber}`); + await this.updater.setProvenCheckpointNumber(targetCheckpointNumber); } // TODO(palla/reorg): Set the finalized block when we add support for it. + // const currentFinalizedBlock = currentBlocks.finalized.block.number; // if (targetL2BlockNumber < currentFinalizedBlock) { - // this.log.info(`Clearing finalized L2 block number`); - // await this.store.setFinalizedL2BlockNumber(0); + // this.log.info(`Rolling back finalized L2 checkpoint to ${targetCheckpointNumber}`); + // await this.updater.setFinalizedCheckpointNumber(targetCheckpointNumber); // } } } diff --git a/yarn-project/archiver/src/modules/validation.test.ts b/yarn-project/archiver/src/modules/validation.test.ts index aa11589bb5d5..0bfeb50f1566 100644 --- a/yarn-project/archiver/src/modules/validation.test.ts +++ b/yarn-project/archiver/src/modules/validation.test.ts @@ -2,7 +2,7 @@ import type { EpochCache } from '@aztec/epoch-cache'; import { CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { times } from '@aztec/foundation/collection'; -import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; +import { Secp256k1Signer, flipSignature } from '@aztec/foundation/crypto/secp256k1-signer'; import { Signature } from '@aztec/foundation/eth-signature'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { CommitteeAttestation, EthAddress } from '@aztec/stdlib/block'; @@ -153,6 +153,27 @@ describe('validateCheckpointAttestations', () => { expect(result.invalidIndex).toBe(0); }); + it('fails if an attestation signature has a high-s value (malleable signature)', async () => { + const checkpoint = await makeCheckpoint(signers.slice(0, 4), committee); + + // Flip the signature at index 2 to give it a high-s value + const original = checkpoint.attestations[2]; + const flipped = flipSignature(original.signature); + checkpoint.attestations[2] = new CommitteeAttestation(original.address, flipped); + + // Verify the flipped signature is detected as invalid + const attestations = getAttestationInfoFromPublishedCheckpoint(checkpoint); + expect(attestations[2].status).toBe('invalid-signature'); + + const result = await validateCheckpointAttestations(checkpoint, epochCache, constants, logger); + assert(!result.valid); + assert(result.reason === 'invalid-attestation'); + expect(result.checkpoint.checkpointNumber).toEqual(checkpoint.checkpoint.number); + expect(result.checkpoint.archive.toString()).toEqual(checkpoint.checkpoint.archive.root.toString()); + expect(result.committee).toEqual(committee); + expect(result.invalidIndex).toBe(2); + }); + it('reports correct index when invalid attestation follows provided address', async () => { const checkpoint = await makeCheckpoint(signers.slice(0, 3), committee); diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 907a6a2c5e2c..5d8bc7bd69d8 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -342,9 +342,6 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { deps.p2pClientDeps, ); - // We should really not be modifying the config object - config.txPublicSetupAllowList = config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); - // We'll accumulate sentinel watchers here const watchers: Watcher[] = []; @@ -618,7 +615,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { } public async getAllowedPublicSetup(): Promise { - return this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); + return [...(await getDefaultAllowedSetupFunctions()), ...(this.config.txPublicSetupAllowListExtend ?? [])]; } /** @@ -1318,7 +1315,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { blockNumber, l1ChainId: this.l1ChainId, rollupVersion: this.version, - setupAllowList: this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()), + setupAllowList: [ + ...(await getDefaultAllowedSetupFunctions()), + ...(this.config.txPublicSetupAllowListExtend ?? []), + ], gasFees: await this.getCurrentMinFees(), skipFeeEnforcement, txsPermitted: !this.config.disableTransactions, diff --git a/yarn-project/aztec/src/cli/cmds/start_node.ts b/yarn-project/aztec/src/cli/cmds/start_node.ts index abed355ce4f6..a034cf3a6f5a 100644 --- a/yarn-project/aztec/src/cli/cmds/start_node.ts +++ b/yarn-project/aztec/src/cli/cmds/start_node.ts @@ -4,10 +4,14 @@ import { Fr } from '@aztec/aztec.js/fields'; import { getSponsoredFPCAddress } from '@aztec/cli/cli-utils'; import { getL1Config } from '@aztec/cli/config'; import { getPublicClient } from '@aztec/ethereum/client'; +import { RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; import { SecretValue } from '@aztec/foundation/config'; +import { EthAddress } from '@aztec/foundation/eth-address'; import type { NamespacedApiHandlers } from '@aztec/foundation/json-rpc/server'; +import { startHttpRpcServer } from '@aztec/foundation/json-rpc/server'; import { Agent, makeUndiciFetch } from '@aztec/foundation/json-rpc/undici'; import type { LogFn } from '@aztec/foundation/log'; +import { sleep } from '@aztec/foundation/sleep'; import { ProvingJobConsumerSchema, createProvingJobBrokerClient } from '@aztec/prover-client/broker'; import { type CliPXEOptions, type PXEConfig, allPxeConfigMappings } from '@aztec/pxe/config'; import { AztecNodeAdminApiSchema, AztecNodeApiSchema } from '@aztec/stdlib/interfaces/client'; @@ -21,6 +25,8 @@ import { import { EmbeddedWallet } from '@aztec/wallets/embedded'; import { getGenesisValues } from '@aztec/world-state/testing'; +import Koa from 'koa'; + import { createAztecNode } from '../../local-network/index.js'; import { extractNamespacedOptions, @@ -31,6 +37,72 @@ import { import { getVersions } from '../versioning.js'; import { startProverBroker } from './start_prover_broker.js'; +const ROLLUP_POLL_INTERVAL_MS = 600_000; + +/** + * Waits until the canonical rollup's genesis archive root matches the expected local genesis root. + * If the rollup is not yet compatible (e.g. during L1 contract upgrades), enters standby mode: + * starts a lightweight HTTP server for K8s liveness probes and polls until a compatible rollup appears. + */ +async function waitForCompatibleRollup( + publicClient: ReturnType, + registryAddress: EthAddress, + rollupVersion: number | 'canonical', + expectedGenesisRoot: Fr, + port: number | undefined, + userLog: LogFn, +): Promise { + const registry = new RegistryContract(publicClient, registryAddress); + const rollupAddress = await registry.getRollupAddress(rollupVersion); + const rollup = new RollupContract(publicClient, rollupAddress.toString()); + + let l1GenesisRoot: Fr; + try { + l1GenesisRoot = await rollup.getGenesisArchiveTreeRoot(); + } catch (err: any) { + throw new Error( + `Could not retrieve genesis archive root from canonical rollup at ${rollupAddress}: ${err.message}`, + ); + } + + if (l1GenesisRoot.equals(expectedGenesisRoot)) { + return; + } + + userLog( + `Genesis root mismatch: expected ${expectedGenesisRoot}, got ${l1GenesisRoot} from rollup at ${rollupAddress}. ` + + `Entering standby mode. Will poll every ${ROLLUP_POLL_INTERVAL_MS / 1000}s for a compatible rollup...`, + ); + + const standbyServer = await startHttpRpcServer({ getApp: () => new Koa(), isHealthy: () => true }, { port }); + userLog(`Standby status server listening on port ${standbyServer.port}`); + + try { + while (true) { + await sleep(ROLLUP_POLL_INTERVAL_MS); + + const currentRollupAddress = await registry.getRollupAddress(rollupVersion); + const currentRollup = new RollupContract(publicClient, currentRollupAddress.toString()); + + try { + l1GenesisRoot = await currentRollup.getGenesisArchiveTreeRoot(); + } catch { + userLog(`Failed to fetch genesis root from rollup at ${currentRollupAddress}. Retrying...`); + continue; + } + + if (l1GenesisRoot.equals(expectedGenesisRoot)) { + userLog(`Compatible rollup found at ${currentRollupAddress}. Exiting standby mode.`); + return; + } + + userLog(`Still waiting. Rollup at ${currentRollupAddress} has genesis root ${l1GenesisRoot}.`); + } + } finally { + await new Promise((resolve, reject) => standbyServer.close(err => (err ? reject(err) : resolve()))); + } +} + export async function startNode( options: any, signalHandlers: (() => Promise)[], @@ -96,6 +168,20 @@ export async function startNode( if (!nodeConfig.l1Contracts.registryAddress || nodeConfig.l1Contracts.registryAddress.isZero()) { throw new Error('L1 registry address is required to start Aztec Node'); } + + // Wait for a compatible rollup before proceeding with full L1 config fetch. + // This prevents crashes when the canonical rollup hasn't been upgraded yet. + const publicClient = getPublicClient(nodeConfig); + const rollupVersion: number | 'canonical' = nodeConfig.rollupVersion ?? 'canonical'; + await waitForCompatibleRollup( + publicClient, + nodeConfig.l1Contracts.registryAddress, + rollupVersion, + genesisArchiveRoot, + options.port, + userLog, + ); + const { addresses, config } = await getL1Config( nodeConfig.l1Contracts.registryAddress, nodeConfig.l1RpcUrls, diff --git a/yarn-project/bot/src/factory.ts b/yarn-project/bot/src/factory.ts index 31b05f540b8e..970dd845ec69 100644 --- a/yarn-project/bot/src/factory.ts +++ b/yarn-project/bot/src/factory.ts @@ -49,7 +49,11 @@ export class BotFactory { private readonly store: BotStore, private readonly aztecNode: AztecNode, private readonly aztecNodeAdmin?: AztecNodeAdmin, - ) {} + ) { + // Set fee padding on the wallet so that all transactions during setup + // (token deploy, minting, etc.) use the configured padding, not the default. + this.wallet.setMinFeePadding(config.minFeePadding); + } /** * Initializes a new bot by setting up the sender account, registering the recipient, diff --git a/yarn-project/cli/src/config/network_config.ts b/yarn-project/cli/src/config/network_config.ts index 820f5f1b5da5..a8a9c59b8757 100644 --- a/yarn-project/cli/src/config/network_config.ts +++ b/yarn-project/cli/src/config/network_config.ts @@ -144,4 +144,7 @@ export async function enrichEnvironmentWithNetworkConfig(networkName: NetworkNam if (networkConfig.blockDurationMs !== undefined) { enrichVar('SEQ_BLOCK_DURATION_MS', String(networkConfig.blockDurationMs)); } + if (networkConfig.txPublicSetupAllowListExtend) { + enrichVar('TX_PUBLIC_SETUP_ALLOWLIST', networkConfig.txPublicSetupAllowListExtend); + } } diff --git a/yarn-project/end-to-end/src/e2e_bot.test.ts b/yarn-project/end-to-end/src/e2e_bot.test.ts index 00c5b06e16c3..78a1f6affff9 100644 --- a/yarn-project/end-to-end/src/e2e_bot.test.ts +++ b/yarn-project/end-to-end/src/e2e_bot.test.ts @@ -134,8 +134,9 @@ describe('e2e_bot', () => { // TODO: this should be taken from the `setup` call above l1Mnemonic: new SecretValue('test test test test test test test test test test test junk'), flushSetupTransactions: true, - // Increase fee headroom to handle fee volatility from rapid block building in tests - minFeePadding: 9, + // Increase fee headroom to handle fee volatility from rapid block building in tests. + // Fees can escalate >10x due to blocks built by earlier tests and bridge operations. + minFeePadding: 99, }; { @@ -174,8 +175,10 @@ describe('e2e_bot', () => { // TODO: this should be taken from the `setup` call above l1Mnemonic: new SecretValue('test test test test test test test test test test test junk'), flushSetupTransactions: true, - // Increase fee headroom to handle fee volatility from rapid block building in tests - minFeePadding: 9, + // Increase fee headroom to handle fee volatility from rapid block building in tests. + // This test is especially susceptible because changing salt triggers a new bridge claim, + // adding more block building on top of what earlier tests already produced. + minFeePadding: 99, }; { diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts index 80c2fec76ff0..e4740dde721d 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts @@ -25,7 +25,6 @@ import { privateKeyToAccount } from 'viem/accounts'; import { getAnvilPort } from '../fixtures/fixtures.js'; import { type EndToEndContext, getPrivateKeyFromIndex } from '../fixtures/utils.js'; -import { proveInteraction } from '../test-wallet/utils.js'; import { EpochsTestContext } from './epochs_test.js'; jest.setTimeout(1000 * 60 * 10); @@ -109,6 +108,120 @@ describe('e2e_epochs/epochs_invalidate_block', () => { await test.teardown(); }); + /** + * Configures all sequencers with an attack config, enables the attack for a single checkpoint, + * disables it after the first checkpoint is mined (also stopping block production), and waits + * for the checkpoint to be invalidated. Verifies the chain rolled back to the initial state. + */ + async function runInvalidationTest(opts: { + attackConfig: Record; + disableConfig: Record; + }) { + const sequencers = nodes.map(node => node.getSequencer()!); + const initialCheckpointNumber = (await nodes[0].getL2Tips()).checkpointed.checkpoint.number; + + sequencers.forEach(sequencer => { + sequencer.updateConfig({ ...opts.attackConfig, minTxsPerBlock: 0 }); + }); + + // Disable the attack after the first checkpoint is mined and prevent further block production + test.monitor.once('checkpoint', ({ checkpointNumber }) => { + logger.warn(`Disabling attack after checkpoint ${checkpointNumber} has been mined`); + sequencers.forEach(sequencer => { + sequencer.updateConfig({ ...opts.disableConfig, minTxsPerBlock: 100 }); + }); + }); + + await Promise.all(sequencers.map(s => s.start())); + + // Wait for the CheckpointInvalidated event + const checkpointInvalidatedFilter = await l1Client.createContractEventFilter({ + address: rollupContract.address, + abi: RollupAbi, + eventName: 'CheckpointInvalidated', + fromBlock: 1n, + toBlock: 'latest', + }); + + const checkpointInvalidatedEvents = await retryUntil( + async () => { + const events = await l1Client.getFilterLogs({ filter: checkpointInvalidatedFilter }); + return events.length > 0 ? events : undefined; + }, + 'CheckpointInvalidated event', + test.L2_SLOT_DURATION_IN_S * 5, + 0.1, + ); + + // Verify the checkpoint was invalidated and the chain rolled back + const [event] = checkpointInvalidatedEvents; + logger.warn(`CheckpointInvalidated event emitted`, { event }); + expect(event.args.checkpointNumber).toBeGreaterThan(initialCheckpointNumber); + expect(await test.rollup.getCheckpointNumber()).toEqual(initialCheckpointNumber); + + logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); + } + + /** + * Configures all sequencers with an attack config, starts them, waits for two consecutive + * invalidations of the same checkpoint (confirming the invalid-then-re-invalidated pattern), + * disables the attack, and verifies the chain progresses and all nodes sync. + */ + async function runDoubleInvalidationTest(opts: { + attackConfig: Record; + disableConfig: Record; + }) { + const sequencers = nodes.map(node => node.getSequencer()!); + sequencers.forEach(sequencer => { + sequencer.updateConfig({ ...opts.attackConfig, minTxsPerBlock: 0 }); + }); + + await Promise.all(sequencers.map(s => s.start())); + + // Wait until we see two invalidations, both should be for the same checkpoint + let lastInvalidatedCheckpointNumber: CheckpointNumber | undefined; + const invalidatePromise = promiseWithResolvers(); + const unsubscribe = rollupContract.listenToCheckpointInvalidated(data => { + logger.warn(`Checkpoint ${data.checkpointNumber} has been invalidated`, data); + if (lastInvalidatedCheckpointNumber === undefined) { + lastInvalidatedCheckpointNumber = data.checkpointNumber; + } else { + expect(data.checkpointNumber).toEqual(lastInvalidatedCheckpointNumber); + invalidatePromise.resolve(); + unsubscribe(); + } + }); + await Promise.race([ + timeoutPromise(1000 * test.L2_SLOT_DURATION_IN_S * 8, 'Waiting for two checkpoint invalidations'), + invalidatePromise.promise, + ]); + + sequencers.forEach(sequencer => { + sequencer.updateConfig(opts.disableConfig); + }); + + // Ensure chain progresses + const targetCheckpointNumber = CheckpointNumber(lastInvalidatedCheckpointNumber! + 2); + logger.warn(`Waiting until checkpoint ${targetCheckpointNumber} has been mined`); + await test.monitor.waitUntilCheckpoint(targetCheckpointNumber); + + // Wait for all nodes to sync + const targetBlock = targetCheckpointNumber; + logger.warn(`Waiting for all nodes to sync to block ${targetBlock}`); + await retryUntil( + async () => { + const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); + logger.info(`Node synced block numbers: ${blockNumbers.join(', ')}`); + return blockNumbers.every(bn => bn > targetBlock); + }, + 'Node sync check', + test.L2_SLOT_DURATION_IN_S * 5, + 0.5, + ); + + logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); + } + it('proposer invalidates previous checkpoint with multiple blocks while posting its own', async () => { const sequencers = nodes.map(node => node.getSequencer()!); const [initialCheckpointNumber, initialBlockNumber] = await nodes[0] @@ -213,123 +326,46 @@ describe('e2e_epochs/epochs_invalidate_block', () => { // Slot S+1: Checkpoint N is invalidated, and checkpoint N' (same number) is proposed instead, but also has invalid attestations // Slot S+2: Proposer tries to invalidate checkpoint N, when they should invalidate checkpoint N' instead, and fails it('chain progresses if a checkpoint with insufficient attestations is invalidated with an invalid one', async () => { - // Configure all sequencers to skip collecting attestations before starting and always build blocks - logger.warn('Configuring all sequencers to skip attestation collection'); - const sequencers = nodes.map(node => node.getSequencer()!); - sequencers.forEach(sequencer => { - sequencer.updateConfig({ skipCollectingAttestations: true, minTxsPerBlock: 0 }); + await runDoubleInvalidationTest({ + attackConfig: { skipCollectingAttestations: true }, + disableConfig: { skipCollectingAttestations: false }, }); - - // Start all sequencers - await Promise.all(sequencers.map(s => s.start())); - logger.warn(`Started all sequencers with skipCollectingAttestations=true`); - - // Wait until we see two invalidations, both should be for the same checkpoint - let lastInvalidatedCheckpointNumber: CheckpointNumber | undefined; - const invalidatePromise = promiseWithResolvers(); - const unsubscribe = rollupContract.listenToCheckpointInvalidated(data => { - logger.warn(`Checkpoint ${data.checkpointNumber} has been invalidated`, data); - if (lastInvalidatedCheckpointNumber === undefined) { - lastInvalidatedCheckpointNumber = data.checkpointNumber; - } else { - expect(data.checkpointNumber).toEqual(lastInvalidatedCheckpointNumber); - invalidatePromise.resolve(); - unsubscribe(); - } - }); - await Promise.race([timeoutPromise(1000 * test.L2_SLOT_DURATION_IN_S * 8), invalidatePromise.promise]); - - // Disable skipCollectingAttestations and send txs so MBPS can produce multi-block checkpoints - sequencers.forEach(sequencer => { - sequencer.updateConfig({ skipCollectingAttestations: false }); - }); - logger.warn('Sending transactions to enable multi-block checkpoints'); - const from = context.accounts[0]; - for (let i = 0; i < 4; i++) { - const tx = await proveInteraction(context.wallet, testContract.methods.emit_nullifier(new Fr(100 + i)), { from }); - await tx.send({ wait: NO_WAIT }); - } - - // Ensure chain progresses - const targetCheckpointNumber = CheckpointNumber(lastInvalidatedCheckpointNumber! + 2); - logger.warn(`Waiting until checkpoint ${targetCheckpointNumber} has been mined`); - await test.monitor.waitUntilCheckpoint(targetCheckpointNumber); - - // Wait for all nodes to sync the new block - const targetBlock = targetCheckpointNumber; - logger.warn(`Waiting for all nodes to sync to block ${targetBlock}`); - await retryUntil( - async () => { - const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); - logger.info(`Node synced block numbers: ${blockNumbers.join(', ')}`); - return blockNumbers.every(bn => bn > targetBlock); - }, - 'Node sync check', - test.L2_SLOT_DURATION_IN_S * 5, - 0.5, - ); - - await test.assertMultipleBlocksPerSlot(2); - - logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); }); // Regression for Joe's Q42025 London attack. Same as above but with an invalid signature instead of insufficient ones. it('chain progresses if a checkpoint with an invalid attestation is invalidated with an invalid one', async () => { - // Configure all sequencers to skip collecting attestations before starting and always build blocks - logger.warn('Configuring all sequencers to inject one invalid attestation'); - const sequencers = nodes.map(node => node.getSequencer()!); - sequencers.forEach(sequencer => { - sequencer.updateConfig({ injectFakeAttestation: true, minTxsPerBlock: 0 }); + await runDoubleInvalidationTest({ + attackConfig: { injectFakeAttestation: true }, + disableConfig: { injectFakeAttestation: false }, }); + }); - // Start all sequencers - await Promise.all(sequencers.map(s => s.start())); - logger.warn(`Started all sequencers with injectFakeAttestation=true`); - - // Wait until we see two invalidations, both should be for the same checkpoint - let lastInvalidatedCheckpointNumber: CheckpointNumber | undefined; - const invalidatePromise = promiseWithResolvers(); - const unsubscribe = rollupContract.listenToCheckpointInvalidated(data => { - logger.warn(`Checkpoint ${data.checkpointNumber} has been invalidated`, data); - if (lastInvalidatedCheckpointNumber === undefined) { - lastInvalidatedCheckpointNumber = data.checkpointNumber; - } else { - expect(data.checkpointNumber).toEqual(lastInvalidatedCheckpointNumber); - invalidatePromise.resolve(); - unsubscribe(); - } + // Regression for A-71: Ensure the node correctly invalidates checkpoints where an attestation has a malleable + // signature (high-s value). The Rollup contract uses OpenZeppelin's ECDSA recover which rejects high-s values + // per EIP-2, so these signatures recover to address(0) on L1 but may succeed offchain. + it('proposer invalidates checkpoint with high-s value attestation', async () => { + await runInvalidationTest({ + attackConfig: { injectHighSValueAttestation: true }, + disableConfig: { injectHighSValueAttestation: false }, }); - await Promise.race([ - timeoutPromise(1000 * test.L2_SLOT_DURATION_IN_S * 8, 'Invalidating checkpoints'), - invalidatePromise.promise, - ]); + }); - // Disable injectFakeAttestations - sequencers.forEach(sequencer => { - sequencer.updateConfig({ injectFakeAttestation: false }); + // Regression for A-71: Ensure the node correctly invalidates checkpoints where an attestation's signature + // cannot be recovered (e.g. r=0). On L1, ecrecover returns address(0) for such signatures. + it('proposer invalidates checkpoint with unrecoverable signature attestation', async () => { + await runInvalidationTest({ + attackConfig: { injectUnrecoverableSignatureAttestation: true }, + disableConfig: { injectUnrecoverableSignatureAttestation: false }, }); + }); - // Ensure chain progresses - const targetCheckpointNumber = CheckpointNumber(lastInvalidatedCheckpointNumber! + 2); - logger.warn(`Waiting until checkpoint ${targetCheckpointNumber} has been mined`); - await test.monitor.waitUntilCheckpoint(targetCheckpointNumber); - - // Wait for all nodes to sync the new block - const targetBlock = targetCheckpointNumber; - logger.warn(`Waiting for all nodes to sync to block ${targetBlock}`); - await retryUntil( - async () => { - const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); - logger.info(`Node synced block numbers: ${blockNumbers.join(', ')}`); - return blockNumbers.every(bn => bn > targetBlock); - }, - 'Node sync check', - test.L2_SLOT_DURATION_IN_S * 5, - 0.5, - ); - - logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); + // Regression for the node accepting attestations that did not conform to the committee order, + // but L1 requires the same ordering. See #18219. + it('proposer invalidates previous block with shuffled attestations', async () => { + await runInvalidationTest({ + attackConfig: { shuffleAttestationOrdering: true }, + disableConfig: { shuffleAttestationOrdering: false }, + }); }); // Here we disable invalidation checks from two of the proposers. Our goal is to get two invalid checkpoints @@ -441,116 +477,6 @@ describe('e2e_epochs/epochs_invalidate_block', () => { logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); }); - it('proposer invalidates previous checkpoint without publishing its own', async () => { - const sequencers = nodes.map(node => node.getSequencer()!); - const initialCheckpointNumber = (await nodes[0].getL2Tips()).checkpointed.checkpoint.number; - - // Configure all sequencers to skip collecting attestations before starting - logger.warn('Configuring all sequencers to skip attestation collection and always publish blocks'); - sequencers.forEach(sequencer => { - sequencer.updateConfig({ skipCollectingAttestations: true, minTxsPerBlock: 0 }); - }); - - // Disable skipCollectingAttestations after the first block is mined and prevent sequencers from publishing any more blocks - test.monitor.once('checkpoint', ({ checkpointNumber }) => { - logger.warn(`Disabling skipCollectingAttestations after L2 block ${checkpointNumber} has been mined`); - sequencers.forEach(sequencer => { - sequencer.updateConfig({ skipCollectingAttestations: false, minTxsPerBlock: 100 }); - }); - }); - - // Start all sequencers - await Promise.all(sequencers.map(s => s.start())); - logger.warn(`Started all sequencers with skipCollectingAttestations=true`); - - // Create a filter for CheckpointInvalidated events - const checkpointInvalidatedFilter = await l1Client.createContractEventFilter({ - address: rollupContract.address, - abi: RollupAbi, - eventName: 'CheckpointInvalidated', - fromBlock: 1n, - toBlock: 'latest', - }); - - // The next proposer should invalidate the previous checkpoint - logger.warn('Waiting for next proposer to invalidate the previous checkpoint'); - - // Wait for the CheckpointInvalidated event - const checkpointInvalidatedEvents = await retryUntil( - async () => { - const events = await l1Client.getFilterLogs({ filter: checkpointInvalidatedFilter }); - return events.length > 0 ? events : undefined; - }, - 'CheckpointInvalidated event', - test.L2_SLOT_DURATION_IN_S * 5, - 0.1, - ); - - // Verify the CheckpointInvalidated event was emitted and that the block was removed - const [event] = checkpointInvalidatedEvents; - logger.warn(`CheckpointInvalidated event emitted`, { event }); - expect(event.args.checkpointNumber).toBeGreaterThan(initialCheckpointNumber); - expect(await test.rollup.getCheckpointNumber()).toEqual(initialCheckpointNumber); - - logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); - }); - - // Same as test above but with shuffled attestations instead of missing attestations - // REFACTOR: Remove code duplication with above test (and others?) - it('proposer invalidates previous block with shuffled attestations', async () => { - const sequencers = nodes.map(node => node.getSequencer()!); - const initialCheckpointNumber = (await nodes[0].getL2Tips()).checkpointed.checkpoint.number; - - // Configure all sequencers to shuffle attestations before starting - logger.warn('Configuring all sequencers to shuffle attestations and always publish blocks'); - sequencers.forEach(sequencer => { - sequencer.updateConfig({ shuffleAttestationOrdering: true, minTxsPerBlock: 0 }); - }); - - // Disable shuffleAttestationOrdering after the first block is mined and prevent sequencers from publishing any more blocks - test.monitor.once('checkpoint', ({ checkpointNumber }) => { - logger.warn(`Disabling shuffleAttestationOrdering after L2 block ${checkpointNumber} has been mined`); - sequencers.forEach(sequencer => { - sequencer.updateConfig({ shuffleAttestationOrdering: false, minTxsPerBlock: 100 }); - }); - }); - - // Start all sequencers - await Promise.all(sequencers.map(s => s.start())); - logger.warn(`Started all sequencers with shuffleAttestationOrdering=true`); - - // Create a filter for CheckpointInvalidated events - const checkpointInvalidatedFilter = await l1Client.createContractEventFilter({ - address: rollupContract.address, - abi: RollupAbi, - eventName: 'CheckpointInvalidated', - fromBlock: 1n, - toBlock: 'latest', - }); - - // The next proposer should invalidate the previous checkpoint - logger.warn('Waiting for next proposer to invalidate the previous checkpoint'); - - // Wait for the CheckpointInvalidated event - const checkpointInvalidatedEvents = await retryUntil( - async () => { - const events = await l1Client.getFilterLogs({ filter: checkpointInvalidatedFilter }); - return events.length > 0 ? events : undefined; - }, - 'CheckpointInvalidated event', - test.L2_SLOT_DURATION_IN_S * 5, - 0.1, - ); - - // Verify the CheckpointInvalidated event was emitted and that the block was removed - const [event] = checkpointInvalidatedEvents; - logger.warn(`CheckpointInvalidated event emitted`, { event }); - expect(event.args.checkpointNumber).toBeGreaterThan(initialCheckpointNumber); - expect(await test.rollup.getCheckpointNumber()).toEqual(initialCheckpointNumber); - - logger.warn(`Test succeeded '${expect.getState().currentTestName}'`); - }); - it('committee member invalidates a block if proposer does not come through', async () => { const sequencers = nodes.map(node => node.getSequencer()!); const initialCheckpointNumber = await nodes[0].getL2Tips().then(t => t.checkpointed.checkpoint.number); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts index 1917f419e9f4..247a56d44ae9 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts @@ -145,6 +145,20 @@ describe('e2e_epochs/epochs_mbps', () => { /** Retrieves all checkpoints from the archiver, checks that one has the target block count, and returns its number. */ async function assertMultipleBlocksPerSlot(targetBlockCount: number, logger: Logger): Promise { + // Wait for the first validator's archiver to index a checkpoint with the target block count. + // waitForTx polls the initial setup node, but this archiver belongs to nodes[0] (the first + // validator). They sync L1 independently, so there's a race window of ~200-400ms. + const waitTimeout = test.L2_SLOT_DURATION_IN_S * 3; + await retryUntil( + async () => { + const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); + return checkpoints.some(pc => pc.checkpoint.blocks.length >= targetBlockCount) || undefined; + }, + `checkpoint with at least ${targetBlockCount} blocks`, + waitTimeout, + 0.5, + ); + const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); logger.warn(`Retrieved ${checkpoints.length} checkpoints from archiver`, { checkpoints: checkpoints.map(pc => pc.checkpoint.getStats()), diff --git a/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts index 2f68d908d458..3ebef6ac94da 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts @@ -4,6 +4,7 @@ import { EthAddress } from '@aztec/aztec.js/addresses'; import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { bufferToHex } from '@aztec/foundation/string'; import { OffenseType } from '@aztec/slasher'; +import { TopicType } from '@aztec/stdlib/p2p'; import { jest } from '@jest/globals'; import fs from 'fs'; @@ -15,7 +16,7 @@ import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNode } from '../fixtures/setup_p2p_test.js'; import { getPrivateKeyFromIndex } from '../fixtures/utils.js'; import { P2PNetworkTest } from './p2p_network.js'; -import { awaitCommitteeExists, awaitOffenseDetected } from './shared.js'; +import { awaitCommitteeExists, awaitEpochWithProposer, awaitOffenseDetected } from './shared.js'; const TEST_TIMEOUT = 600_000; // 10 minutes @@ -141,6 +142,7 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { coinbase: coinbase1, attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals + dontStartSequencer: true, }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 1, @@ -159,6 +161,7 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { coinbase: coinbase2, attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals + dontStartSequencer: true, }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 2, @@ -172,7 +175,10 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { // Create honest nodes with unique validator keys (indices 1 and 2) t.logger.warn('Creating honest nodes'); const honestNode1 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 3, t.bootstrapNodeEnr, @@ -182,7 +188,10 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { shouldCollectMetrics(), ); const honestNode2 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider!, BOOT_NODE_UDP_PORT + 4, t.bootstrapNodeEnr, @@ -194,10 +203,27 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; - // Wait for P2P mesh and the committee to be fully formed before proceeding - await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS); + // Wait for P2P mesh on all needed topics before starting sequencers + await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS, 30, 0.1, [ + TopicType.tx, + TopicType.block_proposal, + TopicType.checkpoint_proposal, + ]); await awaitCommitteeExists({ rollup, logger: t.logger }); + // Advance to an epoch where the malicious proposer is selected + const epochCache = (honestNode1 as TestAztecNodeService).epochCache; + await awaitEpochWithProposer({ + epochCache, + cheatCodes: t.ctx.cheatCodes.rollup, + targetProposer: maliciousProposerAddress, + logger: t.logger, + }); + + // Start all sequencers simultaneously + t.logger.warn('Starting all sequencers'); + await Promise.all(nodes.map(n => n.getSequencer()!.start())); + // Wait for offenses to be detected // We expect BOTH duplicate proposal AND duplicate attestation offenses // The malicious proposer nodes create duplicate proposals (same key, different coinbase) @@ -236,7 +262,6 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { } // Verify that for each duplicate attestation offense, the attester for that slot is the malicious validator - const epochCache = (honestNode1 as TestAztecNodeService).epochCache; for (const offense of duplicateAttestationOffenses) { const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); const committeeInfo = await epochCache.getCommittee(offenseSlot); diff --git a/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts index 374e4527d4ef..c0b6062acac6 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts @@ -4,6 +4,7 @@ import { EthAddress } from '@aztec/aztec.js/addresses'; import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { bufferToHex } from '@aztec/foundation/string'; import { OffenseType } from '@aztec/slasher'; +import { TopicType } from '@aztec/stdlib/p2p'; import { jest } from '@jest/globals'; import fs from 'fs'; @@ -15,7 +16,7 @@ import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNode } from '../fixtures/setup_p2p_test.js'; import { getPrivateKeyFromIndex } from '../fixtures/utils.js'; import { P2PNetworkTest } from './p2p_network.js'; -import { awaitCommitteeExists, awaitOffenseDetected } from './shared.js'; +import { awaitCommitteeExists, awaitEpochWithProposer, awaitOffenseDetected } from './shared.js'; const TEST_TIMEOUT = 600_000; // 10 minutes @@ -130,6 +131,7 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { validatorPrivateKey: maliciousPrivateKeyHex, coinbase: coinbase1, broadcastEquivocatedProposals: true, + dontStartSequencer: true, }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 1, @@ -147,6 +149,7 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { validatorPrivateKey: maliciousPrivateKeyHex, coinbase: coinbase2, broadcastEquivocatedProposals: true, + dontStartSequencer: true, }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 2, @@ -160,7 +163,10 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { // Create honest nodes with unique validator keys (indices 1 and 2) t.logger.warn('Creating honest nodes'); const honestNode1 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 3, t.bootstrapNodeEnr, @@ -170,7 +176,10 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { shouldCollectMetrics(), ); const honestNode2 = await createNode( - t.ctx.aztecNodeConfig, + { + ...t.ctx.aztecNodeConfig, + dontStartSequencer: true, + }, t.ctx.dateProvider, BOOT_NODE_UDP_PORT + 4, t.bootstrapNodeEnr, @@ -182,10 +191,27 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; - // Wait for P2P mesh and the committee to be fully formed before proceeding - await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS); + // Wait for P2P mesh on all needed topics before starting sequencers + await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS, 30, 0.1, [ + TopicType.tx, + TopicType.block_proposal, + TopicType.checkpoint_proposal, + ]); await awaitCommitteeExists({ rollup, logger: t.logger }); + // Advance to an epoch where the malicious proposer is selected + const epochCache = (honestNode1 as TestAztecNodeService).epochCache; + await awaitEpochWithProposer({ + epochCache, + cheatCodes: t.ctx.cheatCodes.rollup, + targetProposer: maliciousValidatorAddress, + logger: t.logger, + }); + + // Start all sequencers simultaneously + t.logger.warn('Starting all sequencers'); + await Promise.all(nodes.map(n => n.getSequencer()!.start())); + // Wait for offense to be detected // The honest nodes should detect the duplicate proposal from the malicious validator t.logger.warn('Waiting for duplicate proposal offense to be detected...'); @@ -208,7 +234,6 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { } // Verify that for each offense, the proposer for that slot is the malicious validator - const epochCache = (honestNode1 as TestAztecNodeService).epochCache; for (const offense of offenses) { const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); const proposerForSlot = await epochCache.getProposerAttesterAddressInSlot(offenseSlot); diff --git a/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts b/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts index bdb752da9f00..6e90af2fad87 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/p2p_network.ts @@ -407,6 +407,7 @@ export class P2PNetworkTest { expectedNodeCount?: number, timeoutSeconds = 30, checkIntervalSeconds = 0.1, + topics: TopicType[] = [TopicType.tx], ) { const nodeCount = expectedNodeCount ?? nodes.length; const minPeerCount = nodeCount - 1; @@ -433,26 +434,28 @@ export class P2PNetworkTest { this.logger.warn('All nodes connected to P2P mesh'); - // Wait for GossipSub mesh to form for the tx topic. + // Wait for GossipSub mesh to form for all specified topics. // We only require at least 1 mesh peer per node because GossipSub // stops grafting once it reaches Dlo peers and won't fill the mesh to all available peers. - this.logger.warn('Waiting for GossipSub mesh to form for tx topic...'); - await Promise.all( - nodes.map(async (node, index) => { - const p2p = node.getP2P(); - await retryUntil( - async () => { - const meshPeers = await p2p.getGossipMeshPeerCount(TopicType.tx); - this.logger.debug(`Node ${index} has ${meshPeers} gossip mesh peers for tx topic`); - return meshPeers >= 1 ? true : undefined; - }, - `Node ${index} to have gossip mesh peers for tx topic`, - timeoutSeconds, - checkIntervalSeconds, - ); - }), - ); - this.logger.warn('All nodes have gossip mesh peers for tx topic'); + for (const topic of topics) { + this.logger.warn(`Waiting for GossipSub mesh to form for ${topic} topic...`); + await Promise.all( + nodes.map(async (node, index) => { + const p2p = node.getP2P(); + await retryUntil( + async () => { + const meshPeers = await p2p.getGossipMeshPeerCount(topic); + this.logger.debug(`Node ${index} has ${meshPeers} gossip mesh peers for ${topic} topic`); + return meshPeers >= 1 ? true : undefined; + }, + `Node ${index} to have gossip mesh peers for ${topic} topic`, + timeoutSeconds, + checkIntervalSeconds, + ); + }), + ); + this.logger.warn(`All nodes have gossip mesh peers for ${topic} topic`); + } } async teardown() { diff --git a/yarn-project/end-to-end/src/e2e_p2p/shared.ts b/yarn-project/end-to-end/src/e2e_p2p/shared.ts index 5b3450673e24..b4bf13758ef9 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/shared.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/shared.ts @@ -6,12 +6,13 @@ import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; import { TxHash } from '@aztec/aztec.js/tx'; import type { RollupCheatCodes } from '@aztec/aztec/testing'; +import type { EpochCacheInterface } from '@aztec/epoch-cache'; import type { EmpireSlashingProposerContract, RollupContract, TallySlashingProposerContract, } from '@aztec/ethereum/contracts'; -import { EpochNumber } from '@aztec/foundation/branded-types'; +import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { timesAsync, unique } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { retryUntil } from '@aztec/foundation/retry'; @@ -150,6 +151,48 @@ export async function awaitCommitteeExists({ return committee!.map(c => c.toString() as `0x${string}`); } +/** + * Advance epochs until we find one where the target proposer is selected for at least one slot. + * With N validators and M slots per epoch, a specific proposer may not be selected in any given epoch. + * For example, with 4 validators and 2 slots/epoch, there is about a 44% chance per epoch. + */ +export async function awaitEpochWithProposer({ + epochCache, + cheatCodes, + targetProposer, + logger, + maxAttempts = 20, +}: { + epochCache: EpochCacheInterface; + cheatCodes: RollupCheatCodes; + targetProposer: EthAddress; + logger: Logger; + maxAttempts?: number; +}): Promise { + const { epochDuration } = await cheatCodes.getConfig(); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const currentEpoch = await cheatCodes.getEpoch(); + const startSlot = Number(currentEpoch) * Number(epochDuration); + const endSlot = startSlot + Number(epochDuration); + + logger.info(`Checking epoch ${currentEpoch} (slots ${startSlot}-${endSlot - 1}) for proposer ${targetProposer}`); + + for (let s = startSlot; s < endSlot; s++) { + const proposer = await epochCache.getProposerAttesterAddressInSlot(SlotNumber(s)); + if (proposer && proposer.equals(targetProposer)) { + logger.warn(`Found target proposer ${targetProposer} in slot ${s} of epoch ${currentEpoch}`); + return; + } + } + + logger.info(`Target proposer not found in epoch ${currentEpoch}, advancing to next epoch`); + await cheatCodes.advanceToNextEpoch(); + } + + throw new Error(`Target proposer ${targetProposer} not found in any slot after ${maxAttempts} epoch attempts`); +} + export async function awaitOffenseDetected({ logger, nodeAdmin, diff --git a/yarn-project/end-to-end/src/spartan/n_tps.test.ts b/yarn-project/end-to-end/src/spartan/n_tps.test.ts index 72a3d3002955..d7a774b5fc3b 100644 --- a/yarn-project/end-to-end/src/spartan/n_tps.test.ts +++ b/yarn-project/end-to-end/src/spartan/n_tps.test.ts @@ -90,10 +90,10 @@ const mempoolTxMinedDelayQuery = (perc: string) => const mempoolAttestationMinedDelayQuery = (perc: string) => `histogram_quantile(${perc}, sum(rate(aztec_mempool_attestations_mined_delay_milliseconds_bucket{k8s_namespace_name="${config.NAMESPACE}"}[1m])) by (le))`; -const peerCountQuery = () => `avg(aztec_peer_manager_peer_count{k8s_namespace_name="${config.NAMESPACE}"})`; +const peerCountQuery = () => `avg(aztec_peer_manager_peer_count_peers{k8s_namespace_name="${config.NAMESPACE}"})`; -const peerConnectionDurationQuery = (perc: string) => - `histogram_quantile(${perc}, sum(rate(aztec_peer_manager_peer_connection_duration_milliseconds_bucket{k8s_namespace_name="${config.NAMESPACE}"}[1m])) by (le))`; +const peerConnectionDurationQuery = (perc: string, windowSeconds: number) => + `histogram_quantile(${perc}, sum(rate(aztec_peer_manager_peer_connection_duration_milliseconds_bucket{k8s_namespace_name="${config.NAMESPACE}"}[${windowSeconds}s])) by (le))`; describe('sustained N TPS test', () => { jest.setTimeout(60 * 60 * 1000 * 10); // 10 hours @@ -168,8 +168,8 @@ describe('sustained N TPS test', () => { try { const [avgCount, durationP50, durationP95] = await Promise.all([ prometheusClient.querySingleValue(peerCountQuery()), - prometheusClient.querySingleValue(peerConnectionDurationQuery('0.50')), - prometheusClient.querySingleValue(peerConnectionDurationQuery('0.95')), + prometheusClient.querySingleValue(peerConnectionDurationQuery('0.50', TEST_DURATION_SECONDS + 60)), + prometheusClient.querySingleValue(peerConnectionDurationQuery('0.95', TEST_DURATION_SECONDS + 60)), ]); metrics.recordPeerStats(avgCount, durationP50, durationP95); logger.debug('Scraped peer stats', { avgCount, durationP50, durationP95 }); @@ -384,7 +384,7 @@ describe('sustained N TPS test', () => { const tx = await (config.REAL_VERIFIER ? submitProven(wallet, fee) : submitUnproven(wallet, fee)); const t1 = performance.now(); - metrics.recordSentTx(tx, `high_value_${highValueTps}tps`); + metrics.recordSentTx(tx, 'tx_inclusion_time'); const txHash = await tx.send({ wait: NO_WAIT }); const t2 = performance.now(); @@ -461,8 +461,8 @@ describe('sustained N TPS test', () => { logger.warn(`Failed transaction ${idx + 1}: ${result.error}`); }); - const highValueGroup = `high_value_${highValueTps}tps`; - const inclusionStats = metrics.inclusionTimeInSeconds(highValueGroup); + const txInclusionGroup = 'tx_inclusion_time'; + const inclusionStats = metrics.inclusionTimeInSeconds(txInclusionGroup); logger.info(`Transaction inclusion summary: ${successCount} succeeded, ${failureCount} failed`); logger.info('Inclusion time stats', inclusionStats); }); diff --git a/yarn-project/end-to-end/src/spartan/slash_inactivity.test.ts b/yarn-project/end-to-end/src/spartan/slash_inactivity.test.ts index 6092f1af2605..bdd7ea3e7fd0 100644 --- a/yarn-project/end-to-end/src/spartan/slash_inactivity.test.ts +++ b/yarn-project/end-to-end/src/spartan/slash_inactivity.test.ts @@ -38,7 +38,7 @@ describe('slash inactivity test', () => { let client: ViemPublicClient; let rollup: RollupContract; - let slashSettings: TallySlasherSettings; + let slashSettings: Omit; let constants: Omit; let monitor: ChainMonitor; let offlineValidator: EthAddress; diff --git a/yarn-project/end-to-end/src/spartan/tx_metrics.ts b/yarn-project/end-to-end/src/spartan/tx_metrics.ts index 6234641a9276..20d536432ceb 100644 --- a/yarn-project/end-to-end/src/spartan/tx_metrics.ts +++ b/yarn-project/end-to-end/src/spartan/tx_metrics.ts @@ -296,7 +296,7 @@ export class TxInclusionMetrics { value: stats.mean, }, { - name: `${group}/median_inclusion`, + name: `${group}/p50_inclusion`, unit: 's', value: stats.median, }, diff --git a/yarn-project/ethereum/src/contracts/registry.ts b/yarn-project/ethereum/src/contracts/registry.ts index 89156ec13c7a..a4bf15f16bb8 100644 --- a/yarn-project/ethereum/src/contracts/registry.ts +++ b/yarn-project/ethereum/src/contracts/registry.ts @@ -3,7 +3,7 @@ import { createLogger } from '@aztec/foundation/log'; import { RegistryAbi } from '@aztec/l1-artifacts/RegistryAbi'; import { TestERC20Abi } from '@aztec/l1-artifacts/TestERC20Abi'; -import { type GetContractReturnType, type Hex, getContract } from 'viem'; +import { type GetContractReturnType, type Hex, getAbiItem, getContract } from 'viem'; import type { L1ContractAddresses } from '../l1_contract_addresses.js'; import type { ViemClient } from '../types.js'; @@ -128,4 +128,34 @@ export class RegistryContract { public async getRewardDistributor(): Promise { return EthAddress.fromString(await this.registry.read.getRewardDistributor()); } + + /** Returns the L1 timestamp at which the given rollup was registered via addRollup(). */ + public async getCanonicalRollupRegistrationTimestamp( + rollupAddress: EthAddress, + fromBlock?: bigint, + ): Promise { + const event = getAbiItem({ abi: RegistryAbi, name: 'CanonicalRollupUpdated' }); + const start = fromBlock ?? 0n; + const latestBlock = await this.client.getBlockNumber(); + const chunkSize = 1_000n; + + for (let from = start; from <= latestBlock; from += chunkSize) { + const to = from + chunkSize - 1n > latestBlock ? latestBlock : from + chunkSize - 1n; + const logs = await this.client.getLogs({ + address: this.address.toString(), + fromBlock: from, + toBlock: to, + strict: true, + event, + args: { instance: rollupAddress.toString() }, + }); + + if (logs.length > 0) { + const block = await this.client.getBlock({ blockNumber: logs[0].blockNumber }); + return block.timestamp; + } + } + + return undefined; + } } diff --git a/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.test.ts b/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.test.ts index bcf160e48e6a..11684ed5ebb3 100644 --- a/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.test.ts +++ b/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.test.ts @@ -147,7 +147,7 @@ describe('L1TxUtils', () => { address: l1Client.account.address, }); - // Next send fails at sendRawTransaction (e.g. network error) + // Next send fails at sendRawTransaction (e.g. network error / 429) const originalSendRawTransaction = l1Client.sendRawTransaction.bind(l1Client); using _sendSpy = jest .spyOn(l1Client, 'sendRawTransaction') @@ -163,6 +163,29 @@ describe('L1TxUtils', () => { expect((await l1Client.getTransaction({ hash: txHash })).nonce).toBe(expectedNonce); }, 30_000); + it('bumps nonce when getTransactionCount returns a stale value after a successful send', async () => { + // Send a successful tx first to advance the chain nonce + await gasUtils.sendAndMonitorTransaction(request); + + const expectedNonce = await l1Client.getTransactionCount({ + blockTag: 'pending', + address: l1Client.account.address, + }); + + // Simulate a stale fallback RPC node that returns the pre-send nonce + const originalGetTransactionCount = l1Client.getTransactionCount.bind(l1Client); + using _spy = jest + .spyOn(l1Client, 'getTransactionCount') + .mockImplementationOnce(() => Promise.resolve(expectedNonce - 1)) // stale: one behind + .mockImplementation(originalGetTransactionCount); + + // Despite the stale count, the send should use lastSentNonce+1 = expectedNonce + const { txHash, state } = await gasUtils.sendTransaction(request); + + expect(state.nonce).toBe(expectedNonce); + expect((await l1Client.getTransaction({ hash: txHash })).nonce).toBe(expectedNonce); + }, 30_000); + // Regression for TMNT-312 it('speed-up of blob tx sets non-zero maxFeePerBlobGas', async () => { await cheatCodes.setAutomine(false); diff --git a/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts b/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts index 48b8dfc41aa5..f6292311fc7e 100644 --- a/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts +++ b/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts @@ -45,6 +45,8 @@ const MAX_L1_TX_STATES = 32; export class L1TxUtils extends ReadOnlyL1TxUtils { protected txs: L1TxState[] = []; + /** Last nonce successfully sent to the chain. Used as a lower bound when a fallback RPC node returns a stale count. */ + private lastSentNonce: number | undefined; /** Tx delayer for testing. Only set when enableDelayer config is true. */ public delayer?: Delayer; /** KZG instance for blob operations. */ @@ -105,6 +107,11 @@ export class L1TxUtils extends ReadOnlyL1TxUtils { this.metrics?.recordMinedTx(l1TxState, new Date(l1Timestamp)); } else if (newState === TxUtilsState.NOT_MINED) { this.metrics?.recordDroppedTx(l1TxState); + // The tx was dropped: the chain nonce reverted to l1TxState.nonce, so our lower bound is + // no longer valid. Clear it so the next send fetches the real nonce from the chain. + if (this.lastSentNonce === l1TxState.nonce) { + this.lastSentNonce = undefined; + } } // Update state in the store @@ -246,7 +253,11 @@ export class L1TxUtils extends ReadOnlyL1TxUtils { ); } - const nonce = await this.client.getTransactionCount({ address: account, blockTag: 'pending' }); + const chainNonce = await this.client.getTransactionCount({ address: account, blockTag: 'pending' }); + // If a fallback RPC node returns a stale count (lower than what we last sent), use our + // local lower bound to avoid sending a duplicate of an already-pending transaction. + const nonce = + this.lastSentNonce !== undefined && chainNonce <= this.lastSentNonce ? this.lastSentNonce + 1 : chainNonce; const baseState = { request, gasLimit, blobInputs, gasPrice, nonce }; const txData = this.makeTxData(baseState, { isCancelTx: false }); @@ -254,6 +265,8 @@ export class L1TxUtils extends ReadOnlyL1TxUtils { // Send the new tx const signedRequest = await this.prepareSignedTransaction(txData); const txHash = await this.client.sendRawTransaction({ serializedTransaction: signedRequest }); + // Update after tx is sent successfully + this.lastSentNonce = nonce; // Create the new state for monitoring const l1TxState: L1TxState = { diff --git a/yarn-project/foundation/src/config/config.test.ts b/yarn-project/foundation/src/config/config.test.ts index c21021c8d067..a4e0f7ad17b2 100644 --- a/yarn-project/foundation/src/config/config.test.ts +++ b/yarn-project/foundation/src/config/config.test.ts @@ -1,6 +1,6 @@ import { jest } from '@jest/globals'; -import { type ConfigMappingsType, getConfigFromMappings, numberConfigHelper } from './index.js'; +import { type ConfigMappingsType, bigintConfigHelper, getConfigFromMappings, numberConfigHelper } from './index.js'; describe('Config', () => { describe('getConfigFromMappings', () => { @@ -131,4 +131,37 @@ describe('Config', () => { }); }); }); + + describe('bigintConfigHelper', () => { + it('parses plain integer strings', () => { + const { parseEnv } = bigintConfigHelper(); + expect(parseEnv!('123')).toBe(123n); + expect(parseEnv!('0')).toBe(0n); + expect(parseEnv!('200000000000000000000000')).toBe(200000000000000000000000n); + }); + + it('parses scientific notation', () => { + const { parseEnv } = bigintConfigHelper(); + expect(parseEnv!('1e+23')).toBe(100000000000000000000000n); + expect(parseEnv!('2E+23')).toBe(200000000000000000000000n); + expect(parseEnv!('1e23')).toBe(100000000000000000000000n); + expect(parseEnv!('5e18')).toBe(5000000000000000000n); + }); + + it('parses scientific notation with decimal mantissa', () => { + const { parseEnv } = bigintConfigHelper(); + expect(parseEnv!('1.5e10')).toBe(15000000000n); + expect(parseEnv!('2.5e5')).toBe(250000n); + }); + + it('returns default value for empty string', () => { + const { parseEnv } = bigintConfigHelper(42n); + expect(parseEnv!('')).toBe(42n); + }); + + it('throws for non-integer scientific notation results', () => { + const { parseEnv } = bigintConfigHelper(); + expect(() => parseEnv!('1e-3')).toThrow(); + }); + }); }); diff --git a/yarn-project/foundation/src/config/index.ts b/yarn-project/foundation/src/config/index.ts index 7ed7d3ef6ceb..8962dea3b1b0 100644 --- a/yarn-project/foundation/src/config/index.ts +++ b/yarn-project/foundation/src/config/index.ts @@ -177,6 +177,21 @@ export function bigintConfigHelper(defaultVal?: bigint): Pick { expect(recoveredAddress.toString()).toEqual(expectedAddress.toString()); }); }); + +describe('generateRecoverableSignature', () => { + it('produces a signature from which an address can be recovered', () => { + const sig = generateRecoverableSignature(); + const hash = Buffer32.random(); + const recovered = tryRecoverAddress(hash, sig); + expect(recovered).toBeDefined(); + }); +}); + +describe('generateUnrecoverableSignature', () => { + it('produces a signature from which no address can be recovered', () => { + const sig = generateUnrecoverableSignature(); + const hash = Buffer32.random(); + const recovered = tryRecoverAddress(hash, sig); + expect(recovered).toBeUndefined(); + }); +}); diff --git a/yarn-project/foundation/src/crypto/secp256k1-signer/utils.ts b/yarn-project/foundation/src/crypto/secp256k1-signer/utils.ts index a8a459d01b4e..2226ae635550 100644 --- a/yarn-project/foundation/src/crypto/secp256k1-signer/utils.ts +++ b/yarn-project/foundation/src/crypto/secp256k1-signer/utils.ts @@ -210,3 +210,35 @@ export function recoverPublicKey(hash: Buffer32, signature: Signature, opts: Rec const publicKey = sig.recoverPublicKey(hash.buffer).toHex(false); return Buffer.from(publicKey, 'hex'); } + +/** Arbitrary hash used for testing signature recoverability. */ +const PROBE_HASH = Buffer32.fromBuffer(keccak256(Buffer.from('signature-recoverability-probe'))); + +/** + * Generates a random valid ECDSA signature that is recoverable to some address. + * Since Signature.random() produces real signatures via secp256k1 signing, the result is always + * recoverable, but we verify defensively by checking tryRecoverAddress. + */ +export function generateRecoverableSignature(): Signature { + for (let i = 0; i < 100; i++) { + const sig = Signature.random(); + if (tryRecoverAddress(PROBE_HASH, sig) !== undefined) { + return sig; + } + } + throw new Secp256k1Error('Failed to generate a recoverable signature after 100 attempts'); +} + +/** + * Generates a random signature where ECDSA address recovery fails. + * Uses random r/s values (not from real signing) so that r is unlikely to be a valid secp256k1 x-coordinate. + */ +export function generateUnrecoverableSignature(): Signature { + for (let i = 0; i < 100; i++) { + const sig = new Signature(Buffer32.random(), Buffer32.random(), 27); + if (tryRecoverAddress(PROBE_HASH, sig) === undefined) { + return sig; + } + } + throw new Secp256k1Error('Failed to generate an unrecoverable signature after 100 attempts'); +} diff --git a/yarn-project/foundation/src/eth-signature/eth_signature.test.ts b/yarn-project/foundation/src/eth-signature/eth_signature.test.ts index 2c642c2c32a8..dd19ae14520b 100644 --- a/yarn-project/foundation/src/eth-signature/eth_signature.test.ts +++ b/yarn-project/foundation/src/eth-signature/eth_signature.test.ts @@ -1,7 +1,9 @@ import { Buffer32 } from '@aztec/foundation/buffer'; -import { Secp256k1Signer, recoverAddress } from '@aztec/foundation/crypto/secp256k1-signer'; +import { Secp256k1Signer, recoverAddress, tryRecoverAddress } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; +import { secp256k1 } from '@noble/curves/secp256k1'; + import { Signature } from './eth_signature.js'; const randomSigner = () => { @@ -62,4 +64,24 @@ describe('eth signature', () => { const deserialized = Signature.fromString(serialized); checkEquivalence(signature, deserialized); }); + + it('random() produces a valid recoverable signature with low s-value', () => { + const sig = Signature.random(); + + // v should be 27 or 28 + expect([27, 28]).toContain(sig.v); + + // Signature should not be empty + expect(sig.isEmpty()).toBe(false); + + // s should be in the low half of the curve (low s-value) + const sBigInt = sig.s.toBigInt(); + const halfN = secp256k1.CURVE.n / 2n; + expect(sBigInt).toBeLessThanOrEqual(halfN); + + // Signature should be recoverable (tryRecoverAddress should return an address for any hash) + const hash = Buffer32.random(); + const recovered = tryRecoverAddress(hash, sig); + expect(recovered).toBeDefined(); + }); }); diff --git a/yarn-project/foundation/src/eth-signature/eth_signature.ts b/yarn-project/foundation/src/eth-signature/eth_signature.ts index c76343f37540..62545d3cc4e9 100644 --- a/yarn-project/foundation/src/eth-signature/eth_signature.ts +++ b/yarn-project/foundation/src/eth-signature/eth_signature.ts @@ -1,8 +1,10 @@ import { Buffer32 } from '@aztec/foundation/buffer'; import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; +import { secp256k1 } from '@noble/curves/secp256k1'; import { z } from 'zod'; +import { randomBytes } from '../crypto/random/index.js'; import { hasHexPrefix, hexToBuffer } from '../string/index.js'; /** @@ -77,8 +79,12 @@ export class Signature { return new Signature(Buffer32.fromBuffer(hexToBuffer(sig.r)), Buffer32.fromBuffer(hexToBuffer(sig.s)), sig.yParity); } + /** Generates a random valid ECDSA signature with a low s-value by signing a random message with a random key. */ static random(): Signature { - return new Signature(Buffer32.random(), Buffer32.random(), 1); + const privateKey = randomBytes(32); + const message = randomBytes(32); + const { r, s, recovery } = secp256k1.sign(message, privateKey); + return new Signature(Buffer32.fromBigInt(r), Buffer32.fromBigInt(s), recovery ? 28 : 27); } static empty(): Signature { diff --git a/yarn-project/foundation/src/log/bigint-utils.ts b/yarn-project/foundation/src/log/bigint-utils.ts index 6cc94101ac2f..c9083ec1bfd0 100644 --- a/yarn-project/foundation/src/log/bigint-utils.ts +++ b/yarn-project/foundation/src/log/bigint-utils.ts @@ -11,6 +11,9 @@ export function convertBigintsToStrings(obj: unknown): unknown { } if (obj !== null && typeof obj === 'object') { + if (typeof (obj as any).toJSON === 'function') { + return convertBigintsToStrings((obj as any).toJSON()); + } const result: Record = {}; for (const key in obj) { result[key] = convertBigintsToStrings((obj as Record)[key]); diff --git a/yarn-project/foundation/src/log/pino-logger.test.ts b/yarn-project/foundation/src/log/pino-logger.test.ts index 9881535d4f58..22afc9e6a293 100644 --- a/yarn-project/foundation/src/log/pino-logger.test.ts +++ b/yarn-project/foundation/src/log/pino-logger.test.ts @@ -273,6 +273,35 @@ describe('pino-logger', () => { }); }); + it('serializes objects with toJSON() instead of dumping raw properties', () => { + const testLogger = createLogger('tojson-test'); + capturingStream.clear(); + + // Simulate an EthAddress-like object with an internal buffer and a toJSON method + const addressLike = { + buffer: Buffer.from('1234567890abcdef1234567890abcdef12345678', 'hex'), + toJSON() { + return '0x1234567890abcdef1234567890abcdef12345678'; + }, + }; + + testLogger.info('address logging test', { + validator: addressLike, + nested: { addr: addressLike }, + array: [addressLike], + plainString: 'hello', + }); + + const entries = capturingStream.getJsonLines(); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + validator: '0x1234567890abcdef1234567890abcdef12345678', + nested: { addr: '0x1234567890abcdef1234567890abcdef12345678' }, + array: ['0x1234567890abcdef1234567890abcdef12345678'], + plainString: 'hello', + }); + }); + it('returns bindings via getBindings', () => { const testLogger = createLogger('bindings-test', { actor: 'main', instanceId: 'id-123' }); const bindings = testLogger.getBindings(); diff --git a/yarn-project/p2p/src/config.test.ts b/yarn-project/p2p/src/config.test.ts index f537cffab724..7c80cedbf670 100644 --- a/yarn-project/p2p/src/config.test.ts +++ b/yarn-project/p2p/src/config.test.ts @@ -5,18 +5,14 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { getP2PDefaultConfig, parseAllowList } from './config.js'; describe('config', () => { - it('parses allow list', async () => { - const instance = { address: await AztecAddress.random() }; + it('parses allow list with required selectors', async () => { const instanceFunction = { address: await AztecAddress.random(), selector: FunctionSelector.random() }; - const classId = { classId: Fr.random() }; const classFunction = { classId: Fr.random(), selector: FunctionSelector.random() }; - const config = [instance, instanceFunction, classId, classFunction]; + const config = [instanceFunction, classFunction]; const configStrings = [ - `I:${instance.address}`, `I:${instanceFunction.address}:${instanceFunction.selector}`, - `C:${classId.classId}`, `C:${classFunction.classId}:${classFunction.selector}`, ]; const stringifiedAllowList = configStrings.join(','); @@ -25,6 +21,30 @@ describe('config', () => { expect(allowList).toEqual(config); }); + it('rejects instance entry without selector', async () => { + const address = await AztecAddress.random(); + expect(() => parseAllowList(`I:${address}`)).toThrow('selector is required'); + }); + + it('rejects class entry without selector', () => { + const classId = Fr.random(); + expect(() => parseAllowList(`C:${classId}`)).toThrow('selector is required'); + }); + + it('rejects entry with unknown type', () => { + expect(() => parseAllowList(`X:0x1234:0x12345678`)).toThrow('unknown type'); + }); + + it('parses empty string', () => { + expect(parseAllowList('')).toEqual([]); + }); + + it('handles whitespace in entries', async () => { + const instanceFunction = { address: await AztecAddress.random(), selector: FunctionSelector.random() }; + const allowList = parseAllowList(` I:${instanceFunction.address}:${instanceFunction.selector} `); + expect(allowList).toEqual([instanceFunction]); + }); + it('defaults missing txs collector type to new', () => { const config = getP2PDefaultConfig(); expect(config.txCollectionMissingTxsCollectorType).toBe('new'); diff --git a/yarn-project/p2p/src/config.ts b/yarn-project/p2p/src/config.ts index f9f951019191..050f2b8bb233 100644 --- a/yarn-project/p2p/src/config.ts +++ b/yarn-project/p2p/src/config.ts @@ -38,7 +38,7 @@ export interface P2PConfig ChainConfig, TxCollectionConfig, TxFileStoreConfig, - Pick { + Pick { /** A flag dictating whether the P2P subsystem should be enabled. */ p2pEnabled: boolean; @@ -150,8 +150,8 @@ export interface P2PConfig /** The maximum possible size of the P2P DB in KB. Overwrites the general dataStoreMapSizeKb. */ p2pStoreMapSizeKb?: number; - /** Which calls are allowed in the public setup phase of a tx. */ - txPublicSetupAllowList: AllowedElement[]; + /** Additional entries to extend the default setup allow list. */ + txPublicSetupAllowListExtend: AllowedElement[]; /** The maximum number of pending txs before evicting lower priority txs. */ maxPendingTxCount: number; @@ -393,12 +393,13 @@ export const p2pConfigMappings: ConfigMappingsType = { parseEnv: (val: string | undefined) => (val ? +val : undefined), description: 'The maximum possible size of the P2P DB in KB. Overwrites the general dataStoreMapSizeKb.', }, - txPublicSetupAllowList: { + txPublicSetupAllowListExtend: { env: 'TX_PUBLIC_SETUP_ALLOWLIST', parseEnv: (val: string) => parseAllowList(val), - description: 'The list of functions calls allowed to run in setup', + description: + 'Additional entries to extend the default setup allow list. Format: I:address:selector,C:classId:selector', printDefault: () => - 'AuthRegistry, FeeJuice.increase_public_balance, Token.increase_public_balance, FPC.prepare_fee', + 'Default: AuthRegistry._set_authorized, FeeJuice._increase_public_balance, Token._increase_public_balance, Token.transfer_in_public', }, maxPendingTxCount: { env: 'P2P_MAX_PENDING_TX_COUNT', @@ -523,11 +524,9 @@ export const bootnodeConfigMappings = pickConfigMappings( /** * Parses a string to a list of allowed elements. - * Each encoded is expected to be of one of the following formats - * `I:${address}` - * `I:${address}:${selector}` - * `C:${classId}` - * `C:${classId}:${selector}` + * Each entry is expected to be of one of the following formats: + * `I:${address}:${selector}` — instance (contract address) with function selector + * `C:${classId}:${selector}` — class with function selector * * @param value The string to parse * @returns A list of allowed elements @@ -540,31 +539,34 @@ export function parseAllowList(value: string): AllowedElement[] { } for (const val of value.split(',')) { - const [typeString, identifierString, selectorString] = val.split(':'); - const selector = selectorString !== undefined ? FunctionSelector.fromString(selectorString) : undefined; + const trimmed = val.trim(); + if (!trimmed) { + continue; + } + const [typeString, identifierString, selectorString] = trimmed.split(':'); + + if (!selectorString) { + throw new Error( + `Invalid allow list entry "${trimmed}": selector is required. Expected format: I:address:selector or C:classId:selector`, + ); + } + + const selector = FunctionSelector.fromString(selectorString); if (typeString === 'I') { - if (selector) { - entries.push({ - address: AztecAddress.fromString(identifierString), - selector, - }); - } else { - entries.push({ - address: AztecAddress.fromString(identifierString), - }); - } + entries.push({ + address: AztecAddress.fromString(identifierString), + selector, + }); } else if (typeString === 'C') { - if (selector) { - entries.push({ - classId: Fr.fromHexString(identifierString), - selector, - }); - } else { - entries.push({ - classId: Fr.fromHexString(identifierString), - }); - } + entries.push({ + classId: Fr.fromHexString(identifierString), + selector, + }); + } else { + throw new Error( + `Invalid allow list entry "${trimmed}": unknown type "${typeString}". Expected "I" (instance) or "C" (class).`, + ); } } diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts index 827d91ce84ea..e61456ef5cbf 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts @@ -359,11 +359,10 @@ export class AttestationPool { } const address = sender.toString(); + const ownKey = this.getAttestationKey(slotNumber, proposalId, address); - await this.checkpointAttestations.set( - this.getAttestationKey(slotNumber, proposalId, address), - attestation.toBuffer(), - ); + await this.checkpointAttestations.set(ownKey, attestation.toBuffer()); + this.metrics.trackMempoolItemAdded(ownKey); this.log.debug(`Added own checkpoint attestation for slot ${slotNumber} from ${address}`, { signature: attestation.signature.toString(), @@ -429,6 +428,7 @@ export class AttestationPool { const attestationEndKey = new Fr(oldestSlot).toString(); for await (const key of this.checkpointAttestations.keysAsync({ end: attestationEndKey })) { await this.checkpointAttestations.delete(key); + this.metrics.trackMempoolItemRemoved(key); numberOfAttestations++; } @@ -526,6 +526,7 @@ export class AttestationPool { // Add the attestation await this.checkpointAttestations.set(key, attestation.toBuffer()); + this.metrics.trackMempoolItemAdded(key); // Track this attestation in the per-signer-per-slot index for duplicate detection const slotSignerKey = this.getSlotSignerKey(slotNumber, signerAddress); diff --git a/yarn-project/p2p/src/mem_pools/instrumentation.ts b/yarn-project/p2p/src/mem_pools/instrumentation.ts index bfd3c7b64ac7..d76d2c30ad4a 100644 --- a/yarn-project/p2p/src/mem_pools/instrumentation.ts +++ b/yarn-project/p2p/src/mem_pools/instrumentation.ts @@ -73,7 +73,7 @@ export class PoolInstrumentation { private defaultAttributes; private meter: Meter; - private txAddedTimestamp: Map = new Map(); + private mempoolItemAddedTimestamp: Map = new Map(); constructor( telemetry: TelemetryClient, @@ -114,22 +114,26 @@ export class PoolInstrumentation { } public transactionsAdded(transactions: Tx[]) { - const timestamp = Date.now(); - for (const transaction of transactions) { - this.txAddedTimestamp.set(transaction.txHash.toBigInt(), timestamp); - } + transactions.forEach(tx => this.trackMempoolItemAdded(tx.txHash.toBigInt())); } public transactionsRemoved(hashes: Iterable | Iterable) { - const timestamp = Date.now(); for (const hash of hashes) { - const key = BigInt(hash); - const addedAt = this.txAddedTimestamp.get(key); - if (addedAt !== undefined) { - this.txAddedTimestamp.delete(key); - if (addedAt < timestamp) { - this.minedDelay.record(timestamp - addedAt); - } + this.trackMempoolItemRemoved(BigInt(hash)); + } + } + + public trackMempoolItemAdded(key: bigint | string): void { + this.mempoolItemAddedTimestamp.set(key, Date.now()); + } + + public trackMempoolItemRemoved(key: bigint | string): void { + const timestamp = Date.now(); + const addedAt = this.mempoolItemAddedTimestamp.get(key); + if (addedAt !== undefined) { + this.mempoolItemAddedTimestamp.delete(key); + if (addedAt < timestamp) { + this.minedDelay.record(timestamp - addedAt); } } } diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts index 4f65a0d6b0ae..69b91bd95c52 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.ts @@ -58,6 +58,9 @@ export class AztecKVTxPoolV2 extends (EventEmitter as new () => TypedEventEmitte const hashes = txHashes.map(h => (typeof h === 'string' ? TxHash.fromString(h) : TxHash.fromBigInt(h))); this.emit('txs-removed', { txHashes: hashes }); }, + onTxsMined: (txHashes: string[]) => { + this.#metrics?.transactionsRemoved(txHashes); + }, }; // Create the implementation diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts index 88f6e887b9a8..15f6eb4b051e 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts @@ -45,6 +45,7 @@ import { TxPoolIndices } from './tx_pool_indices.js'; export interface TxPoolV2Callbacks { onTxsAdded: (txs: Tx[], opts: { source?: string }) => void; onTxsRemoved: (txHashes: string[] | bigint[]) => void; + onTxsMined: (txHashes: string[]) => void; } /** @@ -498,6 +499,10 @@ export class TxPoolV2Impl { await this.#evictionManager.evictAfterNewBlock(block.header, nullifiers, feePayers); }); + if (found.length > 0) { + this.#callbacks.onTxsMined(found.map(m => m.txHash)); + } + this.#log.info(`Marked ${found.length} txs as mined in block ${blockId.number}`); } diff --git a/yarn-project/p2p/src/msg_validators/proposal_validator/block_proposal_validator.ts b/yarn-project/p2p/src/msg_validators/proposal_validator/block_proposal_validator.ts index 5de0076aea39..a481256e9f37 100644 --- a/yarn-project/p2p/src/msg_validators/proposal_validator/block_proposal_validator.ts +++ b/yarn-project/p2p/src/msg_validators/proposal_validator/block_proposal_validator.ts @@ -4,7 +4,7 @@ import type { BlockProposal, P2PValidator } from '@aztec/stdlib/p2p'; import { ProposalValidator } from '../proposal_validator/proposal_validator.js'; export class BlockProposalValidator extends ProposalValidator implements P2PValidator { - constructor(epochCache: EpochCacheInterface, opts: { txsPermitted: boolean }) { + constructor(epochCache: EpochCacheInterface, opts: { txsPermitted: boolean; maxTxsPerBlock?: number }) { super(epochCache, opts, 'p2p:block_proposal_validator'); } } diff --git a/yarn-project/p2p/src/msg_validators/proposal_validator/checkpoint_proposal_validator.ts b/yarn-project/p2p/src/msg_validators/proposal_validator/checkpoint_proposal_validator.ts index 763912a04814..74804fe45d21 100644 --- a/yarn-project/p2p/src/msg_validators/proposal_validator/checkpoint_proposal_validator.ts +++ b/yarn-project/p2p/src/msg_validators/proposal_validator/checkpoint_proposal_validator.ts @@ -7,7 +7,7 @@ export class CheckpointProposalValidator extends ProposalValidator implements P2PValidator { - constructor(epochCache: EpochCacheInterface, opts: { txsPermitted: boolean }) { + constructor(epochCache: EpochCacheInterface, opts: { txsPermitted: boolean; maxTxsPerBlock?: number }) { super(epochCache, opts, 'p2p:checkpoint_proposal_validator'); } } diff --git a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts index f6fb7102e537..a926d1f3c144 100644 --- a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts +++ b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts @@ -9,10 +9,16 @@ export abstract class ProposalValidator this.maxTxsPerBlock) { + this.logger.warn( + `Penalizing peer for proposal with ${proposal.txHashes.length} transaction(s) when max is ${this.maxTxsPerBlock}`, + ); + return { result: 'reject', severity: PeerErrorSeverity.MidToleranceError }; + } + // Embedded txs must be listed in txHashes const hashSet = new Set(proposal.txHashes.map(h => h.toString())); const missingTxHashes = diff --git a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator_test_suite.ts b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator_test_suite.ts index 6aa79230f8cd..e58a007a3de7 100644 --- a/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator_test_suite.ts +++ b/yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator_test_suite.ts @@ -1,4 +1,5 @@ import type { EpochCacheInterface } from '@aztec/epoch-cache'; +import { NoCommitteeError } from '@aztec/ethereum/contracts'; import type { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import type { EthAddress } from '@aztec/foundation/eth-address'; import { @@ -9,12 +10,13 @@ import { } from '@aztec/stdlib/p2p'; import type { TxHash } from '@aztec/stdlib/tx'; +import { jest } from '@jest/globals'; import type { MockProxy } from 'jest-mock-extended'; export interface ProposalValidatorTestParams { validatorFactory: ( epochCache: EpochCacheInterface, - opts: { txsPermitted: boolean }, + opts: { txsPermitted: boolean; maxTxsPerBlock?: number }, ) => { validate: (proposal: TProposal) => Promise }; makeProposal: (options?: any) => Promise; makeHeader: (epochNumber: number | bigint, slotNumber: number | bigint, blockNumber: number | bigint) => any; @@ -105,6 +107,26 @@ export function sharedProposalValidatorTests { + const currentProposer = getSigner(); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + }); + + // Override getSender to return undefined (invalid signature) + jest.spyOn(mockProposal as any, 'getSender').mockReturnValue(undefined); + + mockGetProposer(getAddress(currentProposer), getAddress()); + const result = await validator.validate(mockProposal); + expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.MidToleranceError }); + + // Should not try to resolve proposer if signature is invalid + expect(epochCache.getProposerAttesterAddressInSlot).not.toHaveBeenCalled(); + }); + it('returns mid tolerance error if proposer is not current proposer for current slot', async () => { const currentProposer = getSigner(); const nextProposer = getSigner(); @@ -152,6 +174,34 @@ export function sharedProposalValidatorTests { + const currentProposer = getSigner(); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + }); + + epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(undefined); + const result = await validator.validate(mockProposal); + expect(result).toEqual({ result: 'accept' }); + }); + + it('returns low tolerance error when getProposerAttesterAddressInSlot throws NoCommitteeError', async () => { + const currentProposer = getSigner(); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + }); + + epochCache.getProposerAttesterAddressInSlot.mockRejectedValue(new NoCommitteeError()); + const result = await validator.validate(mockProposal); + expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.LowToleranceError }); + }); + it('returns undefined if proposal is valid for current slot and proposer', async () => { const currentProposer = getSigner(); const nextProposer = getSigner(); @@ -226,5 +276,98 @@ export function sharedProposalValidatorTests { + it('returns mid tolerance error if embedded txs are not listed in txHashes', async () => { + const currentProposer = getSigner(); + const txHashes = getTxHashes(2); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + txHashes, + }); + + // Create a fake tx whose hash is NOT in txHashes + const fakeTxHash = getTxHashes(1)[0]; + const fakeTx = { getTxHash: () => fakeTxHash, validateTxHash: () => Promise.resolve(true) }; + Object.defineProperty(mockProposal, 'txs', { get: () => [fakeTx], configurable: true }); + + mockGetProposer(getAddress(currentProposer), getAddress()); + const result = await validator.validate(mockProposal); + expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.MidToleranceError }); + }); + + it('returns low tolerance error if embedded tx has invalid tx hash', async () => { + const currentProposer = getSigner(); + const txHashes = getTxHashes(2); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + txHashes, + }); + + // Create a fake tx whose hash IS in txHashes but validateTxHash returns false + const fakeTx = { getTxHash: () => txHashes[0], validateTxHash: () => Promise.resolve(false) }; + Object.defineProperty(mockProposal, 'txs', { get: () => [fakeTx], configurable: true }); + + mockGetProposer(getAddress(currentProposer), getAddress()); + const result = await validator.validate(mockProposal); + expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.LowToleranceError }); + }); + }); + + describe('maxTxsPerBlock validation', () => { + it('rejects proposal when txHashes exceed maxTxsPerBlock', async () => { + const validatorWithMaxTxs = validatorFactory(epochCache, { txsPermitted: true, maxTxsPerBlock: 2 }); + const currentProposer = getSigner(); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + txHashes: getTxHashes(3), + }); + + mockGetProposer(getAddress(currentProposer), getAddress()); + const result = await validatorWithMaxTxs.validate(mockProposal); + expect(result).toEqual({ result: 'reject', severity: PeerErrorSeverity.MidToleranceError }); + }); + + it('accepts proposal when txHashes count equals maxTxsPerBlock', async () => { + const validatorWithMaxTxs = validatorFactory(epochCache, { txsPermitted: true, maxTxsPerBlock: 2 }); + const currentProposer = getSigner(); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + txHashes: getTxHashes(2), + }); + + mockGetProposer(getAddress(currentProposer), getAddress()); + const result = await validatorWithMaxTxs.validate(mockProposal); + expect(result).toEqual({ result: 'accept' }); + }); + + it('accepts proposal when maxTxsPerBlock is not set (unlimited)', async () => { + // Default validator has no maxTxsPerBlock + const currentProposer = getSigner(); + const header = makeHeader(1, 100, 100); + const mockProposal = await makeProposal({ + blockHeader: header, + lastBlockHeader: header, + signer: currentProposer, + txHashes: getTxHashes(10), + }); + + mockGetProposer(getAddress(currentProposer), getAddress()); + const result = await validator.validate(mockProposal); + expect(result).toEqual({ result: 'accept' }); + }); + }); }); } diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/allowed_public_setup.ts b/yarn-project/p2p/src/msg_validators/tx_validator/allowed_public_setup.ts index 64578772ea82..b18fa82c4853 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/allowed_public_setup.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/allowed_public_setup.ts @@ -1,33 +1,47 @@ -import { FPCContract } from '@aztec/noir-contracts.js/FPC'; import { TokenContractArtifact } from '@aztec/noir-contracts.js/Token'; import { ProtocolContractAddress } from '@aztec/protocol-contracts'; +import { FunctionSelector } from '@aztec/stdlib/abi'; import { getContractClassFromArtifact } from '@aztec/stdlib/contract'; import type { AllowedElement } from '@aztec/stdlib/interfaces/server'; -let defaultAllowedSetupFunctions: AllowedElement[] | undefined = undefined; +let defaultAllowedSetupFunctions: AllowedElement[] | undefined; + +/** Returns the default list of functions allowed to run in the setup phase of a transaction. */ export async function getDefaultAllowedSetupFunctions(): Promise { if (defaultAllowedSetupFunctions === undefined) { + const tokenClassId = (await getContractClassFromArtifact(TokenContractArtifact)).id; + const setAuthorizedInternalSelector = await FunctionSelector.fromSignature('_set_authorized((Field),Field,bool)'); + const setAuthorizedSelector = await FunctionSelector.fromSignature('set_authorized(Field,bool)'); + const increaseBalanceSelector = await FunctionSelector.fromSignature('_increase_public_balance((Field),u128)'); + const transferInPublicSelector = await FunctionSelector.fromSignature( + 'transfer_in_public((Field),(Field),u128,Field)', + ); + defaultAllowedSetupFunctions = [ - // needed for authwit support + // AuthRegistry: needed for authwit support via private path (set_authorized_private enqueues _set_authorized) + { + address: ProtocolContractAddress.AuthRegistry, + selector: setAuthorizedInternalSelector, + }, + // AuthRegistry: needed for authwit support via public path (PublicFeePaymentMethod calls set_authorized directly) { address: ProtocolContractAddress.AuthRegistry, + selector: setAuthorizedSelector, }, - // needed for claiming on the same tx as a spend + // FeeJuice: needed for claiming on the same tx as a spend (claim_and_end_setup enqueues this) { address: ProtocolContractAddress.FeeJuice, - // We can't restrict the selector because public functions get routed via dispatch. - // selector: FunctionSelector.fromSignature('_increase_public_balance((Field),u128)'), + selector: increaseBalanceSelector, }, - // needed for private transfers via FPC + // Token: needed for private transfers via FPC (transfer_to_public enqueues this) { - classId: (await getContractClassFromArtifact(TokenContractArtifact)).id, - // We can't restrict the selector because public functions get routed via dispatch. - // selector: FunctionSelector.fromSignature('_increase_public_balance((Field),u128)'), + classId: tokenClassId, + selector: increaseBalanceSelector, }, + // Token: needed for public transfers via FPC (fee_entrypoint_public enqueues this) { - classId: (await getContractClassFromArtifact(FPCContract.artifact)).id, - // We can't restrict the selector because public functions get routed via dispatch. - // selector: FunctionSelector.fromSignature('prepare_fee((Field),Field,(Field),Field)'), + classId: tokenClassId, + selector: transferInPublicSelector, }, ]; } diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.test.ts b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.test.ts index 966aaf930f2e..33636eb27d0a 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.test.ts @@ -3,7 +3,11 @@ import type { FunctionSelector } from '@aztec/stdlib/abi'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import { makeAztecAddress, makeSelector, mockTx } from '@aztec/stdlib/testing'; -import { TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED, type Tx } from '@aztec/stdlib/tx'; +import { + TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED, + TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT, + type Tx, +} from '@aztec/stdlib/tx'; import { type MockProxy, mock, mockFn } from 'jest-mock-extended'; @@ -138,4 +142,60 @@ describe('PhasesTxValidator', () => { await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED); }); + + it('rejects address match with wrong selector', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + const wrongSelector = makeSelector(99); + await patchNonRevertibleFn(tx, 0, { address: allowedContract, selector: wrongSelector }); + + await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED); + }); + + it('rejects class match with wrong selector', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + const wrongSelector = makeSelector(99); + const address = await patchNonRevertibleFn(tx, 0, { selector: wrongSelector }); + + contractDataSource.getContract.mockImplementationOnce((contractAddress, atTimestamp) => { + if (timestamp !== atTimestamp) { + throw new Error('Unexpected timestamp'); + } + if (address.equals(contractAddress)) { + return Promise.resolve({ + currentContractClassId: allowedContractClass, + originalContractClassId: Fr.random(), + } as any); + } else { + return Promise.resolve(undefined); + } + }); + + await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED); + }); + + it('rejects with unknown contract error when contract is not found', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + const address = await patchNonRevertibleFn(tx, 0, { selector: allowedSetupSelector1 }); + + contractDataSource.getContract.mockImplementationOnce((contractAddress, atTimestamp) => { + if (timestamp !== atTimestamp) { + throw new Error('Unexpected timestamp'); + } + if (address.equals(contractAddress)) { + return Promise.resolve(undefined); + } + return Promise.resolve(undefined); + }); + + await expectInvalid(tx, TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT); + }); + + it('does not fetch contract instance when matching by address', async () => { + const tx = await mockTx(1, { numberOfNonRevertiblePublicCallRequests: 1 }); + await patchNonRevertibleFn(tx, 0, { address: allowedContract, selector: allowedSetupSelector1 }); + + await expectValid(tx); + + expect(contractDataSource.getContract).not.toHaveBeenCalled(); + }); }); diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts index 4b6370fa0477..3e8c0f9b3313 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/phases_validator.ts @@ -6,6 +6,7 @@ import { type PublicCallRequestWithCalldata, TX_ERROR_DURING_VALIDATION, TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED, + TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT, Tx, TxExecutionPhase, type TxValidationResult, @@ -45,7 +46,8 @@ export class PhasesTxValidator implements TxValidator { const setupFns = getCallRequestsWithCalldataByPhase(tx, TxExecutionPhase.SETUP); for (const setupFn of setupFns) { - if (!(await this.isOnAllowList(setupFn, this.setupAllowList))) { + const rejectionReason = await this.checkAllowList(setupFn, this.setupAllowList); + if (rejectionReason) { this.#log.verbose( `Rejecting tx ${tx.getTxHash().toString()} because it calls setup function not on allow list: ${ setupFn.request.contractAddress @@ -53,7 +55,7 @@ export class PhasesTxValidator implements TxValidator { { allowList: this.setupAllowList }, ); - return { result: 'invalid', reason: [TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED] }; + return { result: 'invalid', reason: [rejectionReason] }; } } @@ -66,53 +68,47 @@ export class PhasesTxValidator implements TxValidator { } } - private async isOnAllowList( + /** Returns a rejection reason if the call is not on the allow list, or undefined if it is allowed. */ + private async checkAllowList( publicCall: PublicCallRequestWithCalldata, allowList: AllowedElement[], - ): Promise { + ): Promise { if (publicCall.isEmpty()) { - return true; + return undefined; } const contractAddress = publicCall.request.contractAddress; const functionSelector = publicCall.functionSelector; - // do these checks first since they don't require the contract class + // Check address-based entries first since they don't require the contract class. for (const entry of allowList) { - if ('address' in entry && !('selector' in entry)) { - if (contractAddress.equals(entry.address)) { - return true; - } - } - - if ('address' in entry && 'selector' in entry) { + if ('address' in entry) { if (contractAddress.equals(entry.address) && entry.selector.equals(functionSelector)) { - return true; + return undefined; } } + } - const contractClass = await this.contractsDB.getContractInstance(contractAddress, this.timestamp); - - if (!contractClass) { - throw new Error(`Contract not found: ${contractAddress}`); + // Check class-based entries. Fetch the contract instance lazily (only once). + let contractClassId: undefined | { value: string | undefined }; + for (const entry of allowList) { + if (!('classId' in entry)) { + continue; } - if ('classId' in entry && !('selector' in entry)) { - if (contractClass.currentContractClassId.equals(entry.classId)) { - return true; + if (contractClassId === undefined) { + const instance = await this.contractsDB.getContractInstance(contractAddress, this.timestamp); + contractClassId = { value: instance?.currentContractClassId.toString() }; + if (!contractClassId.value) { + return TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT; } } - if ('classId' in entry && 'selector' in entry) { - if ( - contractClass.currentContractClassId.equals(entry.classId) && - (entry.selector === undefined || entry.selector.equals(functionSelector)) - ) { - return true; - } + if (contractClassId.value === entry.classId.toString() && entry.selector.equals(functionSelector)) { + return undefined; } } - return false; + return TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED; } } diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index 0122f322ebc0..6066e7d2b6b3 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -222,9 +222,13 @@ export class LibP2PService extends WithTracer implements P2PService { this.protocolVersion, ); - this.blockProposalValidator = new BlockProposalValidator(epochCache, { txsPermitted: !config.disableTransactions }); + this.blockProposalValidator = new BlockProposalValidator(epochCache, { + txsPermitted: !config.disableTransactions, + maxTxsPerBlock: config.maxTxsPerBlock, + }); this.checkpointProposalValidator = new CheckpointProposalValidator(epochCache, { txsPermitted: !config.disableTransactions, + maxTxsPerBlock: config.maxTxsPerBlock, }); this.checkpointAttestationValidator = config.fishermanMode ? new FishermanAttestationValidator(epochCache, mempools.attestationPool, telemetry) @@ -1617,7 +1621,10 @@ export class LibP2PService extends WithTracer implements P2PService { nextSlotTimestamp: UInt64, ): Promise> { const gasFees = await this.getGasFees(currentBlockNumber); - const allowedInSetup = this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); + const allowedInSetup = [ + ...(await getDefaultAllowedSetupFunctions()), + ...(this.config.txPublicSetupAllowListExtend ?? []), + ]; const blockNumber = BlockNumber(currentBlockNumber + 1); return createFirstStageTxValidationsForGossipedTransactions( diff --git a/yarn-project/p2p/src/services/peer-manager/metrics.ts b/yarn-project/p2p/src/services/peer-manager/metrics.ts index 06cd513727db..2e1f198611a4 100644 --- a/yarn-project/p2p/src/services/peer-manager/metrics.ts +++ b/yarn-project/p2p/src/services/peer-manager/metrics.ts @@ -18,6 +18,7 @@ export class PeerManagerMetrics { private sentGoodbyes: UpDownCounter; private receivedGoodbyes: UpDownCounter; private peerCount: Gauge; + private healthyPeerCount: Gauge; private lowScoreDisconnects: UpDownCounter; private peerConnectionDuration: Histogram; @@ -49,6 +50,7 @@ export class PeerManagerMetrics { goodbyeReasonAttrs, ); this.peerCount = meter.createGauge(Metrics.PEER_MANAGER_PEER_COUNT); + this.healthyPeerCount = meter.createGauge(Metrics.PEER_MANAGER_HEALTHY_PEER_COUNT); this.lowScoreDisconnects = createUpDownCounterWithDefault(meter, Metrics.PEER_MANAGER_LOW_SCORE_DISCONNECTS, { [Attributes.P2P_PEER_SCORE_STATE]: ['Banned', 'Disconnect'], }); @@ -67,6 +69,10 @@ export class PeerManagerMetrics { this.peerCount.record(count); } + public recordHealthyPeerCount(count: number) { + this.healthyPeerCount.record(count); + } + public recordLowScoreDisconnect(scoreState: 'Banned' | 'Disconnect') { this.lowScoreDisconnects.add(1, { [Attributes.P2P_PEER_SCORE_STATE]: scoreState }); } @@ -79,6 +85,7 @@ export class PeerManagerMetrics { const connectedAt = this.peerConnectedAt.get(id.toString()); if (connectedAt) { this.peerConnectionDuration.record(Date.now() - connectedAt); + this.peerConnectedAt.delete(id.toString()); } } } diff --git a/yarn-project/p2p/src/services/peer-manager/peer_manager.ts b/yarn-project/p2p/src/services/peer-manager/peer_manager.ts index 336f726b6dec..669f0e149a9c 100644 --- a/yarn-project/p2p/src/services/peer-manager/peer_manager.ts +++ b/yarn-project/p2p/src/services/peer-manager/peer_manager.ts @@ -515,7 +515,8 @@ export class PeerManager implements PeerManagerInterface { ...this.peerScoring.getStats(), }); - this.metrics.recordPeerCount(healthyConnections.length); + this.metrics.recordPeerCount(connections.length); + this.metrics.recordHealthyPeerCount(healthyConnections.length); // Exit if no peers to connect if (peersToConnect <= 0) { diff --git a/yarn-project/prover-node/src/factory.ts b/yarn-project/prover-node/src/factory.ts index c0d3660b09dd..ee312b2ce90d 100644 --- a/yarn-project/prover-node/src/factory.ts +++ b/yarn-project/prover-node/src/factory.ts @@ -19,7 +19,6 @@ import { getPublisherConfigFromProverConfig, } from '@aztec/sequencer-client'; import type { - AztecNode, ITxProvider, ProverConfig, ProvingJobBroker, @@ -38,7 +37,6 @@ import { ProverPublisherFactory } from './prover-publisher-factory.js'; export type ProverNodeDeps = { telemetry?: TelemetryClient; log?: Logger; - aztecNodeTxProvider?: Pick; archiver: Archiver; publisherFactory?: ProverPublisherFactory; broker?: ProvingJobBroker; @@ -128,9 +126,6 @@ export async function createProverNode( telemetry, }); - // TODO(#20393): Check that the tx collection node sources are properly injected - // See aztecNodeTxProvider - const proverNodeConfig = { ...pick( config, diff --git a/yarn-project/prover-node/src/prover-node.test.ts b/yarn-project/prover-node/src/prover-node.test.ts index 4b9c28d2c8b7..9effe18edd71 100644 --- a/yarn-project/prover-node/src/prover-node.test.ts +++ b/yarn-project/prover-node/src/prover-node.test.ts @@ -207,6 +207,15 @@ describe('prover-node', () => { expect(proverNode.totalJobCount).toEqual(0); }); + it('gathers txs via the p2p client tx provider', async () => { + await proverNode.handleEpochReadyToProve(EpochNumber.fromBigInt(10n)); + // The prover node must route tx gathering through the shared p2p client's tx provider + expect(p2p.getTxProvider).toHaveBeenCalled(); + // One call per block across all checkpoints in the epoch + const totalBlocks = checkpoints.flatMap(c => c.blocks).length; + expect(txProvider.getTxsForBlock).toHaveBeenCalledTimes(totalBlocks); + }); + it('does not start a proof if there is a tx missing from coordinator', async () => { txProvider.getTxsForBlock.mockResolvedValue({ missingTxs: [TxHash.random()], txs: [] }); await proverNode.handleEpochReadyToProve(EpochNumber.fromBigInt(10n)); diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index 469651fba387..64a7d321a2a8 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -13,6 +13,7 @@ import { type P2PConfig, p2pConfigMappings } from '@aztec/p2p/config'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type ChainConfig, + DEFAULT_MAX_TXS_PER_BLOCK, type SequencerConfig, chainConfigMappings, sharedSequencerConfigMappings, @@ -37,7 +38,7 @@ export type { SequencerConfig }; */ export const DefaultSequencerConfig: ResolvedSequencerConfig = { sequencerPollingIntervalMS: 500, - maxTxsPerBlock: 32, + maxTxsPerBlock: DEFAULT_MAX_TXS_PER_BLOCK, minTxsPerBlock: 1, buildCheckpointIfEmpty: false, publishTxsWithProposals: false, @@ -52,6 +53,8 @@ export const DefaultSequencerConfig: ResolvedSequencerConfig = { skipInvalidateBlockAsProposer: false, broadcastInvalidBlockProposal: false, injectFakeAttestation: false, + injectHighSValueAttestation: false, + injectUnrecoverableSignatureAttestation: false, fishermanMode: false, shuffleAttestationOrdering: false, skipPushProposedBlocksToArchiver: false, @@ -68,7 +71,7 @@ export type SequencerClientConfig = SequencerPublisherConfig & SequencerConfig & L1ReaderConfig & ChainConfig & - Pick & + Pick & Pick; export const sequencerConfigMappings: ConfigMappingsType = { @@ -77,11 +80,6 @@ export const sequencerConfigMappings: ConfigMappingsType = { description: 'The number of ms to wait between polling for checking to build on the next slot.', ...numberConfigHelper(DefaultSequencerConfig.sequencerPollingIntervalMS), }, - maxTxsPerBlock: { - env: 'SEQ_MAX_TX_PER_BLOCK', - description: 'The maximum number of txs to include in a block.', - ...numberConfigHelper(DefaultSequencerConfig.maxTxsPerBlock), - }, minTxsPerBlock: { env: 'SEQ_MIN_TX_PER_BLOCK', description: 'The minimum number of txs to include in a block.', @@ -186,6 +184,14 @@ export const sequencerConfigMappings: ConfigMappingsType = { description: 'Inject a fake attestation (for testing only)', ...booleanConfigHelper(DefaultSequencerConfig.injectFakeAttestation), }, + injectHighSValueAttestation: { + description: 'Inject a malleable attestation with a high-s value (for testing only)', + ...booleanConfigHelper(DefaultSequencerConfig.injectHighSValueAttestation), + }, + injectUnrecoverableSignatureAttestation: { + description: 'Inject an attestation with an unrecoverable signature (for testing only)', + ...booleanConfigHelper(DefaultSequencerConfig.injectUnrecoverableSignatureAttestation), + }, fishermanMode: { env: 'FISHERMAN_MODE', description: @@ -214,7 +220,7 @@ export const sequencerConfigMappings: ConfigMappingsType = { description: 'Percent probability (0 - 100) of sequencer skipping checkpoint publishing (testing only)', ...numberConfigHelper(DefaultSequencerConfig.skipPublishingCheckpointsPercent), }, - ...pickConfigMappings(p2pConfigMappings, ['txPublicSetupAllowList']), + ...pickConfigMappings(p2pConfigMappings, ['txPublicSetupAllowListExtend']), }; export const sequencerClientConfigMappings: ConfigMappingsType = { diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts index c58bc4d40afd..0583f969d49e 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.ts @@ -81,8 +81,23 @@ export class SequencerPublisherFactory { const rollup = this.deps.rollupContract; const slashingProposerContract = await rollup.getSlashingProposer(); + const getNextPublisher = async (excludeAddresses: EthAddress[]): Promise => { + const exclusionFilter: PublisherFilter = (utils: L1TxUtils) => { + if (excludeAddresses.some(addr => addr.equals(utils.getSenderAddress()))) { + return false; + } + return filter(utils); + }; + try { + return await this.deps.publisherManager.getAvailablePublisher(exclusionFilter); + } catch { + return undefined; + } + }; + const publisher = new SequencerPublisher(this.sequencerConfig, { l1TxUtils: l1Publisher, + getNextPublisher, telemetry: this.deps.telemetry, blobClient: this.deps.blobClient, rollupContract: this.deps.rollupContract, diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts index d944412263d5..70fbaa1ebc2c 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts @@ -16,6 +16,7 @@ import { } from '@aztec/ethereum/l1-tx-utils'; import { FormattedViemError } from '@aztec/ethereum/utils'; import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { TimeoutError } from '@aztec/foundation/error'; import { EthAddress } from '@aztec/foundation/eth-address'; import { sleep } from '@aztec/foundation/sleep'; import { TestDateProvider } from '@aztec/foundation/timer'; @@ -299,6 +300,140 @@ describe('SequencerPublisher', () => { expect(result).toEqual(undefined); }); + describe('publisher rotation on send failure', () => { + let secondL1TxUtils: MockProxy; + let getNextPublisher: jest.MockedFunction<(excludeAddresses: EthAddress[]) => Promise>; + let rotatingPublisher: SequencerPublisher; + + beforeEach(() => { + secondL1TxUtils = mock(); + secondL1TxUtils.getBlockNumber.mockResolvedValue(1n); + secondL1TxUtils.getSenderAddress.mockReturnValue(EthAddress.random()); + secondL1TxUtils.getSenderBalance.mockResolvedValue(1000n); + + getNextPublisher = jest.fn(); + + const epochCache = mock(); + epochCache.getEpochAndSlotNow.mockReturnValue({ + epoch: EpochNumber(1), + slot: SlotNumber(2), + ts: 3n, + nowMs: 3000n, + }); + epochCache.getCommittee.mockResolvedValue({ + committee: [], + seed: 1n, + epoch: EpochNumber(1), + isEscapeHatchOpen: false, + }); + + rotatingPublisher = new SequencerPublisher({ ethereumSlotDuration: 12, l1ChainId: 1 } as any, { + blobClient, + rollupContract: rollup, + l1TxUtils, + epochCache, + slashingProposerContract, + governanceProposerContract, + slashFactoryContract, + dateProvider: new TestDateProvider(), + metrics: l1Metrics, + lastActions: {}, + getNextPublisher, + }); + }); + + it('rotates to next publisher when forward throws and retries successfully', async () => { + forwardSpy + .mockRejectedValueOnce(new Error('RPC error')) + .mockResolvedValueOnce({ receipt: proposeTxReceipt, errorMsg: undefined }); + getNextPublisher.mockResolvedValueOnce(secondL1TxUtils); + + await rotatingPublisher.enqueueProposeCheckpoint( + new Checkpoint(l2Block.archive, header, [l2Block], l2Block.checkpointNumber), + CommitteeAttestationsAndSigners.empty(), + Signature.empty(), + ); + const result = await rotatingPublisher.sendRequests(); + + expect(forwardSpy).toHaveBeenCalledTimes(2); + // First call uses original publisher, second uses the rotated one + expect(forwardSpy).toHaveBeenNthCalledWith( + 1, + expect.anything(), + l1TxUtils, + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + ); + expect(forwardSpy).toHaveBeenNthCalledWith( + 2, + expect.anything(), + secondL1TxUtils, + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + ); + expect(getNextPublisher).toHaveBeenCalledWith([l1TxUtils.getSenderAddress()]); + // Result is defined (rotation succeeded and tx was sent) + expect(result).toBeDefined(); + expect(result?.sentActions).toContain('propose'); + // l1TxUtils updated to the one that succeeded + expect(rotatingPublisher.l1TxUtils).toBe(secondL1TxUtils); + }); + + it('does not rotate on TimeoutError, re-throws instead', async () => { + forwardSpy.mockRejectedValueOnce(new TimeoutError('timed out')); + + await rotatingPublisher.enqueueProposeCheckpoint( + new Checkpoint(l2Block.archive, header, [l2Block], l2Block.checkpointNumber), + CommitteeAttestationsAndSigners.empty(), + Signature.empty(), + ); + // TimeoutError propagates to the outer catch in sendRequests which returns undefined + const result = await rotatingPublisher.sendRequests(); + + expect(result).toBeUndefined(); + expect(getNextPublisher).not.toHaveBeenCalled(); + expect(forwardSpy).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when all publishers are exhausted', async () => { + forwardSpy + .mockRejectedValueOnce(new Error('RPC error on first')) + .mockRejectedValueOnce(new Error('RPC error on second')); + getNextPublisher.mockResolvedValueOnce(secondL1TxUtils).mockResolvedValueOnce(undefined); + + await rotatingPublisher.enqueueProposeCheckpoint( + new Checkpoint(l2Block.archive, header, [l2Block], l2Block.checkpointNumber), + CommitteeAttestationsAndSigners.empty(), + Signature.empty(), + ); + const result = await rotatingPublisher.sendRequests(); + + expect(forwardSpy).toHaveBeenCalledTimes(2); + expect(getNextPublisher).toHaveBeenCalledTimes(2); + expect(result).toBeUndefined(); + }); + + it('does not rotate when forward returns a revert (on-chain failure)', async () => { + forwardSpy.mockResolvedValue({ receipt: { ...proposeTxReceipt, status: 'reverted' }, errorMsg: 'revert reason' }); + + await rotatingPublisher.enqueueProposeCheckpoint( + new Checkpoint(l2Block.archive, header, [l2Block], l2Block.checkpointNumber), + CommitteeAttestationsAndSigners.empty(), + Signature.empty(), + ); + const result = await rotatingPublisher.sendRequests(); + + expect(forwardSpy).toHaveBeenCalledTimes(1); + expect(getNextPublisher).not.toHaveBeenCalled(); + // Result contains the reverted receipt (no rotation) + expect(result?.result).toMatchObject({ receipt: { status: 'reverted' } }); + }); + }); + it('does not send propose tx if rollup validation fails', async () => { l1TxUtils.simulate.mockRejectedValueOnce(new Error('Test error')); diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index c147e81facda..7398d2fa67cb 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -30,6 +30,7 @@ import { toHex as toPaddedHex } from '@aztec/foundation/bigint-buffer'; import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { pick } from '@aztec/foundation/collection'; import type { Fr } from '@aztec/foundation/curves/bn254'; +import { TimeoutError } from '@aztec/foundation/error'; import { EthAddress } from '@aztec/foundation/eth-address'; import { Signature, type ViemSignature } from '@aztec/foundation/eth-signature'; import { type Logger, createLogger } from '@aztec/foundation/log'; @@ -137,6 +138,9 @@ export class SequencerPublisher { /** Address to use for simulations in fisherman mode (actual proposer's address) */ private proposerAddressForSimulation?: EthAddress; + /** Optional callback to obtain a replacement publisher when the current one fails to send. */ + private getNextPublisher?: (excludeAddresses: EthAddress[]) => Promise; + /** L1 fee analyzer for fisherman mode */ private l1FeeAnalyzer?: L1FeeAnalyzer; @@ -175,6 +179,7 @@ export class SequencerPublisher { metrics: SequencerPublisherMetrics; lastActions: Partial>; log?: Logger; + getNextPublisher?: (excludeAddresses: EthAddress[]) => Promise; }, ) { this.log = deps.log ?? createLogger('sequencer:publisher'); @@ -188,6 +193,7 @@ export class SequencerPublisher { this.metrics = deps.metrics ?? new SequencerPublisherMetrics(telemetry, 'SequencerPublisher'); this.tracer = telemetry.getTracer('SequencerPublisher'); this.l1TxUtils = deps.l1TxUtils; + this.getNextPublisher = deps.getNextPublisher; this.rollupContract = deps.rollupContract; @@ -437,19 +443,16 @@ export class SequencerPublisher { }); const blobDataHex = blobConfig?.blobs?.map(b => toHex(b)) as Hex[] | undefined; + const txContext = { multicallData, blobData: blobDataHex, l1BlockNumber }; + this.log.debug('Forwarding transactions', { validRequests: validRequests.map(request => request.action), txConfig, }); - const result = await Multicall3.forward( - validRequests.map(request => request.request), - this.l1TxUtils, - txConfig, - blobConfig, - this.rollupContract.address, - this.log, - ); - const txContext = { multicallData, blobData: blobDataHex, l1BlockNumber }; + const result = await this.forwardWithPublisherRotation(validRequests, txConfig, blobConfig); + if (result === undefined) { + return undefined; + } const { successfulActions = [], failedActions = [] } = this.callbackBundledTransactions( validRequests, result, @@ -472,6 +475,55 @@ export class SequencerPublisher { } } + /** + * Forwards transactions via Multicall3, rotating to the next available publisher if a send + * failure occurs (i.e. the tx never reached the chain). + * On-chain reverts and simulation errors are returned as-is without rotation. + */ + private async forwardWithPublisherRotation( + validRequests: RequestWithExpiry[], + txConfig: RequestWithExpiry['gasConfig'], + blobConfig: L1BlobInputs | undefined, + ) { + const triedAddresses: EthAddress[] = []; + let currentPublisher = this.l1TxUtils; + + while (true) { + triedAddresses.push(currentPublisher.getSenderAddress()); + try { + const result = await Multicall3.forward( + validRequests.map(r => r.request), + currentPublisher, + txConfig, + blobConfig, + this.rollupContract.address, + this.log, + ); + this.l1TxUtils = currentPublisher; + return result; + } catch (err) { + if (err instanceof TimeoutError) { + throw err; + } + const viemError = formatViemError(err); + if (!this.getNextPublisher) { + this.log.error('Failed to publish bundled transactions', viemError); + return undefined; + } + this.log.warn( + `Publisher ${currentPublisher.getSenderAddress()} failed to send, rotating to next publisher`, + viemError, + ); + const nextPublisher = await this.getNextPublisher([...triedAddresses]); + if (!nextPublisher) { + this.log.error('All available publishers exhausted, failed to publish bundled transactions'); + return undefined; + } + currentPublisher = nextPublisher; + } + } + } + private callbackBundledTransactions( requests: RequestWithExpiry[], result: { receipt: TransactionReceipt; errorMsg?: string } | FormattedViemError | undefined, diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 184e83a76506..466d2f259f17 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -9,6 +9,11 @@ import { SlotNumber, } from '@aztec/foundation/branded-types'; import { randomInt } from '@aztec/foundation/crypto/random'; +import { + flipSignature, + generateRecoverableSignature, + generateUnrecoverableSignature, +} from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { Signature } from '@aztec/foundation/eth-signature'; @@ -759,7 +764,12 @@ export class CheckpointProposalJob implements Traceable { const sorted = orderAttestations(trimmed, committee); // Manipulate the attestations if we've been configured to do so - if (this.config.injectFakeAttestation || this.config.shuffleAttestationOrdering) { + if ( + this.config.injectFakeAttestation || + this.config.injectHighSValueAttestation || + this.config.injectUnrecoverableSignatureAttestation || + this.config.shuffleAttestationOrdering + ) { return this.manipulateAttestations(proposal.slotNumber, epoch, seed, committee, sorted); } @@ -788,7 +798,11 @@ export class CheckpointProposalJob implements Traceable { this.epochCache.computeProposerIndex(slotNumber, epoch, seed, BigInt(committee.length)), ); - if (this.config.injectFakeAttestation) { + if ( + this.config.injectFakeAttestation || + this.config.injectHighSValueAttestation || + this.config.injectUnrecoverableSignatureAttestation + ) { // Find non-empty attestations that are not from the proposer const nonProposerIndices: number[] = []; for (let i = 0; i < attestations.length; i++) { @@ -798,8 +812,20 @@ export class CheckpointProposalJob implements Traceable { } if (nonProposerIndices.length > 0) { const targetIndex = nonProposerIndices[randomInt(nonProposerIndices.length)]; - this.log.warn(`Injecting fake attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`); - unfreeze(attestations[targetIndex]).signature = Signature.random(); + if (this.config.injectHighSValueAttestation) { + this.log.warn( + `Injecting high-s value attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`, + ); + unfreeze(attestations[targetIndex]).signature = flipSignature(attestations[targetIndex].signature); + } else if (this.config.injectUnrecoverableSignatureAttestation) { + this.log.warn( + `Injecting unrecoverable signature attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`, + ); + unfreeze(attestations[targetIndex]).signature = generateUnrecoverableSignature(); + } else { + this.log.warn(`Injecting fake attestation in checkpoint for slot ${slotNumber} at index ${targetIndex}`); + unfreeze(attestations[targetIndex]).signature = generateRecoverableSignature(); + } } return new CommitteeAttestationsAndSigners(attestations); } @@ -808,11 +834,20 @@ export class CheckpointProposalJob implements Traceable { this.log.warn(`Shuffling attestation ordering in checkpoint for slot ${slotNumber} (proposer #${proposerIndex})`); const shuffled = [...attestations]; - const [i, j] = [(proposerIndex + 1) % shuffled.length, (proposerIndex + 2) % shuffled.length]; - const valueI = shuffled[i]; - const valueJ = shuffled[j]; - shuffled[i] = valueJ; - shuffled[j] = valueI; + + // Find two non-proposer positions that both have non-empty signatures to swap. + // This ensures the bitmap doesn't change, so the MaliciousCommitteeAttestationsAndSigners + // signers array stays correctly aligned with L1's committee reconstruction. + const swappable: number[] = []; + for (let k = 0; k < shuffled.length; k++) { + if (!shuffled[k].signature.isEmpty() && k !== proposerIndex) { + swappable.push(k); + } + } + if (swappable.length >= 2) { + const [i, j] = [swappable[0], swappable[1]]; + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } const signers = new CommitteeAttestationsAndSigners(attestations).getSigners(); return new MaliciousCommitteeAttestationsAndSigners(shuffled, signers); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 527f7144bf60..7da938a6c828 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -110,7 +110,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter) { const filteredConfig = pickFromSchema(config, SequencerConfigSchema); - this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowList')); + this.log.info(`Updated sequencer config`, omit(filteredConfig, 'txPublicSetupAllowListExtend')); this.config = merge(this.config, filteredConfig); this.timetable = new SequencerTimetable( { diff --git a/yarn-project/slasher/README.md b/yarn-project/slasher/README.md index dbd4454cf1eb..b5270720fa9a 100644 --- a/yarn-project/slasher/README.md +++ b/yarn-project/slasher/README.md @@ -185,7 +185,7 @@ These settings are configured locally on each validator node: - `slashProposeInvalidAttestationsPenalty`: Penalty for PROPOSED_INSUFFICIENT_ATTESTATIONS and PROPOSED_INCORRECT_ATTESTATIONS - `slashAttestDescendantOfInvalidPenalty`: Penalty for ATTESTED_DESCENDANT_OF_INVALID - `slashUnknownPenalty`: Default penalty for unknown offense types -- `slashMaxPayloadSize`: Maximum size of slash payloads (empire model) +- `slashMaxPayloadSize`: Maximum size of slash payloads. In the empire model this limits offenses per payload; in the tally model it limits offenses considered when building the vote for a round (same prioritization: uncontroversial first, then by amount and age), so that execution payload stays within gas limits. - `slashMinPenaltyPercentage`: Agree to slashes if they are at least this percentage of the configured penalty (empire model) - `slashMaxPenaltyPercentage`: Agree to slashes if they are at most this percentage of the configured penalty (empire model) diff --git a/yarn-project/slasher/src/config.ts b/yarn-project/slasher/src/config.ts index 79cef1e58b1a..646225cc6e20 100644 --- a/yarn-project/slasher/src/config.ts +++ b/yarn-project/slasher/src/config.ts @@ -155,7 +155,8 @@ export const slasherConfigMappings: ConfigMappingsType = { ...numberConfigHelper(DefaultSlasherConfig.slashMaxPayloadSize), }, slashGracePeriodL2Slots: { - description: 'Number of L2 slots to wait before considering a slashing offense expired.', + description: + 'Number of L2 slots after the network upgrade during which slashing offenses are ignored. The upgrade time is determined from the CanonicalRollupUpdated event.', env: 'SLASH_GRACE_PERIOD_L2_SLOTS', ...numberConfigHelper(DefaultSlasherConfig.slashGracePeriodL2Slots), }, diff --git a/yarn-project/slasher/src/empire_slasher_client.test.ts b/yarn-project/slasher/src/empire_slasher_client.test.ts index 4aa40aee11f9..1bff7848fdc5 100644 --- a/yarn-project/slasher/src/empire_slasher_client.test.ts +++ b/yarn-project/slasher/src/empire_slasher_client.test.ts @@ -50,6 +50,7 @@ describe('EmpireSlasherClient', () => { slotDuration: 4, ethereumSlotDuration: 12, slashingAmounts: undefined, + rollupRegisteredAtL2Slot: SlotNumber(0), }; const config: SlasherConfig = { diff --git a/yarn-project/slasher/src/factory/create_facade.ts b/yarn-project/slasher/src/factory/create_facade.ts index af6f7ed9cd6c..6787fc65ce75 100644 --- a/yarn-project/slasher/src/factory/create_facade.ts +++ b/yarn-project/slasher/src/factory/create_facade.ts @@ -1,13 +1,15 @@ import { EpochCache } from '@aztec/epoch-cache'; -import { RollupContract } from '@aztec/ethereum/contracts'; +import { RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; import type { L1ReaderConfig } from '@aztec/ethereum/l1-reader'; import type { ViemClient } from '@aztec/ethereum/types'; +import { SlotNumber } from '@aztec/foundation/branded-types'; import { unique } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; import { DateProvider } from '@aztec/foundation/timer'; import type { DataStoreConfig } from '@aztec/kv-store/config'; import { createStore } from '@aztec/kv-store/lmdb-v2'; +import { getSlotAtTimestamp } from '@aztec/stdlib/epoch-helpers'; import type { SlasherConfig } from '@aztec/stdlib/interfaces/server'; import { SlasherClientFacade } from '../slasher_client_facade.js'; @@ -18,7 +20,7 @@ import type { Watcher } from '../watcher.js'; /** Creates a slasher client facade that updates itself whenever the rollup slasher changes */ export async function createSlasherFacade( config: SlasherConfig & DataStoreConfig & { ethereumSlotDuration: number }, - l1Contracts: Pick, + l1Contracts: Pick, l1Client: ViemClient, watchers: Watcher[], dateProvider: DateProvider, @@ -34,6 +36,32 @@ export async function createSlasherFacade( const kvStore = await createStore('slasher', SCHEMA_VERSION, config, logger.getBindings()); const rollup = new RollupContract(l1Client, l1Contracts.rollupAddress); + // Compute and cache the L2 slot at which the rollup was registered as canonical + const settingsMap = kvStore.openMap('slasher-settings'); + const cacheKey = `registeredSlot:${l1Contracts.rollupAddress}`; + let rollupRegisteredAtL2Slot = (await settingsMap.getAsync(cacheKey)) as SlotNumber | undefined; + + if (rollupRegisteredAtL2Slot === undefined) { + const registry = new RegistryContract(l1Client, l1Contracts.registryAddress); + const l1StartBlock = await rollup.getL1StartBlock(); + const registrationTimestamp = await registry.getCanonicalRollupRegistrationTimestamp( + l1Contracts.rollupAddress, + l1StartBlock, + ); + if (registrationTimestamp !== undefined) { + const l1GenesisTime = await rollup.getL1GenesisTime(); + const slotDuration = await rollup.getSlotDuration(); + rollupRegisteredAtL2Slot = getSlotAtTimestamp(registrationTimestamp, { + l1GenesisTime, + slotDuration: Number(slotDuration), + }); + } else { + rollupRegisteredAtL2Slot = SlotNumber(0); + } + await settingsMap.set(cacheKey, rollupRegisteredAtL2Slot); + logger.info(`Canonical rollup registered at L2 slot ${rollupRegisteredAtL2Slot}`); + } + const slashValidatorsNever = config.slashSelfAllowed ? config.slashValidatorsNever : unique([...config.slashValidatorsNever, ...validatorAddresses].map(a => a.toString())).map(EthAddress.fromString); @@ -48,6 +76,7 @@ export async function createSlasherFacade( epochCache, dateProvider, kvStore, + rollupRegisteredAtL2Slot, logger, ); } diff --git a/yarn-project/slasher/src/factory/create_implementation.ts b/yarn-project/slasher/src/factory/create_implementation.ts index d793f3285709..0c6eb8ce6d76 100644 --- a/yarn-project/slasher/src/factory/create_implementation.ts +++ b/yarn-project/slasher/src/factory/create_implementation.ts @@ -5,6 +5,7 @@ import { TallySlashingProposerContract, } from '@aztec/ethereum/contracts'; import type { ViemClient } from '@aztec/ethereum/types'; +import type { SlotNumber } from '@aztec/foundation/branded-types'; import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; import { DateProvider } from '@aztec/foundation/timer'; @@ -31,19 +32,40 @@ export async function createSlasherImplementation( epochCache: EpochCache, dateProvider: DateProvider, kvStore: AztecLMDBStoreV2, + rollupRegisteredAtL2Slot: SlotNumber, logger = createLogger('slasher'), ) { const proposer = await rollup.getSlashingProposer(); if (!proposer) { return new NullSlasherClient(config); } else if (proposer.type === 'tally') { - return createTallySlasher(config, rollup, proposer, watchers, dateProvider, epochCache, kvStore, logger); + return createTallySlasher( + config, + rollup, + proposer, + watchers, + dateProvider, + epochCache, + kvStore, + rollupRegisteredAtL2Slot, + logger, + ); } else { if (!slashFactoryAddress || slashFactoryAddress.equals(EthAddress.ZERO)) { throw new Error('Cannot initialize an empire-based SlasherClient without a SlashFactory address'); } const slashFactory = new SlashFactoryContract(l1Client, slashFactoryAddress.toString()); - return createEmpireSlasher(config, rollup, proposer, slashFactory, watchers, dateProvider, kvStore, logger); + return createEmpireSlasher( + config, + rollup, + proposer, + slashFactory, + watchers, + dateProvider, + kvStore, + rollupRegisteredAtL2Slot, + logger, + ); } } @@ -55,6 +77,7 @@ async function createEmpireSlasher( watchers: Watcher[], dateProvider: DateProvider, kvStore: AztecLMDBStoreV2, + rollupRegisteredAtL2Slot: SlotNumber, logger = createLogger('slasher'), ): Promise { if (slashingProposer.type !== 'empire') { @@ -97,6 +120,7 @@ async function createEmpireSlasher( l1StartBlock, ethereumSlotDuration: config.ethereumSlotDuration, slashingAmounts: undefined, + rollupRegisteredAtL2Slot, }; const payloadsStore = new SlasherPayloadsStore(kvStore, { @@ -130,13 +154,14 @@ async function createTallySlasher( dateProvider: DateProvider, epochCache: EpochCache, kvStore: AztecLMDBStoreV2, + rollupRegisteredAtL2Slot: SlotNumber, logger = createLogger('slasher'), ): Promise { if (slashingProposer.type !== 'tally') { throw new Error('Slashing proposer contract is not of type tally'); } - const settings = await getTallySlasherSettings(rollup, slashingProposer); + const settings = { ...(await getTallySlasherSettings(rollup, slashingProposer)), rollupRegisteredAtL2Slot }; const slasher = await rollup.getSlasherContract(); const offensesStore = new SlasherOffensesStore(kvStore, { diff --git a/yarn-project/slasher/src/factory/get_settings.ts b/yarn-project/slasher/src/factory/get_settings.ts index 078073847e13..6fd10662edcd 100644 --- a/yarn-project/slasher/src/factory/get_settings.ts +++ b/yarn-project/slasher/src/factory/get_settings.ts @@ -5,7 +5,7 @@ import type { TallySlasherSettings } from '../tally_slasher_client.js'; export async function getTallySlasherSettings( rollup: RollupContract, slashingProposer?: TallySlashingProposerContract, -): Promise { +): Promise> { if (!slashingProposer) { const rollupSlashingProposer = await rollup.getSlashingProposer(); if (!rollupSlashingProposer || rollupSlashingProposer.type !== 'tally') { @@ -40,7 +40,7 @@ export async function getTallySlasherSettings( rollup.getTargetCommitteeSize(), ]); - const settings: TallySlasherSettings = { + const settings: Omit = { slashingExecutionDelayInRounds: Number(slashingExecutionDelayInRounds), slashingRoundSize: Number(slashingRoundSize), slashingRoundSizeInEpochs: Number(slashingRoundSizeInEpochs), diff --git a/yarn-project/slasher/src/slash_offenses_collector.test.ts b/yarn-project/slasher/src/slash_offenses_collector.test.ts index f23d1824fb18..3eeeb532494e 100644 --- a/yarn-project/slasher/src/slash_offenses_collector.test.ts +++ b/yarn-project/slasher/src/slash_offenses_collector.test.ts @@ -1,3 +1,4 @@ +import { SlotNumber } from '@aztec/foundation/branded-types'; import { EthAddress } from '@aztec/foundation/eth-address'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { openTmpStore } from '@aztec/kv-store/lmdb'; @@ -18,6 +19,7 @@ describe('SlashOffensesCollector', () => { const settings: SlashOffensesCollectorSettings = { epochDuration: 32, slashingAmounts: [100n, 200n, 300n], + rollupRegisteredAtL2Slot: 100 as SlotNumber, }; const config: SlasherConfig = { @@ -90,27 +92,28 @@ describe('SlashOffensesCollector', () => { }); }); - it('should skip offenses that happen during grace period', async () => { + it('should skip offenses that happen during grace period after upgrade', async () => { const validator1 = EthAddress.random(); const validator2 = EthAddress.random(); - // Create offense during grace period (slot < slashGracePeriodL2Slots = 10) + // Grace period is registeredSlot (100) + gracePeriodL2Slots (10) = 110 + // Create offense during grace period (slot 105 < 110) const gracePeriodOffense: WantToSlashArgs[] = [ { validator: validator1, amount: 1000000000000000000n, offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, // Slot-based offense - epochOrSlot: 5n, // Within grace period (< 10) + epochOrSlot: 105n, // Within grace period (< 110) }, ]; - // Create offense after grace period + // Create offense after grace period (slot 115 >= 110) const validOffense: WantToSlashArgs[] = [ { validator: validator2, amount: 2000000000000000000n, offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, // Slot-based offense - epochOrSlot: 20n, // After grace period (>= 10) + epochOrSlot: 115n, // After grace period (>= 110) }, ]; @@ -134,25 +137,26 @@ describe('SlashOffensesCollector', () => { const validator2 = EthAddress.random(); const validator3 = EthAddress.random(); - // Create an event with multiple offenses in a single array + // Grace period ends at registeredSlot (100) + gracePeriod (10) = 110 + // All offenses are after the grace period const multipleOffensesArgs: WantToSlashArgs[] = [ { validator: validator1, amount: 1000000000000000000n, offenseType: OffenseType.INACTIVITY, - epochOrSlot: 100n, + epochOrSlot: 100n, // epoch 100 → slot 3200, well past grace period }, { validator: validator2, amount: 2000000000000000000n, offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, - epochOrSlot: 50n, + epochOrSlot: 150n, // slot 150 >= 110 }, { validator: validator3, amount: 1500000000000000000n, offenseType: OffenseType.ATTESTED_DESCENDANT_OF_INVALID, - epochOrSlot: 75n, + epochOrSlot: 175n, // slot 175 >= 110 }, ]; @@ -182,14 +186,14 @@ describe('SlashOffensesCollector', () => { validator: validator2, amount: 2000000000000000000n, offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, - epochOrSlot: 50n, + epochOrSlot: 150n, }); expect(offensesByValidator[validator3.toString()]).toMatchObject({ validator: validator3, amount: 1500000000000000000n, offenseType: OffenseType.ATTESTED_DESCENDANT_OF_INVALID, - epochOrSlot: 75n, + epochOrSlot: 175n, }); }); }); diff --git a/yarn-project/slasher/src/slash_offenses_collector.ts b/yarn-project/slasher/src/slash_offenses_collector.ts index 551f868ccec3..59cc7a0e1dc6 100644 --- a/yarn-project/slasher/src/slash_offenses_collector.ts +++ b/yarn-project/slasher/src/slash_offenses_collector.ts @@ -1,3 +1,4 @@ +import type { SlotNumber } from '@aztec/foundation/branded-types'; import { createLogger } from '@aztec/foundation/log'; import type { Prettify } from '@aztec/foundation/types'; import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; @@ -9,7 +10,11 @@ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher } from './watch export type SlashOffensesCollectorConfig = Prettify>; export type SlashOffensesCollectorSettings = Prettify< - Pick & { slashingAmounts: [bigint, bigint, bigint] | undefined } + Pick & { + slashingAmounts: [bigint, bigint, bigint] | undefined; + /** L2 slot at which the rollup was registered as canonical in the Registry. Used to anchor the slash grace period. */ + rollupRegisteredAtL2Slot: SlotNumber; + } >; /** @@ -110,9 +115,9 @@ export class SlashOffensesCollector { return this.offensesStore.markAsSlashed(offenses); } - /** Returns whether to skip an offense if it happened during the grace period at the beginning of the chain */ + /** Returns whether to skip an offense if it happened during the grace period after the network upgrade */ private shouldSkipOffense(offense: Offense): boolean { const offenseSlot = getSlotForOffense(offense, this.settings); - return offenseSlot < this.config.slashGracePeriodL2Slots; + return offenseSlot < this.settings.rollupRegisteredAtL2Slot + this.config.slashGracePeriodL2Slots; } } diff --git a/yarn-project/slasher/src/slasher_client_facade.ts b/yarn-project/slasher/src/slasher_client_facade.ts index 943084816870..0ef4a677ac0a 100644 --- a/yarn-project/slasher/src/slasher_client_facade.ts +++ b/yarn-project/slasher/src/slasher_client_facade.ts @@ -32,6 +32,7 @@ export class SlasherClientFacade implements SlasherClientInterface { private epochCache: EpochCache, private dateProvider: DateProvider, private kvStore: AztecLMDBStoreV2, + private rollupRegisteredAtL2Slot: SlotNumber, private logger = createLogger('slasher'), ) {} @@ -88,6 +89,7 @@ export class SlasherClientFacade implements SlasherClientInterface { this.epochCache, this.dateProvider, this.kvStore, + this.rollupRegisteredAtL2Slot, this.logger, ); } diff --git a/yarn-project/slasher/src/tally_slasher_client.test.ts b/yarn-project/slasher/src/tally_slasher_client.test.ts index 9ca14eaa7a44..72bc53d93097 100644 --- a/yarn-project/slasher/src/tally_slasher_client.test.ts +++ b/yarn-project/slasher/src/tally_slasher_client.test.ts @@ -50,6 +50,7 @@ describe('TallySlasherClient', () => { l1GenesisTime: BigInt(Math.floor(Date.now() / 1000) - 10000), slotDuration: 4, slashingQuorumSize: 110, + rollupRegisteredAtL2Slot: SlotNumber(0), }; const config: SlasherConfig = { @@ -519,6 +520,99 @@ describe('TallySlasherClient', () => { expect(actions).toHaveLength(1); expect(actions[0].type).toBe('vote-offenses'); }); + + it('should return all offenses for the round regardless of slashMaxPayloadSize', async () => { + const currentRound = 5n; + const targetRound = 3n; // currentRound - offset(2) + const baseSlot = targetRound * BigInt(roundSize); + + // slashMaxPayloadSize has no effect on gatherOffensesForRound; truncation happens later + // in getSlashConsensusVotesFromOffenses after always-slash offenses are merged in. + tallySlasherClient.updateConfig({ slashMaxPayloadSize: 2 }); + + await addPendingOffense({ + validator: committee[0], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[0], + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[1], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[2], + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[2], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[1], + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + const offenses = await tallySlasherClient.gatherOffensesForRound(currentRound); + + expect(offenses).toHaveLength(3); + }); + + it('should return all offenses for the round', async () => { + const currentRound = 5n; + const targetRound = 3n; + const baseSlot = targetRound * BigInt(roundSize); + + await addPendingOffense({ + validator: committee[0], + epochOrSlot: baseSlot, + amount: slashingUnit, + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[1], + epochOrSlot: baseSlot, + amount: slashingUnit * 2n, + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + const offenses = await tallySlasherClient.gatherOffensesForRound(currentRound); + expect(offenses).toHaveLength(2); + }); + + it('should produce a valid vote action respecting slashMaxPayloadSize', async () => { + const currentRound = 5n; + const targetRound = 3n; + const baseSlot = targetRound * BigInt(roundSize); + + // Cap at 1 slashed validator-epoch pair; the highest-amount validator should survive + tallySlasherClient.updateConfig({ slashMaxPayloadSize: 1 }); + + await addPendingOffense({ + validator: committee[0], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[0], + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[1], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[2], + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + await addPendingOffense({ + validator: committee[2], + epochOrSlot: baseSlot, + amount: settings.slashingAmounts[1], + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + const currentSlot = currentRound * BigInt(roundSize); + const action = await tallySlasherClient.getVoteOffensesAction(SlotNumber.fromBigInt(currentSlot)); + + expect(action).toBeDefined(); + assert(action!.type === 'vote-offenses'); + // Only committee[1] (3 units, highest) survives; the others are zeroed out + expect(action!.votes[0]).toBe(0); // committee[0]: 1 unit, dropped + expect(action!.votes[1]).toBe(3); // committee[1]: 3 units, kept + expect(action!.votes[2]).toBe(0); // committee[2]: 2 units, dropped + }); }); describe('getSlashPayloads', () => { diff --git a/yarn-project/slasher/src/tally_slasher_client.ts b/yarn-project/slasher/src/tally_slasher_client.ts index 70ef6fdfeeb6..d226775b7b92 100644 --- a/yarn-project/slasher/src/tally_slasher_client.ts +++ b/yarn-project/slasher/src/tally_slasher_client.ts @@ -46,7 +46,10 @@ export type TallySlasherSettings = Prettify< >; export type TallySlasherClientConfig = SlashOffensesCollectorConfig & - Pick; + Pick< + SlasherConfig, + 'slashValidatorsAlways' | 'slashValidatorsNever' | 'slashExecuteRoundsLookBack' | 'slashMaxPayloadSize' + >; /** * The Tally Slasher client is responsible for managing slashable offenses using @@ -349,25 +352,28 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC return undefined; } - const offensesToSlashLog = offensesToSlash.map(offense => ({ - ...offense, - amount: offense.amount.toString(), - })); this.log.info(`Voting to slash ${offensesToSlash.length} offenses`, { slotNumber, currentRound, slashedRound, - offensesToSlash: offensesToSlashLog, + offensesToSlash, }); const committees = await this.collectCommitteesActiveDuringRound(slashedRound); const epochsForCommittees = getEpochsForRound(slashedRound, this.settings); - const votes = getSlashConsensusVotesFromOffenses( + const { slashMaxPayloadSize } = this.config; + const { votes, truncatedCount } = getSlashConsensusVotesFromOffenses( offensesToSlash, committees, epochsForCommittees.map(e => BigInt(e)), - this.settings, + { ...this.settings, maxSlashedValidators: slashMaxPayloadSize }, ); + if (truncatedCount > 0) { + this.log.warn( + `Vote truncated: ${truncatedCount} validator-epoch pairs dropped to stay within gas limit of ${slashMaxPayloadSize}`, + { slotNumber, currentRound, slashedRound }, + ); + } if (votes.every(v => v === 0)) { this.log.warn(`Computed votes for offenses are all zero. Skipping vote.`, { slotNumber, diff --git a/yarn-project/stdlib/src/config/sequencer-config.ts b/yarn-project/stdlib/src/config/sequencer-config.ts index 7619cdce7e68..31d0eca9458a 100644 --- a/yarn-project/stdlib/src/config/sequencer-config.ts +++ b/yarn-project/stdlib/src/config/sequencer-config.ts @@ -1,7 +1,10 @@ -import type { ConfigMappingsType } from '@aztec/foundation/config'; +import { type ConfigMappingsType, numberConfigHelper } from '@aztec/foundation/config'; import type { SequencerConfig } from '../interfaces/configs.js'; +/** Default maximum number of transactions per block. */ +export const DEFAULT_MAX_TXS_PER_BLOCK = 32; + /** * Partial sequencer config mappings for fields that need to be shared across packages. * The full sequencer config mappings remain in sequencer-client, but shared fields @@ -9,7 +12,7 @@ import type { SequencerConfig } from '../interfaces/configs.js'; * to avoid duplication. */ export const sharedSequencerConfigMappings: ConfigMappingsType< - Pick + Pick > = { blockDurationMs: { env: 'SEQ_BLOCK_DURATION_MS', @@ -26,4 +29,9 @@ export const sharedSequencerConfigMappings: ConfigMappingsType< parseEnv: (val: string) => (val ? parseInt(val, 10) : 0), defaultValue: 0, }, + maxTxsPerBlock: { + env: 'SEQ_MAX_TX_PER_BLOCK', + description: 'The maximum number of txs to include in a block.', + ...numberConfigHelper(DEFAULT_MAX_TXS_PER_BLOCK), + }, }; diff --git a/yarn-project/stdlib/src/gas/gas_fees.ts b/yarn-project/stdlib/src/gas/gas_fees.ts index ca2e700c27da..7387b2df0496 100644 --- a/yarn-project/stdlib/src/gas/gas_fees.ts +++ b/yarn-project/stdlib/src/gas/gas_fees.ts @@ -56,8 +56,14 @@ export class GasFees { return this.clone(); } else if (typeof scalar === 'bigint') { return new GasFees(this.feePerDaGas * scalar, this.feePerL2Gas * scalar); + } else if (Number.isInteger(scalar)) { + const s = BigInt(scalar); + return new GasFees(this.feePerDaGas * s, this.feePerL2Gas * s); } else { - return new GasFees(Number(this.feePerDaGas) * scalar, Number(this.feePerL2Gas) * scalar); + return new GasFees( + BigInt(Math.ceil(Number(this.feePerDaGas) * scalar)), + BigInt(Math.ceil(Number(this.feePerL2Gas) * scalar)), + ); } } diff --git a/yarn-project/stdlib/src/interfaces/allowed_element.ts b/yarn-project/stdlib/src/interfaces/allowed_element.ts index b8de0fd4e661..807f21e5286f 100644 --- a/yarn-project/stdlib/src/interfaces/allowed_element.ts +++ b/yarn-project/stdlib/src/interfaces/allowed_element.ts @@ -6,18 +6,14 @@ import type { FunctionSelector } from '../abi/function_selector.js'; import type { AztecAddress } from '../aztec-address/index.js'; import { schemas, zodFor } from '../schemas/index.js'; -type AllowedInstance = { address: AztecAddress }; type AllowedInstanceFunction = { address: AztecAddress; selector: FunctionSelector }; -type AllowedClass = { classId: Fr }; type AllowedClassFunction = { classId: Fr; selector: FunctionSelector }; -export type AllowedElement = AllowedInstance | AllowedInstanceFunction | AllowedClass | AllowedClassFunction; +export type AllowedElement = AllowedInstanceFunction | AllowedClassFunction; export const AllowedElementSchema = zodFor()( z.union([ z.object({ address: schemas.AztecAddress, selector: schemas.FunctionSelector }), - z.object({ address: schemas.AztecAddress }), z.object({ classId: schemas.Fr, selector: schemas.FunctionSelector }), - z.object({ classId: schemas.Fr }), ]), ); diff --git a/yarn-project/stdlib/src/interfaces/block-builder.ts b/yarn-project/stdlib/src/interfaces/block-builder.ts index b5b5ea9a4c1a..f0c4eb780468 100644 --- a/yarn-project/stdlib/src/interfaces/block-builder.ts +++ b/yarn-project/stdlib/src/interfaces/block-builder.ts @@ -50,14 +50,17 @@ export interface PublicProcessorValidator { export type FullNodeBlockBuilderConfig = Pick & Pick & - Pick; + Pick< + SequencerConfig, + 'txPublicSetupAllowListExtend' | 'fakeProcessingDelayPerTxMs' | 'fakeThrowAfterProcessingTxCount' + >; export const FullNodeBlockBuilderConfigKeys: (keyof FullNodeBlockBuilderConfig)[] = [ 'l1GenesisTime', 'slotDuration', 'l1ChainId', 'rollupVersion', - 'txPublicSetupAllowList', + 'txPublicSetupAllowListExtend', 'fakeProcessingDelayPerTxMs', 'fakeThrowAfterProcessingTxCount', ] as const; diff --git a/yarn-project/stdlib/src/interfaces/configs.ts b/yarn-project/stdlib/src/interfaces/configs.ts index 3cd2912c078f..88c7db90d3eb 100644 --- a/yarn-project/stdlib/src/interfaces/configs.ts +++ b/yarn-project/stdlib/src/interfaces/configs.ts @@ -31,8 +31,8 @@ export interface SequencerConfig { acvmWorkingDirectory?: string; /** The path to the ACVM binary */ acvmBinaryPath?: string; - /** The list of functions calls allowed to run in setup */ - txPublicSetupAllowList?: AllowedElement[]; + /** Additional entries to extend the default setup allow list. */ + txPublicSetupAllowListExtend?: AllowedElement[]; /** Max block size */ maxBlockSizeInBytes?: number; /** Payload address to vote for */ @@ -59,6 +59,10 @@ export interface SequencerConfig { broadcastInvalidBlockProposal?: boolean; /** Inject a fake attestation (for testing only) */ injectFakeAttestation?: boolean; + /** Inject a malleable attestation with a high-s value (for testing only) */ + injectHighSValueAttestation?: boolean; + /** Inject an attestation with an unrecoverable signature (for testing only) */ + injectUnrecoverableSignatureAttestation?: boolean; /** Whether to run in fisherman mode: builds blocks on every slot for validation without publishing */ fishermanMode?: boolean; /** Shuffle attestation ordering to create invalid ordering (for testing only) */ @@ -90,7 +94,7 @@ export const SequencerConfigSchema = zodFor()( feeRecipient: schemas.AztecAddress.optional(), acvmWorkingDirectory: z.string().optional(), acvmBinaryPath: z.string().optional(), - txPublicSetupAllowList: z.array(AllowedElementSchema).optional(), + txPublicSetupAllowListExtend: z.array(AllowedElementSchema).optional(), maxBlockSizeInBytes: z.number().optional(), governanceProposerPayload: schemas.EthAddress.optional(), l1PublishingTime: z.number().optional(), @@ -104,6 +108,8 @@ export const SequencerConfigSchema = zodFor()( secondsBeforeInvalidatingBlockAsNonCommitteeMember: z.number(), broadcastInvalidBlockProposal: z.boolean().optional(), injectFakeAttestation: z.boolean().optional(), + injectHighSValueAttestation: z.boolean().optional(), + injectUnrecoverableSignatureAttestation: z.boolean().optional(), fishermanMode: z.boolean().optional(), shuffleAttestationOrdering: z.boolean().optional(), blockDurationMs: z.number().positive().optional(), @@ -126,7 +132,7 @@ type SequencerConfigOptionalKeys = | 'fakeProcessingDelayPerTxMs' | 'fakeThrowAfterProcessingTxCount' | 'l1PublishingTime' - | 'txPublicSetupAllowList' + | 'txPublicSetupAllowListExtend' | 'minValidTxsPerBlock' | 'minBlocksForCheckpoint'; diff --git a/yarn-project/stdlib/src/interfaces/validator.ts b/yarn-project/stdlib/src/interfaces/validator.ts index 09851cfbb98c..c6596a6aca72 100644 --- a/yarn-project/stdlib/src/interfaces/validator.ts +++ b/yarn-project/stdlib/src/interfaces/validator.ts @@ -62,7 +62,7 @@ export type ValidatorClientConfig = ValidatorHASignerConfig & { }; export type ValidatorClientFullConfig = ValidatorClientConfig & - Pick & + Pick & Pick< SlasherConfig, 'slashBroadcastedInvalidBlockPenalty' | 'slashDuplicateProposalPenalty' | 'slashDuplicateAttestationPenalty' @@ -91,8 +91,9 @@ export const ValidatorClientConfigSchema = zodFor>()( ValidatorClientConfigSchema.extend({ - txPublicSetupAllowList: z.array(AllowedElementSchema).optional(), + txPublicSetupAllowListExtend: z.array(AllowedElementSchema).optional(), broadcastInvalidBlockProposal: z.boolean().optional(), + maxTxsPerBlock: z.number().optional(), slashBroadcastedInvalidBlockPenalty: schemas.BigInt, slashDuplicateProposalPenalty: schemas.BigInt, slashDuplicateAttestationPenalty: schemas.BigInt, diff --git a/yarn-project/stdlib/src/slashing/tally.test.ts b/yarn-project/stdlib/src/slashing/tally.test.ts index d79a9dcb280e..4c0fafc58087 100644 --- a/yarn-project/stdlib/src/slashing/tally.test.ts +++ b/yarn-project/stdlib/src/slashing/tally.test.ts @@ -41,7 +41,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2, mockValidator3]]; const epochsForCommittees = [5n]; // Committee for epoch 5 - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(2); // Only 25n from epoch 5 offense for validator1 @@ -62,7 +62,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1]]; const epochsForCommittees = [5n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(3); // Capped at MAX_SLASH_UNITS_PER_VALIDATOR @@ -91,7 +91,7 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n]; // Committees for epochs 5 and 6 - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(2); // validator1 in committee1 @@ -125,7 +125,7 @@ describe('TallySlashingHelpers', () => { [mockValidator1, mockValidator3], ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(2); // validator1 in committee1, epoch 5 offense (20n) @@ -150,7 +150,7 @@ describe('TallySlashingHelpers', () => { const committees: EthAddress[][] = []; const epochsForCommittees: bigint[] = []; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toEqual([]); }); @@ -167,7 +167,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator2, mockValidator3]]; const epochsForCommittees = [5n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(0); // validator2 has no offenses @@ -197,7 +197,7 @@ describe('TallySlashingHelpers', () => { [mockValidator1, mockValidator3], ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(3); // validator1 in committee1, always-slash (30n) @@ -228,7 +228,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2, mockValidator3]]; const epochsForCommittees = [2n]; // Committee for epoch 2 - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(BlockNumber(1)); // validator1: 15n offense maps to epoch 2 @@ -255,7 +255,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2]]; const epochsForCommittees = [2n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(2); // validator1: 10n + 15n = 25n total for epoch 2 @@ -288,7 +288,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2]]; const epochsForCommittees = [3n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(2); // validator1: 8n + 7n + 5n = 20n total @@ -318,7 +318,7 @@ describe('TallySlashingHelpers', () => { [mockValidator1, mockValidator3], ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(3); // validator1 committee1: 20n(always) + 15n(epoch5) = 35n @@ -352,7 +352,7 @@ describe('TallySlashingHelpers', () => { [mockValidator1, mockValidator2], ]; const epochsForCommittees = [0n, 1n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize expect(votes[0]).toEqual(BlockNumber(1)); // validator1 epoch0: 15n offense @@ -383,7 +383,7 @@ describe('TallySlashingHelpers', () => { const committees = [[mockValidator1, mockValidator2, mockValidator3]]; const epochsForCommittees = [5n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(4); // Padded to targetCommitteeSize expect(votes[0]).toEqual(0); // validator1: 0n amount = 0 slash units @@ -409,7 +409,7 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n, 7n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); // Should be 12 elements (4 per committee), not 8 expect(votes).toHaveLength(12); @@ -437,7 +437,7 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); expect(votes.slice(0, 4)).toEqual([0, 0, 0, 0]); // Padded empty committee @@ -460,13 +460,73 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(8); expect(votes.slice(0, 4)).toEqual([0, 2, 0, 0]); // validator2 in first committee (20n = 2 units) expect(votes.slice(4, 8)).toEqual([0, 0, 0, 0]); // Padded empty committee }); + it('truncates to maxSlashedValidators unique (validator, epoch) pairs', () => { + const offenses: Offense[] = [ + { validator: mockValidator1, amount: 30n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n }, + { validator: mockValidator2, amount: 20n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n }, + { validator: mockValidator3, amount: 10n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n }, + ]; + + const committees = [[mockValidator1, mockValidator2, mockValidator3, mockValidator4]]; + const epochsForCommittees = [5n]; + // Only 2 slashed validators allowed; validator3 should be zeroed out + const { votes, truncatedCount } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, { + ...settings, + maxSlashedValidators: 2, + }); + + expect(truncatedCount).toBe(1); + expect(votes).toHaveLength(4); + expect(votes[0]).toEqual(3); // validator1: included (1st) + expect(votes[1]).toEqual(2); // validator2: included (2nd) + expect(votes[2]).toEqual(0); // validator3: zeroed out (limit reached) + expect(votes[3]).toEqual(0); // validator4: no offenses + }); + + it('counts the same validator in multiple epochs as separate slashed pairs', () => { + // An always-slash validator appears once per epoch committee, each generating a slash() call + const offenses = [ + { + validator: mockValidator1, + amount: 30n, + offenseType: OffenseType.UNKNOWN, + epochOrSlot: undefined, // always-slash + }, + { validator: mockValidator2, amount: 20n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 5n }, + { validator: mockValidator3, amount: 10n, offenseType: OffenseType.INACTIVITY, epochOrSlot: 6n }, + ]; + + const committees = [ + [mockValidator1, mockValidator2], + [mockValidator1, mockValidator3], + ]; + const epochsForCommittees = [5n, 6n]; + // Limit of 3: validator1@epoch5, validator2@epoch5, validator1@epoch6 are included; + // validator3@epoch6 is zeroed out + const { votes, truncatedCount } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, { + ...settings, + maxSlashedValidators: 3, + }); + + expect(truncatedCount).toBe(1); + expect(votes).toHaveLength(8); // 2 committees × 4 targetCommitteeSize + expect(votes[0]).toEqual(3); // validator1 @ epoch5: included (1st) + expect(votes[1]).toEqual(2); // validator2 @ epoch5: included (2nd) + expect(votes[2]).toEqual(0); // padded + expect(votes[3]).toEqual(0); // padded + expect(votes[4]).toEqual(3); // validator1 @ epoch6: included (3rd) + expect(votes[5]).toEqual(0); // validator3 @ epoch6: zeroed out (limit reached) + expect(votes[6]).toEqual(0); // padded + expect(votes[7]).toEqual(0); // padded + }); + it('handles multiple consecutive empty committees', () => { const offenses: Offense[] = [ { @@ -485,7 +545,7 @@ describe('TallySlashingHelpers', () => { ]; const epochsForCommittees = [5n, 6n, 7n, 8n]; - const votes = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); + const { votes } = getSlashConsensusVotesFromOffenses(offenses, committees, epochsForCommittees, settings); expect(votes).toHaveLength(16); // 4 committees × 4 targetCommitteeSize expect(votes.slice(0, 4)).toEqual([0, 0, 0, 0]); // Committee 0: no matching offenses diff --git a/yarn-project/stdlib/src/slashing/tally.ts b/yarn-project/stdlib/src/slashing/tally.ts index 91bed7e4ff35..63ffe7fb1c28 100644 --- a/yarn-project/stdlib/src/slashing/tally.ts +++ b/yarn-project/stdlib/src/slashing/tally.ts @@ -12,6 +12,8 @@ import type { Offense, ValidatorSlashVote } from './types.js'; * @param committees - Array of committees (each containing array of validator addresses) * @param epochsForCommittees - Array of epochs corresponding to each committee * @param settings - Settings including slashingAmounts and optional validator override lists + * @param settings.maxSlashedValidators - If set, limits the total number of [validator, epoch] pairs + * with non-zero votes. * @returns Array of ValidatorSlashVote, where each vote is how many slash units the validator in that position should be slashed */ export function getSlashConsensusVotesFromOffenses( @@ -22,9 +24,10 @@ export function getSlashConsensusVotesFromOffenses( slashingAmounts: [bigint, bigint, bigint]; epochDuration: number; targetCommitteeSize: number; + maxSlashedValidators?: number; }, -): ValidatorSlashVote[] { - const { slashingAmounts, targetCommitteeSize } = settings; +): { votes: ValidatorSlashVote[]; truncatedCount: number } { + const { slashingAmounts, targetCommitteeSize, maxSlashedValidators } = settings; if (committees.length !== epochsForCommittees.length) { throw new Error('committees and epochsForCommittees must have the same length'); @@ -53,7 +56,19 @@ export function getSlashConsensusVotesFromOffenses( return padArrayEnd(votes, 0, targetCommitteeSize); }); - return votes; + // if a cap is set, zero out the lowest-vote [validator, epoch] pairs so that the most severe slashes stay. + if (maxSlashedValidators === undefined) { + return { votes, truncatedCount: 0 }; + } + + const nonZeroByDescendingVote = [...votes.entries()].filter(([, vote]) => vote > 0).sort(([, a], [, b]) => b - a); + + const toTruncate = nonZeroByDescendingVote.slice(maxSlashedValidators); + for (const [idx] of toTruncate) { + votes[idx] = 0; + } + + return { votes, truncatedCount: toTruncate.length }; } /** Returns the slash vote for the given amount to slash. */ diff --git a/yarn-project/stdlib/src/tx/validator/error_texts.ts b/yarn-project/stdlib/src/tx/validator/error_texts.ts index 30f4867d209f..cf737a2160c4 100644 --- a/yarn-project/stdlib/src/tx/validator/error_texts.ts +++ b/yarn-project/stdlib/src/tx/validator/error_texts.ts @@ -6,6 +6,7 @@ export const TX_ERROR_GAS_LIMIT_TOO_HIGH = 'Gas limit is higher than the amount // Phases export const TX_ERROR_SETUP_FUNCTION_NOT_ALLOWED = 'Setup function not on allow list'; +export const TX_ERROR_SETUP_FUNCTION_UNKNOWN_CONTRACT = 'Setup function targets unknown contract'; // Nullifiers export const TX_ERROR_DUPLICATE_NULLIFIER_IN_TX = 'Duplicate nullifier in tx'; diff --git a/yarn-project/telemetry-client/src/attributes.ts b/yarn-project/telemetry-client/src/attributes.ts index feaf353df7d9..24a3db3913bb 100644 --- a/yarn-project/telemetry-client/src/attributes.ts +++ b/yarn-project/telemetry-client/src/attributes.ts @@ -156,3 +156,6 @@ export const HA_DUTY_TYPE = 'aztec.ha_signer.duty_type'; /** HA signer node identifier */ export const HA_NODE_ID = 'aztec.ha_signer.node_id'; + +/** The address of an attester (validator) participating in consensus */ +export const ATTESTER_ADDRESS = 'aztec.attester.address'; diff --git a/yarn-project/telemetry-client/src/metrics.ts b/yarn-project/telemetry-client/src/metrics.ts index d30a4185e15b..4b095a876e3b 100644 --- a/yarn-project/telemetry-client/src/metrics.ts +++ b/yarn-project/telemetry-client/src/metrics.ts @@ -764,6 +764,12 @@ export const PEER_MANAGER_PEER_COUNT: MetricDefinition = { unit: 'peers', valueType: ValueType.INT, }; +export const PEER_MANAGER_HEALTHY_PEER_COUNT: MetricDefinition = { + name: 'aztec.peer_manager.healthy_peer_count', + description: 'Number of healthy (non-protected, non-banned) peers', + unit: 'peers', + valueType: ValueType.INT, +}; export const PEER_MANAGER_LOW_SCORE_DISCONNECTS: MetricDefinition = { name: 'aztec.peer_manager.low_score_disconnects', description: 'Number of peers disconnected due to low score', @@ -1261,6 +1267,16 @@ export const VALIDATOR_ATTESTATION_FAILED_NODE_ISSUE_COUNT: MetricDefinition = { description: 'The number of failed attestations due to node issues (timeout, missing data, etc.)', valueType: ValueType.INT, }; +export const VALIDATOR_CURRENT_EPOCH: MetricDefinition = { + name: 'aztec.validator.current_epoch', + description: 'The current epoch number, reflecting total epochs elapsed since genesis', + valueType: ValueType.INT, +}; +export const VALIDATOR_ATTESTED_EPOCH_COUNT: MetricDefinition = { + name: 'aztec.validator.attested_epoch_count', + description: 'The number of epochs in which this node successfully submitted at least one attestation', + valueType: ValueType.INT, +}; export const NODEJS_EVENT_LOOP_DELAY_MIN: MetricDefinition = { name: 'nodejs.eventloop.delay.min', diff --git a/yarn-project/validator-client/src/checkpoint_builder.ts b/yarn-project/validator-client/src/checkpoint_builder.ts index c8ec5c5671fe..74059c27ce35 100644 --- a/yarn-project/validator-client/src/checkpoint_builder.ts +++ b/yarn-project/validator-client/src/checkpoint_builder.ts @@ -148,7 +148,10 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder { } protected async makeBlockBuilderDeps(globalVariables: GlobalVariables, fork: MerkleTreeWriteOperations) { - const txPublicSetupAllowList = this.config.txPublicSetupAllowList ?? (await getDefaultAllowedSetupFunctions()); + const txPublicSetupAllowList = [ + ...(await getDefaultAllowedSetupFunctions()), + ...(this.config.txPublicSetupAllowListExtend ?? []), + ]; const contractsDB = new PublicContractsDB(this.contractDataSource, this.log.getBindings()); const guardedFork = new GuardedMerkleTreeOperations(fork); diff --git a/yarn-project/validator-client/src/factory.ts b/yarn-project/validator-client/src/factory.ts index f2cc3e4d80e7..eacdb5322965 100644 --- a/yarn-project/validator-client/src/factory.ts +++ b/yarn-project/validator-client/src/factory.ts @@ -29,6 +29,7 @@ export function createBlockProposalHandler( const metrics = new ValidatorMetrics(deps.telemetry); const blockProposalValidator = new BlockProposalValidator(deps.epochCache, { txsPermitted: !config.disableTransactions, + maxTxsPerBlock: config.maxTxsPerBlock, }); return new BlockProposalHandler( deps.checkpointsBuilder, diff --git a/yarn-project/validator-client/src/metrics.ts b/yarn-project/validator-client/src/metrics.ts index 26c35cec5948..160ac8c17280 100644 --- a/yarn-project/validator-client/src/metrics.ts +++ b/yarn-project/validator-client/src/metrics.ts @@ -1,3 +1,5 @@ +import type { EpochNumber } from '@aztec/foundation/branded-types'; +import type { EthAddress } from '@aztec/foundation/eth-address'; import type { BlockProposal } from '@aztec/stdlib/p2p'; import { Attributes, @@ -16,6 +18,8 @@ export class ValidatorMetrics { private successfulAttestationsCount: UpDownCounter; private failedAttestationsBadProposalCount: UpDownCounter; private failedAttestationsNodeIssueCount: UpDownCounter; + private currentEpoch: Gauge; + private attestedEpochCount: UpDownCounter; private reexMana: Histogram; private reexTx: Histogram; @@ -64,6 +68,10 @@ export class ValidatorMetrics { }, ); + this.currentEpoch = meter.createGauge(Metrics.VALIDATOR_CURRENT_EPOCH); + + this.attestedEpochCount = createUpDownCounterWithDefault(meter, Metrics.VALIDATOR_ATTESTED_EPOCH_COUNT); + this.reexMana = meter.createHistogram(Metrics.VALIDATOR_RE_EXECUTION_MANA); this.reexTx = meter.createHistogram(Metrics.VALIDATOR_RE_EXECUTION_TX_COUNT); @@ -110,4 +118,14 @@ export class ValidatorMetrics { [Attributes.IS_COMMITTEE_MEMBER]: inCommittee, }); } + + /** Update the gauge tracking the current epoch number (proxy for total epochs elapsed). */ + public setCurrentEpoch(epoch: EpochNumber) { + this.currentEpoch.record(Number(epoch)); + } + + /** Increment the count of epochs in which the given attester submitted at least one attestation. */ + public incAttestedEpochCount(attester: EthAddress) { + this.attestedEpochCount.add(1, { [Attributes.ATTESTER_ADDRESS]: attester.toString() }); + } } diff --git a/yarn-project/validator-client/src/validator.integration.test.ts b/yarn-project/validator-client/src/validator.integration.test.ts index dd30f91bd4be..811a84927be0 100644 --- a/yarn-project/validator-client/src/validator.integration.test.ts +++ b/yarn-project/validator-client/src/validator.integration.test.ts @@ -127,7 +127,7 @@ describe('ValidatorClient Integration', () => { slotDuration: l1Constants.slotDuration, l1ChainId: chainId.toNumber(), rollupVersion: version.toNumber(), - txPublicSetupAllowList: [], + txPublicSetupAllowListExtend: [], }, synchronizer, archiver, diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index 8cfb1dc6bd65..0fb99d4c6e41 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -89,6 +89,8 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) private lastEpochForCommitteeUpdateLoop: EpochNumber | undefined; private epochCacheUpdateLoop: RunningPromise; + /** Tracks the last epoch in which each attester successfully submitted at least one attestation. */ + private lastAttestedEpochByAttester: Map = new Map(); private proposersOfInvalidBlocks: Set = new Set(); @@ -160,6 +162,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) this.log.trace(`No committee found for slot`); return; } + this.metrics.setCurrentEpoch(epoch); if (epoch !== this.lastEpochForCommitteeUpdateLoop) { const me = this.getValidatorAddresses(); const committeeSet = new Set(committee.map(v => v.toString())); @@ -197,6 +200,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) const metrics = new ValidatorMetrics(telemetry); const blockProposalValidator = new BlockProposalValidator(epochCache, { txsPermitted: !config.disableTransactions, + maxTxsPerBlock: config.maxTxsPerBlock, }); const blockProposalHandler = new BlockProposalHandler( checkpointsBuilder, @@ -555,6 +559,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) this.metrics.incSuccessfulAttestations(inCommittee.length); + // Track epoch participation per attester: count each (attester, epoch) pair at most once + const proposalEpoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants()); + for (const attester of inCommittee) { + const key = attester.toString(); + const lastEpoch = this.lastAttestedEpochByAttester.get(key); + if (lastEpoch === undefined || proposalEpoch > lastEpoch) { + this.lastAttestedEpochByAttester.set(key, proposalEpoch); + this.metrics.incAttestedEpochCount(attester); + } + } + // Determine which validators should attest let attestors: EthAddress[]; if (partOfCommittee) {