diff --git a/programs/drift/src/controller/orders.rs b/programs/drift/src/controller/orders.rs index bc8b3aec4..f17c3f905 100644 --- a/programs/drift/src/controller/orders.rs +++ b/programs/drift/src/controller/orders.rs @@ -584,6 +584,89 @@ pub fn cancel_orders( Ok(canceled_order_ids) } +/// Cancels all trigger orders for a specific market when a position is completely closed. +/// +/// This function is called after a position is fully closed to automatically cancel any +/// remaining trigger orders (stop-loss/take-profit) for that market, preventing orphaned +/// orders from executing unexpectedly and creating unintended positions. +/// +/// # Arguments +/// * `user` - Mutable reference to the user account +/// * `user_key` - Public key of the user +/// * `market_index` - Index of the market whose trigger orders should be cancelled +/// * `market_type` - Type of market (Perp or Spot) +/// * `perp_market_map` - Reference to perp market map +/// * `spot_market_map` - Reference to spot market map +/// * `oracle_map` - Mutable reference to oracle map +/// * `now` - Current timestamp +/// * `slot` - Current slot number +/// +/// # Returns +/// * `DriftResult` - Ok(()) if successful, error otherwise +/// +/// # Example Flow +/// 1. User opens short position with stop-loss trigger order at $110 +/// 2. User manually closes position at current price +/// 3. This function cancels the stop-loss order to prevent it from triggering later +/// +/// # See Also +/// * Issue #923: https://github.com/drift-labs/protocol-v2/issues/923 +pub fn cancel_trigger_orders_for_closed_position( + user: &mut User, + user_key: &Pubkey, + market_index: u16, + market_type: MarketType, + perp_market_map: &PerpMarketMap, + spot_market_map: &SpotMarketMap, + oracle_map: &mut OracleMap, + now: i64, + slot: u64, +) -> DriftResult { + // Iterate through all user orders to find trigger orders for this market + for order_index in 0..user.orders.len() { + // Skip orders that are not currently open + if user.orders[order_index].status != OrderStatus::Open { + continue; + } + + // Only cancel trigger orders (TriggerMarket or TriggerLimit) + // Regular limit/market orders are not affected + if !user.orders[order_index].must_be_triggered() { + continue; + } + + // Only cancel orders for the specified market type (perp vs spot) + if user.orders[order_index].market_type != market_type { + continue; + } + + // Only cancel orders for the specific market index + if user.orders[order_index].market_index != market_index { + continue; + } + + // Cancel the trigger order + cancel_order( + order_index, + user, + user_key, + perp_market_map, + spot_market_map, + oracle_map, + now, + slot, + OrderActionExplanation::OrderExpired, + None, + 0, + false, + )?; + } + + user.update_last_active_slot(slot); + + Ok(()) +} + pub fn cancel_order_by_order_id( order_id: u32, user: &AccountLoader, @@ -2042,6 +2125,28 @@ fn fulfill_perp_order( )?; } + // Fix for issue #923: Cancel all trigger orders if position is completely closed + // Only check if we actually filled something (base_asset_amount > 0) + if base_asset_amount > 0 { + let position_index = get_position_index(&user.perp_positions, market_index)?; + + // If position is now completely closed (base_asset_amount == 0), cancel all trigger orders + // This prevents orphaned stop-loss/take-profit orders from executing unexpectedly + if user.perp_positions[position_index].base_asset_amount == 0 { + cancel_trigger_orders_for_closed_position( + user, + user_key, + market_index, + MarketType::Perp, + perp_market_map, + spot_market_map, + oracle_map, + now, + slot, + )?; + } + } + Ok((base_asset_amount, quote_asset_amount)) } @@ -4680,6 +4785,28 @@ fn fulfill_spot_order( } } + // Fix for issue #923: Cancel all trigger orders if spot position is completely closed + // Only check if we actually filled something (base_asset_amount > 0) + if base_asset_amount > 0 { + let spot_position_index = user.get_spot_position_index(base_market_index)?; + + // If spot position is now completely closed (scaled_balance == 0), cancel all trigger orders + // This prevents orphaned stop-loss/take-profit orders from executing unexpectedly + if user.spot_positions[spot_position_index].scaled_balance == 0 { + cancel_trigger_orders_for_closed_position( + user, + user_key, + base_market_index, + MarketType::Spot, + perp_market_map, + spot_market_map, + oracle_map, + now, + slot, + )?; + } + } + Ok((base_asset_amount, quote_asset_amount)) } diff --git a/tests/cancelTriggerOrdersOnPositionClose.ts b/tests/cancelTriggerOrdersOnPositionClose.ts new file mode 100644 index 000000000..3fb44e48b --- /dev/null +++ b/tests/cancelTriggerOrdersOnPositionClose.ts @@ -0,0 +1,453 @@ +import * as anchor from '@coral-xyz/anchor'; +import { assert } from 'chai'; + +import { Program } from '@coral-xyz/anchor'; + +import { Keypair, PublicKey } from '@solana/web3.js'; + +import { + TestClient, + BN, + PRICE_PRECISION, + PositionDirection, + User, + Wallet, + getMarketOrderParams, + OrderTriggerCondition, + getTriggerMarketOrderParams, + OrderStatus, +} from '../sdk/src'; + +import { + mockOracleNoProgram, + mockUSDCMint, + mockUserUSDCAccount, + setFeedPriceNoProgram, + initializeQuoteSpotMarket, +} from './testHelpers'; +import { + BASE_PRECISION, + convertToNumber, + OracleSource, + PERCENTAGE_PRECISION, + QUOTE_PRECISION, +} from '../sdk'; +import { startAnchor } from 'solana-bankrun'; +import { TestBulkAccountLoader } from '../sdk/src/accounts/testBulkAccountLoader'; +import { BankrunContextWrapper } from '../sdk/src/bankrun/bankrunConnection'; + +describe('Cancel trigger orders on position close (Issue #923)', () => { + const chProgram = anchor.workspace.Drift as Program; + + let bulkAccountLoader: TestBulkAccountLoader; + let bankrunContextWrapper: BankrunContextWrapper; + + let driftClient: TestClient; + let driftClientUser: User; + + let fillerDriftClient: TestClient; + let fillerDriftClientUser: User; + + let usdcMint; + let userUSDCAccount; + + const mantissaSqrtScale = new BN(Math.sqrt(PRICE_PRECISION.toNumber())); + const ammInitialQuoteAssetReserve = new anchor.BN(5 * 10 ** 13).mul( + mantissaSqrtScale + ); + const ammInitialBaseAssetReserve = new anchor.BN(5 * 10 ** 13).mul( + mantissaSqrtScale + ); + + const usdcAmount = new BN(10 * 10 ** 6); + + let solUsd; + let marketIndexes; + let spotMarketIndexes; + let oracleInfos; + + before(async () => { + const context = await startAnchor('', [], []); + + bankrunContextWrapper = new BankrunContextWrapper(context); + + bulkAccountLoader = new TestBulkAccountLoader( + bankrunContextWrapper.connection, + 'processed', + 1 + ); + + usdcMint = await mockUSDCMint(bankrunContextWrapper); + userUSDCAccount = await mockUserUSDCAccount( + usdcMint, + usdcAmount, + bankrunContextWrapper + ); + + solUsd = await mockOracleNoProgram(bankrunContextWrapper, 1); + marketIndexes = [0]; + spotMarketIndexes = [0]; + oracleInfos = [ + { + publicKey: solUsd, + source: OracleSource.PYTH, + }, + ]; + + driftClient = 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 driftClient.initialize(usdcMint.publicKey, true); + await driftClient.subscribe(); + await initializeQuoteSpotMarket(driftClient, usdcMint.publicKey); + await driftClient.updatePerpAuctionDuration(new BN(0)); + + const periodicity = new BN(60 * 60); // 1 HOUR + + await driftClient.initializePerpMarket( + 0, + solUsd, + ammInitialBaseAssetReserve, + ammInitialQuoteAssetReserve, + periodicity + ); + + await driftClient.initializeUserAccountAndDepositCollateral( + usdcAmount, + userUSDCAccount.publicKey + ); + + driftClientUser = new User({ + driftClient: driftClient, + userAccountPublicKey: await driftClient.getUserAccountPublicKey(), + accountSubscription: { + type: 'polling', + accountLoader: bulkAccountLoader, + }, + }); + await driftClientUser.subscribe(); + + // Create a filler client (needed to fill orders) + fillerDriftClient = driftClient; + fillerDriftClientUser = driftClientUser; + }); + + beforeEach(async () => { + await driftClient.moveAmmPrice( + 0, + ammInitialBaseAssetReserve, + ammInitialQuoteAssetReserve + ); + await setFeedPriceNoProgram(bankrunContextWrapper, 1, solUsd); + }); + + after(async () => { + await driftClient.unsubscribe(); + await driftClientUser.unsubscribe(); + }); + + it('Trigger orders are cancelled when short position is closed', async () => { + // Set oracle price to $1 + await setFeedPriceNoProgram(bankrunContextWrapper, 1, solUsd); + + // Step 1: Open a short position + const orderParams = getMarketOrderParams({ + marketIndex: 0, + direction: PositionDirection.SHORT, + baseAssetAmount: BASE_PRECISION, + }); + await driftClient.placePerpOrder(orderParams); + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + const orderIndex = 0; + const order = driftClientUser.getUserAccount().orders[orderIndex]; + assert.ok(order.baseAssetAmount.eq(BASE_PRECISION)); + + // Fill the short order + await fillerDriftClient.fillPerpOrder( + await driftClientUser.getUserAccountPublicKey(), + driftClientUser.getUserAccount(), + order + ); + + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + // Verify short position is open + const position = driftClientUser.getUserAccount().perpPositions[0]; + assert.ok(position.baseAssetAmount.lt(new BN(0))); // Negative = short + + // Step 2: Place a stop-loss trigger order (should trigger if price goes above $1.10) + const triggerOrderParams = getTriggerMarketOrderParams({ + marketIndex: 0, + direction: PositionDirection.LONG, // To close the short + baseAssetAmount: BASE_PRECISION, + triggerPrice: PRICE_PRECISION.mul(new BN(110)).div(new BN(100)), // $1.10 + triggerCondition: OrderTriggerCondition.ABOVE, + }); + await driftClient.placePerpOrder(triggerOrderParams); + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + // Verify trigger order exists + let triggerOrder = driftClientUser.getUserAccount().orders.find( + (o) => o.status === OrderStatus.OPEN && o.triggerPrice.gt(new BN(0)) + ); + assert.ok(triggerOrder !== undefined, 'Trigger order should exist'); + assert.ok( + triggerOrder.triggerPrice.eq( + PRICE_PRECISION.mul(new BN(110)).div(new BN(100)) + ), + 'Trigger price should be $1.10' + ); + + // Step 3: Close the short position with a market order + const closeOrderParams = getMarketOrderParams({ + marketIndex: 0, + direction: PositionDirection.LONG, // Opposite direction to close + baseAssetAmount: BASE_PRECISION, + }); + await driftClient.placePerpOrder(closeOrderParams); + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + const closeOrder = + driftClientUser.getUserAccount().orders[ + driftClientUser.getUserAccount().orders.findIndex( + (o) => o.status === OrderStatus.OPEN && o.triggerPrice.eq(new BN(0)) + ) + ]; + + // Fill the closing order + await fillerDriftClient.fillPerpOrder( + await driftClientUser.getUserAccountPublicKey(), + driftClientUser.getUserAccount(), + closeOrder + ); + + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + // Verify position is completely closed + const closedPosition = driftClientUser.getUserAccount().perpPositions[0]; + assert.ok( + closedPosition.baseAssetAmount.eq(new BN(0)), + 'Position should be completely closed' + ); + + // Step 4: Verify trigger order was automatically cancelled (THE FIX) + triggerOrder = driftClientUser.getUserAccount().orders.find( + (o) => o.status === OrderStatus.OPEN && o.triggerPrice.gt(new BN(0)) + ); + assert.ok( + triggerOrder === undefined, + 'Trigger order should be automatically cancelled when position closes' + ); + + console.log('✅ Test passed: Trigger orders are cancelled on position close'); + }); + + it('Trigger orders remain active when position is only partially closed', async () => { + // Set oracle price to $1 + await setFeedPriceNoProgram(bankrunContextWrapper, 1, solUsd); + + // Step 1: Open a larger short position (2x BASE_PRECISION) + const orderParams = getMarketOrderParams({ + marketIndex: 0, + direction: PositionDirection.SHORT, + baseAssetAmount: BASE_PRECISION.mul(new BN(2)), + }); + await driftClient.placePerpOrder(orderParams); + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + const order = driftClientUser.getUserAccount().orders[0]; + + // Fill the short order + await fillerDriftClient.fillPerpOrder( + await driftClientUser.getUserAccountPublicKey(), + driftClientUser.getUserAccount(), + order + ); + + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + // Step 2: Place a stop-loss trigger order + const triggerOrderParams = getTriggerMarketOrderParams({ + marketIndex: 0, + direction: PositionDirection.LONG, + baseAssetAmount: BASE_PRECISION.mul(new BN(2)), + triggerPrice: PRICE_PRECISION.mul(new BN(110)).div(new BN(100)), + triggerCondition: OrderTriggerCondition.ABOVE, + }); + await driftClient.placePerpOrder(triggerOrderParams); + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + // Step 3: Partially close the position (only 1x BASE_PRECISION) + const partialCloseParams = getMarketOrderParams({ + marketIndex: 0, + direction: PositionDirection.LONG, + baseAssetAmount: BASE_PRECISION, // Only half + }); + await driftClient.placePerpOrder(partialCloseParams); + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + const partialCloseOrder = + driftClientUser.getUserAccount().orders[ + driftClientUser.getUserAccount().orders.findIndex( + (o) => o.status === OrderStatus.OPEN && o.triggerPrice.eq(new BN(0)) + ) + ]; + + // Fill the partial closing order + await fillerDriftClient.fillPerpOrder( + await driftClientUser.getUserAccountPublicKey(), + driftClientUser.getUserAccount(), + partialCloseOrder + ); + + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + // Verify position is still open (not completely closed) + const position = driftClientUser.getUserAccount().perpPositions[0]; + assert.ok( + !position.baseAssetAmount.eq(new BN(0)), + 'Position should still be open' + ); + + // Verify trigger order is still active (should NOT be cancelled) + const triggerOrder = driftClientUser.getUserAccount().orders.find( + (o) => o.status === OrderStatus.OPEN && o.triggerPrice.gt(new BN(0)) + ); + assert.ok( + triggerOrder !== undefined, + 'Trigger order should remain active when position is only partially closed' + ); + + console.log( + '✅ Test passed: Trigger orders remain active on partial position close' + ); + }); + + it('Multiple trigger orders are all cancelled when position closes', async () => { + // Set oracle price to $1 + await setFeedPriceNoProgram(bankrunContextWrapper, 1, solUsd); + + // Step 1: Open a short position + const orderParams = getMarketOrderParams({ + marketIndex: 0, + direction: PositionDirection.SHORT, + baseAssetAmount: BASE_PRECISION, + }); + await driftClient.placePerpOrder(orderParams); + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + const order = driftClientUser.getUserAccount().orders[0]; + + // Fill the short order + await fillerDriftClient.fillPerpOrder( + await driftClientUser.getUserAccountPublicKey(), + driftClientUser.getUserAccount(), + order + ); + + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + // Step 2: Place multiple trigger orders (stop-loss and take-profit) + const stopLossParams = getTriggerMarketOrderParams({ + marketIndex: 0, + direction: PositionDirection.LONG, + baseAssetAmount: BASE_PRECISION, + triggerPrice: PRICE_PRECISION.mul(new BN(110)).div(new BN(100)), // $1.10 + triggerCondition: OrderTriggerCondition.ABOVE, + }); + await driftClient.placePerpOrder(stopLossParams); + + const takeProfitParams = getTriggerMarketOrderParams({ + marketIndex: 0, + direction: PositionDirection.LONG, + baseAssetAmount: BASE_PRECISION, + triggerPrice: PRICE_PRECISION.mul(new BN(90)).div(new BN(100)), // $0.90 + triggerCondition: OrderTriggerCondition.BELOW, + }); + await driftClient.placePerpOrder(takeProfitParams); + + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + // Verify both trigger orders exist + let triggerOrders = driftClientUser + .getUserAccount() + .orders.filter( + (o) => o.status === OrderStatus.OPEN && o.triggerPrice.gt(new BN(0)) + ); + assert.equal( + triggerOrders.length, + 2, + 'Should have 2 trigger orders (stop-loss and take-profit)' + ); + + // Step 3: Close the position + const closeOrderParams = getMarketOrderParams({ + marketIndex: 0, + direction: PositionDirection.LONG, + baseAssetAmount: BASE_PRECISION, + }); + await driftClient.placePerpOrder(closeOrderParams); + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + const closeOrder = + driftClientUser.getUserAccount().orders[ + driftClientUser.getUserAccount().orders.findIndex( + (o) => o.status === OrderStatus.OPEN && o.triggerPrice.eq(new BN(0)) + ) + ]; + + // Fill the closing order + await fillerDriftClient.fillPerpOrder( + await driftClientUser.getUserAccountPublicKey(), + driftClientUser.getUserAccount(), + closeOrder + ); + + await driftClient.fetchAccounts(); + await driftClientUser.fetchAccounts(); + + // Verify ALL trigger orders were cancelled + triggerOrders = driftClientUser + .getUserAccount() + .orders.filter( + (o) => o.status === OrderStatus.OPEN && o.triggerPrice.gt(new BN(0)) + ); + assert.equal( + triggerOrders.length, + 0, + 'All trigger orders should be cancelled when position closes' + ); + + console.log('✅ Test passed: Multiple trigger orders are all cancelled'); + }); +});