@metamask-previews/perps-controller 17.3.0-preview-d3f9964be → 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.
- package/CHANGELOG.md +72 -1
- package/dist/PerpsController-method-action-types.d.ts +10 -3
- package/dist/PerpsController-method-action-types.d.ts.map +1 -1
- package/dist/PerpsController-method-action-types.js.map +1 -1
- package/dist/PerpsController.d.ts +10 -3
- package/dist/PerpsController.d.ts.map +1 -1
- package/dist/PerpsController.js +62 -8
- package/dist/PerpsController.js.map +1 -1
- package/dist/constants/perpsConfig.d.ts +55 -0
- package/dist/constants/perpsConfig.d.ts.map +1 -1
- package/dist/constants/perpsConfig.js +55 -0
- package/dist/constants/perpsConfig.js.map +1 -1
- package/dist/providers/AggregatedPerpsProvider.d.ts +19 -0
- package/dist/providers/AggregatedPerpsProvider.d.ts.map +1 -1
- package/dist/providers/AggregatedPerpsProvider.js +21 -0
- package/dist/providers/AggregatedPerpsProvider.js.map +1 -1
- package/dist/providers/HyperLiquidProvider.d.ts +7 -0
- package/dist/providers/HyperLiquidProvider.d.ts.map +1 -1
- package/dist/providers/HyperLiquidProvider.js +133 -34
- package/dist/providers/HyperLiquidProvider.js.map +1 -1
- package/dist/providers/LighterProvider.d.ts.map +1 -1
- package/dist/providers/LighterProvider.js +2 -0
- package/dist/providers/LighterProvider.js.map +1 -1
- package/dist/services/MarketDataService.d.ts.map +1 -1
- package/dist/services/MarketDataService.js +16 -4
- package/dist/services/MarketDataService.js.map +1 -1
- package/dist/services/RewardsIntegrationService.d.ts +47 -2
- package/dist/services/RewardsIntegrationService.d.ts.map +1 -1
- package/dist/services/RewardsIntegrationService.js +400 -19
- package/dist/services/RewardsIntegrationService.js.map +1 -1
- package/dist/services/ServiceContext.d.ts +9 -1
- package/dist/services/ServiceContext.d.ts.map +1 -1
- package/dist/services/ServiceContext.js.map +1 -1
- package/dist/services/TerminalMarketService.d.ts +1 -1
- package/dist/services/TerminalMarketService.d.ts.map +1 -1
- package/dist/services/TerminalMarketService.js +11 -2
- package/dist/services/TerminalMarketService.js.map +1 -1
- package/dist/services/TradingService.d.ts.map +1 -1
- package/dist/services/TradingService.js +291 -18
- package/dist/services/TradingService.js.map +1 -1
- package/dist/types/index.d.ts +65 -4
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/messenger.d.ts +17 -1
- package/dist/types/messenger.d.ts.map +1 -1
- package/dist/types/messenger.js.map +1 -1
- package/dist/utils/hyperLiquidAdapter.d.ts +7 -0
- package/dist/utils/hyperLiquidAdapter.d.ts.map +1 -1
- package/dist/utils/hyperLiquidAdapter.js +1 -1
- package/dist/utils/hyperLiquidAdapter.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/subscriptionFeeWaiver.d.ts +201 -0
- package/dist/utils/subscriptionFeeWaiver.d.ts.map +1 -0
- package/dist/utils/subscriptionFeeWaiver.js +334 -0
- package/dist/utils/subscriptionFeeWaiver.js.map +1 -0
- package/package.json +11 -6
|
@@ -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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 {
|
|
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.#
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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(
|
|
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(
|
|
359
|
+
async #readSubscriptionBenefits() {
|
|
233
360
|
const epoch = this.#benefitsEpoch;
|
|
234
361
|
try {
|
|
235
|
-
const benefits = await
|
|
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
|
*
|