Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: VoteExtension Slinky logic #1139

Merged
merged 3 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions protocol/app/process/market_price_decoder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package process

import (
sdk "github.com/cosmos/cosmos-sdk/types"
pricestypes "github.com/dydxprotocol/v4-chain/protocol/x/prices/types"
)

// MarketPriceDecoder is an interface for decoding market price transactions, This interface is responsible
// for distinguishing between logic for unmarshalling MarketPriceUpdates, between MarketPriceUpdates
// determined by the proposer's price-cache, and from VoteExtensions.
type UpdateMarketPriceTxDecoder interface {
// DecodeUpdateMarketPricesTx decodes the tx-bytes from the RequestProcessProposal and returns a MarketPriceUpdateTx.
DecodeUpdateMarketPricesTx(ctx sdk.Context, txs [][]byte) (*UpdateMarketPricesTx, error)

// GetTxOffset returns the offset that other injected txs should be placed with respect to their normally
// expected indices. This method is used to account for injected vote-extensions, or any other injected
// txs from dependencies.
GetTxOffset(ctx sdk.Context) int
}

func NewUpdateMarketPricesTx(
ctx sdk.Context, pk ProcessPricesKeeper, msg *pricestypes.MsgUpdateMarketPrices) *UpdateMarketPricesTx {
return &UpdateMarketPricesTx{
ctx: ctx,
pricesKeeper: pk,
msg: msg,
}
}
18 changes: 18 additions & 0 deletions protocol/app/vote_extensions/expected_keepers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package vote_extensions

import (
sdk "github.com/cosmos/cosmos-sdk/types"
oracletypes "github.com/skip-mev/slinky/pkg/types"

pricestypes "github.com/dydxprotocol/v4-chain/protocol/x/prices/types"
)

// PricesKeeper is the expected interface for the x/price keeper used by the vote extension handlers
type PricesKeeper interface {
GetCurrencyPairFromID(ctx sdk.Context, id uint64) (cp oracletypes.CurrencyPair, found bool)
GetValidMarketPriceUpdates(ctx sdk.Context) *pricestypes.MsgUpdateMarketPrices
UpdateMarketPrices(
ctx sdk.Context,
updates []*pricestypes.MsgUpdateMarketPrices_MarketPrice,
) (err error)
}
62 changes: 62 additions & 0 deletions protocol/app/vote_extensions/extend_vote_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package vote_extensions

import (
"fmt"

cometabci "github.com/cometbft/cometbft/abci/types"
sdk "github.com/cosmos/cosmos-sdk/types"

"github.com/dydxprotocol/v4-chain/protocol/app/process"
prices "github.com/dydxprotocol/v4-chain/protocol/x/prices/types"
)

// ExtendVoteHandler is a wrapper around the Slinky ExtendVoteHandler. This wrapper is responsible for
Eric-Warehime marked this conversation as resolved.
Show resolved Hide resolved
// applying the newest MarketPriceUpdates in a block so that the prices to be submitted in a vote extension are
// determined on the latest available information.
type ExtendVoteHandler struct {
SlinkyExtendVoteHandler sdk.ExtendVoteHandler
PricesTxDecoder process.UpdateMarketPriceTxDecoder
PricesKeeper PricesKeeper
}

// ExtendVoteHandler returns a sdk.ExtendVoteHandler, responsible for the following:
// 1. Decoding the x/prices MsgUpdateMarketPrices in the current block - fail on errors
// 2. Validating the proposed MsgUpdateMarketPrices in accordance with the ProcessProposal check
// 3. Updating the market prices in the PricesKeeper so that the GetValidMarketPriceUpdates function returns the
// latest available market prices
// 4. Calling the Slinky ExtendVoteHandler to handle the rest of ExtendVote
//
// See https://github.com/skip-mev/slinky/blob/a5b1d3d3a2723e4746b5d588c512d7cc052dc0ff/abci/ve/vote_extension.go#L77
// for the Slinky ExtendVoteHandler logic.
func (e *ExtendVoteHandler) ExtendVoteHandler() sdk.ExtendVoteHandler {
return func(ctx sdk.Context, req *cometabci.RequestExtendVote) (resp *cometabci.ResponseExtendVote, err error) {
// Decode the x/prices txn in the current block
updatePricesTx, err := e.PricesTxDecoder.DecodeUpdateMarketPricesTx(ctx, req.Txs)
if err != nil {
return nil, fmt.Errorf("DecodeMarketPricesTx failure %w", err)
}

// ensure that the proposed MsgUpdateMarketPrices is valid in accordance w/ stateful information
// this check is equivalent to the check in ProcessProposal (indexPriceCache has not been updated)
err = updatePricesTx.Validate()
if err != nil {
return nil, fmt.Errorf("updatePricesTx.Validate failure %w", err)
}

// Update the market prices in the PricesKeeper, so that the GetValidMarketPriceUpdates
// function returns the latest available market prices.
updateMarketPricesMsg, ok := updatePricesTx.GetMsg().(*prices.MsgUpdateMarketPrices)
if !ok {
return nil, fmt.Errorf("expected %s, got %T", "MsgUpdateMarketPrices", updateMarketPricesMsg)
}

// Update the market prices in the PricesKeeper
err = e.PricesKeeper.UpdateMarketPrices(ctx, updateMarketPricesMsg.MarketPriceUpdates)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we updating market prices in ExtendVote?

Copy link
Contributor Author

@Eric-Warehime Eric-Warehime Mar 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because VEs are generated before deliver tx runs.

When we tested the protocol changes initially we ran into the following scenario:

  1. ExtendVote in block N-1 runs and suggests a price change to price New.
  2. Prepare/Process Proposal runs in block N and the price change is accepted into the block.
  3. ExtendVote runs in block N and suggests to change the price to New (or New + Delta where Delta is smaller than the required min price change) since DeliverTx hasn't actually changed the x/prices state.
  4. Block N+1 runs and PrepareProposal creates an UpdateMarketPrices Tx which is rejected in ProcessProposal because of the min_price_change constraint being violated.

The chain in this case would perpetually propose an UpdateMarketPrices tx which would be rejected and it would never make progress.

The solution to this was to execute the changes to the PricesKeeper in ExtendVote before calling GetValidMarketPriceUpdates. This way all suggested price changes are based on the state the prices will be in after the current block is applied.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIuc, Since ExtendVote for block N occurs before block N is committed, the price updates proposed by block N may not be valid?

I think I'm a little confused too, because I thought if in Block N+1 received a consensus through vote extension which New + Delta where Delta is less than min_price_change, PrepareProposal would just ignore that price and would not create an UpdateMarketPrices Tx with a price update

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIuc, Since ExtendVote for block N occurs before block N is committed, the price updates proposed by block N may not be valid?

Correct. The changes are valid as long as we first apply the changes in block N.

I think I'm a little confused too, because I thought if in Block N+1 received a consensus through vote extension which New + Delta where Delta is less than min_price_change, PrepareProposal would just ignore that price and would not create an UpdateMarketPrices Tx with a price update

No, the Default prepare handler which uses the existing logic calls the Prices keeper to generate the tx which means prices with less than min_price_change movement are ignored.

In the Slinky prepare handler we just aggregate over the data in the VEs so the responsibility for making sure that data is valid is in the ExtendVoteHandler.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have prices transaction as the last one in a block as things such as order matches are created based on old prices. If we update prices in ExtendVote, that will potentially make other transactions invalid

if err != nil {
return nil, fmt.Errorf("failed to update market prices in extend vote handler pre-slinky invocation %w", err)
}

// Call the Slinky ExtendVoteHandler
return e.SlinkyExtendVoteHandler(ctx, req)
}
}
133 changes: 133 additions & 0 deletions protocol/app/vote_extensions/extend_vote_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package vote_extensions

import (
"fmt"
"testing"

cometabci "github.com/cometbft/cometbft/abci/types"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/dydxprotocol/v4-chain/protocol/app/process"
"github.com/dydxprotocol/v4-chain/protocol/mocks"
"github.com/dydxprotocol/v4-chain/protocol/testutil/constants"
)

func TestExtendVoteHandlerDeecodeMarketPricesFailure(t *testing.T) {
slinkyEvh := mocks.NewExtendVoteHandler(t)
pricesTxDecoder := mocks.NewUpdateMarketPriceTxDecoder(t)
pricesKeeper := mocks.NewPricesKeeper(t)
evh := ExtendVoteHandler{
SlinkyExtendVoteHandler: slinkyEvh.Execute,
PricesTxDecoder: pricesTxDecoder,
PricesKeeper: pricesKeeper,
}

pricesTxDecoder.On("DecodeUpdateMarketPricesTx", mock.Anything, mock.Anything).Return(
nil, fmt.Errorf("foobar"))
_, err := evh.ExtendVoteHandler()(sdk.Context{}, &cometabci.RequestExtendVote{Txs: make([][]byte, 0)})

require.ErrorContains(t, err, "DecodeMarketPricesTx failure foobar")
pricesTxDecoder.AssertExpectations(t)
pricesKeeper.AssertExpectations(t)
slinkyEvh.AssertExpectations(t)
}

func TestExtendVoteHandlerUpdatePricesTxValidateFailure(t *testing.T) {
slinkyEvh := mocks.NewExtendVoteHandler(t)
pricesTxDecoder := mocks.NewUpdateMarketPriceTxDecoder(t)
pricesKeeper := mocks.NewPricesKeeper(t)
evh := ExtendVoteHandler{
SlinkyExtendVoteHandler: slinkyEvh.Execute,
PricesTxDecoder: pricesTxDecoder,
PricesKeeper: pricesKeeper,
}

pricesTxDecoder.On("DecodeUpdateMarketPricesTx", mock.Anything, mock.Anything).Return(
process.NewUpdateMarketPricesTx(sdk.Context{}, pricesKeeper, constants.InvalidMsgUpdateMarketPricesStateless),
nil)
_, err := evh.ExtendVoteHandler()(sdk.Context{}, &cometabci.RequestExtendVote{Txs: make([][]byte, 0)})

require.ErrorContains(t, err, "updatePricesTx.Validate failure")
pricesTxDecoder.AssertExpectations(t)
pricesKeeper.AssertExpectations(t)
slinkyEvh.AssertExpectations(t)
}

func TestExtendVoteHandlerUpdateMarketPricesError(t *testing.T) {
slinkyEvh := mocks.NewExtendVoteHandler(t)
pricesTxDecoder := mocks.NewUpdateMarketPriceTxDecoder(t)
pricesKeeper := mocks.NewPricesKeeper(t)
evh := ExtendVoteHandler{
SlinkyExtendVoteHandler: slinkyEvh.Execute,
PricesTxDecoder: pricesTxDecoder,
PricesKeeper: pricesKeeper,
}

pricesTxDecoder.On("DecodeUpdateMarketPricesTx", mock.Anything, mock.Anything).Return(
process.NewUpdateMarketPricesTx(sdk.Context{}, pricesKeeper, constants.EmptyMsgUpdateMarketPrices),
nil)
pricesKeeper.On("PerformStatefulPriceUpdateValidation", mock.Anything, mock.Anything, mock.Anything).
Return(nil)
pricesKeeper.On("UpdateMarketPrices", mock.Anything, mock.Anything).
Return(fmt.Errorf(""))
_, err := evh.ExtendVoteHandler()(sdk.Context{}, &cometabci.RequestExtendVote{Txs: make([][]byte, 0)})

require.ErrorContains(t, err, "failed to update market prices in extend vote handler pre-slinky invocation")
pricesTxDecoder.AssertExpectations(t)
pricesKeeper.AssertExpectations(t)
slinkyEvh.AssertExpectations(t)
}

func TestExtendVoteHandlerSlinkyFailure(t *testing.T) {
slinkyEvh := mocks.NewExtendVoteHandler(t)
pricesTxDecoder := mocks.NewUpdateMarketPriceTxDecoder(t)
pricesKeeper := mocks.NewPricesKeeper(t)
evh := ExtendVoteHandler{
SlinkyExtendVoteHandler: slinkyEvh.Execute,
PricesTxDecoder: pricesTxDecoder,
PricesKeeper: pricesKeeper,
}

pricesTxDecoder.On("DecodeUpdateMarketPricesTx", mock.Anything, mock.Anything).Return(
process.NewUpdateMarketPricesTx(sdk.Context{}, pricesKeeper, constants.EmptyMsgUpdateMarketPrices),
nil)
pricesKeeper.On("PerformStatefulPriceUpdateValidation", mock.Anything, mock.Anything, mock.Anything).
Return(nil)
pricesKeeper.On("UpdateMarketPrices", mock.Anything, mock.Anything).Return(nil)
slinkyEvh.On("Execute", mock.Anything, mock.Anything).
Return(&cometabci.ResponseExtendVote{}, fmt.Errorf("slinky failure"))
_, err := evh.ExtendVoteHandler()(sdk.Context{}, &cometabci.RequestExtendVote{Txs: make([][]byte, 0)})

require.ErrorContains(t, err, "slinky failure")
pricesTxDecoder.AssertExpectations(t)
pricesKeeper.AssertExpectations(t)
slinkyEvh.AssertExpectations(t)
}

func TestExtendVoteHandlerSlinkySuccess(t *testing.T) {
slinkyEvh := mocks.NewExtendVoteHandler(t)
pricesTxDecoder := mocks.NewUpdateMarketPriceTxDecoder(t)
pricesKeeper := mocks.NewPricesKeeper(t)
evh := ExtendVoteHandler{
SlinkyExtendVoteHandler: slinkyEvh.Execute,
PricesTxDecoder: pricesTxDecoder,
PricesKeeper: pricesKeeper,
}

pricesTxDecoder.On("DecodeUpdateMarketPricesTx", mock.Anything, mock.Anything).Return(
process.NewUpdateMarketPricesTx(sdk.Context{}, pricesKeeper, constants.EmptyMsgUpdateMarketPrices),
nil)
pricesKeeper.On("PerformStatefulPriceUpdateValidation", mock.Anything, mock.Anything, mock.Anything).
Return(nil)
pricesKeeper.On("UpdateMarketPrices", mock.Anything, mock.Anything).Return(nil)
slinkyEvh.On("Execute", mock.Anything, mock.Anything).
Return(&cometabci.ResponseExtendVote{}, nil)
_, err := evh.ExtendVoteHandler()(sdk.Context{}, &cometabci.RequestExtendVote{Txs: make([][]byte, 0)})

require.NoError(t, err)
pricesTxDecoder.AssertExpectations(t)
pricesKeeper.AssertExpectations(t)
slinkyEvh.AssertExpectations(t)
}
77 changes: 77 additions & 0 deletions protocol/app/vote_extensions/oracle_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package vote_extensions

import (
"context"
"fmt"
"strconv"
"time"

sdk "github.com/cosmos/cosmos-sdk/types"
oracleservicetypes "github.com/skip-mev/slinky/service/servers/oracle/types"
"google.golang.org/grpc"
)

// OraclePrices is an implementation of the Slinky OracleClient interface.
// This object is responsible for requesting prices from the x/prices module, and injecting those prices into the
// vote-extension.
// The
type OraclePrices struct {
PricesKeeper PricesKeeper
}

// NewOraclePrices returns a new OracleClient object.
func NewOraclePrices(pricesKeeper PricesKeeper) *OraclePrices {
return &OraclePrices{
PricesKeeper: pricesKeeper,
}
}

// Start is a no-op.
func (o *OraclePrices) Start(_ context.Context) error {
return nil
}

// Stop is a no-op.
func (o *OraclePrices) Stop() error {
return nil
}

// Prices is called in ExtendVoteHandler to determine which Prices are put into the extended commit.
// This method is responsible for doing the following:
// 1. Get the latest prices from the x/prices module's indexPriceCache via GetValidMarketPriceUpdates
// 2. Translate the response from x/prices into a QueryPricesResponse, and return it.
//
// This method fails if:
// - The passed in context is not an sdk.Context
func (o *OraclePrices) Prices(ctx context.Context,
_ *oracleservicetypes.QueryPricesRequest,
_ ...grpc.CallOption) (*oracleservicetypes.QueryPricesResponse, error) {
sdkCtx, ok := ctx.(sdk.Context)
if !ok {
return nil, fmt.Errorf("OraclePrices was passed on non-sdk context object")
}

// get the final prices to include in the vote-extension from the x/prices module
validUpdates := o.PricesKeeper.GetValidMarketPriceUpdates(sdkCtx)
if validUpdates == nil {
sdkCtx.Logger().Info("prices keeper returned no valid market price updates")
return nil, nil
}
sdkCtx.Logger().Info("prices keeper returned valid updates", "length", len(validUpdates.MarketPriceUpdates))

// translate price updates into oracle response
var outputResponse = &oracleservicetypes.QueryPricesResponse{
Prices: make(map[string]string),
Timestamp: time.Now(),
}
for _, update := range validUpdates.MarketPriceUpdates {
mappedPair, found := o.PricesKeeper.GetCurrencyPairFromID(sdkCtx, uint64(update.GetMarketId()))
if found {
sdkCtx.Logger().Info("added currency pair", "pair", mappedPair.String())
outputResponse.Prices[mappedPair.String()] = strconv.FormatUint(update.Price, 10)
} else {
sdkCtx.Logger().Info("failed to add currency pair", "pair", mappedPair.String())
}
}
return outputResponse, nil
}
Loading
Loading