Skip to content

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

Open
abretonc7s wants to merge 18 commits into
mainfrom
TAT-3967-feat-rework-fee-resolver-adr-0064
Open

abretonc7s wants to merge 18 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

TAT-3618 shipped the perps subscription fee waiver as a binary 0-bips discount enforced
through a dedicated approved builder address. ADR 0064 has since moved past that revision
and explicitly rejects the dedicated-builder approach — it needs a per-user approval and
gives the backend no order context. This PR reworks the @metamask/perps-controller side
to match the current ADR text.

Blended waiver instead of binary. RewardsIntegrationService.resolveFee() now takes an
optional order notional and derives the subscription source's effective rate from it:
0 bips when the remaining allowance covers the order, MaxFee × (1 − remaining / orderNotional)
otherwise. remainingNotionalUsd was already read but only ever passed through; it now
actually bounds the waiver.

Subscription competes rather than short-circuiting. The blended rate enters the same
lowest-wins comparison as rewards. Previously the code set feeBips = 0 unconditionally
when the gate passed, so subscription could never lose; now a partial blend can be beaten
by a deeper VIP or season discount, and only wins when it is genuinely cheaper.

Preview and submit share one formula. PerpsController.calculateFees threads the
existing FeeCalculationParams.amount into the resolver, and MarketDataService re-prices
the MetaMask fee component from the resulting resolution. The formula itself lives in one
pure helper (src/utils/subscriptionFeeWaiver.ts) that both paths call, so a quoted fee
and a charged fee cannot drift.

Attribution moves from the builder address to the cloid. #getBuilderOrderContext no
longer swaps builders; 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, something a
dedicated 0-bips builder could not express. When subscription wins, one provider helper
stamps the order's client order ID with a reserved program marker plus a
fee_reduction_applied flag. Every submission path routes through that helper: primary
placement, Scale ladder, attached and standalone TP/SL, position TP/SL update, batch close,
modify/replace, and chase. Any other fee source leaves the client order ID untouched.

Two details worth flagging for review:

  • The flag byte sits after the leading 4-byte marker rather than replacing it. That is
    what lets the marking compose with the Scale ladder's own cloid: a rung keeps its
    4d4d5343 group marker and its rung index, so group recovery from open orders and
    cancel-by-client-order-ID keep working.
  • createScaleOrderIdentity now zeroes the byte the flag occupies (entropy shortened from
    22 to 20 hex characters). Without that, random entropy would set fee_reduction_applied
    on roughly half of all unmarked ladders and they would decode downstream as waived.
    Group entropy drops from 88 to 80 bits, still far beyond what ladder uniqueness needs,
    and previously placed ladders stay recoverable.

SubscriptionController over the messenger. Two allowed actions are added:
SubscriptionController:getPerpsBenefits (benefits hydration, which the ADR moves off the
plain DI callback) and SubscriptionController:registerAddress (registers the current HL
trading address as CAIP-10 at preview time, re-sent after an account switch, so a fill
decoded off the HL fan-out can be attributed to a profile). They are declared structurally
because no SubscriptionController package exists in this monorepo yet; a client that does
not register them keeps the existing DI path and skips registration, so nothing regresses.

Independent kill switch. perpsSubscriptionFeeWaiverEnabled disables only the
subscription source, leaving VIP, season, and the default builder fee alone (ADR Milestone
8). It fails open — an absent, malformed, or unreachable flag reads as enabled — because
silently dropping a benefit the user pays for is worse than serving it one release too long.

Dedicated builder deprecated, not deleted. AC 7 asks for removal "once cloid marking is
verified in shadow mode". That verification has not happened, so the approval machinery is
made unreachable from order construction and marked @deprecated across the controller,
provider, aggregated provider, and the PerpsProvider type. PerpsController.approveSubscriptionBuilderFee
is now a no-op that resolves false, keeping callers building while they migrate. Deleting
it is a follow-up.

Known limitation: the cloid program marker (SUBSCRIPTION_CLOID_CONFIG.ProgramId) is a
placeholder. The registry value is an open [TODO] in ADR 0064 and belongs to the cloid
schema owners — out of scope here. It is isolated in a single constant so adopting the real
value is a one-line change; the decoder side must not be enabled against the placeholder.

Validation

  • Full perps-controller Jest suite: 80 suites, 3661 passed, 40 skipped.
  • Root yarn build (ts-bridge): exit 0, with the new symbols present in the emitted output.
  • mm-harness check diff --profile fast: policy-suppressions, ESLint, oxfmt, and Jest all
    pass over the 18 changed files.
  • yarn workspace @metamask/perps-controller run changelog:validate: pass.
  • Executable validation recipe: pass, 25/25 nodes, combining the Jest suites that encode
    each acceptance criterion with live HyperLiquid testnet reads through isolated headless
    controllers. Revert-sensitivity was checked rather than assumed — stashing a single source
    file fails 12 asserted tests.
  • Write-path recipes were deliberately not run: they submit real signed orders to testnet,
    and this change alters the payload those orders carry.

References

  • TAT-3967
  • ADR: MetaMask/decisions — 0064-subscription-perps-fee-waiver.md (status: IN REVIEW)
  • Supersedes the design shipped in TAT-3618
  • Mobile UI for the waived/blended state is TAT-3622 (out of scope here)

Client follow-ups this PR does not include:

  • Register SubscriptionController:getPerpsBenefits and SubscriptionController:registerAddress
    on the client messenger. Until then, benefits hydration falls back to the DI callback and
    address registration is skipped.
  • Pass amount (order notional in USD) to calculateFees. A preview that omits it quotes
    the full-waiver rate, which can under-quote a partial blend.

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 — 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 the injected hook, 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 SubscriptionController exposes no address-registration action so only the injected hook is observed (AC5). 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": "provider",
        "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"
      }
    }
  }
}

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 resolution, charged builder fees, and order client IDs on live trading paths, with breaking messenger typing and quote semantics when notional is omitted.

Overview
Implements ADR 0064 in @metamask/perps-controller: subscription fee relief is no longer a dedicated zero-fee builder plus per-user approval. It is a notional-aware blended MetaMask builder fee that competes with rewards in the same lowest-wins resolver, with preview and submit sharing one formula (including venue tenths-of-a-bip quantization).

Fee quoting and submission thread order USD notional through calculateFees (FeeCalculationParams.amount) and every TradingService write path (place, close, batch close, TP/SL, flip) so bounded allowances are not quoted or charged as full waivers. Fee previews re-price the MetaMask component from unified resolution via applyFeeResolution; results can expose subscriptionWaiverKind, covered notional, and chargesMetamaskBuilderFee.

Attribution stamps generated HyperLiquid client order IDs when subscription actually reduces the charged fee (#applySubscriptionCloid across placement, scale, chase replacements, TP/SL, batch close—not on modify). Caller-supplied cloids are never rewritten. The standard builder address is always used at the resolved fee.

Subscription integration hydrates benefits via SubscriptionController:getBenefits (with DI fallback), optional registerTradingAddress at preview and on account switch, remote flag perpsSubscriptionFeeWaiverEnabled, and exports @metamask/perps-controller/utils waiver helpers. approveSubscriptionBuilderFee is deprecated and always resolves true without calling the provider.

Breaking: parent messenger types must include SubscriptionControllerGetBenefitsAction; clients should pass order notional to calculateFees for accurate subscription quotes.

Reviewed by Cursor Bugbot for commit 221f5fc. 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.

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 13149f8. Configure here.

address,
chainId,
this.#deps.logger,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Wrong chain ID for address registration

High Severity

registerTradingAddress builds the CAIP-10 from NetworkController's currently selected network instead of HyperLiquid's chain (eip155:999 / eip155:998). Preview and account-switch registration therefore announce the trading address on whatever chain the wallet happens to have selected, so a fill decoded off the HyperLiquid fan-out cannot match the registered identifier and profile attribution fails.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 13149f8. Configure here.

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.

Confirmed — this is a real defect, not a false positive.

registerTradingAddress builds the CAIP-10 from NetworkController:getState().selectedNetworkClientId, so the chain is whatever the wallet has selected rather than HyperLiquid's (eip155:999 / eip155:998). The existing test encodes the wrong behaviour too — it asserts /^eip155:1:0x/u, i.e. Ethereum mainnet.

Not fixed in this push, deliberately. The service has no testnet signal: isTestnet lives in PerpsController state, and RewardsIntegrationService receives only deps and the messenger. Correcting it means choosing how the HL chain reaches the service (a constructor dep, a parameter from the two call sites, or a getter) and inverting a test expectation — a design call beyond a CI-failure pass, which is what this run was scoped to.

The limitation is now recorded in the package changelog in 5fc1ecf so it is not lost: attribution is affected, while fee resolution and order placement are not.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant