diff --git a/CHANGELOG.md b/CHANGELOG.md index 79af36d76..a285db52d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [#801](https://github.com/NibiruChain/nibiru/pull/801) - remove unused pair constants * [#788](https://github.com/NibiruChain/nibiru/pull/788) - add --overwrite flag to the nibid init call of localnet.sh * [#804](https://github.com/NibiruChain/nibiru/pull/804) - bump ibc-go to v3.1.1 +* [#817](https://github.com/NibiruChain/nibiru/pull/817) - Make post prices transactions gasless for whitelisted oracles * [#818](https://github.com/NibiruChain/nibiru/pull/818) - fix(localnet.sh): add max leverage to vpools in genesis to fix open-position * [#819](https://github.com/NibiruChain/nibiru/pull/819) - add golangci-linter using docker in Makefile diff --git a/Makefile b/Makefile index 2e30d1412..8eb773cad 100644 --- a/Makefile +++ b/Makefile @@ -124,10 +124,10 @@ PACKAGES_NOSIMULATION = $(shell go list ./... | grep -v '/simapp') RUNSIM = $(BINDIR)/runsim test-unit: - go test $(PACKAGES_NOSIMULATION) -short -cover + @go test $(PACKAGES_NOSIMULATION) -short -cover test-integration: - go test -v $(PACKAGES_NOSIMULATION) -cover + @go test -v $(PACKAGES_NOSIMULATION) -cover runsim: $(RUNSIM) $(RUNSIM): diff --git a/app/ante.go b/app/ante.go index c16cb8b23..1586937ae 100644 --- a/app/ante.go +++ b/app/ante.go @@ -6,11 +6,18 @@ import ( "github.com/cosmos/cosmos-sdk/x/auth/ante" ibcante "github.com/cosmos/ibc-go/v3/modules/core/ante" ibckeeper "github.com/cosmos/ibc-go/v3/modules/core/keeper" + + gaslessante "github.com/NibiruChain/nibiru/app/antedecorators/gasless" + + feeante "github.com/NibiruChain/nibiru/app/antedecorators/fee" + pricefeedkeeper "github.com/NibiruChain/nibiru/x/pricefeed/keeper" ) type AnteHandlerOptions struct { ante.HandlerOptions IBCKeeper *ibckeeper.Keeper + + PricefeedKeeper pricefeedkeeper.Keeper } /* @@ -43,7 +50,8 @@ func NewAnteHandler(options AnteHandlerOptions) (sdk.AnteHandler, error) { ante.NewTxTimeoutHeightDecorator(), ante.NewValidateMemoDecorator(options.AccountKeeper), ante.NewConsumeGasForTxSizeDecorator(options.AccountKeeper), - ante.NewDeductFeeDecorator(options.AccountKeeper, options.BankKeeper, options.FeegrantKeeper), + gaslessante.NewGaslessDecorator(options.PricefeedKeeper), + feeante.NewDeductFeeDecorator(options.AccountKeeper, options.BankKeeper, options.FeegrantKeeper), // Replace fee ante from cosmos auth with a custom one. // SetPubKeyDecorator must be called before all signature verification decorators ante.NewSetPubKeyDecorator(options.AccountKeeper), ante.NewValidateSigCountDecorator(options.AccountKeeper), diff --git a/app/antedecorators/fee/fee.go b/app/antedecorators/fee/fee.go new file mode 100644 index 000000000..f6b29f4e8 --- /dev/null +++ b/app/antedecorators/fee/fee.go @@ -0,0 +1,84 @@ +package fee + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + "github.com/cosmos/cosmos-sdk/x/auth/ante" + "github.com/cosmos/cosmos-sdk/x/auth/types" +) + +// DeductFeeDecorator deducts fees from the first signer of the tx +// If the first signer does not have the funds to pay for the fees, return with InsufficientFunds error +// Call next AnteHandler if fees successfully deducted +// CONTRACT: Tx must implement FeeTx interface to use DeductFeeDecorator +type DeductFeeDecorator struct { + ak ante.AccountKeeper + bankKeeper types.BankKeeper + feegrantKeeper ante.FeegrantKeeper +} + +func NewDeductFeeDecorator(ak ante.AccountKeeper, bk types.BankKeeper, fk ante.FeegrantKeeper) DeductFeeDecorator { + return DeductFeeDecorator{ + ak: ak, + bankKeeper: bk, + feegrantKeeper: fk, + } +} + +func (dfd DeductFeeDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (newCtx sdk.Context, err error) { + feeTx, ok := tx.(sdk.FeeTx) + if !ok { + return ctx, sdkerrors.Wrap(sdkerrors.ErrTxDecode, "Tx must be a FeeTx") + } + + if addr := dfd.ak.GetModuleAddress(types.FeeCollectorName); addr == nil { + return ctx, fmt.Errorf("fee collector module account (%s) has not been set", types.FeeCollectorName) + } + + fee := feeTx.GetFee() + feePayer := feeTx.FeePayer() + feeGranter := feeTx.FeeGranter() + + deductFeesFrom := feePayer + + // if feegranter set deduct fee from feegranter account. + // this works with only when feegrant enabled. + if feeGranter != nil { + if dfd.feegrantKeeper == nil { + return ctx, sdkerrors.Wrap(sdkerrors.ErrInvalidRequest, "fee grants are not enabled") + } else if !feeGranter.Equals(feePayer) { + err := dfd.feegrantKeeper.UseGrantedFees(ctx, feeGranter, feePayer, fee, tx.GetMsgs()) + + if err != nil { + return ctx, sdkerrors.Wrapf(err, "%s not allowed to pay fees from %s", feeGranter, feePayer) + } + } + + deductFeesFrom = feeGranter + } + + deductFeesFromAcc := dfd.ak.GetAccount(ctx, deductFeesFrom) + if deductFeesFromAcc == nil { + return ctx, sdkerrors.Wrapf(sdkerrors.ErrUnknownAddress, "fee payer address: %s does not exist", deductFeesFrom) + } + + // Gas meter is set to 1 for gasless transactions. + // Depends on GasLessDecorator for this to happen. + if ctx.GasMeter().GasConsumed() == 1 { + // do nothing + } else if !feeTx.GetFee().IsZero() { + err = ante.DeductFees(dfd.bankKeeper, ctx, deductFeesFromAcc, feeTx.GetFee()) + if err != nil { + return ctx, err + } + } + + events := sdk.Events{sdk.NewEvent(sdk.EventTypeTx, + sdk.NewAttribute(sdk.AttributeKeyFee, feeTx.GetFee().String()), + )} + ctx.EventManager().EmitEvents(events) + + return next(ctx, tx, simulate) +} diff --git a/app/antedecorators/fee/fee_test.go b/app/antedecorators/fee/fee_test.go new file mode 100644 index 000000000..5beec11fa --- /dev/null +++ b/app/antedecorators/fee/fee_test.go @@ -0,0 +1,54 @@ +package fee_test + +import ( + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + "github.com/cosmos/cosmos-sdk/simapp" + "github.com/cosmos/cosmos-sdk/testutil/testdata" + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/NibiruChain/nibiru/app" + "github.com/NibiruChain/nibiru/app/antedecorators/fee" +) + +func (suite *AnteTestSuite) TestDeductFees() { + suite.SetupTest(false) // setup + suite.txBuilder = suite.clientCtx.TxConfig.NewTxBuilder() + + // keys and addresses + priv1, _, addr1 := testdata.KeyTestPubAddr() + + // msg and signatures + msg := testdata.NewTestMsg(addr1) + feeAmount := sdk.NewCoins(sdk.NewInt64Coin(app.BondDenom, 150)) + gasLimit := testdata.NewTestGasLimit() + suite.Require().NoError(suite.txBuilder.SetMsgs(msg)) + suite.txBuilder.SetFeeAmount(feeAmount) + suite.txBuilder.SetGasLimit(gasLimit) + + privs, accNums, accSeqs := []cryptotypes.PrivKey{priv1}, []uint64{0}, []uint64{0} + tx, err := suite.CreateTestTx(privs, accNums, accSeqs, suite.ctx.ChainID()) + suite.Require().NoError(err) + + // Set account with insufficient funds + acc := suite.app.AccountKeeper.NewAccountWithAddress(suite.ctx, addr1) + suite.app.AccountKeeper.SetAccount(suite.ctx, acc) + coins := sdk.NewCoins(sdk.NewCoin(app.BondDenom, sdk.NewInt(10))) + err = simapp.FundAccount(suite.app.BankKeeper, suite.ctx, addr1, coins) + suite.Require().NoError(err) + + dfd := fee.NewDeductFeeDecorator(suite.app.AccountKeeper, suite.app.BankKeeper, nil) + antehandler := sdk.ChainAnteDecorators(dfd) + + _, err = antehandler(suite.ctx, tx, false) + + suite.Require().NotNil(err, "Tx did not error when fee payer had insufficient funds") + + // Set account with sufficient funds + suite.app.AccountKeeper.SetAccount(suite.ctx, acc) + err = simapp.FundAccount(suite.app.BankKeeper, suite.ctx, addr1, sdk.NewCoins(sdk.NewCoin(app.BondDenom, sdk.NewInt(200)))) + suite.Require().NoError(err) + + _, err = antehandler(suite.ctx, tx, false) + + suite.Require().Nil(err, "Tx errored after account has been set with sufficient funds") +} diff --git a/app/antedecorators/fee/testutil_test.go b/app/antedecorators/fee/testutil_test.go new file mode 100644 index 000000000..e955cce54 --- /dev/null +++ b/app/antedecorators/fee/testutil_test.go @@ -0,0 +1,119 @@ +package fee_test + +import ( + "testing" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/tx" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + "github.com/cosmos/cosmos-sdk/testutil/testdata" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/tx/signing" + "github.com/cosmos/cosmos-sdk/x/auth/ante" + xauthsigning "github.com/cosmos/cosmos-sdk/x/auth/signing" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/stretchr/testify/suite" + tmproto "github.com/tendermint/tendermint/proto/tendermint/types" + + "github.com/NibiruChain/nibiru/app" + "github.com/NibiruChain/nibiru/x/testutil/testapp" +) + +// AnteTestSuite is a test suite to be used with ante handler tests. +type AnteTestSuite struct { + suite.Suite + + app *app.NibiruApp + anteHandler sdk.AnteHandler + ctx sdk.Context + clientCtx client.Context + txBuilder client.TxBuilder +} + +// returns context and app with params set on account keeper +func createTestApp(isCheckTx bool) (*app.NibiruApp, sdk.Context) { + app := testapp.NewNibiruApp(true) + ctx := app.BaseApp.NewContext(isCheckTx, tmproto.Header{}) + app.AccountKeeper.SetParams(ctx, authtypes.DefaultParams()) + + return app, ctx +} + +// SetupTest setups a new test, with new app, context, and anteHandler. +func (suite *AnteTestSuite) SetupTest(isCheckTx bool) { + suite.app, suite.ctx = createTestApp(isCheckTx) + suite.ctx = suite.ctx.WithBlockHeight(1) + + // Set up TxConfig. + encodingConfig := app.MakeTestEncodingConfig() + // We're using TestMsg encoding in some tests, so register it here. + encodingConfig.Amino.RegisterConcrete(&testdata.TestMsg{}, "testdata.TestMsg", nil) + testdata.RegisterInterfaces(encodingConfig.InterfaceRegistry) + + suite.clientCtx = client.Context{}. + WithTxConfig(encodingConfig.TxConfig) + + anteHandler, err := ante.NewAnteHandler( + ante.HandlerOptions{ + AccountKeeper: suite.app.AccountKeeper, + BankKeeper: suite.app.BankKeeper, + FeegrantKeeper: suite.app.FeeGrantKeeper, + SignModeHandler: encodingConfig.TxConfig.SignModeHandler(), + SigGasConsumer: ante.DefaultSigVerificationGasConsumer, + }, + ) + + suite.Require().NoError(err) + suite.anteHandler = anteHandler +} + +// CreateTestTx is a helper function to create a tx given multiple inputs. +func (suite *AnteTestSuite) CreateTestTx(privs []cryptotypes.PrivKey, accNums []uint64, accSeqs []uint64, chainID string) (xauthsigning.Tx, error) { + // First round: we gather all the signer infos. We use the "set empty + // signature" hack to do that. + var sigsV2 []signing.SignatureV2 + for i, priv := range privs { + sigV2 := signing.SignatureV2{ + PubKey: priv.PubKey(), + Data: &signing.SingleSignatureData{ + SignMode: suite.clientCtx.TxConfig.SignModeHandler().DefaultMode(), + Signature: nil, + }, + Sequence: accSeqs[i], + } + + sigsV2 = append(sigsV2, sigV2) + } + err := suite.txBuilder.SetSignatures(sigsV2...) + if err != nil { + return nil, err + } + + // Second round: all signer infos are set, so each signer can sign. + sigsV2 = []signing.SignatureV2{} + for i, priv := range privs { + signerData := xauthsigning.SignerData{ + ChainID: chainID, + AccountNumber: accNums[i], + Sequence: accSeqs[i], + } + sigV2, err := tx.SignWithPrivKey( + suite.clientCtx.TxConfig.SignModeHandler().DefaultMode(), signerData, + suite.txBuilder, priv, suite.clientCtx.TxConfig, accSeqs[i]) + if err != nil { + return nil, err + } + + sigsV2 = append(sigsV2, sigV2) + } + err = suite.txBuilder.SetSignatures(sigsV2...) + if err != nil { + return nil, err + } + + return suite.txBuilder.GetTx(), nil +} + +func TestAnteTestSuite(t *testing.T) { + suite.Run(t, new(AnteTestSuite)) +} diff --git a/app/antedecorators/gasless/gasless.go b/app/antedecorators/gasless/gasless.go new file mode 100644 index 000000000..0c57c8a92 --- /dev/null +++ b/app/antedecorators/gasless/gasless.go @@ -0,0 +1,57 @@ +package gasless + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + + types "github.com/NibiruChain/nibiru/app/antedecorators/types" + "github.com/NibiruChain/nibiru/x/common" + pricefeedkeeper "github.com/NibiruChain/nibiru/x/pricefeed/keeper" + pricefeedtypes "github.com/NibiruChain/nibiru/x/pricefeed/types" +) + +type GaslessDecorator struct { + pricefeedKeeper pricefeedkeeper.Keeper +} + +func NewGaslessDecorator(pricefeedKeeper pricefeedkeeper.Keeper) GaslessDecorator { + return GaslessDecorator{pricefeedKeeper: pricefeedKeeper} +} + +func (gd GaslessDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (newCtx sdk.Context, err error) { + if simulate || !isTxGasless(tx, ctx, gd.pricefeedKeeper) { + return next(ctx, tx, simulate) + } + + gaslessMeter := types.GasLessMeter() + return next(ctx.WithGasMeter(gaslessMeter), tx, simulate) +} + +func isTxGasless(tx sdk.Tx, ctx sdk.Context, pricefeedKeeper pricefeedkeeper.Keeper) bool { + if len(tx.GetMsgs()) == 0 { + // empty TX shouldn't be gasless + return false + } + for _, msg := range tx.GetMsgs() { + switch m := msg.(type) { + case *pricefeedtypes.MsgPostPrice: + if pricefeedPostPriceIsGasless(m, ctx, pricefeedKeeper) { + continue + } + return false + default: + return false + } + } + return true +} + +// Check if the sender is a whitelisted oracle +func pricefeedPostPriceIsGasless(msg *pricefeedtypes.MsgPostPrice, ctx sdk.Context, keeper pricefeedkeeper.Keeper) bool { + valAddr, err := sdk.AccAddressFromBech32(msg.Oracle) + if err != nil { + return false + } + + pair := common.AssetPair{Token0: msg.Token0, Token1: msg.Token1} + return keeper.IsWhitelistedOracle(ctx, pair.String(), valAddr) +} diff --git a/app/antedecorators/gasless/gasless_test.go b/app/antedecorators/gasless/gasless_test.go new file mode 100644 index 000000000..bff2e8613 --- /dev/null +++ b/app/antedecorators/gasless/gasless_test.go @@ -0,0 +1,144 @@ +package gasless_test + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + types3 "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/stretchr/testify/require" + + gaslessante "github.com/NibiruChain/nibiru/app/antedecorators/gasless" + types2 "github.com/NibiruChain/nibiru/app/antedecorators/types" + "github.com/NibiruChain/nibiru/x/pricefeed/types" + "github.com/NibiruChain/nibiru/x/testutil/sample" + "github.com/NibiruChain/nibiru/x/testutil/testapp" +) + +var oracleAddr = sample.AccAddress() + +type DecoratorWithNormalGasMeterCheck struct { + t *testing.T +} + +func (ad DecoratorWithNormalGasMeterCheck) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (newCtx sdk.Context, err error) { + require.IsType(ad.t, sdk.NewGasMeter(111), ctx.GasMeter()) + + return next(ctx, tx, simulate) +} + +type DecoratorWithInfiniteGasMeterCheck struct { + t *testing.T +} + +func (ad DecoratorWithInfiniteGasMeterCheck) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (newCtx sdk.Context, err error) { + require.IsType(ad.t, types2.GasLessMeter(), ctx.GasMeter()) + + return next(ctx, tx, simulate) +} + +type TxWithPostPriceMsg struct{} + +func (tx TxWithPostPriceMsg) GetMsgs() []sdk.Msg { + return []sdk.Msg{ + &types.MsgPostPrice{ + Oracle: oracleAddr.String(), + Token0: "unibi", + Token1: "unusd", + }, + } +} + +func (tx TxWithPostPriceMsg) ValidateBasic() error { + return nil +} + +type TxWithoutPostPriceMsg struct{} + +func (tx TxWithoutPostPriceMsg) GetMsgs() []sdk.Msg { + return []sdk.Msg{ + &types3.MsgSend{}, + } +} + +func (tx TxWithoutPostPriceMsg) ValidateBasic() error { + return nil +} + +func TestGaslessDecorator_Whitelisted(t *testing.T) { + tests := []struct { + name string + isWhitelisted bool + shouldChangeMeter bool + tx sdk.Tx + simulate bool + }{ + { + /* name */ "whitelisted address", + /* isWhitelisted */ true, + /* shouldChangeMeter */ true, + /* tx */ TxWithPostPriceMsg{}, + /* simulate */ false, + }, + { + /* name */ "whitelisted address, simulation", + /* isWhitelisted */ true, + /* shouldChangeMeter */ false, + /* tx */ TxWithPostPriceMsg{}, + /* simulate */ true, + }, + { + /* name */ "whitelisted address but tx without price feed message", + /* isWhitelisted */ true, + /* shouldChangeMeter */ false, + /* tx */ TxWithoutPostPriceMsg{}, + /* simulate */ false, + }, + { + /* name */ "not whitelisted address with post price tx", + /* isWhitelisted */ false, + /* shouldChangeMeter */ false, + /* tx */ TxWithPostPriceMsg{}, + /* simulate */ false, + }, + { + /* name */ "not whitelisted address without post price tx", + /* isWhitelisted */ false, + /* shouldChangeMeter */ false, + /* tx */ TxWithoutPostPriceMsg{}, + /* simulate */ false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + app, ctx := testapp.NewNibiruAppAndContext(true) + ctx = ctx.WithGasMeter(sdk.NewGasMeter(10000000)) + + if tc.isWhitelisted { + // If we whitelist, the gas meter changes. + app.PricefeedKeeper.WhitelistOracles(ctx, []sdk.AccAddress{oracleAddr}) + } + + var anteDecorators []sdk.AnteDecorator + if tc.shouldChangeMeter { + anteDecorators = []sdk.AnteDecorator{ + DecoratorWithNormalGasMeterCheck{t}, + gaslessante.NewGaslessDecorator(app.PricefeedKeeper), + DecoratorWithInfiniteGasMeterCheck{t}, + } + } else { + anteDecorators = []sdk.AnteDecorator{ + DecoratorWithNormalGasMeterCheck{t}, + gaslessante.NewGaslessDecorator(app.PricefeedKeeper), + DecoratorWithNormalGasMeterCheck{t}, + } + } + + chainedHandler := sdk.ChainAnteDecorators(anteDecorators...) + + _, err := chainedHandler(ctx, tc.tx, tc.simulate) + require.NoError(t, err) + }) + } +} diff --git a/app/antedecorators/types/gas.go b/app/antedecorators/types/gas.go new file mode 100644 index 000000000..24064c4a4 --- /dev/null +++ b/app/antedecorators/types/gas.go @@ -0,0 +1,46 @@ +package types + +import ( + "fmt" + + "github.com/cosmos/cosmos-sdk/types" +) + +type gaslessMeter struct { + consumed types.Gas +} + +// GasLessMeter returns a reference to a new gaslessMeter. +func GasLessMeter() types.GasMeter { + return &gaslessMeter{ + consumed: 1, + } +} + +func (g *gaslessMeter) GasConsumed() types.Gas { + return 1 +} + +func (g *gaslessMeter) GasConsumedToLimit() types.Gas { + return 1 +} + +func (g *gaslessMeter) Limit() types.Gas { + return 1 +} + +func (g *gaslessMeter) ConsumeGas(types.Gas, string) { +} +func (g *gaslessMeter) RefundGas(types.Gas, string) {} + +func (g *gaslessMeter) IsPastLimit() bool { + return false +} + +func (g *gaslessMeter) IsOutOfGas() bool { + return false +} + +func (g *gaslessMeter) String() string { + return fmt.Sprintf("GaslessMeter:\n consumed: %d", g.consumed) +} diff --git a/app/app.go b/app/app.go index 345c7850c..e3e9d6909 100644 --- a/app/app.go +++ b/app/app.go @@ -754,7 +754,8 @@ func NewNibiruApp( SignModeHandler: encodingConfig.TxConfig.SignModeHandler(), SigGasConsumer: ante.DefaultSigVerificationGasConsumer, }, - IBCKeeper: app.IBCKeeper, + PricefeedKeeper: app.PricefeedKeeper, + IBCKeeper: app.IBCKeeper, }) if err != nil { diff --git a/go.mod b/go.mod index 0d6152413..f898bd63b 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,10 @@ require ( github.com/gogo/protobuf v1.3.3 github.com/golang/mock v1.6.0 github.com/golang/protobuf v1.5.2 + github.com/google/gofuzz v1.2.0 github.com/gorilla/mux v1.8.0 github.com/grpc-ecosystem/grpc-gateway v1.16.0 + github.com/pkg/errors v0.9.1 github.com/rakyll/statik v0.1.7 github.com/regen-network/cosmos-proto v0.3.1 github.com/spf13/cast v1.5.0 @@ -63,7 +65,6 @@ require ( github.com/gogo/gateway v1.1.0 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/btree v1.0.0 // indirect - github.com/google/gofuzz v1.2.0 // indirect github.com/google/orderedcode v0.0.1 // indirect github.com/gorilla/handlers v1.5.1 // indirect github.com/gorilla/websocket v1.5.0 // indirect @@ -94,7 +95,6 @@ require ( github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.0.1 // indirect github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.12.1 // indirect github.com/prometheus/client_model v0.2.0 // indirect diff --git a/x/dex/client/testutil/cli_test.go b/x/dex/client/testutil/cli_test.go index 42f19f010..2411ea5ad 100644 --- a/x/dex/client/testutil/cli_test.go +++ b/x/dex/client/testutil/cli_test.go @@ -324,9 +324,6 @@ func (s IntegrationTestSuite) TestCNewExitPoolCmd() { var finalBalance banktypes.QueryAllBalancesResponse s.Require().NoError(ctx.Codec.UnmarshalJSON(resp.Bytes(), &finalBalance)) - fmt.Println("Final balance:") - fmt.Println(finalBalance) - s.Require().Equal( originalBalance.Balances.AmountOf("uusdc").Add(tc.expectedOtherToken), finalBalance.Balances.AmountOf("uusdc"), diff --git a/x/epochs/keeper/epoch.go b/x/epochs/keeper/epoch.go index a4f54c9c0..19645176b 100644 --- a/x/epochs/keeper/epoch.go +++ b/x/epochs/keeper/epoch.go @@ -48,7 +48,6 @@ func (k Keeper) IterateEpochInfo(ctx sdk.Context, fn func(index int64, epochInfo defer func(iterator sdk.Iterator) { err := iterator.Close() if err != nil { - fmt.Println(err) panic(err) } }(iterator) diff --git a/x/perp/keeper/clearing_house.go b/x/perp/keeper/clearing_house.go index 58f54105e..9f21eff83 100644 --- a/x/perp/keeper/clearing_house.go +++ b/x/perp/keeper/clearing_house.go @@ -95,7 +95,6 @@ func (k Keeper) OpenPosition( // - Checks that quote asset is not zero. // - Checks that leverage is not zero. // - Checks that leverage is below requirement. -// func (k Keeper) checkOpenPositionRequirements(ctx sdk.Context, pair common.AssetPair, quoteAssetAmount sdk.Int, leverage sdk.Dec) error { if err := k.requireVpool(ctx, pair); err != nil { return err diff --git a/x/perp/spec/01_concepts.md b/x/perp/spec/01_concepts.md index cbbf8d058..f3f7b3d14 100644 --- a/x/perp/spec/01_concepts.md +++ b/x/perp/spec/01_concepts.md @@ -46,11 +46,11 @@ position_size = baseReserves - baseReservesAfterSwap The notional value of the position, or **position notional**, is the total value a position controls in units of the quote asset. Notional value expresses the value a derivatives contract theoretically controls. On Nibiru, it is defined more concretely by ```go -positionNotional = abs(quoteReserves - k/(baseReserves + position_size)) +positionNotional = abs(quoteReserves - k / (baseReserves + position_size)) leverage = positionNotional / margin. ``` -Let's say that the mark price of ether is $3000 in our previous example. This implies that the trader with a long position of size 5 has a position notional of $15,000. And if the trader has 10x **leverage**, for example, she must have put down $1500 as margin (collateral backing the position). +Let's say that the mark price of ether is \$3000 in our previous example. This implies that the trader with a long position of size 5 has a position notional of \$15,000. And if the trader has 10x **leverage**, for example, she must have put down \$1500 as margin (collateral backing the position). ## Margin and Margin Ratio @@ -66,7 +66,7 @@ Here, `unrealizedPnL` is computed using either the mark price or the 15 minute T When the virtual price is not within the spread tolerance to the index price, the margin ratio used is the highest value between a calculation with the index price (oracle based on underlying) and the mark price (derivative price). -Another good way to think about margin ratio is as the inverse of a position's effective leverage. I.e. if a trader puts down $100 as margin with 5x leverage, the notional is $500 and the margin ratio is 20%, which is equivalent ot `1 / leverage`. +Another good way to think about margin ratio is as the inverse of a position's effective leverage. I.e. if a trader puts down $100 as margin with 5x leverage, the notional is \$500 and the margin ratio is 20%, which is equivalent ot `1 / leverage`. #### Cross Margin versus Isolated Margin @@ -91,17 +91,16 @@ Perpetual contracts rely on a scheduled payment between longs and shorts known a Longs and shorts are paid with the exact funding rate formula [used by FTX](https://help.ftx.com/hc/en-us/articles/360027946571-Funding). Realized and unrealized funding payments are updated every block directly on each position. Global funding calculations are recorded in a time-weighted fashion, where the **funding rate** is the difference between the mark TWAP and index TWAP divided by the number of funding payments per day: -```python -funding_rate -= (mark_TWAP - index_TWAP) / funding_payments_per_day +```go +fundingRate = (markTWAP - indexTWAP) / fundingPaymentsPerDay ``` In the initial version of Nibi-Perps, these payments will occur every half-hour, implying a `funding_payments_per_day` value of 48. This setup is analogous to a traditional future that expires once a day. If a perp trades consistently at 2% above its underlying index price, the funding payments would amount to 2% of the position size after a full day. If the funding rate is positive, mark price > index price and longs pay shorts. Nibi-Perps automatically deducts the funding payment amount from the margin of the long positions. -```python -funding_payment = position_size * funding_rate +```go +fundingPayment = positionSize * fundingRate ``` Here, position size refers to amount of base asset represented by the derivative. I.e., a BTC:USD perp with 7 BTC of exposure would have a position size of 7. diff --git a/x/pricefeed/client/cli/cli_test.go b/x/pricefeed/client/cli/cli_test.go index 8288123f0..e768c50ac 100644 --- a/x/pricefeed/client/cli/cli_test.go +++ b/x/pricefeed/client/cli/cli_test.go @@ -8,6 +8,10 @@ import ( "testing" "time" + "github.com/cosmos/cosmos-sdk/client" + banktestutil "github.com/cosmos/cosmos-sdk/x/bank/client/testutil" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/cosmos/cosmos-sdk/client/flags" "github.com/cosmos/cosmos-sdk/crypto/hd" "github.com/cosmos/cosmos-sdk/crypto/keyring" @@ -339,7 +343,7 @@ func (s IntegrationTestSuite) TestOraclesCmd() { args []string expectedOracles []string - expectErr bool + expectPass bool respType proto.Message }{ { @@ -363,7 +367,7 @@ func (s IntegrationTestSuite) TestOraclesCmd() { args: []string{ "invalid:pair", }, - expectErr: false, + expectPass: false, expectedOracles: []string{}, respType: &pricefeedtypes.QueryOraclesResponse{}, }, @@ -377,7 +381,8 @@ func (s IntegrationTestSuite) TestOraclesCmd() { clientCtx := val.ClientCtx.WithOutputFormat("json") out, err := sdktestutilcli.ExecTestCLICmd(clientCtx, cmd, tc.args) - if tc.expectErr { + + if tc.expectPass { s.Require().Error(err, out.String()) } else { s.Require().NoError(err, out.String()) @@ -391,6 +396,15 @@ func (s IntegrationTestSuite) TestOraclesCmd() { }) } } + +func queryBankBalance(ctx client.Context, s IntegrationTestSuite, account sdk.AccAddress) (finalBalance banktypes.QueryAllBalancesResponse) { + resp, err := banktestutil.QueryBalancesExec(ctx, account) + s.Require().NoError(err) + s.Require().NoError(ctx.Codec.UnmarshalJSON(resp.Bytes(), &finalBalance)) + + return +} + func (s IntegrationTestSuite) TestSetPriceCmd() { err := s.network.WaitForNextBlock() s.Require().NoError(err) @@ -402,11 +416,11 @@ func (s IntegrationTestSuite) TestSetPriceCmd() { expireInOneHour := strconv.Itoa(int(now.Add(1 * time.Hour).Unix())) expiredTS := strconv.Itoa(int(now.Add(-1 * time.Hour).Unix())) - gasFeeToken := sdk.NewCoins(sdk.NewInt64Coin(s.cfg.BondDenom, 1_000_000)) + gasFeeToken := sdk.NewCoins(sdk.NewInt64Coin(s.cfg.BondDenom, 1000)) for _, oracleName := range []string{"genOracle", "wrongOracle"} { _, err = testutilcli.FillWalletFromValidator( /*addr=*/ s.oracleMap[oracleName], - /*balanece=*/ gasFeeToken, + /*balance=*/ gasFeeToken, /*Validator=*/ val, /*feesDenom=*/ s.cfg.BondDenom) s.Require().NoError(err) @@ -423,6 +437,7 @@ func (s IntegrationTestSuite) TestSetPriceCmd() { args []string expectedPriceForPair map[string]sdk.Dec + expectedFeePaid sdk.Int respType proto.Message expectedCode uint32 fromOracle string @@ -434,8 +449,9 @@ func (s IntegrationTestSuite) TestSetPriceCmd() { }, expectedPriceForPair: map[string]sdk.Dec{ gov.String(): sdk.NewDec(100)}, - respType: &sdk.TxResponse{}, - fromOracle: "genOracle", + expectedFeePaid: sdk.NewInt(0), + respType: &sdk.TxResponse{}, + fromOracle: "genOracle", }, { name: "Set the price of the collateral token", @@ -444,35 +460,39 @@ func (s IntegrationTestSuite) TestSetPriceCmd() { }, expectedPriceForPair: map[string]sdk.Dec{ col.String(): sdk.MustNewDecFromStr("0.85")}, - respType: &sdk.TxResponse{}, - fromOracle: "genOracle", + expectedFeePaid: sdk.NewInt(0), + respType: &sdk.TxResponse{}, + fromOracle: "genOracle", }, { name: "Use invalid oracle", args: []string{ col.Token0, col.Token1, "0.5", expireInOneHour, }, - respType: &sdk.TxResponse{}, - expectedCode: 6, - fromOracle: "wrongOracle", + expectedFeePaid: sdk.NewInt(10), // Pay fee since this oracle is not whitelisted + respType: &sdk.TxResponse{}, + expectedCode: 6, + fromOracle: "wrongOracle", }, { name: "Set invalid pair returns an error", args: []string{ "invalid", "pair", "123", expireInOneHour, }, - expectedCode: 6, - respType: &sdk.TxResponse{}, - fromOracle: "genOracle", + expectedFeePaid: sdk.NewInt(10), // Invalid pair means that oracle is not whitelisted for this, needs to pay fees + expectedCode: 6, + respType: &sdk.TxResponse{}, + fromOracle: "genOracle", }, { name: "Set expired pair returns an error", args: []string{ col.Token0, col.Token1, "100", expiredTS, }, - expectedCode: 3, - respType: &sdk.TxResponse{}, - fromOracle: "genOracle", + expectedCode: 3, + expectedFeePaid: sdk.NewInt(0), + respType: &sdk.TxResponse{}, + fromOracle: "genOracle", }, } @@ -484,11 +504,23 @@ func (s IntegrationTestSuite) TestSetPriceCmd() { clientCtx := val.ClientCtx commonArgs = append(commonArgs, - fmt.Sprintf("--%s=%s", flags.FlagFrom, s.oracleMap[tc.fromOracle])) + fmt.Sprintf("--%s=%s", flags.FlagFrom, s.oracleMap[tc.fromOracle]), + ) + + bankBalanceStart := queryBankBalance(clientCtx, s, s.oracleMap[tc.fromOracle]) + out, err := sdktestutilcli.ExecTestCLICmd(clientCtx, cmd, append(tc.args, commonArgs...)) + bankBalanceEnd := queryBankBalance(clientCtx, s, s.oracleMap[tc.fromOracle]) + s.Require().NoError(err) s.Require().NoError(clientCtx.Codec.UnmarshalJSON(out.Bytes(), tc.respType)) + s.Require().EqualValues( + tc.expectedFeePaid.Int64(), + bankBalanceStart.Balances.AmountOf(common.DenomGov). + Sub(bankBalanceEnd.Balances.AmountOf(common.DenomGov)).Int64(), + ) + txResp := tc.respType.(*sdk.TxResponse) err = val.ClientCtx.Codec.UnmarshalJSON(out.Bytes(), txResp) s.Require().NoError(err) @@ -570,7 +602,9 @@ func (s IntegrationTestSuite) TestX_CmdAddOracleProposalAndVote() { _, err = testutilcli.FillWalletFromValidator(oracle, gasTokens, val, s.cfg.BondDenom) s.Require().NoError(err) - s.T().Log("load example json as bytes") + // ---------------------------------------------------------------------- + s.T().Log("load example proposal json as bytes") + // ---------------------------------------------------------------------- proposal := &pricefeedtypes.AddOracleProposal{ Title: "Cataclysm-004", Description: "Whitelists Delphi to post prices for OHM and BTC", @@ -593,14 +627,19 @@ func (s IntegrationTestSuite) TestX_CmdAddOracleProposalAndVote() { contents, err := ioutil.ReadFile(proposalJSON.Name()) s.Assert().NoError(err) + // ---------------------------------------------------------------------- s.T().Log("Unmarshal json bytes into proposal object; check validity") + // ---------------------------------------------------------------------- encodingConfig := simappparams.MakeTestEncodingConfig() proposal = &pricefeedtypes.AddOracleProposal{} err = encodingConfig.Marshaler.UnmarshalJSON(contents, proposal) s.Assert().NoError(err) s.Require().NoError(proposal.Validate()) - s.T().Log("Submit proposal and unmarshal tx response") + // ---------------------------------------------------------------------- + s.T().Log(`Submit proposal and unmarshal tx response + $ nibid tx gov submit-proposal add-oracle [proposal-json] --deposit=[deposit] [flags]`) + // ---------------------------------------------------------------------- args := []string{ proposalJSON.Name(), fmt.Sprintf("--%s=1000unibi", govcli.FlagDeposit), @@ -622,8 +661,11 @@ func (s IntegrationTestSuite) TestX_CmdAddOracleProposalAndVote() { s.Assert().NoError(err) s.Assert().EqualValues(0, txResp.Code, out.String()) + // ---------------------------------------------------------------------- s.T().Log(`Check that proposal was correctly submitted with gov client - $ nibid query gov proposal 1`) + $ nibid query gov proposal 1 + `) + // ---------------------------------------------------------------------- // the proposal tx won't be included until next block s.Assert().NoError(s.network.WaitForNextBlock()) govQueryClient := govtypes.NewQueryClient(clientCtx) @@ -643,8 +685,10 @@ func (s IntegrationTestSuite) TestX_CmdAddOracleProposalAndVote() { proposalsQueryResponse.Proposals[0].TotalDeposit, ) + // ---------------------------------------------------------------------- s.T().Log(`Move proposal to vote status by meeting min deposit - $ nibid tx gov deposit [proposal-id] [deposit] [flags]`) + $ nibid tx gov deposit [proposal-id] [deposit] [flags]`) + // ---------------------------------------------------------------------- govDepositParams, err := govQueryClient.Params( context.Background(), &govtypes.QueryParamsRequest{ParamsType: govtypes.ParamDeposit}) s.Assert().NoError(err) @@ -668,9 +712,11 @@ func (s IntegrationTestSuite) TestX_CmdAddOracleProposalAndVote() { proposalsQueryResponse.Proposals[0].Status, "proposal should be in voting period since min deposit has been met") + // ---------------------------------------------------------------------- s.T().Log(`Vote on the proposal. - $ nibid tx gov vote [proposal-id] [option] [flags] - e.g. $ nibid tx gov vote 1 yes`) + $ nibid tx gov vote [proposal-id] [option] [flags] + For example, $ nibid tx gov vote 1 yes`) + // ---------------------------------------------------------------------- args = []string{ /*proposal-id=*/ "1", /*option=*/ "yes", @@ -694,7 +740,9 @@ func (s IntegrationTestSuite) TestX_CmdAddOracleProposalAndVote() { }, 20*time.Second, 2*time.Second, "proposal should pass after voting period") + // ---------------------------------------------------------------------- s.T().Log("verify that the new proposed pairs have been added to the params") + // ---------------------------------------------------------------------- cmd = cli.CmdQueryParams() args = []string{} queryResp := &pricefeedtypes.QueryParamsResponse{} @@ -703,7 +751,9 @@ func (s IntegrationTestSuite) TestX_CmdAddOracleProposalAndVote() { expectedPairs := append(pricefeedtypes.DefaultPairs, proposalPairs...) s.Assert().EqualValues(expectedPairs, queryResp.Params.Pairs) + // ---------------------------------------------------------------------- s.T().Log("verify that the oracle was whitelisted with a query") + // ---------------------------------------------------------------------- cmd = cli.CmdQueryOracles() for _, pair := range proposalPairs { args = []string{pair.String()} diff --git a/x/vpool/client/cli/cli_test.go b/x/vpool/client/cli/cli_test.go index 37c82d135..5b51e61a7 100644 --- a/x/vpool/client/cli/cli_test.go +++ b/x/vpool/client/cli/cli_test.go @@ -8,8 +8,6 @@ import ( "time" "github.com/cosmos/cosmos-sdk/client/flags" - "github.com/cosmos/cosmos-sdk/crypto/hd" - "github.com/cosmos/cosmos-sdk/crypto/keyring" sdktestutil "github.com/cosmos/cosmos-sdk/testutil" sdktestutilcli "github.com/cosmos/cosmos-sdk/testutil/cli" sdk "github.com/cosmos/cosmos-sdk/types" @@ -27,14 +25,14 @@ import ( vpooltypes "github.com/NibiruChain/nibiru/x/vpool/types" ) -type IntegrationTestSuite struct { +type VpoolCLISuite struct { suite.Suite cfg testutilcli.Config network *testutilcli.Network } -func (s *IntegrationTestSuite) SetupSuite() { +func (s *VpoolCLISuite) SetupSuite() { if testing.Short() { s.T().Skip("skipping integration test suite") } @@ -58,31 +56,19 @@ func (s *IntegrationTestSuite) SetupSuite() { s.Assert().Equal(sdk.NewDec(10), res.Price.Price) } -func (s *IntegrationTestSuite) TearDownSuite() { +func (s *VpoolCLISuite) TearDownSuite() { s.T().Log("tearing down integration test suite") s.network.Cleanup() } -func (s IntegrationTestSuite) TestX_CmdAddVpool() { +func (s VpoolCLISuite) TestX_CmdAddVpool() { s.Require().Len(s.network.Validators, 1) val := s.network.Validators[0] clientCtx := val.ClientCtx.WithOutputFormat("json") - proposer, _, err := val.ClientCtx.Keyring.NewMnemonic( - /* uid */ "proposer", - /* language */ keyring.English, - /* hdPath */ sdk.FullFundraiserPath, - /* bip39Passphrase */ "", - /* algo */ hd.Secp256k1, - ) - s.Require().NoError(err) - s.T().Log("Fill proposer wallet to pay gas for prosal") - gasTokens := sdk.NewCoins(sdk.NewInt64Coin(s.cfg.BondDenom, 100_000_000)) - oracle := sdk.AccAddress(proposer.GetPubKey().Address()) - _, err = testutilcli.FillWalletFromValidator(oracle, gasTokens, val, s.cfg.BondDenom) - s.Require().NoError(err) - - s.T().Log("load example json as bytes") + // ---------------------------------------------------------------------- + s.T().Log("load example proposal json as bytes") + // ---------------------------------------------------------------------- proposal := &vpooltypes.CreatePoolProposal{ Title: "Create ETH:USD pool", Description: "Creates an ETH:USD pool", @@ -107,7 +93,9 @@ func (s IntegrationTestSuite) TestX_CmdAddVpool() { val.ClientCtx.Codec.MustUnmarshalJSON(contents, proposal) s.Require().NoError(proposal.ValidateBasic()) + // ---------------------------------------------------------------------- s.T().Log("Submit proposal and unmarshal tx response") + // ---------------------------------------------------------------------- args := []string{ proposalJSON.Name(), fmt.Sprintf("--%s=1000unibi", govcli.FlagDeposit), @@ -129,8 +117,10 @@ func (s IntegrationTestSuite) TestX_CmdAddVpool() { s.Assert().NoError(err) s.Assert().EqualValues(0, txResp.Code, out.String()) + // ---------------------------------------------------------------------- s.T().Log(`Check that proposal was correctly submitted with gov client $ nibid query gov proposal 1`) + // ---------------------------------------------------------------------- // the proposal tx won't be included until next block s.Assert().NoError(s.network.WaitForNextBlock()) govQueryClient := govtypes.NewQueryClient(clientCtx) @@ -150,13 +140,16 @@ func (s IntegrationTestSuite) TestX_CmdAddVpool() { proposalsQueryResponse.Proposals[0].TotalDeposit, ) + // ---------------------------------------------------------------------- s.T().Log(`Move proposal to vote status by meeting min deposit $ nibid tx gov deposit [proposal-id] [deposit] [flags]`) + // ---------------------------------------------------------------------- + expectedProposalIDStr := "1" govDepositParams, err := govQueryClient.Params( context.Background(), &govtypes.QueryParamsRequest{ParamsType: govtypes.ParamDeposit}) s.Assert().NoError(err) args = []string{ - /*proposal-id=*/ "1", + /*proposal-id=*/ expectedProposalIDStr, /*deposit=*/ govDepositParams.DepositParams.MinDeposit.String(), fmt.Sprintf("--%s=true", flags.FlagSkipConfirmation), fmt.Sprintf("--%s=test", flags.FlagKeyringBackend), @@ -175,11 +168,13 @@ func (s IntegrationTestSuite) TestX_CmdAddVpool() { proposalsQueryResponse.Proposals[0].Status, "proposal should be in voting period since min deposit has been met") + // ---------------------------------------------------------------------- s.T().Log(`Vote on the proposal. $ nibid tx gov vote [proposal-id] [option] [flags] e.g. $ nibid tx gov vote 1 yes`) + // ---------------------------------------------------------------------- args = []string{ - /*proposal-id=*/ "1", + /*proposal-id=*/ expectedProposalIDStr, /*option=*/ "yes", fmt.Sprintf("--%s=true", flags.FlagSkipConfirmation), fmt.Sprintf("--%s=test", flags.FlagKeyringBackend), @@ -201,7 +196,9 @@ func (s IntegrationTestSuite) TestX_CmdAddVpool() { }, 20*time.Second, 2*time.Second, "proposal should pass after voting period") + // ---------------------------------------------------------------------- s.T().Log("verify that the new proposed pool exists") + // ---------------------------------------------------------------------- cmd = cli.CmdGetVpools() args = []string{} queryResp := &vpooltypes.QueryAllPoolsResponse{} @@ -223,10 +220,9 @@ func (s IntegrationTestSuite) TestX_CmdAddVpool() { found = true } } - require.True(s.T(), found, "pool does not exist") } -func TestIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(IntegrationTestSuite)) +func TestVpoolCLISuite(t *testing.T) { + suite.Run(t, new(VpoolCLISuite)) } diff --git a/x/vpool/keeper/keeper.go b/x/vpool/keeper/keeper.go index 9ab34f45e..effb52f91 100644 --- a/x/vpool/keeper/keeper.go +++ b/x/vpool/keeper/keeper.go @@ -355,7 +355,8 @@ func (k Keeper) GetMaintenanceMarginRatio(ctx sdk.Context, pair common.AssetPair return pool.MaintenanceMarginRatio } -/** +/* +* GetMaxLeverage returns the maximum leverage required to open a position in the pool. args: