Skip to content

refactor(perps): simplify TAT-3016 payment-token inference + agentic cycle validation cp-7.72.2#29171

Merged
abretonc7s merged 6 commits into
perps/fix-avail-balance-order-entryfrom
perps/fix-avail-balance-order-entry-strip
Apr 22, 2026
Merged

refactor(perps): simplify TAT-3016 payment-token inference + agentic cycle validation cp-7.72.2#29171
abretonc7s merged 6 commits into
perps/fix-avail-balance-order-entryfrom
perps/fix-avail-balance-order-entry-strip

Conversation

@abretonc7s
Copy link
Copy Markdown
Contributor

@abretonc7s abretonc7s commented Apr 22, 2026

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 USDC for order-entry while keeping availableBalance = withdrawable for 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 of useDefaultPayWithTokenWhenNoPerpsBalance. 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:

  • A testID on PerpsWithdrawView'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 the PerpsProvider interface.
  • Two reusable flows: hl-balance-validation (captures AccountState + PerpsMarketListView balance + PerpsWithdrawView balance + PerpsOrderView Pay-with symbol, asserts the fold invariant and no-fold-leak on withdraw) and hl-provision-fixture (wraps userSetAbstraction + usdClassTransfer).

Every timing gate is a wait_for polling a deterministic condition. No arbitrary wait: Nms.

Out of scope (deliberate)

  • No contract reshape (availableBalance → spendableBalance / withdrawableBalance) — that's TAT-3047.
  • No withdraw-path logic edits. availableBalance semantics preserved.
  • No MYX logic beyond the trivial availableToTradeBalance = availableBalance field.
  • No upstream @metamask/perps-controller changes.
  • Mode-aware fold gating (honour Unified vs Standard on the mobile side) — also TAT-3047.
  • Cross-account selectedPaymentToken reset — 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:

Feature: TAT-3016 HL abstraction cycle

  Scenario: Spot-funded Trading account cycled through Unified / Standard / Unified
    Given the Trading account (0x316B…01fA) is in Unified Mode with spot USDC collateral
    And all open positions have been closed

    When the recipe captures AccountState + PerpsMarketListView balance + PerpsWithdrawView balance + PerpsOrderView Pay-with
    And flips HL abstraction to Standard via userSetAbstraction
    And re-captures
    And flips back to Unified
    And captures once more

    Then availableToTradeBalance >= availableBalance in every phase
    And PerpsMarketListView shows the fold (≈ $100.76) in every phase
    And PerpsWithdrawView shows "Available Perps balance: $0" in every phase
    And PerpsOrderView Pay-with row shows "Perps balance" in every phase
    And teardown leaves the account in Unified Mode

Observed values on the shared Trading account (last run: 105/105 PASS):

Phase availableBalance availableToTradeBalance Market-list balance Withdraw balance Order-form Pay-with
Initial (Unified) $0.00 $100.75 $100.76 Available Perps balance: $0 Perps balance
After flip → Standard $0.00 $100.75 $100.76 Available Perps balance: $0 Perps balance
Restored (Unified) $0.00 $100.75 $100.76 Available Perps balance: $0 Perps balance

Identical 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-validation and perps/hl-provision-fixture this recipe composes are committed under scripts/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).

Market list Withdraw Order form (Pay-with)
Initial (Unified) Initial market list Initial withdraw Initial order form
After flip → Standard Standard market list Standard withdraw Standard order form
Restored (Unified) Restored market list Restored withdraw Restored order form

Pre-merge author checklist

Performance checks (if applicable)

  • I've tested on Android
  • I've tested with a power user scenario
  • I've instrumented key operations with Sentry traces for production performance metrics

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

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)
@abretonc7s abretonc7s requested a review from a team as a code owner April 22, 2026 11:33
@github-actions
Copy link
Copy Markdown
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.

@abretonc7s abretonc7s marked this pull request as draft April 22, 2026 11:34
@metamaskbotv2 metamaskbotv2 Bot added the team-perps Perps team label Apr 22, 2026
@github-actions
Copy link
Copy Markdown
Contributor

E2E Fixture Validation — Schema is up to date
12 value mismatches detected (expected — fixture represents an existing user).
View details

…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.
@github-actions
Copy link
Copy Markdown
Contributor

🔍 Smart E2E Test Selection

⏭️ Smart E2E selection skipped - draft PR

All E2E tests pre-selected.

View GitHub Actions results

@abretonc7s abretonc7s marked this pull request as ready for review April 22, 2026 12:52
@aganglada aganglada changed the title refactor(perps): strip PR #29150 noise from TAT-3016 hotfix refactor(perps): strip PR #29150 noise from TAT-3016 hotfix cp-7.72.2 Apr 22, 2026
@sonarqubecloud
Copy link
Copy Markdown

@abretonc7s abretonc7s changed the title refactor(perps): strip PR #29150 noise from TAT-3016 hotfix cp-7.72.2 refactor(perps): simplify TAT-3016 payment-token inference + agentic cycle validation Apr 22, 2026
@abretonc7s abretonc7s merged commit c63aad7 into perps/fix-avail-balance-order-entry Apr 22, 2026
61 of 67 checks passed
@abretonc7s abretonc7s deleted the perps/fix-avail-balance-order-entry-strip branch April 22, 2026 13:14
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 22, 2026
@aganglada aganglada changed the title refactor(perps): simplify TAT-3016 payment-token inference + agentic cycle validation refactor(perps): simplify TAT-3016 payment-token inference + agentic cycle validation cp-7.72.2 Apr 22, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants