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 diff --git a/wasmbinding/burn_tokens.go b/wasmbinding/burn_tokens.go new file mode 100644 index 0000000000..d4536c6b50 --- /dev/null +++ b/wasmbinding/burn_tokens.go @@ -0,0 +1,48 @@ +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 + // 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()+"/") { + 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..ee9d306124 --- /dev/null +++ b/wasmbinding/test/burn_tokens_test.go @@ -0,0 +1,112 @@ +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}, + {"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) { + _, _, 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)) +}