Skip to content

Commit

Permalink
feat: add basket invariants (#787)
Browse files Browse the repository at this point in the history
* udpate comments

* basket invarinats

* fix build

* fix configurator initialization

* normalize coins

* update mocks

* adding unit tests to basket invariants
  • Loading branch information
robert-zaremba authored Feb 19, 2022
1 parent dbdf4ee commit fa6616a
Show file tree
Hide file tree
Showing 9 changed files with 222 additions and 21 deletions.
6 changes: 3 additions & 3 deletions x/ecocredit/basket/msg_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ const creditTypeAbbrMaxLen = 3

var errBadReq = sdkerrors.ErrInvalidRequest

// first character must be alphabetic, the rest can be alphanumeric. We reduce length constraints by one to account for
// the first character being forced to alphabetic.
// first character must be alphabetic, the rest can be alphanumeric. We reduce length
// constraints by one to account for the first character being forced to alphabetic.
var reName = regexp.MustCompile(fmt.Sprintf("^[[:alpha:]][[:alnum:]]{%d,%d}$", nameMinLen-1, nameMaxLen-1))

// ValidateBasic does a stateless sanity check on the provided data.
Expand Down Expand Up @@ -109,7 +109,7 @@ func validateDateCriteria(d *DateCriteria) error {

// BasketDenom formats denom and display denom:
// * denom: eco.<m.Exponent><m.CreditTypeAbbrev>.<m.Name>
// * display denom: eco.<m.Exponent><m.CreditTypeAbbrev>.<m.Name>
// * display denom: eco.<m.CreditTypeAbbrev>.<m.Name>
// Returns error if MsgCrete.Exponent is not supported
func BasketDenom(name, creditTypeAbbrev string, exponent uint32) (string, string, error) {
const basketDenomPrefix = "eco."
Expand Down
2 changes: 2 additions & 0 deletions x/ecocredit/expected_keepers.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ type BankKeeper interface {
SendCoinsFromModuleToAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins) error
SpendableCoins(ctx sdk.Context, addr sdk.AccAddress) sdk.Coins
SetDenomMetaData(ctx sdk.Context, denomMetaData banktypes.Metadata)

GetSupply(ctx sdk.Context, denom string) sdk.Coin
}

type DistributionKeeper interface {
Expand Down
14 changes: 14 additions & 0 deletions x/ecocredit/mocks/expected_keepers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion x/ecocredit/server/basket/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ func TestFeeToLow(t *testing.T) {
t.Parallel()

s := setupBase(t)

minFee := sdk.NewCoins(sdk.NewCoin("regen", sdk.NewInt(100)))

s.ecocreditKeeper.EXPECT().GetCreateBasketFee(gomock.Any()).Return(minFee)
Expand Down
98 changes: 98 additions & 0 deletions x/ecocredit/server/basket/invariants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package basket

import (
"context"
"fmt"
"sort"
"strings"

sdk "github.com/cosmos/cosmos-sdk/types"

basketv1 "github.com/regen-network/regen-ledger/api/regen/ecocredit/basket/v1"
"github.com/regen-network/regen-ledger/types/math"
"github.com/regen-network/regen-ledger/x/ecocredit"
)

func (k Keeper) RegisterInvariants(ir sdk.InvariantRegistry) {
ir.RegisterRoute(ecocredit.ModuleName, "basket-supply", k.basketSupplyInvariant())
}

func (k Keeper) basketSupplyInvariant() sdk.Invariant {
return func(ctx sdk.Context) (string, bool) {
goCtx := sdk.WrapSDKContext(ctx)

bals, err := k.computeBasketBalances(goCtx)
if err != nil {
return err.Error(), true
}
return BasketSupplyInvariant(ctx, k.stateStore.BasketStore(), k.bankKeeper, bals)
}
}

type bankSupplyStore interface {
GetSupply(ctx sdk.Context, denom string) sdk.Coin
}

// BasketSupplyInvariant cross check the balance of baskets and bank
func BasketSupplyInvariant(ctx sdk.Context, store basketv1.BasketStore, bank bankSupplyStore, basketBalances map[uint64]math.Dec) (string, bool) {
goCtx := sdk.WrapSDKContext(ctx)

bids := make([]uint64, len(basketBalances))
i := 0
for bid := range basketBalances {
bids[i] = bid
i++
}
sort.Slice(bids, func(i, j int) bool { return bids[i] < bids[j] })

var inbalances []string
for _, bid := range bids {
bal := basketBalances[bid]
balInt, err := bal.BigInt()
if err != nil {
return fmt.Sprintf("Can't convert Dec to big.Int, %v", err), true
}
b, err := store.Get(goCtx, bid)
if err != nil {
return fmt.Sprintf("Can't get basket %v: %v", bid, err), true
}
c := bank.GetSupply(ctx, b.BasketDenom)
balSdkInt := sdk.NewIntFromBigInt(balInt)
if !c.Amount.Equal(balSdkInt) {
inbalances = append(inbalances, fmt.Sprintf("Basket denom %s is imbalanced, expected: %v, got %v",
b.BasketDenom, balSdkInt, c.Amount))
}
}
if len(inbalances) != 0 {
return strings.Join(inbalances, "\n"), true
}
return "", false
}

// computeBasketBalances returns a map from basket id to the total number of eco credits
func (k Keeper) computeBasketBalances(ctx context.Context) (map[uint64]math.Dec, error) {
it, err := k.stateStore.BasketBalanceStore().List(ctx, &basketv1.BasketBalancePrimaryKey{})
if err != nil {
return nil, fmt.Errorf("can't create basket balance iterator, %w", err)
}
balances := map[uint64]math.Dec{}
for it.Next() {
b, err := it.Value()
if err != nil {
return nil, fmt.Errorf("Can't get basket balance %w", err)
}
bal, err := math.NewDecFromString(b.Balance)
if err != nil {
return nil, fmt.Errorf("Can't decode balance %s as math.Dec: %w", b.Balance, err)
}
if a, ok := balances[b.BasketId]; ok {
if a, err = a.Add(bal); err != nil {
return nil, fmt.Errorf("Can't add balances: %w", err)
}
balances[b.BasketId] = a
} else {
balances[b.BasketId] = bal
}
}
return balances, nil
}
84 changes: 84 additions & 0 deletions x/ecocredit/server/basket/invariants_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package basket_test

import (
"testing"

sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/stretchr/testify/require"

basketv1 "github.com/regen-network/regen-ledger/api/regen/ecocredit/basket/v1"
"github.com/regen-network/regen-ledger/types/math"
"github.com/regen-network/regen-ledger/x/ecocredit/server/basket"
)

type BasketWithSupply struct {
supply int64
b basketv1.Basket
}

type BankSupplyMock map[string]sdk.Coin

func (bs BankSupplyMock) GetSupply(_ sdk.Context, denom string) sdk.Coin {
if c, ok := bs[denom]; ok {
return c
}
return sdk.NewInt64Coin(denom, 0)
}

func TestBasketSupplyInvarint(t *testing.T) {
require := require.New(t)
s := setupBase(t)

baskets := []BasketWithSupply{
{10, basketv1.Basket{BasketDenom: "bb1", Name: "b1"}},
{20, basketv1.Basket{BasketDenom: "bb2", Name: "b2"}},
}
store := s.stateStore.BasketStore()
basketBalances := map[uint64]math.Dec{}
correctBalances := BankSupplyMock{}
for _, b := range baskets {
id, err := store.InsertReturningID(s.ctx, &b.b)
require.NoError(err)
basketBalances[id] = math.NewDecFromInt64(b.supply)
correctBalances[b.b.BasketDenom] = sdk.NewInt64Coin(b.b.BasketDenom, b.supply)
}

tcs := []struct {
name string
bank BankSupplyMock
msg string
}{
{"no bank supply",
BankSupplyMock{}, "imbalanced"},
{"partial bank supply",
BankSupplyMock{"bb1": newCoin("bb1", 10)}, "bb2 is imbalanced"},
{"smaller bank supply",
BankSupplyMock{"bb1": newCoin("bb1", 8)}, "bb1 is imbalanced"},
{"smaller bank supply2",
BankSupplyMock{"bb1": newCoin("bb1", 10), "bb2": newCoin("bb2", 10)}, "bb2 is imbalanced"},
{"bigger bank supply",
BankSupplyMock{"bb1": newCoin("bb1", 10), "bb2": newCoin("bb2", 30)}, "bb2 is imbalanced"},

{"all good",
correctBalances, ""},
{"more denoms",
BankSupplyMock{"bb1": newCoin("bb1", 10), "bb2": newCoin("bb2", 20), "other": newCoin("other", 100)}, ""},
}

for _, tc := range tcs {
tc.bank.GetSupply(s.sdkCtx, "abc")

msg, _ := basket.BasketSupplyInvariant(s.sdkCtx, store, tc.bank, basketBalances)
if tc.msg != "" {
require.Contains(msg, tc.msg, tc.name)
} else {
require.Empty(msg, tc.name)
}
}

t.Log(baskets)
}

func newCoin(denom string, a int64) sdk.Coin {
return sdk.NewInt64Coin(denom, a)
}
28 changes: 16 additions & 12 deletions x/ecocredit/server/invariants.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package server
import (
"context"
"fmt"

"github.com/cosmos/cosmos-sdk/store/types"
sdk "github.com/cosmos/cosmos-sdk/types"

"github.com/regen-network/regen-ledger/types/math"
"github.com/regen-network/regen-ledger/x/ecocredit"
baskettypes "github.com/regen-network/regen-ledger/x/ecocredit/basket"
Expand All @@ -14,6 +16,7 @@ import (
func (s serverImpl) RegisterInvariants(ir sdk.InvariantRegistry) {
ir.RegisterRoute(ecocredit.ModuleName, "tradable-supply", s.tradableSupplyInvariant())
ir.RegisterRoute(ecocredit.ModuleName, "retired-supply", s.retiredSupplyInvariant())
s.basketKeeper.RegisterInvariants(ir)
}

func (s serverImpl) tradableSupplyInvariant() sdk.Invariant {
Expand All @@ -30,7 +33,7 @@ func (s serverImpl) getBasketBalanceMap(ctx context.Context) map[string]math.Dec
if err != nil {
panic(err)
}
basketBalances := make(map[string]math.Dec) // map of batch_denom to balance
batchBalances := make(map[string]math.Dec) // map of a basket batch_denom to balance
for _, basket := range res.Baskets {
res, err := s.basketKeeper.BasketBalances(ctx, &baskettypes.QueryBasketBalancesRequest{BasketDenom: basket.BasketDenom})
if err != nil {
Expand All @@ -41,54 +44,55 @@ func (s serverImpl) getBasketBalanceMap(ctx context.Context) map[string]math.Dec
if err != nil {
panic(err)
}
if existingBal, ok := basketBalances[bal.BatchDenom]; ok {
if existingBal, ok := batchBalances[bal.BatchDenom]; ok {
existingBal, err = existingBal.Add(amount)
if err != nil {
panic(err)
}
basketBalances[bal.BatchDenom] = existingBal
batchBalances[bal.BatchDenom] = existingBal
} else {
basketBalances[bal.BatchDenom] = amount
batchBalances[bal.BatchDenom] = amount
}
}
}
return basketBalances
return batchBalances
}

func tradableSupplyInvariant(store types.KVStore, basketBalances map[string]math.Dec) (string, bool) {
var (
msg string
broken bool
)
calTradableSupplies := make(map[string]math.Dec)
// sum of tradeable eco credits with credits locked in baskets
sumBatchSupplies := make(map[string]math.Dec) // map batch denom => balance

ecocredit.IterateBalances(store, ecocredit.TradableBalancePrefix, func(_, denom, b string) bool {
balance, err := math.NewNonNegativeDecFromString(b)
if err != nil {
broken = true
msg += fmt.Sprintf("error while parsing tradable balance %v", err)
}
if supply, ok := calTradableSupplies[denom]; ok {
if supply, ok := sumBatchSupplies[denom]; ok {
supply, err := math.SafeAddBalance(supply, balance)
if err != nil {
broken = true
msg += fmt.Sprintf("error adding credit batch tradable supply %v", err)
}
calTradableSupplies[denom] = supply
sumBatchSupplies[denom] = supply
} else {
calTradableSupplies[denom] = balance
sumBatchSupplies[denom] = balance
}

return false
})

for denom, amt := range basketBalances {
if amount, ok := calTradableSupplies[denom]; ok {
if amount, ok := sumBatchSupplies[denom]; ok {
amount, err := math.SafeAddBalance(amount, amt)
if err != nil {
panic(err)
}
calTradableSupplies[denom] = amount
sumBatchSupplies[denom] = amount
} else {
panic("unknown denom in basket")
}
Expand All @@ -100,7 +104,7 @@ func tradableSupplyInvariant(store types.KVStore, basketBalances map[string]math
broken = true
msg += fmt.Sprintf("error while parsing tradable supply for denom: %s", denom)
}
if s1, ok := calTradableSupplies[denom]; ok {
if s1, ok := sumBatchSupplies[denom]; ok {
if supply.Cmp(s1) != 0 {
broken = true
msg += fmt.Sprintf("tradable supply is incorrect for %s credit batch, expected %v, got %v", denom, supply, s1)
Expand Down
2 changes: 0 additions & 2 deletions x/ecocredit/server/invariants_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,7 @@ func TestTradableSupplyInvariants(t *testing.T) {
ctx, storeKey := setupStore(t)
store := ctx.KVStore(storeKey)
t.Run(tc.msg, func(t *testing.T) {

initBalances(t, store, tc.balances)

initSupply(t, store, tc.supply)

msg, broken := tradableSupplyInvariant(store, tc.basketBalance)
Expand Down
8 changes: 5 additions & 3 deletions x/ecocredit/server/testsuite/suite.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"github.com/regen-network/regen-ledger/x/ecocredit"
)

const basketFeeDenom = "bfee"

type IntegrationTestSuite struct {
suite.Suite

Expand Down Expand Up @@ -93,7 +95,7 @@ func (s *IntegrationTestSuite) SetupSuite() {
Precision: 6,
},
)
s.basketFee = sdk.NewInt64Coin("foo", 20)
s.basketFee = sdk.NewInt64Coin(basketFeeDenom, 20)
ecocreditParams.BasketCreationFee = sdk.NewCoins(s.basketFee)
s.paramSpace.SetParamSet(s.sdkCtx, &ecocreditParams)

Expand All @@ -116,7 +118,7 @@ func (s *IntegrationTestSuite) TestBasketScenario() {
classId, batchDenom := s.createClassAndIssueBatch(user, user, "bazcredits", userTotalCreditBalance.String(), "2020-01-01", "2022-01-01")

// fund account to create a basket
balanceBefore := sdk.NewInt64Coin("foo", 30000)
balanceBefore := sdk.NewInt64Coin(basketFeeDenom, 30000)
s.fundAccount(user, sdk.NewCoins(balanceBefore))
s.mockDist.EXPECT().FundCommunityPool(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(interface{}, interface{}, interface{}) error {
err := s.bankKeeper.SendCoinsFromAccountToModule(s.sdkCtx, user, ecocredit.ModuleName, sdk.NewCoins(s.basketFee))
Expand Down Expand Up @@ -144,7 +146,7 @@ func (s *IntegrationTestSuite) TestBasketScenario() {

// assert the fee was paid - the fee mechanism was mocked, but we still call the same underlying SendFromAccountToModule
// function so the result is the same
balanceAfter := s.getUserBalance(user, "foo")
balanceAfter := s.getUserBalance(user, basketFeeDenom)
require.Equal(balanceAfter.Add(s.basketFee), balanceBefore)

// put some BAZ credits in the basket
Expand Down

0 comments on commit fa6616a

Please sign in to comment.