@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +72 -1
  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/TerminalMarketService.d.ts +1 -1
  35. package/dist/services/TerminalMarketService.d.ts.map +1 -1
  36. package/dist/services/TerminalMarketService.js +11 -2
  37. package/dist/services/TerminalMarketService.js.map +1 -1
  38. package/dist/services/TradingService.d.ts.map +1 -1
  39. package/dist/services/TradingService.js +291 -18
  40. package/dist/services/TradingService.js.map +1 -1
  41. package/dist/types/index.d.ts +65 -4
  42. package/dist/types/index.d.ts.map +1 -1
  43. package/dist/types/index.js.map +1 -1
  44. package/dist/types/messenger.d.ts +17 -1
  45. package/dist/types/messenger.d.ts.map +1 -1
  46. package/dist/types/messenger.js.map +1 -1
  47. package/dist/utils/hyperLiquidAdapter.d.ts +7 -0
  48. package/dist/utils/hyperLiquidAdapter.d.ts.map +1 -1
  49. package/dist/utils/hyperLiquidAdapter.js +1 -1
  50. package/dist/utils/hyperLiquidAdapter.js.map +1 -1
  51. package/dist/utils/index.d.ts +1 -0
  52. package/dist/utils/index.d.ts.map +1 -1
  53. package/dist/utils/index.js +1 -0
  54. package/dist/utils/index.js.map +1 -1
  55. package/dist/utils/subscriptionFeeWaiver.d.ts +201 -0
  56. package/dist/utils/subscriptionFeeWaiver.d.ts.map +1 -0
  57. package/dist/utils/subscriptionFeeWaiver.js +334 -0
  58. package/dist/utils/subscriptionFeeWaiver.js.map +1 -0
  59. package/package.json +11 -6
@@ -6,7 +6,7 @@ import { PerpsMeasurementName } from '../constants/performanceMetrics.js';
6
6
  import { PERPS_CONSTANTS } from '../constants/perpsConfig.js';
7
7
  import { PerpsAnalyticsEvent, PerpsTraceNames, PerpsTraceOperations, } from '../types/index.js';
8
8
  import { ensureError } from '../utils/errorUtils.js';
9
- import { isLimitExecutionOrderType } from '../utils/orderTypes.js';
9
+ import { isLimitExecutionOrderType, SCALE_ORDER_COUNT, } from '../utils/orderTypes.js';
10
10
  /**
11
11
  * TradingService
12
12
  *
@@ -440,7 +440,7 @@ export class TradingService {
440
440
  },
441
441
  });
442
442
  // Calculate fee discount at execution time (fresh, secure)
443
- const feeResolution = await this.#calculateFeeDiscountWithMeasurement();
443
+ const feeResolution = await this.#calculateFeeDiscountWithMeasurement(this.#resolveOrderNotionalUsd(params));
444
444
  this.#deps.debugLogger.log('TradingService: Fee resolution calculated', {
445
445
  feeDiscountBips: feeResolution?.discountBips,
446
446
  feeSource: feeResolution?.source,
@@ -594,16 +594,25 @@ export class TradingService {
594
594
  * @param options - The configuration options.
595
595
  * @param options.symbol - The trading pair symbol.
596
596
  * @param options.context - The service context for dependencies.
597
+ * @param options.provider - The provider the write will be submitted through,
598
+ * used to narrow the read to positions that route can reach. Omit for a
599
+ * read-only lookup that does not precede a write.
600
+ * @param options.providerId - Explicit route, when the caller supplied one.
597
601
  * @returns The result of the operation.
598
602
  */
599
603
  async #loadPositionData(options) {
600
- const { symbol, context } = options;
604
+ const { symbol, context, provider, providerId } = options;
601
605
  const positionLoadStart = this.#deps.performance.now();
602
606
  try {
603
607
  const positions = context.getPositions
604
608
  ? await context.getPositions()
605
609
  : [];
606
- const position = positions.find((pos) => pos.symbol === symbol);
610
+ // Matching on symbol alone can pick another provider's position when two
611
+ // providers list the same market, so a routed write narrows first.
612
+ const candidates = provider
613
+ ? this.#positionsForWriteRoute({ positions, provider, providerId })
614
+ : positions;
615
+ const position = candidates.find((pos) => pos.symbol === symbol);
607
616
  this.#deps.tracer.setMeasurement(PerpsMeasurementName.PerpsGetPositionsOperation, this.#deps.performance.now() - positionLoadStart, 'millisecond');
608
617
  return position;
609
618
  }
@@ -869,13 +878,219 @@ export class TradingService {
869
878
  });
870
879
  }
871
880
  /**
872
- * Calculate fee discount with performance measurement
873
- * Uses controller dependencies injected via setControllerDependencies()
874
- * Helper method for placeOrder orchestration
881
+ * Resolve the total USD notional a batch close will submit.
875
882
  *
876
- * @returns The result of the operation.
883
+ * HyperLiquid takes one builder context for the whole batch, so the fee is
884
+ * resolved once against everything it closes rather than per position.
885
+ * Positions are read through the service context; when that read is
886
+ * unavailable or empty the notional is undefined, which the resolver reads as
887
+ * "no notional to blend against".
888
+ *
889
+ * @param options - The configuration options.
890
+ * @param options.params - Which positions the batch will close.
891
+ * @param options.provider - The provider that will submit the batch, and so
892
+ * the only one whose positions it can close.
893
+ * @returns The summed notional in USD, or undefined when it cannot be read.
894
+ */
895
+ async #resolveBatchCloseNotionalUsd(options) {
896
+ const { params, provider } = options;
897
+ try {
898
+ // Read through the provider that will actually submit the batch, then
899
+ // keep only what that route can close: an aggregating provider's
900
+ // `getPositions` still spans every active provider, so summing the rest
901
+ // would inflate the notional and shrink the waiver for positions this
902
+ // call never touches.
903
+ const positions = this.#positionsForWriteRoute({
904
+ positions: await provider.getPositions(),
905
+ provider,
906
+ });
907
+ // `closeAll`, or an omitted/empty symbol list, means every position.
908
+ // `closeAll` is checked first because the provider gives it precedence
909
+ // over a symbol list: pricing a filtered subset while the batch closes
910
+ // everything would resolve the fee against too small a notional and
911
+ // over-grant the waiver.
912
+ const selected = params.closeAll !== true && params.symbols && params.symbols.length > 0
913
+ ? positions.filter((position) => params.symbols?.includes(position.symbol))
914
+ : positions;
915
+ const total = selected.reduce((sum, position) => {
916
+ const value = Math.abs(Number.parseFloat(position.positionValue));
917
+ return Number.isFinite(value) ? sum + value : sum;
918
+ }, 0);
919
+ return total > 0 ? total : undefined;
920
+ }
921
+ catch (error) {
922
+ // Pricing the batch must never fail the close. Without a notional the
923
+ // resolver quotes the full waiver, which is what it did before.
924
+ this.#deps.debugLogger.log('TradingService: Could not price batch close for the fee resolver', {
925
+ error: ensureError(error, 'TradingService.resolveBatchCloseNotionalUsd').message,
926
+ });
927
+ return undefined;
928
+ }
929
+ }
930
+ /**
931
+ * Keep only the positions a write through this route can actually touch.
932
+ *
933
+ * An aggregating provider reads positions from every active provider while a
934
+ * write goes to one, so pricing a close against the unfiltered list can bill
935
+ * against positions the call cannot close — and, when two providers list the
936
+ * same symbol, can price one provider's position for a write submitted to
937
+ * another. A provider that does not aggregate reports no write route and its
938
+ * positions are all its own.
939
+ *
940
+ * @param options - The configuration options.
941
+ * @param options.positions - Positions as read, possibly across providers.
942
+ * @param options.provider - The provider the write will be submitted through.
943
+ * @param options.providerId - Explicit route, when the caller supplied one.
944
+ * @returns The positions that route can reach.
945
+ */
946
+ #positionsForWriteRoute(options) {
947
+ const { positions, provider, providerId } = options;
948
+ const route = provider.getWriteProviderId?.(providerId);
949
+ if (route === undefined) {
950
+ return positions;
951
+ }
952
+ // A position carries its provider only when an aggregator injected one;
953
+ // without that attribution there is nothing to filter on.
954
+ return positions.filter((position) => position.providerId === undefined || position.providerId === route);
955
+ }
956
+ /**
957
+ * The position's USD value per unit of size.
958
+ *
959
+ * Used to price a partial close, which names a size but usually no price.
960
+ *
961
+ * @param position - The loaded position, when one was found.
962
+ * @returns The per-unit price, or undefined when it cannot be derived.
963
+ */
964
+ #resolvePositionUnitPrice(position) {
965
+ if (!position) {
966
+ return undefined;
967
+ }
968
+ const value = Math.abs(Number.parseFloat(position.positionValue));
969
+ const size = Math.abs(Number.parseFloat(position.size));
970
+ if (!Number.isFinite(value) || !Number.isFinite(size) || size <= 0) {
971
+ return undefined;
972
+ }
973
+ return value / size;
974
+ }
975
+ /**
976
+ * Resolve an order's USD notional for the fee resolver.
977
+ *
978
+ * `usdAmount` is the hybrid model's source of truth and is preferred whenever
979
+ * the caller supplied it. Otherwise the notional is `size × price`, taking the
980
+ * best price available: an explicit limit price, then the caller's price
981
+ * snapshot, then the live market price it was quoted against.
982
+ *
983
+ * Returns undefined when no price is available rather than guessing. The
984
+ * resolver reads that as "no notional to blend against" and quotes the full
985
+ * waiver rate — the same answer it gave before the notional was threaded
986
+ * through, so an unpriceable order is no worse off than it was.
987
+ *
988
+ * @param params - The order-shaped parameters in scope at the call site.
989
+ * @param params.size - Order size in base units, when known.
990
+ * @param params.usdAmount - Order notional in USD, when the caller supplied it.
991
+ * @param params.price - Limit price, when the placement carries one.
992
+ * @param params.triggerPrice - Trigger level, for a placement that has no
993
+ * limit price of its own.
994
+ * @param params.currentPrice - Live market price the order was quoted against.
995
+ * @param params.priceAtCalculation - Price snapshot taken when size was derived.
996
+ * @param params.scaleMinPrice - Lowest rung of a Scale ladder, when the
997
+ * placement is one.
998
+ * @param params.scaleMaxPrice - Highest rung of a Scale ladder, when the
999
+ * placement is one.
1000
+ * @param params.scaleNumOrders - Number of rungs in a Scale ladder, when
1001
+ * the placement supplies it.
1002
+ * @param params.scaleSkew - Optional linear size weighting across a Scale
1003
+ * ladder. A value above 1 puts more size on the highest-price rungs.
1004
+ * @returns The order notional in USD, or undefined when it cannot be priced.
877
1005
  */
878
- async #calculateFeeDiscountWithMeasurement() {
1006
+ #resolveOrderNotionalUsd(params) {
1007
+ const usdAmount = params.usdAmount === undefined
1008
+ ? undefined
1009
+ : Number.parseFloat(params.usdAmount);
1010
+ if (usdAmount !== undefined &&
1011
+ Number.isFinite(usdAmount) &&
1012
+ usdAmount > 0) {
1013
+ return usdAmount;
1014
+ }
1015
+ const size = params.size === undefined ? undefined : Number.parseFloat(params.size);
1016
+ if (size === undefined || !Number.isFinite(size) || size <= 0) {
1017
+ return undefined;
1018
+ }
1019
+ const limitPrice = params.price === undefined ? undefined : Number.parseFloat(params.price);
1020
+ // A trigger placement carries no limit price; the level it activates at is
1021
+ // the only price it states, so it prices the order.
1022
+ const triggerPrice = params.triggerPrice === undefined
1023
+ ? undefined
1024
+ : Number.parseFloat(params.triggerPrice);
1025
+ // A Scale ladder states no single price. For an even ladder the midpoint
1026
+ // is its average price, but a skew weights the rung sizes, so the midpoint
1027
+ // is no longer the ladder's notional price. Use the same linear weights as
1028
+ // `splitScaleSizes` when the caller supplies the rung count. The provider
1029
+ // rounds each rung onto its venue size grid later; this is the equivalent
1030
+ // weighted calculation before that bounded rounding, without making the
1031
+ // service depend on provider-specific market metadata.
1032
+ const scaleMinPrice = params.scaleMinPrice === undefined
1033
+ ? undefined
1034
+ : Number.parseFloat(params.scaleMinPrice);
1035
+ const scaleMaxPrice = params.scaleMaxPrice === undefined
1036
+ ? undefined
1037
+ : Number.parseFloat(params.scaleMaxPrice);
1038
+ const scaleWeightedPrice = scaleMinPrice !== undefined &&
1039
+ scaleMaxPrice !== undefined &&
1040
+ Number.isFinite(scaleMinPrice) &&
1041
+ Number.isFinite(scaleMaxPrice) &&
1042
+ scaleMinPrice > 0 &&
1043
+ scaleMaxPrice > 0
1044
+ ? (() => {
1045
+ const count = params.scaleNumOrders;
1046
+ const skew = params.scaleSkew;
1047
+ if (count === undefined ||
1048
+ !Number.isInteger(count) ||
1049
+ count < SCALE_ORDER_COUNT.min ||
1050
+ count > SCALE_ORDER_COUNT.max ||
1051
+ (skew !== undefined && (!Number.isFinite(skew) || skew <= 0))) {
1052
+ return (scaleMinPrice + scaleMaxPrice) / 2;
1053
+ }
1054
+ let weightedPrice = 0;
1055
+ let weightSum = 0;
1056
+ for (let index = 0; index < count; index++) {
1057
+ const weight = 1 + (((skew ?? 1) - 1) * index) / (count - 1);
1058
+ const price = scaleMinPrice +
1059
+ ((scaleMaxPrice - scaleMinPrice) * index) / (count - 1);
1060
+ weightedPrice += price * weight;
1061
+ weightSum += weight;
1062
+ }
1063
+ return weightedPrice / weightSum;
1064
+ })()
1065
+ : undefined;
1066
+ const price = [
1067
+ limitPrice,
1068
+ triggerPrice,
1069
+ scaleWeightedPrice,
1070
+ params.priceAtCalculation,
1071
+ params.currentPrice,
1072
+ ]
1073
+ .filter((candidate) => candidate !== undefined &&
1074
+ Number.isFinite(candidate) &&
1075
+ candidate > 0)
1076
+ .at(0);
1077
+ return price === undefined ? undefined : size * price;
1078
+ }
1079
+ /**
1080
+ * Resolve the fee discount for a submission, measuring the call.
1081
+ *
1082
+ * Uses controller dependencies injected via `setControllerDependencies()`;
1083
+ * without them there is no resolver to ask and the caller pays the
1084
+ * undiscounted fee. Helper method for the placement orchestration paths.
1085
+ *
1086
+ * @param orderNotionalUsd - The order's notional in USD, when it can be
1087
+ * priced. This is what lets the subscription source resolve to a blended
1088
+ * rate: omitting it resolves every bounded allowance as a full waiver,
1089
+ * charging 0 bips on an order the preview quoted a blend for.
1090
+ * @returns The resolved fee, or undefined when controller dependencies are
1091
+ * unavailable.
1092
+ */
1093
+ async #calculateFeeDiscountWithMeasurement(orderNotionalUsd) {
879
1094
  // Check if controller dependencies are available
880
1095
  if (!this.#controllerDeps) {
881
1096
  this.#deps.debugLogger.log('TradingService: Controller dependencies not set, skipping fee discount');
@@ -883,14 +1098,18 @@ export class TradingService {
883
1098
  }
884
1099
  const { rewardsIntegrationService } = this.#controllerDeps;
885
1100
  const orderExecutionFeeDiscountStartTime = this.#deps.performance.now();
886
- // Calculate fee discount using messenger pattern (service handles controller access internally)
887
- const resolution = await rewardsIntegrationService.resolveFee();
1101
+ // The notional is what lets the subscription source resolve to a blended
1102
+ // rate. Submitting without it would resolve every bounded allowance as a
1103
+ // full waiver, charging 0 bips on an order the preview quoted a blend for.
1104
+ const resolution = await rewardsIntegrationService.resolveFee(orderNotionalUsd);
888
1105
  const orderExecutionFeeDiscountDuration = this.#deps.performance.now() - orderExecutionFeeDiscountStartTime;
889
1106
  // Record measurement
890
1107
  this.#deps.tracer.setMeasurement(PerpsMeasurementName.PerpsRewardsOrderExecutionFeeDiscountApiCall, orderExecutionFeeDiscountDuration, 'millisecond');
891
1108
  this.#deps.debugLogger.log('TradingService: Fee discount API call completed', {
892
1109
  discountBips: resolution.discountBips,
893
1110
  source: resolution.source,
1111
+ orderNotionalUsd,
1112
+ subscriptionWaiverKind: resolution.subscriptionWaiverKind,
894
1113
  duration: `${orderExecutionFeeDiscountDuration.toFixed(0)}ms`,
895
1114
  });
896
1115
  return resolution;
@@ -928,7 +1147,7 @@ export class TradingService {
928
1147
  },
929
1148
  });
930
1149
  // Calculate fee discount only if required dependencies are available
931
- const feeResolution = await this.#calculateFeeDiscountWithMeasurement();
1150
+ const feeResolution = await this.#calculateFeeDiscountWithMeasurement(this.#resolveOrderNotionalUsd(params.newOrder));
932
1151
  // Execute order edit with fee discount management
933
1152
  const result = await this.#withFeeDiscount({
934
1153
  provider,
@@ -1311,6 +1530,8 @@ export class TradingService {
1311
1530
  position = await this.#loadPositionData({
1312
1531
  symbol: params.symbol,
1313
1532
  context,
1533
+ provider,
1534
+ providerId: params.providerId,
1314
1535
  });
1315
1536
  // Emit submitted event before the provider round-trip
1316
1537
  this.#trackSubmitted(PerpsAnalyticsEvent.PositionCloseTransaction, {
@@ -1321,8 +1542,20 @@ export class TradingService {
1321
1542
  [PERPS_EVENT_PROPERTY.BULK_ACTION_ID]: bulkActionId,
1322
1543
  }),
1323
1544
  });
1324
- // Calculate fee discount with measurement
1325
- const feeResolution = await this.#calculateFeeDiscountWithMeasurement();
1545
+ // Calculate fee discount with measurement. A full close commonly carries
1546
+ // only a symbol, so `params` alone prices it as undefined and the resolver
1547
+ // would quote a full waiver on an order the preview blended. The position
1548
+ // loaded above is the authoritative notional for exactly that case.
1549
+ const feeResolution = await this.#calculateFeeDiscountWithMeasurement(this.#resolveOrderNotionalUsd({
1550
+ ...params,
1551
+ // A partial close names a size but often no price; the position's own
1552
+ // value per unit prices it. A full close names neither, and falls back
1553
+ // to the whole position value below.
1554
+ currentPrice: params.currentPrice ?? this.#resolvePositionUnitPrice(position),
1555
+ }) ??
1556
+ this.#resolveOrderNotionalUsd({
1557
+ usdAmount: position?.positionValue,
1558
+ }));
1326
1559
  // Execute position close with fee discount management
1327
1560
  result = await this.#withFeeDiscount({
1328
1561
  provider,
@@ -1443,7 +1676,9 @@ export class TradingService {
1443
1676
  });
1444
1677
  // Use batch close if provider supports it (provider handles filtering)
1445
1678
  if (provider.closePositions) {
1446
- const feeResolution = await this.#calculateFeeDiscountWithMeasurement();
1679
+ // The batch submits under one builder context, so its notional is the
1680
+ // sum of the positions it will close, not any single one of them.
1681
+ const feeResolution = await this.#calculateFeeDiscountWithMeasurement(await this.#resolveBatchCloseNotionalUsd({ params, provider }));
1447
1682
  operationResult = await this.#withFeeDiscount({
1448
1683
  provider,
1449
1684
  feeResolution,
@@ -1605,8 +1840,40 @@ export class TradingService {
1605
1840
  [PERPS_EVENT_PROPERTY.SOURCE]: source,
1606
1841
  ...this.#buildAttributionProperties(params.trackingData),
1607
1842
  });
1608
- // Get fee discount from rewards
1609
- const feeResolution = await this.#calculateFeeDiscountWithMeasurement();
1843
+ // The batch shares one builder fee. Price each included trigger at its
1844
+ // own level and size, then use the largest notional. An omitted size
1845
+ // covers the absolute position size, including for short positions.
1846
+ let positionPromise;
1847
+ const getPosition = () => (positionPromise ??= params.position
1848
+ ? Promise.resolve(params.position)
1849
+ : this.#loadPositionData({
1850
+ symbol: params.symbol,
1851
+ context,
1852
+ provider,
1853
+ providerId: params.providerId,
1854
+ }));
1855
+ const triggerNotionalsUsd = await Promise.all([
1856
+ { price: params.takeProfitPrice, size: params.takeProfitSize },
1857
+ { price: params.stopLossPrice, size: params.stopLossSize },
1858
+ ]
1859
+ .filter((trigger) => trigger.price !== undefined)
1860
+ .map(async ({ price, size }) => {
1861
+ const triggerSize = size ?? (await getPosition())?.size;
1862
+ return this.#resolveOrderNotionalUsd({
1863
+ size: triggerSize === undefined
1864
+ ? undefined
1865
+ : Math.abs(Number.parseFloat(triggerSize)).toString(),
1866
+ price,
1867
+ });
1868
+ }));
1869
+ // A known sibling cannot stand in for a trigger whose size is unknown.
1870
+ // Passing undefined withholds a bounded waiver for the entire batch.
1871
+ const pricedTriggerNotionalsUsd = triggerNotionalsUsd.filter((notional) => notional !== undefined && Number.isFinite(notional) && notional > 0);
1872
+ const tpslNotionalUsd = pricedTriggerNotionalsUsd.length > 0 &&
1873
+ pricedTriggerNotionalsUsd.length === triggerNotionalsUsd.length
1874
+ ? Math.max(...pricedTriggerNotionalsUsd)
1875
+ : undefined;
1876
+ const feeResolution = await this.#calculateFeeDiscountWithMeasurement(tpslNotionalUsd);
1610
1877
  // Execute with fee discount management
1611
1878
  result = await this.#withFeeDiscount({
1612
1879
  provider,
@@ -1860,7 +2127,13 @@ export class TradingService {
1860
2127
  [PERPS_EVENT_PROPERTY.ACTION]: flipAction,
1861
2128
  ...this.#buildAttributionProperties(trackingData),
1862
2129
  });
1863
- const feeResolution = await this.#calculateFeeDiscountWithMeasurement();
2130
+ // The flip order is 2x the position: one leg closes it, one opens the
2131
+ // opposite. `orderParams` deliberately carries no price, so the notional
2132
+ // comes from the position's own reported USD value.
2133
+ const flipNotionalUsd = this.#resolveOrderNotionalUsd({
2134
+ usdAmount: position.positionValue,
2135
+ });
2136
+ const feeResolution = await this.#calculateFeeDiscountWithMeasurement(flipNotionalUsd === undefined ? undefined : flipNotionalUsd * 2);
1864
2137
  // Place flip order (HyperLiquid handles margin transfer automatically)
1865
2138
  const result = await this.#withFeeDiscount({
1866
2139
  provider,