feat(perps): [perps-controller] Unified fee resolver for subscription waiver (cached, no submit-path calls) - #9857
Conversation
Resolve the MetaMask builder fee across every source and return the lowest: default (BUILDER_FEE_CONFIG), rewards (VIP + season, already collapsed by RewardsController), and subscription. The subscription source contributes 0 bips only when the eligibility gate — status active, perpsFeeWaiver entitled, usage available, not exhausted — passes on a cached benefits snapshot. Benefits arrive through a new optional PerpsPlatformDependencies.subscription.getPerpsBenefits() dependency and are cached stale-while-revalidate, mirroring the existing VIP pattern. getSubscriptionFeeWaiverStatus() reads the gate synchronously and only kicks off an opportunistic refresh, so no benefits request is ever awaited on the order-signing path. A missing, hard-stale, or unreachable snapshot fails the gate and falls back to the next-lowest source rather than erroring or over-granting, and backend exhaustion needs no client action because nothing is reserved client-side. calculateFees() surfaces the same cached gate as FeeCalculationResult.subscription (eligible, reason, remainingNotionalUsd) without adjusting the quoted rates, mutating the cap, or issuing a request. No provider change is required: a subscription win resolves to a 10000 bips discount, which the existing builder-fee math already maps to builder.f = 0. A test pins that so it cannot regress.
Throttle the opportunistic benefits refresh on the last read attempt rather than the last success. A failing read never advanced the snapshot timestamp, so during a benefits outage every fee preview started a new request; calculateFees() is a per-input call in a trading UI. Report a null benefits payload as reason 'no-subscription' instead of 'inactive'. The DI contract documents null as "no subscription to report" while the published type documents 'inactive' as "status is not active", so the reason string a client renders was wrong. The gate outcome is unchanged. Re-read the cached waiver status after the awaited rewards round trip so a background refresh landing during that window is picked up. Add invalidateSubscriptionBenefits() so clients can drop the snapshot on sign-out or a profile switch; the snapshot carries no profile identity and would otherwise keep answering for the previous profile until the next successful refresh. Cover the PerpsController -> MarketDataService fee-preview wiring with two controller-level tests, both branches. Deleting the wiring previously left the whole suite green.
Expose subscription benefits invalidation to clients. The previous pass added invalidateSubscriptionBenefits() to RewardsIntegrationService, but that service is private to the controller and is not exported from the package, so no consumer could reach it while the changelog instructed them to call it. PerpsController now delegates to it and the method is registered in MESSENGER_EXPOSED_METHODS, making it callable as the PerpsController:invalidateSubscriptionBenefits action; the generated action types are regenerated to match. Fence in-flight benefits reads behind an epoch counter. Invalidation cleared the snapshot but left a running read free to write its result back, so a read issued for the previous profile could repopulate the cache after a sign-out and mark it fresh. The epoch is captured when the read starts and compared before the write; a superseded read is discarded. The same check guards the attempt timestamp, so a discarded read cannot throttle the new identity's first fetch. Reword the changelog to name the controller method and messenger action rather than the unreachable service method.
- Clear the benefits dedupe handle in invalidateSubscriptionBenefits so the next refresh starts a fresh read for the new identity instead of awaiting the fenced in-flight one. - Export PerpsControllerInvalidateSubscriptionBenefitsAction from the package root, matching every sibling PerpsController*Action.
Automated pr-complete run — #9857
Worker reportPR #9857 — Comments ReportPR: #9857 Fetched actionable comments
Triage evidenceComment 1 — REAL. Reproduction path in the code as of
Net effect: the first status read after an identity change consumes its refresh opportunity on a read that can only be discarded, contradicting the method's own documented contract ("The next status read reports Comment 2 — REAL. General PR-conversation comments: none. Fixes appliedCommit: Files changed (39 insertions, 0 deletions):
Comment 1 fix rationale. Clearing the dedupe handle is the minimal correction: the fenced read can only be discarded, so deduping the new identity's caller onto it consumed the refresh opportunity without hydrating the cache. Comment 2 fix rationale. One line, alphabetically placed between Downstream compatibility assessment (step 8)
Validation
Runner-flag note: the checklist's Replies and thread resolution
Live re-fetch at step 4 confirmed the pre-rendered snapshot: exactly 2 unresolved inline comments, 0 general PR-conversation comments, 0 human reviews, |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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 a40cd12. Configure here.

Explanation
ADR 0064 requires the subscription fee waiver to remain attributable to a dedicated HyperLiquid builder and forbids subscription network activity during order signing.
This PR adds a unified fee resolver that chooses the lowest fee across default, rewards, and subscription sources. Subscription contributes a zero fee only when a cached benefits snapshot passes the eligibility gate (
active, entitled, usage available, not exhausted). Missing, stale, or unreachable benefits fail closed to the next-lowest source.ADR 0064 submit contract
resolveFee()andgetSubscriptionFeeWaiverStatus()are pure subscription-cache reads. They never startgetPerpsBenefits()from the signing path.refreshSubscriptionBenefits().PerpsFeeResolution, including its source, through order construction.subscriptionBuilderAddressTestnetorsubscriptionBuilderAddressMainnetonly after explicit approval throughPerpsController.approveSubscriptionBuilderFee().Package effects
PerpsPlatformDependencies.subscription.getPerpsBenefits()dependency.PerpsFeeResolution, andSUBSCRIPTION_BENEFITS_CACHE.FeeCalculationResultwhen the dependency is wired.PerpsControllermessenger actions.Validation
@metamask/perps-controllertest suite: 77 suites passed; 2,726 passed, 40 skipped.git diff --check: pass.perps-controllerproject successfully while rebuilding project references. A standalone package rebuild still hits the repository's existing TypeScript project-reference artifact mismatch (index.d.tsexpected while dependencies emitindex.d.mts).mm-harness validation
The task-local recipe
temp/core-9857-subscription-fee-routing.recipe.jsonpassesmm-harness --planand composes the focused static checks with the reusableperps.order-lifecyclerecipe.The reusable live lane validates ordinary HyperLiquid testnet signing/order lifecycle. A true subscription-builder E2E remains blocked until the dedicated builder is registered and the subscription onboarding/benefits fixture exists; this checkout also has no seeded wallet fixture. The recipe therefore does not claim live proof of the subscription waiver path yet.
References
Checklist
Screenshots/Recordings
Not applicable; this is controller and provider behavior with no UI changes.
Note
High Risk
Touches fee attribution, builder approval, and order-signing paths on HyperLiquid; incorrect waiver or builder routing could charge wrong fees or mis-attribute revenue, though caching and fail-closed gates limit over-granting.
Overview
Introduces a unified MetaMask builder fee resolver that picks the lowest rate among default, rewards (VIP/season), and an optional subscription perps fee waiver. Subscription contributes 0 bips only when a cached benefits snapshot passes an eligibility gate; missing, stale, or failed benefits fail closed to the next-lowest source instead of blocking trades or over-granting.
Wires an optional
subscription.getPerpsBenefits()dependency with stale-while-revalidate caching (SUBSCRIPTION_BENEFITS_CACHE), attempt-based refresh throttling, and identity invalidation on sign-out/profile switch.resolveFee()/ waiver status reads never start network calls on the order-signing path;calculateFees()hydrates viarefreshSubscriptionBenefits()and surfaces read-onlyFeeCalculationResult.subscriptionpreview fields.Trading now passes full
PerpsFeeResolution(source + discount) into providers with serialized concurrent fee context; flip orders use the same pipeline. HyperLiquid centralizes builder payloads in#getBuilderOrderContext(): subscription waivers use configurable subscription builder addresses at 0 fee only afterapproveSubscriptionBuilderFee(); otherwise orders use the ordinary builder at the standard fee. Chase sessions retain captured builder address/fee across re-prices.Exposes
approveSubscriptionBuilderFeeandinvalidateSubscriptionBenefitsonPerpsController(messenger actions); aggregated provider forwards resolution and delegates subscription approval to HyperLiquid.Reviewed by Cursor Bugbot for commit 250e573. Bugbot is set up for automated code reviews on this repo. Configure here.