refactor(perps): simplify TAT-3016 payment-token inference + agentic cycle validation cp-7.72.2#29171
Merged
abretonc7s merged 6 commits intoApr 22, 2026
Conversation
Removes the 'explicit' | 'autoNoPerpsBalance' source-tag state machine that was pulled from superseded PR #29150. Replaces it with simple inference in useInitPerpsPaymentToken: when the user now has native buying power AND the saved token equals the pay-with-any-token fallback candidate, the saved selection is treated as stale and cleared. Reverts the state-machine plumbing across types, selectors, PerpsController, method-action-types, and usePerpsSavePendingConfig. No change to pending-config TTL or persistence shape otherwise.
Folds the availableToTradeBalance bump directly into addSpotBalanceToAccountState so a single call updates both totalBalance and availableToTradeBalance per fix-prompt.md §L53. Deletes the addSpotUsdcToAvailableToTradeBalance wrapper and its call-site pairs in HyperLiquidProvider + HyperLiquidSubscriptionService, and drops the unused getSpotBalanceByCoin helper. Semantics unchanged: availableToTradeBalance = withdrawable + getSpotBalance(spotState), matching the SPOT_COLLATERAL_COINS allowlist (USDC-only today).
…ctor Rolls back the 215-line rewrite toward the pre-hotfix single-hook shape and keeps only the changes required by TAT-3016: - Balance gate now reads availableToTradeBalance ?? availableBalance so the hook correctly no-ops for spot-funded users. - Extracts computePreferredFallbackPayTokenCandidate as an internal helper shared between useDefaultPayWithTokenWhenNoPerpsBalance and the new usePreferredFallbackPayTokenCandidate hook. - Adds useHasNativeTradeablePerpsBalance + arePaymentTokensEqual exports consumed by useInitPerpsPaymentToken for stale-selection detection (replacing the stripped source-tag state machine).
Documents the fixture matrix (dev1 Standard / Trading Unified / dev2 spot-only / dev3 empty), manual HL web UI setup per fixture, and the five validation scenarios (Standard regression, Unified mixed balance, Unified spot-only THE FIX, Unified empty, withdraw non-regression). Companion to the evidence/recipe.json automation and manual iOS simulator recordings.
Replaces the streamed-totalBalance repro (untracked) with an assertion-based recipe that proves the hotfix contract on the two pre-provisioned fixtures: - dev1 (Standard mode): availableToTradeBalance === availableBalance and PerpsMarketDetails surfaces the Long CTA, not Add Funds. - Trading (Unified mode): availableToTradeBalance >= availableBalance (spot USDC/USDH folded into buying power) and the Long CTA renders. Scenarios for the spot-only (dev2) and empty (dev3) fixtures require manual HL web UI setup; see evidence/QA.md for the full five-scenario manual playbook with iOS simulator recordings. Run: bash scripts/perps/agentic/validate-recipe.sh evidence \\ --artifacts-dir evidence/validation-run-\$(date +%Y%m%d-%H%M)
Contributor
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
Contributor
|
✅ E2E Fixture Validation — Schema is up to date |
…dation - PerpsWithdrawView gets a testID on the available-balance text so agentic recipes can read the displayed value and assert the withdraw path shows availableBalance, not availableToTradeBalance. - HyperLiquidProvider.getExchangeClient() is a narrowly-scoped public escape hatch used by admin/test flows to drive userSetAbstraction and usdClassTransfer via the SDK. Not part of the PerpsProvider interface. - scripts/perps/agentic: new hl-balance-validation flow captures AccountState + perps-market-available-balance-text (PerpsMarketListView) + perps-withdraw-available-balance-text (PerpsWithdrawView) with deterministic wait_for gates. New hl-provision-fixture flow wraps userSetAbstraction / usdClassTransfer so recipes can cycle abstraction modes programmatically. New hl-fixture-state eval exposes the balance snapshot used by the flow assertions. - evidence/QA.md + evidence/recipe.json removed from tracking; these are per-PR local artifacts and belong under the agentic task dir, not the repo.
Contributor
🔍 Smart E2E Test Selection⏭️ Smart E2E selection skipped - draft PR All E2E tests pre-selected. |
|
michalconsensys
approved these changes
Apr 22, 2026
c63aad7
into
perps/fix-avail-balance-order-entry
61 of 67 checks passed
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.



Description
Small cleanup on top of Matt's TAT-3016 hotfix, plus an agentic validation harness that proves the fix contract holds end-to-end.
Matt's hotfix surfaces
availableToTradeBalance = withdrawable + spot USDCfor order-entry while keepingavailableBalance = withdrawablefor the withdraw path. #29150 introduced that change but carried a lot of incidental churn — a state machine for payment-token sources, a wrapper helper that only added noise, and a 215-line refactor ofuseDefaultPayWithTokenWhenNoPerpsBalance. None of those were part of the fix contract. This PR strips them and inlines a simpler inference: clear the saved payment token only when the user now has native buying power and the saved token matches the auto-fallback candidate.Net production-code delta vs the base hotfix: −182 lines.
Validation harness
One recipe drives the shared Trading account (currently in the exact "Unified Mode + spot-only" state that broke orders) through three phases — capture initial state, flip HL abstraction to Standard via
userSetAbstraction, capture, flip back to Unified, capture. Setup closes open positions (HL rejects abstraction flips while positions are open); teardown restores Unified.Three small additions make this composable for future perps PRs:
testIDonPerpsWithdrawView's available-balance text so automation can read what users see.HyperLiquidProvider.getExchangeClient()— narrow escape hatch that lets admin/test flows drive any SDK action without growing a controller method per operation. Not on thePerpsProviderinterface.hl-balance-validation(captures AccountState + PerpsMarketListView balance + PerpsWithdrawView balance + PerpsOrderView Pay-with symbol, asserts the fold invariant and no-fold-leak on withdraw) andhl-provision-fixture(wrapsuserSetAbstraction+usdClassTransfer).Every timing gate is a
wait_forpolling a deterministic condition. No arbitrarywait: Nms.Out of scope (deliberate)
availableBalance → spendableBalance/withdrawableBalance) — that's TAT-3047.availableBalancesemantics preserved.availableToTradeBalance = availableBalancefield.@metamask/perps-controllerchanges.selectedPaymentTokenreset — pre-existing bug discovered while validating, filed as TAT-3050. Not this PR's problem.Changelog
CHANGELOG entry: null
Related issues
Fixes: TAT-3016
Context:
Manual testing steps
The useful path is automated. To reproduce locally run the recipe against the shared Trading account with
bash scripts/perps/agentic/validate-recipe.sh evidence. Per-phase assertions:Observed values on the shared Trading account (last run: 105/105 PASS):
availableBalanceavailableToTradeBalanceIdentical balances across Unified / Standard flips are intentional per #29150's product assumption: "spot USDC is treated as interchangeable with Perps trading collateral for the markets we currently surface." HL's HIP-3 / non-USDC markets are deferred to TAT-3047.
Full recipe source
{ "title": "TAT-3016 validation: single-account HL abstraction-mode cycle on Trading — capture AccountState + withdraw + market-details balance readouts before/after Unified toggle", "description": "Uses the shared Trading account (0x316BDE155acd07609872a56Bc32CcfB0B13201fA). Setup closes all open positions and cancels all open orders because HL rejects userSetAbstraction while positions/orders/TWAPs exist. Workflow cycles HL abstraction: Unified (initial) -> disabled (Standard) -> Unified (restored). At each phase perps/hl-balance-validation captures and asserts (a) PerpsController.state.accountState (availableBalance, availableToTradeBalance, fold invariant), (b) PerpsMarketDetails balance text (perps-market-available-balance-text), (c) PerpsWithdraw balance text (perps-withdraw-available-balance-text) which must never leak the spot fold. Teardown restores Unified mode as a safety net. DESTRUCTIVE to Trading's position book — positions are NOT reopened.", "initial_conditions": { "testnet": false }, "validate": { "workflow": { "pre_conditions": [], "setup": [ { "id": "login-detect-route", "action": "eval_sync", "expression": "(function(){try{var r=globalThis.__AGENTIC__&&globalThis.__AGENTIC__.getRoute();return JSON.stringify({route:r?r.name:'unknown'})}catch(e){return JSON.stringify({route:'unknown'})}})()", "assert": { "operator": "not_null", "field": "route" } }, { "id": "select-trading", "action": "select_account", "address": "0x316BDE155acd07609872a56Bc32CcfB0B13201fA" }, { "id": "wait-provider", "action": "wait_for", "expression": "(function(){try{var ctrl=Engine.context.PerpsController;var ready=!!(ctrl&&ctrl.state&&ctrl.state.activeProvider);return JSON.stringify({ready:ready})}catch(e){return JSON.stringify({ready:false})}})()", "assert": { "operator": "eq", "field": "ready", "value": true }, "timeout_ms": 20000 }, { "id": "prime-provider-clients", "action": "wait_for", "expression": "Engine.context.PerpsController.getAccountState().then(function(r){return JSON.stringify({primed:true,availableBalance:r.availableBalance})}).catch(function(e){return JSON.stringify({primed:false,error:String(e&&e.message||e)})})", "assert": { "operator": "eq", "field": "primed", "value": true }, "timeout_ms": 30000 }, { "id": "clear-stale-payment-token", "action": "eval_async", "expression": "(function(){try{var ctrl=Engine.context.PerpsController;if(ctrl.setSelectedPaymentToken)ctrl.setSelectedPaymentToken(null);if(ctrl.clearPendingTradeConfiguration)ctrl.clearPendingTradeConfiguration();return Promise.resolve(JSON.stringify({cleared:true,selectedPaymentToken:ctrl.state&&ctrl.state.selectedPaymentToken}))}catch(e){return Promise.resolve(JSON.stringify({cleared:false,error:String(e)}))}})()", "assert": { "operator": "eq", "field": "cleared", "value": true }, "timeout_ms": 5000 }, { "id": "close-all-positions", "action": "eval_async", "expression": "Engine.context.PerpsController.getPositions().then(function(ps){if(ps.length===0){return JSON.stringify({closed:0})}var calls=ps.map(function(p){return Engine.context.PerpsController.closePosition({symbol:p.symbol}).catch(function(e){return {err:String(e&&e.message||e),symbol:p.symbol}})});return Promise.all(calls).then(function(){return JSON.stringify({closed:ps.length,symbols:ps.map(function(p){return p.symbol})})})})", "assert": { "operator": "not_null", "field": "closed" }, "timeout_ms": 60000 }, { "id": "wait-positions-cleared", "action": "wait_for", "expression": "Engine.context.PerpsController.getPositions().then(function(ps){return JSON.stringify({positionsLeft:ps.length,cleared:ps.length===0})})", "assert": { "operator": "eq", "field": "cleared", "value": true }, "timeout_ms": 60000 }, { "id": "cancel-all-orders", "action": "eval_async", "expression": "Engine.context.PerpsController.getOpenOrders().then(function(os){if(os.length===0){return JSON.stringify({cancelled:0})}var calls=os.map(function(o){return Engine.context.PerpsController.cancelOrder({orderId:o.orderId,symbol:o.symbol}).catch(function(e){return {err:String(e&&e.message||e),orderId:o.orderId}})});return Promise.all(calls).then(function(){return JSON.stringify({cancelled:os.length})})})", "assert": { "operator": "not_null", "field": "cancelled" }, "timeout_ms": 30000 }, { "id": "wait-orders-cleared", "action": "wait_for", "expression": "Engine.context.PerpsController.getOpenOrders().then(function(os){return JSON.stringify({ordersLeft:os.length,cleared:os.length===0})})", "assert": { "operator": "eq", "field": "cleared", "value": true }, "timeout_ms": 30000 } ], "entry": "assert-mainnet", "nodes": { "assert-mainnet": { "action": "eval_sync", "expression": "JSON.stringify({isTestnet:Engine.context.PerpsController.state&&Engine.context.PerpsController.state.isTestnet===true})", "assert": { "operator": "eq", "field": "isTestnet", "value": false }, "next": "phase1-initial-unified" }, "phase1-initial-unified": { "action": "call", "ref": "perps/hl-balance-validation", "params": { "expectedMode": "unified", "phaseLabel": "initial-unified" }, "next": "flip-to-standard" }, "flip-to-standard": { "action": "call", "ref": "perps/hl-provision-fixture", "params": { "abstraction": "disabled", "transferDirection": "none" }, "next": "refresh-post-flip-standard" }, "refresh-post-flip-standard": { "action": "wait_for", "expression": "Engine.context.PerpsController.getAccountState().then(function(r){return JSON.stringify({ok:true,availableBalance:r.availableBalance,availableToTradeBalance:r.availableToTradeBalance})}).catch(function(e){return JSON.stringify({ok:false,error:String(e)})})", "assert": { "operator": "eq", "field": "ok", "value": true }, "timeout_ms": 30000, "next": "phase2-after-flip-standard" }, "phase2-after-flip-standard": { "action": "call", "ref": "perps/hl-balance-validation", "params": { "expectedMode": "standard", "phaseLabel": "after-flip-standard" }, "next": "flip-back-to-unified" }, "flip-back-to-unified": { "action": "call", "ref": "perps/hl-provision-fixture", "params": { "abstraction": "unifiedAccount", "transferDirection": "none" }, "next": "refresh-post-flip-unified" }, "refresh-post-flip-unified": { "action": "wait_for", "expression": "Engine.context.PerpsController.getAccountState().then(function(r){return JSON.stringify({ok:true,availableBalance:r.availableBalance,availableToTradeBalance:r.availableToTradeBalance})}).catch(function(e){return JSON.stringify({ok:false,error:String(e)})})", "assert": { "operator": "eq", "field": "ok", "value": true }, "timeout_ms": 30000, "next": "phase3-restored-unified" }, "phase3-restored-unified": { "action": "call", "ref": "perps/hl-balance-validation", "params": { "expectedMode": "unified", "phaseLabel": "restored-unified" }, "next": "done" }, "done": { "action": "end", "status": "pass" } }, "teardown": [ { "id": "teardown-select-trading", "action": "select_account", "address": "0x316BDE155acd07609872a56Bc32CcfB0B13201fA" }, { "id": "teardown-wait-trading", "action": "wait_for", "expression": "Engine.context.PerpsController.getAccountState().then(function(r){return JSON.stringify({ready:true,availableToTradeBalance:r.availableToTradeBalance})}).catch(function(e){return JSON.stringify({ready:false,error:String(e)})})", "assert": { "operator": "eq", "field": "ready", "value": true }, "timeout_ms": 20000 }, { "id": "teardown-restore-unified", "action": "eval_async", "expression": "(function(){try{var ctrl=Engine.context.PerpsController;var provider=ctrl.getActiveProvider();var accs=Engine.context.AccountsController.state.internalAccounts;var user=accs.accounts[accs.selectedAccount].address;return provider.getExchangeClient().then(function(client){return client.userSetAbstraction({user:user,abstraction:'unifiedAccount'})}).then(function(r){return JSON.stringify({ok:true,restored:'unifiedAccount'})}).catch(function(e){return JSON.stringify({ok:false,error:String(e&&e.message||e),note:'Trading may be left in Standard mode; re-flip manually on HL web if needed'})})}catch(e){return JSON.stringify({ok:false,error:String(e)})}})()", "assert": { "operator": "not_null", "field": "ok" }, "timeout_ms": 30000 } ] } } }The flows
perps/hl-balance-validationandperps/hl-provision-fixturethis recipe composes are committed underscripts/perps/agentic/teams/perps/flows/.</details>
Screenshots/Recordings
Before
This PR layers on top of Matt's already-landing hotfix; the user-visible behavior does not change. The harness below demonstrates the hotfix contract is preserved across the cleanup.
After
Three-phase cycle evidence. Each phase captures market-list balance (shows fold), withdraw balance (shows withdrawable-only), and order-form Pay-with (shows Perps balance by default).
Pre-merge author checklist
Performance checks (if applicable)
Pre-merge reviewer checklist