Skip to content

fix(perps): tighten Scale ladder contract - #10065

Merged
abretonc7s merged 4 commits into
mainfrom
fix/perps-scale-ladder-contract
Sep 2, 2026
Merged

fix(perps): tighten Scale ladder contract#10065
abretonc7s merged 4 commits into
mainfrom
fix/perps-scale-ladder-contract

Conversation

@geositta

@geositta geositta commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Explanation

The Scale price ladder API introduced in #10021 reused OrderCapabilitiesUnavailableReason, which exposed strategy_market_unsupported even though no ladder path can return that reason. This made exhaustive consumer handling include an impossible state. The original tests also verified fractional preview formatting and integer placement separately, but did not directly prove that fractional preview prices match submitted wire prices.

This PR:

  • Adds and exports ScalePriceLadderUnavailableReason, excluding the capability-only strategy_market_unsupported reason.
  • Uses the narrower reason type for PerpsScalePriceLadder and the controller's Scale-specific unavailable response helper.
  • Adds a fractional-price parity test that compares getScalePriceLadder output with the prices sent by Scale placement.
  • Updates the Unreleased changelog entry for the new public type.

Runtime behavior and dependencies are unchanged.

References

Validation

  • Focused HyperLiquid strategy-order suite: 288 tests passed
  • Full @metamask/perps-controller test suite
  • TypeScript validation
  • Scoped ESLint and formatting checks
  • Changelog validation
  • Diff checks

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Low Risk
Type-level contract tightening and tests only; placement and routing behavior are unchanged, with minor compile-time impact for exhaustive handling of ladder unavailable reasons.

Overview
Tightens the Scale price ladder public contract so unavailable results no longer reuse the broader order-capabilities reason union.

PerpsScalePriceLadder and PerpsController Scale-specific unavailable helpers now use ScalePriceLadderUnavailableReason and DirectProviderScalePriceLadderUnavailableReason, which drop the capability-only strategy_market_unsupported reason that ladder APIs cannot return. The new types are exported from the package entry; HyperLiquid’s internal capability lookup typing is aligned with the direct-provider ladder reason.

Adds a HyperLiquid strategy-order test that getScalePriceLadder preview prices for fractional bounds match the prices submitted on placeOrder for a scale ladder. Updates the Unreleased changelog to document the exported reason types.

Reviewed by Cursor Bugbot for commit caa9bfe. Bugbot is set up for automated code reviews on this repo. Configure here.

@geositta
geositta marked this pull request as ready for review September 1, 2026 23:35
@geositta
geositta requested review from a team as code owners September 1, 2026 23:35
@geositta
geositta deployed to default-branch September 1, 2026 23:35 — with GitHub Actions Active

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e841a8b. Configure here.

@abretonc7s

Copy link
Copy Markdown
Contributor

Automated Review — PR #10065

BETA — Automated review from the farmslot pipeline.

Recommendation REQUEST_CHANGES
Reviewed commit e841a8b0a7f09694cb463095c1faa0d0630180eb
Tier standard
Recipe N/A

Summary

The type-narrowing half of this PR is correct and I verified it end to end. The
new parity test does not test what it claims: it previews the ladder for one
market and places the order on a different one. One-line fix.

Full review details

Blocking findings

1. Parity test previews BTC but places on ETH

packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts:3038-3053

const preview = await provider.getScalePriceLadder({
  symbol: 'BTC',          // <- ladder computed with BTC szDecimals
  ...
});
await provider.placeOrder({
  ...baseOrder,           // <- baseOrder.symbol === 'ETH' (line 687)
  orderType: 'scale',
  ...
});

The fixture at lines 213-214 gives BTC szDecimals: 3 and ETH
szDecimals: 4. normalizeHyperLiquidScalePriceLadder
(src/providers/HyperLiquidProvider.ts:1251-1265) formats every rung through
formatHyperLiquidPrice (src/utils/hyperLiquidAdapter.ts:561-585), whose
decimal cap is MaxPriceDecimals(6) - szDecimals. The two symbols therefore
follow different rounding paths, so the assertion compares a BTC ladder against
ETH wire prices.

It passes today only by coincidence: for 1234.567 → 1234.767 the 5-significant-
figure cap dominates the decimal cap and both paths land on
['1234.6','1234.7','1234.8']. Change the bounds and the paths diverge — e.g.
12.34567 → 12.34967 yields ['12.346','12.348','12.35'] at szDecimals: 3
but collapses to ['12.35','12.35','12.35'] at szDecimals: 4 (which would
additionally trip ORDER_SCALE_RANGE_INVALID on the duplicate check). The PR
description states the test proves "fractional preview prices match the prices
submitted on Scale placeOrder"; as written it proves that only for a market the
order was never placed on.

Fix: use symbol: baseOrder.symbol in the preview call (or set
symbol: 'BTC' on the placeOrder params). Worth re-running afterwards, since
ETH's szDecimals: 4 exercises a stricter decimal cap than BTC's.

Non-blocking observations

  1. packages/perps-controller/src/providers/HyperLiquidProvider.ts:1268-1278
    HyperLiquidOrderCapabilityMarket inlines
    Exclude<DirectProviderOrderCapabilitiesUnavailableReason, 'strategy_market_unsupported'>
    rather than reusing a named alias. The exclusion now appears twice in the
    package (here and ScalePriceLadderUnavailableReason), so a future reason
    added to only one place will drift. A shared
    DirectProviderScalePriceLadderUnavailableReason alias in types/index.ts
    would keep them in step. Cosmetic.
  2. The parity test asserts equality between two computed outputs but never
    asserts the concrete values, so a shared regression in
    computeScalePriceLadder / formatHyperLiquidPrice would keep it green.
    Pinning the expected array (as the sibling integer test at line 3025 does)
    would close that hole.

Correctness of the narrowing (verified)

The claim that strategy_market_unsupported is unreachable on every ladder path
holds. Exhaustive enumeration of the reasons any ladder code path can emit:

Source Reasons emitted
PerpsController.getScalePriceLadder (src/PerpsController.ts:2944-2984) provider_unavailable, provider_not_routable, not_implemented
AggregatedPerpsProvider.getScalePriceLadder (src/providers/AggregatedPerpsProvider.ts:336-361) provider_not_found, not_implemented, provider_not_routable
HyperLiquidProvider.getScalePriceLadder (src/providers/HyperLiquidProvider.ts:1659-1685) provider_not_routable + whatever #getOrderCapabilityMarket returns
HyperLiquidProvider.#getOrderCapabilityMarket (src/providers/HyperLiquidProvider.ts:1694-1743) invalid_symbol, provider_unavailable, market_not_found

None produce strategy_market_unsupported. The narrowed
HyperLiquidOrderCapabilityMarket unavailable variant stays assignable to
DirectProviderOrderCapabilities, so getOrderCapabilities
(src/providers/HyperLiquidProvider.ts:1645-1649) still type-checks in its
unavailable branch. HyperLiquid never emits that reason at all — it advertises
HYPERLIQUID_ORDER_CAPABILITIES uniformly once the market resolves.

Export surface is additive: ScalePriceLadderUnavailableReason added at
src/index.ts:297; nothing removed or renamed. No runtime behavior change.

Downstream compatibility (Mobile / Extension)

No downstream impact.

  • The whole getScalePriceLadder API (feat(perps): add provider-routed Scale price normalization #10021) sits in ## [Unreleased] above
    ## [15.0.0]. Nothing published carries the wide reason type, so narrowing
    it before release cannot break a released consumer.
  • Local clones scanned (~/dev/metamask/metamask-mobile-{1,2},
    ~/dev/metamask/metamask-extension-{1,2}; the ../metamask-mobile /
    ../metamask-extension paths in the checklist do not exist on this host).
    Zero references to getScalePriceLadder, PerpsScalePriceLadder, or
    ScalePriceLadderUnavailableReason.
  • The only downstream strategy_market_unsupported hits are Mobile test
    fixtures on the order-capabilities path, which this PR does not touch:
    • app/components/UI/Perps/hooks/usePerpsProvider.test.ts:326 (mocks
      getOrderCapabilities)
    • app/components/UI/Perps/Views/PerpsProMarketView/PerpsProMarketView.view.test.tsx:93
      (mocks getOrderCapabilities)
      PerpsOrderCapabilities.reason keeps the full
      OrderCapabilitiesUnavailableReason union, so both stay valid.
  • Declared deps: Mobile @metamask/perps-controller@^15.0.0, Extension
    ^12.0.0. Package version on this branch is still 15.0.0 (unreleased).

Semver: additive type export in an unreleased section → minor on next release.
Narrowing an unreleased type is not a breaking change and correctly carries no
**BREAKING:** prefix.

Validation

Command Result
yarn workspace @metamask/perps-controller run jest --no-coverage tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts PASS — 288/288 tests, 1 suite, 1.672 s
yarn workspace @metamask/perps-controller run changelog:validate PASS — no output
yarn eslint <5 changed source/test files> PASS — no violations
yarn build (monorepo root) PASS — exit 0, ✔ Project built successfully
grep ScalePriceLadderUnavailableReason packages/perps-controller/dist/**/*.d.cts Present at dist/index.d.cts:35 and dist/types/index.d.cts:1319,1328

Notes on the build step: yarn workspace @metamask/perps-controller run build
and build:all both fail standalone (ts-bridge project references need sibling
dist/ outputs), which is the documented environment property, not a defect in
this PR. Root yarn build is the correct command and succeeds.

Divergence-of-szDecimals evidence for finding 1 was produced by replaying the
formatHyperLiquidPrice / roundToSignificantFigures logic
(src/utils/hyperLiquidAdapter.ts:561, src/utils/significantFigures.ts:70)
against both fixture values in a scratch script; no repo files were modified.

Per the review contract this was a static-code pass: no project start, no CDP,
no recipes, no screenshots, no runtime QA. No Farmslot evidence was linked to
this PR, so there was none to audit.

@abretonc7s abretonc7s left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review — see comment above for full details.

geositta and others added 4 commits September 1, 2026 22:02
Narrow unavailable reasons to reachable states and verify fractional previews match submitted prices.

Co-authored-by: Cursor <cursoragent@cursor.com>
Match the shared market lookup type to the reasons it can actually return so Scale ladder declaration builds remain type-safe.

Co-authored-by: Cursor <cursoragent@cursor.com>
Associate the Unreleased Scale normalization entry with the current pull request so changelog CI can verify coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use one precision-sensitive market for preview and placement, and centralize the direct-provider unavailable reason type.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abretonc7s

Copy link
Copy Markdown
Contributor

Automated Review — PR #10065

BETA — Automated review from the farmslot pipeline.

Recommendation APPROVE
Reviewed commit b19ebcad433af67c717f53a90cf7903b30e73008
Tier standard
Recipe N/A

Summary

The one blocking finding from generation 1 is fixed, and both non-blocking
observations were picked up as well. I re-derived the formatting math rather than
trusting the green test, and the new pinned expectation is genuinely
szDecimals-sensitive.

Full review details

Prior findings — revalidation

1. Parity test previews BTC but places on ETHRESOLVED

packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts:3031-3067

The preview now destructures const { symbol } = baseOrder and passes it to both
getScalePriceLadder and placeOrder, so preview and placement resolve the same
market (ETH, szDecimals: 4). Bounds moved from 1234.567 → 1234.767 to
12.341 → 12.381, and the ladder is pinned before the parity assertion:

expect(preview.prices).toStrictEqual(['12.34', '12.36', '12.38']);

Verified this is symbol-sensitive rather than another coincidence. In
formatHyperLiquidPrice (src/utils/hyperLiquidAdapter.ts:561-585) the decimal
cap is MaxPriceDecimals(6) - szDecimals, and the 5-significant-figure cap only
applies afterwards:

Symbol szDecimals Decimal cap Ladder for 12.341 → 12.381, count 3
ETH (used) 4 2 ['12.34', '12.36', '12.38']
BTC (old bug) 3 3 ['12.341', '12.361', '12.381']

Five significant figures is never reached at either cap here, so the decimal cap
decides the output. Reintroducing the symbol mismatch now fails the pinned
assertion instead of passing by convergence. Observation 2 from the prior review
(no concrete values pinned) is closed by the same line.

Observation 1 — duplicated Exclude<>RESOLVED

src/types/index.ts:1625-1634 introduces the named alias and re-expresses the
routed type in terms of it:

export type DirectProviderScalePriceLadderUnavailableReason = Exclude<
  DirectProviderOrderCapabilitiesUnavailableReason,
  'strategy_market_unsupported'
>;
export type ScalePriceLadderUnavailableReason =
  | DirectProviderScalePriceLadderUnavailableReason
  | RoutedOrderCapabilitiesUnavailableReason;

HyperLiquidProvider.ts:1268-1276 consumes the alias, so the exclusion is stated
once. The new ScalePriceLadderUnavailableReason is structurally identical to the
generation-1 definition: OrderCapabilitiesUnavailableReason is exactly
DirectProvider… | Routed… (src/types/index.ts:1599-1601) and
'strategy_market_unsupported' lives only in the direct half
(src/types/index.ts:1588-1592), so distributing the Exclude<> changes nothing
about the resolved union. No consumer-visible drift from generation 1.

New findings on the incremental delta

None blocking.

Non-blocking observations

  1. src/providers/HyperLiquidProvider.ts:1269-1276 — the unavailable variant of
    HyperLiquidOrderCapabilityMarket is now hand-written (status / providerId
    / reason) instead of derived via
    Extract<DirectProviderOrderCapabilities, { status: 'unavailable' }>. That was
    the price of narrowing reason, but it means a future field added to the
    shared unavailable shape will not reach this type. Omit<Extract<…>, 'reason'> & { reason: DirectProviderScalePriceLadderUnavailableReason } would keep the
    derivation; readability is arguably worse. Cosmetic either way.
  2. The test's new currentPrice: 12.36 / usdAmount: '300' overrides are load
    bearing — baseOrder is priced at 3000, and without them the per-rung
    notional falls under the minimum order value at the new bounds. No comment
    explains that, so a later edit could strip them as noise. One trailing comment
    would protect the fixture.

Correctness (re-verified on the current head)

The generation-1 conclusion still holds: no ladder code path can emit
strategy_market_unsupported. Reasons reachable per source —
PerpsController.getScalePriceLadder (src/PerpsController.ts:2944-2984):
provider_unavailable, provider_not_routable, not_implemented;
AggregatedPerpsProvider.getScalePriceLadder
(src/providers/AggregatedPerpsProvider.ts:336-361): provider_not_found,
not_implemented, provider_not_routable;
HyperLiquidProvider.getScalePriceLadder (src/providers/HyperLiquidProvider.ts:1656-1682):
provider_not_routable plus whatever #getOrderCapabilityMarket
(:1691-1740) returns — invalid_symbol, provider_unavailable,
market_not_found. None is the excluded reason.

getOrderCapabilities (src/providers/HyperLiquidProvider.ts:1642-1647) still
returns the narrowed unavailable variant in its DirectProviderOrderCapabilities
slot; the root build type-checks it, confirming assignability survived the
inlining.

PerpsController.ts:111 keeps importing OrderCapabilitiesUnavailableReason
still used at :3003 by #getUnavailableOrderCapabilities, so no orphaned
import. ESLint agrees.

Export surface is additive only: DirectProviderScalePriceLadderUnavailableReason
and ScalePriceLadderUnavailableReason added at src/index.ts:297-298. Nothing
removed or renamed. No runtime behavior change anywhere in the diff.

Downstream compatibility (Mobile / Extension)

No downstream impact.

  • The entire getScalePriceLadder API (feat(perps): add provider-routed Scale price normalization #10021) is still under ## [Unreleased]
    above ## [15.0.0], and the package version on this branch is 15.0.0. Nothing
    published carries the wide reason type, so narrowing it pre-release cannot
    break a released consumer.
  • Clones scanned: ../metamask-mobile-{1,2}, ../metamask-extension-{1,2}. The
    literal ../metamask-mobile / ../metamask-extension paths in the checklist do
    not exist on this host. Zero references to getScalePriceLadder,
    PerpsScalePriceLadder, ScalePriceLadderUnavailableReason,
    DirectProviderScalePriceLadderUnavailableReason, or
    OrderCapabilitiesUnavailableReason in any of them.
  • The only downstream strategy_market_unsupported hits are two Mobile test
    fixtures on the untouched order-capabilities path:
    app/components/UI/Perps/hooks/usePerpsProvider.test.ts:326 and
    app/components/UI/Perps/Views/PerpsProMarketView/PerpsProMarketView.view.test.tsx:93,
    both mocking getOrderCapabilities. PerpsOrderCapabilities.reason keeps the
    full OrderCapabilitiesUnavailableReason union, so both stay valid.
  • Semver: two additive type exports in an unreleased section → minor on next
    release. Narrowing an unreleased type is not breaking and correctly carries no
    **BREAKING:** prefix. The changelog entry was updated to name both new types.

Validation

Command Result
yarn workspace @metamask/perps-controller run jest --no-coverage tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts -t 'submits the provider preview prices for fractional bounds' PASS — 1 passed, 287 skipped
yarn workspace @metamask/perps-controller run jest --no-coverage tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts PASS — 288/288, 1.326 s
yarn workspace @metamask/perps-controller run changelog:validate PASS — exit 0, no output
yarn eslint <5 changed source/test files> PASS — exit 0, no violations
yarn build (monorepo root) PASS — ✔ Project built successfully
grep DirectProviderScalePriceLadderUnavailableReason packages/perps-controller/dist/**/*.d.{cts,mts} Present at dist/index.d.cts:35, dist/index.d.mts:35, dist/types/index.d.cts:1319,1321

Root yarn build was run because the delta adds a public type export; per-package
build / build:all cannot run standalone here (ts-bridge project references),
which is a documented property of this monorepo, not a defect in the PR.

Per the review contract this was a static-code pass: no project start, no CDP, no
recipes, no screenshots, no runtime QA. No Farmslot evidence is linked to this PR,
so there was none to audit.

abretonc7s
abretonc7s previously approved these changes Sep 2, 2026

@abretonc7s abretonc7s left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review — see comment above for full details.

@abretonc7s
abretonc7s added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit b9f9c76 Sep 2, 2026
47 checks passed
@abretonc7s
abretonc7s deleted the fix/perps-scale-ladder-contract branch September 2, 2026 03:20
@abretonc7s abretonc7s mentioned this pull request Sep 2, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants