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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ 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 5e6f40e. Configure here.
- 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, |

Explanation
Current state.
RewardsIntegrationService.calculateUserFeeDiscount()resolves exactly one source for the MetaMask builder fee: the rewards discount (VIP and season, already collapsed into one value byRewardsController). ADR 0064 (rev 2026-07-29) adds subscription as another source — and drops the earlier decide-at-submit + reserve model, so there is no reserve call, no cloid, and nothing may go over the network while an order is being signed.Solution.
RewardsIntegrationServicenow owns a unified fee resolver. It works in fee basis points, lowest wins, and converts once at the edge to the discount bips providers already consume:defaultBUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR= 10rewards(VIP + season)10 * (1 - rewardsDiscountBips / 10000)subscription0, only when the eligibility gate passesThe subscription source is gated on
status=active∧perpsFeeWaiverentitled ∧usage=available∧ not exhausted, evaluated against a cached benefits snapshot. The snapshot arrives through a new optional DI dependency,PerpsPlatformDependencies.subscription.getPerpsBenefits()— the client owns the Profile JWT and performsGET /v1/profiles/{profileId}/benefits, mirroring how the rewards read is already injected. The cache is stale-while-revalidate: served as-is insideSUBSCRIPTION_BENEFITS_CACHE.FreshMs, still served past it while a background refresh runs, and no longer trusted to grant the waiver pastMaxStaleMs.getSubscriptionFeeWaiverStatus()is synchronous and only kicks the refresh off, so the order-signing path never awaits a benefits request.Degradation is one-directional. A missing, hard-stale, or unreachable snapshot fails the gate and the resolver falls back to the next-lowest source — it never errors and never over-grants.
exhausted=trueneeds no client action at all: nothing is reserved client-side, so the next refresh simply stops passing the gate.PerpsController.calculateFees()reads that same cached gate and surfaces it asFeeCalculationResult.subscription(eligible,reason,remainingNotionalUsd). The preview is read-only: it does not adjust the quoted rates, mutate the cap, or issue a request. Client note: render "fees waived" fromsubscription.eligiblerather than expecting a zeroedmetamaskFeeRate— re-deriving the waived rate in the preview would put a second source of truth next to the resolver.Package effects.
PerpsSubscriptionBenefits,PerpsSubscriptionUsage,PerpsSubscriptionFeeWaiverStatus,PerpsFeeSource,PerpsFeeResolution; new constantSUBSCRIPTION_BENEFITS_CACHE.RewardsIntegrationService:resolveFee(),getSubscriptionFeeWaiverStatus(),refreshSubscriptionBenefits().ServiceContext(subscriptionFeeWaiver?), soMarketDataService.calculateFees()keeps its{ provider, params, context }signature. It is deliberately not onFeeCalculationParams: that object is passed verbatim toprovider.calculateFees()and is the public argument ofPerpsController.calculateFees(), so a controller-owned cache value there would widen the provider interface and let callers supply their own waiver status.subscriptionis optional andFeeCalculationResult.subscriptionis omitted entirely when it is not wired, so clients without the subscription waiver (extension) see no change.Math.floor(MaxFeeTenthsBps * (1 - d/10000))already lands onbuilder.f = 0. Added a test pinning that so the ADR requirement cannot silently regress.Validation.
@metamask/perps-controllersuite: 77 suites, 2708 passed (40 pre-existing skips).resolveFee()still resolves), all three cache-freshness windows, hard-stale and unreachable fallback, backend exhaustion, refresh dedupe, the fee-preview surfacing and its absence of side effects, andbuilder.f = 0.yarn build: pass, with the new symbols present in the emitted.d.cts/.cjs.changelog:validate: pass.References
PerpsPlatformDependencies.subscriptionand render the waiver fromFeeCalculationResult.subscription. Ships mobile-only for v1.Checklist
subscriptiondependency is optional and the newFeeCalculationResult.subscriptionfield is omitted when it is not wired, so existing consumers are unaffected.Screenshots/Recordings
Note
Medium Risk
Changes which discount applies at order placement (fee/revenue) and uses cached subscription state with fail-closed stale handling; mis-wiring or missing invalidation could under-charge or briefly mis-attribute waivers across profiles.
Overview
Adds a unified MetaMask builder fee resolver in
RewardsIntegrationServicethat picks the lowest fee among default, rewards (VIP/season), and an optional subscription source injected viaPerpsPlatformDependencies.subscription.getPerpsBenefits(). Subscription contributes 0 bips only when a strict eligibility gate passes on a stale-while-revalidate cache (SUBSCRIPTION_BENEFITS_CACHE); order signing never awaits benefits—reads are synchronous and refresh runs in the background with attempt-based throttling and epoch fencing oninvalidateSubscriptionBenefits()(also onPerpsController/ messenger) for sign-out and profile switches.calculateUserFeeDiscount()now returns the resolver’s winning discount (full waiver →10000bips /builder.f = 0on HyperLiquid).calculateFees()attaches a read-onlyFeeCalculationResult.subscriptionpreview (eligible,reason,remainingNotionalUsd) without changing quoted rates. Clients withoutsubscriptionwired are unchanged.Reviewed by Cursor Bugbot for commit 8cc36fc. Bugbot is set up for automated code reviews on this repo. Configure here.