Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 23 additions & 23 deletions packages/perps-controller/src/utils/significantFigures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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();
};
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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', () => {
Expand All @@ -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');
});
});
});