@metamask-previews/perps-controller 17.4.0-preview-fb486948f → 17.4.0-preview-aecbce4ff

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/PerpsController-method-action-types.d.ts +10 -3
  3. package/dist/PerpsController-method-action-types.d.ts.map +1 -1
  4. package/dist/PerpsController-method-action-types.js.map +1 -1
  5. package/dist/PerpsController.d.ts +10 -3
  6. package/dist/PerpsController.d.ts.map +1 -1
  7. package/dist/PerpsController.js +62 -8
  8. package/dist/PerpsController.js.map +1 -1
  9. package/dist/constants/perpsConfig.d.ts +55 -0
  10. package/dist/constants/perpsConfig.d.ts.map +1 -1
  11. package/dist/constants/perpsConfig.js +55 -0
  12. package/dist/constants/perpsConfig.js.map +1 -1
  13. package/dist/providers/AggregatedPerpsProvider.d.ts +19 -0
  14. package/dist/providers/AggregatedPerpsProvider.d.ts.map +1 -1
  15. package/dist/providers/AggregatedPerpsProvider.js +21 -0
  16. package/dist/providers/AggregatedPerpsProvider.js.map +1 -1
  17. package/dist/providers/HyperLiquidProvider.d.ts +7 -0
  18. package/dist/providers/HyperLiquidProvider.d.ts.map +1 -1
  19. package/dist/providers/HyperLiquidProvider.js +133 -34
  20. package/dist/providers/HyperLiquidProvider.js.map +1 -1
  21. package/dist/providers/LighterProvider.d.ts.map +1 -1
  22. package/dist/providers/LighterProvider.js +2 -0
  23. package/dist/providers/LighterProvider.js.map +1 -1
  24. package/dist/services/MarketDataService.d.ts.map +1 -1
  25. package/dist/services/MarketDataService.js +16 -4
  26. package/dist/services/MarketDataService.js.map +1 -1
  27. package/dist/services/RewardsIntegrationService.d.ts +47 -2
  28. package/dist/services/RewardsIntegrationService.d.ts.map +1 -1
  29. package/dist/services/RewardsIntegrationService.js +400 -19
  30. package/dist/services/RewardsIntegrationService.js.map +1 -1
  31. package/dist/services/ServiceContext.d.ts +9 -1
  32. package/dist/services/ServiceContext.d.ts.map +1 -1
  33. package/dist/services/ServiceContext.js.map +1 -1
  34. package/dist/services/TradingService.d.ts.map +1 -1
  35. package/dist/services/TradingService.js +291 -18
  36. package/dist/services/TradingService.js.map +1 -1
  37. package/dist/types/index.d.ts +57 -3
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/dist/types/index.js.map +1 -1
  40. package/dist/types/messenger.d.ts +17 -1
  41. package/dist/types/messenger.d.ts.map +1 -1
  42. package/dist/types/messenger.js.map +1 -1
  43. package/dist/utils/hyperLiquidAdapter.d.ts +7 -0
  44. package/dist/utils/hyperLiquidAdapter.d.ts.map +1 -1
  45. package/dist/utils/hyperLiquidAdapter.js +1 -1
  46. package/dist/utils/hyperLiquidAdapter.js.map +1 -1
  47. package/dist/utils/index.d.ts +1 -0
  48. package/dist/utils/index.d.ts.map +1 -1
  49. package/dist/utils/index.js +1 -0
  50. package/dist/utils/index.js.map +1 -1
  51. package/dist/utils/subscriptionFeeWaiver.d.ts +201 -0
  52. package/dist/utils/subscriptionFeeWaiver.d.ts.map +1 -0
  53. package/dist/utils/subscriptionFeeWaiver.js +334 -0
  54. package/dist/utils/subscriptionFeeWaiver.js.map +1 -0
  55. package/package.json +6 -1
@@ -1,13 +1,16 @@
1
- import { BASIS_POINTS_DIVISOR, BUILDER_FEE_CONFIG, } from '../constants/hyperLiquidConfig.js';
2
- import { PERPS_CONSTANTS, SUBSCRIPTION_BENEFITS_CACHE, } from '../constants/perpsConfig.js';
1
+ import { BASIS_POINTS_DIVISOR, BUILDER_FEE_CONFIG, HYPERLIQUID_MAINNET_CHAIN_ID, HYPERLIQUID_TESTNET_CHAIN_ID, } from '../constants/hyperLiquidConfig.js';
2
+ import { PERPS_CONSTANTS, SUBSCRIPTION_BENEFITS_CACHE, SUBSCRIPTION_FEE_WAIVER_FLAG, } from '../constants/perpsConfig.js';
3
3
  import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js';
4
4
  import { ensureError } from '../utils/errorUtils.js';
5
5
  import { formatAccountToCaipAccountId } from '../utils/rewardsUtils.js';
6
+ import { quantizeBuilderFeeTenthsBps, resolveSubscriptionWaiverRate, } from '../utils/subscriptionFeeWaiver.js';
6
7
  /**
7
8
  * Default MetaMask builder fee, in basis points.
8
9
  * This is the fee every user pays when no cheaper source applies.
9
10
  */
10
11
  const DEFAULT_FEE_BIPS = BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR;
12
+ /** `SubscriptionController` reports allowances in micro-USD. */
13
+ const MICRO_USD_PER_USD = 1_000_000;
11
14
  /**
12
15
  * RewardsIntegrationService
13
16
  *
@@ -57,6 +60,19 @@ export class RewardsIntegrationService {
57
60
  * a sign-out or profile switch.
58
61
  */
59
62
  #benefitsEpoch = 0;
63
+ /**
64
+ * CAIP-10 addresses already registered with the subscription profile this
65
+ * session, so preview does not re-send the same registration on every
66
+ * keystroke. Cleared on account switch and on cache invalidation.
67
+ */
68
+ #registeredTradingAddresses = new Set();
69
+ /**
70
+ * Whether `SubscriptionController:getBenefits` has ever answered on this
71
+ * messenger. Action registration can be delegated, in which case it does not
72
+ * appear in `getRegisteredActionTypes`, so an answered call is the only
73
+ * reliable proof that the messenger route is available.
74
+ */
75
+ #messengerBenefitsAnswered = false;
60
76
  /**
61
77
  * Create a new RewardsIntegrationService instance
62
78
  *
@@ -87,10 +103,11 @@ export class RewardsIntegrationService {
87
103
  * Calculate user fee discount from the unified fee resolver.
88
104
  * Returns discount in basis points (e.g., 6500 = 65% discount)
89
105
  *
106
+ * @param orderNotionalUsd - Order notional (USD), when the caller knows it.
90
107
  * @returns The fee discount in basis points, or undefined if no source resolved.
91
108
  */
92
- async calculateUserFeeDiscount() {
93
- const resolution = await this.resolveFee();
109
+ async calculateUserFeeDiscount(orderNotionalUsd) {
110
+ const resolution = await this.resolveFee(orderNotionalUsd);
94
111
  return resolution.discountBips;
95
112
  }
96
113
  /**
@@ -100,9 +117,22 @@ export class RewardsIntegrationService {
100
117
  * unresolved cached source simply drops out of the comparison, so the worst
101
118
  * case is the default fee rather than an error or an over-granted waiver.
102
119
  *
120
+ * The subscription source contributes an effective rate rather than a flat
121
+ * zero (ADR 0064): the allowance may cover only part of the order, and the
122
+ * blend that results has to be able to lose to a deeper VIP or season
123
+ * discount. Passing the order notional is what makes that blend possible.
124
+ *
125
+ * Without a notional the outcome depends on whether the backend bounded the
126
+ * allowance. An unbounded allowance still resolves to the full waiver — there
127
+ * is no cap to over-consume. A *bounded* one is withheld entirely rather than
128
+ * quoted as a full waiver, because charging nothing on an order of unknown
129
+ * size would silently spend the cap; such a caller gets the next-lowest
130
+ * source instead.
131
+ *
132
+ * @param orderNotionalUsd - Order notional (USD), when the caller knows it.
103
133
  * @returns The winning fee, its source, and the subscription gate outcome.
104
134
  */
105
- async resolveFee() {
135
+ async resolveFee(orderNotionalUsd) {
106
136
  const rewardsDiscountBips = await this.#calculateRewardsDiscount();
107
137
  // Pure cache read: subscription benefits must never start a network request
108
138
  // while an order is being prepared for signing.
@@ -118,10 +148,39 @@ export class RewardsIntegrationService {
118
148
  source = 'rewards';
119
149
  }
120
150
  }
121
- // Nothing can undercut a waived fee, so the gate passing always wins.
122
- if (subscription.eligible) {
123
- feeBips = 0;
151
+ const waiver = resolveSubscriptionWaiverRate({
152
+ status: subscription,
153
+ maxFeeBips: DEFAULT_FEE_BIPS,
154
+ orderNotionalUsd,
155
+ });
156
+ let subscriptionWaiverKind;
157
+ let subscriptionCoveredNotionalUsd;
158
+ // The waiver competes like any other source. A full waiver still wins on
159
+ // `<=`, but a partial blend only wins when it is genuinely cheaper than the
160
+ // rewards discount — the ADR's requirement that subscription be able to
161
+ // lose.
162
+ //
163
+ // Compared *after* venue quantization, because that is the fee the order
164
+ // pays. The builder fee is submitted in integer tenths of a basis point, so
165
+ // a blend like 9.9999 bips is cheaper than the 10-bip default in arithmetic
166
+ // and identical to it on the wire. Selecting subscription on the raw number
167
+ // would label such an order `source: 'subscription'` with a 0-bip discount
168
+ // while it pays full price, and would disagree with the cloid marker, which
169
+ // already gates on the quantized fee.
170
+ const toTenthsBps = (bips) => quantizeBuilderFeeTenthsBps(Math.round((1 - bips / DEFAULT_FEE_BIPS) * BASIS_POINTS_DIVISOR));
171
+ // Subscription must be strictly cheaper on the wire to claim the order. A
172
+ // tie buys the user nothing — the same rate is already available from the
173
+ // source that won — while claiming it marks the cloid and spends the
174
+ // remaining allowance, so a tie is left to the other source. That holds at
175
+ // any rate, including a rewards discount that already reaches 0 bips.
176
+ const waiverSurvivesQuantization = toTenthsBps(waiver.feeBips) < toTenthsBps(feeBips);
177
+ if (waiver.applies &&
178
+ waiver.feeBips <= feeBips &&
179
+ waiverSurvivesQuantization) {
180
+ feeBips = waiver.feeBips;
124
181
  source = 'subscription';
182
+ subscriptionWaiverKind = waiver.kind === 'partial' ? 'partial' : 'full';
183
+ subscriptionCoveredNotionalUsd = waiver.coveredNotionalUsd;
125
184
  }
126
185
  const discountBips = source === 'default'
127
186
  ? undefined
@@ -132,10 +191,20 @@ export class RewardsIntegrationService {
132
191
  discountBips,
133
192
  defaultFeeBips: DEFAULT_FEE_BIPS,
134
193
  rewardsDiscountBips,
194
+ orderNotionalUsd,
135
195
  subscriptionEligible: subscription.eligible,
136
196
  subscriptionReason: subscription.reason,
197
+ subscriptionWaiverKind,
198
+ subscriptionCoveredNotionalUsd,
137
199
  });
138
- return { feeBips, discountBips, source, subscription };
200
+ return {
201
+ feeBips,
202
+ discountBips,
203
+ source,
204
+ subscription,
205
+ subscriptionWaiverKind,
206
+ subscriptionCoveredNotionalUsd,
207
+ };
139
208
  }
140
209
  /**
141
210
  * Read the subscription fee-waiver gate from the cached benefits snapshot.
@@ -146,7 +215,14 @@ export class RewardsIntegrationService {
146
215
  * @returns Whether the waiver applies, why, and the remaining notional.
147
216
  */
148
217
  getSubscriptionFeeWaiverStatus() {
149
- if (!this.#deps.subscription) {
218
+ if (!this.#hasSubscriptionSource()) {
219
+ return { eligible: false, reason: 'no-source' };
220
+ }
221
+ // ADR 0064 Milestone 8: the subscription source has to be killable on its
222
+ // own. Reported as `no-source` so a disabled flag is indistinguishable from
223
+ // an unwired client downstream — both mean "subscription contributes
224
+ // nothing", and neither touches rewards or the default fee.
225
+ if (!this.#isSubscriptionFeeWaiverEnabled()) {
150
226
  return { eligible: false, reason: 'no-source' };
151
227
  }
152
228
  const now = Date.now();
@@ -162,6 +238,56 @@ export class RewardsIntegrationService {
162
238
  }
163
239
  return evaluateFeeWaiverGate(snapshot.benefits);
164
240
  }
241
+ /**
242
+ * Whether this client has any way to read subscription benefits.
243
+ *
244
+ * Either wiring counts: a registered `SubscriptionController:getBenefits`
245
+ * action (the ADR 0064 target) or the legacy injected `subscription` callback.
246
+ * Requiring the injected one would make the messenger path unreachable on
247
+ * exactly the configuration it was added for, so the messenger is probed by
248
+ * asking whether an action handler exists rather than by calling it — this
249
+ * runs on the synchronous status read and must not start work.
250
+ *
251
+ * @returns True when benefits can be read by some route.
252
+ */
253
+ #hasSubscriptionSource() {
254
+ if (this.#deps.subscription || this.#messengerBenefitsAnswered) {
255
+ return true;
256
+ }
257
+ try {
258
+ // `getRegisteredActionTypes` reports this messenger's own registrations.
259
+ // A delegated action does not appear there, which is why it is only a
260
+ // positive signal — `refreshSubscriptionBenefits` still attempts the call
261
+ // regardless, and an answer sets `#messengerBenefitsAnswered` above.
262
+ return this.#messenger
263
+ .getRegisteredActionTypes()
264
+ .includes('SubscriptionController:getBenefits');
265
+ }
266
+ catch {
267
+ return false;
268
+ }
269
+ }
270
+ /**
271
+ * Whether the subscription fee-waiver source is enabled remotely.
272
+ *
273
+ * Fails open: an absent flag, a malformed value, or an unreachable flag
274
+ * controller all read as enabled, because silently dropping a benefit the
275
+ * user pays for is worse than serving it one release too long. The kill
276
+ * switch is an explicit `false`.
277
+ *
278
+ * @returns True unless the remote flag explicitly disables the source.
279
+ */
280
+ #isSubscriptionFeeWaiverEnabled() {
281
+ try {
282
+ const { remoteFeatureFlags } = this.#messenger.call('RemoteFeatureFlagController:getState');
283
+ const flag = remoteFeatureFlags?.[SUBSCRIPTION_FEE_WAIVER_FLAG];
284
+ return flag !== false;
285
+ }
286
+ catch {
287
+ // No flag controller registered, or the read threw: keep the source.
288
+ return true;
289
+ }
290
+ }
165
291
  /**
166
292
  * Refresh the cached subscription benefits snapshot.
167
293
  *
@@ -172,10 +298,10 @@ export class RewardsIntegrationService {
172
298
  * @returns A promise that settles when the refresh completes.
173
299
  */
174
300
  async refreshSubscriptionBenefits() {
175
- const source = this.#deps.subscription;
176
- if (!source) {
177
- return;
178
- }
301
+ // Deliberately not gated on `#hasSubscriptionSource`: a delegated action is
302
+ // invisible to `getRegisteredActionTypes`, so the first call is what proves
303
+ // it is reachable. A client with no source at all reads `null` here, which
304
+ // costs one no-op call per freshness window and leaves the gate closed.
179
305
  if (this.#benefitsRefresh) {
180
306
  await this.#benefitsRefresh;
181
307
  return;
@@ -189,7 +315,7 @@ export class RewardsIntegrationService {
189
315
  sinceAttempt < SUBSCRIPTION_BENEFITS_CACHE.FreshMs) {
190
316
  return;
191
317
  }
192
- const refresh = this.#readSubscriptionBenefits(source);
318
+ const refresh = this.#readSubscriptionBenefits();
193
319
  this.#benefitsRefresh = refresh;
194
320
  // `finally` always defers, so this never clears the handle we just set.
195
321
  refresh
@@ -221,18 +347,19 @@ export class RewardsIntegrationService {
221
347
  // fetching for the new identity. Its `finally` guard compares against the
222
348
  // current handle, so it will not clear whatever replaces it here.
223
349
  this.#benefitsRefresh = undefined;
350
+ // The identity behind the registration changed too, so the new one has to
351
+ // announce itself rather than inherit the previous profile's registration.
352
+ this.#registeredTradingAddresses.clear();
224
353
  this.#deps.debugLogger.log('RewardsIntegrationService: Subscription benefits cache invalidated');
225
354
  }
226
355
  /**
227
356
  * Perform one benefits read and store it, keeping the previous snapshot on
228
357
  * error. Never rejects, so callers cannot produce an unhandled rejection.
229
- *
230
- * @param source - The injected subscription benefits source.
231
358
  */
232
- async #readSubscriptionBenefits(source) {
359
+ async #readSubscriptionBenefits() {
233
360
  const epoch = this.#benefitsEpoch;
234
361
  try {
235
- const benefits = await source.getPerpsBenefits();
362
+ const benefits = await this.#getPerpsBenefits();
236
363
  if (epoch !== this.#benefitsEpoch) {
237
364
  // Invalidated while this read was in flight: it belongs to a previous
238
365
  // identity, so discarding it is the only safe outcome.
@@ -268,6 +395,183 @@ export class RewardsIntegrationService {
268
395
  }
269
396
  }
270
397
  }
398
+ /**
399
+ * Read subscription benefits, preferring the messenger over the DI callback.
400
+ *
401
+ * ADR 0064 moves hydration onto `SubscriptionController`. The messenger is
402
+ * tried first and the injected `subscription` dependency is only a fallback,
403
+ * so a client that ships `SubscriptionController` without the legacy callback
404
+ * hydrates normally — that configuration is the ADR's target, not an edge
405
+ * case. A client with neither gets `null`, which reads as "no subscription".
406
+ *
407
+ * @returns The benefits payload, or null when there is none to report.
408
+ */
409
+ async #getPerpsBenefits() {
410
+ let pending;
411
+ try {
412
+ // Called without awaiting so the fallback stays synchronous when no
413
+ // handler is registered: the DI read must start in the same tick, or a
414
+ // caller that inspects the in-flight state sees an idle service.
415
+ const result = this.#messenger.call('SubscriptionController:getBenefits');
416
+ // `undefined` means nothing handled the action, which is the fallback
417
+ // case rather than an answer.
418
+ if (result !== undefined) {
419
+ this.#messengerBenefitsAnswered = true;
420
+ pending = Promise.resolve(result).then(adaptSubscriptionBenefits);
421
+ }
422
+ }
423
+ catch (error) {
424
+ // A handler that has answered before exists, so a synchronous throw is a
425
+ // real failure rather than an unregistered action. Treating it as the
426
+ // latter would fall through to `null` and erase a valid cached snapshot,
427
+ // exactly as an asynchronous rejection would.
428
+ if (isNotSubscribedRejection(error)) {
429
+ // Definitive "not entitled", even thrown synchronously.
430
+ return null;
431
+ }
432
+ // An unregistered action throws a recognisable "no handler" error; any
433
+ // other synchronous throw came from a handler that exists and failed, so
434
+ // it must not be mistaken for an absent action and cached as `null` —
435
+ // including on the very first call, before one has ever answered.
436
+ if (!isUnregisteredActionError(error) && !this.#deps.subscription) {
437
+ throw error;
438
+ }
439
+ // Otherwise: unregistered action, or a throw with a DI source to fall
440
+ // back to.
441
+ }
442
+ const fallback = this.#deps.subscription;
443
+ if (pending) {
444
+ try {
445
+ return await pending;
446
+ }
447
+ catch (error) {
448
+ if (isNotSubscribedRejection(error)) {
449
+ // A definitive answer, not a failed read: the profile is not
450
+ // entitled. Returning `null` replaces the cached snapshot, which is
451
+ // the point — preserving it would keep granting the waiver for the
452
+ // rest of the staleness window after entitlement ended.
453
+ return null;
454
+ }
455
+ if (!fallback) {
456
+ // Nothing else can answer, so this rejection is the whole result.
457
+ // Returning `null` here would be stored as a successful "no
458
+ // subscription" snapshot and silently erase a valid cached one; let
459
+ // it reach the refresh handler, which keeps the previous snapshot.
460
+ throw error;
461
+ }
462
+ // A registered handler that rejects still falls back to the injected
463
+ // source rather than erasing the cached snapshot.
464
+ }
465
+ }
466
+ if (!fallback) {
467
+ // No handler answered and no injected source: genuinely nothing to
468
+ // report, which is a real `null` rather than a swallowed failure.
469
+ return null;
470
+ }
471
+ return await fallback.getPerpsBenefits();
472
+ }
473
+ /**
474
+ * Register the current HyperLiquid trading address with the subscription
475
+ * profile, so a later fill decoded off the HL fan-out can be attributed.
476
+ *
477
+ * ADR 0064 calls for this at preview time and again whenever the selected
478
+ * account changes. Registration is idempotent backend-side and deliberately
479
+ * never throws: it is observability plumbing, and a failure here must not
480
+ * block a fee preview.
481
+ *
482
+ * New clients may route this through the structural
483
+ * `SubscriptionController:registerAddress` action. Older clients continue to
484
+ * use the injected `subscription` hook as a compatibility fallback.
485
+ *
486
+ * @param address - The EVM trading address to register.
487
+ * @param options - Registration options.
488
+ * @param options.isTestnet - Whether the caller is trading against the
489
+ * HyperLiquid testnet. Selects which HyperLiquid chain the CAIP-10 names;
490
+ * defaults to mainnet.
491
+ * @returns A promise that resolves once the attempt settles.
492
+ */
493
+ async registerTradingAddress(address, options) {
494
+ try {
495
+ // HyperLiquid's own chain, not the wallet's selected network. The address
496
+ // is being announced so a fill decoded off the HyperLiquid fan-out can be
497
+ // matched back to a profile, and that fill names `eip155:999` /
498
+ // `eip155:998`. Registering under whatever network the wallet happened to
499
+ // have selected produces an identifier no fill will ever match.
500
+ const chainId = options?.isTestnet
501
+ ? HYPERLIQUID_TESTNET_CHAIN_ID
502
+ : HYPERLIQUID_MAINNET_CHAIN_ID;
503
+ const caipAccountId = formatAccountToCaipAccountId(address, chainId, this.#deps.logger);
504
+ if (!caipAccountId) {
505
+ return;
506
+ }
507
+ // Skip the round trip when this address was already registered for this
508
+ // session: preview runs on every keystroke in the order form.
509
+ if (this.#registeredTradingAddresses.has(caipAccountId)) {
510
+ return;
511
+ }
512
+ // Capture the identity before invoking either integration. If profile
513
+ // invalidation happens while the handler is pending, its completion must
514
+ // not repopulate the registration cache for the old profile.
515
+ const epoch = this.#benefitsEpoch;
516
+ // New clients can route registration through SubscriptionController.
517
+ // Older clients do not expose this action and continue using the
518
+ // injected hook below.
519
+ let registration;
520
+ try {
521
+ const result = this.#messenger.call('SubscriptionController:registerAddress', caipAccountId);
522
+ if (result !== undefined) {
523
+ registration = Promise.resolve(result);
524
+ }
525
+ }
526
+ catch (error) {
527
+ if (!isUnregisteredActionError(error) && !this.#deps.subscription) {
528
+ throw error;
529
+ }
530
+ }
531
+ if (!registration) {
532
+ const source = this.#deps.subscription;
533
+ if (!source?.registerTradingAddress) {
534
+ // Visible rather than silent: a client wired only to benefits has no
535
+ // way to register, and a missing registration is otherwise
536
+ // indetectable until fills arrive unattributed.
537
+ this.#deps.debugLogger.log('RewardsIntegrationService: No trading-address registration hook wired; fills will be unattributed', { address });
538
+ return;
539
+ }
540
+ registration = source.registerTradingAddress(caipAccountId);
541
+ }
542
+ // Fence the completion against an invalidation that lands while this
543
+ // registration is in flight. `invalidateSubscriptionBenefits` clears the
544
+ // registered set because the profile behind it changed; writing back
545
+ // afterwards would re-add an address registered for the *previous*
546
+ // profile, and the new profile would then skip its own registration and
547
+ // leave its fills unattributable.
548
+ await registration;
549
+ if (epoch !== this.#benefitsEpoch) {
550
+ this.#deps.debugLogger.log('RewardsIntegrationService: Trading address registration superseded', { caipAccountId });
551
+ return;
552
+ }
553
+ this.#registeredTradingAddresses.add(caipAccountId);
554
+ this.#deps.debugLogger.log('RewardsIntegrationService: Trading address registered', { caipAccountId });
555
+ }
556
+ catch (error) {
557
+ // An offline client or a backend refusal both land here. Neither is a
558
+ // reason to fail a fee preview.
559
+ this.#deps.debugLogger.log('RewardsIntegrationService: Trading address registration skipped', {
560
+ address,
561
+ error: ensureError(error, 'RewardsIntegrationService.registerTradingAddress').message,
562
+ });
563
+ }
564
+ }
565
+ /**
566
+ * Forget which trading addresses were registered this session.
567
+ *
568
+ * Called when the selected account changes, so the next preview re-sends the
569
+ * registration for the new address rather than assuming the previous one
570
+ * still stands.
571
+ */
572
+ resetRegisteredTradingAddresses() {
573
+ this.#registeredTradingAddresses.clear();
574
+ }
271
575
  /**
272
576
  * Resolve the rewards (VIP + season) discount for the selected account.
273
577
  *
@@ -342,6 +646,83 @@ export class RewardsIntegrationService {
342
646
  }
343
647
  }
344
648
  }
649
+ /**
650
+ * Whether a messenger call failed because no handler is registered.
651
+ *
652
+ * `Messenger.call` reports an unregistered action with a distinctive message.
653
+ * Anything else thrown synchronously came from a handler that does exist, and
654
+ * conflating the two would cache a real failure as "no subscription".
655
+ *
656
+ * @param error - The error thrown by the messenger call.
657
+ * @returns True when the action has no registered handler.
658
+ */
659
+ function isUnregisteredActionError(error) {
660
+ return (error instanceof Error &&
661
+ /handler.*not.*registered|no.*handler.*registered|A handler for .* has not been registered/iu.test(error.message));
662
+ }
663
+ /**
664
+ * Whether a benefits rejection definitively means "this profile is not
665
+ * entitled", as opposed to "the read failed".
666
+ *
667
+ * `SubscriptionController.getBenefits` throws `UserNotSubscribed` when the
668
+ * subscription is inactive or the response reports ineligibility, and clears
669
+ * its own benefits state on that path. Treating it as a transport failure would
670
+ * keep serving a cached waiver for the rest of the staleness window — up to ten
671
+ * minutes of free trading after entitlement ended — so it is converted to a
672
+ * real `null` answer instead.
673
+ *
674
+ * Matched on the message because the controller throws a plain `Error`; any
675
+ * other failure stays a failure and preserves the cached snapshot.
676
+ *
677
+ * @param error - The rejection from the benefits read.
678
+ * @returns True when the rejection means the profile is not entitled.
679
+ */
680
+ function isNotSubscribedRejection(error) {
681
+ return (error instanceof Error && error.message.includes('User is not subscribed'));
682
+ }
683
+ /**
684
+ * Convert `SubscriptionController`'s benefits response into the shape the
685
+ * waiver gate reads.
686
+ *
687
+ * The controller reports allowances in **micro-USD** (`remainingMicroUsd`) and
688
+ * carries no `status`/`entitled` fields: eligibility is the response's own
689
+ * `eligible` flag, and the perps product block holds the cap. Converting once,
690
+ * here at the boundary, keeps the gate and the blended-rate formula working in
691
+ * whole USD.
692
+ *
693
+ * A `remainingMicroUsd` of `null` means the backend reported no bound, which
694
+ * stays an unbounded allowance rather than a spent one.
695
+ *
696
+ * @param response - The controller's benefits response.
697
+ * @returns The internal benefits shape, or null when nothing is entitled.
698
+ */
699
+ function adaptSubscriptionBenefits(response) {
700
+ if (!response) {
701
+ return null;
702
+ }
703
+ const perps = response.products?.perps;
704
+ // `products.perps` is always present on the response, so its existence proves
705
+ // nothing. Entitlement is the response-level `eligible` flag plus positive
706
+ // evidence from the perps block itself: a builder fee rate to apply, or a
707
+ // reported allowance to spend. A block carrying neither describes a profile
708
+ // with no perps benefit, whatever the other products say.
709
+ const hasPerpsBenefit = Boolean(perps &&
710
+ (perps.builderFeeBips !== null ||
711
+ perps.remainingMicroUsd !== null ||
712
+ perps.capMicroUsd !== undefined));
713
+ return {
714
+ status: response.eligible ? 'active' : 'inactive',
715
+ perpsFeeWaiver: {
716
+ entitled: response.eligible && hasPerpsBenefit,
717
+ usage: perps?.exhausted ? 'exhausted' : 'available',
718
+ exhausted: perps?.exhausted,
719
+ remainingNotionalUsd: perps?.remainingMicroUsd === null ||
720
+ perps?.remainingMicroUsd === undefined
721
+ ? undefined
722
+ : perps.remainingMicroUsd / MICRO_USD_PER_USD,
723
+ },
724
+ };
725
+ }
345
726
  /**
346
727
  * Evaluate the perps fee-waiver eligibility gate against a benefits snapshot.
347
728
  *