From c252a1b99bf8869d659a84a704b60f3ac6e02868 Mon Sep 17 00:00:00 2001 From: Denali Marsh Date: Tue, 27 Jul 2021 12:57:23 +0200 Subject: [PATCH] Swap module simulations: swap msg operations (#972) * simulate MsgSwapExactForTokens * extract deadline generation to custom method * simulate MsgSwapForExactTokens * implement simulation decoder * decoder test * add fee to msgs * update comments --- app/params/params.go | 2 + x/swap/simulation/decoder.go | 22 ++- x/swap/simulation/decoder_test.go | 62 +++++++ x/swap/simulation/genesis.go | 2 +- x/swap/simulation/operations.go | 267 +++++++++++++++++++++++++++--- 5 files changed, 327 insertions(+), 28 deletions(-) create mode 100644 x/swap/simulation/decoder_test.go diff --git a/app/params/params.go b/app/params/params.go index 11e5e1af..fdd43a5b 100644 --- a/app/params/params.go +++ b/app/params/params.go @@ -15,6 +15,8 @@ const ( DefaultWeightMsgClaimReward int = 20 DefaultWeightMsgDeposit int = 20 DefaultWeightMsgWithdraw int = 20 + DefaultWeightMsgSwapExactForTokens int = 20 + DefaultWeightMsgSwapForExactTokens int = 20 DefaultWeightMsgIssue int = 20 DefaultWeightMsgRedeem int = 20 DefaultWeightMsgBlock int = 20 diff --git a/x/swap/simulation/decoder.go b/x/swap/simulation/decoder.go index 22ad061a..3f31c358 100644 --- a/x/swap/simulation/decoder.go +++ b/x/swap/simulation/decoder.go @@ -1,13 +1,29 @@ package simulation import ( - "github.com/tendermint/tendermint/libs/kv" + "bytes" + "fmt" "github.com/cosmos/cosmos-sdk/codec" + "github.com/tendermint/tendermint/libs/kv" + + "github.com/kava-labs/kava/x/swap/types" ) // DecodeStore unmarshals the KVPair's Value to the module's corresponding type func DecodeStore(cdc *codec.Codec, kvA, kvB kv.Pair) string { - // TODO: as store keys are added to the module, test marshal/unmarshal of each key prefix - return "" + switch { + case bytes.Equal(kvA.Key[:1], types.PoolKeyPrefix): + var poolRecordA, poolRecordB types.PoolRecord + cdc.MustUnmarshalBinaryLengthPrefixed(kvA.Value, &poolRecordA) + cdc.MustUnmarshalBinaryLengthPrefixed(kvB.Value, &poolRecordB) + return fmt.Sprintf("%v\n%v", poolRecordA, poolRecordB) + case bytes.Equal(kvA.Key[:1], types.DepositorPoolSharesPrefix): + var shareRecordA, shareRecordB types.ShareRecord + cdc.MustUnmarshalBinaryLengthPrefixed(kvA.Value, &shareRecordA) + cdc.MustUnmarshalBinaryLengthPrefixed(kvB.Value, &shareRecordB) + return fmt.Sprintf("%v\n%v", shareRecordA, shareRecordB) + default: + panic(fmt.Sprintf("invalid %s key prefix %X", types.ModuleName, kvA.Key[:1])) + } } diff --git a/x/swap/simulation/decoder_test.go b/x/swap/simulation/decoder_test.go new file mode 100644 index 00000000..abec99b7 --- /dev/null +++ b/x/swap/simulation/decoder_test.go @@ -0,0 +1,62 @@ +package simulation + +import ( + "fmt" + "testing" + + "github.com/cosmos/cosmos-sdk/codec" + "github.com/stretchr/testify/require" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/tendermint/tendermint/crypto" + "github.com/tendermint/tendermint/libs/kv" + + "github.com/kava-labs/kava/x/swap/types" +) + +func makeTestCodec() (cdc *codec.Codec) { + cdc = codec.New() + sdk.RegisterCodec(cdc) + types.RegisterCodec(cdc) + return +} + +func TestDecodeSwapStore(t *testing.T) { + cdc := makeTestCodec() + + depositor := sdk.AccAddress(crypto.AddressHash([]byte("DepositorAddress"))) + reserves := sdk.NewCoins( + sdk.NewCoin("ukava", sdk.NewInt(100000000)), + sdk.NewCoin("usdx", sdk.NewInt(200000000)), + ) + shares := sdk.NewInt(123456) + + poolRecord := types.NewPoolRecord(reserves, shares) + shareRecord := types.NewShareRecord(depositor, poolRecord.PoolID, shares) + + kvPairs := kv.Pairs{ + kv.Pair{Key: types.PoolKeyPrefix, Value: cdc.MustMarshalBinaryLengthPrefixed(poolRecord)}, + kv.Pair{Key: types.DepositorPoolSharesPrefix, Value: cdc.MustMarshalBinaryLengthPrefixed(shareRecord)}, + kv.Pair{Key: []byte{0x99}, Value: []byte{0x99}}, + } + + tests := []struct { + name string + expectedLog string + }{ + {"PoolRecord", fmt.Sprintf("%v\n%v", poolRecord, poolRecord)}, + {"ShareRecord", fmt.Sprintf("%v\n%v", shareRecord, shareRecord)}, + {"other", ""}, + } + for i, tt := range tests { + i, tt := i, tt + t.Run(tt.name, func(t *testing.T) { + switch i { + case len(tests) - 1: + require.Panics(t, func() { DecodeStore(cdc, kvPairs[i], kvPairs[i]) }, tt.name) + default: + require.Equal(t, tt.expectedLog, DecodeStore(cdc, kvPairs[i], kvPairs[i]), tt.name) + } + }) + } +} diff --git a/x/swap/simulation/genesis.go b/x/swap/simulation/genesis.go index 187eef24..8ecf079c 100644 --- a/x/swap/simulation/genesis.go +++ b/x/swap/simulation/genesis.go @@ -17,7 +17,7 @@ import ( var ( //nolint accs []simulation.Account - consistentPools = [2][2]string{{"ukava", "usdx"}, {"bnb", "stake"}} + consistentPools = [2][2]string{{"ukava", "usdx"}, {"bnb", "xrp"}} ) // GenSwapFee generates a random SwapFee in range [0.01, 1.00] diff --git a/x/swap/simulation/operations.go b/x/swap/simulation/operations.go index 1e2787d8..dd09f664 100644 --- a/x/swap/simulation/operations.go +++ b/x/swap/simulation/operations.go @@ -27,8 +27,10 @@ var ( // Simulation operation weights constants const ( - OpWeightMsgDeposit = "op_weight_msg_deposit" - OpWeightMsgWithdraw = "op_weight_msg_withdraw" + OpWeightMsgDeposit = "op_weight_msg_deposit" + OpWeightMsgWithdraw = "op_weight_msg_withdraw" + OpWeightMsgSwapExactForTokens = "op_weight_msg_swap_exact_for_tokens" + OpWeightMsgSwapForExactTokens = "op_weight_msg_swap_for_exact_tokens" ) // WeightedOperations returns all the operations from the module with their respective weights @@ -37,6 +39,8 @@ func WeightedOperations( ) simulation.WeightedOperations { var weightMsgDeposit int var weightMsgWithdraw int + var weightMsgSwapExactForTokens int + var weightMsgSwapForExactTokens int appParams.GetOrGenerate(cdc, OpWeightMsgDeposit, &weightMsgDeposit, nil, func(_ *rand.Rand) { @@ -50,6 +54,18 @@ func WeightedOperations( }, ) + appParams.GetOrGenerate(cdc, OpWeightMsgSwapExactForTokens, &weightMsgSwapExactForTokens, nil, + func(_ *rand.Rand) { + weightMsgSwapExactForTokens = appparams.DefaultWeightMsgSwapExactForTokens + }, + ) + + appParams.GetOrGenerate(cdc, OpWeightMsgSwapForExactTokens, &weightMsgSwapForExactTokens, nil, + func(_ *rand.Rand) { + weightMsgSwapForExactTokens = appparams.DefaultWeightMsgSwapForExactTokens + }, + ) + return simulation.WeightedOperations{ simulation.NewWeightedOperation( weightMsgDeposit, @@ -59,6 +75,14 @@ func WeightedOperations( weightMsgWithdraw, SimulateMsgWithdraw(ak, k), ), + simulation.NewWeightedOperation( + weightMsgSwapExactForTokens, + SimulateMsgSwapExactForTokens(ak, k), + ), + simulation.NewWeightedOperation( + weightMsgSwapForExactTokens, + SimulateMsgSwapForExactTokens(ak, k), + ), } } @@ -98,16 +122,8 @@ func SimulateMsgDeposit(ak types.AccountKeeper, k keeper.Keeper) simulation.Oper } slippage := slippageRaw.ToDec().Quo(sdk.NewDec(100)) - // Set up deadline - durationNanoseconds, err := RandIntInclusive(r, - sdk.NewInt((time.Second * 10).Nanoseconds()), // ten seconds - sdk.NewInt((time.Hour * 24).Nanoseconds()), // one day - ) - if err != nil { - panic(err) - } - extraTime := time.Duration(durationNanoseconds.Int64()) - deadline := blockTime.Add(extraTime).Unix() + // Generate random deadline + deadline := genRandDeadline(r, blockTime) depositorAcc := ak.GetAccount(ctx, depositor.Address) depositorCoins := depositorAcc.SpendableCoins(blockTime) @@ -201,8 +217,8 @@ func SimulateMsgWithdraw(ak types.AccountKeeper, k keeper.Keeper) simulation.Ope } coinsOwned := denominatedPool.ShareValue(shareRecord.SharesOwned) - // Get random amount of shares between 2-50% of the total - sharePercentage, err := RandIntInclusive(r, sdk.NewInt(2), sdk.NewInt(50)) + // Get random amount of shares between 2-10% of the total + sharePercentage, err := RandIntInclusive(r, sdk.NewInt(2), sdk.NewInt(10)) if err != nil { panic(err) } @@ -219,17 +235,9 @@ func SimulateMsgWithdraw(ak types.AccountKeeper, k keeper.Keeper) simulation.Ope minTokenAmtB := amtTokenBOwned.Mul(oneLessThanSharePercentage).Quo(sdk.NewInt(100)) minTokenB := sdk.NewCoin(poolRecord.ReservesB.Denom, minTokenAmtB) - // Set up deadline + // Generate random deadline blockTime := ctx.BlockHeader().Time - durationNanoseconds, err := RandIntInclusive(r, - sdk.NewInt((time.Second * 10).Nanoseconds()), // ten seconds - sdk.NewInt((time.Hour * 24).Nanoseconds()), // one day - ) - if err != nil { - panic(err) - } - extraTime := time.Duration(durationNanoseconds.Int64()) - deadline := blockTime.Add(extraTime).Unix() + deadline := genRandDeadline(r, blockTime) // Construct MsgWithdraw msg := types.NewMsgWithdraw(withdrawerAcc.GetAddress(), shares, minTokenA, minTokenB, deadline) @@ -258,6 +266,204 @@ func SimulateMsgWithdraw(ak types.AccountKeeper, k keeper.Keeper) simulation.Ope } } +// SimulateMsgSwapExactForTokens generates a MsgSwapExactForTokens +func SimulateMsgSwapExactForTokens(ak types.AccountKeeper, k keeper.Keeper) simulation.Operation { + return func( + r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simulation.Account, chainID string, + ) (simulation.OperationMsg, []simulation.FutureOperation, error) { + + poolRecords := k.GetAllPools(ctx) + r.Shuffle(len(poolRecords), func(i, j int) { + poolRecords[i], poolRecords[j] = poolRecords[j], poolRecords[i] + }) + + // Find an account-pool pair for which trade is possible + trader, poolRecord, found := findValidAccountPoolRecordPair(accs, poolRecords, func(acc simulation.Account, poolRecord types.PoolRecord) bool { + traderAcc := ak.GetAccount(ctx, acc.Address) + balanceTokenA := traderAcc.GetCoins().AmountOf(poolRecord.ReservesA.Denom) + balanceTokenB := traderAcc.GetCoins().AmountOf(poolRecord.ReservesB.Denom) + if !balanceTokenA.IsPositive() || !balanceTokenB.IsPositive() { + return false + } + return true + }) + if !found { + return simulation.NewOperationMsgBasic(types.ModuleName, "no-operation (no valid pool record and trader)", "", false, nil), nil, nil + } + + // Select input token + randInt, err := RandInt(r, sdk.OneInt(), sdk.NewInt(9)) + if err != nil { + panic(err) + } + inputToken := poolRecord.ReservesA + outputToken := poolRecord.ReservesB + if randInt.Int64()%2 == 0 { + inputToken = poolRecord.ReservesB + outputToken = poolRecord.ReservesA + } + + // Select entity (trader account or pool) with smaller token amount + traderAcc := ak.GetAccount(ctx, trader.Address) + maxTradeAmount := inputToken.Amount + if traderAcc.GetCoins().AmountOf(inputToken.Denom).LT(inputToken.Amount) { + maxTradeAmount = traderAcc.GetCoins().AmountOf(inputToken.Denom) + } + + // Exact input token is between 2-10% of the max trade amount + percentage, err := RandIntInclusive(r, sdk.NewInt(2), sdk.NewInt(10)) + if err != nil { + panic(err) + } + tradeAmount := maxTradeAmount.Mul(percentage).Quo(sdk.NewInt(100)) + exactInputToken := sdk.NewCoin(inputToken.Denom, tradeAmount) + + // Calculate expected output coin + globalSwapFee := k.GetSwapFee(ctx) + tradeAmountAfterFee := exactInputToken.Amount.ToDec().Mul(sdk.OneDec().Sub(globalSwapFee)).TruncateInt() + + var outputAmt big.Int + outputAmt.Mul(outputToken.Amount.BigInt(), tradeAmountAfterFee.BigInt()) + outputAmt.Quo(&outputAmt, inputToken.Amount.Add(tradeAmountAfterFee).BigInt()) + expectedOutTokenAmount := sdk.NewIntFromBigInt(&outputAmt) + expectedOutputToken := sdk.NewCoin(outputToken.Denom, expectedOutTokenAmount) + + // Get random slippage amount between 50-100% + slippageRaw, err := RandIntInclusive(r, sdk.NewInt(50), sdk.NewInt(99)) + if err != nil { + panic(err) + } + slippage := slippageRaw.ToDec().Quo(sdk.NewDec(100)) + + // Generate random deadline + blockTime := ctx.BlockHeader().Time + deadline := genRandDeadline(r, blockTime) + + // Construct MsgSwapExactForTokens + msg := types.NewMsgSwapExactForTokens(traderAcc.GetAddress(), exactInputToken, expectedOutputToken, slippage, deadline) + err = msg.ValidateBasic() + if err != nil { + return noOpMsg, nil, nil + } + + tx := helpers.GenTx( + []sdk.Msg{msg}, + sdk.NewCoins(), + helpers.DefaultGenTxGas, + chainID, + []uint64{traderAcc.GetAccountNumber()}, + []uint64{traderAcc.GetSequence()}, + trader.PrivKey, + ) + + _, result, err := app.Deliver(tx) + if err != nil { + // to aid debugging, add the stack trace to the comment field of the returned opMsg + return simulation.NewOperationMsg(msg, false, fmt.Sprintf("%+v", err)), nil, err + } + return simulation.NewOperationMsg(msg, true, result.Log), nil, nil + } +} + +// SimulateMsgSwapForExactTokens generates a MsgSwapForExactTokens +func SimulateMsgSwapForExactTokens(ak types.AccountKeeper, k keeper.Keeper) simulation.Operation { + return func( + r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simulation.Account, chainID string, + ) (simulation.OperationMsg, []simulation.FutureOperation, error) { + + poolRecords := k.GetAllPools(ctx) + r.Shuffle(len(poolRecords), func(i, j int) { + poolRecords[i], poolRecords[j] = poolRecords[j], poolRecords[i] + }) + + // Find an account-pool pair for which trade is possible + trader, poolRecord, found := findValidAccountPoolRecordPair(accs, poolRecords, func(acc simulation.Account, poolRecord types.PoolRecord) bool { + traderAcc := ak.GetAccount(ctx, acc.Address) + balanceTokenA := traderAcc.GetCoins().AmountOf(poolRecord.ReservesA.Denom) + balanceTokenB := traderAcc.GetCoins().AmountOf(poolRecord.ReservesB.Denom) + if !balanceTokenA.IsPositive() || !balanceTokenB.IsPositive() { + return false + } + return true + }) + if !found { + return simulation.NewOperationMsgBasic(types.ModuleName, "no-operation (no valid pool record and trader)", "", false, nil), nil, nil + } + + // Select input token + randInt, err := RandInt(r, sdk.OneInt(), sdk.NewInt(9)) + if err != nil { + panic(err) + } + inputToken := poolRecord.ReservesA + outputToken := poolRecord.ReservesB + if randInt.Int64()%2 == 0 { + inputToken = poolRecord.ReservesB + outputToken = poolRecord.ReservesA + } + + // Select entity (trader account or pool) with smaller token amount + traderAcc := ak.GetAccount(ctx, trader.Address) + maxTradeAmount := inputToken.Amount + if traderAcc.GetCoins().AmountOf(inputToken.Denom).LT(inputToken.Amount) { + maxTradeAmount = traderAcc.GetCoins().AmountOf(inputToken.Denom) + } + + // Expected input token is between 2-10% of the max trade amount + percentage, err := RandIntInclusive(r, sdk.NewInt(2), sdk.NewInt(10)) + if err != nil { + panic(err) + } + tradeAmount := maxTradeAmount.Mul(percentage).Quo(sdk.NewInt(100)) + expectedInputToken := sdk.NewCoin(inputToken.Denom, tradeAmount) + + // Calculate exact output coin + globalSwapFee := k.GetSwapFee(ctx) + tradeAmountAfterFee := expectedInputToken.Amount.ToDec().Mul(sdk.OneDec().Sub(globalSwapFee)).TruncateInt() + + var outputAmt big.Int + outputAmt.Mul(outputToken.Amount.BigInt(), tradeAmountAfterFee.BigInt()) + outputAmt.Quo(&outputAmt, inputToken.Amount.Add(tradeAmountAfterFee).BigInt()) + outputTokenAmount := sdk.NewIntFromBigInt(&outputAmt) + exactOutputToken := sdk.NewCoin(outputToken.Denom, outputTokenAmount) + + // Get random slippage amount between 50-100% + slippageRaw, err := RandIntInclusive(r, sdk.NewInt(50), sdk.NewInt(99)) + if err != nil { + panic(err) + } + slippage := slippageRaw.ToDec().Quo(sdk.NewDec(100)) + + // Generate random deadline + blockTime := ctx.BlockHeader().Time + deadline := genRandDeadline(r, blockTime) + + // Construct MsgSwapForExactTokens + msg := types.NewMsgSwapForExactTokens(traderAcc.GetAddress(), expectedInputToken, exactOutputToken, slippage, deadline) + err = msg.ValidateBasic() + if err != nil { + return noOpMsg, nil, nil + } + + tx := helpers.GenTx( + []sdk.Msg{msg}, + sdk.NewCoins(), + helpers.DefaultGenTxGas, + chainID, + []uint64{traderAcc.GetAccountNumber()}, + []uint64{traderAcc.GetSequence()}, + trader.PrivKey, + ) + + _, result, err := app.Deliver(tx) + if err != nil { + // to aid debugging, add the stack trace to the comment field of the returned opMsg + return simulation.NewOperationMsg(msg, false, fmt.Sprintf("%+v", err)), nil, err + } + return simulation.NewOperationMsg(msg, true, result.Log), nil, nil + } +} + // From a set of coins return a coin of the specified denom with 1-10% of the total amount func randCoinFromCoins(r *rand.Rand, coins sdk.Coins, denom string) sdk.Coin { percentOfBalance, err := RandIntInclusive(r, sdk.OneInt(), sdk.NewInt(10)) @@ -309,6 +515,19 @@ func findValidAccountPoolRecordPair(accounts []simulation.Account, pools types.P return simulation.Account{}, types.PoolRecord{}, false } +func genRandDeadline(r *rand.Rand, blockTime time.Time) int64 { + // Set up deadline + durationNanoseconds, err := RandIntInclusive(r, + sdk.NewInt((time.Second * 10).Nanoseconds()), // ten seconds + sdk.NewInt((time.Hour * 24).Nanoseconds()), // one day + ) + if err != nil { + panic(err) + } + extraTime := time.Duration(durationNanoseconds.Int64()) + return blockTime.Add(extraTime).Unix() +} + // RandIntInclusive 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) {