Skip to content

Commit fcd05a6

Browse files
authored
fix: resolve ORDER_PRICE_REQUIRED error on perps position flip (#29691)
## **Description** TradingService.flipPosition() deliberately omits currentPrice from the order params it passes to provider.placeOrder() — passing a stale entry price would corrupt IOC slippage calculation. However, placeOrder() was running #validateOrderBeforePlacement before fetching the live price, so the minimum-USD check had no price to work with and threw ORDER_PRICE_REQUIRED on every flip. Root cause: step ordering in HyperLiquidProvider.placeOrder(). Validation ran at step 1; the live price fetch (#getAssetInfo) ran at step 3. Fix: reorder placeOrder() so #getAssetInfo runs first, compute effectivePrice (live price, or caller-supplied price if present), then pass it into #validateOrderBeforePlacement. #ensureReadyForTrading stays after validation, so invalid orders never trigger builder-fee / DEX-abstraction signature prompts unnecessarily. As a follow-on, effectivePrice is hoisted above the try block so the $10-minimum retry path can also use the fetched price when the caller omits currentPrice — without this, a flip order that hit the $10 edge case would fail silently instead of retrying. No changes to TradingService.flipPosition — the intentional omission of entryPrice is correct and preserved. Affected tests: Three HyperLiquidProvider tests updated to reflect the new ordering (the "missing price data" error now surfaces from #getAssetInfo rather than validation; the price-less PUMP retry test now succeeds with two exchange calls) Two new tests added for the explicit flip-order path ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: fix regression to flip position perps ## **Related issues** Fixes: MetaMask/metamask-extension#42375 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/1a789aff-37e1-4e7f-825b-a4af00f14211 ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the execution order in `HyperLiquidProvider.placeOrder`, affecting validation/signing flow and retry behavior for minimum-order errors. Risk is moderate because it touches core order placement logic, but the change is localized and covered by updated/new tests. > > **Overview** > Fixes a regression where price-less market orders (notably `flipPosition`) failed with `ORDER_PRICE_REQUIRED` by fetching the live price via `#getAssetInfo` before provider-level validation and validating against the computed `effectivePrice`. > > Also hoists `effectivePrice` so the "$10 minimum order value" retry path can derive an adjusted `usdAmount` from the fetched price when `currentPrice` is omitted. Updates existing tests and adds new coverage to assert success for flip-style params, the $10-minimum retry behavior, and the revised error surfaced when price data is missing. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 106369c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 95e7280 commit fcd05a6

2 files changed

Lines changed: 101 additions & 43 deletions

File tree

app/controllers/perps/providers/HyperLiquidProvider.test.ts

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,7 +1226,7 @@ describe('HyperLiquidProvider', () => {
12261226
);
12271227
});
12281228

1229-
it('validates price requirement before attempting order placement', async () => {
1229+
it('retries with adjusted USD when price-less order hits $10 minimum (uses fetched price from allMids)', async () => {
12301230
// Create provider with PUMP in the asset mapping
12311231
provider = createTestProvider({
12321232
initialAssetMapping: [
@@ -1256,24 +1256,38 @@ describe('HyperLiquidProvider', () => {
12561256
isBuy: true,
12571257
size: '2553',
12581258
orderType: 'market',
1259+
// No currentPrice: provider fetches live price (0.003918) and uses it
1260+
// for both validation and the $10-minimum retry path.
12591261
};
12601262

1263+
const mockOrder = jest
1264+
.fn()
1265+
.mockRejectedValueOnce(
1266+
new Error('Order must have minimum value of $10'),
1267+
)
1268+
.mockResolvedValueOnce({
1269+
status: 'ok',
1270+
response: {
1271+
data: {
1272+
statuses: [
1273+
{ filled: { oid: 123, totalSz: '2553', avgPx: '0.004' } },
1274+
],
1275+
},
1276+
},
1277+
});
1278+
12611279
mockClientService.getExchangeClient = jest.fn().mockReturnValue({
12621280
...createMockExchangeClient(),
1263-
order: jest
1264-
.fn()
1265-
.mockRejectedValueOnce(
1266-
new Error('Order must have minimum value of $10'),
1267-
),
1281+
order: mockOrder,
12681282
});
12691283

12701284
const result = await provider.placeOrder(orderParams);
12711285

1272-
expect(result.success).toBe(false);
1273-
expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED);
1274-
expect(
1275-
mockClientService.getExchangeClient().order,
1276-
).not.toHaveBeenCalled();
1286+
// The live price is fetched → validation passes → order is submitted →
1287+
// first call hits $10 minimum → retry uses fetched price to compute
1288+
// adjusted usdAmount → second call succeeds.
1289+
expect(result.success).toBe(true);
1290+
expect(mockOrder).toHaveBeenCalledTimes(2);
12771291
});
12781292

12791293
it('closes a position successfully', async () => {
@@ -2812,6 +2826,7 @@ describe('HyperLiquidProvider', () => {
28122826
});
28132827

28142828
it('handles missing price data', async () => {
2829+
mockSubscriptionService.getCachedPrice.mockReturnValueOnce(undefined);
28152830
(
28162831
mockClientService.getInfoClient().allMids as jest.Mock
28172832
).mockResolvedValueOnce({});
@@ -2825,8 +2840,10 @@ describe('HyperLiquidProvider', () => {
28252840

28262841
const result = await provider.placeOrder(orderParams);
28272842

2843+
// allMids returns {} so #getOrFetchPrice parses price as 0, which is
2844+
// invalid. The error surfaces from #getAssetInfo before validation runs.
28282845
expect(result.success).toBe(false);
2829-
expect(result.error).toContain(PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED);
2846+
expect(result.error).toContain('Invalid price for BTC: 0');
28302847
});
28312848

28322849
it('handles missing position in close operation', async () => {
@@ -3545,19 +3562,45 @@ describe('HyperLiquidProvider', () => {
35453562
expect(result.error).toContain('Failed to update leverage');
35463563
});
35473564

3548-
it('fails market order without current price or usdAmount', async () => {
3565+
it('succeeds with market order without current price or usdAmount (uses fetched price)', async () => {
3566+
// The provider now fetches the live price before validation so callers
3567+
// that intentionally omit currentPrice (e.g. flipPosition) work correctly.
35493568
const orderParams: OrderParams = {
35503569
symbol: 'BTC',
35513570
isBuy: true,
35523571
size: '0.1',
35533572
orderType: 'market',
3554-
// No currentPrice or usdAmount provided - should fail validation
3573+
// No currentPrice or usdAmount: provider fetches live price (50000)
35553574
};
35563575

35573576
const result = await provider.placeOrder(orderParams);
35583577

3559-
expect(result.success).toBe(false);
3560-
expect(result.error).toContain(PERPS_ERROR_CODES.ORDER_PRICE_REQUIRED);
3578+
expect(result.success).toBe(true);
3579+
expect(mockClientService.getExchangeClient().order).toHaveBeenCalled();
3580+
});
3581+
3582+
it('placeOrder validates against fetched price when params omit currentPrice (flipPosition path)', async () => {
3583+
// Simulate the exact OrderParams shape that TradingService.flipPosition
3584+
// builds: symbol + isBuy + size + orderType + leverage, no price fields.
3585+
const flipOrderParams: OrderParams = {
3586+
symbol: 'BTC',
3587+
isBuy: false, // flipping long → short
3588+
size: '1', // 2× the 0.5 BTC position
3589+
orderType: 'market',
3590+
leverage: 10,
3591+
// currentPrice, usdAmount, price intentionally absent
3592+
};
3593+
3594+
const result = await provider.placeOrder(flipOrderParams);
3595+
3596+
// Live price (50000) is fetched from allMids → validation passes
3597+
// (0.1 BTC × $50 000 = $5 000 >> $10 minimum) → order executes.
3598+
expect(result.success).toBe(true);
3599+
expect(
3600+
mockClientService.getExchangeClient().order,
3601+
).toHaveBeenCalledWith(
3602+
expect.objectContaining({ orders: expect.any(Array) }),
3603+
);
35613604
});
35623605

35633606
it('handles order with custom slippage', async () => {

app/controllers/perps/providers/HyperLiquidProvider.ts

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3543,6 +3543,9 @@ export class HyperLiquidProvider implements PerpsProvider {
35433543
* @returns A promise that resolves to the result.
35443544
*/
35453545
async placeOrder(params: OrderParams, retryCount = 0): Promise<OrderResult> {
3546+
// Hoisted so the retry path in the catch block can use the fetched price
3547+
// even when the caller (e.g. flipPosition) omits currentPrice from params.
3548+
let effectivePrice: number | undefined;
35463549
try {
35473550
this.#deps.debugLogger.log('Placing order via HyperLiquid SDK:', params);
35483551

@@ -3557,38 +3560,18 @@ export class HyperLiquidProvider implements PerpsProvider {
35573560
throw new Error(validation.error);
35583561
}
35593562

3560-
// Validate order at provider level (enforces USD validation rules)
3561-
await this.#validateOrderBeforePlacement(params);
3562-
3563-
// Ensure provider is ready for trading (includes signing operations)
3564-
await this.#ensureReadyForTrading();
3565-
3566-
// Debug: Log asset map state before order placement
3567-
const allMapKeys = Array.from(this.#symbolToAssetId.keys());
3568-
const hip3Keys = allMapKeys.filter((key) => key.includes(':'));
3569-
const assetExists = this.#symbolToAssetId.has(params.symbol);
3570-
this.#deps.debugLogger.log('Asset map state at order time', {
3571-
requestedCoin: params.symbol,
3572-
assetExistsInMap: assetExists,
3573-
totalAssetsInMap: this.#symbolToAssetId.size,
3574-
hip3AssetsCount: hip3Keys.length,
3575-
hip3AssetsSample: hip3Keys.slice(0, 10),
3576-
hip3Enabled: this.#hip3Enabled,
3577-
allowlistMarkets: this.#allowlistMarkets,
3578-
blocklistMarkets: this.#blocklistMarkets,
3579-
});
3580-
35813563
// Extract DEX name for API calls (main DEX = null)
35823564
const { dex: dexName } = parseAssetName(params.symbol);
35833565

3584-
// 1. Get asset info and current price
3566+
// 1. Get asset info and current price before validation so price-less
3567+
// callers (e.g. flipPosition) can validate against the live fetched price.
35853568
const { assetInfo, currentPrice, meta } = await this.#getAssetInfo({
35863569
symbol: params.symbol,
35873570
dexName,
35883571
});
35893572

3590-
// Allow override with UI-provided price (optimization to avoid API call)
3591-
const effectivePrice =
3573+
// Allow override with UI-provided price (optimization to avoid API call).
3574+
effectivePrice =
35923575
params.currentPrice && params.currentPrice > 0
35933576
? params.currentPrice
35943577
: currentPrice;
@@ -3601,6 +3584,35 @@ export class HyperLiquidProvider implements PerpsProvider {
36013584
});
36023585
}
36033586

3587+
// Validate order at provider level (enforces USD validation rules).
3588+
// Pass effectivePrice so price-less market orders (e.g. flipPosition)
3589+
// validate against the live fetched price instead of failing with
3590+
// ORDER_PRICE_REQUIRED.
3591+
await this.#validateOrderBeforePlacement({
3592+
...params,
3593+
currentPrice: effectivePrice,
3594+
});
3595+
3596+
// Ensure provider is ready for trading (includes signing operations).
3597+
// Kept after validation so invalid orders never trigger signature prompts
3598+
// (builder-fee approval, DEX abstraction enablement, etc.).
3599+
await this.#ensureReadyForTrading();
3600+
3601+
// Debug: Log asset map state before order placement
3602+
const allMapKeys = Array.from(this.#symbolToAssetId.keys());
3603+
const hip3Keys = allMapKeys.filter((key) => key.includes(':'));
3604+
const assetExists = this.#symbolToAssetId.has(params.symbol);
3605+
this.#deps.debugLogger.log('Asset map state at order time', {
3606+
requestedCoin: params.symbol,
3607+
assetExistsInMap: assetExists,
3608+
totalAssetsInMap: this.#symbolToAssetId.size,
3609+
hip3AssetsCount: hip3Keys.length,
3610+
hip3AssetsSample: hip3Keys.slice(0, 10),
3611+
hip3Enabled: this.#hip3Enabled,
3612+
allowlistMarkets: this.#allowlistMarkets,
3613+
blocklistMarkets: this.#blocklistMarkets,
3614+
});
3615+
36043616
// 2. Calculate final position size with USD reconciliation
36053617
const { finalPositionSize } = calculateFinalPositionSize({
36063618
usdAmount: params.usdAmount,
@@ -3706,10 +3718,13 @@ export class HyperLiquidProvider implements PerpsProvider {
37063718
// USD-based order: adjust the USD amount directly
37073719
originalValue = params.usdAmount;
37083720
adjustedUsdAmount = (parseFloat(params.usdAmount) * 1.015).toFixed(2);
3709-
} else if (params.currentPrice) {
3710-
// Size-based order: calculate USD from size and adjust
3721+
} else if (effectivePrice) {
3722+
// Size-based order: calculate USD from size and adjust.
3723+
// Use the hoisted effectivePrice (fetched live price) so callers that
3724+
// omit currentPrice (e.g. flipPosition) can still recover from the
3725+
// $10-minimum edge case.
37113726
const sizeValue = parseFloat(params.size);
3712-
const estimatedUsd = sizeValue * params.currentPrice;
3727+
const estimatedUsd = sizeValue * effectivePrice;
37133728
originalValue = `${estimatedUsd.toFixed(2)} (calculated from size ${params.size})`;
37143729
adjustedUsdAmount = (estimatedUsd * 1.015).toFixed(2);
37153730
} else {

0 commit comments

Comments
 (0)