Repository navigation
feat(perps): perps-controller: make Hyperliquid deposit and withdrawal testable on testnet - #10991
Merged
Merged
Conversation
abretonc7s
marked this pull request as ready for review
October 22, 2026 07:08
abretonc7s
temporarily deployed
to
default-branch
October 22, 2026 07:08 — with
GitHub Actions
Inactive
…trum Sepolia bridge DepositService forced mainnet deposit routes, so a testnet session prepared an Arbitrum One USDC transfer to the mainnet bridge. Take routes for the provider's own network instead. Point the testnet USDC asset at USDC2 (0x1baAbB04529D43a73232B713C0FE471f7c7334d5), the token the Arbitrum Sepolia bridge credits and pays out, so testnet deposit and withdrawal routes name the right token. Refs: TAT-4204
abretonc7s
force-pushed
the
TAT-4204-feat-perps-controller-make-hyperliq
branch
from
October 22, 2026 08:08
a6d58de to
e86e862
Compare
abretonc7s
enabled auto-merge
October 22, 2026 08:47
aganglada
approved these changes
Oct 22, 2026
SnowingFox
pushed a commit
to SnowingFox/core-1
that referenced
this pull request
Oct 24, 2026
## Explanation Major release of `@metamask/perps-controller` (`21.0.0` → `22.0.0`). - **Breaking:** testnet deposit and withdrawal routes use Arbitrum Sepolia USDC2 (`0x1baAbB04529D43a73232B713C0FE471f7c7334d5`). `USDC_ARBITRUM_TESTNET_ADDRESS` and `HYPERLIQUID_ASSET_CONFIGS.usdc.testnet` change to that address, and `withdraw` rejects the previous testnet asset ID. `depositWithConfirmation` prepares an Arbitrum Sepolia transfer to the Hyperliquid testnet bridge. ([MetaMask#10991](MetaMask#10991)) - `TrackingData`, `TPSLTrackingData`, and `WithdrawParams` accept an optional `environmentType`. Events that already copy attribution include `environment_type` when it is set. ([MetaMask#10899](MetaMask#10899)) - New export `@metamask/perps-controller/providers/lighter`, plus optional `LighterCredentials.loadProvider`, so a web client can bundle Lighter itself. `switchProvider('lighter')` reports why the provider failed to load. A missing Lighter module is debug-logged instead of sent through `logger.error`. ([MetaMask#10996](MetaMask#10996)) - The `closePositions` summary event includes `order_type: 'market'`. ([MetaMask#11027](MetaMask#11027)) - Engines allow Node.js 26. ([MetaMask#10981](MetaMask#10981)) ## References - [MetaMask#10991](MetaMask#10991) - [MetaMask#10899](MetaMask#10899) - [MetaMask#10996](MetaMask#10996) - [MetaMask#11027](MetaMask#11027) - [MetaMask#10981](MetaMask#10981) ## 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 - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Major perps-controller release with breaking testnet token/bridge behavior; this PR only bumps versions but consumers picking up 22.0.0 inherit that risk. > > **Overview** > **Monorepo release 1359.0.0** that publishes **`@metamask/perps-controller` 22.0.0** and bumps **`@metamask/transaction-pay-middleware`** to depend on `^22.0.0` (plus `yarn.lock` and root package version). > > The diff is mostly versioning and changelog housekeeping; the shipped **22.0.0** behavior (already merged) includes a **breaking testnet USDC2 / Arbitrum Sepolia deposit path** change, optional **`environmentType`** on perps tracking/withdraw params, **Lighter** `loadProvider` + public provider export, **`closePositions`** analytics `order_type: 'market'`, and **Node.js 26** engine support. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8356d7d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Explanation
Perps deposits couldn't be tested on Hyperliquid testnet. Every end-to-end check needed real mainnet funds.
There were two problems:
DepositService.prepareTransactioncalledprovider.getDepositRoutes({ isTestnet: false }). On a testnet session,depositWithConfirmationtherefore asked the wallet for an Arbitrum One USDC transfer to the mainnet bridge.USDC_ARBITRUM_TESTNET_ADDRESS,HYPERLIQUID_ASSET_CONFIGS.usdc.testnet) pointed at0x75faf114…AA4d. That isn't the token the testnet bridge uses:usdcToken()on the Arbitrum Sepolia bridge0x08cfc1B6…6f89returns USDC20x1baAbB04…4d5. Withdrawal routes advertised the same wrong token, even thoughwithdraw3pays out in USDC2.The fix:
DepositServicecallsprovider.getDepositRoutes()with no argument.HyperLiquidProvideralready falls back to its ownisTestnetMode(),AggregatedPerpsProviderforwards the call to its default provider, and Lighter still returns no deposit route.Mainnet doesn't change: Arbitrum One USDC
0xaf88…5831goes to bridge0x2df1…3df7, as before.Before this change, I checked that testnet credits a full deposit. A real 29.84 USDC2 transfer to the testnet bridge (
0xb53a3e01…6daf16, 2026-10-12) showed up on the sender's Hyperliquid testnet ledger 5 s later, under the same hash. The Hyperliquid bridge2 docs list the same bridge and USDC2 as the testnet permit domain.Validation (testnet, Trading fixture account, real
PerpsControllerand messenger; the wallet side signs with the fixture key on Arbitrum Sepolia):0xdfe2eda2e73edff52f6de95a3db22605bcb67cc91f13971efbcb06fa20aa9a2c(ledgerdeposit6.0, same hash)0xbeca9f1a0209f1bd3e7e308f6781dfb23f5b7453580a99bc7f2e54623cc3dd7d(ledgerwithdraw2.0 + fee 1.0, same hash)Before the fix, the same recipe fails at the testnet route check: the testnet session prepares chain
0xa4b1, Arbitrum One USDC, mainnet bridge. The mainnet guard passes both before and after.Things reviewers might not expect:
USDC_ARBITRUM_TESTNET_ADDRESSis exported with a literal type, so its declared type changes to the USDC2 address, and on testnetwithdrawnow rejects the old asset ID0x75faf114…AA4d. Code that reads the constant orgetWithdrawalRoutes()follows the change automatically; anything that hardcodes the old address or types against the old literal has to switch.No network client found for chain 0x66eeeerror instead of quietly preparing a mainnet transfer.References
Validation Recipe
recipe.json (17 nodes — Hyperliquid deposit and withdrawal on testnet - Core)
{ "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json", "title": "Hyperliquid deposit and withdrawal on testnet - Core", "paramsSchema": { "type": "object", "additionalProperties": false, "properties": { "account": { "type": "string", "default": "Trading", "description": "Wallet-fixture private-key account that owns the testnet funds." }, "deposit_amount": { "type": "string", "default": "6", "description": "USDC2 the confirming wallet deposits; at least 5, the bridge minimum." }, "withdraw_amount": { "type": "string", "default": "3", "description": "USDC withdrawn through the controller; above the 1.01 route minimum." } } }, "proofTargets": [ { "id": "AC-1", "claim": "[TAT-4204] With a testnet session, depositWithConfirmation builds an Arbitrum Sepolia transfer of the token the testnet bridge credits, to the testnet bridge, and the Hyperliquid testnet balance increases after confirmation." }, { "id": "AC-2", "claim": "[TAT-4204] With a mainnet session, the deposit route and token are unchanged." }, { "id": "AC-3", "claim": "[TAT-4204] A testnet withdrawal through the controller lands on Arbitrum Sepolia and the Hyperliquid testnet balance drops by the amount plus fee." }, { "id": "AC-4", "claim": "[TAT-4204] Both flows are proven end to end with a recipe on core (testnet, tx hashes and balances before and after recorded), not only unit tests." }, { "id": "AC-5", "claim": "[TAT-4204] If testnet cannot support a full deposit, the PR is replaced by a written finding with evidence and a recommended cheapest mainnet validation path." } ], "workflow": { "entry": "ac2-mainnet-prepare", "nodes": { "ac2-mainnet-prepare": { "action": "command", "timeout_ms": 180000, "intent": "Start a deposit on a mainnet session and decline it at the wallet confirmation", "next": "ac2-mainnet-prepare-exit" }, "ac2-mainnet-prepare-exit": { "action": "assert_exit_code", "source": "ac2-mainnet-prepare", "expected": 0, "intent": "The mainnet deposit driver finished without an error", "next": "ac2-mainnet-prepare-evidence" }, "ac2-mainnet-prepare-evidence": { "action": "index_artifacts", "artifacts": [ { "type": "json", "category": "proof", "label": "ac2-mainnet-prepare driver record", "proofTarget": "AC-2" }, { "type": "log", "category": "proof", "label": "ac2-mainnet-prepare controller debug log", "proofTarget": "AC-2" } ], "intent": "Keep the ac2 mainnet prepare record and controller log in the run package", "next": "ac2-mainnet-route-unchanged" }, "ac2-mainnet-route-unchanged": { "action": "assert_json", "assert": { "all": [ { "path": "$.network", "operator": "eq", "value": "mainnet" }, { "path": "$.networkLookup.chainId", "operator": "eq", "value": "0xa4b1" }, { "path": "$.request.chainId", "operator": "eq", "value": "0xa4b1" }, { "path": "$.request.type", "operator": "eq", "value": "perpsDeposit" }, { "path": "$.request.functionName", "operator": "eq", "value": "transfer" }, { "path": "$.request.to", "operator": "eq", "value": "0xaf88d065e77c8cc2239327c5edb3a432268e5831" }, { "path": "$.request.recipient", "operator": "eq", "value": "0x2df1c51e09aecf9cacb7bc98cb1742757f163df7" }, { "path": "$.wallet.broadcast", "operator": "eq", "value": false }, { "path": "$.signatures", "operator": "length_eq", "value": 0 }, { "path": "$.depositRequest.status", "operator": "eq", "value": "cancelled" } ] }, "intent": "Mainnet deposits still send Arbitrum One USDC to the mainnet bridge, and nothing was signed or sent", "proves": [ "AC-2" ], "next": "ac1-testnet-prepare" }, "ac1-testnet-prepare": { "action": "command", "timeout_ms": 180000, "intent": "Start a deposit on a testnet session and inspect the transaction the wallet is asked to confirm", "next": "ac1-testnet-prepare-exit" }, "ac1-testnet-prepare-exit": { "action": "assert_exit_code", "source": "ac1-testnet-prepare", "expected": 0, "intent": "The testnet deposit preparation finished without an error", "next": "ac1-testnet-prepare-evidence" }, "ac1-testnet-prepare-evidence": { "action": "index_artifacts", "artifacts": [ { "type": "json", "category": "proof", "label": "ac1-testnet-prepare driver record", "proofTarget": "AC-1" }, { "type": "log", "category": "proof", "label": "ac1-testnet-prepare controller debug log", "proofTarget": "AC-1" } ], "intent": "Keep the ac1 testnet prepare record and controller log in the run package", "next": "ac1-testnet-route" }, "ac1-testnet-route": { "action": "assert_json", "assert": { "all": [ { "path": "$.network", "operator": "eq", "value": "testnet" }, { "path": "$.bridge.usdcToken", "operator": "eq", "value": "0x1baabb04529d43a73232b713c0fe471f7c7334d5" }, { "path": "$.networkLookup.chainId", "operator": "eq", "value": "0x66eee" }, { "path": "$.request.chainId", "operator": "eq", "value": "0x66eee" }, { "path": "$.request.functionName", "operator": "eq", "value": "transfer" }, { "path": "$.request.to", "operator": "eq", "value": "0x1baabb04529d43a73232b713c0fe471f7c7334d5" }, { "path": "$.request.recipient", "operator": "eq", "value": "0x08cfc1b6b2dcf36a1480b99353a354aa8ac56f89" }, { "path": "$.wallet.broadcast", "operator": "eq", "value": false } ] }, "intent": "A testnet deposit asks for an Arbitrum Sepolia transfer of the bridge's own token to the testnet bridge", "proves": [ "AC-1" ], "next": "ac1-testnet-deposit" }, "ac1-testnet-deposit": { "action": "command", "timeout_ms": 360000, "intent": "Deposit on testnet and confirm it in the wallet, then wait for Hyperliquid testnet to credit it", "next": "ac1-testnet-deposit-exit" }, "ac1-testnet-deposit-exit": { "action": "assert_exit_code", "source": "ac1-testnet-deposit", "expected": 0, "intent": "The confirmed testnet deposit finished without an error", "next": "ac1-testnet-deposit-evidence" }, "ac1-testnet-deposit-evidence": { "action": "index_artifacts", "artifacts": [ { "type": "json", "category": "proof", "label": "ac1-testnet-deposit driver record", "proofTarget": "AC-1" }, { "type": "log", "category": "proof", "label": "ac1-testnet-deposit controller debug log", "proofTarget": "AC-1" } ], "intent": "Keep the ac1 testnet deposit record and controller log in the run package", "next": "ac1-deposit-credited" }, "ac1-deposit-credited": { "action": "assert_json", "assert": { "all": [ { "path": "$.request.chainId", "operator": "eq", "value": "0x66eee" }, { "path": "$.request.to", "operator": "eq", "value": "0x1baabb04529d43a73232b713c0fe471f7c7334d5" }, { "path": "$.request.recipient", "operator": "eq", "value": "0x08cfc1b6b2dcf36a1480b99353a354aa8ac56f89" }, { "path": "$.wallet.decision", "operator": "eq", "value": "confirmed" }, { "path": "$.wallet.receipt.status", "operator": "eq", "value": "success" }, { "path": "$.txHash", "operator": "matches", "value": "^0x[0-9a-f]{64}$" }, { "path": "$.depositRequest.status", "operator": "eq", "value": "completed" }, { "path": "$.depositRequest.txHash", "operator": "matches", "value": "^0x[0-9a-f]{64}$" }, { "path": "$.arbitrumSepoliaSpentMicros", "operator": "eq", "value": 6000000 }, { "path": "$.before.controller.totalMicros", "operator": "gte", "value": 0 }, { "path": "$.after.controller.totalMicros", "operator": "gt", "value": 0 }, { "path": "$.creditedMicros", "operator": "eq", "value": 6000000 }, { "path": "$.ledger.hash", "operator": "matches", "value": "^0x[0-9a-f]{64}$" }, { "path": "$.ledger.usdcMicros", "operator": "eq", "value": 6000000 } ] }, "intent": "The confirmed deposit left Arbitrum Sepolia, Hyperliquid testnet recorded it under the same transaction hash, and the balance rose by the same 6 USDC", "proves": [ "AC-1", "AC-4", "AC-5" ], "next": "ac3-testnet-withdraw" }, "ac3-testnet-withdraw": { "action": "command", "timeout_ms": 900000, "intent": "Withdraw on testnet through the controller and wait for the payout on Arbitrum Sepolia", "next": "ac3-testnet-withdraw-exit" }, "ac3-testnet-withdraw-exit": { "action": "assert_exit_code", "source": "ac3-testnet-withdraw", "expected": 0, "intent": "The testnet withdrawal finished without an error", "next": "ac3-testnet-withdraw-evidence" }, "ac3-testnet-withdraw-evidence": { "action": "index_artifacts", "artifacts": [ { "type": "json", "category": "proof", "label": "ac3-testnet-withdraw driver record", "proofTarget": "AC-3" }, { "type": "log", "category": "proof", "label": "ac3-testnet-withdraw controller debug log", "proofTarget": "AC-3" } ], "intent": "Keep the ac3 testnet withdraw record and controller log in the run package", "next": "ac3-withdraw-landed" }, "ac3-withdraw-landed": { "action": "assert_json", "assert": { "all": [ { "path": "$.route.caipChainId", "operator": "eq", "value": "eip155:421614" }, { "path": "$.route.token", "operator": "eq", "value": "0x1baabb04529d43a73232b713c0fe471f7c7334d5" }, { "path": "$.result.success", "operator": "eq", "value": true }, { "path": "$.ledger.hash", "operator": "matches", "value": "^0x[0-9a-f]{64}$" }, { "path": "$.ledger.usdcMicros", "operator": "gt", "value": 0 }, { "path": "$.arrival.chainId", "operator": "eq", "value": 421614 }, { "path": "$.arrival.token", "operator": "eq", "value": "0x1baabb04529d43a73232b713c0fe471f7c7334d5" }, { "path": "$.arrival.txHash", "operator": "matches", "value": "^0x[0-9a-f]{64}$" }, { "path": "$.arrival.amountMicros", "operator": "gt", "value": 0 }, { "path": "$.reconciliation.feeMicros", "operator": "eq", "value": 1000000 }, { "path": "$.reconciliation.hlDropMicros", "operator": "gt", "value": 0 }, { "path": "$.reconciliation.unexplainedMicros", "operator": "eq", "value": 0 }, { "path": "$.before.controller.totalMicros", "operator": "gt", "value": 0 }, { "path": "$.after.controller.totalMicros", "operator": "gte", "value": 0 } ] }, "intent": "The withdrawal landed on Arbitrum Sepolia in the advertised token, Hyperliquid testnet recorded it under the payout hash, and the balance fell by the landed amount plus the fee", "proves": [ "AC-3", "AC-4" ], "next": "done" }, "done": { "action": "end", "status": "pass" } } } }Validation Logs
Run: 43bb42c0-5714-4333-9c00-59326eaeeb18
Runner: @metamask/recipe-runner
Runner revision: unknown
Recipe digest: sha256:f2f74c0b58774eb4713fd5e6023123f5d36bf09f3dcbdb5efe56c0ba64cb52ad
Full output (17/17 passed, pass)
Checklist
USDC_ARBITRUM_TESTNET_ADDRESSliteral type and the testnet withdrawal asset ID change (CHANGELOG entry prefixed**BREAKING:**). No draft client PRs yet.corepackage uses these constants. Mobile (perps-transaction.ts,useWithdrawValidation.ts) and Extension (perps-withdraw-page.tsx) production code imports them from@metamask/perps-controller, so it needs no source change. Tests that pin the old testnet asset ID need updating on upgrade: MobileuseWithdrawValidation.test.tsandhyperLiquidValidation.test.ts, Extensionperps-withdraw-page.test.tsx.Full evidence gallery and publication receipt
Publication approval
Arthur · person · arthur · 2026-10-22T07:06:55.652Z
Package:
c051188d3e3d29cd104fe8829356aa3b94fd5469e5293a1427778c14ae348cba. Visual receipts: none.Note
Medium Risk
Breaking exported testnet USDC constants and deposit route selection affect real fund flows on testnet; mainnet paths are intentionally unchanged but financial routing code was modified.
Overview
Hyperliquid testnet deposits and withdrawals now use the token and chain the testnet bridge actually credits and pays out, instead of mainnet Arbitrum One routes and a wrong testnet USDC address.
Breaking:
USDC_ARBITRUM_TESTNET_ADDRESSandHYPERLIQUID_ASSET_CONFIGS.usdc.testnetswitch to USDC2 (0x1baAbB04529D43a73232B713C0FE471f7c7334d5). Testnetwithdrawrejects the previous asset ID; consumers must not hardcode0x75faf114….Deposit routing:
DepositService.prepareTransactioncallsprovider.getDepositRoutes()with noisTestnet: falseoverride, so a testnet session prepares an Arbitrum Sepolia transfer of USDC2 to the testnet bridge. Mainnet behavior is unchanged.Changelog and unit tests (
getSupportedAssets,DepositService) document and lock in the new addresses and route selection.Reviewed by Cursor Bugbot for commit e86e862. Bugbot is set up for automated code reviews on this repo. Configure here.