diff --git a/protocol/testutil/constants/positions.go b/protocol/testutil/constants/positions.go index 5035068aa7..0dcf8e48cc 100644 --- a/protocol/testutil/constants/positions.go +++ b/protocol/testutil/constants/positions.go @@ -76,7 +76,7 @@ var ( // Long position for arbitrary isolated market PerpetualPosition_OneISOLong = satypes.PerpetualPosition{ PerpetualId: 3, - Quantums: dtypes.NewInt(100_000_000), + Quantums: dtypes.NewInt(1_000_000_000), FundingIndex: dtypes.NewInt(0), } PerpetualPosition_OneISO2Long = satypes.PerpetualPosition{ diff --git a/protocol/x/subaccounts/keeper/isolated_subaccount.go b/protocol/x/subaccounts/keeper/isolated_subaccount.go index e5547d3986..d302817788 100644 --- a/protocol/x/subaccounts/keeper/isolated_subaccount.go +++ b/protocol/x/subaccounts/keeper/isolated_subaccount.go @@ -2,10 +2,12 @@ package keeper import ( "math" + "math/big" errorsmod "cosmossdk.io/errors" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/dydxprotocol/v4-chain/protocol/lib" + assettypes "github.com/dydxprotocol/v4-chain/protocol/x/assets/types" perptypes "github.com/dydxprotocol/v4-chain/protocol/x/perpetuals/types" "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" ) @@ -22,7 +24,7 @@ import ( // caused a failure, if any. func (k Keeper) checkIsolatedSubaccountConstraints( ctx sdk.Context, - settledUpdates []settledUpdate, + settledUpdates []SettledUpdate, perpetuals []perptypes.Perpetual, ) ( success bool, @@ -31,11 +33,7 @@ func (k Keeper) checkIsolatedSubaccountConstraints( ) { success = true successPerUpdate = make([]types.UpdateResult, len(settledUpdates)) - var perpIdToMarketType = make(map[uint32]perptypes.PerpetualMarketType) - - for _, perpetual := range perpetuals { - perpIdToMarketType[perpetual.GetId()] = perpetual.Params.MarketType - } + perpIdToMarketType := getPerpIdToMarketTypeMap(perpetuals) for i, u := range settledUpdates { result, err := isValidIsolatedPerpetualUpdates(u, perpIdToMarketType) @@ -63,7 +61,7 @@ func (k Keeper) checkIsolatedSubaccountConstraints( // - a subaccount with no positions cannot be updated to have positions in multiple isolated // perpetuals or a combination of isolated and non-isolated perpetuals func isValidIsolatedPerpetualUpdates( - settledUpdate settledUpdate, + settledUpdate SettledUpdate, perpIdToMarketType map[uint32]perptypes.PerpetualMarketType, ) (types.UpdateResult, error) { // If there are no perpetual updates, then this update does not violate constraints for isolated @@ -140,3 +138,222 @@ func isValidIsolatedPerpetualUpdates( return types.Success, nil } + +// GetIsolatedPerpetualStateTransition computes whether an isolated perpetual position will be +// opened or closed for a subaccount. +// This function assumes that the subaccount is valid under isolated perpetual constraints. +// The input `settledUpdate` must have an updated subaccount (`settledUpdate.SettledSubaccount`), +// so all the updates must have been applied already to the subaccount. +func GetIsolatedPerpetualStateTransition( + settledUpdateWithUpdatedSubaccount SettledUpdate, + perpetuals []perptypes.Perpetual, +) (*types.IsolatedPerpetualPositionStateTransition, error) { + perpIdToMarketType := getPerpIdToMarketTypeMap(perpetuals) + // This subaccount needs to have had the updates in the `settledUpdate` already applied to it. + updatedSubaccount := settledUpdateWithUpdatedSubaccount.SettledSubaccount + // If there are no perpetual updates, then no perpetual position could have been opened or closed + // on the subaccount. + if len(settledUpdateWithUpdatedSubaccount.PerpetualUpdates) == 0 { + return nil, nil + } + + // If there are more than 1 valid perpetual update, or more than 1 valid perpetual position on the + // subaccount, it is not isolated to an isolated perpetual, and so no isolated perpetual position + // could have been opened or closed. + if len(settledUpdateWithUpdatedSubaccount.PerpetualUpdates) > 1 || + len(updatedSubaccount.PerpetualPositions) > 1 { + return nil, nil + } + + // Now, from the above checks, we know there is only a single perpetual update and 0 or 1 perpetual + // positions. + perpetualUpdate := settledUpdateWithUpdatedSubaccount.PerpetualUpdates[0] + marketType, exists := perpIdToMarketType[perpetualUpdate.PerpetualId] + if !exists { + return nil, errorsmod.Wrap( + perptypes.ErrPerpetualDoesNotExist, lib.UintToString(perpetualUpdate.PerpetualId), + ) + } + // If the perpetual update is not for an isolated perpetual, no isolated perpetual position is + // being opened or closed. + if marketType != perptypes.PerpetualMarketType_PERPETUAL_MARKET_TYPE_ISOLATED { + return nil, nil + } + + // If the updated subaccount does not have any perpetual positions, then an isolated perpetual + // position must have been closed due to the perpetual update. + if len(updatedSubaccount.PerpetualPositions) == 0 { + return &types.IsolatedPerpetualPositionStateTransition{ + SubaccountId: updatedSubaccount.Id, + PerpetualId: perpetualUpdate.PerpetualId, + QuoteQuantums: updatedSubaccount.GetUsdcPosition(), + Transition: types.Closed, + }, nil + } + + // After the above checks, the subaccount must have only a single perpetual position, which is for + // the same isolated perpetual as the perpetual update. + perpetualPosition := updatedSubaccount.PerpetualPositions[0] + // If the size of the update and the position are the same, the perpetual update must have opened + // the position. + if perpetualUpdate.GetBigQuantums().Cmp(perpetualPosition.GetBigQuantums()) == 0 { + if len(settledUpdateWithUpdatedSubaccount.AssetUpdates) != 1 { + return nil, errorsmod.Wrapf( + types.ErrFailedToUpdateSubaccounts, + "Subaccount with id %v opened perpteual position with perpetual id %d with invalid number of"+ + " changes to asset positions (%d), should only be 1 asset update", + updatedSubaccount.Id, + perpetualUpdate.PerpetualId, + len(settledUpdateWithUpdatedSubaccount.AssetUpdates), + ) + } + if settledUpdateWithUpdatedSubaccount.AssetUpdates[0].AssetId != assettypes.AssetUsdc.Id { + return nil, errorsmod.Wrapf( + types.ErrFailedToUpdateSubaccounts, + "Subaccount with id %v opened perpteual position with perpetual id %d without a change to the"+ + " quote currency's asset position.", + updatedSubaccount.Id, + perpetualUpdate.PerpetualId, + ) + } + // Collateral equal to the quote currency asset position before the update was applied needs to be transferred. + // Subtract the delta from the updated subaccount's quote currency asset position size to get the size + // of the quote currency asset position. + quoteQuantumsBeforeUpdate := new(big.Int).Sub( + updatedSubaccount.GetUsdcPosition(), + settledUpdateWithUpdatedSubaccount.AssetUpdates[0].GetBigQuantums(), + ) + return &types.IsolatedPerpetualPositionStateTransition{ + SubaccountId: updatedSubaccount.Id, + PerpetualId: perpetualUpdate.PerpetualId, + QuoteQuantums: quoteQuantumsBeforeUpdate, + Transition: types.Opened, + }, nil + } + + // The isolated perpetual position changed size but was not opened or closed. + return nil, nil +} + +// transferCollateralForIsolatedPerpetual transfers collateral between an isolated collateral pool +// and the cross-perpetual collateral pool based on whether an isolated perpetual position was +// opened or closed in a subaccount. +// Note: This uses the `x/bank` keeper and modifies `x/bank` state. +func (k *Keeper) transferCollateralForIsolatedPerpetual( + ctx sdk.Context, + stateTransition *types.IsolatedPerpetualPositionStateTransition, +) error { + // No collateral to transfer if no state transition. + if stateTransition == nil { + return nil + } + + // If there are zero quantums to transfer, don't transfer collateral. + if stateTransition.QuoteQuantums.Sign() == 0 { + return nil + } + + isolatedCollateralPoolAddr, err := k.GetCollateralPoolFromPerpetualId(ctx, stateTransition.PerpetualId) + if err != nil { + return err + } + var toModuleAddr sdk.AccAddress + var fromModuleAddr sdk.AccAddress + + // If an isolated perpetual position was opened in the subaccount, then move collateral from the + // cross-perpetual collateral pool to the isolated perpetual collateral pool. + if stateTransition.Transition == types.Opened { + toModuleAddr = isolatedCollateralPoolAddr + fromModuleAddr = types.ModuleAddress + // If the isolated perpetual position was closed, then move collateral from the isolated + // perpetual collateral pool to the cross-perpetual collateral pool. + } else if stateTransition.Transition == types.Closed { + toModuleAddr = types.ModuleAddress + fromModuleAddr = isolatedCollateralPoolAddr + } else { + // Should never hit this. + return errorsmod.Wrapf( + types.ErrFailedToUpdateSubaccounts, + "Invalid state transition %v for isolated perpetual with id %d in subaccount with id %v", + stateTransition, + stateTransition.PerpetualId, + stateTransition.SubaccountId, + ) + } + + // Invalid to transfer negative quantums. This should already be caught by collateralization + // checks as well. + if stateTransition.QuoteQuantums.Sign() == -1 { + return errorsmod.Wrapf( + types.ErrFailedToUpdateSubaccounts, + "Subaccount with id %v %s perpteual position with perpetual id %d with negative collateral %s to transfer", + stateTransition.SubaccountId, + stateTransition.Transition.String(), + stateTransition.PerpetualId, + stateTransition.QuoteQuantums.String(), + ) + } + + // Transfer collateral between collateral pools. + _, coinToTransfer, err := k.assetsKeeper.ConvertAssetToCoin( + ctx, + // TODO(DEC-715): Support non-USDC assets. + assettypes.AssetUsdc.Id, + stateTransition.QuoteQuantums, + ) + if err != nil { + return err + } + + if err = k.bankKeeper.SendCoins( + ctx, + fromModuleAddr, + toModuleAddr, + []sdk.Coin{coinToTransfer}, + ); err != nil { + return err + } + + return nil +} + +// computeAndExecuteCollateralTransfer computes collateral transfers resulting from updates to +// a subaccount and executes the collateral transfer using `x/bank`.` +// The input `settledUpdate` must have an updated subaccount (`settledUpdate.SettledSubaccount`), +// so all the updates must have been applied already to the subaccount. +// Note: This uses the `x/bank` keeper and modifies `x/bank` state. +func (k *Keeper) computeAndExecuteCollateralTransfer( + ctx sdk.Context, + settledUpdateWithUpdatedSubaccount SettledUpdate, + perpetuals []perptypes.Perpetual, +) error { + // The subaccount in `settledUpdateWithUpdatedSubaccount` already has the perpetual updates + // and asset updates applied to it. + stateTransition, err := GetIsolatedPerpetualStateTransition( + settledUpdateWithUpdatedSubaccount, + perpetuals, + ) + if err != nil { + return err + } + if err := k.transferCollateralForIsolatedPerpetual( + ctx, + stateTransition, + ); err != nil { + return err + } + + return nil +} + +func getPerpIdToMarketTypeMap( + perpetuals []perptypes.Perpetual, +) map[uint32]perptypes.PerpetualMarketType { + var perpIdToMarketType = make(map[uint32]perptypes.PerpetualMarketType) + + for _, perpetual := range perpetuals { + perpIdToMarketType[perpetual.GetId()] = perpetual.Params.MarketType + } + + return perpIdToMarketType +} diff --git a/protocol/x/subaccounts/keeper/isolated_subaccount_test.go b/protocol/x/subaccounts/keeper/isolated_subaccount_test.go new file mode 100644 index 0000000000..25073a920e --- /dev/null +++ b/protocol/x/subaccounts/keeper/isolated_subaccount_test.go @@ -0,0 +1,335 @@ +package keeper_test + +import ( + "math/big" + "testing" + + "github.com/dydxprotocol/v4-chain/protocol/dtypes" + "github.com/dydxprotocol/v4-chain/protocol/testutil/constants" + assettypes "github.com/dydxprotocol/v4-chain/protocol/x/assets/types" + perptypes "github.com/dydxprotocol/v4-chain/protocol/x/perpetuals/types" + "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/keeper" + "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" + "github.com/stretchr/testify/require" +) + +func TestGetIsolatedPerpetualStateTransition(t *testing.T) { + tests := map[string]struct { + // parameters + settledUpdateWithUpdatedSubaccount keeper.SettledUpdate + perpetuals []perptypes.Perpetual + + // expectation + expectedStateTransition *types.IsolatedPerpetualPositionStateTransition + expectedErr error + }{ + `If no perpetual updates, nil state transition is returned`: { + settledUpdateWithUpdatedSubaccount: keeper.SettledUpdate{ + SettledSubaccount: types.Subaccount{ + Id: &constants.Alice_Num0, + PerpetualPositions: nil, + AssetPositions: nil, + }, + PerpetualUpdates: nil, + AssetUpdates: nil, + }, + perpetuals: nil, + expectedStateTransition: nil, + }, + `If single non-isolated perpetual updates, nil state transition is returned`: { + settledUpdateWithUpdatedSubaccount: keeper.SettledUpdate{ + SettledSubaccount: types.Subaccount{ + Id: &constants.Alice_Num0, + PerpetualPositions: nil, + AssetPositions: nil, + }, + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(0), + BigQuantumsDelta: big.NewInt(-100), + }, + }, + AssetUpdates: nil, + }, + perpetuals: []perptypes.Perpetual{ + constants.BtcUsd_100PercentMarginRequirement, + }, + expectedStateTransition: nil, + }, + `If multiple non-isolated perpetual updates, nil state transition is returned`: { + settledUpdateWithUpdatedSubaccount: keeper.SettledUpdate{ + SettledSubaccount: types.Subaccount{ + Id: &constants.Alice_Num0, + PerpetualPositions: nil, + AssetPositions: nil, + }, + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(0), + BigQuantumsDelta: big.NewInt(-100), + }, + { + PerpetualId: uint32(1), + BigQuantumsDelta: big.NewInt(-200), + }, + }, + AssetUpdates: nil, + }, + perpetuals: []perptypes.Perpetual{ + constants.BtcUsd_100PercentMarginRequirement, + constants.EthUsd_NoMarginRequirement, + }, + expectedStateTransition: nil, + }, + `If multiple non-isolated perpetual positions, nil state transition is returned`: { + settledUpdateWithUpdatedSubaccount: keeper.SettledUpdate{ + SettledSubaccount: types.Subaccount{ + Id: &constants.Alice_Num0, + PerpetualPositions: []*types.PerpetualPosition{ + &constants.PerpetualPosition_OneBTCLong, + &constants.PerpetualPosition_OneTenthEthLong, + }, + AssetPositions: nil, + }, + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(0), + BigQuantumsDelta: big.NewInt(-100), + }, + }, + AssetUpdates: nil, + }, + perpetuals: []perptypes.Perpetual{ + constants.BtcUsd_100PercentMarginRequirement, + constants.EthUsd_NoMarginRequirement, + }, + expectedStateTransition: nil, + }, + `If single isolated perpetual update, no perpetual position, state transition is returned for closed position`: { + settledUpdateWithUpdatedSubaccount: keeper.SettledUpdate{ + SettledSubaccount: types.Subaccount{ + Id: &constants.Alice_Num0, + PerpetualPositions: nil, + AssetPositions: []*types.AssetPosition{ + &constants.Usdc_Asset_10_000, + }, + }, + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(3), + BigQuantumsDelta: big.NewInt(-1_000_000_000), + }, + }, + AssetUpdates: []types.AssetUpdate{ + { + AssetId: assettypes.AssetUsdc.Id, + BigQuantumsDelta: big.NewInt(100_000_000), + }, + }, + }, + perpetuals: []perptypes.Perpetual{ + constants.IsoUsd_IsolatedMarket, + }, + expectedStateTransition: &types.IsolatedPerpetualPositionStateTransition{ + SubaccountId: &constants.Alice_Num0, + PerpetualId: uint32(3), + QuoteQuantums: constants.Usdc_Asset_10_000.GetBigQuantums(), + Transition: types.Closed, + }, + }, + `If single isolated perpetual update, existing perpetual position with same size, state transition is returned for + opened position`: { + settledUpdateWithUpdatedSubaccount: keeper.SettledUpdate{ + SettledSubaccount: types.Subaccount{ + Id: &constants.Alice_Num0, + PerpetualPositions: []*types.PerpetualPosition{ + &constants.PerpetualPosition_OneISOLong, + }, + AssetPositions: []*types.AssetPosition{ + { + AssetId: assettypes.AssetUsdc.Id, + Quantums: dtypes.NewInt(-40_000_000), // -$40 + }, + }, + }, + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(3), + BigQuantumsDelta: big.NewInt(1_000_000_000), // 1 ISO + }, + }, + AssetUpdates: []types.AssetUpdate{ + { + AssetId: assettypes.AssetUsdc.Id, + BigQuantumsDelta: big.NewInt(-50_000_000), // -$50 + }, + }, + }, + perpetuals: []perptypes.Perpetual{ + constants.IsoUsd_IsolatedMarket, + }, + expectedStateTransition: &types.IsolatedPerpetualPositionStateTransition{ + SubaccountId: &constants.Alice_Num0, + PerpetualId: uint32(3), + QuoteQuantums: big.NewInt(10_000_000), // $-40 - (-$50) + Transition: types.Opened, + }, + }, + `If single isolated perpetual update, existing perpetual position with different size, nil state transition + returned`: { + settledUpdateWithUpdatedSubaccount: keeper.SettledUpdate{ + SettledSubaccount: types.Subaccount{ + Id: &constants.Alice_Num0, + PerpetualPositions: []*types.PerpetualPosition{ + &constants.PerpetualPosition_OneISOLong, + }, + AssetPositions: []*types.AssetPosition{ + { + AssetId: assettypes.AssetUsdc.Id, + Quantums: dtypes.NewInt(-40_000_000), // -$65 + }, + }, + }, + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(3), + BigQuantumsDelta: big.NewInt(500_000_000), // 0.5 ISO + }, + }, + AssetUpdates: []types.AssetUpdate{ + { + AssetId: assettypes.AssetUsdc.Id, + BigQuantumsDelta: big.NewInt(-25_000_000), // -$25 + }, + }, + }, + perpetuals: []perptypes.Perpetual{ + constants.IsoUsd_IsolatedMarket, + }, + expectedStateTransition: nil, + }, + `Returns error if perpetual position was opened with no asset updates`: { + settledUpdateWithUpdatedSubaccount: keeper.SettledUpdate{ + SettledSubaccount: types.Subaccount{ + Id: &constants.Alice_Num0, + PerpetualPositions: []*types.PerpetualPosition{ + &constants.PerpetualPosition_OneISOLong, + }, + AssetPositions: []*types.AssetPosition{ + { + AssetId: assettypes.AssetUsdc.Id, + Quantums: dtypes.NewInt(50_000_000), // $50 + }, + }, + }, + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(3), + BigQuantumsDelta: big.NewInt(1_000_000_000), // 1 ISO + }, + }, + AssetUpdates: nil, + }, + perpetuals: []perptypes.Perpetual{ + constants.IsoUsd_IsolatedMarket, + }, + expectedStateTransition: nil, + expectedErr: types.ErrFailedToUpdateSubaccounts, + }, + `Returns error if perpetual position was opened with multiple asset updates`: { + settledUpdateWithUpdatedSubaccount: keeper.SettledUpdate{ + SettledSubaccount: types.Subaccount{ + Id: &constants.Alice_Num0, + PerpetualPositions: []*types.PerpetualPosition{ + &constants.PerpetualPosition_OneISOLong, + }, + AssetPositions: []*types.AssetPosition{ + { + AssetId: assettypes.AssetUsdc.Id, + Quantums: dtypes.NewInt(-40_000_000), // -$40 + }, + { + AssetId: constants.BtcUsd.Id, + Quantums: dtypes.NewInt(100_000_000), // 1 BTC + }, + }, + }, + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(3), + BigQuantumsDelta: big.NewInt(1_000_000_000), // 1 ISO + }, + }, + AssetUpdates: []types.AssetUpdate{ + { + AssetId: assettypes.AssetUsdc.Id, + BigQuantumsDelta: big.NewInt(-50_000_000), // -$50 + }, + { + AssetId: constants.BtcUsd.Id, + BigQuantumsDelta: big.NewInt(100_000_000), // 1 BTC + }, + }, + }, + perpetuals: []perptypes.Perpetual{ + constants.IsoUsd_IsolatedMarket, + }, + expectedStateTransition: nil, + expectedErr: types.ErrFailedToUpdateSubaccounts, + }, + `Returns error if perpetual position was opened with non-usdc asset update`: { + settledUpdateWithUpdatedSubaccount: keeper.SettledUpdate{ + SettledSubaccount: types.Subaccount{ + Id: &constants.Alice_Num0, + PerpetualPositions: []*types.PerpetualPosition{ + &constants.PerpetualPosition_OneISOLong, + }, + AssetPositions: []*types.AssetPosition{ + { + AssetId: assettypes.AssetUsdc.Id, + Quantums: dtypes.NewInt(50_000_000), // $50 + }, + { + AssetId: constants.BtcUsd.Id, + Quantums: dtypes.NewInt(100_000_000), // 1 BTC + }, + }, + }, + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(3), + BigQuantumsDelta: big.NewInt(1_000_000_000), // 1 ISO + }, + }, + AssetUpdates: []types.AssetUpdate{ + { + AssetId: constants.BtcUsd.Id, + BigQuantumsDelta: big.NewInt(100_000_000), // 1 BTC + }, + }, + }, + perpetuals: []perptypes.Perpetual{ + constants.IsoUsd_IsolatedMarket, + }, + expectedStateTransition: nil, + expectedErr: types.ErrFailedToUpdateSubaccounts, + }, + } + + for name, tc := range tests { + t.Run( + name, func(t *testing.T) { + stateTransition, err := keeper.GetIsolatedPerpetualStateTransition( + tc.settledUpdateWithUpdatedSubaccount, + tc.perpetuals, + ) + if tc.expectedErr != nil { + require.Error(t, tc.expectedErr, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedStateTransition, stateTransition) + } + }, + ) + } +} diff --git a/protocol/x/subaccounts/keeper/subaccount.go b/protocol/x/subaccounts/keeper/subaccount.go index ca9e978e80..55a1b5fb14 100644 --- a/protocol/x/subaccounts/keeper/subaccount.go +++ b/protocol/x/subaccounts/keeper/subaccount.go @@ -206,12 +206,12 @@ func (k Keeper) getSettledUpdates( updates []types.Update, requireUniqueSubaccount bool, ) ( - settledUpdates []settledUpdate, + settledUpdates []SettledUpdate, subaccountIdToFundingPayments map[types.SubaccountId]map[uint32]dtypes.SerializableInt, err error, ) { var idToSettledSubaccount = make(map[types.SubaccountId]types.Subaccount) - settledUpdates = make([]settledUpdate, len(updates)) + settledUpdates = make([]SettledUpdate, len(updates)) subaccountIdToFundingPayments = make(map[types.SubaccountId]map[uint32]dtypes.SerializableInt) // Iterate over all updates and query the relevant `Subaccounts`. @@ -236,7 +236,7 @@ func (k Keeper) getSettledUpdates( subaccountIdToFundingPayments[u.SubaccountId] = fundingPayments } - settledUpdate := settledUpdate{ + settledUpdate := SettledUpdate{ SettledSubaccount: settledSubaccount, AssetUpdates: u.AssetUpdates, PerpetualUpdates: u.PerpetualUpdates, @@ -255,6 +255,9 @@ func (k Keeper) getSettledUpdates( // Returns a boolean indicating whether the update was successfully applied or not. If `false`, then no // updates to any subaccount were made. A second return value returns an array of `UpdateResult` which map // to the `updates` to indicate which of the updates caused a failure, if any. +// This function also transfers collateral between the cross-perpetual collateral pool and isolated +// perpetual collateral pools if any of the updates led to an isolated perpetual posititon to be opened +// or closed. This is done using the `x/bank` keeper and updates `x/bank` state. // // Each `SubaccountId` in the `updates` must be unique or an error is returned. func (k Keeper) UpdateSubaccounts( @@ -287,6 +290,7 @@ func (k Keeper) UpdateSubaccounts( updateType, allPerps, ) + if !success || err != nil { return success, successPerUpdate, err } @@ -306,6 +310,20 @@ func (k Keeper) UpdateSubaccounts( // Apply the updates to asset positions. UpdateAssetPositions(settledUpdates) + // Transfer collateral between collateral pools for any isolated perpetual positions that changed + // state due to an update. + for _, settledUpdateWithUpdatedSubaccount := range settledUpdates { + if err := k.computeAndExecuteCollateralTransfer( + ctx, + // The subaccount in `settledUpdateWithUpdatedSubaccount` already has the perpetual updates + // and asset updates applied to it. + settledUpdateWithUpdatedSubaccount, + allPerps, + ); err != nil { + return false, nil, err + } + } + // Apply all updates, including a subaccount update event in the Indexer block message // per update and emit a cometbft event for each settled funding payment. for _, u := range settledUpdates { @@ -384,7 +402,8 @@ func (k Keeper) CanUpdateSubaccounts( } allPerps := k.perpetualsKeeper.GetAllPerpetuals(ctx) - return k.internalCanUpdateSubaccounts(ctx, settledUpdates, updateType, allPerps) + success, successPerUpdate, err = k.internalCanUpdateSubaccounts(ctx, settledUpdates, updateType, allPerps) + return success, successPerUpdate, err } // getSettledSubaccount returns 1. a new settled subaccount given an unsettled subaccount, @@ -515,7 +534,8 @@ func checkPositionUpdatable( return nil } -// internalCanUpdateSubaccounts will validate all `updates` to the relevant subaccounts. +// internalCanUpdateSubaccounts will validate all `updates` to the relevant subaccounts and compute +// if any of the updates led to an isolated perpetual position being opened or closed. // The `updates` do not have to contain `Subaccounts` with unique `SubaccountIds`. // Each update is considered in isolation. Thus if two updates are provided // with the same `Subaccount`, they are validated without respect to each @@ -528,7 +548,7 @@ func checkPositionUpdatable( // caused a failure, if any. func (k Keeper) internalCanUpdateSubaccounts( ctx sdk.Context, - settledUpdates []settledUpdate, + settledUpdates []SettledUpdate, updateType types.UpdateType, perpetuals []perptypes.Perpetual, ) ( @@ -646,7 +666,7 @@ func (k Keeper) internalCanUpdateSubaccounts( // We must now check if the state transition is valid. if bigNewInitialMargin.Cmp(bigNewNetCollateral) > 0 { // Get the current collateralization and margin requirements without the update applied. - emptyUpdate := settledUpdate{ + emptyUpdate := SettledUpdate{ SettledSubaccount: u.SettledSubaccount, } @@ -785,7 +805,7 @@ func (k Keeper) GetNetCollateralAndMarginRequirements( return nil, nil, nil, err } - settledUpdate := settledUpdate{ + settledUpdate := SettledUpdate{ SettledSubaccount: settledSubaccount, AssetUpdates: update.AssetUpdates, PerpetualUpdates: update.PerpetualUpdates, @@ -810,7 +830,7 @@ func (k Keeper) GetNetCollateralAndMarginRequirements( // If two position updates reference the same position, an error is returned. func (k Keeper) internalGetNetCollateralAndMarginRequirements( ctx sdk.Context, - settledUpdate settledUpdate, + settledUpdate SettledUpdate, ) ( bigNetCollateral *big.Int, bigInitialMargin *big.Int, diff --git a/protocol/x/subaccounts/keeper/subaccount_helper.go b/protocol/x/subaccounts/keeper/subaccount_helper.go index 71721cb9a6..79b14fbc2e 100644 --- a/protocol/x/subaccounts/keeper/subaccount_helper.go +++ b/protocol/x/subaccounts/keeper/subaccount_helper.go @@ -12,7 +12,7 @@ import ( // been updated. This will include any asset postions that were closed due to an update. // TODO(DEC-1295): look into reducing code duplication here using Generics+Reflect. func getUpdatedAssetPositions( - update settledUpdate, + update SettledUpdate, ) []*types.AssetPosition { assetIdToPositionMap := make(map[uint32]*types.AssetPosition) for _, assetPosition := range update.SettledSubaccount.AssetPositions { @@ -53,7 +53,7 @@ func getUpdatedAssetPositions( // been updated. This will include any perpetual postions that were closed due to an update or that // received / paid out funding payments.. func getUpdatedPerpetualPositions( - update settledUpdate, + update SettledUpdate, fundingPayments map[uint32]dtypes.SerializableInt, ) []*types.PerpetualPosition { perpetualIdToPositionMap := make(map[uint32]*types.PerpetualPosition) @@ -102,7 +102,7 @@ func getUpdatedPerpetualPositions( // to reflect settledUpdate.PerpetualUpdates. // For newly created positions, use `perpIdToFundingIndex` map to populate the `FundingIndex` field. func UpdatePerpetualPositions( - settledUpdates []settledUpdate, + settledUpdates []SettledUpdate, perpIdToFundingIndex map[uint32]dtypes.SerializableInt, ) { // Apply the updates. @@ -166,7 +166,7 @@ func UpdatePerpetualPositions( // For each settledUpdate in settledUpdates, updates its SettledSubaccount.AssetPositions // to reflect settledUpdate.AssetUpdates. func UpdateAssetPositions( - settledUpdates []settledUpdate, + settledUpdates []SettledUpdate, ) { // Apply the updates. for i, u := range settledUpdates { diff --git a/protocol/x/subaccounts/keeper/subaccount_test.go b/protocol/x/subaccounts/keeper/subaccount_test.go index 49fcaab685..cbea84e10a 100644 --- a/protocol/x/subaccounts/keeper/subaccount_test.go +++ b/protocol/x/subaccounts/keeper/subaccount_test.go @@ -6,12 +6,15 @@ import ( "strconv" "testing" + sdkmath "cosmossdk.io/math" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" "github.com/dydxprotocol/v4-chain/protocol/lib" sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" "github.com/dydxprotocol/v4-chain/protocol/dtypes" indexerevents "github.com/dydxprotocol/v4-chain/protocol/indexer/events" + bank_testutil "github.com/dydxprotocol/v4-chain/protocol/testutil/bank" big_testutil "github.com/dydxprotocol/v4-chain/protocol/testutil/big" "github.com/dydxprotocol/v4-chain/protocol/testutil/constants" testutil "github.com/dydxprotocol/v4-chain/protocol/testutil/keeper" @@ -299,16 +302,20 @@ func TestUpdateSubaccounts(t *testing.T) { perpetualPositions []*types.PerpetualPosition assetPositions []*types.AssetPosition + // collateral pool state + collateralPoolUsdcBalances map[string]int64 + // updates updates []types.Update // expectations - expectedQuoteBalance *big.Int - expectedPerpetualPositions []*types.PerpetualPosition - expectedAssetPositions []*types.AssetPosition - expectedSuccess bool - expectedSuccessPerUpdate []types.UpdateResult - expectedErr error + expectedCollateralPoolUsdcBalances map[string]int64 + expectedQuoteBalance *big.Int + expectedPerpetualPositions []*types.PerpetualPosition + expectedAssetPositions []*types.AssetPosition + expectedSuccess bool + expectedSuccessPerUpdate []types.UpdateResult + expectedErr error // Only contains the updated perpetual positions, to assert against the events included. expectedUpdatedPerpetualPositions map[types.SubaccountId][]*types.PerpetualPosition @@ -2252,11 +2259,212 @@ func TestUpdateSubaccounts(t *testing.T) { }, msgSenderEnabled: true, }, + `Isolated - subaccounts - empty subaccount has update to open position for isolated perpetual, + collateral is moved from cross-perpetual collateral pool to isolated perpetual collateral pool`: { + assetPositions: testutil.CreateUsdcAssetPosition(big.NewInt(1_000_000_000_000)), + collateralPoolUsdcBalances: map[string]int64{ + types.ModuleAddress.String(): 1_500_000_000_000, // $1,500,000 USDC + }, + expectedCollateralPoolUsdcBalances: map[string]int64{ + types.ModuleAddress.String(): 500_000_000_000, // $500,000 USDC + authtypes.NewModuleAddress( + types.ModuleName + ":" + lib.UintToString(constants.PerpetualPosition_OneISOLong.PerpetualId), + ).String(): 1_000_000_000_000, // $1,000,000 USDC + }, + expectedSuccess: true, + expectedSuccessPerUpdate: []types.UpdateResult{types.Success}, + perpetuals: []perptypes.Perpetual{ + constants.BtcUsd_NoMarginRequirement, + constants.IsoUsd_IsolatedMarket, + }, + perpetualPositions: []*types.PerpetualPosition{}, + expectedPerpetualPositions: []*types.PerpetualPosition{ + { + PerpetualId: uint32(3), + Quantums: dtypes.NewInt(1_000_000_000), // 1 ISO + FundingIndex: dtypes.NewInt(0), + }, + }, + expectedUpdatedPerpetualPositions: map[types.SubaccountId][]*types.PerpetualPosition{ + defaultSubaccountId: { + { + PerpetualId: uint32(3), + Quantums: dtypes.NewInt(1_000_000_000), // 1 ISO + FundingIndex: dtypes.NewInt(0), + }, + }, + }, + expectedAssetPositions: []*types.AssetPosition{ + { + AssetId: uint32(0), + Quantums: dtypes.NewInt(999_900_000_000), + }, + }, + expectedUpdatedAssetPositions: map[types.SubaccountId][]*types.AssetPosition{ + defaultSubaccountId: { + { + AssetId: uint32(0), + Quantums: dtypes.NewInt(999_900_000_000), + }, + }, + }, + updates: []types.Update{ + { + AssetUpdates: testutil.CreateUsdcAssetUpdate(big.NewInt(-100_000_000)), // -$100 + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(3), + BigQuantumsDelta: big.NewInt(1_000_000_000), // 1 ISO + }, + }, + }, + }, + msgSenderEnabled: true, + }, + `Isolated - subaccounts - subaccount has update to close position for isolated perpetual, + collateral is moved from isolated perpetual collateral pool to cross perpetual collateral pool`: { + assetPositions: testutil.CreateUsdcAssetPosition(big.NewInt(999_900_000_000)), // $999,900 USDC + collateralPoolUsdcBalances: map[string]int64{ + types.ModuleAddress.String(): 2_000_000_000_000, // $500,000 USDC + authtypes.NewModuleAddress( + types.ModuleName + ":" + lib.UintToString(constants.PerpetualPosition_OneISOLong.PerpetualId), + ).String(): 1_500_000_000_000, // $1,500,000 USDC + }, + expectedCollateralPoolUsdcBalances: map[string]int64{ + types.ModuleAddress.String(): 3_000_000_000_000, // $3,000,000 USDC + authtypes.NewModuleAddress( + types.ModuleName + ":" + lib.UintToString(constants.PerpetualPosition_OneISOLong.PerpetualId), + ).String(): 500_000_000_000, // $500,000 USDC + }, + expectedSuccess: true, + expectedSuccessPerUpdate: []types.UpdateResult{types.Success}, + perpetuals: []perptypes.Perpetual{ + constants.BtcUsd_NoMarginRequirement, + constants.IsoUsd_IsolatedMarket, + }, + perpetualPositions: []*types.PerpetualPosition{ + { + PerpetualId: uint32(3), + Quantums: dtypes.NewInt(1_000_000_000), // 1 ISO + FundingIndex: dtypes.NewInt(0), + }, + }, + expectedPerpetualPositions: []*types.PerpetualPosition{}, + expectedUpdatedPerpetualPositions: map[types.SubaccountId][]*types.PerpetualPosition{ + defaultSubaccountId: { + { + PerpetualId: uint32(3), + Quantums: dtypes.NewInt(0), + FundingIndex: dtypes.NewInt(0), + }, + }, + }, + expectedAssetPositions: []*types.AssetPosition{ + { + AssetId: uint32(0), + Quantums: dtypes.NewInt(1_000_000_000_000), + }, + }, + expectedUpdatedAssetPositions: map[types.SubaccountId][]*types.AssetPosition{ + defaultSubaccountId: { + { + AssetId: uint32(0), + Quantums: dtypes.NewInt(1_000_000_000_000), + }, + }, + }, + updates: []types.Update{ + { + AssetUpdates: testutil.CreateUsdcAssetUpdate(big.NewInt(100_000_000)), // $100 + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(3), + BigQuantumsDelta: big.NewInt(-1_000_000_000), // -1 ISO + }, + }, + }, + }, + msgSenderEnabled: true, + }, + `Isolated subaccounts - empty subaccount has update to open position for isolated perpetual, + errors out when collateral pool for cross perpetuals has no funds`: { + assetPositions: testutil.CreateUsdcAssetPosition(big.NewInt(1_000_000_000_000)), + expectedSuccess: false, + expectedSuccessPerUpdate: []types.UpdateResult{}, + perpetuals: []perptypes.Perpetual{ + constants.BtcUsd_NoMarginRequirement, + constants.IsoUsd_IsolatedMarket, + }, + perpetualPositions: []*types.PerpetualPosition{}, + expectedPerpetualPositions: []*types.PerpetualPosition{}, + expectedAssetPositions: []*types.AssetPosition{ + { + AssetId: uint32(0), + Quantums: dtypes.NewInt(1_000_000_000_000), + }, + }, + updates: []types.Update{ + { + AssetUpdates: testutil.CreateUsdcAssetUpdate(big.NewInt(-100_000_000)), // -$100 + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(3), + BigQuantumsDelta: big.NewInt(1_000_000_000), // 1 ISO + }, + }, + }, + }, + expectedErr: sdkerrors.ErrInsufficientFunds, + msgSenderEnabled: true, + }, + `Isolated subaccounts - isolated subaccount has update to close position for isolated perpetual, + errors out when collateral pool for isolated perpetual has no funds`: { + assetPositions: testutil.CreateUsdcAssetPosition(big.NewInt(1_000_000_000_000)), + expectedSuccess: false, + expectedSuccessPerUpdate: []types.UpdateResult{}, + perpetuals: []perptypes.Perpetual{ + constants.BtcUsd_NoMarginRequirement, + constants.IsoUsd_IsolatedMarket, + }, + perpetualPositions: []*types.PerpetualPosition{ + { + PerpetualId: uint32(3), + Quantums: dtypes.NewInt(1_000_000_000), // 1 ISO + FundingIndex: dtypes.NewInt(0), + }, + }, + expectedPerpetualPositions: []*types.PerpetualPosition{ + { + PerpetualId: uint32(3), + Quantums: dtypes.NewInt(1_000_000_000), // 1 ISO + FundingIndex: dtypes.NewInt(0), + }, + }, + expectedAssetPositions: []*types.AssetPosition{ + { + AssetId: uint32(0), + Quantums: dtypes.NewInt(1_000_000_000_000), + }, + }, + updates: []types.Update{ + { + AssetUpdates: testutil.CreateUsdcAssetUpdate(big.NewInt(100_000_000)), // $100 + PerpetualUpdates: []types.PerpetualUpdate{ + { + PerpetualId: uint32(3), + BigQuantumsDelta: big.NewInt(-1_000_000_000), // -1 ISO + }, + }, + }, + }, + expectedErr: sdkerrors.ErrInsufficientFunds, + msgSenderEnabled: true, + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { - ctx, keeper, pricesKeeper, perpetualsKeeper, _, _, assetsKeeper, _, _ := testutil.SubaccountsKeepers( + ctx, keeper, pricesKeeper, perpetualsKeeper, _, bankKeeper, assetsKeeper, _, _ := testutil.SubaccountsKeepers( t, tc.msgSenderEnabled, ) @@ -2313,6 +2521,18 @@ func TestUpdateSubaccounts(t *testing.T) { } } + for collateralPoolAddr, usdcBal := range tc.collateralPoolUsdcBalances { + err := bank_testutil.FundAccount( + ctx, + sdk.MustAccAddressFromBech32(collateralPoolAddr), + sdk.Coins{ + sdk.NewCoin(asstypes.AssetUsdc.Denom, sdkmath.NewInt(usdcBal)), + }, + *bankKeeper, + ) + require.NoError(t, err) + } + subaccount := createNSubaccount(keeper, ctx, 1, big.NewInt(1_000))[0] subaccount.PerpetualPositions = tc.perpetualPositions subaccount.AssetPositions = tc.assetPositions @@ -2365,6 +2585,18 @@ func TestUpdateSubaccounts(t *testing.T) { for i, ep := range tc.expectedAssetPositions { require.Equal(t, *ep, *newSubaccount.AssetPositions[i]) } + + for collateralPoolAddr, expectedUsdcBal := range tc.expectedCollateralPoolUsdcBalances { + usdcBal := bankKeeper.GetBalance( + ctx, + sdk.MustAccAddressFromBech32(collateralPoolAddr), + asstypes.AssetUsdc.Denom, + ) + require.Equal(t, + sdk.NewCoin(asstypes.AssetUsdc.Denom, sdkmath.NewInt(expectedUsdcBal)), + usdcBal, + ) + } }) } } diff --git a/protocol/x/subaccounts/keeper/update.go b/protocol/x/subaccounts/keeper/update.go index 179139452a..30e0ad58f7 100644 --- a/protocol/x/subaccounts/keeper/update.go +++ b/protocol/x/subaccounts/keeper/update.go @@ -4,11 +4,11 @@ import ( "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" ) -// settledUpdate is used internally in the subaccounts keeper to +// SettledUpdate is used internally in the subaccounts keeper to // to specify changes to one or more `Subaccounts` (for example the // result of a trade, transfer, etc). // The subaccount is always in its settled state. -type settledUpdate struct { +type SettledUpdate struct { // The `Subaccount` for which this update applies to, in its settled form. SettledSubaccount types.Subaccount // A list of changes to make to any `AssetPositions` in the `Subaccount`. diff --git a/protocol/x/subaccounts/types/isolated_perpetual_position_state_transition.go b/protocol/x/subaccounts/types/isolated_perpetual_position_state_transition.go new file mode 100644 index 0000000000..ce57aef490 --- /dev/null +++ b/protocol/x/subaccounts/types/isolated_perpetual_position_state_transition.go @@ -0,0 +1,35 @@ +package types + +import "math/big" + +type PositionStateTransition uint + +const ( + Opened PositionStateTransition = iota + Closed +) + +var positionStateTransitionStringMap = map[PositionStateTransition]string{ + Opened: "opened", + Closed: "closed", +} + +func (t PositionStateTransition) String() string { + result, exists := positionStateTransitionStringMap[t] + if !exists { + return "UnexpectedStateTransitionError" + } + + return result +} + +// Represents a state transition for an isolated perpetual position in a subaccount. +type IsolatedPerpetualPositionStateTransition struct { + SubaccountId *SubaccountId + PerpetualId uint32 + // TODO(DEC-715): Support non-USDC assets. + // Quote quantums of collateral to transfer as a result of the state transition. + QuoteQuantums *big.Int + // The state transition that occurred for the isolated perpetual positions. + Transition PositionStateTransition +} diff --git a/protocol/x/subaccounts/types/isolated_perpetual_position_state_transition_test.go b/protocol/x/subaccounts/types/isolated_perpetual_position_state_transition_test.go new file mode 100644 index 0000000000..b58c616858 --- /dev/null +++ b/protocol/x/subaccounts/types/isolated_perpetual_position_state_transition_test.go @@ -0,0 +1,36 @@ +package types_test + +import ( + "testing" + + "github.com/dydxprotocol/v4-chain/protocol/x/subaccounts/types" + "github.com/stretchr/testify/require" +) + +func TestPositionStateTransitionString(t *testing.T) { + tests := map[string]struct { + value types.PositionStateTransition + expectedResult string + }{ + "Opened": { + value: types.Opened, + expectedResult: "opened", + }, + "Closed": { + value: types.Closed, + expectedResult: "closed", + }, + "UnexpectedError": { + value: types.PositionStateTransition(2), + expectedResult: "UnexpectedStateTransitionError", + }, + } + + // Run tests. + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result := tc.value.String() + require.Equal(t, result, tc.expectedResult) + }) + } +}