diff --git a/Dockerfile b/Dockerfile index 7310fbe9..8aaaa4e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ ARG TARGETARCH ARG GOARCH # See https://github.com/initia-labs/movevm/releases -ENV LIBMOVEVM_VERSION=v0.5.0 +ENV LIBMOVEVM_VERSION=v0.5.1 # Install necessary packages RUN set -eux; apk add --no-cache ca-certificates build-base git cmake diff --git a/go.mod b/go.mod index 489ccf28..96b3321b 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/hashicorp/go-metrics v0.5.3 github.com/initia-labs/OPinit v0.5.5 // we also need to update `LIBMOVEVM_VERSION` of Dockerfile#9 - github.com/initia-labs/movevm v0.5.0 + github.com/initia-labs/movevm v0.5.1 github.com/noble-assets/forwarding/v2 v2.0.0-20240521090705-86712c4c9e43 github.com/pelletier/go-toml v1.9.5 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index c6f5cbb9..4e489523 100644 --- a/go.sum +++ b/go.sum @@ -732,8 +732,8 @@ github.com/initia-labs/OPinit/api v0.5.1 h1:zwyJf7HtKJCKvLJ1R9PjVfJO1L+d/jKoeFyT github.com/initia-labs/OPinit/api v0.5.1/go.mod h1:gHK6DEWb3/DqQD5LjKirUx9jilAh2UioXanoQdgqVfU= github.com/initia-labs/cometbft v0.0.0-20240923045653-ba99eb347236 h1:+HmPQ1uptOe4r5oQHuHMG5zF1F3maNoEba5uiTUMnlk= github.com/initia-labs/cometbft v0.0.0-20240923045653-ba99eb347236/go.mod h1:GPHp3/pehPqgX1930HmK1BpBLZPxB75v/dZg8Viwy+o= -github.com/initia-labs/movevm v0.5.0 h1:dBSxoVyUumSE4x6/ZSOWtvbtZpw+V4W25/NH6qLU0uQ= -github.com/initia-labs/movevm v0.5.0/go.mod h1:aUWdvFZPdULjJ2McQTE+mLnfnG3CLAz0TWJRFzFFUwg= +github.com/initia-labs/movevm v0.5.1 h1:Nl5SizJIfRLM6clz/zV8dOFUXcnlMP2wOQsZB/mmU2w= +github.com/initia-labs/movevm v0.5.1/go.mod h1:aUWdvFZPdULjJ2McQTE+mLnfnG3CLAz0TWJRFzFFUwg= github.com/jhump/protoreflect v1.15.3 h1:6SFRuqU45u9hIZPJAoZ8c28T3nK64BNdp9w6jFonzls= github.com/jhump/protoreflect v1.15.3/go.mod h1:4ORHmSBmlCW8fh3xHmJMGyul1zNqZK4Elxc8qKP+p1k= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= diff --git a/x/bank/keeper/msg_server.go b/x/bank/keeper/msg_server.go index 092bf905..aef97fc0 100644 --- a/x/bank/keeper/msg_server.go +++ b/x/bank/keeper/msg_server.go @@ -4,8 +4,6 @@ import ( "context" "github.com/hashicorp/go-metrics" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" errorsmod "cosmossdk.io/errors" "github.com/cosmos/cosmos-sdk/telemetry" @@ -85,7 +83,52 @@ func (k msgServer) Send(goCtx context.Context, msg *types.MsgSend) (*types.MsgSe } func (k msgServer) MultiSend(goCtx context.Context, msg *types.MsgMultiSend) (*types.MsgMultiSendResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "not supported") + if len(msg.Inputs) == 0 { + return nil, types.ErrNoInputs + } + + if len(msg.Inputs) != 1 { + return nil, types.ErrMultipleSenders + } + + if len(msg.Outputs) == 0 { + return nil, types.ErrNoOutputs + } + + in := msg.Inputs[0] + if err := types.ValidateInputOutputs(in, msg.Outputs); err != nil { + return nil, err + } + + ctx := sdk.UnwrapSDKContext(goCtx) + + // NOTE: totalIn == totalOut should already have been checked + if err := k.IsSendEnabledCoins(ctx, in.Coins...); err != nil { + return nil, err + } + + if base, ok := k.Keeper.(BaseKeeper); ok { + for _, out := range msg.Outputs { + accAddr, err := base.ak.AddressCodec().StringToBytes(out.Address) + if err != nil { + return nil, err + } + + if k.BlockedAddr(accAddr) { + return nil, errorsmod.Wrapf(sdkerrors.ErrUnauthorized, "%s is not allowed to receive funds", out.Address) + } + + } + } else { + return nil, sdkerrors.ErrInvalidRequest.Wrapf("invalid keeper type: %T", k.Keeper) + } + + err := k.InputOutputCoins(ctx, msg.Inputs[0], msg.Outputs) + if err != nil { + return nil, err + } + + return &types.MsgMultiSendResponse{}, nil } func (k msgServer) UpdateParams(goCtx context.Context, req *types.MsgUpdateParams) (*types.MsgUpdateParamsResponse, error) { diff --git a/x/bank/keeper/msg_server_test.go b/x/bank/keeper/msg_server_test.go index 2e2a46e9..b22766c0 100644 --- a/x/bank/keeper/msg_server_test.go +++ b/x/bank/keeper/msg_server_test.go @@ -170,6 +170,120 @@ func TestMsgSend(t *testing.T) { } } +func TestMsgMultiSend(t *testing.T) { + ctx, input := createDefaultTestInput(t) + + origDenom := "sendableCoin" + origCoins := sdk.NewCoins(sdk.NewInt64Coin(origDenom, 100)) + sendCoins := sdk.NewCoins(sdk.NewInt64Coin(origDenom, 50)) + input.BankKeeper.SetSendEnabled(ctx, origDenom, true) + + testCases := []struct { + name string + input *banktypes.MsgMultiSend + expErr bool + expErrMsg string + }{ + { + name: "no inputs to send transaction", + input: &banktypes.MsgMultiSend{}, + expErr: true, + expErrMsg: "no inputs to send transaction", + }, + { + name: "no inputs to send transaction", + input: &banktypes.MsgMultiSend{ + Outputs: []banktypes.Output{ + {Address: addrs[4].String(), Coins: sendCoins}, + }, + }, + expErr: true, + expErrMsg: "no inputs to send transaction", + }, + { + name: "more than one inputs to send transaction", + input: &banktypes.MsgMultiSend{ + Inputs: []banktypes.Input{ + {Address: addrs[0].String(), Coins: origCoins}, + {Address: addrs[0].String(), Coins: origCoins}, + }, + }, + expErr: true, + expErrMsg: "multiple senders not allowed", + }, + { + name: "no outputs to send transaction", + input: &banktypes.MsgMultiSend{ + Inputs: []banktypes.Input{ + {Address: addrs[0].String(), Coins: origCoins}, + }, + }, + expErr: true, + expErrMsg: "no outputs to send transaction", + }, + { + name: "invalid send to blocked address", + input: &banktypes.MsgMultiSend{ + Inputs: []banktypes.Input{ + {Address: addrs[0].String(), Coins: origCoins}, + }, + Outputs: []banktypes.Output{ + {Address: addrs[1].String(), Coins: sendCoins}, + {Address: authtypes.NewModuleAddress(govtypes.ModuleName).String(), Coins: sendCoins}, + }, + }, + expErr: true, + expErrMsg: "is not allowed to receive funds", + }, + { + name: "input/output amount mismatch", + input: &banktypes.MsgMultiSend{ + Inputs: []banktypes.Input{ + {Address: addrs[0].String(), Coins: origCoins}, + }, + Outputs: []banktypes.Output{ + {Address: addrs[1].String(), Coins: origCoins}, + {Address: addrs[2].String(), Coins: sendCoins}, + }, + }, + expErr: true, + expErrMsg: "sum inputs != sum outputs", + }, + { + name: "valid send", + input: &banktypes.MsgMultiSend{ + Inputs: []banktypes.Input{ + {Address: addrs[0].String(), Coins: origCoins}, + }, + Outputs: []banktypes.Output{ + {Address: addrs[1].String(), Coins: sendCoins}, + {Address: addrs[2].String(), Coins: sendCoins}, + }, + }, + expErr: false, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if len(tc.input.Inputs) > 0 && !tc.input.Inputs[0].Coins.IsZero() && tc.input.Inputs[0].Address != "" { + fromAddr, err := input.AccountKeeper.AddressCodec().StringToBytes(tc.input.Inputs[0].Address) + require.NoError(t, err) + input.Faucet.Fund(ctx, fromAddr, tc.input.Inputs[0].Coins...) + } + + _, err := bankkeeper.NewMsgServerImpl(input.BankKeeper).MultiSend(ctx, tc.input) + if tc.expErr { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} + func TestMsgSetSendEnabled(t *testing.T) { ctx, input := createDefaultTestInput(t) diff --git a/x/bank/keeper/send.go b/x/bank/keeper/send.go index 351291cf..001c99aa 100644 --- a/x/bank/keeper/send.go +++ b/x/bank/keeper/send.go @@ -5,11 +5,11 @@ import ( "fmt" "cosmossdk.io/core/store" + "cosmossdk.io/math" "github.com/cosmos/cosmos-sdk/codec" "github.com/cosmos/cosmos-sdk/telemetry" sdk "github.com/cosmos/cosmos-sdk/types" - sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" cosmosbank "github.com/cosmos/cosmos-sdk/x/bank/keeper" "github.com/cosmos/cosmos-sdk/x/bank/types" @@ -104,11 +104,91 @@ func (k MoveSendKeeper) SetParams(ctx context.Context, params types.Params) erro return k.Params.Set(ctx, params) } -// InputOutputCoins performs multi-send functionality. It accepts a series of -// inputs that correspond to a series of outputs. It returns an error if the -// inputs and outputs don't lineup or if any single transfer of tokens fails. -func (k MoveSendKeeper) InputOutputCoins(ctx context.Context, inputs types.Input, outputs []types.Output) error { - return sdkerrors.ErrNotSupported +// InputOutputCoins performs multi-send functionality. It transfers coins from a single sender +// to multiple recipients. An error is returned upon failure. +func (k MoveSendKeeper) InputOutputCoins(ctx context.Context, input types.Input, outputs []types.Output) error { + // Safety check ensuring that when sending coins the keeper must maintain the + // Check supply invariant and validity of Coins. + if err := types.ValidateInputOutputs(input, outputs); err != nil { + return err + } + + fromAddr, err := k.ak.AddressCodec().StringToBytes(input.Address) + if err != nil { + return err + } + + // event emission + sdkCtx := sdk.UnwrapSDKContext(ctx) + sdkCtx.EventManager().EmitEvent( + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(types.AttributeKeySender, input.Address), + ), + ) + + // emit coin spent event + sdkCtx.EventManager().EmitEvent( + types.NewCoinSpentEvent(fromAddr, input.Coins), + ) + + // emit coin received events and do address caching + addrMap := make(map[string][]byte) + for _, output := range outputs { + addr, err := k.ak.AddressCodec().StringToBytes(output.Address) + if err != nil { + return err + } + + // cache bytes address + addrMap[output.Address] = addr + + // emit coin received event + sdkCtx.EventManager().EmitEvent( + types.NewCoinReceivedEvent(addr, output.Coins), + ) + + // emit transfer event (for compatibility with cosmos bank) + sdkCtx.EventManager().EmitEvent( + sdk.NewEvent( + types.EventTypeTransfer, + sdk.NewAttribute(types.AttributeKeyRecipient, output.Address), + sdk.NewAttribute(sdk.AttributeKeyAmount, output.Coins.String()), + ), + ) + } + + for _, coin := range input.Coins { + if !coin.Amount.IsPositive() { + continue + } + + recipients := make([]sdk.AccAddress, 0, len(outputs)) + amounts := make([]math.Int, 0, len(outputs)) + for _, output := range outputs { + // Create account if recipient does not exist. + outAddress := addrMap[output.Address] + accExists := k.ak.HasAccount(ctx, outAddress) + if !accExists { + defer telemetry.IncrCounter(1, "new", "account") + k.ak.SetAccount(ctx, k.ak.NewAccountWithAddress(ctx, outAddress)) + } + + amount := output.Coins.AmountOf(coin.Denom) + if !amount.IsPositive() { + continue + } + + recipients = append(recipients, outAddress) + amounts = append(amounts, output.Coins.AmountOf(coin.Denom)) + } + + if err = k.mk.MultiSend(ctx, fromAddr, coin.Denom, recipients, amounts); err != nil { + return err + } + } + + return nil } // SendCoins transfers amt coins from a sending account to a receiving account. diff --git a/x/bank/types/expected_keeper.go b/x/bank/types/expected_keeper.go index 44d6a09f..0b143408 100644 --- a/x/bank/types/expected_keeper.go +++ b/x/bank/types/expected_keeper.go @@ -22,6 +22,7 @@ type MoveBankKeeper interface { SendCoins(ctx context.Context, fromAddr sdk.AccAddress, toAddr sdk.AccAddress, amt sdk.Coins) error MintCoins(ctx context.Context, addr sdk.AccAddress, amount sdk.Coins) error BurnCoins(ctx context.Context, addr sdk.AccAddress, amount sdk.Coins) error + MultiSend(ctx context.Context, fromAddr sdk.AccAddress, denom string, toAddrs []sdk.AccAddress, amounts []math.Int) error // supply GetSupply(ctx context.Context, denom string) (math.Int, error) diff --git a/x/move/keeper/bank.go b/x/move/keeper/bank.go index 5c3d2ab3..4abb5717 100644 --- a/x/move/keeper/bank.go +++ b/x/move/keeper/bank.go @@ -2,6 +2,7 @@ package keeper import ( "context" + "encoding/json" "errors" "fmt" @@ -531,3 +532,61 @@ func (k MoveBankKeeper) SendCoin( false, ) } + +func (k MoveBankKeeper) MultiSend( + ctx context.Context, + sender sdk.AccAddress, + denom string, + recipients []sdk.AccAddress, + amounts []math.Int, +) error { + if len(recipients) != len(amounts) { + return moderrors.Wrapf(types.ErrInvalidRequest, "recipients and amounts length mismatch") + } else if len(recipients) == 0 { + return moderrors.Wrapf(types.ErrInvalidRequest, "recipients and amounts length should be greater than 0") + } + + senderVMAddr, err := vmtypes.NewAccountAddressFromBytes(sender) + if err != nil { + return err + } + + metadata, err := types.MetadataAddressFromDenom(denom) + if err != nil { + return err + } + metadataArg, err := json.Marshal(metadata.String()) + if err != nil { + return err + } + + recipientAddrs := make([]string, len(recipients)) + for i, toAddr := range recipients { + toVmAddr, err := vmtypes.NewAccountAddressFromBytes(toAddr) + if err != nil { + return err + } + + recipientAddrs[i] = toVmAddr.String() + } + recipientsArg, err := json.Marshal(recipientAddrs) + if err != nil { + return err + } + + amountsArg, err := json.Marshal(amounts) + if err != nil { + return err + } + + return k.executeEntryFunction( + ctx, + []vmtypes.AccountAddress{vmtypes.StdAddress, senderVMAddr}, + vmtypes.StdAddress, + types.MoveModuleNameCoin, + types.FunctionNameCoinSudoMultiSend, + []vmtypes.TypeTag{}, + [][]byte{metadataArg, recipientsArg, amountsArg}, + true, + ) +} diff --git a/x/move/keeper/bank_test.go b/x/move/keeper/bank_test.go index e7816a86..d0a549a1 100644 --- a/x/move/keeper/bank_test.go +++ b/x/move/keeper/bank_test.go @@ -224,3 +224,35 @@ func Test_BurnCoins(t *testing.T) { require.Equal(t, sdk.NewCoin("foo", sdkmath.NewInt(500_000)), input.BankKeeper.GetBalance(ctx, twoAddr, "foo")) require.Equal(t, sdk.NewCoin(barDenom, sdkmath.NewInt(500_000)), input.BankKeeper.GetBalance(ctx, twoAddr, barDenom)) } + +func Test_MultiSend(t *testing.T) { + ctx, input := createDefaultTestInput(t) + moveBankKeeper := input.MoveKeeper.MoveBankKeeper() + + bz, err := hex.DecodeString("0000000000000000000000000000000000000002") + require.NoError(t, err) + twoAddr := sdk.AccAddress(bz) + + bz, err = hex.DecodeString("0000000000000000000000000000000000000003") + require.NoError(t, err) + threeAddr := sdk.AccAddress(bz) + + bz, err = hex.DecodeString("0000000000000000000000000000000000000004") + require.NoError(t, err) + fourAddr := sdk.AccAddress(bz) + + bz, err = hex.DecodeString("0000000000000000000000000000000000000005") + require.NoError(t, err) + fiveAddr := sdk.AccAddress(bz) + + amount := sdk.NewCoins(sdk.NewCoin(bondDenom, sdkmath.NewIntFromUint64(1_000_000))) + input.Faucet.Fund(ctx, twoAddr, amount...) + + err = moveBankKeeper.MultiSend(ctx, twoAddr, bondDenom, []sdk.AccAddress{threeAddr, fourAddr, fiveAddr}, []sdkmath.Int{sdkmath.NewIntFromUint64(300_000), sdkmath.NewIntFromUint64(400_000), sdkmath.NewIntFromUint64(300_000)}) + require.NoError(t, err) + + require.Equal(t, sdk.NewCoin(bondDenom, sdkmath.ZeroInt()), input.BankKeeper.GetBalance(ctx, twoAddr, bondDenom)) + require.Equal(t, uint64(300_000), input.BankKeeper.GetBalance(ctx, threeAddr, bondDenom).Amount.Uint64()) + require.Equal(t, uint64(400_000), input.BankKeeper.GetBalance(ctx, fourAddr, bondDenom).Amount.Uint64()) + require.Equal(t, uint64(300_000), input.BankKeeper.GetBalance(ctx, fiveAddr, bondDenom).Amount.Uint64()) +} diff --git a/x/move/types/connector.go b/x/move/types/connector.go index ced58205..da16ca5c 100644 --- a/x/move/types/connector.go +++ b/x/move/types/connector.go @@ -39,11 +39,12 @@ const ( FunctionNameInitiaNftBurn = "burn" // function names for coin - FunctionNameCoinBalance = "balance" - FunctionNameCoinRegister = "register" - FunctionNameCoinTransfer = "transfer" - FunctionNameCoinSudoTransfer = "sudo_transfer" - FunctionNameCoinWhitelist = "whitelist" + FunctionNameCoinBalance = "balance" + FunctionNameCoinRegister = "register" + FunctionNameCoinTransfer = "transfer" + FunctionNameCoinSudoTransfer = "sudo_transfer" + FunctionNameCoinSudoMultiSend = "sudo_multisend" + FunctionNameCoinWhitelist = "whitelist" // function names for staking FunctionNameStakingInitializeForChain = "initialize_for_chain"