Skip to content

feat(perps): rework fee resolver for ADR 0064 cloid-based subscription waiver - #10294

Merged
abretonc7s merged 26 commits into
mainfrom
TAT-3967-feat-rework-fee-resolver-adr-0064
Sep 23, 2026
Merged

abretonc7s merged 26 commits into
mainfrom
TAT-3967-feat-rework-fee-resolver-adr-0064

Conversation

@abretonc7s

@abretonc7s abretonc7s commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Explanation

ADR 0064 replaces the old dedicated zero-fee builder with a per-user, cloid-attributed waiver. This PR updates @metamask/perps-controller to use that model.

  • The resolver computes a full or blended subscription rate from order notional and remaining allowance. Subscription competes with the default, VIP, and season rates, and the lowest rate wins.
  • Preview and submit share the same formula and venue tenths-of-a-basis-point quantization. Submit paths price their actual notional, including mixed TP/SL updates and skewed Scale ladders.
  • Orders use the standard builder. When subscription reduces the charged fee, the provider marks the cloid through one helper while preserving Scale group recovery.
  • Benefits hydrate through SubscriptionController:getBenefits. Address registration uses the structural SubscriptionController:registerAddress action, with the existing injected hook as a fallback. The remote perpsSubscriptionFeeWaiverEnabled flag disables only this source.
  • The dedicated-builder approval API is deprecated and no longer used to build orders.

The cloid program ID remains an ADR registry dependency. Caller-supplied cloids and Scale cloids receive the fee treatment but cannot always provide a downstream-decodable attribution marker; that limitation remains documented for the schema and billing owners.

Validation

Use the Core harness from the repository root:

mm-harness check diff --target /Users/deeeed/dev/metamask/core-1 --adapter core --profile fast

The diff gate passes policy suppressions, ESLint, oxfmt, and Jest. The read-only recipe passes its Jest assertions and live HyperLiquid testnet reads for positions, orders, and account state. It does not submit testnet orders.

Additional checks:

  • yarn build passes.
  • The focused RewardsIntegrationService and TradingService suites pass: 193 tests.
  • messenger-action-types:check passes.
  • changelog:validate passes.

Consumer follow-up

Clients must register the benefits and address actions and pass FeeCalculationParams.amount to calculateFees when quoting a partial waiver. Mobile UI work remains tracked by TAT-3622.

References

  • TAT-3967
  • ADR: MetaMask/decisions 0064-subscription-perps-fee-waiver.md (IN REVIEW)
  • Supersedes TAT-3618

Validation Recipe

recipe.json (0 steps — TAT-3967 subscription fee waiver — ADR 0064 rework)
{
  "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
  "title": "TAT-3967 subscription fee waiver \u2014 ADR 0064 rework",
  "description": "Proves the ADR 0064 subscription fee-waiver rework in @metamask/perps-controller: blended vs. full waiver from order notional, the blend competing (and losing) in the lowest-wins comparison, a shared preview/submit formula quantized to the venue's tenths of a basis point, cloid program_id marking centralized in one chokepoint every placement path routes through, benefits hydration over SubscriptionController:getBenefits, address registration through SubscriptionController:registerAddress with an injected-hook fallback, an independent remote kill flag, and a builder-order context that no longer selects the dedicated subscription builder. Two criteria are PARTIAL rather than proven and recipe-coverage.md says why: orders carrying a caller-supplied clientOrderId and Scale rungs receive the discount without a decodable marker (AC4), and older clients may still use the injected registration hook (AC5 compatibility fallback). Ends with live headless controller reads proving the changed package still operates against HyperLiquid.",
  "paramsSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "account": {
        "type": "string",
        "default": "",
        "description": "Fixture account name, defaulting to dev1. Without a fixture, supply an EVM address for read-only inspection."
      },
      "network": {
        "type": "string",
        "enum": [
          "testnet",
          "mainnet"
        ],
        "default": "testnet",
        "description": "Network for every isolated controller read."
      }
    }
  },
  "workflow": {
    "entry": "status",
    "nodes": {
      "status": {
        "action": "app.status",
        "next": "waiver-formula",
        "intent": "Resolve the core checkout and report headless compatibility mode"
      },
      "waiver-formula": {
        "action": "command",
        "cmd": "NODE_OPTIONS=--experimental-vm-modules npx jest --config /Users/deeeed/dev/metamask/core-1/packages/perps-controller/jest.config.cjs --rootDir /Users/deeeed/dev/metamask/core-1/packages/perps-controller packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts --reporters=default --verbose --no-coverage",
        "timeout_ms": 600000,
        "allow_failure": true,
        "next": "assert-waiver-formula-exit",
        "intent": "Run the shared blended-rate formula suite that preview and submit both consume"
      },
      "assert-waiver-formula-exit": {
        "action": "assert_exit_code",
        "node": "waiver-formula",
        "expected": 0,
        "next": "assert-full-waiver",
        "intent": "AC1/AC3: the shared waiver formula suite must pass"
      },
      "assert-full-waiver": {
        "action": "assert_output",
        "node": "waiver-formula",
        "stream": "stderr",
        "contains": "waives the whole fee when the remaining allowance covers the order notional",
        "next": "assert-blended-waiver",
        "intent": "AC1: full waiver when remaining >= order notional is covered by a named test"
      },
      "assert-blended-waiver": {
        "action": "assert_output",
        "node": "waiver-formula",
        "stream": "stderr",
        "contains": "blends the fee by the uncovered share when the allowance is smaller than the order notional",
        "next": "assert-waiver-unknown-policy",
        "intent": "AC1: partial blend MaxFee * (1 - remaining/orderNotional) is covered by a named test"
      },
      "resolver": {
        "action": "command",
        "cmd": "NODE_OPTIONS=--experimental-vm-modules npx jest --config /Users/deeeed/dev/metamask/core-1/packages/perps-controller/jest.config.cjs --rootDir /Users/deeeed/dev/metamask/core-1/packages/perps-controller packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts --reporters=default --verbose --no-coverage",
        "timeout_ms": 900000,
        "allow_failure": true,
        "next": "assert-resolver-exit",
        "intent": "Run the unified fee resolver suite covering every fee source"
      },
      "assert-resolver-exit": {
        "action": "assert_exit_code",
        "node": "resolver",
        "expected": 0,
        "next": "assert-resolver-blend-loses",
        "intent": "AC1/AC2/AC6/AC7: the resolver suite must pass"
      },
      "assert-resolver-blend-loses": {
        "action": "assert_output",
        "node": "resolver",
        "stream": "stderr",
        "contains": "lets a rewards discount beat a partial subscription blend",
        "next": "assert-resolver-quantization",
        "intent": "AC2: a partial blend can lose the lowest-wins comparison to VIP/season"
      },
      "assert-resolver-exhausted": {
        "action": "assert_output",
        "node": "resolver",
        "stream": "stderr",
        "contains": "withholds the waiver when the allowance is exhausted",
        "next": "assert-resolver-flag",
        "intent": "AC1: exhausted/ineligible/stale still fail closed"
      },
      "assert-resolver-flag": {
        "action": "assert_output",
        "node": "resolver",
        "stream": "stderr",
        "contains": "drops the subscription source when the remote feature flag disables it",
        "next": "assert-resolver-messenger-only",
        "intent": "AC6: the remote kill flag removes only the subscription source"
      },
      "provider": {
        "action": "command",
        "cmd": "NODE_OPTIONS=--experimental-vm-modules npx jest --config /Users/deeeed/dev/metamask/core-1/packages/perps-controller/jest.config.cjs --rootDir /Users/deeeed/dev/metamask/core-1/packages/perps-controller packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts --reporters=default --verbose --no-coverage",
        "timeout_ms": 900000,
        "allow_failure": true,
        "next": "assert-provider-exit",
        "intent": "Run the provider suite covering cloid marking across every order placement path"
      },
      "assert-provider-exit": {
        "action": "assert_exit_code",
        "node": "provider",
        "expected": 0,
        "next": "assert-provider-marks",
        "intent": "AC4/AC7: the cloid marking suite must pass"
      },
      "assert-provider-marks": {
        "action": "assert_output",
        "node": "provider",
        "stream": "stderr",
        "contains": "marks the cloid with the subscription program id when subscription wins",
        "next": "assert-provider-unmarked",
        "intent": "AC4: a subscription win marks the cloid"
      },
      "assert-provider-unmarked": {
        "action": "assert_output",
        "node": "provider",
        "stream": "stderr",
        "contains": "leaves the cloid unmarked when any other fee source wins",
        "next": "assert-provider-builder",
        "intent": "AC4: a non-subscription source leaves the cloid unmarked"
      },
      "assert-provider-builder": {
        "action": "assert_output",
        "node": "provider",
        "stream": "stderr",
        "contains": "keeps the standard builder address when subscription wins",
        "next": "assert-provider-scale",
        "intent": "AC7: the dedicated subscription builder address is no longer selected"
      },
      "controller": {
        "action": "command",
        "cmd": "NODE_OPTIONS=--experimental-vm-modules npx jest --config /Users/deeeed/dev/metamask/core-1/packages/perps-controller/jest.config.cjs --rootDir /Users/deeeed/dev/metamask/core-1/packages/perps-controller packages/perps-controller/tests/src/PerpsController.operations.test.ts --reporters=default --verbose --no-coverage",
        "timeout_ms": 900000,
        "allow_failure": true,
        "next": "assert-controller-exit",
        "intent": "Run the controller suite covering preview notional threading and address registration"
      },
      "assert-controller-exit": {
        "action": "assert_exit_code",
        "node": "controller",
        "expected": 0,
        "next": "assert-controller-register",
        "intent": "AC3/AC5: the controller suite must pass"
      },
      "assert-controller-register": {
        "action": "assert_output",
        "node": "controller",
        "stream": "stderr",
        "contains": "registers the current HyperLiquid address at preview time",
        "next": "assert-controller-reregister",
        "intent": "AC5: calculateFees registers the trading address"
      },
      "assert-controller-reregister": {
        "action": "assert_output",
        "node": "controller",
        "stream": "stderr",
        "contains": "re-registers the trading address when the selected account changes",
        "next": "assert-controller-preview",
        "intent": "AC5: registration is re-sent on account switch"
      },
      "assert-controller-preview": {
        "action": "assert_output",
        "node": "controller",
        "stream": "stderr",
        "contains": "resolves the preview fee against the order notional",
        "next": "submit",
        "intent": "AC3 (preview half): the preview resolves against this quote own order notional"
      },
      "read-positions": {
        "action": "metamask.perps.read_positions",
        "account": "{{params.account}}",
        "network": "{{params.network}}",
        "mode": "all",
        "next": "read-orders",
        "intent": "Read live Perps positions from the headless controller carrying the change"
      },
      "read-orders": {
        "action": "metamask.perps.read_orders",
        "account": "{{params.account}}",
        "network": "{{params.network}}",
        "mode": "all",
        "next": "read-account",
        "intent": "Read live Perps open orders from the headless controller carrying the change"
      },
      "read-account": {
        "action": "metamask.perps.read_account",
        "account": "{{params.account}}",
        "network": "{{params.network}}",
        "next": "done",
        "intent": "Read live Perps account state from the headless controller carrying the change"
      },
      "done": {
        "action": "end",
        "status": "pass"
      },
      "assert-provider-scale": {
        "action": "assert_output",
        "node": "provider",
        "stream": "stderr",
        "contains": "marks every rung cloid when subscription wins and keeps the ladder recoverable",
        "next": "assert-provider-chase",
        "intent": "AC4: the Scale ladder path marks its rungs while staying recoverable"
      },
      "submit": {
        "action": "command",
        "cmd": "NODE_OPTIONS=--experimental-vm-modules npx jest --config /Users/deeeed/dev/metamask/core-1/packages/perps-controller/jest.config.cjs --rootDir /Users/deeeed/dev/metamask/core-1/packages/perps-controller packages/perps-controller/tests/src/services/TradingService.test.ts --reporters=default --verbose --no-coverage",
        "timeout_ms": 900000,
        "allow_failure": true,
        "next": "assert-submit-exit",
        "intent": "Run the submit-path suite covering the order notional reaching the fee resolver"
      },
      "assert-submit-exit": {
        "action": "assert_exit_code",
        "node": "submit",
        "expected": 0,
        "next": "assert-submit-notional",
        "intent": "AC3: the submit-path suite must pass"
      },
      "assert-submit-notional": {
        "action": "assert_output",
        "node": "submit",
        "stream": "stderr",
        "contains": "resolves the submit fee against the order notional, not a bare rate",
        "next": "assert-submit-blend",
        "intent": "AC3 (submit half): the submit path resolves against the order notional, not a bare rate"
      },
      "assert-submit-blend": {
        "action": "assert_output",
        "node": "submit",
        "stream": "stderr",
        "contains": "charges a partial blend at submit when the allowance is bounded",
        "next": "assert-submit-full-close",
        "intent": "AC3: submit charges the same partial blend the preview quotes, so the two paths agree"
      },
      "assert-submit-full-close": {
        "action": "assert_output",
        "node": "submit",
        "stream": "stderr",
        "contains": "prices a full close from the loaded position notional",
        "next": "assert-submit-partial-close",
        "intent": "AC3: a full close prices from the loaded position, not an undefined notional"
      },
      "assert-submit-partial-close": {
        "action": "assert_output",
        "node": "submit",
        "stream": "stderr",
        "contains": "prices a partial close from the position unit price",
        "next": "assert-submit-routed-close",
        "intent": "AC3: a partial close prices from the position unit price"
      },
      "assert-resolver-messenger-only": {
        "action": "assert_output",
        "node": "resolver",
        "stream": "stderr",
        "contains": "hydrates and grants the waiver for a messenger-only client",
        "next": "assert-resolver-cache-preserved",
        "intent": "AC5: a client wiring only the SubscriptionController actions hydrates and receives the waiver"
      },
      "assert-waiver-caller-cloid": {
        "action": "assert_output",
        "node": "waiver-formula",
        "stream": "stderr",
        "contains": "preserves a caller client order ID that begins with a reserved marker",
        "next": "assert-waiver-blend-withheld",
        "intent": "AC4: a caller-supplied client order ID is never rewritten, even with a reserved prefix"
      },
      "assert-provider-chase": {
        "action": "assert_output",
        "node": "provider",
        "stream": "stderr",
        "contains": "marks the replacement cloid after the subscription context is cleared",
        "next": "assert-waiver-caller-cloid",
        "intent": "AC4: a chase replacement marks its cloid after the live fee resolution is cleared"
      },
      "assert-waiver-blend-withheld": {
        "action": "assert_output",
        "node": "waiver-formula",
        "stream": "stderr",
        "contains": "withholds a bounded allowance when the order notional is undefined",
        "next": "assert-waiver-quantized",
        "intent": "AC1: a bounded allowance is withheld, not granted, when the order cannot be priced"
      },
      "assert-waiver-quantized": {
        "action": "assert_output",
        "node": "waiver-formula",
        "stream": "stderr",
        "contains": "quotes the venue-quantized rate the submit path charges",
        "next": "assert-waiver-malformed-cloid",
        "intent": "AC3: the preview quotes the venue-floored rate submit charges, not the raw fraction"
      },
      "assert-submit-routed-close": {
        "action": "assert_output",
        "node": "submit",
        "stream": "stderr",
        "contains": "prices a routed close from the routed provider position",
        "next": "assert-submit-batch-route",
        "intent": "AC3: a routed close prices from the position its own route holds"
      },
      "assert-submit-batch-route": {
        "action": "assert_output",
        "node": "submit",
        "stream": "stderr",
        "contains": "prices a batch close only from positions the route can close",
        "next": "read-positions",
        "intent": "AC3: a batch close prices only from positions its write route can close"
      },
      "assert-resolver-cache-preserved": {
        "action": "assert_output",
        "node": "resolver",
        "stream": "stderr",
        "contains": "keeps a cached snapshot when a messenger-only benefits read rejects",
        "next": "assert-resolver-sync-throw",
        "intent": "AC5: a rejected messenger benefits read preserves the cached snapshot"
      },
      "assert-resolver-sync-throw": {
        "action": "assert_output",
        "node": "resolver",
        "stream": "stderr",
        "contains": "keeps a cached snapshot when a benefits handler throws synchronously",
        "next": "assert-resolver-no-register-hook",
        "intent": "AC5: a synchronous handler failure preserves the cached snapshot rather than erasing it"
      },
      "assert-resolver-no-register-hook": {
        "action": "assert_output",
        "node": "resolver",
        "stream": "stderr",
        "contains": "reports the gap when the client has no registration hook",
        "next": "assert-resolver-register-action",
        "intent": "AC5: a client with no registration hook is reported rather than silently unattributed"
      },
      "assert-waiver-malformed-cloid": {
        "action": "assert_output",
        "node": "waiver-formula",
        "stream": "stderr",
        "contains": "rejects a malformed cloid whose flag byte is only partly hex",
        "next": "assert-resolver-perps-benefit",
        "intent": "AC4: a malformed client order ID is not reported as fee-reduced"
      },
      "assert-resolver-perps-benefit": {
        "action": "assert_output",
        "node": "resolver",
        "stream": "stderr",
        "contains": "withholds the waiver when the perps block reports no benefit",
        "next": "controller",
        "intent": "AC1: the waiver needs positive evidence of a perps benefit, not merely a present block"
      },
      "assert-waiver-unknown-policy": {
        "action": "assert_output",
        "node": "waiver-formula",
        "stream": "stderr",
        "contains": "leaves a zero rate untouched when the provider reports no fee policy",
        "intent": "AC3: a provider that reports no builder-fee policy keeps its own zero rate, so an omitted optional field cannot add a fee",
        "next": "assert-waiver-concurrent-submit"
      },
      "assert-waiver-concurrent-submit": {
        "action": "assert_output",
        "node": "waiver-formula",
        "stream": "stderr",
        "contains": "re-prices a zero provider rate left behind by a concurrent waived submit",
        "intent": "AC3: a preview racing a fully waived submit does not inherit that waiver",
        "next": "assert-waiver-nonhex-cloid"
      },
      "assert-waiver-nonhex-cloid": {
        "action": "assert_output",
        "node": "waiver-formula",
        "stream": "stderr",
        "contains": "rejects a length-correct, prefix-matching id whose body is not hex",
        "intent": "AC4: the program-marker predicate rejects malformed client order IDs rather than feeding the decoder garbage",
        "next": "resolver"
      },
      "assert-resolver-quantization": {
        "action": "assert_output",
        "node": "resolver",
        "stream": "stderr",
        "contains": "does not claim the subscription source when the blend quantizes to the full fee",
        "intent": "AC2/AC3: a blend that rounds to the full fee at venue precision cannot report source=subscription",
        "next": "assert-resolver-exhausted"
      },
      "assert-resolver-register-action": {
        "action": "assert_output",
        "node": "resolver",
        "stream": "stderr",
        "contains": "registers through SubscriptionController for messenger-only clients",
        "next": "provider",
        "intent": "AC5: messenger-only clients can route address registration through SubscriptionController"
      }
    }
  }
}

Validation Logs

Full output (47/47 passed, pass)
# MetaMask Recipe Run

Status: pass
Duration: 12s
Nodes: 47/47 passed

## Steps
- PASS status (app.status, 1ms): platform=core
- PASS waiver-formula (command, 1.3s): exitCode=0, stderr=PASS perps-controller packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts
  resolveSubscriptionWaiverRate
    ✓ waives the whole fee when the remaining allowance covers the order notional (2 ms)
    ✓ waives the whole fee at the exact boundary where remaining equals the notional (1 ms)
    ✓ blends the fee by the uncovered share when the allowance is smaller than the order notional
    ✓ charges almost the full fee when the allowance barely covers the order
    ✓ does not apply when the gate did not pass (1 ms)
    ✓ does not apply when an eligible gate reports a spent allowance
    ✓ treats an unbounded allowance as a full waiver (1 ms)
    ✓ withholds a bounded allowance when the order notional is undefined
    ✓ withholds a bounded allowance when the order notional is 0
    ✓ withholds a bounded allowance when the order notional is -100 (1 ms)
    ✓ withholds a bounded allowance when the order notional is NaN
    ✓ still waives an unbounded allowance when the order notional is undefined
    ✓ still waives an unbounded allowance when the order notional is 0 (1 ms)
  markSubscriptionCloid
    ✓ marks a fresh cloid with the subscription program id and the fee-reduction flag
    ✓ rejects a length-correct, prefix-matching id whose body is not hex
    ✓ pads short entropy rather than producing a malformed cloid
    ✓ produces distinct cloids for distinct entropy
    ✓ preserves an existing Scale cloid marker and its rung index
    ✓ returns a caller-supplied client order ID untouched (1 ms)
    ✓ returns a short caller client order ID untouched rather than replacing it
    ✓ preserves a caller client order ID that begins with a reserved marker (1 ms)
    ✓ re-stamps an id only when it is declared as package-generated
    ✓ rejects a caller client order ID that is not hex (13 ms)
    ✓ keeps every marked rung of one ladder distinct (1 ms)
  hasFeeReductionAppliedFlag
    ✓ reports no flag for undefined (1 ms)
    ✓ reports no flag for null
    ✓ reports no flag for ""
    ✓ reports no flag for "0xdeadbeef" (3 ms)
    ✓ does not trust the flag byte outside the subscription program marker (1 ms)
    ✓ reports no false positives across a legacy Scale ladder population (1 ms)
    ✓ rejects a malformed cloid whose flag byte is only partly hex (1 ms)
    ✓ reports no flag for an unmarked Scale cloid, whose flag byte is reserved
  applyFeeResolution
    ✓ re-prices the MetaMask component and the total from a blended resolution (1 ms)
    ✓ zeroes the MetaMask component on a full waiver
    ✓ quotes the venue-quantized rate the submit path charges
    ✓ reprices to the undiscounted rate when the default source won (1 ms)
    ✓ leaves quoted amounts alone for a non-positive notional of "-1000"
    ✓ leaves quoted amounts alone for a non-positive notional of "0"
    ✓ leaves the quote untouched when no resolution was computed (1 ms)
    ✓ leaves a placement that carries no builder fee untouched
    ✓ leaves a zero rate untouched when the provider reports no fee policy
    ✓ re-prices a zero provider rate left behind by a concurrent waived submit (1 ms)
    ✓ re-prices rates without amounts when no notional was supplied

Test Suites: 1 passed, 1 total
Tests:       43 passed, 43 total
Snapshots:   0 total
Time:        0.41 s, estimated 1 s
Ran all test suites matching packages/perps-controller/tests/src/utils/subscriptionFeeWaiver.test.ts.

- PASS assert-waiver-formula-exit (assert_exit_code, 1ms): source=waiver-formula, expected=0, actual=0
- PASS assert-full-waiver (assert_output, 1ms): source=waiver-formula, stream=stderr, contains=waives the whole fee when the remaining allowance covers the order notional
- PASS assert-blended-waiver (assert_output, 0ms): source=waiver-formula, stream=stderr, contains=blends the fee by the uncovered share when the allowance is smaller than the order notional
- PASS assert-waiver-unknown-policy (assert_output, 0ms): source=waiver-formula, stream=stderr, contains=leaves a zero rate untouched when the provider reports no fee policy
- PASS assert-waiver-concurrent-submit (assert_output, 0ms): source=waiver-formula, stream=stderr, contains=re-prices a zero provider rate left behind by a concurrent waived submit
- PASS assert-waiver-nonhex-cloid (assert_output, 0ms): source=waiver-formula, stream=stderr, contains=rejects a length-correct, prefix-matching id whose body is not hex
- PASS resolver (command, 1.2s): exitCode=0, stderr=PASS perps-controller packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts
  RewardsIntegrationService
    calculateUserFeeDiscount
      ✓ calculates fee discount successfully with valid discount (3 ms)
      ✓ returns 0 when no discount available (1 ms)
      ✓ returns undefined when rewards subscription state has not hydrated yet (1 ms)
      ✓ returns undefined when no EVM account found
      ✓ returns undefined when chain ID not found (1 ms)
      ✓ returns undefined when getFeeDiscount throws error
      ✓ returns undefined when NetworkController throws error (1 ms)
      ✓ handles different chain IDs correctly (1 ms)
      ✓ calculates discount percentage correctly in logs (2 ms)
    unified fee resolver
      ✓ returns the lowest fee bips across the default, rewards and subscription sources (3 ms)
      ✓ resolves the subscription source to a 0 bips fee only when the eligibility gate passes (8 ms)
      ✓ does not claim the subscription source when the blend quantizes to the full fee (2 ms)
      ✓ still claims the subscription source when the blend survives quantization (1 ms)
      ✓ does not start a benefits network read on the fee resolution path (1 ms)
      ✓ serves a stale snapshot without refreshing on the cache-read path (1 ms)
      ✓ falls back to the next-lowest source when the cached benefits snapshot is hard-stale (2 ms)
      ✓ falls back to the next-lowest source when the benefits read is unreachable (2 ms)
      ✓ honors exhausted=true from the backend on the next cache refresh (1 ms)
      ✓ reports no subscription source when the dependency is not wired (1 ms)
      ✓ deduplicates concurrent benefits refreshes (1 ms)
      ✓ keeps pure cache reads off the network after a failed refresh (2 ms)
      ✓ invalidates the cached benefits snapshot on demand (1 ms)
      ✓ discards an in-flight benefits read that resolves after invalidation (1 ms)
      ✓ starts a fresh read when the next caller arrives while a fenced read is still in flight (1 ms)
      ✓ uses a background refresh that lands during the rewards round trip (1 ms)
      ✓ keeps calculateUserFeeDiscount returning the resolved discount bips (1 ms)
      ✓ waives the whole fee when the remaining allowance covers the order notional (1 ms)
      ✓ waives the whole fee when the remaining allowance exactly equals the order notional (1 ms)
      ✓ blends the fee by the uncovered share when the allowance is smaller than the order notional (2 ms)
      ✓ lets a rewards discount beat a partial subscription blend (1 ms)
      ✓ lets a partial subscription blend win when it undercuts rewards (1 ms)
      ✓ withholds the waiver when the allowance is exhausted (1 ms)
      ✓ withholds the waiver when an eligible gate reports a spent allowance (1 ms)
      ✓ withholds the blend when the cached snapshot is hard-stale (1 ms)
      ✓ withholds a bounded allowance when no order notional is supplied (2 ms)
      ✓ still waives an unbounded allowance with no order notional (1 ms)
      ✓ drops the subscription source when the remote feature flag disables it (5 ms)
      ✓ keeps the subscription source when the remote flag is enabled or absent (1 ms)
      ✓ reads benefits through the SubscriptionController action when one is registered (1 ms)
      ✓ hydrates and grants the waiver for a messenger-only client (1 ms)
      ✓ keeps a cached snapshot when a messenger-only benefits read rejects (2 ms)
      ✓ keeps a cached snapshot when a benefits handler throws synchronously (1 ms)
      ✓ drops a cached waiver when the profile is definitively not subscribed (1 ms)
      ✓ withholds the waiver when the perps block reports no benefit (1 ms)
      ✓ keeps the waiver when the perps block reports a builder fee but no cap
      ✓ does not cache a first-call handler failure as no subscription (1 ms)
      ✓ still reports no source when neither wiring is present
      ✓ falls back to the injected benefits source when no SubscriptionController action is registered (1 ms)
      ✓ registers the trading address once per address and again after a reset (1 ms)
      ✓ reports the gap when the client has no registration hook
      ✓ never throws when address registration is unavailable (1 ms)
      ✓ each instance uses its own deps (1 ms)

Test Suites: 1 passed, 1 total
Tests:       52 passed, 52 total
Snapshots:   0 total
Time:        0.467 s, estimated 1 s
Ran all test suites matching packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts.

- PASS assert-resolver-exit (assert_exit_code, 0ms): source=resolver, expected=0, actual=0
- PASS assert-resolver-blend-loses (assert_output, 1ms): source=resolver, stream=stderr, contains=lets a rewards discount beat a partial subscription blend
- PASS assert-resolver-quantization (assert_output, 0ms): source=resolver, stream=stderr, contains=does not claim the subscription source when the blend quantizes to the full fee
- PASS assert-resolver-exhausted (assert_output, 0ms): source=resolver, stream=stderr, contains=withholds the waiver when the allowance is exhausted
- PASS assert-resolver-flag (assert_output, 0ms): source=resolver, stream=stderr, contains=drops the subscription source when the remote feature flag disables it
- PASS assert-resolver-messenger-only (assert_output, 0ms): source=resolver, stream=stderr, contains=hydrates and grants the waiver for a messenger-only client
- PASS assert-resolver-cache-preserved (assert_output, 0ms): source=resolver, stream=stderr, contains=keeps a cached snapshot when a messenger-only benefits read rejects
- PASS assert-resolver-sync-throw (assert_output, 0ms): source=resolver, stream=stderr, contains=keeps a cached snapshot when a benefits handler throws synchronously
- PASS assert-resolver-no-register-hook (assert_output, 0ms): source=resolver, stream=stderr, contains=reports the gap when the client has no registration hook
- PASS provider (command, 2.7s): exitCode=0, stderr=PASS perps-controller packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts
  HyperLiquidProvider
    Builder Fee and Referral Integration
      ✓ includes builder fee and referral setup in order placement (19 ms)
      ✓ keeps the standard builder address when subscription wins (10 ms)
      ✓ charges a blended subscription fee on the standard builder (4 ms)
      ✓ initializes clients before approving the subscription builder (7 ms)
      ✓ keeps subscription approval reads scoped to the initiating account (5 ms)
      ✓ fences subscription approval across disconnect and preserves reconnect dedupe (5 ms)
      ✓ includes builder fee and referral setup in TP/SL updates (4 ms)
      ✓ uses HTTP for builder fee reads during a cold-start TP/SL update (5 ms)
      ✓ uses a builder approval completed while acquiring the global lock (4 ms)
      ✓ skips referral setup when user is the builder (4 ms)
      ✓ handles builder fee approval failure (non-blocking) (6 ms)
      ✓ retries builder fee approval after a previous attempt failed (8 ms)
      ✓ skips builder fee retry when previous attempt succeeded (4 ms)
      ✓ leaves builder fee cache empty when wrapped KEYRING_LOCKED is thrown (4 ms)
      ✓ handles referral code setup failure (non-blocking) (4 ms)
      ✓ leaves referral cache empty when wrapped KEYRING_LOCKED is thrown (3 ms)
      ✓ skips referral setup when referral code is not ready (2 ms)
      ✓ skips referral setup when user already has a referral (3 ms)
      ✓ uses testnet builder address when in testnet mode (3 ms)
    Builder Fee Global Cache (PR #25334)
      ○ skipped returns early when global cache indicates already attempted
      ○ skipped waits for in-flight operation instead of duplicating request
      ○ skipped caches success after successful approval
      ○ skipped caches failure to prevent repeated signing requests
      ○ skipped skips cache when KEYRING_LOCKED error is thrown
    Referral Global Cache (PR #25334)
      ○ skipped returns early when global cache indicates already attempted
      ○ skipped waits for in-flight operation instead of duplicating request
      ○ skipped caches success after successful referral setup
      ○ skipped caches failure to prevent repeated signing requests
      ○ skipped caches success when user already has referral on-chain
      ○ skipped skips cache and Sentry when KEYRING_LOCKED error is thrown
    ADR 0064 subscription cloid marking
      ✓ marks the cloid with the subscription program id when subscription wins (8 ms)
      ✓ leaves the cloid unmarked when any other fee source wins (3 ms)
      ✓ leaves the cloid unmarked when a near-exhausted allowance reduced nothing (5 ms)
      ✓ still marks the cloid when a partial blend genuinely reduces the fee (2 ms)
      ✓ marks the cloid on a TP/SL placement path (4 ms)
      ✓ marks the cloid on a position TP/SL update path (3 ms)
      ✓ keeps each marked order id unique within one submission (3 ms)

PASS perps-controller packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts
  HyperLiquidProvider
    Trading Operations
      ✓ brings the SDK clients up before reading asset metadata (14 ms)
      ✓ places a market order successfully (4 ms)
      ✓ places a limit order successfully (4 ms)
      ✓ uses Gtc TIF for limit orders (regression test) (4 ms)
      ✓ tracks performance measurements when placing order (7 ms)
      ✓ calculates USD position size correctly for market orders (4 ms)
      ✓ calculates USD position size correctly for limit orders (3 ms)
      ✓ handles order placement errors (4 ms)
      ✓ edits an order successfully (5 ms)
      ✓ edits a market order with slippage calculation (5 ms)
      ✓ handles editOrder when asset is not found (2 ms)
      ✓ handles editOrder when no price is available (2 ms)
      ✓ falls back to REST API when cached price is zero (2 ms)
      ✓ falls back to REST API when cached price is NaN (3 ms)
      ✓ falls back to REST API when cached price is negative (2 ms)
      ✓ falls back to REST API when cached price is Infinity (7 ms)
      ✓ throws error when REST price is negative (2 ms)
      ✓ throws error when REST price is Infinity (3 ms)
      ✓ handles editOrder when asset ID is not found (2 ms)
      ✓ cancels an order successfully (2 ms)
      ✓ self-heals an empty prefetch asset map before validating the coin on cancel (6 ms)
      ✓ still rejects a genuinely unknown coin after cancel hydration (2 ms)
      ✓ propagates unrelated cancel failures unchanged (2 ms)
      ✓ retries USD-based order when rejected for $10 minimum with adjusted amount (9 ms)
      ✓ retries size-based order with currentPrice when rejected for $10 minimum (3 ms)
      ✓ retries with adjusted USD when price-less order hits $10 minimum (uses fetched price from allMids) (11 ms)
      ✓ closes a position successfully (3 ms)
      ✓ repairs missing HIP-3 asset IDs during closePosition after degraded discovery (6 ms)
      ✓ rejects placeOrder for a HIP-3 DEX whose collateral token is not USDC (TAT-3304) (5 ms)
      ✓ rejects placeOrder for a HIP-3 DEX whose collateral token index cannot be resolved against spot metadata (4 ms)
    closePosition with TP/SL handling
      ✓ closes position without TP/SL successfully (1 ms)
      ✓ handles position with TP/SL successfully (3 ms)
      ✓ handles partial position close with TP/SL (10 ms)
      ✓ handles position without open TP/SL orders (3 ms)
      ✓ handles close position when position not found (1 ms)
      ✓ handles short position close with TP/SL (3 ms)
      ✓ handles position close even if TP/SL info is unavailable (3 ms)
    closePosition reduce-only safety
      ✓ uses the live position size when the provided snapshot is stale (2 ms)
      ✓ uses the live position side when the snapshot direction is stale (2 ms)
      ✓ revalidates against the per-DEX slice, not a frozen aggregate, after a reconnect (2 ms)
      ✓ does not fail a close for a position missing only from a frozen aggregate (2 ms)
      ✓ fails fast without submitting an order or a REST lookup when the position is already closed (4 ms)
      ✓ clamps a requested close size to the live position size (2 ms)
      ✓ rejects a non-positive close size instead of closing the whole position (2 ms)
      ✓ rejects a non-numeric close size (2 ms)
      ✓ treats an empty close size as a full close (5 ms)
      ✓ rejects a full close whose price moved beyond maxSlippageBps (4 ms)
      ✓ submits the exact live size for a full close inside maxSlippageBps (2 ms)
      ✓ caps a partial close at the clamped size when usdAmount implies more (3 ms)
      ✓ revalidates against a live slice while the aggregate flag is still false (2 ms)
      ✓ uses the target-DEX REST position when the WebSocket slice is unavailable (4 ms)
      ✓ fetches live positions for a symbol whose DEX the cache does not cover (6 ms)
      ✓ uses the live position when the target DEX answers the uncovered-DEX lookup (2 ms)
      ✓ fails when the target DEX answers with zero positions (2 ms)
      ✓ fails when the target DEX answers without the position but holds others (1 ms)
      ✓ reports provider unavailable when the target DEX query fails (3 ms)
      ✓ reports provider unavailable when the HIP-3 target DEX query fails (2 ms)
      ✓ keeps the clamp when a partial close also carries usdAmount (3 ms)
      ✓ clamps a close of the entire position requested by explicit size (2 ms)
      ✓ submits the exact position size for a full close instead of a USD-derived size (2 ms)
      ✓ never rounds a partial close size up to meet the requested USD (1 ms)
      ✓ does not retry a close rejected for the $10 minimum with a larger size (2 ms)
      ✓ does not retry a partial close rejected for the $10 minimum either (5 ms)
      ✓ rejects a reduce-only close whose size floors to zero (2 ms)
      ✓ closes a position the frozen aggregate omits when no snapshot is supplied (2 ms)
      ✓ uses target-DEX REST when no current slice exists (3 ms)
      ✓ does not report a position closed when its target-DEX REST query fails (1 ms)
      ✓ still reports no position when both the aggregate and the fresh slice agree it is gone (1 ms)
      ✓ closes a HIP-3 position the frozen aggregate has never seen (2 ms)
      ✓ keeps a grid-aligned close size intact despite floating point error (1 ms)
    closePositions reduce-only safety
      ✓ closes the live per-DEX size, not the frozen aggregate size (2 ms)
      ✓ closes on the live per-DEX side when the aggregate side is stale (5 ms)
      ✓ closes a position present only in the fresh per-DEX slice (1 ms)
      ✓ does not submit an order for a position the fresh slice reports closed (2 ms)
      ✓ closes a HIP-3 position the frozen aggregate has never seen (3 ms)
      ✓ uses REST while another expected DEX has not initialized (9 ms)
      ✓ does not replace a fresh REST result with an older WebSocket slice (2 ms)
      ✓ uses REST when the expected DEX slices are incomplete (2 ms)
      ✓ rejects a partial all-DEX REST snapshot (2 ms)
      ✓ closes a selected BTC position when loading xyz positions fails (5 ms)
      ✓ closes selected BTC when a requested xyz DEX load fails (3 ms)
      ✓ preserves unavailable symbols when batch submission fails (2 ms)
      ✓ rejects a main-only REST snapshot when HIP-3 DEX discovery fails (2 ms)
      ✓ rejects a malformed all-DEX REST position response (2 ms)
    updateMargin position freshness
      ✓ uses the live per-DEX side instead of the frozen aggregate side (1 ms)
      ✓ finds a position present only in the live per-DEX slice (3 ms)
      ✓ does not update a position the live per-DEX slice reports closed
      ✓ uses REST when the target DEX has not republished
      ✓ reports provider unavailable when the target-DEX REST query fails (1 ms)
      ✓ uses the current HIP-3 slice without a REST lookup (2 ms)
    Batch Operations
      cancelOrders
        ✓ returns failure when no orders provided (1 ms)
        ✓ cancels multiple orders successfully (1 ms)
        ✓ handles batch cancel errors
        ✓ maps recognized per-status batch cancel rejections to a standardized code (4 ms)
        ✓ rejects a non-ok batch response even when its statuses say success (1 ms)
        ✓ maps recognized batch cancel rejections to a standardized code (1 ms)
      closePositions
        ✓ returns failure when no positions to close
        ✓ closes multiple positions successfully (1 ms)
        ✓ rounds each reduce-only close size down to the size grid (1 ms)
        ✓ reports a position smaller than one size increment as failed and still closes the rest (2 ms)
        ✓ credits a HIP-3 margin transfer only to its own order in a mixed batch (2 ms)
        ✓ reports every position as failed when all of them are dust (3 ms)
        ✓ handles batch close errors (1 ms)
    updatePositionTPSL
      ✓ updates position TP/SL successfully (2 ms)
      ✓ handles update with only take profit price
      ✓ handles update with only stop loss price (1 ms)
    ADR 0064 subscription cloid marking on replace and batch paths
      ✓ leaves the replacement cloid unmarked because modify charges no builder fee (1 ms)
      ✓ marks the cloid on the batch close path (1 ms)
      ✓ leaves the cloid unmarked when any other fee source wins (1 ms)

PASS perps-controller packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts
  HyperLiquidProvider - strategy order types
    native TWAP margin-mode occupancy
      ✓ blocks conflicting mode for activated schedules with no fills (12 ms)
      ✓ blocks conflicting mode for waitingForTrigger schedules with no fills (4 ms)
      ✓ blocks conflicting mode for futureVenueStatus schedules with no fills (3 ms)
      ✓ allows matching mode while a native TWAP remains active (3 ms)
      ✓ allows mode changes after a finished terminal update supersedes activation (7 ms)
      ✓ allows mode changes after a stopped terminal update supersedes activation (3 ms)
      ✓ allows mode changes after a terminated terminal update supersedes activation (4 ms)
      ✓ allows mode changes after a error terminal update supersedes activation (3 ms)
      ✓ compares mixed venue timestamp units before selecting a schedule status (7 ms)
      ✓ ignores active TWAP schedules for another asset (2 ms)
      ✓ fails closed if fresh TWAP history is unavailable (3 ms)
      ✓ rechecks TWAP occupancy between preview and placement (4 ms)
      ✓ preserves omitted-mode requests without a new history read (4 ms)
    explicit margin mode across order types
      stop_market
        ✓ rejects a mode change while an unfilled native TWAP is active before signing (10 ms)
        ✓ forwards cross mode at the SDK boundary (7 ms)
        ✓ forwards isolated mode at the SDK boundary (5 ms)
        ✓ forwards undefined mode at the SDK boundary (5 ms)
        ✓ rejects a conflicting mode before signatures or submission (6 ms)
        ✓ rejects an unsupported market before signatures or submission (2 ms)
      twap
        ✓ rejects a mode change while an unfilled native TWAP is active before signing (3 ms)
        ✓ forwards cross mode at the SDK boundary (8 ms)
        ✓ forwards isolated mode at the SDK boundary (5 ms)
        ✓ forwards undefined mode at the SDK boundary (2 ms)
        ✓ rejects a conflicting mode before signatures or submission (6 ms)
        ✓ rejects an unsupported market before signatures or submission (2 ms)
      scale
        ✓ rejects a mode change while an unfilled native TWAP is active before signing (2 ms)
        ✓ forwards cross mode at the SDK boundary (5 ms)
        ✓ forwards isolated mode at the SDK boundary (3 ms)
        ✓ forwards undefined mode at the SDK boundary (8 ms)
        ✓ rejects a conflicting mode before signatures or submission (2 ms)
        ✓ rejects an unsupported market before signatures or submission (1 ms)
      chase
        ✓ rejects a mode change while an unfilled native TWAP is active before signing (3 ms)
        ✓ forwards cross mode at the SDK boundary (3 ms)
        ✓ forwards isolated mode at the SDK boundary (2 ms)
        ✓ forwards undefined mode at the SDK boundary (3 ms)
        ✓ rejects a conflicting mode before signatures or submission (4 ms)
        ✓ rejects an unsupported market before signatures or submission (1 ms)
    Builder fee policy
      ✓ applies one builder context to a default parent and TP/SL batch (3 ms)
      ✓ applies one builder context to a position TP/SL batch (3 ms)
      ✓ keeps existing protection when builder approval fails (6 ms)
      ✓ does not replace whole-position protection when pre-cancel is refused (5 ms)
      ✓ reports lost protection when the old cancel outcome is unknown (3 ms)
      ✓ restores whole-position protection when replacement fails after pre-cancel (7 ms)
      ✓ reports lost protection when restoration returns a rejected status (2 ms)
      ✓ reports lost protection when restoration throws (3 ms)
      ✓ reports recreated protection IDs when restoration is incomplete (2 ms)
      ✓ restores only the order confirmed cancelled when replacement fails (3 ms)
      ✓ restores a standalone trigger with its remaining size and builder fee (3 ms)
      ✓ accepts an old TP/SL order that is already gone before replacement (1 ms)
      ✓ rejects a TP/SL batch with one failed placement status (2 ms)
      ✓ returns a recoverable ID when partial-placement cleanup is refused (5 ms)
      ✓ reports lost protection when whole-position cleanup is refused (2 ms)
      ✓ reports a filled partial trigger without trying to cancel it (1 ms)
      ✓ restores protection removed by a mixed old pre-cancel without placing the replacement (2 ms)
      ✓ accepts one successful placement status per TP/SL order (2 ms)
      ✓ accepts waitingForTrigger for a combined TP/SL placement (2 ms)
      ✓ accepts mixed resting and waitingForTrigger placement statuses (1 ms)
      ✓ replaces existing protection when the trigger waits for activation (1 ms)
      ✓ reconciles a waiting trigger before cleaning up a mixed failure (5 ms)
      ✓ reports lost protection when a waiting trigger cannot be reconciled after a mixed failure (2 ms)
      ✓ restores old protection after an unknown placement status (2 ms)
      ✓ restores old protection after an incomplete placement response (1 ms)
      ✓ cancels old protection before placing partial TP/SL (1 ms)
      ✓ restores old protection when a partial TP/SL replacement fails (2 ms)
    TWAP placement
      ✓ does not request builder-fee approval (3 ms)
      ✓ approves the builder fee when a standard order follows a TWAP (4 ms)
      ✓ rejects provider-level TWAP duration undefined before submission (2 ms)
      ✓ rejects provider-level TWAP duration 1.5 before submission (9 ms)
      ✓ rejects provider-level TWAP duration 0 before submission (1 ms)
      ✓ rejects provider-level TWAP duration 1441 before submission (2 ms)
      ✓ rejects provider-level TWAP duration 9007199254740992 before submission (2 ms)
      ✓ still approves the builder fee when a standard order joins TWAP setup (2 ms)
      ✓ does not restore spot metadata after disconnect (4 ms)
      ✓ submits the venue TWAP action rather than an order (4 ms)
      ✓ returns the venue TWAP id as the handle (2 ms)
      ✓ defaults randomize and reduce-only to false (1 ms)
      ✓ surfaces a venue rejection as a failed result (1 ms)
      ✓ rejects malformed TWAP response id "987" (1 ms)
      ✓ rejects malformed TWAP response id NaN (1 ms)
      ✓ rejects malformed TWAP response id Infinity (1 ms)
      ✓ rejects malformed TWAP response id -1 (1 ms)
      ✓ rejects malformed TWAP response id 1.5 (3 ms)
    TWAP cancellation
      ✓ uses the TWAP cancel endpoint, never the order cancel endpoint (1 ms)
      ✓ rejects malformed TWAP handle "987junk" before signing (1 ms)
      ✓ rejects malformed TWAP handle "NaN" before signing (1 ms)
      ✓ rejects malformed TWAP handle "-1" before signing (1 ms)
      ✓ rejects malformed TWAP handle "1.5" before signing
      ✓ rejects malformed TWAP handle "9007199254740992" before signing (1 ms)
      ✓ treats an already-finished TWAP cancel as successful (1 ms)
      ✓ reports a refused TWAP cancel as a failure (4 ms)
      ✓ rejects a handle that neither tracking nor venue history can authenticate (1 ms)
      ✓ cancels an untracked TWAP when venue history is temporarily unavailable (2 ms)
      ✓ does not authenticate an unknown TWAP through a failed history read (1 ms)
      ✓ preserves handle ownership across provider recreation (2 ms)
      ✓ leaves an ordinary cancel on the order endpoint (1 ms)
      ✓ retains the requested exchange ID when an ordinary cancel rejects (2 ms)
      ✓ rejects every ordinary cancel when the batch response is truncated (3 ms)
    TWAP lifecycle
      ✓ adapts active progress and slice fills from the venue (3 ms)
      ✓ deduplicates history and distinguishes an underfilled completion (2 ms)
      ✓ bounds negative venue execution before reporting TWAP progress (2 ms)
      ✓ bounds oversized venue execution before reporting TWAP progress (2 ms)
      ✓ maps the venue waitingForTrigger status to active (1 ms)
      ✓ maps the venue stopped status to canceled (4 ms)
      ✓ maps the venue futureStatus status to active (2 ms)
      ✓ omits a TWAP with malformed venue decimals (2 ms)
      ✓ coalesces concurrent HIP-3 TWAP collateral cleanup (4 ms)
      ✓ retries a failed HIP-3 collateral rebalance after provider recreation (5 ms)
      when a completing fill ties lastUpdated across history entries
        ✓ keeps the terminal record (venue order, terminal record first) (1 ms)
        ✓ keeps the terminal record (reversed, activation first) (1 ms)
    Scale placement
      ✓ fans out one order per rung, spread across the range (2 ms)
      ✓ marks every rung cloid when subscription wins and keeps the ladder recoverable (1 ms)
      ✓ leaves rung cloids unmarked when subscription did not win (3 ms)
      ✓ submits the provider preview prices for fractional bounds (3 ms)
      ✓ splits the size across the rungs so the total is preserved (2 ms)
      ✓ weights the rungs along the ladder when a skew is supplied (2 ms)
      ✓ weights the bottom of the ladder for a skew below 1 (1 ms)
      ✓ splits evenly for a skew of exactly 1 (1 ms)
      ✓ rests every rung as a plain GTC limit order (1 ms)
      ✓ returns the ladder children alongside a group handle (3 ms)
      ✓ fails when every rung is rejected (2 ms)
      ✓ rejects missing placement statuses (2 ms)
      ✓ rejects non-array placement statuses (2 ms)
      ✓ reports accepted IDs after a non-ok response but keeps only resting rungs recoverable (2 ms)
      ✓ cancels every child of the group in one batch (2 ms)
      ✓ does not restore a canceled group from a stale open-order cache (3 ms)
      ✓ does not shrink a live Scale group from a partial open-order snapshot (3 ms)
      ✓ cleans a waiting child instead of registering it for later recovery (2 ms)
      ✓ adds later Scale rungs to a partially recovered group (3 ms)
      ✓ reports an incomplete group cancel and keeps the handle for a retry (4 ms)
      ✓ rejects a cancel for a group it does not hold (1 ms)
    Chase placement
      ✓ rests post-only at the near touch for a buy (2 ms)
      ✓ refreshes the touch and retries an initial post-only rejection (2 ms)
      ✓ refreshes the touch and retries an initial oracle-distance rejection (3 ms)
      ✓ stops after three retryable initial-placement rejections (2 ms)
      ✓ does not retry a non-retryable initial-placement rejection (2 ms)
      ✓ rests at the best ask for a sell (2 ms)
      ✓ returns a session handle carrying the live order (2 ms)
      ✓ exposes the running session state needed by clients (4 ms)
      ✓ refreshes the snapshot remaining size after a partial fill (3 ms)
      ✓ retries a temporarily unknown child before refreshing Chase state (106 ms)
      ✓ marks a confirmed fill and stops the Chase timer (2 ms)
      ✓ does not report an externally cancelled child as filled
      ✓ reports a canceled child with no remainder as filled (1 ms)
      ✓ backgrounds every active session without cancelling its resting child (1 ms)
      ✓ retains HIP-3 collateral while a Chase child rests and retries cleanup (1 ms)
      ✓ reports a backgrounded child that later fills as filled (1 ms)
      ✓ fails when the book has no price on the side it must rest at
      ✓ cancels the live order and stops the session (1 ms)
      ✓ deduplicates concurrent termination of the same Chase (1 ms)
      ✓ stops the owning session when its child is cancelled directly
      ✓ returns an error result when child cancellation setup fails (2 ms)
      ✓ backgrounds an admitted placement while blocking newer placements (1 ms)
      ✓ reports an incomplete chase cancel and keeps the handle for a retry (1 ms)
      ✓ keeps the terminal reason when cancelling a stopped Chase fails (1 ms)
      ✓ rejects a cancel for a session it does not hold (1 ms)
    Placement validation
      ✓ does not reach the exchange when shared validation rejects (1 ms)
      ✓ places a twap strategy on a HIP-3 market (2 ms)
      ✓ places a scale strategy on a HIP-3 market (1 ms)
      ✓ places a chase strategy on a HIP-3 market (1 ms)
    Existing order types are unaffected
      ✓ still routes a market order through the order action (1 ms)
      ✓ still routes a limit order through the order action (1 ms)
    Chase re-pricing loop
      ✓ cancels and re-places when the touch moves (3 ms)
      ✓ does not replace a child cancelled directly during a reprice (1 ms)
      ✓ does not replace a Chase child cancelled in a batch during a reprice (1 ms)
      ✓ finishes an admitted reprice before suspending its replacement (1 ms)
      ✓ leaves the order alone while the touch holds still (1 ms)
      ✓ uses the throttled default interval when none is supplied (1 ms)
      ✓ defers re-pricing while another Chase placement is in flight (2 ms)
      ✓ drains an in-flight re-price before starting another Chase placement (1 ms)
      ✓ stops chasing once the order is no longer resting (1 ms)
      ✓ stops re-pricing at the repricing cap (1 ms)
      ✓ stops re-pricing once the window closes (1 ms)
      ✓ rests at the configured max-distance boundary and stops chasing (1 ms)
      ✓ keeps chasing when the touch moves favorably beyond max distance (2 ms)
      ✓ stops every running chase on disconnect (1 ms)
    Scale ladder is not atomic
      ✓ rejects and cleans every accepted status in a mixed resolved response containing waiting children (1 ms)
      ✓ rejects and cleans every accepted status in a mixed thrown response containing waiting children (1 ms)
      ✓ treats already-gone waiting-child cleanup as complete (1 ms)
      ✓ rejects an all-waiting Scale batch and cleans every rung by CLOID
      ✓ cancels every unclassified rung by CLOID for a future status (1 ms)
      ✓ cancels every unclassified rung by CLOID for a malformed status (1 ms)
      ✓ cancels every unclassified rung by CLOID for a multi-key hybrid status (2 ms)
      ✓ cancels every unclassified rung by CLOID for a truncated status array (1 ms)
      ✓ cancels every unclassified rung by CLOID for a malformed status payload
      ✓ keeps an unclassified rung retryable when CLOID cleanup is refused (2 ms)
      ✓ recovers a mixed Scale response thrown by the SDK (2 ms)
      ✓ cleans and rethrows a mixed response with a unknown status (1 ms)
      ✓ cleans and rethrows a mixed response with a invalid order ID (1 ms)
      ✓ cleans and rethrows a mixed response with a hybrid accepted and error entry (2 ms)
      ✓ preserves an all-rejected SDK error for existing error mapping (4 ms)
      ✓ does not unwrap a top-level SDK error (1 ms)
      ✓ does not unwrap a malformed SDK error (1 ms)
      ✓ does not unwrap a unrelated SDK SDK error (1 ms)
      ✓ does not unwrap a non-SDK SDK error (2 ms)
      ✓ keeps accepted rungs when the ladder only partly rests (2 ms)
      ✓ returns filled and resting IDs but cancels only resting rungs (2 ms)
      ✓ keeps an incomplete cleanup recoverable by its group handle (1 ms)
      ✓ accepts filled rungs and exposes all accepted IDs in the result (4 ms)
      ✓ rejects a malformed negative scale order ID instead of treating it as a rejected rung (2 ms)
      ✓ rejects a malformed fractional scale order ID instead of treating it as a rejected rung (1 ms)
      ✓ rejects a malformed unsafe scale order ID instead of treating it as a rejected rung (2 ms)
      ✓ rejects a malformed non-numeric scale order ID instead of treating it as a rejected rung (1 ms)
    Chase cancel racing a re-pricing tick
      ✓ rests nothing when a cancel lands between the tick cancelling and re-placing (4 ms)
      ✓ keeps the live child reachable when the tick cancel is refused (3 ms)
    Chase cancel racing the replacement placement
      ✓ cancels the replacement when the cancel lands during its round trip (2 ms)
    Fee quoting for strategy placements
      ✓ quotes market with its provider-owned builder fee policy (2 ms)
      ✓ quotes limit with its provider-owned builder fee policy (1 ms)
      ✓ quotes stop_market with its provider-owned builder fee policy (1 ms)
      ✓ quotes stop_limit with its provider-owned builder fee policy (1 ms)
      ✓ quotes take_profit_market with its provider-owned builder fee policy (1 ms)
      ✓ quotes take_profit_limit with its provider-owned builder fee policy (1 ms)
      ✓ quotes twap with its provider-owned builder fee policy (1 ms)
      ✓ quotes scale with its provider-owned builder fee policy (3 ms)
      ✓ quotes chase with its provider-owned builder fee policy (2 ms)
      ✓ uses the safe builder fee for unknown runtime order type future_order (1 ms)
      ✓ uses the safe builder fee for unknown runtime order type constructor (2 ms)
      ✓ uses the safe builder fee for unknown runtime order type toString (1 ms)
      ✓ uses the safe builder fee for unknown runtime order type __proto__ (1 ms)
      ✓ keeps a discounted TWAP quote at zero MetaMask builder fee (2 ms)
      ✓ returns zero fee amounts for a zero notional quote (1 ms)
      ✓ quotes a chase at the maker rate even when isMaker is false (5 ms)
      ✓ quotes a resting scale ladder at the maker rate (1 ms)
      ✓ quotes a TWAP at the taker protocol rate without a builder fee (1 ms)
    Order capabilities
      ✓ advertises the strategies supported for a routed market (2 ms)
      ✓ resolves support before the asset mapping is populated (2 ms)
      ✓ advertises strategies for an existing HIP-3 market (3 ms)
      ✓ does not advertise a HIP-3 market when the HIP-3 kill switch is off (3 ms)
      ✓ does not advertise a HIP-3 market when the market is blocklisted (4 ms)
      ✓ does not advertise a non-USDC-collateral HIP-3 market (2 ms)
      ✓ reports a missing HIP-3 market after checking its DEX metadata (2 ms)
      ✓ reports a disconnected HIP-3 provider as unavailable (2 ms)
      ✓ keeps delisted main-DEX discovery aligned with placement (4 ms)
      ✓ reports an empty symbol as invalid (1 ms)
      ✓ reports a route missing its market as invalid (2 ms)
      ✓ reports a route missing its DEX as invalid (5 ms)
      ✓ reports malformed symbol " ETH" as invalid (1 ms)
      ✓ reports malformed symbol "ETH " as invalid (2 ms)
      ✓ reports malformed symbol "ETH BTC" as invalid (1 ms)
      ✓ reports malformed symbol "a:b:c" as invalid (2 ms)
      ✓ reports an unknown main-DEX market as unavailable (2 ms)
      ✓ refreshes metadata at the freshness boundary (2 ms)
      ✓ shares fresh metadata across unrelated symbols (1 ms)
      ✓ does not reuse session-long metadata for capability discovery (4 ms)
      ✓ ages capability metadata from request completion (2 ms)
      ✓ isolates capability metadata from overlapping shared cache writes (2 ms)
      ✓ deduplicates concurrent metadata refreshes (2 ms)
      ✓ refreshes and coalesces capability metadata per HIP-3 DEX (3 ms)
      ✓ refreshes metadata after reconnect (2 ms)
      ✓ reports capabilities unavailable during disconnect (2 ms)
      ✓ does not reconnect when initialization overlaps a disconnect (2 ms)
      ✓ does not initialize while a disconnect is in progress (4 ms)
      ✓ shares one teardown between overlapping disconnects (1 ms)
      ✓ invalidates an in-flight refresh when client disconnect fails (2 ms)
      ✓ retries immediately after a metadata refresh failure (2 ms)
      ✓ discards an in-flight main-DEX refresh after disconnect (2 ms)
      ✓ does not cache capability metadata that resolves after disconnect (1 ms)
      ✓ does not cache general metadata that resolves after disconnect (2 ms)
      ✓ returns market data that finishes during disconnect without caching it (5 ms)
      ✓ does not cache Perp DEX metadata that resolves after disconnect (128 ms)
      ✓ clears cached capability metadata when the network changes (1 ms)
      ✓ retries client setup after initialization fails during a network toggle (2 ms)
      ✓ keeps the current network eligible when disconnect fails during a network toggle (5 ms)
      ✓ does not cache capability metadata that resolves after a network change (2 ms)
      ✓ reports unavailable when market metadata cannot be loaded (2 ms)
    Scale price ladder preview
      ✓ normalizes every rung with provider-owned market precision (2 ms)
      ✓ rejects rungs that collapse to duplicate provider prices (1 ms)
      ✓ reports a route owned by another provider (1 ms)
      ✓ reports an unknown market (1 ms)
    Network-scoped fee cache
      ✓ clears cached user fee rates when the network changes (2 ms)
      ✓ does not cache user fee rates that resolve after a network change (5 ms)
      ✓ does not cache Perp DEX metadata that resolves after a network change (2 ms)
      ✓ retains confirmed builder approval that finishes after disconnect (3 ms)
      ✓ does not share pending builder setup across accounts (1 ms)
    Strategy notional minimums
      ✓ rejects a TWAP below the venue's minimum total (1 ms)
      ✓ accepts a TWAP at the venue's minimum total (1 ms)
      ✓ never reaches the exchange for an under-funded ladder (2 ms)
      ✓ never reaches the exchange when a skew starves the cheapest rung (1 ms)
      ✓ accepts the same ladder without the skew (4 ms)
      ✓ never reaches the exchange for an invalid skew (1 ms)
      ✓ leaves a chase on the ordinary per-order minimum (1 ms)
    Chase tick failures
      ✓ keeps chasing when a book read fails, leaving the order resting (3 ms)
      ✓ ends the session cleanly when the replacement fails to rest (2 ms)
    A cancel refused because the order already left the book
      ✓ completes a chase cancel whose child had already filled (2 ms)
      ✓ completes a chase cancel when the SDK throws that its child is gone (2 ms)
      ✓ completes a scale cancel when one rung had already filled (5 ms)
      ✓ retains every scale child after a non-ok response (2 ms)
      ✓ retains every scale child after a truncated response (2 ms)
      ✓ still reports a genuinely refused cancel as incomplete (2 ms)
    Chase reprice when the exchange refuses the cancel
      ✓ leaves the old order alone and retries on the next tick (2 ms)
    editOrder rejects strategy placements
      ✓ refuses to modify an order into a twap (2 ms)
      ✓ refuses to modify an order into a scale (1 ms)
      ✓ refuses to modify an order into a chase (1 ms)
    Scale ladder is validated before anything is signed
      ✓ rejects a range whose rungs collapse onto the same venue price (4 ms)
      ✓ rejects a total that cannot give every rung a whole size unit (2 ms)
      ✓ rejects a ladder whose rungs fall below the per-order minimum (2 ms)
      ✓ rejects on the real grid slice, not the average one (1 ms)
      ✓ submits exactly the ladder it validated (2 ms)
    Chase replacements keep the fee they were quoted at
      ✓ reuses the placement-time builder fee after the discount is cleared (2 ms)
      ✓ marks the replacement cloid after the subscription context is cleared (4 ms)
    Chase re-prices only what is still resting
      ✓ reads the remainder after the cancel, not before it (3 ms)
      ✓ replaces a partly filled order at its remaining size (2 ms)
      ✓ reports no remainder when the canceled child filled during repricing (2 ms)
      ✓ ends the session without cancelling when the order already filled (2 ms)
      ✓ retries on the next tick when the resting child is briefly unknown (3 ms)
    Strategy notional is checked against the submitted size
      ✓ rejects a TWAP whose size-grid rounding drops it under the venue minimum (1 ms)
    Chase pricing against the book
      ✓ joins the touch when the spread is a single tick (2 ms)
      ✓ re-prices when the external touch moves behind its own order (5 ms)
    Chase concurrency cap
      ✓ refuses a chase beyond the venue's simultaneous limit (1 ms)
      ✓ refuses an overflow chase before signing or changing leverage (1 ms)
      ✓ frees a slot when a chase is cancelled (1 ms)
    Chase cancel racing the post-cancel remainder read
      ✓ rests nothing when a cancel lands during the remainder read (1 ms)
    Chase state when a post-cancel read fails
      ✓ retries a transient unknown status before replacing the child (3 ms)
      ✓ does not retain the cancelled child as a Chase route after a fill (1 ms)
      ✓ ends the session rather than rescheduling a chase with nothing resting
    Chase concurrency cap under concurrent placement
      ✓ does not exceed the cap when placements overlap (1 ms)
    Chase netting uses the live resting size
      ✓ resolves ambiguous Chase children concurrently (1 ms)
      ✓ sees external liquidity sharing its level after a partial fill (2 ms)
    Strategy placement racing a disconnect
      ✓ retracts a TWAP that finishes after teardown through its captured client (1 ms)
      ✓ treats an already-finished stale TWAP as retracted (1 ms)
      ✓ retracts resting scale rungs and registers no stale group after teardown (2 ms)
    Chase placement racing a disconnect
      ✓ keeps placement blocked until every overlapping lifecycle owner exits (1 ms)
      ✓ rejects a placement admitted after disconnect starts (1 ms)
      ✓ retracts the order it could not put a strategy behind (2 ms)
      ✓ reports the resting order when it cannot be retracted
      ✓ reports the resting order when the retraction itself fails (1 ms)
      ✓ retains legacy HIP-3 collateral when an orphaned child still rests (1 ms)
      ✓ places nothing when the teardown lands during the book read (1 ms)
    Two chases on the same side do not leapfrog each other
      ✓ treats another session of its own as not-external liquidity (3 ms)
    Chase fee rate against the user schedule
      ✓ quotes the maker rate even when the account has its own fee tier
    Chase placement racing a disconnect during preparation
      ✓ places nothing when the teardown lands during preparation (1 ms)

Test Suites: 3 passed, 3 total
Tests:       11 skipped, 465 passed, 476 total
Snapshots:   0 total
Time:        1.977 s, estimated 3 s
Ran all test suites matching packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts|packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts|packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts.

- PASS assert-provider-exit (assert_exit_code, 0ms): source=provider, expected=0, actual=0
- PASS assert-provider-marks (assert_output, 0ms): source=provider, stream=stderr, contains=marks the cloid with the subscription program id when subscription wins
- PASS assert-provider-unmarked (assert_output, 0ms): source=provider, stream=stderr, contains=leaves the cloid unmarked when any other fee source wins
- PASS assert-provider-builder (assert_output, 0ms): source=provider, stream=stderr, contains=keeps the standard builder address when subscription wins
- PASS assert-provider-scale (assert_output, 0ms): source=provider, stream=stderr, contains=marks every rung cloid when subscription wins and keeps the ladder recoverable
- PASS assert-provider-chase (assert_output, 0ms): source=provider, stream=stderr, contains=marks the replacement cloid after the subscription context is cleared
- PASS assert-waiver-caller-cloid (assert_output, 1ms): source=waiver-formula, stream=stderr, contains=preserves a caller client order ID that begins with a reserved marker
- PASS assert-waiver-blend-withheld (assert_output, 0ms): source=waiver-formula, stream=stderr, contains=withholds a bounded allowance when the order notional is undefined
- PASS assert-waiver-quantized (assert_output, 0ms): source=waiver-formula, stream=stderr, contains=quotes the venue-quantized rate the submit path charges
- PASS assert-waiver-malformed-cloid (assert_output, 0ms): source=waiver-formula, stream=stderr, contains=rejects a malformed cloid whose flag byte is only partly hex
- PASS assert-resolver-perps-benefit (assert_output, 0ms): source=resolver, stream=stderr, contains=withholds the waiver when the perps block reports no benefit
- PASS controller (command, 1.7s): exitCode=0, stderr=PASS perps-controller packages/perps-controller/tests/src/PerpsController.operations.test.ts
  PerpsController
    validation methods
      ✓ validates close position (8 ms)
      ✓ validates withdrawal (3 ms)
    position management
      ✓ updates position TP/SL (2 ms)
      ✓ calculates maintenance margin (2 ms)
      ✓ updates margin successfully (2 ms)
      ✓ handles updateMargin error (28 ms)
      ✓ flips position successfully (1 ms)
      ✓ handles flipPosition error
    order capabilities
      ✓ returns capabilities from the active routed provider (1 ms)
      ✓ reports unavailable while no provider can answer (2 ms)
      ✓ reports unavailable when the provider omits the optional hook (2 ms)
      ✓ preserves a direct unavailable reason when the provider omits its identity (1 ms)
      ✓ preserves the resolved Lighter provider when its hook is unavailable (2 ms)
      ✓ preserves the resolved provider when capability discovery fails (1 ms)
      ✓ preserves the requested provider when provider resolution fails (3 ms)
      ✓ routes an explicit provider through the active aggregator (2 ms)
      ✓ rejects an explicit route that conflicts with the resolved provider (1 ms)
      ✓ does not infer routing support from a provider protocol ID (1 ms)
      ✓ rejects twap placement that conflicts with the resolved provider (21 ms)
      ✓ rejects scale placement that conflicts with the resolved provider (1 ms)
      ✓ rejects chase placement that conflicts with the resolved provider (1 ms)
      ✓ keeps an accepted placement providerId in the service request (2 ms)
      ✓ routes twap placement without providerId to the active provider (4 ms)
      ✓ routes scale placement without providerId to the active provider (2 ms)
      ✓ routes chase placement without providerId to the active provider (2 ms)
      ✓ rejects a conflicting direct-provider route for an ordinary order (2 ms)
      ✓ rejects twap cancellation that conflicts with the resolved provider (1 ms)
      ✓ rejects scale cancellation that conflicts with the resolved provider (2 ms)
      ✓ rejects chase cancellation that conflicts with the resolved provider (2 ms)
      ✓ keeps an accepted cancellation providerId in the service request (2 ms)
      ✓ routes twap cancellation without providerId to the active provider (1 ms)
      ✓ routes scale cancellation without providerId to the active provider (1 ms)
      ✓ routes chase cancellation without providerId to the active provider (2 ms)
      ✓ rejects twap validation that conflicts with the resolved provider (2 ms)
      ✓ rejects scale validation that conflicts with the resolved provider (2 ms)
      ✓ rejects chase validation that conflicts with the resolved provider (2 ms)
      ✓ keeps an accepted validation providerId in the service request (1 ms)
      ✓ routes twap validation without providerId to the active provider (1 ms)
      ✓ routes scale validation without providerId to the active provider (1 ms)
      ✓ routes chase validation without providerId to the active provider (3 ms)
      ✓ rejects a conflicting direct-provider route during ordinary validation (1 ms)
      ✓ rejects a conflicting direct-provider route for an ordinary cancel (1 ms)
      ✓ rejects a conflicting direct-provider route for an edit (1 ms)
      ✓ returns the unsupported result for a twap edit without requiring a route (1 ms)
      ✓ returns the unsupported result for a scale edit without requiring a route (1 ms)
      ✓ returns the unsupported result for a chase edit without requiring a route (1 ms)
      ✓ rejects a conflicting direct-provider route for a position close (2 ms)
      ✓ rejects a conflicting direct-provider route for a TP/SL update (1 ms)
      ✓ rejects a conflicting direct-provider route for close validation (1 ms)
    Scale price ladder
      ✓ uses the active provider when providerId is omitted (1 ms)
      ✓ routes an explicit provider through the active aggregator (1 ms)
      ✓ reports an unavailable active provider (1 ms)
      ✓ reports a provider that does not implement ladder normalization (1 ms)
      ✓ rejects an explicit route that conflicts with the active provider (1 ms)
      ✓ attaches the resolved provider when an unavailable result omits it (3 ms)
      ✓ preserves provider validation errors (2 ms)
    fee calculations
      ✓ calculates fees (2 ms)
      ✓ rejects a conflicting direct-provider route for an ordinary fee quote (2 ms)
      ✓ rejects a twap fee route that conflicts with the resolved provider (1 ms)
      ✓ rejects a scale fee route that conflicts with the resolved provider (1 ms)
      ✓ rejects a chase fee route that conflicts with the resolved provider (2 ms)
      ✓ routes a twap fee quote without providerId to the active provider (1 ms)
      ✓ routes a scale fee quote without providerId to the active provider (2 ms)
      ✓ routes a chase fee quote without providerId to the active provider (1 ms)
      ✓ passes the cached subscription waiver status to the fee preview (2 ms)
      ✓ resolves the preview fee against the order notional (2 ms)
      ✓ resolves without an order notional when the preview quotes a bare rate (1 ms)
      ✓ registers the current HyperLiquid address at preview time (2 ms)
      ✓ never fails a fee preview when address registration rejects (1 ms)
      ✓ re-registers the trading address when the selected account changes (4 ms)
      ✓ no longer approves a dedicated subscription builder (1 ms)
      ✓ exposes subscription benefits invalidation to clients (1 ms)
      ✓ omits the subscription waiver from the fee preview when no source is wired (2 ms)
    reportOrderToDataLake
      ✓ delegates to DataLakeService.reportOrder (1 ms)
    durable-settlement surfacing (manual recoveries / recovered dispatches)
      ✓ returns empty lists when the active provider has no durable settlement state (2 ms)
      ✓ routes to the active provider when it implements the durable-settlement contract (2 ms)
    getAvailableDexs
      ✓ returns available HIP-3 DEXs from provider (2 ms)
      ✓ passes filter parameters to provider (1 ms)
      ✓ throws error when provider does not support HIP-3 (2 ms)
    depositWithConfirmation
      ✓ returns promise result (5 ms)
      ✓ delegates to DepositService.prepareTransaction (5 ms)
      ✓ calls NetworkController:findNetworkClientIdByChainId with correct chainId (4 ms)
      ✓ calls TransactionController:addTransaction with prepared transaction (2 ms)
      ✓ throws error when controller not initialized (3 ms)
      ✓ throws error when no active provider (3 ms)
      ✓ propagates DepositService errors (2 ms)
      ✓ propagates NetworkController:findNetworkClientIdByChainId errors (3 ms)
      ✓ marks deposit request as failed when networkClientId is not found (2 ms)
      ✓ propagates TransactionController:addTransaction errors (3 ms)
      ✓ clears transaction ID when error occurs and not user cancellation (3 ms)
      ✓ preserves state when user cancels transaction (4 ms)
      ✓ clears stale deposit results before transaction (3 ms)
      ✓ updates state with transaction details (2 ms)
      ✓ stores depositId from service immediately (2 ms)
      ✓ delegates to DepositService with provider (2 ms)
      ✓ adds deposit request to tracking initially as pending (2 ms)
      ✓ uses default amount when not provided (1 ms)
      ✓ updates deposit request to completed when transaction succeeds (1 ms)
      ✓ handles concurrent deposit operations without data corruption (1 ms)
      ✓ uses addTransaction when placeOrder is true
      ✓ returns resolved promise with transaction ID when placeOrder is true (3 ms)
      ✓ clears depositInProgress after successful transaction (2 ms)
      ✓ handles non-user-cancelled transaction errors after confirmation (1 ms)
      ✓ handles user cancelled transaction with different error messages (2 ms)
    updateWithdrawalStatus
      ✓ updates withdrawal status to completed with txHash (1 ms)
      ✓ removes withdrawal request when status is failed (2 ms)
      ✓ clears withdrawal progress when status completed (1 ms)
      ✓ clears withdrawal progress when status failed (1 ms)
      ✓ finds withdrawal by ID (1 ms)
      ✓ does nothing when withdrawal ID not found
      ✓ updates state correctly for multiple withdrawals (1 ms)
      ✓ handles undefined txHash gracefully (1 ms)
    completeWithdrawalFromHistory
      ✓ does not mutate FIFO guards or emit analytics when withdrawal id is unknown (1 ms)
      ✓ removes the request, updates FIFO guards, and tracks completion when id matches (1 ms)
    markFirstOrderCompleted
      ✓ marks first order completed for mainnet (1 ms)
      ✓ marks first order completed for testnet
      ✓ only updates status for current network (1 ms)
      ✓ does not crash when called multiple times
      ✓ logs completion without throwing (1 ms)
    getWithdrawalRoutes error handling
      ✓ logs error in getWithdrawalRoutes when provider throws
      ✓ returns empty array from getWithdrawalRoutes on error (1 ms)
      ✓ handles edge case with null provider gracefully

Test Suites: 1 passed, 1 total
Tests:       122 passed, 122 total
Snapshots:   0 total
Time:        0.756 s, estimated 1 s
Ran all test suites matching packages/perps-controller/tests/src/PerpsController.operations.test.ts.

- PASS assert-controller-exit (assert_exit_code, 0ms): source=controller, expected=0, actual=0
- PASS assert-controller-register (assert_output, 0ms): source=controller, stream=stderr, contains=registers the current HyperLiquid address at preview time
- PASS assert-controller-reregister (assert_output, 0ms): source=controller, stream=stderr, contains=re-registers the trading address when the selected account changes
- PASS assert-controller-preview (assert_output, 0ms): source=controller, stream=stderr, contains=resolves the preview fee against the order notional
- PASS submit (command, 1.3s): exitCode=0, stderr=PASS perps-controller packages/perps-controller/tests/src/services/TradingService.test.ts
  TradingService
    placeOrder
      ✓ preserves the subscription source through order construction (5 ms)
      ✓ resolves the submit fee against the order notional, not a bare rate (1 ms)
      ✓ prefers the caller-supplied USD amount over size times price (1 ms)
      ✓ charges a partial blend at submit when the allowance is bounded (2 ms)
      ✓ prices a trigger placement from its trigger price (2 ms)
      ✓ still resolves a fee when the order cannot be priced (2 ms)
      ✓ isolates fee resolutions between concurrent orders (2 ms)
      ✓ places order successfully without fee discount (2 ms)
      ✓ places order successfully with fee discount applied and cleared (5 ms)
      ✓ clears fee discount when order placement fails (29 ms)
      ✓ adds and removes order from pending state optimistically (1 ms)
      ✓ saves trade configuration when leverage is provided
      ✓ tracks analytics event when order succeeds (1 ms)
      ✓ tracks accepted Scale size and weighted limit price separately from execution price
      ✓ keeps mixed Scale executed and partial analytics on separate size and notional meanings (1 ms)
      ✓ includes trade_with_token and mm_pay fields when trackingData has tradeWithToken and pay token/network (1 ms)
      ✓ includes chart_library when trackingData has chartLibrary
      ✓ includes mm_pay_token_selected "Perps Balance" when user uses perps balance
      ✓ tracks analytics event when order fails
      ✓ reports order to data lake on success (fire-and-forget) (1 ms)
      ✓ does not throw when data lake reporting fails
      ✓ creates trace for order placement (1 ms)
      ✓ adds payment_token tag for order trace (perps_balance when not tradeWithToken) (1 ms)
      ✓ adds payment_token tag for order trace (token symbol when tradeWithToken) (1 ms)
      ✓ handles order placement failure (1 ms)
      ✓ handles provider exception during order placement (1 ms)
      ✓ handles data lake reporting failure (11 ms)
    editOrder
      ✓ edits order successfully without fee discount (1 ms)
      ✓ edits order successfully with fee discount applied and cleared
      ✓ tracks analytics event when edit succeeds (1 ms)
      ✓ tracks analytics event when edit fails
      ✓ clears fee discount when edit throws exception (1 ms)
      ✓ handles order edit failure (1 ms)
      ✓ handles provider exception during order edit (1 ms)
    cancelOrder
      ✓ cancels order successfully (1 ms)
      ✓ tracks analytics event when cancellation succeeds (1 ms)
      ✓ tracks analytics event when cancellation fails (1 ms)
      ✓ logs error when cancellation throws exception (1 ms)
      ✓ handles order cancel failure (1 ms)
      ✓ logs error when provider returns a failure result without throwing (1 ms)
      ✓ handles provider exception during order cancel
    cancelOrders
      ✓ cancels all orders excluding TP/SL when cancelAll is true (1 ms)
      ✓ allows canceling TP/SL orders when specified by orderId
      ✓ cancels orders for specific coins when provided (1 ms)
      ✓ returns empty results when no orders match filters
      ✓ handles partial failures gracefully
      ✓ pauses and resumes streams during batch cancellation
      ✓ resumes streams even when operation throws error (1 ms)
      ✓ uses fallback when provider does not support batch cancellation
      ✓ logs batch error when provider.cancelOrders returns partial/full failure (1 ms)
      ✓ does NOT log batch error when using fallback path (provider.cancelOrders undefined) (1 ms)
    closePosition
      ✓ prices a full close from the loaded position notional (1 ms)
      ✓ prices a partial close from the position unit price
      ✓ prices a routed close from the routed provider position
      ✓ prefers an explicit close USD amount over the position value (1 ms)
      ✓ closes position successfully without fee discount
      ✓ closes position successfully with fee discount applied and cleared
      ✓ tracks analytics with PNL calculation
      ✓ reports order to data lake on successful close (1 ms)
      ✓ detects direction from position size
      ✓ tracks analytics on position close failure (1 ms)
      ✓ logs error when provider returns a failure result without throwing
    closePositions
      ✓ prices a batch close only from positions the route can close (1 ms)
      ✓ closes all positions when closeAll is true
      ✓ closes specific coins when provided
      ✓ returns empty results when no positions match
      ✓ handles partial failures gracefully (5 ms)
      ✓ uses fallback when provider does not support batch closing (1 ms)
      ✓ logs batch error when provider.closePositions returns partial/full failure
      ✓ does NOT log batch error when using fallback path (provider.closePositions undefined) (1 ms)
    updatePositionTPSL
      ✓ updates TP/SL successfully without fee discount (1 ms)
      ✓ updates TP/SL successfully with fee discount applied and cleared
      ✓ tracks analytics event when update succeeds (1 ms)
      ✓ tracks analytics event when update fails
      ✓ includes direction and size in analytics (1 ms)
      ✓ clears fee discount when update throws exception
      ✓ logs error with message and context when provider throws (1 ms)
    updateMargin
      ✓ updates margin successfully when adding margin (1 ms)
      ✓ updates margin successfully when removing margin
      ✓ throws error when provider does not support margin adjustment (21 ms)
      ✓ returns error when margin update fails (1 ms)
      ✓ tracks analytics on success (1 ms)
      ✓ tracks analytics on failure with error message (1 ms)
      ✓ emits a failed Risk Management event on a non-throwing { success: false } result
      ✓ emits the failed Risk Management event exactly once on a thrown error (1 ms)
      ✓ emits the Risk Management event exactly once on success (1 ms)
      ✓ updates state on success
      ✓ creates trace for margin update (1 ms)
    flipPosition
      ✓ preserves the subscription source for flip orders (1 ms)
      ✓ places order with 2x position size to flip position (1 ms)
      ✓ flips long position to short (isBuy=false) (1 ms)
      ✓ flips short position to long (isBuy=true)
      ✓ does not pass entry price as currentPrice to the provider (1 ms)
      ✓ returns error when order placement fails (1 ms)
      ✓ tracks analytics on success (1 ms)
      ✓ tracks analytics on failure (3 ms)
      ✓ propagates attribution properties on failure (1 ms)
      ✓ updates state on success (1 ms)
      ✓ creates trace for flip position (1 ms)
      ✓ uses correct order params including leverage (1 ms)
    consolidated analytics pipeline
      ✓ emits a terminal close event when no local position is found (1 ms)
      ✓ populates metamask_fee on flip success from trackingData
      ✓ adds effective leverage (positionUSD / marginUSD, 1 dp) to the close event properties (1 ms)
      ✓ populates effective leverage even when configured leverage is missing (TP/SL close) (1 ms)
      ✓ omits leverage (never NaN) when marginUsed is zero or non-finite (1 ms)
      ✓ propagates entry_point/discovery_source/perp_discovery_source on trade events (1 ms)
      status=submitted before provider round-trip
        ✓ emits a submitted trade event before placeOrder
        ✓ emits a submitted close event before closePosition (1 ms)
        ✓ emits a submitted cancel event before cancelOrder
        ✓ emits a submitted risk-management event before updatePositionTPSL (1 ms)
        ✓ emits a submitted trade event before flipPosition
        ✓ emits a terminal failed event when the flip is rejected without throwing (1 ms)
      hl_fee_rate on trade + close
        ✓ includes hl_fee_rate when present in trackingData (1 ms)
        ✓ omits hl_fee_rate when unavailable
      partial fill on open trade
        ✓ emits an additional partially_filled trade event with order_size, amount_filled, and remaining_amount from the accepted size (3 ms)
        ✓ does not count rejected Scale rungs as remaining fill exposure (1 ms)
        ✓ does not emit a partially_filled event on a complete fill of the normalized submitted size (1 ms)
        ✓ does not classify a partial fill when the provider omits submittedSize
        ✓ does not leak amount_filled/remaining_amount onto the executed trade event (1 ms)
        ✓ computes remaining_amount with exact decimal math (no binary-float dust) (1 ms)
        ✓ classifies a partial fill when parseFloat would collapse the two sizes to equal
        ✓ does not classify a partial fill when filledSize is not a finite number (1 ms)
        ✓ does not classify a partial fill or emit any NaN size when submittedSize is not finite (1 ms)
        ✓ does not emit a partially_filled event for a failed result even when it carries sizes
      number_positions_closed on batch close
        ✓ carries the successful-close count on the batch close summary event
        ✓ does not add number_positions_closed to a single-position close event (1 ms)
      bulk_action_id on batch close/cancel
        ✓ attaches bulk_action_id to the batch close summary event (1 ms)
        ✓ attaches bulk_action_id to per-item close events in the fallback path
        ✓ attaches bulk_action_id to the batch cancel summary event (1 ms)

Test Suites: 1 passed, 1 total
Tests:       129 passed, 129 total
Snapshots:   0 total
Time:        0.485 s, estimated 1 s
Ran all test suites matching packages/perps-controller/tests/src/services/TradingService.test.ts.

- PASS assert-submit-exit (assert_exit_code, 0ms): source=submit, expected=0, actual=0
- PASS assert-submit-notional (assert_output, 0ms): source=submit, stream=stderr, contains=resolves the submit fee against the order notional, not a bare rate
- PASS assert-submit-blend (assert_output, 0ms): source=submit, stream=stderr, contains=charges a partial blend at submit when the allowance is bounded
- PASS assert-submit-full-close (assert_output, 0ms): source=submit, stream=stderr, contains=prices a full close from the loaded position notional
- PASS assert-submit-partial-close (assert_output, 0ms): source=submit, stream=stderr, contains=prices a partial close from the position unit price
- PASS assert-submit-routed-close (assert_output, 0ms): source=submit, stream=stderr, contains=prices a routed close from the routed provider position
- PASS assert-submit-batch-route (assert_output, 0ms): source=submit, stream=stderr, contains=prices a batch close only from positions the route can close
- PASS read-positions (metamask.perps.read_positions, 1.2s): network=testnet, account=0x8Dc6...9003, count=0, matching=0, proof=perps-controller-getPositions
- PASS read-orders (metamask.perps.read_orders, 1.2s): network=testnet, account=0x8Dc6...9003, count=0, matching=0, proof=perps-controller-getOpenOrders
- PASS read-account (metamask.perps.read_account, 1.2s): network=testnet, account=0x8Dc6...9003, proof=perps-controller-getAccountState, totalBalance=625.804161, spendable=625.804161, marginUsed=0.0, unrealizedPnl=0
- PASS done (end, 0ms)

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 — the changelog marks the calculateFees quoting change as BREAKING (a subscription-waived quote can now return different rates), but no client draft PRs are prepared yet. The client-side work is tracked separately (TAT-3622 for mobile UI; messenger registration and passing amount are listed under References).

Screenshots/Recordings


Note

High Risk
Changes fee quoting, charging, and subscription entitlement across preview and submit paths, plus breaking messenger types—incorrect notional or benefits caching could over-grant waivers or mis-attribute fills.

Overview
Implements ADR 0064 in @metamask/perps-controller: subscription waivers no longer use a dedicated zero-fee builder. All sources pay through the standard HyperLiquid builder, and attribution rides on client order ID (cloid) marking when subscription actually lowers the charged fee.

Fee resolution now blends subscription against order notional (USD) and remaining allowance (full vs partial), competes with rewards/default at venue-quantized tenths of a basis point, and requires subscription to be strictly cheaper after quantization to win. calculateFees and submit paths thread the same notional (closes, batch closes, TP/SL, scale/chase, flips) so previews match what gets charged; bounded allowances are withheld when notional is unknown.

Integration: benefits hydrate via SubscriptionController:getBenefits (micro-USD → USD at the boundary); trading addresses register on preview and account switch via SubscriptionController:registerAddress or an injected fallback. Remote flag perpsSubscriptionFeeWaiverEnabled can disable only the subscription source. approveSubscriptionBuilderFee is deprecated and always resolves true without calling the provider.

Breaking for clients: PerpsControllerAllowedActions adds subscription messenger actions; calculateFees quoted rates can change when a partial waiver applies—callers should pass FeeCalculationParams.amount. New exports include @metamask/perps-controller/utils and waiver/cloid helpers (markSubscriptionCloid, hasFeeReductionAppliedFlag, etc.). HyperLiquid submission paths route orders through a single #applySubscriptionCloid chokepoint; caller-supplied cloids are never rewritten.

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

abretonc7s and others added 14 commits September 17, 2026 16:41
…loid marking

ADR 0064 has moved past the revision TAT-3618 was built against and rejects the
dedicated approved-builder approach it shipped, citing per-user approval
overhead and no order context. Rework the perps-controller side to match.

Resolve the subscription source as a blended rate rather than a flat zero:
0 bips when the remaining allowance covers the order notional, otherwise
MaxFee * (1 - remaining / orderNotional). That rate now competes in the existing
lowest-wins comparison instead of short-circuiting it, so a partial blend can
lose to a deeper VIP or season discount. The formula lives in one pure helper
that preview and submit both call, and calculateFees threads the order notional
through it, so a quoted fee and a charged fee cannot drift.

Move subscription attribution from the builder address to the order's client
order ID. Every source now pays through the standard builder at the resolved
fee, which is also what lets a partial waiver charge a real blended fee. One
provider helper stamps the program marker and a fee_reduction_applied flag when
subscription wins, and every submission path routes through it: placement, Scale
ladder, attached and standalone TP/SL, position TP/SL update, batch close,
modify/replace, and chase. Any other source leaves the id untouched.

The flag byte sits after the leading marker rather than replacing it, so a Scale
rung keeps its group marker and rung index and stays recoverable. The Scale
identity generator now reserves that byte; without it, random entropy would set
the flag on roughly half of all unmarked ladders.

Add SubscriptionController allowed actions for benefits hydration and CAIP-10
trading-address registration at preview time, re-sent after an account switch,
falling back to the injected dependency when a client registers neither. Add the
perpsSubscriptionFeeWaiverEnabled remote flag, which kills only the subscription
source and fails open.

Deprecate the dedicated subscription builder rather than deleting it: the
acceptance criterion conditions removal on shadow-mode verification, which has
not happened, so the approval path is made unreachable from order construction
and kept for a cheap rollback.

The cloid program marker is a placeholder; the registry value is an open TODO in
the ADR and is owned by the cloid schema owners.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two blockers the original test suite did not cover.

The submit path resolved the fee with no order notional, so every bounded
allowance took the resolver's "no notional to blend against" branch and came
back as a full waiver. A 250 USD allowance against a 1000 USD order was quoted
7.5 bips by calculateFees and charged 0 at submit, over-consuming the allowance
and stamping the cloid as fully waived. Thread the notional through
#calculateFeeDiscountWithMeasurement to all six submit paths, priced from the
parameters already in scope: usdAmount where the hybrid model supplies it,
otherwise size times the best available price. A batch close is priced from the
sum of the positions it will close, since HyperLiquid takes one builder context
for the whole batch. An order that cannot be priced passes undefined rather than
a guess, which is the behaviour it had before.

registerTradingAddress early-returned on the injected subscription dependency,
gating the messenger call on the very callback it was written to replace, so a
client shipping SubscriptionController without the dependency registered
nothing. Drop the guard and let the existing catch absorb an unregistered
action. Writing the test surfaced a second defect: a messenger that answers an
unregistered action with undefined would have cached the address as registered,
so a SubscriptionController wired after the first preview would never receive
it. An unhandled call no longer touches the dedupe cache.

Also rename the preview test to what it actually checks and add the submit-side
assertion it claimed, export the new util from the barrel, and populate the
recipe-quality dimensions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects in the cloid marking introduced by this branch.

A caller-supplied OrderParams.clientOrderId was rewritten in place when the
subscription source won, so the venue received an id the caller never chose:
0xdeadbeefcafebabe0011223344556677 was submitted as
0xdeadbeef01febabe0011223344556677, and a short id was discarded outright. That
id is the caller's reconciliation and idempotency key, which is a correctness
contract, while attribution is observability — so only ids this package
generates are re-stamped now, and anything else is returned untouched and goes
unattributed.

A nearly-spent allowance blends to just under the full fee, so it still won the
lowest-wins comparison while its discount rounded to zero and the builder fee
floored to the full rate. The order was charged full price and stamped
fee_reduction_applied. Marking now follows the charged fee rather than the
winning source.

hasFeeReductionAppliedFlag read the flag byte with no marker check. The byte
held random group entropy in Scale ladders placed before this change, so 51% of
2000 synthetic historical rungs decoded as fee-waived. The subscription program
marker is the one prefix no released client ever emitted, so the flag is only
trusted behind it; measured 0% after. The consequence is that a marked Scale
rung now reads as unwaived, since it keeps its own group marker to preserve
recovery and cancel-by-cloid — recorded in the exported JSDoc and the changelog,
and asserted in the Scale tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects, two of them in paths this branch claimed to have already covered.

Benefits hydration was unreachable over the messenger. Both the eligibility read
and the refresh returned early on the injected subscription dependency, before
SubscriptionController:getPerpsBenefits could run, so a client that adopts the
controller action without retaining the legacy callback always resolved
no-source. The previous round removed that guard from address registration but
left it on hydration, which is the path the ADR actually targets. A source
predicate now accepts either wiring, and the refresh attempts the messenger
regardless so a delegated registration — invisible to getRegisteredActionTypes —
can prove itself on first call.

A full position close priced its fee from the close parameters, which commonly
carry only a symbol, so the notional was undefined and the resolver quoted a full
waiver on an order the preview had blended. The authoritative position is loaded
a few lines earlier; it now supplies the notional, and a partial close is priced
from the position's value per unit.

Provenance of a client order ID was inferred from its leading marker, so a
caller-supplied cloid beginning with a reserved prefix had its flag byte
rewritten — the exact contract the previous round introduced. A prefix cannot
prove authorship, so the marking now takes an explicit isGenerated flag and the
provider declares the ids it just generated.

Also corrects the evidence: the coverage table marked submit agreement and the
SubscriptionController integration proven without exercising the failing paths,
and claimed more attribution than Scale ladders can deliver downstream. Four
assertions added, the two deliberate marking exclusions documented, and the weak
count corrected from 0 to 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adopts the registered subscription program_id 0x0100, zero-extended big-endian
into the existing 4-byte marker field so the flag-byte offset and the rest of
the cloid layout are unchanged. It cannot collide with the Scale marker and no
released client emitted a cloid starting with those bytes, so the decoder stays
safe against historical fills.

A chase replacement paid the discounted fee with an unmarked client order ID.
The session already stores the builder fee it was quoted at, precisely because
the fee resolution behind it is cleared when the caller's placeOrder returns —
but marking still read that live resolution, so every replacement after the
first went out unattributed. The marking decision is now captured on the session
alongside the fee. Proven by a test that failed before the fix.

A bounded allowance with no determinable order notional resolved to a full
waiver, charging nothing on an order of unknown size and over-consuming the cap.
It now withholds the source, matching how an exhausted or stale gate behaves. An
unbounded allowance is unchanged: with no reported cap there is nothing to
over-consume.

Batch-close notional summed every aggregated provider's positions while the
batch routes to one, inflating the notional and shrinking the waiver. Position
carries no provider id, so it is now read through the provider that submits.

The deprecated approval method resolves true rather than false: it answers
whether the subscription builder is ready, and nothing needs approving, so false
read as a setup failure. Adds an exact ./utils subpath export and exports the
two SubscriptionController action types. The allowed-actions unions are
deliberately not exported — the controller guidelines forbid it and lint
enforces it.

Two further findings ask that orders which cannot carry attribution — Scale
fills and caller-supplied client order IDs — be denied the subscription rate.
That means charging entitled subscribers full price to keep backend accounting
clean, which is a product trade-off rather than an implementation detail; it is
recorded in the task report with the structural notes a decision would need.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five defects, three of them in fixes from earlier rounds.

A rejected messenger benefits read could erase a valid cached snapshot. The
catch claimed to fall back rather than erase, but with no injected subscription
dependency it fell through to null, which the refresh then stored as a
successful "no subscription" result — wiping a waiver the user still held. The
rejection now propagates when nothing else can answer, so the outer handler
keeps the previous snapshot.

Closes priced against positions their write could not reach. The previous round
routed batch-close pricing through provider.getPositions() on the assumption
that it read only the submitting provider; in aggregated mode it spans every
active provider while the write goes to the default one. A routed single close
had the same shape through symbol-only matching, so with two providers listing
one market it could price the wrong provider's position. AggregatedPerpsProvider
now reports which provider a write reaches — protocolId names the aggregate and
reads span providers, so nothing exposed this — and pricing filters on the
providerId the aggregator already injects.

Preview quoted an unfloored fractional fee while submit floored to the venue's
tenths of a basis point, so a 6.667-bip blend was quoted at 6.667 and charged at
6.6. Both paths now share one quantization helper.

The migration note promised that omitting the notional preserves a full waiver,
which the previous round reversed for bounded allowances. Corrected, and the
breaking note now also covers rewards repricing and the quantization change.

Also corrects the evidence rather than the criterion: AC4 requires every winning
placement to mark the cloid, and caller-supplied client order IDs and Scale
rungs do not, so it is recorded as PARTIAL with the recipe-quality verdict moved
to warn. The underlying attribution gap needs a product decision and stays open
in the task report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Consumes the real SubscriptionController instead of an invented one.

This branch declared SubscriptionController:getPerpsBenefits and
SubscriptionController:registerAddress as structural types, on the stated
premise that SubscriptionController does not live in this monorepo. It does:
packages/subscription-controller exposes SubscriptionController:getBenefits, and
neither of the two names this branch used exists anywhere. The real contract
also differs — allowances arrive in micro-USD, eligibility is a response flag
rather than a status string — so the shape being decoded was wrong too.

perps-controller now depends on @metamask/subscription-controller, imports its
action type rather than restating it, and converts micro-USD to USD once at the
boundary so the gate and the blended-rate formula keep working in whole USD. The
registerAddress action is removed; address registration runs through an optional
hook on the injected dependency until a real action exists, rather than calling
a name nothing answers.

Four smaller defects: a fee preview returned the provider's own rate when the
default source won, which reflects whatever discount the last submit pushed into
it and could leak a concurrent order's discount into an unrelated quote; a
take-profit/stop-loss update with neither a position snapshot nor tracking data
resolved no notional and, since bounded waivers now fail closed, silently lost
the waiver; a synchronous throw from a registered benefits handler was
indistinguishable from an unregistered action and fell through to null, erasing
a cached snapshot; and an account switch cleared registration without
registering the new address, so an order submitted before the next preview went
unattributed.

Also corrects public type documentation that claimed quoted rates are not
adjusted from the subscription waiver, and a coverage document that recorded AC4
as PARTIAL and then counted it as proven.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reclassifies the messenger-union expansion as breaking. Messenger constrains a
child's action union to be a subset of its parent's, so adding
SubscriptionController:getBenefits to PerpsControllerAllowedActions forces every
strict parent messenger type to add the action before it builds — including
clients that never register the handler. Runtime behaviour for those clients is
unchanged because the injected fallback still applies, but the build is not, and
the changelog described this as additive. It is now a breaking entry with
migration guidance to coordinate the client messenger updates.

The messenger action docblock for approveSubscriptionBuilderFee still described
the pre-ADR contract, promising that waivers fall back to the ordinary builder
until approval succeeds. The controller method was deprecated and made a no-op
two rounds ago; this second docblock was missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four defects, three of them introduced by earlier rounds of this branch.

A UserNotSubscribed rejection was treated as a failed read and preserved the
cached snapshot. It is a definitive answer — SubscriptionController throws it
when entitlement has ended and clears its own benefits state on the same path —
so preserving the snapshot kept granting the waiver for the rest of the
ten-minute staleness window after the user stopped paying. It now resolves to
null, which replaces the snapshot, while every other failure still preserves it.

An order edit marked its replacement client order ID as fee-reduced, two lines
below a comment recording that HyperLiquid's modify action carries no builder
field. No MetaMask fee is charged on that action, so the marking reported a
reduction on an order that paid nothing. The replacement now inherits the
resting order's attribution instead.

A trigger placement could not be priced: the notional resolver consulted the
limit price, the caller's snapshot and the live quote, but not triggerPrice,
which is the only price a stop or take-profit placement carries. Bounded
allowances fail closed, so such an order silently lost the waiver.

A fee preview read the subscription status separately from the resolution that
produces its rates, so an invalidation or feature-flag change between the two
could attach metadata describing a waiver the rates did not reflect.

Also regenerates PerpsController-method-action-types.ts, which a previous commit
hand-edited even though it is generated, leaving messenger-action-types:check
failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reports the trading-address registration gap instead of returning silently. A
client that adopts SubscriptionController over the messenger but injects no
registerTradingAddress hook cannot register an address at all — no such
messenger action exists — so its fills arrive unattributed with nothing to point
at. Calling an action nothing answers would be worse, so the early return stays,
but it now logs and the JSDoc states the consequence rather than only explaining
the design.

Removes a stale paragraph above SUBSCRIPTION_CLOID_CONFIG that still called the
program id a placeholder pending the registry, directly above the docblock
describing the registered value and its encoding.

Narrows the recipe decision's AC5 claim, which asserted that address
registration runs via the SubscriptionController integration — the precise thing
AC5 is recorded PARTIAL for not doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Corrects the resolveFee documentation, which still described the fail-open
behaviour a previous round replaced. It said a caller with no order notional
receives the full-waiver rate; that holds only when the backend reported no
allowance bound. A bounded allowance is withheld in that case, deliberately, so
an order of unknown size cannot silently spend the cap. The docblock now
distinguishes the two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four defects in code paths the suite did not exercise.

products.perps is always present on the benefits response, so testing its
existence proved nothing about entitlement: a profile eligible for other
products, whose perps block carried no builder fee, no allowance and no cap, was
granted an unbounded full waiver. Entitlement now requires positive evidence
from the perps block itself.

The cloid decoder validated length and prefix but not that the id was hex.
parseInt('1z', 16) is 1, so a client order ID with a partly-hex flag byte
reported whichever flags its leading digit encoded. The whole id is now matched
against a hex pattern.

applyFeeResolution accepted any finite amount, so a negative notional produced
negative feeAmount and metamaskFeeAmount. A non-positive value is not an order
size; rates are still re-priced but the amounts are left as the provider
reported them.

A synchronous benefits-handler failure on the very first call was
indistinguishable from an unregistered action, because the distinction rested on
whether a call had previously succeeded, and its null was cached as a successful
"no subscription" answer. The distinction is now made on the error itself.

Also repairs a docblock left mangled by an earlier insertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Report source: 'subscription' only when the waiver survives the venue's
quantization of the builder fee to tenths of a basis point. A blend just
under the default rounds to the same charge, so such an order was labelled
subscription-sourced with a 0 bips discount while paying full price, and
disagreed with the client order ID, which already withholds its marking
there.

Distinguish a metamaskFeeRate of 0 that means "this placement carries no
builder fee" from the 0 a concurrent fully waived submit leaves in provider
state, via a new chargesMetamaskBuilderFee field on FeeCalculationResult.
An ordinary preview racing such a submit previously inherited its waiver.

Reject non-hex values from isSubscriptionProgramCloid, which matched on
length and prefix alone although it gates the decoder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Make repricing a zero MetaMask fee rate opt-in. The previous commit added
chargesMetamaskBuilderFee so a structural zero could be told apart from a
waived one, but the call site mapped both false and undefined onto "charges
a fee". A PerpsProvider written before the field existed reports a zero and
no policy, so its quote gained the default 10-bip fee on an order that pays
none. The policy is now carried as a tri-state, and only a provider that
explicitly reports it does charge a builder fee has its zero overwritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abretonc7s abretonc7s changed the title chore: prepare farmslot publication pkg-881a52ed-mu6mcb7j feat(perps): rework fee resolver for ADR 0064 cloid-based subscription waiver Sep 18, 2026
@abretonc7s
abretonc7s marked this pull request as ready for review September 18, 2026 07:14
@abretonc7s
abretonc7s requested review from a team as code owners September 18, 2026 07:14
@abretonc7s
abretonc7s deployed to default-branch September 18, 2026 07:14 — with GitHub Actions Active
…fee-resolver-adr-0064

# Conflicts:
#	packages/perps-controller/CHANGELOG.md
@abretonc7s abretonc7s changed the title feat(perps): rework fee resolver for ADR 0064 cloid-based subscription waiver feat(perps): rework fee resolver for ADR 0064 cloid-based subscription waiver [NOT-READY] Sep 18, 2026

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

Stale Bugbot comment from a previous run.

Comment thread packages/perps-controller/src/services/RewardsIntegrationService.ts
abretonc7s and others added 3 commits September 18, 2026 17:26
Repair three JSDoc blocks garbled by a patch applied twice over itself:

- applyFeeResolution: drop the duplicated summary line whose stray '/**'
  rendered as the literal 'resolution./**' in TypeDoc output.
- #resolvePositionUnitPrice: same shape, drop the duplicated line.
- #calculateFeeDiscountWithMeasurement: remove the stranded block left
  documenting #resolveBatchCloseNotionalUsd and reattach a corrected one,
  documenting orderNotionalUsd — the parameter whose absence resolves every
  bounded allowance as a full waiver.

Comment-only; no executable statement changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- tsconfig.lint.json: add the missing `../subscription-controller/tsconfig.lint.json`
  reference, via `yarn lint:tsconfigs:fix` (lint:tsconfigs:all).
- README.md: add the `perps_controller --> subscription_controller` edge to the
  dependency graph, via `yarn readme-content:update` (readme-content:check).
- subscriptionFeeWaiver.ts: apply oxfmt to `isSubscriptionProgramCloid`
  (lint:misc:check).
- CHANGELOG.md: record that the trading-address CAIP-10 is built from the wallet's
  selected network rather than HyperLiquid's chain, so a client on another network
  registers under the wrong chain and its fills cannot be attributed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Check changelog` requires each Unreleased entry to link the pull request that
introduced it, which the released sections already do. All 35 top-level entries
now carry the #10294 link; nested detail bullets are left unlinked, matching the
surrounding convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@abretonc7s abretonc7s left a comment •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

.

Comment thread packages/perps-controller/src/services/TradingService.ts Outdated
Comment thread packages/perps-controller/src/services/TradingService.ts Outdated
Comment thread packages/perps-controller/src/services/RewardsIntegrationService.ts
abretonc7s and others added 2 commits September 21, 2026 22:31
Register the trading address under HyperLiquid's own chain (eip155:999 /
eip155:998) instead of the wallet's selected network, so a fill decoded off
the HyperLiquid fan-out can match the registered identifier. PerpsController
supplies isTestnet at both call sites.

Price a partial TP/SL update from the trigger size it submits rather than the
whole position, taking the larger of takeProfitSize and stopLossSize since
both go up under one builder context. An unpriceable partial still falls back
to the position notional.

Honor closeAll ahead of symbols when pricing a batch close, matching the
provider's own selection precedence.

Discard a trading-address registration whose profile was invalidated while it
was in flight, reusing the benefits-cache epoch fence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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

Stale Bugbot comment from a previous run.

Comment thread packages/perps-controller/src/services/TradingService.ts
Restore Prettier formatting in TradingService, which failed lint:misc:check
on the previous push.

Price a Scale ladder from the midpoint of scaleMinPrice and scaleMaxPrice. A
Scale placement states no single price, so its notional could not be derived
and a bounded subscription waiver was withheld at submit after a preview that
quoted one. A stated usdAmount still takes precedence. Chase and TWAP state no
price at all and remain priced from usdAmount or the quoted market price.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@abretonc7s abretonc7s left a comment •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

.

Comment thread packages/perps-controller/src/services/TradingService.ts Outdated
Comment thread packages/perps-controller/src/services/TradingService.ts Outdated
Comment thread packages/perps-controller/src/types/messenger.ts
Comment thread packages/perps-controller/src/services/RewardsIntegrationService.ts Outdated
@abretonc7s abretonc7s changed the title feat(perps): rework fee resolver for ADR 0064 cloid-based subscription waiver [NOT-READY] feat(perps): rework fee resolver for ADR 0064 cloid-based subscription waiver Sep 22, 2026

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

Comment thread packages/perps-controller/src/services/TradingService.ts Outdated
@Bigshmow Bigshmow self-assigned this Sep 22, 2026

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

One thing worth reconciling before this lands: ADR 0064 says "Resolver cache TTL stays ≤ 5 min because it is additive to every window above", but SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs is 10 min. Pre-existing on main rather than introduced here, but since this is the PR that implements 0064 it seems like the place to settle it — either bring the ceiling to 5, or update the ADR's blind-window math, which assumes 5 in the "trip threshold + cache TTL ≈ 20 min" figure.

Otherwise just one minor inline. Comparing sources after venue quantization is a nice catch, and withholding on a bounded allowance with unknown notional is the right fail-closed direction.

Comment thread packages/perps-controller/src/services/RewardsIntegrationService.ts
abretonc7s and others added 2 commits September 22, 2026 23:47
…fee-resolver-adr-0064

# Conflicts:
#	packages/perps-controller/CHANGELOG.md
… fee

A fee tie let the subscription waiver claim the order, which marks the
client order ID and spends the remaining allowance without making the
order any cheaper than the source it tied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bigshmow
Bigshmow previously approved these changes Sep 22, 2026
@abretonc7s
abretonc7s added this pull request to the merge queue Sep 22, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 22, 2026
@abretonc7s
abretonc7s added this pull request to the merge queue Sep 23, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 23, 2026
@abretonc7s
abretonc7s added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 76abf8b Sep 23, 2026
42 checks passed
@abretonc7s
abretonc7s deleted the TAT-3967-feat-rework-fee-resolver-adr-0064 branch September 23, 2026 01:27
abretonc7s added a commit to MetaMask/skills that referenced this pull request Sep 23, 2026
Distribute the generated Perps static review checklist for Mobile,
Extension and Core.

- This PR contains only Perps review content, its generator, parity
tests and documentation.
- It is independent of installer PR #174. The skill can be invoked
directly from the checked out source; installer explicit-only controls
are a separate follow-up in #174.
- `--analyzer-out <path> --client <client>` optionally exports inlined
context for an existing PR risk analyzer. It does not enable an analyzer
integration.
- Validation: the repository test suite passes, including per-client
parity and install/reinstall checks.

Farmslot validation ran the Core template with Astra medium, registered
all 30 child checklist rows, saved three actionable findings plus line
comments, and posted the selected review to MetaMask/core#10294 after
the human gate.
@geositta geositta mentioned this pull request Sep 23, 2026
3 of 4 tasks
pull Bot pushed a commit to Reality2byte/core that referenced this pull request Sep 24, 2026
## Explanation

Publish `@metamask/perps-controller@18.0.0` so clients can consume the
ADR-0064 cloid-based subscription fee waiver rework that has accumulated
on `main` since 17.4.0
([MetaMask#10294](MetaMask#10294)), along with the
`fillId` addition to `OrderFill`
([MetaMask#10384](MetaMask#10384)).

Major bump: the release carries two breaking changes, both from
[MetaMask#10294](MetaMask#10294).

1. **`PerpsControllerAllowedActions` now includes
`SubscriptionController:getBenefits` and
`SubscriptionController:registerAddress`.** This is a type break for
every client, including clients that never register those handlers.
`Messenger` requires each child action to exist in the parent action
union, so a strict parent messenger type must include both action types
before it will build. Runtime behavior is unchanged for clients that
don't register them — an unregistered action falls back to the injected
`subscription` dependency — but the build does not pass without the
type. **The Mobile and Extension messenger updates need to be
coordinated with this release.**
2. **`PerpsController.calculateFees` now quotes the subscription waiver
as a blended rate derived from the order notional.** `feeRate`,
`feeAmount`, `metamaskFeeRate`, and `metamaskFeeAmount` can differ from
17.4.0 when a subscription waiver applies, and
`FeeCalculationParams.amount` (order notional in USD) is now required
for quote/submit parity. Omitting it changes the quote rather than
preserving the old one: a waiver with a bounded remaining allowance is
withheld entirely from a notional-less quote, so the preview reports the
next-lowest fee source.

The release also deprecates
`PerpsController.approveSubscriptionBuilderFee`,
`PerpsProvider.approveSubscriptionBuilderFee`, and the dedicated
subscription builder address configuration — subscription attribution
now rides on the order's client order ID, so the controller method is a
no-op that always resolves `true`. See the [18.0.0 changelog
section](https://github.com/MetaMask/core/blob/release/1282.0.0/packages/perps-controller/CHANGELOG.md)
for the full set of additions and fixes.

No workspace package depends on `@metamask/perps-controller`, so nothing
else in the monorepo needs a version bump or a dependency pin here.

## References

- Rework fee resolver for ADR 0064 cloid-based subscription waiver:
MetaMask#10294
- Fix `mergeOrderFills` dedupe key dropping real executions from history
(adds `OrderFill.fillId`): MetaMask#10384
- Breaking change process:
https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md

## Checklist

- [x] I've updated the test suite for new or updated code as appropriate
- [x] 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
- This release publishes breaking changes; the Mobile and Extension
messenger-type updates still need to be landed alongside it.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **High Risk**
> The release exposes breaking Perps messenger types and fee-quote
semantics; Mobile and Extension must land coordinated updates before or
with adoption.
> 
> **Overview**
> This release PR bumps the monorepo to **1282.0.0** and publishes
**`@metamask/perps-controller@18.0.0`**, cutting the `[18.0.0]`
changelog section from `[Unreleased]` and updating package versions. No
implementation code changes appear in the diff—the branch ships work
already on `main` since **17.4.0**.
> 
> Consumers upgrading get the **ADR-0064 subscription fee waiver
rework** ([MetaMask#10294](MetaMask#10294)):
blended waiver quotes tied to order notional, client-order-ID
attribution (replacing subscription builder approval), new
fee/subscription types and `@metamask/perps-controller/utils` exports,
plus **`OrderFill.fillId`**
([MetaMask#10384](MetaMask#10384)) for deduping
distinct executions.
> 
> **Breaking:** parent messenger types must add
`SubscriptionController:getBenefits` and
`SubscriptionController:registerAddress`, and fee previews must pass
**`FeeCalculationParams.amount`** when subscription waivers apply or
quotes can diverge from submit.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
cf8880f. 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: Cursor <cursoragent@cursor.com>
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.

3 participants