diff --git a/README.md b/README.md index 04365073..a0836fab 100644 --- a/README.md +++ b/README.md @@ -270,10 +270,15 @@ grep -hoR --include="*.bats" 'test_tags=[^ ]*' . | sed 's/.*test_tags=//' | tr ' - reth-l1 - smooth-crypto-lib - transaction-eoa +- transaction-erc1155 - transaction-erc20 - transaction-erc721 +- transaction-eth +- transaction-matic +- transaction-pol - transaction-uniswap - weth +- withdraw - zkevm-batch - zkevm-counters diff --git a/TESTSINVENTORY.md b/TESTSINVENTORY.md index 859e504a..7e0c50dc 100644 --- a/TESTSINVENTORY.md +++ b/TESTSINVENTORY.md @@ -294,6 +294,15 @@ Table of tests currently implemented or being implemented in the E2E repository. | Transfer message | [Link](./tests/aggkit/bridge-e2e.bats#L11) | | | Verify batches | [Link](./tests/zkevm/batch-verification.bats#L10) | | | Verify certificate settlement | [Link](./tests/aggkit/e2e-pp.bats#L10) | | +| bridge ERC1155 from L1 to L2 via pos bridge | [Link](./tests/pos/bridge/pos.bats#L262) | | +| bridge ERC20 from L1 to L2 via plasma bridge | [Link](./tests/pos/bridge/plasma.bats#L316) | | +| bridge ERC20 from L1 to L2 via pos bridge | [Link](./tests/pos/bridge/pos.bats#L97) | | +| bridge ERC721 from L1 to L2 via plasma bridge | [Link](./tests/pos/bridge/plasma.bats#L421) | | +| bridge ERC721 from L1 to L2 via pos bridge | [Link](./tests/pos/bridge/pos.bats#L184) | | +| bridge ETH from L1 to L2 via plasma bridge | [Link](./tests/pos/bridge/plasma.bats#L206) | | +| bridge ETH from L1 to L2 via pos bridge | [Link](./tests/pos/bridge/pos.bats#L30) | | +| bridge MATIC from L1 to L2 via plasma bridge | [Link](./tests/pos/bridge/plasma.bats#L97) | | +| bridge POL from L1 to L2 via plasma bridge | [Link](./tests/pos/bridge/plasma.bats#L59) | | | bridge transaction is indexed and autoclaimed on L2 | [Link](./tests/bridge-hub-api.bats#L14) | | | bridge transaction is indexed on L1 | [Link](./tests/bridge-hub-api.bats#L95) | | | foo | [Link](./tests/foo.bats#L10) | | @@ -304,6 +313,14 @@ Table of tests currently implemented or being implemented in the E2E repository. | send multiple transactions with same nonce and verify rejection | [Link](./tests/evm-rpc/simple-validations.bats#L168) | | | send zero priced transactions and confirm rejection | [Link](./tests/evm-rpc/simple-validations.bats#L36) | | | trigger local balance tree underflow bridge revert | [Link](./tests/pessimistic/local-balance-tree-underflow.bats#L18) | | +| withdraw ERC1155 from L2 to L1 via pos bridge | [Link](./tests/pos/bridge/pos.bats#L294) | | +| withdraw ERC20 from L2 to L1 via plasma bridge | [Link](./tests/pos/bridge/plasma.bats#L353) | | +| withdraw ERC20 from L2 to L1 via pos bridge | [Link](./tests/pos/bridge/pos.bats#L132) | | +| withdraw ERC721 from L2 to L1 via plasma bridge | [Link](./tests/pos/bridge/plasma.bats#L465) | | +| withdraw ERC721 from L2 to L1 via pos bridge | [Link](./tests/pos/bridge/pos.bats#L215) | | +| withdraw ETH from L2 to L1 via pos bridge | [Link](./tests/pos/bridge/pos.bats#L52) | | +| withdraw MaticWeth from L2 to L1 via plasma bridge | [Link](./tests/pos/bridge/plasma.bats#L242) | | +| withdraw native tokens from L2 to L1 via plasma bridge | [Link](./tests/pos/bridge/plasma.bats#L134) | | ## Kurtosis Tests diff --git a/core/helpers/pos-setup.bash b/core/helpers/pos-setup.bash new file mode 100644 index 00000000..3bfd7802 --- /dev/null +++ b/core/helpers/pos-setup.bash @@ -0,0 +1,174 @@ +# This function sets up environment variables for `pos` tests using a Kurtosis Polygon PoS +# environment if they are not already provided. +pos_setup() { + # Private key used to send transactions. + export PRIVATE_KEY=${PRIVATE_KEY:-"0xd40311b5a5ca5eaeb48dfba5403bde4993ece8eccf4190e98e19fcd4754260ea"} + echo "PRIVATE_KEY=${PRIVATE_KEY}" + + # The name of the Kurtosis enclave (used for default values). + export ENCLAVE_NAME=${ENCLAVE_NAME:-"pos"} + echo "ENCLAVE_NAME=${ENCLAVE_NAME}" + + # L1 and L2 RPC and API URLs. + if [[ -z "${L1_RPC_URL:-}" ]]; then + if l1_rpc_port=$(kurtosis port print "${ENCLAVE_NAME}" el-1-geth-lighthouse rpc 2>/dev/null); then + export L1_RPC_URL="http://${l1_rpc_port}" + elif l1_rpc_port=$(kurtosis port print "${ENCLAVE_NAME}" el-1-reth-lighthouse rpc 2>/dev/null); then + export L1_RPC_URL="http://${l1_rpc_port}" + else + echo "❌ Failed to resolve L1 RPC URL from Kurtosis (tried el-1-geth-lighthouse and el-1-reth-lighthouse)" + exit 1 + fi + fi + echo "L1_RPC_URL=${L1_RPC_URL}" + + export L2_RPC_URL=${L2_RPC_URL:-$(kurtosis port print "${ENCLAVE_NAME}" "l2-el-1-bor-heimdall-v2-validator" rpc)} + export L2_CL_API_URL=${L2_CL_API_URL:-$(kurtosis port print "${ENCLAVE_NAME}" "l2-cl-1-heimdall-v2-bor-validator" http)} + + echo "L2_RPC_URL=${L2_RPC_URL}" + echo "L2_CL_API_URL=${L2_CL_API_URL}" + + if [[ -z "${L1_GOVERNANCE_PROXY_ADDRESS:-}" ]] || + [[ -z "${L1_ROOT_CHAIN_PROXY_ADDRESS:-}" ]] || + [[ -z "${L1_DEPOSIT_MANAGER_PROXY_ADDRESS:-}" ]] || + [[ -z "${L1_WITHDRAW_MANAGER_PROXY_ADDRESS:-}" ]] || + [[ -z "${L1_ERC20_PREDICATE_ADDRESS:-}" ]] || + [[ -z "${L1_ERC721_PREDICATE_ADDRESS:-}" ]] || + [[ -z "${L1_STAKE_MANAGER_PROXY_ADDRESS:-}" ]] || + [[ -z "${L1_STAKING_INFO_ADDRESS:-}" ]] || + [[ -z "${L1_MATIC_TOKEN_ADDRESS:-}" ]] || + [[ -z "${L1_POL_TOKEN_ADDRESS:-}" ]] || + [[ -z "${L1_WETH_TOKEN_ADDRESS:-}" ]] || + [[ -z "${L1_ERC20_TOKEN_ADDRESS:-}" ]] || + [[ -z "${L1_ERC721_TOKEN_ADDRESS:-}" ]] || + [[ -z "${L2_STATE_RECEIVER_ADDRESS:-}" ]] || + [[ -z "${L2_WETH_TOKEN_ADDRESS:-}" ]] || + [[ -z "${L2_ERC20_TOKEN_ADDRESS:-}" ]] || + [[ -z "${L2_ERC721_TOKEN_ADDRESS:-}" ]]; then + plasma_bridge_addresses=$(kurtosis files inspect "${ENCLAVE_NAME}" plasma-bridge-addresses contractAddresses.json | jq) + + # L1 contract addresses. + export L1_GOVERNANCE_PROXY_ADDRESS=${L1_GOVERNANCE_PROXY_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.GovernanceProxy')} + echo "L1_GOVERNANCE_PROXY_ADDRESS=${L1_GOVERNANCE_PROXY_ADDRESS}" + + export L1_ROOT_CHAIN_PROXY_ADDRESS=${L1_ROOT_CHAIN_PROXY_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.RootChainProxy')} + echo "L1_ROOT_CHAIN_PROXY_ADDRESS=${L1_ROOT_CHAIN_PROXY_ADDRESS}" + + export L1_DEPOSIT_MANAGER_PROXY_ADDRESS=${L1_DEPOSIT_MANAGER_PROXY_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.DepositManagerProxy')} + echo "L1_DEPOSIT_MANAGER_PROXY_ADDRESS=${L1_DEPOSIT_MANAGER_PROXY_ADDRESS}" + + export L1_WITHDRAW_MANAGER_PROXY_ADDRESS=${L1_WITHDRAW_MANAGER_PROXY_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.WithdrawManagerProxy')} + echo "L1_WITHDRAW_MANAGER_PROXY_ADDRESS=${L1_WITHDRAW_MANAGER_PROXY_ADDRESS}" + + export L1_ERC20_PREDICATE_ADDRESS=${L1_ERC20_PREDICATE_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.predicates.ERC20Predicate')} + echo "L1_ERC20_PREDICATE_ADDRESS=${L1_ERC20_PREDICATE_ADDRESS}" + + export L1_ERC721_PREDICATE_ADDRESS=${L1_ERC721_PREDICATE_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.predicates.ERC721Predicate')} + echo "L1_ERC721_PREDICATE_ADDRESS=${L1_ERC721_PREDICATE_ADDRESS}" + + export L1_STAKE_MANAGER_PROXY_ADDRESS=${L1_STAKE_MANAGER_PROXY_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.StakeManagerProxy')} + echo "L1_STAKE_MANAGER_PROXY_ADDRESS=${L1_STAKE_MANAGER_PROXY_ADDRESS}" + + export L1_STAKING_INFO_ADDRESS=${L1_STAKING_INFO_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.StakingInfo')} + echo "L1_STAKING_INFO_ADDRESS=${L1_STAKING_INFO_ADDRESS}" + + export L1_MATIC_TOKEN_ADDRESS=${L1_MATIC_TOKEN_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.tokens.MaticToken')} + echo "L1_MATIC_TOKEN_ADDRESS=${L1_MATIC_TOKEN_ADDRESS}" + + export L1_POL_TOKEN_ADDRESS=${L1_POL_TOKEN_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.tokens.PolToken')} + echo "L1_POL_TOKEN_ADDRESS=${L1_POL_TOKEN_ADDRESS}" + + export L1_ERC20_TOKEN_ADDRESS=${L1_ERC20_TOKEN_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.tokens.TestToken')} + echo "L1_ERC20_TOKEN_ADDRESS=${L1_ERC20_TOKEN_ADDRESS}" + + export L1_ERC721_TOKEN_ADDRESS=${L1_ERC721_TOKEN_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.tokens.RootERC721')} + echo "L1_ERC721_TOKEN_ADDRESS=${L1_ERC721_TOKEN_ADDRESS}" + + # L2 contract addresses. + export L2_STATE_RECEIVER_ADDRESS=${L2_STATE_RECEIVER_ADDRESS:-$(kurtosis files inspect "${ENCLAVE_NAME}" l2-el-genesis genesis.json | jq --raw-output '.config.bor.stateReceiverContract')} + echo "L2_STATE_RECEIVER_ADDRESS=${L2_STATE_RECEIVER_ADDRESS}" + + export L2_ERC20_TOKEN_ADDRESS=${L2_ERC20_TOKEN_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.child.tokens.TestToken')} + echo "L2_ERC20_TOKEN_ADDRESS=${L2_ERC20_TOKEN_ADDRESS}" + + export L2_ERC721_TOKEN_ADDRESS=${L2_ERC721_TOKEN_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.child.tokens.RootERC721')} + echo "L2_ERC721_TOKEN_ADDRESS=${L2_ERC721_TOKEN_ADDRESS}" + + export L1_WETH_TOKEN_ADDRESS=${L1_WETH_TOKEN_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.root.tokens.MaticWeth')} + echo "L1_WETH_TOKEN_ADDRESS=${L1_WETH_TOKEN_ADDRESS}" + + export L2_WETH_TOKEN_ADDRESS=${L2_WETH_TOKEN_ADDRESS:-$(echo "${plasma_bridge_addresses}" | jq --raw-output '.child.tokens.MaticWeth')} + echo "L2_WETH_TOKEN_ADDRESS=${L2_WETH_TOKEN_ADDRESS}" + fi + + # pos-bridge addresses + if [[ -z "${L1_ROOT_CHAIN_MANAGER_PROXY:-}" ]] || + [[ -z "${L1_ERC20_BRIDGE_PREDICATE_PROXY:-}" ]] || + [[ -z "${L1_DUMMY_ERC20:-}" ]] || + [[ -z "${L2_DUMMY_ERC1155:-}" ]]; then + pos_bridge_addresses=$(kurtosis files inspect "${ENCLAVE_NAME}" pos-bridge-addresses contractAddresses.json | jq) + export L1_ROOT_CHAIN_MANAGER_PROXY=${L1_ROOT_CHAIN_MANAGER_PROXY:-$(echo "${pos_bridge_addresses}" | jq --raw-output '.root.posBridge.RootChainManagerProxy')} + export L1_ERC20_BRIDGE_PREDICATE_PROXY=${L1_ERC20_BRIDGE_PREDICATE_PROXY:-$(echo "${pos_bridge_addresses}" | jq --raw-output '.root.posBridge.ERC20PredicateProxy')} + export L1_ERC721_BRIDGE_PREDICATE_PROXY=${L1_ERC721_BRIDGE_PREDICATE_PROXY:-$(echo "${pos_bridge_addresses}" | jq --raw-output '.root.posBridge.ERC721PredicateProxy')} + export L1_ERC1155_BRIDGE_PREDICATE_PROXY=${L1_ERC1155_BRIDGE_PREDICATE_PROXY:-$(echo "${pos_bridge_addresses}" | jq --raw-output '.root.posBridge.ERC1155PredicateProxy')} + export L1_ETHER_BRIDGE_PREDICATE_PROXY=${L1_ETHER_BRIDGE_PREDICATE_PROXY:-$(echo "${pos_bridge_addresses}" | jq --raw-output '.root.posBridge.EtherPredicateProxy')} + export L1_DUMMY_ERC20=${L1_DUMMY_ERC20:-$(echo "${pos_bridge_addresses}" | jq --raw-output '.root.posBridge.DummyERC20')} + export L2_DUMMY_ERC20=${L2_DUMMY_ERC20:-$(echo "${pos_bridge_addresses}" | jq --raw-output '.child.posBridge.DummyERC20')} + export L1_DUMMY_ERC721=${L1_DUMMY_ERC721:-$(echo "${pos_bridge_addresses}" | jq --raw-output '.root.posBridge.DummyERC721')} + export L2_DUMMY_ERC721=${L2_DUMMY_ERC721:-$(echo "${pos_bridge_addresses}" | jq --raw-output '.child.posBridge.DummyERC721')} + export L1_DUMMY_ERC1155=${L1_DUMMY_ERC1155:-$(echo "${pos_bridge_addresses}" | jq --raw-output '.root.posBridge.DummyERC1155')} + export L2_DUMMY_ERC1155=${L2_DUMMY_ERC1155:-$(echo "${pos_bridge_addresses}" | jq --raw-output '.child.posBridge.DummyERC1155')} + export L2_MATIC_WETH=${L2_MATIC_WETH:-$(echo "${pos_bridge_addresses}" | jq --raw-output '.child.posBridge.MaticWETH')} + echo "L1_ROOT_CHAIN_MANAGER_PROXY=${L1_ROOT_CHAIN_MANAGER_PROXY}" + echo "L2_MATIC_WETH=${L2_MATIC_WETH}" + fi +} + +# Create and fund an ephemeral wallet. Sets ephemeral_private_key and +# ephemeral_address. Calls `skip` if the chain is not processing transactions +# (e.g. stalled in mixed-version networks). +# +# Usage: _fund_ephemeral [amount] (default: 1ether) +_fund_ephemeral() { + local amount="${1:-1ether}" + local wallet_json + wallet_json=$(cast wallet new --json | jq '.[0]') + # shellcheck disable=SC2034 # intentional global: used by calling test scripts + ephemeral_private_key=$(echo "$wallet_json" | jq -r '.private_key') + ephemeral_address=$(echo "$wallet_json" | jq -r '.address') + echo "ephemeral_address: $ephemeral_address" >&3 + + local _err + if ! _err=$(cast send --rpc-url "$L2_RPC_URL" --private-key "$PRIVATE_KEY" \ + --legacy --gas-limit 21000 --value "$amount" "$ephemeral_address" 2>&1 >/dev/null); then + case "$_err" in + *"replacement transaction underpriced"*|*"not confirmed within"*|*"nonce too low"*) + skip "Chain stalled — cannot fund ephemeral wallet" + ;; + *) + echo "Fund ephemeral failed: $_err" >&2 + return 1 + ;; + esac + fi +} + +# Wrapper around `cast send` that skips the test on chain-stall errors. +# Use inside @test functions where a `cast send` is needed. +# +# Usage: _send_or_skip [cast send args...] +_send_or_skip() { + local _err + if ! _err=$(cast send "$@" 2>&1); then + case "$_err" in + *"replacement transaction underpriced"*|*"not confirmed within"*|*"nonce too low"*) + skip "Chain stalled — transaction cannot be submitted" + ;; + *) + echo "$_err" >&2 + return 1 + ;; + esac + fi + echo "$_err" +} diff --git a/core/helpers/scripts/pos-bridge.bash b/core/helpers/scripts/pos-bridge.bash new file mode 100644 index 00000000..bfb2d5e9 --- /dev/null +++ b/core/helpers/scripts/pos-bridge.bash @@ -0,0 +1,85 @@ +#!/bin/bash +# Shared helpers for Polygon PoS bridge tests (both plasma and pos bridge). +# Sourced from tests/pos/bridge/*.bats via `load` in setup(). +# +# Assumes pos_setup() has already been called (defines L1/L2 env vars, timeout_seconds, +# interval_seconds). + +# Variables set by test environment setup functions - disable shellcheck warnings +# shellcheck disable=SC2154 +declare timeout_seconds interval_seconds + +# Commands that read the current state-sync counter on each side. Re-evaluated via +# `eval` by the eventually helpers so ${L2_CL_API_URL} / ${L2_RPC_URL} are picked up +# at the time of the call. +heimdall_state_sync_count_cmd='curl "${L2_CL_API_URL}/clerk/event-records/count" | jq -r ".count"' +bor_state_sync_count_cmd='cast call --gas-limit 15000000 --rpc-url "${L2_RPC_URL}" "${L2_STATE_RECEIVER_ADDRESS}" "lastStateId()(uint)"' +checkpoint_count_cmd='curl -s "${L2_CL_API_URL}/checkpoints/latest" | jq --raw-output ".checkpoint.id"' + +# Wait for Heimdall to observe at least one new state sync since `state_sync_count`. +wait_for_heimdall_state_sync() { + local state_sync_count="$1" + echo "Monitoring state syncs on Heimdall..." + assert_command_eventually_greater_or_equal "${heimdall_state_sync_count_cmd}" $((state_sync_count + 1)) "${timeout_seconds}" "${interval_seconds}" +} + +# Wait for Bor to observe at least one new state sync since `state_sync_count`. +wait_for_bor_state_sync() { + local state_sync_count="$1" + echo "Monitoring state syncs on Bor..." + assert_command_eventually_greater_or_equal "${bor_state_sync_count_cmd}" $((state_sync_count + 1)) "${timeout_seconds}" "${interval_seconds}" +} + +# Convenience: snapshot both counters, run $1 (a command), then wait for both counters +# to increment. Used by tests that trigger an L1 deposit and need the full state-sync +# round-trip to complete before asserting on L2 state. +wait_for_state_sync_after_deposit() { + local initial_hm="$1" + local initial_bor="$2" + wait_for_heimdall_state_sync "${initial_hm}" + wait_for_bor_state_sync "${initial_bor}" +} + +# Read the latest checkpoint id (0 if none yet). +latest_checkpoint_id() { + local id + id=$(eval "${checkpoint_count_cmd}") + [[ "${id}" == "null" ]] && id=0 + echo "${id}" +} + +# Block until a checkpoint with id > ${initial_id} exists on Heimdall. +wait_for_new_checkpoint() { + local initial_id="$1" + echo "Waiting for a new checkpoint on L1..." + assert_command_eventually_greater_or_equal "${checkpoint_count_cmd}" $((initial_id + 1)) "${timeout_seconds}" "${interval_seconds}" +} + +# Generate the ABI-encoded exit payload for a burn tx on L2 via polycli. Both the plasma +# bridge's ERC20PredicateBurnOnly.startExitWithBurntTokens(bytes) and the pos bridge's +# RootChainManager.exit(bytes) consume the same format. +# +# Usage: generate_pos_exit_payload [log_index=0] [timeout=${timeout_seconds}] +generate_pos_exit_payload() { + local tx_hash="$1" + local log_index="${2:-0}" + local timeout="${3:-${timeout_seconds}}" + local deadline=$((SECONDS + timeout)) + local payload="" + while [[ $SECONDS -lt $deadline ]]; do + echo "Trying to generate exit payload for tx ${tx_hash} (log-index=${log_index})..." >&2 + if payload=$(polycli pos exit-proof \ + --l1-rpc-url "${L1_RPC_URL}" \ + --l2-rpc-url "${L2_RPC_URL}" \ + --root-chain-address "${L1_ROOT_CHAIN_PROXY_ADDRESS}" \ + --tx-hash "${tx_hash}" \ + --log-index "${log_index}"); then + echo "${payload}" + return 0 + fi + echo "Checkpoint not yet indexed, retrying in ${interval_seconds}s..." >&2 + sleep "${interval_seconds}" + done + echo "Error: failed to generate exit payload for tx ${tx_hash} within ${timeout} seconds." >&2 + return 1 +} diff --git a/tests/pos/bridge/README.md b/tests/pos/bridge/README.md new file mode 100644 index 00000000..0d92c9b7 --- /dev/null +++ b/tests/pos/bridge/README.md @@ -0,0 +1,101 @@ +# Polygon PoS bridge e2e + +End-to-end tests for the two Polygon PoS <-> Ethereum bridges. These suites run against a `kurtosis-pos` devnet and exercise real bridge + withdraw round-trips on the deployed contracts. + +## The two bridges + +Polygon PoS has always had two bridges in production, targeting different token classes: + +| Bridge | Governing contract on L1 | Token classes it carries | On-chain L2 representation | +|---|---|---|---| +| **Plasma** | `DepositManagerProxy` / `WithdrawManagerProxy` | POL, MATIC, ETH, ERC20, ERC721 | Native gas token at `0x…1010` and child copies | +| **PoS (pos bridge)** | `RootChainManagerProxy` / `ChildChainManagerProxy` | ERC20, ERC721, ERC1155, ETH | Per-type child token contracts (`ChildERC20`, `ChildERC721`, `ChildERC1155`, `MaticWETH`) | + +The two bridges aren't interchangeable. A given token maps to exactly one. + +### Why both? + +Plasma is the older design. It was the original PoS bridge — low-throughput, single native-gas-token pathway, deposits via `DepositManager.depositERC20(...)` and withdrawals via Plasma exits with an exit period. It's the only path for **POL, MATIC, and the L2 native gas token** because the L2 side is a precompile at `0x…1010`, not a contract the PoS bridge can mint into. + +PoS (pos-portal, at `maticnetwork/pos-portal`) came later for everything else. It's a generic ERC-standard bridge: a single `RootChainManager` fronts a pluggable set of token **predicates** (`ERC20Predicate`, `ERC721Predicate`, `ERC1155Predicate`, `EtherPredicate`, plus `Mintable*` variants), each responsible for locking the L1 side and validating the exit proof. The L2 side is a real contract (`ChildERC20`, etc.) that mints on deposit state-sync and burns on withdrawal. + +### Confirmation that POL / MATIC are plasma-only + +Verified against Ethereum mainnet on `RootChainManagerProxy` (`0xA0c68C638235ee32657e8f720a23ceC1bFc77C77`): + +``` +rootToChildToken(0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6) = 0x0 # POL +rootToChildToken(0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0) = 0x0 # MATIC +``` + +No child mapping → pos-portal does not bridge either token. Those flows only exist under Plasma. + +## Bridge-vs-withdraw flow shape + +Both suites follow the same skeleton; the entry points and the withdraw path differ. + +| Stage | Plasma | PoS | +|---|---|---| +| Bridge (L1 → L2) | approve `DepositManagerProxy`, then `depositERC20(token, amount)` | approve the specific predicate, then `RootChainManager.depositFor(user, rootToken, abi.encode(args))` | +| Bridge ETH | wraps into `MaticWeth` on L2 via `DepositManager` path | `RootChainManager.depositEtherFor(user)` with `msg.value`; shows up as `MaticWETH` on L2 | +| Withdraw phase 1 | `L1_ERC20_PREDICATE_ADDRESS.startExitWithBurntTokens(payload)` | `RootChainManagerProxy.exit(payload)` (one-shot) | +| Withdraw phase 2 | `WithdrawManagerProxy.processExits(token)` after `HALF_EXIT_PERIOD` | — (`exit` releases funds directly) | +| Checkpoint wait | Yes | Yes | +| Exit payload format | `polycli pos exit-proof` | Same `polycli pos exit-proof` — both bridges consume the same `ExitPayloadReader` layout | +| L2 burn (native) | `0x…1010.withdraw(amount)` (log-index **1** — `Withdraw` event) | n/a (no native path in PoS) | +| L2 burn (ERC20) | child token `withdraw` | child token `withdraw` (log-index **0** — `Transfer` to `0x0`) | +| L2 burn (ERC721) | child token `withdraw` | `ChildERC721.withdraw(tokenId)` | +| L2 burn (ERC1155) | n/a | `ChildERC1155.withdrawSingle(id, amount)` / `withdrawBatch(ids, amounts)` | + +## Files + +- [`plasma.bats`](./plasma.bats) — Plasma bridge suite. Covers POL, MATIC, ETH, ERC20, ERC721. +- [`pos.bats`](./pos.bats) — pos bridge suite. Covers ETH, ERC20, ERC721, ERC1155. + +Shared helpers (state-sync waiters, exit-payload generation, address resolution) live in `../../core/helpers/pos-setup.bash` and `../../core/helpers/scripts/eventually.bash`. + +## Test scoreboard + +| # | Token & direction | Plasma | PoS | Notes | +|---|---|---|---|---| +| 1 | bridge POL (L1 → L2) | ✅ | n/a | plasma-only | +| 2 | bridge MATIC (L1 → L2) | ✅ | n/a | plasma-only | +| 3 | bridge ETH (L1 → L2) | ✅ | ✅ | different entry point | +| 4 | bridge ERC20 (L1 → L2) | ✅ | ✅ | | +| 5 | bridge ERC721 (L1 → L2) | ✅ | ✅ | | +| 6 | bridge ERC1155 (L1 → L2) | n/a | ✅ | PoS-only | +| 7 | withdraw native → POL (L2 → L1) | ✅ | n/a | plasma-only (native burn) | +| 8 | withdraw ETH via MaticWETH (L2 → L1) | ✅ | ✅ | | +| 9 | withdraw ERC20 (L2 → L1) | ✅ | ✅ | | +| 10 | withdraw ERC721 (L2 → L1) | ✅ | ✅ | | +| 11 | withdraw ERC1155 (L2 → L1) | n/a | ✅ | PoS-only | + +Coverage is complete for each bridge's actual on-chain responsibilities — the asymmetries (`n/a` cells) reflect what the bridge does on mainnet, not missing test cases. Mintable predicate variants (`MintableERC20`/`721`/`1155`) are deployed but not yet tested in either suite. + +## Running + +Both suites assume a live `kurtosis-pos` devnet named `pos-2` (override with `ENCLAVE_NAME`): + +```bash +# from internal/e2e +export BATS_LIB_PATH="$PWD/core/helpers/lib" +ENCLAVE_NAME=pos-2 bats tests/pos/bridge/plasma.bats +ENCLAVE_NAME=pos-2 bats tests/pos/bridge/pos.bats +``` + +Run a subset by tag: + +```bash +ENCLAVE_NAME=pos-2 bats tests/pos/bridge/pos.bats --filter-tags pos-bridge +ENCLAVE_NAME=pos-2 bats tests/pos/bridge/plasma.bats --filter-tags withdraw +``` + +Withdraws take several minutes per test — each needs a fresh L1 checkpoint to cover the burn block before the exit proof can be built. Plasma withdraws wait an additional `HALF_EXIT_PERIOD` seconds after queuing. + +## History + +- **2017-2020** — Plasma bridge deployed as the only PoS L1↔L2 pathway. +- **2020-2021** — `pos-portal` built to generalise the bridge for ERC20/721/1155/Ether. Two bridges co-exist ever since; each token class is assigned to one. +- **July 2021** — typed-tx (EIP-1559) support added to `ERC20PredicateBurnOnly` / `ERC721PredicateBurnOnly` (commit `2e3d42c8`). The non-burn-only variants (`ERC20Predicate`, `ERC721Predicate`) never got the fix — they still call `toList()` on the raw typed receipt and silently revert for typed burns. +- **September 2024** — MATIC → POL migration. POL and MATIC remain plasma-bridged; pos-portal mappings for both are zero on mainnet today. `DepositManager` was upgraded to auto-convert deposited MATIC to POL and to pay out POL on native withdraws. +- **May 2025** — pos-portal migrated its test infra from Truffle to Hardhat. The deploy migration scripts (`scripts/1_initial_migration.js` through `5_initialize_child_chain_contracts.js`) were left in place but are no longer wired to a runner — pos-portal has no maintained end-to-end deploy path at HEAD. `kurtosis-pos` deploys it through its own Foundry-based orchestration (`pos-contract-deployer` image). diff --git a/tests/pos/bridge/plasma.bats b/tests/pos/bridge/plasma.bats new file mode 100644 index 00000000..e876265e --- /dev/null +++ b/tests/pos/bridge/plasma.bats @@ -0,0 +1,528 @@ +#!/usr/bin/env bats +# bats file_tags=pos +# shellcheck disable=SC2154 # heimdall_state_sync_count_cmd/bor_state_sync_count_cmd are defined by pos-bridge.bash + +# Plasma bridge tests — see ./README.md for how plasma relates to pos bridge. + +setup() { + load "../../../core/helpers/pos-setup.bash" + load "../../../core/helpers/scripts/eventually.bash" + load "../../../core/helpers/scripts/pos-bridge.bash" + pos_setup + + bridge_amount=$(cast to-unit 1ether wei) + timeout_seconds=${TIMEOUT_SECONDS:-"180"} + interval_seconds=${INTERVAL_SECONDS:-"10"} +} + +# process_plasma_exit queues → waits for the exit window → calls processExits once. +# Plasma-specific (no equivalent in pos bridge). +# +# Instead of a blind retry loop, reads exitableAt from the on-chain priority queue +# (WithdrawManager.exitsQueues(token).getMin()) and sleeps precisely until the exit +# window opens (HALF_EXIT_PERIOD = 1s on devnet, ~7 days on mainnet), then calls +# processExits with an explicit gas limit. +process_plasma_exit() { + local token="$1" + + local queue + queue=$(cast call --rpc-url "${L1_RPC_URL}" "${L1_WITHDRAW_MANAGER_PROXY_ADDRESS}" \ + "exitsQueues(address)(address)" "${token}") + echo "Exit queue for ${token}: ${queue}" + + local exitable_at + exitable_at=$(cast call --rpc-url "${L1_RPC_URL}" "${queue}" "getMin()(uint256,uint256)" \ + | awk 'NR==1{print $1}') + echo "exitableAt: ${exitable_at}" + + local current_ts + current_ts=$(cast block --rpc-url "${L1_RPC_URL}" --json | jq -r '.timestamp' | xargs printf "%d\n") + local wait_secs=$(( exitable_at - current_ts + 2 )) + if [[ $wait_secs -gt 0 ]]; then + echo "Exit window opens in ${wait_secs}s (now=${current_ts}, exitableAt=${exitable_at}), sleeping..." + sleep "$wait_secs" + fi + + # processExits must use --gas-limit: without it cast send auto-estimates gas at the time + # of the eth_estimateGas call (which may see a short early-return path when exitableAt has + # not elapsed yet), producing a gas estimate too low for the actual ~125K execution. + echo "Calling processExits(${token})..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" --gas-limit 500000 \ + "${L1_WITHDRAW_MANAGER_PROXY_ADDRESS}" "processExits(address)" "${token}" +} + +############################################################################## +# POL / MATIC <-> Native L2 +############################################################################## + +# bats test_tags=bridge,transaction-pol +@test "bridge POL from L1 to L2 via plasma bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + # Get the initial balances. + initial_l1_balance=$(cast call --rpc-url "${L1_RPC_URL}" --json "${L1_POL_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + initial_l2_balance=$(cast balance --rpc-url "${L2_RPC_URL}" "${address}") + + echo "Initial balances:" + echo "- L1 POL balance: ${initial_l1_balance}" + echo "- L2 native tokens balance: ${initial_l2_balance} wei" + + heimdall_state_sync_count=$(eval "${heimdall_state_sync_count_cmd}") + bor_state_sync_count=$(eval "${bor_state_sync_count_cmd}") + + # Bridge some POL tokens from L1 to L2 to trigger a state sync. + # The DepositManager remaps POL to MATIC internally before the state sync, + # so the L2 native token balance increases identically to bridging MATIC. + echo "Approving the DepositManager contract to spend POL tokens on our behalf..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_POL_TOKEN_ADDRESS}" "approve(address,uint)" "${L1_DEPOSIT_MANAGER_PROXY_ADDRESS}" "${bridge_amount}" + + echo "Depositing POL tokens to trigger a state sync..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DEPOSIT_MANAGER_PROXY_ADDRESS}" "depositERC20(address,uint)" "${L1_POL_TOKEN_ADDRESS}" "${bridge_amount}" + + # Wait for Heimdall and Bor to process the bridge event. + wait_for_heimdall_state_sync "${heimdall_state_sync_count}" + wait_for_bor_state_sync "${bor_state_sync_count}" + + # Monitor the balances on L1 and L2. + echo "Monitoring POL balance on L1..." + assert_token_balance_eventually_lower_or_equal "${L1_POL_TOKEN_ADDRESS}" "${address}" "$(echo "${initial_l1_balance} - ${bridge_amount}" | bc)" "${L1_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" + + echo "Monitoring native tokens balance on L2..." + assert_ether_balance_eventually_greater_or_equal "${address}" "$(echo "${initial_l2_balance} + ${bridge_amount}" | bc)" "${L2_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} + +# bats test_tags=bridge,transaction-matic +@test "bridge MATIC from L1 to L2 via plasma bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + # Get the initial balances. + initial_l1_balance=$(cast call --rpc-url "${L1_RPC_URL}" --json "${L1_MATIC_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + initial_l2_balance=$(cast balance --rpc-url "${L2_RPC_URL}" "${address}") + + echo "Initial balances:" + echo "- L1 MATIC balance: ${initial_l1_balance}" + echo "- L2 native tokens balance: ${initial_l2_balance} wei" + + heimdall_state_sync_count=$(eval "${heimdall_state_sync_count_cmd}") + bor_state_sync_count=$(eval "${bor_state_sync_count_cmd}") + + # Bridge some MATIC tokens from L1 to L2 to trigger a state sync. + # 1 MATIC token = 1000000000000000000 wei. + echo "Approving the DepositManager contract to spend MATIC tokens on our behalf..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_MATIC_TOKEN_ADDRESS}" "approve(address,uint)" "${L1_DEPOSIT_MANAGER_PROXY_ADDRESS}" "${bridge_amount}" + + echo "Depositing MATIC tokens to trigger a state sync..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DEPOSIT_MANAGER_PROXY_ADDRESS}" "depositERC20(address,uint)" "${L1_MATIC_TOKEN_ADDRESS}" "${bridge_amount}" + + # Wait for Heimdall and Bor to process the bridge event. + wait_for_heimdall_state_sync "${heimdall_state_sync_count}" + wait_for_bor_state_sync "${bor_state_sync_count}" + + # Monitor the balances on L1 and L2. + echo "Monitoring MATIC balance on L1..." + assert_token_balance_eventually_lower_or_equal "${L1_MATIC_TOKEN_ADDRESS}" "${address}" "$(echo "${initial_l1_balance} - ${bridge_amount}" | bc)" "${L1_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" + + echo "Monitoring native tokens balance on L2..." + assert_ether_balance_eventually_greater_or_equal "${address}" "$(echo "${initial_l2_balance} + ${bridge_amount}" | bc)" "${L2_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} + +# bats test_tags=withdraw,transaction-pol +@test "withdraw native tokens from L2 to L1 via plasma bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + # Get initial balances and latest checkpoint ID. + initial_l1_pol_balance=$(cast call --rpc-url "${L1_RPC_URL}" --json "${L1_POL_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + initial_l2_balance=$(cast balance --rpc-url "${L2_RPC_URL}" "${address}") + initial_checkpoint_id=$(latest_checkpoint_id) + + echo "Initial balances and state:" + echo "- L1 POL balance: ${initial_l1_pol_balance}" + echo "- L2 native balance: ${initial_l2_balance} wei" + echo "- Latest checkpoint ID: ${initial_checkpoint_id}" + + # Burn native tokens on L2 to initiate the Plasma exit. + withdraw_amount=$(cast to-unit 1ether wei) + echo "Burning ${withdraw_amount} wei on L2..." + withdraw_receipt=$(cast send \ + --rpc-url "${L2_RPC_URL}" \ + --private-key "${PRIVATE_KEY}" \ + --value "${withdraw_amount}" \ + --gas-price 30gwei \ + --priority-gas-price 30gwei \ + --gas-limit 200000 \ + --json \ + "0x0000000000000000000000000000000000001010" \ + "withdraw(uint256)" "${withdraw_amount}") + withdraw_tx_hash=$(echo "${withdraw_receipt}" | jq --raw-output ".transactionHash") + withdraw_block_hex=$(echo "${withdraw_receipt}" | jq --raw-output ".blockNumber") + withdraw_block=$(printf "%d" "${withdraw_block_hex}") + echo "Withdraw tx: ${withdraw_tx_hash} (block ${withdraw_block})" + + # Verify L2 native balance decreased. + echo "Verifying L2 native balance decreased..." + assert_ether_balance_eventually_lower_or_equal "${address}" "$(echo "${initial_l2_balance} - ${withdraw_amount}" | bc)" "${L2_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" + + # Wait for a new checkpoint on L1 that covers the withdrawal block. + # This confirms validators have attested to the burn, which is a prerequisite for building a valid exit Merkle proof. + echo "Waiting for a new checkpoint to cover L2 block ${withdraw_block}..." + wait_for_new_checkpoint "${initial_checkpoint_id}" + + # Generate the exit payload for the burn transaction. + # It includes the burn tx receipt, a Merkle proof of that receipt in the block's receipts trie, and a checkpoint proof. + # Retried in a loop because the checkpoint may not yet be indexed by polycli even after being confirmed on L1. + # The native token (0x1010) emits LogTransfer at log index 0 and Withdraw at log index 1, so we pass --log-index 1. + echo "Generating the exit payload for the burn transaction..." + payload=$(generate_pos_exit_payload "${withdraw_tx_hash}" 1 $((2 * timeout_seconds))) + + # Start the exit on L1 via the ERC20Predicate contract. + # Note: startExitWithBurntTokens is on ERC20Predicate, not on WithdrawManagerProxy. + echo "Starting the exit on L1 with the generated payload..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + --gas-limit 500000 \ + "${L1_ERC20_PREDICATE_ADDRESS}" "startExitWithBurntTokens(bytes)" "${payload}" + + # Process the exit on L1. + # Exits are queued under MATIC (the rootToken in the Withdraw event topic[1]). + # WithdrawManager converts MATIC exits to POL when releasing funds. + echo "Processing the exit on L1..." + process_plasma_exit "${L1_MATIC_TOKEN_ADDRESS}" + + # Verify L1 POL balance increased by the withdrawn amount. + echo "Verifying L1 POL balance increased..." + assert_token_balance_eventually_greater_or_equal "${L1_POL_TOKEN_ADDRESS}" "${address}" \ + "$(echo "${initial_l1_pol_balance} + ${withdraw_amount}" | bc)" \ + "${L1_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} + +############################################################################## +# ETH (Native L1) / MaticWeth +############################################################################## + +# bats test_tags=bridge,transaction-eth +@test "bridge ETH from L1 to L2 via plasma bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + # Get the initial balances. + initial_l1_balance=$(cast balance --rpc-url "${L1_RPC_URL}" "${address}") + initial_l2_balance=$(cast call --rpc-url "${L2_RPC_URL}" --json "${L2_WETH_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + + echo "Initial balances:" + echo "- L1 ETH balance: ${initial_l1_balance}" + echo "- L2 MaticWeth balance: ${initial_l2_balance}" + + heimdall_state_sync_count=$(eval "${heimdall_state_sync_count_cmd}") + bor_state_sync_count=$(eval "${bor_state_sync_count_cmd}") + + # Bridge some ETH from L1 to L2 to trigger a state sync. + # The DepositManager wraps ETH into MaticWeth on L1, so the L2 + # MaticWeth balance increases rather than the native gas balance. + echo "Depositing ETH to trigger a state sync..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + --value "${bridge_amount}" \ + "${L1_DEPOSIT_MANAGER_PROXY_ADDRESS}" "depositEther()" + + # Wait for Heimdall and Bor to process the bridge event. + wait_for_heimdall_state_sync "${heimdall_state_sync_count}" + wait_for_bor_state_sync "${bor_state_sync_count}" + + # Monitor the balances on L1 and L2. + # L1 ETH decreases by at least bridge_amount (gas costs make it decrease further). + echo "Monitoring ETH balance on L1..." + assert_ether_balance_eventually_lower_or_equal "${address}" "$(echo "${initial_l1_balance} - ${bridge_amount}" | bc)" "${L1_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" + + echo "Monitoring MaticWeth balance on L2..." + assert_token_balance_eventually_greater_or_equal "${L2_WETH_TOKEN_ADDRESS}" "${address}" "$(echo "${initial_l2_balance} + ${bridge_amount}" | bc)" "${L2_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} + +# bats test_tags=withdraw,transaction-eth +@test "withdraw MaticWeth from L2 to L1 via plasma bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + # Get initial balances and latest checkpoint ID. + initial_l1_balance=$(cast balance --rpc-url "${L1_RPC_URL}" "${address}") + initial_l2_balance=$(cast call --rpc-url "${L2_RPC_URL}" --json "${L2_WETH_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + initial_checkpoint_id=$(latest_checkpoint_id) + + echo "Initial balances and state:" + echo "- L1 ETH balance: ${initial_l1_balance}" + echo "- L2 MaticWeth balance: ${initial_l2_balance}" + echo "- Latest checkpoint ID: ${initial_checkpoint_id}" + + # Burn MaticWeth on L2 to initiate the Plasma exit. + # The WithdrawManager will release the corresponding ETH locked on L1. + withdraw_amount=$(cast to-unit 1ether wei) + echo "Burning ${withdraw_amount} MaticWeth on L2..." + withdraw_receipt=$(cast send \ + --rpc-url "${L2_RPC_URL}" \ + --private-key "${PRIVATE_KEY}" \ + --gas-price 30gwei \ + --priority-gas-price 30gwei \ + --gas-limit 200000 \ + --json \ + "${L2_WETH_TOKEN_ADDRESS}" \ + "withdraw(uint256)" "${withdraw_amount}") + withdraw_tx_hash=$(echo "${withdraw_receipt}" | jq --raw-output ".transactionHash") + withdraw_block_hex=$(echo "${withdraw_receipt}" | jq --raw-output ".blockNumber") + withdraw_block=$(printf "%d" "${withdraw_block_hex}") + echo "Withdraw tx: ${withdraw_tx_hash} (block ${withdraw_block})" + + # Verify L2 MaticWeth balance decreased. + echo "Verifying L2 MaticWeth balance decreased..." + assert_token_balance_eventually_lower_or_equal "${L2_WETH_TOKEN_ADDRESS}" "${address}" "$(echo "${initial_l2_balance} - ${withdraw_amount}" | bc)" "${L2_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" + + # Wait for a new checkpoint on L1 that covers the withdrawal block. + # This confirms validators have attested to the burn, which is a prerequisite for building a valid exit Merkle proof. + echo "Waiting for a new checkpoint to cover L2 block ${withdraw_block}..." + wait_for_new_checkpoint "${initial_checkpoint_id}" + + # Generate the exit payload for the burn transaction. + # It includes the burn tx receipt, a Merkle proof of that receipt in the block's receipts trie, and a checkpoint proof. + # Retried in a loop because the checkpoint may not yet be indexed by polycli even after being confirmed on L1. + # The MaticWeth contract emits: log 0 = Transfer, log 1 = Withdraw(rootToken=WETH, from, ...). + echo "Generating the exit payload for the burn transaction..." + payload=$(generate_pos_exit_payload "${withdraw_tx_hash}" 1 $((2 * timeout_seconds))) + + # Start the exit on L1 with the generated payload. + # ERC20Predicate handles WETH exits: it reads rootToken=WETH from the Withdraw event and queues + # the exit. processExits(WETH) then calls DepositManager which unwraps WETH and sends ETH to user. + echo "Starting the exit on L1 with the generated payload..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" --gas-limit 500000 \ + "${L1_ERC20_PREDICATE_ADDRESS}" "startExitWithBurntTokens(bytes)" "${payload}" + + # Process the exit on L1. + # WETH is queued under L1_WETH_TOKEN_ADDRESS; processExits unwraps WETH to ETH internally. + echo "Processing the exit on L1..." + process_plasma_exit "${L1_WETH_TOKEN_ADDRESS}" + + # Verify L1 ETH balance increased by the withdrawn amount minus gas. + # ETH is the gas token: startExitWithBurntTokens and processExits both consume ETH as gas. + # We allow up to 0.01 ETH (10^16 wei) to cover gas across both transactions. + echo "Verifying L1 ETH balance increased..." + local gas_allowance=10000000000000000 + assert_ether_balance_eventually_greater_or_equal "${address}" \ + "$(echo "${initial_l1_balance} + ${withdraw_amount} - ${gas_allowance}" | bc)" \ + "${L1_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} + +############################################################################## +# ERC20 +############################################################################## + +# bats test_tags=bridge,transaction-erc20 +@test "bridge ERC20 from L1 to L2 via plasma bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + # Get the initial balances. + initial_l1_balance=$(cast call --rpc-url "${L1_RPC_URL}" --json "${L1_ERC20_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + initial_l2_balance=$(cast call --rpc-url "${L2_RPC_URL}" --json "${L2_ERC20_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + + echo "Initial ERC20 balances:" + echo "- L1: ${initial_l1_balance}" + echo "- L2: ${initial_l2_balance}" + + heimdall_state_sync_count=$(eval "${heimdall_state_sync_count_cmd}") + bor_state_sync_count=$(eval "${bor_state_sync_count_cmd}") + + # Bridge some ERC20 tokens from L1 to L2. + # 1 ERC20 token = 1000000000000000000 wei. + echo "Approving the DepositManager contract to spend ERC20 tokens on our behalf..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_ERC20_TOKEN_ADDRESS}" "approve(address,uint)" "${L1_DEPOSIT_MANAGER_PROXY_ADDRESS}" "${bridge_amount}" + + echo "Depositing ERC20 tokens..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DEPOSIT_MANAGER_PROXY_ADDRESS}" "depositERC20(address,uint)" "${L1_ERC20_TOKEN_ADDRESS}" "${bridge_amount}" + + # Wait for Heimdall and Bor to process the bridge event. + wait_for_heimdall_state_sync "${heimdall_state_sync_count}" + wait_for_bor_state_sync "${bor_state_sync_count}" + + # Monitor the balances on L1 and L2. + echo "Monitoring ERC20 balance on L1..." + assert_token_balance_eventually_lower_or_equal "${L1_ERC20_TOKEN_ADDRESS}" "${address}" "$(echo "${initial_l1_balance} - ${bridge_amount}" | bc)" "${L1_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" + + echo "Monitoring ERC20 balance on L2..." + assert_token_balance_eventually_greater_or_equal "${L2_ERC20_TOKEN_ADDRESS}" "${address}" "$(echo "${initial_l2_balance} + ${bridge_amount}" | bc)" "${L2_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} + +# bats test_tags=withdraw,transaction-erc20 +@test "withdraw ERC20 from L2 to L1 via plasma bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + # Get initial balances and latest checkpoint ID. + initial_l1_balance=$(cast call --rpc-url "${L1_RPC_URL}" --json "${L1_ERC20_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + initial_l2_balance=$(cast call --rpc-url "${L2_RPC_URL}" --json "${L2_ERC20_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + initial_checkpoint_id=$(latest_checkpoint_id) + + echo "Initial balances and state:" + echo "- L1 ERC20 balance: ${initial_l1_balance}" + echo "- L2 ERC20 balance: ${initial_l2_balance}" + echo "- Latest checkpoint ID: ${initial_checkpoint_id}" + + # Burn ERC20 tokens on L2 to initiate the Plasma exit. + withdraw_amount=$(cast to-unit 1ether wei) + echo "Burning ${withdraw_amount} ERC20 tokens on L2..." + withdraw_receipt=$(cast send \ + --rpc-url "${L2_RPC_URL}" \ + --private-key "${PRIVATE_KEY}" \ + --gas-price 30gwei \ + --priority-gas-price 30gwei \ + --gas-limit 200000 \ + --json \ + "${L2_ERC20_TOKEN_ADDRESS}" \ + "withdraw(uint256)" "${withdraw_amount}") + withdraw_tx_hash=$(echo "${withdraw_receipt}" | jq --raw-output ".transactionHash") + withdraw_block_hex=$(echo "${withdraw_receipt}" | jq --raw-output ".blockNumber") + withdraw_block=$(printf "%d" "${withdraw_block_hex}") + echo "Withdraw tx: ${withdraw_tx_hash} (block ${withdraw_block})" + + # Verify L2 ERC20 balance decreased. + echo "Verifying L2 ERC20 balance decreased..." + assert_token_balance_eventually_lower_or_equal "${L2_ERC20_TOKEN_ADDRESS}" "${address}" "$(echo "${initial_l2_balance} - ${withdraw_amount}" | bc)" "${L2_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" + + # Wait for a new checkpoint on L1 that covers the withdrawal block. + # This confirms validators have attested to the burn, which is a prerequisite for building a valid exit Merkle proof. + echo "Waiting for a new checkpoint to cover L2 block ${withdraw_block}..." + wait_for_new_checkpoint "${initial_checkpoint_id}" + + # Generate the exit payload for the burn transaction. + # It includes the burn tx receipt, a Merkle proof of that receipt in the block's receipts trie, and a checkpoint proof. + # Retried in a loop because the checkpoint may not yet be indexed by polycli even after being confirmed on L1. + # The ERC20 contract emits: log 0 = Transfer, log 1 = Withdraw(rootToken=ERC20, from, ...). + echo "Generating the exit payload for the burn transaction..." + payload=$(generate_pos_exit_payload "${withdraw_tx_hash}" 1 $((2 * timeout_seconds))) + + # Start the exit on L1 with the generated payload. + # ERC20Predicate handles ERC20 exits: reads rootToken from the Withdraw event and queues the exit. + echo "Starting the exit on L1 with the generated payload..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" --gas-limit 500000 \ + "${L1_ERC20_PREDICATE_ADDRESS}" "startExitWithBurntTokens(bytes)" "${payload}" + + # Process the exit on L1. + echo "Processing the exit on L1..." + process_plasma_exit "${L1_ERC20_TOKEN_ADDRESS}" + + # Verify L1 ERC20 balance increased by the withdrawn amount. + echo "Verifying L1 ERC20 balance increased..." + assert_token_balance_eventually_greater_or_equal "${L1_ERC20_TOKEN_ADDRESS}" "${address}" \ + "$(echo "${initial_l1_balance} + ${withdraw_amount}" | bc)" \ + "${L1_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} + +############################################################################## +# ERC721 +############################################################################## + +# bats test_tags=bridge,transaction-erc721 +@test "bridge ERC721 from L1 to L2 via plasma bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + # Mint an ERC721 token. + total_supply=$(cast call --rpc-url "${L1_RPC_URL}" --json "${L1_ERC721_TOKEN_ADDRESS}" "totalSupply()(uint)" | jq --raw-output '.[0]') + token_id=$((total_supply + 1)) + + echo "Minting the ERC721 token (id: ${token_id})..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_ERC721_TOKEN_ADDRESS}" "mint(uint)" "${token_id}" + + # Get the initial values. + initial_l1_balance=$(cast call --rpc-url "${L1_RPC_URL}" --json "${L1_ERC721_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + initial_l2_balance=$(cast call --rpc-url "${L2_RPC_URL}" --json "${L2_ERC721_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + + echo "Initial ERC721 balances:" + echo "- L1: ${initial_l1_balance}" + echo "- L2: ${initial_l2_balance}" + + heimdall_state_sync_count=$(eval "${heimdall_state_sync_count_cmd}") + bor_state_sync_count=$(eval "${bor_state_sync_count_cmd}") + + # Bridge the ERC721 token from L1 to L2. + echo "Approving the DepositManager contract to spend ERC721 tokens on our behalf..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_ERC721_TOKEN_ADDRESS}" "approve(address,uint)" "${L1_DEPOSIT_MANAGER_PROXY_ADDRESS}" "${token_id}" + + echo "Depositing ERC721 tokens..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DEPOSIT_MANAGER_PROXY_ADDRESS}" "depositERC721(address,uint)" "${L1_ERC721_TOKEN_ADDRESS}" "${token_id}" + + # Wait for Heimdall and Bor to process the bridge event. + wait_for_heimdall_state_sync "${heimdall_state_sync_count}" + wait_for_bor_state_sync "${bor_state_sync_count}" + + # Monitor the balances on L1 and L2. + echo "Monitoring ERC721 balance on L1..." + assert_token_balance_eventually_lower_or_equal "${L1_ERC721_TOKEN_ADDRESS}" "${address}" $((initial_l1_balance - 1)) "${L1_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" + + echo "Monitoring ERC721 balance on L2..." + assert_token_balance_eventually_greater_or_equal "${L2_ERC721_TOKEN_ADDRESS}" "${address}" $((initial_l2_balance + 1)) "${L2_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} + +# bats test_tags=withdraw,transaction-erc721 +@test "withdraw ERC721 from L2 to L1 via plasma bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + # Get a token ID owned by the address on L2. + token_id=$(cast call --rpc-url "${L2_RPC_URL}" --json "${L2_ERC721_TOKEN_ADDRESS}" "tokenOfOwnerByIndex(address,uint256)(uint256)" "${address}" 0 | jq --raw-output '.[0]') + echo "Withdrawing ERC721 token ID: ${token_id}" + + # Get initial balances and latest checkpoint ID. + initial_l1_balance=$(cast call --rpc-url "${L1_RPC_URL}" --json "${L1_ERC721_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + initial_l2_balance=$(cast call --rpc-url "${L2_RPC_URL}" --json "${L2_ERC721_TOKEN_ADDRESS}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + initial_checkpoint_id=$(latest_checkpoint_id) + + echo "Initial balances and state:" + echo "- L1 ERC721 balance: ${initial_l1_balance}" + echo "- L2 ERC721 balance: ${initial_l2_balance}" + echo "- Latest checkpoint ID: ${initial_checkpoint_id}" + + # Burn the ERC721 token on L2 to initiate the Plasma exit. + echo "Burning ERC721 token (id: ${token_id}) on L2..." + withdraw_receipt=$(cast send \ + --rpc-url "${L2_RPC_URL}" \ + --private-key "${PRIVATE_KEY}" \ + --gas-price 30gwei \ + --priority-gas-price 30gwei \ + --gas-limit 200000 \ + --json \ + "${L2_ERC721_TOKEN_ADDRESS}" \ + "withdraw(uint256)" "${token_id}") + withdraw_tx_hash=$(echo "${withdraw_receipt}" | jq --raw-output ".transactionHash") + withdraw_block_hex=$(echo "${withdraw_receipt}" | jq --raw-output ".blockNumber") + withdraw_block=$(printf "%d" "${withdraw_block_hex}") + echo "Withdraw tx: ${withdraw_tx_hash} (block ${withdraw_block})" + + # Verify L2 ERC721 balance decreased. + echo "Verifying L2 ERC721 balance decreased..." + assert_token_balance_eventually_lower_or_equal "${L2_ERC721_TOKEN_ADDRESS}" "${address}" $((initial_l2_balance - 1)) "${L2_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" + + # Wait for a new checkpoint on L1 that covers the withdrawal block. + # This confirms validators have attested to the burn, which is a prerequisite for building a valid exit Merkle proof. + echo "Waiting for a new checkpoint to cover L2 block ${withdraw_block}..." + wait_for_new_checkpoint "${initial_checkpoint_id}" + + # Generate the exit payload for the burn transaction. + # It includes the burn tx receipt, a Merkle proof of that receipt in the block's receipts trie, and a checkpoint proof. + # Retried in a loop because the checkpoint may not yet be indexed by polycli even after being confirmed on L1. + # The ERC721 contract emits: log 0 = Transfer, log 1 = Withdraw(rootToken=ERC721, tokenId). + echo "Generating the exit payload for the burn transaction..." + payload=$(generate_pos_exit_payload "${withdraw_tx_hash}" 1 $((2 * timeout_seconds))) + + # Start the exit on L1 with the generated payload. + # ERC721Predicate handles ERC721 exits: reads rootToken from the Withdraw event and queues the exit. + echo "Starting the exit on L1 with the generated payload..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" --gas-limit 500000 \ + "${L1_ERC721_PREDICATE_ADDRESS}" "startExitWithBurntTokens(bytes)" "${payload}" + + # Process the exit on L1. + echo "Processing the exit on L1..." + process_plasma_exit "${L1_ERC721_TOKEN_ADDRESS}" + + # Verify L1 ERC721 balance increased. + echo "Verifying L1 ERC721 balance increased..." + assert_token_balance_eventually_greater_or_equal "${L1_ERC721_TOKEN_ADDRESS}" "${address}" \ + $((initial_l1_balance + 1)) "${L1_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} diff --git a/tests/pos/bridge/pos.bats b/tests/pos/bridge/pos.bats new file mode 100644 index 00000000..37aeb67e --- /dev/null +++ b/tests/pos/bridge/pos.bats @@ -0,0 +1,334 @@ +#!/usr/bin/env bats +# bats file_tags=pos +# shellcheck disable=SC2154 # heimdall_state_sync_count_cmd/bor_state_sync_count_cmd are defined by pos-bridge.bash + +# pos bridge tests — see ./README.md for how plasma relates to pos bridge. + +setup() { + load "../../../core/helpers/pos-setup.bash" + load "../../../core/helpers/scripts/eventually.bash" + load "../../../core/helpers/scripts/pos-bridge.bash" + pos_setup + + bridge_amount=$(cast to-unit 1ether wei) + timeout_seconds=${TIMEOUT_SECONDS:-"180"} + interval_seconds=${INTERVAL_SECONDS:-"10"} +} + +# Mint `amount` DummyERC20 to the deployer on L1. DummyERC20 exposes a public mint. +_mint_dummy_erc20() { + local amount="$1" + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DUMMY_ERC20}" "mint(uint256)" "${amount}" +} + +############################################################################## +# ETH (Native L1) / MaticWETH +############################################################################## + +# bats test_tags=bridge,transaction-eth +@test "bridge ETH from L1 to L2 via pos bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + initial_l1_eth=$(cast balance --rpc-url "${L1_RPC_URL}" "${address}") + initial_l2_weth=$(cast call --rpc-url "${L2_RPC_URL}" --json "${L2_MATIC_WETH}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + echo "Initial L1 ETH: ${initial_l1_eth}" + echo "Initial L2 MaticWETH: ${initial_l2_weth}" + + heimdall_count=$(eval "${heimdall_state_sync_count_cmd}") + bor_count=$(eval "${bor_state_sync_count_cmd}") + + echo "Calling RootChainManager.depositEtherFor..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" --value "${bridge_amount}" \ + "${L1_ROOT_CHAIN_MANAGER_PROXY}" "depositEtherFor(address)" "${address}" + + wait_for_state_sync_after_deposit "${heimdall_count}" "${bor_count}" + + echo "Monitoring L2 MaticWETH balance..." + assert_token_balance_eventually_greater_or_equal "${L2_MATIC_WETH}" "${address}" "$(echo "${initial_l2_weth} + ${bridge_amount}" | bc)" "${L2_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} + +# bats test_tags=withdraw,transaction-eth +@test "withdraw ETH from L2 to L1 via pos bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + # Seed L2 MaticWETH if empty (bridge 1 ETH in first). + l2_balance=$(cast call --rpc-url "${L2_RPC_URL}" --json "${L2_MATIC_WETH}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + if [[ "${l2_balance}" == "0" ]]; then + hm=$(eval "${heimdall_state_sync_count_cmd}") + bc_=$(eval "${bor_state_sync_count_cmd}") + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" --value "${bridge_amount}" \ + "${L1_ROOT_CHAIN_MANAGER_PROXY}" "depositEtherFor(address)" "${address}" + wait_for_state_sync_after_deposit "${hm}" "${bc_}" + fi + + initial_l1_eth=$(cast balance --rpc-url "${L1_RPC_URL}" "${address}") + initial_checkpoint=$(latest_checkpoint_id) + + withdraw_amount=$(cast to-unit 1ether wei) + echo "Burning ${withdraw_amount} MaticWETH on L2..." + burn=$(cast send --rpc-url "${L2_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + --gas-price 30gwei --priority-gas-price 30gwei --gas-limit 200000 --json \ + "${L2_MATIC_WETH}" "withdraw(uint256)" "${withdraw_amount}") + burn_tx=$(echo "${burn}" | jq --raw-output ".transactionHash") + + echo "Waiting for a new checkpoint on L1..." + wait_for_new_checkpoint "${initial_checkpoint}" + + echo "Generating exit payload..." + payload=$(generate_pos_exit_payload "${burn_tx}" 0 $((2 * timeout_seconds))) + + echo "Submitting exit on L1..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" --gas-limit 500000 \ + "${L1_ROOT_CHAIN_MANAGER_PROXY}" "exit(bytes)" "${payload}" + + # EtherPredicate releases real ETH to the exitor on L1. Account for gas consumed by the exit tx. + # Allow up to 0.01 ETH of gas overhead (matches plasma MaticWeth test tolerance). + echo "Verifying L1 ETH balance increased by roughly withdraw_amount..." + expected_min=$(echo "${initial_l1_eth} + ${withdraw_amount} - 10000000000000000" | bc) + assert_ether_balance_eventually_greater_or_equal "${address}" "${expected_min}" "${L1_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} + +############################################################################## +# ERC20 +############################################################################## + +# bats test_tags=bridge,transaction-erc20 +@test "bridge ERC20 from L1 to L2 via pos bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + # Ensure the deployer holds enough DummyERC20 to cover the deposit. + _mint_dummy_erc20 "${bridge_amount}" + + initial_l1_balance=$(cast call --rpc-url "${L1_RPC_URL}" --json "${L1_DUMMY_ERC20}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + initial_l2_balance=$(cast call --rpc-url "${L2_RPC_URL}" --json "${L2_DUMMY_ERC20}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + echo "Initial L1 DummyERC20: ${initial_l1_balance}" + echo "Initial L2 ChildERC20: ${initial_l2_balance}" + + heimdall_count=$(eval "${heimdall_state_sync_count_cmd}") + bor_count=$(eval "${bor_state_sync_count_cmd}") + + echo "Approving ERC20Predicate to spend DummyERC20..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DUMMY_ERC20}" "approve(address,uint)" "${L1_ERC20_BRIDGE_PREDICATE_PROXY}" "${bridge_amount}" + + echo "Calling RootChainManager.depositFor..." + # depositData for ERC20 is abi.encode(amount) — a single uint256 word. + deposit_data=$(cast abi-encode "f(uint256)" "${bridge_amount}") + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_ROOT_CHAIN_MANAGER_PROXY}" "depositFor(address,address,bytes)" \ + "${address}" "${L1_DUMMY_ERC20}" "${deposit_data}" + + wait_for_state_sync_after_deposit "${heimdall_count}" "${bor_count}" + + echo "Monitoring L1 DummyERC20 balance..." + assert_token_balance_eventually_lower_or_equal "${L1_DUMMY_ERC20}" "${address}" "$(echo "${initial_l1_balance} - ${bridge_amount}" | bc)" "${L1_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" + + echo "Monitoring L2 ChildERC20 balance..." + assert_token_balance_eventually_greater_or_equal "${L2_DUMMY_ERC20}" "${address}" "$(echo "${initial_l2_balance} + ${bridge_amount}" | bc)" "${L2_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} + +# bats test_tags=withdraw,transaction-erc20 +@test "withdraw ERC20 from L2 to L1 via pos bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + + # Seed L2 balance if needed by running a deposit first. To keep this test self-contained + # we assume a prior bridge test landed funds; fall back to a deposit if the L2 balance is 0. + l2_balance=$(cast call --rpc-url "${L2_RPC_URL}" --json "${L2_DUMMY_ERC20}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + if [[ "${l2_balance}" == "0" ]]; then + echo "L2 balance is 0, bridging in first..." + _mint_dummy_erc20 "${bridge_amount}" + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DUMMY_ERC20}" "approve(address,uint)" "${L1_ERC20_BRIDGE_PREDICATE_PROXY}" "${bridge_amount}" + hm=$(eval "${heimdall_state_sync_count_cmd}") + bc_=$(eval "${bor_state_sync_count_cmd}") + deposit_data=$(cast abi-encode "f(uint256)" "${bridge_amount}") + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_ROOT_CHAIN_MANAGER_PROXY}" "depositFor(address,address,bytes)" \ + "${address}" "${L1_DUMMY_ERC20}" "${deposit_data}" + wait_for_state_sync_after_deposit "${hm}" "${bc_}" + fi + + initial_l1_balance=$(cast call --rpc-url "${L1_RPC_URL}" --json "${L1_DUMMY_ERC20}" "balanceOf(address)(uint)" "${address}" | jq --raw-output '.[0]') + initial_checkpoint=$(latest_checkpoint_id) + + withdraw_amount=$(cast to-unit 1ether wei) + echo "Burning ${withdraw_amount} ChildERC20 on L2..." + withdraw_receipt=$(cast send --rpc-url "${L2_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + --gas-price 30gwei --priority-gas-price 30gwei --gas-limit 200000 --json \ + "${L2_DUMMY_ERC20}" "withdraw(uint256)" "${withdraw_amount}") + withdraw_tx_hash=$(echo "${withdraw_receipt}" | jq --raw-output ".transactionHash") + echo "Burn tx: ${withdraw_tx_hash}" + + echo "Waiting for a new checkpoint on L1..." + wait_for_new_checkpoint "${initial_checkpoint}" + + echo "Generating exit payload..." + # ChildERC20.withdraw emits only a Transfer event (to zero address) at log index 0. + payload=$(generate_pos_exit_payload "${withdraw_tx_hash}" 0 $((2 * timeout_seconds))) + + echo "Submitting exit on L1..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" --gas-limit 500000 \ + "${L1_ROOT_CHAIN_MANAGER_PROXY}" "exit(bytes)" "${payload}" + + echo "Verifying L1 DummyERC20 balance increased..." + assert_token_balance_eventually_greater_or_equal "${L1_DUMMY_ERC20}" "${address}" \ + "$(echo "${initial_l1_balance} + ${withdraw_amount}" | bc)" "${L1_RPC_URL}" "${timeout_seconds}" "${interval_seconds}" +} + +############################################################################## +# ERC721 +############################################################################## + +# bats test_tags=bridge,transaction-erc721 +@test "bridge ERC721 from L1 to L2 via pos bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + # Use timestamp as tokenId to avoid collisions across reruns. + token_id=$(( $(date +%s%N) )) + + echo "Minting DummyERC721 tokenId=${token_id}..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DUMMY_ERC721}" "mint(uint256)" "${token_id}" + + heimdall_count=$(eval "${heimdall_state_sync_count_cmd}") + bor_count=$(eval "${bor_state_sync_count_cmd}") + + echo "Approving ERC721Predicate..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DUMMY_ERC721}" "approve(address,uint)" "${L1_ERC721_BRIDGE_PREDICATE_PROXY}" "${token_id}" + + echo "Calling RootChainManager.depositFor for ERC721..." + deposit_data=$(cast abi-encode "f(uint256)" "${token_id}") + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_ROOT_CHAIN_MANAGER_PROXY}" "depositFor(address,address,bytes)" \ + "${address}" "${L1_DUMMY_ERC721}" "${deposit_data}" + + wait_for_state_sync_after_deposit "${heimdall_count}" "${bor_count}" + + echo "Verifying tokenId on L2 ChildERC721..." + want=$(echo "${address}" | tr "[:upper:]" "[:lower:]") + got=$(cast call --rpc-url "${L2_RPC_URL}" "${L2_DUMMY_ERC721}" "ownerOf(uint256)(address)" "${token_id}" | tr "[:upper:]" "[:lower:]") + [[ "${got}" == "${want}" ]] || { echo "Owner mismatch: got=${got} want=${want}"; exit 1; } +} + +# bats test_tags=withdraw,transaction-erc721 +@test "withdraw ERC721 from L2 to L1 via pos bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + token_id=$(( $(date +%s%N) )) + + echo "Seeding L2 with tokenId=${token_id}: mint on L1, bridge, then withdraw..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DUMMY_ERC721}" "mint(uint256)" "${token_id}" + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DUMMY_ERC721}" "approve(address,uint)" "${L1_ERC721_BRIDGE_PREDICATE_PROXY}" "${token_id}" + hm=$(eval "${heimdall_state_sync_count_cmd}") + bc_=$(eval "${bor_state_sync_count_cmd}") + deposit_data=$(cast abi-encode "f(uint256)" "${token_id}") + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_ROOT_CHAIN_MANAGER_PROXY}" "depositFor(address,address,bytes)" \ + "${address}" "${L1_DUMMY_ERC721}" "${deposit_data}" + wait_for_state_sync_after_deposit "${hm}" "${bc_}" + + initial_checkpoint=$(latest_checkpoint_id) + + echo "Burning ChildERC721 tokenId=${token_id} on L2..." + burn=$(cast send --rpc-url "${L2_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + --gas-price 30gwei --priority-gas-price 30gwei --gas-limit 200000 --json \ + "${L2_DUMMY_ERC721}" "withdraw(uint256)" "${token_id}") + burn_tx=$(echo "${burn}" | jq --raw-output ".transactionHash") + + echo "Waiting for a new checkpoint on L1..." + wait_for_new_checkpoint "${initial_checkpoint}" + + echo "Generating exit payload..." + # ChildERC721._burn emits Approval then Transfer, so the Transfer we exit on is log index 1. + payload=$(generate_pos_exit_payload "${burn_tx}" 1 $((2 * timeout_seconds))) + + echo "Submitting exit on L1..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" --gas-limit 500000 \ + "${L1_ROOT_CHAIN_MANAGER_PROXY}" "exit(bytes)" "${payload}" + + echo "Verifying L1 DummyERC721 ownership restored..." + want=$(echo "${address}" | tr "[:upper:]" "[:lower:]") + got=$(cast call --rpc-url "${L1_RPC_URL}" "${L1_DUMMY_ERC721}" "ownerOf(uint256)(address)" "${token_id}" | tr "[:upper:]" "[:lower:]") + [[ "${got}" == "${want}" ]] || { echo "Owner mismatch: got=${got} want=${want}"; exit 1; } +} + +############################################################################## +# ERC1155 +############################################################################## + +# bats test_tags=bridge,transaction-erc1155 +@test "bridge ERC1155 from L1 to L2 via pos bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + # Fresh id per run so balances don't leak between runs. + id=$(( $(date +%s%N) )) + amount=100 + + echo "Minting DummyERC1155 id=${id} amount=${amount}..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DUMMY_ERC1155}" "mint(address,uint256,uint256)" "${address}" "${id}" "${amount}" + + heimdall_count=$(eval "${heimdall_state_sync_count_cmd}") + bor_count=$(eval "${bor_state_sync_count_cmd}") + + echo "setApprovalForAll on ERC1155Predicate..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DUMMY_ERC1155}" "setApprovalForAll(address,bool)" "${L1_ERC1155_BRIDGE_PREDICATE_PROXY}" true + + echo "Calling RootChainManager.depositFor for ERC1155..." + # depositData = abi.encode(uint256[] ids, uint256[] amounts, bytes data). + deposit_data=$(cast abi-encode "f(uint256[],uint256[],bytes)" "[${id}]" "[${amount}]" "0x") + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_ROOT_CHAIN_MANAGER_PROXY}" "depositFor(address,address,bytes)" \ + "${address}" "${L1_DUMMY_ERC1155}" "${deposit_data}" + + wait_for_state_sync_after_deposit "${heimdall_count}" "${bor_count}" + + echo "Verifying L2 ChildERC1155 balance..." + l2_balance_cmd='cast call --rpc-url "${L2_RPC_URL}" --json "${L2_DUMMY_ERC1155}" "balanceOf(address,uint256)(uint)" "'"${address}"'" "'"${id}"'" | jq -r ".[0]"' + assert_command_eventually_greater_or_equal "${l2_balance_cmd}" "${amount}" "${timeout_seconds}" "${interval_seconds}" +} + +# bats test_tags=withdraw,transaction-erc1155 +@test "withdraw ERC1155 from L2 to L1 via pos bridge" { + address=$(cast wallet address --private-key "${PRIVATE_KEY}") + id=$(( $(date +%s%N) )) + amount=100 + + echo "Seeding L2 ERC1155 (id=${id}, amount=${amount}): mint, approve, bridge..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DUMMY_ERC1155}" "mint(address,uint256,uint256)" "${address}" "${id}" "${amount}" + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_DUMMY_ERC1155}" "setApprovalForAll(address,bool)" "${L1_ERC1155_BRIDGE_PREDICATE_PROXY}" true + hm=$(eval "${heimdall_state_sync_count_cmd}") + bc_=$(eval "${bor_state_sync_count_cmd}") + deposit_data=$(cast abi-encode "f(uint256[],uint256[],bytes)" "[${id}]" "[${amount}]" "0x") + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + "${L1_ROOT_CHAIN_MANAGER_PROXY}" "depositFor(address,address,bytes)" \ + "${address}" "${L1_DUMMY_ERC1155}" "${deposit_data}" + wait_for_state_sync_after_deposit "${hm}" "${bc_}" + + initial_l1_balance=$(cast call --rpc-url "${L1_RPC_URL}" --json "${L1_DUMMY_ERC1155}" "balanceOf(address,uint256)(uint)" "${address}" "${id}" | jq --raw-output '.[0]') + initial_checkpoint=$(latest_checkpoint_id) + + echo "Burning ChildERC1155 on L2 via withdrawSingle..." + burn=$(cast send --rpc-url "${L2_RPC_URL}" --private-key "${PRIVATE_KEY}" \ + --gas-price 30gwei --priority-gas-price 30gwei --gas-limit 200000 --json \ + "${L2_DUMMY_ERC1155}" "withdrawSingle(uint256,uint256)" "${id}" "${amount}") + burn_tx=$(echo "${burn}" | jq --raw-output ".transactionHash") + + echo "Waiting for a new checkpoint on L1..." + wait_for_new_checkpoint "${initial_checkpoint}" + + echo "Generating exit payload..." + payload=$(generate_pos_exit_payload "${burn_tx}" 0 $((2 * timeout_seconds))) + + echo "Submitting exit on L1..." + cast send --rpc-url "${L1_RPC_URL}" --private-key "${PRIVATE_KEY}" --gas-limit 500000 \ + "${L1_ROOT_CHAIN_MANAGER_PROXY}" "exit(bytes)" "${payload}" + + echo "Verifying L1 DummyERC1155 balance increased..." + l1_balance_cmd='cast call --rpc-url "${L1_RPC_URL}" --json "${L1_DUMMY_ERC1155}" "balanceOf(address,uint256)(uint)" "'"${address}"'" "'"${id}"'" | jq -r ".[0]"' + assert_command_eventually_greater_or_equal "${l1_balance_cmd}" "$(echo "${initial_l1_balance} + ${amount}" | bc)" "${timeout_seconds}" "${interval_seconds}" +}