diff --git a/.claude/agents/analyze-logs.md b/.claude/agents/analyze-logs.md index 3d9b4d3387f5..ecf65cdc281e 100644 --- a/.claude/agents/analyze-logs.md +++ b/.claude/agents/analyze-logs.md @@ -2,7 +2,6 @@ name: analyze-logs description: | Deep-read test logs and extract relevant information. Runs in separate context to avoid polluting the main conversation. Accepts local file paths (preferred) or hashes. Returns condensed summaries, not raw logs. -model: sonnet --- # CI Log Analysis Agent diff --git a/.claude/agents/identify-ci-failures.md b/.claude/agents/identify-ci-failures.md index 954f486b4bec..54dee6ccc43d 100644 --- a/.claude/agents/identify-ci-failures.md +++ b/.claude/agents/identify-ci-failures.md @@ -2,7 +2,6 @@ name: identify-ci-failures description: | Identify CI failures from a PR number, CI URL, or log hash. Returns structured list of failures with local file paths for downloaded logs. Use this subagent to find what failed before deeper analysis. -model: sonnet --- # CI Failure Identification Agent @@ -46,6 +45,10 @@ Return a structured report: [If found in logs, provide the History URL for finding successful runs] ``` +Do NOT: +- Return raw multi-thousand-line log dumps +- Attempt to fix any failures (just identify them) + ## Workflow ### Step 1: Get CI Log Hash diff --git a/.claude/skills/ci-logs/SKILL.md b/.claude/skills/ci-logs/SKILL.md index b51ae8885a70..7e2146ecbee5 100644 --- a/.claude/skills/ci-logs/SKILL.md +++ b/.claude/skills/ci-logs/SKILL.md @@ -1,49 +1,26 @@ --- name: ci-logs description: Analyze CI logs from ci.aztec-labs.com. Use this instead of WebFetch for CI URLs. -user-invocable: true -arguments: +argument-hint: --- # CI Log Analysis -When you need to analyze logs from ci.aztec-labs.com, use the Task tool to spawn the analyze-logs agent. +When you need to analyze logs from ci.aztec-labs.com, delegate to the `analyze-logs` subagent. ## Usage 1. **Extract the hash** from the URL (e.g., `http://ci.aztec-labs.com/e93bcfdc738dc2e0` → `e93bcfdc738dc2e0`) -2. **Spawn the analyze-logs agent** using the Task tool: - -``` -Task( - subagent_type: "analyze-logs", - prompt: "Analyze CI log hash: . Focus: errors", - description: "Analyze CI logs" -) -``` +2. **Spawn the `analyze-logs` subagent** using the Task tool with the hash and focus area (e.g. "errors", "test \", or a custom question) in the prompt. ## Examples **User asks:** "What failed in http://ci.aztec-labs.com/343c52b17688d2cd" -**You do:** -``` -Task( - subagent_type: "analyze-logs", - prompt: "Analyze CI log hash: 343c52b17688d2cd. Focus: errors. Download with: yarn ci dlog 343c52b17688d2cd > /tmp/343c52b17688d2cd.log", - description: "Analyze CI failure" -) -``` - -**For specific test analysis:** -``` -Task( - subagent_type: "analyze-logs", - prompt: "Analyze CI log hash: 343c52b17688d2cd. Focus: test 'my test name'", - description: "Analyze test failure" -) -``` +**You do:** Use the Task tool with `subagent_type: "analyze-logs"` and prompt including the hash `343c52b17688d2cd`, focus on errors, and instruction to download with `yarn ci dlog`. + +**For specific test analysis:** Same approach, but set the focus to the test name. ## Do NOT diff --git a/.claude/skills/noir-sync-update/SKILL.md b/.claude/skills/noir-sync-update/SKILL.md index 29a6b67e681b..d99931703b72 100644 --- a/.claude/skills/noir-sync-update/SKILL.md +++ b/.claude/skills/noir-sync-update/SKILL.md @@ -5,19 +5,9 @@ description: Perform necessary follow-on updates as a result of updating the noi # Noir Sync Update -## Workflow +## Steps -Copy this checklist and track progress: - -``` -Noir Sync Update Progress: -- [ ] Step 1: Ensure that the new submodule commit has been pulled. -- [ ] Step 2: Update the `Cargo.lock` file in `avm-transpiler`. -- [ ] Step 3: Update the `yarn.lock` file in `yarn-project`. -- [ ] Step 4: Format `noir-projects`. -``` - -After each step, commit the results. +After each step, verify with `git status` and commit the results before proceeding. ## Critical Verification Rules @@ -25,13 +15,11 @@ After each step, commit the results. **IMPORTANT:** Always run `git status` from the repository root directory, not from subdirectories. Running `git status noir-projects/` from inside `noir-projects/` will fail silently. -### Step 1: Ensure that the new submodule commit has been pulled - -Run `./bootstrap.sh` in `noir` to ensure that the new submodule commit has been pulled. +### 1. Ensure submodule is pulled -This shouldn't update any files such that a commit is necessary. +Run `./bootstrap.sh` in `noir` to ensure that the new submodule commit has been pulled. This shouldn't produce changes that need committing. -### Step 2: Update `Cargo.lock` in `avm-transpiler` +### 2. Update `Cargo.lock` in `avm-transpiler` **Before updating**, determine the expected noir version: 1. Read `noir/noir-repo/.release-please-manifest.json` to find the expected version (e.g., `1.0.0-beta.18`) @@ -55,13 +43,13 @@ It's possible that changes in dependencies result in `avm-transpiler` no longer - If transient dependency mismatches mean changes to the dependency tree are necessary, then the `Cargo.lock` file in `avm-transpiler` should be modified. **DO NOT MODIFY `noir/noir-repo`**. - If updates are necessary due to changes in exports from `noir/noir-repo` packages, then perform the necessary updates to import statements, etc. -### Step 3: Update `yarn.lock` in `yarn-project` +### 3. Update `yarn.lock` in `yarn-project` Run `yarn install` in `yarn-project` to update the `yarn.lock` file. **After running**, verify with `git status yarn-project/yarn.lock` that the file was modified before committing. -### Step 4: Format `noir-projects` +### 4. Format `noir-projects` Run `./bootstrap.sh format` in `noir-projects`. diff --git a/.claude/skills/updating-changelog/SKILL.md b/.claude/skills/updating-changelog/SKILL.md index a6754f46f05a..018859f8833e 100644 --- a/.claude/skills/updating-changelog/SKILL.md +++ b/.claude/skills/updating-changelog/SKILL.md @@ -5,19 +5,9 @@ description: Updates changelog documentation for contract developers and node op # Updating Changelog -## Workflow +## Steps -Copy this checklist and track progress: - -``` -Changelog Update Progress: -- [ ] Step 1: Determine target changelog file from .release-please-manifest.json -- [ ] Step 2: Analyze branch changes (git diff next...HEAD) -- [ ] Step 3: Generate draft entries for review -- [ ] Step 4: Edit documentation files after approval -``` - -### Step 1: Determine Target Files +### 1. Determine Target Files Read `.release-please-manifest.json` to get the version (e.g., `{"." : "4.0.0"}` → edit `v4.md`). @@ -26,7 +16,7 @@ Read `.release-please-manifest.json` to get the version (e.g., `{"." : "4.0.0"}` - Aztec contract developers: `docs/docs-developers/docs/resources/migration_notes.md` - Node operators and Ethereum contract developers: `docs/docs-network/reference/changelog/v{major}.md` -### Step 2: Analyze Branch Changes +### 2. Analyze Branch Changes Run `git diff next...HEAD --stat` for overview, then `git diff next...HEAD` for details. @@ -37,11 +27,11 @@ Run `git diff next...HEAD --stat` for overview, then `git diff next...HEAD` for - Deprecations - Configuration changes (CLI flags, environment variables) -### Step 3: Generate Draft Entries +### 3. Generate Draft Entries Present draft entries for review BEFORE editing files. Match the formatting conventions by reading existing entries in each file. -### Step 4: Edit Documentation +### 4. Edit Documentation After approval, add entries to the appropriate files. @@ -66,8 +56,6 @@ Explanation of what changed. **Impact**: Effect on existing code. -```` - **Component tags:** `[Aztec.nr]`, `[Aztec.js]`, `[PXE]`, `[Aztec Node]`, `[AVM]`, `[L1 Contracts]`, `[CLI]` ## Node Operator Changelog Format @@ -75,35 +63,31 @@ Explanation of what changed. **File:** `docs/docs-network/reference/changelog/v{major}.md` **Breaking changes:** -```markdown +````markdown ### Feature Name **v{previous}:** ```bash --old-flag ($OLD_ENV_VAR) -```` +``` **v{current}:** - ```bash --new-flag ($NEW_ENV_VAR) ``` **Migration**: How to migrate. - ```` **New features:** -```markdown +````markdown ### Feature Name ```bash --new-flag ($ENV_VAR) -```` +``` Description of the feature. - -``` +```` **Changed defaults:** Use table format with Flag, Environment Variable, Previous, New columns. -``` diff --git a/ci3/cache_download b/ci3/cache_download index 7372433ae211..d3cca549febe 100755 --- a/ci3/cache_download +++ b/ci3/cache_download @@ -36,23 +36,55 @@ else endpoint="https://build-cache.aztec-labs.com" fi -if [[ -n "${S3_BUILD_CACHE_AWS_PARAMS:-}" ]]; then - # Use AWS CLI with custom params (e.g., custom endpoint) - # NOTE: This is NOT currently used, but allows for using minio or other S3-compatible storage for tests. - s3_uri="s3://aztec-ci-artifacts/build-cache/$tar_file" - aws $S3_BUILD_CACHE_AWS_PARAMS s3 cp "$s3_uri" "-" | extract_tar -elif [[ -n "${CACHE_SSH_HOST:-}" ]]; then - # Run S3 download on remote host via SSH jump and pipe back - if ! ssh "$CACHE_SSH_HOST" "curl -s -f \"$endpoint/$tar_file\"" | extract_tar; then - echo_stderr "SSH cache download of $tar_file via $CACHE_SSH_HOST failed." - exit 1 +# Downloads the artifact from remote to stdout. +function download_from_remote { + if [[ -n "${S3_BUILD_CACHE_AWS_PARAMS:-}" ]]; then + # Use AWS CLI with custom params (e.g., custom endpoint) + # NOTE: This is NOT currently used, but allows for using minio or other S3-compatible storage for tests. + s3_uri="s3://aztec-ci-artifacts/build-cache/$tar_file" + aws $S3_BUILD_CACHE_AWS_PARAMS s3 cp "$s3_uri" "-" 2>/dev/null + elif [[ -n "${CACHE_SSH_HOST:-}" ]]; then + # Run remote download on remote host via SSH jump and pipe back + ssh "$CACHE_SSH_HOST" "curl -s -f \"$endpoint/$tar_file\"" + else + # Default to download from remote via curl + curl -s -f "$endpoint/$tar_file" fi -else - # Default to AWS S3 URL via curl - # Attempt to download and extract the cache file - if ! curl -s -f "$endpoint/$tar_file" | extract_tar; then +} + +# Local cache: if CACHE_LOCAL_DIR is set, check local cache first, +# and on miss, download from remote into local cache before extracting. +# If the cache directory cannot be created, skip local caching and fall through. +if [[ -n "${CACHE_LOCAL_DIR:-}" ]] && ! mkdir -p "$CACHE_LOCAL_DIR" 2>/dev/null; then + echo_stderr "Warning: Cannot create local cache dir $CACHE_LOCAL_DIR, skipping local cache." + CACHE_LOCAL_DIR="" +fi + +if [[ -n "${CACHE_LOCAL_DIR:-}" ]]; then + local_cache_file="$CACHE_LOCAL_DIR/$tar_file" + + if [[ -f "$local_cache_file" ]]; then + echo_stderr "Local cache hit for $tar_file." + extract_tar < "$local_cache_file" + echo_stderr "Cache extraction of $tar_file from local cache complete in ${SECONDS}s." + exit 0 + fi + + echo_stderr "Local cache miss for $tar_file, downloading from remote." + if ! download_from_remote > "$local_cache_file"; then + rm -f "$local_cache_file" echo_stderr "Cache download of $tar_file failed." exit 1 fi + + extract_tar < "$local_cache_file" + echo_stderr "Cache download and extraction of $tar_file complete in ${SECONDS}s." + exit 0 fi + +if ! download_from_remote | extract_tar; then + echo_stderr "Cache download of $tar_file failed." + exit 1 +fi + echo_stderr "Cache download and extraction of $tar_file complete in ${SECONDS}s." diff --git a/ci3/cache_local.test.sh b/ci3/cache_local.test.sh new file mode 100755 index 000000000000..ce919ffe6768 --- /dev/null +++ b/ci3/cache_local.test.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# Test script for local cache functionality in cache_download and cache_upload. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +test_root="/tmp/cache-local-test-$$" +passed=0 +failed=0 + +cleanup() { + rm -rf "$test_root" +} +trap cleanup EXIT + +log() { echo -e "\033[1m$1\033[0m"; } +pass() { echo -e " \033[32m✓ $1\033[0m"; ((++passed)); } +fail() { echo -e " \033[31m✗ $1\033[0m"; ((++failed)); } + +setup() { + log "Setting up test environment in $test_root" + mkdir -p "$test_root"/{local-cache,extract,source} + + # Create some test content to tar up. + echo "hello world" > "$test_root/source/file1.txt" + echo "foo bar" > "$test_root/source/file2.txt" + + # Create a test tar.gz from that content. + tar -czf "$test_root/test-artifact.tar.gz" -C "$test_root/source" . +} + +test_download_without_local_cache() { + log "\nTest 1: cache_download without CACHE_LOCAL_DIR (baseline)" + + # Without CACHE_LOCAL_DIR, and without S3 access, cache_download should fail. + # This just confirms that the env var being unset means no local cache logic runs. + unset CACHE_LOCAL_DIR 2>/dev/null || true + if NO_CACHE=1 "$script_dir/cache_download" "some-file.tar.gz" "$test_root/extract" 2>/dev/null; then + fail "Should have exited with error when NO_CACHE=1" + else + pass "cache_download respects NO_CACHE=1 without local cache" + fi +} + +test_download_local_cache_hit() { + log "\nTest 2: cache_download with local cache hit" + + export CACHE_LOCAL_DIR="$test_root/local-cache" + rm -rf "$test_root/extract" + mkdir -p "$test_root/extract" + + # Place the artifact in the local cache. + cp "$test_root/test-artifact.tar.gz" "$CACHE_LOCAL_DIR/test-artifact.tar.gz" + + # cache_download should find it in local cache and extract. + if "$script_dir/cache_download" "test-artifact.tar.gz" "$test_root/extract" 2>/dev/null; then + if [[ -f "$test_root/extract/file1.txt" ]] && [[ -f "$test_root/extract/file2.txt" ]]; then + pass "Local cache hit extracted files correctly" + else + fail "Local cache hit did not extract files" + fi + else + fail "cache_download failed on local cache hit" + fi + + # Verify stderr mentions local cache hit. + local stderr_output + rm -rf "$test_root/extract" + mkdir -p "$test_root/extract" + stderr_output=$("$script_dir/cache_download" "test-artifact.tar.gz" "$test_root/extract" 2>&1 >/dev/null) || true + if echo "$stderr_output" | grep -q "Local cache hit"; then + pass "Reported local cache hit in stderr" + else + fail "Did not report local cache hit (got: $stderr_output)" + fi +} + +test_download_local_cache_miss() { + log "\nTest 3: cache_download with local cache miss (no remote)" + + export CACHE_LOCAL_DIR="$test_root/local-cache-empty" + mkdir -p "$CACHE_LOCAL_DIR" + rm -rf "$test_root/extract" + mkdir -p "$test_root/extract" + + # With an empty local cache and no S3 access, download should fail. + local stderr_output + stderr_output=$("$script_dir/cache_download" "nonexistent.tar.gz" "$test_root/extract" 2>&1 >/dev/null) || true + if echo "$stderr_output" | grep -q "Local cache miss"; then + pass "Reported local cache miss in stderr" + else + fail "Did not report local cache miss (got: $stderr_output)" + fi +} + +test_upload_saves_to_local_cache() { + log "\nTest 4: cache_upload saves to local cache" + + export CACHE_LOCAL_DIR="$test_root/local-cache-upload" + mkdir -p "$CACHE_LOCAL_DIR" + + # cache_upload requires CI=1 or S3_FORCE_UPLOAD, and AWS credentials. + # We set S3_FORCE_UPLOAD but expect S3 upload to fail (no credentials) - that's OK, + # we just want to verify the local cache copy happens. + local stderr_output + stderr_output=$(S3_FORCE_UPLOAD=1 "$script_dir/cache_upload" "test-upload.tar.gz" "$test_root/source/file1.txt" "$test_root/source/file2.txt" 2>&1 >/dev/null) || true + + if [[ -f "$CACHE_LOCAL_DIR/test-upload.tar.gz" ]]; then + pass "cache_upload saved artifact to local cache" + else + fail "cache_upload did not save artifact to local cache" + fi + + if echo "$stderr_output" | grep -q "Saved test-upload.tar.gz to local cache"; then + pass "Reported saving to local cache in stderr" + else + fail "Did not report saving to local cache (got: $stderr_output)" + fi +} + +test_upload_without_local_cache() { + log "\nTest 5: cache_upload without CACHE_LOCAL_DIR" + + unset CACHE_LOCAL_DIR 2>/dev/null || true + + # Without CACHE_LOCAL_DIR, upload should not create any local cache files. + local stderr_output + stderr_output=$(S3_FORCE_UPLOAD=1 "$script_dir/cache_upload" "test-no-local.tar.gz" "$test_root/source/file1.txt" 2>&1 >/dev/null) || true + + if echo "$stderr_output" | grep -q "local cache"; then + fail "Should not mention local cache when CACHE_LOCAL_DIR is unset" + else + pass "No local cache activity when CACHE_LOCAL_DIR is unset" + fi +} + +test_roundtrip() { + log "\nTest 6: Upload then download roundtrip via local cache" + + export CACHE_LOCAL_DIR="$test_root/local-cache-roundtrip" + mkdir -p "$CACHE_LOCAL_DIR" + + # Upload: creates the tar and saves to local cache. + S3_FORCE_UPLOAD=1 "$script_dir/cache_upload" "roundtrip.tar.gz" "$test_root/source/file1.txt" "$test_root/source/file2.txt" 2>/dev/null || true + + # Download: should find it in local cache and extract. + rm -rf "$test_root/extract" + mkdir -p "$test_root/extract" + if "$script_dir/cache_download" "roundtrip.tar.gz" "$test_root/extract" 2>/dev/null; then + if [[ -f "$test_root/extract/$test_root/source/file1.txt" ]] || [[ -f "$test_root/extract/file1.txt" ]]; then + pass "Roundtrip: upload then download via local cache works" + else + # The tar preserves full paths, so check with the full path structure. + if tar -tzf "$CACHE_LOCAL_DIR/roundtrip.tar.gz" | grep -q "file1.txt"; then + pass "Roundtrip: artifact in local cache contains expected files" + else + fail "Roundtrip: extracted files not found" + fi + fi + else + fail "Roundtrip: cache_download failed" + fi +} + +test_disabled_cache_skips_local() { + log "\nTest 7: disabled-cache key skips local cache" + + export CACHE_LOCAL_DIR="$test_root/local-cache" + + local stderr_output + stderr_output=$("$script_dir/cache_download" "disabled-cache-foo.tar.gz" "$test_root/extract" 2>&1 >/dev/null) || true + if echo "$stderr_output" | grep -q "uncommitted changes"; then + pass "disabled-cache still triggers early exit before local cache" + else + fail "disabled-cache did not trigger early exit (got: $stderr_output)" + fi +} + +test_inaccessible_cache_dir_falls_through() { + log "\nTest 8: Inaccessible CACHE_LOCAL_DIR falls through gracefully" + + # Use a path we definitely can't create (root-owned directory). + export CACHE_LOCAL_DIR="/proc/fake-cache-dir" + + local stderr_output + stderr_output=$("$script_dir/cache_download" "test-artifact.tar.gz" "$test_root/extract" 2>&1 >/dev/null) || true + if echo "$stderr_output" | grep -q "Cannot create local cache dir"; then + pass "Download warns about inaccessible cache dir" + else + fail "Download did not warn about inaccessible dir (got: $stderr_output)" + fi + # Should NOT see "Local cache hit" or "Local cache miss" since it fell through. + if echo "$stderr_output" | grep -q "Local cache"; then + fail "Download should not attempt local cache when dir is inaccessible" + else + pass "Download skipped local cache logic when dir is inaccessible" + fi + + # Test upload too. + stderr_output=$(S3_FORCE_UPLOAD=1 "$script_dir/cache_upload" "test-upload-fallthrough.tar.gz" "$test_root/source/file1.txt" 2>&1 >/dev/null) || true + if echo "$stderr_output" | grep -q "Cannot create local cache dir"; then + pass "Upload warns about inaccessible cache dir" + else + fail "Upload did not warn about inaccessible dir (got: $stderr_output)" + fi +} + +main() { + log "=== Local Cache Test Suite ===\n" + + setup + + test_download_without_local_cache + test_download_local_cache_hit + test_download_local_cache_miss + test_upload_saves_to_local_cache + test_upload_without_local_cache + test_roundtrip + test_disabled_cache_skips_local + test_inaccessible_cache_dir_falls_through + + log "\n=== Results ===" + echo -e "\033[32mPassed: $passed\033[0m" + echo -e "\033[31mFailed: $failed\033[0m" + + if [[ $failed -gt 0 ]]; then + exit 1 + fi +} + +main "$@" diff --git a/ci3/cache_upload b/ci3/cache_upload index 805731f388ec..fa4eb32f46bd 100755 --- a/ci3/cache_upload +++ b/ci3/cache_upload @@ -42,6 +42,16 @@ else tar -czf $out_tar "$@" fi +# Save to local cache if enabled. +if [[ -n "${CACHE_LOCAL_DIR:-}" ]]; then + if mkdir -p "$CACHE_LOCAL_DIR" 2>/dev/null; then + cp "$out_tar" "$CACHE_LOCAL_DIR/$name" + echo_stderr "Saved $name to local cache." + else + echo_stderr "Warning: Cannot create local cache dir $CACHE_LOCAL_DIR, skipping local cache." + fi +fi + # Pipe tar directly to AWS S3 cp if aws ${S3_BUILD_CACHE_AWS_PARAMS:-} s3 cp $out_tar "s3://aztec-ci-artifacts/build-cache/$name" &>/dev/null; then echo_stderr "Cache upload of $name complete in ${SECONDS}s." diff --git a/ci3/run_test_cmd b/ci3/run_test_cmd index 1586444573f7..66334e535f27 100755 --- a/ci3/run_test_cmd +++ b/ci3/run_test_cmd @@ -170,39 +170,42 @@ if [ "$live_logging" -eq 1 ]; then publish_pid=$! fi -# Reset timer and run the test in background (for prompt signal handling) using exec_test. -SECONDS=0 -set +e -$ci3/exec_test "$cmd" >> "$tmp_file" 2>&1 & -test_pid=$! -wait $test_pid -code=$? - -# If the test received a SIGTERM or SIGINT, we don't want to track or print anything, just exit. -if [ "$code" -eq 143 ] || [ "$code" -eq 130 ]; then - exit $code -fi +function run_test { + # Reset timer and run the test in background (for prompt signal handling) using exec_test. + SECONDS=0 + set +e + $ci3/exec_test "$cmd" >> "$tmp_file" 2>&1 & + test_pid=$! + wait $test_pid + code=$? + + # If the test received a SIGTERM or SIGINT, we don't want to track or print anything, just exit. + if [ "$code" -eq 143 ] || [ "$code" -eq 130 ]; then + exit $code + fi +} -if [ "$CI_REDIS_AVAILABLE" -eq 1 ]; then - # If the test succeeded and we're in CI, set success flag for test. This key is unique to the test. - # If the test succeeded and we're logging passes, save the test log. - # If the test failed, save the test log. - if [ $code -eq 0 ]; then - if [ "$CI" -eq 1 ]; then - redis_cli SETEX $key 604800 $log_key &>/dev/null - fi - if [ "$pass_log" -eq 1 ]; then - # Publish final log. - publish_log_final +function finalize_test { + if [ "$CI_REDIS_AVAILABLE" -eq 1 ]; then + # If the test succeeded and we're in CI, set success flag for test. This key is unique to the test. + # If the test succeeded and we're logging passes, save the test log. + # If the test failed, save the test log. + if [ $code -eq 0 ]; then + if [ "$CI" -eq 1 ]; then + redis_cli SETEX $key 604800 $log_key &>/dev/null + fi + if [ "$pass_log" -eq 1 ]; then + publish_log_final + else + # Scrub the link we optimistically (for live logging) set earlier. + log_info="" + fi else - # Scrub the link we optimistically (for live logging) set earlier. - log_info="" + # Publish final log, extending lifetime of failure to 12 weeks. + publish_log_final $((60 * 60 * 24 * 7 * 12)) fi - else - # Publish final log, extending lifetime of failure to 12 weeks. - publish_log_final $((60 * 60 * 24 * 7 * 12)) fi -fi +} function track_test { local list_key=$1 @@ -239,6 +242,8 @@ function publish_redis { # Show PASSED and early out on success. function pass { + finalize_test + local line="${green}PASSED${reset}${log_info:-}: $test_cmd (${SECONDS}s)" echo -e "$line" @@ -252,6 +257,8 @@ function pass { # Show FAILED and exit with error code. function fail { + finalize_test + local line="${red}FAILED${reset}${log_info:-}: $test_cmd (${SECONDS}s) (code: $code)" echo -e "$line" @@ -303,6 +310,8 @@ function flake { exit 0 } +run_test + # Test passed. [ $code -eq 0 ] && pass @@ -318,9 +327,18 @@ owners=$(echo "$test_entries" | jq -r '.owners[]' | sort -u) # Extract flake_group_id from first matching entry flake_group_id=$(echo "$test_entries" | jq -r '.flake_group_id // empty' | head -1) -# To not fail a test, we at least need an owner to notify. +# If there's no owner for a failed test, we consider it a hard fail. +# Otherwise we perform a single retry. if [ -z "$owners" ]; then fail else - flake + echo -e "${yellow}RETRYING${reset}${log_info:-}: $test_cmd" + + run_test + + # Test passed. Signal it as a flake, but pass. + [ $code -eq 0 ] && flake + + # Otherwise we failed twice in a row, so hard fail. + fail fi diff --git a/yarn-project/.claude/skills/debug-e2e/SKILL.md b/yarn-project/.claude/skills/debug-e2e/SKILL.md index 3b3ed4296d21..5f1b39a97d10 100644 --- a/yarn-project/.claude/skills/debug-e2e/SKILL.md +++ b/yarn-project/.claude/skills/debug-e2e/SKILL.md @@ -1,6 +1,7 @@ --- name: debug-e2e description: Interactive debugging for failed e2e tests. Orchestrates the debugging session but delegates log reading to subagents to keep the main conversation clean. Use for ping-pong debugging sessions where you want to form and test hypotheses together with the user. +argument-hint: --- # E2E Test Debugging @@ -145,19 +146,17 @@ To understand when a test started failing: To run tests locally for verification: ```bash -cd end-to-end - # Run specific test -yarn test:e2e .test.ts -t '' +yarn workspace @aztec/end-to-end test:e2e .test.ts -t '' # With verbose logging -LOG_LEVEL=verbose yarn test:e2e .test.ts -t '' +LOG_LEVEL=verbose yarn workspace @aztec/end-to-end test:e2e .test.ts -t '' # With debug logging (very detailed) -LOG_LEVEL=debug yarn test:e2e .test.ts -t '' +LOG_LEVEL=debug yarn workspace @aztec/end-to-end test:e2e .test.ts -t '' # With specific module logging -LOG_LEVEL='info; debug:sequencer,p2p' yarn test:e2e .test.ts -t '' +LOG_LEVEL='info; debug:sequencer,p2p' yarn workspace @aztec/end-to-end test:e2e .test.ts -t '' ``` ## Log Structure diff --git a/yarn-project/.claude/skills/fix-pr/SKILL.md b/yarn-project/.claude/skills/fix-pr/SKILL.md index 56158e5f8d31..15df2bb38577 100644 --- a/yarn-project/.claude/skills/fix-pr/SKILL.md +++ b/yarn-project/.claude/skills/fix-pr/SKILL.md @@ -16,6 +16,17 @@ Autonomous workflow to fix CI failures for a PR. Delegates failure identificatio ## Workflow +### Phase 0: Validate PR + +Before doing anything, verify the PR is valid: + +```bash +gh pr view --repo AztecProtocol/aztec-packages --json state,baseRefName,headRefName +``` + +**Abort if:** +- `state` is not `OPEN` → "PR #\ is \, nothing to fix." + ### Phase 1: Identify Failures Spawn the `identify-ci-failures` subagent: @@ -37,20 +48,14 @@ This returns: ### Phase 2: Checkout and Rebase ```bash -# Get PR info -gh pr view --repo AztecProtocol/aztec-packages --json headRefName,baseRefName - -# Checkout PR gh pr checkout - -# Rebase on base branch git fetch origin git rebase origin/ ``` If there are conflicts: 1. Resolve the conflicts -2. `git add .` +2. `git add ` 3. `git rebase --continue` **Important**: Always REBASE, never merge. @@ -73,76 +78,51 @@ Run from `yarn-project` directory. | Failure Type | Fix Action | |-------------|------------| -| **FORMAT** | `yarn format ` | +| **FORMAT** | `yarn format` | | **LINT** | `yarn lint` | | **BUILD** | `yarn build`, fix TypeScript errors, repeat | | **UNIT TEST** | `yarn workspace @aztec/ test `, fix, repeat | | **E2E TEST** | For simple failures, fix. For complex failures, suggest `/debug-e2e` | -#### Format Errors -```bash -yarn format -``` +### Phase 5: Quality Checklist -#### Lint Errors -```bash -yarn lint -``` +Before committing, run from `yarn-project`: -#### Build Errors ```bash yarn build -# Fix errors shown -yarn build # Repeat until clean +yarn format +yarn lint ``` -#### Unit Test Errors +Run tests for modified files: ```bash yarn workspace @aztec/ test .test.ts -# Fix errors -yarn workspace @aztec/ test .test.ts # Repeat until passing -``` - -#### E2E Test Errors - -For simple failures (obvious assertion fix): -```bash -yarn workspace @aztec/end-to-end test:e2e .test.ts -t '' -# Fix and repeat ``` -For complex failures (flaky, timeout, unclear cause): -- Inform the user that this needs deeper investigation -- Suggest using `/debug-e2e` skill instead - -### Phase 5: Quality Checklist - -Before committing, run from `yarn-project`: +### Phase 6: Commit and Push -```bash -yarn build # Ensure it compiles -yarn format # Format modified packages -yarn lint # Lint (same as CI) -``` +If the PR targets `next`, amend to keep it as a single commit: -Run tests for modified files: ```bash -yarn workspace @aztec/ test .test.ts +git add . +git commit --amend --no-edit +git push --force-with-lease ``` -### Phase 6: Amend and Push +Otherwise, create a normal commit: ```bash git add . -git commit --amend --no-edit -git push --force-with-lease +git commit -m "fix: " +git push ``` ## Key Points +- **Validate first**: Only fix PRs that are open - **Delegate identification**: Use `identify-ci-failures` subagent, don't analyze logs directly - **Rebase, don't merge**: Always rebase on the base branch -- **Amend, don't create new commits**: PRs should be single commits +- **Amend only for PRs targeting `next`**: Other PRs use normal commits - **Bootstrap when needed**: Only if changes outside yarn-project - **Escalate e2e failures**: Complex e2e issues need `/debug-e2e` diff --git a/yarn-project/.claude/skills/rebase-pr/SKILL.md b/yarn-project/.claude/skills/rebase-pr/SKILL.md index d06cc4d6b25b..5bf3e731536f 100644 --- a/yarn-project/.claude/skills/rebase-pr/SKILL.md +++ b/yarn-project/.claude/skills/rebase-pr/SKILL.md @@ -16,28 +16,26 @@ Simple workflow to rebase a PR on its base branch, resolve conflicts, and push. ## Workflow -### Step 1: Get PR Info +### Step 1: Validate PR ```bash -gh pr view --repo AztecProtocol/aztec-packages --json headRefName,baseRefName +gh pr view --repo AztecProtocol/aztec-packages --json state,headRefName,baseRefName ``` -Note the `baseRefName` (usually `next` or `master`). +**Abort if:** +- `state` is not `OPEN` → "PR #\ is \, nothing to rebase." -### Step 2: Checkout PR +Note the `baseRefName` (usually `next` or `merge-train/*`). -```bash -gh pr checkout -``` - -### Step 3: Rebase on Base Branch +### Step 2: Checkout and Rebase ```bash +gh pr checkout git fetch origin git rebase origin/ ``` -### Step 4: Resolve Conflicts (if any) +### Step 3: Resolve Conflicts (if any) If there are conflicts: @@ -62,7 +60,7 @@ If there are conflicts: **Important**: Always REBASE, never merge. -### Step 5: Bootstrap (if needed) +### Step 4: Bootstrap (if needed) Check if changes exist outside `yarn-project`: ```bash @@ -74,7 +72,7 @@ If yes, run bootstrap from repo root: (cd $(git rev-parse --show-toplevel) && BOOTSTRAP_TO=yarn-project ./bootstrap.sh) ``` -### Step 6: Verify Build +### Step 5: Verify Build Run from `yarn-project`: @@ -84,16 +82,20 @@ yarn build If there are build errors from the rebase, fix them. -### Step 7: Quality Checklist +### Step 6: Quality Checklist Format and lint ALL packages: ```bash yarn format -yarn lint +yarn lint ``` -### Step 8: Amend and Push +### Step 7: Commit and Push + +If there are changes from build fixes or conflict resolution, commit and push. + +If the PR targets `next`, amend to keep it as a single commit: ```bash git add . @@ -101,10 +103,18 @@ git commit --amend --no-edit git push --force-with-lease ``` +Otherwise, create a normal commit: + +```bash +git add . +git commit -m "fix: resolve rebase conflicts" +git push --force-with-lease +``` + ## Key Points - **Rebase, don't merge**: Always use `git rebase`, never `git merge` -- **Amend, don't create new commits**: PRs should be single commits +- **Amend only for PRs targeting `next`**: Other PRs use normal commits - **Bootstrap when needed**: Only if there are changes outside yarn-project - **Verify build**: Always run `yarn build` after rebase - **Force push with lease**: Use `--force-with-lease` for safety diff --git a/yarn-project/.claude/skills/worktree-spawn/SKILL.md b/yarn-project/.claude/skills/worktree-spawn/SKILL.md index fe24000b90fd..7232f1a45115 100644 --- a/yarn-project/.claude/skills/worktree-spawn/SKILL.md +++ b/yarn-project/.claude/skills/worktree-spawn/SKILL.md @@ -1,6 +1,7 @@ --- name: worktree-spawn description: Spawn an independent Claude instance in a git worktree to work on a task in parallel. Use when the user wants to delegate a task to run independently while continuing the current conversation. +argument-hint: --- # Worktree Spawn diff --git a/yarn-project/CLAUDE.md b/yarn-project/CLAUDE.md index e36bea16575c..051d4edf5634 100644 --- a/yarn-project/CLAUDE.md +++ b/yarn-project/CLAUDE.md @@ -232,16 +232,14 @@ For PRs with multiple commits that should be preserved (e.g., porting multiple P ### Fixing PRs -When fixing an existing PR (CI failures, review feedback, etc.), always amend the existing commit - never create new commits. +PRs are squashed to a single commit on merge, so during development just create normal commits. Only amend when explicitly asked or when using the `/fix-pr` skill on a PR targeting `next`. ```bash git add . -git commit --amend --no-edit -git push --force-with-lease +git commit -m "fix: address review feedback" +git push ``` -This keeps the PR as a single commit. CI enforces PRs have a single commit. - ### Breaking Changes 1. Use the `/update-changelog` skill for documenting any breaking changes diff --git a/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts b/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts index 83eb9725121e..d0cf585f608e 100644 --- a/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts +++ b/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts @@ -49,7 +49,7 @@ const NODE_COUNT = 5; const VALIDATOR_COUNT = 4; const COMMITTEE_SIZE = 4; -describe('HA Full Setup', () => { +describe.skip('HA Full Setup', () => { jest.setTimeout(20 * 60 * 1000); // 20 minutes let logger: Logger; 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 22b3a068191b..8f7def4aab3d 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 @@ -27,7 +27,7 @@ import { sendL1ToL2Message } from '../fixtures/l1_to_l2_messaging.js'; import { type EndToEndContext, getPrivateKeyFromIndex } from '../fixtures/utils.js'; import { EpochsTestContext } from './epochs_test.js'; -jest.setTimeout(1000 * 60 * 15); +jest.setTimeout(1000 * 60 * 20); const NODE_COUNT = 4; const EXPECTED_BLOCKS_PER_CHECKPOINT = 3; @@ -87,8 +87,8 @@ describe('e2e_epochs/epochs_mbps', () => { initialValidators: validators, mockGossipSubNetwork: true, disableAnvilTestWatcher: true, - aztecProofSubmissionEpochs: 1024, - startProverNode: false, + startProverNode: true, + aztecEpochDuration: 4, enforceTimeTable: true, // L1 slot duration - using < 8 to enable test mode optimizations ethereumSlotDuration: 4, @@ -142,19 +142,21 @@ describe('e2e_epochs/epochs_mbps', () => { logger.warn(`Test setup completed.`, { validators: validators.map(v => v.attester.toString()) }); } - /** Retrieves all checkpoints from the archiver and checks that one of them at least has the target block count */ - async function assertMultipleBlocksPerSlot(targetBlockCount: number, logger: Logger) { + /** 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 { const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); logger.warn(`Retrieved ${checkpoints.length} checkpoints from archiver`, { checkpoints: checkpoints.map(pc => pc.checkpoint.getStats()), }); let expectedBlockNumber = checkpoints[0].checkpoint.blocks[0].number; - let targetFound = false; + let multiBlockCheckpointNumber: CheckpointNumber | undefined; for (const checkpoint of checkpoints) { const blockCount = checkpoint.checkpoint.blocks.length; - targetFound = targetFound || blockCount >= targetBlockCount; + if (blockCount >= targetBlockCount && multiBlockCheckpointNumber === undefined) { + multiBlockCheckpointNumber = checkpoint.checkpoint.number; + } logger.warn(`Checkpoint ${checkpoint.checkpoint.number} has ${blockCount} blocks`, { checkpoint: checkpoint.checkpoint.getStats(), }); @@ -168,7 +170,16 @@ describe('e2e_epochs/epochs_mbps', () => { } } - expect(targetFound).toBe(true); + expect(multiBlockCheckpointNumber).toBeDefined(); + return multiBlockCheckpointNumber!; + } + + /** Waits until a specific multi-block checkpoint is proven, verifying that proving succeeds with MBPS blocks. */ + async function waitForProvenCheckpoint(targetCheckpoint: CheckpointNumber) { + const provenTimeout = test.L2_SLOT_DURATION_IN_S * test.epochDuration * 4; + logger.warn(`Waiting for checkpoint ${targetCheckpoint} to be proven (timeout=${provenTimeout}s)`); + await test.waitUntilProvenCheckpointNumber(targetCheckpoint, provenTimeout); + logger.warn(`Proven checkpoint advanced to ${test.monitor.provenCheckpointNumber}`); } afterEach(async () => { @@ -202,7 +213,8 @@ describe('e2e_epochs/epochs_mbps', () => { ); logger.warn(`All txs have been mined`); - await assertMultipleBlocksPerSlot(EXPECTED_BLOCKS_PER_CHECKPOINT, logger); + const multiBlockCheckpoint = await assertMultipleBlocksPerSlot(EXPECTED_BLOCKS_PER_CHECKPOINT, logger); + await waitForProvenCheckpoint(multiBlockCheckpoint); }); it('builds multiple blocks per slot with transactions anchored to proposed blocks', async () => { @@ -239,7 +251,8 @@ describe('e2e_epochs/epochs_mbps', () => { logger.warn(`All txs have been mined`); // We are fine with at least 2 blocks per checkpoint, since we may lose one sub-slot if assembling a tx is slow - await assertMultipleBlocksPerSlot(2, logger); + const multiBlockCheckpoint = await assertMultipleBlocksPerSlot(2, logger); + await waitForProvenCheckpoint(multiBlockCheckpoint); }); it('builds multiple blocks per slot with L2 to L1 messages', async () => { @@ -270,7 +283,7 @@ describe('e2e_epochs/epochs_mbps', () => { await Promise.all(txHashes.map(txHash => waitForTx(context.aztecNode, txHash, { timeout }))); logger.warn(`All L2→L1 message txs have been mined`); - await assertMultipleBlocksPerSlot(EXPECTED_BLOCKS_PER_CHECKPOINT, logger); + const multiBlockCheckpoint = await assertMultipleBlocksPerSlot(EXPECTED_BLOCKS_PER_CHECKPOINT, logger); // Verify L2→L1 messages are in the blocks const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); @@ -278,6 +291,7 @@ describe('e2e_epochs/epochs_mbps', () => { const allL2ToL1Messages = allBlocks.flatMap(block => block.body.txEffects.flatMap(txEffect => txEffect.l2ToL1Msgs)); logger.warn(`Found ${allL2ToL1Messages.length} L2→L1 message(s) across all blocks`, { allL2ToL1Messages }); expect(allL2ToL1Messages.length).toBeGreaterThanOrEqual(TX_COUNT); + await waitForProvenCheckpoint(multiBlockCheckpoint); }); it('builds multiple blocks per slot with L1 to L2 messages', async () => { @@ -366,7 +380,8 @@ describe('e2e_epochs/epochs_mbps', () => { await Promise.all(consumeTxHashes.map(txHash => waitForTx(context.aztecNode, txHash, { timeout }))); logger.warn(`All ${consumeTxHashes.length} L1→L2 messages consumed`); - await assertMultipleBlocksPerSlot(2, logger); + const multiBlockCheckpoint = await assertMultipleBlocksPerSlot(2, logger); + await waitForProvenCheckpoint(multiBlockCheckpoint); }); it('builds multiple blocks per slot and non-validators re-execute and sync multi-block slots', async () => { @@ -450,5 +465,8 @@ describe('e2e_epochs/epochs_mbps', () => { test.L2_SLOT_DURATION_IN_S * 10, 0.5, ); + + const multiBlockCheckpoint = await assertMultipleBlocksPerSlot(2, logger); + await waitForProvenCheckpoint(multiBlockCheckpoint); }); }); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts index 2c4d7458d7c6..4d0c64b7980e 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts @@ -213,10 +213,14 @@ export class EpochsTestContext { this.logger.warn('Creating and syncing a simulated prover node...'); const proverNodePrivateKey = this.getNextPrivateKey(); const proverIndex = this.proverNodes.length + 1; + const { mockGossipSubNetwork } = this.context; const proverNode = await withLoggerBindings({ actor: `prover-${proverIndex}` }, () => createAndSyncProverNode( proverNodePrivateKey, - { ...this.context.config }, + { + ...this.context.config, + p2pEnabled: this.context.config.p2pEnabled || mockGossipSubNetwork !== undefined, + }, { dataDirectory: join(this.context.config.dataDirectory!, randomBytes(8).toString('hex')), proverId: EthAddress.fromNumber(proverIndex), @@ -225,7 +229,12 @@ export class EpochsTestContext { }, this.context.aztecNode, this.context.prefilledPublicData ?? [], - { dateProvider: this.context.dateProvider }, + { + dateProvider: this.context.dateProvider, + p2pClientDeps: mockGossipSubNetwork + ? { p2pServiceFactory: getMockPubSubP2PServiceFactory(mockGossipSubNetwork) } + : undefined, + }, ), ); this.proverNodes.push(proverNode); diff --git a/yarn-project/end-to-end/src/fixtures/setup.ts b/yarn-project/end-to-end/src/fixtures/setup.ts index 32d4255595b2..eb323801fa1b 100644 --- a/yarn-project/end-to-end/src/fixtures/setup.ts +++ b/yarn-project/end-to-end/src/fixtures/setup.ts @@ -505,7 +505,7 @@ export async function setup( const proverNodeConfig = { ...config.proverNodeConfig, dataDirectory: proverNodeDataDirectory, - p2pEnabled: false, + p2pEnabled: !!mockGossipSubNetwork, }; proverNode = await createAndSyncProverNode( proverNodePrivateKeyHex, @@ -513,6 +513,11 @@ export async function setup( proverNodeConfig, aztecNodeService, prefilledPublicData, + { + p2pClientDeps: mockGossipSubNetwork + ? { p2pServiceFactory: getMockPubSubP2PServiceFactory(mockGossipSubNetwork) } + : undefined, + }, ); } diff --git a/yarn-project/ethereum/src/l1_tx_utils/fee-strategies/p75_competitive.ts b/yarn-project/ethereum/src/l1_tx_utils/fee-strategies/p75_competitive.ts index 3cfe0fe1cd51..c83524d592ea 100644 --- a/yarn-project/ethereum/src/l1_tx_utils/fee-strategies/p75_competitive.ts +++ b/yarn-project/ethereum/src/l1_tx_utils/fee-strategies/p75_competitive.ts @@ -134,7 +134,7 @@ export const P75AllTxsPriorityFeeStrategy: PriorityFeeStrategy = { // Sanity check: cap competitive fee at 100x network estimate to avoid using unrealistic fees const maxReasonableFee = networkEstimate * 100n; if (competitiveFee > maxReasonableFee && networkEstimate > 0n) { - logger?.warn('Competitive fee exceeds sanity cap, using capped value', { + logger?.debug('Competitive fee exceeds sanity cap, using capped value', { competitiveFee: formatGwei(competitiveFee), networkEstimate: formatGwei(networkEstimate), cappedTo: formatGwei(maxReasonableFee), diff --git a/yarn-project/ethereum/src/l1_tx_utils/fee-strategies/p75_competitive_blob_txs_only.ts b/yarn-project/ethereum/src/l1_tx_utils/fee-strategies/p75_competitive_blob_txs_only.ts index f8624d010293..3863de806116 100644 --- a/yarn-project/ethereum/src/l1_tx_utils/fee-strategies/p75_competitive_blob_txs_only.ts +++ b/yarn-project/ethereum/src/l1_tx_utils/fee-strategies/p75_competitive_blob_txs_only.ts @@ -207,7 +207,7 @@ export const P75BlobTxsOnlyPriorityFeeStrategy: PriorityFeeStrategy = { // Debug: Log suspicious fees from history if (medianHistoricalFee > 100n * WEI_CONST) { - logger?.warn('Suspicious high fee in history', { + logger?.debug('Suspicious high fee in history', { historicalMedian: formatGwei(medianHistoricalFee), allP75Fees: percentile75Fees.map(f => formatGwei(f)), }); diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index 7d3d4bcf32d1..e5cc85f214de 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -100,6 +100,7 @@ export type EnvVar = | 'P2P_BATCH_TX_REQUESTER_TX_BATCH_SIZE' | 'P2P_BATCH_TX_REQUESTER_BAD_PEER_THRESHOLD' | 'P2P_BLOCK_CHECK_INTERVAL_MS' + | 'P2P_SLOT_CHECK_INTERVAL_MS' | 'P2P_BLOCK_REQUEST_BATCH_SIZE' | 'P2P_BOOTSTRAP_NODE_ENR_VERSION_CHECK' | 'P2P_BOOTSTRAP_NODES_AS_FULL_PEERS' diff --git a/yarn-project/node-keystore/src/keystore_manager.test.ts b/yarn-project/node-keystore/src/keystore_manager.test.ts index 6ac491678b8c..b2861a964a8f 100644 --- a/yarn-project/node-keystore/src/keystore_manager.test.ts +++ b/yarn-project/node-keystore/src/keystore_manager.test.ts @@ -1442,7 +1442,7 @@ describe('KeystoreManager', () => { expect(validateAccessSpy).toHaveBeenCalledWith(testUrl, [publisherAddress.toString()]); }); - it('should handle validation errors', async () => { + it('should handle validation errors after retries are exhausted', async () => { const testUrl = 'http://test-signer:9000'; const address = EthAddress.random(); @@ -1456,11 +1456,52 @@ describe('KeystoreManager', () => { ], }; + const manager = new KeystoreManager(keystore); + using validateAccessSpy = jest.spyOn(RemoteSigner, 'validateAccess'); - validateAccessSpy.mockRejectedValueOnce(new Error('Connection refused')); + validateAccessSpy.mockRejectedValue(new Error('Connection refused')); + + jest.useFakeTimers(); + + const promise = manager.validateSigners().catch(err => err); + await jest.advanceTimersByTimeAsync(32_000); + const error = await promise; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Connection refused'); + + jest.useRealTimers(); + }); + + it('should retry and succeed when validateAccess fails transiently', async () => { + const testUrl = 'http://test-signer:9000'; + const address = EthAddress.random(); + + const keystore: KeyStore = { + schemaVersion: 1, + validators: [ + { + attester: { address, remoteSignerUrl: testUrl }, + feeRecipient: await AztecAddress.random(), + }, + ], + }; const manager = new KeystoreManager(keystore); - await expect(manager.validateSigners()).rejects.toThrow('Connection refused'); + + using validateAccessSpy = jest.spyOn(RemoteSigner, 'validateAccess'); + validateAccessSpy + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockResolvedValueOnce(undefined); + + jest.useFakeTimers(); + + const promise = manager.validateSigners(); + await jest.advanceTimersByTimeAsync(4_000); + await expect(promise).resolves.not.toThrow(); + expect(validateAccessSpy).toHaveBeenCalledTimes(3); + + jest.useRealTimers(); }); it('should skip validation for mnemonic and JSON V3 configs', async () => { diff --git a/yarn-project/node-keystore/src/keystore_manager.ts b/yarn-project/node-keystore/src/keystore_manager.ts index f734d7c33ceb..23391fe6bbc1 100644 --- a/yarn-project/node-keystore/src/keystore_manager.ts +++ b/yarn-project/node-keystore/src/keystore_manager.ts @@ -7,6 +7,7 @@ import type { EthSigner } from '@aztec/ethereum/eth-signer'; import { Buffer32 } from '@aztec/foundation/buffer'; import { EthAddress } from '@aztec/foundation/eth-address'; import type { Signature } from '@aztec/foundation/eth-signature'; +import { makeBackoff, retry } from '@aztec/foundation/retry'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { Wallet } from '@ethersproject/wallet'; @@ -61,7 +62,7 @@ export class KeystoreManager { /** * Validates all remote signers in the keystore are accessible and have the required addresses. - * Should be called after construction if validation is needed. + * Retries each web3signer URL with backoff to tolerate transient unavailability at boot time. */ async validateSigners(): Promise { // Collect all remote signers with their addresses grouped by URL @@ -127,12 +128,18 @@ export class KeystoreManager { collectRemoteSigners(this.keystore.prover.publisher, this.keystore.remoteSigner); } - // Validate each remote signer URL with all its addresses - for (const [url, addresses] of remoteSignersByUrl.entries()) { - if (addresses.size > 0) { - await RemoteSigner.validateAccess(url, Array.from(addresses)); - } - } + // Validate each remote signer URL with all its addresses, retrying on transient failures + await Promise.all( + Array.from(remoteSignersByUrl.entries()) + .filter(([, addresses]) => addresses.size > 0) + .map(([url, addresses]) => + retry( + () => RemoteSigner.validateAccess(url, Array.from(addresses)), + `Validating web3signer at ${url}`, + makeBackoff([1, 2, 4, 8, 16]), + ), + ), + ); } /** diff --git a/yarn-project/p2p/src/client/factory.ts b/yarn-project/p2p/src/client/factory.ts index a1646d91549f..9cfb36eaa5ce 100644 --- a/yarn-project/p2p/src/client/factory.ts +++ b/yarn-project/p2p/src/client/factory.ts @@ -4,18 +4,24 @@ import { DateProvider } from '@aztec/foundation/timer'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; import type { DataStoreConfig } from '@aztec/kv-store/config'; import { AztecLMDBStoreV2, createStore } from '@aztec/kv-store/lmdb-v2'; -import type { L2BlockSource } from '@aztec/stdlib/block'; +import type { BlockHash, L2BlockSource } from '@aztec/stdlib/block'; import type { ChainConfig } from '@aztec/stdlib/config'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import type { ClientProtocolCircuitVerifier, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import { P2PClientType } from '@aztec/stdlib/p2p'; +import { MerkleTreeId } from '@aztec/stdlib/trees'; import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-client'; import { P2PClient } from '../client/p2p_client.js'; import type { P2PConfig } from '../config.js'; import { AttestationPool, type AttestationPoolApi } from '../mem_pools/attestation_pool/attestation_pool.js'; import type { MemPools } from '../mem_pools/interface.js'; -import { AztecKVTxPool, type TxPool } from '../mem_pools/tx_pool/index.js'; +import type { TxPoolV2 } from '../mem_pools/tx_pool_v2/interfaces.js'; +import type { TxMetaData } from '../mem_pools/tx_pool_v2/tx_metadata.js'; +import { AztecKVTxPoolV2 } from '../mem_pools/tx_pool_v2/tx_pool_v2.js'; +import { AggregateTxValidator } from '../msg_validators/tx_validator/aggregate_tx_validator.js'; +import { BlockHeaderTxValidator } from '../msg_validators/tx_validator/block_header_validator.js'; +import { DoubleSpendTxValidator } from '../msg_validators/tx_validator/double_spend_validator.js'; import { DummyP2PService } from '../services/dummy_service.js'; import { LibP2PService } from '../services/index.js'; import { createFileStoreTxSources } from '../services/tx_collection/file_store_tx_source.js'; @@ -25,7 +31,7 @@ import { TxFileStore } from '../services/tx_file_store/tx_file_store.js'; import { configureP2PClientAddresses, createLibP2PPeerIdFromPrivateKey, getPeerIdPrivateKey } from '../util.js'; export type P2PClientDeps = { - txPool?: TxPool; + txPool?: TxPoolV2; store?: AztecAsyncKVStore; attestationPool?: AttestationPoolApi; logger?: Logger; @@ -70,13 +76,51 @@ export async function createP2PClient( const attestationStore = await createStore(P2P_ATTESTATION_STORE_NAME, 1, config, bindings); const l1Constants = await archiver.getL1Constants(); - const mempools: MemPools = { - txPool: - deps.txPool ?? - new AztecKVTxPool(store, archive, worldStateSynchronizer, telemetry, { + /** Validator factory for pool re-validation (double-spend + block header only). */ + const createPoolTxValidator = async () => { + await worldStateSynchronizer.syncImmediate(); + return new AggregateTxValidator( + new DoubleSpendTxValidator( + { + nullifiersExist: async (nullifiers: Buffer[]) => { + const merkleTree = worldStateSynchronizer.getCommitted(); + const indices = await merkleTree.findLeafIndices(MerkleTreeId.NULLIFIER_TREE, nullifiers); + return indices.map(index => index !== undefined); + }, + }, + bindings, + ), + new BlockHeaderTxValidator( + { + getArchiveIndices: (archives: BlockHash[]) => { + const merkleTree = worldStateSynchronizer.getCommitted(); + return merkleTree.findLeafIndices(MerkleTreeId.ARCHIVE, archives); + }, + }, + bindings, + ), + ); + }; + + const txPool = + deps.txPool ?? + new AztecKVTxPoolV2( + store, + archive, + { + l2BlockSource: archiver, + worldStateSynchronizer, + createTxValidator: createPoolTxValidator, + }, + telemetry, + { maxPendingTxCount: config.maxPendingTxCount, archivedTxLimit: config.archivedTxLimit, - }), + }, + ); + + const mempools: MemPools = { + txPool, attestationPool: deps.attestationPool ?? new AttestationPool(attestationStore, telemetry), }; @@ -138,6 +182,7 @@ export async function createP2PClient( p2pService, txCollection, txFileStore, + epochCache, config, dateProvider, telemetry, diff --git a/yarn-project/p2p/src/client/interface.ts b/yarn-project/p2p/src/client/interface.ts index 6489c62773f5..350fc90c1ffb 100644 --- a/yarn-project/p2p/src/client/interface.ts +++ b/yarn-project/p2p/src/client/interface.ts @@ -1,7 +1,8 @@ +import type { SlotNumber } from '@aztec/foundation/branded-types'; import type { EthAddress, L2BlockId } from '@aztec/stdlib/block'; import type { P2PApiFull } from '@aztec/stdlib/interfaces/server'; import type { BlockProposal, CheckpointAttestation, CheckpointProposal, P2PClientType } from '@aztec/stdlib/p2p'; -import type { Tx, TxHash } from '@aztec/stdlib/tx'; +import type { BlockHeader, Tx, TxHash } from '@aztec/stdlib/tx'; import type { PeerId } from '@libp2p/interface'; import type { ENR } from '@nethermindeth/enr'; @@ -100,14 +101,6 @@ export type P2P = P2PApiFull & */ registerDuplicateAttestationCallback(callback: (info: DuplicateAttestationInfo) => void): void; - /** - * Request a list of transactions from another peer by their tx hashes. - * @param txHashes - Hashes of the txs to query. - * @param pinnedPeerId - An optional peer id that will be used to request the tx from (in addition to other random peers). - * @returns A list of transactions or undefined if the transactions are not found. - */ - requestTxsByHash(txHashes: TxHash[], pinnedPeerId: PeerId): Promise; - /** * Verifies the 'tx' and, if valid, adds it to local tx pool and forwards it to other peers. * @param tx - The transaction. @@ -122,11 +115,10 @@ export type P2P = P2PApiFull & addTxsToPool(txs: Tx[]): Promise; /** - * Deletes 'txs' from the pool, given hashes. - * NOT used if we use sendTx as reconcileTxPool will handle this. - * @param txHashes - Hashes to check. + * Handles failed transaction execution by removing txs from the pool. + * @param txHashes - Hashes of the transactions that failed execution. **/ - deleteTxs(txHashes: TxHash[]): Promise; + handleFailedExecution(txHashes: TxHash[]): Promise; /** * Returns a transaction in the transaction pool by its hash. @@ -178,10 +170,21 @@ export type P2P = P2PApiFull & getPendingTxCount(): Promise; /** - * Marks transactions as non-evictable in the pool. - * @param txHashes - Hashes of the transactions to mark as non-evictable. + * Protects existing transactions by hash for a given slot. + * Returns hashes of transactions that weren't found in the pool. + * @param txHashes - Hashes of the transactions to protect. + * @param blockHeader - The block header providing slot context. + * @returns Hashes of transactions not found in the pool. + */ + protectTxs(txHashes: TxHash[], blockHeader: BlockHeader): Promise; + + /** + * Prepares the pool for a new slot. + * Unprotects transactions from earlier slots and validates them before + * returning to pending state. + * @param slotNumber - The slot number to prepare for */ - markTxsAsNonEvictable(txHashes: TxHash[]): Promise; + prepareForSlot(slotNumber: SlotNumber): Promise; /** * Starts the p2p client. diff --git a/yarn-project/p2p/src/client/p2p_client.test.ts b/yarn-project/p2p/src/client/p2p_client.test.ts index 773376927c24..25200df67014 100644 --- a/yarn-project/p2p/src/client/p2p_client.test.ts +++ b/yarn-project/p2p/src/client/p2p_client.test.ts @@ -1,23 +1,14 @@ import { MockL2BlockSource } from '@aztec/archiver/test'; +import type { EpochCacheInterface } from '@aztec/epoch-cache'; import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; -import { times, timesAsync } from '@aztec/foundation/collection'; -import { Fr } from '@aztec/foundation/curves/bn254'; +import { timesAsync } from '@aztec/foundation/collection'; import { retryFastUntil } from '@aztec/foundation/retry'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { L2Block } from '@aztec/stdlib/block'; import { EmptyL1RollupConstants, type L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; -import { GasFees } from '@aztec/stdlib/gas'; -import type { MerkleTreeReadOperations, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import { P2PClientType } from '@aztec/stdlib/p2p'; import { mockTx } from '@aztec/stdlib/testing'; -import { - MerkleTreeId, - NullifierLeaf, - NullifierLeafPreimage, - PublicDataTreeLeaf, - PublicDataTreeLeafPreimage, -} from '@aztec/stdlib/trees'; import { TxArray, TxHash, TxHashArray } from '@aztec/stdlib/tx'; import { expect, jest } from '@jest/globals'; @@ -27,14 +18,13 @@ import type { P2PConfig } from '../config.js'; import type { P2PService } from '../index.js'; import { type AttestationPool, createTestAttestationPool } from '../mem_pools/attestation_pool/attestation_pool.js'; import type { MemPools } from '../mem_pools/interface.js'; -import { AztecKVTxPool } from '../mem_pools/tx_pool/aztec_kv_tx_pool.js'; -import type { TxPool } from '../mem_pools/tx_pool/index.js'; +import type { TxPoolV2 } from '../mem_pools/tx_pool_v2/interfaces.js'; import { ReqRespSubProtocol } from '../services/reqresp/interface.js'; import type { TxCollection } from '../services/tx_collection/tx_collection.js'; import { P2PClient } from './p2p_client.js'; describe('P2P Client', () => { - let txPool: MockProxy; + let txPool: MockProxy; let attestationPool: AttestationPool; let mempools: MemPools; let blockSource: MockL2BlockSource; @@ -42,16 +32,15 @@ describe('P2P Client', () => { let kvStore: AztecAsyncKVStore; let client: P2PClient; let txCollection: MockProxy; + let epochCache: MockProxy; let l1Constants: L1RollupConstants; beforeEach(async () => { - txPool = mock(); - txPool.getAllTxs.mockResolvedValue([]); + txPool = mock(); txPool.getPendingTxHashes.mockResolvedValue([]); txPool.getMinedTxHashes.mockResolvedValue([]); - txPool.getAllTxHashes.mockResolvedValue([]); txPool.hasTxs.mockResolvedValue([]); - txPool.addTxs.mockResolvedValue(1); + txPool.addPendingTxs.mockResolvedValue({ accepted: [], ignored: [], rejected: [] }); p2pService = mock(); p2pService.sendBatchRequest.mockResolvedValue([]); @@ -60,6 +49,9 @@ describe('P2P Client', () => { txCollection = mock(); txCollection.getConstants.mockReturnValue(l1Constants); + epochCache = mock(); + epochCache.getCurrentAndNextSlot.mockReturnValue({ currentSlot: SlotNumber(0), nextSlot: SlotNumber(1) }); + attestationPool = await createTestAttestationPool(); blockSource = new MockL2BlockSource(); @@ -71,7 +63,17 @@ describe('P2P Client', () => { }); const createClient = (config: Partial = {}) => - new P2PClient(P2PClientType.Full, kvStore, blockSource, mempools, p2pService, txCollection, undefined, config); + new P2PClient( + P2PClientType.Full, + kvStore, + blockSource, + mempools, + p2pService, + txCollection, + undefined, + epochCache, + config, + ); const advanceToProvenBlock = async (blockNumber: BlockNumber) => { blockSource.setProvenBlockNumber(blockNumber); @@ -105,24 +107,27 @@ describe('P2P Client', () => { const tx1 = await mockTx(); const tx2 = await mockTx(); + txPool.addPendingTxs.mockResolvedValueOnce({ accepted: [tx1.getTxHash()], ignored: [], rejected: [] }); await client.sendTx(tx1); + txPool.addPendingTxs.mockResolvedValueOnce({ accepted: [tx2.getTxHash()], ignored: [], rejected: [] }); await client.sendTx(tx2); - expect(txPool.addTxs).toHaveBeenCalledTimes(2); + expect(txPool.addPendingTxs).toHaveBeenCalledTimes(2); expect(p2pService.propagate).toHaveBeenCalledTimes(2); await client.stop(); }); - it('adds txs to pool and dont propagate it if it already existed', async () => { + it('does not propagate tx if it already existed', async () => { await client.start(); const tx1 = await mockTx(); + txPool.addPendingTxs.mockResolvedValueOnce({ accepted: [tx1.getTxHash()], ignored: [], rejected: [] }); await client.sendTx(tx1); - txPool.addTxs.mockResolvedValueOnce(0); + txPool.addPendingTxs.mockResolvedValueOnce({ accepted: [], ignored: [tx1.getTxHash()], rejected: [] }); await client.sendTx(tx1); - expect(txPool.addTxs).toHaveBeenCalledTimes(2); + expect(txPool.addPendingTxs).toHaveBeenCalledTimes(2); expect(p2pService.propagate).toHaveBeenCalledTimes(1); await client.stop(); @@ -132,14 +137,16 @@ describe('P2P Client', () => { await client.start(); const tx1 = await mockTx(); const tx2 = await mockTx(); + txPool.addPendingTxs.mockResolvedValueOnce({ accepted: [tx1.getTxHash()], ignored: [], rejected: [] }); await client.sendTx(tx1); + txPool.addPendingTxs.mockResolvedValueOnce({ accepted: [tx2.getTxHash()], ignored: [], rejected: [] }); await client.sendTx(tx2); - expect(txPool.addTxs).toHaveBeenCalledTimes(2); + expect(txPool.addPendingTxs).toHaveBeenCalledTimes(2); await client.stop(); const tx3 = await mockTx(); await expect(client.sendTx(tx3)).rejects.toThrow(); - expect(txPool.addTxs).toHaveBeenCalledTimes(2); + expect(txPool.addPendingTxs).toHaveBeenCalledTimes(2); }); it('restores the previous block number it was at', async () => { @@ -154,17 +161,17 @@ describe('P2P Client', () => { it('deletes txs once block is finalized', async () => { blockSource.setProvenBlockNumber(0); await client.start(); - expect(txPool.deleteTxs).not.toHaveBeenCalled(); + expect(txPool.handleFinalizedBlock).not.toHaveBeenCalled(); await advanceToProvenBlock(BlockNumber(5)); - expect(txPool.deleteTxs).not.toHaveBeenCalled(); + expect(txPool.handleFinalizedBlock).not.toHaveBeenCalled(); await advanceToFinalizedBlock(BlockNumber(5)); - expect(txPool.deleteTxs).toHaveBeenCalledTimes(1); - txPool.deleteTxs.mockClear(); + expect(txPool.handleFinalizedBlock).toHaveBeenCalledTimes(1); + txPool.handleFinalizedBlock.mockClear(); await advanceToFinalizedBlock(BlockNumber(8)); - expect(txPool.deleteTxs).toHaveBeenCalledTimes(1); + expect(txPool.handleFinalizedBlock).toHaveBeenCalledTimes(1); await client.stop(); }); @@ -173,20 +180,25 @@ describe('P2P Client', () => { const mockTx2 = await mockTx(); const mockTx3 = await mockTx(); + // None of the txs are in the pool + txPool.getTxByHash.mockResolvedValue(undefined); + // P2P service will not return tx2 p2pService.sendBatchRequest.mockResolvedValue([new TxArray(...[mockTx1, mockTx3])]); - // Spy on the tx pool addTxs method, it should not be called for the missing tx - const addTxsSpy = jest.spyOn(txPool, 'addTxs'); + // Spy on the tx pool addPendingTxs method, it should not be called for the missing tx + const addTxsSpy = jest.spyOn(txPool, 'addPendingTxs'); - // We query for all 3 txs + await client.start(); + + // We query for all 3 txs via getTxsByHash which internally requests from the network const txHashes = await Promise.all([mockTx1.getTxHash(), mockTx2.getTxHash(), mockTx3.getTxHash()]); - const results = await client.requestTxsByHash(txHashes, undefined); + const results = await client.getTxsByHash(txHashes, undefined); - // We should receive the found transactions - expect(results).toEqual([mockTx1, mockTx3]); + // We should receive the found transactions (tx2 will be undefined) + expect(results).toEqual([mockTx1, undefined, mockTx3]); - // P2P should have been called with the 3 tx hashes + // P2P should have been called with the 3 tx hashes (all missing from pool) expect(p2pService.sendBatchRequest).toHaveBeenCalledWith( ReqRespSubProtocol.TX, txHashes.map(hash => new TxHashArray(...[hash])), @@ -199,6 +211,8 @@ describe('P2P Client', () => { // Retrieved txs should have been added to the pool expect(addTxsSpy).toHaveBeenCalledTimes(1); expect(addTxsSpy).toHaveBeenCalledWith([mockTx1, mockTx3]); + + await client.stop(); }); it('getTxsByHash handles missing items', async () => { @@ -213,8 +227,7 @@ describe('P2P Client', () => { Promise.resolve(txHash === txInMempool.getTxHash() ? txInMempool : undefined), ); - const addTxsSpy = jest.spyOn(txPool, 'addTxs'); - const requestTxsSpy = jest.spyOn(client, 'requestTxsByHash'); + const addTxsSpy = jest.spyOn(txPool, 'addPendingTxs'); p2pService.sendBatchRequest.mockResolvedValue([new TxArray(...[txToBeRequested])]); @@ -223,12 +236,19 @@ describe('P2P Client', () => { const query = await Promise.all([txInMempool.getTxHash(), txToBeRequested.getTxHash(), txToNotBeFound.getTxHash()]); const results = await client.getTxsByHash(query, undefined); - // We should return the resolved transactions - expect(results).toEqual([txInMempool, txToBeRequested]); + // We should return the resolved transactions (txToNotBeFound is undefined) + expect(results).toEqual([txInMempool, txToBeRequested, undefined]); // We should add the found requested transactions to the pool expect(addTxsSpy).toHaveBeenCalledWith([txToBeRequested]); - // We should request the missing transactions from the network, but only find one of them - expect(requestTxsSpy).toHaveBeenCalledWith([txToBeRequested.getTxHash(), txToNotBeFound.getTxHash()], undefined); + // The p2p service should have been called to request the missing txs + expect(p2pService.sendBatchRequest).toHaveBeenCalledWith( + ReqRespSubProtocol.TX, + expect.anything(), + undefined, + expect.anything(), + expect.anything(), + expect.anything(), + ); }); it('getPendingTxs respects pagination', async () => { @@ -254,68 +274,20 @@ describe('P2P Client', () => { await expect(client.getPendingTxs(10, TxHash.random())).resolves.toEqual([]); }); - it('getTxs respects pagination', async () => { - const allTxs = await timesAsync(50, i => mockTx(i)); - const minedTxs = allTxs.slice(0, Math.ceil(allTxs.length / 3)); - const pendingTxs = allTxs.slice(Math.ceil(allTxs.length / 3)); - - txPool.getMinedTxHashes.mockResolvedValue(minedTxs.map(tx => [tx.getTxHash(), BlockNumber(42)])); - txPool.getPendingTxHashes.mockResolvedValue(await Promise.all(pendingTxs.map(tx => tx.getTxHash()))); - - txPool.getAllTxs.mockResolvedValue(allTxs); - txPool.getAllTxHashes.mockResolvedValue(await Promise.all(allTxs.map(tx => tx.getTxHash()))); - txPool.getTxByHash.mockImplementation(hash => Promise.resolve(allTxs.find(tx => hash.equals(tx.getTxHash())))); - - for (const [txType, txs] of [ - ['all', allTxs], - ['pending', pendingTxs], - ['mined', minedTxs], - ] as const) { - const firstPage = await client.getTxs(txType, 2); - expect(firstPage).toEqual(txs.slice(0, 2)); - const secondPage = await client.getTxs(txType, 2, firstPage.at(-1)!.getTxHash()); - expect(secondPage).toEqual(txs.slice(2, 4)); - const thirdPage = await client.getTxs(txType, 10, secondPage.at(-1)!.getTxHash()); - expect(thirdPage).toEqual(txs.slice(4, 14)); - const lastPage = await client.getTxs(txType, undefined, thirdPage.at(-1)!.getTxHash()); - expect(lastPage).toEqual(txs.slice(14)); - - await expect(client.getTxs(txType, 1, lastPage.at(-1)!.getTxHash())).resolves.toEqual([]); - await expect(client.getTxs(txType)).resolves.toEqual(txs); - - await expect(client.getTxs(txType, 0)).rejects.toThrow(); - await expect(client.getTxs(txType, -1)).rejects.toThrow(); - - await expect(client.getTxs(txType, 10, TxHash.random())).resolves.toEqual([]); - } - }); - describe('Chain prunes', () => { - it('deletes transactions mined in pruned blocks when flag is enabled', async () => { - client = createClient({ txPoolDeleteTxsAfterReorg: true }); + it('calls handlePrunedBlocks when chain is pruned', async () => { blockSource.setProvenBlockNumber(0); await client.start(); - // Create two transactions: - // 1. A transaction mined in block 95 (which will be pruned) - // 2. A transaction mined in block 90 (which will remain) - const txMinedInPrunedBlock = await mockTx(); - const txMinedInKeptBlock = await mockTx(); - - // Mock the mined transactions - txPool.getMinedTxHashes.mockResolvedValue([ - [txMinedInPrunedBlock.getTxHash(), BlockNumber(95)], - [txMinedInKeptBlock.getTxHash(), BlockNumber(90)], - ]); - - txPool.getAllTxs.mockResolvedValue([txMinedInPrunedBlock, txMinedInKeptBlock]); - // Prune the chain back to block 90 blockSource.removeBlocks(10); await client.sync(); - // Verify only the transaction mined in the pruned block is deleted - expect(txPool.deleteTxs).toHaveBeenCalledWith([txMinedInPrunedBlock.getTxHash()]); + // Verify handlePrunedBlocks is called with the correct block ID + expect(txPool.handlePrunedBlocks).toHaveBeenCalledWith({ + number: BlockNumber(90), + hash: expect.any(String), + }); await client.stop(); }); @@ -359,58 +331,6 @@ describe('P2P Client', () => { finalized: { block: { number: BlockNumber(50), hash: expect.any(String) }, checkpoint: anyCheckpoint }, }); }); - - it('deletes txs created from a pruned block', async () => { - client = createClient(); - blockSource.setProvenBlockNumber(0); - await client.start(); - - // add two txs to the pool. One build against block 90, one against block 95 - // then prune the chain back to block 90 - // only one tx should be deleted - const goodTx = await mockTx(); - goodTx.data.constants.anchorBlockHeader.globalVariables.blockNumber = BlockNumber(90); - - const badTx = await mockTx(); - badTx.data.constants.anchorBlockHeader.globalVariables.blockNumber = BlockNumber(95); - - txPool.getAllTxs.mockResolvedValue([goodTx, badTx]); - - blockSource.removeBlocks(10); - await client.sync(); - expect(txPool.deleteTxs).toHaveBeenCalledWith([badTx.getTxHash()]); - await client.stop(); - }); - - it('moves mined and valid txs back to the pending set', async () => { - client = createClient(); - blockSource.setProvenBlockNumber(0); - await client.start(); - - // add three txs to the pool built against different blocks - // then prune the chain back to block 90 - // only one tx should be deleted - const goodButOldTx = await mockTx(); - goodButOldTx.data.constants.anchorBlockHeader.globalVariables.blockNumber = BlockNumber(89); - - const goodTx = await mockTx(); - goodTx.data.constants.anchorBlockHeader.globalVariables.blockNumber = BlockNumber(90); - - const badTx = await mockTx(); - badTx.data.constants.anchorBlockHeader.globalVariables.blockNumber = BlockNumber(95); - - txPool.getAllTxs.mockResolvedValue([goodButOldTx, goodTx, badTx]); - txPool.getMinedTxHashes.mockResolvedValue([ - [goodButOldTx.getTxHash(), BlockNumber(90)], - [goodTx.getTxHash(), BlockNumber(91)], - ]); - - blockSource.removeBlocks(10); - await client.sync(); - expect(txPool.deleteTxs).toHaveBeenCalledWith([badTx.getTxHash()]); - expect(txPool.markMinedAsPending).toHaveBeenCalledWith([goodTx.getTxHash()], expect.any(Number)); - await client.stop(); - }); }); describe('Attestation pool pruning', () => { @@ -501,90 +421,5 @@ describe('P2P Client', () => { expect(await actualBlock.hash()).toEqual(await block.hash()); expect(actualTxHashes).toEqual([block.body.txEffects[1].txHash]); }); - - it('clears non-evictable txs when new blocks are synced', async () => { - await client.start(); - blockSource.addProposedBlocks([await L2Block.random(BlockNumber(101))]); - await client.sync(); - - expect(txPool.clearNonEvictableTxs).toHaveBeenCalled(); - }); - - it('evicts low priority txs after block is mined and non-evictable status is cleared', async () => { - const worldState = mock(); - const db = mock(); - worldState.getCommitted.mockReturnValue(db); - worldState.getSnapshot.mockReturnValue(db); - - db.findLeafIndices.mockImplementation((_tree, leaves) => { - return Promise.resolve(times(leaves.length, () => 1n)); - }); - db.getPreviousValueIndex.mockImplementation((_tree, slot) => { - return Promise.resolve({ index: slot, alreadyPresent: true }); - }); - db.getLeafPreimage.mockImplementation((tree, index) => { - return Promise.resolve( - tree === MerkleTreeId.NULLIFIER_TREE - ? new NullifierLeafPreimage(new NullifierLeaf(new Fr(index)), Fr.ONE, 1n) - : new PublicDataTreeLeafPreimage(new PublicDataTreeLeaf(new Fr(index), new Fr(1e18)), Fr.ONE, 1n), - ); - }); - - const realTxPool = new AztecKVTxPool( - await openTmpStore('p2p'), - await openTmpStore('archive'), - worldState, - undefined, - { maxPendingTxCount: 3 }, - ); - - const realMempools = { txPool: realTxPool, attestationPool }; - const realClient = new P2PClient( - P2PClientType.Full, - await openTmpStore('test-real'), - blockSource, - realMempools, - p2pService, - txCollection, - undefined, - {}, - ); - - let nextTxSeed = 1; - const tx1 = await mockTx(nextTxSeed++, { maxPriorityFeesPerGas: new GasFees(1, 1) }); - const tx2 = await mockTx(nextTxSeed++, { maxPriorityFeesPerGas: new GasFees(2, 2) }); - const tx3 = await mockTx(nextTxSeed++, { maxPriorityFeesPerGas: new GasFees(3, 3) }); - await realTxPool.addTxs([tx1, tx2, tx3]); - await expect(realTxPool.getPendingTxHashes()).resolves.toEqual([ - tx3.getTxHash(), - tx2.getTxHash(), - tx1.getTxHash(), - ]); - - await realTxPool.markTxsAsNonEvictable([tx1.getTxHash()]); - - const tx4 = await mockTx(nextTxSeed++, { maxPriorityFeesPerGas: new GasFees(4, 4) }); - const tx5 = await mockTx(nextTxSeed++, { maxPriorityFeesPerGas: new GasFees(5, 5) }); - await realTxPool.addTxs([tx4, tx5]); - await expect(realTxPool.getPendingTxHashes()).resolves.toEqual([ - tx5.getTxHash(), - tx4.getTxHash(), - tx1.getTxHash(), - ]); - - await realClient.start(); - blockSource.addProposedBlocks([await L2Block.random(BlockNumber(101))]); - await realClient.sync(); - - const tx6 = await mockTx(nextTxSeed++, { maxPriorityFeesPerGas: new GasFees(6, 6) }); - await realTxPool.addTxs([tx6]); - await expect(realTxPool.getPendingTxHashes()).resolves.toEqual([ - tx6.getTxHash(), - tx5.getTxHash(), - tx4.getTxHash(), - ]); - - await realClient.stop(); - }); }); }); diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index 3afbcf88dfe4..5ef8c4db63ed 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -1,12 +1,15 @@ import { GENESIS_BLOCK_HEADER_HASH } from '@aztec/constants'; +import type { EpochCacheInterface } from '@aztec/epoch-cache'; import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { createLogger } from '@aztec/foundation/log'; +import { RunningPromise } from '@aztec/foundation/promise'; import { DateProvider } from '@aztec/foundation/timer'; import type { AztecAsyncKVStore, AztecAsyncSingleton } from '@aztec/kv-store'; import { L2TipsKVStore } from '@aztec/kv-store/stores'; import { type EthAddress, type L2Block, + type L2BlockId, type L2BlockSource, L2BlockStream, type L2BlockStreamEvent, @@ -22,7 +25,7 @@ import { type CheckpointProposal, type P2PClientType, } from '@aztec/stdlib/p2p'; -import type { Tx, TxHash } from '@aztec/stdlib/tx'; +import type { BlockHeader, Tx, TxHash } from '@aztec/stdlib/tx'; import { Attributes, type TelemetryClient, WithTracer, getTelemetryClient, trackSpan } from '@aztec/telemetry-client'; import type { PeerId } from '@libp2p/interface'; @@ -31,7 +34,7 @@ import type { ENR } from '@nethermindeth/enr'; import { type P2PConfig, getP2PDefaultConfig } from '../config.js'; import type { AttestationPoolApi } from '../mem_pools/attestation_pool/attestation_pool.js'; import type { MemPools } from '../mem_pools/interface.js'; -import type { TxPool } from '../mem_pools/tx_pool/index.js'; +import type { TxPoolV2 } from '../mem_pools/tx_pool_v2/interfaces.js'; import type { AuthRequest, StatusMessage } from '../services/index.js'; import { ReqRespSubProtocol, @@ -71,7 +74,7 @@ export class P2PClient private l2Tips: L2TipsStore; private synchedLatestSlot: AztecAsyncSingleton; - private txPool: TxPool; + private txPool: TxPoolV2; private attestationPool: AttestationPoolApi; private config: P2PConfig; @@ -82,14 +85,12 @@ export class P2PClient private validatorAddresses: EthAddress[] = []; - /** - * In-memory P2P client constructor. - * @param store - The client's instance of the KV store. - * @param l2BlockSource - P2P client's source for fetching existing blocks. - * @param txPool - The client's instance of a transaction pool. Defaults to in-memory implementation. - * @param p2pService - The concrete instance of p2p networking to use. - * @param log - A logger. - */ + /** Tracks the last slot for which we called prepareForSlot */ + private lastSlotProcessed: SlotNumber = SlotNumber.ZERO; + + /** Polls for slot changes and calls prepareForSlot on the tx pool */ + private slotMonitor: RunningPromise | undefined; + constructor( _clientType: T, private store: AztecAsyncKVStore, @@ -98,6 +99,7 @@ export class P2PClient private p2pService: P2PService, private txCollection: TxCollection, private txFileStore: TxFileStore | undefined, + private epochCache: EpochCacheInterface, config: Partial = {}, private _dateProvider: DateProvider = new DateProvider(), private telemetry: TelemetryClient = getTelemetryClient(), @@ -167,10 +169,9 @@ export class P2PClient return this.l2Tips.getL2BlockHash(number); } - public updateP2PConfig(config: Partial): Promise { - this.txPool.updateConfig(config); + public async updateP2PConfig(config: Partial): Promise { + await this.txPool.updateConfig(config); this.p2pService.updateConfig(config); - return Promise.resolve(); } public getL2Tips(): Promise { @@ -199,7 +200,7 @@ export class P2PClient break; case 'chain-pruned': this.txCollection.stopCollectingForBlocksAfter(event.block.number); - await this.handlePruneL2Blocks(event.block.number); + await this.handlePruneL2Blocks(event.block); break; case 'chain-checkpointed': break; @@ -215,7 +216,6 @@ export class P2PClient } #assertIsReady() { - // this.log.info('Checking if p2p client is ready, current state: ', this.currentState); if (!this.isReady()) { throw new Error('P2P client not ready'); } @@ -233,6 +233,9 @@ export class P2PClient return this.syncPromise; } + // Start the tx pool first, as it needs to hydrate state from persistence + await this.txPool.start(); + // get the current latest block numbers const latestBlockNumbers = await this.l2BlockSource.getL2Tips(); this.latestBlockNumberAtStart = latestBlockNumbers.proposed.number; @@ -283,6 +286,15 @@ export class P2PClient this.blockStream!.start(); await this.txCollection.start(); this.txFileStore?.start(); + + // Start slot monitor to call prepareForSlot when the slot changes + this.slotMonitor = new RunningPromise( + () => this.maybeCallPrepareForSlot(), + this.log, + this.config.slotCheckIntervalMS, + ); + this.slotMonitor.start(); + return this.syncPromise; } @@ -313,6 +325,8 @@ export class P2PClient */ public async stop() { this.log.debug('Stopping p2p client...'); + await this.slotMonitor?.stop(); + this.log.debug('Stopped slot monitor'); await tryStop(this.txCollection); this.log.debug('Stopped tx collection service'); await this.txFileStore?.stop(); @@ -321,6 +335,8 @@ export class P2PClient this.log.debug('Stopped p2p service'); await this.blockStream?.stop(); this.log.debug('Stopped block downloader'); + await this.txPool.stop(); + this.log.debug('Stopped tx pool'); await this.runningPromise; this.setCurrentState(P2PClientState.STOPPED); this.log.info('P2P client stopped'); @@ -409,7 +425,7 @@ export class P2PClient /** * Uses the batched Request Response protocol to request a set of transactions from the network. */ - public async requestTxsByHash(txHashes: TxHash[], pinnedPeerId: PeerId | undefined): Promise { + private async requestTxsByHash(txHashes: TxHash[], pinnedPeerId: PeerId | undefined): Promise { const timeoutMs = 8000; // Longer timeout for now const maxRetryAttempts = 10; // Keep retrying within the timeout const requests = chunkTxHashesRequest(txHashes); @@ -426,7 +442,7 @@ export class P2PClient const txs = txBatches.flat(); if (txs.length > 0) { - await this.txPool.addTxs(txs); + await this.txPool.addPendingTxs(txs); } const txHashesStr = txHashes.map(tx => tx.toString()).join(', '); @@ -436,79 +452,40 @@ export class P2PClient return txs; } - public getPendingTxs(limit?: number, after?: TxHash): Promise { - return this.getTxs('pending', limit, after); - } - - public getPendingTxCount(): Promise { - return this.txPool.getPendingTxCount(); - } - - public async *iteratePendingTxs(): AsyncIterableIterator { - for (const txHash of await this.txPool.getPendingTxHashes()) { - const tx = await this.txPool.getTxByHash(txHash); - if (tx) { - yield tx; - } - } - } - - /** - * Returns all transactions in the transaction pool. - * @param filter - The type of txs to return - * @param limit - How many txs to return - * @param after - If paginating, the last known tx hash. Will return txs after this hash - * @returns An array of Txs. - */ - public async getTxs(filter: 'all' | 'pending' | 'mined', limit?: number, after?: TxHash): Promise { + public async getPendingTxs(limit?: number, after?: TxHash): Promise { if (limit !== undefined && limit <= 0) { throw new TypeError('limit must be greater than 0'); } - let txs: Tx[] | undefined = undefined; - let txHashes: TxHash[]; - - if (filter === 'all') { - txs = await this.txPool.getAllTxs(); - txHashes = await Promise.all(txs.map(tx => tx.getTxHash())); - } else if (filter === 'mined') { - const minedTxHashes = await this.txPool.getMinedTxHashes(); - txHashes = minedTxHashes.map(([txHash]) => txHash); - } else if (filter === 'pending') { - txHashes = await this.txPool.getPendingTxHashes(); - } else { - const _: never = filter; - throw new Error(`Unknown filter ${filter}`); - } + let txHashes = await this.txPool.getPendingTxHashes(); let startIndex = 0; - let endIndex: number | undefined = undefined; - if (after) { startIndex = txHashes.findIndex(txHash => after.equals(txHash)); - - // if we can't find the last tx in our set then return an empty array as pagination is no longer valid. if (startIndex === -1) { return []; } - - // increment by one because we don't want to return the same tx again startIndex++; } - if (limit !== undefined) { - endIndex = startIndex + limit; - } - + const endIndex = limit !== undefined ? startIndex + limit : undefined; txHashes = txHashes.slice(startIndex, endIndex); - if (txs) { - txs = txs.slice(startIndex, endIndex); - } else { - const maybeTxs = await Promise.all(txHashes.map(txHash => this.txPool.getTxByHash(txHash))); - txs = maybeTxs.filter((tx): tx is Tx => !!tx); - } - return txs; + const maybeTxs = await Promise.all(txHashes.map(txHash => this.txPool.getTxByHash(txHash))); + return maybeTxs.filter((tx): tx is Tx => !!tx); + } + + public getPendingTxCount(): Promise { + return this.txPool.getPendingTxCount(); + } + + public async *iteratePendingTxs(): AsyncIterableIterator { + for (const txHash of await this.txPool.getPendingTxHashes()) { + const tx = await this.txPool.getTxByHash(txHash); + if (tx) { + yield tx; + } + } } /** @@ -586,15 +563,19 @@ export class P2PClient } /** - * Verifies the 'tx' and, if valid, adds it to local tx pool and forwards it to other peers. - * @param tx - The tx to verify. + * Accepts a transaction, adds it to local tx pool and forwards it to other peers. + * @param tx - The tx to send. * @returns Empty promise. **/ public async sendTx(tx: Tx): Promise { - const addedCount = await this.addTxsToPool([tx]); - const txAddedSuccessfully = addedCount === 1; - if (txAddedSuccessfully) { + this.#assertIsReady(); + const result = await this.txPool.addPendingTxs([tx]); + if (result.accepted.length === 1) { await this.p2pService.propagate(tx); + } else { + this.log.warn( + `Tx ${tx.getTxHash()} not propagated: accepted=${result.accepted.length} ignored=${result.ignored.length} rejected=${result.rejected.length}`, + ); } } @@ -604,7 +585,7 @@ export class P2PClient **/ public async addTxsToPool(txs: Tx[]): Promise { this.#assertIsReady(); - return await this.txPool.addTxs(txs); + return (await this.txPool.addPendingTxs(txs)).accepted.length; } /** @@ -612,8 +593,9 @@ export class P2PClient * @param txHash - Hash of the tx to query. * @returns Pending or mined depending on its status, or undefined if not found. */ - public getTxStatus(txHash: TxHash): Promise<'pending' | 'mined' | 'deleted' | undefined> { - return this.txPool.getTxStatus(txHash); + public async getTxStatus(txHash: TxHash): Promise<'pending' | 'mined' | 'deleted' | undefined> { + const status = await this.txPool.getTxStatus(txHash); + return status === 'protected' ? 'pending' : status; } public getEnr(): ENR | undefined { @@ -625,14 +607,12 @@ export class P2PClient } /** - * Deletes the 'txs' from the pool. - * NOT used if we use sendTx as reconcileTxPool will handle this. - * @param txHashes - Hashes of the transactions to delete. - * @returns Empty promise. + * Handles failed transaction execution by removing txs from the pool. + * @param txHashes - Hashes of the transactions that failed execution. **/ - public async deleteTxs(txHashes: TxHash[]): Promise { + public async handleFailedExecution(txHashes: TxHash[]): Promise { this.#assertIsReady(); - await this.txPool.deleteTxs(txHashes); + await this.txPool.handleFailedExecution(txHashes); } /** @@ -692,14 +672,13 @@ export class P2PClient } /** - * Mark all txs from these blocks as mined. + * Handles mined blocks by marking the txs in them as mined. * @param blocks - A list of existing blocks with txs that the P2P client needs to ensure the tx pool is reconciled with. * @returns Empty promise. */ - private async markTxsAsMinedFromBlocks(blocks: L2Block[]): Promise { + private async handleMinedBlocks(blocks: L2Block[]): Promise { for (const block of blocks) { - const txHashes = block.body.txEffects.map(txEffect => txEffect.txHash); - await this.txPool.markAsMined(txHashes, block.header); + await this.txPool.handleMinedBlock(block); } } @@ -710,16 +689,14 @@ export class P2PClient */ private async handleLatestL2Blocks(blocks: L2Block[]): Promise { if (!blocks.length) { - return Promise.resolve(); + return; } - await this.markTxsAsMinedFromBlocks(blocks); - await this.txPool.clearNonEvictableTxs(); + await this.handleMinedBlocks(blocks); + await this.maybeCallPrepareForSlot(); await this.startCollectingMissingTxs(blocks); - const lastBlock = blocks.at(-1)!; await this.synchedLatestSlot.set(BigInt(lastBlock.header.getSlot())); - this.log.verbose(`Synched to latest block ${lastBlock.number}`); } /** Request txs for unproven blocks so the prover node has more chances to get them. */ @@ -758,68 +735,31 @@ export class P2PClient */ private async handleFinalizedL2Blocks(blocks: L2Block[]): Promise { if (!blocks.length) { - return Promise.resolve(); + return; } - this.log.debug(`Handling finalized blocks ${blocks.length} up to ${blocks.at(-1)?.number}`); - const lastBlockNum = blocks[blocks.length - 1].number; - const lastBlockSlot = blocks[blocks.length - 1].header.getSlot(); - - const txHashes = blocks.flatMap(block => block.body.txEffects.map(txEffect => txEffect.txHash)); - this.log.debug(`Deleting ${txHashes.length} txs from pool from finalized blocks up to ${lastBlockNum}`); - await this.txPool.deleteTxs(txHashes, { permanently: true }); - await this.txPool.cleanupDeletedMinedTxs(lastBlockNum); - - await this.attestationPool.deleteOlderThan(lastBlockSlot); - - this.log.debug(`Synched to finalized block ${lastBlockNum} at slot ${lastBlockSlot}`); + // Finalization is monotonic, so we only need to call with the last block + const lastBlock = blocks.at(-1)!; + await this.txPool.handleFinalizedBlock(lastBlock.header); + await this.attestationPool.deleteOlderThan(lastBlock.header.getSlot()); } /** * Updates the tx pool after a chain prune. - * @param latestBlock - The block number the chain was pruned to. + * @param latestBlock - The block ID the chain was pruned to. */ - private async handlePruneL2Blocks(latestBlock: BlockNumber): Promise { - const txsToDelete = new Map(); - const minedTxs = await this.txPool.getMinedTxHashes(); - - // Find transactions that reference pruned blocks in their historical header - for (const tx of await this.txPool.getAllTxs()) { - // every tx that's been generated against a block that has now been pruned is no longer valid - if (tx.data.constants.anchorBlockHeader.globalVariables.blockNumber > latestBlock) { - const txHash = tx.getTxHash(); - txsToDelete.set(txHash.toString(), txHash); - } - } - - this.log.info(`Detected chain prune. Removing ${txsToDelete.size} txs built against pruned blocks.`, { - newLatestBlock: latestBlock, - previousLatestBlock: await this.getSyncedLatestBlockNum(), - txsToDelete: Array.from(txsToDelete.keys()), - }); - - // delete invalid txs (both pending and mined) - await this.txPool.deleteTxs(Array.from(txsToDelete.values())); - - // everything left in the mined set was built against a block on the proven chain so its still valid - // move back to pending the txs that were reorged out of the chain, unless txPoolDeleteTxsAfterReorg is set, - // in which case we clean them up to avoid potential reorg loops - // NOTE: we can't move _all_ txs back to pending because the tx pool could keep hold of mined txs for longer - // (see this.keepProvenTxsFor) - const minedTxsFromReorg: TxHash[] = []; - for (const [txHash, blockNumber] of minedTxs) { - // We keep the txsToDelete out of this list as they have already been deleted above - if (blockNumber > latestBlock && !txsToDelete.has(txHash.toString())) { - minedTxsFromReorg.push(txHash); - } - } + private async handlePruneL2Blocks(latestBlock: L2BlockId): Promise { + await this.txPool.handlePrunedBlocks(latestBlock); + } - if (this.config.txPoolDeleteTxsAfterReorg) { - this.log.info(`Deleting ${minedTxsFromReorg.length} mined txs from reorg`); - await this.txPool.deleteTxs(minedTxsFromReorg); - } else { - await this.txPool.markMinedAsPending(minedTxsFromReorg, latestBlock); + /** Checks if the slot has changed and calls prepareForSlot if so. */ + private async maybeCallPrepareForSlot(): Promise { + const { currentSlot } = this.epochCache.getCurrentAndNextSlot(); + if (currentSlot <= this.lastSlotProcessed) { + return; } + this.lastSlotProcessed = currentSlot; + await this.txPool.prepareForSlot(currentSlot); } private async startServiceIfSynched() { @@ -864,11 +804,23 @@ export class P2PClient } /** - * Marks transactions as non-evictable in the pool. - * @param txHashes - Hashes of the transactions to mark as non-evictable. + * Protects existing transactions by hash for a given slot. + * Returns hashes of transactions that weren't found in the pool. + * @param txHashes - Hashes of the transactions to protect. + * @param blockHeader - The block header providing slot context. + * @returns Hashes of transactions not found in the pool. + */ + public protectTxs(txHashes: TxHash[], blockHeader: BlockHeader): Promise { + return this.txPool.protectTxs(txHashes, blockHeader); + } + + /** + * Prepares the pool for a new slot. + * Unprotects transactions from earlier slots and validates them. + * @param slotNumber - The slot number to prepare for */ - public markTxsAsNonEvictable(txHashes: TxHash[]): Promise { - return this.txPool.markTxsAsNonEvictable(txHashes); + public async prepareForSlot(slotNumber: SlotNumber): Promise { + await this.txPool.prepareForSlot(slotNumber); } public handleAuthRequestFromPeer(authRequest: AuthRequest, peerId: PeerId): Promise { diff --git a/yarn-project/p2p/src/client/test/p2p_client.integration_batch_txs.test.ts b/yarn-project/p2p/src/client/test/p2p_client.integration_batch_txs.test.ts index a8e926c6c956..c30babeac1b7 100644 --- a/yarn-project/p2p/src/client/test/p2p_client.integration_batch_txs.test.ts +++ b/yarn-project/p2p/src/client/test/p2p_client.integration_batch_txs.test.ts @@ -1,5 +1,5 @@ import type { EpochCache } from '@aztec/epoch-cache'; -import { BlockNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { times } from '@aztec/foundation/collection'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; @@ -17,7 +17,7 @@ import { type MockProxy, mock } from 'jest-mock-extended'; import type { P2PClient } from '../../client/p2p_client.js'; import { type P2PConfig, getP2PDefaultConfig } from '../../config.js'; import type { AttestationPool } from '../../mem_pools/attestation_pool/attestation_pool.js'; -import type { TxPool } from '../../mem_pools/tx_pool/index.js'; +import type { TxPoolV2 } from '../../mem_pools/tx_pool_v2/interfaces.js'; import { BatchTxRequester } from '../../services/reqresp/batch-tx-requester/batch_tx_requester.js'; import type { BatchTxRequesterLibP2PService } from '../../services/reqresp/batch-tx-requester/interface.js'; import type { IBatchRequestTxValidator } from '../../services/reqresp/batch-tx-requester/tx_validator.js'; @@ -31,7 +31,7 @@ const TEST_TIMEOUT = 120_000; jest.setTimeout(TEST_TIMEOUT); describe('p2p client integration batch txs', () => { - let txPool: MockProxy; + let txPool: MockProxy; let attestationPool: MockProxy; let epochCache: MockProxy; let worldState: MockProxy; @@ -47,7 +47,7 @@ describe('p2p client integration batch txs', () => { beforeEach(() => { clients = []; - txPool = mock(); + txPool = mock(); attestationPool = mock(); epochCache = mock(); worldState = mock(); @@ -64,6 +64,9 @@ describe('p2p client integration batch txs', () => { //@ts-expect-error - we want to mock the getEpochAndSlotInNextL1Slot method, mocking ts is enough epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ ts: BigInt(0) }); epochCache.getRegisteredValidators.mockResolvedValue([]); + epochCache.getCurrentAndNextSlot.mockReturnValue({ currentSlot: SlotNumber(0), nextSlot: SlotNumber(1) }); + + attestationPool.isEmpty.mockResolvedValue(true); epochCache.getL1Constants.mockReturnValue({ l1StartBlock: 0n, l1GenesisTime: 0n, @@ -75,10 +78,7 @@ describe('p2p client integration batch txs', () => { }); txPool.hasTxs.mockResolvedValue([]); - txPool.getAllTxs.mockImplementation(() => { - return Promise.resolve([] as Tx[]); - }); - txPool.addTxs.mockResolvedValue(1); + txPool.addPendingTxs.mockResolvedValue({ accepted: [], ignored: [], rejected: [] }); txPool.getTxsByHash.mockImplementation(() => { return Promise.resolve([] as Tx[]); }); @@ -123,7 +123,7 @@ describe('p2p client integration batch txs', () => { }); }; - const setupClients = async (numberOfPeers: number, txPoolMocks?: MockProxy[]) => { + const setupClients = async (numberOfPeers: number, txPoolMocks?: MockProxy[]) => { if (txPoolMocks) { const peerIdPrivateKeys = generatePeerIdPrivateKeys(numberOfPeers); let ports = []; @@ -195,13 +195,14 @@ describe('p2p client integration batch txs', () => { ]; // Create individual txPool mocks for each peer - const txPoolMocks: MockProxy[] = []; + const txPoolMocks: MockProxy[] = []; for (let i = 0; i < NUMBER_OF_PEERS; i++) { - const peerTxPool = mock(); + const peerTxPool = mock(); const { start, end } = peerTxDistribution[i]; const peerTxs = txs.slice(start, end); const peerTxHashSet = new Set(peerTxs.map(tx => tx.txHash.toString())); + peerTxPool.isEmpty.mockResolvedValue(true); peerTxPool.hasTxs.mockImplementation((hashes: TxHash[]) => { return Promise.resolve(hashes.map(h => peerTxHashSet.has(h.toString()))); }); diff --git a/yarn-project/p2p/src/client/test/p2p_client.integration_block_txs.test.ts b/yarn-project/p2p/src/client/test/p2p_client.integration_block_txs.test.ts index b83eccda403f..f9de992f1810 100644 --- a/yarn-project/p2p/src/client/test/p2p_client.integration_block_txs.test.ts +++ b/yarn-project/p2p/src/client/test/p2p_client.integration_block_txs.test.ts @@ -15,8 +15,10 @@ import { describe, expect, it, jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; import { type P2PConfig, getP2PDefaultConfig } from '../../config.js'; -import type { AttestationPool, TxPool } from '../../mem_pools/index.js'; -import { BlockTxsRequest, BlockTxsResponse, ReqRespSubProtocol } from '../../services/index.js'; +import type { AttestationPool } from '../../mem_pools/attestation_pool/attestation_pool.js'; +import type { TxPoolV2 } from '../../mem_pools/tx_pool_v2/interfaces.js'; +import { ReqRespSubProtocol } from '../../services/reqresp/interface.js'; +import { BlockTxsRequest, BlockTxsResponse } from '../../services/reqresp/protocols/block_txs/block_txs_reqresp.js'; import { ReqRespStatus } from '../../services/reqresp/status.js'; import { makeAndStartTestP2PClients } from '../../test-helpers/index.js'; import { createMockTxWithMetadata } from '../../test-helpers/mock-tx-helpers.js'; @@ -28,7 +30,7 @@ jest.setTimeout(TEST_TIMEOUT); const NUMBER_OF_PEERS = 2; describe('p2p client integration block txs protocol ', () => { - let txPool: MockProxy; + let txPool: MockProxy; let attestationPool: MockProxy; let epochCache: MockProxy; let worldState: MockProxy; @@ -45,7 +47,7 @@ describe('p2p client integration block txs protocol ', () => { let blockProposal: BlockProposal; beforeEach(async () => { - txPool = mock(); + txPool = mock(); attestationPool = mock(); epochCache = mock(); worldState = mock(); @@ -68,10 +70,7 @@ describe('p2p client integration block txs protocol ', () => { txPool.isEmpty.mockResolvedValue(true); txPool.hasTxs.mockResolvedValue([]); - txPool.getAllTxs.mockImplementation(() => { - return Promise.resolve([] as Tx[]); - }); - txPool.addTxs.mockResolvedValue(1); + txPool.addPendingTxs.mockResolvedValue({ accepted: [], ignored: [], rejected: [] }); txPool.getTxsByHash.mockImplementation(() => { return Promise.resolve([] as Tx[]); }); diff --git a/yarn-project/p2p/src/client/test/p2p_client.integration_message_propagation.test.ts b/yarn-project/p2p/src/client/test/p2p_client.integration_message_propagation.test.ts index aa036728e254..265aec7b6709 100644 --- a/yarn-project/p2p/src/client/test/p2p_client.integration_message_propagation.test.ts +++ b/yarn-project/p2p/src/client/test/p2p_client.integration_message_propagation.test.ts @@ -1,5 +1,5 @@ import type { EpochCache } from '@aztec/epoch-cache'; -import { BlockNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { type Logger, createLogger } from '@aztec/foundation/log'; @@ -7,7 +7,7 @@ import { type PromiseWithResolvers, promiseWithResolvers } from '@aztec/foundati import { retryUntil } from '@aztec/foundation/retry'; import { sleep } from '@aztec/foundation/sleep'; import { emptyChainConfig } from '@aztec/stdlib/config'; -import type { WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; +import type { MerkleTreeReadOperations, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import { BlockProposal, CheckpointAttestation } from '@aztec/stdlib/p2p'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; import { type MakeConsensusPayloadOptions, makeBlockProposal } from '@aztec/stdlib/testing'; @@ -21,7 +21,7 @@ import type { P2PClient } from '../../client/p2p_client.js'; import { type P2PConfig, getP2PDefaultConfig } from '../../config.js'; import type { AttestationPool } from '../../mem_pools/attestation_pool/attestation_pool.js'; import { mockCheckpointAttestation } from '../../mem_pools/attestation_pool/mocks.js'; -import type { TxPool } from '../../mem_pools/tx_pool/index.js'; +import type { TxPoolV2 } from '../../mem_pools/tx_pool_v2/interfaces.js'; import type { LibP2PService } from '../../services/libp2p/libp2p_service.js'; import { type MakeTestP2PClientOptions, @@ -36,7 +36,7 @@ const TEST_TIMEOUT = 120_000; jest.setTimeout(TEST_TIMEOUT); describe('p2p client integration message propagation', () => { - let txPool: MockProxy; + let txPool: MockProxy; let attestationPool: MockProxy; let epochCache: MockProxy; let worldState: MockProxy; @@ -46,9 +46,11 @@ describe('p2p client integration message propagation', () => { let clients: P2PClient[] = []; + const currentSlot = SlotNumber(1); + beforeEach(() => { clients = []; - txPool = mock(); + txPool = mock(); attestationPool = mock(); epochCache = mock(); worldState = mock(); @@ -58,6 +60,7 @@ describe('p2p client integration message propagation', () => { //@ts-expect-error - we want to mock the getEpochAndSlotInNextL1Slot method, mocking ts is enough epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ ts: BigInt(0) }); + epochCache.getCurrentAndNextSlot.mockReturnValue({ currentSlot, nextSlot: SlotNumber(currentSlot + 1) }); epochCache.getRegisteredValidators.mockResolvedValue([]); epochCache.getL1Constants.mockReturnValue({ l1StartBlock: 0n, @@ -69,12 +72,20 @@ describe('p2p client integration message propagation', () => { targetCommitteeSize: 48, }); + const mockMerkleTreeOps = mock(); + mockMerkleTreeOps.findLeafIndices.mockResolvedValue([]); + worldState.getCommitted.mockReturnValue(mockMerkleTreeOps); + worldState.getSnapshot.mockReturnValue(mockMerkleTreeOps); + txPool.isEmpty.mockResolvedValue(true); txPool.hasTxs.mockResolvedValue([]); - txPool.getAllTxs.mockImplementation(() => { - return Promise.resolve([] as Tx[]); - }); - txPool.addTxs.mockResolvedValue(1); + txPool.addPendingTxs.mockImplementation((txs: Tx[]) => + Promise.resolve({ + accepted: txs.map(tx => tx.getTxHash()), + ignored: [], + rejected: [], + }), + ); txPool.getTxsByHash.mockImplementation(() => { return Promise.resolve([] as Tx[]); }); @@ -194,7 +205,7 @@ describe('p2p client integration message propagation', () => { // Client 1 sends a block proposal const dummyPayload: MakeConsensusPayloadOptions = { signer: Secp256k1Signer.random(), - header: CheckpointHeader.random(), + header: CheckpointHeader.random({ slotNumber: currentSlot }), archive: Fr.random(), txHashes: [TxHash.random()], }; @@ -346,7 +357,7 @@ describe('p2p client integration message propagation', () => { // Client 1 sends a block proposal const dummyPayload: MakeConsensusPayloadOptions = { signer: Secp256k1Signer.random(), - header: CheckpointHeader.random(), + header: CheckpointHeader.random({ slotNumber: currentSlot }), archive: Fr.random(), txHashes: [TxHash.random()], }; diff --git a/yarn-project/p2p/src/client/test/p2p_client.integration_reqresp.test.ts b/yarn-project/p2p/src/client/test/p2p_client.integration_reqresp.test.ts new file mode 100644 index 000000000000..39bb76ecdc3a --- /dev/null +++ b/yarn-project/p2p/src/client/test/p2p_client.integration_reqresp.test.ts @@ -0,0 +1,229 @@ +import type { EpochAndSlot, EpochCache } from '@aztec/epoch-cache'; +import { BlockNumber } from '@aztec/foundation/branded-types'; +import { type Logger, createLogger } from '@aztec/foundation/log'; +import { sleep } from '@aztec/foundation/sleep'; +import { emptyChainConfig } from '@aztec/stdlib/config'; +import type { WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; +import { Tx, TxArray, TxHash, TxHashArray } from '@aztec/stdlib/tx'; + +import { describe, expect, it, jest } from '@jest/globals'; +import { type MockProxy, mock } from 'jest-mock-extended'; + +import type { P2PClient } from '../../client/p2p_client.js'; +import { type P2PConfig, getP2PDefaultConfig } from '../../config.js'; +import type { AttestationPool } from '../../mem_pools/attestation_pool/attestation_pool.js'; +import type { TxPoolV2 } from '../../mem_pools/tx_pool_v2/interfaces.js'; +import type { LibP2PService } from '../../services/libp2p/libp2p_service.js'; +import type { ReqRespInterface } from '../../services/reqresp/interface.js'; +import { ReqRespSubProtocol } from '../../services/reqresp/interface.js'; +import { ReqRespStatus } from '../../services/reqresp/status.js'; +import { makeAndStartTestP2PClients } from '../../test-helpers/make-test-p2p-clients.js'; +import { MockGossipSubNetwork } from '../../test-helpers/mock-pubsub.js'; +import { createMockTxWithMetadata } from '../../test-helpers/mock-tx-helpers.js'; + +const TEST_TIMEOUT = 120_000; +jest.setTimeout(TEST_TIMEOUT); + +// Tests general reqresp flow using the MockReqResp class. +describe('p2p client integration reqresp', () => { + let txPool: MockProxy; + let attestationPool: MockProxy; + let epochCache: MockProxy; + let worldState: MockProxy; + + let logger: Logger; + let p2pBaseConfig: P2PConfig; + + let clients: P2PClient[] = []; + + beforeEach(() => { + clients = []; + txPool = mock(); + attestationPool = mock(); + epochCache = mock(); + worldState = mock(); + + logger = createLogger('p2p:test:integration-reqresp'); + p2pBaseConfig = { ...emptyChainConfig, ...getP2PDefaultConfig() }; + + epochCache.getEpochAndSlotInNextL1Slot.mockReturnValue({ ts: BigInt(0) } as EpochAndSlot & { now: bigint }); + epochCache.getRegisteredValidators.mockResolvedValue([]); + epochCache.getL1Constants.mockReturnValue({ + l1StartBlock: 0n, + l1GenesisTime: 0n, + slotDuration: 24, + epochDuration: 16, + ethereumSlotDuration: 12, + proofSubmissionEpochs: 2, + targetCommitteeSize: 48, + }); + + txPool.isEmpty.mockResolvedValue(true); + txPool.hasTxs.mockResolvedValue([]); + txPool.addPendingTxs.mockResolvedValue({ accepted: [], ignored: [], rejected: [] }); + txPool.getTxsByHash.mockImplementation(() => { + return Promise.resolve([] as Tx[]); + }); + + attestationPool.isEmpty.mockResolvedValue(true); + attestationPool.tryAddBlockProposal.mockResolvedValue({ added: true, alreadyExists: false, count: 1 }); + + worldState.status.mockResolvedValue({ + state: mock(), + syncSummary: { + latestBlockNumber: BlockNumber.ZERO, + latestBlockHash: '', + finalizedBlockNumber: BlockNumber.ZERO, + treesAreSynched: false, + oldestHistoricBlockNumber: BlockNumber.ZERO, + }, + }); + logger.info(`Starting test ${expect.getState().currentTestName}`); + }); + + afterEach(async () => { + logger.info(`Tearing down state for ${expect.getState().currentTestName}`); + await shutdown(clients); + logger.info('Shut down p2p clients'); + + jest.restoreAllMocks(); + jest.resetAllMocks(); + jest.clearAllMocks(); + + clients = []; + }); + + const shutdown = async (clients: P2PClient[]) => { + await Promise.all(clients.map(client => client.stop())); + await sleep(1000); + }; + + /** Extracts the reqresp interface from a P2PClient via the private p2pService. */ + const getReqResp = (client: P2PClient): ReqRespInterface => { + const p2pService = (client as any).p2pService as LibP2PService; + return (p2pService as any).reqresp as ReqRespInterface; + }; + + /** Extracts the peer ID from a P2PClient via the mock PubSub node. */ + const getPeerId = (client: P2PClient) => { + const p2pService = (client as any).p2pService as LibP2PService; + return (p2pService as any).node.peerId; + }; + + it('can request txs from peers via mock reqresp', async () => { + const numberOfNodes = 2; + const mockGossipSubNetwork = new MockGossipSubNetwork(); + + const testConfig = { + p2pBaseConfig: { ...p2pBaseConfig, rollupVersion: 1 }, + mockAttestationPool: attestationPool, + mockTxPool: txPool, + mockEpochCache: epochCache, + mockWorldState: worldState, + alwaysTrueVerifier: true, + mockGossipSubNetwork, + logger, + }; + + const clientsAndConfig = await makeAndStartTestP2PClients(numberOfNodes, testConfig); + clients = clientsAndConfig.map(c => c.client); + + await sleep(1000); + + // Create a mock tx and configure the shared pool to return it + const tx = await createMockTxWithMetadata(testConfig.p2pBaseConfig); + const txHash = tx.getTxHash(); + + txPool.getTxByHash.mockImplementation((hash: TxHash) => Promise.resolve(hash.equals(txHash) ? tx : undefined)); + + // Request the tx from node-2, which will route to node-1 via the mock network + const reqresp = getReqResp(clients[1]); + const responses = await reqresp.sendBatchRequest(ReqRespSubProtocol.TX, [new TxHashArray(txHash)], undefined); + + expect(responses).toHaveLength(1); + const txArray = responses[0] as TxArray; + expect(txArray).toHaveLength(1); + + const receivedTxHash = txArray[0].getTxHash(); + expect(receivedTxHash.toString()).toEqual(txHash.toString()); + }); + + it('sendRequestToPeer routes to the correct peer handler', async () => { + const numberOfNodes = 2; + const mockGossipSubNetwork = new MockGossipSubNetwork(); + + const testConfig = { + p2pBaseConfig: { ...p2pBaseConfig, rollupVersion: 1 }, + mockAttestationPool: attestationPool, + mockTxPool: txPool, + mockEpochCache: epochCache, + mockWorldState: worldState, + alwaysTrueVerifier: true, + mockGossipSubNetwork, + logger, + }; + + const clientsAndConfig = await makeAndStartTestP2PClients(numberOfNodes, testConfig); + clients = clientsAndConfig.map(c => c.client); + + await sleep(1000); + + // Create a mock tx and configure the shared pool to return it + const tx = await createMockTxWithMetadata(testConfig.p2pBaseConfig); + const txHash = tx.getTxHash(); + + txPool.getTxByHash.mockImplementation((hash: TxHash) => Promise.resolve(hash.equals(txHash) ? tx : undefined)); + + // Get node-1's peer ID and node-2's reqresp + const node1PeerId = getPeerId(clients[0]); + const reqresp = getReqResp(clients[1]); + + // Send a direct request to node-1 + const response = await reqresp.sendRequestToPeer( + node1PeerId, + ReqRespSubProtocol.TX, + new TxHashArray(txHash).toBuffer(), + ); + + expect(response.status).toBe(ReqRespStatus.SUCCESS); + if (response.status === ReqRespStatus.SUCCESS) { + const txArray = TxArray.fromBuffer(response.data); + expect(txArray).toHaveLength(1); + + const receivedTxHash = txArray[0].getTxHash(); + expect(receivedTxHash.toString()).toEqual(txHash.toString()); + } + }); + + it('reqresp returns empty when peer has no matching txs', async () => { + const numberOfNodes = 2; + const mockGossipSubNetwork = new MockGossipSubNetwork(); + + const testConfig = { + p2pBaseConfig: { ...p2pBaseConfig, rollupVersion: 1 }, + mockAttestationPool: attestationPool, + mockTxPool: txPool, + mockEpochCache: epochCache, + mockWorldState: worldState, + alwaysTrueVerifier: true, + mockGossipSubNetwork, + logger, + }; + + const clientsAndConfig = await makeAndStartTestP2PClients(numberOfNodes, testConfig); + clients = clientsAndConfig.map(c => c.client); + + await sleep(1000); + + // Request a random tx hash that no peer has + const randomTxHash = TxHash.random(); + const reqresp = getReqResp(clients[1]); + const responses = await reqresp.sendBatchRequest(ReqRespSubProtocol.TX, [new TxHashArray(randomTxHash)], undefined); + + // The handler returns an empty TxArray (serialized as a 4-byte vector with count 0), + // so sendBatchRequest includes it as a response with an empty TxArray. + expect(responses).toHaveLength(1); + const txArray = responses[0] as TxArray; + expect(txArray).toHaveLength(0); + }); +}); diff --git a/yarn-project/p2p/src/client/test/p2p_client.integration_status_handshake.test.ts b/yarn-project/p2p/src/client/test/p2p_client.integration_status_handshake.test.ts index 91c23abd573d..5a01506ff304 100644 --- a/yarn-project/p2p/src/client/test/p2p_client.integration_status_handshake.test.ts +++ b/yarn-project/p2p/src/client/test/p2p_client.integration_status_handshake.test.ts @@ -13,7 +13,7 @@ import { type MockProxy, mock } from 'jest-mock-extended'; import type { P2PClient } from '../../client/p2p_client.js'; import { type P2PConfig, getP2PDefaultConfig } from '../../config.js'; import type { AttestationPool } from '../../mem_pools/attestation_pool/attestation_pool.js'; -import type { TxPool } from '../../mem_pools/tx_pool/index.js'; +import type { TxPoolV2 } from '../../mem_pools/tx_pool_v2/interfaces.js'; import { ReqRespSubProtocol } from '../../services/reqresp/interface.js'; import { ReqRespStatus } from '../../services/reqresp/status.js'; import { makeTestP2PClients, startTestP2PClients } from '../../test-helpers/make-test-p2p-clients.js'; @@ -24,7 +24,7 @@ jest.setTimeout(TEST_TIMEOUT); const NUMBER_OF_PEERS = 2; describe('p2p client integration status handshake', () => { - let txPool: MockProxy; + let txPool: MockProxy; let attestationPool: MockProxy; let epochCache: MockProxy; let worldState: MockProxy; @@ -36,7 +36,7 @@ describe('p2p client integration status handshake', () => { beforeEach(() => { clients = []; - txPool = mock(); + txPool = mock(); attestationPool = mock(); epochCache = mock(); worldState = mock(); diff --git a/yarn-project/p2p/src/client/test/p2p_client.integration_txs.test.ts b/yarn-project/p2p/src/client/test/p2p_client.integration_txs.test.ts index 4eff7c08ed66..5cacb81e4b97 100644 --- a/yarn-project/p2p/src/client/test/p2p_client.integration_txs.test.ts +++ b/yarn-project/p2p/src/client/test/p2p_client.integration_txs.test.ts @@ -14,19 +14,25 @@ import { type MockProxy, mock } from 'jest-mock-extended'; import type { P2PClient } from '../../client/p2p_client.js'; import { type P2PConfig, getP2PDefaultConfig } from '../../config.js'; import type { AttestationPool } from '../../mem_pools/attestation_pool/attestation_pool.js'; -import type { TxPool } from '../../mem_pools/tx_pool/index.js'; +import type { TxPoolV2 } from '../../mem_pools/tx_pool_v2/interfaces.js'; import { ReqRespSubProtocol } from '../../services/reqresp/interface.js'; import { chunkTxHashesRequest } from '../../services/reqresp/protocols/tx.js'; import { makeAndStartTestP2PClients } from '../../test-helpers/make-test-p2p-clients.js'; import { createMockTxWithMetadata } from '../../test-helpers/mock-tx-helpers.js'; +/** Calls the protected requestTxsByHash method on a P2PClient for testing. */ +const requestTxsByHash = (client: P2PClient, txHashes: TxHash[], pinnedPeerId?: unknown): Promise => + ( + client as unknown as { requestTxsByHash(txHashes: TxHash[], pinnedPeerId: unknown): Promise } + ).requestTxsByHash(txHashes, pinnedPeerId); + const TEST_TIMEOUT = 120000; jest.setTimeout(TEST_TIMEOUT); const NUMBER_OF_PEERS = 2; describe('p2p client integration', () => { - let txPool: MockProxy; + let txPool: MockProxy; let attestationPool: MockProxy; let epochCache: MockProxy; let worldState: MockProxy; @@ -38,7 +44,7 @@ describe('p2p client integration', () => { beforeEach(() => { clients = []; - txPool = mock(); + txPool = mock(); attestationPool = mock(); epochCache = mock(); worldState = mock(); @@ -61,10 +67,7 @@ describe('p2p client integration', () => { txPool.isEmpty.mockResolvedValue(true); txPool.hasTxs.mockResolvedValue([]); - txPool.getAllTxs.mockImplementation(() => { - return Promise.resolve([] as Tx[]); - }); - txPool.addTxs.mockResolvedValue(1); + txPool.addPendingTxs.mockResolvedValue({ accepted: [], ignored: [], rejected: [] }); txPool.getTxsByHash.mockImplementation(() => { return Promise.resolve([] as Tx[]); }); @@ -125,7 +128,7 @@ describe('p2p client integration', () => { const tx = await createMockTxWithMetadata(config); const txHash = tx.getTxHash(); - const requestedTxs = await client1.requestTxsByHash([txHash], undefined); + const requestedTxs = await requestTxsByHash(client1, [txHash], undefined); expect(requestedTxs).toEqual([]); }); @@ -153,7 +156,7 @@ describe('p2p client integration', () => { // Mock the tx pool to return the tx we are looking for txPool.getTxByHash.mockImplementationOnce(() => Promise.resolve(tx)); - const requestedTxs = await client1.requestTxsByHash([txHash], undefined); + const requestedTxs = await requestTxsByHash(client1, [txHash], undefined); expect(requestedTxs).toHaveLength(1); const requestedTx = requestedTxs[0]; @@ -193,11 +196,11 @@ describe('p2p client integration', () => { //@ts-expect-error - we want to spy on the sendRequestToPeer method const sendRequestToPeerSpy = jest.spyOn(client1.p2pService.reqresp, 'sendRequestToPeer'); - const resultingTxs = await client1.requestTxsByHash(txHashes, undefined); + const resultingTxs = await requestTxsByHash(client1, txHashes, undefined); expect(resultingTxs).toHaveLength(txs.length); // Expect the tx to be the returned tx to be the same as the one we mocked - resultingTxs.forEach((requestedTx, i) => { + resultingTxs.forEach((requestedTx: Tx, i: number) => { expect(requestedTx.toBuffer()).toStrictEqual(txs[i].toBuffer()); }); @@ -251,12 +254,12 @@ describe('p2p client integration', () => { //@ts-expect-error - we want to spy on the sendRequestToPeer method const sendRequestToPeerSpy = jest.spyOn(client1.p2pService.reqresp, 'sendRequestToPeer'); - const resultingTxs = await client1.requestTxsByHash(txHashes, undefined); + const resultingTxs = await requestTxsByHash(client1, txHashes, undefined); expect(resultingTxs).toHaveLength(txs.length / 2); // Expect the tx to be the returned tx to be the same as the one we mocked // Note we have only returned the half of the txs, so we expect the resulting txs to be every other tx - resultingTxs.forEach((requestedTx, i) => { + resultingTxs.forEach((requestedTx: Tx, i: number) => { expect(requestedTx.toBuffer()).toStrictEqual(txs[2 * i].toBuffer()); }); @@ -305,7 +308,7 @@ describe('p2p client integration', () => { // Return the correct tx with an invalid proof -> active attack txPool.getTxByHash.mockImplementationOnce(() => Promise.resolve(tx)); - const requestedTxs = await client1.requestTxsByHash([txHash], undefined); + const requestedTxs = await requestTxsByHash(client1, [txHash], undefined); // Even though we got a response, the proof was deemed invalid expect(requestedTxs).toEqual([]); @@ -342,7 +345,7 @@ describe('p2p client integration', () => { // Return an invalid tx txPool.getTxByHash.mockImplementationOnce(() => Promise.resolve(tx2)); - const requestedTxs = await client1.requestTxsByHash([txHash], undefined); + const requestedTxs = await requestTxsByHash(client1, [txHash], undefined); // Even though we got a response, the proof was deemed invalid expect(requestedTxs).toEqual([]); diff --git a/yarn-project/p2p/src/config.ts b/yarn-project/p2p/src/config.ts index ad66cdb570cf..4443a9eab9e3 100644 --- a/yarn-project/p2p/src/config.ts +++ b/yarn-project/p2p/src/config.ts @@ -45,6 +45,9 @@ export interface P2PConfig /** The frequency in which to check for new L2 blocks. */ blockCheckIntervalMS: number; + /** The frequency in which to check for new L2 slots. */ + slotCheckIntervalMS: number; + /** The number of blocks to fetch in a single batch. */ blockRequestBatchSize: number; @@ -207,6 +210,11 @@ export const p2pConfigMappings: ConfigMappingsType = { description: 'The frequency in which to check for new L2 blocks.', ...numberConfigHelper(100), }, + slotCheckIntervalMS: { + env: 'P2P_SLOT_CHECK_INTERVAL_MS', + description: 'The frequency in which to check for new L2 slots.', + ...numberConfigHelper(1000), + }, debugDisableColocationPenalty: { env: 'DEBUG_P2P_DISABLE_COLOCATION_PENALTY', description: 'DEBUG: Disable colocation penalty - NEVER set to true in production', diff --git a/yarn-project/p2p/src/index.ts b/yarn-project/p2p/src/index.ts index 0afa5fb39c6c..7f84e745d48f 100644 --- a/yarn-project/p2p/src/index.ts +++ b/yarn-project/p2p/src/index.ts @@ -7,5 +7,6 @@ export * from './enr/index.js'; export * from './config.js'; export * from './mem_pools/attestation_pool/index.js'; export * from './mem_pools/tx_pool/index.js'; +export * from './mem_pools/tx_pool_v2/index.js'; export * from './msg_validators/index.js'; export * from './services/index.js'; diff --git a/yarn-project/p2p/src/mem_pools/index.ts b/yarn-project/p2p/src/mem_pools/index.ts index 8495637df556..f515092b4b06 100644 --- a/yarn-project/p2p/src/mem_pools/index.ts +++ b/yarn-project/p2p/src/mem_pools/index.ts @@ -1,3 +1,6 @@ export { AttestationPool, type AttestationPoolApi } from './attestation_pool/attestation_pool.js'; export { type MemPools } from './interface.js'; +// Old TxPool exports - kept temporarily for external consumers export { type TxPool } from './tx_pool/tx_pool.js'; +// New TxPoolV2 exports +export { type TxPoolV2, type TxPoolV2Config, type TxPoolV2Events, type AddTxsResult } from './tx_pool_v2/index.js'; diff --git a/yarn-project/p2p/src/mem_pools/interface.ts b/yarn-project/p2p/src/mem_pools/interface.ts index 3f38b4c86a6d..01b9924404e1 100644 --- a/yarn-project/p2p/src/mem_pools/interface.ts +++ b/yarn-project/p2p/src/mem_pools/interface.ts @@ -1,10 +1,10 @@ import type { AttestationPoolApi } from './attestation_pool/attestation_pool.js'; -import type { TxPool } from './tx_pool/tx_pool.js'; +import type { TxPoolV2 } from './tx_pool_v2/interfaces.js'; /** * A interface the combines all mempools */ export type MemPools = { - txPool: TxPool; + txPool: TxPoolV2; attestationPool: AttestationPoolApi; }; diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/README.md b/yarn-project/p2p/src/mem_pools/tx_pool_v2/README.md index d78bf47806b5..c69e5ef8e9c6 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/README.md +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/README.md @@ -28,13 +28,21 @@ TxPoolV2 manages transactions through a state machine with clear transitions: │ MINED │───────────────────┘ │ (included in a block) │ handlePrunedBlocks() └─────────────────────────────────────┘ (reorg) - │ - │ handleFinalizedBlock() - ▼ -┌─────────────────────────────────────┐ -│ DELETED │ -│ (optionally archived) │ -└─────────────────────────────────────┘ + │ │ + │ handleFinalizedBlock() │ eviction after reorg + ▼ ▼ +┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐ +│ DELETED │ │ SOFT-DELETED │ +│ (hard-deleted or archived) │ │ (kept in DB for debugging) │ +└─────────────────────────────────────┘ └─────────────────────────────────────┘ + │ + │ handleFinalizedBlock() + │ (mined block finalized) + ▼ + ┌─────────────────────────────────────┐ + │ HARD-DELETED │ + │ (permanently removed from DB) │ + └─────────────────────────────────────┘ ``` ## Key Components @@ -52,6 +60,16 @@ Core implementation containing: - Pre-add rule execution - Post-event eviction rule execution +### DeletedPool (`deleted_pool.ts`) + +Manages soft deletion of transactions from pruned blocks: +- When a reorg (chain prune) occurs, transactions from pruned blocks are tracked with their original mined block number +- When these transactions are later evicted (e.g., failed validation, nullifier conflict), they are "soft-deleted" instead of removed +- Soft-deleted transactions remain in the database for debugging and potential resubmission +- When the original mined block is finalized on the new chain, soft-deleted transactions are permanently hard-deleted + +This ensures transactions from reorged blocks are kept around until we're certain they won't be needed. + ### TxMetaData (`tx_metadata.ts`) Lightweight metadata stored alongside each transaction: @@ -68,8 +86,37 @@ Lightweight metadata stored alongside each transaction: State is derived by TxPoolIndices: - `mined` if `minedL2BlockId` is set - `protected` if in protection map +- `deleted` if soft-deleted (from a pruned block, evicted but kept in DB) - `pending` otherwise +## Soft Deletion + +When a chain reorganization occurs, transactions that were mined in pruned blocks are handled specially: + +1. **Tracking**: When `handlePrunedBlocks` is called, all un-mined transactions are tracked by their original mined block number +2. **Soft Delete**: If these transactions are later evicted (failed validation, nullifier conflict, etc.), they are "soft-deleted" - removed from indices but kept in the database +3. **Retrieval**: Soft-deleted transactions can still be retrieved via `getTxByHash` and `hasTxs`, and return status `'deleted'` from `getTxStatus` +4. **Hard Delete**: When `handleFinalizedBlock` is called and the finalized block number reaches or exceeds the transaction's original mined block, the transaction is permanently removed + +This design allows: +- Debugging reorg scenarios by keeping transaction data available +- Potential resubmission of transactions that failed validation after a reorg +- Clean eventual cleanup once we're certain the transaction won't be needed + +**Example scenario:** +1. Tx mined at block 10 +2. Chain prunes to block 5 (tx becomes un-mined, tracked as minedAtBlock=10) +3. Tx fails validation and is soft-deleted +4. Block 9 finalized → tx still in DB (minedAtBlock=10 > finalized=9) +5. Block 10 finalized → tx hard-deleted (minedAtBlock=10 ≤ finalized=10) + +If the tx is re-mined at a higher block before being soft-deleted: +1. Tx mined at block 10, pruned (tracked as minedAtBlock=10) +2. Tx re-mined at block 15, pruned again (updated to minedAtBlock=15) +3. Tx soft-deleted +4. Block 10 finalized → tx still in DB +5. Block 15 finalized → tx hard-deleted + ## Architecture: Pre-add vs Post-event Rules **Pre-add rules** (run during `addPendingTxs`): @@ -120,7 +167,7 @@ import { AztecKVTxPoolV2 } from './tx_pool_v2.js'; const pool = new AztecKVTxPoolV2(txStore, archiveStore, { l2BlockSource: archiver, worldStateSynchronizer: worldState, - pendingTxValidator: validator, + createTxValidator: () => validator, }); await pool.start(); @@ -195,9 +242,12 @@ The archive uses FIFO eviction when `archivedTxLimit` is reached. ## Testing ```bash -# Unit tests (131 tests) +# Unit tests (177 tests) yarn test src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts +# Deleted pool tests (17 tests) +yarn test src/mem_pools/tx_pool_v2/deleted_pool.test.ts + # Compatibility tests (25 tests) yarn test src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.test.ts new file mode 100644 index 000000000000..23330d5c9c84 --- /dev/null +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.test.ts @@ -0,0 +1,404 @@ +import { BlockNumber } from '@aztec/foundation/branded-types'; +import { createLogger } from '@aztec/foundation/log'; +import type { AztecAsyncMap } from '@aztec/kv-store'; +import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; + +import { DeletedPool } from './deleted_pool.js'; + +describe('DeletedPool', () => { + let pool: DeletedPool; + let store: Awaited>; + let txsDB: AztecAsyncMap; + + beforeEach(async () => { + store = await openTmpStore('deleted-pool-test'); + txsDB = store.openMap('txs'); + pool = new DeletedPool(store, txsDB, createLogger('test')); + await pool.hydrateFromDatabase(); + }); + + afterEach(async () => { + await store.delete(); + }); + + describe('markFromPrunedBlock', () => { + it('marks transactions as from a pruned block', async () => { + await pool.markFromPrunedBlock([ + { txHash: 'tx1', minedAtBlock: BlockNumber(5) }, + { txHash: 'tx2', minedAtBlock: BlockNumber(7) }, + ]); + + expect(pool.isFromPrunedBlock('tx1')).toBe(true); + expect(pool.isFromPrunedBlock('tx2')).toBe(true); + expect(pool.isFromPrunedBlock('tx3')).toBe(false); + }); + + it('records the block number in which tx was originally mined', async () => { + await pool.markFromPrunedBlock([ + { txHash: 'tx1', minedAtBlock: BlockNumber(5) }, + { txHash: 'tx2', minedAtBlock: BlockNumber(10) }, + ]); + + expect(pool.getMinedAtBlock('tx1')).toBe(BlockNumber(5)); + expect(pool.getMinedAtBlock('tx2')).toBe(BlockNumber(10)); + expect(pool.getMinedAtBlock('tx3')).toBeUndefined(); + }); + + it('handles empty array gracefully', async () => { + await pool.markFromPrunedBlock([]); + expect(pool.getCount()).toBe(0); + }); + + it('updates to higher mined block when tx is re-mined and pruned again', async () => { + // First prune: tx was mined at block 10 + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(10) }]); + expect(pool.getMinedAtBlock('tx1')).toBe(BlockNumber(10)); + + // Second prune: tx was re-mined at block 15 then pruned again + // Should update to block 15 (higher) + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(15) }]); + expect(pool.getMinedAtBlock('tx1')).toBe(BlockNumber(15)); + + // Lower block should be ignored + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(12) }]); + expect(pool.getMinedAtBlock('tx1')).toBe(BlockNumber(15)); + }); + }); + + describe('clearIfMinedHigher', () => { + it('clears tracking when tx re-mines at a higher block', async () => { + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(10) }]); + expect(pool.isFromPrunedBlock('tx1')).toBe(true); + + await pool.clearIfMinedHigher('tx1', BlockNumber(12)); + + expect(pool.isFromPrunedBlock('tx1')).toBe(false); + expect(pool.getMinedAtBlock('tx1')).toBeUndefined(); + expect(pool.getCount()).toBe(0); + }); + + it('clears tracking when tx re-mines at the same block', async () => { + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(10) }]); + + await pool.clearIfMinedHigher('tx1', BlockNumber(10)); + + expect(pool.isFromPrunedBlock('tx1')).toBe(false); + expect(pool.getMinedAtBlock('tx1')).toBeUndefined(); + }); + + it('preserves tracking when tx re-mines at a lower block', async () => { + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(10) }]); + + await pool.clearIfMinedHigher('tx1', BlockNumber(8)); + + expect(pool.isFromPrunedBlock('tx1')).toBe(true); + expect(pool.getMinedAtBlock('tx1')).toBe(BlockNumber(10)); + }); + + it('is a no-op for untracked transactions', async () => { + await pool.clearIfMinedHigher('tx1', BlockNumber(10)); + + expect(pool.isFromPrunedBlock('tx1')).toBe(false); + expect(pool.getCount()).toBe(0); + }); + + it('persists clearing across restarts', async () => { + await pool.markFromPrunedBlock([ + { txHash: 'tx1', minedAtBlock: BlockNumber(10) }, + { txHash: 'tx2', minedAtBlock: BlockNumber(10) }, + ]); + + // Clear tx1 (re-mined higher), keep tx2 + await pool.clearIfMinedHigher('tx1', BlockNumber(12)); + + const pool2 = new DeletedPool(store, txsDB, createLogger('test2')); + await pool2.hydrateFromDatabase(); + + expect(pool2.isFromPrunedBlock('tx1')).toBe(false); + expect(pool2.isFromPrunedBlock('tx2')).toBe(true); + }); + }); + + describe('deleteTx', () => { + it('soft-deletes tx from pruned block (keeps in DB)', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(5) }]); + expect(pool.isSoftDeleted('tx1')).toBe(false); + + const result = await pool.deleteTx('tx1'); + + expect(result).toBe('soft'); + expect(pool.isSoftDeleted('tx1')).toBe(true); + expect(await txsDB.getAsync('tx1')).toBeDefined(); // Still in DB + }); + + it('hard-deletes tx NOT from pruned block (removes from DB)', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + // tx1 is NOT marked as from pruned block + + const result = await pool.deleteTx('tx1'); + + expect(result).toBe('hard'); + expect(pool.isSoftDeleted('tx1')).toBe(false); + expect(await txsDB.getAsync('tx1')).toBeUndefined(); // Removed from DB + }); + }); + + describe('finalizeBlock', () => { + it('hard-deletes only soft-deleted txs mined at or before the finalized block', async () => { + // Add txs to the database + await txsDB.set('tx1', Buffer.from('data1')); + await txsDB.set('tx2', Buffer.from('data2')); + await txsDB.set('tx3', Buffer.from('data3')); + + // Mark as from pruned blocks - each was mined at a different block + await pool.markFromPrunedBlock([ + { txHash: 'tx1', minedAtBlock: BlockNumber(5) }, + { txHash: 'tx2', minedAtBlock: BlockNumber(10) }, + { txHash: 'tx3', minedAtBlock: BlockNumber(15) }, + ]); + + // Soft-delete tx1 and tx2 via deleteTx (tx3 is still in indices) + await pool.deleteTx('tx1'); + await pool.deleteTx('tx2'); + + // Finalize block 10 + const hardDeleted = await pool.finalizeBlock(BlockNumber(10)); + + // Only tx1 and tx2 should be hard-deleted (soft-deleted and mined <= 10) + expect(hardDeleted).toContain('tx1'); + expect(hardDeleted).toContain('tx2'); + expect(hardDeleted).not.toContain('tx3'); // Not soft-deleted + + // Verify removed from DB + expect(await txsDB.getAsync('tx1')).toBeUndefined(); + expect(await txsDB.getAsync('tx2')).toBeUndefined(); + expect(await txsDB.getAsync('tx3')).toBeDefined(); // Still in DB + + // Verify removed from tracking + expect(pool.isFromPrunedBlock('tx1')).toBe(false); + expect(pool.isFromPrunedBlock('tx2')).toBe(false); + expect(pool.isFromPrunedBlock('tx3')).toBe(true); // Still tracked + }); + + it('does not hard-delete txs that are from pruned block but not soft-deleted', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(5) }]); + // tx1 is NOT deleted via deleteTx (still in indices) + + const hardDeleted = await pool.finalizeBlock(BlockNumber(10)); + + expect(hardDeleted).toHaveLength(0); + expect(await txsDB.getAsync('tx1')).toBeDefined(); + expect(pool.isFromPrunedBlock('tx1')).toBe(true); + }); + + it('hard-deletes tx only after it is soft-deleted and its mined block is finalized', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + + // 1. Tx was mined in block 10, then chain pruned + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(10) }]); + expect(pool.isFromPrunedBlock('tx1')).toBe(true); + expect(pool.isSoftDeleted('tx1')).toBe(false); + + // 2. Finalize block 5 - tx should NOT be deleted (mined at block 10) + const hardDeleted1 = await pool.finalizeBlock(BlockNumber(5)); + expect(hardDeleted1).toHaveLength(0); + expect(await txsDB.getAsync('tx1')).toBeDefined(); + expect(pool.isFromPrunedBlock('tx1')).toBe(true); + + // 3. Soft-delete the tx (e.g., due to eviction) + const result = await pool.deleteTx('tx1'); + expect(result).toBe('soft'); + expect(pool.isSoftDeleted('tx1')).toBe(true); + expect(await txsDB.getAsync('tx1')).toBeDefined(); // Still in DB + + // 4. Finalize block 9 - tx should NOT be deleted (mined at block 10) + const hardDeleted2 = await pool.finalizeBlock(BlockNumber(9)); + expect(hardDeleted2).toHaveLength(0); + expect(await txsDB.getAsync('tx1')).toBeDefined(); + + // 5. Finalize block 10 - tx should now be hard-deleted + const hardDeleted3 = await pool.finalizeBlock(BlockNumber(10)); + expect(hardDeleted3).toContain('tx1'); + expect(await txsDB.getAsync('tx1')).toBeUndefined(); // Gone from DB + expect(pool.isFromPrunedBlock('tx1')).toBe(false); + expect(pool.isSoftDeleted('tx1')).toBe(false); + }); + + it('tx re-mined at higher block is kept until that block is finalized', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + + // 1. Tx mined at block 4, then pruned + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(4) }]); + expect(pool.getMinedAtBlock('tx1')).toBe(BlockNumber(4)); + + // 2. Tx re-mined at block 5, then pruned again + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(5) }]); + expect(pool.getMinedAtBlock('tx1')).toBe(BlockNumber(5)); + + // 3. Tx is soft-deleted (e.g., failed validation after second prune) + await pool.deleteTx('tx1'); + expect(pool.isSoftDeleted('tx1')).toBe(true); + + // 4. Block 4 finalized - tx should NOT be hard-deleted (mined at 5 > finalized 4) + const deleted4 = await pool.finalizeBlock(BlockNumber(4)); + expect(deleted4).toHaveLength(0); + expect(await txsDB.getAsync('tx1')).toBeDefined(); + + // 5. Block 5 finalized - tx should be hard-deleted (mined at 5 <= finalized 5) + const deleted5 = await pool.finalizeBlock(BlockNumber(5)); + expect(deleted5).toContain('tx1'); + expect(await txsDB.getAsync('tx1')).toBeUndefined(); + }); + + it('multiple txs with different mined blocks finalize at correct times', async () => { + // Setup: 3 txs mined at different blocks, all pruned + await txsDB.set('tx5', Buffer.from('data5')); + await txsDB.set('tx10', Buffer.from('data10')); + await txsDB.set('tx15', Buffer.from('data15')); + + await pool.markFromPrunedBlock([ + { txHash: 'tx5', minedAtBlock: BlockNumber(5) }, + { txHash: 'tx10', minedAtBlock: BlockNumber(10) }, + { txHash: 'tx15', minedAtBlock: BlockNumber(15) }, + ]); + + // Soft-delete all of them + await pool.deleteTx('tx5'); + await pool.deleteTx('tx10'); + await pool.deleteTx('tx15'); + + // Finalize block 5 - only tx5 should be hard-deleted + const deleted5 = await pool.finalizeBlock(BlockNumber(5)); + expect(deleted5).toEqual(['tx5']); + expect(await txsDB.getAsync('tx5')).toBeUndefined(); + expect(await txsDB.getAsync('tx10')).toBeDefined(); + expect(await txsDB.getAsync('tx15')).toBeDefined(); + + // Finalize block 10 - only tx10 should be hard-deleted + const deleted10 = await pool.finalizeBlock(BlockNumber(10)); + expect(deleted10).toEqual(['tx10']); + expect(await txsDB.getAsync('tx10')).toBeUndefined(); + expect(await txsDB.getAsync('tx15')).toBeDefined(); + + // Finalize block 15 - tx15 should be hard-deleted + const deleted15 = await pool.finalizeBlock(BlockNumber(15)); + expect(deleted15).toEqual(['tx15']); + expect(await txsDB.getAsync('tx15')).toBeUndefined(); + }); + + it('returns empty array when no transactions qualify', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + await pool.markFromPrunedBlock([{ txHash: 'tx1', minedAtBlock: BlockNumber(10) }]); + await pool.deleteTx('tx1'); // Soft-delete + + const hardDeleted = await pool.finalizeBlock(BlockNumber(5)); + + expect(hardDeleted).toHaveLength(0); + expect(await txsDB.getAsync('tx1')).toBeDefined(); + expect(pool.isSoftDeleted('tx1')).toBe(true); + }); + }); + + describe('persistence', () => { + it('persists pruned block state across restarts', async () => { + await pool.markFromPrunedBlock([ + { txHash: 'tx1', minedAtBlock: BlockNumber(5) }, + { txHash: 'tx2', minedAtBlock: BlockNumber(10) }, + ]); + + // Create a new pool instance with the same store + const pool2 = new DeletedPool(store, txsDB, createLogger('test2')); + await pool2.hydrateFromDatabase(); + + expect(pool2.isFromPrunedBlock('tx1')).toBe(true); + expect(pool2.isFromPrunedBlock('tx2')).toBe(true); + expect(pool2.getMinedAtBlock('tx1')).toBe(BlockNumber(5)); + expect(pool2.getMinedAtBlock('tx2')).toBe(BlockNumber(10)); + }); + + it('persists finalized entries', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + await txsDB.set('tx2', Buffer.from('data2')); + + await pool.markFromPrunedBlock([ + { txHash: 'tx1', minedAtBlock: BlockNumber(5) }, + { txHash: 'tx2', minedAtBlock: BlockNumber(5) }, + ]); + await pool.deleteTx('tx1'); + await pool.deleteTx('tx2'); + await pool.finalizeBlock(BlockNumber(5)); + + // Create a new pool instance with the same store + const pool2 = new DeletedPool(store, txsDB, createLogger('test2')); + await pool2.hydrateFromDatabase(); + + expect(pool2.isFromPrunedBlock('tx1')).toBe(false); + expect(pool2.isFromPrunedBlock('tx2')).toBe(false); + }); + + it('persists soft-deleted state across restarts', async () => { + await txsDB.set('tx1', Buffer.from('data1')); + await txsDB.set('tx2', Buffer.from('data2')); + + // Mark as from pruned block and soft-delete + await pool.markFromPrunedBlock([ + { txHash: 'tx1', minedAtBlock: BlockNumber(5) }, + { txHash: 'tx2', minedAtBlock: BlockNumber(5) }, + ]); + await pool.deleteTx('tx1'); + // tx2 is NOT soft-deleted + + expect(pool.isSoftDeleted('tx1')).toBe(true); + expect(pool.isSoftDeleted('tx2')).toBe(false); + + // Create a new pool instance with the same store (simulates restart) + const pool2 = new DeletedPool(store, txsDB, createLogger('test2')); + await pool2.hydrateFromDatabase(); + + // Soft-deleted state should be preserved + expect(pool2.isSoftDeleted('tx1')).toBe(true); + expect(pool2.isSoftDeleted('tx2')).toBe(false); + + // Finalization should work correctly after restart + const hardDeleted = await pool2.finalizeBlock(BlockNumber(5)); + expect(hardDeleted).toContain('tx1'); + expect(hardDeleted).not.toContain('tx2'); // Not soft-deleted + + // tx1 should be hard-deleted, tx2 still in DB + expect(await txsDB.getAsync('tx1')).toBeUndefined(); + expect(await txsDB.getAsync('tx2')).toBeDefined(); + }); + }); + + describe('getCount and getPrunedTxHashes', () => { + it('returns correct count', async () => { + expect(pool.getCount()).toBe(0); + + await pool.markFromPrunedBlock([ + { txHash: 'tx1', minedAtBlock: BlockNumber(5) }, + { txHash: 'tx2', minedAtBlock: BlockNumber(5) }, + ]); + expect(pool.getCount()).toBe(2); + + await pool.markFromPrunedBlock([{ txHash: 'tx3', minedAtBlock: BlockNumber(10) }]); + expect(pool.getCount()).toBe(3); + }); + + it('returns all pruned tx hashes', async () => { + await pool.markFromPrunedBlock([ + { txHash: 'tx1', minedAtBlock: BlockNumber(5) }, + { txHash: 'tx2', minedAtBlock: BlockNumber(5) }, + ]); + await pool.markFromPrunedBlock([{ txHash: 'tx3', minedAtBlock: BlockNumber(10) }]); + + const hashes = pool.getPrunedTxHashes(); + + expect(hashes).toHaveLength(3); + expect(hashes).toContain('tx1'); + expect(hashes).toContain('tx2'); + expect(hashes).toContain('tx3'); + }); + }); +}); diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.ts new file mode 100644 index 000000000000..8eeda41937fc --- /dev/null +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/deleted_pool.ts @@ -0,0 +1,234 @@ +import { BlockNumber } from '@aztec/foundation/branded-types'; +import type { Logger } from '@aztec/foundation/log'; +import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store'; + +/** + * State stored for each transaction from a pruned block. + */ +type DeletedTxState = { + /** Block number in which the transaction was originally mined (before being un-mined by a prune) */ + minedAtBlock: BlockNumber; + /** Whether the transaction has been soft-deleted (removed from indices) */ + softDeleted: boolean; +}; + +/** + * Serializes DeletedTxState to a Buffer. + * Format: 4 bytes for blockNumber (uint32) + 1 byte for softDeleted (0 or 1) + */ +function serializeState(state: DeletedTxState): Buffer { + const buffer = Buffer.alloc(5); + buffer.writeUInt32BE(Number(state.minedAtBlock), 0); + buffer.writeUInt8(state.softDeleted ? 1 : 0, 4); + return buffer; +} + +/** + * Deserializes a Buffer to DeletedTxState. + */ +function deserializeState(buffer: Buffer): DeletedTxState { + return { + minedAtBlock: BlockNumber(buffer.readUInt32BE(0)), + softDeleted: buffer.readUInt8(4) === 1, + }; +} + +/** + * Manages all transaction deletions in the pool. + * + * When a chain prune (reorg) happens, transactions from pruned blocks are tracked here. + * This class is responsible for ALL deletion decisions: + * + * - Transactions from pruned blocks are "soft deleted" - removed from indices but kept + * in the database for later re-execution + * - Transactions NOT from pruned blocks are "hard deleted" - completely removed from DB + * + * When a block is finalized, soft-deleted transactions that were originally mined at or + * before that block number are permanently (hard) deleted. + */ +export class DeletedPool { + /** Persisted map: txHash -> DeletedTxState (serialized) */ + #deletedTxsDB: AztecAsyncMap; + + /** Reference to the main txs database for hard deletion */ + #txsDB: AztecAsyncMap; + + /** In-memory state for transactions from pruned blocks */ + #state: Map = new Map(); + + #log: Logger; + + constructor(store: AztecAsyncKVStore, txsDB: AztecAsyncMap, log: Logger) { + this.#deletedTxsDB = store.openMap('deleted_txs'); + this.#txsDB = txsDB; + this.#log = log; + } + + /** + * Loads state from the database on startup. + */ + async hydrateFromDatabase(): Promise { + let prunedCount = 0; + let softDeletedCount = 0; + + for await (const [txHash, buffer] of this.#deletedTxsDB.entriesAsync()) { + const state = deserializeState(buffer); + this.#state.set(txHash, state); + prunedCount++; + if (state.softDeleted) { + softDeletedCount++; + } + } + + if (prunedCount > 0 || softDeletedCount > 0) { + this.#log.info(`Loaded ${prunedCount} txs from pruned blocks, ${softDeletedCount} soft-deleted`); + } + } + + /** + * Marks transactions as being from a pruned block. + * Called during handlePrunedBlocks for ALL transactions that were un-mined. + * + * If a tx was previously tracked (e.g., mined at block 4, pruned, re-mined at block 5, + * pruned again), updates to the higher block number. This ensures the tx is kept until + * its most recent mined block is finalized. + * + * @param txs - Array of {txHash, minedAtBlock} pairs, where minedAtBlock is the block + * number in which the tx was mined before being un-mined + */ + async markFromPrunedBlock(txs: { txHash: string; minedAtBlock: BlockNumber }[]): Promise { + if (txs.length === 0) { + return; + } + + let count = 0; + for (const { txHash, minedAtBlock } of txs) { + const existing = this.#state.get(txHash); + // Update if not tracked, or if this is a higher mined block (tx was re-mined then pruned again) + if (existing === undefined || minedAtBlock > existing.minedAtBlock) { + const state: DeletedTxState = { + minedAtBlock, + softDeleted: existing?.softDeleted ?? false, + }; + this.#state.set(txHash, state); + await this.#deletedTxsDB.set(txHash, serializeState(state)); + count++; + } + } + + if (count > 0) { + this.#log.debug(`Marked ${count} transactions from pruned blocks`); + } + } + + /** + * Deletes a transaction. This is the single entry point for ALL deletions. + * + * - If the tx is from a pruned block: soft-delete (keep in DB, mark as deleted) + * - If the tx is NOT from a pruned block: hard-delete (remove from DB) + * + * @returns 'soft' if soft-deleted, 'hard' if hard-deleted + */ + async deleteTx(txHash: string): Promise<'soft' | 'hard'> { + const existing = this.#state.get(txHash); + if (existing !== undefined) { + // Soft delete - keep in DB + const state: DeletedTxState = { + minedAtBlock: existing.minedAtBlock, + softDeleted: true, + }; + this.#state.set(txHash, state); + await this.#deletedTxsDB.set(txHash, serializeState(state)); + return 'soft'; + } else { + // Hard delete - remove from DB + await this.#txsDB.delete(txHash); + return 'hard'; + } + } + + /** + * Clears tracking for a transaction if it re-mines at a block number >= the tracked minedAtBlock. + * + * When a tx re-mines at a higher (or equal) block, the old high-water mark is no longer needed: + * any future prune would re-add the tx with an even higher block number. Clearing keeps + * DeletedPool consistent — only txs that are actually un-mined should be tracked here. + * + * When a tx re-mines at a lower block, we must preserve the existing entry to retain + * the high-water mark for re-execution purposes. + */ + async clearIfMinedHigher(txHash: string, minedAtBlock: BlockNumber): Promise { + const existing = this.#state.get(txHash); + if (existing !== undefined && minedAtBlock >= existing.minedAtBlock) { + this.#state.delete(txHash); + await this.#deletedTxsDB.delete(txHash); + this.#log.debug( + `Cleared tracking for tx ${txHash}: re-mined at block ${minedAtBlock} (was ${existing.minedAtBlock})`, + ); + } + } + + /** + * Checks if a transaction is from a pruned block. + */ + isFromPrunedBlock(txHash: string): boolean { + return this.#state.has(txHash); + } + + /** + * Checks if a transaction is soft-deleted. + */ + isSoftDeleted(txHash: string): boolean { + return this.#state.get(txHash)?.softDeleted ?? false; + } + + /** + * Gets the block number in which a transaction was originally mined. + */ + getMinedAtBlock(txHash: string): BlockNumber | undefined { + return this.#state.get(txHash)?.minedAtBlock; + } + + /** + * Finalizes transactions when a block is finalized. + * Hard-deletes transactions that were originally mined at or before the finalized block. + * + * @returns The hashes of transactions that were hard-deleted + */ + async finalizeBlock(finalizedBlockNumber: BlockNumber): Promise { + const toHardDelete: string[] = []; + for (const [txHash, state] of this.#state) { + if (state.softDeleted && state.minedAtBlock <= finalizedBlockNumber) { + toHardDelete.push(txHash); + } + } + + if (toHardDelete.length === 0) { + return []; + } + + // Hard-delete from all stores + for (const txHash of toHardDelete) { + this.#state.delete(txHash); + await this.#deletedTxsDB.delete(txHash); + await this.#txsDB.delete(txHash); + } + + this.#log.debug(`Finalized ${toHardDelete.length} txs from pruned blocks at block ${finalizedBlockNumber}`); + return toHardDelete; + } + + /** + * Gets the count of transactions from pruned blocks. + */ + getCount(): number { + return this.#state.size; + } + + /** + * Gets all transaction hashes from pruned blocks. + */ + getPrunedTxHashes(): string[] { + return Array.from(this.#state.keys()); + } +} diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/eviction_manager.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/eviction_manager.test.ts index 29e2259862b4..7e44fb8364ca 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/eviction_manager.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/eviction_manager.test.ts @@ -4,7 +4,7 @@ import { BlockHeader } from '@aztec/stdlib/tx'; import { type MockProxy, mock } from 'jest-mock-extended'; -import type { TxMetaData } from '../tx_metadata.js'; +import { type TxMetaData, stubTxMetaValidationData } from '../tx_metadata.js'; import { EvictionManager } from './eviction_manager.js'; import { EvictionEvent, @@ -182,6 +182,7 @@ describe('EvictionManager', () => { feeLimit: 100n, nullifiers: [`0x${txHash.slice(2)}null1`], includeByTimestamp: 0n, + data: stubTxMetaValidationData(), }); beforeEach(() => { @@ -315,6 +316,7 @@ describe('EvictionManager', () => { feeLimit: 100n, nullifiers: [`0x${txHash.slice(2)}null1`], includeByTimestamp: 0n, + data: stubTxMetaValidationData(), }); preAddRule1.check.mockRejectedValue(new Error('Rule failed')); diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.test.ts index aa5fe1156a32..64b514b04681 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.test.ts @@ -7,7 +7,7 @@ import { BlockHeader, GlobalVariables } from '@aztec/stdlib/tx'; import { jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; -import type { TxMetaData } from '../tx_metadata.js'; +import { type TxMetaData, stubTxMetaValidationData } from '../tx_metadata.js'; import { FeePayerBalanceEvictionRule } from './fee_payer_balance_eviction_rule.js'; import type { EvictionContext, PoolOperations } from './interfaces.js'; import { EvictionEvent } from './interfaces.js'; @@ -42,6 +42,7 @@ describe('FeePayerBalanceEvictionRule', () => { feeLimit: opts.feeLimit ?? 100n, nullifiers: [`0x${txHash.slice(2)}null1`], includeByTimestamp: 0n, + data: stubTxMetaValidationData(), }); // Create mock pool operations diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_pre_add_rule.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_pre_add_rule.test.ts index 18331ef781d2..e839c7adff0b 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_pre_add_rule.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_pre_add_rule.test.ts @@ -1,4 +1,4 @@ -import type { TxMetaData } from '../tx_metadata.js'; +import { type TxMetaData, stubTxMetaValidationData } from '../tx_metadata.js'; import { FeePayerBalancePreAddRule } from './fee_payer_balance_pre_add_rule.js'; import type { PreAddPoolAccess } from './interfaces.js'; @@ -23,6 +23,7 @@ describe('FeePayerBalancePreAddRule', () => { feeLimit: opts.feeLimit ?? 100n, nullifiers: [`0x${txHash.slice(2)}null1`], includeByTimestamp: 0n, + data: stubTxMetaValidationData(), }); // Mock pool access with configurable behavior diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_mining_rule.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_mining_rule.test.ts index b58d9b7914e1..5a822681a09d 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_mining_rule.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_mining_rule.test.ts @@ -3,7 +3,7 @@ import { BlockHeader } from '@aztec/stdlib/tx'; import { jest } from '@jest/globals'; -import type { TxMetaData } from '../tx_metadata.js'; +import { type TxMetaData, stubTxMetaValidationData } from '../tx_metadata.js'; import type { EvictionContext, PoolOperations } from './interfaces.js'; import { EvictionEvent } from './interfaces.js'; import { InvalidTxsAfterMiningRule } from './invalid_txs_after_mining_rule.js'; @@ -24,16 +24,21 @@ describe('InvalidTxsAfterMiningRule', () => { nullifiers?: string[]; includeByTimestamp?: bigint; } = {}, - ): TxMetaData => ({ - txHash, - anchorBlockHeaderHash: '0x1234', - priorityFee: 100n, - feePayer: '0xfeepayer', - claimAmount: 0n, - feeLimit: 100n, - nullifiers: opts.nullifiers ?? [`0x${txHash.slice(2)}null1`], - includeByTimestamp: opts.includeByTimestamp ?? DEFAULT_INCLUDE_BY_TIMESTAMP, - }); + ): TxMetaData => { + const nullifiers = opts.nullifiers ?? [`0x${txHash.slice(2)}null1`]; + const includeByTimestamp = opts.includeByTimestamp ?? DEFAULT_INCLUDE_BY_TIMESTAMP; + return { + txHash, + anchorBlockHeaderHash: '0x1234', + priorityFee: 100n, + feePayer: '0xfeepayer', + claimAmount: 0n, + feeLimit: 100n, + nullifiers, + includeByTimestamp, + data: stubTxMetaValidationData({ includeByTimestamp }), + }; + }; // Create mock pool operations const createPoolOps = (pendingTxs: TxMetaData[]): PoolOperations => { diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.test.ts index e53d6bd9682c..8ce5108cf7b6 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.test.ts @@ -7,7 +7,7 @@ import { BlockHeader } from '@aztec/stdlib/tx'; import { jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; -import type { TxMetaData } from '../tx_metadata.js'; +import { type TxMetaData, stubTxMetaValidationData } from '../tx_metadata.js'; import type { EvictionContext, PoolOperations } from './interfaces.js'; import { EvictionEvent } from './interfaces.js'; import { InvalidTxsAfterReorgRule } from './invalid_txs_after_reorg_rule.js'; @@ -30,6 +30,7 @@ describe('InvalidTxsAfterReorgRule', () => { feeLimit: 100n, nullifiers: [`0x${txHash.slice(2)}null1`], includeByTimestamp: 0n, + data: stubTxMetaValidationData(), }); // Create mock pool operations diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/low_priority_pre_add_rule.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/low_priority_pre_add_rule.test.ts index 68b3bb633987..cc3f655b9b0f 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/low_priority_pre_add_rule.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/low_priority_pre_add_rule.test.ts @@ -1,4 +1,4 @@ -import type { TxMetaData } from '../tx_metadata.js'; +import { type TxMetaData, stubTxMetaValidationData } from '../tx_metadata.js'; import type { PreAddPoolAccess } from './interfaces.js'; import { LowPriorityPreAddRule } from './low_priority_pre_add_rule.js'; @@ -15,6 +15,7 @@ describe('LowPriorityPreAddRule', () => { feeLimit: 100n, nullifiers: [`0x${txHash.slice(2)}null1`], includeByTimestamp: 0n, + data: stubTxMetaValidationData(), }); // Mock pool access with configurable behavior diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/nullifier_conflict_rule.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/nullifier_conflict_rule.test.ts index b1fa9c3d8cab..10ee39758573 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/nullifier_conflict_rule.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/nullifier_conflict_rule.test.ts @@ -1,4 +1,4 @@ -import type { TxMetaData } from '../tx_metadata.js'; +import { type TxMetaData, stubTxMetaValidationData } from '../tx_metadata.js'; import type { PreAddPoolAccess } from './interfaces.js'; import { NullifierConflictRule } from './nullifier_conflict_rule.js'; @@ -20,6 +20,7 @@ describe('NullifierConflictRule', () => { feeLimit: 1000n, nullifiers, includeByTimestamp: 0n, + data: stubTxMetaValidationData(), }); // Mock pool access with configurable behavior diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/index.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/index.ts index c59523428ed6..391e7edccab0 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/index.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/index.ts @@ -9,3 +9,4 @@ export { } from './interfaces.js'; export { type TxMetaData, type TxState, buildTxMetaData, comparePriority } from './tx_metadata.js'; export { TxArchive } from './archive/index.js'; +export { DeletedPool } from './deleted_pool.js'; diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts index 6341d0bb11dd..15db226efaa8 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/interfaces.ts @@ -55,8 +55,8 @@ export type TxPoolV2Dependencies = { l2BlockSource: L2BlockSource; /** World state synchronizer for validating transactions after chain prunes */ worldStateSynchronizer: WorldStateSynchronizer; - /** Validator for transactions entering the pending pool */ - pendingTxValidator: TxValidator; + /** Factory that creates a validator for re-validating pool transactions using metadata */ + createTxValidator: () => Promise>; }; /** diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_metadata.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_metadata.test.ts index 41ce6ecf7dbc..efe38ea6b786 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_metadata.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_metadata.test.ts @@ -1,6 +1,12 @@ import { mockTx } from '@aztec/stdlib/testing'; -import { type TxMetaData, buildTxMetaData, checkNullifierConflict, comparePriority } from './tx_metadata.js'; +import { + type TxMetaData, + buildTxMetaData, + checkNullifierConflict, + comparePriority, + stubTxMetaValidationData, +} from './tx_metadata.js'; describe('TxMetaData', () => { describe('buildTxMetaData', () => { @@ -41,6 +47,7 @@ describe('TxMetaData', () => { feeLimit: 1000n, nullifiers: [], includeByTimestamp: 0n, + data: stubTxMetaValidationData(), }); it('returns negative when first has lower priority fee', () => { @@ -72,6 +79,7 @@ describe('TxMetaData', () => { feeLimit: 1000n, nullifiers, includeByTimestamp: 0n, + data: stubTxMetaValidationData(), }); it('returns no conflict when nullifiers do not overlap', () => { diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_metadata.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_metadata.ts index 374a77d639dc..96686068c8c9 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_metadata.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_metadata.ts @@ -1,12 +1,27 @@ +import { BlockNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { ProtocolContractAddress } from '@aztec/protocol-contracts'; -import type { L2BlockId } from '@aztec/stdlib/block'; +import { BlockHash, type L2BlockId } from '@aztec/stdlib/block'; import type { Tx } from '@aztec/stdlib/tx'; import { getFeePayerBalanceDelta } from '../../msg_validators/tx_validator/fee_payer_balance.js'; import { getTxPriorityFee } from '../tx_pool/priority.js'; import type { PreAddResult } from './eviction/interfaces.js'; +/** Validator-compatible data interface, mirroring the subset of PrivateKernelTailCircuitPublicInputs used by validators. */ +export type TxMetaValidationData = { + getNonEmptyNullifiers(): Fr[]; + includeByTimestamp: bigint; + constants: { + anchorBlockHeader: { + hash(): Promise; + globalVariables: { + blockNumber: BlockNumber; + }; + }; + }; +}; + /** * Lightweight in-memory representation of a transaction. * Stored for every tx in the pool to enable efficient queries and challenges @@ -42,22 +57,29 @@ export type TxMetaData = { /** Timestamp by which the transaction must be included (for expiration checks) */ readonly includeByTimestamp: bigint; + + /** Validator-compatible data, providing the same access patterns as Tx.data */ + readonly data: TxMetaValidationData; }; /** Transaction state derived from TxMetaData fields and pool protection status */ -export type TxState = 'pending' | 'protected' | 'mined'; +export type TxState = 'pending' | 'protected' | 'mined' | 'deleted'; /** * Builds TxMetaData from a full Tx object. * Extracts all relevant fields for efficient in-memory storage and querying. + * Fr values are captured in closures for zero-cost re-validation. */ export async function buildTxMetaData(tx: Tx): Promise { const txHash = tx.getTxHash().toString(); - const anchorBlockHeaderHash = (await tx.data.constants.anchorBlockHeader.hash()).toString(); + const nullifierFrs = tx.data.getNonEmptyNullifiers(); + const nullifiers = nullifierFrs.map(n => n.toString()); + const anchorBlockHeaderHashFr = await tx.data.constants.anchorBlockHeader.hash(); + const anchorBlockHeaderHash = anchorBlockHeaderHashFr.toString(); + const includeByTimestamp = tx.data.includeByTimestamp; + const anchorBlockNumber = tx.data.constants.anchorBlockHeader.globalVariables.blockNumber; const priorityFee = getTxPriorityFee(tx); const feePayer = tx.data.feePayer.toString(); - const nullifiers = tx.data.getNonEmptyNullifiers().map(n => n.toString()); - const includeByTimestamp = tx.data.includeByTimestamp; const { feeLimit, claimAmount } = await getFeePayerBalanceDelta(tx, ProtocolContractAddress.FeeJuice); @@ -70,6 +92,16 @@ export async function buildTxMetaData(tx: Tx): Promise { feeLimit, nullifiers, includeByTimestamp, + data: { + getNonEmptyNullifiers: () => nullifierFrs, + includeByTimestamp, + constants: { + anchorBlockHeader: { + hash: () => Promise.resolve(anchorBlockHeaderHashFr), + globalVariables: { blockNumber: anchorBlockNumber }, + }, + }, + }, }; } @@ -159,3 +191,17 @@ export function checkNullifierConflict( return { shouldIgnore: false, txHashesToEvict }; } + +/** Creates a stub TxMetaValidationData for tests that don't exercise validators. */ +export function stubTxMetaValidationData(overrides: { includeByTimestamp?: bigint } = {}): TxMetaValidationData { + return { + getNonEmptyNullifiers: () => [], + includeByTimestamp: overrides.includeByTimestamp ?? 0n, + constants: { + anchorBlockHeader: { + hash: () => Promise.resolve(new BlockHash(Fr.ZERO)), + globalVariables: { blockNumber: BlockNumber(0) }, + }, + }, + }; +} diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts index 19c2fe1caaaf..8b76a026d01a 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.compat.test.ts @@ -28,10 +28,11 @@ import { BlockHeader, GlobalVariables, type Tx, TxEffect, TxHash, type TxValidat import { jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; +import type { TxMetaData } from './tx_metadata.js'; import { AztecKVTxPoolV2 } from './tx_pool_v2.js'; /** A validator that accepts all transactions. */ -const alwaysValidValidator: TxValidator = { +const alwaysValidValidator: TxValidator = { validateTx: () => Promise.resolve({ result: 'valid' }), }; @@ -85,7 +86,7 @@ describe('TxPoolV2 Compatibility Tests', () => { pool = new AztecKVTxPoolV2(await openTmpStore('p2p'), await openTmpStore('archive'), { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); await pool.start(); }); @@ -323,7 +324,7 @@ describe('TxPoolV2 Compatibility Tests', () => { { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }, undefined, // telemetry { archivedTxLimit: 2 }, @@ -364,7 +365,7 @@ describe('TxPoolV2 Compatibility Tests', () => { { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }, undefined, // telemetry { maxPendingTxCount: 3 }, @@ -420,7 +421,7 @@ describe('TxPoolV2 Compatibility Tests', () => { { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }, undefined, // telemetry { maxPendingTxCount: 10 }, @@ -463,7 +464,7 @@ describe('TxPoolV2 Compatibility Tests', () => { { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }, undefined, // telemetry { maxPendingTxCount: 10 }, @@ -635,7 +636,7 @@ describe('TxPoolV2 Compatibility Tests', () => { { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }, undefined, // telemetry { maxPendingTxCount: 0 }, diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts index e50e77e2449a..7b943c56d6b5 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts @@ -18,13 +18,14 @@ import { BlockHeader, GlobalVariables, type Tx, TxEffect, TxHash, type TxValidat import { type MockProxy, mock } from 'jest-mock-extended'; +import type { TxMetaData } from './tx_metadata.js'; import { AztecKVTxPoolV2 } from './tx_pool_v2.js'; // Tx type alias for cleaner type annotations type MockTx = Awaited>; /** A validator that accepts all transactions. Used in tests that don't need validation. */ -const alwaysValidValidator: TxValidator = { +const alwaysValidValidator: TxValidator = { validateTx: () => Promise.resolve({ result: 'valid' }), }; @@ -116,7 +117,7 @@ describe('TxPoolV2', () => { pool = new AztecKVTxPoolV2(store, archiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); await pool.start(); @@ -501,7 +502,7 @@ describe('TxPoolV2', () => { describe('validator rejection', () => { let rejectingPool: AztecKVTxPoolV2; - let rejectingValidator: TxValidator; + let rejectingValidator: TxValidator; let txsToReject: Set; let rejectingStore: Awaited>; let rejectingArchiveStore: Awaited>; @@ -510,9 +511,8 @@ describe('TxPoolV2', () => { // Create a validator that rejects specific transactions txsToReject = new Set(); rejectingValidator = { - validateTx: (tx: Tx) => { - const txHash = tx.getTxHash().toString(); - if (txsToReject.has(txHash)) { + validateTx: (meta: TxMetaData) => { + if (txsToReject.has(meta.txHash)) { return Promise.resolve({ result: 'invalid', reason: ['test rejection'] }); } return Promise.resolve({ result: 'valid' }); @@ -524,7 +524,7 @@ describe('TxPoolV2', () => { rejectingPool = new AztecKVTxPoolV2(rejectingStore, rejectingArchiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: rejectingValidator, + createTxValidator: () => Promise.resolve(rejectingValidator), }); await rejectingPool.start(); }); @@ -1512,6 +1512,7 @@ describe('TxPoolV2', () => { const pending = toStrings(await pool.getPendingTxHashes()); expect(pending).toHaveLength(1); expect(pending).toContain(hashOf(txMined)); + // txPending was never mined, never in a pruned block, so it's hard-deleted expect(await pool.getTxStatus(txPending.getTxHash())).toBeUndefined(); expectRemovedTxs(txPending); // txPending evicted due to nullifier conflict }); @@ -1542,7 +1543,7 @@ describe('TxPoolV2', () => { const pending = toStrings(await pool.getPendingTxHashes()); expect(pending).toHaveLength(1); expect(pending).toContain(hashOf(txPending)); - expect(await pool.getTxStatus(txMined.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txMined.getTxHash())).toBe('deleted'); expectRemovedTxs(txMined); // txMined deleted due to lower priority }); @@ -1581,8 +1582,8 @@ describe('TxPoolV2', () => { const pending = toStrings(await pool.getPendingTxHashes()); expect(pending).toHaveLength(1); expect(pending).toContain(hashOf(tx2)); // tx2 has fee=15, highest - expect(await pool.getTxStatus(tx1.getTxHash())).toBeUndefined(); - expect(await pool.getTxStatus(tx3.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(tx1.getTxHash())).toBe('deleted'); + expect(await pool.getTxStatus(tx3.getTxHash())).toBe('deleted'); expectRemovedTxs(tx1, tx3); // Lower priority txs deleted }); @@ -1614,6 +1615,7 @@ describe('TxPoolV2', () => { const pending = toStrings(await pool.getPendingTxHashes()); expect(pending).toHaveLength(1); expect(pending).toContain(hashOf(txMined)); + // txPending1 and txPending2 were never mined, never in a pruned block, so hard-deleted expect(await pool.getTxStatus(txPending1.getTxHash())).toBeUndefined(); expect(await pool.getTxStatus(txPending2.getTxHash())).toBeUndefined(); expectRemovedTxs(txPending1, txPending2); // Both evicted @@ -1647,19 +1649,19 @@ describe('TxPoolV2', () => { expect(pending).toHaveLength(2); expect(pending).toContain(hashOf(txPendingHigh)); expect(pending).toContain(hashOf(txPendingLow)); - expect(await pool.getTxStatus(txMined.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txMined.getTxHash())).toBe('deleted'); expectRemovedTxs(txMined); // txMined deleted }); }); describe('validation during restore', () => { - let mockValidator: MockProxy>; + let mockValidator: MockProxy>; let poolWithValidator: AztecKVTxPoolV2; let validatorStore: Awaited>; let validatorArchiveStore: Awaited>; beforeEach(async () => { - mockValidator = mock>(); + mockValidator = mock>(); // Default to valid mockValidator.validateTx.mockResolvedValue({ result: 'valid' }); @@ -1668,7 +1670,7 @@ describe('TxPoolV2', () => { poolWithValidator = new AztecKVTxPoolV2(validatorStore, validatorArchiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: mockValidator, + createTxValidator: () => Promise.resolve(mockValidator), }); await poolWithValidator.start(); }); @@ -1721,8 +1723,8 @@ describe('TxPoolV2', () => { await poolWithValidator.addProtectedTxs([txValid, txInvalid, txAlsoValid], slot1Header); // Configure validator to reject only txInvalid - mockValidator.validateTx.mockImplementation((tx: Tx) => { - if (tx.getTxHash().equals(txInvalid.getTxHash())) { + mockValidator.validateTx.mockImplementation((meta: TxMetaData) => { + if (meta.txHash === txInvalid.getTxHash().toString()) { return Promise.resolve({ result: 'invalid', reason: ['invalid proof'] }); } return Promise.resolve({ result: 'valid' }); @@ -1752,10 +1754,10 @@ describe('TxPoolV2', () => { reason: ['timestamp expired'], }); - // Reorg - tx should be deleted due to validation failure + // Reorg - tx should be soft-deleted due to validation failure await poolWithValidator.handlePrunedBlocks(block0Id); - expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBeUndefined(); + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('deleted'); expect(await poolWithValidator.getPendingTxCount()).toBe(0); }); @@ -1787,8 +1789,8 @@ describe('TxPoolV2', () => { await poolWithValidator.handleMinedBlock(makeBlock([txValid, txInvalid, txAlsoValid], slot1Header)); // Configure validator to reject only txInvalid - mockValidator.validateTx.mockImplementation((tx: Tx) => { - if (tx.getTxHash().equals(txInvalid.getTxHash())) { + mockValidator.validateTx.mockImplementation((meta: TxMetaData) => { + if (meta.txHash === txInvalid.getTxHash().toString()) { return Promise.resolve({ result: 'invalid', reason: ['nullifier exists'] }); } return Promise.resolve({ result: 'valid' }); @@ -1797,7 +1799,7 @@ describe('TxPoolV2', () => { await poolWithValidator.handlePrunedBlocks(block0Id); expect(await poolWithValidator.getTxStatus(txValid.getTxHash())).toBe('pending'); - expect(await poolWithValidator.getTxStatus(txInvalid.getTxHash())).toBeUndefined(); + expect(await poolWithValidator.getTxStatus(txInvalid.getTxHash())).toBe('deleted'); expect(await poolWithValidator.getTxStatus(txAlsoValid.getTxHash())).toBe('pending'); expect(await poolWithValidator.getPendingTxCount()).toBe(2); }); @@ -1816,8 +1818,8 @@ describe('TxPoolV2', () => { await poolWithValidator.addProtectedTxs([txProtected], slot1Header); // Make validator reject the protected tx - mockValidator.validateTx.mockImplementation((tx: Tx) => { - if (tx.getTxHash().equals(txProtected.getTxHash())) { + mockValidator.validateTx.mockImplementation((meta: TxMetaData) => { + if (meta.txHash === txProtected.getTxHash().toString()) { return Promise.resolve({ result: 'invalid', reason: ['invalid'] }); } return Promise.resolve({ result: 'valid' }); @@ -1834,6 +1836,464 @@ describe('TxPoolV2', () => { }); }); + describe('soft deletion', () => { + let mockValidator: MockProxy>; + let poolWithValidator: AztecKVTxPoolV2; + let validatorStore: Awaited>; + let validatorArchiveStore: Awaited>; + + beforeEach(async () => { + mockValidator = mock>(); + mockValidator.validateTx.mockResolvedValue({ result: 'valid' }); + + validatorStore = await openTmpStore('p2p-soft-delete'); + validatorArchiveStore = await openTmpStore('archive-soft-delete'); + poolWithValidator = new AztecKVTxPoolV2(validatorStore, validatorArchiveStore, { + l2BlockSource: mockL2BlockSource, + worldStateSynchronizer: mockWorldState, + createTxValidator: () => Promise.resolve(mockValidator), + }); + await poolWithValidator.start(); + }); + + afterEach(async () => { + await poolWithValidator.stop(); + await validatorStore.delete(); + await validatorArchiveStore.delete(); + }); + + it('soft-deleted txs have deleted status but are still retrievable via getTxByHash', async () => { + const tx = await mockTx(1); + + // Add and mine + await poolWithValidator.addPendingTxs([tx]); + await poolWithValidator.handleMinedBlock(makeBlock([tx], slot1Header)); + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('mined'); + + // Make validator reject this tx so it gets soft-deleted on prune + mockValidator.validateTx.mockResolvedValue({ + result: 'invalid', + reason: ['timestamp expired'], + }); + + // Prune - tx should be soft-deleted (removed from indices but kept in DB) + await poolWithValidator.handlePrunedBlocks(block0Id); + + // Status is 'deleted' + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await poolWithValidator.getPendingTxCount()).toBe(0); + + // But still retrievable via getTxByHash + const retrieved = await poolWithValidator.getTxByHash(tx.getTxHash()); + expect(retrieved).toBeDefined(); + expect(retrieved!.getTxHash().toString()).toEqual(tx.getTxHash().toString()); + }); + + it('handleFinalizedBlock hard-deletes soft-deleted txs', async () => { + const tx = await mockTx(1); + + // Add and mine at block 1 + await poolWithValidator.addPendingTxs([tx]); + await poolWithValidator.handleMinedBlock(makeBlock([tx], slot1Header)); + + // Make validator reject to cause soft deletion + mockValidator.validateTx.mockResolvedValue({ + result: 'invalid', + reason: ['invalid'], + }); + + // Prune - tx is soft-deleted at block 0 (prune point) + await poolWithValidator.handlePrunedBlocks(block0Id); + + // Verify still retrievable + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeDefined(); + + // Finalize block 1 - should hard-delete soft-deleted tx (pruned at block 0 <= finalized block 1) + await poolWithValidator.handleFinalizedBlock(slot1Header); + + // Now completely gone from DB + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeUndefined(); + }); + + it('soft-deleted tx is not hard-deleted until finalized block reaches prune point', async () => { + const tx = await mockTx(1); + + // Create header for block 2 + const block2Header = BlockHeader.empty({ + globalVariables: GlobalVariables.empty({ + blockNumber: BlockNumber(2), + slotNumber: SlotNumber(2), + }), + }); + + // Add, mine at block 2 + await poolWithValidator.addPendingTxs([tx]); + await poolWithValidator.handleMinedBlock(makeBlock([tx], block2Header)); + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('mined'); + + // Make validator reject + mockValidator.validateTx.mockResolvedValue({ + result: 'invalid', + reason: ['invalid'], + }); + + // Prune to block 1 - tx mined at block 2 gets un-mined, fails validation, soft-deleted + // The tx was mined at block 2, so it should only be hard-deleted when block 2 is finalized + const block1Id: L2BlockId = { number: BlockNumber(1), hash: '0x1' }; + await poolWithValidator.handlePrunedBlocks(block1Id); + + // Verify soft-deleted (status is 'deleted', still in DB) + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeDefined(); + + // Finalize block 1 - should NOT hard-delete (mined at 2 > finalized 1) + await poolWithValidator.handleFinalizedBlock(slot1Header); + + // Still retrievable + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeDefined(); + + // Finalize block 2 - NOW it should be hard-deleted (mined at 2 <= finalized 2) + await poolWithValidator.handleFinalizedBlock(block2Header); + + // Gone + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeUndefined(); + }); + + it('evicted txs during nullifier conflict are soft-deleted and retrievable', async () => { + const txPending = await mockPublicTx(1, 10); + const txMined = await mockPublicTx(2, 5); + + // Give mined tx the same nullifier as pending tx + setNullifier(txMined, 0, getNullifier(txPending, 0)); + + // Add mined tx first and mine it + await poolWithValidator.addPendingTxs([txMined]); + await poolWithValidator.handleMinedBlock(makeBlock([txMined], slot1Header)); + + // Now txPending can be added (higher priority) + await poolWithValidator.addPendingTxs([txPending]); + + // Reorg - txMined tries to return but loses to txPending (lower priority) + // It should be soft-deleted, not hard-deleted + await poolWithValidator.handlePrunedBlocks(block0Id); + + // txMined should have 'deleted' status but still be retrievable + expect(await poolWithValidator.getTxStatus(txMined.getTxHash())).toBe('deleted'); + const retrieved = await poolWithValidator.getTxByHash(txMined.getTxHash()); + expect(retrieved).toBeDefined(); + expect(retrieved!.getTxHash().toString()).toEqual(txMined.getTxHash().toString()); + + // txPending should be in pending + expect(await poolWithValidator.getTxStatus(txPending.getTxHash())).toBe('pending'); + }); + + it('hasTxs returns true for soft-deleted txs', async () => { + const tx = await mockTx(1); + + // Add and mine + await poolWithValidator.addPendingTxs([tx]); + await poolWithValidator.handleMinedBlock(makeBlock([tx], slot1Header)); + + // Make validator reject to cause soft deletion + mockValidator.validateTx.mockResolvedValue({ + result: 'invalid', + reason: ['invalid'], + }); + + // Prune - tx is soft-deleted + await poolWithValidator.handlePrunedBlocks(block0Id); + + // hasTxs should still return true for soft-deleted tx + const [hasTx] = await poolWithValidator.hasTxs([tx.getTxHash()]); + expect(hasTx).toBe(true); + + // getTxStatus returns 'deleted' + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('deleted'); + + // And getTxByHash still works + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeDefined(); + }); + + it('hasTxs returns false after hard deletion', async () => { + const tx = await mockTx(1); + + // Add and mine + await poolWithValidator.addPendingTxs([tx]); + await poolWithValidator.handleMinedBlock(makeBlock([tx], slot1Header)); + + // Make validator reject to cause soft deletion + mockValidator.validateTx.mockResolvedValue({ + result: 'invalid', + reason: ['invalid'], + }); + + // Prune - tx is soft-deleted + await poolWithValidator.handlePrunedBlocks(block0Id); + + // Finalize - tx is hard-deleted + await poolWithValidator.handleFinalizedBlock(slot1Header); + + // hasTxs should return false after hard deletion + const [hasTx] = await poolWithValidator.hasTxs([tx.getTxHash()]); + expect(hasTx).toBe(false); + + // getTxByHash returns undefined + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeUndefined(); + }); + + describe('full soft deletion lifecycle', () => { + it('prune -> soft-delete -> finalize -> gone', async () => { + const tx = await mockTx(1); + + // 1. Add transaction as pending + await poolWithValidator.addPendingTxs([tx]); + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('pending'); + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeDefined(); + + // 2. Mine the transaction + await poolWithValidator.handleMinedBlock(makeBlock([tx], slot1Header)); + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('mined'); + + // 3. Prune (reorg) - transaction fails validation and is soft-deleted + mockValidator.validateTx.mockResolvedValue({ + result: 'invalid', + reason: ['nullifier already exists'], + }); + await poolWithValidator.handlePrunedBlocks(block0Id); + + // Transaction is soft-deleted: status is 'deleted', still retrievable + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeDefined(); + expect((await poolWithValidator.hasTxs([tx.getTxHash()]))[0]).toBe(true); + expect(await poolWithValidator.getPendingTxCount()).toBe(0); + + // 4. Finalize the block - transaction is hard-deleted + await poolWithValidator.handleFinalizedBlock(slot1Header); + + // Transaction is completely gone (status undefined, not 'deleted') + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBeUndefined(); + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeUndefined(); + expect((await poolWithValidator.hasTxs([tx.getTxHash()]))[0]).toBe(false); + }); + + it('multiple txs with different mined blocks finalize at correct times', async () => { + const tx1 = await mockTx(1); + const tx2 = await mockTx(2); + const tx3 = await mockTx(3); + + const block2Header = BlockHeader.empty({ + globalVariables: GlobalVariables.empty({ + blockNumber: BlockNumber(2), + slotNumber: SlotNumber(2), + }), + }); + + const block3Header = BlockHeader.empty({ + globalVariables: GlobalVariables.empty({ + blockNumber: BlockNumber(3), + slotNumber: SlotNumber(3), + }), + }); + + // Add and mine all txs at different blocks + await poolWithValidator.addPendingTxs([tx1]); + await poolWithValidator.handleMinedBlock(makeBlock([tx1], slot1Header)); // mined at block 1 + + await poolWithValidator.addPendingTxs([tx2]); + await poolWithValidator.handleMinedBlock(makeBlock([tx2], block2Header)); // mined at block 2 + + await poolWithValidator.addPendingTxs([tx3]); + await poolWithValidator.handleMinedBlock(makeBlock([tx3], block3Header)); // mined at block 3 + + // Make validator reject all + mockValidator.validateTx.mockResolvedValue({ + result: 'invalid', + reason: ['invalid'], + }); + + // Prune to block 0 - un-mines all txs (mined at 1, 2, 3 are all > 0) + // All fail validation and are soft-deleted + // Each tx tracks its original mined block (1, 2, 3 respectively) + await poolWithValidator.handlePrunedBlocks(block0Id); + + // All are soft-deleted, retrievable + expect(await poolWithValidator.getTxByHash(tx1.getTxHash())).toBeDefined(); + expect(await poolWithValidator.getTxByHash(tx2.getTxHash())).toBeDefined(); + expect(await poolWithValidator.getTxByHash(tx3.getTxHash())).toBeDefined(); + + // Finalize block 1 - only tx1 should be hard-deleted (mined at block 1) + await poolWithValidator.handleFinalizedBlock(slot1Header); + + expect(await poolWithValidator.getTxByHash(tx1.getTxHash())).toBeUndefined(); + expect(await poolWithValidator.getTxByHash(tx2.getTxHash())).toBeDefined(); + expect(await poolWithValidator.getTxByHash(tx3.getTxHash())).toBeDefined(); + + // Finalize block 2 - tx2 should be hard-deleted (mined at block 2) + await poolWithValidator.handleFinalizedBlock(block2Header); + + expect(await poolWithValidator.getTxByHash(tx2.getTxHash())).toBeUndefined(); + expect(await poolWithValidator.getTxByHash(tx3.getTxHash())).toBeDefined(); + + // Finalize block 3 - tx3 should be hard-deleted (mined at block 3) + await poolWithValidator.handleFinalizedBlock(block3Header); + + expect(await poolWithValidator.getTxByHash(tx3.getTxHash())).toBeUndefined(); + }); + + it('soft-deleted txs are excluded from state-specific queries but included in hash queries', async () => { + const txPending = await mockTx(1); + const txToSoftDelete = await mockTx(2); + + // Add both as pending + await poolWithValidator.addPendingTxs([txPending, txToSoftDelete]); + expect(await poolWithValidator.getPendingTxCount()).toBe(2); + + // Mine txToSoftDelete + await poolWithValidator.handleMinedBlock(makeBlock([txToSoftDelete], slot1Header)); + + // Make validator reject + mockValidator.validateTx.mockResolvedValue({ + result: 'invalid', + reason: ['invalid'], + }); + + // Prune - txToSoftDelete is soft-deleted + await poolWithValidator.handlePrunedBlocks(block0Id); + + // State-specific queries should NOT include soft-deleted tx + expect(await poolWithValidator.getPendingTxCount()).toBe(1); + const pendingHashes = await poolWithValidator.getPendingTxHashes(); + expect(pendingHashes.map(h => h.toString())).toContain(txPending.getTxHash().toString()); + expect(pendingHashes.map(h => h.toString())).not.toContain(txToSoftDelete.getTxHash().toString()); + + // Hash-based queries should include soft-deleted tx + const [hasPending, hasSoftDeleted] = await poolWithValidator.hasTxs([ + txPending.getTxHash(), + txToSoftDelete.getTxHash(), + ]); + expect(hasPending).toBe(true); + expect(hasSoftDeleted).toBe(true); + + // Both retrievable by hash + expect(await poolWithValidator.getTxByHash(txPending.getTxHash())).toBeDefined(); + expect(await poolWithValidator.getTxByHash(txToSoftDelete.getTxHash())).toBeDefined(); + + // Status differs + expect(await poolWithValidator.getTxStatus(txPending.getTxHash())).toBe('pending'); + expect(await poolWithValidator.getTxStatus(txToSoftDelete.getTxHash())).toBe('deleted'); + }); + + it('getTxsByHash returns soft-deleted txs', async () => { + const tx1 = await mockTx(1); + const tx2 = await mockTx(2); + + // Add and mine + await poolWithValidator.addPendingTxs([tx1, tx2]); + await poolWithValidator.handleMinedBlock(makeBlock([tx1, tx2], slot1Header)); + + // Make validator reject + mockValidator.validateTx.mockResolvedValue({ + result: 'invalid', + reason: ['invalid'], + }); + + // Prune - both soft-deleted + await poolWithValidator.handlePrunedBlocks(block0Id); + + // getTxsByHash should return both + const txs = await poolWithValidator.getTxsByHash([tx1.getTxHash(), tx2.getTxHash()]); + expect(txs).toHaveLength(2); + expect(txs[0]).toBeDefined(); + expect(txs[1]).toBeDefined(); + expect(txs[0]!.getTxHash().toString()).toBe(tx1.getTxHash().toString()); + expect(txs[1]!.getTxHash().toString()).toBe(tx2.getTxHash().toString()); + }); + }); + + describe('protected tx in pruned block', () => { + it('protected tx during prune that later fails validation should be soft-deleted', async () => { + const tx = await mockTx(1); + + // Add, protect, and mine the tx + await poolWithValidator.addPendingTxs([tx]); + await poolWithValidator.addProtectedTxs([tx], slot1Header); + await poolWithValidator.handleMinedBlock(makeBlock([tx], slot1Header)); + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('mined'); + + // Prune - tx is un-mined but stays protected (validator passes at this point) + await poolWithValidator.handlePrunedBlocks(block0Id); + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('protected'); + + // Now make validator reject this tx + mockValidator.validateTx.mockResolvedValue({ + result: 'invalid', + reason: ['timestamp expired'], + }); + + // Unprotect (prepareForSlot) - tx fails validation + await poolWithValidator.prepareForSlot(SlotNumber(2)); + + // The tx was in a pruned block, so it should be SOFT-deleted, not hard-deleted + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeDefined(); + }); + + it('protected tx during prune that later loses nullifier conflict should be soft-deleted', async () => { + const txProtected = await mockPublicTx(1, 5); + const txHigherPriority = await mockPublicTx(2, 10); + + // Give them the same nullifier + setNullifier(txHigherPriority, 0, getNullifier(txProtected, 0)); + + // Add, protect, and mine txProtected + await poolWithValidator.addPendingTxs([txProtected]); + await poolWithValidator.addProtectedTxs([txProtected], slot1Header); + await poolWithValidator.handleMinedBlock(makeBlock([txProtected], slot1Header)); + expect(await poolWithValidator.getTxStatus(txProtected.getTxHash())).toBe('mined'); + + // Prune - txProtected is un-mined but stays protected + await poolWithValidator.handlePrunedBlocks(block0Id); + expect(await poolWithValidator.getTxStatus(txProtected.getTxHash())).toBe('protected'); + + // Now add a higher priority tx with same nullifier + await poolWithValidator.addPendingTxs([txHigherPriority]); + expect(await poolWithValidator.getTxStatus(txHigherPriority.getTxHash())).toBe('pending'); + + // Unprotect (prepareForSlot) - txProtected loses nullifier conflict + await poolWithValidator.prepareForSlot(SlotNumber(2)); + + // The tx was in a pruned block, so it should be SOFT-deleted, not hard-deleted + expect(await poolWithValidator.getTxStatus(txProtected.getTxHash())).toBe('deleted'); + expect(await poolWithValidator.getTxByHash(txProtected.getTxHash())).toBeDefined(); + + // Higher priority tx should be pending + expect(await poolWithValidator.getTxStatus(txHigherPriority.getTxHash())).toBe('pending'); + }); + + it('tx not in pruned block that is deleted should be hard-deleted', async () => { + const tx = await mockTx(1); + + // Add tx as pending (never mined, so never pruned) + await poolWithValidator.addPendingTxs([tx]); + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBe('pending'); + + // Make validator reject + mockValidator.validateTx.mockResolvedValue({ + result: 'invalid', + reason: ['invalid'], + }); + + // Protect and then unprotect - tx fails validation + await poolWithValidator.addProtectedTxs([tx], slot1Header); + await poolWithValidator.prepareForSlot(SlotNumber(2)); + + // The tx was never in a pruned block, so it should be HARD-deleted + expect(await poolWithValidator.getTxStatus(tx.getTxHash())).toBeUndefined(); + expect(await poolWithValidator.getTxByHash(tx.getTxHash())).toBeUndefined(); + }); + }); + }); + describe('handleFailedExecution', () => { it('deletes failed transactions', async () => { const tx = await mockTx(1); @@ -2399,15 +2859,15 @@ describe('TxPoolV2', () => { await pool.handlePrunedBlocks(block0Id); - // Low priority tx should be evicted, high priority should be pending + // Low priority tx should be evicted (soft-deleted since from pruned block), high priority should be pending expect(await pool.getTxStatus(txHigh.getTxHash())).toBe('pending'); - expect(await pool.getTxStatus(txLow.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txLow.getTxHash())).toBe('deleted'); }); it('priority ordering is correct - highest priority funded first', async () => { const sharedFeePayer = AztecAddress.fromBigInt(999n); - // Balance covers only 2 out of 3 txs - setFeePayerBalance(BigInt(4e8)); + // Initial balance covers all 3 txs + setFeePayerBalance(BigInt(6e8)); db.findLeafIndices.mockResolvedValue([1n]); // Anchor blocks valid @@ -2432,14 +2892,17 @@ describe('TxPoolV2', () => { await pool.addPendingTxs([txPriority1, txPriority5, txPriority10]); await pool.handleMinedBlock(makeBlock([txPriority1, txPriority5, txPriority10], slot1Header)); + // Reduce balance to only cover 2 txs before reorg + setFeePayerBalance(BigInt(4e8)); + // Reorg - triggers balance eviction await pool.handlePrunedBlocks(block0Id); // Highest (priority 10) and middle (priority 5) should remain - // Lowest (priority 1) should be evicted + // Lowest (priority 1) should be soft-deleted (from pruned block) expect(await pool.getTxStatus(txPriority10.getTxHash())).toBe('pending'); expect(await pool.getTxStatus(txPriority5.getTxHash())).toBe('pending'); - expect(await pool.getTxStatus(txPriority1.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txPriority1.getTxHash())).toBe('deleted'); expect(await pool.getPendingTxCount()).toBe(2); }); @@ -2485,8 +2948,8 @@ describe('TxPoolV2', () => { await pool.handlePrunedBlocks(block0Id); - // Tx should be deleted because its anchor block was pruned - expect(await pool.getTxStatus(tx.getTxHash())).toBeUndefined(); + // Tx should be soft-deleted (from pruned block and anchor block was pruned) + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); }); it('keeps txs with valid anchor blocks after reorg', async () => { @@ -2527,9 +2990,9 @@ describe('TxPoolV2', () => { await pool.handlePrunedBlocks(block0Id); - // Valid tx should be restored to pending, invalid tx should be deleted + // Valid tx should be restored to pending, invalid tx should be soft-deleted (from pruned block) expect(await pool.getTxStatus(txValid.getTxHash())).toBe('pending'); - expect(await pool.getTxStatus(txInvalid.getTxHash())).toBeUndefined(); + expect(await pool.getTxStatus(txInvalid.getTxHash())).toBe('deleted'); }); it('evicts all txs when shared anchor block is pruned', async () => { @@ -2544,9 +3007,9 @@ describe('TxPoolV2', () => { await pool.handlePrunedBlocks(block0Id); - // Both should be deleted since their anchor block was pruned - expect(await pool.getTxStatus(tx1.getTxHash())).toBeUndefined(); - expect(await pool.getTxStatus(tx2.getTxHash())).toBeUndefined(); + // Both should be soft-deleted (from pruned block and anchor block was pruned) + expect(await pool.getTxStatus(tx1.getTxHash())).toBe('deleted'); + expect(await pool.getTxStatus(tx2.getTxHash())).toBe('deleted'); }); }); @@ -3492,7 +3955,7 @@ describe('TxPoolV2', () => { const pool1 = new AztecKVTxPoolV2(testStore, testArchiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); await pool1.start(); @@ -3517,7 +3980,7 @@ describe('TxPoolV2', () => { const pool2 = new AztecKVTxPoolV2(testStore, testArchiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); await pool2.start(); @@ -3545,7 +4008,7 @@ describe('TxPoolV2', () => { { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }, undefined, // telemetry { maxPendingTxCount: 100 }, @@ -3571,7 +4034,7 @@ describe('TxPoolV2', () => { { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }, undefined, // telemetry { maxPendingTxCount: 3 }, @@ -3603,7 +4066,7 @@ describe('TxPoolV2', () => { const pool1 = new AztecKVTxPoolV2(testStore, testArchiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); await pool1.start(); @@ -3620,7 +4083,7 @@ describe('TxPoolV2', () => { const pool2 = new AztecKVTxPoolV2(testStore, testArchiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); await pool2.start(); @@ -3649,7 +4112,7 @@ describe('TxPoolV2', () => { const pool1 = new AztecKVTxPoolV2(testStore, testArchiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); await pool1.start(); @@ -3683,7 +4146,7 @@ describe('TxPoolV2', () => { { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }, undefined, // telemetry { maxPendingTxCount: 0 }, // No pending txs allowed @@ -3711,7 +4174,7 @@ describe('TxPoolV2', () => { const pool1 = new AztecKVTxPoolV2(testStore, testArchiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); await pool1.start(); @@ -3724,9 +4187,9 @@ describe('TxPoolV2', () => { await pool1.stop(); // Create validator that rejects tx1 - const selectiveValidator: TxValidator = { - validateTx: (tx: Tx) => { - if (tx.getTxHash().toString() === tx1.getTxHash().toString()) { + const selectiveValidator: TxValidator = { + validateTx: (meta: TxMetaData) => { + if (meta.txHash === tx1.getTxHash().toString()) { return Promise.resolve({ result: 'invalid', reason: ['test rejection'] }); } return Promise.resolve({ result: 'valid' }); @@ -3737,7 +4200,7 @@ describe('TxPoolV2', () => { const pool2 = new AztecKVTxPoolV2(testStore, testArchiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: selectiveValidator, + createTxValidator: () => Promise.resolve(selectiveValidator), }); await pool2.start(); @@ -3762,7 +4225,7 @@ describe('TxPoolV2', () => { const pool1 = new AztecKVTxPoolV2(testStore, testArchiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); await pool1.start(); @@ -3787,7 +4250,7 @@ describe('TxPoolV2', () => { const pool2 = new AztecKVTxPoolV2(testStore, testArchiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); await pool2.start(); diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_bench.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_bench.test.ts index 536dbc1a9405..8e196bcf3cfc 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_bench.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_bench.test.ts @@ -21,11 +21,12 @@ import { mkdir, writeFile } from 'fs/promises'; import { type MockProxy, mock } from 'jest-mock-extended'; import path from 'path'; +import type { TxMetaData } from './tx_metadata.js'; import { TxPoolBenchMetrics, TxPoolOperation } from './tx_pool_bench_metrics.js'; import { AztecKVTxPoolV2 } from './tx_pool_v2.js'; /** A validator that accepts all transactions. */ -const alwaysValidValidator: TxValidator = { +const alwaysValidValidator: TxValidator = { validateTx: () => Promise.resolve({ result: 'valid' }), }; @@ -139,7 +140,7 @@ describe('TxPoolV2: benchmarks', () => { const pool = new AztecKVTxPoolV2(store, archiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); await pool.start(); const cleanup = async () => { @@ -493,7 +494,7 @@ describe('TxPoolV2: benchmarks', () => { const pool1 = new AztecKVTxPoolV2(store, archiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); await pool1.start(); @@ -510,7 +511,7 @@ describe('TxPoolV2: benchmarks', () => { const pool2 = new AztecKVTxPoolV2(store, archiveStore, { l2BlockSource: mockL2BlockSource, worldStateSynchronizer: mockWorldState, - pendingTxValidator: alwaysValidValidator, + createTxValidator: () => Promise.resolve(alwaysValidValidator), }); const startTime = performance.now(); 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 4d0292f53bd2..54bfb3b7ec0c 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 @@ -1,4 +1,4 @@ -import { SlotNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; import type { Logger } from '@aztec/foundation/log'; import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store'; import { ProtocolContractAddress } from '@aztec/protocol-contracts'; @@ -10,6 +10,7 @@ import { DatabasePublicStateSource } from '@aztec/stdlib/trees'; import { BlockHeader, Tx, TxHash, type TxValidator } from '@aztec/stdlib/tx'; import { TxArchive } from './archive/index.js'; +import { DeletedPool } from './deleted_pool.js'; import { EvictionManager, FeePayerBalanceEvictionRule, @@ -53,7 +54,7 @@ export class TxPoolV2Impl { // === Dependencies === #l2BlockSource: L2BlockSource; #worldStateSynchronizer: WorldStateSynchronizer; - #pendingTxValidator: TxValidator; + #createTxValidator: TxPoolV2Dependencies['createTxValidator']; // === In-Memory Indices === #indices: TxPoolIndices = new TxPoolIndices(); @@ -61,6 +62,7 @@ export class TxPoolV2Impl { // === Config & Services === #config: TxPoolV2Config; #archive: TxArchive; + #deletedPool: DeletedPool; #evictionManager: EvictionManager; #log: Logger; #callbacks: TxPoolV2Callbacks; @@ -78,10 +80,11 @@ export class TxPoolV2Impl { this.#l2BlockSource = deps.l2BlockSource; this.#worldStateSynchronizer = deps.worldStateSynchronizer; - this.#pendingTxValidator = deps.pendingTxValidator; + this.#createTxValidator = deps.createTxValidator; this.#config = { ...DEFAULT_TX_POOL_V2_CONFIG, ...config }; this.#archive = new TxArchive(archiveStore, this.#config.archivedTxLimit, log); + this.#deletedPool = new DeletedPool(store, this.#txsDB, log); this.#log = log; this.#callbacks = callbacks; @@ -116,7 +119,10 @@ export class TxPoolV2Impl { * by running pre-add rules to resolve nullifier conflicts, balance checks, and pool size limits. */ async hydrateFromDatabase(): Promise { - // Step 1: Load all transactions from DB + // Step 0: Hydrate deleted pool state + await this.#deletedPool.hydrateFromDatabase(); + + // Step 1: Load all transactions from DB (excluding soft-deleted) const { loaded, errors: deserializationErrors } = await this.#loadAllTxsFromDb(); // Step 2: Check mined status for each tx @@ -134,7 +140,10 @@ export class TxPoolV2Impl { } // Step 4: Validate non-mined transactions - const { valid, invalid } = await this.#validateTxBatch(nonMined, 'on startup'); + const { valid, invalid } = await this.#revalidateMetadata( + nonMined.map(e => e.meta), + 'on startup', + ); // Step 5: Populate mined indices (these don't need conflict resolution) for (const meta of mined) { @@ -229,13 +238,13 @@ export class TxPoolV2Impl { const txHash = tx.getTxHash(); const txHashStr = txHash.toString(); - // Validate transaction - if (!(await this.#validateTx(tx))) { + // Build metadata and validate using metadata + const meta = await buildTxMetaData(tx); + if (!(await this.#validateMeta(meta))) { return { status: 'rejected' }; } - // Build metadata and run pre-add rules - const meta = await buildTxMetaData(tx); + // Run pre-add rules const preAddResult = await this.#evictionManager.runPreAddRules(meta, poolAccess); if (preAddResult.shouldIgnore) { @@ -267,14 +276,14 @@ export class TxPoolV2Impl { return 'ignored'; } - // Validate transaction (no logging for dry-run check) - const validationResult = await this.#pendingTxValidator.validateTx(tx); - if (validationResult.result !== 'valid') { + // Build metadata and validate using metadata + const meta = await buildTxMetaData(tx); + const validationResult = await this.#validateMeta(meta, undefined, 'can add pending'); + if (validationResult !== true) { return 'rejected'; } - // Build metadata and use pre-add rules - const meta = await buildTxMetaData(tx); + // Use pre-add rules const poolAccess = this.#createPreAddPoolAccess(); const preAddResult = await this.#evictionManager.runPreAddRules(meta, poolAccess); @@ -347,6 +356,7 @@ export class TxPoolV2Impl { // Add new mined tx (callback emitted by #addTx) await this.#addTx(tx, { mined: blockId }, opts); } + await this.#deletedPool.clearIfMinedHigher(txHashStr, blockId.number); } }); } @@ -373,6 +383,7 @@ export class TxPoolV2Impl { // Step 4: Mark txs as mined (only those we have in the pool) for (const meta of found) { this.#indices.markAsMined(meta, blockId); + await this.#deletedPool.clearIfMinedHigher(meta.txHash, blockId.number); } // Step 5: Run eviction rules (remove pending txs with conflicting nullifiers/expired timestamps) @@ -397,7 +408,7 @@ export class TxPoolV2Impl { this.#log.info(`Preparing for slot ${slotNumber}: unprotecting ${txsToRestore.length} txs`); // Step 4: Validate for pending pool - const { valid, invalid } = await this.#loadAndValidateTxs(txsToRestore, 'during prepareForSlot'); + const { valid, invalid } = await this.#revalidateMetadata(txsToRestore, 'during prepareForSlot'); // Step 5: Resolve nullifier conflicts and add winners to pending indices const { added, toEvict } = this.#applyNullifierConflictResolution(valid); @@ -426,24 +437,34 @@ export class TxPoolV2Impl { this.#log.info(`Handling prune to block ${latestBlock.number}: un-mining ${txsToUnmine.length} txs`); - // Step 2: Unmine - clear mined status from metadata + // Step 2: Mark ALL un-mined txs with their original mined block number + // This ensures they get soft-deleted if removed later, and only hard-deleted + // when their original mined block is finalized + await this.#deletedPool.markFromPrunedBlock( + txsToUnmine.map(m => ({ + txHash: m.txHash, + minedAtBlock: BlockNumber(m.minedL2BlockId!.number), + })), + ); + + // Step 3: Unmine - clear mined status from metadata for (const meta of txsToUnmine) { this.#indices.markAsUnmined(meta); } - // Step 3: Filter out protected txs (they'll be handled by prepareForSlot) + // Step 4: Filter out protected txs (they'll be handled by prepareForSlot) const unprotectedTxs = this.#indices.filterUnprotected(txsToUnmine); // Step 4: Validate for pending pool - const { valid, invalid } = await this.#loadAndValidateTxs(unprotectedTxs, 'during handlePrunedBlocks'); + const { valid, invalid } = await this.#revalidateMetadata(unprotectedTxs, 'during handlePrunedBlocks'); - // Step 5: Resolve nullifier conflicts and add winners to pending indices + // Step 6: Resolve nullifier conflicts and add winners to pending indices const { toEvict } = this.#applyNullifierConflictResolution(valid); - // Step 6: Delete invalid and evicted txs + // Step 7: Delete invalid and evicted txs await this.#deleteTxsBatch([...invalid, ...toEvict]); - // Step 7: Run eviction rules for ALL pending txs (not just restored ones) + // Step 8: Run eviction rules for ALL pending txs (not just restored ones) // This handles cases like existing pending txs with invalid fee payer balances await this.#evictionManager.evictAfterChainPrune(latestBlock.number); } @@ -458,16 +479,13 @@ export class TxPoolV2Impl { async handleFinalizedBlock(block: BlockHeader): Promise { const blockNumber = block.globalVariables.blockNumber; - // Step 1: Find txs mined at or before finalized block - const txsToFinalize = this.#indices.findTxsMinedAtOrBefore(blockNumber); - if (txsToFinalize.length === 0) { - return; - } + // Step 1: Find mined txs at or before finalized block + const minedTxsToFinalize = this.#indices.findTxsMinedAtOrBefore(blockNumber); - // Step 2: Collect txs for archiving (before deletion) + // Step 2: Collect mined txs for archiving (before deletion) const txsToArchive: Tx[] = []; if (this.#archive.isEnabled()) { - for (const txHashStr of txsToFinalize) { + for (const txHashStr of minedTxsToFinalize) { const buffer = await this.#txsDB.getAsync(txHashStr); if (buffer) { txsToArchive.push(Tx.fromBuffer(buffer)); @@ -475,15 +493,20 @@ export class TxPoolV2Impl { } } - // Step 3: Delete from active pool - await this.#deleteTxsBatch(txsToFinalize); + // Step 3: Delete mined txs from active pool + await this.#deleteTxsBatch(minedTxsToFinalize); + + // Step 4: Finalize soft-deleted txs + await this.#deletedPool.finalizeBlock(blockNumber); - // Step 4: Archive + // Step 5: Archive mined txs if (txsToArchive.length > 0) { await this.#archive.archiveTxs(txsToArchive); } - this.#log.info(`Finalized ${txsToFinalize.length} txs from blocks up to ${blockNumber}`); + if (minedTxsToFinalize.length > 0) { + this.#log.info(`Finalized ${minedTxsToFinalize.length} mined txs from blocks up to ${blockNumber}`); + } } // === Query Methods === @@ -503,15 +526,23 @@ export class TxPoolV2Impl { } hasTxs(txHashes: TxHash[]): boolean[] { - return txHashes.map(h => this.#indices.has(h.toString())); + return txHashes.map(h => { + const hashStr = h.toString(); + return this.#indices.has(hashStr) || this.#deletedPool.isSoftDeleted(hashStr); + }); } getTxStatus(txHash: TxHash): TxState | undefined { - const meta = this.#indices.getMetadata(txHash.toString()); - if (!meta) { - return undefined; + const txHashStr = txHash.toString(); + const meta = this.#indices.getMetadata(txHashStr); + if (meta) { + return this.#indices.getTxState(meta); + } + // Check if soft-deleted + if (this.#deletedPool.isSoftDeleted(txHashStr)) { + return 'deleted'; } - return this.#indices.getTxState(meta); + return undefined; } getPendingTxHashes(): TxHash[] { @@ -624,10 +655,15 @@ export class TxPoolV2Impl { * Deletes a transaction from both indices and DB. * Emits onTxsRemoved callback immediately after DB delete. */ + /** + * Deletes a transaction from the pool. + * Delegates to DeletedPool which decides soft vs hard delete based on whether + * the tx is from a pruned block. + */ async #deleteTx(txHashStr: string): Promise { this.#indices.remove(txHashStr); - await this.#txsDB.delete(txHashStr); this.#callbacks.onTxsRemoved([txHashStr]); + await this.#deletedPool.deleteTx(txHashStr); } /** Deletes a batch of transactions, emitting callbacks individually for each. */ @@ -641,64 +677,36 @@ export class TxPoolV2Impl { // PRIVATE HELPERS - Validation & Conflict Resolution // ============================================================================ - /** Validates a single transaction, returning true if valid */ - async #validateTx(tx: Tx, context?: string): Promise { - const result = await this.#pendingTxValidator.validateTx(tx); + /** Validates transaction metadata, returning true if valid */ + async #validateMeta(meta: TxMetaData, validator?: TxValidator, context?: string): Promise { + const txValidator = validator ?? (await this.#createTxValidator()); + const result = await txValidator.validateTx(meta); if (result.result !== 'valid') { const contextStr = context ? ` ${context}` : ''; - this.#log.info(`Tx ${tx.getTxHash()}${contextStr} failed validation: ${result.reason?.join(', ')}`); + this.#log.info(`Tx ${meta.txHash}${contextStr} failed validation: ${result.reason?.join(', ')}`); return false; } return true; } - /** Loads transactions from DB, returning loaded txs and missing hashes */ - async #loadTxsFromDb(metas: TxMetaData[]): Promise<{ loaded: { tx: Tx; meta: TxMetaData }[]; missing: string[] }> { - const loaded: { tx: Tx; meta: TxMetaData }[] = []; - const missing: string[] = []; - - for (const meta of metas) { - const buffer = await this.#txsDB.getAsync(meta.txHash); - if (!buffer) { - this.#log.warn(`Tx ${meta.txHash} not found in DB`); - missing.push(meta.txHash); - continue; - } - loaded.push({ tx: Tx.fromBuffer(buffer), meta }); - } - - return { loaded, missing }; - } - - /** Validates a batch of transactions, returning valid and invalid groups */ - async #validateTxBatch( - txs: { tx: Tx; meta: TxMetaData }[], + /** Validates metadata directly */ + async #revalidateMetadata( + metas: TxMetaData[], context?: string, ): Promise<{ valid: TxMetaData[]; invalid: string[] }> { const valid: TxMetaData[] = []; const invalid: string[] = []; - - for (const { tx, meta } of txs) { - if (await this.#validateTx(tx, context)) { + const validator = await this.#createTxValidator(); + for (const meta of metas) { + if (await this.#validateMeta(meta, validator, context)) { valid.push(meta); } else { invalid.push(meta.txHash); } } - return { valid, invalid }; } - /** Loads transactions from DB and validates them */ - async #loadAndValidateTxs( - metas: TxMetaData[], - context?: string, - ): Promise<{ valid: TxMetaData[]; invalid: string[] }> { - const { loaded, missing } = await this.#loadTxsFromDb(metas); - const { valid, invalid } = await this.#validateTxBatch(loaded, context); - return { valid, invalid: [...missing, ...invalid] }; - } - /** * Resolves nullifier conflicts between incoming txs and existing pending txs. * Modifies the pending indices during iteration to maintain consistent state @@ -768,6 +776,11 @@ export class TxPoolV2Impl { const errors: string[] = []; for await (const [txHashStr, buffer] of this.#txsDB.entriesAsync()) { + // Skip soft-deleted transactions - they stay in DB but not in indices + if (this.#deletedPool.isSoftDeleted(txHashStr)) { + continue; + } + try { const tx = Tx.fromBuffer(buffer); const meta = await buildTxMetaData(tx); diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/aggregate_tx_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/aggregate_tx_validator.ts index 53aa9e9bcb26..c7f9ef2b5d24 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/aggregate_tx_validator.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/aggregate_tx_validator.ts @@ -1,6 +1,6 @@ -import type { ProcessedTx, Tx, TxValidationResult, TxValidator } from '@aztec/stdlib/tx'; +import type { TxValidationResult, TxValidator } from '@aztec/stdlib/tx'; -export class AggregateTxValidator implements TxValidator { +export class AggregateTxValidator implements TxValidator { #validators: TxValidator[]; constructor(...validators: TxValidator[]) { if (validators.length === 0) { diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/block_header_validator.test.ts b/yarn-project/p2p/src/msg_validators/tx_validator/block_header_validator.test.ts index 9980f958dffe..09b468833684 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/block_header_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/block_header_validator.test.ts @@ -1,14 +1,14 @@ import { BlockNumber } from '@aztec/foundation/branded-types'; import { BlockHash } from '@aztec/stdlib/block'; import { mockTxForRollup } from '@aztec/stdlib/testing'; -import { type AnyTx, TX_ERROR_BLOCK_HEADER, type TxValidationResult } from '@aztec/stdlib/tx'; +import { TX_ERROR_BLOCK_HEADER, type Tx, type TxValidationResult } from '@aztec/stdlib/tx'; import { type MockProxy, mock, mockFn } from 'jest-mock-extended'; import { type ArchiveSource, BlockHeaderTxValidator } from './block_header_validator.js'; describe('BlockHeaderTxValidator', () => { - let txValidator: BlockHeaderTxValidator; + let txValidator: BlockHeaderTxValidator; let archiveSource: MockProxy; beforeEach(() => { diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/block_header_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/block_header_validator.ts index d9cec201eadc..ae696a227696 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/block_header_validator.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/block_header_validator.ts @@ -1,12 +1,24 @@ import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; import type { BlockHash } from '@aztec/stdlib/block'; -import { type AnyTx, TX_ERROR_BLOCK_HEADER, type TxValidationResult, type TxValidator } from '@aztec/stdlib/tx'; +import { TX_ERROR_BLOCK_HEADER, type TxValidationResult, type TxValidator } from '@aztec/stdlib/tx'; export interface ArchiveSource { getArchiveIndices: (archives: BlockHash[]) => Promise<(bigint | undefined)[]>; } -export class BlockHeaderTxValidator implements TxValidator { +/** Structural interface for block header validation. */ +export interface HasBlockHeaderData { + txHash: { toString(): string }; + data: { + constants: { + anchorBlockHeader: { + hash(): Promise; + }; + }; + }; +} + +export class BlockHeaderTxValidator implements TxValidator { #log: Logger; #archiveSource: ArchiveSource; @@ -18,7 +30,7 @@ export class BlockHeaderTxValidator implements TxValidator { async validateTx(tx: T): Promise { const [index] = await this.#archiveSource.getArchiveIndices([await tx.data.constants.anchorBlockHeader.hash()]); if (index === undefined) { - this.#log.verbose(`Rejecting tx ${'txHash' in tx ? tx.txHash : tx.hash} for referencing an unknown block header`); + this.#log.verbose(`Rejecting tx ${tx.txHash} for referencing an unknown block header`); return { result: 'invalid', reason: [TX_ERROR_BLOCK_HEADER] }; } return { result: 'valid' }; diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/double_spend_validator.test.ts b/yarn-project/p2p/src/msg_validators/tx_validator/double_spend_validator.test.ts index af64e2b817d1..cc8e9635a924 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/double_spend_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/double_spend_validator.test.ts @@ -1,19 +1,19 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { mockTx, mockTxForRollup } from '@aztec/stdlib/testing'; -import { type AnyTx, TX_ERROR_DUPLICATE_NULLIFIER_IN_TX, TX_ERROR_EXISTING_NULLIFIER } from '@aztec/stdlib/tx'; +import { TX_ERROR_DUPLICATE_NULLIFIER_IN_TX, TX_ERROR_EXISTING_NULLIFIER, type Tx } from '@aztec/stdlib/tx'; import { type MockProxy, mock } from 'jest-mock-extended'; import { DoubleSpendTxValidator, type NullifierSource } from './double_spend_validator.js'; describe('DoubleSpendTxValidator', () => { - let txValidator: DoubleSpendTxValidator; + let txValidator: DoubleSpendTxValidator; let nullifierSource: MockProxy; - const expectValid = async (tx: AnyTx) => { + const expectValid = async (tx: Tx) => { await expect(txValidator.validateTx(tx)).resolves.toEqual({ result: 'valid' }); }; - const expectInvalid = async (tx: AnyTx, reason: string) => { + const expectInvalid = async (tx: Tx, reason: string) => { await expect(txValidator.validateTx(tx)).resolves.toEqual({ result: 'invalid', reason: [reason] }); }; diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/double_spend_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/double_spend_validator.ts index ec6a21cdd34a..011757d1f442 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/double_spend_validator.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/double_spend_validator.ts @@ -1,9 +1,8 @@ +import type { Fr } from '@aztec/foundation/curves/bn254'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; import { - type AnyTx, TX_ERROR_DUPLICATE_NULLIFIER_IN_TX, TX_ERROR_EXISTING_NULLIFIER, - Tx, type TxValidationResult, type TxValidator, } from '@aztec/stdlib/tx'; @@ -12,7 +11,13 @@ export interface NullifierSource { nullifiersExist: (nullifiers: Buffer[]) => Promise; } -export class DoubleSpendTxValidator implements TxValidator { +/** Structural interface for double-spend validation. */ +export interface HasNullifierData { + txHash: { toString(): string }; + data: { getNonEmptyNullifiers(): Fr[] }; +} + +export class DoubleSpendTxValidator implements TxValidator { #log: Logger; #nullifierSource: NullifierSource; @@ -22,17 +27,17 @@ export class DoubleSpendTxValidator implements TxValidator { } async validateTx(tx: T): Promise { - const nullifiers = tx instanceof Tx ? tx.data.getNonEmptyNullifiers() : tx.txEffect.nullifiers; + const nullifiers = tx.data.getNonEmptyNullifiers(); // Ditch this tx if it has repeated nullifiers const uniqueNullifiers = new Set(nullifiers.map(n => n.toBigInt())); if (uniqueNullifiers.size !== nullifiers.length) { - this.#log.verbose(`Rejecting tx ${'txHash' in tx ? tx.txHash : tx.hash} for emitting duplicate nullifiers`); + this.#log.verbose(`Rejecting tx ${tx.txHash} for emitting duplicate nullifiers`); return { result: 'invalid', reason: [TX_ERROR_DUPLICATE_NULLIFIER_IN_TX] }; } if ((await this.#nullifierSource.nullifiersExist(nullifiers.map(n => n.toBuffer()))).some(Boolean)) { - this.#log.verbose(`Rejecting tx ${'txHash' in tx ? tx.txHash : tx.hash} for repeating a nullifier`); + this.#log.verbose(`Rejecting tx ${tx.txHash} for repeating a nullifier`); return { result: 'invalid', reason: [TX_ERROR_EXISTING_NULLIFIER] }; } diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/timestamp_validator.test.ts b/yarn-project/p2p/src/msg_validators/tx_validator/timestamp_validator.test.ts index c9d4d9fc9871..bbce8df574e9 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/timestamp_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/timestamp_validator.test.ts @@ -1,14 +1,13 @@ import { BlockNumber } from '@aztec/foundation/branded-types'; import { mockTx, mockTxForRollup } from '@aztec/stdlib/testing'; -import type { AnyTx, Tx } from '@aztec/stdlib/tx'; -import { TX_ERROR_INVALID_INCLUDE_BY_TIMESTAMP } from '@aztec/stdlib/tx'; +import { TX_ERROR_INVALID_INCLUDE_BY_TIMESTAMP, type Tx } from '@aztec/stdlib/tx'; import { TimestampTxValidator } from './timestamp_validator.js'; describe('TimestampTxValidator', () => { let timestamp: bigint; let seed = 1; - let validator: TimestampTxValidator; + let validator: TimestampTxValidator; const setValidatorAtBlock = (blockNumber: BlockNumber) => { timestamp = 10n; diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/timestamp_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/timestamp_validator.ts index e343cbc844df..02b24aae0f96 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/timestamp_validator.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/timestamp_validator.ts @@ -1,15 +1,24 @@ -import { BlockNumber } from '@aztec/foundation/branded-types'; +import type { BlockNumber } from '@aztec/foundation/branded-types'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; -import { - type AnyTx, - TX_ERROR_INVALID_INCLUDE_BY_TIMESTAMP, - type TxValidationResult, - type TxValidator, - getTxHash, -} from '@aztec/stdlib/tx'; +import { TX_ERROR_INVALID_INCLUDE_BY_TIMESTAMP, type TxValidationResult, type TxValidator } from '@aztec/stdlib/tx'; import type { UInt64 } from '@aztec/stdlib/types'; -export class TimestampTxValidator implements TxValidator { +/** Structural interface for timestamp validation. */ +export interface HasTimestampData { + txHash: { toString(): string }; + data: { + includeByTimestamp: bigint; + constants: { + anchorBlockHeader: { + globalVariables: { + blockNumber: BlockNumber; + }; + }; + }; + }; +} + +export class TimestampTxValidator implements TxValidator { #log: Logger; constructor( @@ -38,11 +47,7 @@ export class TimestampTxValidator implements TxValidator { ); } this.#log.verbose( - `Rejecting tx ${getTxHash( - tx, - )} for low expiration timestamp. Tx expiration timestamp: ${includeByTimestamp}, timestamp: ${ - this.values.timestamp - }.`, + `Rejecting tx ${tx.txHash} for low expiration timestamp. Tx expiration timestamp: ${includeByTimestamp}, timestamp: ${this.values.timestamp}.`, ); return Promise.resolve({ result: 'invalid', reason: [TX_ERROR_INVALID_INCLUDE_BY_TIMESTAMP] }); } else { diff --git a/yarn-project/p2p/src/services/encoding.test.ts b/yarn-project/p2p/src/services/encoding.test.ts index 628eea021d43..2be4857d29ce 100644 --- a/yarn-project/p2p/src/services/encoding.test.ts +++ b/yarn-project/p2p/src/services/encoding.test.ts @@ -356,6 +356,33 @@ describe('SnappyTransform', () => { 'exceeds maximum allowed size of 200kb', ); }); + + it('should use maxSizeKbOverride when provided, ignoring topic and default limits', () => { + const transform = new SnappyTransform(); + + // Data at 50kb should pass with 100kb override + const data = Buffer.alloc(50 * 1024, 'a'); + const compressed = compressSync(data); + expect(() => transform.inboundTransformData(compressed, undefined, 100)).not.toThrow(); + + // Data at 150kb should fail with 100kb override + const dataLarge = Buffer.alloc(150 * 1024, 'a'); + const compressedLarge = compressSync(dataLarge); + expect(() => transform.inboundTransformData(compressedLarge, undefined, 100)).toThrow( + 'exceeds maximum allowed size of 100kb', + ); + }); + + it('should prefer maxSizeKbOverride over topic-specific limit', () => { + const transform = new SnappyTransform(); + + // TX topic has a 512kb limit, but override is 10kb + const data = Buffer.alloc(50 * 1024, 'a'); // 50kb - within tx limit but over override + const compressed = compressSync(data); + expect(() => transform.inboundTransformData(compressed, TopicType.tx, 10)).toThrow( + 'exceeds maximum allowed size of 10kb', + ); + }); }); describe('exact boundary conditions', () => { diff --git a/yarn-project/p2p/src/services/encoding.ts b/yarn-project/p2p/src/services/encoding.ts index e44998dd9197..c2a5e6dc5a87 100644 --- a/yarn-project/p2p/src/services/encoding.ts +++ b/yarn-project/p2p/src/services/encoding.ts @@ -78,11 +78,11 @@ export class SnappyTransform implements DataTransform { return this.inboundTransformData(Buffer.from(data), topic); } - public inboundTransformData(data: Buffer, topic?: TopicType): Buffer { + public inboundTransformData(data: Buffer, topic?: TopicType, maxSizeKbOverride?: number): Buffer { if (data.length === 0) { return data; } - const maxSizeKb = this.maxSizesKb[topic!] ?? this.defaultMaxSizeKb; + const maxSizeKb = maxSizeKbOverride ?? this.maxSizesKb[topic!] ?? this.defaultMaxSizeKb; const { decompressedSize } = readSnappyPreamble(data); if (decompressedSize > maxSizeKb * 1024) { this.logger.warn(`Decompressed size ${decompressedSize} exceeds maximum allowed size of ${maxSizeKb}kb`); diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts index 34603f63d97e..c11b78877d81 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts @@ -15,6 +15,7 @@ import { makeBlockProposal, makeCheckpointHeader, makeCheckpointProposal, + mockTx, } from '@aztec/stdlib/testing'; import { type Tx, TxArray, TxHashArray, type TxValidator } from '@aztec/stdlib/tx'; import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-client'; @@ -32,7 +33,7 @@ import { MAX_CHECKPOINT_PROPOSALS_PER_SLOT, } from '../../mem_pools/attestation_pool/attestation_pool.js'; import type { MemPools } from '../../mem_pools/interface.js'; -import type { TxPool } from '../../mem_pools/tx_pool/tx_pool.js'; +import type { TxPoolV2 } from '../../mem_pools/tx_pool_v2/interfaces.js'; import type { PubSubLibp2p } from '../../util.js'; import type { PeerManagerInterface } from '../peer-manager/interface.js'; import type { ReqRespInterface } from '../reqresp/interface.js'; @@ -126,6 +127,97 @@ describe('LibP2PService', () => { }); }); + describe('handleGossipedTx - propagation based on pool acceptance', () => { + let txService: TestLibP2PService; + let txPeerManager: MockProxy; + let txPeerId: MockProxy; + let txReportSpy: jest.Mock; + let txPool: MockProxy; + + beforeEach(() => { + txPeerManager = mock(); + txPeerId = mock({ + toString: () => MOCK_PEER_ID, + }); + txReportSpy = jest.fn(); + txPool = mock(); + + const txNode = mock(); + txNode.services = { + pubsub: { + reportMessageValidationResult: txReportSpy, + }, + } as any; + + txService = createTestLibP2PService({ + peerManager: txPeerManager, + node: txNode, + txPool, + }); + // Make validatePropagatedTx pass by default + txService.validatePropagatedTxMock.mockResolvedValue(true); + }); + + it('should propagate (Accept) when pool accepts the transaction', async () => { + const tx = await mockTx(); + const txHash = tx.getTxHash(); + + txPool.addPendingTxs.mockResolvedValue({ + accepted: [txHash], + ignored: [], + rejected: [], + }); + + await txService.handleGossipedTx(tx.toBuffer(), 'test-msg-id', txPeerId); + + expect(txReportSpy).toHaveBeenCalledWith('test-msg-id', MOCK_PEER_ID, TopicValidatorResult.Accept); + expect(txPool.addPendingTxs).toHaveBeenCalled(); + }); + + it('should NOT propagate (Ignore) when pool ignores the transaction', async () => { + const tx = await mockTx(); + const txHash = tx.getTxHash(); + + txPool.addPendingTxs.mockResolvedValue({ + accepted: [], + ignored: [txHash], + rejected: [], + }); + + await txService.handleGossipedTx(tx.toBuffer(), 'test-msg-id', txPeerId); + + expect(txReportSpy).toHaveBeenCalledWith('test-msg-id', MOCK_PEER_ID, TopicValidatorResult.Ignore); + expect(txPool.addPendingTxs).toHaveBeenCalled(); + }); + + it('should NOT propagate (Reject) when pool rejects the transaction', async () => { + const tx = await mockTx(); + const txHash = tx.getTxHash(); + + txPool.addPendingTxs.mockResolvedValue({ + accepted: [], + ignored: [], + rejected: [txHash], + }); + + await txService.handleGossipedTx(tx.toBuffer(), 'test-msg-id', txPeerId); + + expect(txReportSpy).toHaveBeenCalledWith('test-msg-id', MOCK_PEER_ID, TopicValidatorResult.Reject); + expect(txPool.addPendingTxs).toHaveBeenCalled(); + }); + + it('should NOT propagate (Reject) when gossip validation fails', async () => { + const tx = await mockTx(); + + txService.validatePropagatedTxMock.mockResolvedValue(false); + + await txService.handleGossipedTx(tx.toBuffer(), 'test-msg-id', txPeerId); + + expect(txReportSpy).toHaveBeenCalledWith('test-msg-id', MOCK_PEER_ID, TopicValidatorResult.Reject); + expect(txPool.addPendingTxs).not.toHaveBeenCalled(); + }); + }); + describe('validateRequestedBlock', () => { it('should return false and penalize on number mismatch', async () => { const requested = new Fr(10); @@ -376,7 +468,7 @@ describe('LibP2PService', () => { describe('processBlockFromPeer', () => { let attestationPool: AttestationPool; - let mockTxPool: MockProxy; + let mockTxPool: MockProxy; let mockEpochCache: MockProxy; let signer: Secp256k1Signer; let blockReceivedCallback: jest.Mock; @@ -388,8 +480,8 @@ describe('LibP2PService', () => { beforeEach(() => { signer = Secp256k1Signer.random(); attestationPool = new AttestationPool(openTmpStore(true)); - mockTxPool = mock(); - mockTxPool.markTxsAsNonEvictable.mockResolvedValue(); + mockTxPool = mock(); + mockTxPool.protectTxs.mockResolvedValue([]); mockEpochCache = mock(); mockEpochCache.getCurrentAndNextSlot.mockReturnValue({ currentSlot, nextSlot }); @@ -429,7 +521,7 @@ describe('LibP2PService', () => { expect(blockReceivedCallback).toHaveBeenCalledWith(expect.any(Object), mockPeerId); // Verify txs were marked as non-evictable - expect(mockTxPool.markTxsAsNonEvictable).toHaveBeenCalledTimes(1); + expect(mockTxPool.protectTxs).toHaveBeenCalledTimes(1); // Verify message was accepted expect(reportMessageValidationResultSpy).toHaveBeenCalledWith('msg-1', MOCK_PEER_ID, TopicValidatorResult.Accept); @@ -614,7 +706,7 @@ describe('LibP2PService', () => { describe('handleGossipedCheckpointProposal', () => { let attestationPool: AttestationPool; - let mockTxPool: MockProxy; + let mockTxPool: MockProxy; let mockEpochCache: MockProxy; let signer: Secp256k1Signer; let blockReceivedCallback: jest.Mock; @@ -627,8 +719,8 @@ describe('LibP2PService', () => { beforeEach(() => { signer = Secp256k1Signer.random(); attestationPool = new AttestationPool(openTmpStore(true)); - mockTxPool = mock(); - mockTxPool.markTxsAsNonEvictable.mockResolvedValue(); + mockTxPool = mock(); + mockTxPool.protectTxs.mockResolvedValue([]); mockEpochCache = mock(); mockEpochCache.getCurrentAndNextSlot.mockReturnValue({ currentSlot, nextSlot }); @@ -731,7 +823,7 @@ describe('LibP2PService', () => { expect(checkpointReceivedCallback).toHaveBeenCalledTimes(1); // Verify txs were marked as non-evictable (for the lastBlock) - expect(mockTxPool.markTxsAsNonEvictable).toHaveBeenCalledTimes(1); + expect(mockTxPool.protectTxs).toHaveBeenCalledTimes(1); // Verify both were stored in attestation pool const storedCheckpoint = await attestationPool.getCheckpointProposal(proposal.archive.toString()); @@ -761,7 +853,7 @@ describe('LibP2PService', () => { blockReceivedCallback.mockClear(); checkpointReceivedCallback.mockClear(); reportMessageValidationResultSpy.mockClear(); - mockTxPool.markTxsAsNonEvictable.mockClear(); + mockTxPool.protectTxs.mockClear(); mockPeerManager.penalizePeer.mockClear(); // Create checkpoint with lastBlock that would exceed the cap @@ -796,7 +888,7 @@ describe('LibP2PService', () => { expect(storedBlock).toBeDefined(); // Txs were marked as non-evictable since the block was processed - expect(mockTxPool.markTxsAsNonEvictable).toHaveBeenCalled(); + expect(mockTxPool.protectTxs).toHaveBeenCalled(); }); it('checkpoint rejected when lastBlock is equivocated', async () => { @@ -872,7 +964,7 @@ interface CreateTestLibP2PServiceOptions { node: MockProxy; archiver?: MockProxy; attestationPool?: AttestationPool; - txPool?: MockProxy; + txPool?: MockProxy; epochCache?: MockProxy; } @@ -884,6 +976,9 @@ class TestLibP2PService extends LibP2PService { /** Mocked validateRequestedTx for testing. */ public validateRequestedTxMock: jest.Mock; + /** Mocked validatePropagatedTx for testing gossip tx handling. */ + public validatePropagatedTxMock: jest.Mock<(tx: Tx, peerId: PeerId) => Promise>; + /** Stub validator returned by createRequestedTxValidator. */ private stubValidator: TxValidator; @@ -937,6 +1032,7 @@ class TestLibP2PService extends LibP2PService { this.testEpochCache = epochCache; this.validateRequestedTxMock = jest.fn(() => Promise.resolve()); + this.validatePropagatedTxMock = jest.fn(() => Promise.resolve(true)); this.stubValidator = { validateTx: () => Promise.resolve({ result: 'valid' as const }), }; @@ -947,6 +1043,16 @@ class TestLibP2PService extends LibP2PService { return super.handleNewGossipMessage(msg, msgId, source); } + /** Exposes the protected handleGossipedTx for testing. */ + public override handleGossipedTx(payloadData: Buffer, msgId: string, source: PeerId): Promise { + return super.handleGossipedTx(payloadData, msgId, source); + } + + /** Override to use the mock for validatePropagatedTx. */ + protected override validatePropagatedTx(tx: Tx, peerId: PeerId): Promise { + return this.validatePropagatedTxMock(tx, peerId); + } + /** Exposes the protected validateRequestedBlock for testing. */ public override validateRequestedBlock(requested: Fr, response: L2Block, peerId: PeerId): Promise { return super.validateRequestedBlock(requested, response, peerId); @@ -999,7 +1105,7 @@ function createTestLibP2PService(options: CreateTestLibP2PServiceOptions): TestL node, archiver = mock(), attestationPool = new AttestationPool(openTmpStore(true)), - txPool = mock(), + txPool = mock(), epochCache = mock(), } = options; @@ -1018,7 +1124,7 @@ function createTestLibP2PServiceWithPools( mockPeerManager: MockProxy, reportMessageValidationResultSpy: jest.Mock, attestationPool: AttestationPool, - mockTxPool: MockProxy, + mockTxPool: MockProxy, mockEpochCache: MockProxy, ): TestLibP2PService { const mockNode = mock(); diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index 66279ddb0545..8988db51902e 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -1,6 +1,5 @@ import type { EpochCacheInterface } from '@aztec/epoch-cache'; import { BlockNumber, type SlotNumber } from '@aztec/foundation/branded-types'; -import { randomInt } from '@aztec/foundation/crypto/random'; import { Fr } from '@aztec/foundation/curves/bn254'; import { type Logger, createLibp2pComponentLogger, createLogger } from '@aztec/foundation/log'; import { RunningPromise } from '@aztec/foundation/running-promise'; @@ -903,20 +902,33 @@ export class LibP2PService extends const validationFunc: () => Promise> = async () => { const tx = Tx.fromBuffer(payloadData); const isValid = await this.validatePropagatedTx(tx, source); - const exists = isValid && (await this.mempools.txPool.hasTx(tx.getTxHash())); + if (!isValid) { + this.logger.trace(`Rejecting invalid propagated tx`, { + [Attributes.P2P_ID]: source.toString(), + }); + return { result: TopicValidatorResult.Reject }; + } + + // Propagate only on pool acceptance + const txHash = tx.getTxHash(); + const addResult = await this.mempools.txPool.addPendingTxs([tx], { source: 'gossip' }); + + const wasAccepted = addResult.accepted.some(h => h.equals(txHash)); + const wasIgnored = addResult.ignored.some(h => h.equals(txHash)); this.logger.trace(`Validate propagated tx`, { isValid, - exists, + wasAccepted, + wasIgnored, [Attributes.P2P_ID]: source.toString(), }); - if (!isValid) { - return { result: TopicValidatorResult.Reject }; - } else if (exists) { + if (wasAccepted) { + return { result: TopicValidatorResult.Accept, obj: tx }; + } else if (wasIgnored) { return { result: TopicValidatorResult.Ignore, obj: tx }; } else { - return { result: TopicValidatorResult.Accept, obj: tx }; + return { result: TopicValidatorResult.Reject }; } }; @@ -925,6 +937,7 @@ export class LibP2PService extends return; } + // Tx was accepted into pool and will be propagated - just log and record metrics const txHash = tx.getTxHash(); const txHashString = txHash.toString(); this.logger.verbose(`Received tx ${txHashString} from external peer ${source.toString()} via gossip`, { @@ -932,13 +945,7 @@ export class LibP2PService extends txHash: txHashString, }); - if (this.config.dropTransactions && randomInt(1000) < this.config.dropTransactionsProbability * 1000) { - this.logger.warn(`Intentionally dropping tx ${txHashString} (probability rule)`); - return; - } - this.instrumentation.incrementTxReceived(1); - await this.mempools.txPool.addTxs([tx]); } /** @@ -1146,8 +1153,8 @@ export class LibP2PService extends ...block.toBlockInfo(), }); - // Mark the txs in this proposal as non-evictable - await this.mempools.txPool.markTxsAsNonEvictable(block.txHashes); + // Mark the txs in this proposal as protected + await this.mempools.txPool.protectTxs(block.txHashes, block.blockHeader); // Call the block received callback to validate the proposal. // Note: Validators do NOT attest to individual blocks, only to checkpoint proposals. @@ -1534,7 +1541,7 @@ export class LibP2PService extends @trackSpan('Libp2pService.validatePropagatedTx', tx => ({ [Attributes.TX_HASH]: tx.getTxHash().toString(), })) - private async validatePropagatedTx(tx: Tx, peerId: PeerId): Promise { + protected async validatePropagatedTx(tx: Tx, peerId: PeerId): Promise { const currentBlockNumber = await this.archiver.getBlockNumber(); // We accept transactions if they are not expired by the next slot (checked based on the IncludeByTimestamp field) diff --git a/yarn-project/p2p/src/services/reqresp/interface.ts b/yarn-project/p2p/src/services/reqresp/interface.ts index a423ffe3382d..e1eb6c6a6fd3 100644 --- a/yarn-project/p2p/src/services/reqresp/interface.ts +++ b/yarn-project/p2p/src/services/reqresp/interface.ts @@ -1,5 +1,6 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { L2Block } from '@aztec/stdlib/block'; +import { MAX_L2_BLOCK_SIZE_KB } from '@aztec/stdlib/p2p'; import { TxArray, TxHashArray } from '@aztec/stdlib/tx'; import type { PeerId } from '@libp2p/interface'; @@ -7,8 +8,13 @@ import type { PeerId } from '@libp2p/interface'; import type { P2PReqRespConfig } from './config.js'; import type { ConnectionSampler } from './connection-sampler/connection_sampler.js'; import { AuthRequest, AuthResponse } from './protocols/auth.js'; -import { BlockTxsRequest, BlockTxsResponse } from './protocols/block_txs/block_txs_reqresp.js'; +import { + BlockTxsRequest, + BlockTxsResponse, + calculateBlockTxsResponseSize, +} from './protocols/block_txs/block_txs_reqresp.js'; import { StatusMessage } from './protocols/status.js'; +import { calculateTxResponseSize } from './protocols/tx.js'; import type { ReqRespStatus } from './status.js'; /* @@ -211,6 +217,25 @@ export const subProtocolMap = { }, }; +/** + * Type for a function that calculates the expected response size in KB for a given request. + */ +export type ExpectedResponseSizeCalculator = (requestBuffer: Buffer) => number; + +/** + * Map of sub-protocols to their expected response size calculators. + * These are used to validate that responses don't exceed expected sizes based on request parameters. + */ +export const subProtocolSizeCalculators: Record = { + [ReqRespSubProtocol.TX]: calculateTxResponseSize, + [ReqRespSubProtocol.BLOCK_TXS]: calculateBlockTxsResponseSize, + [ReqRespSubProtocol.BLOCK]: () => MAX_L2_BLOCK_SIZE_KB, + [ReqRespSubProtocol.STATUS]: () => 1, + [ReqRespSubProtocol.PING]: () => 1, + [ReqRespSubProtocol.AUTH]: () => 1, + [ReqRespSubProtocol.GOODBYE]: () => 1, // No response expected, but provide minimal limit +}; + export interface ReqRespInterface { start( subProtocolHandlers: Partial, diff --git a/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs.test.ts b/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs.test.ts index e898b947b2c5..7bab3416ad9e 100644 --- a/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs.test.ts +++ b/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs.test.ts @@ -1,14 +1,14 @@ import { BlockNumber } from '@aztec/foundation/branded-types'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { BlockProposal } from '@aztec/stdlib/p2p'; +import { BlockProposal, MAX_TX_SIZE_KB } from '@aztec/stdlib/p2p'; import { makeBlockHeader, makeBlockProposal, mockTx } from '@aztec/stdlib/testing'; import { TxArray, TxHash, TxHashArray } from '@aztec/stdlib/tx'; import { describe, expect, it } from '@jest/globals'; import { BitVector } from './bitvector.js'; -import { BlockTxsRequest, BlockTxsResponse } from './block_txs_reqresp.js'; +import { BlockTxsRequest, BlockTxsResponse, calculateBlockTxsResponseSize } from './block_txs_reqresp.js'; describe('BlockTxRequest', () => { // eslint-disable-next-line require-await @@ -176,3 +176,49 @@ describe('BlockTxResponse', () => { expect(deserialized.txIndices.getTrueIndices()).toEqual([]); }); }); + +describe('calculateBlockTxsResponseSize', () => { + it('should return correct size based on requested tx indices', () => { + const archiveRoot = Fr.random(); + const txIndices = BitVector.init(16, [0, 5, 10, 15]); // 4 txs requested + const request = new BlockTxsRequest(archiveRoot, new TxHashArray(), txIndices); + const buffer = request.toBuffer(); + + expect(calculateBlockTxsResponseSize(buffer)).toBe(4 * MAX_TX_SIZE_KB + 1); + }); + + it('should return correct size for a single requested tx', () => { + const archiveRoot = Fr.random(); + const txIndices = BitVector.init(8, [3]); // 1 tx requested + const request = new BlockTxsRequest(archiveRoot, new TxHashArray(), txIndices); + const buffer = request.toBuffer(); + + expect(calculateBlockTxsResponseSize(buffer)).toBe(MAX_TX_SIZE_KB + 1); + }); + + it('should return overhead-only for request with no indices set', () => { + const archiveRoot = Fr.random(); + const txIndices = BitVector.init(8, []); // 0 txs requested + const request = new BlockTxsRequest(archiveRoot, new TxHashArray(), txIndices); + const buffer = request.toBuffer(); + + expect(calculateBlockTxsResponseSize(buffer)).toBe(1); // just overhead + }); + + it('should return correct size for request with all indices set', () => { + const count = 32; + const allIndices = Array.from({ length: count }, (_, i) => i); + const archiveRoot = Fr.random(); + const txIndices = BitVector.init(count, allIndices); + const request = new BlockTxsRequest(archiveRoot, new TxHashArray(), txIndices); + const buffer = request.toBuffer(); + + expect(calculateBlockTxsResponseSize(buffer)).toBe(count * MAX_TX_SIZE_KB + 1); + }); + + it('should fall back to single tx size for garbage buffer', () => { + const garbage = Buffer.from('not a valid buffer'); + + expect(calculateBlockTxsResponseSize(garbage)).toBe(MAX_TX_SIZE_KB + 1); + }); +}); diff --git a/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.test.ts b/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.test.ts index ec802f451e56..b43fbd537613 100644 --- a/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.test.ts +++ b/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.test.ts @@ -9,7 +9,8 @@ import { Tx, TxHash, TxHashArray } from '@aztec/stdlib/tx'; import type { PeerId } from '@libp2p/interface'; import { type MockProxy, mock } from 'jest-mock-extended'; -import type { AttestationPool, TxPool } from '../../../../mem_pools/index.js'; +import type { AttestationPool } from '../../../../mem_pools/index.js'; +import type { TxPoolV2 } from '../../../../mem_pools/tx_pool_v2/interfaces.js'; import { ReqRespStatus } from '../../status.js'; import { BitVector } from './bitvector.js'; import { reqRespBlockTxsHandler } from './block_txs_handler.js'; @@ -18,7 +19,7 @@ import { BlockTxsRequest, BlockTxsResponse } from './block_txs_reqresp.js'; describe('reqRespBlockTxsHandler', () => { let attestationPool: MockProxy; let archiver: MockProxy; - let txPool: MockProxy; + let txPool: MockProxy; let peerId: PeerId; const makeTx = (txHash?: TxHash) => Tx.random({ txHash }) as Tx; @@ -41,7 +42,7 @@ describe('reqRespBlockTxsHandler', () => { beforeEach(() => { attestationPool = mock(); archiver = mock(); - txPool = mock(); + txPool = mock(); peerId = mock(); attestationPool.getBlockProposal.mockResolvedValue(undefined); diff --git a/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.ts b/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.ts index 5fe6646b05bc..2ac1612547f9 100644 --- a/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.ts +++ b/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.ts @@ -5,7 +5,7 @@ import { TxArray } from '@aztec/stdlib/tx'; import type { PeerId } from '@libp2p/interface'; import type { AttestationPoolApi } from '../../../../mem_pools/attestation_pool/attestation_pool.js'; -import type { TxPool } from '../../../../mem_pools/index.js'; +import type { TxPoolV2 } from '../../../../mem_pools/tx_pool_v2/interfaces.js'; import type { ReqRespSubProtocolHandler } from '../../interface.js'; import { ReqRespStatus, ReqRespStatusError } from '../../status.js'; import { BitVector } from './bitvector.js'; @@ -21,7 +21,7 @@ import { BlockTxsRequest, BlockTxsResponse } from './block_txs_reqresp.js'; export function reqRespBlockTxsHandler( attestationPool: AttestationPoolApi, archiver: L2BlockSource, - txPool: TxPool, + txPool: TxPoolV2, ): ReqRespSubProtocolHandler { /** * Handler for block txs requests diff --git a/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_reqresp.ts b/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_reqresp.ts index 3bf696eb3001..4df100c08f84 100644 --- a/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_reqresp.ts +++ b/yarn-project/p2p/src/services/reqresp/protocols/block_txs/block_txs_reqresp.ts @@ -1,5 +1,6 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; +import { MAX_TX_SIZE_KB } from '@aztec/stdlib/p2p'; import { TxArray, type TxHash, TxHashArray } from '@aztec/stdlib/tx'; import { BitVector } from './bitvector.js'; @@ -125,3 +126,19 @@ export class BlockTxsResponse { return new BlockTxsResponse(Fr.ZERO, new TxArray(), BitVector.init(0, [])); } } + +/** + * Calculate the expected response size for a BLOCK_TXS request. + * @param requestBuffer - The serialized request buffer containing BlockTxsRequest + * @returns Expected response size in KB + */ +export function calculateBlockTxsResponseSize(requestBuffer: Buffer): number { + try { + const request = BlockTxsRequest.fromBuffer(requestBuffer); + const requestedTxCount = request.txIndices.getTrueIndices().length; + return requestedTxCount * MAX_TX_SIZE_KB + 1; // +1 KB overhead for serialization + } catch { + // If we can't parse the request, fall back to allowing a single transaction response + return MAX_TX_SIZE_KB + 1; + } +} diff --git a/yarn-project/p2p/src/services/reqresp/protocols/tx.test.ts b/yarn-project/p2p/src/services/reqresp/protocols/tx.test.ts new file mode 100644 index 000000000000..b7103bf2f395 --- /dev/null +++ b/yarn-project/p2p/src/services/reqresp/protocols/tx.test.ts @@ -0,0 +1,52 @@ +import { MAX_TX_SIZE_KB } from '@aztec/stdlib/p2p'; +import { TxHash, TxHashArray } from '@aztec/stdlib/tx'; + +import { describe, expect, it } from '@jest/globals'; + +import { calculateTxResponseSize } from './tx.js'; + +describe('calculateTxResponseSize', () => { + it('should return correct size for a single tx hash', () => { + const hashes = new TxHashArray(TxHash.random()); + const buffer = hashes.toBuffer(); + + expect(calculateTxResponseSize(buffer)).toBe(MAX_TX_SIZE_KB + 1); + }); + + it('should return correct size for multiple tx hashes', () => { + const hashes = new TxHashArray(TxHash.random(), TxHash.random(), TxHash.random()); + const buffer = hashes.toBuffer(); + + expect(calculateTxResponseSize(buffer)).toBe(3 * MAX_TX_SIZE_KB + 1); + }); + + it('should return correct size for 8 tx hashes (default batch size)', () => { + const hashes = new TxHashArray(...Array.from({ length: 8 }, () => TxHash.random())); + const buffer = hashes.toBuffer(); + + expect(calculateTxResponseSize(buffer)).toBe(8 * MAX_TX_SIZE_KB + 1); + }); + + it('should fall back to single tx size for a raw TxHash buffer (not TxHashArray)', () => { + // A raw TxHash (32 bytes) is not a valid TxHashArray serialization. + // TxHashArray.fromBuffer silently returns empty array on parse failure. + const rawHash = TxHash.random().toBuffer(); + + expect(calculateTxResponseSize(rawHash)).toBe(MAX_TX_SIZE_KB + 1); + }); + + it('should fall back to single tx size for garbage buffer', () => { + const garbage = Buffer.from('not a valid buffer'); + + expect(calculateTxResponseSize(garbage)).toBe(MAX_TX_SIZE_KB + 1); + }); + + it('should return at least single tx size for empty TxHashArray', () => { + const hashes = new TxHashArray(); + const buffer = hashes.toBuffer(); + + // Empty TxHashArray serializes to a valid buffer with length prefix 0 + // We expect at least 1 * MAX_TX_SIZE_KB + 1 + expect(calculateTxResponseSize(buffer)).toBe(MAX_TX_SIZE_KB + 1); + }); +}); diff --git a/yarn-project/p2p/src/services/reqresp/protocols/tx.ts b/yarn-project/p2p/src/services/reqresp/protocols/tx.ts index ab64b2b13979..f121cbf9d3f2 100644 --- a/yarn-project/p2p/src/services/reqresp/protocols/tx.ts +++ b/yarn-project/p2p/src/services/reqresp/protocols/tx.ts @@ -1,4 +1,5 @@ import { chunk } from '@aztec/foundation/collection'; +import { MAX_TX_SIZE_KB } from '@aztec/stdlib/p2p'; import { TxArray, TxHash, TxHashArray } from '@aztec/stdlib/tx'; import type { PeerId } from '@libp2p/interface'; @@ -55,3 +56,24 @@ export function reqRespTxHandler(mempools: MemPools): ReqRespSubProtocolHandler export function chunkTxHashesRequest(hashes: TxHash[], chunkSize = 1): Array { return chunk(hashes, chunkSize).map(chunk => new TxHashArray(...chunk)); } + +/** + * Calculate the expected response size for a TX request. + * @param requestBuffer - The serialized request buffer containing TxHashArray + * @returns Expected response size in KB + */ +export function calculateTxResponseSize(requestBuffer: Buffer): number { + try { + const txHashes = TxHashArray.fromBuffer(requestBuffer); + // TxHashArray.fromBuffer returns empty array on parse failure, so check for that + if (txHashes.length === 0 && requestBuffer.length > 0) { + // If we got an empty array but had a non-empty buffer, parsing likely failed + // Fall back to allowing a single transaction response + return MAX_TX_SIZE_KB + 1; + } + return Math.max(txHashes.length, 1) * MAX_TX_SIZE_KB + 1; // +1 KB overhead, at least 1 tx + } catch { + // If we can't parse the request, fall back to allowing a single transaction response + return MAX_TX_SIZE_KB + 1; + } +} diff --git a/yarn-project/p2p/src/services/reqresp/reqresp.ts b/yarn-project/p2p/src/services/reqresp/reqresp.ts index 36a9ced80e65..7d544395230d 100644 --- a/yarn-project/p2p/src/services/reqresp/reqresp.ts +++ b/yarn-project/p2p/src/services/reqresp/reqresp.ts @@ -36,6 +36,7 @@ import { type ReqRespSubProtocolValidators, type SubProtocolMap, responseFromBuffer, + subProtocolSizeCalculators, } from './interface.js'; import { ReqRespMetrics } from './metrics.js'; import { @@ -437,6 +438,9 @@ export class ReqResp implements ReqRespInterface { try { this.metrics.recordRequestSent(subProtocol); + // Calculate expected response size based on the request payload + const expectedSizeKb = subProtocolSizeCalculators[subProtocol](payload); + this.logger.trace(`Sending request to peer ${peerId.toString()} on sub protocol ${subProtocol}`); stream = await this.connectionSampler.dialProtocol(peerId, subProtocol, dialTimeout); this.logger.trace( @@ -444,11 +448,14 @@ export class ReqResp implements ReqRespInterface { ); const timeoutErr = new IndividualReqRespTimeoutError(); + // Create a wrapper to pass the expected size to readMessage + const readMessageWithSizeLimit = (source: AsyncIterable) => + this.readMessage(source, expectedSizeKb); const [_, resp] = await executeTimeout( signal => Promise.all([ pipeline([payload], stream!.sink, { signal }), - pipeline(stream!.source, this.readMessage.bind(this), { signal }), + pipeline(stream!.source, readMessageWithSizeLimit, { signal }), ]), this.individualRequestTimeoutMs, () => timeoutErr, @@ -510,8 +517,11 @@ export class ReqResp implements ReqRespInterface { * The message is split into two components * - The first chunk should contain a control byte, indicating the status of the response see `ReqRespStatus` * - The second chunk should contain the response data + * + * @param source - The async iterable source of data chunks + * @param maxSizeKb - Optional maximum expected size in KB for the decompressed response */ - private async readMessage(source: AsyncIterable): Promise { + private async readMessage(source: AsyncIterable, maxSizeKb?: number): Promise { let status: ReqRespStatus | undefined; const chunks: Uint8Array[] = []; @@ -536,7 +546,7 @@ export class ReqResp implements ReqRespInterface { } const messageData = Buffer.concat(chunks); - const message: Buffer = this.snappyTransform.inboundTransformData(messageData); + const message: Buffer = this.snappyTransform.inboundTransformData(messageData, undefined, maxSizeKb); return { status: status ?? ReqRespStatus.UNKNOWN, diff --git a/yarn-project/p2p/src/services/tx_collection/fast_tx_collection.ts b/yarn-project/p2p/src/services/tx_collection/fast_tx_collection.ts index bbb6c2c54b97..f9bb9f8e399c 100644 --- a/yarn-project/p2p/src/services/tx_collection/fast_tx_collection.ts +++ b/yarn-project/p2p/src/services/tx_collection/fast_tx_collection.ts @@ -20,7 +20,7 @@ import { SendBatchRequestCollector, } from './proposal_tx_collector.js'; import type { FastCollectionRequest, FastCollectionRequestInput } from './tx_collection.js'; -import type { TxCollectionSink } from './tx_collection_sink.js'; +import type { TxAddContext, TxCollectionSink } from './tx_collection_sink.js'; import type { TxSource } from './tx_source.js'; export class FastTxCollection { @@ -235,6 +235,7 @@ export class FastTxCollection { method: 'fast-node-rpc', ...request.blockInfo, }, + this.getAddContext(request), ); // Clear from the active requests the txs we just requested @@ -288,6 +289,7 @@ export class FastTxCollection { }, Array.from(request.missingTxHashes).map(txHash => TxHash.fromString(txHash)), { description: `reqresp for slot ${slotNumber}`, method: 'fast-req-resp', ...opts, ...request.blockInfo }, + this.getAddContext(request), ); } catch (err) { this.log.error(`Error sending fast reqresp request for txs`, err, { @@ -297,6 +299,15 @@ export class FastTxCollection { } } + /** Returns the TxAddContext for the given request, used by the sink to add txs to the pool correctly. */ + private getAddContext(request: FastCollectionRequest): TxAddContext { + if (request.type === 'proposal') { + return { type: 'proposal', blockHeader: request.blockProposal.blockHeader }; + } else { + return { type: 'mined', block: request.block }; + } + } + /** * Handle txs by marking them as found for the requests that are waiting for them, and resolves the request if all its txs have been found. * Called internally and from the main tx collection manager whenever the tx pool emits a tx-added event. diff --git a/yarn-project/p2p/src/services/tx_collection/file_store_tx_collection.test.ts b/yarn-project/p2p/src/services/tx_collection/file_store_tx_collection.test.ts index e2f702ea4154..fc3aaad52284 100644 --- a/yarn-project/p2p/src/services/tx_collection/file_store_tx_collection.test.ts +++ b/yarn-project/p2p/src/services/tx_collection/file_store_tx_collection.test.ts @@ -1,21 +1,24 @@ +import { BlockNumber } from '@aztec/foundation/branded-types'; import { createLogger } from '@aztec/foundation/log'; import { promiseWithResolvers } from '@aztec/foundation/promise'; +import { L2Block } from '@aztec/stdlib/block'; import { Tx, TxHash } from '@aztec/stdlib/tx'; import { getTelemetryClient } from '@aztec/telemetry-client'; import { jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; -import type { TxPool } from '../../mem_pools/index.js'; +import type { TxPoolV2 } from '../../mem_pools/tx_pool_v2/interfaces.js'; import { FileStoreTxCollection } from './file_store_tx_collection.js'; import type { FileStoreTxSource } from './file_store_tx_source.js'; -import { TxCollectionSink } from './tx_collection_sink.js'; +import { type TxAddContext, TxCollectionSink } from './tx_collection_sink.js'; describe('FileStoreTxCollection', () => { let fileStoreCollection: FileStoreTxCollection; let fileStoreSources: MockProxy[]; let txCollectionSink: TxCollectionSink; - let txPool: MockProxy; + let txPool: MockProxy; + let context: TxAddContext; let txs: Tx[]; let txHashes: TxHash[]; @@ -55,11 +58,9 @@ describe('FileStoreTxCollection', () => { }; beforeEach(async () => { + txPool = mock(); jest.spyOn(Math, 'random').mockReturnValue(0); - txPool = mock(); - txPool.addTxs.mockImplementation(txs => Promise.resolve(txs.length)); - const log = createLogger('test'); txCollectionSink = new TxCollectionSink(txPool, getTelemetryClient(), log); @@ -69,6 +70,9 @@ describe('FileStoreTxCollection', () => { txs = await Promise.all([makeTx(), makeTx(), makeTx()]); txHashes = txs.map(tx => tx.getTxHash()); + + const block = await L2Block.random(BlockNumber(1)); + context = { type: 'mined', block }; }); afterEach(async () => { @@ -84,15 +88,13 @@ describe('FileStoreTxCollection', () => { // Set up event listener before calling startCollecting const txsAddedPromise = waitForTxsAdded(txs.length); - fileStoreCollection.startCollecting(txHashes); + fileStoreCollection.startCollecting(txHashes, context); // Wait for all txs to be processed via events await txsAddedPromise; expect(fileStoreSources[0].getTxsByHash).toHaveBeenCalled(); - expect(txPool.addTxs).toHaveBeenCalledWith(expect.arrayContaining([txs[0]]), { source: 'tx-collection' }); - expect(txPool.addTxs).toHaveBeenCalledWith(expect.arrayContaining([txs[1]]), { source: 'tx-collection' }); - expect(txPool.addTxs).toHaveBeenCalledWith(expect.arrayContaining([txs[2]]), { source: 'tx-collection' }); + expect(txPool.addMinedTxs).toHaveBeenCalled(); }); it('skips txs marked as found while queued', async () => { @@ -101,7 +103,7 @@ describe('FileStoreTxCollection', () => { fileStoreCollection.start(); // Queue all txs, then mark the first as found before workers process it - fileStoreCollection.startCollecting(txHashes); + fileStoreCollection.startCollecting(txHashes, context); fileStoreCollection.foundTxs([txs[0]]); // Set up event listener - only 2 txs should be downloaded @@ -116,20 +118,18 @@ describe('FileStoreTxCollection', () => { expect(requestedHashes).not.toContainEqual(txHashes[0]); }); - it('stops tracking txs when foundTxs is called', async () => { + it('stops tracking txs when foundTxs is called after queueing', async () => { setFileStoreTxs(fileStoreSources[0], txs); fileStoreCollection.start(); - // Mark first tx as found before queueing + // Queue all txs, then immediately mark first as found + fileStoreCollection.startCollecting(txHashes, context); fileStoreCollection.foundTxs([txs[0]]); // Set up event listener - only 2 txs should be downloaded const txsAddedPromise = waitForTxsAdded(2); - // Queue all txs - but first one was already found - fileStoreCollection.startCollecting(txHashes); - // Wait for workers to process await txsAddedPromise; @@ -139,34 +139,38 @@ describe('FileStoreTxCollection', () => { expect(requestedHashes).not.toContainEqual(txHashes[0]); // Verify second and third tx were downloaded - expect(txPool.addTxs).toHaveBeenCalledWith(expect.arrayContaining([txs[1]]), { source: 'tx-collection' }); - expect(txPool.addTxs).toHaveBeenCalledWith(expect.arrayContaining([txs[2]]), { source: 'tx-collection' }); + expect(txPool.addMinedTxs).toHaveBeenCalled(); }); it('tries multiple file stores when tx not found in first', async () => { // Only second store has tx[0] setFileStoreTxs(fileStoreSources[1], [txs[0]]); + // Ensure we always start with source 0 so we can test the fallback to source 1 + jest.spyOn(Math, 'random').mockReturnValue(0); + fileStoreCollection.start(); // Set up event listener const txsAddedPromise = waitForTxsAdded(1); - fileStoreCollection.startCollecting([txHashes[0]]); + fileStoreCollection.startCollecting([txHashes[0]], context); await txsAddedPromise; // First store was tried but didn't have it expect(fileStoreSources[0].getTxsByHash).toHaveBeenCalled(); // Second store was tried and found it expect(fileStoreSources[1].getTxsByHash).toHaveBeenCalled(); - expect(txPool.addTxs).toHaveBeenCalledWith([txs[0]], { source: 'tx-collection' }); + expect(txPool.addMinedTxs).toHaveBeenCalled(); + + jest.restoreAllMocks(); }); it('does not start workers if no file store sources are configured', async () => { const log = createLogger('test'); fileStoreCollection = new FileStoreTxCollection([], txCollectionSink, log); fileStoreCollection.start(); - fileStoreCollection.startCollecting(txHashes); + fileStoreCollection.startCollecting(txHashes, context); // Give some time for potential processing await new Promise(resolve => setTimeout(resolve, 50)); @@ -175,19 +179,21 @@ describe('FileStoreTxCollection', () => { }); it('does not re-queue txs that are already pending', async () => { + // Set txs on both sources so download count is deterministic regardless of random start index setFileStoreTxs(fileStoreSources[0], txs); + setFileStoreTxs(fileStoreSources[1], txs); fileStoreCollection.start(); // Set up event listener const txsAddedPromise = waitForTxsAdded(txs.length); - fileStoreCollection.startCollecting(txHashes); - fileStoreCollection.startCollecting(txHashes); // Duplicate call + fileStoreCollection.startCollecting(txHashes, context); + fileStoreCollection.startCollecting(txHashes, context); // Duplicate call await txsAddedPromise; - // Each tx should only be downloaded once + // Each tx should only be downloaded once (one source call per tx) const allCalls = fileStoreSources.flatMap(s => s.getTxsByHash.mock.calls); expect(allCalls.length).toBe(txHashes.length); }); diff --git a/yarn-project/p2p/src/services/tx_collection/file_store_tx_collection.ts b/yarn-project/p2p/src/services/tx_collection/file_store_tx_collection.ts index 13a2a0b52ca3..709238344a5d 100644 --- a/yarn-project/p2p/src/services/tx_collection/file_store_tx_collection.ts +++ b/yarn-project/p2p/src/services/tx_collection/file_store_tx_collection.ts @@ -3,7 +3,7 @@ import { FifoMemoryQueue } from '@aztec/foundation/queue'; import { Tx, TxHash } from '@aztec/stdlib/tx'; import type { FileStoreTxSource } from './file_store_tx_source.js'; -import type { TxCollectionSink } from './tx_collection_sink.js'; +import type { TxAddContext, TxCollectionSink } from './tx_collection_sink.js'; // Internal constants (not configurable by node operators) const FILE_STORE_DOWNLOAD_CONCURRENCY = 5; // Max concurrent downloads @@ -14,8 +14,8 @@ const FILE_STORE_DOWNLOAD_CONCURRENCY = 5; // Max concurrent downloads * collection is managed by the TxCollection orchestrator, not this class. */ export class FileStoreTxCollection { - /** Set of tx hashes that have been queued for download (prevents duplicate queueing). */ - private pendingTxs = new Set(); + /** Map from tx hash to add context for txs queued for download. */ + private pendingTxs = new Map(); /** * Tracks tx hashes found elsewhere, even before startCollecting is called. @@ -88,11 +88,11 @@ export class FileStoreTxCollection { } /** Queue the given tx hashes for file store collection. */ - public startCollecting(txHashes: TxHash[]) { + public startCollecting(txHashes: TxHash[], context: TxAddContext) { for (const txHash of txHashes) { const hashStr = txHash.toString(); if (!this.pendingTxs.has(hashStr) && !this.foundTxHashes.has(hashStr)) { - this.pendingTxs.add(hashStr); + this.pendingTxs.set(hashStr, context); this.downloadQueue.put(txHash); } } @@ -110,28 +110,34 @@ export class FileStoreTxCollection { /** Processes a single tx hash from the download queue. */ private async processDownload(txHash: TxHash) { const hashStr = txHash.toString(); + const context = this.pendingTxs.get(hashStr); // Skip if already found by another method - if (!this.pendingTxs.has(hashStr)) { + if (!context) { return; } - await this.downloadTx(txHash); + await this.downloadTx(txHash, context); this.pendingTxs.delete(hashStr); } /** Attempt to download a tx from file stores (round-robin). */ - private async downloadTx(txHash: TxHash) { + private async downloadTx(txHash: TxHash, context: TxAddContext) { const startIndex = Math.floor(Math.random() * this.fileStoreSources.length); for (let i = startIndex; i < startIndex + this.fileStoreSources.length; i++) { const source = this.fileStoreSources[i % this.fileStoreSources.length]; try { - const result = await this.txCollectionSink.collect(hashes => source.getTxsByHash(hashes), [txHash], { - description: `file-store ${source.getInfo()}`, - method: 'file-store', - fileStore: source.getInfo(), - }); + const result = await this.txCollectionSink.collect( + hashes => source.getTxsByHash(hashes), + [txHash], + { + description: `file-store ${source.getInfo()}`, + method: 'file-store', + fileStore: source.getInfo(), + }, + context, + ); if (result.txs.length > 0) { return; diff --git a/yarn-project/p2p/src/services/tx_collection/slow_tx_collection.ts b/yarn-project/p2p/src/services/tx_collection/slow_tx_collection.ts index bdda8e17b422..eb337e0a0778 100644 --- a/yarn-project/p2p/src/services/tx_collection/slow_tx_collection.ts +++ b/yarn-project/p2p/src/services/tx_collection/slow_tx_collection.ts @@ -91,6 +91,7 @@ export class SlowTxCollection { for (const txHash of txHashes) { this.missingTxs.set(txHash.toString(), { + block, blockNumber: block.number, deadline: this.getDeadlineForSlot(block.header.getSlot()), readyForReqResp: this.nodes.length === 0, // If we have no nodes, we can start reqresp immediately @@ -109,18 +110,26 @@ export class SlowTxCollection { // Gather all missing txs that are not in fast collection and request them from the node const missingTxs = this.getMissingTxsForSlowCollection(); - const missingTxHashes = missingTxs.map(([txHash]) => txHash).map(TxHash.fromString); - if (missingTxHashes.length === 0) { + if (missingTxs.length === 0) { return; } - // Request in chunks to avoid hitting RPC limits - for (const batch of chunk(missingTxHashes, this.config.txCollectionNodeRpcMaxBatchSize)) { - await this.txCollectionSink.collect(txHashes => node.getTxsByHash(txHashes), batch, { - description: `node ${node.getInfo()}`, - node: node.getInfo(), - method: 'slow-node-rpc', - }); + // Group by block so we pass the correct mined context to the sink + for (const entries of this.groupByBlock(missingTxs)) { + const block = entries[0][1].block; + const txHashes = entries.map(([txHash]) => TxHash.fromString(txHash)); + for (const batch of chunk(txHashes, this.config.txCollectionNodeRpcMaxBatchSize)) { + await this.txCollectionSink.collect( + hashes => node.getTxsByHash(hashes), + batch, + { + description: `node ${node.getInfo()}`, + node: node.getInfo(), + method: 'slow-node-rpc', + }, + { type: 'mined', block }, + ); + } } // Mark every tx that is still missing as ready for reqresp. @@ -149,25 +158,30 @@ export class SlowTxCollection { const pinnedPeer = undefined; const timeoutMs = this.config.txCollectionSlowReqRespTimeoutMs; - const maxPeers = boundInclusive(Math.ceil(missingTxs.length / 3), 4, 16); const maxRetryAttempts = 3; - // Send a batch request via reqresp for the missing txs - await this.txCollectionSink.collect( - async txHashes => { - const txs = await this.reqResp.sendBatchRequest( - ReqRespSubProtocol.TX, - chunkTxHashesRequest(txHashes), - pinnedPeer, - timeoutMs, - maxPeers, - maxRetryAttempts, - ); - return txs.flat(); - }, - missingTxs.map(([txHash]) => TxHash.fromString(txHash)), - { description: 'slow reqresp', timeoutMs, method: 'slow-req-resp' }, - ); + // Group by block so we pass the correct mined context to the sink + for (const entries of this.groupByBlock(missingTxs)) { + const block = entries[0][1].block; + const txHashes = entries.map(([txHash]) => TxHash.fromString(txHash)); + const maxPeers = boundInclusive(Math.ceil(txHashes.length / 3), 4, 16); + await this.txCollectionSink.collect( + async hashes => { + const txs = await this.reqResp.sendBatchRequest( + ReqRespSubProtocol.TX, + chunkTxHashesRequest(hashes), + pinnedPeer, + timeoutMs, + maxPeers, + maxRetryAttempts, + ); + return txs.flat(); + }, + txHashes, + { description: 'slow reqresp', timeoutMs, method: 'slow-req-resp' }, + { type: 'mined', block }, + ); + } } /** Retrieves all missing txs for the slow collection process. This is, all missing txs that are not part of a fast request. */ @@ -223,6 +237,21 @@ export class SlowTxCollection { } } + /** Groups missing tx entries by block number. */ + private groupByBlock(entries: [string, MissingTxInfo][]): [string, MissingTxInfo][][] { + const groups = new Map(); + for (const entry of entries) { + const bn = +entry[1].blockNumber; + let group = groups.get(bn); + if (!group) { + group = []; + groups.set(bn, group); + } + group.push(entry); + } + return [...groups.values()]; + } + /** Computes the proof submission deadline for a given slot, a tx mined in this slot is no longer interesting after this deadline */ private getDeadlineForSlot(slotNumber: SlotNumber): Date { const epoch = getEpochAtSlot(slotNumber, this.constants); diff --git a/yarn-project/p2p/src/services/tx_collection/tx_collection.test.ts b/yarn-project/p2p/src/services/tx_collection/tx_collection.test.ts index 4cbb82e43ed5..abd6ae020af1 100644 --- a/yarn-project/p2p/src/services/tx_collection/tx_collection.test.ts +++ b/yarn-project/p2p/src/services/tx_collection/tx_collection.test.ts @@ -13,8 +13,7 @@ import { jest } from '@jest/globals'; import type { PeerId } from '@libp2p/interface'; import { type MockProxy, mock } from 'jest-mock-extended'; -import type { TxPool } from '../../mem_pools/index.js'; -import type { TxPoolEvents } from '../../mem_pools/tx_pool/tx_pool.js'; +import type { TxPoolV2, TxPoolV2Events } from '../../mem_pools/tx_pool_v2/interfaces.js'; import type { BatchTxRequesterLibP2PService } from '../reqresp/batch-tx-requester/interface.js'; import type { ConnectionSampler } from '../reqresp/connection-sampler/connection_sampler.js'; import { type ReqRespInterface, ReqRespSubProtocol } from '../reqresp/interface.js'; @@ -34,7 +33,7 @@ describe('TxCollection', () => { const connectionSampler = mock(); const mockP2PService = mock({ connectionSampler }); let nodes: MockProxy[]; - let txPool: MockProxy; + let txPool: MockProxy; let constants: L1RollupConstants; let config: TxCollectionConfig; let dateProvider: TestDateProvider; @@ -109,8 +108,8 @@ describe('TxCollection', () => { ); }; - const expectTxsAddedToPool = (txs: Tx[]) => { - expect(txPool.addTxs).toHaveBeenCalledWith(txs, { source: 'tx-collection' }); + const expectTxsMinedInPool = (txs: Tx[]) => { + expect(txPool.addMinedTxs).toHaveBeenCalledWith(txs, block.header, { source: 'tx-collection' }); }; const sortByHash = (txs: Tx[]) => txs.sort((a, b) => a.txHash.toString().localeCompare(b.txHash.toString())); @@ -122,7 +121,7 @@ describe('TxCollection', () => { nodes = [makeNode('node1'), makeNode('node2')]; - txPool = mock(); + txPool = mock(); txPool.getTxsByHash.mockResolvedValue([]); dateProvider = new TestDateProvider(); @@ -165,7 +164,7 @@ describe('TxCollection', () => { await txCollection.trigger(); expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes); - expectTxsAddedToPool(txs); + expectTxsMinedInPool(txs); jest.clearAllMocks(); await txCollection.trigger(); @@ -181,15 +180,15 @@ describe('TxCollection', () => { await txCollection.trigger(); expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes); - expectTxsAddedToPool([txs[0]]); - expectTxsAddedToPool([txs[1]]); + expectTxsMinedInPool([txs[0]]); + expectTxsMinedInPool([txs[1]]); jest.clearAllMocks(); setNodeTxs(nodes[0], [txs[0], txs[2]]); await txCollection.trigger(); expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[2]]); expect(nodes[1].getTxsByHash).toHaveBeenCalledWith([txHashes[2]]); - expectTxsAddedToPool([txs[2]]); + expectTxsMinedInPool([txs[2]]); jest.clearAllMocks(); await txCollection.trigger(); @@ -206,8 +205,8 @@ describe('TxCollection', () => { await txCollection.trigger(); expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes.slice(0, 5)); expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes.slice(5, 10)); - expectTxsAddedToPool(txs.slice(0, 5)); - expectTxsAddedToPool(txs.slice(5, 10)); + expectTxsMinedInPool(txs.slice(0, 5)); + expectTxsMinedInPool(txs.slice(5, 10)); jest.clearAllMocks(); await txCollection.trigger(); @@ -224,14 +223,14 @@ describe('TxCollection', () => { expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes); expect(reqResp.sendBatchRequest).not.toHaveBeenCalled(); - expectTxsAddedToPool([txs[0]]); - expectTxsAddedToPool([txs[1]]); + expectTxsMinedInPool([txs[0]]); + expectTxsMinedInPool([txs[1]]); jest.clearAllMocks(); setReqRespTxs([txs[2]]); await txCollection.trigger(); expectReqRespToHaveBeenCalledWith([txHashes[2]]); - expectTxsAddedToPool([txs[2]]); + expectTxsMinedInPool([txs[2]]); jest.clearAllMocks(); await txCollection.trigger(); @@ -246,13 +245,13 @@ describe('TxCollection', () => { setReqRespTxs([txs[0]]); await txCollection.trigger(); expectReqRespToHaveBeenCalledWith(txHashes); - expectTxsAddedToPool([txs[0]]); + expectTxsMinedInPool([txs[0]]); jest.clearAllMocks(); setReqRespTxs([txs[1]]); await txCollection.trigger(); expectReqRespToHaveBeenCalledWith([txHashes[1], txHashes[2]]); - expectTxsAddedToPool([txs[1]]); + expectTxsMinedInPool([txs[1]]); }); it('rejects expired txs', async () => { @@ -318,12 +317,12 @@ describe('TxCollection', () => { await txCollection.collectFastForBlock(block, [txHashes[0]], { deadline }); expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[0]]); - expectTxsAddedToPool([txs[0]]); + expectTxsMinedInPool([txs[0]]); jest.clearAllMocks(); await txCollection.trigger(); expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[1], txHashes[2]]); - expectTxsAddedToPool([txs[1], txs[2]]); + expectTxsMinedInPool([txs[1], txs[2]]); }); it('stops collecting a tx when reported as found from the pool', async () => { @@ -333,13 +332,13 @@ describe('TxCollection', () => { expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes); - await txCollection.handleTxsAddedToPool({ txs: [txs[0]], source: 'test' }); + txCollection.handleTxsAddedToPool({ txs: [txs[0]], source: 'test' }); jest.clearAllMocks(); setNodeTxs(nodes[0], [txs[1]]); await txCollection.trigger(); expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[1], txHashes[2]]); - expectTxsAddedToPool([txs[1]]); + expectTxsMinedInPool([txs[1]]); }); it('stops collecting txs based on block number', async () => { @@ -349,12 +348,16 @@ describe('TxCollection', () => { txCollection.startCollecting(blocks[2], [txHashes[2]]); await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); + // Each block's txs are requested separately since they're grouped by block + expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[0]]); + expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[1]]); + expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[2]]); jest.clearAllMocks(); txCollection.stopCollectingForBlocksUpTo(BlockNumber(1)); await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[1], txHashes[2]]); + expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[1]]); + expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[2]]); jest.clearAllMocks(); txCollection.stopCollectingForBlocksAfter(BlockNumber(2)); @@ -369,7 +372,7 @@ describe('TxCollection', () => { await txCollection.trigger(); expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes); - expectTxsAddedToPool([txs[0]]); + expectTxsMinedInPool([txs[0]]); jest.clearAllMocks(); txPool.getTxsByHash.mockResolvedValue([txs[1]]); @@ -388,7 +391,7 @@ describe('TxCollection', () => { const collected = await txCollection.collectFastForBlock(block, txHashes, { deadline }); expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); expect(reqResp.sendBatchRequest).not.toHaveBeenCalled(); - expectTxsAddedToPool(txs); + expectTxsMinedInPool(txs); expect(collected).toEqual(txs); }); @@ -407,10 +410,10 @@ describe('TxCollection', () => { expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes.slice(10, 15)); expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes.slice(15, 20)); - expectTxsAddedToPool(txs.slice(0, 5)); - expectTxsAddedToPool(txs.slice(5, 10)); - expectTxsAddedToPool(txs.slice(10, 15)); - expectTxsAddedToPool(txs.slice(15, 20)); + expectTxsMinedInPool(txs.slice(0, 5)); + expectTxsMinedInPool(txs.slice(5, 10)); + expectTxsMinedInPool(txs.slice(10, 15)); + expectTxsMinedInPool(txs.slice(15, 20)); expect(sortByHash(collected)).toEqual(sortByHash(txs)); }); @@ -423,9 +426,9 @@ describe('TxCollection', () => { expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes); expectReqRespToHaveBeenCalledWith([txHashes[2]]); - expectTxsAddedToPool([txs[0]]); - expectTxsAddedToPool([txs[1]]); - expectTxsAddedToPool([txs[2]]); + expectTxsMinedInPool([txs[0]]); + expectTxsMinedInPool([txs[1]]); + expectTxsMinedInPool([txs[2]]); expect(collected).toEqual(txs); }); @@ -434,7 +437,7 @@ describe('TxCollection', () => { setReqRespTxs(txs); const collected = await txCollection.collectFastForBlock(block, txHashes, { deadline }); expectReqRespToHaveBeenCalledWith(txHashes); - expectTxsAddedToPool(txs); + expectTxsMinedInPool(txs); expect(collected).toEqual(txs); }); @@ -448,8 +451,8 @@ describe('TxCollection', () => { expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[2]]); expectReqRespToHaveBeenCalledWith([txHashes[1], txHashes[2]]); - expectTxsAddedToPool([txs[0]]); - expectTxsAddedToPool([txs[1]]); + expectTxsMinedInPool([txs[0]]); + expectTxsMinedInPool([txs[1]]); expect(collected).toEqual([txs[0], txs[1]]); }); @@ -466,15 +469,10 @@ describe('TxCollection', () => { expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes); - txPool.addTxs.mockImplementation(txs => { - jest.clearAllMocks(); - return Promise.resolve(txs.length); - }); - // Simulate a tx found in a node, another one via reqresp, and a third one added to the pool via gossipsub setNodeTxs(nodes[0], [txs[0]]); reqRespPromise.resolve([new TxArray(...[txs[1]])]); - await txCollection.handleTxsAddedToPool({ txs: [txs[2]], source: 'test' }); + txCollection.handleTxsAddedToPool({ txs: [txs[2]], source: 'test' }); jest.clearAllMocks(); const collected = await collectionPromise; @@ -492,12 +490,12 @@ describe('TxCollection', () => { const collectionPromise = txCollection.collectFastForBlock(block, txHashes, { deadline }); await sleep(1000); - await txCollection.handleTxsAddedToPool({ txs: [txs[2]], source: 'test' }); + txCollection.handleTxsAddedToPool({ txs: [txs[2]], source: 'test' }); const collected = await collectionPromise; expect(dateProvider.now()).toBeLessThan(+deadline); - expectTxsAddedToPool([txs[0]]); - expectTxsAddedToPool([txs[1]]); + expectTxsMinedInPool([txs[0]]); + expectTxsMinedInPool([txs[1]]); expect(collected).toEqual([txs[0], txs[1], txs[2]]); }); @@ -534,8 +532,6 @@ describe('TxCollection', () => { it('collects txs from file store after slow delay', async () => { setFileStoreTxs(fileStoreSources[0], txs); - txPool.addTxs.mockImplementation(addedTxs => Promise.resolve(addedTxs.length)); - txPool.hasTx.mockResolvedValue(false); await txCollection.start(); txCollection.startCollecting(block, txHashes); @@ -554,14 +550,12 @@ describe('TxCollection', () => { it('does not download txs from file store if found via P2P before delay expires', async () => { setFileStoreTxs(fileStoreSources[0], txs); - txPool.addTxs.mockImplementation(addedTxs => Promise.resolve(addedTxs.length)); - txPool.hasTx.mockResolvedValue(false); await txCollection.start(); txCollection.startCollecting(block, txHashes); // Simulate all txs found via P2P before delay expires - await txCollection.handleTxsAddedToPool({ txs, source: 'test' }); + txCollection.handleTxsAddedToPool({ txs, source: 'test' }); // Now advance time past the delay dateProvider.setTime(dateProvider.now() + 200); @@ -587,5 +581,5 @@ class TestTxCollection extends TxCollection { declare slowCollection: SlowTxCollection; declare fastCollection: TestFastTxCollection; declare fileStoreCollection: TxCollection['fileStoreCollection']; - declare handleTxsAddedToPool: TxPoolEvents['txs-added']; + declare handleTxsAddedToPool: TxPoolV2Events['txs-added']; } diff --git a/yarn-project/p2p/src/services/tx_collection/tx_collection.ts b/yarn-project/p2p/src/services/tx_collection/tx_collection.ts index 38c3f65cbcbd..6d142fbba25e 100644 --- a/yarn-project/p2p/src/services/tx_collection/tx_collection.ts +++ b/yarn-project/p2p/src/services/tx_collection/tx_collection.ts @@ -12,20 +12,19 @@ import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-clien import type { PeerId } from '@libp2p/interface'; -import type { TxPool } from '../../mem_pools/index.js'; -import type { TxPoolEvents } from '../../mem_pools/tx_pool/tx_pool.js'; +import type { TxPoolV2, TxPoolV2Events } from '../../mem_pools/tx_pool_v2/interfaces.js'; import type { BatchTxRequesterLibP2PService } from '../reqresp/batch-tx-requester/interface.js'; import type { TxCollectionConfig } from './config.js'; import { FastTxCollection } from './fast_tx_collection.js'; import { FileStoreTxCollection } from './file_store_tx_collection.js'; import type { FileStoreTxSource } from './file_store_tx_source.js'; import { SlowTxCollection } from './slow_tx_collection.js'; -import { TxCollectionSink } from './tx_collection_sink.js'; +import { type TxAddContext, TxCollectionSink } from './tx_collection_sink.js'; import type { TxSource } from './tx_source.js'; export type CollectionMethod = 'fast-req-resp' | 'fast-node-rpc' | 'slow-req-resp' | 'slow-node-rpc' | 'file-store'; -export type MissingTxInfo = { blockNumber: BlockNumber; deadline: Date; readyForReqResp: boolean }; +export type MissingTxInfo = { block: L2Block; blockNumber: BlockNumber; deadline: Date; readyForReqResp: boolean }; export type FastCollectionRequestInput = | { type: 'block'; block: L2Block } @@ -67,10 +66,10 @@ export class TxCollection { private readonly txCollectionSink: TxCollectionSink; /** Handler for the txs-added event from the tx pool */ - protected readonly handleTxsAddedToPool: TxPoolEvents['txs-added']; + protected readonly handleTxsAddedToPool: TxPoolV2Events['txs-added']; /** Handler for the txs-added event from the tx collection sink */ - protected readonly handleTxsFound: TxPoolEvents['txs-added']; + protected readonly handleTxsFound: TxPoolV2Events['txs-added']; /** Whether the service has been started. */ private started = false; @@ -82,7 +81,7 @@ export class TxCollection { private readonly p2pService: BatchTxRequesterLibP2PService, private readonly nodes: TxSource[], private readonly constants: L1RollupConstants, - private readonly txPool: TxPool, + private readonly txPool: TxPoolV2, private readonly config: TxCollectionConfig, fileStoreSources: FileStoreTxSource[] = [], private readonly dateProvider: DateProvider = new DateProvider(), @@ -120,12 +119,12 @@ export class TxCollection { this.config.txCollectionReconcileIntervalMs, ); - this.handleTxsFound = (args: Parameters[0]) => { + this.handleTxsFound = (args: Parameters[0]) => { this.foundTxs(args.txs); }; this.txCollectionSink.on('txs-added', this.handleTxsFound); - this.handleTxsAddedToPool = (args: Parameters[0]) => { + this.handleTxsAddedToPool = (args: Parameters[0]) => { const { txs, source } = args; if (source !== 'tx-collection') { this.foundTxs(txs); @@ -175,10 +174,16 @@ export class TxCollection { // Delay file store collection to give P2P methods time to find txs first if (this.hasFileStoreSources) { + const context: TxAddContext = { type: 'mined', block }; sleep(this.config.txCollectionFileStoreSlowDelayMs) .then(() => { if (this.started) { - this.fileStoreCollection.startCollecting(txHashes); + // Only queue txs that are still missing after the delay + const stillMissing = new Set(this.slowCollection.getMissingTxHashes().map(h => h.toString())); + const remaining = txHashes.filter(h => stillMissing.has(h.toString())); + if (remaining.length > 0) { + this.fileStoreCollection.startCollecting(remaining, context); + } } }) .catch(err => this.log.error('Error in file store slow delay', err)); @@ -214,10 +219,11 @@ export class TxCollection { // Delay file store collection to give P2P methods time to find txs first if (this.hasFileStoreSources) { + const context = this.getAddContextForInput(input); sleep(this.config.txCollectionFileStoreFastDelayMs) .then(() => { if (this.started) { - this.fileStoreCollection.startCollecting(hashes); + this.fileStoreCollection.startCollecting(hashes, context); } }) .catch(err => this.log.error('Error in file store fast delay', err)); @@ -226,6 +232,15 @@ export class TxCollection { return this.fastCollection.collectFastFor(input, txHashes, opts); } + /** Returns the TxAddContext for the given fast collection request input */ + private getAddContextForInput(input: FastCollectionRequestInput): TxAddContext { + if (input.type === 'proposal') { + return { type: 'proposal', blockHeader: input.blockProposal.blockHeader }; + } else { + return { type: 'mined', block: input.block }; + } + } + /** Mark the given txs as found. Stops collecting them. */ private foundTxs(txs: Tx[]) { this.slowCollection.foundTxs(txs); diff --git a/yarn-project/p2p/src/services/tx_collection/tx_collection_sink.ts b/yarn-project/p2p/src/services/tx_collection/tx_collection_sink.ts index 5c7b5d1ff138..406348907331 100644 --- a/yarn-project/p2p/src/services/tx_collection/tx_collection_sink.ts +++ b/yarn-project/p2p/src/services/tx_collection/tx_collection_sink.ts @@ -1,24 +1,28 @@ import type { Logger } from '@aztec/foundation/log'; import { elapsed } from '@aztec/foundation/timer'; import type { TypedEventEmitter } from '@aztec/foundation/types'; -import { Tx, type TxHash } from '@aztec/stdlib/tx'; +import type { L2Block } from '@aztec/stdlib/block'; +import type { BlockHeader, Tx, TxHash } from '@aztec/stdlib/tx'; import type { TelemetryClient } from '@aztec/telemetry-client'; import EventEmitter from 'node:events'; -import type { TxPool, TxPoolEvents } from '../../mem_pools/tx_pool/tx_pool.js'; +import type { TxPoolV2, TxPoolV2Events } from '../../mem_pools/tx_pool_v2/interfaces.js'; import { TxCollectionInstrumentation } from './instrumentation.js'; import type { CollectionMethod } from './tx_collection.js'; +/** Context determining how collected txs should be added to the pool. */ +export type TxAddContext = { type: 'proposal'; blockHeader: BlockHeader } | { type: 'mined'; block: L2Block }; + /** * Executes collection requests from the fast and slow collection loops, and handles collected txs * by adding them to the tx pool and emitting events, as well as handling logging and metrics. */ -export class TxCollectionSink extends (EventEmitter as new () => TypedEventEmitter) { +export class TxCollectionSink extends (EventEmitter as new () => TypedEventEmitter) { private readonly instrumentation: TxCollectionInstrumentation; constructor( - private readonly txPool: TxPool, + private readonly txPool: TxPoolV2, telemetryClient: TelemetryClient, private readonly log: Logger, ) { @@ -30,6 +34,7 @@ export class TxCollectionSink extends (EventEmitter as new () => TypedEventEmitt collectValidTxsFn: (txHashes: TxHash[]) => Promise<(Tx | undefined)[]>, requested: TxHash[], info: Record & { description: string; method: CollectionMethod }, + context: TxAddContext, ) { this.log.trace(`Requesting ${requested.length} txs via ${info.description}`, { ...info, @@ -99,12 +104,13 @@ export class TxCollectionSink extends (EventEmitter as new () => TypedEventEmitt }, ); - return await this.foundTxs(validTxs, { ...info, duration }); + return await this.foundTxs(validTxs, { ...info, duration }, context); } private async foundTxs( txs: Tx[], info: Record & { description: string; method: CollectionMethod; duration: number }, + context: TxAddContext, ) { // Report metrics for the collection this.instrumentation.increaseTxsFor(info.method, txs.length, info.duration); @@ -112,9 +118,13 @@ export class TxCollectionSink extends (EventEmitter as new () => TypedEventEmitt // Mark txs as found in the slow missing txs set and all fast requests this.emit('txs-added', { txs }); - // Add the txs to the tx pool (should not fail, but we catch it just in case) + // Add the txs to the tx pool using the appropriate method based on context try { - await this.txPool.addTxs(txs, { source: `tx-collection` }); + if (context.type === 'mined') { + await this.txPool.addMinedTxs(txs, context.block.header, { source: 'tx-collection' }); + } else { + await this.txPool.addProtectedTxs(txs, context.blockHeader, { source: 'tx-collection' }); + } } catch (err) { this.log.error(`Error adding txs to the pool via ${info.description}`, err, { ...info, diff --git a/yarn-project/p2p/src/services/tx_file_store/tx_file_store.test.ts b/yarn-project/p2p/src/services/tx_file_store/tx_file_store.test.ts index 47ad859c1ef6..3a35574afc7f 100644 --- a/yarn-project/p2p/src/services/tx_file_store/tx_file_store.test.ts +++ b/yarn-project/p2p/src/services/tx_file_store/tx_file_store.test.ts @@ -97,7 +97,7 @@ describe('TxFileStore', () => { const spy = jest.spyOn(fileStore, 'save'); const tx = await makeTx(); - await txPool.addTxs([tx]); + await txPool.addPendingTxs([tx]); await txFileStore!.flush(); @@ -113,7 +113,7 @@ describe('TxFileStore', () => { const spy = jest.spyOn(fileStore, 'save'); const tx1 = await makeTx(); - await txPool.addTxs([tx1]); + await txPool.addPendingTxs([tx1]); await txFileStore!.flush(); const countBefore = await countUploadedFiles(); @@ -124,7 +124,7 @@ describe('TxFileStore', () => { // Add another tx after stopping - should not be uploaded // stop() synchronously removes the event listener, so no race condition const tx2 = await makeTx(); - await txPool.addTxs([tx2]); + await txPool.addPendingTxs([tx2]); expect(spy).toHaveBeenCalledTimes(1); @@ -140,7 +140,7 @@ describe('TxFileStore', () => { const spy = jest.spyOn(fileStore, 'save'); const tx = await makeTx(); - await txPool.addTxs([tx]); + await txPool.addPendingTxs([tx]); await txFileStore!.flush(); @@ -157,7 +157,7 @@ describe('TxFileStore', () => { const tx1 = await makeTx(); const tx2 = await makeTx(); - await txPool.addTxs([tx1, tx2]); + await txPool.addPendingTxs([tx1, tx2]); await txFileStore!.flush(); @@ -192,7 +192,7 @@ describe('TxFileStore', () => { .fill(0) .map(() => makeTx()), ); - await txPool.addTxs(txs); + await txPool.addPendingTxs(txs); await txFileStore!.flush(); @@ -212,10 +212,10 @@ describe('TxFileStore', () => { const tx = await makeTx(); // Upload same tx twice - await txPool.addTxs([tx]); + await txPool.addPendingTxs([tx]); await txFileStore!.flush(); - await txPool.addTxs([tx]); + await txPool.addPendingTxs([tx]); await txFileStore!.flush(); // Dedup happens synchronously before upload starts // Should only upload once (second is deduplicated) @@ -234,7 +234,7 @@ describe('TxFileStore', () => { // Queue 4 txs - with maxQueueSize=2, overflow logic drops 2 oldest const txs = await Promise.all([makeTx(), makeTx(), makeTx(), makeTx()]); - await txPool.addTxs(txs); + await txPool.addPendingTxs(txs); // Check pending count immediately after enqueue (before processing) // 4 added - 2 dropped = 2 remaining in queue (+ 0 active at this point) @@ -262,7 +262,7 @@ describe('TxFileStore', () => { .mockImplementation(originalSave); const tx = await makeTx(); - await txPool.addTxs([tx]); + await txPool.addPendingTxs([tx]); // flush() waits for all uploads including retries await txFileStore!.flush(); @@ -290,7 +290,7 @@ describe('TxFileStore', () => { const tx1 = await makeTx(); const tx2 = await makeTx(); - await txPool.addTxs([tx1, tx2]); + await txPool.addPendingTxs([tx1, tx2]); // flush() waits for all uploads including retries await txFileStore!.flush(); @@ -312,7 +312,7 @@ describe('TxFileStore', () => { const tx1 = await makeTx(); const tx2 = await makeTx(); - await txPool.addTxs([tx1, tx2]); + await txPool.addPendingTxs([tx1, tx2]); // Check immediately after enqueue (before processing starts) expect(txFileStore!.getPendingUploadCount()).toBe(2); diff --git a/yarn-project/p2p/src/services/tx_file_store/tx_file_store.ts b/yarn-project/p2p/src/services/tx_file_store/tx_file_store.ts index 3e6429e0d922..13ea96d8621f 100644 --- a/yarn-project/p2p/src/services/tx_file_store/tx_file_store.ts +++ b/yarn-project/p2p/src/services/tx_file_store/tx_file_store.ts @@ -6,7 +6,7 @@ import { type FileStore, createFileStore } from '@aztec/stdlib/file-store'; import type { Tx } from '@aztec/stdlib/tx'; import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-client'; -import type { TxPool, TxPoolEvents } from '../../mem_pools/tx_pool/index.js'; +import type { TxPoolV2 } from '../../mem_pools/index.js'; import type { TxFileStoreConfig } from './config.js'; import { TxFileStoreInstrumentation } from './instrumentation.js'; @@ -18,7 +18,7 @@ export class TxFileStore { private uploadQueue: Tx[] = []; private activeUploads = 0; private readonly queueProcessor: RunningPromise; - private readonly handleTxsAdded: TxPoolEvents['txs-added']; + private readonly handleTxsAdded: (args: { txs: Tx[]; source?: string }) => void; /** Recently uploaded tx hashes for deduplication. */ private recentUploads: Set = new Set(); @@ -27,7 +27,7 @@ export class TxFileStore { private constructor( private readonly fileStore: FileStore, - private readonly txPool: TxPool, + private readonly txPool: TxPoolV2, private readonly config: TxFileStoreConfig, private readonly instrumentation: TxFileStoreInstrumentation, private readonly log: Logger, @@ -48,7 +48,7 @@ export class TxFileStore { * @returns The file store instance, or undefined if not configured/enabled. */ static async create( - txPool: TxPool, + txPool: TxPoolV2, config: TxFileStoreConfig, log: Logger = createLogger('p2p:tx_file_store'), telemetry: TelemetryClient = getTelemetryClient(), diff --git a/yarn-project/p2p/src/services/tx_provider.test.ts b/yarn-project/p2p/src/services/tx_provider.test.ts index dc62c0589165..f21980fffccd 100644 --- a/yarn-project/p2p/src/services/tx_provider.test.ts +++ b/yarn-project/p2p/src/services/tx_provider.test.ts @@ -1,9 +1,9 @@ import { BlockNumber } from '@aztec/foundation/branded-types'; import { times } from '@aztec/foundation/collection'; -import { P2PClient, type PeerId, type TxPool, TxProvider } from '@aztec/p2p'; +import { P2PClient, type PeerId, type TxPoolV2, TxProvider } from '@aztec/p2p'; import type { BlockProposal } from '@aztec/stdlib/p2p'; import { makeBlockProposal, mockTx } from '@aztec/stdlib/testing'; -import { Tx, type TxHash } from '@aztec/stdlib/tx'; +import { Tx, TxHash } from '@aztec/stdlib/tx'; import { jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; @@ -16,7 +16,7 @@ type TxResults = { txs: Tx[]; missingTxs: TxHash[] }; describe('TxProvider', () => { // Dependencies let txCollection: MockProxy; - let txPool: MockProxy; + let txPool: MockProxy; let txValidator: MockProxy>; // Subject under test @@ -80,17 +80,28 @@ describe('TxProvider', () => { opts = { deadline: new Date(Date.now() + 10_000), pinnedPeer: undefined }; txCollection = mock(); - txPool = mock(); + txPool = mock(); txValidator = mock>(); txPool.getTxsByHash.mockImplementation(txHashes => Promise.resolve(txHashes.map(txHash => txPools.get(txHash.toString()))), ); - txPool.addTxs.mockImplementation(async txs => { + txPool.addPendingTxs.mockImplementation(async txs => { + const accepted: TxHash[] = []; const hashes = await Promise.all(txs.map(tx => tx.getTxHash())); - txs.forEach((tx, index) => txPools.set(hashes[index].toString(), tx)); - return Promise.resolve(txs.length); + txs.forEach((tx, index) => { + txPools.set(hashes[index].toString(), tx); + accepted.push(hashes[index]); + }); + return Promise.resolve({ accepted, ignored: [], rejected: [] }); + }); + + txPool.addProtectedTxs.mockImplementation(txs => { + for (const tx of txs) { + txPools.set(tx.getTxHash().toString(), tx); + } + return Promise.resolve(); }); txCollection.collectFastFor.mockImplementation((_request, txHashes) => { diff --git a/yarn-project/p2p/src/services/tx_provider.ts b/yarn-project/p2p/src/services/tx_provider.ts index ea0f5edb7193..7279609db8cd 100644 --- a/yarn-project/p2p/src/services/tx_provider.ts +++ b/yarn-project/p2p/src/services/tx_provider.ts @@ -5,13 +5,13 @@ import { elapsed } from '@aztec/foundation/timer'; import type { L2Block, L2BlockInfo } from '@aztec/stdlib/block'; import type { ITxProvider } from '@aztec/stdlib/interfaces/server'; import type { BlockProposal } from '@aztec/stdlib/p2p'; -import { Tx, TxHash } from '@aztec/stdlib/tx'; +import { type BlockHeader, Tx, TxHash } from '@aztec/stdlib/tx'; import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-client'; import type { PeerId } from '@libp2p/interface'; import type { P2PClient } from '../client/p2p_client.js'; -import type { TxPool } from '../mem_pools/index.js'; +import type { TxPoolV2 } from '../mem_pools/tx_pool_v2/interfaces.js'; import type { FastCollectionRequestInput, TxCollection } from './tx_collection/tx_collection.js'; import { TxProviderInstrumentation } from './tx_provider_instrumentation.js'; @@ -24,7 +24,7 @@ export class TxProvider implements ITxProvider { constructor( private txCollection: TxCollection, - private txPool: TxPool, + private txPool: TxPoolV2, private txValidator: Pick, private log: Logger = createLogger('p2p:tx-collector'), client: TelemetryClient = getTelemetryClient(), @@ -144,6 +144,7 @@ export class TxProvider implements ITxProvider { // Take txs from the proposal body if there are any // Note that we still have to validate these txs, but we do it in parallel with tx collection const proposal = request.type === 'proposal' ? request.blockProposal : undefined; + const proposalBlockHeader = proposal?.blockHeader; const txsFromProposal = this.extractFromProposal(proposal, [...missingTxHashes]); if (txsFromProposal.length > 0) { this.instrumentation.incTxsFromProposals(txsFromProposal.length); @@ -155,7 +156,7 @@ export class TxProvider implements ITxProvider { } if (missingTxHashes.size === 0) { - await this.processProposalTxs(txsFromProposal); + await this.processProposalTxs(txsFromProposal, proposalBlockHeader!); this.instrumentation.incTxsFromP2P(0, txHashes.length); return { txsFromMempool, txsFromProposal }; } @@ -163,7 +164,7 @@ export class TxProvider implements ITxProvider { // Start tx collection from the network if needed, while we validate the txs taken from the proposal in parallel const [txsFromNetwork] = await Promise.all([ this.collectFromP2P(request, [...missingTxHashes], opts), - this.processProposalTxs(txsFromProposal), + this.processProposalTxs(txsFromProposal, proposalBlockHeader!), ] as const); if (txsFromNetwork.length > 0) { @@ -222,11 +223,11 @@ export class TxProvider implements ITxProvider { return compactArray(proposal.txs ?? []).filter(tx => missingTxHashes.includes(tx.getTxHash().toString())); } - private async processProposalTxs(txs: Tx[]): Promise { + private async processProposalTxs(txs: Tx[], blockHeader: BlockHeader): Promise { if (txs.length === 0) { return; } await this.txValidator.validate(txs); - await this.txPool.addTxs(txs); + await this.txPool.addProtectedTxs(txs, blockHeader); } } diff --git a/yarn-project/p2p/src/test-helpers/make-test-p2p-clients.ts b/yarn-project/p2p/src/test-helpers/make-test-p2p-clients.ts index c49d29c41c7e..b0c69cb7ebe5 100644 --- a/yarn-project/p2p/src/test-helpers/make-test-p2p-clients.ts +++ b/yarn-project/p2p/src/test-helpers/make-test-p2p-clients.ts @@ -13,7 +13,7 @@ import { createP2PClient } from '../client/index.js'; import type { P2PClient } from '../client/p2p_client.js'; import type { P2PConfig } from '../config.js'; import type { AttestationPool } from '../mem_pools/attestation_pool/attestation_pool.js'; -import type { TxPool } from '../mem_pools/tx_pool/index.js'; +import type { TxPoolV2 } from '../mem_pools/tx_pool_v2/interfaces.js'; import { generatePeerIdPrivateKeys } from '../test-helpers/generate-peer-id-private-keys.js'; import { getPorts } from './get-ports.js'; import { makeEnrs } from './make-enrs.js'; @@ -22,7 +22,7 @@ import { AlwaysFalseCircuitVerifier, AlwaysTrueCircuitVerifier } from './reqresp export interface MakeTestP2PClientOptions { mockAttestationPool: AttestationPool; - mockTxPool: TxPool; + mockTxPool: TxPoolV2; mockEpochCache: EpochCache; mockWorldState: WorldStateSynchronizer; alwaysTrueVerifier?: boolean; @@ -108,7 +108,7 @@ export async function makeTestP2PClient( undefined, undefined, { - txPool: mockTxPool as unknown as TxPool, + txPool: mockTxPool as unknown as TxPoolV2, attestationPool: mockAttestationPool as unknown as AttestationPool, store: kvStore, logger, diff --git a/yarn-project/p2p/src/test-helpers/mock-pubsub.ts b/yarn-project/p2p/src/test-helpers/mock-pubsub.ts index 610eeca44dd8..12e227d9461c 100644 --- a/yarn-project/p2p/src/test-helpers/mock-pubsub.ts +++ b/yarn-project/p2p/src/test-helpers/mock-pubsub.ts @@ -19,8 +19,20 @@ import { import type { P2PConfig } from '../config.js'; import type { MemPools } from '../mem_pools/interface.js'; -import { DummyPeerDiscoveryService, DummyPeerManager, DummyReqResp, LibP2PService } from '../services/index.js'; -import type { ReqRespInterface } from '../services/reqresp/interface.js'; +import { DummyPeerDiscoveryService, DummyPeerManager, LibP2PService } from '../services/index.js'; +import type { P2PReqRespConfig } from '../services/reqresp/config.js'; +import type { ConnectionSampler } from '../services/reqresp/connection-sampler/connection_sampler.js'; +import { + type ReqRespInterface, + type ReqRespResponse, + type ReqRespSubProtocol, + type ReqRespSubProtocolHandler, + type ReqRespSubProtocolHandlers, + type ReqRespSubProtocolValidators, + type SubProtocolMap, + responseFromBuffer, +} from '../services/reqresp/interface.js'; +import { ReqRespStatus } from '../services/reqresp/status.js'; import { GossipSubEvent } from '../types/index.js'; import type { PubSubLibp2p } from '../util.js'; @@ -52,7 +64,7 @@ export function getMockPubSubP2PServiceFactory( deps.logger.verbose('Creating mock PubSub service'); const libp2p = new MockPubSub(peerId, network); const peerManager = new DummyPeerManager(peerId, network); - const reqresp: ReqRespInterface = new DummyReqResp(); + const reqresp: ReqRespInterface = new MockReqResp(peerId, network); const peerDiscoveryService = new DummyPeerDiscoveryService(); const service = new LibP2PService( clientType as T, @@ -74,6 +86,115 @@ export function getMockPubSubP2PServiceFactory( }; } +/** + * Mock implementation of ReqRespInterface that routes requests to other peers' handlers through the mock network. + * When a peer calls sendBatchRequest, the mock iterates over network peers and invokes their registered handler + * for the sub-protocol, simulating the request-response protocol without actual libp2p streams. + */ +class MockReqResp implements ReqRespInterface { + private handlers: Partial = {}; + private logger = createLogger('p2p:test:mock-reqresp'); + + constructor( + private peerId: PeerId, + private network: MockGossipSubNetwork, + ) { + network.registerReqRespPeer(this); + } + + updateConfig(_config: Partial): void {} + + start( + subProtocolHandlers: Partial, + _subProtocolValidators: ReqRespSubProtocolValidators, + ): Promise { + Object.assign(this.handlers, subProtocolHandlers); + return Promise.resolve(); + } + + addSubProtocol( + subProtocol: ReqRespSubProtocol, + handler: ReqRespSubProtocolHandler, + _validator?: ReqRespSubProtocolValidators[ReqRespSubProtocol], + ): Promise { + this.handlers[subProtocol] = handler; + return Promise.resolve(); + } + + stop(): Promise { + this.handlers = {}; + return Promise.resolve(); + } + + getHandler(subProtocol: ReqRespSubProtocol): ReqRespSubProtocolHandler | undefined { + return this.handlers[subProtocol]; + } + + async sendBatchRequest( + subProtocol: SubProtocol, + requests: InstanceType[], + pinnedPeer: PeerId | undefined, + _timeoutMs?: number, + _maxPeers?: number, + _maxRetryAttempts?: number, + ): Promise[]> { + const responses: InstanceType[] = []; + const peers = this.network.getReqRespPeers().filter(p => !p.peerId.equals(this.peerId)); + const targetPeers = pinnedPeer ? peers.filter(p => p.peerId.equals(pinnedPeer)) : peers; + + for (const request of requests) { + const requestBuffer = request.toBuffer(); + for (const peer of targetPeers) { + const handler = peer.getHandler(subProtocol); + if (!handler) { + continue; + } + try { + const responseBuffer = await handler(this.peerId, requestBuffer); + if (responseBuffer.length > 0) { + const response = responseFromBuffer(subProtocol, responseBuffer); + responses.push(response as InstanceType); + break; + } + } catch (err) { + this.logger.debug(`Mock reqresp handler error from peer ${peer.peerId}`, { err }); + } + } + } + + return responses; + } + + async sendRequestToPeer( + peerId: PeerId, + subProtocol: ReqRespSubProtocol, + payload: Buffer, + _dialTimeout?: number, + ): Promise { + const peer = this.network.getReqRespPeers().find(p => p.peerId.equals(peerId)); + const handler = peer?.getHandler(subProtocol); + if (!handler) { + return { status: ReqRespStatus.SUCCESS, data: Buffer.from([]) }; + } + try { + const data = await handler(this.peerId, payload); + return { status: ReqRespStatus.SUCCESS, data }; + } catch { + return { status: ReqRespStatus.FAILURE }; + } + } + + getConnectionSampler(): Pick { + return { + getPeerListSortedByConnectionCountAsc: () => + this.network + .getReqRespPeers() + .filter(p => !p.peerId.equals(this.peerId)) + .map(p => p.peerId), + }; + } +} + /** * Implementation of PubSub services that relies on a mock gossip sub network. * This is used in tests to simulate a gossip sub network without needing a real P2P network. @@ -157,6 +278,7 @@ class MockGossipSubService extends TypedEventEmitter implements */ export class MockGossipSubNetwork { private peers: MockGossipSubService[] = []; + private reqRespPeers: MockReqResp[] = []; private nextMsgId = 0; private logger = createLogger('p2p:test:mock-gossipsub-network'); @@ -169,6 +291,14 @@ export class MockGossipSubNetwork { this.peers.push(peer); } + public registerReqRespPeer(peer: MockReqResp): void { + this.reqRespPeers.push(peer); + } + + public getReqRespPeers(): MockReqResp[] { + return this.reqRespPeers; + } + public publishToPeers(topic: TopicStr, data: Uint8Array, sender: PeerId): void { const msgId = (this.nextMsgId++).toString(); this.logger.debug(`Network is distributing message on topic ${topic}`, { diff --git a/yarn-project/p2p/src/test-helpers/testbench-utils.ts b/yarn-project/p2p/src/test-helpers/testbench-utils.ts index 3f2ec8ee5906..2afb25e9f37f 100644 --- a/yarn-project/p2p/src/test-helpers/testbench-utils.ts +++ b/yarn-project/p2p/src/test-helpers/testbench-utils.ts @@ -1,6 +1,7 @@ import type { EpochCacheInterface } from '@aztec/epoch-cache'; -import { type BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import type { Logger } from '@aztec/foundation/log'; +import type { L2Block, L2BlockId } from '@aztec/stdlib/block'; import type { WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import type { BlockProposal, @@ -13,14 +14,16 @@ import { type BlockHeader, Tx, TxHash } from '@aztec/stdlib/tx'; import EventEmitter from 'events'; import type { TryAddResult } from '../mem_pools/attestation_pool/attestation_pool.js'; -import type { TxPool } from '../mem_pools/tx_pool/index.js'; +import type { AddTxsResult, TxPoolV2, TxPoolV2Config } from '../mem_pools/tx_pool_v2/interfaces.js'; +import type { TxState } from '../mem_pools/tx_pool_v2/tx_metadata.js'; import { RateLimitStatus } from '../services/reqresp/rate-limiter/rate_limiter.js'; /** * In-memory TxPool implementation for testing. * Provides basic tx storage without persistence. + * Implements TxPoolV2 interface with stub implementations for testing. */ -export class InMemoryTxPool extends EventEmitter implements TxPool { +export class InMemoryTxPool extends EventEmitter implements TxPoolV2 { private txsByHash = new Map(); private logger: Logger | null = null; @@ -54,63 +57,106 @@ export class InMemoryTxPool extends EventEmitter implements TxPool { this.removeAllListeners(); } - addTxs(txs: Tx[], opts?: { source?: string }): Promise { + // === Core Operations (TxPoolV2) === + + addPendingTxs(txs: Tx[], opts?: { source?: string }): Promise { + const accepted: TxHash[] = []; const newTxs: Tx[] = []; - let added = 0; for (const tx of txs) { const key = tx.getTxHash().toString(); if (!this.txsByHash.has(key)) { newTxs.push(tx); - added += 1; + accepted.push(tx.getTxHash()); } this.txsByHash.set(key, tx); } if (newTxs.length > 0) { this.emit('txs-added', { txs: newTxs, source: opts?.source }); } - return Promise.resolve(added); + return Promise.resolve({ accepted, ignored: [], rejected: [] }); } - getTxByHash(hash: TxHash): Promise { - return Promise.resolve(this.txsByHash.get(hash.toString())); + canAddPendingTx(tx: Tx): Promise<'accepted' | 'ignored' | 'rejected'> { + const key = tx.getTxHash().toString(); + if (this.txsByHash.has(key)) { + return Promise.resolve('ignored'); + } + return Promise.resolve('accepted'); } - getTxsByHash(hashes: TxHash[]): Promise<(Tx | undefined)[]> { - const result = hashes.map(h => this.txsByHash.get(h.toString())); - const found = result.filter(tx => tx !== undefined).length; - this.logger?.debug(`[TxPool] getTxsByHash: requested ${hashes.length}, found ${found}`); - return Promise.resolve(result); + addProtectedTxs(txs: Tx[], _block: BlockHeader, opts?: { source?: string }): Promise { + for (const tx of txs) { + const key = tx.getTxHash().toString(); + this.txsByHash.set(key, tx); + } + if (txs.length > 0) { + this.emit('txs-added', { txs, source: opts?.source }); + } + return Promise.resolve(); } - hasTxs(hashes: TxHash[]): Promise { - return Promise.resolve(hashes.map(h => this.txsByHash.has(h.toString()))); + protectTxs(txHashes: TxHash[], _block: BlockHeader): Promise { + const notFound: TxHash[] = []; + for (const txHash of txHashes) { + if (!this.txsByHash.has(txHash.toString())) { + notFound.push(txHash); + } + } + return Promise.resolve(notFound); } - hasTx(hash: TxHash): Promise { - return Promise.resolve(this.txsByHash.has(hash.toString())); + addMinedTxs(txs: Tx[], _block: BlockHeader, _opts?: { source?: string }): Promise { + for (const tx of txs) { + const key = tx.getTxHash().toString(); + this.txsByHash.set(key, tx); + } + return Promise.resolve(); } - getArchivedTxByHash(_hash: TxHash): Promise { - return Promise.resolve(undefined); + // === State Transition Handlers (TxPoolV2) === + + handleMinedBlock(_block: L2Block): Promise { + return Promise.resolve(); } - async markAsMined(_txHashes: TxHash[], _blockHeader: BlockHeader): Promise {} + prepareForSlot(_slotNumber: SlotNumber): Promise { + return Promise.resolve(); + } - async markMinedAsPending(_txHashes: TxHash[], _latestBlock: BlockNumber): Promise {} + handlePrunedBlocks(_latestBlock: L2BlockId): Promise { + return Promise.resolve(); + } - deleteTxs(txHashes: TxHash[], _opts?: { permanently?: boolean }): Promise { + handleFailedExecution(txHashes: TxHash[]): Promise { for (const txHash of txHashes) { this.txsByHash.delete(txHash.toString()); } return Promise.resolve(); } - getAllTxs(): Promise { - return Promise.resolve([...this.txsByHash.values()]); + handleFinalizedBlock(_block: BlockHeader): Promise { + return Promise.resolve(); } - getAllTxHashes(): Promise { - return Promise.resolve([...this.txsByHash.keys()].map(key => TxHash.fromString(key))); + // === Query Operations (TxPoolV2) === + + getTxByHash(hash: TxHash): Promise { + return Promise.resolve(this.txsByHash.get(hash.toString())); + } + + getTxsByHash(hashes: TxHash[]): Promise<(Tx | undefined)[]> { + const result = hashes.map(h => this.txsByHash.get(h.toString())); + const found = result.filter(tx => tx !== undefined).length; + this.logger?.debug(`[TxPool] getTxsByHash: requested ${hashes.length}, found ${found}`); + return Promise.resolve(result); + } + + hasTxs(hashes: TxHash[]): Promise { + return Promise.resolve(hashes.map(h => this.txsByHash.has(h.toString()))); + } + + getArchivedTxByHash(_hash: TxHash): Promise { + return Promise.resolve(undefined); } getPendingTxHashes(): Promise { @@ -121,26 +167,40 @@ export class InMemoryTxPool extends EventEmitter implements TxPool { return Promise.resolve(this.txsByHash.size); } - getMinedTxHashes(): Promise<[tx: TxHash, blockNumber: BlockNumber][]> { + getMinedTxHashes(): Promise<[TxHash, L2BlockId][]> { return Promise.resolve([]); } - getTxStatus(hash: TxHash): Promise<'pending' | 'mined' | 'deleted' | undefined> { - return Promise.resolve(this.txsByHash.has(hash.toString()) ? 'pending' : undefined); + getMinedTxCount(): Promise { + return Promise.resolve(0); } - updateConfig(_config: { maxPendingTxCount?: number; archivedTxLimit?: number }): void {} + getTxStatus(hash: TxHash): Promise { + return Promise.resolve(this.txsByHash.has(hash.toString()) ? 'pending' : undefined); + } isEmpty(): Promise { return Promise.resolve(this.txsByHash.size === 0); } - async markTxsAsNonEvictable(_txHashes: TxHash[]): Promise {} + getLowestPriorityPending(_limit: number): Promise { + return Promise.resolve([]); + } - async clearNonEvictableTxs(): Promise {} + // === Configuration (TxPoolV2) === - cleanupDeletedMinedTxs(_blockNumber: BlockNumber): Promise { - return Promise.resolve(0); + updateConfig(_config: Partial): Promise { + return Promise.resolve(); + } + + // === Lifecycle (TxPoolV2) === + + start(): Promise { + return Promise.resolve(); + } + + stop(): Promise { + return Promise.resolve(); } } diff --git a/yarn-project/p2p/src/testbench/p2p_client_testbench_worker.ts b/yarn-project/p2p/src/testbench/p2p_client_testbench_worker.ts index 77bf3d47338b..64971c783d56 100644 --- a/yarn-project/p2p/src/testbench/p2p_client_testbench_worker.ts +++ b/yarn-project/p2p/src/testbench/p2p_client_testbench_worker.ts @@ -144,7 +144,7 @@ class TestLibP2PService extends Li const txHash = tx.getTxHash(); const txHashString = txHash.toString(); this.logger.verbose(`Received tx ${txHashString} from external peer ${source.toString()}.`); - await this.mempools.txPool.addTxs([tx]); + await this.mempools.txPool.addPendingTxs([tx]); } else { await super.handleGossipedTx(payload, msgId, source); } diff --git a/yarn-project/prover-node/src/factory.ts b/yarn-project/prover-node/src/factory.ts index 6f74ee485d3c..dd8b755985f1 100644 --- a/yarn-project/prover-node/src/factory.ts +++ b/yarn-project/prover-node/src/factory.ts @@ -16,7 +16,7 @@ import { createForwarderL1TxUtilsFromEthSigner, createL1TxUtilsFromEthSignerWithStore, } from '@aztec/node-lib/factories'; -import { NodeRpcTxSource, createP2PClient } from '@aztec/p2p'; +import { NodeRpcTxSource, type P2PClientDeps, createP2PClient } from '@aztec/p2p'; import { type ProverClientConfig, createProverClient } from '@aztec/prover-client'; import { createAndStartProvingBroker } from '@aztec/prover-client/broker'; import type { AztecNode, ProvingJobBroker } from '@aztec/stdlib/interfaces/server'; @@ -42,6 +42,7 @@ export type ProverNodeDeps = { broker?: ProvingJobBroker; l1TxUtils?: L1TxUtils; dateProvider?: DateProvider; + p2pClientDeps?: P2PClientDeps; }; /** Creates a new prover node given a config. */ @@ -175,9 +176,11 @@ export async function createProverNode( dateProvider, telemetry, { - txCollectionNodeSources: deps.aztecNodeTxProvider - ? [new NodeRpcTxSource(deps.aztecNodeTxProvider, 'TestNode')] - : [], + ...deps.p2pClientDeps, + txCollectionNodeSources: [ + ...(deps.p2pClientDeps?.txCollectionNodeSources ?? []), + ...(deps.aztecNodeTxProvider ? [new NodeRpcTxSource(deps.aztecNodeTxProvider, 'TestNode')] : []), + ], }, ); diff --git a/yarn-project/prover-node/src/job/epoch-proving-job.test.ts b/yarn-project/prover-node/src/job/epoch-proving-job.test.ts index 3f572901e304..93a5920cb80d 100644 --- a/yarn-project/prover-node/src/job/epoch-proving-job.test.ts +++ b/yarn-project/prover-node/src/job/epoch-proving-job.test.ts @@ -1,6 +1,7 @@ import { BatchedBlob } from '@aztec/blob-lib/types'; import { BlockNumber, CheckpointNumber, EpochNumber } from '@aztec/foundation/branded-types'; import { fromEntries, times, timesParallel } from '@aztec/foundation/collection'; +import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { toArray } from '@aztec/foundation/iterable'; import { sleep } from '@aztec/foundation/sleep'; @@ -12,6 +13,7 @@ import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import type { EpochProver, MerkleTreeWriteOperations, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import { Proof } from '@aztec/stdlib/proofs'; import { RootRollupPublicInputs } from '@aztec/stdlib/rollup'; +import { MerkleTreeId } from '@aztec/stdlib/trees'; import type { ProcessedTx, Tx } from '@aztec/stdlib/tx'; import { BlockHeader } from '@aztec/stdlib/tx'; import { getTelemetryClient } from '@aztec/telemetry-client'; @@ -237,4 +239,41 @@ describe('epoch-proving-job', () => { expect(prover.finalizeEpoch).toHaveBeenCalled(); expect(publisher.submitEpochProof).not.toHaveBeenCalled(); }); + + it('inserts L1 to L2 messages into the message tree only for the first block of each checkpoint', async () => { + const l1ToL2Messages: Record = fromEntries( + checkpoints.map(c => [c.number, [Fr.random(), Fr.random()]]), + ); + + const txsMap = new Map(txs.map(tx => [tx.getTxHash().toString(), tx])); + const data: EpochProvingJobData = { + checkpoints, + txs: txsMap, + epochNumber: EpochNumber(epochNumber), + l1ToL2Messages, + previousBlockHeader: initialHeader, + attestations, + }; + + const job = new EpochProvingJob( + data, + worldState, + prover, + publicProcessorFactory, + publisher, + l2BlockSource, + metrics, + undefined, + { parallelBlockLimit: 32 }, + ); + + await job.run(); + + expect(job.getState()).toEqual('completed'); + + // appendLeaves should be called once per checkpoint (for the first block only), not once per block + const appendLeavesCalls = db.appendLeaves.mock.calls.filter(call => call[0] === MerkleTreeId.L1_TO_L2_MESSAGE_TREE); + expect(appendLeavesCalls).toHaveLength(NUM_CHECKPOINTS); + expect(appendLeavesCalls).not.toHaveLength(NUM_BLOCKS); + }); }); diff --git a/yarn-project/prover-node/src/job/epoch-proving-job.ts b/yarn-project/prover-node/src/job/epoch-proving-job.ts index 0546940e4806..8983a5047bdb 100644 --- a/yarn-project/prover-node/src/job/epoch-proving-job.ts +++ b/yarn-project/prover-node/src/job/epoch-proving-job.ts @@ -193,7 +193,8 @@ export class EpochProvingJob implements Traceable { previousHeader, ); - for (const block of checkpoint.blocks) { + for (let blockIndex = 0; blockIndex < checkpoint.blocks.length; blockIndex++) { + const block = checkpoint.blocks[blockIndex]; const globalVariables = block.header.globalVariables; const txs = this.getTxs(block); @@ -211,8 +212,12 @@ export class EpochProvingJob implements Traceable { // Start block proving await this.prover.startNewBlock(block.number, globalVariables.timestamp, txs.length); - // Process public fns - const db = await this.createFork(BlockNumber(block.number - 1), l1ToL2Messages); + // Process public fns. L1 to L2 messages are only inserted for the first block of a checkpoint, + // as the fork for subsequent blocks already includes them from the previous block's synced state. + const db = await this.createFork( + BlockNumber(block.number - 1), + blockIndex === 0 ? l1ToL2Messages : undefined, + ); const config = PublicSimulatorConfig.from({ proverId: this.prover.getProverId().toField(), skipFeeEnforcement: false, @@ -295,22 +300,29 @@ export class EpochProvingJob implements Traceable { } /** - * Create a new db fork for tx processing, inserting all L1 to L2. + * Create a new db fork for tx processing, optionally inserting L1 to L2 messages. + * L1 to L2 messages should only be inserted for the first block in a checkpoint, + * as subsequent blocks' synced state already includes them. * REFACTOR: The prover already spawns a db fork of its own for each block, so we may be able to do away with just one fork. */ - private async createFork(blockNumber: BlockNumber, l1ToL2Messages: Fr[]) { + private async createFork(blockNumber: BlockNumber, l1ToL2Messages: Fr[] | undefined) { + this.log.verbose(`Creating fork at ${blockNumber}`, { blockNumber }); const db = await this.dbProvider.fork(blockNumber); - const l1ToL2MessagesPadded = padArrayEnd( - l1ToL2Messages, - Fr.ZERO, - NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP, - 'Too many L1 to L2 messages', - ); - this.log.verbose(`Creating fork at ${blockNumber} with ${l1ToL2Messages.length} L1 to L2 messages`, { - blockNumber, - l1ToL2Messages: l1ToL2Messages.map(m => m.toString()), - }); - await db.appendLeaves(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, l1ToL2MessagesPadded); + + if (l1ToL2Messages !== undefined) { + this.log.verbose(`Inserting ${l1ToL2Messages.length} L1 to L2 messages in fork`, { + blockNumber, + l1ToL2Messages: l1ToL2Messages.map(m => m.toString()), + }); + const l1ToL2MessagesPadded = padArrayEnd( + l1ToL2Messages, + Fr.ZERO, + NUMBER_OF_L1_L2_MESSAGES_PER_ROLLUP, + 'Too many L1 to L2 messages', + ); + await db.appendLeaves(MerkleTreeId.L1_TO_L2_MESSAGE_TREE, l1ToL2MessagesPadded); + } + return db; } diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts index fdba28718c27..a9d5ad82750c 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts @@ -830,7 +830,7 @@ describe('CheckpointProposalJob', () => { }); expect(checkpoint).toBeUndefined(); - expect(p2p.deleteTxs).toHaveBeenCalledWith(failedTxs.map(ftx => ftx.tx.txHash)); + expect(p2p.handleFailedExecution).toHaveBeenCalledWith(failedTxs.map(ftx => ftx.tx.txHash)); }); it('does not build a block if checkpoint builder fails with invalid txs', async () => { @@ -852,7 +852,7 @@ describe('CheckpointProposalJob', () => { }); expect(checkpoint).toBeUndefined(); - expect(p2p.deleteTxs).toHaveBeenCalledWith(failedTxs.map(ftx => ftx.tx.txHash)); + expect(p2p.handleFailedExecution).toHaveBeenCalledWith(failedTxs.map(ftx => ftx.tx.txHash)); }); }); 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 1e763d519286..1da1d7c3100f 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -779,7 +779,7 @@ export class CheckpointProposalJob implements Traceable { const failedTxData = failedTxs.map(fail => fail.tx); const failedTxHashes = failedTxData.map(tx => tx.getTxHash()); this.log.verbose(`Dropping failed txs ${failedTxHashes.join(', ')}`); - await this.p2pClient.deleteTxs(failedTxHashes); + await this.p2pClient.handleFailedExecution(failedTxHashes); } /** diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 508ffbaf8344..fd14e9b12e4a 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -376,6 +376,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter implements TxValidator { +export class EmptyTxValidator implements TxValidator { public validateTx(_tx: T): Promise { return Promise.resolve({ result: 'valid' }); } diff --git a/yarn-project/stdlib/src/tx/validator/tx_validator.ts b/yarn-project/stdlib/src/tx/validator/tx_validator.ts index 7942f4095651..5851591004f1 100644 --- a/yarn-project/stdlib/src/tx/validator/tx_validator.ts +++ b/yarn-project/stdlib/src/tx/validator/tx_validator.ts @@ -20,7 +20,7 @@ export type TxValidationResult = | { result: 'invalid'; reason: string[] } | { result: 'skipped'; reason: string[] }; -export interface TxValidator { +export interface TxValidator { validateTx(tx: T): Promise; } diff --git a/yarn-project/txe/src/state_machine/dummy_p2p_client.ts b/yarn-project/txe/src/state_machine/dummy_p2p_client.ts index 452b5d1d8c5a..f1b02767d255 100644 --- a/yarn-project/txe/src/state_machine/dummy_p2p_client.ts +++ b/yarn-project/txe/src/state_machine/dummy_p2p_client.ts @@ -18,7 +18,7 @@ import type { import type { EthAddress, L2BlockStreamEvent, L2Tips } from '@aztec/stdlib/block'; import type { PeerInfo } from '@aztec/stdlib/interfaces/server'; import type { BlockProposal, CheckpointAttestation, CheckpointProposal } from '@aztec/stdlib/p2p'; -import type { Tx, TxHash } from '@aztec/stdlib/tx'; +import type { BlockHeader, Tx, TxHash } from '@aztec/stdlib/tx'; export class DummyP2P implements P2P { public validate(_txs: Tx[]): Promise { @@ -73,8 +73,8 @@ export class DummyP2P implements P2P { throw new Error('DummyP2P does not implement "sendTx"'); } - public deleteTxs(_txHashes: TxHash[]): Promise { - throw new Error('DummyP2P does not implement "deleteTxs"'); + public handleFailedExecution(_txHashes: TxHash[]): Promise { + throw new Error('DummyP2P does not implement "handleFailedExecution"'); } public getTxByHashFromPool(_txHash: TxHash): Promise { @@ -159,14 +159,6 @@ export class DummyP2P implements P2P { throw new Error('DummyP2P does not implement "sync"'); } - public requestTxsByHash(_txHashes: TxHash[]): Promise { - throw new Error('DummyP2P does not implement "requestTxsByHash"'); - } - - public getTxs(_filter: 'all' | 'pending' | 'mined'): Promise { - throw new Error('DummyP2P does not implement "getTxs"'); - } - public getTxsByHashFromPool(_txHashes: TxHash[]): Promise<(Tx | undefined)[]> { throw new Error('DummyP2P does not implement "getTxsByHashFromPool"'); } @@ -191,8 +183,12 @@ export class DummyP2P implements P2P { throw new Error('DummyP2P does not implement "getSyncedLatestSlot"'); } - markTxsAsNonEvictable(_: TxHash[]): Promise { - throw new Error('DummyP2P does not implement "markTxsAsNonEvictable".'); + protectTxs(_txHashes: TxHash[], _blockHeader: BlockHeader): Promise { + throw new Error('DummyP2P does not implement "protectTxs".'); + } + + prepareForSlot(_slotNumber: SlotNumber): Promise { + return Promise.resolve(); } addReqRespSubProtocol(