diff --git a/x/accounts/account_test.go b/x/accounts/account_test.go new file mode 100644 index 0000000000..8a3a427523 --- /dev/null +++ b/x/accounts/account_test.go @@ -0,0 +1,31 @@ +package accounts + +import ( + "context" + + "google.golang.org/protobuf/types/known/emptypb" + + "cosmossdk.io/x/accounts/internal/implementation" +) + +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 + }) +} + +func (t TestAccount) RegisterExecuteHandlers(builder *implementation.ExecuteBuilder) { + implementation.RegisterExecuteHandler(builder, func(_ context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil + }) +} + +func (t TestAccount) RegisterQueryHandlers(builder *implementation.QueryBuilder) { + implementation.RegisterQueryHandler(builder, func(_ context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil + }) +} diff --git a/x/accounts/keeper.go b/x/accounts/keeper.go new file mode 100644 index 0000000000..f705e95dea --- /dev/null +++ b/x/accounts/keeper.go @@ -0,0 +1,165 @@ +package accounts + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + + "cosmossdk.io/collections" + "cosmossdk.io/core/store" + "cosmossdk.io/x/accounts/internal/implementation" +) + +var errAccountTypeNotFound = errors.New("account type not found") + +var ( + // AccountTypeKeyPrefix is the prefix for the account type key. + AccountTypeKeyPrefix = collections.NewPrefix(0) + // AccountNumberKey is the key for the account number. + AccountNumberKey = collections.NewPrefix(1) +) + +func NewKeeper(ss store.KVStoreService, accounts map[string]implementation.Account) (Keeper, error) { + sb := collections.NewSchemaBuilder(ss) + keeper := Keeper{ + storeService: ss, + accounts: map[string]implementation.Implementation{}, + AccountNumber: collections.NewSequence(sb, AccountNumberKey, "account_number"), + AccountsByType: collections.NewMap(sb, AccountTypeKeyPrefix, "accounts_by_type", collections.BytesKey, collections.StringValue), + } + + // make accounts implementation + for typ, acc := range accounts { + impl, err := implementation.NewImplementation(acc) + if err != nil { + return Keeper{}, err + } + keeper.accounts[typ] = impl + } + schema, err := sb.Build() + if err != nil { + return Keeper{}, err + } + keeper.Schema = schema + return keeper, nil +} + +type Keeper struct { + storeService store.KVStoreService + + accounts map[string]implementation.Implementation + + // Schema is the schema for the module. + Schema collections.Schema + // AccountNumber is the last global account number. + AccountNumber collections.Sequence + + // AccountsByType maps account address to their implementation. + AccountsByType collections.Map[[]byte, string] +} + +// Create creates a new account of the given type. +func (k Keeper) Create( + ctx context.Context, + accountType string, + creator []byte, + initRequest any, +) (any, []byte, error) { + impl, err := k.getImplementation(accountType) + if err != nil { + return nil, nil, err + } + + // make a new account address + accountAddr, err := k.makeAddress(ctx) + if err != nil { + return nil, nil, err + } + + // make the context and init the account + ctx = implementation.MakeAccountContext(ctx, k.storeService, accountAddr, creator) + resp, err := impl.Init(ctx, initRequest) + if err != nil { + return nil, nil, err + } + + // map account address to account type + if err := k.AccountsByType.Set(ctx, accountAddr, accountType); err != nil { + return nil, nil, err + } + return resp, accountAddr, nil +} + +// Execute executes a state transition on the given account. +func (k Keeper) Execute( + ctx context.Context, + accountAddr []byte, + sender []byte, + execRequest any, +) (any, error) { + // get account type + accountType, err := k.AccountsByType.Get(ctx, accountAddr) + if err != nil { + return nil, err + } + + // get account implementation + impl, err := k.getImplementation(accountType) + if err != nil { + // this means the account was initialized with an implementation + // that the chain does not know about, in theory should never happen, + // as it might signal that the app-dev stopped supporting an account type. + return nil, err + } + + // make the context and execute the account state transition. + ctx = implementation.MakeAccountContext(ctx, k.storeService, accountAddr, sender) + return impl.Execute(ctx, execRequest) +} + +// Query queries the given account. +func (k Keeper) Query( + ctx context.Context, + accountAddr []byte, + queryRequest any, +) (any, error) { + // get account type + accountType, err := k.AccountsByType.Get(ctx, accountAddr) + if err != nil { + return nil, err + } + + // get account implementation + impl, err := k.getImplementation(accountType) + if err != nil { + // this means the account was initialized with an implementation + // that the chain does not know about, in theory should never happen, + // as it might signal that the app-dev stopped supporting an account type. + return nil, err + } + + // make the context and execute the account state transition. + ctx = implementation.MakeAccountContext(ctx, k.storeService, accountAddr, nil) + return impl.Query(ctx, queryRequest) +} + +func (k Keeper) getImplementation(accountType string) (implementation.Implementation, error) { + impl, ok := k.accounts[accountType] + if !ok { + return implementation.Implementation{}, fmt.Errorf("%w: %s", errAccountTypeNotFound, accountType) + } + return impl, nil +} + +func (k Keeper) makeAddress(ctx context.Context) ([]byte, error) { + num, err := k.AccountNumber.Next(ctx) + if err != nil { + return nil, err + } + + // TODO: better address scheme, ref: https://github.com/cosmos/cosmos-sdk/issues/17516 + addr := sha256.Sum256(append([]byte("x/accounts"), binary.BigEndian.AppendUint64(nil, num)...)) + return addr[:], nil +} diff --git a/x/accounts/keeper_test.go b/x/accounts/keeper_test.go new file mode 100644 index 0000000000..ab5beffe70 --- /dev/null +++ b/x/accounts/keeper_test.go @@ -0,0 +1,95 @@ +package accounts + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/emptypb" + + "cosmossdk.io/collections" + "cosmossdk.io/collections/colltest" + "cosmossdk.io/x/accounts/internal/implementation" +) + +func newKeeper(t *testing.T, accounts map[string]implementation.Account) (Keeper, context.Context) { + t.Helper() + ss, ctx := colltest.MockStore() + m, err := NewKeeper(ss, accounts) + require.NoError(t, err) + return m, ctx +} + +func TestKeeper_Create(t *testing.T) { + m, ctx := newKeeper(t, map[string]implementation.Account{ + "test": TestAccount{}, + }) + + t.Run("ok", func(t *testing.T) { + sender := []byte("sender") + + resp, addr, err := m.Create(ctx, "test", sender, &emptypb.Empty{}) + require.NoError(t, err) + require.Equal(t, &emptypb.Empty{}, resp) + require.NotNil(t, addr) + + // ensure acc number was increased. + num, err := m.AccountNumber.Peek(ctx) + require.NoError(t, err) + require.Equal(t, uint64(1), num) + + // ensure account mapping + accType, err := m.AccountsByType.Get(ctx, addr) + require.NoError(t, err) + require.Equal(t, "test", accType) + }) + + t.Run("unknown account type", func(t *testing.T) { + _, _, err := m.Create(ctx, "unknown", []byte("sender"), &emptypb.Empty{}) + require.ErrorIs(t, err, errAccountTypeNotFound) + }) +} + +func TestKeeper_Execute(t *testing.T) { + m, ctx := newKeeper(t, map[string]implementation.Account{ + "test": TestAccount{}, + }) + + // create account + sender := []byte("sender") + _, accAddr, err := m.Create(ctx, "test", sender, &emptypb.Empty{}) + require.NoError(t, err) + + t.Run("ok", func(t *testing.T) { + resp, err := m.Execute(ctx, accAddr, sender, &emptypb.Empty{}) + require.NoError(t, err) + require.Equal(t, &emptypb.Empty{}, resp) + }) + + t.Run("unknown account", func(t *testing.T) { + _, err := m.Execute(ctx, []byte("unknown"), sender, &emptypb.Empty{}) + require.ErrorIs(t, err, collections.ErrNotFound) + }) +} + +func TestKeeper_Query(t *testing.T) { + m, ctx := newKeeper(t, map[string]implementation.Account{ + "test": TestAccount{}, + }) + + // create account + sender := []byte("sender") + _, accAddr, err := m.Create(ctx, "test", sender, &emptypb.Empty{}) + require.NoError(t, err) + + t.Run("ok", func(t *testing.T) { + resp, err := m.Query(ctx, accAddr, &emptypb.Empty{}) + require.NoError(t, err) + require.Equal(t, &emptypb.Empty{}, resp) + }) + + t.Run("unknown account", func(t *testing.T) { + _, err := m.Query(ctx, []byte("unknown"), &emptypb.Empty{}) + require.ErrorIs(t, err, collections.ErrNotFound) + }) +}