From ce7cfe220c92dadba26706ac2a5f3e4b898eb69b Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Wed, 14 Oct 2026 17:40:33 +0200 Subject: [PATCH 1/3] Keep contract burn_tokens working after the tokenfactory removal Contracts that issued factory denoms redeem by sending the retired tokenfactory burn_tokens custom message: the Silo and Eris liquid staking hubs burn iSEI and ampSEI when they submit an unbonding batch, and the Wormhole token translator burns when bridging out. Without the module that message ends the whole execution with "Unknown Sei Wasm Message". LegacyBurnTokensHandler serves it ahead of the SDK message handler by burning from the calling contract's own balance through the Wasm module account, the same operation BankMsg::Burn already performs, and only for denoms of the form factory//. Creating, minting, admin changes and metadata stay removed, and direct MsgBurn transactions are still refused. Co-authored-by: Cursor --- wasmbinding/burn_tokens.go | 47 ++++++++++++ wasmbinding/message_plugin.go | 3 + wasmbinding/test/burn_tokens_test.go | 111 +++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 wasmbinding/burn_tokens.go create mode 100644 wasmbinding/test/burn_tokens_test.go diff --git a/wasmbinding/burn_tokens.go b/wasmbinding/burn_tokens.go new file mode 100644 index 0000000000..489ecf7c84 --- /dev/null +++ b/wasmbinding/burn_tokens.go @@ -0,0 +1,47 @@ +package wasmbinding + +import ( + "encoding/json" + "strings" + + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" + wasmkeeper "github.com/sei-protocol/sei-chain/sei-wasmd/x/wasm/keeper" + wasmtypes "github.com/sei-protocol/sei-chain/sei-wasmd/x/wasm/types" + wasmvmtypes "github.com/sei-protocol/sei-chain/sei-wasmvm/types" +) + +// legacyBurnTokensMsg is the burn_tokens custom message of the retired tokenfactory module. +type legacyBurnTokensMsg struct { + BurnTokens *struct { + Amount sdk.Coin `json:"amount"` + } `json:"burn_tokens"` +} + +// LegacyBurnTokensHandler burns coins from the calling contract's own balance for the +// retired tokenfactory burn_tokens custom message. It accepts only denoms the contract +// created, of the form factory//, and leaves every other message to +// the next handler. +func LegacyBurnTokensHandler(burner wasmtypes.Burner) wasmkeeper.MessageHandlerFunc { + return func(ctx sdk.Context, contractAddr sdk.AccAddress, _ string, msg wasmvmtypes.CosmosMsg, _ wasmvmtypes.MessageInfo, _ wasmtypes.CodeInfo) ([]sdk.Event, [][]byte, error) { + var parsed legacyBurnTokensMsg + if msg.Custom == nil || json.Unmarshal(msg.Custom, &parsed) != nil || parsed.BurnTokens == nil { + return nil, nil, wasmtypes.ErrUnknownMsg + } + amount := parsed.BurnTokens.Amount + if err := amount.Validate(); err != nil || !amount.IsPositive() { + return nil, nil, sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "burn_tokens amount %s", amount) + } + if !strings.HasPrefix(amount.Denom, "factory/"+contractAddr.String()+"/") { + return nil, nil, sdkerrors.Wrapf(sdkerrors.ErrUnauthorized, "%s was not created by %s", amount.Denom, contractAddr) + } + coins := sdk.NewCoins(amount) + if err := burner.SendCoinsFromAccountToModule(ctx, contractAddr, wasmtypes.ModuleName, coins); err != nil { + return nil, nil, sdkerrors.Wrap(err, "transfer to module") + } + if err := burner.BurnCoins(ctx, wasmtypes.ModuleName, coins); err != nil { + return nil, nil, sdkerrors.Wrap(err, "burn coins") + } + return nil, nil, nil + } +} diff --git a/wasmbinding/message_plugin.go b/wasmbinding/message_plugin.go index 5fe7aa2dd1..1c0c6afde1 100644 --- a/wasmbinding/message_plugin.go +++ b/wasmbinding/message_plugin.go @@ -44,6 +44,9 @@ func CustomMessageHandler( Custom: CustomEncoder, }) return wasmkeeper.NewMessageHandlerChain( + // CustomEncoder refuses burn_tokens with an error that ends the chain, so the + // legacy burn must run before the SDK message handler. + LegacyBurnTokensHandler(bankKeeper), wasmkeeper.NewSDKMessageHandler(&CustomRouter{MessageRouter: router, evmKeeper: evmKeeper}, encoders), wasmkeeper.NewBurnCoinMessageHandler(bankKeeper), ) diff --git a/wasmbinding/test/burn_tokens_test.go b/wasmbinding/test/burn_tokens_test.go new file mode 100644 index 0000000000..327c7ff935 --- /dev/null +++ b/wasmbinding/test/burn_tokens_test.go @@ -0,0 +1,111 @@ +package wasmbinding + +import ( + "fmt" + "testing" + + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" + wasmtypes "github.com/sei-protocol/sei-chain/sei-wasmd/x/wasm/types" + wasmvmtypes "github.com/sei-protocol/sei-chain/sei-wasmvm/types" + "github.com/sei-protocol/sei-chain/wasmbinding" + minttypes "github.com/sei-protocol/sei-chain/x/mint/types" + "github.com/stretchr/testify/require" +) + +var legacyBurnContract = sdk.AccAddress("legacy-burn-contract") + +// legacyBurnTokens returns the burn_tokens custom message a contract built on +// sei-cosmwasm sends for amount of denom. +func legacyBurnTokens(denom, amount string) wasmvmtypes.CosmosMsg { + return wasmvmtypes.CosmosMsg{Custom: []byte(fmt.Sprintf(`{"burn_tokens":{"amount":{"denom":%q,"amount":%q}}}`, denom, amount))} +} + +func TestLegacyBurnTokensBurnsTheContractsOwnBalance(t *testing.T) { + testWrapper, _ := SetupWasmbindingTest(t) + ctx, bank := testWrapper.Ctx, testWrapper.App.BankKeeper + denom := "factory/" + legacyBurnContract.String() + "/stake" + mintLegacyBurnBalance(t, bank, ctx, sdk.NewInt64Coin(denom, 100)) + + _, _, err := wasmbinding.LegacyBurnTokensHandler(bank). + DispatchMsg(ctx, legacyBurnContract, "", legacyBurnTokens(denom, "40"), wasmvmtypes.MessageInfo{}, wasmtypes.CodeInfo{}) + require.NoError(t, err) + require.Equal(t, sdk.NewInt64Coin(denom, 60), bank.GetBalance(ctx, legacyBurnContract, denom)) + require.Equal(t, sdk.NewInt64Coin(denom, 60), bank.GetSupply(ctx, denom)) +} + +func TestLegacyBurnTokensRefusesWhatItCannotBurn(t *testing.T) { + testWrapper, _ := SetupWasmbindingTest(t) + ctx, bank := testWrapper.Ctx, testWrapper.App.BankKeeper + own := "factory/" + legacyBurnContract.String() + "/stake" + foreign := "factory/" + sdk.AccAddress("another-denom-creatr").String() + "/stake" + mintLegacyBurnBalance(t, bank, ctx, sdk.NewInt64Coin(own, 100)) + mintLegacyBurnBalance(t, bank, ctx, sdk.NewInt64Coin(foreign, 100)) + mintLegacyBurnBalance(t, bank, ctx, sdk.NewInt64Coin("usei", 100)) + + for _, tc := range []struct { + name string + msg wasmvmtypes.CosmosMsg + error *sdkerrors.Error + }{ + {"another creator's denom", legacyBurnTokens(foreign, "1"), sdkerrors.ErrUnauthorized}, + {"a native denom", legacyBurnTokens("usei", "1"), sdkerrors.ErrUnauthorized}, + {"a zero amount", legacyBurnTokens(own, "0"), sdkerrors.ErrInvalidCoins}, + {"more than the balance", legacyBurnTokens(own, "101"), sdkerrors.ErrInsufficientFunds}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _, err := wasmbinding.LegacyBurnTokensHandler(bank). + DispatchMsg(ctx, legacyBurnContract, "", tc.msg, wasmvmtypes.MessageInfo{}, wasmtypes.CodeInfo{}) + require.ErrorIs(t, err, tc.error) + }) + } + for _, denom := range []string{own, foreign, "usei"} { + require.Equal(t, sdk.NewInt64Coin(denom, 100), bank.GetBalance(ctx, legacyBurnContract, denom), denom) + } +} + +func TestLegacyBurnTokensLeavesOtherMessagesToTheNextHandler(t *testing.T) { + testWrapper, _ := SetupWasmbindingTest(t) + handler := wasmbinding.LegacyBurnTokensHandler(testWrapper.App.BankKeeper) + for _, msg := range []wasmvmtypes.CosmosMsg{ + {}, + {Bank: &wasmvmtypes.BankMsg{Burn: &wasmvmtypes.BurnMsg{Amount: wasmvmtypes.Coins{{Denom: "usei", Amount: "1"}}}}}, + {Custom: []byte(`{"mint_tokens":{"amount":{"denom":"factory/creator/subdenom","amount":"1"}}}`)}, + {Custom: []byte(`{"call_evm":{"to":"0x0000000000000000000000000000000000000001","data":""}}`)}, + {Custom: []byte(`not json`)}, + } { + _, _, err := handler.DispatchMsg(testWrapper.Ctx, legacyBurnContract, "", msg, wasmvmtypes.MessageInfo{}, wasmtypes.CodeInfo{}) + require.ErrorIs(t, err, wasmtypes.ErrUnknownMsg, string(msg.Custom)) + } +} + +// TestCustomMessageHandlerServesLegacyBurnTokens requires the Sei Wasm message +// handler to burn for burn_tokens while still refusing the other retired +// tokenfactory messages. +func TestCustomMessageHandlerServesLegacyBurnTokens(t *testing.T) { + testWrapper, _ := SetupWasmbindingTest(t) + ctx, bank := testWrapper.Ctx, testWrapper.App.BankKeeper + denom := "factory/" + legacyBurnContract.String() + "/stake" + mintLegacyBurnBalance(t, bank, ctx, sdk.NewInt64Coin(denom, 100)) + messenger := wasmbinding.CustomMessageHandler( + testWrapper.App.MsgServiceRouter(), bank, &testWrapper.App.EvmKeeper, testWrapper.App.AppCodec()) + + _, _, err := messenger.DispatchMsg(ctx, legacyBurnContract, "", legacyBurnTokens(denom, "25"), wasmvmtypes.MessageInfo{}, wasmtypes.CodeInfo{}) + require.NoError(t, err) + require.Equal(t, sdk.NewInt64Coin(denom, 75), bank.GetSupply(ctx, denom)) + + mint := wasmvmtypes.CosmosMsg{Custom: []byte(fmt.Sprintf(`{"mint_tokens":{"amount":{"denom":%q,"amount":"1"}}}`, denom))} + _, _, err = messenger.DispatchMsg(ctx, legacyBurnContract, "", mint, wasmvmtypes.MessageInfo{}, wasmtypes.CodeInfo{}) + require.Equal(t, wasmvmtypes.UnsupportedRequest{Kind: "Unknown Sei Wasm Message"}, err) + require.Equal(t, sdk.NewInt64Coin(denom, 75), bank.GetSupply(ctx, denom)) +} + +func mintLegacyBurnBalance(t *testing.T, bank interface { + MintCoins(sdk.Context, string, sdk.Coins) error + SendCoinsFromModuleToAccount(sdk.Context, string, sdk.AccAddress, sdk.Coins) error +}, ctx sdk.Context, coin sdk.Coin) { + t.Helper() + coins := sdk.NewCoins(coin) + require.NoError(t, bank.MintCoins(ctx, minttypes.ModuleName, coins)) + require.NoError(t, bank.SendCoinsFromModuleToAccount(ctx, minttypes.ModuleName, legacyBurnContract, coins)) +} From 66e64360f6deda39c7344c3bcfabb5e6367a1ad2 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 15 Oct 2026 16:48:13 +0200 Subject: [PATCH 2/3] Add the changelog entry for #4549 Co-authored-by: Cursor --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e93e58046e..9284b4f066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ ## Unreleased sei-chain +* [#4549](https://github.com/sei-protocol/sei-chain/pull/4549) Keep the tokenfactory `burn_tokens` Wasm message working after v6.8: a contract can still burn a `factory/` denom it created from its own balance, so the Silo and Eris liquid staking hubs and the Wormhole token translator keep their redemption paths. Minting and every other tokenfactory message stay removed. * [#4536](https://github.com/sei-protocol/sei-chain/pull/4536) Remove the tokenfactory module behind the v6.8 upgrade: module, store, params, protobuf schema, CLI, wasm bindings, the bank `factory/` denom transfer allowlist and tooling are gone; only the tokenfactory Msg types remain decodable so historical blocks still trace. Existing `factory/` balances stay in bank but can no longer be minted or burned, and denoms that had a transfer allowlist become freely transferable. The upgrade deletes at most 1,000 allowlists from the bank store and leaves any beyond that in place, unenforced. App-hash breaking at the upgrade height. * [#4319](https://github.com/sei-protocol/sei-chain/pull/4319) Remove the oracle module behind the v6.8 upgrade: module, store, protobuf schema, wasm query route and tooling are gone; only the oracle Msg types remain decodable so historical blocks still trace. App-hash breaking at the upgrade height. * [#4332](https://github.com/sei-protocol/sei-chain/pull/4332) Bump sei-protocol/go-ethereum to v1.15.7-sei-21 From c835f7c3eed5631631b1a1bba5aa9a8454410f25 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 15 Oct 2026 17:03:40 +0200 Subject: [PATCH 3/3] Refuse a burn_tokens message that leaves out the amount Coin.Validate dereferences the amount, so a burn_tokens message whose amount has a denom but no amount panicked instead of returning ErrInvalidCoins, and Wasm dispatch re-raises that panic past any reply_on_error submessage. Co-authored-by: Cursor --- wasmbinding/burn_tokens.go | 3 ++- wasmbinding/test/burn_tokens_test.go | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/wasmbinding/burn_tokens.go b/wasmbinding/burn_tokens.go index 489ecf7c84..d4536c6b50 100644 --- a/wasmbinding/burn_tokens.go +++ b/wasmbinding/burn_tokens.go @@ -29,7 +29,8 @@ func LegacyBurnTokensHandler(burner wasmtypes.Burner) wasmkeeper.MessageHandlerF return nil, nil, wasmtypes.ErrUnknownMsg } amount := parsed.BurnTokens.Amount - if err := amount.Validate(); err != nil || !amount.IsPositive() { + // Validate dereferences the amount, which is nil when the message leaves it out. + if amount.Amount.IsNil() || amount.Validate() != nil || !amount.IsPositive() { return nil, nil, sdkerrors.Wrapf(sdkerrors.ErrInvalidCoins, "burn_tokens amount %s", amount) } if !strings.HasPrefix(amount.Denom, "factory/"+contractAddr.String()+"/") { diff --git a/wasmbinding/test/burn_tokens_test.go b/wasmbinding/test/burn_tokens_test.go index 327c7ff935..ee9d306124 100644 --- a/wasmbinding/test/burn_tokens_test.go +++ b/wasmbinding/test/burn_tokens_test.go @@ -51,6 +51,7 @@ func TestLegacyBurnTokensRefusesWhatItCannotBurn(t *testing.T) { {"another creator's denom", legacyBurnTokens(foreign, "1"), sdkerrors.ErrUnauthorized}, {"a native denom", legacyBurnTokens("usei", "1"), sdkerrors.ErrUnauthorized}, {"a zero amount", legacyBurnTokens(own, "0"), sdkerrors.ErrInvalidCoins}, + {"a missing amount", wasmvmtypes.CosmosMsg{Custom: []byte(fmt.Sprintf(`{"burn_tokens":{"amount":{"denom":%q}}}`, own))}, sdkerrors.ErrInvalidCoins}, {"more than the balance", legacyBurnTokens(own, "101"), sdkerrors.ErrInsufficientFunds}, } { t.Run(tc.name, func(t *testing.T) {