diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 0aca98fb28..7987706322 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -132,6 +132,12 @@ jobs: "./integration_test/evm_module/scripts/evm_giga_tests.sh" ] }, + { + name: "EVM GIGA Mixed (Determinism)", + scripts: [ + "./integration_test/evm_module/scripts/evm_giga_mixed_tests.sh" + ] + }, ] steps: - uses: actions/checkout@v3 diff --git a/Makefile b/Makefile index 20bb021c70..249b72c3cc 100644 --- a/Makefile +++ b/Makefile @@ -341,6 +341,56 @@ giga-integration-test: @echo "=== GIGA Integration Tests Complete ===" .PHONY: giga-integration-test +# Run a mixed-mode cluster: node 0 uses GIGA_EXECUTOR, nodes 1-3 use standard V2. +# Any determinism divergence between giga and V2 will cause the giga node to halt. +docker-cluster-start-giga-mixed: docker-cluster-stop build-docker-node + @rm -rf $(PROJECT_HOME)/build/generated + @mkdir -p $(shell go env GOPATH)/pkg/mod + @mkdir -p $(shell go env GOCACHE) + @cd docker && \ + if [ "$${DOCKER_DETACH:-}" = "true" ]; then \ + DETACH_FLAG="-d"; \ + else \ + DETACH_FLAG=""; \ + fi; \ + DOCKER_PLATFORM=$(DOCKER_PLATFORM) USERID=$(shell id -u) GROUPID=$(shell id -g) GOCACHE=$(shell go env GOCACHE) NUM_ACCOUNTS=10 INVARIANT_CHECK_INTERVAL=${INVARIANT_CHECK_INTERVAL} UPGRADE_VERSION_LIST=${UPGRADE_VERSION_LIST} MOCK_BALANCES=${MOCK_BALANCES} \ + docker compose -f docker-compose.yml -f docker-compose.giga-mixed.yml up $$DETACH_FLAG +.PHONY: docker-cluster-start-giga-mixed + +# Run the giga mixed-mode integration test. +# Starts a cluster where only node 0 runs giga (sequential), nodes 1-3 run standard V2. +# Then runs hardhat tests. If giga produces different results, node 0 will halt. +giga-mixed-integration-test: + @echo "=== Starting GIGA Mixed-Mode Integration Tests ===" + @echo "=== Node 0: GIGA_EXECUTOR=true, Nodes 1-3: standard V2 ===" + @$(MAKE) docker-cluster-stop || true + @rm -rf $(PROJECT_HOME)/build/generated + @DOCKER_DETACH=true $(MAKE) docker-cluster-start-giga-mixed + @echo "Waiting for cluster to be ready..." + @timeout=300; elapsed=0; \ + while [ $$elapsed -lt $$timeout ]; do \ + if [ -f "build/generated/launch.complete" ] && [ $$(cat build/generated/launch.complete | wc -l) -ge 4 ]; then \ + echo "All 4 nodes are ready (took $${elapsed}s)"; \ + break; \ + fi; \ + sleep 5; \ + elapsed=$$((elapsed + 5)); \ + echo " Waiting... ($${elapsed}s elapsed)"; \ + done; \ + if [ $$elapsed -ge $$timeout ]; then \ + echo "ERROR: Cluster failed to start within $${timeout}s"; \ + $(MAKE) docker-cluster-stop; \ + exit 1; \ + fi + @echo "Waiting 10s for nodes to stabilize..." + @sleep 10 + @echo "=== Running GIGA EVM Tests (mixed mode) ===" + @./integration_test/evm_module/scripts/evm_giga_tests.sh || (echo "TEST FAILURE - check if node 0 (giga) halted due to consensus mismatch" && $(MAKE) docker-cluster-stop && exit 1) + @echo "=== Stopping cluster ===" + @$(MAKE) docker-cluster-stop + @echo "=== GIGA Mixed-Mode Integration Tests Complete ===" +.PHONY: giga-mixed-integration-test + # Implements test splitting and running. This is pulled directly from # the github action workflows for better local reproducibility. diff --git a/app/app.go b/app/app.go index e60522181c..e6b2eca4ec 100644 --- a/app/app.go +++ b/app/app.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "math" + "math/big" "net/http" "os" "path/filepath" @@ -89,10 +90,12 @@ import ( upgradekeeper "github.com/cosmos/cosmos-sdk/x/upgrade/keeper" upgradetypes "github.com/cosmos/cosmos-sdk/x/upgrade/types" "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/tracing" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/vm" "github.com/ethereum/go-ethereum/ethclient" ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/holiman/uint256" "github.com/sei-protocol/sei-chain/giga/deps/tasks" "github.com/gogo/protobuf/proto" @@ -174,6 +177,7 @@ import ( gigabankkeeper "github.com/sei-protocol/sei-chain/giga/deps/xbank/keeper" gigaevmkeeper "github.com/sei-protocol/sei-chain/giga/deps/xevm/keeper" gigaevmstate "github.com/sei-protocol/sei-chain/giga/deps/xevm/state" + "github.com/sei-protocol/sei-chain/giga/deps/xevm/types/ethtx" ) // this line is used by starport scaffolding # stargate/wasm/app/enabledProposals @@ -697,6 +701,8 @@ func New( tkeys[evmtypes.TransientStoreKey], app.GetSubspace(evmtypes.ModuleName), app.receiptStore, app.GigaBankKeeper, &app.AccountKeeper, &app.StakingKeeper, app.TransferKeeper, wasmkeeper.NewDefaultPermissionKeeper(app.WasmKeeper), &app.WasmKeeper, &app.UpgradeKeeper) + app.GigaEvmKeeper.UseRegularStore = true + app.GigaBankKeeper.UseRegularStore = true app.GigaBankKeeper.RegisterRecipientChecker(app.GigaEvmKeeper.CanAddressReceive) // Read Giga Executor config gigaExecutorConfig, err := gigaconfig.ReadConfig(appOpts) @@ -1678,6 +1684,12 @@ func (app *App) ProcessBlock(ctx sdk.Context, txs [][]byte, req BlockProcessRequ evmTxs[originalIndex] = app.GetEVMMsg(prioritizedTypedTxs[relativePrioritizedIndex]) } + // Flush giga stores so WriteDeferredBalances (which uses the standard BankKeeper) + // can see balance changes made by the giga executor via GigaBankKeeper. + if app.GigaExecutorEnabled { + ctx.GigaMultiStore().WriteGiga() + } + // Finalize all Bank Module Transfers here so that events are included for prioritiezd txs deferredWriteEvents := app.BankKeeper.WriteDeferredBalances(ctx) events = append(events, deferredWriteEvents...) @@ -1690,6 +1702,12 @@ func (app *App) ProcessBlock(ctx sdk.Context, txs [][]byte, req BlockProcessRequ txResults[originalIndex] = otherResults[relativeOtherIndex] evmTxs[originalIndex] = app.GetEVMMsg(otherTypedTxs[relativeOtherIndex]) } + + // Flush giga stores after second round (same reason as above) + if app.GigaExecutorEnabled { + ctx.GigaMultiStore().WriteGiga() + } + app.EvmKeeper.SetTxResults(txResults) app.EvmKeeper.SetMsgs(evmTxs) @@ -1742,13 +1760,95 @@ func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgE } } + // ============================================================================ + // Fee validation (mirrors V2's ante handler checks in evm_checktx.go) + // NOTE: In V2, failed transactions still increment nonce and charge gas. + // We track validation errors here but don't return early - we still need to + // create stateDB, increment nonce, and finalize state to match V2 behavior. + // ============================================================================ + baseFee := app.GigaEvmKeeper.GetBaseFee(ctx) + if baseFee == nil { + baseFee = new(big.Int) // default to 0 when base fee is unset + } + + // Track validation errors - we'll skip execution but still finalize state + var validationErr *abci.ExecTxResult + + // 1. Fee cap < base fee check (INSUFFICIENT_MAX_FEE_PER_GAS) + // V2: evm_checktx.go line 284-286 + if txData.GetGasFeeCap().Cmp(baseFee) < 0 { + validationErr = &abci.ExecTxResult{ + Code: sdkerrors.ErrInsufficientFee.ABCICode(), + Log: "max fee per gas less than block base fee", + } + } + + // 2. Tip > fee cap check (PRIORITY_GREATER_THAN_MAX_FEE_PER_GAS) + // This is checked in txData.Validate() for DynamicFeeTx, but we also check here + // to ensure consistent rejection before execution. + if validationErr == nil && txData.GetGasTipCap().Cmp(txData.GetGasFeeCap()) > 0 { + validationErr = &abci.ExecTxResult{ + Code: 1, + Log: "max priority fee per gas higher than max fee per gas", + } + } + + // 3. Gas limit * gas price overflow check (GASLIMIT_PRICE_PRODUCT_OVERFLOW) + // V2: Uses IsValidInt256(tx.Fee()) in dynamic_fee_tx.go Validate() + // Fee = GasFeeCap * GasLimit, must fit in 256 bits + if validationErr == nil && !ethtx.IsValidInt256(txData.Fee()) { + validationErr = &abci.ExecTxResult{ + Code: 1, + Log: "fee out of bound", + } + } + + // 4. TX gas limit > block gas limit check (GAS_ALLOWANCE_EXCEEDED) + // V2: x/evm/ante/basic.go lines 63-68 + if validationErr == nil { + if cp := ctx.ConsensusParams(); cp != nil && cp.Block != nil { + if cp.Block.MaxGas > 0 && ethTx.Gas() > uint64(cp.Block.MaxGas) { //nolint:gosec + validationErr = &abci.ExecTxResult{ + Code: sdkerrors.ErrOutOfGas.ABCICode(), + Log: fmt.Sprintf("tx gas limit %d exceeds block max gas %d", ethTx.Gas(), cp.Block.MaxGas), + } + } + } + } + // Prepare context for EVM transaction (set infinite gas meter like original flow) ctx = ctx.WithGasMeter(sdk.NewInfiniteGasMeterWithMultiplier(ctx)) - // Create state DB for this transaction + // If validation failed, increment nonce via keeper (matching V2's DeliverTxCallback behavior + // in x/evm/ante/basic.go). V2 does NOT create stateDB or handle surplus for early failures. + if validationErr != nil { + // Match V2 error handling: bump nonce directly via keeper (not stateDB) + currentNonce := app.GigaEvmKeeper.GetNonce(ctx, sender) + app.GigaEvmKeeper.SetNonce(ctx, sender, currentNonce+1) + + // V2 reports intrinsic gas as gasUsed even on validation failure (for metrics), + // but no actual balance is deducted + intrinsicGas, _ := core.IntrinsicGas(ethTx.Data(), ethTx.AccessList(), ethTx.SetCodeAuthorizations(), ethTx.To() == nil, true, true, true) + validationErr.GasUsed = int64(intrinsicGas) //nolint:gosec + validationErr.GasWanted = int64(ethTx.Gas()) //nolint:gosec + return validationErr, nil + } + + // Create state DB for this transaction (only for valid transactions) stateDB := gigaevmstate.NewDBImpl(ctx, &app.GigaEvmKeeper, false) defer stateDB.Cleanup() + // Pre-charge gas fee (like V2's ante handler), then execute with feeAlreadyCharged=true. + // V2 charges fees in the ante handler, then runs the EVM with feeAlreadyCharged=true + // which skips buyGas/refundGas/coinbase. Without this, GasUsed differs between Giga + // and V2, causing LastResultsHash → AppHash divergence. + effectiveGasPrice := new(big.Int).Add(new(big.Int).Set(ethTx.GasTipCap()), baseFee) + if effectiveGasPrice.Cmp(ethTx.GasFeeCap()) > 0 { + effectiveGasPrice.Set(ethTx.GasFeeCap()) + } + gasFee := new(big.Int).Mul(new(big.Int).SetUint64(ethTx.Gas()), effectiveGasPrice) + stateDB.SubBalance(sender, uint256.MustFromBig(gasFee), tracing.BalanceDecreaseGasBuy) + // Get gas pool gp := app.GigaEvmKeeper.GetGasPool() @@ -1768,12 +1868,25 @@ func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgE // Create Giga executor VM gigaExecutor := gigaexecutor.NewGethExecutor(*blockCtx, stateDB, cfg, vm.Config{}, gigaprecompiles.AllCustomPrecompilesFailFast) - // Execute the transaction through giga VM - execResult, execErr := gigaExecutor.ExecuteTransaction(ethTx, sender, app.GigaEvmKeeper.GetBaseFee(ctx), &gp) + // Execute with feeAlreadyCharged=true — matching V2's msg_server behavior + execResult, execErr := gigaExecutor.ExecuteTransactionFeeCharged(ethTx, sender, baseFee, &gp) if execErr != nil { + // Match V2 error handling: bump nonce, commit fee deduction, track surplus + stateDB.SetNonce(sender, stateDB.GetNonce(sender)+1, tracing.NonceChangeEoACall) + surplus, ferr := stateDB.Finalize() + if ferr != nil { + ctx.Logger().Error("giga: failed to finalize stateDB on consensus error", + "txHash", ethTx.Hash().Hex(), + "error", ferr, + ) + } + bloom := ethtypes.Bloom{} + app.EvmKeeper.AppendToEvmTxDeferredInfo(ctx, bloom, ethTx.Hash(), surplus) + return &abci.ExecTxResult{ - Code: 1, - Log: fmt.Sprintf("giga executor apply message error: %v", execErr), + Code: 1, + GasWanted: int64(ethTx.Gas()), //nolint:gosec + Log: fmt.Sprintf("giga executor apply message error: %v", execErr), }, nil } @@ -1783,8 +1896,8 @@ func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgE return nil, execResult.Err } - // Finalize state changes - _, ferr := stateDB.Finalize() + // Finalize state changes — captures surplus (fee deduction + execution balance changes) + surplus, ferr := stateDB.Finalize() if ferr != nil { return &abci.ExecTxResult{ Code: 1, @@ -1820,9 +1933,6 @@ func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgE } // Append deferred info for EndBlock processing - // Calculate surplus (gas fee paid minus gas used * effective gas price) - // For giga executor, we set surplus to zero since we're not charging gas fees through the normal flow - surplus := sdk.ZeroInt() bloom := ethtypes.Bloom{} bloom.SetBytes(receipt.LogsBloom) app.EvmKeeper.AppendToEvmTxDeferredInfo(ctx, bloom, ethTx.Hash(), surplus) diff --git a/contracts/src/MultiHopSwapTester.sol b/contracts/src/MultiHopSwapTester.sol new file mode 100644 index 0000000000..a7c3ea5ed0 --- /dev/null +++ b/contracts/src/MultiHopSwapTester.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * @title SimpleToken + * @dev Minimal ERC20 token for testing multi-hop swaps. + * Deployer gets an initial mint and can mint more for testing. + */ +contract SimpleToken is ERC20 { + constructor(string memory name_, string memory symbol_, uint256 initialSupply) ERC20(name_, symbol_) { + _mint(msg.sender, initialSupply); + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +/** + * @title SimplePair + * @dev Minimal Uniswap V2-style constant-product AMM pair. + * Holds reserves of two tokens and allows swaps between them. + * No LP tokens, fees, or flash-loan protection — just the swap math + * needed to exercise multi-hop cross-contract token transfers. + */ +contract SimplePair { + address public token0; + address public token1; + uint256 public reserve0; + uint256 public reserve1; + + event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to); + event Sync(uint256 reserve0, uint256 reserve1); + + constructor(address _token0, address _token1) { + token0 = _token0; + token1 = _token1; + } + + /** + * @dev Add initial liquidity. Tokens must already be transferred to this contract. + */ + function addLiquidity() external { + reserve0 = ERC20(token0).balanceOf(address(this)); + reserve1 = ERC20(token1).balanceOf(address(this)); + emit Sync(reserve0, reserve1); + } + + /** + * @dev Swap: caller sends tokenIn to this contract first, then calls swap. + * Calculates output using x*y=k (no fee for simplicity). + * @param amountIn Amount of input token already transferred to this contract + * @param tokenIn Address of the input token + * @param to Recipient of the output tokens + * @return amountOut Amount of output tokens sent + */ + function swap(uint256 amountIn, address tokenIn, address to) external returns (uint256 amountOut) { + require(tokenIn == token0 || tokenIn == token1, "invalid token"); + require(amountIn > 0, "zero input"); + + bool isToken0 = (tokenIn == token0); + (uint256 resIn, uint256 resOut) = isToken0 ? (reserve0, reserve1) : (reserve1, reserve0); + address tokenOut = isToken0 ? token1 : token0; + + // x * y = k, so amountOut = resOut - k / (resIn + amountIn) + // With 0.3% fee: amountInWithFee = amountIn * 997 + uint256 amountInWithFee = amountIn * 997; + amountOut = (amountInWithFee * resOut) / (resIn * 1000 + amountInWithFee); + require(amountOut > 0, "insufficient output"); + + // Transfer output to recipient + ERC20(tokenOut).transfer(to, amountOut); + + // Update reserves + reserve0 = ERC20(token0).balanceOf(address(this)); + reserve1 = ERC20(token1).balanceOf(address(this)); + + if (isToken0) { + emit Swap(msg.sender, amountIn, 0, 0, amountOut, to); + } else { + emit Swap(msg.sender, 0, amountIn, amountOut, 0, to); + } + emit Sync(reserve0, reserve1); + } +} + +/** + * @title SimpleRouter + * @dev Minimal multi-hop swap router. Chains swaps through multiple pairs, + * transferring tokens between contracts at each hop — exactly like + * the Dragonswap router call that triggered the AppHash divergence. + * + * The key property this exercises: many cross-contract ERC20 transferFrom/ + * transfer calls within a single top-level tx, each going through + * separate EVM CALL frames with separate EVM snapshots. + */ +contract SimpleRouter { + /** + * @dev Execute a multi-hop swap through a series of pairs. + * @param amountIn Amount of the first token to swap + * @param path Array of token addresses representing the swap path + * e.g. [tokenA, tokenB, tokenC] swaps A→B then B→C + * @param pairs Array of pair addresses for each hop + * e.g. [pairAB, pairBC] + * @param to Final recipient of the output tokens + * @return amountOut Final output amount + */ + function swapExactTokensForTokens( + uint256 amountIn, + address[] calldata path, + address[] calldata pairs, + address to + ) external returns (uint256 amountOut) { + require(path.length >= 2, "path too short"); + require(pairs.length == path.length - 1, "pairs/path mismatch"); + + // Pull input tokens from sender to the first pair + ERC20(path[0]).transferFrom(msg.sender, pairs[0], amountIn); + + // Chain swaps through each pair + amountOut = amountIn; + for (uint256 i = 0; i < pairs.length; i++) { + address recipient = (i < pairs.length - 1) ? pairs[i + 1] : to; + amountOut = SimplePair(pairs[i]).swap(amountOut, path[i], recipient); + } + } +} diff --git a/contracts/src/ProxySwapTester.sol b/contracts/src/ProxySwapTester.sol new file mode 100644 index 0000000000..19973f57da --- /dev/null +++ b/contracts/src/ProxySwapTester.sol @@ -0,0 +1,410 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * Reproduces the exact pattern from the failing mainnet tx 0xf0ca0ec2... + * + * Key patterns being tested: + * 1. Proxy token with delegatecall (like Sei's USDC proxy 0xe15fC38F → 0xcaFdC392) + * 2. V3-style callback swaps (pool calls back into router mid-swap) + * 3. Balance verification via staticcall after callback mutates state + * 4. Multiple cross-contract transfers touching the same token storage + */ + +// ─── Proxy Token ─────────────────────────────────────────────────────────────── + +/** + * @title TokenImplementation + * @dev ERC20 implementation that lives behind a proxy. All storage is on the proxy. + * Uses raw storage slots matching OpenZeppelin layout so delegatecall works. + */ +contract TokenImplementation { + // Storage layout: slot 0 = mapping(address => uint256) balances + // slot 1 = mapping(address => mapping(address => uint256)) allowances + // slot 2 = uint256 totalSupply + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function _balanceSlot(address a) internal pure returns (bytes32) { + return keccak256(abi.encode(a, uint256(0))); + } + + function _allowanceSlot(address owner_, address spender_) internal pure returns (bytes32) { + return keccak256(abi.encode(spender_, keccak256(abi.encode(owner_, uint256(1))))); + } + + function balanceOf(address a) external view returns (uint256 bal) { + bytes32 slot = _balanceSlot(a); + assembly { bal := sload(slot) } + } + + function totalSupply() external view returns (uint256 ts) { + assembly { ts := sload(2) } + } + + function transfer(address to, uint256 amount) external returns (bool) { + return _transfer(msg.sender, to, amount); + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + bytes32 slot = _allowanceSlot(from, msg.sender); + uint256 currentAllowance; + assembly { currentAllowance := sload(slot) } + if (currentAllowance != type(uint256).max) { + require(currentAllowance >= amount, "insufficient allowance"); + assembly { sstore(slot, sub(currentAllowance, amount)) } + } + return _transfer(from, to, amount); + } + + function approve(address spender, uint256 amount) external returns (bool) { + bytes32 slot = _allowanceSlot(msg.sender, spender); + assembly { sstore(slot, amount) } + emit Approval(msg.sender, spender, amount); + return true; + } + + function mint(address to, uint256 amount) external { + bytes32 balSlot = _balanceSlot(to); + uint256 bal; + assembly { bal := sload(balSlot) } + assembly { sstore(balSlot, add(bal, amount)) } + uint256 ts; + assembly { ts := sload(2) } + assembly { sstore(2, add(ts, amount)) } + emit Transfer(address(0), to, amount); + } + + function _transfer(address from, address to, uint256 amount) internal returns (bool) { + bytes32 fromSlot = _balanceSlot(from); + bytes32 toSlot = _balanceSlot(to); + uint256 fromBal; + uint256 toBal; + assembly { fromBal := sload(fromSlot) } + require(fromBal >= amount, "insufficient balance"); + assembly { + sstore(fromSlot, sub(fromBal, amount)) + toBal := sload(toSlot) + sstore(toSlot, add(toBal, amount)) + } + emit Transfer(from, to, amount); + return true; + } +} + +/** + * @title ProxyToken + * @dev Minimal proxy that delegates all calls to TokenImplementation. + * Storage lives here, code lives in the implementation. + * This reproduces the Sei USDC proxy pattern from the failing tx. + */ +contract ProxyToken { + address public immutable implementation; + + constructor(address impl) { + implementation = impl; + } + + fallback() external payable { + address impl = implementation; + assembly { + calldatacopy(0, 0, calldatasize()) + let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) + returndatacopy(0, 0, returndatasize()) + switch result + case 0 { revert(0, returndatasize()) } + default { return(0, returndatasize()) } + } + } +} + +// ─── V3-Style Pool with Callback ─────────────────────────────────────────────── + +interface ISwapCallback { + function swapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external; +} + +/** + * @title CallbackPool + * @dev V3-style AMM pool that calls back into the caller to receive input tokens. + * Reproduces the pattern: pool sends output first, then callbacks to get input, + * then verifies balance. This creates nested CALL frames that mutate state. + */ +contract CallbackPool { + address public token0; + address public token1; + + event Swap(address indexed sender, int256 amount0, int256 amount1); + + constructor(address _token0, address _token1) { + token0 = _token0; + token1 = _token1; + } + + /** + * @dev V3-style swap with callback. + * Matches the EXACT operation order from the real mainnet V3 pool trace: + * 1. Transfer output token to recipient (state mutation FIRST) + * 2. STATICCALL balanceOf(inputToken) — pre-callback balance check + * 3. Callback to msg.sender to deliver input tokens + * 4. STATICCALL balanceOf(inputToken) — post-callback verification + * + * This order is critical: there's a dirty write (output transfer) sitting + * in the state BEFORE the first STATICCALL read. The giga KV store's + * snapshot/cache behavior must handle this correctly. + */ + function swap( + address recipient, + bool zeroForOne, + uint256 amountIn, + bytes calldata data + ) external returns (int256 amount0, int256 amount1) { + // Calculate output (simplified: 99% of input for a ~1:1 pool) + uint256 amountOut = amountIn * 99 / 100; + + address tokenIn = zeroForOne ? token0 : token1; + address tokenOut = zeroForOne ? token1 : token0; + + // Step 1: Send output tokens to recipient FIRST — state mutation before any reads + // (Real V3 pools transfer output before checking input balance) + (bool ok1,) = tokenOut.call( + abi.encodeWithSignature("transfer(address,uint256)", recipient, amountOut) + ); + require(ok1, "output transfer failed"); + + // Step 2: Check balance of input token AFTER the output transfer + // This read happens with dirty state from step 1 already in the snapshot + (bool ok0, bytes memory bal0) = tokenIn.staticcall( + abi.encodeWithSignature("balanceOf(address)", address(this)) + ); + require(ok0, "balanceOf failed"); + uint256 balanceBefore = abi.decode(bal0, (uint256)); + + // Step 3: Callback to the caller to deliver input tokens to this pool + if (zeroForOne) { + amount0 = int256(amountIn); + amount1 = -int256(amountOut); + } else { + amount0 = -int256(amountOut); + amount1 = int256(amountIn); + } + ISwapCallback(msg.sender).swapCallback(amount0, amount1, data); + + // Step 4: Verify we received the input tokens (balance check via staticcall) + // This read must see the callback's state mutations + (bool ok2, bytes memory bal1) = tokenIn.staticcall( + abi.encodeWithSignature("balanceOf(address)", address(this)) + ); + require(ok2, "balanceOf after failed"); + uint256 balanceAfter = abi.decode(bal1, (uint256)); + require(balanceAfter >= balanceBefore + amountIn, "insufficient input received"); + + emit Swap(msg.sender, amount0, amount1); + } +} + +// ─── V2-Style Pool (no callback) ────────────────────────────────────────────── + +/** + * @title SimpleV2Pool + * @dev V2-style pool: tokens sent first, then swap() called. + * Reproduces the Dragonswap V2 pair from the failing tx. + */ +contract SimpleV2Pool { + address public token0; + address public token1; + uint112 public reserve0; + uint112 public reserve1; + + event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, + uint256 amount0Out, uint256 amount1Out, address indexed to); + event Sync(uint112 reserve0, uint112 reserve1); + + constructor(address _token0, address _token1) { + token0 = _token0; + token1 = _token1; + } + + function addLiquidity() external { + (bool ok0, bytes memory b0) = token0.staticcall( + abi.encodeWithSignature("balanceOf(address)", address(this)) + ); + (bool ok1, bytes memory b1) = token1.staticcall( + abi.encodeWithSignature("balanceOf(address)", address(this)) + ); + require(ok0 && ok1, "balanceOf failed"); + reserve0 = uint112(abi.decode(b0, (uint256))); + reserve1 = uint112(abi.decode(b1, (uint256))); + emit Sync(reserve0, reserve1); + } + + function getReserves() external view returns (uint112, uint112, uint32) { + return (reserve0, reserve1, uint32(block.timestamp)); + } + + function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata) external { + require(amount0Out > 0 || amount1Out > 0, "insufficient output"); + + if (amount0Out > 0) { + (bool ok,) = token0.call( + abi.encodeWithSignature("transfer(address,uint256)", to, amount0Out) + ); + require(ok, "transfer0 failed"); + } + if (amount1Out > 0) { + (bool ok,) = token1.call( + abi.encodeWithSignature("transfer(address,uint256)", to, amount1Out) + ); + require(ok, "transfer1 failed"); + } + + _updateReserves(); + } + + function _updateReserves() internal { + (bool ok0, bytes memory b0) = token0.staticcall( + abi.encodeWithSignature("balanceOf(address)", address(this)) + ); + (bool ok1, bytes memory b1) = token1.staticcall( + abi.encodeWithSignature("balanceOf(address)", address(this)) + ); + require(ok0 && ok1, "balanceOf failed"); + uint256 balance0 = abi.decode(b0, (uint256)); + uint256 balance1 = abi.decode(b1, (uint256)); + + reserve0 = uint112(balance0); + reserve1 = uint112(balance1); + + emit Sync(reserve0, reserve1); + } +} + +// ─── Multi-Hop Router with Callback Support ──────────────────────────────────── + +/** + * @title CallbackRouter + * @dev Router that chains swaps through V3 callback pools and V2 pools. + * Reproduces the exact swap pattern from the failing tx: + * 1. transferFrom(user → router) for input token + * 2. V3 pool swap with callback (pool sends output, callbacks for input) + * 3. Another V3 pool swap with callback + * 4. V2 pool swap (router sends tokens first, then calls swap) + * 5. Transfer final output to user + */ +contract CallbackRouter is ISwapCallback { + // Transient state for callback + struct SwapState { + address tokenIn; + address pool; + uint256 amountIn; + } + + SwapState private _pendingSwap; + + /** + * @dev Called by the V3 pool during swap to deliver input tokens. + */ + function swapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external override { + // Determine which amount is positive (that's what we owe the pool) + uint256 amountOwed; + if (amount0Delta > 0) { + amountOwed = uint256(amount0Delta); + } else { + amountOwed = uint256(amount1Delta); + } + + // Transfer the owed tokens to the pool + (bool ok,) = _pendingSwap.tokenIn.call( + abi.encodeWithSignature("transfer(address,uint256)", msg.sender, amountOwed) + ); + require(ok, "callback transfer failed"); + } + + /** + * @dev Execute the full multi-hop swap mimicking the failing mainnet tx. + * + * Path: tokenA --(V3 callback pool 1)--> proxyToken --(V3 callback pool 2)--> tokenB + * tokenB --(V2 pool)--> tokenA (back to original token for arb profit) + */ + function executeMultiHopSwap( + uint256 amountIn, + address tokenA, + address proxyToken, + address tokenB, + address v3Pool1, + address v3Pool2, + address v2Pool, + address recipient + ) external returns (uint256 finalAmount) { + // Pull tokenA from sender + _safeCall(tokenA, abi.encodeWithSignature( + "transferFrom(address,address,uint256)", msg.sender, address(this), amountIn + )); + + // Hop 1: V3 callback swap (tokenA → proxyToken) + uint256 hop1Out = _v3Swap(v3Pool1, tokenA, amountIn); + + // Hop 2: V3 callback swap (proxyToken → tokenB) + uint256 hop2Out = _v3Swap(v3Pool2, proxyToken, hop1Out); + + // Hop 3: V2 swap (tokenB → tokenA) + uint256 hop3Out = _v2Swap(v2Pool, tokenB, tokenA, hop2Out); + + // Transfer final output to recipient + _safeCall(tokenA, abi.encodeWithSignature( + "transfer(address,uint256)", recipient, hop3Out + )); + + finalAmount = hop3Out; + } + + function _safeCall(address target, bytes memory data) internal { + (bool ok,) = target.call(data); + require(ok, "call failed"); + } + + function _v3Swap(address pool, address tokenIn, uint256 amountIn) internal returns (uint256 amountOut) { + _pendingSwap = SwapState({ tokenIn: tokenIn, pool: pool, amountIn: amountIn }); + (bool ok, bytes memory ret) = pool.call( + abi.encodeWithSignature( + "swap(address,bool,uint256,bytes)", + address(this), true, amountIn, bytes("") + ) + ); + require(ok, "v3 swap failed"); + (, int256 amount1) = abi.decode(ret, (int256, int256)); + amountOut = amount1 < 0 ? uint256(-amount1) : uint256(amount1); + } + + function _v2Swap(address pool, address tokenIn, address tokenOut, uint256 amountIn) internal returns (uint256 amountOut) { + // Send input tokens to pool + _safeCall(tokenIn, abi.encodeWithSignature("transfer(address,uint256)", pool, amountIn)); + + // Get reserves and token0 + (bool ok1, bytes memory resData) = pool.staticcall(abi.encodeWithSignature("getReserves()")); + require(ok1, "getReserves failed"); + (uint112 r0, uint112 r1,) = abi.decode(resData, (uint112, uint112, uint32)); + + (bool ok2, bytes memory t0Data) = pool.staticcall(abi.encodeWithSignature("token0()")); + require(ok2, "token0 failed"); + address poolToken0 = abi.decode(t0Data, (address)); + + // Calculate output amount + uint256 amount0Out; + uint256 amount1Out; + if (poolToken0 == tokenIn) { + uint256 fee = amountIn * 997; + amountOut = (fee * uint256(r1)) / (uint256(r0) * 1000 + fee); + amount1Out = amountOut; + } else { + uint256 fee = amountIn * 997; + amountOut = (fee * uint256(r0)) / (uint256(r1) * 1000 + fee); + amount0Out = amountOut; + } + + _safeCall(pool, abi.encodeWithSignature( + "swap(uint256,uint256,address,bytes)", amount0Out, amount1Out, address(this), bytes("") + )); + } +} diff --git a/contracts/test/EVMGigaTest.js b/contracts/test/EVMGigaTest.js index 6d5e0773bd..ef7681799f 100644 --- a/contracts/test/EVMGigaTest.js +++ b/contracts/test/EVMGigaTest.js @@ -100,7 +100,7 @@ describe("GIGA EVM Tests", function () { describe("ERC20 Deployment", function () { let testToken; - it("should deploy TestToken contract", async function () { + it("should deploy TestToken with correct supply and metadata", async function () { const TestToken = await ethers.getContractFactory("TestToken"); testToken = await TestToken.deploy("GigaTestToken", "GTT", { gasPrice: ethers.parseUnits('100', 'gwei') @@ -113,41 +113,18 @@ describe("GIGA EVM Tests", function () { // Verify contract code was deployed const code = await ethers.provider.getCode(tokenAddress); expect(code.length).to.be.greaterThan(2); // More than just "0x" - }); - - it("should have correct initial token supply", async function () { - const TestToken = await ethers.getContractFactory("TestToken"); - testToken = await TestToken.deploy("GigaTestToken", "GTT", { - gasPrice: ethers.parseUnits('100', 'gwei') - }); - await testToken.waitForDeployment(); - // TestToken.sol mints 1000 * (10 ** decimals()) to msg.sender - // decimals() is 18 by default in OpenZeppelin ERC20 + // Verify initial supply: deployer receives 1000e18 tokens const expectedInitialSupply = ethers.parseUnits("1000", 18); - const ownerBalance = await testToken.balanceOf(owner.address); const totalSupply = await testToken.totalSupply(); - - // Verify the Solidity behavior: deployer receives 1000e18 tokens expect(ownerBalance).to.equal(expectedInitialSupply); expect(totalSupply).to.equal(expectedInitialSupply); - }); - - it("should have correct token metadata", async function () { - const TestToken = await ethers.getContractFactory("TestToken"); - testToken = await TestToken.deploy("MetadataToken", "MTK", { - gasPrice: ethers.parseUnits('100', 'gwei') - }); - await testToken.waitForDeployment(); - const name = await testToken.name(); - const symbol = await testToken.symbol(); - const decimals = await testToken.decimals(); - - expect(name).to.equal("MetadataToken"); - expect(symbol).to.equal("MTK"); - expect(decimals).to.equal(18n); + // Verify metadata + expect(await testToken.name()).to.equal("GigaTestToken"); + expect(await testToken.symbol()).to.equal("GTT"); + expect(await testToken.decimals()).to.equal(18n); }); }); @@ -155,7 +132,7 @@ describe("GIGA EVM Tests", function () { let testToken; const initialSupply = ethers.parseUnits("1000", 18); - beforeEach(async function () { + before(async function () { const TestToken = await ethers.getContractFactory("TestToken"); testToken = await TestToken.deploy("TransferToken", "TFR", { gasPrice: ethers.parseUnits('100', 'gwei') @@ -179,8 +156,6 @@ describe("GIGA EVM Tests", function () { const ownerBalanceBefore = await testToken.balanceOf(owner.address); const recipientBalanceBefore = await testToken.balanceOf(recipientAddress); - // Verify initial state - expect(ownerBalanceBefore).to.equal(initialSupply); expect(recipientBalanceBefore).to.equal(0n); // Execute transfer @@ -210,13 +185,11 @@ describe("GIGA EVM Tests", function () { const recipientAddress = await recipient.getAddress(); const transferAmount = ethers.parseUnits("50", 18); - // Execute transfer and wait for receipt explicitly const tx = await testToken.transfer(recipientAddress, transferAmount, { gasPrice: ethers.parseUnits('100', 'gwei') }); const receipt = await tx.wait(); - // Verify transaction succeeded expect(receipt.status).to.equal(1); // Check for Transfer event in logs @@ -231,7 +204,6 @@ describe("GIGA EVM Tests", function () { expect(transferEvent).to.not.be.undefined; - // Parse and verify event args const parsedEvent = testToken.interface.parseLog(transferEvent); expect(parsedEvent.args.from).to.equal(owner.address); expect(parsedEvent.args.to).to.equal(recipientAddress); @@ -257,11 +229,12 @@ describe("GIGA EVM Tests", function () { const recipient2 = ethers.Wallet.createRandom().connect(ethers.provider); const recipient3 = ethers.Wallet.createRandom().connect(ethers.provider); - const amount1 = ethers.parseUnits("100", 18); - const amount2 = ethers.parseUnits("200", 18); - const amount3 = ethers.parseUnits("150", 18); + const amount1 = ethers.parseUnits("10", 18); + const amount2 = ethers.parseUnits("20", 18); + const amount3 = ethers.parseUnits("15", 18); + + const ownerBalanceBefore = await testToken.balanceOf(owner.address); - // Execute three sequential transfers await (await testToken.transfer(await recipient1.getAddress(), amount1, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); @@ -274,7 +247,6 @@ describe("GIGA EVM Tests", function () { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); - // Verify all balances const balance1 = await testToken.balanceOf(await recipient1.getAddress()); const balance2 = await testToken.balanceOf(await recipient2.getAddress()); const balance3 = await testToken.balanceOf(await recipient3.getAddress()); @@ -283,9 +255,8 @@ describe("GIGA EVM Tests", function () { expect(balance1).to.equal(amount1); expect(balance2).to.equal(amount2); expect(balance3).to.equal(amount3); - expect(ownerBalance).to.equal(initialSupply - amount1 - amount2 - amount3); + expect(ownerBalance).to.equal(ownerBalanceBefore - amount1 - amount2 - amount3); - // Total supply should remain unchanged const totalSupply = await testToken.totalSupply(); expect(totalSupply).to.equal(initialSupply); }); @@ -295,7 +266,7 @@ describe("GIGA EVM Tests", function () { let testToken; const initialSupply = ethers.parseUnits("1000", 18); - beforeEach(async function () { + before(async function () { const TestToken = await ethers.getContractFactory("TestToken"); testToken = await TestToken.deploy("ApprovalToken", "APR", { gasPrice: ethers.parseUnits('100', 'gwei') @@ -354,6 +325,601 @@ describe("GIGA EVM Tests", function () { }); }); + // ============================================================================ + // Failing Transaction Tests + // + // These tests submit transactions that REVERT but still get mined into blocks. + // This is critical for mixed-mode testing because failing txs affect the + // ExecTxResult fields (Code, Data, GasUsed) that go into LastResultsHash. + // If giga and V2 handle failing txs differently, the giga node will halt. + // ============================================================================ + describe("Failing Transactions (Reverts)", function () { + let evmTester; + + before(async function () { + // Deploy EVMCompatibilityTester which has revertIfFalse() + const EVMCompatibilityTester = await ethers.getContractFactory("EVMCompatibilityTester"); + evmTester = await EVMCompatibilityTester.deploy({ + gasPrice: ethers.parseUnits('100', 'gwei') + }); + await evmTester.waitForDeployment(); + }); + + it("should mine a reverted call to revertIfFalse(false)", async function () { + // Send with explicit gas limit so the tx gets mined even though it reverts. + // The key is: the node must produce the same Code/Data/GasUsed for this revert. + try { + const tx = await evmTester.revertIfFalse(false, { + gasLimit: 100000, + gasPrice: ethers.parseUnits('100', 'gwei') + }); + const receipt = await tx.wait(); + // If we get here, the tx was mined — check it reverted + expect(receipt.status).to.equal(0); + } catch (e) { + // ethers v6 may throw on reverts — check the receipt from the error + if (e.receipt) { + expect(e.receipt.status).to.equal(0); + expect(e.receipt.gasUsed).to.be.greaterThan(0n); + } + // If no receipt at all, that's also acceptable (client-side rejection) + } + }); + + it("should mine a reverted ERC20 transfer (insufficient balance)", async function () { + // Deploy a fresh token, then try to transfer from an account with 0 balance + const TestToken = await ethers.getContractFactory("TestToken"); + const token = await TestToken.deploy("FailToken", "FTK", { + gasPrice: ethers.parseUnits('100', 'gwei') + }); + await token.waitForDeployment(); + + // Create a second signer with no tokens + let spender; + if (accounts[1]) { + spender = accounts[1].signer; + } else { + spender = ethers.Wallet.createRandom().connect(ethers.provider); + await fundAddress(await spender.getAddress()); + await delay(); + } + + // Try to transfer tokens that spender doesn't have + const tokenAsSpender = token.connect(spender); + try { + const tx = await tokenAsSpender.transfer(owner.address, ethers.parseUnits("100", 18), { + gasLimit: 100000, + gasPrice: ethers.parseUnits('100', 'gwei') + }); + const receipt = await tx.wait(); + expect(receipt.status).to.equal(0); + } catch (e) { + if (e.receipt) { + expect(e.receipt.status).to.equal(0); + expect(e.receipt.gasUsed).to.be.greaterThan(0n); + } + } + }); + + it("should handle mixed success and failure in same block window", async function () { + // Send a batch: successful transfer, then failing call, then successful call. + // Each tx goes into a separate block but this exercises the pattern + // where a block has both passing and failing txs. + + const recipient = ethers.Wallet.createRandom().connect(ethers.provider); + const recipientAddr = await recipient.getAddress(); + + // 1. Successful native transfer + const tx1 = await owner.sendTransaction({ + to: recipientAddr, + value: ethers.parseEther("0.01"), + gasPrice: ethers.parseUnits('100', 'gwei') + }); + const receipt1 = await tx1.wait(); + expect(receipt1.status).to.equal(1); + + // 2. Failing call — revertIfFalse(false) + try { + const tx2 = await evmTester.revertIfFalse(false, { + gasLimit: 100000, + gasPrice: ethers.parseUnits('100', 'gwei') + }); + const receipt2 = await tx2.wait(); + // mined as failed + if (receipt2) expect(receipt2.status).to.equal(0); + } catch (e) { + if (e.receipt) { + expect(e.receipt.status).to.equal(0); + } + } + + // 3. Successful call — revertIfFalse(true) + const tx3 = await evmTester.revertIfFalse(true, { + gasLimit: 100000, + gasPrice: ethers.parseUnits('100', 'gwei') + }); + const receipt3 = await tx3.wait(); + expect(receipt3.status).to.equal(1); + }); + + it("should handle out-of-gas transaction", async function () { + // Send a contract call with very little gas — it should fail with OOG. + // Consensus-error txs (e.g., floor data gas check) are included in the block + // with code=1 but no EVM receipt is written, so tx.wait() would hang. + // We use a timeout race to avoid hanging. + try { + const tx = await evmTester.revertIfFalse(true, { + gasLimit: 21500, // Just barely above 21000 intrinsic, not enough for the call + gasPrice: ethers.parseUnits('100', 'gwei') + }); + // Race between tx.wait() and a timeout — receipt may never arrive for consensus errors + const receipt = await Promise.race([ + tx.wait().catch(e => e.receipt || null), + new Promise(resolve => setTimeout(() => resolve(null), 3000)) + ]); + if (receipt) expect(receipt.status).to.equal(0); + } catch (e) { + if (e.receipt) { + expect(e.receipt.status).to.equal(0); + } + // OOG may also be rejected at the RPC level — that's fine + } + }); + + it("should handle transfer to non-existent contract with data", async function () { + // Call a function on an address that has no code — this succeeds in EVM + // (calling an EOA with data just returns with no revert) + const fakeContract = new ethers.Contract( + "0x000000000000000000000000000000000000dEaD", + ["function nonExistentFunction() external"], + owner + ); + try { + const tx = await fakeContract.nonExistentFunction({ + gasLimit: 50000, + gasPrice: ethers.parseUnits('100', 'gwei') + }); + const receipt = await Promise.race([ + tx.wait().catch(e => e.receipt || null), + new Promise(resolve => setTimeout(() => resolve(null), 3000)) + ]); + // Calling a non-contract address with data succeeds (no revert) but wastes gas + } catch (e) { + // May revert or succeed — either way, it exercises the code path + } + }); + }); + + // ============================================================================ + // Multi-Hop Swap Tests + // + // These tests reproduce the pattern from the mainnet tx that caused an AppHash + // divergence: a multi-hop DEX swap routing through multiple AMM pairs, each + // involving cross-contract CALL frames with ERC20 transferFrom/transfer and + // storage reads/writes across many contracts in a single transaction. + // + // If the giga KV store layer handles cross-contract state differently from + // the regular store, this test will cause the giga node to halt. + // ============================================================================ + describe("Multi-Hop Swap (Cross-Contract Token Transfers)", function () { + let tokenA, tokenB, tokenC, tokenD; + let pairAB, pairBC, pairCD; + let router; + + const INITIAL_SUPPLY = ethers.parseUnits("1000000", 18); + const LIQUIDITY_AMOUNT = ethers.parseUnits("100000", 18); + const SWAP_AMOUNT = ethers.parseUnits("1000", 18); + + before(async function () { + // Deploy 4 tokens (simulates WSEI, USDC, DRG, and another token in the path) + const SimpleToken = await ethers.getContractFactory("SimpleToken"); + tokenA = await SimpleToken.deploy("TokenA", "TKA", INITIAL_SUPPLY, { gasPrice: ethers.parseUnits('100', 'gwei') }); + await tokenA.waitForDeployment(); + tokenB = await SimpleToken.deploy("TokenB", "TKB", INITIAL_SUPPLY, { gasPrice: ethers.parseUnits('100', 'gwei') }); + await tokenB.waitForDeployment(); + tokenC = await SimpleToken.deploy("TokenC", "TKC", INITIAL_SUPPLY, { gasPrice: ethers.parseUnits('100', 'gwei') }); + await tokenC.waitForDeployment(); + tokenD = await SimpleToken.deploy("TokenD", "TKD", INITIAL_SUPPLY, { gasPrice: ethers.parseUnits('100', 'gwei') }); + await tokenD.waitForDeployment(); + + // Deploy 3 pairs: A/B, B/C, C/D + const SimplePair = await ethers.getContractFactory("SimplePair"); + pairAB = await SimplePair.deploy(await tokenA.getAddress(), await tokenB.getAddress(), { gasPrice: ethers.parseUnits('100', 'gwei') }); + await pairAB.waitForDeployment(); + pairBC = await SimplePair.deploy(await tokenB.getAddress(), await tokenC.getAddress(), { gasPrice: ethers.parseUnits('100', 'gwei') }); + await pairBC.waitForDeployment(); + pairCD = await SimplePair.deploy(await tokenC.getAddress(), await tokenD.getAddress(), { gasPrice: ethers.parseUnits('100', 'gwei') }); + await pairCD.waitForDeployment(); + + // Deploy router + const SimpleRouter = await ethers.getContractFactory("SimpleRouter"); + router = await SimpleRouter.deploy({ gasPrice: ethers.parseUnits('100', 'gwei') }); + await router.waitForDeployment(); + + // Add liquidity to each pair + // Pair A/B + await (await tokenA.transfer(await pairAB.getAddress(), LIQUIDITY_AMOUNT, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + await (await tokenB.transfer(await pairAB.getAddress(), LIQUIDITY_AMOUNT, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + await (await pairAB.addLiquidity({ gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + // Pair B/C + await (await tokenB.transfer(await pairBC.getAddress(), LIQUIDITY_AMOUNT, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + await (await tokenC.transfer(await pairBC.getAddress(), LIQUIDITY_AMOUNT, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + await (await pairBC.addLiquidity({ gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + // Pair C/D + await (await tokenC.transfer(await pairCD.getAddress(), LIQUIDITY_AMOUNT, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + await (await tokenD.transfer(await pairCD.getAddress(), LIQUIDITY_AMOUNT, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + await (await pairCD.addLiquidity({ gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + }); + + it("should execute a 2-hop swap (A → B → C) through router", async function () { + // Approve router to spend tokenA + await (await tokenA.approve(await router.getAddress(), SWAP_AMOUNT, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + const balanceABefore = await tokenA.balanceOf(owner.address); + const balanceCBefore = await tokenC.balanceOf(owner.address); + + // Execute 2-hop swap: A → B → C + const tx = await router.swapExactTokensForTokens( + SWAP_AMOUNT, + [await tokenA.getAddress(), await tokenB.getAddress(), await tokenC.getAddress()], + [await pairAB.getAddress(), await pairBC.getAddress()], + owner.address, + { gasPrice: ethers.parseUnits('100', 'gwei'), gasLimit: 1000000 } + ); + const receipt = await tx.wait(); + + expect(receipt.status).to.equal(1); + + // TokenA balance should decrease by SWAP_AMOUNT + const balanceAAfter = await tokenA.balanceOf(owner.address); + expect(balanceABefore - balanceAAfter).to.equal(SWAP_AMOUNT); + + // TokenC balance should increase (some amount after two swaps with fees) + const balanceCAfter = await tokenC.balanceOf(owner.address); + expect(balanceCAfter).to.be.greaterThan(balanceCBefore); + + // Should have emitted multiple Transfer events across different contracts + expect(receipt.logs.length).to.be.greaterThanOrEqual(4); + }); + + it("should execute a 3-hop swap (A → B → C → D) through router", async function () { + // Approve router to spend tokenA + await (await tokenA.approve(await router.getAddress(), SWAP_AMOUNT, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + const balanceABefore = await tokenA.balanceOf(owner.address); + const balanceDBefore = await tokenD.balanceOf(owner.address); + + // Execute 3-hop swap: A → B → C → D + const tx = await router.swapExactTokensForTokens( + SWAP_AMOUNT, + [await tokenA.getAddress(), await tokenB.getAddress(), await tokenC.getAddress(), await tokenD.getAddress()], + [await pairAB.getAddress(), await pairBC.getAddress(), await pairCD.getAddress()], + owner.address, + { gasPrice: ethers.parseUnits('100', 'gwei'), gasLimit: 1500000 } + ); + const receipt = await tx.wait(); + + expect(receipt.status).to.equal(1); + + // TokenA balance should decrease by SWAP_AMOUNT + const balanceAAfter = await tokenA.balanceOf(owner.address); + expect(balanceABefore - balanceAAfter).to.equal(SWAP_AMOUNT); + + // TokenD balance should increase + const balanceDAfter = await tokenD.balanceOf(owner.address); + expect(balanceDAfter).to.be.greaterThan(balanceDBefore); + + // Should have many transfer events (at least 6: transferFrom + 3 hops × 2 events each) + expect(receipt.logs.length).to.be.greaterThanOrEqual(6); + }); + + it("should execute multiple swaps in sequence (exercises cross-tx state)", async function () { + // Approve a large amount for multiple swaps + const totalApproval = SWAP_AMOUNT * 3n; + await (await tokenA.approve(await router.getAddress(), totalApproval, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + const smallAmount = ethers.parseUnits("100", 18); + + // Swap 1: A → B (single hop) + const tx1 = await router.swapExactTokensForTokens( + smallAmount, + [await tokenA.getAddress(), await tokenB.getAddress()], + [await pairAB.getAddress()], + owner.address, + { gasPrice: ethers.parseUnits('100', 'gwei'), gasLimit: 500000 } + ); + const receipt1 = await tx1.wait(); + expect(receipt1.status).to.equal(1); + + // Swap 2: A → B → C (two hops) + const tx2 = await router.swapExactTokensForTokens( + smallAmount, + [await tokenA.getAddress(), await tokenB.getAddress(), await tokenC.getAddress()], + [await pairAB.getAddress(), await pairBC.getAddress()], + owner.address, + { gasPrice: ethers.parseUnits('100', 'gwei'), gasLimit: 1000000 } + ); + const receipt2 = await tx2.wait(); + expect(receipt2.status).to.equal(1); + + // Swap 3: A → B → C → D (three hops) + const tx3 = await router.swapExactTokensForTokens( + smallAmount, + [await tokenA.getAddress(), await tokenB.getAddress(), await tokenC.getAddress(), await tokenD.getAddress()], + [await pairAB.getAddress(), await pairBC.getAddress(), await pairCD.getAddress()], + owner.address, + { gasPrice: ethers.parseUnits('100', 'gwei'), gasLimit: 1500000 } + ); + const receipt3 = await tx3.wait(); + expect(receipt3.status).to.equal(1); + + // Verify the pair reserves are consistent + const r0AB = await pairAB.reserve0(); + const r1AB = await pairAB.reserve1(); + expect(r0AB).to.be.greaterThan(0n); + expect(r1AB).to.be.greaterThan(0n); + }); + + it("should handle swap + direct transfer in same block window", async function () { + // This exercises the pattern where both a complex multi-hop swap and a simple + // ERC20 transfer happen in nearby blocks — testing cross-tx state consistency. + + // Approve for swap + await (await tokenA.approve(await router.getAddress(), SWAP_AMOUNT, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + const recipient = ethers.Wallet.createRandom().connect(ethers.provider); + const recipientAddr = await recipient.getAddress(); + const directTransferAmount = ethers.parseUnits("50", 18); + + // Direct transfer of tokenB to a fresh address + const tx1 = await tokenB.transfer(recipientAddr, directTransferAmount, { + gasPrice: ethers.parseUnits('100', 'gwei') + }); + await tx1.wait(); + + // Multi-hop swap that also touches tokenB + const tx2 = await router.swapExactTokensForTokens( + ethers.parseUnits("500", 18), + [await tokenA.getAddress(), await tokenB.getAddress(), await tokenC.getAddress()], + [await pairAB.getAddress(), await pairBC.getAddress()], + owner.address, + { gasPrice: ethers.parseUnits('100', 'gwei'), gasLimit: 1000000 } + ); + const receipt2 = await tx2.wait(); + expect(receipt2.status).to.equal(1); + + // Verify the direct transfer recipient still has correct balance + const recipientBalance = await tokenB.balanceOf(recipientAddr); + expect(recipientBalance).to.equal(directTransferAmount); + }); + }); + + // ============================================================================ + // Proxy + Callback Multi-Hop Swap Tests + // + // These tests reproduce the EXACT pattern from the mainnet tx 0xf0ca0ec2... + // that caused an AppHash divergence: + // 1. Proxy token with delegatecall (like Sei's USDC proxy) + // 2. V3-style callback swaps (pool calls back into router mid-swap) + // 3. V2-style pool swap in the final hop + // 4. Balance verification via staticcall after callback mutates state + // 5. Multiple cross-contract transfers touching the same proxy token storage + // + // If the giga KV store handles delegatecall storage or cross-contract reads + // differently, this test will cause the giga node to halt with AppHash mismatch. + // ============================================================================ + describe("Proxy + Callback Multi-Hop Swap (Mainnet Repro)", function () { + let tokenA, proxyToken, tokenB; + let tokenImpl; + let v3Pool1, v3Pool2, v2Pool; + let router; + + const SUPPLY = ethers.parseUnits("10000000", 18); + const LIQUIDITY = ethers.parseUnits("1000000", 18); + const SWAP_AMOUNT = ethers.parseUnits("1000", 18); + + before(async function () { + // Deploy tokens + const SimpleToken = await ethers.getContractFactory("SimpleToken"); + tokenA = await SimpleToken.deploy("TokenA", "TKA", SUPPLY, { gasPrice: ethers.parseUnits('100', 'gwei') }); + await tokenA.waitForDeployment(); + tokenB = await SimpleToken.deploy("TokenB", "TKB", SUPPLY, { gasPrice: ethers.parseUnits('100', 'gwei') }); + await tokenB.waitForDeployment(); + + // Deploy proxy token (like Sei's USDC proxy) + const TokenImplementation = await ethers.getContractFactory("TokenImplementation"); + tokenImpl = await TokenImplementation.deploy({ gasPrice: ethers.parseUnits('100', 'gwei') }); + await tokenImpl.waitForDeployment(); + + const ProxyToken = await ethers.getContractFactory("ProxyToken"); + const proxyTokenContract = await ProxyToken.deploy(await tokenImpl.getAddress(), { gasPrice: ethers.parseUnits('100', 'gwei') }); + await proxyTokenContract.waitForDeployment(); + + // Wrap proxy in the TokenImplementation interface so we can call mint/transfer/etc + proxyToken = await ethers.getContractAt("TokenImplementation", await proxyTokenContract.getAddress()); + + // Mint proxy tokens to owner + await (await proxyToken.mint(owner.address, SUPPLY, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + // Deploy V3 callback pools + // Pool 1: tokenA (token0) / proxyToken (token1) + const CallbackPool = await ethers.getContractFactory("CallbackPool"); + v3Pool1 = await CallbackPool.deploy(await tokenA.getAddress(), await proxyToken.getAddress(), { gasPrice: ethers.parseUnits('100', 'gwei') }); + await v3Pool1.waitForDeployment(); + + // Pool 2: proxyToken (token0) / tokenB (token1) + v3Pool2 = await CallbackPool.deploy(await proxyToken.getAddress(), await tokenB.getAddress(), { gasPrice: ethers.parseUnits('100', 'gwei') }); + await v3Pool2.waitForDeployment(); + + // Deploy V2 pool: tokenB (token0) / tokenA (token1) + const SimpleV2Pool = await ethers.getContractFactory("SimpleV2Pool"); + v2Pool = await SimpleV2Pool.deploy(await tokenB.getAddress(), await tokenA.getAddress(), { gasPrice: ethers.parseUnits('100', 'gwei') }); + await v2Pool.waitForDeployment(); + + // Deploy router + const CallbackRouter = await ethers.getContractFactory("CallbackRouter"); + router = await CallbackRouter.deploy({ gasPrice: ethers.parseUnits('100', 'gwei') }); + await router.waitForDeployment(); + + // Fund pools with liquidity + // V3 Pool 1: needs proxyToken (output when swapping tokenA→proxyToken) + await (await proxyToken.transfer(await v3Pool1.getAddress(), LIQUIDITY, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + // V3 Pool 2: needs tokenB (output when swapping proxyToken→tokenB) + await (await tokenB.transfer(await v3Pool2.getAddress(), LIQUIDITY, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + // V2 Pool: needs both tokenB and tokenA for reserves + await (await tokenB.transfer(await v2Pool.getAddress(), LIQUIDITY, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + await (await tokenA.transfer(await v2Pool.getAddress(), LIQUIDITY, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + await (await v2Pool.addLiquidity({ gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + }); + + it("should execute full 3-hop swap through proxy token with callbacks", async function () { + // Approve router to spend tokenA + await (await tokenA.approve(await router.getAddress(), SWAP_AMOUNT, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + const balABefore = await tokenA.balanceOf(owner.address); + + // Execute the multi-hop swap: tokenA → proxyToken → tokenB → tokenA + const tx = await router.executeMultiHopSwap( + SWAP_AMOUNT, + await tokenA.getAddress(), + await proxyToken.getAddress(), + await tokenB.getAddress(), + await v3Pool1.getAddress(), + await v3Pool2.getAddress(), + await v2Pool.getAddress(), + owner.address, + { gasPrice: ethers.parseUnits('100', 'gwei'), gasLimit: 2000000 } + ); + const receipt = await tx.wait(); + + expect(receipt.status).to.equal(1); + // Should have many events (Transfer events from each hop + Swap/Sync) + expect(receipt.logs.length).to.be.greaterThanOrEqual(5); + }); + + it("should execute multiple proxy token swaps in sequence", async function () { + const smallAmount = ethers.parseUnits("100", 18); + + for (let i = 0; i < 2; i++) { + await (await tokenA.approve(await router.getAddress(), smallAmount, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + const tx = await router.executeMultiHopSwap( + smallAmount, + await tokenA.getAddress(), + await proxyToken.getAddress(), + await tokenB.getAddress(), + await v3Pool1.getAddress(), + await v3Pool2.getAddress(), + await v2Pool.getAddress(), + owner.address, + { gasPrice: ethers.parseUnits('100', 'gwei'), gasLimit: 2000000 } + ); + const receipt = await tx.wait(); + expect(receipt.status).to.equal(1); + } + }); + + it("should handle proxy token direct transfers interleaved with swaps", async function () { + // Direct proxy token transfer to a fresh address + const recipient = ethers.Wallet.createRandom().connect(ethers.provider); + const recipientAddr = await recipient.getAddress(); + const directAmount = ethers.parseUnits("500", 18); + + await (await proxyToken.transfer(recipientAddr, directAmount, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + // Then do a swap that also touches the proxy token + const swapAmt = ethers.parseUnits("200", 18); + await (await tokenA.approve(await router.getAddress(), swapAmt, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + const tx = await router.executeMultiHopSwap( + swapAmt, + await tokenA.getAddress(), + await proxyToken.getAddress(), + await tokenB.getAddress(), + await v3Pool1.getAddress(), + await v3Pool2.getAddress(), + await v2Pool.getAddress(), + owner.address, + { gasPrice: ethers.parseUnits('100', 'gwei'), gasLimit: 2000000 } + ); + const receipt = await tx.wait(); + expect(receipt.status).to.equal(1); + + // Verify direct transfer recipient still has correct balance + const recipientBalance = await proxyToken.balanceOf(recipientAddr); + expect(recipientBalance).to.equal(directAmount); + }); + + it("should verify proxy token balances are consistent after complex swaps", async function () { + // Get balances of all participants + const ownerBal = await proxyToken.balanceOf(owner.address); + const pool1Bal = await proxyToken.balanceOf(await v3Pool1.getAddress()); + const pool2Bal = await proxyToken.balanceOf(await v3Pool2.getAddress()); + const routerBal = await proxyToken.balanceOf(await router.getAddress()); + + // Do a swap + const swapAmt = ethers.parseUnits("50", 18); + await (await tokenA.approve(await router.getAddress(), swapAmt, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + const tx = await router.executeMultiHopSwap( + swapAmt, + await tokenA.getAddress(), + await proxyToken.getAddress(), + await tokenB.getAddress(), + await v3Pool1.getAddress(), + await v3Pool2.getAddress(), + await v2Pool.getAddress(), + owner.address, + { gasPrice: ethers.parseUnits('100', 'gwei'), gasLimit: 2000000 } + ); + const receipt = await tx.wait(); + expect(receipt.status).to.equal(1); + + // Get balances after + const ownerBalAfter = await proxyToken.balanceOf(owner.address); + const pool1BalAfter = await proxyToken.balanceOf(await v3Pool1.getAddress()); + const pool2BalAfter = await proxyToken.balanceOf(await v3Pool2.getAddress()); + const routerBalAfter = await proxyToken.balanceOf(await router.getAddress()); + + // Pool1 should have gained proxy tokens (received via callback, sent some out) + // The exact amounts depend on the swap math, but all should be non-negative + expect(pool1BalAfter).to.be.greaterThanOrEqual(0n); + expect(pool2BalAfter).to.be.greaterThanOrEqual(0n); + // Router should have 0 proxy tokens (all passed through) + expect(routerBalAfter).to.equal(0n); + }); + + it("should handle V3 callback swap reading state from prior block", async function () { + // Wait for a new block so the swap's STATICCALL balance checks + // read committed (cross-block) state from the pools. + await delay(); + + const swapAmt = ethers.parseUnits("1000", 18); + await (await tokenA.approve(await router.getAddress(), swapAmt * 2n, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + + // Do 2 sequential swaps — each reads state written by the previous one + for (let i = 0; i < 2; i++) { + const tx = await router.executeMultiHopSwap( + swapAmt, + await tokenA.getAddress(), + await proxyToken.getAddress(), + await tokenB.getAddress(), + await v3Pool1.getAddress(), + await v3Pool2.getAddress(), + await v2Pool.getAddress(), + owner.address, + { gasPrice: ethers.parseUnits('100', 'gwei'), gasLimit: 3000000 } + ); + const receipt = await tx.wait(); + expect(receipt.status).to.equal(1); + } + }); + }); + describe("Gas Usage Verification", function () { it("should correctly account for gas in native transfers", async function () { const recipient = ethers.Wallet.createRandom().connect(ethers.provider); @@ -407,4 +973,70 @@ describe("GIGA EVM Tests", function () { expect(nativeBalanceBefore - nativeBalanceAfter).to.equal(gasCost); }); }); + + // ============================================================================ + // Cross-Block State Dependency + // + // Tests that state committed by block N is correctly visible in block N+1. + // The giga store flushes on WriteGiga() — if this flush is incomplete, + // the next block would read stale data. + // ============================================================================ + describe("Cross-Block State Consistency", function () { + const SUPPLY = ethers.parseUnits("10000000", 18); + let pToken; + + before(async function () { + // Deploy a single proxy token for both cross-block tests + const TokenImplementation = await ethers.getContractFactory("TokenImplementation"); + const impl = await TokenImplementation.deploy({ gasPrice: ethers.parseUnits('100', 'gwei') }); + await impl.waitForDeployment(); + const ProxyToken = await ethers.getContractFactory("ProxyToken"); + const proxy = await ProxyToken.deploy(await impl.getAddress(), { gasPrice: ethers.parseUnits('100', 'gwei') }); + await proxy.waitForDeployment(); + pToken = await ethers.getContractAt("TokenImplementation", await proxy.getAddress()); + await (await pToken.mint(owner.address, SUPPLY, { gasPrice: ethers.parseUnits('100', 'gwei') })).wait(); + }); + + it("should correctly read proxy token balance written in previous block", async function () { + // Block N: Transfer to a fresh address (goes through delegatecall storage write) + const recipient = ethers.Wallet.createRandom().connect(ethers.provider); + const amount = ethers.parseUnits("12345", 18); + const tx = await pToken.transfer(await recipient.getAddress(), amount, { + gasPrice: ethers.parseUnits('100', 'gwei'), + }); + const receipt = await tx.wait(); + const blockN = receipt.blockNumber; + + // Block N+1: Read the balance — must see the write from block N + await delay(); + const bal = await pToken.balanceOf(await recipient.getAddress()); + expect(bal).to.equal(amount); + + console.log(` Wrote in block ${blockN}, verified in subsequent read`); + }); + + it("should maintain consistency across multiple blocks of proxy token ops", async function () { + // Do 3 rounds of: transfer, wait, verify + // Each round depends on the previous round's committed state + const addr = ethers.Wallet.createRandom().connect(ethers.provider); + const addrHex = await addr.getAddress(); + let recipientBal = 0n; + const transferAmt = ethers.parseUnits("1000", 18); + + for (let round = 0; round < 3; round++) { + const ownerBalBefore = await pToken.balanceOf(owner.address); + const tx = await pToken.transfer(addrHex, transferAmt, { + gasPrice: ethers.parseUnits('100', 'gwei'), + }); + await tx.wait(); + recipientBal += transferAmt; + + const ownerBal = await pToken.balanceOf(owner.address); + const rBal = await pToken.balanceOf(addrHex); + expect(ownerBal).to.equal(ownerBalBefore - transferAmt); + expect(rBal).to.equal(recipientBal); + } + console.log(` 3 rounds of proxy token transfer+verify completed`); + }); + }); }); diff --git a/docker/docker-compose.giga-mixed.yml b/docker/docker-compose.giga-mixed.yml new file mode 100644 index 0000000000..4513d392cd --- /dev/null +++ b/docker/docker-compose.giga-mixed.yml @@ -0,0 +1,31 @@ +# Docker Compose override for mixed-mode giga testing. +# Only node 0 runs with GIGA_EXECUTOR enabled; nodes 1-3 run standard V2. +# This allows testing that giga produces identical results to V2 at the +# consensus level — any divergence will cause the giga node to halt. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.giga-mixed.yml up +# +# Or via Makefile: +# make giga-mixed-integration-test + +services: + node0: + environment: + - GIGA_EXECUTOR=true + - GIGA_OCC=false + + node1: + environment: + - GIGA_EXECUTOR=false + - GIGA_OCC=false + + node2: + environment: + - GIGA_EXECUTOR=false + - GIGA_OCC=false + + node3: + environment: + - GIGA_EXECUTOR=false + - GIGA_OCC=false diff --git a/giga/executor/executor.go b/giga/executor/executor.go index 9722dd06af..4145c2b427 100644 --- a/giga/executor/executor.go +++ b/giga/executor/executor.go @@ -47,3 +47,19 @@ func (e *Executor) ExecuteTransaction(tx *types.Transaction, sender common.Addre return executionResult, nil } + +// ExecuteTransactionFeeCharged executes a transaction assuming the gas fee has already been charged +// (like V2's msg_server path where the ante handler charges fees separately). +// This ensures the EVM does NOT charge/refund gas fees during execution, matching V2's behavior +// where feeAlreadyCharged=true is passed to StateTransition.Execute(). +func (e *Executor) ExecuteTransactionFeeCharged(tx *types.Transaction, sender common.Address, baseFee *big.Int, gasPool *core.GasPool) (*core.ExecutionResult, error) { + message, err := core.TransactionToMessage(tx, &internal.Signer{From: sender}, baseFee) + if err != nil { + return nil, err + } + + e.evm.SetTxContext(core.NewEVMTxContext(message)) + // feeAlreadyCharged=true: skip buyGas/refund (fees charged separately, like V2 ante handler) + // shouldIncrementNonce=true: increment nonce during execution (same as V2 msg_server) + return core.NewStateTransition(e.evm, message, gasPool, true, true).Execute() +} diff --git a/integration_test/evm_module/scripts/evm_giga_mixed_tests.sh b/integration_test/evm_module/scripts/evm_giga_mixed_tests.sh new file mode 100755 index 0000000000..cc403e1443 --- /dev/null +++ b/integration_test/evm_module/scripts/evm_giga_mixed_tests.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# +# GIGA Mixed-Mode EVM Integration Tests +# +# This script replaces the default cluster with a mixed-mode cluster where: +# - Node 0: GIGA_EXECUTOR=true (sequential mode) +# - Nodes 1-3: Standard V2 executor +# +# If giga produces different results from V2, the giga node will halt with +# a consensus failure (AppHash or LastResultsHash mismatch). +# +# The GitHub workflow starts a default cluster before running matrix scripts. +# This wrapper tears it down and starts a mixed-mode cluster instead. +# + +set -e + +echo "=== GIGA Mixed-Mode Integration Test ===" +echo "=== Node 0: GIGA_EXECUTOR=true, Nodes 1-3: standard V2 ===" + +# Stop the default cluster that the workflow started +echo "Stopping default cluster..." +make docker-cluster-stop || true + +# Start mixed-mode cluster (node 0 = giga, nodes 1-3 = V2) +# build-docker-node is a no-op since the image was already built +echo "Starting mixed-mode cluster..." +DOCKER_DETACH=true make docker-cluster-start-giga-mixed + +# Wait for all 4 nodes to be ready +echo "Waiting for mixed cluster to be ready..." +timeout=300 +elapsed=0 +while [ $elapsed -lt $timeout ]; do + if [ -f "build/generated/launch.complete" ] && [ $(cat build/generated/launch.complete | wc -l) -ge 4 ]; then + echo "All 4 nodes are ready (took ${elapsed}s)" + break + fi + sleep 5 + elapsed=$((elapsed + 5)) + echo " Waiting... (${elapsed}s elapsed)" +done +if [ $elapsed -ge $timeout ]; then + echo "ERROR: Mixed cluster failed to start within ${timeout}s" + make docker-cluster-stop + exit 1 +fi + +echo "Waiting 10s for nodes to stabilize..." +sleep 10 + +# Run the same giga EVM tests — they hit node 0 (giga) via seilocal RPC +echo "=== Running GIGA EVM Tests against mixed cluster ===" +./integration_test/evm_module/scripts/evm_giga_tests.sh +EXIT_CODE=$? + +if [ $EXIT_CODE -ne 0 ]; then + echo "TEST FAILURE — check if node 0 (giga) halted due to consensus mismatch" + echo "Logs: build/generated/logs/seid-0.log" +fi + +exit $EXIT_CODE