diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e2602e5..c1b4078eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Features +- program: add ix to pause deposits/withdraws if vault invariant broken ([#1387](https://github.com/drift-labs/protocol-v2/pull/1387)) + ### Fixes - program: skip liq perp oracle twap check if market is in settlement ([#1406](https://github.com/drift-labs/protocol-v2/pull/1406)) diff --git a/programs/drift/src/instructions/keeper.rs b/programs/drift/src/instructions/keeper.rs index 16fd76375..2d0dd4a76 100644 --- a/programs/drift/src/instructions/keeper.rs +++ b/programs/drift/src/instructions/keeper.rs @@ -41,7 +41,7 @@ use crate::state::high_leverage_mode_config::HighLeverageModeConfig; use crate::state::insurance_fund_stake::InsuranceFundStake; use crate::state::oracle_map::OracleMap; use crate::state::order_params::{OrderParams, PlaceOrderOptions, SwiftOrderParamsMessage}; -use crate::state::paused_operations::PerpOperation; +use crate::state::paused_operations::{PerpOperation, SpotOperation}; use crate::state::perp_market::{ContractType, MarketStatus, PerpMarket}; use crate::state::perp_market_map::{ get_market_set_for_spot_positions, get_market_set_for_user_positions, get_market_set_from_list, @@ -2414,6 +2414,26 @@ pub fn handle_force_delete_user<'c: 'info, 'info>( Ok(()) } +pub fn handle_pause_spot_market_deposit_withdraw( + ctx: Context, +) -> Result<()> { + let spot_market = &mut load_mut!(ctx.accounts.spot_market)?; + + let result = + validate_spot_market_vault_amount(spot_market, ctx.accounts.spot_market_vault.amount); + + validate!( + matches!(result, Err(ErrorCode::SpotMarketVaultInvariantViolated)), + ErrorCode::DefaultError, + "spot market vault amount is valid" + )?; + + spot_market.paused_operations = spot_market.paused_operations | SpotOperation::Deposit as u8; + spot_market.paused_operations = spot_market.paused_operations | SpotOperation::Withdraw as u8; + + Ok(()) +} + #[derive(Accounts)] pub struct FillOrder<'info> { pub state: Box>, @@ -2910,3 +2930,16 @@ pub struct ForceDeleteUser<'info> { /// CHECK: forced drift_signer pub drift_signer: AccountInfo<'info>, } + +#[derive(Accounts)] +pub struct PauseSpotMarketDepositWithdraw<'info> { + pub state: Box>, + pub keeper: Signer<'info>, + #[account(mut)] + pub spot_market: AccountLoader<'info, SpotMarket>, + #[account( + seeds = [b"spot_market_vault".as_ref(), spot_market.load()?.market_index.to_le_bytes().as_ref()], + bump, + )] + pub spot_market_vault: Box>, +} diff --git a/programs/drift/src/lib.rs b/programs/drift/src/lib.rs index cde40ba8d..71d414b04 100644 --- a/programs/drift/src/lib.rs +++ b/programs/drift/src/lib.rs @@ -727,6 +727,12 @@ pub mod drift { handle_post_multi_pyth_pull_oracle_updates_atomic(ctx, params) } + pub fn pause_spot_market_deposit_withdraw( + ctx: Context, + ) -> Result<()> { + handle_pause_spot_market_deposit_withdraw(ctx) + } + // Admin Instructions pub fn initialize(ctx: Context) -> Result<()> { diff --git a/sdk/src/driftClient.ts b/sdk/src/driftClient.ts index 17adb35fa..f2bfd28a7 100644 --- a/sdk/src/driftClient.ts +++ b/sdk/src/driftClient.ts @@ -8937,6 +8937,35 @@ export class DriftClient { return ix; } + public async getPauseSpotMarketDepositWithdrawIx( + spotMarketIndex: number + ): Promise { + const spotMarket = await this.getSpotMarketAccount(spotMarketIndex); + return this.program.instruction.pauseSpotMarketDepositWithdraw({ + accounts: { + state: await this.getStatePublicKey(), + keeper: this.wallet.publicKey, + spotMarket: spotMarket.pubkey, + spotMarketVault: spotMarket.vault, + }, + }); + } + + public async pauseSpotMarketDepositWithdraw( + spotMarketIndex: number, + txParams?: TxParams + ): Promise { + const { txSig } = await this.sendTransaction( + await this.buildTransaction( + await this.getPauseSpotMarketDepositWithdrawIx(spotMarketIndex), + txParams + ), + [], + this.opts + ); + return txSig; + } + private handleSignedTransaction(signedTxs: SignedTxData[]) { if (this.enableMetricsEvents && this.metricsEventEmitter) { this.metricsEventEmitter.emit('txSigned', signedTxs); diff --git a/sdk/src/idl/drift.json b/sdk/src/idl/drift.json index 5054baad3..d573d84ac 100644 --- a/sdk/src/idl/drift.json +++ b/sdk/src/idl/drift.json @@ -3280,6 +3280,32 @@ } ] }, + { + "name": "pauseSpotMarketDepositWithdraw", + "accounts": [ + { + "name": "state", + "isMut": false, + "isSigner": false + }, + { + "name": "keeper", + "isMut": false, + "isSigner": true + }, + { + "name": "spotMarket", + "isMut": true, + "isSigner": false + }, + { + "name": "spotMarketVault", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, { "name": "initialize", "accounts": [ diff --git a/test-scripts/run-anchor-tests.sh b/test-scripts/run-anchor-tests.sh index ebf7fdadd..029bcd064 100644 --- a/test-scripts/run-anchor-tests.sh +++ b/test-scripts/run-anchor-tests.sh @@ -50,6 +50,7 @@ test_files=( order.ts ordersWithSpread.ts pauseExchange.ts + pauseDepositWithdraw.ts perpLpJit.ts perpLpRiskMitigation.ts phoenixTest.ts diff --git a/tests/pauseDepositWithdraw.ts b/tests/pauseDepositWithdraw.ts new file mode 100644 index 000000000..94031a2d0 --- /dev/null +++ b/tests/pauseDepositWithdraw.ts @@ -0,0 +1,272 @@ +import * as anchor from '@coral-xyz/anchor'; +import { assert } from 'chai'; + +import { Program } from '@coral-xyz/anchor'; + +import { PublicKey } from '@solana/web3.js'; + +import { + TestClient, + BN, + EventSubscriber, + SPOT_MARKET_RATE_PRECISION, + SpotBalanceType, + isVariant, + OracleSource, + SPOT_MARKET_WEIGHT_PRECISION, + SPOT_MARKET_CUMULATIVE_INTEREST_PRECISION, + OracleInfo, +} from '../sdk/src'; + +import { + createUserWithUSDCAccount, + mockOracleNoProgram, + mockUSDCMint, + mockUserUSDCAccount, + sleep, +} from './testHelpers'; +import { + getBalance, +} from '../sdk/src/math/spotBalance'; +import { + createBurnInstruction, + TOKEN_2022_PROGRAM_ID, +} from '@solana/spl-token'; +import { + QUOTE_PRECISION, + SPOT_MARKET_BALANCE_PRECISION, + SpotOperation, +} from '../sdk'; +import { startAnchor } from 'solana-bankrun'; +import { TestBulkAccountLoader } from '../sdk/src/accounts/testBulkAccountLoader'; +import { BankrunContextWrapper } from '../sdk/src/bankrun/bankrunConnection'; + +describe('spot deposit and withdraw 22', () => { + const chProgram = anchor.workspace.Drift as Program; + + let admin: TestClient; + let eventSubscriber: EventSubscriber; + + let bulkAccountLoader: TestBulkAccountLoader; + + let bankrunContextWrapper: BankrunContextWrapper; + + let solOracle: PublicKey; + + let usdcMint; + + let firstUserDriftClient: TestClient; + let firstUserDriftClientUSDCAccount: PublicKey; + + const usdcAmount = new BN(10 * 10 ** 6); + const largeUsdcAmount = new BN(10_000 * 10 ** 6); + + let marketIndexes: number[]; + let spotMarketIndexes: number[]; + let oracleInfos: OracleInfo[]; + + before(async () => { + const context = await startAnchor('', [], []); + + bankrunContextWrapper = new BankrunContextWrapper(context); + + bulkAccountLoader = new TestBulkAccountLoader( + bankrunContextWrapper.connection, + 'processed', + 1 + ); + + eventSubscriber = new EventSubscriber( + bankrunContextWrapper.connection.toConnection(), + chProgram + ); + + await eventSubscriber.subscribe(); + + usdcMint = await mockUSDCMint( + bankrunContextWrapper, + TOKEN_2022_PROGRAM_ID, + true + ); + console.log('testhere'); + await mockUserUSDCAccount(usdcMint, largeUsdcAmount, bankrunContextWrapper); + + solOracle = await mockOracleNoProgram(bankrunContextWrapper, 30); + + marketIndexes = []; + spotMarketIndexes = [0, 1]; + oracleInfos = [{ publicKey: solOracle, source: OracleSource.PYTH }]; + + admin = new TestClient({ + connection: bankrunContextWrapper.connection.toConnection(), + wallet: bankrunContextWrapper.provider.wallet, + programID: chProgram.programId, + opts: { + commitment: 'confirmed', + }, + activeSubAccountId: 0, + perpMarketIndexes: marketIndexes, + spotMarketIndexes: spotMarketIndexes, + subAccountIds: [], + oracleInfos, + accountSubscription: { + type: 'polling', + accountLoader: bulkAccountLoader, + }, + }); + + await admin.initialize(usdcMint.publicKey, true); + await admin.subscribe(); + }); + + after(async () => { + await admin.unsubscribe(); + await eventSubscriber.unsubscribe(); + await firstUserDriftClient.unsubscribe(); + }); + + it('Initialize USDC Market', async () => { + const optimalUtilization = SPOT_MARKET_RATE_PRECISION.div( + new BN(2) + ).toNumber(); // 50% utilization + const optimalRate = SPOT_MARKET_RATE_PRECISION.mul(new BN(20)).toNumber(); // 2000% APR + const maxRate = SPOT_MARKET_RATE_PRECISION.mul(new BN(50)).toNumber(); // 5000% APR + const initialAssetWeight = SPOT_MARKET_WEIGHT_PRECISION.toNumber(); + const maintenanceAssetWeight = SPOT_MARKET_WEIGHT_PRECISION.toNumber(); + const initialLiabilityWeight = SPOT_MARKET_WEIGHT_PRECISION.toNumber(); + const maintenanceLiabilityWeight = SPOT_MARKET_WEIGHT_PRECISION.toNumber(); + await admin.initializeSpotMarket( + usdcMint.publicKey, + optimalUtilization, + optimalRate, + maxRate, + PublicKey.default, + OracleSource.QUOTE_ASSET, + initialAssetWeight, + maintenanceAssetWeight, + initialLiabilityWeight, + maintenanceLiabilityWeight, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); + const txSig = await admin.updateWithdrawGuardThreshold( + 0, + new BN(10 ** 10).mul(QUOTE_PRECISION) + ); + bankrunContextWrapper.printTxLogs(txSig); + await admin.fetchAccounts(); + const spotMarket = await admin.getSpotMarketAccount(0); + assert(spotMarket.marketIndex === 0); + assert(spotMarket.optimalUtilization === optimalUtilization); + assert(spotMarket.optimalBorrowRate === optimalRate); + assert(spotMarket.maxBorrowRate === maxRate); + assert( + spotMarket.cumulativeBorrowInterest.eq( + SPOT_MARKET_CUMULATIVE_INTEREST_PRECISION + ) + ); + assert( + spotMarket.cumulativeDepositInterest.eq( + SPOT_MARKET_CUMULATIVE_INTEREST_PRECISION + ) + ); + assert(spotMarket.initialAssetWeight === initialAssetWeight); + assert(spotMarket.maintenanceAssetWeight === maintenanceAssetWeight); + assert(spotMarket.initialLiabilityWeight === initialLiabilityWeight); + assert(spotMarket.maintenanceAssetWeight === maintenanceAssetWeight); + + assert(admin.getStateAccount().numberOfSpotMarkets === 1); + }); + + it('First User Deposit USDC', async () => { + [firstUserDriftClient, firstUserDriftClientUSDCAccount] = + await createUserWithUSDCAccount( + bankrunContextWrapper, + usdcMint, + chProgram, + usdcAmount, + marketIndexes, + spotMarketIndexes, + oracleInfos, + bulkAccountLoader + ); + + const marketIndex = 0; + await sleep(100); + await firstUserDriftClient.fetchAccounts(); + const txSig = await firstUserDriftClient.deposit( + usdcAmount, + marketIndex, + firstUserDriftClientUSDCAccount + ); + bankrunContextWrapper.printTxLogs(txSig); + + const spotMarket = await admin.getSpotMarketAccount(marketIndex); + assert( + spotMarket.depositBalance.eq( + new BN(10 * SPOT_MARKET_BALANCE_PRECISION.toNumber()) + ) + ); + + const vaultAmount = new BN( + ( + await bankrunContextWrapper.connection.getTokenAccount(spotMarket.vault) + ).amount.toString() + ); + assert(vaultAmount.eq(usdcAmount)); + + const expectedBalance = getBalance( + usdcAmount, + spotMarket, + SpotBalanceType.DEPOSIT + ); + const spotPosition = firstUserDriftClient.getUserAccount().spotPositions[0]; + assert(isVariant(spotPosition.balanceType, 'deposit')); + assert(spotPosition.scaledBalance.eq(expectedBalance)); + + assert(firstUserDriftClient.getUserAccount().totalDeposits.eq(usdcAmount)); + }); + + it('Pause Deposit Withdraw Fails', async () => { + try { + await admin.pauseSpotMarketDepositWithdraw(0); + assert(false); + } catch (e) { + console.log(e); + } + }); + + it('Pause Deposit Withdraw Succeeds', async () => { + const spotMarket = await admin.getSpotMarketAccount(0); + const burnIx = createBurnInstruction( + spotMarket.vault, + usdcMint.publicKey, + admin.wallet.publicKey, + usdcAmount.toNumber(), + [admin.wallet.payer], + TOKEN_2022_PROGRAM_ID + ); + const tx = await admin.buildTransaction([burnIx]); + // @ts-ignore + await admin.sendTransaction(tx); + + await admin.pauseSpotMarketDepositWithdraw(0); + + await admin.fetchAccounts(); + const spotMarketAfter = await admin.getSpotMarketAccount(0); + const pausedOperations = spotMarketAfter.pausedOperations; + assert( + pausedOperations === (SpotOperation.DEPOSIT | SpotOperation.WITHDRAW) + ); + }); +}); diff --git a/tests/testHelpers.ts b/tests/testHelpers.ts index f3d93719f..7119a535d 100644 --- a/tests/testHelpers.ts +++ b/tests/testHelpers.ts @@ -12,6 +12,9 @@ import { createAssociatedTokenAccountIdempotentInstruction, ACCOUNT_SIZE, createSyncNativeInstruction, + createInitializePermanentDelegateInstruction, + getMintLen, + ExtensionType, } from '@solana/spl-token'; import { AccountInfo, @@ -116,16 +119,24 @@ export async function mockOracleNoProgram( export async function mockUSDCMint( context: BankrunContextWrapper, - tokenProgram = TOKEN_PROGRAM_ID + tokenProgram = TOKEN_PROGRAM_ID, + permanentDelegate?: boolean ): Promise { const fakeUSDCMint = anchor.web3.Keypair.generate(); + + let space = MintLayout.span; + if (permanentDelegate) { + space = getMintLen([ExtensionType.PermanentDelegate]); + } + const createUSDCMintAccountIx = SystemProgram.createAccount({ fromPubkey: context.provider.wallet.publicKey, newAccountPubkey: fakeUSDCMint.publicKey, lamports: 10_000_000_000, - space: MintLayout.span, + space: space, programId: tokenProgram, }); + const initCollateralMintIx = createInitializeMintInstruction( fakeUSDCMint.publicKey, 6, @@ -138,7 +149,20 @@ export async function mockUSDCMint( const fakeUSDCTx = new Transaction(); fakeUSDCTx.add(createUSDCMintAccountIx); + + if (permanentDelegate) { + fakeUSDCTx.add( + createInitializePermanentDelegateInstruction( + fakeUSDCMint.publicKey, + // @ts-ignore + context.provider.wallet.publicKey, + tokenProgram + ) + ); + } + fakeUSDCTx.add(initCollateralMintIx); + await context.sendTransaction(fakeUSDCTx, [fakeUSDCMint]); return fakeUSDCMint; }