Skip to content

fix(perps): harden HyperLiquid cancel readiness and map account-mode errors - #9750

Merged
abretonc7s merged 5 commits into
mainfrom
TAT-3633-fix-core-harden-hyperliquid-cancel
Aug 3, 2026
Merged

fix(perps): harden HyperLiquid cancel readiness and map account-mode errors#9750
abretonc7s merged 5 commits into
mainfrom
TAT-3633-fix-core-harden-hyperliquid-cancel

Conversation

@abretonc7s

@abretonc7s abretonc7s commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Explanation

After a service-worker restart, HyperLiquidProvider.cancelOrder validated the coin against an empty prefetch asset map before #ensureReadyForTrading() could rebuild it, causing valid cancels to fail with ORDER_UNKNOWN_COIN. This change hydrates trading readiness first and routes cancel failures through #mapError.

HyperLiquid also returns raw "multi-sig required" and "invalid nonce" strings on exchange writes. These are now mapped to EXCHANGE_MULTI_SIG_REQUIRED and EXCHANGE_INVALID_NONCE, with cached abstraction mode attached to account-mode error log context for Sentry triage.

References

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

Client follow-up: Mobile and Extension should add translation entries for EXCHANGE_MULTI_SIG_REQUIRED and EXCHANGE_INVALID_NONCE (widens PerpsErrorCode union, same pattern as EXCHANGE_ACCOUNT_NOT_FOUND).

Harness follow-up: Land metamask.perps.cancel_order in metamask-harness (core adapter). Cold-start E2E proof documented in task artifacts (recipe-cancel-cold.json + before/after traces).


Note

Medium Risk
Breaking change widens PerpsErrorCode and touches trading error paths (place/cancel); behavior is well-tested but clients must add translations for the new codes.

Overview
Fixes cold-start cancel failures by calling #ensureReady() before coin validation in cancelOrder, so an empty prefetch asset map is rebuilt and valid markets no longer get ORDER_UNKNOWN_COIN after a service-worker restart.

Adds breaking EXCHANGE_MULTI_SIG_REQUIRED and EXCHANGE_INVALID_NONCE to PERPS_ERROR_CODES, mapping HyperLiquid "multi-sig required" and "invalid nonce" on placeOrder, cancelOrder, and cancelOrders. cancelOrder now maps per-status rejections that do not throw (not only the generic 'Order cancellation failed'). Account-mode failures attach cached abstraction mode to error log context via new getCachedAbstractionMode on HyperLiquidSubscriptionService.

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

@abretonc7s

abretonc7s commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Automated fix-bug run — TAT-3633

Metric Value
Run c4043259
Duration ?
Model cursor/composer-2.5-fast
Nudges 0
Worker report

Explanation

After a service-worker restart, HyperLiquidProvider.cancelOrder validated the coin against an empty prefetch asset map before #ensureReadyForTrading() could rebuild it, causing valid cancels to fail with ORDER_UNKNOWN_COIN. This change hydrates trading readiness first and routes cancel failures through #mapError.

HyperLiquid also returns raw "multi-sig required" and "invalid nonce" strings on exchange writes. These are now mapped to EXCHANGE_MULTI_SIG_REQUIRED and EXCHANGE_INVALID_NONCE, with cached abstraction mode attached to account-mode error log context for Sentry triage.

References

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

Client follow-up: Mobile and Extension should add translation entries for EXCHANGE_MULTI_SIG_REQUIRED and EXCHANGE_INVALID_NONCE (widens PerpsErrorCode union, same pattern as EXCHANGE_ACCOUNT_NOT_FOUND).

Harness follow-up: Land metamask.perps.cancel_order in metamask-harness (core adapter). Cold-start E2E proof documented in task artifacts (recipe-cancel-cold.json + before/after traces).

@abretonc7s abretonc7s changed the title chore: prepare farmslot publication pkg-c4043259-msaeoh7c fix(perps): harden HyperLiquid cancel readiness and map account-mode errors Aug 1, 2026
@abretonc7s
abretonc7s marked this pull request as ready for review August 1, 2026 13:28
@abretonc7s
abretonc7s requested review from a team as code owners August 1, 2026 13:28
Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts Outdated
Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts
abretonc7s added a commit that referenced this pull request Aug 1, 2026
Link changelog entries to #9750 and route placeOrder error logging through

Co-authored-by: Cursor <cursoragent@cursor.com>
#getTradingErrorContext so getUserAddressWithDefault is awaited correctly.
@abretonc7s
abretonc7s enabled auto-merge August 1, 2026 13:58

@aganglada aganglada 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.

Review summary

Solid fix for the cold-start ORDER_UNKNOWN_COIN cancel bug and good follow-through on the Bugbot findings (#ensureReady() vs #ensureReadyForTrading(), unified #getTradingErrorContext). Tests and CI look strong.

Requesting changes before approval: the single-cancel success path still drops HyperLiquid's per-status error and returns a generic string, so the new EXCHANGE_MULTI_SIG_REQUIRED / EXCHANGE_INVALID_NONCE mappings never apply when HL returns a non-thrown status rejection — which is the common cancel failure shape.

What looks good

  • cancelOrder now hydrates the asset map via #ensureReady() before coin validation, matching placeOrder / editOrder and avoiding signature prompts on invalid symbols.
  • Catch-path cancel failures correctly route through #mapError and #getTradingErrorContext.
  • New error codes and HL string mappings in #errorMappings are well covered by tests (for thrown rejections).

Before merge (blocking)

  1. Parse and map non-thrown cancel status errors — see inline comment.

Non-blocking follow-ups

  • Mark the new codes as BREAKING in the changelog (same union-widening pattern as EXCHANGE_ACCOUNT_NOT_FOUND).
  • Consider parity for cancelOrders catch path (#mapError + #getTradingErrorContext).
  • Extension/Mobile will need translation entries before bumping @metamask/perps-controller (called out in the PR description).

Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts Outdated
Comment thread packages/perps-controller/CHANGELOG.md Outdated
abretonc7s and others added 4 commits August 3, 2026 19:30
…rors

Reorder cancelOrder to rebuild the asset map before coin validation so
service-worker restarts no longer surface ORDER_UNKNOWN_COIN for valid
markets, and map HyperLiquid multi-sig/nonce rejections to stable codes.

Co-authored-by: Cursor <cursoragent@cursor.com>
Link changelog entries to #9750 and route placeOrder error logging through

Co-authored-by: Cursor <cursoragent@cursor.com>
#getTradingErrorContext so getUserAddressWithDefault is awaited correctly.
Run #ensureReady() before coin validation and defer #ensureReadyForTrading()
until after, matching placeOrder/editOrder. Avoids signature prompts on
invalid cancels while preserving cold-start self-heal for valid markets.

Co-authored-by: Cursor <cursoragent@cursor.com>
Single cancelOrder only checked for a 'success' status and returned a
hard-coded 'Order cancellation failed' string, so the EXCHANGE_MULTI_SIG_REQUIRED
and EXCHANGE_INVALID_NONCE mappings never applied to the shape HyperLiquid
actually returns most of the time: a resolved status object carrying the raw
exchange error. Read that error and pipe it through #mapError, as cancelOrders
already does for its per-status errors.

Give the cancelOrders catch path the same treatment (#mapError plus
#getTradingErrorContext), and label the new error codes BREAKING in the
changelog since they widen the exported PerpsErrorCode union.
@abretonc7s
abretonc7s force-pushed the TAT-3633-fix-core-harden-hyperliquid-cancel branch from b9c8968 to b3bce24 Compare August 3, 2026 11:44

@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 b3bce24. Configure here.

Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts
cancelOrders returned HyperLiquid's raw per-status error strings while
cancelOrder maps that same non-thrown shape, so a batch cancel could
surface an unmapped "multi-sig required" that single cancel standardizes.
Route each per-status rejection through #mapError, keeping undefined when
a status carries no error text.
@abretonc7s

Copy link
Copy Markdown
Contributor Author

Automated pr-complete run — #9750

Metric Value
Run ee0a2167
Duration ?
Model claude/opus
Nudges 0
Worker report

PR 9750 — Comments Report

PR: #9750
Branch: TAT-3633-fix-core-harden-hyperliquid-cancel
Review decision at fetch time: CHANGES_REQUESTED (aganglada)

Integration

  • git rebase origin/main → clean (status rebased), new base d62554e43.
  • Rebase range touched yarn.lock + package manifests → yarn install --immutable run, completed with expected peer warnings only.

Fetched comments

# Source ID Author Where Summary Triage Action
1 review (inline) 3695703983 cursor[bot] HyperLiquidProvider.ts (L3496-3510 / L2404-2428) #handleOrderError used sync getUserAddress() → placeOrder Sentry context incomplete ALREADY FIXED (thread resolved) none — addressed in commit 814ec9535/prior; thread already isResolved: true
2 review (inline) 3695703986 cursor[bot] HyperLiquidProvider.ts:4107 cancelOrder called #ensureReadyForTrading() before coin validation → signature prompts on invalid cancels ALREADY FIXED (thread resolved) none — fixed in 154418e54 (#ensureReady() only); thread already isResolved: true
3 review (inline) 3702769256 aganglada HyperLiquidProvider.ts:4138 Blocking — single cancelOrder drops HL per-status error object and returns hard-coded 'Order cancellation failed', so new #mapError mappings never apply for non-thrown status rejections REAL Parse status object error, pipe through #mapError; add test with non-success status object
4 review (inline) 3702769268 aganglada CHANGELOG.md:34 Nit — label new error codes entry BREAKING (union widening, same as EXCHANGE_ACCOUNT_NOT_FOUND) REAL Relabel changelog entry + note exhaustive-Record consumers
5 issue (conversation) 5151629581 abretonc7s conversation Automated run report (PR author's own summary) INFORMATIONAL none

Review-summary non-blocking follow-ups from aganglada:

  • (a) changelog BREAKING label → covered by Rewrite and test KeyringController #4.
  • (b) "Consider parity for cancelOrders catch path (#mapError + #getTradingErrorContext)" → treated as REAL (small, in-scope, same file/behavior family).
  • (c) Extension/Mobile translation entries before version bump → out of scope for this repo/PR (already called out in PR description).

Fixes applied

  1. cancelOrder non-thrown status mapping (HyperLiquidProvider.ts:4133-4155) — replaced the success ? undefined : 'Order cancellation failed' shape with an early success return plus a status-object read. The raw exchange string is piped through #mapError, so EXCHANGE_MULTI_SIG_REQUIRED / EXCHANGE_INVALID_NONCE now apply to HL's common (non-thrown) rejection shape. Falls back to 'Order cancellation failed' when the status carries no error text, preserving the existing behavior for statuses: ['failed'].
    • The SDK types every cancel status as the literal 'success', so reading .error needs the same cast cancelOrders already uses at HyperLiquidProvider.ts:4231. An in-operator narrowing was rejected by both no-restricted-syntax (ESLint) and TS2339 (Property 'error' does not exist on type 'never') — caught by the build, not by lint or tests.
    • The non-thrown path deliberately does not call logger.error. The most common status rejection is the benign "Order was never placed, already canceled, or filled", and logging it would flood Sentry; the thrown path (which carries the account-mode abstraction context) still logs.
  2. cancelOrders catch-path parity (HyperLiquidProvider.ts:4238-4258) — routes batch failures through #mapError + #getTradingErrorContext, matching cancelOrder. The non-Error throw fallback to BATCH_CANCEL_FAILED is preserved.
  3. Changelog — the EXCHANGE_MULTI_SIG_REQUIRED / EXCHANGE_INVALID_NONCE Added entry is now **BREAKING:** with union-widening and migration sub-bullets mirroring the EXCHANGE_ACCOUNT_NOT_FOUND entry. The Fixed entry documents the new cancelOrder / cancelOrders error shapes.
  4. Tests — 4 new cases: 2 parameterized non-thrown status rejections mapping to the new codes, 1 asserting an unmapped status error string is preserved verbatim, 1 asserting cancelOrders maps recognized batch rejections.

Downstream compatibility assessment (Mobile / Extension)

Touched published surface: packages/perps-controller/src/providers/HyperLiquidProvider.ts (behavior only) and CHANGELOG.md. No changes to exported types, messenger actions/state, or package manifests in this follow-up; PerpsErrorCode already gained the two codes earlier in the PR.

Risk items for consumers:

  • CancelOrderResult.error — for a HyperLiquid rejection matching "multi-sig required" / "invalid nonce", this now carries a PerpsErrorCode rather than 'Order cancellation failed'. For any other status rejection it carries HL's raw per-status string instead of the generic constant. A client that string-matched 'Order cancellation failed' would need updating; the field is typed string | undefined and is already documented as a translation key elsewhere, so this is the intended direction.
  • CancelOrdersResult.results[].error — recognized thrown rejections now carry a PerpsErrorCode rather than the raw exchange message. Same reasoning; the batch success path (per-status error strings) is unchanged.
  • Union widening — unchanged from the PR's existing state, now correctly labelled BREAKING in the changelog with migration steps for both clients' translatePerpsError / translate-perps-error maps.

No mainnet targeting introduced; no e2e/ scripts touched.

Validation

Command Result
yarn install --immutable (post-rebase, manifests changed) PASS — peer warnings only (YN0002/YN0086, expected)
jest tests/src/providers/ (13 suites, incl. both edited test files) PASS — 575 passed, 36 skipped, 0 failed
eslint on the 3 changed source/test files PASS — 0 problems (after fixing a no-restricted-syntax in-operator violation)
yarn workspace @metamask/perps-controller run changelog:validate PASS
yarn build (root, full monorepo) PASS (exit 0)

Note on the broad build: the bounded yarn workspace @metamask/perps-controller run build / build:all both fail in this checkout with TS6305 … has not been built from source file because dependency dist/ dirs are absent. Confirmed pre-existing by stashing the diff and re-running (baseline exit 1, identical errors). The root yarn build was therefore run once to get a real type signal — it surfaced the genuine TS2339 above, and passes cleanly after the fix. Not looped.

Recipe / headless validation (step 10)

Perps read smoke — PASS. The PR touches packages/perps-controller, so the required post-merge regression gate ran against this checkout (rebased onto origin/main + review fixes):

mm-harness run temp/recipe/harness/core/runner/recipes/core/perps/read-markets.recipe.json \
  --adapter core --target /Users/deeeed/dev/metamask/core-1 \
  --artifacts-dir temp/tasks/fix/9750-0803-192846/artifacts/perps-smoke --json
→ exit 0, status "pass"

Evidence: artifacts/perps-smoke/{summary.json,trace.json,report.md,artifact-manifest.json}.

Note: the checklist's --project-root flag does not exist on this runner build (CLI_UNKNOWN_OPTION); the equivalent is --target, which is what was used.

Inherited recipe re-run — SKIPPED (blocked, not a failure). artifacts/recipe.json (TAT-3633 cancel lifecycle) fails static validation before executing anything:

✗ recipe.action_not_declared_by_manifest workflow.nodes.cancel_order.action
  — Recipe action metamask.perps.cancel_order is not declared by the runner action manifest.

This is the harness gap the PR body already tracks as a follow-up ("Land metamask.perps.cancel_order in metamask-harness (core adapter)"), not a regression from the rebase or from these review fixes — the recipe never reaches execution, and the wallet fixture is present (temp/recipe/runtime/wallet-fixture.json), so the block is purely the missing action. Evidence: artifacts/recipe-rerun/plan-blocked.txt (exit 5).

The recipe is also a write-path proof (it places a real signed BTC limit on HyperLiquid testnet); the single-cancel behavior it would exercise is covered by the Jest cases added for this review instead.

Confirmed against the runner's own manifest — the available core perps actions are start_state, teardown_state, place_order, edit_order, close_orders, close_positions, ensure_orders, ensure_positions, assert_orders, assert_positions, update_position_tpsl, read_account, read_orders, read_positions. cancel_order (the singular path this recipe was authored to exercise, as opposed to batch close_orders) is genuinely absent.

Artifact-contract consequence. The terminal contract's whenPresent rule requires a artifacts/recipe-run/ package whose recipe.json canonically matches artifacts/recipe.json whenever the latter exists. Since the recipe cannot execute, that package cannot be produced honestly. Rather than substitute a different recipe's run (which the checker would reject as a mismatch, and which would misrepresent what was proven), the unrunnable document was moved out of the contract path to artifacts/recipe-rerun/recipe-not-run.json (byte-identical to the preserved inputs/inherited/recipe.json) and artifacts/recipe-decision.json was written with required: false, recording the reason, the actions checked, and — explicitly — what this run did and did not prove headlessly.

Commit and push

  • Commit: b3bce2491fix(perps): map non-thrown HyperLiquid cancel status errors
  • Pushed with --force-with-lease (branch was rebased in step 3): b9c896810...b3bce2491origin/TAT-3633-fix-core-harden-hyperliquid-cancel

Files changed in this follow-up commit:

File Change
packages/perps-controller/src/providers/HyperLiquidProvider.ts cancelOrder non-thrown status mapping; cancelOrders catch-path #mapError + #getTradingErrorContext parity
packages/perps-controller/CHANGELOG.md New error codes labelled BREAKING with migration bullets; Fixed entry documents the new cancel error shapes
packages/perps-controller/tests/src/providers/HyperLiquidProvider.error-handling.test.ts +3 cases (2 mapped non-thrown status rejections, 1 unmapped status error preserved)
packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +1 case (cancelOrders maps recognized batch rejections)

Replies and thread resolution

Comment ID Triage Reply Thread resolved
3702769256 (aganglada, blocking) REAL discussion_r3703575587 YES (PRRT_kwDOCBB0Cc6V69mwisResolved: true)
3702769268 (aganglada, nit) REAL discussion_r3703576408 YES (PRRT_kwDOCBB0Cc6V69m4isResolved: true)
3695703983 (cursor[bot]) already fixed earlier in PR none — thread was already resolved before this run already resolved
3695703986 (cursor[bot]) already fixed earlier in PR none — thread was already resolved before this run already resolved
5151629581 (abretonc7s, conversation) informational (author's own run report) none required n/a

Notes for the reviewer

  • reviewDecision was CHANGES_REQUESTED; the single blocking item is addressed, so a re-review is needed to clear it.
  • Per the single-pass rule, comments posted by cursor[bot]/Bugbot in response to this push were not fetched or acted on.

@aganglada aganglada 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.

Re-review summary

Verdict: Approve — all blocking items from the prior review are addressed. Good to merge once client translation PRs are lined up for the union-widening codes.


Blocking items — resolved

  1. Non-thrown cancel status mapping (b3bce24, 26e4313) — cancelOrder now early-returns on 'success' and otherwise reads the per-status error, pipes it through #mapError, and falls back to 'Order cancellation failed' only when no error text is present. Thrown and non-thrown paths are both covered by tests.

  2. cancelOrders parity — catch path now uses #mapError + #getTradingErrorContext; per-status rejections in the success response also go through #mapError, matching single cancel. Bugbot's batch-mapping finding is fixed.

  3. Changelog BREAKING label — new codes are documented with migration bullets mirroring EXCHANGE_ACCOUNT_NOT_FOUND.


What still looks good

  • Cold-start fix#ensureReady() before coin validation, #ensureReadyForTrading() deferred until after validation. Matches placeOrder / editOrder and avoids signature prompts on invalid symbols. Tests cover self-heal + unknown-coin rejection.
  • #getTradingErrorContext — unified async context for place/cancel paths; abstraction mode attached for account-mode exchange errors. #handleOrderError fix (awaited getUserAddressWithDefault) is in place.
  • Error mappingmulti-sig required / invalid nonce → stable codes with good test coverage (thrown + non-thrown cancel, batch cancel, placeOrder).
  • Sentry hygiene — non-thrown cancel path intentionally skips logger.error for benign status rejections like "already canceled"; thrown path still logs with abstraction context.

Non-blocking notes (no action required)

  • Client coordination — Mobile/Extension need EXCHANGE_MULTI_SIG_REQUIRED and EXCHANGE_INVALID_NONCE in their translate maps before bumping @metamask/perps-controller (already called out in PR description).
  • Harness gapmetamask.perps.cancel_order action still missing from harness; Jest coverage is a reasonable substitute until that's landed.
  • SDK typing — the { error?: string } cast on cancel statuses is pragmatic given HL's types; comment explains why and matches the existing cancelOrders pattern.

Test plan

  • Cancel-related unit tests pass locally (19 cases across error-handling + trading suites)
  • Cold-start cancel on Extension after SW restart (original TAT-3633 repro)
  • Confirm mapped codes render in Mobile/Extension translate helpers before release bump

@abretonc7s
abretonc7s added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit eddae25 Aug 3, 2026
45 checks passed
@abretonc7s
abretonc7s deleted the TAT-3633-fix-core-harden-hyperliquid-cancel branch August 3, 2026 14:21
abretonc7s added a commit that referenced this pull request Aug 3, 2026
Both the changelog entry and the isHyperLiquidMultiSigRequiredError JSDoc
asserted that HyperLiquid returns the unhyphenated "multisig required"
spelling. Only the hyphenated "Multi-sig required" has been observed, and
the mapping table added in #9750 carries that key alone.

Matching both spellings is unchanged and still correct; only the stated
justification was wrong. Documentation only, no behaviour change.

Refs TAT-3214
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