feat(accounts): allow accounts to query and execute modules (#17685)

Co-authored-by: unknown unknown <unknown@unknown>
This commit is contained in:
testinginprod 2023-09-13 20:39:18 +02:00 committed by GitHub
parent 2154ce0bbe
commit c946e14211
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 292 additions and 30 deletions

View File

@ -7,6 +7,8 @@ import (
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/wrapperspb"
bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1"
basev1beta1 "cosmossdk.io/api/cosmos/base/v1beta1"
"cosmossdk.io/x/accounts/internal/implementation"
)
@ -15,8 +17,13 @@ var _ implementation.Account = (*TestAccount)(nil)
type TestAccount struct{}
func (t TestAccount) RegisterInitHandler(builder *implementation.InitBuilder) {
implementation.RegisterInitHandler(builder, func(_ context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) {
return &emptypb.Empty{}, nil
implementation.RegisterInitHandler(builder, func(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) {
// we also force a module call here to test things work as expected.
_, err := implementation.QueryModule[bankv1beta1.QueryBalanceResponse](ctx, &bankv1beta1.QueryBalanceRequest{
Address: string(implementation.Whoami(ctx)),
Denom: "atom",
})
return &emptypb.Empty{}, err
})
}
@ -33,6 +40,28 @@ func (t TestAccount) RegisterExecuteHandlers(builder *implementation.ExecuteBuil
return wrapperspb.UInt64(value), nil
})
// this is for intermodule comms testing, we simulate a bank send
implementation.RegisterExecuteHandler(builder, func(ctx context.Context, req *wrapperspb.Int64Value) (*emptypb.Empty, error) {
resp, err := implementation.ExecModule[bankv1beta1.MsgSendResponse](ctx, &bankv1beta1.MsgSend{
FromAddress: string(implementation.Whoami(ctx)),
ToAddress: "recipient",
Amount: []*basev1beta1.Coin{
{
Denom: "test",
Amount: strconv.FormatInt(req.Value, 10),
},
},
})
if err != nil {
return nil, err
}
if resp == nil {
panic("nil response") // should never happen
}
return &emptypb.Empty{}, nil
})
}
func (t TestAccount) RegisterQueryHandlers(builder *implementation.QueryBuilder) {
@ -43,4 +72,22 @@ func (t TestAccount) RegisterQueryHandlers(builder *implementation.QueryBuilder)
implementation.RegisterQueryHandler(builder, func(_ context.Context, req *wrapperspb.UInt64Value) (*wrapperspb.StringValue, error) {
return wrapperspb.String(strconv.FormatUint(req.Value, 10)), nil
})
// test intermodule comms, we simulate someone is sending the account a request for the accounts balance
// of a given denom.
implementation.RegisterQueryHandler(builder, func(ctx context.Context, req *wrapperspb.StringValue) (*wrapperspb.Int64Value, error) {
resp, err := implementation.QueryModule[bankv1beta1.QueryBalanceResponse](ctx, &bankv1beta1.QueryBalanceRequest{
Address: string(implementation.Whoami(ctx)),
Denom: req.Value,
})
if err != nil {
return nil, err
}
amt, err := strconv.ParseInt(resp.Balance.Amount, 10, 64)
if err != nil {
return nil, err
}
return wrapperspb.Int64(amt), nil
})
}

View File

@ -3,6 +3,7 @@ module cosmossdk.io/x/accounts
go 1.21
require (
cosmossdk.io/api v0.7.0
cosmossdk.io/collections v0.4.0
cosmossdk.io/core v0.11.0
github.com/cosmos/gogoproto v1.4.11
@ -12,7 +13,6 @@ require (
)
require (
cosmossdk.io/api v0.7.0 // indirect
cosmossdk.io/depinject v1.0.0-alpha.4 // indirect
github.com/DataDog/zstd v1.5.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@ -48,6 +48,8 @@ require (
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -188,6 +188,10 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 h1:L6iMMGrtzgHsWofoFcihmDEMYeDR9KN/ThbPWGrh++g=
google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5/go.mod h1:oH/ZOT02u4kWEp7oYBGYFFkCdKS/uYR9Z7+0/xuuFp8=
google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e h1:z3vDksarJxsAKM5dmEGv0GHwE2hKJ096wZra71Vs4sw=
google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M=
google.golang.org/grpc v1.58.0 h1:32JY8YpPMSR45K+c3o6b8VL73V+rR8k+DeMIr4vRH8o=

View File

@ -1,19 +1,29 @@
package implementation
import (
"bytes"
"context"
"errors"
"fmt"
"google.golang.org/protobuf/proto"
"cosmossdk.io/core/store"
"cosmossdk.io/x/accounts/internal/prefixstore"
)
var errUnauthorized = errors.New("unauthorized")
type contextKey struct{}
type contextValue struct {
store store.KVStore // store is the prefixed store for the account.
sender []byte // sender is the address of the entity invoking the account action.
whoami []byte // whoami is the address of the account being invoked.
originalContext context.Context // originalContext that was used to build the account context.
store store.KVStore // store is the prefixed store for the account.
sender []byte // sender is the address of the entity invoking the account action.
whoami []byte // whoami is the address of the account being invoked.
originalContext context.Context // originalContext that was used to build the account context.
getExpectedSender func(msg proto.Message) ([]byte, error)
moduleExec func(ctx context.Context, msg proto.Message) (proto.Message, error)
moduleQuery func(ctx context.Context, msg proto.Message) (proto.Message, error)
}
// MakeAccountContext creates a new account execution context given:
@ -21,15 +31,68 @@ type contextValue struct {
// accountAddr: the address of the account being invoked, which is used to give the
// account a prefixed storage.
// sender: the address of entity invoking the account action.
func MakeAccountContext(ctx context.Context, storeSvc store.KVStoreService, accountAddr, sender []byte) context.Context {
func MakeAccountContext(
ctx context.Context,
storeSvc store.KVStoreService,
accountAddr,
sender []byte,
getSenderFunc func(msg proto.Message) ([]byte, error),
moduleExec func(ctx context.Context, msg proto.Message) (proto.Message, error),
moduleQuery func(ctx context.Context, msg proto.Message) (proto.Message, error),
) context.Context {
return context.WithValue(ctx, contextKey{}, contextValue{
store: prefixstore.New(storeSvc.OpenKVStore(ctx), accountAddr),
sender: sender,
whoami: accountAddr,
originalContext: ctx,
store: prefixstore.New(storeSvc.OpenKVStore(ctx), accountAddr),
sender: sender,
whoami: accountAddr,
originalContext: ctx,
getExpectedSender: getSenderFunc,
moduleExec: moduleExec,
moduleQuery: moduleQuery,
})
}
// ExecModule can be used to execute a message towards a module.
func ExecModule[Resp any, RespProto ProtoMsg[Resp], Req any, ReqProto ProtoMsg[Req]](ctx context.Context, msg ReqProto) (RespProto, error) {
// get sender
v := ctx.Value(contextKey{}).(contextValue)
// check sender
expectedSender, err := v.getExpectedSender(msg)
if err != nil {
return nil, err
}
if !bytes.Equal(expectedSender, v.whoami) {
return nil, errUnauthorized
}
// execute module, unwrapping the original context.
resp, err := v.moduleExec(v.originalContext, msg)
if err != nil {
return nil, err
}
concreteResp, ok := resp.(RespProto)
if !ok {
return nil, fmt.Errorf("unexpected response type %T", resp)
}
return concreteResp, nil
}
// QueryModule can be used by an account to execute a module query.
func QueryModule[Resp any, RespProto ProtoMsg[Resp], Req any, ReqProto ProtoMsg[Req]](ctx context.Context, msg ReqProto) (RespProto, error) {
// we do not need to check the sender in a query because it is not a state transition.
// we also unwrap the original context.
v := ctx.Value(contextKey{}).(contextValue)
resp, err := v.moduleQuery(v.originalContext, msg)
if err != nil {
return nil, err
}
concreteResp, ok := resp.(RespProto)
if !ok {
return nil, fmt.Errorf("unexpected response type %T", resp)
}
return concreteResp, nil
}
// OpenKVStore returns the prefixed store for the account given the context.
func OpenKVStore(ctx context.Context) store.KVStore {
return ctx.Value(contextKey{}).(contextValue).store

View File

@ -1,9 +1,11 @@
package implementation
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/wrapperspb"
"cosmossdk.io/collections"
@ -11,12 +13,12 @@ import (
)
func TestMakeAccountContext(t *testing.T) {
storeService, ctx := colltest.MockStore()
storeService, originalContext := colltest.MockStore()
accountAddr := []byte("accountAddr")
sender := []byte("sender")
sb := collections.NewSchemaBuilderFromAccessor(OpenKVStore)
accountCtx := MakeAccountContext(ctx, storeService, accountAddr, sender)
accountCtx := MakeAccountContext(originalContext, storeService, accountAddr, sender, nil, nil, nil)
// ensure whoami
require.Equal(t, accountAddr, Whoami(accountCtx))
@ -34,10 +36,42 @@ func TestMakeAccountContext(t *testing.T) {
// we want to ensure that the account wrote in the correct prefix.
// this store is the global x/accounts module store.
store := storeService.OpenKVStore(ctx)
store := storeService.OpenKVStore(originalContext)
// now we want the value to be store in the following accounts prefix (accountAddr + itemPrefix)
value, err := store.Get(append(accountAddr, itemPrefix...))
require.NoError(t, err)
require.Equal(t, []byte{0, 0, 0, 0, 0, 0, 3, 232}, value)
// ensure getSenderAccount blocks impersonation
accountCtx = MakeAccountContext(originalContext, storeService, []byte("impersonator"), []byte("account-invoker"), func(_ proto.Message) ([]byte, error) {
return []byte("legit-exec-module"), nil
}, nil, nil)
_, err = ExecModule[wrapperspb.StringValue](accountCtx, &wrapperspb.UInt64Value{Value: 1000})
require.ErrorIs(t, err, errUnauthorized)
// ensure calling ExecModule works
accountCtx = MakeAccountContext(originalContext, storeService, []byte("legit-exec-module"), []byte("invoker"), func(_ proto.Message) ([]byte, error) {
return []byte("legit-exec-module"), nil
}, func(ctx context.Context, msg proto.Message) (proto.Message, error) {
// ensure we unwrapped the context when invoking a module call
require.Equal(t, originalContext, ctx)
return wrapperspb.String("module exec was called"), nil
}, nil)
resp, err := ExecModule[wrapperspb.StringValue](accountCtx, &wrapperspb.UInt64Value{Value: 1000})
require.NoError(t, err)
require.True(t, proto.Equal(wrapperspb.String("module exec was called"), resp))
// ensure calling QueryModule works, also by setting everything else communication related to nil
// we can guarantee that exec paths do not impact query paths.
accountCtx = MakeAccountContext(originalContext, storeService, nil, nil, nil, nil, func(ctx context.Context, msg proto.Message) (proto.Message, error) {
require.Equal(t, originalContext, ctx)
return wrapperspb.String("module query was called"), nil
})
resp, err = QueryModule[wrapperspb.StringValue](accountCtx, &wrapperspb.UInt64Value{Value: 1000})
require.NoError(t, err)
require.True(t, proto.Equal(wrapperspb.String("module query was called"), resp))
}

View File

@ -7,6 +7,8 @@ import (
"errors"
"fmt"
"google.golang.org/protobuf/proto"
"cosmossdk.io/collections"
"cosmossdk.io/core/address"
"cosmossdk.io/core/store"
@ -22,14 +24,25 @@ var (
AccountNumberKey = collections.NewPrefix(1)
)
func NewKeeper(ss store.KVStoreService, addressCodec address.Codec, accounts map[string]implementation.Account) (Keeper, error) {
func NewKeeper(
ss store.KVStoreService,
addressCodec address.Codec,
getMsgSenderFunc func(msg proto.Message) ([]byte, error),
execModuleFunc func(ctx context.Context, msg proto.Message) (proto.Message, error),
queryModuleFunc func(ctx context.Context, msg proto.Message) (proto.Message, error),
accounts map[string]implementation.Account,
) (Keeper, error) {
sb := collections.NewSchemaBuilder(ss)
keeper := Keeper{
storeService: ss,
addressCodec: addressCodec,
accounts: map[string]implementation.Implementation{},
AccountNumber: collections.NewSequence(sb, AccountNumberKey, "account_number"),
AccountsByType: collections.NewMap(sb, AccountTypeKeyPrefix, "accounts_by_type", collections.BytesKey, collections.StringValue),
storeService: ss,
addressCodec: addressCodec,
getSenderFunc: getMsgSenderFunc,
execModuleFunc: execModuleFunc,
queryModuleFunc: queryModuleFunc,
accounts: map[string]implementation.Implementation{},
Schema: collections.Schema{},
AccountNumber: collections.NewSequence(sb, AccountNumberKey, "account_number"),
AccountsByType: collections.NewMap(sb, AccountTypeKeyPrefix, "accounts_by_type", collections.BytesKey, collections.StringValue),
}
// make accounts implementation
@ -49,12 +62,15 @@ func NewKeeper(ss store.KVStoreService, addressCodec address.Codec, accounts map
}
type Keeper struct {
storeService store.KVStoreService
// deps coming from the runtime
storeService store.KVStoreService
addressCodec address.Codec
getSenderFunc func(msg proto.Message) ([]byte, error)
execModuleFunc func(ctx context.Context, msg proto.Message) (proto.Message, error)
queryModuleFunc func(ctx context.Context, msg proto.Message) (proto.Message, error)
accounts map[string]implementation.Implementation
addressCodec address.Codec
// Schema is the schema for the module.
Schema collections.Schema
// AccountNumber is the last global account number.
@ -82,7 +98,7 @@ func (k Keeper) Init(
}
// make the context and init the account
ctx = implementation.MakeAccountContext(ctx, k.storeService, accountAddr, creator)
ctx = k.makeAccountContext(ctx, accountAddr, creator, false)
resp, err := impl.Init(ctx, initRequest)
if err != nil {
return nil, nil, err
@ -118,7 +134,7 @@ func (k Keeper) Execute(
}
// make the context and execute the account state transition.
ctx = implementation.MakeAccountContext(ctx, k.storeService, accountAddr, sender)
ctx = k.makeAccountContext(ctx, accountAddr, sender, false)
return impl.Execute(ctx, execRequest)
}
@ -143,8 +159,8 @@ func (k Keeper) Query(
return nil, err
}
// make the context and execute the account state transition.
ctx = implementation.MakeAccountContext(ctx, k.storeService, accountAddr, nil)
// make the context and execute the account query
ctx = k.makeAccountContext(ctx, accountAddr, nil, true)
return impl.Query(ctx, queryRequest)
}
@ -166,3 +182,35 @@ func (k Keeper) makeAddress(ctx context.Context) ([]byte, error) {
addr := sha256.Sum256(append([]byte("x/accounts"), binary.BigEndian.AppendUint64(nil, num)...))
return addr[:], nil
}
// makeAccountContext makes a new context for the given account.
func (k Keeper) makeAccountContext(ctx context.Context, accountAddr, sender []byte, isQuery bool) context.Context {
// if it's not a query we create a context that allows to do anything.
if !isQuery {
return implementation.MakeAccountContext(
ctx,
k.storeService,
accountAddr,
sender,
k.getSenderFunc,
k.execModuleFunc,
k.queryModuleFunc,
)
}
// if it's a query we create a context that does not allow to execute modules
// and does not allow to get the sender.
return implementation.MakeAccountContext(
ctx,
k.storeService,
accountAddr,
nil,
func(_ proto.Message) ([]byte, error) {
return nil, fmt.Errorf("cannot get sender from query")
},
func(ctx context.Context, _ proto.Message) (proto.Message, error) {
return nil, fmt.Errorf("cannot execute module from query")
},
k.queryModuleFunc,
)
}

View File

@ -5,8 +5,12 @@ import (
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/wrapperspb"
bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1"
basev1beta1 "cosmossdk.io/api/cosmos/base/v1beta1"
"cosmossdk.io/collections"
"cosmossdk.io/collections/colltest"
"cosmossdk.io/core/address"
@ -23,15 +27,20 @@ func (a addressCodec) BytesToString(bz []byte) (string, error) { return string
func newKeeper(t *testing.T, accounts map[string]implementation.Account) (Keeper, context.Context) {
t.Helper()
ss, ctx := colltest.MockStore()
m, err := NewKeeper(ss, addressCodec{}, accounts)
m, err := NewKeeper(ss, addressCodec{}, nil, nil, nil, accounts)
require.NoError(t, err)
return m, ctx
}
func TestKeeper_Create(t *testing.T) {
func TestKeeper_Init(t *testing.T) {
m, ctx := newKeeper(t, map[string]implementation.Account{
"test": TestAccount{},
})
m.queryModuleFunc = func(ctx context.Context, msg proto.Message) (proto.Message, error) {
_, ok := msg.(*bankv1beta1.QueryBalanceRequest)
require.True(t, ok)
return &bankv1beta1.QueryBalanceResponse{}, nil
}
t.Run("ok", func(t *testing.T) {
sender := []byte("sender")
@ -62,6 +71,9 @@ func TestKeeper_Execute(t *testing.T) {
m, ctx := newKeeper(t, map[string]implementation.Account{
"test": TestAccount{},
})
m.queryModuleFunc = func(_ context.Context, _ proto.Message) (proto.Message, error) {
return &bankv1beta1.QueryBalanceResponse{}, nil
}
// create account
sender := []byte("sender")
@ -78,12 +90,33 @@ func TestKeeper_Execute(t *testing.T) {
_, err := m.Execute(ctx, []byte("unknown"), sender, &emptypb.Empty{})
require.ErrorIs(t, err, collections.ErrNotFound)
})
t.Run("exec module", func(t *testing.T) {
m.execModuleFunc = func(ctx context.Context, msg proto.Message) (proto.Message, error) {
concrete, ok := msg.(*bankv1beta1.MsgSend)
require.True(t, ok)
require.Equal(t, concrete.ToAddress, "recipient")
return &bankv1beta1.MsgSendResponse{}, nil
}
m.getSenderFunc = func(msg proto.Message) ([]byte, error) {
require.Equal(t, msg.(*bankv1beta1.MsgSend).FromAddress, string(accAddr))
return accAddr, nil
}
resp, err := m.Execute(ctx, accAddr, sender, &wrapperspb.Int64Value{Value: 1000})
require.NoError(t, err)
require.True(t, proto.Equal(&emptypb.Empty{}, resp.(proto.Message)))
})
}
func TestKeeper_Query(t *testing.T) {
m, ctx := newKeeper(t, map[string]implementation.Account{
"test": TestAccount{},
})
m.queryModuleFunc = func(_ context.Context, _ proto.Message) (proto.Message, error) {
return &bankv1beta1.QueryBalanceResponse{}, nil
}
// create account
sender := []byte("sender")
@ -100,4 +133,23 @@ func TestKeeper_Query(t *testing.T) {
_, err := m.Query(ctx, []byte("unknown"), &emptypb.Empty{})
require.ErrorIs(t, err, collections.ErrNotFound)
})
t.Run("query module", func(t *testing.T) {
// we inject the module query function, which accepts only a specific type of message
// we force the response
m.queryModuleFunc = func(ctx context.Context, msg proto.Message) (proto.Message, error) {
concrete, ok := msg.(*bankv1beta1.QueryBalanceRequest)
require.True(t, ok)
require.Equal(t, string(accAddr), concrete.Address)
require.Equal(t, concrete.Denom, "atom")
return &bankv1beta1.QueryBalanceResponse{Balance: &basev1beta1.Coin{
Denom: "atom",
Amount: "1000",
}}, nil
}
resp, err := m.Query(ctx, accAddr, wrapperspb.String("atom"))
require.NoError(t, err)
require.True(t, proto.Equal(wrapperspb.Int64(1000), resp.(proto.Message)))
})
}

View File

@ -1,6 +1,7 @@
package accounts
import (
"context"
"testing"
"github.com/stretchr/testify/require"
@ -9,6 +10,7 @@ import (
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/wrapperspb"
bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1"
"cosmossdk.io/x/accounts/internal/implementation"
v1 "cosmossdk.io/x/accounts/v1"
)
@ -17,6 +19,11 @@ func TestMsgServer(t *testing.T) {
k, ctx := newKeeper(t, map[string]implementation.Account{
"test": TestAccount{},
})
k.queryModuleFunc = func(ctx context.Context, msg proto.Message) (proto.Message, error) {
_, ok := msg.(*bankv1beta1.QueryBalanceRequest)
require.True(t, ok)
return &bankv1beta1.QueryBalanceResponse{}, nil
}
s := NewMsgServer(k)

View File

@ -1,6 +1,7 @@
package accounts
import (
"context"
"testing"
"github.com/stretchr/testify/require"
@ -9,6 +10,7 @@ import (
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/wrapperspb"
bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1"
"cosmossdk.io/x/accounts/internal/implementation"
v1 "cosmossdk.io/x/accounts/v1"
)
@ -17,6 +19,9 @@ func TestQueryServer(t *testing.T) {
k, ctx := newKeeper(t, map[string]implementation.Account{
"test": TestAccount{},
})
k.queryModuleFunc = func(ctx context.Context, msg proto.Message) (proto.Message, error) {
return &bankv1beta1.QueryBalanceResponse{}, nil
}
ms := NewMsgServer(k)
qs := NewQueryServer(k)