diff --git a/x/ecocredit/server/core/create_project.go b/x/ecocredit/server/core/create_project.go new file mode 100644 index 0000000000..a6052d4c22 --- /dev/null +++ b/x/ecocredit/server/core/create_project.go @@ -0,0 +1,86 @@ +package core + +import ( + "context" + "github.com/cosmos/cosmos-sdk/orm/types/ormerrors" + ecocreditv1beta1 "github.com/regen-network/regen-ledger/api/regen/ecocredit/v1beta1" + "github.com/regen-network/regen-ledger/types" + "github.com/regen-network/regen-ledger/x/ecocredit" + "github.com/regen-network/regen-ledger/x/ecocredit/v1beta1" +) + +// CreateProject creates a new project for a specific credit class. +func (k Keeper) CreateProject(ctx context.Context, req *v1beta1.MsgCreateProject) (*v1beta1.MsgCreateProjectResponse, error) { + sdkCtx := types.UnwrapSDKContext(ctx) + classID := req.ClassId + classInfo, err := k.stateStore.ClassInfoStore().GetByName(ctx, classID) + if err != nil { + return nil, err + } + + err = k.assertClassIssuer(ctx, classInfo.Id, req.Issuer) + if err != nil { + return nil, err + } + + projectID := req.ProjectId + if projectID == "" { + exists := true + for ; exists; sdkCtx.GasMeter().ConsumeGas(gasCostPerIteration, "project id sequence") { + projectID, err = k.genProjectID(ctx, classInfo.Id, classInfo.Name) + if err != nil { + return nil, err + } + exists, err = k.stateStore.ProjectInfoStore().HasByClassIdName(ctx, classInfo.Id, projectID) + if err != nil { + return nil, err + } + } + } + + if err = k.stateStore.ProjectInfoStore().Insert(ctx, &ecocreditv1beta1.ProjectInfo{ + Name: projectID, + ClassId: classInfo.Id, + ProjectLocation: req.ProjectLocation, + Metadata: req.Metadata, + }); err != nil { + return nil, err + } + + if err := sdkCtx.EventManager().EmitTypedEvent(&v1beta1.EventCreateProject{ + ClassId: classID, + ProjectId: projectID, + Issuer: req.Issuer, + ProjectLocation: req.ProjectLocation, + }); err != nil { + return nil, err + } + + return &v1beta1.MsgCreateProjectResponse{ + ProjectId: projectID, + }, nil +} + +// genProjectID generates a projectID when no projectID was given for CreateProject. +// The ID is generated by concatenating the classID and a sequence number. +func (k Keeper) genProjectID(ctx context.Context, classRowID uint64, classID string) (string, error) { + var nextID uint64 + projectSeqNo, err := k.stateStore.ProjectSequenceStore().Get(ctx, classRowID) + switch err { + case ormerrors.NotFound: + nextID = 1 + case nil: + nextID = projectSeqNo.NextProjectId + default: + return "", err + } + + if err = k.stateStore.ProjectSequenceStore().Save(ctx, &ecocreditv1beta1.ProjectSequence{ + ClassId: classRowID, + NextProjectId: nextID + 1, + }); err != nil { + return "", err + } + + return ecocredit.FormatProjectID(classID, nextID), nil +} diff --git a/x/ecocredit/server/core/create_project_test.go b/x/ecocredit/server/core/create_project_test.go new file mode 100644 index 0000000000..2a3ec33333 --- /dev/null +++ b/x/ecocredit/server/core/create_project_test.go @@ -0,0 +1,101 @@ +package core + +import ( + "context" + "github.com/cosmos/cosmos-sdk/orm/types/ormerrors" + "github.com/cosmos/cosmos-sdk/types" + ecocreditv1beta1 "github.com/regen-network/regen-ledger/api/regen/ecocredit/v1beta1" + "github.com/regen-network/regen-ledger/x/ecocredit/v1beta1" + "gotest.tools/v3/assert" + "testing" +) + +func TestCreateProject_ValidProjectState(t *testing.T) { + t.Parallel() + s := setupBase(t) + makeClass(t, s.ctx, s.stateStore, s.addr) + res, err := s.k.CreateProject(s.ctx, &v1beta1.MsgCreateProject{ + Issuer: s.addr.String(), + ClassId: "C01", + Metadata: nil, + ProjectLocation: "US-NY", + ProjectId: "FOO", + }) + assert.NilError(t, err) + assert.Equal(t, res.ProjectId, "FOO") + + project, err := s.stateStore.ProjectInfoStore().GetByName(s.ctx, "FOO") + assert.NilError(t, err) + assert.Equal(t, project.ProjectLocation, "US-NY") +} + +func TestCreateProject_GeneratedProjectID(t *testing.T) { + t.Parallel() + s := setupBase(t) + makeClass(t, s.ctx, s.stateStore, s.addr) + res, err := s.k.CreateProject(s.ctx, &v1beta1.MsgCreateProject{ + Issuer: s.addr.String(), + ClassId: "C01", + Metadata: nil, + ProjectLocation: "US-NY", + ProjectId: "", + }) + assert.NilError(t, err) + assert.Equal(t, res.ProjectId, "C0101", "got project id: %s", res.ProjectId) + + res, err = s.k.CreateProject(s.ctx, &v1beta1.MsgCreateProject{ + Issuer: s.addr.String(), + ClassId: "C01", + Metadata: nil, + ProjectLocation: "US-NY", + ProjectId: "", + }) + assert.NilError(t, err) + assert.Equal(t, res.ProjectId, "C0102", "got project id: %s", res.ProjectId) +} + +func TestCreateProject_BadClassID(t *testing.T) { + t.Parallel() + s := setupBase(t) + _, err := s.k.CreateProject(s.ctx, &v1beta1.MsgCreateProject{ + Issuer: s.addr.String(), + ClassId: "NOPE", + ProjectLocation: "US-NY", + ProjectId: "", + }) + assert.ErrorContains(t, err, "not found") +} + +func TestCreateProject_NoDuplicates(t *testing.T) { + t.Parallel() + s := setupBase(t) + makeClass(t, s.ctx, s.stateStore, s.addr) + _, err := s.k.CreateProject(s.ctx, &v1beta1.MsgCreateProject{ + Issuer: s.addr.String(), + ClassId: "C01", + ProjectLocation: "US-NY", + ProjectId: "FOO", + }) + assert.NilError(t, err) + + _, err = s.k.CreateProject(s.ctx, &v1beta1.MsgCreateProject{ + Issuer: s.addr.String(), + ClassId: "C01", + ProjectLocation: "US-NY", + ProjectId: "FOO", + }) + assert.ErrorContains(t, err, ormerrors.UniqueKeyViolation.Error()) +} + +func makeClass(t *testing.T, ctx context.Context, ss ecocreditv1beta1.StateStore, addr types.AccAddress) { + assert.NilError(t, ss.ClassInfoStore().Insert(ctx, &ecocreditv1beta1.ClassInfo{ + Name: "C01", + Admin: addr, + Metadata: nil, + CreditType: "C", + })) + assert.NilError(t, ss.ClassIssuerStore().Insert(ctx, &ecocreditv1beta1.ClassIssuer{ + ClassId: 1, + Issuer: addr, + })) +} diff --git a/x/ecocredit/server/core/keeper.go b/x/ecocredit/server/core/keeper.go index c491023017..dc0ee850f1 100644 --- a/x/ecocredit/server/core/keeper.go +++ b/x/ecocredit/server/core/keeper.go @@ -7,6 +7,10 @@ import ( "github.com/regen-network/regen-ledger/x/ecocredit" ) +// TODO: Revisit this once we have proper gas fee framework. +// Tracking issues https://github.com/cosmos/cosmos-sdk/issues/9054, https://github.com/cosmos/cosmos-sdk/discussions/9072 +const gasCostPerIteration = uint64(10) + type Keeper struct { stateStore ecocreditv1beta1.StateStore bankKeeper ecocredit.BankKeeper diff --git a/x/ecocredit/server/core/utils.go b/x/ecocredit/server/core/utils.go new file mode 100644 index 0000000000..377ecdd00d --- /dev/null +++ b/x/ecocredit/server/core/utils.go @@ -0,0 +1,21 @@ +package core + +import ( + "context" + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" +) + +// assertClassIssuer makes sure that the issuer is part of issuers of given classID. +// Returns ErrUnauthorized otherwise. +func (k Keeper) assertClassIssuer(goCtx context.Context, classID uint64, issuer string) error { + addr, _ := sdk.AccAddressFromBech32(issuer) + found, err := k.stateStore.ClassIssuerStore().Has(goCtx, classID, addr) + if err != nil { + return err + } + if !found { + return sdkerrors.ErrUnauthorized.Wrapf("%s is not an issuer for the class", issuer) + } + return nil +}