Skip to content

fix(exit-certificate): implement ERC-20 balance storage patching for SC-locked exits (F-01)#1622

Open
joanestebanr wants to merge 1 commit into
feature/exit-certificate-toolfrom
feat/exit_certificate_f01_token_sclocked
Open

fix(exit-certificate): implement ERC-20 balance storage patching for SC-locked exits (F-01)#1622
joanestebanr wants to merge 1 commit into
feature/exit-certificate-toolfrom
feat/exit_certificate_f01_token_sclocked

Conversation

@joanestebanr
Copy link
Copy Markdown
Collaborator

@joanestebanr joanestebanr commented May 21, 2026

🔄 Changes Summary

Fix F-01: ensureERC20Balance storage patching for SC-locked ERC-20 exits

  • Implements ensureERC20Balance in Step G, which was a stub that always returned an error without actually patching Anvil storage.
  • The function now patches _balances[account] via hardhat_setStorageAt using a two-layout detection strategy, verifying balanceOf after each attempt:
    1. OZ v4 non-upgradeable: _balances mapping at storage slot 0
    2. OZ v5 upgradeable: _balances inside the namespaced ERC20Storage struct at ERC20StorageLocation = 0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00
  • Adds a package-level erc20NamespacedStorageLocation constant documenting the OZ v5 storage namespace derivation.

Refactor: remove lbtFile config option

  • lbtFile was an escape hatch to skip Step 0 by providing a pre-generated LBT. Step 0 now always runs, so the field is removed.
  • Merges RunStepCWithEntries back into RunStepC (simpler API, no config dependency).
  • Updates resolveOrGenerateLBT, loadWrappedTokensFromLBT, runSingleC, runSingleB, runSingleF, runSingleG accordingly.

Kurtosis script improvements

  • Adds two helper scripts for the Kurtosis test environment.
  • Embeds a pre-generated exit address keypair (0xe25f5B65E4976025f670e52b790a9746F27A3DB6) in configuration_based_on_kurtosis.sh so the exit address is stable across runs without requiring Foundry at script runtime. The private key and an encrypted keystore (password: test) are written to tmp/ on first execution.

⚠️ Breaking Changes

  • lbtFile config field removed. Any existing config files using it will have the field silently ignored (unknown JSON fields are not errors, but Step 0 will now always run).

📋 Config Updates

  • lbtFile removed from Config and rawConfig. Step 0 is no longer skippable via config.

✅ Testing

  • 🤖 Automatic: Existing unit tests pass (ok github.com/agglayer/aggkit/tools/exit_certificate)
  • 🖱️ Manual:
    1. Run tools/exit_certificate/scripts/reproduce_sc_locked.sh against a live Kurtosis aggkit enclave
    2. The script deploys a TokenHolder smart contract on L2, transfers wrapped ERC-20 tokens to it, then drives the exit-certificate tool from steps 0→G
    3. Before fix: Step G failed with ERC20InsufficientBalance (0xe450d38c) when replaying the SC-locked BridgeExit
    4. After fix: Step G completes and emits a valid NewLocalExitRoot

🐞 Issues

  • Fixes F-01: ensureERC20Balance always errors for SC-locked ERC-20 exits

🔗 Related PRs

  • N/A

📝 Notes

  • TokenWrapped (wTTK) deployed by AgglayerBridge uses OZ v5 upgradeable storage, so the slot-0 attempt is a no-op. The second candidate (namespaced storage) is the one that matches and patches the balance correctly.
  • The two-layout approach is safe: a failed slot write leaves the token balance unchanged and the loop moves to the next candidate. If neither layout works the function returns a descriptive error.

…for SC-locked ERC-20 exits (F-01)

`ensureERC20Balance` was a stub that always returned an error without
patching Anvil storage.  This implements two-layout detection via
`hardhat_setStorageAt`:
  1. OZ v4 non-upgradeable: `_balances` at mapping slot 0
  2. OZ v5 upgradeable: `_balances` inside the namespaced `ERC20Storage`
     struct at `ERC20StorageLocation` (0x52c63...ce00)

After each attempt the function reads `balanceOf` to verify the patch
succeeded before trying the next layout.  Wrapped tokens deployed by
AgglayerBridge (wTTK) use OZ v5 upgradeable storage, so the slot-0
attempt is a no-op and the second candidate fixes the balance.

Also adds two helper scripts:
- `scripts/bridge_l1_to_l2.sh`: bridges ETH/ERC-20 from L1→L2 in a
  running Kurtosis `aggkit` enclave
- `scripts/reproduce_sc_locked.sh`: end-to-end reproducer for F-01
  (deploys a TokenHolder SC on L2, transfers wTTK to it, then drives
  the exit-certificate tool through steps 0→G to confirm the fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented May 21, 2026

Claude finished @joanestebanr's task in 2m 6s —— View job


Code Review Complete ✓


🚀 What's New

This PR implements proper ERC-20 balance storage patching for smart-contract-locked exits in Step G. The ensureERC20Balance function now correctly patches Anvil storage using a two-layout detection strategy (OpenZeppelin v4 non-upgradeable at slot 0, and OZ v5 upgradeable with namespaced storage), verifying balance via balanceOf after each attempt. Additionally, two helper scripts are provided for reproducing and testing SC-locked scenarios in Kurtosis environments.

🐛 Bug Fixes

  • F-01: Fixed ensureERC20Balance which previously always errored for SC-locked ERC-20 exits without attempting storage patching. The function now properly patches _balances[account] via hardhat_setStorageAt using layout detection.

📋 Config Updates

None

⚠️ Breaking Changes

None


🔍 Review Feedback

Step G Implementation (step_g.go)

🟢 Strengths:

  • Clean implementation of two-layout detection with verification loop
  • Well-documented storage namespace constant with derivation formula
  • Proper error handling and informative logging at each stage
  • Safe approach: failed slot writes don't corrupt state, loop just continues
  • Good separation of concerns with balanceOf closure for verification

🟡 Suggested Improvements:

  1. Consider adding unit tests (tools/exit_certificate/step_g.go:476-560)

    • The ensureERC20Balance function has complex logic with multiple code paths
    • Would benefit from table-driven tests covering:
      • Sufficient initial balance (early return)
      • OZ v4 layout match (first candidate succeeds)
      • OZ v5 layout match (second candidate succeeds)
      • No layout match (both candidates fail)
    • Consider extracting to a testable function or adding integration tests
  2. Magic number for storage location (tools/exit_certificate/step_g.go:478)

    var erc20NamespacedStorageLocation = common.HexToHash("0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00")
    • The constant is well-documented with derivation formula
    • Consider adding a comment referencing the OZ v5 source or ERC-7201 standard
    • Alternatively, could add a compile-time verification test that derives this value programmatically
  3. Error message clarity (tools/exit_certificate/step_g.go:557-559)

    return fmt.Errorf("could not patch ERC-20 balance for token %s account %s: "+
        "no storage layout matched (tried OZ v4 slot-0 and OZ v5 upgradeable)",
        tokenAddr.Hex(), account.Hex())
    • Good descriptive error, but could mention actual vs required balance for debugging
    • Suggestion: "...no storage layout matched (current: %s, required: %s, tried OZ v4 slot-0 and OZ v5 upgradeable)"
  4. Consider adding more layout candidates (tools/exit_certificate/step_g.go:535-538)

    • Current implementation covers OZ v4 and v5 upgradeable
    • May want to document known unsupported layouts or add a comment about extensibility
    • If other token implementations are encountered, the list can be easily extended

🟢 Code Quality Observations:

  • Follows Go best practices: clear naming, proper error wrapping
  • Good use of closures for balanceOf helper
  • Logging levels appropriate (Info for patching, Debug for intermediate steps)
  • Proper handling of big.Int comparisons with .Cmp()

Shell Scripts Review

reproduce_sc_locked.sh:

🟡 Suggested Improvements:

  1. Python dependency (reproduce_sc_locked.sh:121, 257, 365, 459, 474, 540)

    • Script uses python3 extensively for JSON parsing and arithmetic
    • Consider checking for python3 in check_deps() function
    • Alternatively, could use jq for JSON parsing (already common in Foundry setups)
  2. Hardcoded deposit count extraction offset (reproduce_sc_locked.sh:132)

    local deposit_count_hex="${hex_data:448:64}"
    • Magic number 448 (7*64) is documented in comment, which is good
    • This is ABI-dependent; if BridgeEvent signature changes, this breaks silently
    • Consider adding a comment with the event signature for reference
  3. Set -e with conditional logic (reproduce_sc_locked.sh:16, 447, 505)

    set -euo pipefail
    # ...later...
    set +e
    "$OUTPUT_DIR/exit-certificate" --config "$config_file" --step 0 ...
    set -e
    • Correctly uses set +e for expected failures
    • Good pattern for allowing steps to fail without exiting script
  4. Timeout handling (reproduce_sc_locked.sh:52, 92)

    • CLAIM_TIMEOUT=300 seconds is configurable via env var (good)
    • The wait loop has proper timeout and status messages
    • Well implemented

🟢 Strengths:

  • Comprehensive reproduction script with detailed logging
  • Proper error handling and dependency checks
  • Good use of environment variable overrides for flexibility
  • Clear section headers and step-by-step execution
  • Proper cleanup with trap handlers

bridge_l1_to_l2.sh:

🟢 Strengths:

  • Clean CLI interface with help text
  • Proper argument parsing with validation
  • Good defaults with environment variable overrides
  • Includes optional --wait flag for claim verification
  • Reusable across different scenarios

🟡 Minor Suggestions:

  1. Arithmetic overflow comments (bridge_l1_to_l2.sh:322)

    # bash integer arithmetic overflows for wei values > 2^63; use python3 for the comparison.
    • Good awareness of limitation and proper workaround
    • Consider noting this is only needed for very large values
  2. Fallback parsing logic (bridge_l1_to_l2.sh:269-272)

    wrapped=$(cast parse-bytes32-address "$result" 2>/dev/null || echo "")
    if [[ -z "$wrapped" ]] || [[ "$wrapped" == "0x0000000000000000000000000000000000000000" ]]; then
        wrapped=$(cast abi-decode "f()(address)" "$result" 2>/dev/null | head -1 || echo "")
    fi
    • Defensive parsing with fallback is good practice
    • Shows awareness of different RPC response formats

📊 Overall Assessment

Code Quality: ⭐⭐⭐⭐☆ (4/5)

  • Solid implementation with proper error handling
  • Well-documented constants and logic
  • Could benefit from unit tests for complex functions

Security: ⭐⭐⭐⭐⭐ (5/5)

  • Safe storage manipulation approach (verification after each write)
  • No security vulnerabilities identified
  • Proper input validation and error handling

Maintainability: ⭐⭐⭐⭐☆ (4/5)

  • Clear code structure and naming
  • Good logging for debugging
  • Scripts are well-organized with helper functions
  • Could improve with additional inline documentation for complex logic

Testing: ⭐⭐⭐☆☆ (3/5)

  • Manual testing documented with reproduction script
  • PR description states existing unit tests pass
  • Would benefit from specific unit tests for new ensureERC20Balance logic
  • Integration test coverage via reproduction script is good

✅ Approval Recommendation

This PR successfully fixes bug F-01 and implements a robust solution for SC-locked ERC-20 exits. The implementation is sound, safe, and well-documented. The suggested improvements are mostly about enhancing testability and documentation rather than fixing issues.

Recommended Actions:

  1. 🟡 Consider adding unit tests for ensureERC20Balance (not blocking)
  2. 🟢 Could add python3 to dependency check in reproduce script (nice to have)
  3. 🟢 Optional: Add reference to OZ v5 docs or ERC-7201 for storage namespace (documentation)

The PR is ready to merge as-is. The suggested improvements can be addressed in follow-up PRs if desired.


Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e71c290e54

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +531 to +543
return "0x" + hex.EncodeToString(crypto.Keccak256(preimage))
}

// Try OZ v4 (slot 0) first, then OZ v5 upgradeable (namespaced storage).
candidates := []string{
erc20BalanceSlot(common.Hash{}), // OZ v4: _balances at slot 0
erc20BalanceSlot(erc20NamespacedStorageLocation), // OZ v5 upgradeable
}

for _, slotHex := range candidates {
if _, err := singleRPC(ctx, rpcURL, "hardhat_setStorageAt",
[]any{tokenAddr.Hex(), slotHex, valueHex}, defaultRetries); err != nil {
return fmt.Errorf("set ERC-20 balance storage slot: %w", err)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize slot index before calling hardhat_setStorageAt

hardhat_setStorageAt expects the storage position as an RPC quantity, but erc20BalanceSlot always returns a zero-padded 32-byte hex string. When the computed hash starts with 0, some nodes reject it as an invalid quantity, and this function returns immediately from the first candidate instead of reaching the fallback layout, so ERC-20 balance patching fails intermittently for affected accounts/tokens.

Useful? React with 👍 / 👎.

@sonarqubecloud
Copy link
Copy Markdown

@joanestebanr joanestebanr self-assigned this May 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant