diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index bda6d6ae235..3e39554bd8e 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fix sub-$1 HyperLiquid price rounding miscounting significant figures, needlessly clipping precision on limit/TP/SL/trigger/chase prices and mispricing post-only order chases ([#10052](https://github.com/MetaMask/core/pull/10052)) + ## [15.0.0] ### Added diff --git a/packages/perps-controller/src/utils/significantFigures.ts b/packages/perps-controller/src/utils/significantFigures.ts index 4fe05c1f8f2..985ce9cb531 100644 --- a/packages/perps-controller/src/utils/significantFigures.ts +++ b/packages/perps-controller/src/utils/significantFigures.ts @@ -19,15 +19,21 @@ export const countSignificantFigures = (priceString: string): number => { } const normalized = number.toString(); - const [integerPart, decimalPart = ''] = normalized.split('.'); - const trimmedInteger = integerPart.replace(/^-?0*/u, '') || ''; - - const effectiveIntegerLength = decimalPart - ? trimmedInteger.length - : trimmedInteger.replace(/0+$/u, '').length || - (trimmedInteger.length > 0 ? 1 : 0); + // Strip the sign and decimal point so leading zeros are counted across the + // *whole* number, not just the integer part — "0.001234" has to lose its + // "0" integer part and its two leading fractional zeros to land on the + // correct 4 significant figures ("1234"), not 6. + const digitsOnly = normalized.replace(/^-/u, '').replace('.', ''); + const withoutLeadingZeros = digitsOnly.replace(/^0+/u, '') || '0'; + + // Trailing zeros are only ambiguous (not significant) for an integer with + // no decimal point (e.g. "1000" could be 1-4 sig figs); once there's a + // decimal point, every digit already present is significant. + if (!normalized.includes('.')) { + return withoutLeadingZeros.replace(/0+$/u, '').length || 1; + } - return effectiveIntegerLength + decimalPart.length; + return withoutLeadingZeros.length; }; /** @@ -80,26 +86,20 @@ export const roundToSignificantFigures = ( return priceString; } - const normalized = number.toString(); - const [integerPart, decimalPart = ''] = normalized.split('.'); - - const trimmedInteger = integerPart.replace(/^-?0*/u, '') || ''; - const integerSigFigs = trimmedInteger.length; - - if (!decimalPart) { - return normalized; + if (countSignificantFigures(cleaned) <= maxSigFigs) { + return number.toString(); } - const allowedDecimalDigits = maxSigFigs - integerSigFigs; + // Same order-of-magnitude approach as getPriceTick (orderCalculations.ts) — + // keep the two significant-figures rules in sync instead of maintaining + // two independent implementations of the same HyperLiquid precision rule. + const magnitude = Math.floor(Math.log10(Math.abs(number))); + const decimalPlaces = maxSigFigs - 1 - magnitude; - if (allowedDecimalDigits <= 0) { + if (decimalPlaces <= 0) { return Math.round(number).toString(); } - if (decimalPart.length <= allowedDecimalDigits) { - return normalized; - } - - const rounded = number.toFixed(allowedDecimalDigits); + const rounded = number.toFixed(decimalPlaces); return Number.parseFloat(rounded).toString(); }; diff --git a/packages/perps-controller/tests/src/utils/formatHyperLiquidPrice.test.ts b/packages/perps-controller/tests/src/utils/formatHyperLiquidPrice.test.ts new file mode 100644 index 00000000000..536d94df20b --- /dev/null +++ b/packages/perps-controller/tests/src/utils/formatHyperLiquidPrice.test.ts @@ -0,0 +1,33 @@ +import { formatHyperLiquidPrice } from '../../../src/utils/hyperLiquidAdapter.js'; + +describe('formatHyperLiquidPrice', () => { + it('returns an integer price unchanged', () => { + expect(formatHyperLiquidPrice({ price: 50000, szDecimals: 3 })).toBe( + '50000', + ); + }); + + it('rounds a price >= 1 to the decimal-place grid, then to 5 significant figures', () => { + expect( + formatHyperLiquidPrice({ price: 2999.14159, szDecimals: 4 }), + ).toBe('2999.1'); + }); + + // Regression: countSignificantFigures used to miscount every sub-$1 price + // as having far more significant figures than it does, so a price that was + // already within HyperLiquid's 5-significant-figure limit got needlessly + // re-rounded and lost a real digit of precision. + it.each([ + ['0.001234', 0], + ['0.000463', 0], + ['0.000171', 0], + ])('preserves full precision for %s (szDecimals=%s)', (price, szDecimals) => { + expect(formatHyperLiquidPrice({ price, szDecimals })).toBe(price); + }); + + it('still rounds a sub-$1 price that genuinely exceeds 5 significant figures', () => { + expect( + formatHyperLiquidPrice({ price: '0.00123456', szDecimals: 0 }), + ).toBe('0.001235'); + }); +}); diff --git a/packages/perps-controller/tests/src/utils/orderCalculations.scale-ladder.test.ts b/packages/perps-controller/tests/src/utils/orderCalculations.scale-ladder.test.ts index 6442c97770f..4c14a660773 100644 --- a/packages/perps-controller/tests/src/utils/orderCalculations.scale-ladder.test.ts +++ b/packages/perps-controller/tests/src/utils/orderCalculations.scale-ladder.test.ts @@ -279,12 +279,44 @@ describe('orderCalculations - scale ladder', () => { }), ).toBe('50001'); }); + + // Regression: formatHyperLiquidPrice used to miscount a sub-$1 price's + // significant figures and re-round it back down onto a coarser grid than + // getPriceTick (used two lines above to compute `improved`) had just + // produced. A post-only buy chase meant to rest one tick above the best + // bid came back AT the best bid instead — resting behind the whole + // queue — and a sell chase came back BELOW the best bid, which the venue + // treats as crossing the book and rejects for a post-only (ALO) order. + it('rests strictly inside the spread for a sub-$1 book (HMSTR-shaped)', () => { + const bestBid = 0.000171; + const bestAsk = 0.000175; + + const buyChase = computeChaseQuotePrice({ + bestBid, + bestAsk, + isBuy: true, + szDecimals: 0, + }); + expect(buyChase).toBe('0.000172'); + expect(Number(buyChase)).toBeGreaterThan(bestBid); + + const sellChase = computeChaseQuotePrice({ + bestBid, + bestAsk, + isBuy: false, + szDecimals: 0, + }); + expect(sellChase).toBe('0.000174'); + expect(Number(sellChase)).toBeLessThan(bestAsk); + expect(Number(sellChase)).toBeGreaterThan(bestBid); + }); }); describe('getPriceTick', () => { it.each([ ['decimal-bound at a low price', 12, 4, 0.01], ['significant-figure-bound at a high price', 50000, 3, 1], + ['significant-figure-bound at a sub-$1 price', 0.000171, 0, 0.000001], ])('is %s', (_label, price, szDecimals, expected) => { expect(getPriceTick({ price, szDecimals })).toBeCloseTo(expected, 10); }); diff --git a/packages/perps-controller/tests/src/utils/significantFigures.test.ts b/packages/perps-controller/tests/src/utils/significantFigures.test.ts index b9bac0cb973..9eb24952b05 100644 --- a/packages/perps-controller/tests/src/utils/significantFigures.test.ts +++ b/packages/perps-controller/tests/src/utils/significantFigures.test.ts @@ -11,7 +11,13 @@ describe('significantFigures utilities', () => { ['0', 0], ['not-a-number', 0], ['$1,230.4500', 6], - ['0.001234', 6], + // Sub-$1 values: leading zeros before the first nonzero digit are not + // significant, whether they sit in the integer part or the fraction. + // 0.001234 is HyperLiquid's own canonical example of a valid perp + // price (4 significant figures) — see https://hyperliquid.gitbook.io. + ['0.001234', 4], + ['0.000463', 3], + ['0.000171', 3], ['1000', 1], ['-12.340', 4], ])('counts %s as %s', (input, expected) => { @@ -30,6 +36,14 @@ describe('significantFigures utilities', () => { expect(hasExceededSignificantFigures('123.456', 5)).toBe(true); expect(hasExceededSignificantFigures('123.45', 5)).toBe(false); }); + + it('does not flag a valid sub-$1 price as exceeding the limit', () => { + // Regression: countSignificantFigures used to inflate 0.001234's 4 real + // significant figures to 6 by counting the leading fractional zeros, + // which made this documented-valid HyperLiquid price look invalid. + expect(hasExceededSignificantFigures('0.001234', 5)).toBe(false); + expect(hasExceededSignificantFigures('0.00123456', 5)).toBe(true); + }); }); describe('roundToSignificantFigures', () => { @@ -44,5 +58,19 @@ describe('significantFigures utilities', () => { expect(roundToSignificantFigures('123.4', 5)).toBe('123.4'); expect(roundToSignificantFigures('12345.67', 3)).toBe('12346'); }); + + it('leaves a sub-$1 price within the significant-figure budget untouched', () => { + // Regression: these used to be misclassified as 6 significant figures + // and rounded down to 5 decimal places (0.00046/0.00017), destroying a + // real digit of precision on a price that was already valid. + expect(roundToSignificantFigures('0.001234', 5)).toBe('0.001234'); + expect(roundToSignificantFigures('0.000463', 5)).toBe('0.000463'); + expect(roundToSignificantFigures('0.000171', 5)).toBe('0.000171'); + }); + + it('rounds a sub-$1 price that genuinely exceeds the budget', () => { + expect(roundToSignificantFigures('0.00123456', 5)).toBe('0.0012346'); + expect(roundToSignificantFigures('0.00012345', 4)).toBe('0.0001234'); + }); }); });