mirror of
				https://github.com/0glabs/0g-chain.git
				synced 2025-04-04 15:55:23 +00:00 
			
		
		
		
	Add Auction Simulations (#419)
* first pass * fix bid amount calculation * untested refactor of sim ops and genesis * refactor operations and fix auction bug * add param changes and genesis * address minor TODO * add first draft of invariants * improve param generation * complete invariants * fix genesis tests * log no-op better * small fixes * add missed comma Co-authored-by: John Maheswaran <john@kava.io>
This commit is contained in:
		
							parent
							
								
									8d199746cd
								
							
						
					
					
						commit
						5bdffd5c1c
					
				
							
								
								
									
										13
									
								
								app/app.go
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								app/app.go
									
									
									
									
									
								
							| @ -302,16 +302,15 @@ func NewApp(logger log.Logger, db dbm.DB, traceStore io.Writer, loadLatest bool, | ||||
| 
 | ||||
| 	app.mm.SetOrderEndBlockers(crisis.ModuleName, gov.ModuleName, staking.ModuleName, pricefeed.ModuleName) | ||||
| 
 | ||||
| 	// Note: genutils must occur after staking so that pools are properly
 | ||||
| 	// initialized with tokens from genesis accounts.
 | ||||
| 	//
 | ||||
| 	// Note: Changing the order of the auth module and modules that use module accounts
 | ||||
| 	// results in subtle changes to the way accounts are loaded from genesis.
 | ||||
| 	app.mm.SetOrderInitGenesis( | ||||
| 		auth.ModuleName, validatorvesting.ModuleName, distr.ModuleName, | ||||
| 		auth.ModuleName, // loads all accounts - should run before any module with a module account
 | ||||
| 		validatorvesting.ModuleName, distr.ModuleName, | ||||
| 		staking.ModuleName, bank.ModuleName, slashing.ModuleName, | ||||
| 		gov.ModuleName, mint.ModuleName, supply.ModuleName, crisis.ModuleName, genutil.ModuleName, | ||||
| 		gov.ModuleName, mint.ModuleName, | ||||
| 		pricefeed.ModuleName, cdp.ModuleName, auction.ModuleName, bep3.ModuleName, kavadist.ModuleName, // TODO is this order ok?
 | ||||
| 		supply.ModuleName,  // calculates the total supply from account - should run after modules that modify accounts in genesis
 | ||||
| 		crisis.ModuleName,  // runs the invariants at genesis - should run after other modules
 | ||||
| 		genutil.ModuleName, // genutils must occur after staking so that pools are properly initialized with tokens from genesis accounts.
 | ||||
| 	) | ||||
| 
 | ||||
| 	app.mm.RegisterInvariants(&app.crisisKeeper) | ||||
|  | ||||
| @ -35,6 +35,7 @@ import ( | ||||
| 	stakingsimops "github.com/cosmos/cosmos-sdk/x/staking/simulation/operations" | ||||
| 	"github.com/cosmos/cosmos-sdk/x/supply" | ||||
| 
 | ||||
| 	auctionsimops "github.com/kava-labs/kava/x/auction/simulation/operations" | ||||
| 	bep3simops "github.com/kava-labs/kava/x/bep3/simulation/operations" | ||||
| 	pricefeedsimops "github.com/kava-labs/kava/x/pricefeed/simulation/operations" | ||||
| ) | ||||
| @ -59,6 +60,7 @@ const ( | ||||
| 	OpWeightMsgUndelegate                              = "op_weight_msg_undelegate" | ||||
| 	OpWeightMsgBeginRedelegate                         = "op_weight_msg_begin_redelegate" | ||||
| 	OpWeightMsgUnjail                                  = "op_weight_msg_unjail" | ||||
| 	OpWeightMsgPlaceBid                                = "op_weight_msg_place_bid" | ||||
| 	OpWeightMsgPricefeed                               = "op_weight_msg_pricefeed" | ||||
| 	OpWeightMsgCreateAtomicSwap                        = "op_weight_msg_create_atomic_Swap" | ||||
| ) | ||||
| @ -269,6 +271,17 @@ func testAndRunTxs(app *App, config simulation.Config) []simulation.WeightedOper | ||||
| 			}(nil), | ||||
| 			slashingsimops.SimulateMsgUnjail(app.slashingKeeper), | ||||
| 		}, | ||||
| 		{ | ||||
| 			func(_ *rand.Rand) int { | ||||
| 				var v int | ||||
| 				ap.GetOrGenerate(app.cdc, OpWeightMsgPlaceBid, &v, nil, | ||||
| 					func(_ *rand.Rand) { | ||||
| 						v = 100 | ||||
| 					}) | ||||
| 				return v | ||||
| 			}(nil), | ||||
| 			auctionsimops.SimulateMsgPlaceBid(app.accountKeeper, app.auctionKeeper), | ||||
| 		}, | ||||
| 		{ | ||||
| 			func(_ *rand.Rand) int { | ||||
| 				var v int | ||||
| @ -285,7 +298,7 @@ func testAndRunTxs(app *App, config simulation.Config) []simulation.WeightedOper | ||||
| 				var v int | ||||
| 				ap.GetOrGenerate(app.cdc, OpWeightMsgPricefeed, &v, nil, | ||||
| 					func(_ *rand.Rand) { | ||||
| 						v = 10000 // TODO
 | ||||
| 						v = 100 | ||||
| 					}) | ||||
| 				return v | ||||
| 			}(nil), | ||||
|  | ||||
| @ -55,6 +55,7 @@ var ( | ||||
| 	// functions aliases
 | ||||
| 	NewKeeper                            = keeper.NewKeeper | ||||
| 	NewQuerier                           = keeper.NewQuerier | ||||
| 	RegisterInvariants                   = keeper.RegisterInvariants | ||||
| 	NewSurplusAuction                    = types.NewSurplusAuction | ||||
| 	NewDebtAuction                       = types.NewDebtAuction | ||||
| 	NewCollateralAuction                 = types.NewCollateralAuction | ||||
|  | ||||
| @ -22,7 +22,7 @@ func InitGenesis(ctx sdk.Context, keeper Keeper, supplyKeeper types.SupplyKeeper | ||||
| 	for _, a := range gs.Auctions { | ||||
| 		keeper.SetAuction(ctx, a) | ||||
| 		// find the total coins that should be present in the module account
 | ||||
| 		totalAuctionCoins.Add(a.GetModuleAccountCoins()) | ||||
| 		totalAuctionCoins = totalAuctionCoins.Add(a.GetModuleAccountCoins()) | ||||
| 	} | ||||
| 
 | ||||
| 	// check if the module account exists
 | ||||
|  | ||||
| @ -29,6 +29,12 @@ func TestInitGenesis(t *testing.T) { | ||||
| 		tApp := app.NewTestApp() | ||||
| 		keeper := tApp.GetAuctionKeeper() | ||||
| 		ctx := tApp.NewContext(true, abci.Header{}) | ||||
| 		// setup module account
 | ||||
| 		supplyKeeper := tApp.GetSupplyKeeper() | ||||
| 		moduleAcc := supplyKeeper.GetModuleAccount(ctx, auction.ModuleName) | ||||
| 		require.NoError(t, moduleAcc.SetCoins(testAuction.GetModuleAccountCoins())) | ||||
| 		supplyKeeper.SetModuleAccount(ctx, moduleAcc) | ||||
| 
 | ||||
| 		// create genesis
 | ||||
| 		gs := auction.NewGenesisState( | ||||
| 			10, | ||||
| @ -38,7 +44,7 @@ func TestInitGenesis(t *testing.T) { | ||||
| 
 | ||||
| 		// run init
 | ||||
| 		require.NotPanics(t, func() { | ||||
| 			auction.InitGenesis(ctx, keeper, tApp.GetSupplyKeeper(), gs) | ||||
| 			auction.InitGenesis(ctx, keeper, supplyKeeper, gs) | ||||
| 		}) | ||||
| 
 | ||||
| 		// check state is as expected
 | ||||
| @ -59,7 +65,7 @@ func TestInitGenesis(t *testing.T) { | ||||
| 			return false | ||||
| 		}) | ||||
| 	}) | ||||
| 	t.Run("invalid", func(t *testing.T) { | ||||
| 	t.Run("invalid (invalid nextAuctionID)", func(t *testing.T) { | ||||
| 		// setup keepers
 | ||||
| 		tApp := app.NewTestApp() | ||||
| 		ctx := tApp.NewContext(true, abci.Header{}) | ||||
| @ -71,6 +77,24 @@ func TestInitGenesis(t *testing.T) { | ||||
| 			auction.GenesisAuctions{testAuction}, | ||||
| 		) | ||||
| 
 | ||||
| 		// check init fails
 | ||||
| 		require.Panics(t, func() { | ||||
| 			auction.InitGenesis(ctx, tApp.GetAuctionKeeper(), tApp.GetSupplyKeeper(), gs) | ||||
| 		}) | ||||
| 	}) | ||||
| 	t.Run("invalid (missing mod account coins)", func(t *testing.T) { | ||||
| 		// setup keepers
 | ||||
| 		tApp := app.NewTestApp() | ||||
| 		ctx := tApp.NewContext(true, abci.Header{}) | ||||
| 
 | ||||
| 		// create invalid genesis
 | ||||
| 		gs := auction.NewGenesisState( | ||||
| 			10, | ||||
| 			auction.DefaultParams(), | ||||
| 			auction.GenesisAuctions{testAuction}, | ||||
| 		) | ||||
| 		// invalid as there is no module account setup
 | ||||
| 
 | ||||
| 		// check init fails
 | ||||
| 		require.Panics(t, func() { | ||||
| 			auction.InitGenesis(ctx, tApp.GetAuctionKeeper(), tApp.GetSupplyKeeper(), gs) | ||||
|  | ||||
| @ -175,7 +175,7 @@ func (k Keeper) PlaceBidSurplus(ctx sdk.Context, a types.SurplusAuction, bidder | ||||
| 		), | ||||
| 	) | ||||
| 	if bid.Amount.LT(minNewBidAmt) { | ||||
| 		return a, types.ErrBidTooSmall(k.codespace, bid, sdk.NewCoin(a.Bid.Denom, minNewBidAmt)) | ||||
| 		return a, types.ErrBidTooSmall(k.codespace, bid, sdk.Coin{Denom: a.Bid.Denom, Amount: minNewBidAmt}) // not using NewCoin as it can panic
 | ||||
| 	} | ||||
| 
 | ||||
| 	// New bidder pays back old bidder
 | ||||
| @ -239,7 +239,7 @@ func (k Keeper) PlaceForwardBidCollateral(ctx sdk.Context, a types.CollateralAuc | ||||
| 	) | ||||
| 	minNewBidAmt = sdk.MinInt(minNewBidAmt, a.MaxBid.Amount) // allow new bids to hit MaxBid even though it may be less than the increment %
 | ||||
| 	if bid.Amount.LT(minNewBidAmt) { | ||||
| 		return a, types.ErrBidTooSmall(k.codespace, bid, sdk.NewCoin(a.Bid.Denom, minNewBidAmt)) | ||||
| 		return a, types.ErrBidTooSmall(k.codespace, bid, sdk.Coin{Denom: a.Bid.Denom, Amount: minNewBidAmt}) // not using NewCoin as it can panic
 | ||||
| 	} | ||||
| 	if a.MaxBid.IsLT(bid) { | ||||
| 		return a, types.ErrBidTooLarge(k.codespace, bid, a.MaxBid) | ||||
| @ -314,10 +314,10 @@ func (k Keeper) PlaceReverseBidCollateral(ctx sdk.Context, a types.CollateralAuc | ||||
| 		), | ||||
| 	) | ||||
| 	if lot.Amount.GT(maxNewLotAmt) { | ||||
| 		return a, types.ErrLotTooLarge(k.codespace, lot, sdk.NewCoin(a.Lot.Denom, maxNewLotAmt)) | ||||
| 		return a, types.ErrLotTooLarge(k.codespace, lot, sdk.Coin{Denom: a.Lot.Denom, Amount: maxNewLotAmt}) // not using NewCoin as it can panic
 | ||||
| 	} | ||||
| 	if lot.IsNegative() { | ||||
| 		return a, types.ErrLotTooSmall(k.codespace, lot, sdk.NewCoin(a.Lot.Denom, sdk.ZeroInt())) | ||||
| 		return a, types.ErrLotTooSmall(k.codespace, lot, sdk.Coin{Denom: a.Lot.Denom, Amount: sdk.ZeroInt()}) | ||||
| 	} | ||||
| 
 | ||||
| 	// New bidder pays back old bidder
 | ||||
| @ -380,10 +380,10 @@ func (k Keeper) PlaceBidDebt(ctx sdk.Context, a types.DebtAuction, bidder sdk.Ac | ||||
| 		), | ||||
| 	) | ||||
| 	if lot.Amount.GT(maxNewLotAmt) { | ||||
| 		return a, types.ErrLotTooLarge(k.codespace, lot, sdk.NewCoin(a.Lot.Denom, maxNewLotAmt)) | ||||
| 		return a, types.ErrLotTooLarge(k.codespace, lot, sdk.Coin{Denom: a.Lot.Denom, Amount: maxNewLotAmt}) // not using NewCoin as it can panic
 | ||||
| 	} | ||||
| 	if lot.IsNegative() { | ||||
| 		return a, types.ErrLotTooSmall(k.codespace, lot, sdk.NewCoin(a.Lot.Denom, sdk.ZeroInt())) | ||||
| 		return a, types.ErrLotTooSmall(k.codespace, lot, sdk.Coin{Denom: a.Lot.Denom, Amount: sdk.ZeroInt()}) | ||||
| 	} | ||||
| 
 | ||||
| 	// New bidder pays back old bidder
 | ||||
|  | ||||
							
								
								
									
										143
									
								
								x/auction/keeper/invariants.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								x/auction/keeper/invariants.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,143 @@ | ||||
| package keeper | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/cosmos/cosmos-sdk/store/prefix" | ||||
| 	sdk "github.com/cosmos/cosmos-sdk/types" | ||||
| 
 | ||||
| 	"github.com/kava-labs/kava/x/auction/types" | ||||
| ) | ||||
| 
 | ||||
| // RegisterInvariants registers all staking invariants
 | ||||
| func RegisterInvariants(ir sdk.InvariantRegistry, k Keeper) { | ||||
| 
 | ||||
| 	ir.RegisterRoute(types.ModuleName, "module-account", | ||||
| 		ModuleAccountInvariants(k)) | ||||
| 	ir.RegisterRoute(types.ModuleName, "valid-auctions", | ||||
| 		ValidAuctionInvariant(k)) | ||||
| 	ir.RegisterRoute(types.ModuleName, "valid-index", | ||||
| 		ValidIndexInvariant(k)) | ||||
| } | ||||
| 
 | ||||
| // ModuleAccountInvariant checks that the module account's coins matches those stored in auctions
 | ||||
| func ModuleAccountInvariants(k Keeper) sdk.Invariant { | ||||
| 	return func(ctx sdk.Context) (string, bool) { | ||||
| 
 | ||||
| 		totalAuctionCoins := sdk.NewCoins() | ||||
| 		k.IterateAuctions(ctx, func(auction types.Auction) bool { | ||||
| 			a, ok := auction.(types.GenesisAuction) | ||||
| 			if !ok { | ||||
| 				panic("stored auction type does not fulfill GenesisAuction interface") | ||||
| 			} | ||||
| 			totalAuctionCoins = totalAuctionCoins.Add(a.GetModuleAccountCoins()) | ||||
| 			return false | ||||
| 		}) | ||||
| 
 | ||||
| 		moduleAccCoins := k.supplyKeeper.GetModuleAccount(ctx, types.ModuleName).GetCoins() | ||||
| 		broken := !moduleAccCoins.IsEqual(totalAuctionCoins) | ||||
| 
 | ||||
| 		invariantMessage := sdk.FormatInvariant( | ||||
| 			types.ModuleName, | ||||
| 			"module account", | ||||
| 			fmt.Sprintf( | ||||
| 				"\texpected ModuleAccount coins: %s\n"+ | ||||
| 					"\tactual ModuleAccount coins:   %s\n", | ||||
| 				totalAuctionCoins, moduleAccCoins), | ||||
| 		) | ||||
| 		return invariantMessage, broken | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // ValidAuctionInvariant verifies that all auctions in the store are independently valid
 | ||||
| func ValidAuctionInvariant(k Keeper) sdk.Invariant { | ||||
| 	return func(ctx sdk.Context) (string, bool) { | ||||
| 		var validationErr error | ||||
| 		var invalidAuction types.Auction | ||||
| 		k.IterateAuctions(ctx, func(auction types.Auction) bool { | ||||
| 			a, ok := auction.(types.GenesisAuction) | ||||
| 			if !ok { | ||||
| 				panic("stored auction type does not fulfill GenesisAuction interface") | ||||
| 			} | ||||
| 
 | ||||
| 			currentTime := ctx.BlockTime() | ||||
| 			if !currentTime.Equal(time.Time{}) { // this avoids a simulator bug where app.InitGenesis is called with blockTime=0 instead of the correct time
 | ||||
| 				if a.GetEndTime().Before(currentTime) { | ||||
| 					validationErr = fmt.Errorf("endTime after current block time (%s)", currentTime) | ||||
| 					invalidAuction = a | ||||
| 					return true | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			if err := a.Validate(); err != nil { | ||||
| 				validationErr = err | ||||
| 				invalidAuction = a | ||||
| 				return true | ||||
| 			} | ||||
| 			return false | ||||
| 		}) | ||||
| 
 | ||||
| 		broken := validationErr != nil | ||||
| 		invariantMessage := sdk.FormatInvariant( | ||||
| 			types.ModuleName, | ||||
| 			"valid auctions", | ||||
| 			fmt.Sprintf( | ||||
| 				"\tfound invalid auction, reason: %s\n"+ | ||||
| 					"\tauction:\n\t%s\n", | ||||
| 				validationErr, invalidAuction), | ||||
| 		) | ||||
| 		return invariantMessage, broken | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // ValidIndexInvariant checks that all auctions in the store are also in the index and vice versa.
 | ||||
| func ValidIndexInvariant(k Keeper) sdk.Invariant { | ||||
| 	return func(ctx sdk.Context) (string, bool) { | ||||
| 		/* Method: | ||||
| 		- check all the auction IDs in the index have a corresponding auction in the store | ||||
| 		- index is now valid but there could be extra auction in the store | ||||
| 		- check for these extra auctions by checking num items in the store equals that of index (store keys are always unique) | ||||
| 		- doesn't check the IDs in the auction structs match the IDs in the keys | ||||
| 		*/ | ||||
| 
 | ||||
| 		// Check all auction IDs in the index are in the auction store
 | ||||
| 		store := prefix.NewStore(ctx.KVStore(k.storeKey), types.AuctionKeyPrefix) | ||||
| 
 | ||||
| 		indexIterator := sdk.KVStorePrefixIterator(ctx.KVStore(k.storeKey), types.AuctionByTimeKeyPrefix) | ||||
| 		defer indexIterator.Close() | ||||
| 
 | ||||
| 		var indexLength int | ||||
| 		for ; indexIterator.Valid(); indexIterator.Next() { | ||||
| 			indexLength++ | ||||
| 
 | ||||
| 			idBytes := indexIterator.Value() | ||||
| 			auctionBytes := store.Get(idBytes) | ||||
| 			if auctionBytes == nil { | ||||
| 				invariantMessage := sdk.FormatInvariant( | ||||
| 					types.ModuleName, | ||||
| 					"valid index", | ||||
| 					fmt.Sprintf("\tauction with ID '%d' found in index but not in store", types.Uint64FromBytes(idBytes))) | ||||
| 				return invariantMessage, true | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		// Check length of auction store matches the length of the index
 | ||||
| 		storeIterator := sdk.KVStorePrefixIterator(ctx.KVStore(k.storeKey), types.AuctionKeyPrefix) | ||||
| 		defer storeIterator.Close() | ||||
| 		var storeLength int | ||||
| 		for ; storeIterator.Valid(); storeIterator.Next() { | ||||
| 			storeLength++ | ||||
| 		} | ||||
| 
 | ||||
| 		if storeLength != indexLength { | ||||
| 			invariantMessage := sdk.FormatInvariant( | ||||
| 				types.ModuleName, | ||||
| 				"valid index", | ||||
| 				fmt.Sprintf("\tmismatched number of items in auction store (%d) and index (%d)", storeLength, indexLength)) | ||||
| 			return invariantMessage, true | ||||
| 		} | ||||
| 
 | ||||
| 		return "", false | ||||
| 	} | ||||
| } | ||||
| @ -110,8 +110,10 @@ func NewAppModule(keeper Keeper, supplyKeeper types.SupplyKeeper) AppModule { | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // RegisterInvariants performs a no-op.
 | ||||
| func (AppModule) RegisterInvariants(_ sdk.InvariantRegistry) {} | ||||
| // RegisterInvariants registers the module invariants.
 | ||||
| func (am AppModule) RegisterInvariants(ir sdk.InvariantRegistry) { | ||||
| 	RegisterInvariants(ir, am.keeper) | ||||
| } | ||||
| 
 | ||||
| // Route module message route name
 | ||||
| func (AppModule) Route() string { | ||||
|  | ||||
| @ -2,21 +2,170 @@ package simulation | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"math/rand" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/cosmos/cosmos-sdk/codec" | ||||
| 	sdk "github.com/cosmos/cosmos-sdk/types" | ||||
| 	"github.com/cosmos/cosmos-sdk/types/module" | ||||
| 	"github.com/cosmos/cosmos-sdk/x/auth" | ||||
| 	authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" | ||||
| 	"github.com/cosmos/cosmos-sdk/x/simulation" | ||||
| 	"github.com/cosmos/cosmos-sdk/x/supply" | ||||
| 
 | ||||
| 	"github.com/kava-labs/kava/x/auction/types" | ||||
| 	cdptypes "github.com/kava-labs/kava/x/cdp/types" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	// Block time params are un-exported constants in cosmos-sdk/x/simulation.
 | ||||
| 	// Copy them here in lieu of importing them.
 | ||||
| 	minTimePerBlock time.Duration = (10000 / 2) * time.Second | ||||
| 	maxTimePerBlock time.Duration = 10000 * time.Second | ||||
| 
 | ||||
| 	// Calculate the average block time
 | ||||
| 	AverageBlockTime time.Duration = (maxTimePerBlock - minTimePerBlock) / 2 | ||||
| 	// MaxBidDuration is a crude way of ensuring that BidDuration ≤ MaxAuctionDuration for all generated params
 | ||||
| 	MaxBidDuration time.Duration = AverageBlockTime * 50 | ||||
| ) | ||||
| 
 | ||||
| func GenBidDuration(r *rand.Rand) time.Duration { | ||||
| 	d, err := RandomPositiveDuration(r, 0, MaxBidDuration) | ||||
| 	if err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
| 	return d | ||||
| } | ||||
| func GenMaxAuctionDuration(r *rand.Rand) time.Duration { | ||||
| 	d, err := RandomPositiveDuration(r, MaxBidDuration, AverageBlockTime*200) | ||||
| 	if err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
| 	return d | ||||
| } | ||||
| 
 | ||||
| func GenIncrementCollateral(r *rand.Rand) sdk.Dec { | ||||
| 	return simulation.RandomDecAmount(r, sdk.MustNewDecFromStr("1")) | ||||
| } | ||||
| 
 | ||||
| var GenIncrementDebt = GenIncrementCollateral | ||||
| var GenIncrementSurplus = GenIncrementCollateral | ||||
| 
 | ||||
| // RandomizedGenState generates a random GenesisState for auction
 | ||||
| func RandomizedGenState(simState *module.SimulationState) { | ||||
| 
 | ||||
| 	// TODO implement this fully
 | ||||
| 	// - randomly generating the genesis params
 | ||||
| 	// - overwriting with genesis provided to simulation
 | ||||
| 	auctionGenesis := types.DefaultGenesisState() | ||||
| 	p := types.NewParams( | ||||
| 		GenMaxAuctionDuration(simState.Rand), | ||||
| 		GenBidDuration(simState.Rand), | ||||
| 		GenIncrementSurplus(simState.Rand), | ||||
| 		GenIncrementDebt(simState.Rand), | ||||
| 		GenIncrementCollateral(simState.Rand), | ||||
| 	) | ||||
| 	if err := p.Validate(); err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
| 	auctionGenesis := types.NewGenesisState( | ||||
| 		types.DefaultNextAuctionID, | ||||
| 		p, | ||||
| 		nil, | ||||
| 	) | ||||
| 
 | ||||
| 	// Add auctions
 | ||||
| 	auctions := types.GenesisAuctions{ | ||||
| 		types.NewDebtAuction( | ||||
| 			cdptypes.LiquidatorMacc, // using cdp account rather than generic test one to avoid having to set permissions on the supply keeper
 | ||||
| 			sdk.NewInt64Coin("usdx", 100), | ||||
| 			sdk.NewInt64Coin("ukava", 1000000000000), | ||||
| 			simState.GenTimestamp.Add(time.Hour*5), | ||||
| 			sdk.NewInt64Coin("debt", 100), // same as usdx
 | ||||
| 		), | ||||
| 	} | ||||
| 	var startingID = auctionGenesis.NextAuctionID | ||||
| 	var ok bool | ||||
| 	var totalAuctionCoins sdk.Coins | ||||
| 	for i, a := range auctions { | ||||
| 		auctions[i], ok = a.WithID(uint64(i) + startingID).(types.GenesisAuction) | ||||
| 		if !ok { | ||||
| 			panic("can't convert Auction to GenesisAuction") | ||||
| 		} | ||||
| 		totalAuctionCoins = totalAuctionCoins.Add(a.GetModuleAccountCoins()) | ||||
| 	} | ||||
| 	auctionGenesis.NextAuctionID = startingID + uint64(len(auctions)) | ||||
| 	auctionGenesis.Auctions = append(auctionGenesis.Auctions, auctions...) | ||||
| 
 | ||||
| 	// Also need to update the auction module account (to reflect the coins held in the auctions)
 | ||||
| 	var authGenesis auth.GenesisState | ||||
| 	simState.Cdc.MustUnmarshalJSON(simState.GenState[auth.ModuleName], &authGenesis) | ||||
| 
 | ||||
| 	auctionModAcc, found := getAccount(authGenesis.Accounts, supply.NewModuleAddress(types.ModuleName)) | ||||
| 	if !found { | ||||
| 		auctionModAcc = supply.NewEmptyModuleAccount(types.ModuleName) | ||||
| 	} | ||||
| 	if err := auctionModAcc.SetCoins(totalAuctionCoins); err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
| 	authGenesis.Accounts = replaceOrAppendAccount(authGenesis.Accounts, auctionModAcc) | ||||
| 
 | ||||
| 	// TODO adding bidder coins as well - this should be moved elsewhere
 | ||||
| 	bidder, found := getAccount(authGenesis.Accounts, simState.Accounts[0].Address) // 0 is the bidder // FIXME
 | ||||
| 	if !found { | ||||
| 		panic("bidder not found") | ||||
| 	} | ||||
| 	bidderCoins := sdk.NewCoins(sdk.NewInt64Coin("usdx", 10000000000)) | ||||
| 	if err := bidder.SetCoins(bidder.GetCoins().Add(bidderCoins)); err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
| 	authGenesis.Accounts = replaceOrAppendAccount(authGenesis.Accounts, bidder) | ||||
| 
 | ||||
| 	simState.GenState[auth.ModuleName] = simState.Cdc.MustMarshalJSON(authGenesis) | ||||
| 
 | ||||
| 	// Update the supply genesis state to reflect the new coins
 | ||||
| 	// TODO find some way for this to happen automatically / move it elsewhere
 | ||||
| 	var supplyGenesis supply.GenesisState | ||||
| 	simState.Cdc.MustUnmarshalJSON(simState.GenState[supply.ModuleName], &supplyGenesis) | ||||
| 	supplyGenesis.Supply = supplyGenesis.Supply.Add(totalAuctionCoins).Add(bidderCoins) | ||||
| 	simState.GenState[supply.ModuleName] = simState.Cdc.MustMarshalJSON(supplyGenesis) | ||||
| 
 | ||||
| 	// TODO liquidator mod account doesn't need to be initialized for this example
 | ||||
| 	// - it just mints kava, doesn't need a starting balance
 | ||||
| 	// - and supply.GetModuleAccount creates one if it doesn't exist
 | ||||
| 
 | ||||
| 	// Note: this line prints out the auction genesis state, not just the auction parameters. Some sdk modules print out just the parameters.
 | ||||
| 	fmt.Printf("Selected randomly generated %s parameters:\n%s\n", types.ModuleName, codec.MustMarshalJSONIndent(simState.Cdc, auctionGenesis)) | ||||
| 	simState.GenState[types.ModuleName] = simState.Cdc.MustMarshalJSON(auctionGenesis) | ||||
| } | ||||
| 
 | ||||
| // Return an account from a list of accounts that matches an address.
 | ||||
| func getAccount(accounts []authexported.GenesisAccount, addr sdk.AccAddress) (authexported.GenesisAccount, bool) { | ||||
| 	for _, a := range accounts { | ||||
| 		if a.GetAddress().Equals(addr) { | ||||
| 			return a, true | ||||
| 		} | ||||
| 	} | ||||
| 	return nil, false | ||||
| } | ||||
| 
 | ||||
| // In a list of accounts, replace the first account found with the same address. If not found, append the account.
 | ||||
| func replaceOrAppendAccount(accounts []authexported.GenesisAccount, acc authexported.GenesisAccount) []authexported.GenesisAccount { | ||||
| 	newAccounts := accounts | ||||
| 	for i, a := range accounts { | ||||
| 		if a.GetAddress().Equals(acc.GetAddress()) { | ||||
| 			newAccounts[i] = acc | ||||
| 			return newAccounts | ||||
| 		} | ||||
| 	} | ||||
| 	return append(newAccounts, acc) | ||||
| } | ||||
| 
 | ||||
| func RandomPositiveDuration(r *rand.Rand, inclusiveMin, exclusiveMax time.Duration) (time.Duration, error) { | ||||
| 	min := int64(inclusiveMin) | ||||
| 	max := int64(exclusiveMax) | ||||
| 	if min < 0 || max < 0 { | ||||
| 		return 0, fmt.Errorf("min and max must be positive") | ||||
| 	} | ||||
| 	if min >= max { | ||||
| 		return 0, fmt.Errorf("max must be < min") | ||||
| 	} | ||||
| 	randPositiveInt64 := r.Int63n(max-min) + min | ||||
| 	return time.Duration(randPositiveInt64), nil | ||||
| } | ||||
|  | ||||
							
								
								
									
										177
									
								
								x/auction/simulation/operations/msg.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								x/auction/simulation/operations/msg.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,177 @@ | ||||
| package operations | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"math/big" | ||||
| 	"math/rand" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/cosmos/cosmos-sdk/baseapp" | ||||
| 	sdk "github.com/cosmos/cosmos-sdk/types" | ||||
| 	"github.com/cosmos/cosmos-sdk/x/auth" | ||||
| 	authexported "github.com/cosmos/cosmos-sdk/x/auth/exported" | ||||
| 	"github.com/cosmos/cosmos-sdk/x/simulation" | ||||
| 
 | ||||
| 	"github.com/kava-labs/kava/x/auction" | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	noOpMsg             = simulation.NoOpMsg(auction.ModuleName) | ||||
| 	ErrorNotEnoughCoins = errors.New("account doesn't have enough coins") | ||||
| ) | ||||
| 
 | ||||
| // Return a function that runs a random state change on the module keeper.
 | ||||
| // There's two error paths
 | ||||
| // - return a OpMessage, but nil error - this will log a message but keep running the simulation
 | ||||
| // - return an error - this will stop the simulation
 | ||||
| func SimulateMsgPlaceBid(authKeeper auth.AccountKeeper, keeper auction.Keeper) simulation.Operation { | ||||
| 	handler := auction.NewHandler(keeper) | ||||
| 
 | ||||
| 	return func(r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simulation.Account) ( | ||||
| 		simulation.OperationMsg, []simulation.FutureOperation, error) { | ||||
| 
 | ||||
| 		// get open auctions
 | ||||
| 		openAuctions := auction.Auctions{} | ||||
| 		keeper.IterateAuctions(ctx, func(a auction.Auction) bool { | ||||
| 			openAuctions = append(openAuctions, a) | ||||
| 			return false | ||||
| 		}) | ||||
| 
 | ||||
| 		// shuffle auctions slice so that bids are evenly distributed across auctions
 | ||||
| 		rand.Shuffle(len(openAuctions), func(i, j int) { | ||||
| 			openAuctions[i], openAuctions[j] = openAuctions[j], openAuctions[i] | ||||
| 		}) | ||||
| 		// TODO do the same for accounts?
 | ||||
| 		var accounts []authexported.Account | ||||
| 		for _, acc := range accs { | ||||
| 			accounts = append(accounts, authKeeper.GetAccount(ctx, acc.Address)) | ||||
| 		} | ||||
| 
 | ||||
| 		// search through auctions and an accounts to find a pair where a bid can be placed (ie account has enough coins to place bid on auction)
 | ||||
| 		blockTime := ctx.BlockHeader().Time | ||||
| 		bidder, openAuction, found := findValidAccountAuctionPair(accounts, openAuctions, func(acc authexported.Account, auc auction.Auction) bool { | ||||
| 			_, err := generateBidAmount(r, auc, acc, blockTime) | ||||
| 			if err == ErrorNotEnoughCoins { | ||||
| 				return false // keep searching
 | ||||
| 			} else if err != nil { | ||||
| 				panic(err) // raise errors
 | ||||
| 			} | ||||
| 			return true // found valid pair
 | ||||
| 		}) | ||||
| 		if !found { | ||||
| 			return simulation.NewOperationMsgBasic(auction.ModuleName, "no-operation (no valid auction and bidder)", "", false, nil), nil, nil | ||||
| 		} | ||||
| 
 | ||||
| 		// pick a bid amount for the chosen auction and bidder
 | ||||
| 		amount, _ := generateBidAmount(r, openAuction, bidder, blockTime) | ||||
| 
 | ||||
| 		// create a msg
 | ||||
| 		msg := auction.NewMsgPlaceBid(openAuction.GetID(), bidder.GetAddress(), amount) | ||||
| 		if err := msg.ValidateBasic(); err != nil { // don't submit errors that fail ValidateBasic, use unit tests for testing ValidateBasic
 | ||||
| 			return noOpMsg, nil, fmt.Errorf("expected msg to pass ValidateBasic: %s", msg.GetSignBytes()) | ||||
| 		} | ||||
| 
 | ||||
| 		// submit the msg
 | ||||
| 		result := submitMsg(ctx, handler, msg) | ||||
| 		// Return an operationMsg indicating whether the msg was submitted successfully
 | ||||
| 		// Using result.Log as the comment field as it contains any error message emitted by the keeper
 | ||||
| 		return simulation.NewOperationMsg(msg, result.IsOK(), result.Log), nil, nil | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func submitMsg(ctx sdk.Context, handler sdk.Handler, msg sdk.Msg) sdk.Result { | ||||
| 	ctx, write := ctx.CacheContext() | ||||
| 	result := handler(ctx, msg) | ||||
| 	if result.IsOK() { | ||||
| 		write() | ||||
| 	} | ||||
| 	return result | ||||
| } | ||||
| 
 | ||||
| func generateBidAmount(r *rand.Rand, auc auction.Auction, bidder authexported.Account, blockTime time.Time) (sdk.Coin, error) { | ||||
| 	bidderBalance := bidder.SpendableCoins(blockTime) | ||||
| 
 | ||||
| 	switch a := auc.(type) { | ||||
| 
 | ||||
| 	case auction.DebtAuction: | ||||
| 		if bidderBalance.AmountOf(a.Bid.Denom).LT(a.Bid.Amount) { // stable coin
 | ||||
| 			return sdk.Coin{}, ErrorNotEnoughCoins | ||||
| 		} | ||||
| 		amt, err := RandIntInclusive(r, sdk.ZeroInt(), a.Lot.Amount) // pick amount less than current lot amount // TODO min bid increments
 | ||||
| 		if err != nil { | ||||
| 			panic(err) | ||||
| 		} | ||||
| 		return sdk.NewCoin(a.Lot.Denom, amt), nil // gov coin
 | ||||
| 
 | ||||
| 	case auction.SurplusAuction: | ||||
| 		if bidderBalance.AmountOf(a.Bid.Denom).LT(a.Bid.Amount) { // gov coin // TODO account for bid increments
 | ||||
| 			return sdk.Coin{}, ErrorNotEnoughCoins | ||||
| 		} | ||||
| 		amt, err := RandIntInclusive(r, a.Bid.Amount, bidderBalance.AmountOf(a.Bid.Denom)) | ||||
| 		if err != nil { | ||||
| 			panic(err) | ||||
| 		} | ||||
| 		return sdk.NewCoin(a.Bid.Denom, amt), nil // gov coin
 | ||||
| 
 | ||||
| 	case auction.CollateralAuction: | ||||
| 		if bidderBalance.AmountOf(a.Bid.Denom).LT(a.Bid.Amount) { // stable coin // TODO account for bid increments (in forward phase)
 | ||||
| 			return sdk.Coin{}, ErrorNotEnoughCoins | ||||
| 		} | ||||
| 		if a.IsReversePhase() { | ||||
| 			amt, err := RandIntInclusive(r, sdk.ZeroInt(), a.Lot.Amount) // pick amount less than current lot amount
 | ||||
| 			if err != nil { | ||||
| 				panic(err) | ||||
| 			} | ||||
| 			return sdk.NewCoin(a.Lot.Denom, amt), nil // collateral coin
 | ||||
| 		} else { | ||||
| 			amt, err := RandIntInclusive(r, a.Bid.Amount, sdk.MinInt(bidderBalance.AmountOf(a.Bid.Denom), a.MaxBid.Amount)) | ||||
| 			if err != nil { | ||||
| 				panic(err) | ||||
| 			} | ||||
| 			// pick the MaxBid amount more frequently to increase chance auctions phase get into reverse phase
 | ||||
| 			if r.Intn(10) == 0 { // 10%
 | ||||
| 				amt = a.MaxBid.Amount | ||||
| 			} | ||||
| 			return sdk.NewCoin(a.Bid.Denom, amt), nil // stable coin
 | ||||
| 		} | ||||
| 
 | ||||
| 	default: | ||||
| 		return sdk.Coin{}, fmt.Errorf("unknown auction type") | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // findValidAccountAuctionPair finds an auction and account for which the callback func returns true
 | ||||
| func findValidAccountAuctionPair(accounts []authexported.Account, auctions auction.Auctions, cb func(authexported.Account, auction.Auction) bool) (authexported.Account, auction.Auction, bool) { | ||||
| 	for _, auc := range auctions { | ||||
| 		for _, acc := range accounts { | ||||
| 			if isValid := cb(acc, auc); isValid { | ||||
| 				return acc, auc, true | ||||
| 			} | ||||
| 
 | ||||
| 		} | ||||
| 	} | ||||
| 	return nil, nil, false | ||||
| } | ||||
| 
 | ||||
| // RandInt randomly generates an sdk.Int in the range [inclusiveMin, inclusiveMax]. It works for negative and positive integers.
 | ||||
| func RandIntInclusive(r *rand.Rand, inclusiveMin, inclusiveMax sdk.Int) (sdk.Int, error) { | ||||
| 	if inclusiveMin.GT(inclusiveMax) { | ||||
| 		return sdk.Int{}, fmt.Errorf("min larger than max") | ||||
| 	} | ||||
| 	return RandInt(r, inclusiveMin, inclusiveMax.Add(sdk.OneInt())) | ||||
| } | ||||
| 
 | ||||
| // RandInt randomly generates an sdk.Int in the range [inclusiveMin, exclusiveMax). It works for negative and positive integers.
 | ||||
| func RandInt(r *rand.Rand, inclusiveMin, exclusiveMax sdk.Int) (sdk.Int, error) { | ||||
| 	// validate input
 | ||||
| 	if inclusiveMin.GTE(exclusiveMax) { | ||||
| 		return sdk.Int{}, fmt.Errorf("min larger or equal to max") | ||||
| 	} | ||||
| 	// shift the range to start at 0
 | ||||
| 	shiftedRange := exclusiveMax.Sub(inclusiveMin) // should always be positive given the check above
 | ||||
| 	// randomly pick from the shifted range
 | ||||
| 	shiftedRandInt := sdk.NewIntFromBigInt(new(big.Int).Rand(r, shiftedRange.BigInt())) | ||||
| 	// shift back to the original range
 | ||||
| 	return shiftedRandInt.Add(inclusiveMin), nil | ||||
| } | ||||
| @ -1,14 +1,46 @@ | ||||
| package simulation | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"math/rand" | ||||
| 
 | ||||
| 	"github.com/cosmos/cosmos-sdk/x/simulation" | ||||
| 
 | ||||
| 	"github.com/kava-labs/kava/x/auction/types" | ||||
| ) | ||||
| 
 | ||||
| // ParamChanges defines the parameters that can be modified by param change proposals
 | ||||
| // on the simulation
 | ||||
| func ParamChanges(r *rand.Rand) []simulation.ParamChange { | ||||
| 	// TODO implement this
 | ||||
| 	return []simulation.ParamChange{} | ||||
| 	// Note: params are encoded to JSON before being stored in the param store. These param changes
 | ||||
| 	// update the raw values in the store so values need to be JSON. This is why values that are represented
 | ||||
| 	// as strings in JSON (such as time.Duration) have the escaped quotes.
 | ||||
| 	// TODO should we encode the values properly with ModuleCdc.MustMarshalJSON()?
 | ||||
| 	return []simulation.ParamChange{ | ||||
| 		simulation.NewSimParamChange(types.ModuleName, string(types.KeyBidDuration), "", | ||||
| 			func(r *rand.Rand) string { | ||||
| 				return fmt.Sprintf("\"%d\"", GenBidDuration(r)) | ||||
| 			}, | ||||
| 		), | ||||
| 		simulation.NewSimParamChange(types.ModuleName, string(types.KeyMaxAuctionDuration), "", | ||||
| 			func(r *rand.Rand) string { | ||||
| 				return fmt.Sprintf("\"%d\"", GenMaxAuctionDuration(r)) | ||||
| 			}, | ||||
| 		), | ||||
| 		simulation.NewSimParamChange(types.ModuleName, string(types.KeyIncrementCollateral), "", | ||||
| 			func(r *rand.Rand) string { | ||||
| 				return fmt.Sprintf("\"%d\"", GenIncrementCollateral(r)) | ||||
| 			}, | ||||
| 		), | ||||
| 		simulation.NewSimParamChange(types.ModuleName, string(types.KeyIncrementDebt), "", | ||||
| 			func(r *rand.Rand) string { | ||||
| 				return fmt.Sprintf("\"%d\"", GenIncrementDebt(r)) | ||||
| 			}, | ||||
| 		), | ||||
| 		simulation.NewSimParamChange(types.ModuleName, string(types.KeyIncrementSurplus), "", | ||||
| 			func(r *rand.Rand) string { | ||||
| 				return fmt.Sprintf("\"%d\"", GenIncrementSurplus(r)) | ||||
| 			}, | ||||
| 		), | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -69,6 +69,12 @@ func (a BaseAuction) Validate() error { | ||||
| 	if a.EndTime.After(a.MaxEndTime) { | ||||
| 		return fmt.Errorf("MaxEndTime < EndTime (%s < %s)", a.MaxEndTime, a.EndTime) | ||||
| 	} | ||||
| 	if !a.Lot.IsValid() { | ||||
| 		return fmt.Errorf("invalid lot: %s", a.Lot) | ||||
| 	} | ||||
| 	if !a.Bid.IsValid() { | ||||
| 		return fmt.Errorf("invalid bid: %s", a.Bid) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| @ -148,6 +154,13 @@ func (a DebtAuction) GetModuleAccountCoins() sdk.Coins { | ||||
| // GetPhase returns the direction of a debt auction, which never changes.
 | ||||
| func (a DebtAuction) GetPhase() string { return "reverse" } | ||||
| 
 | ||||
| func (a DebtAuction) Validate() error { | ||||
| 	if !a.CorrespondingDebt.IsValid() { | ||||
| 		return fmt.Errorf("invalid corresponding debt: %s", a.CorrespondingDebt) | ||||
| 	} | ||||
| 	return a.BaseAuction.Validate() | ||||
| } | ||||
| 
 | ||||
| // NewDebtAuction returns a new debt auction.
 | ||||
| func NewDebtAuction(buyerModAccName string, bid sdk.Coin, initialLot sdk.Coin, endTime time.Time, debt sdk.Coin) DebtAuction { | ||||
| 	// Note: Bidder is set to the initiator's module account address instead of module name. (when the first bid is placed, it is paid out to the initiator)
 | ||||
| @ -208,6 +221,19 @@ func (a CollateralAuction) GetPhase() string { | ||||
| 	return "forward" | ||||
| } | ||||
| 
 | ||||
| func (a CollateralAuction) Validate() error { | ||||
| 	if !a.CorrespondingDebt.IsValid() { | ||||
| 		return fmt.Errorf("invalid corresponding debt: %s", a.CorrespondingDebt) | ||||
| 	} | ||||
| 	if !a.MaxBid.IsValid() { | ||||
| 		return fmt.Errorf("invalid max bid: %s", a.MaxBid) | ||||
| 	} | ||||
| 	if err := a.LotReturns.Validate(); err != nil { | ||||
| 		return fmt.Errorf("invalid lot returns: %w", err) | ||||
| 	} | ||||
| 	return a.BaseAuction.Validate() | ||||
| } | ||||
| 
 | ||||
| func (a CollateralAuction) String() string { | ||||
| 	return fmt.Sprintf(`Auction %d: | ||||
|   Initiator:              %s | ||||
| @ -251,16 +277,24 @@ type WeightedAddresses struct { | ||||
| 
 | ||||
| // NewWeightedAddresses returns a new list addresses with weights.
 | ||||
| func NewWeightedAddresses(addrs []sdk.AccAddress, weights []sdk.Int) (WeightedAddresses, sdk.Error) { | ||||
| 	if len(addrs) != len(weights) { | ||||
| 		return WeightedAddresses{}, sdk.ErrInternal("number of addresses doesn't match number of weights") | ||||
| 	} | ||||
| 	for _, w := range weights { | ||||
| 		if w.IsNegative() { | ||||
| 			return WeightedAddresses{}, sdk.ErrInternal("weights contain a negative amount") | ||||
| 		} | ||||
| 	} | ||||
| 	return WeightedAddresses{ | ||||
| 	wa := WeightedAddresses{ | ||||
| 		Addresses: addrs, | ||||
| 		Weights:   weights, | ||||
| 	}, nil | ||||
| 	} | ||||
| 	if err := wa.Validate(); err != nil { | ||||
| 		return WeightedAddresses{}, sdk.ErrInternal(err.Error()) | ||||
| 	} | ||||
| 	return wa, nil | ||||
| } | ||||
| 
 | ||||
| func (wa WeightedAddresses) Validate() error { | ||||
| 	if len(wa.Addresses) != len(wa.Weights) { | ||||
| 		return fmt.Errorf("number of addresses doesn't match number of weights") | ||||
| 	} | ||||
| 	for _, w := range wa.Weights { | ||||
| 		if w.IsNegative() { | ||||
| 			return fmt.Errorf("weights contain a negative amount") | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user
	 Ruaridh
						Ruaridh