Skip to content

feat: migrate token balance migration tests to Anvil multichain harness#2109

Open
valera-grinenko-ai wants to merge 18 commits intomatter-labs:draft-v31from
valera-grinenko-ai:vg/migrate-tbm-tests-to-anvil
Open

feat: migrate token balance migration tests to Anvil multichain harness#2109
valera-grinenko-ai wants to merge 18 commits intomatter-labs:draft-v31from
valera-grinenko-ai:vg/migrate-tbm-tests-to-anvil

Conversation

@valera-grinenko-ai
Copy link
Copy Markdown

@valera-grinenko-ai valera-grinenko-ai commented Apr 2, 2026

Summary

Port the complete Token Balance Migration (TBM) and cross-base-token interop tests from zksync-era to the Anvil multichain testing framework. Stacked on #2108 (interop-b migration).

All original tests are fully covered — 0 skipped.

Changes from base PR (#2108)

  • Spec 10: 20 TBM tests covering forward/reverse migration lifecycle
  • Spec 08: 2 previously-skipped cross-base-token message tests now active (6 total, was 4)
  • Chain 14: New GW-settled chain with custom ERC20 base token
  • L2ChainAssetHandler: setMigrationNumberForTesting (test-only, onlyUpgrader)

Contract change

Added L2ChainAssetHandler.setMigrationNumberForTesting(uint256, uint256) — a test-only function gated by onlyUpgrader, following the existing pattern from GWAssetTracker.setLegacySharedBridgeAddressForLocalTesting. This enables reverse TBM testing on Anvil where the production path (bridgeBurnforwardedBridgeBurn) requires an empty priority tree (i.e. a running sequencer).

Infrastructure: Chain 14 with custom base token

Added chain 14 (GW-settled, port 4054) with a custom ERC20 base token deployed on L1 during state generation. This enables the 2 cross-base-token interop message tests that were previously skipped because all chains shared ETH.

Key infrastructure changes:

  • AnvilChainConfig gains optional baseToken field ("custom" triggers ERC20 deploy)
  • DeploymentRunner deploys TestnetERC20Token on L1 for custom-base-token chains
  • Gateway setup uses per-chain baseTokenAssetId for TBM (generalized from hardcoded ETH)
  • Interop chain registrar validates registration without assuming ETH base token

Test coverage

Spec 08 — Interop Messages (6 tests, was 4)

  • sends a base token message (direct call with value)
  • sends a native ERC20 token message (indirect call via AssetRouter)
  • sends base token to a chain with a different base token ← NEW
  • receives a message sending a base token
  • receives a message sending a native ERC20 token
  • receives base token from sending chain as bridged ERC20 ← NEW
  • sends/receives a bridged ERC20 token message (conditional on availability)

Spec 10 — TBM Lifecycle (20 tests)

Pre-migration (4): migration=0, NotInGatewayMode, execute/unbundle reverts on L1

Forward TBM (8): migration numbers, balance conservation, full TBM flow, idempotency, false assetId, non-migrated mismatch, unregistered chain

Reverse TBM (8): deposit populates GW balance, settlement layer change, bridgehub update, migration number set to 2, initiateGatewayToL1MigrationOnGateway drains balance, GWAT=2, idempotency, L2 pending confirmation

Test migration mapping — Interop Messages (source → target, all 18 tests)

Source: core/tests/ts-integration/tests/interop-b-messages.test.ts
Target: l1-contracts/test/anvil-interop/test/hardhat/08-interop-messages.spec.ts

# zksync-era test era-contracts test Notes
11 Can send cross chain messages (base token) sends a base token message sendMessage API, native deduction
12 Can send cross chain messages (native ERC20) sends a native ERC20 token message Token + native deduction
13 Can send cross chain messages (interop1 base token) sends base token to a chain with a different base token Uses chain 14 (custom ERC20 base token)
14 Can send cross chain messages (bridged ERC20) sends a bridged ERC20 token message Conditional on bridged token availability
15 Can receive a message sending a base token receives a message sending a base token Exact native delta
16 Can receive a message sending a native ERC20 token receives a message sending a native ERC20 token Exact token delta
17 Can receive a message sending the base token from the sending chain receives base token from sending chain as bridged ERC20 ETH arrives as bridged ERC20 on chain 14
18 Can receive a message sending a bridged token receives a message sending a bridged token Exact bridged token delta
Test migration mapping — TBM (source → target, all 28 tests)

Source: core/tests/highlevel-test-tools/tests/token-balance-migration.test.ts
Target: l1-contracts/test/anvil-interop/test/hardhat/10-token-balance-migration.spec.ts

# zksync-era test era-contracts test Notes
1 Correctly assigns chain token balances L1AT migration number is 0 for direct-settled chain (ETH) Checks migration=0 on chain 10 (direct-settled)
2 Cannot initiate interop before migrating to gateway cannot initiate interop before migrating to gateway (NotInGatewayMode) Same revert check on chain 10
3 Can migrate both chains to Gateway (covered by pre-generated state setup) Chains 12/13/14 are already GW-settled
4 Can deposit a token to the chain after migrating to gateway L1 deposit to GW-settled chain populates GW chain balance Deposit via gateway relay
5 Cannot initiate interop to non registered chains cannot initiate interop to non-registered chains Same revert check
6 Can initiate token balance migration to Gateway can run TBM for test token (full initiate + finalize + relay flow) Combined with test 11
7 Cannot initiate interop for non migrated tokens non-migrated tokens have assetMigrationNumber mismatch Verifies the mismatch invariant
8 Cannot withdraw tokens that have not been migrated non-migrated tokens have assetMigrationNumber mismatch Same invariant
9 Can finalize pending withdrawals after migrating to gateway (covered by specs 02, 05) Bridge specs test withdrawal finalization
10 Cannot initiate migration for a false assetId cannot initiate migration for a false assetId Same revert check
11 Can migrate token balances to gateway can run TBM for test token (full initiate + finalize + relay flow) Full flow + idempotency
12 Can withdraw tokens after migrating token balances to gateway (covered by specs 02, 05) Bridge specs
13 Correctly assigns chain token balances after migrating L1AT and GWAT migration numbers >= 1 + match for test token after TBM Split into ETH and test token checks
14-16 Interop send/execute/unbundle of migrated tokens (covered by specs 06, 07, 09) Interop specs run after TBM
17 Can migrate the second chain from gateway can update GW L2Bridgehub settlement layer mapping + can set GW chain migration number to 2 Via harness shims
18 Can initiate interop to chains migrated from gateway (covered by spec 07) Bundle spec sends to all registered chains
19 Can migrate the chain from gateway can change L2 settlement layer from GW back to L1 Via setSettlementLayerViaBootloader
20 Cannot execute interop bundle when settling on L1 cannot execute interop bundle when settling on L1 Same revert check on chain 10
21 Cannot unbundle interop bundle when settling on L1 cannot unbundle interop bundle when settling on L1 Same revert check on chain 10
22 Can withdraw tokens from the chain (covered by specs 02, 05) Bridge specs
23 Can initiate token balance migration from Gateway can initiate reverse TBM on GW (drains chainBalance) initiateGatewayToL1MigrationOnGateway
24 Can deposit a token after migrating from gateway L1 deposit to GW-settled chain populates GW chain balance Deposit test runs before reverse TBM
25 Cannot finalize pending withdrawals before finalizing TBM reverse TBM is idempotent (re-running for drained asset reverts) Balance correctly drained
26 Can migrate token balances to L1 GWAT assetMigrationNumber is 2 after reverse TBM Migration number reaches 2
27 Correctly assigns chain token balances after migrating to L1 GWAT assetMigrationNumber is 2 + L2 ETH assetMigrationNumber is still 1 Cross-layer consistency
28 Can finalize pending withdrawals after migrating from gateway (covered by specs 02, 05) Bridge specs

New tests (not in source)

era-contracts test Notes
sum of GW per-chain balances <= L1 GW chain balance (conservation) Balance conservation invariant

Files changed

File Change
contracts/core/chain-asset-handler/L2ChainAssetHandler.sol setMigrationNumberForTesting (test-only)
scripts/copy-to-zkstack-out.ts Added L2ChainAssetHandler to required contracts
AllContractsHashes.json Updated hashes for L2ChainAssetHandler + dependents
selectors Updated for setMigrationNumberForTesting
zkstack-out/L2ChainAssetHandler.sol/ New ABI file for CI
test/anvil-interop/config/anvil-config.json Chain 14 entry with "baseToken": "custom"
test/anvil-interop/config/chain-14.toml Generated chain config
test/anvil-interop/src/core/types.ts baseToken field on AnvilChainConfig, customBaseTokens on DeploymentState
test/anvil-interop/src/deployment-runner.ts Custom base token ERC20 deployment, per-chain base token support
test/anvil-interop/src/deployers/gateway-setup.ts Generalized base token TBM (was ETH-only)
test/anvil-interop/src/deployers/interop-chain-registrar.ts Removed ETH-only assertion
test/anvil-interop/setup-and-dump-state.ts Persists customBaseTokens in addresses.json
test/anvil-interop/test/hardhat/08-interop-messages.spec.ts 2 new cross-base-token tests
test/anvil-interop/test/hardhat/10-token-balance-migration.spec.ts 20-test TBM spec
test/anvil-interop/src/helpers/harness-shims.ts setGWChainMigrationNumber, setGWBridgehubSettlementLayer
test/anvil-interop/src/helpers/token-balance-migration-helper.ts migrateTokenBalanceFromGW helper
test/anvil-interop/src/core/const.ts SERVICE_TRANSACTION_SENDER constant
test/anvil-interop/chain-states/v0.31.0/*.json Regenerated with chain 14

Test plan

  • Full 10-spec suite: all 10 specs pass (84s parallel, no regressions)
  • Spec 08: 6/6 active tests passing (was 4, +2 cross-base-token)
  • Spec 10: 20/20 TBM tests passing
  • Passes without out/ directory (simulates CI environment)
  • calculate-hashes --check-only passes (foundry-zksync v0.1.5, Node 20)
  • selectors --check passes
  • TypeScript compiles cleanly, lint/prettier pass

🤖 Generated with Claude Code

/// facet to process chain commitment data, which needs an empty priority tree
/// (i.e. a running sequencer). This function allows Anvil-based tests to simulate
/// the migration number change without a sequencer.
function setMigrationNumberForTesting(uint256 _chainId, uint256 _migrationNumber) external onlyUpgrader {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

its better to have a L2ChainAssetHandlerDev which inherits L2ChainAssetHandler and adds this function

// ── Interop attribute selectors (ERC-7786) ─────────────────────

// keccak256("interopCallValue(uint256)")[0:4]
export const INTEROP_CALL_VALUE_SELECTOR = "0x54b16529";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

probably better to get them from the interfaces somhow

* L1 governance Forge script that generates a priority request to the GW chain.
* On Anvil we impersonate the L2ChainAssetHandler to call the bridgehub directly.
*/
export async function setGWBridgehubSettlementLayer(params: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can we just call that forge script instead? probably better to have everything done in the prod manner

@valera-grinenko-ai valera-grinenko-ai force-pushed the vg/migrate-tbm-tests-to-anvil branch 17 times, most recently from a760807 to f88dd7a Compare April 6, 2026 17:10
@valera-grinenko-ai valera-grinenko-ai force-pushed the vg/migrate-tbm-tests-to-anvil branch 4 times, most recently from d195a22 to 195ab51 Compare April 8, 2026 13:23
valera-grinenko-ai and others added 18 commits April 8, 2026 17:13
…ness

Port all 27 interop-b integration tests from zksync-era into the
era-contracts Anvil multichain testing framework (specs 07, 08, 09).

Bundles (spec 07): single/multi/mixed direct and indirect call bundles,
source-chain balance deductions, destination-chain balance deltas.

Messages (spec 08): sendMessage API for base token and ERC20 transfers,
with send-side and receive-side balance verification.

Unbundle (spec 09): progressive unbundling, bundle/call status tracking,
7 negative-path revert checks, cross-chain verify+unbundle meta-bundle.

DummyInteropRecipient contracts are deployed at test runtime using
embedded creation bytecode. No changes to pre-generated chain states.

Token balance migration tests will follow in a separate PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…interop tests

Add a 5th L2 chain (chain 14) with a custom ERC20 base token to enable
the 2 previously-skipped interop message tests (matter-labs#13, matter-labs#17 from source):

- "sends base token to a chain with a different base token"
  (ETH goes through AssetRouter indirect call path)
- "receives base token from sending chain as bridged ERC20"
  (ETH arrives as a wrapped ERC20 on the custom-base-token chain)

Infrastructure changes:
- AnvilChainConfig gains optional baseToken field
- DeploymentRunner deploys TestnetERC20Token on L1 for custom-base-token chains
- Gateway setup uses per-chain baseTokenAssetId for TBM (not hardcoded ETH)
- Interop chain registrar validates registration without assuming ETH base token

All 27 interop-b tests now fully covered — 0 skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Regenerated v0.29.0 pre-generated chain states from the kl/generate-v29-state
branch with chain 14 added. Chain 14 uses a custom ERC20 base token, matching
its v0.31.0 configuration. This ensures the v29→v31 upgrade test covers all
5 L2 chains including the custom-base-token chain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The v0.29.0 states were generated from kl/generate-v29-state which has
v29 AdminFacet code (2-arg upgradeChainFromVersion, selector 0xfc57565f),
but l1-deployment.toml set latest_protocol_version to 0x1f00000000 (v31).
This caused DefaultChainUpgrade to use the 3-arg selector (0x3b6d7534)
which isn't registered in the v29 AdminFacet, reverting with "F".

Fix: set latest_protocol_version to 0x1d00000000 (v29) on the generation
branch and regenerate all chain states including chain 14. Now the upgrade
script correctly uses the 2-arg selector path for v29→v31 upgrades.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…m artifacts

- Add trailing newline to chain-14.toml
- Replace magic number 3 with callStarters.length in unbundle spec
- Derive ERC-7786 attribute selectors from IERC7786Attributes ABI instead
  of hardcoding hex values; unify with token-transfer.ts which had a third
  approach (inline keccak256)
- Load DummyInteropRecipient bytecode from forge artifacts via
  getCreationBytecode() instead of embedding 800 chars of hex
- Add IERC7786Attributes and DummyInteropRecipient to ARTIFACTS registry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Module organization:
- Split 488-line interop-bundle-helper.ts into 3 focused modules:
  erc7930.ts (address encoding), interop-helpers.ts (RPC wrappers),
  balance-helpers.ts (assertions and balance utilities)
- Deduplicate event extraction in token-transfer.ts — now uses
  sendInteropBundle()/executeBundle() instead of hand-rolled copies

ERC-7930/7786 encoding:
- Rewrite ERC-7930 encoding with string concatenation for clarity,
  matching temp-sdk.ts style from zksync-era
- Use IERC7786Attributes.encodeFunctionData() for attribute encoding
  instead of manual selector+abiCoder; remove selector constants
- Document why callStarters[].to uses chain-less encoding

Test structure:
- Make all tests self-contained (send + execute + verify per it() block)
- Use exact balance assertions with gas cost from tx receipt
- Extract assertion helpers: expectNativeSpend, expectBalanceDelta,
  expectRevert (with optional reason matching)
- Standardize naming: interopFee, sourceTokenAddress, sourceAssetId
- Rename helpers for accuracy: approveTokenForNtv, BalanceSnapshot,
  captureBalance

Test coverage:
- Add custom→era cross-base-token interop message test
- Add replay protection test (executeBundle twice reverts)
- Add executionAddress enforcement test (wrong sender reverts)
- Add zero-call bundle test (documents protocol accepts empty bundles)
- Add excess msg.value rejection test

Code cleanup:
- Remove dead code: FAILING_CALL_CONTRACT, extractBundleHash
- Remove duplicated encodeEvmChain/encodeEvmAddress from token-transfer
- Unify SendBundleResult/SendMessageResult into InteropSendResult
- Fix cross-base-token tests to use before/after deltas not absolutes
- Make destTokenAddress local per-test instead of shared mutable state

Infrastructure hardening:
- Fix Anvil process leak on failure in setup-and-dump-state.ts
- Add missing port 4054 to cleanup.sh fallback list
- Set FOUNDRY_PROFILE=anvil-interop in upgrade test
- Replace deprecated fs.rmdirSync with fs.rmSync
- Scope prettier to chain-states/ directory only
- Improve storage slot override TODO tracking
- Document chain-14.toml base_token_addr derivation
- Fix baseToken JSDoc in AnvilChainConfig type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The interop helpers load the IERC7786Attributes interface at module init
time to derive ERC-7786 attribute selectors via encodeFunctionData.
CI integration tests run with --no-compile and rely on zkstack-out/ for
ABIs. Without this file, the import fails with ENOENT.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The CI integration-test job runs without forge build (no out/ directory).
DummyInteropRecipient bytecode must be embedded since there is no
compiled artifact available at runtime. This was the original design
before the review refactored it to use getCreationBytecode().

Also remove DummyInteropRecipient from the ARTIFACTS registry and
the unused getCreationBytecode import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The check-zkstack-out CI job regenerates zkstack-out/ from scratch and
compares against committed files. IERC7786Attributes was added to
zkstack-out/ manually but not registered in the copy script, causing
a mismatch. Add it to REQUIRED_CONTRACTS so it's generated consistently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Set executionAddress = ANVIL_DEFAULT_ACCOUNT_ADDR and
unbundlerAddress = ANVIL_ACCOUNT2_ADDR in sendAndPrepareBundle.
This properly tests role separation — the executor and unbundler
are now different accounts.

Update all unbundleBundle calls to use ANVIL_ACCOUNT2_PRIVATE_KEY
as the correct unbundler signer. The "wrong unbundler" test now
uses the default account (the executor, not the unbundler).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add randomBigNumber(min, max) helper and replace fixed constants with
per-test random amounts in all three interop specs. Each test now
generates its own amount within a safe range, so tests don't pass
only for specific hardcoded values.

Ranges are kept small (gwei-scale for native, reasonable for ERC20)
to avoid balance exhaustion while still being large enough to detect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DummyInteropRecipient bytecode:
- Store the full forge artifact (with bytecode) in zkstack-out/ so CI
  integration tests can load it without forge build
- Update artifact loader to handle both formats: raw ABI arrays (existing
  zkstack-out convention) and full forge artifacts (with bytecode field)
- Add DummyInteropRecipient to ARTIFACTS registry and copy-to-zkstack-out
- Remove embedded hex bytecode from interop-helpers.ts

expectRevert:
- Now checks both the JS error message and on-chain revert data
  (error.error.data / error.data) for the expected reason string
- Failure messages include excerpts of both message and data for debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rebase onto latest draft-v31 (62de149) which regenerated v0.31.0 states
due to the protocol ops tool PR (matter-labs#2097). Regenerated v0.31.0 states from
scratch with chain 14 included.

Chain 14's base_token_addr changed from 0xA4899D3... to 0x86A2EE8...
due to upstream deploy nonce changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…verse flow

Adds L2ChainAssetHandler.setMigrationNumberForTesting (onlyUpgrader)
for reverse TBM testing on Anvil, plus 20-test TBM spec covering:
- Pre-migration state (4 tests)
- Forward TBM to gateway (8 tests)
- Reverse TBM from gateway (8 tests)

Updates zkstack-out, selectors, and AllContractsHashes for the
contract change. Regenerated pre-generated chain states.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@valera-grinenko-ai valera-grinenko-ai force-pushed the vg/migrate-tbm-tests-to-anvil branch from a083757 to 3721720 Compare April 8, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants