diff --git a/x/accounts/go.mod b/x/accounts/go.mod index cfce853bc7..a3f52163ce 100644 --- a/x/accounts/go.mod +++ b/x/accounts/go.mod @@ -4,16 +4,23 @@ go 1.21 require ( github.com/cosmos/gogoproto v1.4.11 + github.com/stretchr/testify v1.8.4 google.golang.org/grpc v1.57.0 + google.golang.org/protobuf v1.31.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.5.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb // indirect golang.org/x/net v0.14.0 // indirect golang.org/x/sys v0.11.0 // indirect golang.org/x/text v0.12.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230815205213-6bfd019c3878 // indirect - google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) + +// REMOVE post v1 release. +replace cosmossdk.io/api => ../../api diff --git a/x/accounts/go.sum b/x/accounts/go.sum index ddc2da649d..c36bae0b51 100644 --- a/x/accounts/go.sum +++ b/x/accounts/go.sum @@ -1,11 +1,17 @@ github.com/cosmos/gogoproto v1.4.11 h1:LZcMHrx4FjUgrqQSWeaGC1v/TeuVFqSLa43CC6aWR2g= github.com/cosmos/gogoproto v1.4.11/go.mod h1:/g39Mh8m17X8Q/GDEs5zYTSNaNnInBSohtaxzQnYq1Y= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb h1:mIKbk8weKhSeLH2GmUTrvx8CjkyJmnU1wFmg59CUjFA= golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= @@ -23,3 +29,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/x/accounts/internal/implementation/account_test.go b/x/accounts/internal/implementation/account_test.go new file mode 100644 index 0000000000..f27c2c8059 --- /dev/null +++ b/x/accounts/internal/implementation/account_test.go @@ -0,0 +1,36 @@ +package implementation + +import ( + "context" + + "google.golang.org/protobuf/types/known/wrapperspb" +) + +var _ Account = (*TestAccount)(nil) + +type TestAccount struct{} + +func (TestAccount) RegisterInitHandler(builder *InitBuilder) { + RegisterInitHandler(builder, func(_ context.Context, req *wrapperspb.StringValue) (*wrapperspb.StringValue, error) { + return &wrapperspb.StringValue{Value: req.Value + "init-echo"}, nil + }) +} + +func (TestAccount) RegisterExecuteHandlers(builder *ExecuteBuilder) { + RegisterExecuteHandler(builder, func(_ context.Context, req *wrapperspb.StringValue) (*wrapperspb.StringValue, error) { + return &wrapperspb.StringValue{Value: req.Value + "execute-echo"}, nil + }) + + RegisterExecuteHandler(builder, func(_ context.Context, req *wrapperspb.BytesValue) (*wrapperspb.BytesValue, error) { + return &wrapperspb.BytesValue{Value: append(req.Value, "bytes-execute-echo"...)}, nil + }) +} + +func (TestAccount) RegisterQueryHandlers(builder *QueryBuilder) { + RegisterQueryHandler(builder, func(_ context.Context, req *wrapperspb.StringValue) (*wrapperspb.StringValue, error) { + return &wrapperspb.StringValue{Value: req.Value + "query-echo"}, nil + }) + RegisterQueryHandler(builder, func(_ context.Context, req *wrapperspb.BytesValue) (*wrapperspb.BytesValue, error) { + return &wrapperspb.BytesValue{Value: append(req.Value, "bytes-query-echo"...)}, nil + }) +} diff --git a/x/accounts/internal/implementation/api_builder.go b/x/accounts/internal/implementation/api_builder.go new file mode 100644 index 0000000000..51fb8aa4b6 --- /dev/null +++ b/x/accounts/internal/implementation/api_builder.go @@ -0,0 +1,106 @@ +package implementation + +import ( + "context" + "errors" + "fmt" + + "google.golang.org/protobuf/reflect/protoreflect" +) + +var ( + errNoInitHandler = errors.New("no init handler") + errNoExecuteHandler = errors.New("account does not accept messages") + errInvalidMessage = errors.New("invalid message") +) + +// NewInitBuilder creates a new InitBuilder instance. +func NewInitBuilder() *InitBuilder { + return &InitBuilder{} +} + +// InitBuilder defines a smart account's initialisation handler builder. +type InitBuilder struct { + // handler is the handler function that will be called when the smart account is initialized. + // Although the function here is defined to take an any, the smart account will work + // with a typed version of it. + handler func(ctx context.Context, initRequest any) (initResponse any, err error) +} + +// makeHandler returns the handler function that will be called when the smart account is initialized. +// It returns an error if no handler was registered. +func (i *InitBuilder) makeHandler() (func(ctx context.Context, initRequest any) (initResponse any, err error), error) { + if i.handler == nil { + return nil, errNoInitHandler + } + return i.handler, nil +} + +// NewExecuteBuilder creates a new ExecuteBuilder instance. +func NewExecuteBuilder() *ExecuteBuilder { + return &ExecuteBuilder{ + handlers: make(map[string]func(ctx context.Context, executeRequest any) (executeResponse any, err error)), + } +} + +// ExecuteBuilder defines a smart account's execution router, it will be used to map an execution message +// to a handler function for a specific account. +type ExecuteBuilder struct { + // handlers is a map of handler functions that will be called when the smart account is executed. + handlers map[string]func(ctx context.Context, executeRequest any) (executeResponse any, err error) + + // err is the error that occurred before building the handler function. + err error +} + +func (r *ExecuteBuilder) getMessageName(msg any) (string, error) { + protoMsg, ok := msg.(protoreflect.ProtoMessage) + if !ok { + return "", fmt.Errorf("%w: expected protoreflect.Message, got %T", errInvalidMessage, msg) + } + return string(protoMsg.ProtoReflect().Descriptor().FullName()), nil +} + +func (r *ExecuteBuilder) makeHandler() (func(ctx context.Context, executeRequest any) (executeResponse any, err error), error) { + // if no handler is registered it's fine, it means the account will not be accepting execution or query messages. + if len(r.handlers) == 0 { + return func(ctx context.Context, _ any) (_ any, err error) { + return nil, errNoExecuteHandler + }, nil + } + + if r.err != nil { + return nil, r.err + } + + // build the real execution handler + return func(ctx context.Context, executeRequest any) (executeResponse any, err error) { + messageName, err := r.getMessageName(executeRequest) + if err != nil { + return nil, fmt.Errorf("%w: unable to get message name", err) + } + handler, ok := r.handlers[messageName] + if !ok { + return nil, fmt.Errorf("%w: no handler for message %s", errInvalidMessage, messageName) + } + return handler(ctx, executeRequest) + }, nil +} + +// NewQueryBuilder creates a new QueryBuilder instance. +func NewQueryBuilder() *QueryBuilder { + return &QueryBuilder{ + er: NewExecuteBuilder(), + } +} + +// QueryBuilder defines a smart account's query router, it will be used to map a query message +// to a handler function for a specific account. +type QueryBuilder struct { + // er is the ExecuteBuilder, since there's no difference between the execution and query handlers API. + er *ExecuteBuilder +} + +func (r *QueryBuilder) makeHandler() (func(ctx context.Context, queryRequest any) (queryResponse any, err error), error) { + return r.er.makeHandler() +} diff --git a/x/accounts/internal/implementation/api_builder_test.go b/x/accounts/internal/implementation/api_builder_test.go new file mode 100644 index 0000000000..8382ea8b8b --- /dev/null +++ b/x/accounts/internal/implementation/api_builder_test.go @@ -0,0 +1,35 @@ +package implementation + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func TestRouterDoubleRegistration(t *testing.T) { + router := NewExecuteBuilder() + RegisterExecuteHandler(router, func(_ context.Context, req *wrapperspb.StringValue) (*wrapperspb.StringValue, error) { return nil, nil }) + RegisterExecuteHandler(router, func(_ context.Context, req *wrapperspb.StringValue) (*wrapperspb.StringValue, error) { return nil, nil }) + + _, err := router.makeHandler() + require.ErrorContains(t, err, "already registered") +} + +func TestEmptyQueryExecuteHandler(t *testing.T) { + qr := NewQueryBuilder() + er := NewExecuteBuilder() + + qh, err := qr.makeHandler() + require.NoError(t, err) + eh, err := er.makeHandler() + require.NoError(t, err) + + ctx := context.Background() + + _, err = qh(ctx, &wrapperspb.StringValue{}) + require.ErrorIs(t, err, errNoExecuteHandler) + _, err = eh(ctx, &wrapperspb.StringValue{}) + require.ErrorIs(t, err, errNoExecuteHandler) +} diff --git a/x/accounts/internal/implementation/implementation.go b/x/accounts/internal/implementation/implementation.go new file mode 100644 index 0000000000..f8fd780a2e --- /dev/null +++ b/x/accounts/internal/implementation/implementation.go @@ -0,0 +1,46 @@ +package implementation + +import "context" + +// NewImplementation creates a new Implementation instance given an Account implementer. +func NewImplementation(account Account) (Implementation, error) { + // make init handler + ir := NewInitBuilder() + account.RegisterInitHandler(ir) + initHandler, err := ir.makeHandler() + if err != nil { + return Implementation{}, err + } + + // make execute handler + er := NewExecuteBuilder() + account.RegisterExecuteHandlers(er) + executeHandler, err := er.makeHandler() + if err != nil { + return Implementation{}, err + } + + // make query handler + qr := NewQueryBuilder() + account.RegisterQueryHandlers(qr) + queryHandler, err := qr.makeHandler() + if err != nil { + return Implementation{}, err + } + return Implementation{ + Init: initHandler, + Execute: executeHandler, + Query: queryHandler, + }, nil +} + +// Implementation wraps an Account implementer in order to provide a concrete +// and non-generic implementation usable by the x/accounts module. +type Implementation struct { + // Init defines the initialisation handler for the smart account. + Init func(ctx context.Context, msg any) (resp any, err error) + // Execute defines the execution handler for the smart account. + Execute func(ctx context.Context, msg any) (resp any, err error) + // Query defines the query handler for the smart account. + Query func(ctx context.Context, msg any) (resp any, err error) +} diff --git a/x/accounts/internal/implementation/implementation_test.go b/x/accounts/internal/implementation/implementation_test.go new file mode 100644 index 0000000000..6b87cb9b8a --- /dev/null +++ b/x/accounts/internal/implementation/implementation_test.go @@ -0,0 +1,66 @@ +package implementation + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func TestImplementation(t *testing.T) { + impl, err := NewImplementation(TestAccount{}) + require.NoError(t, err) + + ctx := context.Background() + + t.Run("execute ok", func(t *testing.T) { + resp, err := impl.Execute(ctx, &wrapperspb.StringValue{Value: "test"}) + require.NoError(t, err) + require.Equal(t, "testexecute-echo", resp.(*wrapperspb.StringValue).Value) + + resp, err = impl.Execute(ctx, &wrapperspb.BytesValue{Value: []byte("test")}) + require.NoError(t, err) + require.Equal(t, "testbytes-execute-echo", string(resp.(*wrapperspb.BytesValue).Value)) + }) + + t.Run("execute - unknown message", func(t *testing.T) { + _, err := impl.Execute(ctx, &wrapperspb.Int32Value{Value: 1}) + require.ErrorIs(t, err, errInvalidMessage) + }) + + t.Run("init ok", func(t *testing.T) { + resp, err := impl.Init(ctx, &wrapperspb.StringValue{Value: "test"}) + require.NoError(t, err) + require.Equal(t, "testinit-echo", resp.(*wrapperspb.StringValue).Value) + }) + + t.Run("init - unknown message", func(t *testing.T) { + _, err := impl.Init(ctx, &wrapperspb.Int32Value{Value: 1}) + require.ErrorIs(t, err, errInvalidMessage) + }) + + t.Run("query ok", func(t *testing.T) { + resp, err := impl.Query(ctx, &wrapperspb.StringValue{Value: "test"}) + require.NoError(t, err) + require.Equal(t, "testquery-echo", resp.(*wrapperspb.StringValue).Value) + + resp, err = impl.Query(ctx, &wrapperspb.BytesValue{Value: []byte("test")}) + require.NoError(t, err) + require.Equal(t, "testbytes-query-echo", string(resp.(*wrapperspb.BytesValue).Value)) + }) + + t.Run("query - unknown message", func(t *testing.T) { + _, err := impl.Query(ctx, &wrapperspb.Int32Value{Value: 1}) + require.ErrorIs(t, err, errInvalidMessage) + }) + + t.Run("all - not a protobuf message", func(t *testing.T) { + _, err := impl.Execute(ctx, "test") + require.ErrorIs(t, err, errInvalidMessage) + _, err = impl.Query(ctx, "test") + require.ErrorIs(t, err, errInvalidMessage) + _, err = impl.Init(ctx, "test") + require.ErrorIs(t, err, errInvalidMessage) + }) +} diff --git a/x/accounts/internal/implementation/interface.go b/x/accounts/internal/implementation/interface.go new file mode 100644 index 0000000000..0183b23f5a --- /dev/null +++ b/x/accounts/internal/implementation/interface.go @@ -0,0 +1,17 @@ +package implementation + +// Account defines a smart account interface. +type Account interface { + // RegisterInitHandler allows the smart account to register an initialisation handler, using + // the provided InitBuilder. The handler will be called when the smart account is initialized + // (deployed). + RegisterInitHandler(builder *InitBuilder) + + // RegisterExecuteHandlers allows the smart account to register execution handlers. + // The smart account might also decide to not register any execution handler. + RegisterExecuteHandlers(builder *ExecuteBuilder) + + // RegisterQueryHandlers allows the smart account to register query handlers. The smart account + // might also decide to not register any query handler. + RegisterQueryHandlers(builder *QueryBuilder) +} diff --git a/x/accounts/internal/implementation/protoaccount.go b/x/accounts/internal/implementation/protoaccount.go new file mode 100644 index 0000000000..19d72597f1 --- /dev/null +++ b/x/accounts/internal/implementation/protoaccount.go @@ -0,0 +1,55 @@ +package implementation + +import ( + "context" + "fmt" + + "google.golang.org/protobuf/reflect/protoreflect" +) + +// ProtoMsg is a generic interface for protobuf messages. +type ProtoMsg[T any] interface { + *T + protoreflect.ProtoMessage +} + +// RegisterInitHandler registers an initialisation handler for a smart account that uses protobuf. +func RegisterInitHandler[ + Req any, ProtoReq ProtoMsg[Req], Resp any, ProtoResp ProtoMsg[Resp], +](router *InitBuilder, handler func(ctx context.Context, req ProtoReq) (ProtoResp, error)) { + reqName := ProtoReq(new(Req)).ProtoReflect().Descriptor().FullName() + router.handler = func(ctx context.Context, initRequest any) (initResponse any, err error) { + concrete, ok := initRequest.(ProtoReq) + if !ok { + return nil, fmt.Errorf("%w: wanted %s, got %T", errInvalidMessage, reqName, initRequest) + } + return handler(ctx, concrete) + } +} + +// RegisterExecuteHandler registers an execution handler for a smart account that uses protobuf. +func RegisterExecuteHandler[ + Req any, ProtoReq ProtoMsg[Req], Resp any, ProtoResp ProtoMsg[Resp], +](router *ExecuteBuilder, handler func(ctx context.Context, req ProtoReq) (ProtoResp, error)) { + reqName := ProtoReq(new(Req)).ProtoReflect().Descriptor().FullName() + // check if not registered already + if _, ok := router.handlers[string(reqName)]; ok { + router.err = fmt.Errorf("handler already registered for message %s", reqName) + return + } + + router.handlers[string(reqName)] = func(ctx context.Context, executeRequest any) (executeResponse any, err error) { + concrete, ok := executeRequest.(ProtoReq) + if !ok { + return nil, fmt.Errorf("%w: wanted %s, got %T", errInvalidMessage, reqName, executeRequest) + } + return handler(ctx, concrete) + } +} + +// RegisterQueryHandler registers a query handler for a smart account that uses protobuf. +func RegisterQueryHandler[ + Req any, ProtoReq ProtoMsg[Req], Resp any, ProtoResp ProtoMsg[Resp], +](router *QueryBuilder, handler func(ctx context.Context, req ProtoReq) (ProtoResp, error)) { + RegisterExecuteHandler(router.er, handler) +}