From 636c385f700d1876cc7707a4d2255d9ba2a5eab6 Mon Sep 17 00:00:00 2001 From: Jeremy Letang Date: Fri, 18 Oct 2024 16:39:06 +0100 Subject: [PATCH] chore: add tests for banking Signed-off-by: Jeremy Letang --- core/banking/engine.go | 1 + core/banking/mocks/mocks.go | 15 ++ core/banking/oneoff_transfers_test.go | 194 ++++++++++++++++++++++++++ core/banking/transfers_common.go | 7 +- core/collateral/engine.go | 6 + core/types/banking.go | 43 ++++-- 6 files changed, 251 insertions(+), 15 deletions(-) diff --git a/core/banking/engine.go b/core/banking/engine.go index 318c1bdae50..8fa89f903c0 100644 --- a/core/banking/engine.go +++ b/core/banking/engine.go @@ -72,6 +72,7 @@ type Collateral interface { Withdraw(ctx context.Context, party, asset string, amount *num.Uint) (*types.LedgerMovement, error) EnableAsset(ctx context.Context, asset types.Asset) error GetPartyGeneralAccount(party, asset string) (*types.Account, error) + GetPartyLockedForStaking(party, asset string) (*types.Account, error) GetPartyVestedRewardAccount(partyID, asset string) (*types.Account, error) TransferFunds(ctx context.Context, transfers []*types.Transfer, diff --git a/core/banking/mocks/mocks.go b/core/banking/mocks/mocks.go index edbdd08013e..725282d7fed 100644 --- a/core/banking/mocks/mocks.go +++ b/core/banking/mocks/mocks.go @@ -213,6 +213,21 @@ func (mr *MockCollateralMockRecorder) GetPartyGeneralAccount(arg0, arg1 interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPartyGeneralAccount", reflect.TypeOf((*MockCollateral)(nil).GetPartyGeneralAccount), arg0, arg1) } +// GetPartyLockedForStaking mocks base method. +func (m *MockCollateral) GetPartyLockedForStaking(arg0, arg1 string) (*types.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPartyLockedForStaking", arg0, arg1) + ret0, _ := ret[0].(*types.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPartyLockedForStaking indicates an expected call of GetPartyLockedForStaking. +func (mr *MockCollateralMockRecorder) GetPartyLockedForStaking(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPartyLockedForStaking", reflect.TypeOf((*MockCollateral)(nil).GetPartyLockedForStaking), arg0, arg1) +} + // GetPartyVestedRewardAccount mocks base method. func (m *MockCollateral) GetPartyVestedRewardAccount(arg0, arg1 string) (*types.Account, error) { m.ctrl.T.Helper() diff --git a/core/banking/oneoff_transfers_test.go b/core/banking/oneoff_transfers_test.go index 9e8fd7d91bb..6af856b9381 100644 --- a/core/banking/oneoff_transfers_test.go +++ b/core/banking/oneoff_transfers_test.go @@ -35,6 +35,7 @@ func TestTransfers(t *testing.T) { t.Run("onefoff not enough funds to transfer", testOneOffTransferNotEnoughFundsToTransfer) t.Run("onefoff invalid transfers", testOneOffTransferInvalidTransfers) t.Run("valid oneoff transfer", testValidOneOffTransfer) + t.Run("valid staking transfers", testStakingTransfers) t.Run("valid oneoff with deliverOn", testValidOneOffTransferWithDeliverOn) t.Run("valid oneoff with deliverOn in the past is done straight away", testValidOneOffTransferWithDeliverOnInThePastStraightAway) t.Run("rejected if doesn't reach minimal amount", testRejectedIfDoesntReachMinimalAmount) @@ -273,6 +274,199 @@ func testValidOneOffTransfer(t *testing.T) { assert.NoError(t, e.TransferFunds(ctx, transfer)) } +func testStakingTransfers(t *testing.T) { + e := getTestEngine(t) + + // let's do a massive fee, easy to test + e.OnTransferFeeFactorUpdate(context.Background(), num.NewDecimalFromFloat(1)) + e.OnStakingAsset(context.Background(), "ETH") + + ctx := context.Background() + + t.Run("cannot transfer to another pubkey lock_for_staking", func(t *testing.T) { + transfer := &types.TransferFunds{ + Kind: types.TransferCommandKindOneOff, + OneOff: &types.OneOffTransfer{ + TransferBase: &types.TransferBase{ + From: "03ae90688632c649c4beab6040ff5bd04dbde8efbf737d8673bbda792a110301", + FromAccountType: types.AccountTypeGeneral, + To: "10ae90688632c649c4beab6040ff5bd04dbde8efbf737d8673bbda792a110301", + ToAccountType: types.AccountTypeLockedForStaking, + Asset: assetNameETH, + Amount: num.NewUint(10), + Reference: "someref", + }, + }, + } + + // asset exists + e.assets.EXPECT().Get(gomock.Any()).Times(1).Return( + assets.NewAsset(&mockAsset{name: assetNameETH, quantum: num.DecimalFromFloat(100)}), nil) + e.broker.EXPECT().Send(gomock.Any()).Times(1) + assert.EqualError(t, e.TransferFunds(ctx, transfer), "transfers to locked for staking allowed only from own general account") + }) + + t.Run("cannot transfer from lock_for_staking to another general account", func(t *testing.T) { + transfer := &types.TransferFunds{ + Kind: types.TransferCommandKindOneOff, + OneOff: &types.OneOffTransfer{ + TransferBase: &types.TransferBase{ + From: "03ae90688632c649c4beab6040ff5bd04dbde8efbf737d8673bbda792a110301", + FromAccountType: types.AccountTypeLockedForStaking, + To: "10ae90688632c649c4beab6040ff5bd04dbde8efbf737d8673bbda792a110301", + ToAccountType: types.AccountTypeGeneral, + Asset: assetNameETH, + Amount: num.NewUint(10), + Reference: "someref", + }, + }, + } + + // asset exists + e.assets.EXPECT().Get(gomock.Any()).Times(1).Return( + assets.NewAsset(&mockAsset{name: assetNameETH, quantum: num.DecimalFromFloat(100)}), nil) + e.broker.EXPECT().Send(gomock.Any()).Times(1) + assert.EqualError(t, e.TransferFunds(ctx, transfer), "transfers from locked for staking allowed only to own general account") + }) + + t.Run("can only transfer from lock_for_staking to own general account", func(t *testing.T) { + transfer := &types.TransferFunds{ + Kind: types.TransferCommandKindOneOff, + OneOff: &types.OneOffTransfer{ + TransferBase: &types.TransferBase{ + From: "03ae90688632c649c4beab6040ff5bd04dbde8efbf737d8673bbda792a110301", + FromAccountType: types.AccountTypeLockedForStaking, + To: "0000000000000000000000000000000000000000000000000000000000000000", + ToAccountType: types.AccountTypeGlobalReward, + Asset: assetNameETH, + Amount: num.NewUint(10), + Reference: "someref", + }, + }, + } + + // asset exists + e.assets.EXPECT().Get(gomock.Any()).Times(1).Return( + assets.NewAsset(&mockAsset{name: assetNameETH, quantum: num.DecimalFromFloat(100)}), nil) + e.broker.EXPECT().Send(gomock.Any()).Times(1) + assert.EqualError(t, e.TransferFunds(ctx, transfer), "can only transfer from locked for staking to general account") + }) + + t.Run("can transfer from general to locked_for_staking and emit stake deposited", func(t *testing.T) { + transfer := &types.TransferFunds{ + Kind: types.TransferCommandKindOneOff, + OneOff: &types.OneOffTransfer{ + TransferBase: &types.TransferBase{ + From: "03ae90688632c649c4beab6040ff5bd04dbde8efbf737d8673bbda792a110301", + FromAccountType: types.AccountTypeGeneral, + To: "03ae90688632c649c4beab6040ff5bd04dbde8efbf737d8673bbda792a110301", + ToAccountType: types.AccountTypeLockedForStaking, + Asset: assetNameETH, + Amount: num.NewUint(10), + Reference: "someref", + }, + }, + } + + fromAcc := types.Account{ + Balance: num.NewUint(100), + } + + // asset exists + e.assets.EXPECT().Get(gomock.Any()).Times(1).Return( + assets.NewAsset(&mockAsset{name: assetNameETH, quantum: num.DecimalFromFloat(100)}), nil) + e.col.EXPECT().GetPartyGeneralAccount(gomock.Any(), gomock.Any()).Times(1).Return(&fromAcc, nil) + + // assert the calculation of fees and transfer request are correct + e.col.EXPECT().TransferFunds(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(1) + + e.broker.EXPECT().Send(gomock.Any()).Times(4) + + // expect a call to the stake accounting + e.stakeAccounting.EXPECT().AddEvent(gomock.Any(), gomock.Any()).Times(1).Do( + func(_ context.Context, evt *types.StakeLinking) { + assert.Equal(t, evt.Type, types.StakeLinkingTypeDeposited) + }) + assert.NoError(t, e.TransferFunds(ctx, transfer)) + }) + + t.Run("can transfer from locked_for_staking to general and emit stake removed", func(t *testing.T) { + transfer := &types.TransferFunds{ + Kind: types.TransferCommandKindOneOff, + OneOff: &types.OneOffTransfer{ + TransferBase: &types.TransferBase{ + From: "03ae90688632c649c4beab6040ff5bd04dbde8efbf737d8673bbda792a110301", + FromAccountType: types.AccountTypeLockedForStaking, + To: "03ae90688632c649c4beab6040ff5bd04dbde8efbf737d8673bbda792a110301", + ToAccountType: types.AccountTypeGeneral, + Asset: assetNameETH, + Amount: num.NewUint(10), + Reference: "someref", + }, + }, + } + + fromAcc := types.Account{ + Balance: num.NewUint(100), + } + + // asset exists + e.assets.EXPECT().Get(gomock.Any()).Times(1).Return( + assets.NewAsset(&mockAsset{name: assetNameETH, quantum: num.DecimalFromFloat(100)}), nil) + e.col.EXPECT().GetPartyLockedForStaking(gomock.Any(), gomock.Any()).Times(1).Return(&fromAcc, nil) + + // assert the calculation of fees and transfer request are correct + e.col.EXPECT().TransferFunds(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(1) + + e.broker.EXPECT().Send(gomock.Any()).Times(4) + + // expect a call to the stake accounting + e.stakeAccounting.EXPECT().AddEvent(gomock.Any(), gomock.Any()).Times(1).Do( + func(_ context.Context, evt *types.StakeLinking) { + assert.Equal(t, evt.Type, types.StakeLinkingTypeRemoved) + }) + assert.NoError(t, e.TransferFunds(ctx, transfer)) + }) + + t.Run("can transfer from vested to general and emit stake removed", func(t *testing.T) { + transfer := &types.TransferFunds{ + Kind: types.TransferCommandKindOneOff, + OneOff: &types.OneOffTransfer{ + TransferBase: &types.TransferBase{ + From: "03ae90688632c649c4beab6040ff5bd04dbde8efbf737d8673bbda792a110301", + FromAccountType: types.AccountTypeVestedRewards, + To: "03ae90688632c649c4beab6040ff5bd04dbde8efbf737d8673bbda792a110301", + ToAccountType: types.AccountTypeGeneral, + Asset: assetNameETH, + Amount: num.NewUint(10), + Reference: "someref", + }, + }, + } + + fromAcc := types.Account{ + Balance: num.NewUint(100), + } + + // asset exists + e.assets.EXPECT().Get(gomock.Any()).Times(1).Return( + assets.NewAsset(&mockAsset{name: assetNameETH, quantum: num.DecimalFromFloat(100)}), nil) + e.col.EXPECT().GetPartyVestedRewardAccount(gomock.Any(), gomock.Any()).Times(1).Return(&fromAcc, nil) + + // assert the calculation of fees and transfer request are correct + e.col.EXPECT().TransferFunds(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(1) + + e.broker.EXPECT().Send(gomock.Any()).Times(4) + + // expect a call to the stake accounting + e.stakeAccounting.EXPECT().AddEvent(gomock.Any(), gomock.Any()).Times(1).Do( + func(_ context.Context, evt *types.StakeLinking) { + assert.Equal(t, evt.Type, types.StakeLinkingTypeRemoved) + }) + assert.NoError(t, e.TransferFunds(ctx, transfer)) + }) +} + func testValidOneOffTransferWithDeliverOnInThePastStraightAway(t *testing.T) { e := getTestEngine(t) diff --git a/core/banking/transfers_common.go b/core/banking/transfers_common.go index b75e16ac2e7..cd7a7dfb5d7 100644 --- a/core/banking/transfers_common.go +++ b/core/banking/transfers_common.go @@ -279,7 +279,7 @@ func (e *Engine) makeFeeTransferForFundsTransfer( } switch fromAccountType { - case types.AccountTypeGeneral, types.AccountTypeVestedRewards: + case types.AccountTypeGeneral, types.AccountTypeVestedRewards, types.AccountTypeLockedForStaking: default: e.log.Panic("from account not supported", logging.String("account-type", fromAccountType.String()), @@ -332,6 +332,11 @@ func (e *Engine) ensureEnoughFundsForTransfer( if err != nil { return err } + case types.AccountTypeLockedForStaking: + account, err = e.col.GetPartyLockedForStaking(from, asset.ID) + if err != nil { + return err + } case types.AccountTypeVestedRewards: // sending from sub account to owners general account if fromDerivedKey != nil { diff --git a/core/collateral/engine.go b/core/collateral/engine.go index 655fa042228..31a943b6a72 100644 --- a/core/collateral/engine.go +++ b/core/collateral/engine.go @@ -4091,6 +4091,12 @@ func (e *Engine) GetPartyGeneralAccount(partyID, asset string) (*types.Account, return e.GetAccountByID(generalID) } +// GetPartyLockedForStaking returns a general account given the partyID. +func (e *Engine) GetPartyLockedForStaking(partyID, asset string) (*types.Account, error) { + generalID := e.accountID(noMarket, partyID, asset, types.AccountTypeLockedForStaking) + return e.GetAccountByID(generalID) +} + // GetPartyBondAccount returns a general account given the partyID. func (e *Engine) GetPartyBondAccount(market, partyID, asset string) (*types.Account, error) { id := e.accountID( diff --git a/core/types/banking.go b/core/types/banking.go index 763a1ec29fe..596a0dd7705 100644 --- a/core/types/banking.go +++ b/core/types/banking.go @@ -45,18 +45,21 @@ const ( ) var ( - ErrMissingTransferKind = errors.New("missing transfer kind") - ErrCannotTransferZeroFunds = errors.New("cannot transfer zero funds") - ErrInvalidFromAccount = errors.New("invalid from account") - ErrInvalidFromDerivedKey = errors.New("invalid from derived key") - ErrInvalidToAccount = errors.New("invalid to account") - ErrUnsupportedFromAccountType = errors.New("unsupported from account type") - ErrUnsupportedToAccountType = errors.New("unsupported to account type") - ErrEndEpochIsZero = errors.New("end epoch is zero") - ErrStartEpochIsZero = errors.New("start epoch is zero") - ErrInvalidFactor = errors.New("invalid factor") - ErrStartEpochAfterEndEpoch = errors.New("start epoch after end epoch") - ErrInvalidToForRewardAccountType = errors.New("to party is invalid for reward account type") + ErrMissingTransferKind = errors.New("missing transfer kind") + ErrCannotTransferZeroFunds = errors.New("cannot transfer zero funds") + ErrInvalidFromAccount = errors.New("invalid from account") + ErrInvalidFromDerivedKey = errors.New("invalid from derived key") + ErrInvalidToAccount = errors.New("invalid to account") + ErrUnsupportedFromAccountType = errors.New("unsupported from account type") + ErrUnsupportedToAccountType = errors.New("unsupported to account type") + ErrEndEpochIsZero = errors.New("end epoch is zero") + ErrStartEpochIsZero = errors.New("start epoch is zero") + ErrInvalidFactor = errors.New("invalid factor") + ErrStartEpochAfterEndEpoch = errors.New("start epoch after end epoch") + ErrInvalidToForRewardAccountType = errors.New("to party is invalid for reward account type") + ErrTransferFromLockedForStakingAllowedOnlyToOwnGeneralAccount = errors.New("transfers from locked for staking allowed only to own general account") + ErrTransferToLockedForStakingAllowedOnlyFromOwnGeneralAccount = errors.New("transfers to locked for staking allowed only from own general account") + ErrCanOnlyTransferFromLockedForStakingToGeneralAccount = errors.New("can only transfer from locked for staking to general account") ) type TransferCommandKind int @@ -93,6 +96,18 @@ func (t *TransferBase) IsValid() error { return ErrCannotTransferZeroFunds } + if t.FromAccountType == AccountTypeLockedForStaking && t.ToAccountType != AccountTypeGeneral { + return ErrCanOnlyTransferFromLockedForStakingToGeneralAccount + } + + if t.FromAccountType == AccountTypeGeneral && t.ToAccountType == AccountTypeLockedForStaking && t.From != t.To { + return ErrTransferToLockedForStakingAllowedOnlyFromOwnGeneralAccount + } + + if t.ToAccountType == AccountTypeGeneral && t.FromAccountType == AccountTypeLockedForStaking && t.From != t.To { + return ErrTransferFromLockedForStakingAllowedOnlyToOwnGeneralAccount + } + // check for derived account transfer if t.FromDerivedKey != nil { if !vgcrypto.IsValidVegaPubKey(*t.FromDerivedKey) { @@ -110,7 +125,7 @@ func (t *TransferBase) IsValid() error { // check for any other transfers switch t.FromAccountType { - case AccountTypeGeneral, AccountTypeVestedRewards /*, AccountTypeLockedForStaking*/ : + case AccountTypeGeneral, AccountTypeVestedRewards, AccountTypeLockedForStaking: break default: return ErrUnsupportedFromAccountType @@ -122,7 +137,7 @@ func (t *TransferBase) IsValid() error { return ErrInvalidToForRewardAccountType } case AccountTypeGeneral, AccountTypeLPFeeReward, AccountTypeMakerReceivedFeeReward, AccountTypeMakerPaidFeeReward, AccountTypeMarketProposerReward, - AccountTypeAverageNotionalReward, AccountTypeRelativeReturnReward, AccountTypeValidatorRankingReward, AccountTypeReturnVolatilityReward, AccountTypeRealisedReturnReward, AccountTypeEligibleEntitiesReward, AccountTypeBuyBackFees: /*, AccountTypeLockedForStaking*/ + AccountTypeAverageNotionalReward, AccountTypeRelativeReturnReward, AccountTypeValidatorRankingReward, AccountTypeReturnVolatilityReward, AccountTypeRealisedReturnReward, AccountTypeEligibleEntitiesReward, AccountTypeBuyBackFees, AccountTypeLockedForStaking: break default: return ErrUnsupportedToAccountType