From 05386d365a6122417f1a29de2a00dee7281c0858 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 13 Jul 2026 14:43:48 -0700 Subject: [PATCH 01/10] fix(giga): route EVM validation failures to v2 fallback (CON-368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Giga's validation-failure path stamped a synthetic receipt (GasUsed=intrinsicGas, GasWanted=tx.Gas()) justified by a comment claiming v2 reports intrinsic gas on validation failure. V2 does not: those failures abort in the ante, where the envelope reports GasWanted=0 (never assigned pre-ante-success) and GasUsed as the ante meter's store-gas consumption. The receipts hash into LastResultsHash, so any block carrying a delivery-time-failed EVM tx split consensus on a mixed giga/v2 fleet — the arctic-1 halt at height 172900033. Validation failures now return an AbortError sentinel, joining the established fallback doctrine (balance migration, self-destruct, iterator): the sync path re-runs the single tx via v2 and OCC falls back batch-wide, so the receipt, nonce bump, and state effects are v2's by construction. This also closes a second divergence the old path carried: giga pre-checked fee+value while v2's ante requires only the fee, so a fee-affordable/value-unaffordable tx failed under giga but executed under v2 — different Code, not just gas. The parity test suite now asserts byte-equal receipts and LastResultsHash on the full failure matrix (fee-exceeds-balance, value-exceeds-balance, nonce high/low, and the incident's drain-race block shape) under both GigaSequential and GigaOCC, replacing the tests that logged gas divergence as expected. NewGigaTestWrapper's redundant EvmKeeper.InitGenesis is removed: it re-wrote a param with different bytes than the genesis round-trip (nil slice marshaling to null instead of []), skewing ante store-gas by a constant 24 between test contexts — a harness-only artifact, since production param bytes are AppHash-covered. --- app/app.go | 80 +++------ app/test_helpers.go | 10 +- giga/executor/precompiles/failfast.go | 18 ++ giga/tests/giga_test.go | 250 ++++++++++++++++++++++---- 4 files changed, 263 insertions(+), 95 deletions(-) diff --git a/app/app.go b/app/app.go index a54473a593..e770a0d7a3 100644 --- a/app/app.go +++ b/app/app.go @@ -2025,19 +2025,12 @@ func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgE ctx = ctx.WithGasMeter(sdk.NewInfiniteGasMeterWithMultiplier(ctx)) if validation.err != nil { - // Validation failed - bump nonce via keeper if it was valid (matches V2's DeliverTxCallback - // behavior where nonce is incremented even on fee validation failures). - // For successful txs, the nonce is bumped by the EVM during execution. - if validation.bumpNonce { - app.GigaEvmKeeper.SetNonce(ctx, sender, validation.currentNonce+1) - app.EvmKeeper.SetNonceBumped(ctx) - } - // 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) - validation.err.GasUsed = int64(intrinsicGas) //nolint:gosec - validation.err.GasWanted = int64(ethTx.Gas()) //nolint:gosec - return validation.err, nil + // Fee/nonce/balance failures fall back to v2 (same doctrine as the + // balance-migration case below): v2 rejects these in its ante chain, + // whose receipt gas fields giga cannot reconstruct (CON-368 was this + // path stamping a synthetic receipt, diverging LastResultsHash on + // mixed fleets). The fallback run also owns the nonce bump. + return nil, gigaprecompiles.ErrValidationFallback } if !isAssociated { @@ -3006,10 +2999,8 @@ func (app *App) inplacetestnetInitializer(pk cryptotypes.PubKey) error { // gigaValidationResult holds the result of EVM transaction validation. type gigaValidationResult struct { - err *abci.ExecTxResult // nil if validation passed - bumpNonce bool // true if tx nonce matches expected nonce - currentNonce uint64 // the expected nonce at time of validation - baseFee *big.Int // the base fee used for validation + err *abci.ExecTxResult // nil if validation passed + baseFee *big.Int // the base fee used for validation } // validateGigaEVMTx validates an EVM tx for fee, nonce, and stateless checks. @@ -3038,7 +3029,6 @@ func (app *App) validateGigaEVMTx( // Check nonce validity - determines if we bump nonce on fee/balance failures currentNonce := app.GigaEvmKeeper.GetNonce(ctx, sender) txNonce := ethTx.Nonce() - bumpNonce := txNonce == currentNonce // Fee cap below base fee if ethTx.GasFeeCap().Cmp(baseFee) < 0 { @@ -3047,9 +3037,7 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrInsufficientFee.ABCICode(), Log: "max fee per gas less than block base fee", }, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + baseFee: baseFee, } } @@ -3061,9 +3049,7 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrInsufficientFee.ABCICode(), Log: "max fee per gas less than minimum fee", }, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + baseFee: baseFee, } } @@ -3078,9 +3064,7 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("nonce too high: address %s, tx: %d state: %d", sender.Hex(), txNonce, currentNonce), }, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + baseFee: baseFee, } } if txNonce < currentNonce { @@ -3089,9 +3073,7 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("nonce too low: address %s, tx: %d state: %d", sender.Hex(), txNonce, currentNonce), }, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + baseFee: baseFee, } } // Nonce overflow guard (currentNonce + 1 would overflow) @@ -3101,9 +3083,7 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("nonce max: address %s, nonce: %d", sender.Hex(), currentNonce), }, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + baseFee: baseFee, } } @@ -3117,9 +3097,7 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("sender not an eoa: address %s, len(code): %d", sender.Hex(), len(senderCode)), }, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + baseFee: baseFee, } } } @@ -3131,9 +3109,7 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("max fee per gas higher than 2^256-1: address %s, maxFeePerGas bit length: %d", sender.Hex(), l), }, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + baseFee: baseFee, } } @@ -3144,9 +3120,7 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("max priority fee per gas higher than 2^256-1: address %s, maxPriorityFeePerGas bit length: %d", sender.Hex(), l), }, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + baseFee: baseFee, } } @@ -3157,9 +3131,7 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("max priority fee per gas higher than max fee per gas: address %s, maxPriorityFeePerGas: %s, maxFeePerGas: %s", sender.Hex(), ethTx.GasTipCap(), ethTx.GasFeeCap()), }, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + baseFee: baseFee, } } @@ -3172,9 +3144,7 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("set-code transaction must not be a create transaction: sender %s", sender.Hex()), }, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + baseFee: baseFee, } } // Set-code tx auth list must be non-empty @@ -3184,9 +3154,7 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrWrongSequence.ABCICode(), Log: fmt.Sprintf("set-code transaction with empty auth list: sender %s", sender.Hex()), }, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + baseFee: baseFee, } } } @@ -3214,18 +3182,14 @@ func (app *App) validateGigaEVMTx( Code: sdkerrors.ErrInsufficientFunds.ABCICode(), Log: fmt.Sprintf("insufficient funds for gas * price + value: address %s have %v want %v: insufficient funds", sender.Hex(), senderBalance, balanceCheck), }, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + baseFee: baseFee, } } // All checks passed return gigaValidationResult{ - err: nil, - bumpNonce: bumpNonce, - currentNonce: currentNonce, - baseFee: baseFee, + err: nil, + baseFee: baseFee, } } diff --git a/app/test_helpers.go b/app/test_helpers.go index 8d25f981b0..7c0eec530c 100644 --- a/app/test_helpers.go +++ b/app/test_helpers.go @@ -141,10 +141,12 @@ func NewTestWrapperWithSc(t *testing.T, tm time.Time, valPub cryptotypes.PubKey, } func NewGigaTestWrapper(t *testing.T, tm time.Time, valPub cryptotypes.PubKey, enableEVMCustomPrecompiles bool, useOcc bool, baseAppOptions ...func(*bam.BaseApp)) *TestWrapper { - wrapper := newTestWrapper(t, tm, valPub, enableEVMCustomPrecompiles, true, TestAppOpts{UseSc: true, EnableGiga: true, EnableGigaOCC: useOcc}, baseAppOptions...) - genState := evmtypes.DefaultGenesis() - wrapper.App.EvmKeeper.InitGenesis(wrapper.Ctx, *genState) - return wrapper + // No extra EvmKeeper.InitGenesis here: newTestWrapper already runs full + // module genesis, and a second raw InitGenesis(DefaultGenesis()) writes + // param bytes that differ from the genesis-JSON round-trip (nil slice + // marshals to null, seeded []byte{} to []), skewing ante store-gas — and + // therefore receipt GasUsed — between giga and v2 test contexts. + return newTestWrapper(t, tm, valPub, enableEVMCustomPrecompiles, true, TestAppOpts{UseSc: true, EnableGiga: true, EnableGigaOCC: useOcc}, baseAppOptions...) } // NewGigaTestWrapperWithRegularStore creates a test wrapper that runs Giga executor diff --git a/giga/executor/precompiles/failfast.go b/giga/executor/precompiles/failfast.go index 352ac778c9..4046ca58c8 100644 --- a/giga/executor/precompiles/failfast.go +++ b/giga/executor/precompiles/failfast.go @@ -85,6 +85,24 @@ func (e *SelfDestructAbortError) IsAbortError() bool { var ErrSelfDestructUnsupported error = &SelfDestructAbortError{} +// ValidationFallbackAbortError signals that the transaction failed EVM +// validation (fee/nonce/balance). V2 rejects these in its ante chain, and the +// receipt it emits there is path-dependent (GasWanted stays unset; GasUsed is +// the ante's store-gas consumption), so giga cannot reconstruct it; the caller +// should fall back to v2, which produces the canonical failure receipt and +// state effects (nonce bump) by construction. +type ValidationFallbackAbortError struct{} + +func (e *ValidationFallbackAbortError) Error() string { + return "EVM validation failed; v2 produces the canonical failure receipt" +} + +func (e *ValidationFallbackAbortError) IsAbortError() bool { + return true +} + +var ErrValidationFallback error = &ValidationFallbackAbortError{} + type FailFastPrecompile struct{} var FailFastSingleton vm.PrecompiledContract = &FailFastPrecompile{} diff --git a/giga/tests/giga_test.go b/giga/tests/giga_test.go index 715eb14187..7f70eac2c5 100644 --- a/giga/tests/giga_test.go +++ b/giga/tests/giga_test.go @@ -2411,6 +2411,12 @@ func TestGigaValidation_InsufficientBalance(t *testing.T) { gigaNonce := gigaCtx.TestApp.GigaEvmKeeper.GetNonce(gigaCtx.Ctx, signer.EvmAddress) require.Equal(t, v2Nonce, gigaNonce, "Nonce should match after balance validation failure") require.Equal(t, uint64(1), gigaNonce, "Nonce should be bumped to 1") + + // The failure receipt must be byte-identical across executors: its + // Code/Data/GasWanted/GasUsed feed LastResultsHash, and any divergence + // splits consensus on a mixed fleet (CON-368). + CompareDeterministicFields(t, "InsufficientBalance", v2Results, gigaResults) + CompareLastResultsHash(t, "InsufficientBalance", v2Results, gigaResults) } // Note: TestGigaValidation_TipCapGreaterThanFeeCap is not possible because @@ -2418,11 +2424,12 @@ func TestGigaValidation_InsufficientBalance(t *testing.T) { // The check in validateGigaEVMTx exists for defense-in-depth but can't be tested // through the normal transaction creation flow. -// TestGigaValidation_GasReportedOnFailure tests that gas is reported on validation failure. -// Note: V2 and Giga may report different GasUsed values on validation failures due to -// differences in how the ante handler chain reports gas consumption. The critical parity -// requirement is that error codes match (for consensus on tx success/failure). -func TestGigaValidation_GasReportedOnFailure(t *testing.T) { +// TestGigaValidation_FailureReceiptParity asserts a validation-failed tx +// produces a byte-identical receipt under both executors. Giga routes +// validation failures to v2 (per-tx fallback, same doctrine as balance +// migration), so the ante-abort receipt — including the gas fields that feed +// LastResultsHash — matches by construction (CON-368). +func TestGigaValidation_FailureReceiptParity(t *testing.T) { blockTime := time.Now() accts := utils.NewTestAccounts(3) @@ -2452,25 +2459,14 @@ func TestGigaValidation_GasReportedOnFailure(t *testing.T) { require.Len(t, v2Results, 1) require.Len(t, gigaResults, 1) - // Error codes must match (critical for consensus) - require.Equal(t, v2Results[0].Code, gigaResults[0].Code, - "Error codes should match: V2=%d Giga=%d", v2Results[0].Code, gigaResults[0].Code) - - // Both should report non-zero gas values - require.Greater(t, gigaResults[0].GasUsed, int64(0), "Giga should report GasUsed > 0") - require.Greater(t, gigaResults[0].GasWanted, int64(0), "Giga should report GasWanted > 0") - - // Log the difference for visibility (not a failure) - if v2Results[0].GasUsed != gigaResults[0].GasUsed { - t.Logf("Note: GasUsed differs on validation failure - V2=%d, Giga=%d (expected due to ante handler differences)", - v2Results[0].GasUsed, gigaResults[0].GasUsed) - } + require.NotEqual(t, uint32(0), v2Results[0].Code, "tx should fail validation") + CompareDeterministicFields(t, "FailureReceiptParity", v2Results, gigaResults) + CompareLastResultsHash(t, "FailureReceiptParity", v2Results, gigaResults) } -// TestGigaValidation_ErrorCodeParity tests that validation failures produce -// identical error codes between V2 and Giga (critical for consensus on tx success/failure). -// Note: GasUsed may differ between V2 and Giga on validation failures due to ante handler -// differences, but this doesn't affect consensus on whether a tx succeeded or failed. +// TestGigaValidation_ErrorCodeParity runs a mixed valid/invalid block and +// asserts the full receipts — not just error codes — match between V2 and +// Giga, since every deterministic receipt field feeds LastResultsHash. func TestGigaValidation_ErrorCodeParity(t *testing.T) { blockTime := time.Now() accts := utils.NewTestAccounts(5) @@ -2520,17 +2516,10 @@ func TestGigaValidation_ErrorCodeParity(t *testing.T) { require.Len(t, v2Results, 3) require.Len(t, gigaResults, 3) - // Compare error codes (critical for consensus) - for i := range v2Results { - require.Equal(t, v2Results[i].Code, gigaResults[i].Code, - "tx[%d] Code mismatch: V2=%d Giga=%d", i, v2Results[i].Code, gigaResults[i].Code) - - // Log gas differences for visibility - if v2Results[i].GasUsed != gigaResults[i].GasUsed { - t.Logf("tx[%d] GasUsed differs: V2=%d Giga=%d (expected for validation failures)", - i, v2Results[i].GasUsed, gigaResults[i].GasUsed) - } - } + // Receipts must be byte-identical — Code/Data/GasWanted/GasUsed feed + // LastResultsHash (CON-368). + CompareDeterministicFields(t, "ErrorCodeParity", v2Results, gigaResults) + CompareLastResultsHash(t, "ErrorCodeParity", v2Results, gigaResults) // Verify expected outcomes require.Equal(t, uint32(0), v2Results[0].Code, "tx[0] should succeed") @@ -2538,6 +2527,201 @@ func TestGigaValidation_ErrorCodeParity(t *testing.T) { require.NotEqual(t, uint32(0), v2Results[2].Code, "tx[2] should fail (nonce too high)") } +// TestGigaValidation_DrainRace_LastResultsHash reproduces CON-368's block +// shape: tx0 drains the sender far enough that tx1 (same sender, next nonce) +// fails its fee check at delivery — a tx that passes admission but fails in +// the block. Receipts must hash identically across executors; under OCC the +// giga batch falls back to serialized v2 execution. +func TestGigaValidation_DrainRace_LastResultsHash(t *testing.T) { + runDrainRace := func(t *testing.T, gigaMode ExecutorMode) { + blockTime := time.Now() + accts := utils.NewTestAccounts(3) + signer := utils.NewSigner() + sink := utils.NewSigner() + to := sink.EvmAddress + + normalFee := big.NewInt(100000000000) // 21000 gas => 2.1e15 wei max fee + fund := big.NewInt(3e15) // covers tx0's value+fee, not tx1's fee + drain := big.NewInt(5e14) + + v2Ctx := NewGigaTestContext(t, accts, blockTime, 1, ModeV2Sequential) + fundAccount(t, v2Ctx, signer.AccountAddress, fund) + v2Ctx.TestApp.EvmKeeper.SetAddressMapping(v2Ctx.Ctx, signer.AccountAddress, signer.EvmAddress) + v2Txs := [][]byte{ + createCustomEVMTx(t, v2Ctx, signer, &to, drain, 21000, normalFee, normalFee, 0), + createCustomEVMTx(t, v2Ctx, signer, &to, big.NewInt(1), 21000, normalFee, normalFee, 1), + } + _, v2Results, _ := RunBlock(t, v2Ctx, v2Txs) + + gigaCtx := NewGigaTestContext(t, accts, blockTime, 1, gigaMode) + fundAccount(t, gigaCtx, signer.AccountAddress, fund) + gigaCtx.TestApp.GigaEvmKeeper.SetAddressMapping(gigaCtx.Ctx, signer.AccountAddress, signer.EvmAddress) + gigaTxs := [][]byte{ + createCustomEVMTx(t, gigaCtx, signer, &to, drain, 21000, normalFee, normalFee, 0), + createCustomEVMTx(t, gigaCtx, signer, &to, big.NewInt(1), 21000, normalFee, normalFee, 1), + } + _, gigaResults, _ := RunBlock(t, gigaCtx, gigaTxs) + + require.Len(t, v2Results, 2) + require.Len(t, gigaResults, 2) + require.Equal(t, uint32(0), v2Results[0].Code, "tx0 (drain) should succeed") + require.NotEqual(t, uint32(0), v2Results[1].Code, "tx1 should fail its fee check at delivery") + CompareDeterministicFields(t, "DrainRace", v2Results, gigaResults) + CompareLastResultsHash(t, "DrainRace", v2Results, gigaResults) + } + t.Run("GigaSequential", func(t *testing.T) { runDrainRace(t, ModeGigaSequential) }) + t.Run("GigaOCC", func(t *testing.T) { runDrainRace(t, ModeGigaOCC) }) +} + +// TestGigaValidation_ValueExceedsBalance_LastResultsHash covers the balance +// check's scope mismatch: giga's pre-check rejects fee+value > balance while +// v2's ante only requires the fee, running the tx to its EVM outcome. The +// fallback makes giga adopt v2's receipt, keeping the executors code- and +// hash-identical for this class too. +func TestGigaValidation_ValueExceedsBalance_LastResultsHash(t *testing.T) { + blockTime := time.Now() + accts := utils.NewTestAccounts(3) + signer := utils.NewSigner() + recipient := utils.NewSigner() + to := recipient.EvmAddress + + normalFee := big.NewInt(100000000000) + fund := big.NewInt(3e15) // covers the 2.1e15 max fee... + value := big.NewInt(2e15) // ...but not fee+value + + v2Ctx := NewGigaTestContext(t, accts, blockTime, 1, ModeV2Sequential) + fundAccount(t, v2Ctx, signer.AccountAddress, fund) + v2Ctx.TestApp.EvmKeeper.SetAddressMapping(v2Ctx.Ctx, signer.AccountAddress, signer.EvmAddress) + v2Tx := createCustomEVMTx(t, v2Ctx, signer, &to, value, 21000, normalFee, normalFee, 0) + _, v2Results, _ := RunBlock(t, v2Ctx, [][]byte{v2Tx}) + + gigaCtx := NewGigaTestContext(t, accts, blockTime, 1, ModeGigaSequential) + fundAccount(t, gigaCtx, signer.AccountAddress, fund) + gigaCtx.TestApp.GigaEvmKeeper.SetAddressMapping(gigaCtx.Ctx, signer.AccountAddress, signer.EvmAddress) + gigaTx := createCustomEVMTx(t, gigaCtx, signer, &to, value, 21000, normalFee, normalFee, 0) + _, gigaResults, _ := RunBlock(t, gigaCtx, [][]byte{gigaTx}) + + require.Len(t, v2Results, 1) + require.Len(t, gigaResults, 1) + CompareDeterministicFields(t, "ValueExceedsBalance", v2Results, gigaResults) + CompareLastResultsHash(t, "ValueExceedsBalance", v2Results, gigaResults) +} + +// TestGigaValidation_FailureClassParity_AllModes is the forward-looking +// coverage net for CON-368's class: every delivery-time EVM failure it can +// construct through the normal signing flow, run under both giga modes, +// asserting full receipt and LastResultsHash parity against v2. New +// validation branches in validateGigaEVMTx should gain a case here. +// +// Not constructible through this harness (documented, not silently skipped): +// tip > feeCap (go-ethereum rejects at signing), sender-not-EOA (requires +// code at a signing address), and fee-cap-below-base-fee when the test +// chain's base fee is zero. +func TestGigaValidation_FailureClassParity_AllModes(t *testing.T) { + require.False(t, app.MockBalancesEnabled, + "parity tests are meaningless under the mock_balances build tag: it tops off the very accounts whose failures are under test") + + type failureCase struct { + name string + // build returns the tx bytes for a context; called once per context so + // signing uses each context's chain setup. + build func(t *testing.T, tCtx *GigaTestContext, signer utils.TestAcct, to common.Address) [][]byte + } + + normalFee := big.NewInt(100000000000) // 21000 gas => 2.1e15 wei max fee + + cases := []failureCase{ + { + // fee alone exceeds balance: v2's ante-abort receipt (CON-368's tx) + name: "FeeExceedsBalance", + build: func(t *testing.T, tCtx *GigaTestContext, signer utils.TestAcct, to common.Address) [][]byte { + fundAccount(t, tCtx, signer.AccountAddress, big.NewInt(1e12)) + return [][]byte{createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(1), 21000, normalFee, normalFee, 0)} + }, + }, + { + // fee affordable, fee+value not: giga's pre-check scope differs + // from v2's ante (fee-only), so parity relies on the fallback + name: "ValueExceedsBalance", + build: func(t *testing.T, tCtx *GigaTestContext, signer utils.TestAcct, to common.Address) [][]byte { + fundAccount(t, tCtx, signer.AccountAddress, big.NewInt(3e15)) + return [][]byte{createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(2e15), 21000, normalFee, normalFee, 0)} + }, + }, + { + name: "NonceTooHigh", + build: func(t *testing.T, tCtx *GigaTestContext, signer utils.TestAcct, to common.Address) [][]byte { + fundAccount(t, tCtx, signer.AccountAddress, big.NewInt(1e18)) + return [][]byte{createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(1), 21000, normalFee, normalFee, 7)} + }, + }, + { + // nonce 0 succeeds, then a second tx replays nonce 0 + name: "NonceTooLow", + build: func(t *testing.T, tCtx *GigaTestContext, signer utils.TestAcct, to common.Address) [][]byte { + fundAccount(t, tCtx, signer.AccountAddress, big.NewInt(1e18)) + return [][]byte{ + createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(1), 21000, normalFee, normalFee, 0), + createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(2), 21000, normalFee, normalFee, 0), + } + }, + }, + { + // CON-368's exact block shape: tx0 drains the fee balance out + // from under tx1 + name: "DrainRace", + build: func(t *testing.T, tCtx *GigaTestContext, signer utils.TestAcct, to common.Address) [][]byte { + fundAccount(t, tCtx, signer.AccountAddress, big.NewInt(3e15)) + return [][]byte{ + createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(5e14), 21000, normalFee, normalFee, 0), + createCustomEVMTx(t, tCtx, signer, &to, big.NewInt(1), 21000, normalFee, normalFee, 1), + } + }, + }, + } + + gigaModes := []struct { + name string + mode ExecutorMode + }{ + {"GigaSequential", ModeGigaSequential}, + {"GigaOCC", ModeGigaOCC}, + } + + for _, tc := range cases { + for _, gm := range gigaModes { + t.Run(tc.name+"/"+gm.name, func(t *testing.T) { + blockTime := time.Now() + accts := utils.NewTestAccounts(3) + signer := utils.NewSigner() + recipient := utils.NewSigner() + to := recipient.EvmAddress + + v2Ctx := NewGigaTestContext(t, accts, blockTime, 1, ModeV2Sequential) + v2Ctx.TestApp.EvmKeeper.SetAddressMapping(v2Ctx.Ctx, signer.AccountAddress, signer.EvmAddress) + v2Txs := tc.build(t, v2Ctx, signer, to) + _, v2Results, _ := RunBlock(t, v2Ctx, v2Txs) + + gigaCtx := NewGigaTestContext(t, accts, blockTime, 1, gm.mode) + gigaCtx.TestApp.GigaEvmKeeper.SetAddressMapping(gigaCtx.Ctx, signer.AccountAddress, signer.EvmAddress) + gigaTxs := tc.build(t, gigaCtx, signer, to) + _, gigaResults, _ := RunBlock(t, gigaCtx, gigaTxs) + + require.Len(t, gigaResults, len(v2Results)) + failed := 0 + for _, r := range v2Results { + if r.Code != 0 { + failed++ + } + } + require.Greater(t, failed, 0, "case must produce at least one failed tx under v2, or it covers nothing") + CompareDeterministicFields(t, tc.name+"/"+gm.name, v2Results, gigaResults) + CompareLastResultsHash(t, tc.name+"/"+gm.name, v2Results, gigaResults) + }) + } + } +} + // TestGigaValidation_FeeCapBelowMinimumFee tests that fee cap < minimum fee is rejected. func TestGigaValidation_FeeCapBelowMinimumFee(t *testing.T) { blockTime := time.Now() From d06795b0a8f3dcf147dc1ade9db822dd690259e6 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 13 Jul 2026 14:55:57 -0700 Subject: [PATCH 02/10] review: xreview round-1 resolutions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Instrument every giga fallback site with a reason attribute on app_giga_fallback_to_v2 (the sync abort branch was previously uncounted, and the counter had no cause label — operators could not tell a validation-failure wave from balance-migration churn). - Rename the sentinel to ErrValidationFailed: the family names the condition, not the remedy. - Correct the drain-race comment: the OCC fallback re-runs through the v2 OCC scheduler, not serial execution. - Present-tense the validation-branch comment; use ExecutorMode.String() and nested subtests per house form; rename the now-misnomered ErrorCodeParity test to MixedBlockReceiptParity. --- app/app.go | 40 +++++++++--- giga/executor/precompiles/failfast.go | 19 +++--- giga/tests/giga_test.go | 87 +++++++++++++-------------- 3 files changed, 81 insertions(+), 65 deletions(-) diff --git a/app/app.go b/app/app.go index e770a0d7a3..3234b767aa 100644 --- a/app/app.go +++ b/app/app.go @@ -1610,7 +1610,8 @@ func (app *App) ProcessTxsSynchronousGiga(ctx sdk.Context, txs [][]byte, typedTx // Store-iteration panic: re-run via v2 so the result matches v2 exactly. if fallbackToV2 { utilmetrics.IncrGigaFallbackToV2Counter() // TODO(PLT-327): remove once app_giga_fallback_to_v2_total verified - appMetrics.gigaFallback.Add(ctx.Context(), 1) + appMetrics.gigaFallback.Add(ctx.Context(), 1, + otelmetric.WithAttributes(attribute.String("reason", "store_iterator"))) res := app.DeliverTxWithResult(ctx, tx, typedTxs[i]) txResults[i] = res ms.Write() @@ -1618,8 +1619,11 @@ func (app *App) ProcessTxsSynchronousGiga(ctx sdk.Context, txs [][]byte, typedTx } if execErr != nil { - // Check if this is a fail-fast error (Cosmos precompile interop detected) + // Abort errors (validation failure, balance migration, self-destruct, + // cosmos-precompile interop) re-run this tx via v2. if gigautils.ShouldExecutionAbort(execErr) { + appMetrics.gigaFallback.Add(ctx.Context(), 1, + otelmetric.WithAttributes(attribute.String("reason", gigaFallbackReason(execErr)))) res := app.DeliverTxWithResult(ctx, tx, typedTxs[i]) txResults[i] = res ms.Write() @@ -1846,7 +1850,8 @@ func (app *App) ProcessTXsWithOCCGiga(ctx sdk.Context, txs [][]byte, typedTxs [] if fallbackToV2 { utilmetrics.IncrGigaFallbackToV2Counter() // TODO(PLT-327): remove once app_giga_fallback_to_v2_total verified - appMetrics.gigaFallback.Add(ctx.Context(), 1) + appMetrics.gigaFallback.Add(ctx.Context(), 1, + otelmetric.WithAttributes(attribute.String("reason", "occ_batch"))) // Discard all EVM changes by skipping cache writes, then re-run all txs via DeliverTx. evmBatchResult = nil v2Entries = make([]*sdk.DeliverTxEntry, len(txs)) @@ -1998,6 +2003,24 @@ func (app *App) ProcessBlock(ctx sdk.Context, txs [][]byte, req *BlockProcessReq // executeEVMTxWithGigaExecutor executes a single EVM transaction using the giga executor. // The sender address is recovered directly from the transaction signature - no Cosmos SDK ante handlers needed. +// gigaFallbackReason labels the app_giga_fallback_to_v2 metric with which +// abort sentinel routed a tx to v2, so operators can tell a validation-failure +// wave from balance-migration churn. +func gigaFallbackReason(err error) string { + switch err.(type) { + case *gigaprecompiles.ValidationFailedAbortError: + return "validation_failed" + case *gigaprecompiles.BalanceMigrationAbortError: + return "balance_migration" + case *gigaprecompiles.SelfDestructAbortError: + return "self_destruct" + case *gigaprecompiles.InvalidPrecompileCallError: + return "invalid_precompile" + default: + return "other" + } +} + func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgEVMTransaction, cache *gigaBlockCache) (*abci.ExecTxResult, error) { // Get the Ethereum transaction from the message ethTx, _ := msg.AsTransaction() @@ -2025,12 +2048,11 @@ func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgE ctx = ctx.WithGasMeter(sdk.NewInfiniteGasMeterWithMultiplier(ctx)) if validation.err != nil { - // Fee/nonce/balance failures fall back to v2 (same doctrine as the - // balance-migration case below): v2 rejects these in its ante chain, - // whose receipt gas fields giga cannot reconstruct (CON-368 was this - // path stamping a synthetic receipt, diverging LastResultsHash on - // mixed fleets). The fallback run also owns the nonce bump. - return nil, gigaprecompiles.ErrValidationFallback + // v2 rejects fee/nonce/balance failures in its ante chain, whose + // receipt gas fields giga cannot reconstruct; a giga-side receipt here + // diverges LastResultsHash on mixed fleets (CON-368). Fall back to v2, + // which also owns the nonce bump. + return nil, gigaprecompiles.ErrValidationFailed } if !isAssociated { diff --git a/giga/executor/precompiles/failfast.go b/giga/executor/precompiles/failfast.go index 4046ca58c8..dec8ff47cb 100644 --- a/giga/executor/precompiles/failfast.go +++ b/giga/executor/precompiles/failfast.go @@ -85,23 +85,20 @@ func (e *SelfDestructAbortError) IsAbortError() bool { var ErrSelfDestructUnsupported error = &SelfDestructAbortError{} -// ValidationFallbackAbortError signals that the transaction failed EVM -// validation (fee/nonce/balance). V2 rejects these in its ante chain, and the -// receipt it emits there is path-dependent (GasWanted stays unset; GasUsed is -// the ante's store-gas consumption), so giga cannot reconstruct it; the caller -// should fall back to v2, which produces the canonical failure receipt and -// state effects (nonce bump) by construction. -type ValidationFallbackAbortError struct{} - -func (e *ValidationFallbackAbortError) Error() string { +// ValidationFailedAbortError signals an EVM validation failure (fee/nonce/ +// balance), whose canonical failure receipt only v2's ante chain can produce; +// the caller should fall back to v2. +type ValidationFailedAbortError struct{} + +func (e *ValidationFailedAbortError) Error() string { return "EVM validation failed; v2 produces the canonical failure receipt" } -func (e *ValidationFallbackAbortError) IsAbortError() bool { +func (e *ValidationFailedAbortError) IsAbortError() bool { return true } -var ErrValidationFallback error = &ValidationFallbackAbortError{} +var ErrValidationFailed error = &ValidationFailedAbortError{} type FailFastPrecompile struct{} diff --git a/giga/tests/giga_test.go b/giga/tests/giga_test.go index 7f70eac2c5..c776a274a7 100644 --- a/giga/tests/giga_test.go +++ b/giga/tests/giga_test.go @@ -2464,10 +2464,10 @@ func TestGigaValidation_FailureReceiptParity(t *testing.T) { CompareLastResultsHash(t, "FailureReceiptParity", v2Results, gigaResults) } -// TestGigaValidation_ErrorCodeParity runs a mixed valid/invalid block and -// asserts the full receipts — not just error codes — match between V2 and -// Giga, since every deterministic receipt field feeds LastResultsHash. -func TestGigaValidation_ErrorCodeParity(t *testing.T) { +// TestGigaValidation_MixedBlockReceiptParity runs a mixed valid/invalid block +// and asserts the full receipts match between V2 and Giga, since every +// deterministic receipt field feeds LastResultsHash. +func TestGigaValidation_MixedBlockReceiptParity(t *testing.T) { blockTime := time.Now() accts := utils.NewTestAccounts(5) @@ -2518,8 +2518,8 @@ func TestGigaValidation_ErrorCodeParity(t *testing.T) { // Receipts must be byte-identical — Code/Data/GasWanted/GasUsed feed // LastResultsHash (CON-368). - CompareDeterministicFields(t, "ErrorCodeParity", v2Results, gigaResults) - CompareLastResultsHash(t, "ErrorCodeParity", v2Results, gigaResults) + CompareDeterministicFields(t, "MixedBlockReceiptParity", v2Results, gigaResults) + CompareLastResultsHash(t, "MixedBlockReceiptParity", v2Results, gigaResults) // Verify expected outcomes require.Equal(t, uint32(0), v2Results[0].Code, "tx[0] should succeed") @@ -2531,7 +2531,7 @@ func TestGigaValidation_ErrorCodeParity(t *testing.T) { // shape: tx0 drains the sender far enough that tx1 (same sender, next nonce) // fails its fee check at delivery — a tx that passes admission but fails in // the block. Receipts must hash identically across executors; under OCC the -// giga batch falls back to serialized v2 execution. +// giga batch re-runs through the v2 OCC scheduler. func TestGigaValidation_DrainRace_LastResultsHash(t *testing.T) { runDrainRace := func(t *testing.T, gigaMode ExecutorMode) { blockTime := time.Now() @@ -2569,8 +2569,9 @@ func TestGigaValidation_DrainRace_LastResultsHash(t *testing.T) { CompareDeterministicFields(t, "DrainRace", v2Results, gigaResults) CompareLastResultsHash(t, "DrainRace", v2Results, gigaResults) } - t.Run("GigaSequential", func(t *testing.T) { runDrainRace(t, ModeGigaSequential) }) - t.Run("GigaOCC", func(t *testing.T) { runDrainRace(t, ModeGigaOCC) }) + for _, mode := range []ExecutorMode{ModeGigaSequential, ModeGigaOCC} { + t.Run(mode.String(), func(t *testing.T) { runDrainRace(t, mode) }) + } } // TestGigaValidation_ValueExceedsBalance_LastResultsHash covers the balance @@ -2680,45 +2681,41 @@ func TestGigaValidation_FailureClassParity_AllModes(t *testing.T) { }, } - gigaModes := []struct { - name string - mode ExecutorMode - }{ - {"GigaSequential", ModeGigaSequential}, - {"GigaOCC", ModeGigaOCC}, - } + gigaModes := []ExecutorMode{ModeGigaSequential, ModeGigaOCC} for _, tc := range cases { - for _, gm := range gigaModes { - t.Run(tc.name+"/"+gm.name, func(t *testing.T) { - blockTime := time.Now() - accts := utils.NewTestAccounts(3) - signer := utils.NewSigner() - recipient := utils.NewSigner() - to := recipient.EvmAddress - - v2Ctx := NewGigaTestContext(t, accts, blockTime, 1, ModeV2Sequential) - v2Ctx.TestApp.EvmKeeper.SetAddressMapping(v2Ctx.Ctx, signer.AccountAddress, signer.EvmAddress) - v2Txs := tc.build(t, v2Ctx, signer, to) - _, v2Results, _ := RunBlock(t, v2Ctx, v2Txs) - - gigaCtx := NewGigaTestContext(t, accts, blockTime, 1, gm.mode) - gigaCtx.TestApp.GigaEvmKeeper.SetAddressMapping(gigaCtx.Ctx, signer.AccountAddress, signer.EvmAddress) - gigaTxs := tc.build(t, gigaCtx, signer, to) - _, gigaResults, _ := RunBlock(t, gigaCtx, gigaTxs) - - require.Len(t, gigaResults, len(v2Results)) - failed := 0 - for _, r := range v2Results { - if r.Code != 0 { - failed++ + t.Run(tc.name, func(t *testing.T) { + for _, gm := range gigaModes { + t.Run(gm.String(), func(t *testing.T) { + blockTime := time.Now() + accts := utils.NewTestAccounts(3) + signer := utils.NewSigner() + recipient := utils.NewSigner() + to := recipient.EvmAddress + + v2Ctx := NewGigaTestContext(t, accts, blockTime, 1, ModeV2Sequential) + v2Ctx.TestApp.EvmKeeper.SetAddressMapping(v2Ctx.Ctx, signer.AccountAddress, signer.EvmAddress) + v2Txs := tc.build(t, v2Ctx, signer, to) + _, v2Results, _ := RunBlock(t, v2Ctx, v2Txs) + + gigaCtx := NewGigaTestContext(t, accts, blockTime, 1, gm) + gigaCtx.TestApp.GigaEvmKeeper.SetAddressMapping(gigaCtx.Ctx, signer.AccountAddress, signer.EvmAddress) + gigaTxs := tc.build(t, gigaCtx, signer, to) + _, gigaResults, _ := RunBlock(t, gigaCtx, gigaTxs) + + require.Len(t, gigaResults, len(v2Results)) + failed := 0 + for _, r := range v2Results { + if r.Code != 0 { + failed++ + } } - } - require.Greater(t, failed, 0, "case must produce at least one failed tx under v2, or it covers nothing") - CompareDeterministicFields(t, tc.name+"/"+gm.name, v2Results, gigaResults) - CompareLastResultsHash(t, tc.name+"/"+gm.name, v2Results, gigaResults) - }) - } + require.Greater(t, failed, 0, "case must produce at least one failed tx under v2, or it covers nothing") + CompareDeterministicFields(t, tc.name+"/"+gm.String(), v2Results, gigaResults) + CompareLastResultsHash(t, tc.name+"/"+gm.String(), v2Results, gigaResults) + }) + } + }) } } From 151a4edecf3b419265ea73994e6420e925d6d4d2 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 13 Jul 2026 15:05:47 -0700 Subject: [PATCH 03/10] review: align NewGigaTestWrapperWithRegularStore with the sibling wrapper Same redundant EvmKeeper.InitGenesis, same divergent param bytes; caught by Claude Code Review after the sibling's removal. --- app/test_helpers.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/test_helpers.go b/app/test_helpers.go index 7c0eec530c..ef0de7d42e 100644 --- a/app/test_helpers.go +++ b/app/test_helpers.go @@ -179,9 +179,10 @@ func NewGigaTestWrapperWithRegularStore(t *testing.T, tm time.Time, valPub crypt } } - // Init genesis for GigaEvmKeeper (now uses regular KVStore) - genState := evmtypes.DefaultGenesis() - wrapper.App.EvmKeeper.InitGenesis(wrapper.Ctx, *genState) + // No extra EvmKeeper.InitGenesis here for the same reason as + // NewGigaTestWrapper above: the raw re-init writes param bytes that + // differ from the genesis-JSON round-trip, skewing ante store-gas + // between contexts. return wrapper } From 75d05099dcf57aad8c8fbb31a2cf2a2818a77c67 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 13 Jul 2026 15:10:18 -0700 Subject: [PATCH 04/10] review: reattach executeEVMTxWithGigaExecutor's doc comment gigaFallbackReason's insertion had split the function from its godoc. --- app/app.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/app.go b/app/app.go index 3234b767aa..d523dc2e91 100644 --- a/app/app.go +++ b/app/app.go @@ -2001,8 +2001,6 @@ func (app *App) ProcessBlock(ctx sdk.Context, txs [][]byte, req *BlockProcessReq return events, txResults, endBlockResp, nil } -// executeEVMTxWithGigaExecutor executes a single EVM transaction using the giga executor. -// The sender address is recovered directly from the transaction signature - no Cosmos SDK ante handlers needed. // gigaFallbackReason labels the app_giga_fallback_to_v2 metric with which // abort sentinel routed a tx to v2, so operators can tell a validation-failure // wave from balance-migration churn. @@ -2021,6 +2019,8 @@ func gigaFallbackReason(err error) string { } } +// executeEVMTxWithGigaExecutor executes a single EVM transaction using the giga executor. +// The sender address is recovered directly from the transaction signature - no Cosmos SDK ante handlers needed. func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgEVMTransaction, cache *gigaBlockCache) (*abci.ExecTxResult, error) { // Get the Ethereum transaction from the message ethTx, _ := msg.AsTransaction() From 7215c0a106646db74f92f789e060e0862b6f7a4d Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 13 Jul 2026 15:18:18 -0700 Subject: [PATCH 05/10] review: trim stale nonce-bump clause from validateGigaEVMTx comment The bump-on-failure mechanism it described moved to v2 via the fallback. --- app/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/app.go b/app/app.go index d523dc2e91..1ccc1552b0 100644 --- a/app/app.go +++ b/app/app.go @@ -3048,7 +3048,7 @@ func (app *App) validateGigaEVMTx( baseFee = new(big.Int) } - // Check nonce validity - determines if we bump nonce on fee/balance failures + // Check nonce validity currentNonce := app.GigaEvmKeeper.GetNonce(ctx, sender) txNonce := ethTx.Nonce() From ed8c3b451d085e945b669b48898b8ac42bc050c7 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 13 Jul 2026 15:27:13 -0700 Subject: [PATCH 06/10] review: consolidate InitGenesis guard comment, drop cross-referential echo --- app/test_helpers.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/app/test_helpers.go b/app/test_helpers.go index ef0de7d42e..805f7256f8 100644 --- a/app/test_helpers.go +++ b/app/test_helpers.go @@ -141,11 +141,9 @@ func NewTestWrapperWithSc(t *testing.T, tm time.Time, valPub cryptotypes.PubKey, } func NewGigaTestWrapper(t *testing.T, tm time.Time, valPub cryptotypes.PubKey, enableEVMCustomPrecompiles bool, useOcc bool, baseAppOptions ...func(*bam.BaseApp)) *TestWrapper { - // No extra EvmKeeper.InitGenesis here: newTestWrapper already runs full - // module genesis, and a second raw InitGenesis(DefaultGenesis()) writes - // param bytes that differ from the genesis-JSON round-trip (nil slice - // marshals to null, seeded []byte{} to []), skewing ante store-gas — and - // therefore receipt GasUsed — between giga and v2 test contexts. + // newTestWrapper runs full module genesis; a raw InitGenesis(DefaultGenesis()) + // on top writes byte-different params (nil marshals to null, seeded []byte{} + // to []) and skews ante store-gas between executor contexts. return newTestWrapper(t, tm, valPub, enableEVMCustomPrecompiles, true, TestAppOpts{UseSc: true, EnableGiga: true, EnableGigaOCC: useOcc}, baseAppOptions...) } @@ -179,11 +177,6 @@ func NewGigaTestWrapperWithRegularStore(t *testing.T, tm time.Time, valPub crypt } } - // No extra EvmKeeper.InitGenesis here for the same reason as - // NewGigaTestWrapper above: the raw re-init writes param bytes that - // differ from the genesis-JSON round-trip, skewing ante store-gas - // between contexts. - return wrapper } From 4ebc025d65831934696cf9f77d5f381f1f1dc55d Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 13 Jul 2026 15:38:55 -0700 Subject: [PATCH 07/10] review: carry abort cause through sentinel Info for batch fallback metric The OCC batch fallback labeled every cause occ_batch; the reason label now carries the aborting tx's cause on both paths, with scope=tx|batch distinguishing single-tx from batch-wide fallbacks. --- app/app.go | 20 +++++++++++++++----- giga/executor/utils/errors.go | 4 ++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/app.go b/app/app.go index 1ccc1552b0..6652ce4b40 100644 --- a/app/app.go +++ b/app/app.go @@ -1611,7 +1611,9 @@ func (app *App) ProcessTxsSynchronousGiga(ctx sdk.Context, txs [][]byte, typedTx if fallbackToV2 { utilmetrics.IncrGigaFallbackToV2Counter() // TODO(PLT-327): remove once app_giga_fallback_to_v2_total verified appMetrics.gigaFallback.Add(ctx.Context(), 1, - otelmetric.WithAttributes(attribute.String("reason", "store_iterator"))) + otelmetric.WithAttributes( + attribute.String("reason", "store_iterator"), + attribute.String("scope", "tx"))) res := app.DeliverTxWithResult(ctx, tx, typedTxs[i]) txResults[i] = res ms.Write() @@ -1623,7 +1625,9 @@ func (app *App) ProcessTxsSynchronousGiga(ctx sdk.Context, txs [][]byte, typedTx // cosmos-precompile interop) re-run this tx via v2. if gigautils.ShouldExecutionAbort(execErr) { appMetrics.gigaFallback.Add(ctx.Context(), 1, - otelmetric.WithAttributes(attribute.String("reason", gigaFallbackReason(execErr)))) + otelmetric.WithAttributes( + attribute.String("reason", gigaFallbackReason(execErr)), + attribute.String("scope", "tx"))) res := app.DeliverTxWithResult(ctx, tx, typedTxs[i]) txResults[i] = res ms.Write() @@ -1841,9 +1845,13 @@ func (app *App) ProcessTXsWithOCCGiga(ctx sdk.Context, txs [][]byte, typedTxs [] return nil, ctx } + fallbackReason := "other" for _, r := range evmBatchResult { if r.Code == gigautils.GigaAbortCode && r.Codespace == gigautils.GigaAbortCodespace { fallbackToV2 = true + if r.Info != "" { + fallbackReason = r.Info + } break } } @@ -1851,7 +1859,9 @@ func (app *App) ProcessTXsWithOCCGiga(ctx sdk.Context, txs [][]byte, typedTxs [] if fallbackToV2 { utilmetrics.IncrGigaFallbackToV2Counter() // TODO(PLT-327): remove once app_giga_fallback_to_v2_total verified appMetrics.gigaFallback.Add(ctx.Context(), 1, - otelmetric.WithAttributes(attribute.String("reason", "occ_batch"))) + otelmetric.WithAttributes( + attribute.String("reason", fallbackReason), + attribute.String("scope", "batch"))) // Discard all EVM changes by skipping cache writes, then re-run all txs via DeliverTx. evmBatchResult = nil v2Entries = make([]*sdk.DeliverTxEntry, len(txs)) @@ -2292,7 +2302,7 @@ func (app *App) makeGigaDeliverTx(cache *gigaBlockCache) func(sdk.Context, abci. resp = abci.ResponseDeliverTx{ Code: gigautils.GigaAbortCode, Codespace: gigautils.GigaAbortCodespace, - Info: gigautils.GigaAbortInfo, + Info: "store_iterator", Log: "giga executor abort: store iteration unsupported, fall back to v2", } return @@ -2331,7 +2341,7 @@ func (app *App) makeGigaDeliverTx(cache *gigaBlockCache) func(sdk.Context, abci. return abci.ResponseDeliverTx{ Code: gigautils.GigaAbortCode, Codespace: gigautils.GigaAbortCodespace, - Info: gigautils.GigaAbortInfo, + Info: gigaFallbackReason(err), Log: "giga executor abort: fall back to v2", } } diff --git a/giga/executor/utils/errors.go b/giga/executor/utils/errors.go index 182bd998a1..e24c4033c8 100644 --- a/giga/executor/utils/errors.go +++ b/giga/executor/utils/errors.go @@ -6,10 +6,10 @@ import ( const ( // GigaAbortCodespace and GigaAbortCode identify a sentinel ResponseDeliverTx - // that signals the caller to fall back to the v2 execution path. + // that signals the caller to fall back to the v2 execution path. The + // response's Info field carries the abort cause for the fallback metric. GigaAbortCodespace = "giga" GigaAbortCode = uint32(1) - GigaAbortInfo = "giga_fallback_to_v2" ) // ShouldExecutionAbort checks if the given error is an AbortError that should From 4496581bd3f3067005142b038ff03c8e344b23bc Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 13 Jul 2026 15:39:17 -0700 Subject: [PATCH 08/10] review: comment sweep, drop narration and em dashes --- app/app.go | 1 - app/test_helpers.go | 3 --- giga/tests/giga_test.go | 8 ++++---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/app/app.go b/app/app.go index 6652ce4b40..9fb0756601 100644 --- a/app/app.go +++ b/app/app.go @@ -3058,7 +3058,6 @@ func (app *App) validateGigaEVMTx( baseFee = new(big.Int) } - // Check nonce validity currentNonce := app.GigaEvmKeeper.GetNonce(ctx, sender) txNonce := ethTx.Nonce() diff --git a/app/test_helpers.go b/app/test_helpers.go index 805f7256f8..bdaa60f237 100644 --- a/app/test_helpers.go +++ b/app/test_helpers.go @@ -141,9 +141,6 @@ func NewTestWrapperWithSc(t *testing.T, tm time.Time, valPub cryptotypes.PubKey, } func NewGigaTestWrapper(t *testing.T, tm time.Time, valPub cryptotypes.PubKey, enableEVMCustomPrecompiles bool, useOcc bool, baseAppOptions ...func(*bam.BaseApp)) *TestWrapper { - // newTestWrapper runs full module genesis; a raw InitGenesis(DefaultGenesis()) - // on top writes byte-different params (nil marshals to null, seeded []byte{} - // to []) and skews ante store-gas between executor contexts. return newTestWrapper(t, tm, valPub, enableEVMCustomPrecompiles, true, TestAppOpts{UseSc: true, EnableGiga: true, EnableGigaOCC: useOcc}, baseAppOptions...) } diff --git a/giga/tests/giga_test.go b/giga/tests/giga_test.go index c776a274a7..11210b1e21 100644 --- a/giga/tests/giga_test.go +++ b/giga/tests/giga_test.go @@ -2427,8 +2427,8 @@ func TestGigaValidation_InsufficientBalance(t *testing.T) { // TestGigaValidation_FailureReceiptParity asserts a validation-failed tx // produces a byte-identical receipt under both executors. Giga routes // validation failures to v2 (per-tx fallback, same doctrine as balance -// migration), so the ante-abort receipt — including the gas fields that feed -// LastResultsHash — matches by construction (CON-368). +// migration), so the ante-abort receipt (including the gas fields that feed +// LastResultsHash) matches by construction (CON-368). func TestGigaValidation_FailureReceiptParity(t *testing.T) { blockTime := time.Now() accts := utils.NewTestAccounts(3) @@ -2516,7 +2516,7 @@ func TestGigaValidation_MixedBlockReceiptParity(t *testing.T) { require.Len(t, v2Results, 3) require.Len(t, gigaResults, 3) - // Receipts must be byte-identical — Code/Data/GasWanted/GasUsed feed + // Receipts must be byte-identical: Code/Data/GasWanted/GasUsed feed // LastResultsHash (CON-368). CompareDeterministicFields(t, "MixedBlockReceiptParity", v2Results, gigaResults) CompareLastResultsHash(t, "MixedBlockReceiptParity", v2Results, gigaResults) @@ -2529,7 +2529,7 @@ func TestGigaValidation_MixedBlockReceiptParity(t *testing.T) { // TestGigaValidation_DrainRace_LastResultsHash reproduces CON-368's block // shape: tx0 drains the sender far enough that tx1 (same sender, next nonce) -// fails its fee check at delivery — a tx that passes admission but fails in +// fails its fee check at delivery, a tx that passes admission but fails in // the block. Receipts must hash identically across executors; under OCC the // giga batch re-runs through the v2 OCC scheduler. func TestGigaValidation_DrainRace_LastResultsHash(t *testing.T) { From 53597327baaf8d0fb644d14085582f4421912b44 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 13 Jul 2026 15:40:48 -0700 Subject: [PATCH 09/10] review: increment legacy fallback counter on sync abort path Keeps the PLT-327 legacy/OTel cross-check consistent across all three fallback sites. --- app/app.go | 1 + 1 file changed, 1 insertion(+) diff --git a/app/app.go b/app/app.go index 9fb0756601..e69a364644 100644 --- a/app/app.go +++ b/app/app.go @@ -1624,6 +1624,7 @@ func (app *App) ProcessTxsSynchronousGiga(ctx sdk.Context, txs [][]byte, typedTx // Abort errors (validation failure, balance migration, self-destruct, // cosmos-precompile interop) re-run this tx via v2. if gigautils.ShouldExecutionAbort(execErr) { + utilmetrics.IncrGigaFallbackToV2Counter() // TODO(PLT-327): remove once app_giga_fallback_to_v2_total verified appMetrics.gigaFallback.Add(ctx.Context(), 1, otelmetric.WithAttributes( attribute.String("reason", gigaFallbackReason(execErr)), From ee36fc227746a3be189fc36461c244875a6db7b6 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Tue, 14 Jul 2026 08:02:32 -0700 Subject: [PATCH 10/10] review: move ErrValidationFailed to gigautils, name fallback-reason constants The sentinel is not precompile-related; it joins the abort plumbing in executor/utils. Metric reason labels get named constants shared across all sites. --- app/app.go | 30 ++++++++++++++++++--------- giga/executor/precompiles/failfast.go | 15 -------------- giga/executor/utils/errors.go | 15 ++++++++++++++ 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/app/app.go b/app/app.go index e69a364644..d583516baa 100644 --- a/app/app.go +++ b/app/app.go @@ -1612,7 +1612,7 @@ func (app *App) ProcessTxsSynchronousGiga(ctx sdk.Context, txs [][]byte, typedTx utilmetrics.IncrGigaFallbackToV2Counter() // TODO(PLT-327): remove once app_giga_fallback_to_v2_total verified appMetrics.gigaFallback.Add(ctx.Context(), 1, otelmetric.WithAttributes( - attribute.String("reason", "store_iterator"), + attribute.String("reason", gigaFallbackStoreIterator), attribute.String("scope", "tx"))) res := app.DeliverTxWithResult(ctx, tx, typedTxs[i]) txResults[i] = res @@ -1846,7 +1846,7 @@ func (app *App) ProcessTXsWithOCCGiga(ctx sdk.Context, txs [][]byte, typedTxs [] return nil, ctx } - fallbackReason := "other" + fallbackReason := gigaFallbackOther for _, r := range evmBatchResult { if r.Code == gigautils.GigaAbortCode && r.Codespace == gigautils.GigaAbortCodespace { fallbackToV2 = true @@ -2012,21 +2012,31 @@ func (app *App) ProcessBlock(ctx sdk.Context, txs [][]byte, req *BlockProcessReq return events, txResults, endBlockResp, nil } +// Fallback-cause labels for the app_giga_fallback_to_v2 metric. +const ( + gigaFallbackValidationFailed = "validation_failed" + gigaFallbackBalanceMigration = "balance_migration" + gigaFallbackSelfDestruct = "self_destruct" + gigaFallbackInvalidPrecompile = "invalid_precompile" + gigaFallbackStoreIterator = "store_iterator" + gigaFallbackOther = "other" +) + // gigaFallbackReason labels the app_giga_fallback_to_v2 metric with which // abort sentinel routed a tx to v2, so operators can tell a validation-failure // wave from balance-migration churn. func gigaFallbackReason(err error) string { switch err.(type) { - case *gigaprecompiles.ValidationFailedAbortError: - return "validation_failed" + case *gigautils.ValidationFailedAbortError: + return gigaFallbackValidationFailed case *gigaprecompiles.BalanceMigrationAbortError: - return "balance_migration" + return gigaFallbackBalanceMigration case *gigaprecompiles.SelfDestructAbortError: - return "self_destruct" + return gigaFallbackSelfDestruct case *gigaprecompiles.InvalidPrecompileCallError: - return "invalid_precompile" + return gigaFallbackInvalidPrecompile default: - return "other" + return gigaFallbackOther } } @@ -2063,7 +2073,7 @@ func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgE // receipt gas fields giga cannot reconstruct; a giga-side receipt here // diverges LastResultsHash on mixed fleets (CON-368). Fall back to v2, // which also owns the nonce bump. - return nil, gigaprecompiles.ErrValidationFailed + return nil, gigautils.ErrValidationFailed } if !isAssociated { @@ -2303,7 +2313,7 @@ func (app *App) makeGigaDeliverTx(cache *gigaBlockCache) func(sdk.Context, abci. resp = abci.ResponseDeliverTx{ Code: gigautils.GigaAbortCode, Codespace: gigautils.GigaAbortCodespace, - Info: "store_iterator", + Info: gigaFallbackStoreIterator, Log: "giga executor abort: store iteration unsupported, fall back to v2", } return diff --git a/giga/executor/precompiles/failfast.go b/giga/executor/precompiles/failfast.go index dec8ff47cb..352ac778c9 100644 --- a/giga/executor/precompiles/failfast.go +++ b/giga/executor/precompiles/failfast.go @@ -85,21 +85,6 @@ func (e *SelfDestructAbortError) IsAbortError() bool { var ErrSelfDestructUnsupported error = &SelfDestructAbortError{} -// ValidationFailedAbortError signals an EVM validation failure (fee/nonce/ -// balance), whose canonical failure receipt only v2's ante chain can produce; -// the caller should fall back to v2. -type ValidationFailedAbortError struct{} - -func (e *ValidationFailedAbortError) Error() string { - return "EVM validation failed; v2 produces the canonical failure receipt" -} - -func (e *ValidationFailedAbortError) IsAbortError() bool { - return true -} - -var ErrValidationFailed error = &ValidationFailedAbortError{} - type FailFastPrecompile struct{} var FailFastSingleton vm.PrecompiledContract = &FailFastPrecompile{} diff --git a/giga/executor/utils/errors.go b/giga/executor/utils/errors.go index e24c4033c8..b42276fad0 100644 --- a/giga/executor/utils/errors.go +++ b/giga/executor/utils/errors.go @@ -12,6 +12,21 @@ const ( GigaAbortCode = uint32(1) ) +// ValidationFailedAbortError signals an EVM validation failure (fee/nonce/ +// balance), whose canonical failure receipt only v2's ante chain can produce; +// the caller should fall back to v2. +type ValidationFailedAbortError struct{} + +func (e *ValidationFailedAbortError) Error() string { + return "EVM validation failed; v2 produces the canonical failure receipt" +} + +func (e *ValidationFailedAbortError) IsAbortError() bool { + return true +} + +var ErrValidationFailed error = &ValidationFailedAbortError{} + // ShouldExecutionAbort checks if the given error is an AbortError that should // cause Giga execution to abort and fall back to standard execution. func ShouldExecutionAbort(err error) bool {