Skip to content
Draft
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
30 changes: 30 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ import (

"github.com/sei-protocol/sei-chain/sei-cosmos/storev2/rootmulti"
"github.com/sei-protocol/sei-chain/utils"
"github.com/sei-protocol/sei-chain/utils/helpers"
utilmetrics "github.com/sei-protocol/sei-chain/utils/metrics"
"github.com/sei-protocol/sei-chain/wasmbinding"
epochmodule "github.com/sei-protocol/sei-chain/x/epoch"
Expand Down Expand Up @@ -2045,6 +2046,15 @@ func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgE
return nil, gigaprecompiles.ErrBalanceMigrationRequired
}

// EIP-7702 authorization authorities must be associated with their true (pubkey-derived)
// Sei address before SetCode installs delegation code, otherwise SetCode creates a mutable
// direct-cast mapping that a later associatePubKey can remap (orphaning staking/distribution
// state). That pre-association is done in the V2 ante handler and requires balance migration,
// which giga's cachekv cannot perform, so defer such transactions to V2.
if app.setCodeTxRequiresAuthorityAssociation(ctx, ethTx) {
return nil, gigaprecompiles.ErrBalanceMigrationRequired
}

// Create state DB for this transaction (only for valid transactions)
stateDB := gigaevmstate.NewDBImpl(ctx, &app.GigaEvmKeeper, false)
defer stateDB.Cleanup()
Expand Down Expand Up @@ -2247,6 +2257,26 @@ func (app *App) executeEVMTxWithGigaExecutor(ctx sdk.Context, msg *evmtypes.MsgE
}, nil
}

// setCodeTxRequiresAuthorityAssociation reports whether an EIP-7702 transaction has any
// authorization authority that is not yet associated with its true (pubkey-derived) Sei
// address. Such an authority must be associated (with balance migration) before SetCode
// runs, which the V2 ante handler does but the giga executor cannot; callers use this to
// defer the transaction to V2. Authorities with unrecoverable signatures are ignored:
// EVM execution skips them too, so no direct-cast mapping is created for them. It returns
// false for non-SetCode transactions (which carry no authorizations).
func (app *App) setCodeTxRequiresAuthorityAssociation(ctx sdk.Context, ethTx *ethtypes.Transaction) bool {
for _, auth := range ethTx.SetCodeAuthorizations() {
_, seiAddr, _, err := helpers.RecoverAddressesFromAuthorization(auth)
if err != nil {
continue
}
if _, associated := app.GigaEvmKeeper.GetEVMAddress(ctx, seiAddr); !associated {
return true
}
}
return false
}

// gigaDeliverTx is the OCC-compatible deliverTx function for the giga executor.
// makeGigaDeliverTx returns an OCC-compatible deliverTx callback that captures the given
// block cache, avoiding mutable state on App for cache lifecycle management.
Expand Down
72 changes: 72 additions & 0 deletions app/setcode_authority_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package app

import (
"math/big"
"testing"
"time"

"github.com/ethereum/go-ethereum/common"
ethtypes "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/holiman/uint256"
tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types"
"github.com/sei-protocol/sei-chain/utils/helpers"
"github.com/stretchr/testify/require"
)

// TestSetCodeTxRequiresAuthorityAssociation verifies the Giga-path guard for the EIP-7702
// root-cause fix: a SetCode transaction whose authorization authority is not yet associated
// with its true Sei address must be deferred to V2 (where the ante handler pre-associates
// it), while an already-associated authority or a non-SetCode transaction need not.
func TestSetCodeTxRequiresAuthorityAssociation(t *testing.T) {
a := Setup(t, false, false, false)
ctx := a.NewContext(false, tmproto.Header{Height: 1, ChainID: "sei-test", Time: time.Now()})
chainID := a.EvmKeeper.ChainID(ctx)

victimKey, err := crypto.GenerateKey()
require.NoError(t, err)
victimEVM := crypto.PubkeyToAddress(victimKey.PublicKey)
_, victimTrueSei, _, err := helpers.GetAddressesFromPubkeyBytes(crypto.FromECDSAPub(&victimKey.PublicKey))
require.NoError(t, err)

auth, err := ethtypes.SignSetCode(victimKey, ethtypes.SetCodeAuthorization{
ChainID: *uint256.MustFromBig(chainID),
Address: common.HexToAddress("0x000000000000000000000000000000000000c0de"),
Nonce: 0,
})
require.NoError(t, err)

sponsorKey, err := crypto.GenerateKey()
require.NoError(t, err)
to := common.HexToAddress("0x00000000000000000000000000000000000000aa")
setCodeTx, err := ethtypes.SignNewTx(sponsorKey, ethtypes.NewPragueSigner(chainID), &ethtypes.SetCodeTx{
ChainID: uint256.MustFromBig(chainID),
Nonce: 0,
GasTipCap: uint256.NewInt(1),
GasFeeCap: uint256.NewInt(1),
Gas: 100000,
To: to,
Value: uint256.NewInt(0),
AuthList: []ethtypes.SetCodeAuthorization{auth},
})
require.NoError(t, err)

// Unassociated authority => giga must defer to V2 so the ante can pre-associate it.
require.True(t, a.setCodeTxRequiresAuthorityAssociation(ctx, setCodeTx))

// Once the authority is associated to its true Sei address, giga can execute directly
// (SetCode will see the association and skip the direct-cast mapping).
a.GigaEvmKeeper.SetAddressMapping(ctx, victimTrueSei, victimEVM)
require.False(t, a.setCodeTxRequiresAuthorityAssociation(ctx, setCodeTx))

// A non-SetCode transaction carries no authorizations and never needs deferral.
legacyTx, err := ethtypes.SignNewTx(sponsorKey, ethtypes.NewPragueSigner(chainID), &ethtypes.LegacyTx{
Nonce: 0,
GasPrice: big.NewInt(1),
Gas: 21000,
To: &to,
Value: big.NewInt(0),
})
require.NoError(t, err)
require.False(t, a.setCodeTxRequiresAuthorityAssociation(ctx, legacyTx))
}
21 changes: 19 additions & 2 deletions sei-cosmos/x/distribution/keeper/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,25 @@ func (h Hooks) AfterValidatorRemoved(ctx sdk.Context, _ sdk.ConsAddress, valAddr
accAddr := sdk.AccAddress(valAddr)
withdrawAddr := h.k.GetDelegatorWithdrawAddr(ctx, accAddr)

if err := h.k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, withdrawAddr, coins); err != nil {
panic(err)
// GetDelegatorWithdrawAddr falls back to the delegator (accAddr) when the
// configured withdraw address cannot receive funds, but that fallback can
// itself be unable to receive — e.g. accAddr is an EVM address whose Sei
// mapping was re-associated to a different address, so CanAddressReceive
// rejects it. This hook runs in EndBlock, so attempting the send and
// panicking on the resulting bank error would halt the chain. Check
// receivability first: when the recipient cannot receive, route the
// commission to the community pool instead. The coins already back the
// distribution module account (where community pool funds are held), so this
// conserves value and avoids the partial module-account debit that a failed
// SendCoins leaves behind.
if h.k.canReceiveWithdrawAddr(ctx, withdrawAddr) {
if err := h.k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, withdrawAddr, coins); err != nil {
panic(err)
}
} else {
feePool := h.k.GetFeePool(ctx)
feePool.CommunityPool = feePool.CommunityPool.Add(sdk.NewDecCoinsFromCoins(coins...)...)
h.k.SetFeePool(ctx, feePool)
}
}
}
Expand Down
55 changes: 55 additions & 0 deletions sei-cosmos/x/distribution/keeper/keeper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,61 @@ func TestAfterValidatorRemovedFallsBackForInvalidWithdrawAddress(t *testing.T) {
require.Equal(t, balanceBefore.Amount.Add(sdk.NewInt(10)), balanceAfter.Amount)
}

// TestAfterValidatorRemovedRoutesToCommunityPoolForUnreceivableValidator covers the
// case where the validator operator address itself cannot receive funds — its EVM
// address was re-associated (e.g. via associatePubKey) away from the direct-cast Sei
// address it was created under. The withdraw-address fallback in GetDelegatorWithdrawAddr
// resolves back to that same unreceivable operator address, so the commission
// force-withdraw fails. AfterValidatorRemoved runs during EndBlock, so it must not panic:
// the commission is routed to the community pool instead, which conserves value because
// the coins already back the distribution module account.
func TestAfterValidatorRemovedRoutesToCommunityPoolForUnreceivableValidator(t *testing.T) {
app := seiapp.Setup(t, false, false, false)
ctx := app.BaseApp.NewContext(false, tmproto.Header{})

// The validator operator is the direct-cast Sei address of an EVM address.
evmAddr := common.HexToAddress("0x3333333333333333333333333333333333333333")
castAddr := sdk.AccAddress(evmAddr[:])
valAddr := sdk.ValAddress(castAddr)
valAccAddr := sdk.AccAddress(valAddr) // == castAddr

require.True(t, app.BankKeeper.CanSendTo(ctx, castAddr))

// Re-associate the EVM address to a different true Sei address, mirroring
// associatePubKey after a validator was created under the direct-cast address.
associatedAddr := seiapp.AddTestAddrs(app, ctx, 1, sdk.NewInt(1000000000))[0]
app.EvmKeeper.SetAddressMapping(ctx, associatedAddr, evmAddr)

// The operator/delegator address can no longer receive funds, and the
// withdraw-address fallback resolves back to that same unreceivable address.
require.False(t, app.BankKeeper.CanSendTo(ctx, castAddr))
require.Equal(t, valAccAddr.String(), app.DistrKeeper.GetDelegatorWithdrawAddr(ctx, valAccAddr).String())

commission := sdk.DecCoins{sdk.NewDecCoin("usei", sdk.NewInt(10))}
coins := sdk.NewCoins(sdk.NewCoin("usei", sdk.NewInt(10)))
distrAcc := app.DistrKeeper.GetDistributionAccount(ctx)
require.NoError(t, apptesting.FundModuleAccount(app.BankKeeper, ctx, distrAcc.GetName(), coins))
app.AccountKeeper.SetModuleAccount(ctx, distrAcc)

app.DistrKeeper.SetValidatorOutstandingRewards(ctx, valAddr, types.ValidatorOutstandingRewards{Rewards: commission})
app.DistrKeeper.SetValidatorAccumulatedCommission(ctx, valAddr, types.ValidatorAccumulatedCommission{Commission: commission})

communityBefore := app.DistrKeeper.GetFeePool(ctx).CommunityPool.AmountOf("usei")
moduleBalanceBefore := app.BankKeeper.GetBalance(ctx, distrAcc.GetAddress(), "usei")

require.NotPanics(t, func() {
app.DistrKeeper.Hooks().AfterValidatorRemoved(ctx, sdk.ConsAddress{}, valAddr)
})

// The commission could not be paid out, so it stays in the distribution module
// account and is accounted to the community pool. No value leaves the module and
// the unreceivable operator address receives nothing.
communityAfter := app.DistrKeeper.GetFeePool(ctx).CommunityPool.AmountOf("usei")
require.Equal(t, communityBefore.Add(sdk.NewDec(10)), communityAfter)
require.True(t, app.BankKeeper.GetBalance(ctx, castAddr, "usei").IsZero())
require.Equal(t, moduleBalanceBefore, app.BankKeeper.GetBalance(ctx, distrAcc.GetAddress(), "usei"))
}

func TestWithdrawValidatorCommission(t *testing.T) {
app := seiapp.Setup(t, false, false, false)
ctx := app.BaseApp.NewContext(false, tmproto.Header{})
Expand Down
24 changes: 24 additions & 0 deletions utils/helpers/address.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
package helpers

import (
"bytes"
"errors"
"math/big"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/ethereum/go-ethereum/common"
ethtypes "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/rlp"
"github.com/sei-protocol/sei-chain/sei-cosmos/crypto/keys/secp256k1"
cryptotypes "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/types"
sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types"
)

// eip7702MagicPrefix is the domain-separator byte prepended to the RLP-encoded
// authorization tuple before hashing, per EIP-7702 (MAGIC = 0x05).
const eip7702MagicPrefix byte = 0x05

var (
big2 = big.NewInt(2)
big8 = big.NewInt(8)
Expand Down Expand Up @@ -106,3 +112,21 @@ func RecoverAddressesFromTx(ethTx *ethtypes.Transaction, signer ethtypes.Signer,
adjustedV := AdjustV(V, ethTx.Type(), chainID)
return GetAddresses(adjustedV, R, S, txHash)
}

// RecoverAddressesFromAuthorization recovers the EVM address, Sei address, and public
// key of the account that signed an EIP-7702 SetCode authorization (the "authority").
// The authorization sig hash is keccak256(0x05 || rlp([chainId, address, nonce])) and
// the recovery id is carried directly in auth.V (yParity, 0 or 1), which GetAddresses
// expects bumped by 27. This mirrors go-ethereum's SetCodeAuthorization.Authority(), but
// additionally returns the recovered public key so the authority can be associated with
// its true Sei address.
func RecoverAddressesFromAuthorization(auth ethtypes.SetCodeAuthorization) (common.Address, sdk.AccAddress, cryptotypes.PubKey, error) {
var buf bytes.Buffer
buf.WriteByte(eip7702MagicPrefix)
if err := rlp.Encode(&buf, []any{auth.ChainID, auth.Address, auth.Nonce}); err != nil {
return common.Address{}, sdk.AccAddress{}, nil, err
}
sigHash := crypto.Keccak256Hash(buf.Bytes())
v := new(big.Int).SetUint64(uint64(auth.V) + 27)
return GetAddresses(v, auth.R.ToBig(), auth.S.ToBig(), sigHash)
}
65 changes: 65 additions & 0 deletions utils/helpers/address_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,76 @@ import (
"github.com/ethereum/go-ethereum/common"
ethtypes "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/holiman/uint256"
"github.com/sei-protocol/sei-chain/sei-cosmos/crypto/keys/secp256k1"
sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types"
"github.com/stretchr/testify/require"
)

// TestRecoverAddressesFromAuthorization verifies that our EIP-7702 authorization sig-hash
// replication recovers the same account go-ethereum's own SignSetCode/Authority() does.
// If the sig hash drifts from go-ethereum, RecoverAddressesFromAuthorization would recover
// the wrong (or no) address and the ante-handler pre-association would silently no-op, so
// this test pins the encoding across several chain IDs, nonces and delegation targets.
func TestRecoverAddressesFromAuthorization(t *testing.T) {
cases := []struct {
name string
chainID *uint256.Int
address common.Address
nonce uint64
}{
{"typical", uint256.NewInt(1329), common.HexToAddress("0x00000000000000000000000000000000000000aa"), 7},
{"zero chain id (valid for any chain)", uint256.NewInt(0), common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), 0},
{"large nonce", uint256.NewInt(11155111), common.Address{}, 1<<63 + 123},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
privKey, err := crypto.GenerateKey()
require.NoError(t, err)
expectedEVM := crypto.PubkeyToAddress(privKey.PublicKey)

signed, err := ethtypes.SignSetCode(privKey, ethtypes.SetCodeAuthorization{
ChainID: *tc.chainID,
Address: tc.address,
Nonce: tc.nonce,
})
require.NoError(t, err)

// go-ethereum's authoritative recovery (used during EVM execution).
authority, err := signed.Authority()
require.NoError(t, err)
require.Equal(t, expectedEVM, authority)

evmAddr, seiAddr, pubkey, err := RecoverAddressesFromAuthorization(signed)
require.NoError(t, err)
require.Equal(t, expectedEVM, evmAddr)
require.Equal(t, authority, evmAddr)

// The Sei address and pubkey must match those derived directly from the key.
expEVM, expSei, expPub, err := GetAddressesFromPubkeyBytes(crypto.FromECDSAPub(&privKey.PublicKey))
require.NoError(t, err)
require.Equal(t, expEVM, evmAddr)
require.Equal(t, expSei, seiAddr)
require.Equal(t, expPub, pubkey)
})
}
}

// TestRecoverAddressesFromAuthorizationInvalidSig ensures a malformed authorization
// signature yields an error rather than a bogus address (the ante handler skips these).
func TestRecoverAddressesFromAuthorizationInvalidSig(t *testing.T) {
_, _, _, err := RecoverAddressesFromAuthorization(ethtypes.SetCodeAuthorization{
ChainID: *uint256.NewInt(1329),
Address: common.HexToAddress("0x00000000000000000000000000000000000000aa"),
Nonce: 1,
V: 0,
R: *uint256.NewInt(0),
S: *uint256.NewInt(0),
})
require.Error(t, err)
}

func TestPubkeyToEVMAddress(t *testing.T) {
tests := []struct {
name string
Expand Down
47 changes: 47 additions & 0 deletions x/evm/ante/preprocess.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,56 @@ func (p *EVMPreprocessDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate
}
}

// EIP-7702 authorization authorities are distinct accounts from the tx sender, so the
// sender association above does not cover them. Associate each authority to its true
// (pubkey-derived) Sei address before EVM execution installs delegation code for it.
// Otherwise SetCode creates a mutable direct-cast EVM->Sei mapping that a later
// associatePubKey call can remap, orphaning any staking/distribution state created
// under the direct-cast identity (which can then halt the chain via the distribution
// validator-removal hook).
p.associateAuthorizationAuthorities(ctx, msg, associateHelper)

return next(ctx, tx, simulate)
}

// associateAuthorizationAuthorities associates every EIP-7702 authorization authority in
// the transaction with its true (pubkey-derived) Sei address, so that a subsequent
// SetCode for the authority does not create a mutable direct-cast mapping. It is
// best-effort: authorities with invalid signatures (which EVM execution would also skip)
// or that are already associated are left untouched, and an association failure skips
// only that authority rather than rejecting the transaction, which go-ethereum would
// still accept.
func (p *EVMPreprocessDecorator) associateAuthorizationAuthorities(ctx sdk.Context, msg *evmtypes.MsgEVMTransaction, associateHelper *helpers.AssociationHelper) {
txData, err := evmtypes.UnpackTxData(msg.Data)
if err != nil {
return
}
setCodeTx, ok := txData.(*ethtx.SetCodeTx)
if !ok {
// Only SetCode (EIP-7702) transactions carry authorizations.
return
}
ethTx := ethtypes.NewTx(setCodeTx.AsEthereumData())
for _, auth := range ethTx.SetCodeAuthorizations() {
evmAddr, seiAddr, pubkey, err := helpers.RecoverAddressesFromAuthorization(auth)
if err != nil {
continue
}
// Cross-check against go-ethereum's authoritative recovery so we only ever
// associate the exact address that SetCode will target during execution.
if authAddr, aerr := auth.Authority(); aerr != nil || authAddr != evmAddr {
continue
}
if _, associated := p.evmKeeper.GetEVMAddress(ctx, seiAddr); associated {
continue
}
cacheCtx, write := ctx.CacheContext()
if err := associateHelper.AssociateAddresses(cacheCtx, seiAddr, evmAddr, pubkey, false); err == nil {
write()
}
}
}

func (p *EVMPreprocessDecorator) IsAccountBalancePositive(ctx sdk.Context, seiAddr sdk.AccAddress, evmAddr common.Address) bool {
baseDenom := p.evmKeeper.GetBaseDenom(ctx)
if amt := p.evmKeeper.BankKeeper().GetBalance(ctx, seiAddr, baseDenom).Amount; amt.IsPositive() {
Expand Down
Loading
Loading