Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 39 additions & 7 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app

import (
"encoding/json"
"errors"
"fmt"
"io"
"maps"
Expand Down Expand Up @@ -39,6 +40,7 @@ import (
"github.com/spf13/cast"

appconfig "github.com/TacBuild/tacchain/app/config"
recovery "github.com/TacBuild/tacchain/app/hardforks/aug2026"
v160 "github.com/TacBuild/tacchain/app/upgrades/v1.6.0"

autocliv1 "cosmossdk.io/api/cosmos/autocli/v1"
Expand Down Expand Up @@ -78,6 +80,7 @@ import (
servertypes "github.com/cosmos/cosmos-sdk/server/types"
testdata_pulsar "github.com/cosmos/cosmos-sdk/testutil/testdata/testpb"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
"github.com/cosmos/cosmos-sdk/types/module"
"github.com/cosmos/cosmos-sdk/types/msgservice"
signingtypes "github.com/cosmos/cosmos-sdk/types/tx/signing"
Expand Down Expand Up @@ -1049,6 +1052,10 @@ func (app *TacChainApp) broadcastEVMTransactions(ethTxs []*ethtypes.Transaction)
signer := ethtypes.LatestSigner(evmvmtypes.GetEthChainConfig())
baseDenom := evmvmtypes.GetEVMCoinDenom()

// One bad transaction must not cost the rest of the batch its broadcast, so
// failures are collected and reported once at the end.
var errs []error

for _, ethTx := range ethTxs {
// The sender has to be recovered and set here: MsgEthereumTx.ValidateBasic
// rejects a message without it, so a broadcast built with FromEthereumTx
Expand All @@ -1057,7 +1064,8 @@ func (app *TacChainApp) broadcastEVMTransactions(ethTxs []*ethtypes.Transaction)
// node's mempool directly, but the transaction never reaches its peers.
msg := &evmvmtypes.MsgEthereumTx{}
if err := msg.FromSignedEthereumTx(ethTx, signer); err != nil {
return fmt.Errorf("failed to recover sender of transaction %s: %w", ethTx.Hash().Hex(), err)
errs = append(errs, fmt.Errorf("failed to recover sender of transaction %s: %w", ethTx.Hash().Hex(), err))
continue
}

// Build through the message itself rather than filling a builder by hand:
Expand All @@ -1068,23 +1076,33 @@ func (app *TacChainApp) broadcastEVMTransactions(ethTxs []*ethtypes.Transaction)
// contained within a tx with 'ExtensionOptionsEthereumTx' option".
cosmosTx, err := msg.BuildTx(app.txConfig.NewTxBuilder(), baseDenom)
if err != nil {
return fmt.Errorf("failed to build cosmos tx for %s: %w", ethTx.Hash().Hex(), err)
errs = append(errs, fmt.Errorf("failed to build cosmos tx for %s: %w", ethTx.Hash().Hex(), err))
continue
}

txBytes, err := app.txConfig.TxEncoder()(cosmosTx)
if err != nil {
return fmt.Errorf("failed to encode transaction: %w", err)
errs = append(errs, fmt.Errorf("failed to encode transaction %s: %w", ethTx.Hash().Hex(), err))
continue
}

res, err := app.clientCtx.BroadcastTxSync(txBytes)
if err != nil {
return fmt.Errorf("failed to broadcast transaction %s: %w", ethTx.Hash().Hex(), err)
errs = append(errs, fmt.Errorf("failed to broadcast transaction %s: %w", ethTx.Hash().Hex(), err))
continue
}
// The submitting path has already handed this transaction to Comet, so
// this node's own cache answers the peer broadcast with "tx already in
// mempool". That is the expected outcome here, not a failure.
if res.Code == sdkerrors.ErrTxInMempoolCache.ABCICode() {
continue
}
if res.Code != 0 {
return fmt.Errorf("transaction %s rejected by mempool: code=%d, log=%s", ethTx.Hash().Hex(), res.Code, res.RawLog)
errs = append(errs, fmt.Errorf("transaction %s rejected by mempool: code=%d, log=%s", ethTx.Hash().Hex(), res.Code, res.RawLog))
}
}
return nil

return errors.Join(errs...)
}

func (app *TacChainApp) setPostHandler() {
Expand All @@ -1103,7 +1121,21 @@ func (app *TacChainApp) Name() string { return app.BaseApp.Name() }

// PreBlocker application updates every pre block
func (app *TacChainApp) PreBlocker(ctx sdk.Context, _ *abci.RequestFinalizeBlock) (*sdk.ResponsePreBlock, error) {
return app.ModuleManager.PreBlock(ctx)
res, err := app.ModuleManager.PreBlock(ctx)
if err != nil {
return res, err
}
// Aug-2026 incident recovery: runs only on a chain-id in ParamsByChainID, at its Height.
if p, ok := recovery.ParamsByChainID[ctx.ChainID()]; ok && ctx.BlockHeight() == p.Height {
if err := recovery.Migrate(ctx, recovery.Keepers{
Account: app.AccountKeeper,
Bank: app.BankKeeper,
Staking: app.StakingKeeper,
}, p); err != nil {
panic(fmt.Sprintf("recovery migration failed at %d on %s: %v", ctx.BlockHeight(), ctx.ChainID(), err))
}
}
return res, nil
}

// BeginBlocker application updates every begin block
Expand Down
56 changes: 55 additions & 1 deletion app/evm_mempool_broadcast_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/cosmos/cosmos-sdk/client"
simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
authante "github.com/cosmos/cosmos-sdk/x/auth/ante"
"github.com/cosmos/evm/mempool/txpool/legacypool"
evmtypes "github.com/cosmos/evm/x/vm/types"
Expand Down Expand Up @@ -51,6 +52,9 @@ func signedEthTx(t *testing.T) (*ethtypes.Transaction, ethcmn.Address) {
type broadcastRecorder struct {
rpcmock.Client

// code the node answers the broadcast with; zero means accepted
code uint32

mu sync.Mutex
calls int
tx cmttypes.Tx
Expand All @@ -62,7 +66,14 @@ func (r *broadcastRecorder) BroadcastTxSync(_ context.Context, tx cmttypes.Tx) (

r.calls++
r.tx = append(r.tx[:0], tx...)
return &coretypes.ResultBroadcastTx{}, nil
return &coretypes.ResultBroadcastTx{Code: r.code}, nil
}

func (r *broadcastRecorder) setCode(code uint32) {
r.mu.Lock()
defer r.mu.Unlock()
r.code = code
r.calls = 0
}

func (r *broadcastRecorder) callCount() int {
Expand Down Expand Up @@ -210,3 +221,46 @@ func TestEVMMempoolBroadcastTxFnDoesNotBlockOnBroadcast(t *testing.T) {
t.Fatal("background broadcast goroutine did not exit")
}
}

func TestBroadcastEVMTransactionsMempoolCodes(t *testing.T) {
tacApp := NewTacChainAppWithCustomOptions(t, true, SetupOptions{
Logger: log.NewTestLogger(t),
DB: dbm.NewMemDB(),
AppOpts: simtestutil.NewAppOptionsWithFlagHome(t.TempDir()),
})

ethTx, _ := signedEthTx(t)

// RegisterTxService may only run once per app, so the same client is reused
// and its answer changed per case.
rpcClient := &broadcastRecorder{}
tacApp.RegisterTxService(client.Context{}.
WithTxConfig(tacApp.txConfig).
WithClient(rpcClient),
)

for _, tc := range []struct {
name string
code uint32
wantErr bool
}{
// The submitting path has already given this transaction to Comet, so
// the node's own cache answers the peer broadcast as a duplicate. That
// is the normal outcome, not something to report.
{"duplicate in mempool cache", sdkerrors.ErrTxInMempoolCache.ABCICode(), false},
{"accepted", 0, false},
{"rejected", sdkerrors.ErrInvalidRequest.ABCICode(), true},
} {
t.Run(tc.name, func(t *testing.T) {
rpcClient.setCode(tc.code)

err := tacApp.broadcastEVMTransactions([]*ethtypes.Transaction{ethTx})
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Equal(t, 1, rpcClient.callCount())
})
}
}
Loading
Loading